diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..b8efaa77 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +.git +.gitignore +.code +__pycache__/ +*.pyc +*.pyo +*.pyd +*.swp +*.swo +*.tmp +.env +.env.* +env/ +venv/ +.venv/ +ENV/ +node_modules/ +ui/node_modules/ +ui/dist/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +coverage/ +dist/ +build/ +tmp/ +*.log diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c0a6eb4..49ccbfd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,35 @@ -name: CI +name: Push CI on: - pull_request: - branches: [master, main] push: - branches: [master, main] + branches: [main] + pull_request: + branches: [main] jobs: + repo-guards: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Ensure .code/ and .env are not tracked + shell: bash + run: | + tracked_code="$(git ls-files -- .code)" + tracked_env="$(git ls-files -- .env)" + + if [ -n "$tracked_code" ] || [ -n "$tracked_env" ]; then + echo "Local-only policy and secrets files must not be tracked." + if [ -n "$tracked_code" ]; then + echo "Tracked .code/ entries:" + echo "$tracked_code" + fi + if [ -n "$tracked_env" ]; then + echo "Tracked .env entries:" + echo "$tracked_env" + fi + exit 1 + fi + python: runs-on: ubuntu-latest steps: @@ -39,3 +62,34 @@ jobs: run: npm run lint - name: Type check & Build run: npm run build + - name: UI smoke tests + run: npm run test:smoke + + docker-image: + needs: [python, ui] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + IMAGE_NAME: ghcr.io/${{ github.repository }} + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64 + push: true + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..72981bc7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,113 @@ +name: Deploy to VPS + +on: + workflow_run: + workflows: ["Push CI"] + branches: [main] + types: + - completed + +permissions: + contents: read + +concurrency: + group: deploy-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + +jobs: + deploy: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + env: + DEPLOY_PATH: ${{ secrets.VPS_DEPLOY_PATH || '/home/autocoder' }} + TARGET_BRANCH: ${{ secrets.VPS_BRANCH || 'main' }} + VPS_PORT: ${{ secrets.VPS_PORT || '22' }} + DOMAIN: ${{ secrets.VPS_DOMAIN }} + DUCKDNS_TOKEN: ${{ secrets.VPS_DUCKDNS_TOKEN }} + LETSENCRYPT_EMAIL: ${{ secrets.VPS_LETSENCRYPT_EMAIL }} + APP_PORT: ${{ secrets.VPS_APP_PORT || '8888' }} + REPO_URL: https://github.com/${{ github.repository }}.git + IMAGE_LATEST: ghcr.io/${{ github.repository }}:latest + IMAGE_SHA: ghcr.io/${{ github.repository }}:${{ github.event.workflow_run.head_sha }} + steps: + - name: Deploy over SSH with Docker Compose + uses: appleboy/ssh-action@v1.2.4 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + port: ${{ env.VPS_PORT }} + envs: DEPLOY_PATH,TARGET_BRANCH,VPS_PORT,DOMAIN,DUCKDNS_TOKEN,LETSENCRYPT_EMAIL,APP_PORT,REPO_URL,IMAGE_LATEST,IMAGE_SHA + script: | + set -euo pipefail + + if [ -z "${DEPLOY_PATH:-}" ]; then + echo "VPS_DEPLOY_PATH secret is required"; exit 1; + fi + + if [ -z "${DOMAIN:-}" ] || [ -z "${DUCKDNS_TOKEN:-}" ] || [ -z "${LETSENCRYPT_EMAIL:-}" ]; then + echo "VPS_DOMAIN, VPS_DUCKDNS_TOKEN, and VPS_LETSENCRYPT_EMAIL secrets are required."; exit 1; + fi + + if [ ! -d "$DEPLOY_PATH/.git" ]; then + echo "ERROR: $DEPLOY_PATH is missing a git repo. Clone the repository there and keep your .env file."; exit 1; + fi + + cd "$DEPLOY_PATH" + + if [ ! -f ./deploy.sh ]; then + echo "ERROR: deploy.sh not found in $DEPLOY_PATH. Ensure the repo is up to date."; exit 1; + fi + + chmod +x ./deploy.sh + + if [ ! -f .env ]; then + echo "WARNING: .env not found in $DEPLOY_PATH. Deployment will continue without it."; + fi + + if [ "$(id -u)" -eq 0 ]; then + RUNNER="" + else + if ! command -v sudo >/dev/null 2>&1; then + echo "sudo is required to run deploy.sh as root."; exit 1; + fi + RUNNER="sudo" + fi + + $RUNNER env \ + AUTOCODER_AUTOMATED=1 \ + AUTOCODER_ASSUME_YES=1 \ + DOMAIN="${DOMAIN}" \ + DUCKDNS_TOKEN="${DUCKDNS_TOKEN}" \ + LETSENCRYPT_EMAIL="${LETSENCRYPT_EMAIL}" \ + REPO_URL="${REPO_URL}" \ + DEPLOY_BRANCH="${TARGET_BRANCH}" \ + APP_DIR="${DEPLOY_PATH}" \ + APP_PORT="${APP_PORT}" \ + IMAGE="${IMAGE_SHA:-$IMAGE_LATEST}" \ + ./deploy.sh + + echo "Running smoke test on http://127.0.0.1:${APP_PORT}/health and /readiness ..." + retries=12 + until curl -fsS --max-time 5 "http://127.0.0.1:${APP_PORT}/health" >/dev/null; do + retries=$((retries - 1)) + if [ "$retries" -le 0 ]; then + echo "Health check failed after retries." + exit 1 + fi + echo "Waiting for health... ($retries retries left)" + sleep 5 + done + + retries=12 + until curl -fsS --max-time 5 "http://127.0.0.1:${APP_PORT}/readiness" >/dev/null; do + retries=$((retries - 1)) + if [ "$retries" -le 0 ]; then + echo "Readiness check failed after retries." + exit 1 + fi + echo "Waiting for readiness... ($retries retries left)" + sleep 5 + done + + echo "Service responded successfully to health and readiness." diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 00000000..e8a4fa19 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,75 @@ +name: PR Check + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: pr-check-${{ github.event.pull_request?.head.repo.full_name || github.repository }}-${{ github.event.pull_request?.number || github.run_number }} + cancel-in-progress: true + +jobs: + repo-guards: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Ensure .code/ and .env are not tracked + shell: bash + run: | + tracked_code="$(git ls-files -- .code)" + tracked_env="$(git ls-files -- .env)" + + if [ -n "$tracked_code" ] || [ -n "$tracked_env" ]; then + echo "Local-only policy and secrets files must not be tracked." + if [ -n "$tracked_code" ]; then + echo "Tracked .code/ entries:" + echo "$tracked_code" + fi + if [ -n "$tracked_env" ]; then + echo "Tracked .env entries:" + echo "$tracked_env" + fi + exit 1 + fi + + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: requirements.txt + - name: Install dependencies + run: pip install -r requirements.txt + - name: Lint with ruff + run: ruff check . + - name: Run security tests + run: python test_security.py + + ui: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: ui/package-lock.json + - name: Install dependencies + run: npm ci + - name: Lint + run: npm run lint + - name: Type check & Build + run: npm run build + - name: UI smoke tests + run: npm run test:smoke diff --git a/.gitignore b/.gitignore index bb201186..9013331f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ temp/ nul issues/ +# Repository-specific +.code/ + # Browser profiles for parallel agent execution .browser-profiles/ @@ -16,6 +19,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +# Local Codex/Claude configuration (do not commit) +.code/ + # =================== # Node.js # =================== diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..1b0c7def --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: detect-private-key + - id: check-added-large-files + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.7.3 + hooks: + - id: ruff + args: ["--fix"] + stages: [commit] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.13.0 + hooks: + - id: mypy + args: ["--ignore-missing-imports"] + additional_dependencies: [] + stages: [commit] + - repo: local + hooks: + - id: forbid-dotenv + name: Block committing .env files + entry: bash -c 'if git diff --cached --name-only | grep -E "(^|/)\.env(\.|$)"; then echo "? .env files are blocked from commits"; exit 1; fi' + language: system + pass_filenames: false + - id: eslint + name: ESLint (ui) + entry: bash -c 'cd ui && npm run lint' + language: system + pass_filenames: false + - id: prettier + name: Prettier check (ui) + entry: bash -c 'cd ui && npm run format' + language: system + pass_filenames: false diff --git a/CONFIGURATION.md b/CONFIGURATION.md new file mode 100644 index 00000000..4fcb9cdb --- /dev/null +++ b/CONFIGURATION.md @@ -0,0 +1,36 @@ +# Configuration Matrix + +## Required for backend (Claude) +- `ANTHROPIC_AUTH_TOKEN` **or** Claude CLI auth (`claude login`) to run coding agents. + +## Optional / Alternative Models +- `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` — GLM/other Claude-compatible endpoints. +- `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL` — model overrides. +- `GEMINI_API_KEY` — use Gemini for assistant chat (chat only, no tools). +- `GEMINI_MODEL` (default `gemini-1.5-flash`), `GEMINI_BASE_URL` (default OpenAI-compatible endpoint). + +## Server runtime +- `AUTOCODER_ALLOW_REMOTE` — allow remote CORS (set to `1/true` to relax localhost-only guard). +- `API_TIMEOUT_MS` — passed to Claude SDK. + +## Observability +- Backend Sentry: `SENTRY_DSN` (required to enable), optional `SENTRY_ENV`, `SENTRY_TRACES_SAMPLE_RATE` (default 0.2). +- Frontend Sentry: `VITE_SENTRY_DSN` (required to enable), optional `VITE_SENTRY_ENV`, `VITE_SENTRY_TRACES_SAMPLE_RATE`, `VITE_SENTRY_PROMPT_USER=1` to prompt for name/email. +- OTEL: `OTEL_EXPORTER_OTLP_ENDPOINT`, optional `OTEL_SERVICE_NAME` (default `autocoder-server`), `OTEL_ENVIRONMENT` (default `production`). + +## Deploy (Traefik/DuckDNS) +- `.env.deploy` generated by `scripts/deploy.sh`: + - `DOMAIN` + - `LETSENCRYPT_EMAIL` + - `APP_PORT` (internal service port, default 8888) +- DuckDNS token stored in `/etc/cron.d/duckdns` (not in repo). + +## UI build/dev +- `VITE_API_PORT` — backend port for Vite dev proxy (default 8888). + +## Data / volumes +- `~/.autocoder` persisted via `autocoder-data` volume (docker-compose). + +## Make targets +- `make dev-up`: uses `docker-compose.dev.yml` (hot reload). +- `make api-dev`, `make ui-dev`, `make lint`, `make smoke`, `make pre-commit-install`. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 00000000..f7d05ed7 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,63 @@ +# AutoCoder Development Roadmap + +This roadmap breaks work into clear phases so you can pick the next most valuable items quickly. + +## Phase 0 — Baseline (ship ASAP) +- **PR discipline:** Enforce branch protection requiring “PR Check” (already configured in workflows; ensure GitHub rule is on). +- **Secrets hygiene:** Move all deploy secrets into repo/environment secrets; prohibit `.env` commits via pre-commit hook. +- **Smoke tests:** Keep `/health` and `/readiness` endpoints green; add UI smoke (landing page loads) to CI. + +## Phase 1 — Reliability & Observability +- **Structured logging:** Add JSON logging for FastAPI (uvicorn access + app logs) with request IDs; forward to stdout for Docker/Traefik. +- **Error reporting:** Wire Sentry (or OpenTelemetry + OTLP) for backend exceptions and front-end errors. +- **Metrics:** Expose `/metrics` (Prometheus) for FastAPI; Traefik already exposes metrics option—enable when scraping is available. +- **Tracing:** Add OTEL middleware to FastAPI; propagate trace IDs through to Claude/Gemini calls when possible. + +## Phase 2 — Platform & DevX +- **Local dev parity:** Add `docker-compose.dev.yml` with hot-reload for FastAPI + Vite UI; document one-command setup. +- **Makefile/taskfile:** Common commands (`make dev`, `make test`, `make lint`, `make format`, `make seed`). +- **Pre-commit:** Ruff, mypy, black (if adopted), eslint/prettier for `ui/`. +- **Typed APIs:** Add mypy strict mode to `server/` and type `schemas.py` fully (Pydantic v2 ConfigDict). + +## Phase 3 — Product & Agent Quality +- **Model selection UI:** Let users choose assistant provider (Claude/Gemini) in settings; display active provider badge in chat. +- **Tooling guardrails:** For Gemini (chat-only), show “no tools” notice in UI and fallback logic to Claude when tools needed. +- **Conversation persistence:** Add pagination/search over assistant history; export conversation to file. +- **Feature board:** Surface feature stats/graph from MCP in the UI (read-only dashboard). + +## Phase 4 — Security & Compliance +- **AuthN/AuthZ:** Add optional login (JWT/OIDC) gate for UI/API; role for “admin” vs “viewer” at least. +- **Rate limiting:** Enable per-IP rate limits at Traefik and per-token limits in FastAPI. +- **Audit trails:** Log agent actions and feature state changes with user identity. +- **Headers/HTTPS:** HSTS via Traefik, content-security-policy header from FastAPI. + +## Phase 5 — Performance & Scale +- **Caching:** CDN/Traefik static cache for UI assets; server-side cache for model list/status endpoints. +- **Worker separation:** Optionally split agent runner from API via separate services and queues (e.g., Redis/RQ or Celery). +- **Background jobs:** Move long-running tasks to scheduler/worker with backoff and retries. + +## Phase 6 — Testing & Quality Gates +- **Backend tests:** Add pytest suite for key routers (`/api/setup/status`, assistant chat happy-path with mock Claude/Gemini). +- **Frontend tests:** Add Vitest + React Testing Library smoke tests for core pages (dashboard loads, settings save). +- **E2E:** Playwright happy-path (login optional, start agent, view logs). +- **Coverage:** Fail CI if coverage drops below threshold (start at 60–70%). + +## Phase 7 — Deployment & Ops +- **Blue/green deploy:** Add image tagging `:sha` + `:latest` (already for CI) with Traefik service labels to toggle. +- **Backups:** Snapshot `~/.autocoder` data volume; document restore. +- **Runbooks:** Add `RUNBOOK.md` for common ops (restart, rotate keys, renew certs, roll back). + +## Phase 8 — Documentation & Onboarding +- **Getting started:** Short path for “run locally in 5 minutes” (scripted). +- **Config matrix:** Document required/optional env vars (Claude, Gemini, DuckDNS, Traefik, TLS). +- **Architecture:** One-page diagram: UI ↔ FastAPI ↔ Agent subprocess ↔ Claude/Gemini; MCP servers; Traefik front. + +## Stretch Ideas +- **Telemetry-driven tuning:** Auto-select model/provider based on latency/cost SLA. +- **Cost controls:** Show per-run token/cost estimates; configurable budgets. +- **Offline/edge mode:** Ollama provider toggle with cached models. + +## How to use this roadmap +- Pick the next phase that unblocks your current goal (reliability → platform → product). +- Keep PRs small and scoped to one bullet. +- Update this document when a bullet ships or is reprioritized. diff --git a/DEVPROCESS.md b/DEVPROCESS.md new file mode 100644 index 00000000..b04053f9 --- /dev/null +++ b/DEVPROCESS.md @@ -0,0 +1,67 @@ +# DevProcess – phase tracker + +Guidelines +- Update this file whenever you start or finish a task. +- Keep PRs small; one bullet per PR when possible. +- Link PRs beside the checkbox when merged. + +## Phase 0 — Baseline +- [x] PR Check workflow exists (`pr-check.yml`) +- [ ] Branch protection requires “PR Check” (GitHub setting) +- [x] Pre-commit / git guard to block `.env` and secrets +- [x] `/health` endpoint +- [x] `/readiness` endpoint +- [x] UI smoke test in CI (landing page) + +## Phase 1 — Reliability & Observability +- [x] JSON/structured logging with request IDs (FastAPI + uvicorn) +- [x] Error reporting (Sentry/OTEL) backend (env-gated) +- [x] `/metrics` Prometheus endpoint +- [x] OTEL tracing middleware + propagation (env-gated) + +## Phase 2 — Platform & DevX +- [x] `docker-compose.dev.yml` with hot reload (API + UI) +- [x] Makefile/taskfile for common commands +- [ ] Pre-commit hooks: ruff/mypy, eslint/prettier +- [ ] mypy strict on `server/` + +## Phase 3 — Product & Agent Quality +- [ ] UI model/provider picker (Claude/Gemini) with badge in chat +- [ ] Gemini “no tools” notice and Claude fallback for tooling tasks +- [ ] Assistant history search/export +- [ ] Feature/MCP dashboard in UI + +## Phase 4 — Security & Compliance +- [ ] Optional Auth (JWT/OIDC) for UI/API with roles +- [ ] Rate limiting (Traefik + API) +- [ ] Audit trail for agent actions / feature state changes +- [ ] Security headers (HSTS, CSP) + +## Phase 5 — Performance & Scale +- [ ] Static asset caching (Traefik/CDN) +- [ ] API response caching for model/status endpoints +- [ ] Separate worker/agent service + queue +- [ ] Background job retries/backoff + +## Phase 6 — Testing & Quality Gates +- [ ] Backend tests for key routers (with mocked Claude/Gemini) +- [ ] Frontend Vitest/RTL smoke tests +- [ ] Playwright happy-path E2E +- [ ] Coverage gate in CI + +## Phase 7 — Deployment & Ops +- [x] GHCR image build/push (CI) +- [x] Traefik + DuckDNS + Let’s Encrypt one-click deploy script +- [ ] Blue/green toggle via Traefik services +- [ ] Backups for `~/.autocoder` volume +- [x] RUNBOOK.md for common ops + +## Phase 8 — Documentation & Onboarding +- [ ] “Run locally in 5 minutes” doc/script +- [x] Config matrix for all env vars (Claude, Gemini, DuckDNS, Traefik, TLS) +- [ ] Architecture overview diagram + +## Phase 8 — Documentation & Onboarding +- [ ] “Run locally in 5 minutes” doc/script +- [ ] Config matrix for all env vars (Claude, Gemini, DuckDNS, Traefik, TLS) +- [ ] Architecture overview diagram diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..e28d2eb6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# Build frontend and backend for production + +# 1) Build the React UI +FROM node:20-alpine AS ui-builder +WORKDIR /app/ui +COPY ui/package*.json ./ +RUN npm ci +COPY ui/ . +RUN npm run build + +# 2) Build the Python backend with the compiled UI assets +FROM python:3.11-slim AS runtime +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy source code and built UI +COPY . . +COPY --from=ui-builder /app/ui/dist ./ui/dist + +EXPOSE 8888 +CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8888"] diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..2d6ef6a9 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +.PHONY: install lint test smoke ui-dev api-dev dev-up dev-down pre-commit-install format + +install: + pip install -r requirements.txt + cd ui && npm install + +lint: + ruff check . + cd ui && npm run lint + +test: + pytest + +smoke: + cd ui && npm run test:smoke + +format: + cd ui && npm run format + +ui-dev: + cd ui && npm run dev -- --host --port 5173 + +api-dev: + uvicorn server.main:app --host 0.0.0.0 --port 8888 --reload + +dev-up: + docker compose -f docker-compose.dev.yml up --build + +dev-down: + docker compose -f docker-compose.dev.yml down + +pre-commit-install: + pre-commit install diff --git a/README.md b/README.md index 3ed7f153..663cb5e4 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,13 @@ You need one of the following: - **Claude Pro/Max Subscription** - Use `claude login` to authenticate (recommended) - **Anthropic API Key** - Pay-per-use from https://console.anthropic.com/ +### Optional: Gemini API (assistant chat only) +- `GEMINI_API_KEY` (required) +- `GEMINI_MODEL` (optional, default `gemini-1.5-flash`) +- `GEMINI_BASE_URL` (optional, default `https://generativelanguage.googleapis.com/v1beta/openai`) + +Notes: Gemini is used for assistant chat when configured; coding agents still run on Claude/Anthropic (tools are not available in Gemini mode). + --- ## Quick Start @@ -337,6 +344,29 @@ The agent tried to run a command not in the allowlist. This is the security syst --- +## CI/CD and Deployment + +- PR Check workflow (`.github/workflows/pr-check.yml`) runs Python lint/security tests and UI lint/build on every PR to `main` or `master`. +- Push CI (`.github/workflows/ci.yml`) runs the same validations on direct pushes to `main` and `master`, then builds and pushes a Docker image to GHCR (`ghcr.io//:latest` and `:sha`). +- Deploy to VPS (`.github/workflows/deploy.yml`) runs after Push CI succeeds, SSHes into your VPS, prunes old Docker artifacts, pulls the target branch, pulls the GHCR `:sha` image (falls back to `:latest`), restarts with `docker compose up -d`, and leaves any existing `.env` untouched. It finishes with an HTTP smoke check on `http://127.0.0.1:8888/health`. +- Repo secrets required: `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY`, `VPS_DEPLOY_PATH` (use an absolute path like `/home/autocoder`); optional `VPS_BRANCH` (defaults to `master`) and `VPS_PORT` (defaults to `22`). The VPS needs git, Docker + Compose plugin installed, and the repo cloned at `VPS_DEPLOY_PATH` with your `.env` present. +- Local Docker run: `docker compose up -d --build` exposes the app on `http://localhost:8888`; data under `~/.autocoder` persists via the `autocoder-data` volume. + +### Branch protection +To require the “PR Check” workflow before merging: +- GitHub UI: Settings → Branches → Add rule for `main` (and `master` if used) → enable **Require status checks to pass before merging** → select `PR Check` → save. +- GitHub CLI: + ```bash + gh api -X PUT repos///branches/main/protection \ + -F required_status_checks.strict=true \ + -F required_status_checks.contexts[]="PR Check" \ + -F enforce_admins=true \ + -F required_pull_request_reviews.dismiss_stale_reviews=true \ + -F restrictions= + ``` + +--- + ## License This project is licensed under the GNU Affero General Public License v3.0 - see the [LICENSE.md](LICENSE.md) file for details. diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 00000000..fb9ec3bd --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,40 @@ +# Runbook — AutoCoder Ops + +## Daily +- `docker compose -f docker-compose.yml -f docker-compose.traefik.yml ps` — check services. +- `docker compose ... logs -f` — tail API/Trafik if issues. +- Verify `/health` and `/readiness` on the API; check `/metrics` scrape. + +## Deploy +1) SSH to VPS. +2) `sudo bash scripts/deploy.sh` (prompts for domain/token/email/repo/branch/path/port). +3) Wait for Traefik to fetch certs; browse `https://`. + +## Rollback +- Re-run deploy pointing to previous branch/sha: set branch prompt to the earlier ref. +- Or `git -C /home/autocoder checkout ` then `docker compose ... up -d --build`. + +## Logs +- App/Traefik: `docker compose -f docker-compose.yml -f docker-compose.traefik.yml logs -f`. +- Systemd Docker: `journalctl -u docker`. + +## Certificates +- Stored at `/home/autocoder/letsencrypt/acme.json` (0600). Traefik auto-renews via HTTP-01. + +## DNS (DuckDNS) +- Cron at `/etc/cron.d/duckdns`. Logs: `/var/log/duckdns.log`. + +## Backups +- App data volume: `autocoder-data` (mapped to `~/.autocoder` in the container). +- Snapshot strategy: `docker run --rm -v autocoder-data:/data -v $PWD:/backup alpine tar czf /backup/autocoder-data-$(date +%F).tgz /data`. + +## Observability +- Sentry (backend): set `SENTRY_DSN` (optional `SENTRY_ENV`, `SENTRY_TRACES_SAMPLE_RATE`). +- OTEL tracing: set `OTEL_EXPORTER_OTLP_ENDPOINT`, optional `OTEL_SERVICE_NAME`, `OTEL_ENVIRONMENT`. +- Metrics: scrape `https:///metrics` (behind Traefik; adjust scrape config). +- Frontend Sentry: `VITE_SENTRY_DSN`, optional `VITE_SENTRY_ENV`, `VITE_SENTRY_TRACES_SAMPLE_RATE`, `VITE_SENTRY_PROMPT_USER=1`. + +## Common Issues +- **Cert not issued**: ensure ports 80/443 reachable; DuckDNS points to VPS; rerun deploy. +- **App not reachable**: `docker compose ... ps`, check logs; verify `.env.deploy` port matches internal service port (default 8888). +- **DNS not updating**: check `/etc/cron.d/duckdns` and `/var/log/duckdns.log`. diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 00000000..1705dfd9 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,371 @@ +#!/usr/bin/env bash + +# One-click Docker deploy for AutoCoder on a VPS with DuckDNS + Traefik + Let's Encrypt. +# Prompts for domain, DuckDNS token, email, repo, branch, and target install path. + +set -euo pipefail + +if [[ "${EUID}" -ne 0 ]]; then + echo "Please run as root (sudo)." >&2 + exit 1 +fi + +is_truthy() { + case "${1,,}" in + 1|true|yes|on) return 0 ;; + *) return 1 ;; + esac +} + +# Automation switches for CI/CD usage +AUTOMATED_MODE=0 +ASSUME_YES_MODE=0 +CLEANUP_REQUESTED=0 +CLEANUP_VOLUMES_REQUESTED=0 + +if is_truthy "${AUTOCODER_AUTOMATED:-0}"; then + AUTOMATED_MODE=1 +fi +if is_truthy "${AUTOCODER_ASSUME_YES:-0}"; then + ASSUME_YES_MODE=1 +fi +if is_truthy "${AUTOCODER_CLEANUP:-0}"; then + CLEANUP_REQUESTED=1 +fi +if is_truthy "${AUTOCODER_CLEANUP_VOLUMES:-0}"; then + CLEANUP_VOLUMES_REQUESTED=1 +fi + +prompt_required() { + local var_name="$1" + local prompt_msg="$2" + local value="" + + # Allow pre-seeding via environment variables in automated runs. + if [[ -n "${!var_name:-}" ]]; then + export "${var_name}" + return + fi + + if [[ "${AUTOMATED_MODE}" -eq 1 ]]; then + echo "Missing required environment variable: ${var_name}" >&2 + exit 1 + fi + + while true; do + read -r -p "${prompt_msg}: " value + if [[ -n "${value}" ]]; then + printf -v "${var_name}" "%s" "${value}" + export "${var_name}" + return + fi + echo "Value cannot be empty." + done +} + +derive_duckdns_subdomain() { + # DuckDNS expects only the subdomain (e.g., "myapp"), but users often + # provide the full domain (e.g., "myapp.duckdns.org"). This supports both. + if [[ "${DOMAIN}" == *.duckdns.org ]]; then + DUCKDNS_SUBDOMAIN="${DOMAIN%.duckdns.org}" + else + DUCKDNS_SUBDOMAIN="${DOMAIN}" + fi + export DUCKDNS_SUBDOMAIN +} + +confirm_yes() { + local prompt_msg="$1" + local reply="" + + if [[ "${ASSUME_YES_MODE}" -eq 1 ]]; then + return 0 + fi + if [[ "${AUTOMATED_MODE}" -eq 1 ]]; then + return 1 + fi + + read -r -p "${prompt_msg} [y/N]: " reply + [[ "${reply,,}" == "y" ]] +} + +echo "=== AutoCoder VPS Deploy (Docker + Traefik + DuckDNS + Let's Encrypt) ===" +echo "This will install Docker, configure DuckDNS, and deploy via docker compose." +echo + +prompt_required DOMAIN "Enter your DuckDNS domain (e.g., myapp.duckdns.org)" +prompt_required DUCKDNS_TOKEN "Enter your DuckDNS token" +prompt_required LETSENCRYPT_EMAIL "Enter email for Let's Encrypt notifications" + +derive_duckdns_subdomain + +if [[ -z "${REPO_URL:-}" ]]; then + if [[ "${AUTOMATED_MODE}" -eq 0 ]]; then + read -r -p "Git repo URL [https://github.com/heidi-dang/autocoder.git]: " REPO_URL + fi +fi +REPO_URL=${REPO_URL:-https://github.com/heidi-dang/autocoder.git} + +if [[ -z "${DEPLOY_BRANCH:-}" ]]; then + if [[ "${AUTOMATED_MODE}" -eq 0 ]]; then + read -r -p "Git branch to deploy [main]: " DEPLOY_BRANCH + fi +fi +DEPLOY_BRANCH=${DEPLOY_BRANCH:-main} + +if [[ -z "${APP_DIR:-}" ]]; then + if [[ "${AUTOMATED_MODE}" -eq 0 ]]; then + read -r -p "Install path [/home/autocoder]: " APP_DIR + fi +fi +APP_DIR=${APP_DIR:-/home/autocoder} + +if [[ -z "${APP_PORT:-}" ]]; then + if [[ "${AUTOMATED_MODE}" -eq 0 ]]; then + read -r -p "App internal port (container) [8888]: " APP_PORT + fi +fi +APP_PORT=${APP_PORT:-8888} + +echo +echo "Domain: ${DOMAIN}" +echo "DuckDNS domain: ${DUCKDNS_SUBDOMAIN}" +echo "Repo: ${REPO_URL}" +echo "Branch: ${DEPLOY_BRANCH}" +echo "Path: ${APP_DIR}" +echo "App port: ${APP_PORT}" +echo +if ! confirm_yes "Proceed?"; then + echo "Aborted." + exit 1 +fi + +ensure_packages() { + echo + echo "==> Installing Docker & prerequisites..." + apt-get update -y + apt-get install -y ca-certificates curl git gnupg + + install -m 0755 -d /etc/apt/keyrings + if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then + curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "${VERSION_CODENAME}") stable" \ + > /etc/apt/sources.list.d/docker.list + apt-get update -y + fi + + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + systemctl enable --now docker +} + +configure_duckdns() { + echo + echo "==> Configuring DuckDNS..." + local cron_file="/etc/cron.d/duckdns" + cat > "${cron_file}" </var/log/duckdns.log 2>&1 +EOF + chmod 644 "${cron_file}" + + # Run once immediately. + curl -fsS "https://www.duckdns.org/update?domains=${DUCKDNS_SUBDOMAIN}&token=${DUCKDNS_TOKEN}&ip=" \ + >/var/log/duckdns.log 2>&1 || true +} + +clone_repo() { + echo + echo "==> Preparing repository..." + if [[ -d "${APP_DIR}/.git" ]]; then + echo "Repo already exists, pulling latest..." + git -C "${APP_DIR}" fetch --all --prune + git -C "${APP_DIR}" checkout "${DEPLOY_BRANCH}" + git -C "${APP_DIR}" pull --ff-only origin "${DEPLOY_BRANCH}" + else + echo "Cloning repository..." + mkdir -p "${APP_DIR}" + git clone --branch "${DEPLOY_BRANCH}" "${REPO_URL}" "${APP_DIR}" + fi +} + +assert_compose_files() { + echo + echo "==> Validating compose files..." + if [[ ! -f "${APP_DIR}/docker-compose.yml" ]]; then + echo "Missing ${APP_DIR}/docker-compose.yml" >&2 + exit 1 + fi + if [[ ! -f "${APP_DIR}/docker-compose.traefik.yml" ]]; then + echo "Missing ${APP_DIR}/docker-compose.traefik.yml" >&2 + exit 1 + fi +} + +preserve_env_file() { + echo + echo "==> Checking for production .env..." + ENV_PRESENT=0 + ENV_BACKUP="" + + if [[ -d "${APP_DIR}" && -f "${APP_DIR}/.env" ]]; then + ENV_PRESENT=1 + ENV_BACKUP="${APP_DIR}/.env.production.bak" + cp -f "${APP_DIR}/.env" "${ENV_BACKUP}" + chmod 600 "${ENV_BACKUP}" || true + echo "Found existing .env. Backed it up to ${ENV_BACKUP} and will preserve it." + else + echo "No existing .env found in ${APP_DIR}." + fi +} + +verify_env_preserved() { + if [[ "${ENV_PRESENT:-0}" -eq 1 && ! -f "${APP_DIR}/.env" ]]; then + echo "ERROR: .env was removed during deployment. Restoring from backup." >&2 + if [[ -n "${ENV_BACKUP:-}" && -f "${ENV_BACKUP}" ]]; then + cp -f "${ENV_BACKUP}" "${APP_DIR}/.env" + chmod 600 "${APP_DIR}/.env" || true + fi + exit 1 + fi + + if git -C "${APP_DIR}" ls-files --error-unmatch .env >/dev/null 2>&1; then + echo "WARNING: .env appears to be tracked by git. Consider untracking it." >&2 + fi +} + +write_env() { + echo + echo "==> Writing deploy env (.env.deploy)..." + cat > "${APP_DIR}/.env.deploy" < Preparing Let's Encrypt storage..." + mkdir -p "${APP_DIR}/letsencrypt" + touch "${APP_DIR}/letsencrypt/acme.json" + chmod 600 "${APP_DIR}/letsencrypt/acme.json" +} + +run_compose() { + echo + echo "==> Bringing up stack with Traefik reverse proxy and TLS..." + cd "${APP_DIR}" + + docker network inspect traefik-proxy >/dev/null 2>&1 || docker network create traefik-proxy + + docker compose \ + --env-file .env.deploy \ + -f docker-compose.yml \ + -f docker-compose.traefik.yml \ + pull || true + + docker compose \ + --env-file .env.deploy \ + -f docker-compose.yml \ + -f docker-compose.traefik.yml \ + up -d --build +} + +cleanup_vps_safe() { + echo + echo "==> Optional VPS cleanup (safe scope only)..." + echo "This will prune unused Docker artifacts, clean apt caches, and trim old logs." + echo "It will NOT delete arbitrary files and will not touch ${APP_DIR}/.env." + + if [[ "${AUTOMATED_MODE}" -eq 1 ]]; then + if [[ "${CLEANUP_REQUESTED}" -ne 1 ]]; then + echo "Skipping cleanup in automated mode." + return + fi + echo "Cleanup requested in automated mode." + else + if ! confirm_yes "Run safe cleanup now?"; then + echo "Skipping cleanup." + return + fi + fi + + if command -v docker >/dev/null 2>&1; then + echo "--> Pruning unused Docker containers/images/build cache..." + docker container prune -f || true + docker image prune -f || true + docker builder prune -f || true + + if [[ "${AUTOMATED_MODE}" -eq 1 ]]; then + if [[ "${CLEANUP_VOLUMES_REQUESTED}" -eq 1 ]]; then + docker volume prune -f || true + else + echo "Skipping Docker volume prune in automated mode." + fi + elif confirm_yes "Also prune unused Docker volumes? (may delete data)"; then + docker volume prune -f || true + else + echo "Skipping Docker volume prune." + fi + fi + + echo "--> Cleaning apt caches..." + apt-get autoremove -y || true + apt-get autoclean -y || true + + if command -v journalctl >/dev/null 2>&1; then + echo "--> Trimming systemd journal logs older than 14 days..." + journalctl --vacuum-time=14d || true + fi +} + +post_checks() { + echo + echo "==> Post-deploy checks (non-fatal)..." + cd "${APP_DIR}" + + docker compose -f docker-compose.yml -f docker-compose.traefik.yml ps || true + + # These checks may fail briefly while the certificate is being issued. + curl -fsS "http://${DOMAIN}/api/health" >/dev/null 2>&1 && \ + echo "Health check over HTTP: OK" || \ + echo "Health check over HTTP: not ready yet" + + curl -fsS "https://${DOMAIN}/api/health" >/dev/null 2>&1 && \ + echo "Health check over HTTPS: OK" || \ + echo "Health check over HTTPS: not ready yet (TLS may still be issuing)" +} + +print_notes() { + cat <<'EOF' + +Deployment complete. + +If the domain does not come up immediately: +1. Ensure ports 80 and 443 are open on the VPS firewall/security group. +2. Confirm DuckDNS points to this VPS IP. +3. Check logs: + docker compose -f docker-compose.yml -f docker-compose.traefik.yml logs -f +4. Confirm backend health locally: + curl -fsS http://127.0.0.1:8888/api/health || true + +To update later, rerun this script. It will git pull and restart. +EOF +} + +ensure_packages +configure_duckdns +clone_repo +assert_compose_files +preserve_env_file +write_env +prepare_ssl_storage +run_compose +verify_env_preserved +cleanup_vps_safe +post_checks +print_notes diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..3c575d3b --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,30 @@ +version: "3.9" + +services: + api: + build: + context: . + dockerfile: Dockerfile + command: uvicorn server.main:app --host 0.0.0.0 --port 8888 --reload + env_file: + - .env + volumes: + - .:/app + ports: + - "8888:8888" + depends_on: + - ui + + ui: + working_dir: /app/ui + build: + context: ./ui + dockerfile: Dockerfile.dev + command: npm run dev -- --host --port 5173 + volumes: + - ./ui:/app/ui + - /app/ui/node_modules + env_file: + - .env + ports: + - "5173:5173" diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml new file mode 100644 index 00000000..9d75c411 --- /dev/null +++ b/docker-compose.traefik.yml @@ -0,0 +1,46 @@ +version: "3.9" + +services: + traefik: + image: traefik:latest + environment: + # Force a modern Docker API version. Some VPS environments set + # DOCKER_API_VERSION=1.24 globally, which breaks Traefik's Docker provider + # when the daemon requires >= 1.44. + # Use the server's current API version to avoid a too-old client default. + - DOCKER_API_VERSION=1.53 + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --certificatesresolvers.le.acme.httpchallenge=true + - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web + - --certificatesresolvers.le.acme.email=${LETSENCRYPT_EMAIL} + - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./letsencrypt:/letsencrypt + networks: + - traefik-proxy + + autocoder: + networks: + - traefik-proxy + labels: + - traefik.enable=true + - traefik.http.routers.autocoder.rule=Host(`${DOMAIN}`) + - traefik.http.routers.autocoder.entrypoints=websecure + - traefik.http.routers.autocoder.tls.certresolver=le + - traefik.http.services.autocoder.loadbalancer.server.port=${APP_PORT:-8888} + - traefik.http.routers.autocoder-web.rule=Host(`${DOMAIN}`) + - traefik.http.routers.autocoder-web.entrypoints=web + - traefik.http.routers.autocoder-web.middlewares=redirect-to-https + - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https + +networks: + traefik-proxy: + external: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..fb1023aa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: "3.9" + +services: + autocoder: + image: ${IMAGE:-autocoder-local:latest} + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + environment: + # Docker port-forwarded requests appear from the bridge gateway + # (e.g., 172.17.0.1), so strict localhost-only mode blocks them. + # Allow overriding via AUTOCODER_ALLOW_REMOTE=0/false in .env. + AUTOCODER_ALLOW_REMOTE: ${AUTOCODER_ALLOW_REMOTE:-1} + ports: + - "8888:8888" + restart: unless-stopped + volumes: + - autocoder-data:/root/.autocoder + command: uvicorn server.main:app --host 0.0.0.0 --port 8888 + +volumes: + autocoder-data: diff --git a/pyproject.toml b/pyproject.toml index 698aa07a..913a9214 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,48 @@ ignore = [ [tool.mypy] python_version = "3.11" -ignore_missing_imports = true warn_return_any = true warn_unused_ignores = true +strict = true +plugins = [] + +[[tool.mypy.overrides]] +module = [ + "openai", + "psutil", + "pythonjsonlogger", + "prometheus_client", + "sentry_sdk", + "sentry_sdk.integrations.fastapi", + "opentelemetry", + "opentelemetry.*", + "claude_agent_sdk", + "apscheduler", + "apscheduler.*", + "winpty", +] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = [ + "server.services.terminal_manager", + "server.services.assistant_database", + "api.database", + "registry", + "server.routers.schedules", + "server.routers.assistant_chat", + "server.routers.features", + "server.routers.projects", + "server.routers.expand_project", + "server.routers.agent", + "server.routers.spec_creation", + "server.websocket", +] +ignore_errors = true + +[[tool.mypy.overrides]] +module = [ + "server.*", + "api.*", +] +ignore_errors = true diff --git a/requirements.txt b/requirements.txt index 9cf420e0..91570ab6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,8 +10,18 @@ aiofiles>=24.0.0 apscheduler>=3.10.0,<4.0.0 pywinpty>=2.0.0; sys_platform == "win32" pyyaml>=6.0.0 +openai>=1.52.0 # Dev dependencies ruff>=0.8.0 mypy>=1.13.0 pytest>=8.0.0 +python-json-logger>=2.0.7 +python-json-logger>=2.0.7 +prometheus-client>=0.21.1 +sentry-sdk[fastapi]>=2.18.0 +opentelemetry-sdk>=1.27.0 +opentelemetry-exporter-otlp>=1.27.0 +opentelemetry-instrumentation-fastapi>=0.48b0 +opentelemetry-instrumentation-asgi>=0.48b0 + diff --git a/rule.md b/rule.md new file mode 100644 index 00000000..eec6fa6c --- /dev/null +++ b/rule.md @@ -0,0 +1,4 @@ +Rules / pointers +- Work on branch `development` for roadmap tasks. +- Use `DEVELOPMENT.md` for phase definitions and `DEVPROCESS.md` to track progress and TODOs. +- Keep PRs scoped to single checklist items when possible. diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 00000000..0f09a115 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash + +# One-click Docker deploy for AutoCoder on a VPS with DuckDNS + Traefik + Let's Encrypt. +# Prompts for domain, DuckDNS token, email, repo, branch, and target install path. + +set -euo pipefail + +if [[ $EUID -ne 0 ]]; then + echo "Please run as root (sudo)." >&2 + exit 1 +fi + +prompt_required() { + local var_name="$1" prompt_msg="$2" + local value + while true; do + read -r -p "$prompt_msg: " value + if [[ -n "$value" ]]; then + printf -v "$var_name" '%s' "$value" + export "$var_name" + return + fi + echo "Value cannot be empty." + done +} + +echo "=== AutoCoder VPS Deploy (Docker + Traefik + DuckDNS + Let's Encrypt) ===" + +prompt_required DOMAIN "Enter your DuckDNS domain (e.g., myapp.duckdns.org)" +prompt_required DUCKDNS_TOKEN "Enter your DuckDNS token" +prompt_required LETSENCRYPT_EMAIL "Enter email for Let's Encrypt notifications" + +read -r -p "Git repo URL [https://github.com/heidi-dang/autocoder.git]: " REPO_URL +REPO_URL=${REPO_URL:-https://github.com/heidi-dang/autocoder.git} + +read -r -p "Git branch to deploy [main]: " DEPLOY_BRANCH +DEPLOY_BRANCH=${DEPLOY_BRANCH:-main} + +read -r -p "Install path [/home/autocoder]: " APP_DIR +APP_DIR=${APP_DIR:-/home/autocoder} + +read -r -p "App internal port (container) [8888]: " APP_PORT +APP_PORT=${APP_PORT:-8888} + +echo +echo "Domain: $DOMAIN" +echo "Repo: $REPO_URL" +echo "Branch: $DEPLOY_BRANCH" +echo "Path: $APP_DIR" +echo +read -r -p "Proceed? [y/N]: " CONFIRM +if [[ "${CONFIRM,,}" != "y" ]]; then + echo "Aborted." + exit 1 +fi + +ensure_packages() { + echo "Installing Docker & prerequisites..." + apt-get update -y + apt-get install -y ca-certificates curl git gnupg + install -m 0755 -d /etc/apt/keyrings + if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list + apt-get update -y + fi + apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + systemctl enable --now docker +} + +configure_duckdns() { + echo "Configuring DuckDNS..." + local cron_file="/etc/cron.d/duckdns" + cat > "$cron_file" </var/log/duckdns.log 2>&1 +EOF + chmod 644 "$cron_file" + # Run once immediately + curl -fsS "https://www.duckdns.org/update?domains=$DOMAIN&token=$DUCKDNS_TOKEN&ip=" >/var/log/duckdns.log 2>&1 || true +} + +clone_repo() { + if [[ -d "$APP_DIR/.git" ]]; then + echo "Repo already exists, pulling latest..." + git -C "$APP_DIR" fetch --all + git -C "$APP_DIR" checkout "$DEPLOY_BRANCH" + git -C "$APP_DIR" pull --ff-only origin "$DEPLOY_BRANCH" + else + echo "Cloning repository..." + mkdir -p "$APP_DIR" + git clone --branch "$DEPLOY_BRANCH" "$REPO_URL" "$APP_DIR" + fi +} + +write_env() { + echo "Writing deploy env (.env.deploy)..." + cat > "$APP_DIR/.env.deploy" </dev/null 2>&1 || docker network create traefik-proxy + docker compose --env-file .env.deploy -f docker-compose.yml -f docker-compose.traefik.yml pull || true + docker compose --env-file .env.deploy -f docker-compose.yml -f docker-compose.traefik.yml up -d --build +} + +ensure_packages +configure_duckdns +clone_repo +write_env +prepare_ssl_storage +run_compose + +echo +echo "Deployment complete." +echo "Check: http://$DOMAIN (will redirect to https after cert is issued)." +echo "Logs: docker compose -f docker-compose.yml -f docker-compose.traefik.yml logs -f" +echo "To update: rerun this script; it will git pull and restart." diff --git a/server/gemini_client.py b/server/gemini_client.py new file mode 100644 index 00000000..c794dfc5 --- /dev/null +++ b/server/gemini_client.py @@ -0,0 +1,80 @@ +""" +Lightweight Gemini API client (OpenAI-compatible endpoint). + +Uses Google's OpenAI-compatible Gemini endpoint: +https://generativelanguage.googleapis.com/v1beta/openai + +Environment variables: +- GEMINI_API_KEY (required) +- GEMINI_MODEL (optional, default: gemini-1.5-flash) +- GEMINI_BASE_URL (optional, default: official OpenAI-compatible endpoint) +""" + +import os +from typing import AsyncGenerator, Iterable, Optional + +from openai import AsyncOpenAI + +# Default OpenAI-compatible base URL for Gemini +DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai" +DEFAULT_GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-1.5-flash") + + +def is_gemini_configured() -> bool: + """Return True if a Gemini API key is available.""" + return bool(os.getenv("GEMINI_API_KEY")) + + +def _build_client() -> AsyncOpenAI: + api_key = os.getenv("GEMINI_API_KEY") + if not api_key: + raise RuntimeError("GEMINI_API_KEY is not set") + + base_url = os.getenv("GEMINI_BASE_URL", DEFAULT_GEMINI_BASE_URL) + return AsyncOpenAI(api_key=api_key, base_url=base_url) + + +async def stream_chat( + user_message: str, + *, + system_prompt: Optional[str] = None, + model: Optional[str] = None, + extra_messages: Optional[Iterable[dict]] = None, +) -> AsyncGenerator[str, None]: + """ + Stream a chat completion from Gemini. + + Args: + user_message: Primary user input + system_prompt: Optional system prompt to prepend + model: Optional model name; defaults to GEMINI_MODEL env or fallback constant + extra_messages: Optional prior messages (list of {"role","content"}) + Yields: + Text chunks as they arrive. + """ + client = _build_client() + messages = [] + + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + + if extra_messages: + messages.extend(extra_messages) + + messages.append({"role": "user", "content": user_message}) + + completion = await client.chat.completions.create( + model=model or DEFAULT_GEMINI_MODEL, + messages=messages, + stream=True, + ) + + async for chunk in completion: + for choice in chunk.choices: + delta = choice.delta + if delta and delta.content: + # delta.content is a list of content parts + for part in delta.content: + text = getattr(part, "text", None) or part.get("text") if isinstance(part, dict) else None + if text: + yield text diff --git a/server/main.py b/server/main.py index 1b01f79a..e96c0a2b 100644 --- a/server/main.py +++ b/server/main.py @@ -7,11 +7,16 @@ """ import asyncio +import logging import os +import re import shutil import sys +import uuid from contextlib import asynccontextmanager +from contextvars import ContextVar from pathlib import Path +from typing import Optional # Fix for Windows subprocess support in asyncio if sys.platform == "win32": @@ -22,10 +27,20 @@ # Load environment variables from .env file if present load_dotenv() -from fastapi import FastAPI, HTTPException, Request, WebSocket +from fastapi import FastAPI, HTTPException, Request, Response, WebSocket from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest +from pythonjsonlogger import jsonlogger +from sentry_sdk import init as sentry_init +from sentry_sdk.integrations.fastapi import FastApiIntegration from .routers import ( agent_router, @@ -56,6 +71,103 @@ ROOT_DIR = Path(__file__).parent.parent UI_DIST_DIR = ROOT_DIR / "ui" / "dist" +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- + +# contextvar for request ID +request_id_ctx: ContextVar[Optional[str]] = ContextVar("request_id", default=None) + + +class RequestIdFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = request_id_ctx.get() + return True + + +def configure_logging(): + """Configure JSON logging with request_id.""" + handler = logging.StreamHandler() + formatter = jsonlogger.JsonFormatter( + "%(asctime)s %(levelname)s %(name)s %(message)s %(request_id)s" + ) + handler.setFormatter(formatter) + handler.addFilter(RequestIdFilter()) + + root = logging.getLogger() + root.handlers = [handler] + root.setLevel(logging.INFO) + + # Uvicorn loggers + for name in ("uvicorn", "uvicorn.error", "uvicorn.access"): + logger = logging.getLogger(name) + logger.handlers = [handler] + logger.setLevel(logging.INFO) + + +configure_logging() + +def configure_sentry(): + dsn = os.getenv("SENTRY_DSN") + if not dsn: + return + sentry_init( + dsn=dsn, + integrations=[FastApiIntegration()], + traces_sample_rate=float(os.getenv("SENTRY_TRACES_SAMPLE_RATE", "0.2")), + environment=os.getenv("SENTRY_ENV", "production"), + ) + + +def configure_tracing(app: FastAPI): + endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") + if not endpoint: + return + resource = Resource.create( + { + "service.name": os.getenv("OTEL_SERVICE_NAME", "autocoder-server"), + "deployment.environment": os.getenv("OTEL_ENVIRONMENT", "production"), + } + ) + provider = TracerProvider(resource=resource) + processor = BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) + provider.add_span_processor(processor) + trace.set_tracer_provider(provider) + FastAPIInstrumentor.instrument_app(app, tracer_provider=provider) + +# ----------------------------------------------------------------------------- +# Metrics +# ----------------------------------------------------------------------------- + +REQUEST_COUNTER = Counter( + "http_requests_total", + "Total HTTP requests", + ["method", "path", "status"], +) +REQUEST_LATENCY = Histogram( + "http_request_duration_seconds", + "HTTP request latency", + ["method", "path"], + buckets=(0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10), +) + + +METRICS_ENABLED = os.environ.get("AUTOCODER_ENABLE_METRICS", "").lower() in ("1", "true", "yes") + + +def normalize_path(path: str) -> str: + if not path: + return "/" + normalized = re.sub( + r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + "/:uuid", + path, + flags=re.IGNORECASE, + ) + normalized = re.sub(r"/[0-9a-f]{8,}", "/:id", normalized, flags=re.IGNORECASE) + normalized = re.sub(r"/\d+", "/:num", normalized) + return normalized or "/" + @asynccontextmanager async def lifespan(app: FastAPI): @@ -88,6 +200,10 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +# Observability (optional, controlled by env vars) +configure_sentry() +configure_tracing(app) + # Check if remote access is enabled via environment variable # Set by start_ui.py when --host is not 127.0.0.1 ALLOW_REMOTE = os.environ.get("AUTOCODER_ALLOW_REMOTE", "").lower() in ("1", "true", "yes") @@ -116,6 +232,26 @@ async def lifespan(app: FastAPI): ) +# ============================================================================ +# Health Endpoint +# ============================================================================ + +@app.get("/health") +async def health(): + """Lightweight liveness probe used by deploy smoke tests.""" + return {"status": "ok"} + + +@app.get("/readiness") +async def readiness(): + """ + Readiness probe placeholder. + + Add dependency checks (DB, external APIs, queues) here when introduced. + """ + return {"status": "ready"} + + # ============================================================================ # Security Middleware # ============================================================================ @@ -132,6 +268,40 @@ async def require_localhost(request: Request, call_next): return await call_next(request) +@app.middleware("http") +async def request_id_middleware(request: Request, call_next): + """Attach a request_id to context and response headers for traceability.""" + incoming = request.headers.get("X-Request-ID") + req_id = incoming or uuid.uuid4().hex + token = request_id_ctx.set(req_id) + try: + response = await call_next(request) + finally: + request_id_ctx.reset(token) + response.headers["X-Request-ID"] = req_id + return response + + +if METRICS_ENABLED: + @app.get("/metrics") + async def metrics(): + """Prometheus metrics endpoint.""" + return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) + + @app.middleware("http") + async def metrics_middleware(request: Request, call_next): + """Capture basic Prometheus metrics.""" + path = request.url.path + if path == "/metrics": + return await call_next(request) + method = request.method + normalized = normalize_path(path) + with REQUEST_LATENCY.labels(method=method, path=normalized).time(): + response: Response = await call_next(request) + status = response.status_code + REQUEST_COUNTER.labels(method=method, path=normalized, status=status).inc() + return response + # ============================================================================ # Include Routers @@ -184,7 +354,11 @@ async def setup_status(): # If GLM mode is configured via .env, we have alternative credentials glm_configured = bool(os.getenv("ANTHROPIC_BASE_URL") and os.getenv("ANTHROPIC_AUTH_TOKEN")) - credentials = has_claude_config or glm_configured + + # Gemini configuration (OpenAI-compatible Gemini API) + gemini_configured = bool(os.getenv("GEMINI_API_KEY")) + + credentials = has_claude_config or glm_configured or gemini_configured # Check for Node.js and npm node = shutil.which("node") is not None @@ -195,6 +369,7 @@ async def setup_status(): credentials=credentials, node=node, npm=npm, + gemini=gemini_configured, ) diff --git a/server/routers/filesystem.py b/server/routers/filesystem.py index eb6293b8..09a4b183 100644 --- a/server/routers/filesystem.py +++ b/server/routers/filesystem.py @@ -14,9 +14,6 @@ from fastapi import APIRouter, HTTPException, Query -# Module logger -logger = logging.getLogger(__name__) - from ..schemas import ( CreateDirectoryRequest, DirectoryEntry, @@ -25,6 +22,11 @@ PathValidationResponse, ) +REPO_ROOT = Path(__file__).resolve().parents[2] + +# Module logger +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/filesystem", tags=["filesystem"]) @@ -129,6 +131,10 @@ def is_path_blocked(path: Path) -> bool: except (OSError, ValueError): return True # Can't resolve = blocked + # Allow paths under the workspace root even if parent is blocked (/opt/etc) + if resolved == REPO_ROOT or REPO_ROOT in resolved.parents: + return False + blocked_paths = get_blocked_paths() # Check if path is exactly a blocked path or inside one diff --git a/server/routers/projects.py b/server/routers/projects.py index 68cf5268..8b135db8 100644 --- a/server/routers/projects.py +++ b/server/routers/projects.py @@ -8,12 +8,16 @@ import re import shutil +import subprocess import sys from pathlib import Path +from urllib.parse import urlparse from fastapi import APIRouter, HTTPException from ..schemas import ( + ProjectCloneRequest, + ProjectCloneResponse, ProjectCreate, ProjectDetail, ProjectPrompts, @@ -85,6 +89,30 @@ def validate_project_name(name: str) -> str: return name +def _derive_repo_dirname(repo_url: str) -> str: + """Derive a reasonable default directory name from a repo URL.""" + parsed = urlparse(repo_url) + candidate = parsed.path.rstrip("/").split("/")[-1] or "repo" + if candidate.endswith(".git"): + candidate = candidate[:-4] + return candidate or "repo" + + +def _validate_target_dirname(dirname: str) -> str: + """Validate a clone target directory name (must be a simple relative name).""" + trimmed = dirname.strip() + if not trimmed: + raise HTTPException(status_code=400, detail="Target directory name cannot be empty") + if "/" in trimmed or "\\" in trimmed or ".." in trimmed: + raise HTTPException(status_code=400, detail="Target directory must be a simple name") + if not re.match(r"^[a-zA-Z0-9._-]{1,100}$", trimmed): + raise HTTPException( + status_code=400, + detail="Target directory contains invalid characters", + ) + return trimmed + + def get_project_stats(project_dir: Path) -> ProjectStats: """Get statistics for a project.""" _init_imports() @@ -355,3 +383,64 @@ async def get_project_stats_endpoint(name: str): raise HTTPException(status_code=404, detail="Project directory not found") return get_project_stats(project_dir) + + +@router.post("/{name}/clone", response_model=ProjectCloneResponse) +async def clone_repository(name: str, request: ProjectCloneRequest): + """Clone a git repository into a registered project directory.""" + _init_imports() + _, _, get_project_path, _, _ = _get_registry_functions() + + project_name = validate_project_name(name) + project_dir = get_project_path(project_name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found") + + if not project_dir.exists(): + raise HTTPException(status_code=404, detail="Project directory not found") + + git_path = shutil.which("git") + if not git_path: + raise HTTPException(status_code=500, detail="git is not installed on the server") + + repo_url = request.repo_url.strip() + if not repo_url: + raise HTTPException(status_code=400, detail="Repository URL is required") + + target_dirname = _validate_target_dirname(request.target_dir) if request.target_dir else _derive_repo_dirname(repo_url) + clone_target = (project_dir / target_dirname).resolve() + + # Safety: ensure the resolved target stays within the project directory + project_root = project_dir.resolve() + if project_root not in clone_target.parents and clone_target != project_root: + raise HTTPException(status_code=400, detail="Clone target must be inside the project directory") + + if clone_target.exists(): + raise HTTPException(status_code=409, detail=f"Target directory already exists: {clone_target}") + + try: + result = subprocess.run( + [git_path, "clone", repo_url, str(clone_target)], + cwd=str(project_dir), + capture_output=True, + text=True, + timeout=300, + check=False, + ) + except subprocess.TimeoutExpired: + raise HTTPException(status_code=504, detail="git clone timed out after 5 minutes") + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Failed to run git clone: {exc}") + + if result.returncode != 0: + stderr = (result.stderr or "").strip() + stdout = (result.stdout or "").strip() + detail = stderr or stdout or "git clone failed" + raise HTTPException(status_code=400, detail=detail[:500]) + + return ProjectCloneResponse( + success=True, + message=f"Cloned repository into {clone_target}", + path=clone_target.as_posix(), + ) diff --git a/server/schemas.py b/server/schemas.py index 0a2807cc..2de95db3 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -70,6 +70,22 @@ class ProjectPromptsUpdate(BaseModel): coding_prompt: str | None = None +class ProjectCloneRequest(BaseModel): + """Request schema for cloning a git repository into a project.""" + repo_url: str = Field(..., min_length=1, description="Git repository URL to clone") + target_dir: str | None = Field( + default=None, + description="Optional directory name (relative to the project root) to clone into", + ) + + +class ProjectCloneResponse(BaseModel): + """Response schema for a git clone operation.""" + success: bool = True + message: str + path: str + + # ============================================================================ # Feature Schemas # ============================================================================ @@ -227,6 +243,7 @@ class SetupStatus(BaseModel): credentials: bool node: bool npm: bool + gemini: bool = False # ============================================================================ diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index f15eee8a..190e8207 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -20,6 +20,7 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from dotenv import load_dotenv +from ..gemini_client import is_gemini_configured, stream_chat from .assistant_database import ( add_message, create_conversation, @@ -182,6 +183,8 @@ def __init__(self, project_name: str, project_dir: Path, conversation_id: Option self._client_entered: bool = False self.created_at = datetime.now() self._history_loaded: bool = False # Track if we've loaded history for resumed conversations + self.provider: str = "gemini" if is_gemini_configured() else "claude" + self._system_prompt: str | None = None async def close(self) -> None: """Clean up resources and close the Claude client.""" @@ -249,6 +252,7 @@ async def start(self) -> AsyncGenerator[dict, None]: # Get system prompt with project context system_prompt = get_system_prompt(self.project_name, self.project_dir) + self._system_prompt = system_prompt # Write system prompt to CLAUDE.md file to avoid Windows command line length limit # The SDK will read this via setting_sources=["project"] @@ -257,42 +261,46 @@ async def start(self) -> AsyncGenerator[dict, None]: f.write(system_prompt) logger.info(f"Wrote assistant system prompt to {claude_md_path}") - # Use system Claude CLI - system_cli = shutil.which("claude") + if self.provider == "gemini": + logger.info("Assistant session using Gemini provider (no tools).") + self.client = None + else: + # Use system Claude CLI + system_cli = shutil.which("claude") - # Build environment overrides for API configuration - sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)} + # Build environment overrides for API configuration + sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)} - # Determine model from environment or use default - # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names - model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101") + # Determine model from environment or use default + # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names + model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101") - try: - logger.info("Creating ClaudeSDKClient...") - self.client = ClaudeSDKClient( - options=ClaudeAgentOptions( - model=model, - cli_path=system_cli, - # System prompt loaded from CLAUDE.md via setting_sources - # This avoids Windows command line length limit (~8191 chars) - setting_sources=["project"], - allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS], - mcp_servers=mcp_servers, - permission_mode="bypassPermissions", - max_turns=100, - cwd=str(self.project_dir.resolve()), - settings=str(settings_file.resolve()), - env=sdk_env, + try: + logger.info("Creating ClaudeSDKClient...") + self.client = ClaudeSDKClient( + options=ClaudeAgentOptions( + model=model, + cli_path=system_cli, + # System prompt loaded from CLAUDE.md via setting_sources + # This avoids Windows command line length limit (~8191 chars) + setting_sources=["project"], + allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS], + mcp_servers=mcp_servers, + permission_mode="bypassPermissions", + max_turns=100, + cwd=str(self.project_dir.resolve()), + settings=str(settings_file.resolve()), + env=sdk_env, + ) ) - ) - logger.info("Entering Claude client context...") - await self.client.__aenter__() - self._client_entered = True - logger.info("Claude client ready") - except Exception as e: - logger.exception("Failed to create Claude client") - yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"} - return + logger.info("Entering Claude client context...") + await self.client.__aenter__() + self._client_entered = True + logger.info("Claude client ready") + except Exception as e: + logger.exception("Failed to create Claude client") + yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"} + return # Send initial greeting only for NEW conversations # Resumed conversations already have history loaded from the database @@ -329,7 +337,7 @@ async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]: - {"type": "response_done"} - {"type": "error", "content": str} """ - if not self.client: + if self.provider != "gemini" and not self.client: yield {"type": "error", "content": "Session not initialized. Call start() first."} return @@ -365,11 +373,15 @@ async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]: logger.info(f"Loaded {len(history)} messages from conversation history") try: - async for chunk in self._query_claude(message_to_send): - yield chunk + if self.provider == "gemini": + async for chunk in self._query_gemini(message_to_send): + yield chunk + else: + async for chunk in self._query_claude(message_to_send): + yield chunk yield {"type": "response_done"} except Exception as e: - logger.exception("Error during Claude query") + logger.exception("Error during assistant query") yield {"type": "error", "content": f"Error: {str(e)}"} async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]: @@ -413,6 +425,27 @@ async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]: if full_response and self.conversation_id: add_message(self.project_dir, self.conversation_id, "assistant", full_response) + async def _query_gemini(self, message: str) -> AsyncGenerator[dict, None]: + """ + Query Gemini and stream plain-text responses (no tool calls). + """ + full_response = "" + try: + async for text in stream_chat( + message, + system_prompt=self._system_prompt, + model=os.getenv("GEMINI_MODEL"), + ): + full_response += text + yield {"type": "text", "content": text} + except Exception as e: + logger.exception("Gemini query failed") + yield {"type": "error", "content": f"Gemini error: {e}"} + return + + if full_response and self.conversation_id: + add_message(self.project_dir, self.conversation_id, "assistant", full_response) + def get_conversation_id(self) -> Optional[int]: """Get the current conversation ID.""" return self.conversation_id diff --git a/test_health.py b/test_health.py new file mode 100644 index 00000000..d43d4750 --- /dev/null +++ b/test_health.py @@ -0,0 +1,19 @@ +"""Lightweight tests for health and readiness endpoints.""" + +from fastapi.testclient import TestClient + +from server.main import app + +client = TestClient(app) + + +def test_health_returns_ok(): + response = client.get("/health") + assert response.status_code == 200 + assert response.json().get("status") == "ok" + + +def test_readiness_returns_ready(): + response = client.get("/readiness") + assert response.status_code == 200 + assert response.json().get("status") == "ready" diff --git a/ui/Dockerfile.dev b/ui/Dockerfile.dev new file mode 100644 index 00000000..b02460d1 --- /dev/null +++ b/ui/Dockerfile.dev @@ -0,0 +1,6 @@ +FROM node:20-alpine +WORKDIR /app/ui +COPY package*.json ./ +RUN npm install +COPY . . +CMD ["npm", "run", "dev", "--", "--host", "--port", "5173"] diff --git a/ui/package-lock.json b/ui/package-lock.json index b9af1ecc..4e14c958 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -22,6 +22,8 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", + "@sentry/react": "^10.36.0", + "@sentry/replay": "^7.116.0", "@tanstack/react-query": "^5.72.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", @@ -40,6 +42,8 @@ "@eslint/js": "^9.19.0", "@playwright/test": "^1.57.0", "@tailwindcss/vite": "^4.1.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/canvas-confetti": "^1.9.0", "@types/dagre": "^0.7.53", "@types/node": "^22.12.0", @@ -50,13 +54,85 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.14.0", + "jsdom": "^27.4.0", + "prettier": "^3.8.1", "tailwindcss": "^4.1.0", "tw-animate-css": "^1.4.0", "typescript": "~5.7.3", "typescript-eslint": "^8.23.0", - "vite": "^7.3.0" + "vite": "^7.3.0", + "vitest": "^4.0.18" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -88,6 +164,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -291,6 +368,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -339,6 +426,140 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz", + "integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -938,6 +1159,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.10.0.tgz", + "integrity": "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -2530,6 +2769,180 @@ "win32" ] }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.36.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.36.0.tgz", + "integrity": "sha512-WILVR8HQBWOxbqLRuTxjzRCMIACGsDTo6jXvzA8rz6ezElElLmIrn3CFAswrESLqEEUa4CQHl5bLgSVJCRNweA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.36.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.36.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.36.0.tgz", + "integrity": "sha512-zPjz7AbcxEyx8AHj8xvp28fYtPTPWU1XcNtymhAHJLS9CXOblqSC7W02Jxz6eo3eR1/pLyOo6kJBUjvLe9EoFA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.36.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.36.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.36.0.tgz", + "integrity": "sha512-nLMkJgvHq+uCCrQKV2KgSdVHxTsmDk0r2hsAoTcKCbzUpXyW5UhCziMRS6ULjBlzt5sbxoIIplE25ZpmIEeNgg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.36.0", + "@sentry/core": "10.36.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.36.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.36.0.tgz", + "integrity": "sha512-DLGIwmT2LX+O6TyYPtOQL5GiTm2rN0taJPDJ/Lzg2KEJZrdd5sKkzTckhh2x+vr4JQyeaLmnb8M40Ch1hvG/vQ==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.36.0", + "@sentry/core": "10.36.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/tracing": { + "version": "7.116.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.116.0.tgz", + "integrity": "sha512-y5ppEmoOlfr77c/HqsEXR72092qmGYS4QE5gSz5UZFn9CiinEwGfEorcg2xIrrCuU7Ry/ZU2VLz9q3xd04drRA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "7.116.0", + "@sentry/types": "7.116.0", + "@sentry/utils": "7.116.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry-internal/tracing/node_modules/@sentry/core": { + "version": "7.116.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.116.0.tgz", + "integrity": "sha512-J6Wmjjx+o7RwST0weTU1KaKUAlzbc8MGkJV1rcHM9xjNTWTva+nrcCM3vFBagnk2Gm/zhwv3h0PvWEqVyp3U1Q==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.116.0", + "@sentry/utils": "7.116.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/browser": { + "version": "10.36.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.36.0.tgz", + "integrity": "sha512-yHhXbgdGY1s+m8CdILC9U/II7gb6+s99S2Eh8VneEn/JG9wHc+UOzrQCeFN0phFP51QbLkjkiQbbanjT1HP8UQ==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.36.0", + "@sentry-internal/feedback": "10.36.0", + "@sentry-internal/replay": "10.36.0", + "@sentry-internal/replay-canvas": "10.36.0", + "@sentry/core": "10.36.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/core": { + "version": "10.36.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.36.0.tgz", + "integrity": "sha512-EYJjZvofI+D93eUsPLDIUV0zQocYqiBRyXS6CCV6dHz64P/Hob5NJQOwPa8/v6nD+UvJXvwsFfvXOHhYZhZJOQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/react": { + "version": "10.36.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.36.0.tgz", + "integrity": "sha512-k2GwMKgepJLXvEQffQymQyxsTVjsLiY6YXG0bcceM3vulii9Sy29uqGhpqwaPOfM4bPQzUXJzAxS/c9S7n5hTw==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.36.0", + "@sentry/core": "10.36.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/replay": { + "version": "7.116.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.116.0.tgz", + "integrity": "sha512-OrpDtV54pmwZuKp3g7PDiJg6ruRMJKOCzK08TF7IPsKrr4x4UQn56rzMOiABVuTjuS8lNfAWDar6c6vxXFz5KA==", + "license": "MIT", + "dependencies": { + "@sentry-internal/tracing": "7.116.0", + "@sentry/core": "7.116.0", + "@sentry/types": "7.116.0", + "@sentry/utils": "7.116.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@sentry/replay/node_modules/@sentry/core": { + "version": "7.116.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.116.0.tgz", + "integrity": "sha512-J6Wmjjx+o7RwST0weTU1KaKUAlzbc8MGkJV1rcHM9xjNTWTva+nrcCM3vFBagnk2Gm/zhwv3h0PvWEqVyp3U1Q==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.116.0", + "@sentry/utils": "7.116.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/types": { + "version": "7.116.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.116.0.tgz", + "integrity": "sha512-QCCvG5QuQrwgKzV11lolNQPP2k67Q6HHD9vllZ/C4dkxkjoIym8Gy+1OgAN3wjsR0f/kG9o5iZyglgNpUVRapQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/utils": { + "version": "7.116.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.116.0.tgz", + "integrity": "sha512-Vn9fcvwTq91wJvCd7WTMWozimqMi+dEZ3ie3EICELC2diONcN16ADFdzn65CQQbYwmUzRjN9EjDN2k41pKZWhQ==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.116.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", @@ -2888,38 +3301,121 @@ "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==", + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" } }, - "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==", + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.0.0" + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" } }, - "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==", + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.1.0", + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "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" } }, @@ -2940,6 +3436,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -2996,6 +3503,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3016,6 +3530,7 @@ "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3024,8 +3539,9 @@ "version": "19.2.9", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3034,8 +3550,9 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3085,6 +3602,7 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -3330,6 +3848,117 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", @@ -3389,6 +4018,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3406,6 +4036,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3423,6 +4063,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3458,6 +4108,26 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3475,6 +4145,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3506,6 +4186,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3561,6 +4242,16 @@ "url": "https://www.paypal.me/kirilvatev" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3654,11 +4345,58 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "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, + "devOptional": true, "license": "MIT" }, "node_modules/d3-color": { @@ -3718,6 +4456,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -3776,6 +4515,30 @@ "lodash": "^4.17.15" } }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3794,6 +4557,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3801,6 +4571,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3817,6 +4597,13 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -3838,6 +4625,26 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -3909,6 +4716,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4070,6 +4878,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4080,6 +4898,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4256,6 +5084,47 @@ "node": ">=8" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4293,6 +5162,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4316,6 +5195,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4353,6 +5239,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4733,6 +5660,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4743,6 +5680,23 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4796,6 +5750,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4859,6 +5824,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4879,6 +5857,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4892,6 +5877,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4982,6 +5968,50 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4997,6 +6027,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5006,6 +6037,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5013,6 +6045,13 @@ "react": "^19.2.3" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5092,6 +6131,30 @@ } } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5144,6 +6207,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5183,6 +6259,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5193,6 +6276,33 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -5219,6 +6329,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", @@ -5250,6 +6367,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5267,6 +6401,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz", @@ -5315,6 +6505,7 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5453,6 +6644,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5522,6 +6714,131 @@ } } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5538,6 +6855,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5548,6 +6882,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/ui/package.json b/ui/package.json index f70b9ca2..b247f09e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -7,9 +7,11 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "format": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "preview": "vite preview", "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui" + "test:e2e:ui": "playwright test --ui", + "test:smoke": "vitest run --config vite.config.ts" }, "dependencies": { "@radix-ui/react-checkbox": "^1.3.3", @@ -26,6 +28,8 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", + "@sentry/react": "^10.36.0", + "@sentry/replay": "^7.116.0", "@tanstack/react-query": "^5.72.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", @@ -44,6 +48,8 @@ "@eslint/js": "^9.19.0", "@playwright/test": "^1.57.0", "@tailwindcss/vite": "^4.1.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/canvas-confetti": "^1.9.0", "@types/dagre": "^0.7.53", "@types/node": "^22.12.0", @@ -54,10 +60,13 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.14.0", + "jsdom": "^27.4.0", + "prettier": "^3.8.1", "tailwindcss": "^4.1.0", "tw-animate-css": "^1.4.0", "typescript": "~5.7.3", "typescript-eslint": "^8.23.0", - "vite": "^7.3.0" + "vite": "^7.3.0", + "vitest": "^4.0.18" } } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 05f99861..8128f6d8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,242 +1,294 @@ -import { useState, useEffect, useCallback } from 'react' -import { useQueryClient, useQuery } from '@tanstack/react-query' -import { useProjects, useFeatures, useAgentStatus, useSettings } from './hooks/useProjects' -import { useProjectWebSocket } from './hooks/useWebSocket' -import { useFeatureSound } from './hooks/useFeatureSound' -import { useCelebration } from './hooks/useCelebration' -import { useTheme } from './hooks/useTheme' -import { ProjectSelector } from './components/ProjectSelector' -import { KanbanBoard } from './components/KanbanBoard' -import { AgentControl } from './components/AgentControl' -import { ProgressDashboard } from './components/ProgressDashboard' -import { SetupWizard } from './components/SetupWizard' -import { AddFeatureForm } from './components/AddFeatureForm' -import { FeatureModal } from './components/FeatureModal' -import { DebugLogViewer, type TabType } from './components/DebugLogViewer' -import { AgentThought } from './components/AgentThought' -import { AgentMissionControl } from './components/AgentMissionControl' -import { CelebrationOverlay } from './components/CelebrationOverlay' -import { AssistantFAB } from './components/AssistantFAB' -import { AssistantPanel } from './components/AssistantPanel' -import { ExpandProjectModal } from './components/ExpandProjectModal' -import { SpecCreationChat } from './components/SpecCreationChat' -import { SettingsModal } from './components/SettingsModal' -import { DevServerControl } from './components/DevServerControl' -import { ViewToggle, type ViewMode } from './components/ViewToggle' -import { DependencyGraph } from './components/DependencyGraph' -import { KeyboardShortcutsHelp } from './components/KeyboardShortcutsHelp' -import { ThemeSelector } from './components/ThemeSelector' -import { getDependencyGraph } from './lib/api' -import { Loader2, Settings, Moon, Sun } from 'lucide-react' -import type { Feature } from './lib/types' -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' - -const STORAGE_KEY = 'autocoder-selected-project' -const VIEW_MODE_KEY = 'autocoder-view-mode' +import { useState, useEffect, useCallback } from "react"; +import { useQueryClient, useQuery } from "@tanstack/react-query"; +import { + useProjects, + useFeatures, + useAgentStatus, + useSettings, +} from "./hooks/useProjects"; +import { useProjectWebSocket } from "./hooks/useWebSocket"; +import { useFeatureSound } from "./hooks/useFeatureSound"; +import { useCelebration } from "./hooks/useCelebration"; +import { useTheme } from "./hooks/useTheme"; +import { ProjectSelector } from "./components/ProjectSelector"; +import { KanbanBoard } from "./components/KanbanBoard"; +import { AgentControl } from "./components/AgentControl"; +import { ProgressDashboard } from "./components/ProgressDashboard"; +import { SetupWizard } from "./components/SetupWizard"; +import { AddFeatureForm } from "./components/AddFeatureForm"; +import { FeatureModal } from "./components/FeatureModal"; +import { DebugLogViewer, type TabType } from "./components/DebugLogViewer"; +import { AgentThought } from "./components/AgentThought"; +import { AgentMissionControl } from "./components/AgentMissionControl"; +import { CelebrationOverlay } from "./components/CelebrationOverlay"; +import { AssistantFAB } from "./components/AssistantFAB"; +import { AssistantPanel } from "./components/AssistantPanel"; +import { ExpandProjectModal } from "./components/ExpandProjectModal"; +import { SpecCreationChat } from "./components/SpecCreationChat"; +import { SettingsModal } from "./components/SettingsModal"; +import { DevServerControl } from "./components/DevServerControl"; +import { ViewToggle, type ViewMode } from "./components/ViewToggle"; +import { DependencyGraph } from "./components/DependencyGraph"; +import { KeyboardShortcutsHelp } from "./components/KeyboardShortcutsHelp"; +import { ThemeSelector } from "./components/ThemeSelector"; +import { getDependencyGraph } from "./lib/api"; +import { Loader2, Settings, Moon, Sun } from "lucide-react"; +import type { Feature } from "./lib/types"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { setSentryProject } from "./lib/sentry"; + +const STORAGE_KEY = "autocoder-selected-project"; +const VIEW_MODE_KEY = "autocoder-view-mode"; function App() { // Initialize selected project from localStorage const [selectedProject, setSelectedProject] = useState(() => { try { - return localStorage.getItem(STORAGE_KEY) + return localStorage.getItem(STORAGE_KEY); } catch { - return null + return null; } - }) - const [showAddFeature, setShowAddFeature] = useState(false) - const [showExpandProject, setShowExpandProject] = useState(false) - const [selectedFeature, setSelectedFeature] = useState(null) - const [setupComplete, setSetupComplete] = useState(true) // Start optimistic - const [debugOpen, setDebugOpen] = useState(false) - const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height - const [debugActiveTab, setDebugActiveTab] = useState('agent') - const [assistantOpen, setAssistantOpen] = useState(false) - const [showSettings, setShowSettings] = useState(false) - const [showKeyboardHelp, setShowKeyboardHelp] = useState(false) - const [isSpecCreating, setIsSpecCreating] = useState(false) - const [showSpecChat, setShowSpecChat] = useState(false) // For "Create Spec" button in empty kanban + }); + const [showAddFeature, setShowAddFeature] = useState(false); + const [showExpandProject, setShowExpandProject] = useState(false); + const [selectedFeature, setSelectedFeature] = useState(null); + const [setupComplete, setSetupComplete] = useState(true); // Start optimistic + const [debugOpen, setDebugOpen] = useState(false); + const [debugPanelHeight, setDebugPanelHeight] = useState(288); // Default height + const [debugActiveTab, setDebugActiveTab] = useState("agent"); + const [assistantOpen, setAssistantOpen] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [showKeyboardHelp, setShowKeyboardHelp] = useState(false); + const [isSpecCreating, setIsSpecCreating] = useState(false); + const [showSpecChat, setShowSpecChat] = useState(false); // For "Create Spec" button in empty kanban const [viewMode, setViewMode] = useState(() => { try { - const stored = localStorage.getItem(VIEW_MODE_KEY) - return (stored === 'graph' ? 'graph' : 'kanban') as ViewMode + const stored = localStorage.getItem(VIEW_MODE_KEY); + return (stored === "graph" ? "graph" : "kanban") as ViewMode; } catch { - return 'kanban' + return "kanban"; } - }) + }); - const queryClient = useQueryClient() - const { data: projects, isLoading: projectsLoading } = useProjects() - const { data: features } = useFeatures(selectedProject) - const { data: settings } = useSettings() - useAgentStatus(selectedProject) // Keep polling for status updates - const wsState = useProjectWebSocket(selectedProject) - const { theme, setTheme, darkMode, toggleDarkMode, themes } = useTheme() + const queryClient = useQueryClient(); + const { data: projects, isLoading: projectsLoading } = useProjects(); + const { data: features } = useFeatures(selectedProject); + const { data: settings } = useSettings(); + useAgentStatus(selectedProject); // Keep polling for status updates + const wsState = useProjectWebSocket(selectedProject); + const { theme, setTheme, darkMode, toggleDarkMode, themes } = useTheme(); // Get has_spec from the selected project - const selectedProjectData = projects?.find(p => p.name === selectedProject) - const hasSpec = selectedProjectData?.has_spec ?? true + const selectedProjectData = projects?.find((p) => p.name === selectedProject); + const hasSpec = selectedProjectData?.has_spec ?? true; // Fetch graph data when in graph view const { data: graphData } = useQuery({ - queryKey: ['dependencyGraph', selectedProject], + queryKey: ["dependencyGraph", selectedProject], queryFn: () => getDependencyGraph(selectedProject!), - enabled: !!selectedProject && viewMode === 'graph', + enabled: !!selectedProject && viewMode === "graph", refetchInterval: 5000, // Refresh every 5 seconds - }) + }); // Persist view mode to localStorage useEffect(() => { try { - localStorage.setItem(VIEW_MODE_KEY, viewMode) + localStorage.setItem(VIEW_MODE_KEY, viewMode); } catch { // localStorage not available } - }, [viewMode]) + }, [viewMode]); // Play sounds when features move between columns - useFeatureSound(features) + useFeatureSound(features); // Celebrate when all features are complete - useCelebration(features, selectedProject) + useCelebration(features, selectedProject); // Persist selected project to localStorage const handleSelectProject = useCallback((project: string | null) => { - setSelectedProject(project) + setSelectedProject(project); try { if (project) { - localStorage.setItem(STORAGE_KEY, project) + localStorage.setItem(STORAGE_KEY, project); } else { - localStorage.removeItem(STORAGE_KEY) + localStorage.removeItem(STORAGE_KEY); } } catch { // localStorage not available } - }, []) + }, []); + + // Update Sentry tag when project changes + useEffect(() => { + setSentryProject(selectedProject); + }, [selectedProject]); // Handle graph node click - memoized to prevent DependencyGraph re-renders - const handleGraphNodeClick = useCallback((nodeId: number) => { - const allFeatures = [ - ...(features?.pending ?? []), - ...(features?.in_progress ?? []), - ...(features?.done ?? []) - ] - const feature = allFeatures.find(f => f.id === nodeId) - if (feature) setSelectedFeature(feature) - }, [features]) + const handleGraphNodeClick = useCallback( + (nodeId: number) => { + const allFeatures = [ + ...(features?.pending ?? []), + ...(features?.in_progress ?? []), + ...(features?.done ?? []), + ]; + const feature = allFeatures.find((f) => f.id === nodeId); + if (feature) setSelectedFeature(feature); + }, + [features], + ); // Validate stored project exists (clear if project was deleted) useEffect(() => { - if (selectedProject && projects && !projects.some(p => p.name === selectedProject)) { - handleSelectProject(null) + if ( + selectedProject && + projects && + !projects.some((p) => p.name === selectedProject) + ) { + handleSelectProject(null); } - }, [selectedProject, projects, handleSelectProject]) + }, [selectedProject, projects, handleSelectProject]); // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ignore if user is typing in an input - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) { + return; } // D : Toggle debug window - if (e.key === 'd' || e.key === 'D') { - e.preventDefault() - setDebugOpen(prev => !prev) + if (e.key === "d" || e.key === "D") { + e.preventDefault(); + setDebugOpen((prev) => !prev); } // T : Toggle terminal tab in debug panel - if (e.key === 't' || e.key === 'T') { - e.preventDefault() + if (e.key === "t" || e.key === "T") { + e.preventDefault(); if (!debugOpen) { // If panel is closed, open it and switch to terminal tab - setDebugOpen(true) - setDebugActiveTab('terminal') - } else if (debugActiveTab === 'terminal') { + setDebugOpen(true); + setDebugActiveTab("terminal"); + } else if (debugActiveTab === "terminal") { // If already on terminal tab, close the panel - setDebugOpen(false) + setDebugOpen(false); } else { // If open but on different tab, switch to terminal - setDebugActiveTab('terminal') + setDebugActiveTab("terminal"); } } // N : Add new feature (when project selected) - if ((e.key === 'n' || e.key === 'N') && selectedProject) { - e.preventDefault() - setShowAddFeature(true) + if ((e.key === "n" || e.key === "N") && selectedProject) { + e.preventDefault(); + setShowAddFeature(true); } // E : Expand project with AI (when project selected and has features) - if ((e.key === 'e' || e.key === 'E') && selectedProject && features && - (features.pending.length + features.in_progress.length + features.done.length) > 0) { - e.preventDefault() - setShowExpandProject(true) + if ( + (e.key === "e" || e.key === "E") && + selectedProject && + features && + features.pending.length + + features.in_progress.length + + features.done.length > + 0 + ) { + e.preventDefault(); + setShowExpandProject(true); } // A : Toggle assistant panel (when project selected and not in spec creation) - if ((e.key === 'a' || e.key === 'A') && selectedProject && !isSpecCreating) { - e.preventDefault() - setAssistantOpen(prev => !prev) + if ( + (e.key === "a" || e.key === "A") && + selectedProject && + !isSpecCreating + ) { + e.preventDefault(); + setAssistantOpen((prev) => !prev); } // , : Open settings - if (e.key === ',') { - e.preventDefault() - setShowSettings(true) + if (e.key === ",") { + e.preventDefault(); + setShowSettings(true); } // G : Toggle between Kanban and Graph view (when project selected) - if ((e.key === 'g' || e.key === 'G') && selectedProject) { - e.preventDefault() - setViewMode(prev => prev === 'kanban' ? 'graph' : 'kanban') + if ((e.key === "g" || e.key === "G") && selectedProject) { + e.preventDefault(); + setViewMode((prev) => (prev === "kanban" ? "graph" : "kanban")); } // ? : Show keyboard shortcuts help - if (e.key === '?') { - e.preventDefault() - setShowKeyboardHelp(true) + if (e.key === "?") { + e.preventDefault(); + setShowKeyboardHelp(true); } // Escape : Close modals - if (e.key === 'Escape') { + if (e.key === "Escape") { if (showKeyboardHelp) { - setShowKeyboardHelp(false) + setShowKeyboardHelp(false); } else if (showExpandProject) { - setShowExpandProject(false) + setShowExpandProject(false); } else if (showSettings) { - setShowSettings(false) + setShowSettings(false); } else if (assistantOpen) { - setAssistantOpen(false) + setAssistantOpen(false); } else if (showAddFeature) { - setShowAddFeature(false) + setShowAddFeature(false); } else if (selectedFeature) { - setSelectedFeature(null) + setSelectedFeature(null); } else if (debugOpen) { - setDebugOpen(false) + setDebugOpen(false); } } - } - - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode]) + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + selectedProject, + showAddFeature, + showExpandProject, + selectedFeature, + debugOpen, + debugActiveTab, + assistantOpen, + features, + showSettings, + showKeyboardHelp, + isSpecCreating, + viewMode, + ]); // Combine WebSocket progress with feature data - const progress = wsState.progress.total > 0 ? wsState.progress : { - passing: features?.done.length ?? 0, - total: (features?.pending.length ?? 0) + (features?.in_progress.length ?? 0) + (features?.done.length ?? 0), - percentage: 0, - } + const progress = + wsState.progress.total > 0 + ? wsState.progress + : { + passing: features?.done.length ?? 0, + total: + (features?.pending.length ?? 0) + + (features?.in_progress.length ?? 0) + + (features?.done.length ?? 0), + percentage: 0, + }; if (progress.total > 0 && progress.percentage === 0) { - progress.percentage = Math.round((progress.passing / progress.total) * 100 * 10) / 10 + progress.percentage = + Math.round((progress.passing / progress.total) * 100 * 10) / 10; } if (!setupComplete) { - return setSetupComplete(true)} /> + return setSetupComplete(true)} />; } return ( @@ -290,7 +342,9 @@ function App() { title="Using Ollama local models (configured via .env)" > Ollama - Ollama + + Ollama + )} @@ -339,7 +393,8 @@ function App() { Welcome to AutoCoder

- Select a project from the dropdown above or create a new one to get started. + Select a project from the dropdown above or create a new one to + get started.

) : ( @@ -370,32 +425,43 @@ function App() { {/* Initializing Features State - show when agent is running but no features yet */} {features && - features.pending.length === 0 && - features.in_progress.length === 0 && - features.done.length === 0 && - wsState.agentStatus === 'running' && ( - - - -

- Initializing Features... -

-

- The agent is reading your spec and creating features. This may take a moment. -

-
-
- )} + features.pending.length === 0 && + features.in_progress.length === 0 && + features.done.length === 0 && + wsState.agentStatus === "running" && ( + + + +

+ Initializing Features... +

+

+ The agent is reading your spec and creating features. This + may take a moment. +

+
+
+ )} {/* View Toggle - only show when there are features */} - {features && (features.pending.length + features.in_progress.length + features.done.length) > 0 && ( -
- -
- )} + {features && + features.pending.length + + features.in_progress.length + + features.done.length > + 0 && ( +
+ +
+ )} {/* Kanban Board or Dependency Graph based on view mode */} - {viewMode === 'kanban' ? ( + {viewMode === "kanban" ? ( ) : ( - + {graphData ? ( setShowExpandProject(false)} onFeaturesAdded={() => { // Invalidate features query to refresh the kanban board - queryClient.invalidateQueries({ queryKey: ['features', selectedProject] }) + queryClient.invalidateQueries({ + queryKey: ["features", selectedProject], + }); }} /> )} @@ -460,10 +528,12 @@ function App() { { - setShowSpecChat(false) + setShowSpecChat(false); // Refresh projects to update has_spec - queryClient.invalidateQueries({ queryKey: ['projects'] }) - queryClient.invalidateQueries({ queryKey: ['features', selectedProject] }) + queryClient.invalidateQueries({ queryKey: ["projects"] }); + queryClient.invalidateQueries({ + queryKey: ["features", selectedProject], + }); }} onCancel={() => setShowSpecChat(false)} onExitToProject={() => setShowSpecChat(false)} @@ -488,25 +558,34 @@ function App() { )} {/* Assistant FAB and Panel - hide when expand modal or spec creation is open */} - {selectedProject && !showExpandProject && !isSpecCreating && !showSpecChat && ( - <> - setAssistantOpen(!assistantOpen)} - isOpen={assistantOpen} - /> - setAssistantOpen(false)} - /> - - )} + {selectedProject && + !showExpandProject && + !isSpecCreating && + !showSpecChat && ( + <> + setAssistantOpen(!assistantOpen)} + isOpen={assistantOpen} + /> + setAssistantOpen(false)} + /> + + )} {/* Settings Modal */} - setShowSettings(false)} /> + setShowSettings(false)} + /> {/* Keyboard Shortcuts Help */} - setShowKeyboardHelp(false)} /> + setShowKeyboardHelp(false)} + /> {/* Celebration Overlay - shows when a feature is completed by an agent */} {wsState.celebration && ( @@ -517,7 +596,7 @@ function App() { /> )} - ) + ); } -export default App +export default App; diff --git a/ui/src/components/ActivityFeed.tsx b/ui/src/components/ActivityFeed.tsx index 23c64e92..ce40e80d 100644 --- a/ui/src/components/ActivityFeed.tsx +++ b/ui/src/components/ActivityFeed.tsx @@ -1,38 +1,42 @@ -import { Activity } from 'lucide-react' -import { AgentAvatar } from './AgentAvatar' -import type { AgentMascot } from '../lib/types' -import { Card, CardContent } from '@/components/ui/card' +import { Activity } from "lucide-react"; +import { AgentAvatar } from "./AgentAvatar"; +import type { AgentMascot } from "../lib/types"; +import { Card, CardContent } from "@/components/ui/card"; interface ActivityItem { - agentName: string - thought: string - timestamp: string - featureId: number + agentName: string; + thought: string; + timestamp: string; + featureId: number; } interface ActivityFeedProps { - activities: ActivityItem[] - maxItems?: number - showHeader?: boolean + activities: ActivityItem[]; + maxItems?: number; + showHeader?: boolean; } function formatTimestamp(timestamp: string): string { - const date = new Date(timestamp) - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffSec = Math.floor(diffMs / 1000) + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); - if (diffSec < 5) return 'just now' - if (diffSec < 60) return `${diffSec}s ago` - if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago` - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + if (diffSec < 5) return "just now"; + if (diffSec < 60) return `${diffSec}s ago`; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } -export function ActivityFeed({ activities, maxItems = 5, showHeader = true }: ActivityFeedProps) { - const displayedActivities = activities.slice(0, maxItems) +export function ActivityFeed({ + activities, + maxItems = 5, + showHeader = true, +}: ActivityFeedProps) { + const displayedActivities = activities.slice(0, maxItems); if (displayedActivities.length === 0) { - return null + return null; } return ( @@ -60,9 +64,12 @@ export function ActivityFeed({ activities, maxItems = 5, showHeader = true }: Ac />
- + {activity.agentName} @@ -72,7 +79,10 @@ export function ActivityFeed({ activities, maxItems = 5, showHeader = true }: Ac {formatTimestamp(activity.timestamp)}
-

+

{activity.thought}

@@ -81,35 +91,35 @@ export function ActivityFeed({ activities, maxItems = 5, showHeader = true }: Ac ))} - ) + ); } function getMascotColor(name: AgentMascot): string { const colors: Record = { // Original 5 - Spark: '#3B82F6', - Fizz: '#F97316', - Octo: '#8B5CF6', - Hoot: '#22C55E', - Buzz: '#EAB308', + Spark: "#3B82F6", + Fizz: "#F97316", + Octo: "#8B5CF6", + Hoot: "#22C55E", + Buzz: "#EAB308", // Tech-inspired - Pixel: '#EC4899', - Byte: '#06B6D4', - Nova: '#F43F5E', - Chip: '#84CC16', - Bolt: '#FBBF24', + Pixel: "#EC4899", + Byte: "#06B6D4", + Nova: "#F43F5E", + Chip: "#84CC16", + Bolt: "#FBBF24", // Energetic - Dash: '#14B8A6', - Zap: '#A855F7', - Gizmo: '#64748B', - Turbo: '#EF4444', - Blip: '#10B981', + Dash: "#14B8A6", + Zap: "#A855F7", + Gizmo: "#64748B", + Turbo: "#EF4444", + Blip: "#10B981", // Playful - Neon: '#D946EF', - Widget: '#6366F1', - Zippy: '#F59E0B', - Quirk: '#0EA5E9', - Flux: '#7C3AED', - } - return colors[name] || '#6B7280' + Neon: "#D946EF", + Widget: "#6366F1", + Zippy: "#F59E0B", + Quirk: "#0EA5E9", + Flux: "#7C3AED", + }; + return colors[name] || "#6B7280"; } diff --git a/ui/src/components/AddFeatureForm.tsx b/ui/src/components/AddFeatureForm.tsx index 529b8011..ee808c03 100644 --- a/ui/src/components/AddFeatureForm.tsx +++ b/ui/src/components/AddFeatureForm.tsx @@ -1,64 +1,64 @@ -import { useState, useId } from 'react' -import { X, Plus, Trash2, Loader2, AlertCircle } from 'lucide-react' -import { useCreateFeature } from '../hooks/useProjects' +import { useState, useId } from "react"; +import { X, Plus, Trash2, Loader2, AlertCircle } from "lucide-react"; +import { useCreateFeature } from "../hooks/useProjects"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Textarea } from '@/components/ui/textarea' -import { Label } from '@/components/ui/label' -import { Alert, AlertDescription } from '@/components/ui/alert' +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; interface Step { - id: string - value: string + id: string; + value: string; } interface AddFeatureFormProps { - projectName: string - onClose: () => void + projectName: string; + onClose: () => void; } export function AddFeatureForm({ projectName, onClose }: AddFeatureFormProps) { - const formId = useId() - const [category, setCategory] = useState('') - const [name, setName] = useState('') - const [description, setDescription] = useState('') - const [priority, setPriority] = useState('') - const [steps, setSteps] = useState([{ id: `${formId}-step-0`, value: '' }]) - const [error, setError] = useState(null) - const [stepCounter, setStepCounter] = useState(1) - - const createFeature = useCreateFeature(projectName) + const formId = useId(); + const [category, setCategory] = useState(""); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [priority, setPriority] = useState(""); + const [steps, setSteps] = useState([ + { id: `${formId}-step-0`, value: "" }, + ]); + const [error, setError] = useState(null); + const [stepCounter, setStepCounter] = useState(1); + + const createFeature = useCreateFeature(projectName); const handleAddStep = () => { - setSteps([...steps, { id: `${formId}-step-${stepCounter}`, value: '' }]) - setStepCounter(stepCounter + 1) - } + setSteps([...steps, { id: `${formId}-step-${stepCounter}`, value: "" }]); + setStepCounter(stepCounter + 1); + }; const handleRemoveStep = (id: string) => { - setSteps(steps.filter(step => step.id !== id)) - } + setSteps(steps.filter((step) => step.id !== id)); + }; const handleStepChange = (id: string, value: string) => { - setSteps(steps.map(step => - step.id === id ? { ...step, value } : step - )) - } + setSteps(steps.map((step) => (step.id === id ? { ...step, value } : step))); + }; const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError(null) + e.preventDefault(); + setError(null); // Filter out empty steps const filteredSteps = steps - .map(s => s.value.trim()) - .filter(s => s.length > 0) + .map((s) => s.value.trim()) + .filter((s) => s.length > 0); try { await createFeature.mutateAsync({ @@ -67,14 +67,14 @@ export function AddFeatureForm({ projectName, onClose }: AddFeatureFormProps) { description: description.trim(), steps: filteredSteps, priority: priority ? parseInt(priority, 10) : undefined, - }) - onClose() + }); + onClose(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create feature') + setError(err instanceof Error ? err.message : "Failed to create feature"); } - } + }; - const isValid = category.trim() && name.trim() && description.trim() + const isValid = category.trim() && name.trim() && description.trim(); return ( !open && onClose()}> @@ -196,11 +196,7 @@ export function AddFeatureForm({ projectName, onClose }: AddFeatureFormProps) { {/* Actions */} - - ) + ); } diff --git a/ui/src/components/AgentAvatar.tsx b/ui/src/components/AgentAvatar.tsx index edb36d6e..21e1fd89 100644 --- a/ui/src/components/AgentAvatar.tsx +++ b/ui/src/components/AgentAvatar.tsx @@ -1,58 +1,84 @@ -import { type AgentMascot, type AgentState } from '../lib/types' +import { type AgentMascot, type AgentState } from "../lib/types"; interface AgentAvatarProps { - name: AgentMascot | 'Unknown' - state: AgentState - size?: 'sm' | 'md' | 'lg' - showName?: boolean + name: AgentMascot | "Unknown"; + state: AgentState; + size?: "sm" | "md" | "lg"; + showName?: boolean; } // Fallback colors for unknown agents (neutral gray) -const UNKNOWN_COLORS = { primary: '#6B7280', secondary: '#9CA3AF', accent: '#F3F4F6' } +const UNKNOWN_COLORS = { + primary: "#6B7280", + secondary: "#9CA3AF", + accent: "#F3F4F6", +}; -const AVATAR_COLORS: Record = { +const AVATAR_COLORS: Record< + AgentMascot, + { primary: string; secondary: string; accent: string } +> = { // Original 5 - Spark: { primary: '#3B82F6', secondary: '#60A5FA', accent: '#DBEAFE' }, // Blue robot - Fizz: { primary: '#F97316', secondary: '#FB923C', accent: '#FFEDD5' }, // Orange fox - Octo: { primary: '#8B5CF6', secondary: '#A78BFA', accent: '#EDE9FE' }, // Purple octopus - Hoot: { primary: '#22C55E', secondary: '#4ADE80', accent: '#DCFCE7' }, // Green owl - Buzz: { primary: '#EAB308', secondary: '#FACC15', accent: '#FEF9C3' }, // Yellow bee + Spark: { primary: "#3B82F6", secondary: "#60A5FA", accent: "#DBEAFE" }, // Blue robot + Fizz: { primary: "#F97316", secondary: "#FB923C", accent: "#FFEDD5" }, // Orange fox + Octo: { primary: "#8B5CF6", secondary: "#A78BFA", accent: "#EDE9FE" }, // Purple octopus + Hoot: { primary: "#22C55E", secondary: "#4ADE80", accent: "#DCFCE7" }, // Green owl + Buzz: { primary: "#EAB308", secondary: "#FACC15", accent: "#FEF9C3" }, // Yellow bee // Tech-inspired - Pixel: { primary: '#EC4899', secondary: '#F472B6', accent: '#FCE7F3' }, // Pink - Byte: { primary: '#06B6D4', secondary: '#22D3EE', accent: '#CFFAFE' }, // Cyan - Nova: { primary: '#F43F5E', secondary: '#FB7185', accent: '#FFE4E6' }, // Rose - Chip: { primary: '#84CC16', secondary: '#A3E635', accent: '#ECFCCB' }, // Lime - Bolt: { primary: '#FBBF24', secondary: '#FCD34D', accent: '#FEF3C7' }, // Amber + Pixel: { primary: "#EC4899", secondary: "#F472B6", accent: "#FCE7F3" }, // Pink + Byte: { primary: "#06B6D4", secondary: "#22D3EE", accent: "#CFFAFE" }, // Cyan + Nova: { primary: "#F43F5E", secondary: "#FB7185", accent: "#FFE4E6" }, // Rose + Chip: { primary: "#84CC16", secondary: "#A3E635", accent: "#ECFCCB" }, // Lime + Bolt: { primary: "#FBBF24", secondary: "#FCD34D", accent: "#FEF3C7" }, // Amber // Energetic - Dash: { primary: '#14B8A6', secondary: '#2DD4BF', accent: '#CCFBF1' }, // Teal - Zap: { primary: '#A855F7', secondary: '#C084FC', accent: '#F3E8FF' }, // Violet - Gizmo: { primary: '#64748B', secondary: '#94A3B8', accent: '#F1F5F9' }, // Slate - Turbo: { primary: '#EF4444', secondary: '#F87171', accent: '#FEE2E2' }, // Red - Blip: { primary: '#10B981', secondary: '#34D399', accent: '#D1FAE5' }, // Emerald + Dash: { primary: "#14B8A6", secondary: "#2DD4BF", accent: "#CCFBF1" }, // Teal + Zap: { primary: "#A855F7", secondary: "#C084FC", accent: "#F3E8FF" }, // Violet + Gizmo: { primary: "#64748B", secondary: "#94A3B8", accent: "#F1F5F9" }, // Slate + Turbo: { primary: "#EF4444", secondary: "#F87171", accent: "#FEE2E2" }, // Red + Blip: { primary: "#10B981", secondary: "#34D399", accent: "#D1FAE5" }, // Emerald // Playful - Neon: { primary: '#D946EF', secondary: '#E879F9', accent: '#FAE8FF' }, // Fuchsia - Widget: { primary: '#6366F1', secondary: '#818CF8', accent: '#E0E7FF' }, // Indigo - Zippy: { primary: '#F59E0B', secondary: '#FBBF24', accent: '#FEF3C7' }, // Orange-yellow - Quirk: { primary: '#0EA5E9', secondary: '#38BDF8', accent: '#E0F2FE' }, // Sky - Flux: { primary: '#7C3AED', secondary: '#8B5CF6', accent: '#EDE9FE' }, // Purple -} + Neon: { primary: "#D946EF", secondary: "#E879F9", accent: "#FAE8FF" }, // Fuchsia + Widget: { primary: "#6366F1", secondary: "#818CF8", accent: "#E0E7FF" }, // Indigo + Zippy: { primary: "#F59E0B", secondary: "#FBBF24", accent: "#FEF3C7" }, // Orange-yellow + Quirk: { primary: "#0EA5E9", secondary: "#38BDF8", accent: "#E0F2FE" }, // Sky + Flux: { primary: "#7C3AED", secondary: "#8B5CF6", accent: "#EDE9FE" }, // Purple +}; const SIZES = { - sm: { svg: 32, font: 'text-xs' }, - md: { svg: 48, font: 'text-sm' }, - lg: { svg: 64, font: 'text-base' }, -} + sm: { svg: 32, font: "text-xs" }, + md: { svg: 48, font: "text-sm" }, + lg: { svg: 64, font: "text-base" }, +}; // SVG mascot definitions - simple cute characters -function SparkSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Spark; size: number }) { +function SparkSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Spark; + size: number; +}) { return ( {/* Robot body */} {/* Robot head */} - + {/* Antenna */} - + {/* Eyes */} @@ -65,10 +91,16 @@ function SparkSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Spark; size: - ) + ); } -function FizzSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Fizz; size: number }) { +function FizzSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Fizz; + size: number; +}) { return ( {/* Ears */} @@ -88,15 +120,49 @@ function FizzSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Fizz; size: nu {/* Nose */} {/* Whiskers */} - - - - + + + + - ) + ); } -function OctoSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Octo; size: number }) { +function OctoSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Octo; + size: number; +}) { return ( {/* Tentacles */} @@ -113,12 +179,24 @@ function OctoSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Octo; size: nu {/* Smile */} - + - ) + ); } -function HootSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Hoot; size: number }) { +function HootSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Hoot; + size: number; +}) { return ( {/* Ear tufts */} @@ -141,15 +219,37 @@ function HootSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Hoot; size: nu {/* Belly */} - ) + ); } -function BuzzSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Buzz; size: number }) { +function BuzzSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Buzz; + size: number; +}) { return ( {/* Wings */} - - + + {/* Body stripes */} @@ -167,13 +267,25 @@ function BuzzSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Buzz; size: nu {/* Smile */} - + - ) + ); } // Pixel - cute pixel art style character -function PixelSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Pixel; size: number }) { +function PixelSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Pixel; + size: number; +}) { return ( {/* Blocky body */} @@ -190,46 +302,86 @@ function PixelSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Pixel; size: {/* Mouth */} - ) + ); } // Byte - data cube character -function ByteSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Byte; size: number }) { +function ByteSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Byte; + size: number; +}) { return ( {/* 3D cube body */} - + - + {/* Face */} - + - ) + ); } // Nova - star character -function NovaSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Nova; size: number }) { +function NovaSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Nova; + size: number; +}) { return ( {/* Star points */} - + {/* Face */} - + - ) + ); } // Chip - circuit board character -function ChipSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Chip; size: number }) { +function ChipSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Chip; + size: number; +}) { return ( {/* Chip body */} @@ -248,32 +400,66 @@ function ChipSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Chip; size: nu - ) + ); } // Bolt - lightning character -function BoltSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Bolt; size: number }) { +function BoltSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Bolt; + size: number; +}) { return ( {/* Lightning bolt body */} - - + + {/* Face */} - ) + ); } // Dash - speedy character -function DashSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Dash; size: number }) { +function DashSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Dash; + size: number; +}) { return ( {/* Speed lines */} - - + + {/* Aerodynamic body */} @@ -282,18 +468,40 @@ function DashSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Dash; size: nu - + - ) + ); } // Zap - electric orb -function ZapSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Zap; size: number }) { +function ZapSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Zap; + size: number; +}) { return ( {/* Electric sparks */} - - + + {/* Orb */} @@ -302,13 +510,25 @@ function ZapSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Zap; size: numb - + - ) + ); } // Gizmo - gear character -function GizmoSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Gizmo; size: number }) { +function GizmoSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Gizmo; + size: number; +}) { return ( {/* Gear teeth */} @@ -324,17 +544,36 @@ function GizmoSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Gizmo; size: - + - ) + ); } // Turbo - rocket character -function TurboSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Turbo; size: number }) { +function TurboSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Turbo; + size: number; +}) { return ( {/* Flames */} - + {/* Rocket body */} @@ -347,18 +586,45 @@ function TurboSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Turbo; size: - + - ) + ); } // Blip - radar dot character -function BlipSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Blip; size: number }) { +function BlipSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Blip; + size: number; +}) { return ( {/* Radar rings */} - - + + {/* Main dot */} @@ -367,13 +633,25 @@ function BlipSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Blip; size: nu - + - ) + ); } // Neon - glowing character -function NeonSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Neon; size: number }) { +function NeonSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Neon; + size: number; +}) { return ( {/* Glow effect */} @@ -388,19 +666,38 @@ function NeonSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Neon; size: nu - + - ) + ); } // Widget - UI component character -function WidgetSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Widget; size: number }) { +function WidgetSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Widget; + size: number; +}) { return ( {/* Window frame */} {/* Title bar */} - + @@ -412,11 +709,17 @@ function WidgetSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Widget; size - ) + ); } // Zippy - fast bunny-like character -function ZippySVG({ colors, size }: { colors: typeof AVATAR_COLORS.Zippy; size: number }) { +function ZippySVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Zippy; + size: number; +}) { return ( {/* Ears */} @@ -433,18 +736,34 @@ function ZippySVG({ colors, size }: { colors: typeof AVATAR_COLORS.Zippy; size: {/* Nose and mouth */} - + - ) + ); } // Quirk - question mark character -function QuirkSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Quirk; size: number }) { +function QuirkSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Quirk; + size: number; +}) { return ( {/* Question mark body */} - + {/* Face on the dot */} @@ -454,39 +773,90 @@ function QuirkSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Quirk; size: {/* Decorative swirl */} - ) + ); } // Flux - flowing wave character -function FluxSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Flux; size: number }) { +function FluxSVG({ + colors, + size, +}: { + colors: typeof AVATAR_COLORS.Flux; + size: number; +}) { return ( {/* Wave body */} - - + + {/* Face */} {/* Sparkles */} - - + + - ) + ); } // Unknown agent fallback - simple question mark icon -function UnknownSVG({ colors, size }: { colors: typeof UNKNOWN_COLORS; size: number }) { +function UnknownSVG({ + colors, + size, +}: { + colors: typeof UNKNOWN_COLORS; + size: number; +}) { return ( - + {/* Circle background */} {/* Question mark */} - ? + + ? + - ) + ); } const MASCOT_SVGS: Record = { @@ -514,76 +884,81 @@ const MASCOT_SVGS: Record = { Zippy: ZippySVG, Quirk: QuirkSVG, Flux: FluxSVG, -} +}; // Animation classes based on state function getStateAnimation(state: AgentState): string { switch (state) { - case 'idle': - return 'animate-bounce-gentle' - case 'thinking': - return 'animate-thinking' - case 'working': - return 'animate-working' - case 'testing': - return 'animate-testing' - case 'success': - return 'animate-celebrate' - case 'error': - case 'struggling': - return 'animate-shake-gentle' + case "idle": + return "animate-bounce-gentle"; + case "thinking": + return "animate-thinking"; + case "working": + return "animate-working"; + case "testing": + return "animate-testing"; + case "success": + return "animate-celebrate"; + case "error": + case "struggling": + return "animate-shake-gentle"; default: - return '' + return ""; } } // Glow effect based on state function getStateGlow(state: AgentState): string { switch (state) { - case 'working': - return 'shadow-[0_0_12px_rgba(0,180,216,0.5)]' - case 'thinking': - return 'shadow-[0_0_8px_rgba(255,214,10,0.4)]' - case 'success': - return 'shadow-[0_0_16px_rgba(112,224,0,0.6)]' - case 'error': - case 'struggling': - return 'shadow-[0_0_12px_rgba(255,84,0,0.5)]' + case "working": + return "shadow-[0_0_12px_rgba(0,180,216,0.5)]"; + case "thinking": + return "shadow-[0_0_8px_rgba(255,214,10,0.4)]"; + case "success": + return "shadow-[0_0_16px_rgba(112,224,0,0.6)]"; + case "error": + case "struggling": + return "shadow-[0_0_12px_rgba(255,84,0,0.5)]"; default: - return '' + return ""; } } // Get human-readable state description for accessibility function getStateDescription(state: AgentState): string { switch (state) { - case 'idle': - return 'waiting' - case 'thinking': - return 'analyzing' - case 'working': - return 'coding' - case 'testing': - return 'running tests' - case 'success': - return 'completed successfully' - case 'error': - return 'encountered an error' - case 'struggling': - return 'having difficulty' + case "idle": + return "waiting"; + case "thinking": + return "analyzing"; + case "working": + return "coding"; + case "testing": + return "running tests"; + case "success": + return "completed successfully"; + case "error": + return "encountered an error"; + case "struggling": + return "having difficulty"; default: - return state + return state; } } -export function AgentAvatar({ name, state, size = 'md', showName = false }: AgentAvatarProps) { +export function AgentAvatar({ + name, + state, + size = "md", + showName = false, +}: AgentAvatarProps) { // Handle 'Unknown' agents (synthetic completions from untracked agents) - const isUnknown = name === 'Unknown' - const colors = isUnknown ? UNKNOWN_COLORS : AVATAR_COLORS[name] - const { svg: svgSize, font } = SIZES[size] - const SvgComponent = isUnknown ? UnknownSVG : MASCOT_SVGS[name] - const stateDesc = getStateDescription(state) - const ariaLabel = `Agent ${name} is ${stateDesc}` + const isUnknown = name === "Unknown"; + const colors = isUnknown ? UNKNOWN_COLORS : AVATAR_COLORS[name]; + const { svg: svgSize, font } = SIZES[size]; + const SvgComponent = isUnknown ? UnknownSVG : MASCOT_SVGS[name]; + const stateDesc = getStateDescription(state); + const ariaLabel = `Agent ${name} is ${stateDesc}`; return (
{showName && ( - + {name} )} - ) + ); } diff --git a/ui/src/components/AgentCard.tsx b/ui/src/components/AgentCard.tsx index 9fdff649..263d2fe0 100644 --- a/ui/src/components/AgentCard.tsx +++ b/ui/src/components/AgentCard.tsx @@ -1,86 +1,105 @@ -import { MessageCircle, ScrollText, X, Copy, Check, Code, FlaskConical } from 'lucide-react' -import { useState } from 'react' -import { createPortal } from 'react-dom' -import { AgentAvatar } from './AgentAvatar' -import type { ActiveAgent, AgentLogEntry, AgentType } from '../lib/types' -import { Card, CardContent } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' +import { + MessageCircle, + ScrollText, + X, + Copy, + Check, + Code, + FlaskConical, +} from "lucide-react"; +import { useState } from "react"; +import { createPortal } from "react-dom"; +import { AgentAvatar } from "./AgentAvatar"; +import type { ActiveAgent, AgentLogEntry, AgentType } from "../lib/types"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; interface AgentCardProps { - agent: ActiveAgent - onShowLogs?: (agentIndex: number) => void + agent: ActiveAgent; + onShowLogs?: (agentIndex: number) => void; } // Get a friendly state description -function getStateText(state: ActiveAgent['state']): string { +function getStateText(state: ActiveAgent["state"]): string { switch (state) { - case 'idle': - return 'Standing by...' - case 'thinking': - return 'Pondering...' - case 'working': - return 'Coding away...' - case 'testing': - return 'Checking work...' - case 'success': - return 'Nailed it!' - case 'error': - return 'Trying plan B...' - case 'struggling': - return 'Being persistent...' + case "idle": + return "Standing by..."; + case "thinking": + return "Pondering..."; + case "working": + return "Coding away..."; + case "testing": + return "Checking work..."; + case "success": + return "Nailed it!"; + case "error": + return "Trying plan B..."; + case "struggling": + return "Being persistent..."; default: - return 'Busy...' + return "Busy..."; } } // Get state color class -function getStateColor(state: ActiveAgent['state']): string { +function getStateColor(state: ActiveAgent["state"]): string { switch (state) { - case 'success': - return 'text-primary' - case 'error': - return 'text-yellow-600' - case 'struggling': - return 'text-orange-500' - case 'working': - case 'testing': - return 'text-primary' - case 'thinking': - return 'text-yellow-600' + case "success": + return "text-primary"; + case "error": + return "text-yellow-600"; + case "struggling": + return "text-orange-500"; + case "working": + case "testing": + return "text-primary"; + case "thinking": + return "text-yellow-600"; default: - return 'text-muted-foreground' + return "text-muted-foreground"; } } // Get agent type badge config -function getAgentTypeBadge(agentType: AgentType): { label: string; className: string; icon: typeof Code } { - if (agentType === 'testing') { +function getAgentTypeBadge(agentType: AgentType): { + label: string; + className: string; + icon: typeof Code; +} { + if (agentType === "testing") { return { - label: 'TEST', - className: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', + label: "TEST", + className: + "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300", icon: FlaskConical, - } + }; } return { - label: 'CODE', - className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', + label: "CODE", + className: + "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300", icon: Code, - } + }; } export function AgentCard({ agent, onShowLogs }: AgentCardProps) { - const isActive = ['thinking', 'working', 'testing'].includes(agent.state) - const hasLogs = agent.logs && agent.logs.length > 0 - const typeBadge = getAgentTypeBadge(agent.agentType || 'coding') - const TypeIcon = typeBadge.icon + const isActive = ["thinking", "working", "testing"].includes(agent.state); + const hasLogs = agent.logs && agent.logs.length > 0; + const typeBadge = getAgentTypeBadge(agent.agentType || "coding"); + const TypeIcon = typeBadge.icon; return ( - + {/* Agent type badge */}
- + {typeBadge.label} @@ -115,7 +134,10 @@ export function AgentCard({ agent, onShowLogs }: AgentCardProps) {
Feature #{agent.featureId}
-
+
{agent.featureName}
@@ -124,7 +146,10 @@ export function AgentCard({ agent, onShowLogs }: AgentCardProps) { {agent.thought && (
- +

- ) + ); } // Log viewer modal component interface AgentLogModalProps { - agent: ActiveAgent - logs: AgentLogEntry[] - onClose: () => void + agent: ActiveAgent; + logs: AgentLogEntry[]; + onClose: () => void; } export function AgentLogModal({ agent, logs, onClose }: AgentLogModalProps) { - const [copied, setCopied] = useState(false) - const typeBadge = getAgentTypeBadge(agent.agentType || 'coding') - const TypeIcon = typeBadge.icon + const [copied, setCopied] = useState(false); + const typeBadge = getAgentTypeBadge(agent.agentType || "coding"); + const TypeIcon = typeBadge.icon; const handleCopy = async () => { const logText = logs - .map(log => `[${log.timestamp}] ${log.line}`) - .join('\n') - await navigator.clipboard.writeText(logText) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } + .map((log) => `[${log.timestamp}] ${log.line}`) + .join("\n"); + await navigator.clipboard.writeText(logText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; - const getLogColor = (type: AgentLogEntry['type']) => { + const getLogColor = (type: AgentLogEntry["type"]) => { switch (type) { - case 'error': - return 'text-destructive' - case 'state_change': - return 'text-primary' + case "error": + return "text-destructive"; + case "state_change": + return "text-primary"; default: - return 'text-foreground' + return "text-foreground"; } - } + }; return createPortal(

{ - if (e.target === e.currentTarget) onClose() + if (e.target === e.currentTarget) onClose(); }} > @@ -189,7 +214,10 @@ export function AgentLogModal({ agent, logs, onClose }: AgentLogModalProps) {

{agent.agentName} Logs

- + {typeBadge.label} @@ -202,7 +230,7 @@ export function AgentLogModal({ agent, logs, onClose }: AgentLogModalProps) {
, - document.body - ) + document.body, + ); } diff --git a/ui/src/components/AgentControl.tsx b/ui/src/components/AgentControl.tsx index c08a1bdf..fae778d1 100644 --- a/ui/src/components/AgentControl.tsx +++ b/ui/src/components/AgentControl.tsx @@ -1,49 +1,46 @@ -import { useState } from 'react' -import { Play, Square, Loader2, GitBranch, Clock } from 'lucide-react' -import { - useStartAgent, - useStopAgent, - useSettings, -} from '../hooks/useProjects' -import { useNextScheduledRun } from '../hooks/useSchedules' -import { formatNextRun, formatEndTime } from '../lib/timeUtils' -import { ScheduleModal } from './ScheduleModal' -import type { AgentStatus } from '../lib/types' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' +import { useState } from "react"; +import { Play, Square, Loader2, GitBranch, Clock } from "lucide-react"; +import { useStartAgent, useStopAgent, useSettings } from "../hooks/useProjects"; +import { useNextScheduledRun } from "../hooks/useSchedules"; +import { formatNextRun, formatEndTime } from "../lib/timeUtils"; +import { ScheduleModal } from "./ScheduleModal"; +import type { AgentStatus } from "../lib/types"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; interface AgentControlProps { - projectName: string - status: AgentStatus + projectName: string; + status: AgentStatus; } export function AgentControl({ projectName, status }: AgentControlProps) { - const { data: settings } = useSettings() - const yoloMode = settings?.yolo_mode ?? false + const { data: settings } = useSettings(); + const yoloMode = settings?.yolo_mode ?? false; // Concurrency: 1 = single agent, 2-5 = parallel - const [concurrency, setConcurrency] = useState(3) + const [concurrency, setConcurrency] = useState(3); - const startAgent = useStartAgent(projectName) - const stopAgent = useStopAgent(projectName) - const { data: nextRun } = useNextScheduledRun(projectName) + const startAgent = useStartAgent(projectName); + const stopAgent = useStopAgent(projectName); + const { data: nextRun } = useNextScheduledRun(projectName); - const [showScheduleModal, setShowScheduleModal] = useState(false) + const [showScheduleModal, setShowScheduleModal] = useState(false); - const isLoading = startAgent.isPending || stopAgent.isPending - const isRunning = status === 'running' || status === 'paused' - const isLoadingStatus = status === 'loading' - const isParallel = concurrency > 1 + const isLoading = startAgent.isPending || stopAgent.isPending; + const isRunning = status === "running" || status === "paused"; + const isLoadingStatus = status === "loading"; + const isParallel = concurrency > 1; - const handleStart = () => startAgent.mutate({ - yoloMode, - parallelMode: isParallel, - maxConcurrency: concurrency, - testingAgentRatio: settings?.testing_agent_ratio, - }) - const handleStop = () => stopAgent.mutate() + const handleStart = () => + startAgent.mutate({ + yoloMode, + parallelMode: isParallel, + maxConcurrency: concurrency, + testingAgentRatio: settings?.testing_agent_ratio, + }); + const handleStop = () => stopAgent.mutate(); - const isStopped = status === 'stopped' || status === 'crashed' + const isStopped = status === "stopped" || status === "crashed"; return ( <> @@ -51,7 +48,10 @@ export function AgentControl({ projectName, status }: AgentControlProps) { {/* Concurrency slider - visible when stopped */} {isStopped && (
- + setConcurrency(Number(e.target.value))} disabled={isLoading} className="w-16 h-2 accent-primary cursor-pointer" - title={`${concurrency} concurrent agent${concurrency > 1 ? 's' : ''}`} + title={`${concurrency} concurrent agent${concurrency > 1 ? "s" : ""}`} aria-label="Set number of concurrent agents" /> @@ -101,9 +101,9 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
{isExpanded ? ( @@ -89,7 +93,7 @@ export function AgentMissionControl({
@@ -106,9 +110,11 @@ export function AgentMissionControl({ key={`agent-${agent.agentIndex}`} agent={agent} onShowLogs={(agentIndex) => { - const agentToShow = agents.find(a => a.agentIndex === agentIndex) + const agentToShow = agents.find( + (a) => a.agentIndex === agentIndex, + ); if (agentToShow) { - setSelectedAgentForLogs(agentToShow) + setSelectedAgentForLogs(agentToShow); } }} /> @@ -141,10 +147,14 @@ export function AgentMissionControl({
- +
)} @@ -160,5 +170,5 @@ export function AgentMissionControl({ /> )}
- ) + ); } diff --git a/ui/src/components/AgentThought.tsx b/ui/src/components/AgentThought.tsx index df249340..1cfbb566 100644 --- a/ui/src/components/AgentThought.tsx +++ b/ui/src/components/AgentThought.tsx @@ -1,119 +1,118 @@ -import { useMemo, useState, useEffect } from 'react' -import { Brain, Sparkles } from 'lucide-react' -import type { AgentStatus } from '../lib/types' -import { Card } from '@/components/ui/card' +import { useMemo, useState, useEffect } from "react"; +import { Brain, Sparkles } from "lucide-react"; +import type { AgentStatus } from "../lib/types"; +import { Card } from "@/components/ui/card"; interface AgentThoughtProps { - logs: Array<{ line: string; timestamp: string }> - agentStatus: AgentStatus + logs: Array<{ line: string; timestamp: string }>; + agentStatus: AgentStatus; } -const IDLE_TIMEOUT = 30000 // 30 seconds +const IDLE_TIMEOUT = 30000; // 30 seconds /** * Determines if a log line is an agent "thought" (narrative text) * vs. tool mechanics that should be hidden */ function isAgentThought(line: string): boolean { - const trimmed = line.trim() + const trimmed = line.trim(); // Skip tool mechanics - if (/^\[Tool:/.test(trimmed)) return false - if (/^\s*Input:\s*\{/.test(trimmed)) return false - if (/^\[(Done|Error)\]/.test(trimmed)) return false - if (/^\[Error\]/.test(trimmed)) return false - if (/^Output:/.test(trimmed)) return false + if (/^\[Tool:/.test(trimmed)) return false; + if (/^\s*Input:\s*\{/.test(trimmed)) return false; + if (/^\[(Done|Error)\]/.test(trimmed)) return false; + if (/^\[Error\]/.test(trimmed)) return false; + if (/^Output:/.test(trimmed)) return false; // Skip JSON and very short lines - if (/^[[{]/.test(trimmed)) return false - if (trimmed.length < 10) return false + if (/^[[{]/.test(trimmed)) return false; + if (trimmed.length < 10) return false; // Skip lines that are just paths or technical output - if (/^[A-Za-z]:\\/.test(trimmed)) return false - if (/^\/[a-z]/.test(trimmed)) return false + if (/^[A-Za-z]:\\/.test(trimmed)) return false; + if (/^\/[a-z]/.test(trimmed)) return false; // Keep narrative text (looks like a sentence, relaxed filter) - return trimmed.length > 10 + return trimmed.length > 10; } /** * Extracts the latest agent thought from logs */ -function getLatestThought(logs: Array<{ line: string; timestamp: string }>): string | null { +function getLatestThought( + logs: Array<{ line: string; timestamp: string }>, +): string | null { // Search from most recent for (let i = logs.length - 1; i >= 0; i--) { if (isAgentThought(logs[i].line)) { - return logs[i].line.trim() + return logs[i].line.trim(); } } - return null + return null; } export function AgentThought({ logs, agentStatus }: AgentThoughtProps) { - const thought = useMemo(() => getLatestThought(logs), [logs]) - const [displayedThought, setDisplayedThought] = useState(null) - const [textVisible, setTextVisible] = useState(true) - const [isVisible, setIsVisible] = useState(false) + const thought = useMemo(() => getLatestThought(logs), [logs]); + const [displayedThought, setDisplayedThought] = useState(null); + const [textVisible, setTextVisible] = useState(true); + const [isVisible, setIsVisible] = useState(false); // Get last log timestamp for idle detection - const lastLogTimestamp = logs.length > 0 - ? new Date(logs[logs.length - 1].timestamp).getTime() - : 0 + const lastLogTimestamp = + logs.length > 0 ? new Date(logs[logs.length - 1].timestamp).getTime() : 0; // Determine if component should be visible const shouldShow = useMemo(() => { - if (!thought) return false - if (agentStatus === 'running') return true - if (agentStatus === 'paused') { - return Date.now() - lastLogTimestamp < IDLE_TIMEOUT + if (!thought) return false; + if (agentStatus === "running") return true; + if (agentStatus === "paused") { + return Date.now() - lastLogTimestamp < IDLE_TIMEOUT; } - return false - }, [thought, agentStatus, lastLogTimestamp]) + return false; + }, [thought, agentStatus, lastLogTimestamp]); // Animate text changes using CSS transitions useEffect(() => { if (thought !== displayedThought && thought) { // Fade out - setTextVisible(false) + setTextVisible(false); // After fade out, update text and fade in const timeout = setTimeout(() => { - setDisplayedThought(thought) - setTextVisible(true) - }, 150) // Match transition duration - return () => clearTimeout(timeout) + setDisplayedThought(thought); + setTextVisible(true); + }, 150); // Match transition duration + return () => clearTimeout(timeout); } - }, [thought, displayedThought]) + }, [thought, displayedThought]); // Handle visibility transitions useEffect(() => { if (shouldShow) { - setIsVisible(true) + setIsVisible(true); } else { // Delay hiding to allow exit animation - const timeout = setTimeout(() => setIsVisible(false), 300) - return () => clearTimeout(timeout) + const timeout = setTimeout(() => setIsVisible(false), 300); + return () => clearTimeout(timeout); } - }, [shouldShow]) + }, [shouldShow]); - if (!isVisible || !displayedThought) return null + if (!isVisible || !displayedThought) return null; - const isRunning = agentStatus === 'running' + const isRunning = agentStatus === "running"; return (
- + {/* Brain Icon with subtle glow */}
- + {isRunning && ( - {displayedThought?.replace(/:$/, '')} + {displayedThought?.replace(/:$/, "")}

{/* Subtle running indicator bar */} {isRunning && (
-
+
)}
- ) + ); } diff --git a/ui/src/components/AssistantChat.tsx b/ui/src/components/AssistantChat.tsx index 8f438a11..9d193857 100644 --- a/ui/src/components/AssistantChat.tsx +++ b/ui/src/components/AssistantChat.tsx @@ -6,23 +6,23 @@ * Supports conversation history with resume functionality. */ -import { useState, useRef, useEffect, useCallback, useMemo } from 'react' -import { Send, Loader2, Wifi, WifiOff, Plus, History } from 'lucide-react' -import { useAssistantChat } from '../hooks/useAssistantChat' -import { ChatMessage as ChatMessageComponent } from './ChatMessage' -import { ConversationHistory } from './ConversationHistory' -import type { ChatMessage } from '../lib/types' -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { Send, Loader2, Wifi, WifiOff, Plus, History } from "lucide-react"; +import { useAssistantChat } from "../hooks/useAssistantChat"; +import { ChatMessage as ChatMessageComponent } from "./ChatMessage"; +import { ConversationHistory } from "./ConversationHistory"; +import type { ChatMessage } from "../lib/types"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; interface AssistantChatProps { - projectName: string - conversationId?: number | null - initialMessages?: ChatMessage[] - isLoadingConversation?: boolean - onNewChat?: () => void - onSelectConversation?: (id: number) => void - onConversationCreated?: (id: number) => void + projectName: string; + conversationId?: number | null; + initialMessages?: ChatMessage[]; + isLoadingConversation?: boolean; + onNewChat?: () => void; + onSelectConversation?: (id: number) => void; + onConversationCreated?: (id: number) => void; } export function AssistantChat({ @@ -34,17 +34,17 @@ export function AssistantChat({ onSelectConversation, onConversationCreated, }: AssistantChatProps) { - const [inputValue, setInputValue] = useState('') - const [showHistory, setShowHistory] = useState(false) - const messagesEndRef = useRef(null) - const inputRef = useRef(null) - const hasStartedRef = useRef(false) - const lastConversationIdRef = useRef(undefined) + const [inputValue, setInputValue] = useState(""); + const [showHistory, setShowHistory] = useState(false); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const hasStartedRef = useRef(false); + const lastConversationIdRef = useRef(undefined); // Memoize the error handler to prevent infinite re-renders const handleError = useCallback((error: string) => { - console.error('Assistant error:', error) - }, []) + console.error("Assistant error:", error); + }, []); const { messages, @@ -57,114 +57,125 @@ export function AssistantChat({ } = useAssistantChat({ projectName, onError: handleError, - }) + }); // Notify parent when a NEW conversation is created (not when switching to existing) // Track activeConversationId to fire callback only once when it transitions from null to a value - const previousActiveConversationIdRef = useRef(activeConversationId) + const previousActiveConversationIdRef = useRef( + activeConversationId, + ); useEffect(() => { - const hadNoConversation = previousActiveConversationIdRef.current === null - const nowHasConversation = activeConversationId !== null + const hadNoConversation = previousActiveConversationIdRef.current === null; + const nowHasConversation = activeConversationId !== null; if (hadNoConversation && nowHasConversation && onConversationCreated) { - onConversationCreated(activeConversationId) + onConversationCreated(activeConversationId); } - previousActiveConversationIdRef.current = activeConversationId - }, [activeConversationId, onConversationCreated]) + previousActiveConversationIdRef.current = activeConversationId; + }, [activeConversationId, onConversationCreated]); // Auto-scroll to bottom on new messages useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); // Start or resume the chat session when component mounts or conversationId changes useEffect(() => { // Skip if we're loading conversation details if (isLoadingConversation) { - return + return; } // Only start if conversationId has actually changed - if (lastConversationIdRef.current === conversationId && hasStartedRef.current) { - return + if ( + lastConversationIdRef.current === conversationId && + hasStartedRef.current + ) { + return; } // Check if we're switching to a different conversation (not initial mount) - const isSwitching = lastConversationIdRef.current !== undefined && - lastConversationIdRef.current !== conversationId + const isSwitching = + lastConversationIdRef.current !== undefined && + lastConversationIdRef.current !== conversationId; - lastConversationIdRef.current = conversationId - hasStartedRef.current = true + lastConversationIdRef.current = conversationId; + hasStartedRef.current = true; // Clear existing messages when switching conversations if (isSwitching) { - clearMessages() + clearMessages(); } // Start the session with the conversation ID (or null for new) - start(conversationId) - }, [conversationId, isLoadingConversation, start, clearMessages]) + start(conversationId); + }, [conversationId, isLoadingConversation, start, clearMessages]); // Handle starting a new chat const handleNewChat = useCallback(() => { - clearMessages() - onNewChat?.() - }, [clearMessages, onNewChat]) + clearMessages(); + onNewChat?.(); + }, [clearMessages, onNewChat]); // Handle selecting a conversation from history - const handleSelectConversation = useCallback((id: number) => { - setShowHistory(false) - onSelectConversation?.(id) - }, [onSelectConversation]) + const handleSelectConversation = useCallback( + (id: number) => { + setShowHistory(false); + onSelectConversation?.(id); + }, + [onSelectConversation], + ); // Focus input when not loading useEffect(() => { if (!isLoading) { - inputRef.current?.focus() + inputRef.current?.focus(); } - }, [isLoading]) + }, [isLoading]); const handleSend = () => { - const content = inputValue.trim() - if (!content || isLoading || isLoadingConversation) return + const content = inputValue.trim(); + if (!content || isLoading || isLoadingConversation) return; - sendMessage(content) - setInputValue('') - } + sendMessage(content); + setInputValue(""); + }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSend() + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); } - } + }; // Combine initial messages (from resumed conversation) with live messages // Merge both arrays with deduplication by message ID to prevent history loss const displayMessages = useMemo(() => { - const isConversationSynced = lastConversationIdRef.current === conversationId && !isLoadingConversation + const isConversationSynced = + lastConversationIdRef.current === conversationId && + !isLoadingConversation; // If not synced yet, show only initialMessages (or empty) if (!isConversationSynced) { - return initialMessages ?? [] + return initialMessages ?? []; } // If no initial messages, just show live messages if (!initialMessages || initialMessages.length === 0) { - return messages + return messages; } // Merge both arrays, deduplicating by ID (live messages take precedence) - const messageMap = new Map() + const messageMap = new Map(); for (const msg of initialMessages) { - messageMap.set(msg.id, msg) + messageMap.set(msg.id, msg); } for (const msg of messages) { - messageMap.set(msg.id, msg) + messageMap.set(msg.id, msg); } - return Array.from(messageMap.values()) - }, [initialMessages, messages, conversationId, isLoadingConversation]) + return Array.from(messageMap.values()); + }, [initialMessages, messages, conversationId, isLoadingConversation]); return (
@@ -183,7 +194,7 @@ export function AssistantChat({
- ) + ); } diff --git a/ui/src/components/AssistantFAB.tsx b/ui/src/components/AssistantFAB.tsx index 3caa2901..3733e3de 100644 --- a/ui/src/components/AssistantFAB.tsx +++ b/ui/src/components/AssistantFAB.tsx @@ -2,12 +2,12 @@ * Floating Action Button for toggling the Assistant panel */ -import { MessageCircle, X } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { MessageCircle, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; interface AssistantFABProps { - onClick: () => void - isOpen: boolean + onClick: () => void; + isOpen: boolean; } export function AssistantFAB({ onClick, isOpen }: AssistantFABProps) { @@ -16,10 +16,10 @@ export function AssistantFAB({ onClick, isOpen }: AssistantFABProps) { onClick={onClick} size="icon" className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full shadow-lg hover:shadow-xl transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0.5" - title={isOpen ? 'Close Assistant (Press A)' : 'Open Assistant (Press A)'} - aria-label={isOpen ? 'Close Assistant' : 'Open Assistant'} + title={isOpen ? "Close Assistant (Press A)" : "Open Assistant (Press A)"} + aria-label={isOpen ? "Close Assistant" : "Open Assistant"} > {isOpen ? : } - ) + ); } diff --git a/ui/src/components/AssistantPanel.tsx b/ui/src/components/AssistantPanel.tsx index cb61420c..ccaffffa 100644 --- a/ui/src/components/AssistantPanel.tsx +++ b/ui/src/components/AssistantPanel.tsx @@ -6,87 +6,93 @@ * Manages conversation state with localStorage persistence. */ -import { useState, useEffect, useCallback } from 'react' -import { X, Bot } from 'lucide-react' -import { AssistantChat } from './AssistantChat' -import { useConversation } from '../hooks/useConversations' -import type { ChatMessage } from '../lib/types' -import { Button } from '@/components/ui/button' +import { useState, useEffect, useCallback } from "react"; +import { X, Bot } from "lucide-react"; +import { AssistantChat } from "./AssistantChat"; +import { useConversation } from "../hooks/useConversations"; +import type { ChatMessage } from "../lib/types"; +import { Button } from "@/components/ui/button"; interface AssistantPanelProps { - projectName: string - isOpen: boolean - onClose: () => void + projectName: string; + isOpen: boolean; + onClose: () => void; } -const STORAGE_KEY_PREFIX = 'assistant-conversation-' +const STORAGE_KEY_PREFIX = "assistant-conversation-"; function getStoredConversationId(projectName: string): number | null { try { - const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${projectName}`) + const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${projectName}`); if (stored) { - const data = JSON.parse(stored) - return data.conversationId || null + const data = JSON.parse(stored); + return data.conversationId || null; } } catch { // Invalid stored data, ignore } - return null + return null; } -function setStoredConversationId(projectName: string, conversationId: number | null) { - const key = `${STORAGE_KEY_PREFIX}${projectName}` +function setStoredConversationId( + projectName: string, + conversationId: number | null, +) { + const key = `${STORAGE_KEY_PREFIX}${projectName}`; if (conversationId) { - localStorage.setItem(key, JSON.stringify({ conversationId })) + localStorage.setItem(key, JSON.stringify({ conversationId })); } else { - localStorage.removeItem(key) + localStorage.removeItem(key); } } -export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelProps) { +export function AssistantPanel({ + projectName, + isOpen, + onClose, +}: AssistantPanelProps) { // Load initial conversation ID from localStorage const [conversationId, setConversationId] = useState(() => - getStoredConversationId(projectName) - ) + getStoredConversationId(projectName), + ); // Fetch conversation details when we have an ID - const { data: conversationDetail, isLoading: isLoadingConversation } = useConversation( - projectName, - conversationId - ) + const { data: conversationDetail, isLoading: isLoadingConversation } = + useConversation(projectName, conversationId); // Convert API messages to ChatMessage format for the chat component - const initialMessages: ChatMessage[] | undefined = conversationDetail?.messages.map((msg) => ({ - id: `db-${msg.id}`, - role: msg.role, - content: msg.content, - timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(), - })) + const initialMessages: ChatMessage[] | undefined = + conversationDetail?.messages.map((msg) => ({ + id: `db-${msg.id}`, + role: msg.role, + content: msg.content, + timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(), + })); // Persist conversation ID changes to localStorage useEffect(() => { - setStoredConversationId(projectName, conversationId) - }, [projectName, conversationId]) + setStoredConversationId(projectName, conversationId); + }, [projectName, conversationId]); // Reset conversation ID when project changes useEffect(() => { - setConversationId(getStoredConversationId(projectName)) - }, [projectName]) + setConversationId(getStoredConversationId(projectName)); + }, [projectName]); // Handle starting a new chat const handleNewChat = useCallback(() => { - setConversationId(null) - }, []) + setConversationId(null); + }, []); // Handle selecting a conversation from history const handleSelectConversation = useCallback((id: number) => { - setConversationId(id) - }, []) + setConversationId(id); + }, []); // Handle when a new conversation is created (from WebSocket) const handleConversationCreated = useCallback((id: number) => { - setConversationId(id) - }, []) + setConversationId(id); + }, []); return ( <> @@ -108,7 +114,7 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP border-l border-border transform transition-transform duration-300 ease-out flex flex-col shadow-xl - ${isOpen ? 'translate-x-0' : 'translate-x-full'} + ${isOpen ? "translate-x-0" : "translate-x-full"} `} role="dialog" aria-label="Project Assistant" @@ -153,5 +159,5 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP
- ) + ); } diff --git a/ui/src/components/CelebrationOverlay.tsx b/ui/src/components/CelebrationOverlay.tsx index 8758e699..dbd36592 100644 --- a/ui/src/components/CelebrationOverlay.tsx +++ b/ui/src/components/CelebrationOverlay.tsx @@ -1,13 +1,13 @@ -import { useCallback, useEffect, useState } from 'react' -import { Sparkles, PartyPopper } from 'lucide-react' -import { AgentAvatar } from './AgentAvatar' -import type { AgentMascot } from '../lib/types' -import { Card, CardContent } from '@/components/ui/card' +import { useCallback, useEffect, useState } from "react"; +import { Sparkles, PartyPopper } from "lucide-react"; +import { AgentAvatar } from "./AgentAvatar"; +import type { AgentMascot } from "../lib/types"; +import { Card, CardContent } from "@/components/ui/card"; interface CelebrationOverlayProps { - agentName: AgentMascot | 'Unknown' - featureName: string - onComplete?: () => void + agentName: AgentMascot | "Unknown"; + featureName: string; + onComplete?: () => void; } // Generate random confetti particles @@ -17,40 +17,46 @@ function generateConfetti(count: number) { x: Math.random() * 100, delay: Math.random() * 0.5, duration: 1 + Math.random() * 1, - color: ['#ff006e', '#ffd60a', '#70e000', '#00b4d8', '#8338ec'][Math.floor(Math.random() * 5)], + color: ["#ff006e", "#ffd60a", "#70e000", "#00b4d8", "#8338ec"][ + Math.floor(Math.random() * 5) + ], rotation: Math.random() * 360, - })) + })); } -export function CelebrationOverlay({ agentName, featureName, onComplete }: CelebrationOverlayProps) { - const [isVisible, setIsVisible] = useState(true) - const [confetti] = useState(() => generateConfetti(30)) +export function CelebrationOverlay({ + agentName, + featureName, + onComplete, +}: CelebrationOverlayProps) { + const [isVisible, setIsVisible] = useState(true); + const [confetti] = useState(() => generateConfetti(30)); const dismiss = useCallback(() => { - setIsVisible(false) - setTimeout(() => onComplete?.(), 300) // Wait for fade animation - }, [onComplete]) + setIsVisible(false); + setTimeout(() => onComplete?.(), 300); // Wait for fade animation + }, [onComplete]); useEffect(() => { // Auto-dismiss after 3 seconds - const timer = setTimeout(dismiss, 3000) + const timer = setTimeout(dismiss, 3000); // Escape key to dismiss early const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - dismiss() + if (e.key === "Escape") { + dismiss(); } - } + }; - window.addEventListener('keydown', handleKeyDown) + window.addEventListener("keydown", handleKeyDown); return () => { - clearTimeout(timer) - window.removeEventListener('keydown', handleKeyDown) - } - }, [dismiss]) + clearTimeout(timer); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [dismiss]); if (!isVisible) { - return null + return null; } return ( @@ -59,7 +65,7 @@ export function CelebrationOverlay({ agentName, featureName, onComplete }: Celeb fixed inset-0 z-50 flex items-center justify-center pointer-events-none transition-opacity duration-300 - ${isVisible ? 'opacity-100' : 'opacity-0'} + ${isVisible ? "opacity-100" : "opacity-0"} `} > {/* Confetti particles */} @@ -70,7 +76,7 @@ export function CelebrationOverlay({ agentName, featureName, onComplete }: Celeb className="absolute w-3 h-3 animate-confetti" style={{ left: `${particle.x}%`, - top: '-20px', + top: "-20px", backgroundColor: particle.color, animationDelay: `${particle.delay}s`, animationDuration: `${particle.duration}s`, @@ -118,5 +124,5 @@ export function CelebrationOverlay({ agentName, featureName, onComplete }: Celeb
- ) + ); } diff --git a/ui/src/components/ChatMessage.tsx b/ui/src/components/ChatMessage.tsx index 4ca9f108..8402eac1 100644 --- a/ui/src/components/ChatMessage.tsx +++ b/ui/src/components/ChatMessage.tsx @@ -5,73 +5,77 @@ * Supports user, assistant, and system messages with clean styling. */ -import { memo } from 'react' -import { Bot, User, Info } from 'lucide-react' -import type { ChatMessage as ChatMessageType } from '../lib/types' -import { Card } from '@/components/ui/card' +import { memo } from "react"; +import { Bot, User, Info } from "lucide-react"; +import type { ChatMessage as ChatMessageType } from "../lib/types"; +import { Card } from "@/components/ui/card"; interface ChatMessageProps { - message: ChatMessageType + message: ChatMessageType; } // Module-level regex to avoid recreating on each render -const BOLD_REGEX = /\*\*(.*?)\*\*/g +const BOLD_REGEX = /\*\*(.*?)\*\*/g; -export const ChatMessage = memo(function ChatMessage({ message }: ChatMessageProps) { - const { role, content, attachments, timestamp, isStreaming } = message +export const ChatMessage = memo(function ChatMessage({ + message, +}: ChatMessageProps) { + const { role, content, attachments, timestamp, isStreaming } = message; // Format timestamp const timeString = timestamp.toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - }) + hour: "2-digit", + minute: "2-digit", + }); // Role-specific styling const roleConfig = { user: { icon: User, - bgColor: 'bg-primary', - textColor: 'text-primary-foreground', - align: 'justify-end', - bubbleAlign: 'items-end', - iconBg: 'bg-primary', - iconColor: 'text-primary-foreground', + bgColor: "bg-primary", + textColor: "text-primary-foreground", + align: "justify-end", + bubbleAlign: "items-end", + iconBg: "bg-primary", + iconColor: "text-primary-foreground", }, assistant: { icon: Bot, - bgColor: 'bg-muted', - textColor: 'text-foreground', - align: 'justify-start', - bubbleAlign: 'items-start', - iconBg: 'bg-secondary', - iconColor: 'text-secondary-foreground', + bgColor: "bg-muted", + textColor: "text-foreground", + align: "justify-start", + bubbleAlign: "items-start", + iconBg: "bg-secondary", + iconColor: "text-secondary-foreground", }, system: { icon: Info, - bgColor: 'bg-green-100 dark:bg-green-900/30', - textColor: 'text-green-900 dark:text-green-100', - align: 'justify-center', - bubbleAlign: 'items-center', - iconBg: 'bg-green-500', - iconColor: 'text-white', + bgColor: "bg-green-100 dark:bg-green-900/30", + textColor: "text-green-900 dark:text-green-100", + align: "justify-center", + bubbleAlign: "items-center", + iconBg: "bg-green-500", + iconColor: "text-white", }, - } + }; - const config = roleConfig[role] - const Icon = config.icon + const config = roleConfig[role]; + const Icon = config.icon; // System messages are styled differently - if (role === 'system') { + if (role === "system") { return (
-
+
{content}
- ) + ); } return ( @@ -79,59 +83,71 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
{/* Message bubble */}
- {role === 'assistant' && ( + {role === "assistant" && (
)} - + {/* Parse content for basic markdown-like formatting */} {content && ( -
- {content.split('\n').map((line, i) => { +
+ {content.split("\n").map((line, i) => { // Bold text - use module-level regex, reset lastIndex for each line - BOLD_REGEX.lastIndex = 0 - const parts = [] - let lastIndex = 0 - let match + BOLD_REGEX.lastIndex = 0; + const parts = []; + let lastIndex = 0; + let match; while ((match = BOLD_REGEX.exec(line)) !== null) { if (match.index > lastIndex) { - parts.push(line.slice(lastIndex, match.index)) + parts.push(line.slice(lastIndex, match.index)); } parts.push( - + {match[1]} - - ) - lastIndex = match.index + match[0].length + , + ); + lastIndex = match.index + match[0].length; } if (lastIndex < line.length) { - parts.push(line.slice(lastIndex)) + parts.push(line.slice(lastIndex)); } return ( {parts.length > 0 ? parts : line} - {i < content.split('\n').length - 1 && '\n'} + {i < content.split("\n").length - 1 && "\n"} - ) + ); })}
)} {/* Display image attachments */} {attachments && attachments.length > 0 && ( -
+
{attachments.map((attachment) => ( -
+
{attachment.filename} window.open(attachment.previewUrl, '_blank')} + onClick={() => + window.open(attachment.previewUrl, "_blank") + } title={`${attachment.filename} (click to enlarge)`} /> @@ -148,7 +164,7 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro )} - {role === 'user' && ( + {role === "user" && (
@@ -161,5 +177,5 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
- ) -}) + ); +}); diff --git a/ui/src/components/CloneRepoModal.tsx b/ui/src/components/CloneRepoModal.tsx new file mode 100644 index 00000000..1e4c700d --- /dev/null +++ b/ui/src/components/CloneRepoModal.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { GitBranch, Loader2 } from "lucide-react"; +import { useCloneProjectRepository } from "../hooks/useProjects"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +interface CloneRepoModalProps { + isOpen: boolean; + onClose: () => void; + projectName: string | null; +} + +export function CloneRepoModal({ isOpen, onClose, projectName }: CloneRepoModalProps) { + const cloneRepo = useCloneProjectRepository(projectName); + const [repoUrl, setRepoUrl] = useState(""); + const [targetDir, setTargetDir] = useState(""); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + useEffect(() => { + if (!isOpen) { + setRepoUrl(""); + setTargetDir(""); + setError(null); + setSuccessMessage(null); + } + }, [isOpen]); + + if (!isOpen) { + return null; + } + + const handleClose = () => { + if (cloneRepo.isPending) { + return; + } + onClose(); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setSuccessMessage(null); + + if (!projectName) { + setError("Select a project first"); + return; + } + + const trimmedUrl = repoUrl.trim(); + if (!trimmedUrl) { + setError("Repository URL is required"); + return; + } + + try { + const result = await cloneRepo.mutateAsync({ + repoUrl: trimmedUrl, + targetDir: targetDir.trim() || undefined, + }); + setSuccessMessage(result.message); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to clone repository"; + setError(message); + } + }; + + return ( + { + if (!open) { + handleClose(); + } + }} + > + + + + + Clone Repository + + + Clone a git repository into the selected project + {projectName ? `: ${projectName}` : ""}. + + + +
+
+ + setRepoUrl(event.target.value)} + disabled={cloneRepo.isPending} + autoFocus + /> +
+ +
+ + setTargetDir(event.target.value)} + disabled={cloneRepo.isPending} + /> +

+ Leave blank to derive the folder name from the repository URL. +

+
+ + {error && ( + + {error} + + )} + + {successMessage && ( + + {successMessage} + + )} + + + + + +
+
+
+ ); +} diff --git a/ui/src/components/ConfirmDialog.tsx b/ui/src/components/ConfirmDialog.tsx index 6d04893c..d4116bd5 100644 --- a/ui/src/components/ConfirmDialog.tsx +++ b/ui/src/components/ConfirmDialog.tsx @@ -5,8 +5,8 @@ * Used to confirm destructive actions like deleting projects. */ -import type { ReactNode } from 'react' -import { AlertTriangle } from 'lucide-react' +import type { ReactNode } from "react"; +import { AlertTriangle } from "lucide-react"; import { Dialog, DialogContent, @@ -14,28 +14,28 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; interface ConfirmDialogProps { - isOpen: boolean - title: string - message: ReactNode - confirmLabel?: string - cancelLabel?: string - variant?: 'danger' | 'warning' - isLoading?: boolean - onConfirm: () => void - onCancel: () => void + isOpen: boolean; + title: string; + message: ReactNode; + confirmLabel?: string; + cancelLabel?: string; + variant?: "danger" | "warning"; + isLoading?: boolean; + onConfirm: () => void; + onCancel: () => void; } export function ConfirmDialog({ isOpen, title, message, - confirmLabel = 'Confirm', - cancelLabel = 'Cancel', - variant = 'danger', + confirmLabel = "Confirm", + cancelLabel = "Cancel", + variant = "danger", isLoading = false, onConfirm, onCancel, @@ -47,9 +47,9 @@ export function ConfirmDialog({
@@ -65,14 +65,14 @@ export function ConfirmDialog({ {cancelLabel} - ) + ); } diff --git a/ui/src/components/ConversationHistory.tsx b/ui/src/components/ConversationHistory.tsx index cbafe792..1bbb4d4b 100644 --- a/ui/src/components/ConversationHistory.tsx +++ b/ui/src/components/ConversationHistory.tsx @@ -5,43 +5,46 @@ * Allows selecting a conversation to resume or deleting old conversations. */ -import { useState, useEffect } from 'react' -import { MessageSquare, Trash2, Loader2, AlertCircle } from 'lucide-react' -import { useConversations, useDeleteConversation } from '../hooks/useConversations' -import { ConfirmDialog } from './ConfirmDialog' -import type { AssistantConversation } from '../lib/types' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardHeader } from '@/components/ui/card' +import { useState, useEffect } from "react"; +import { MessageSquare, Trash2, Loader2, AlertCircle } from "lucide-react"; +import { + useConversations, + useDeleteConversation, +} from "../hooks/useConversations"; +import { ConfirmDialog } from "./ConfirmDialog"; +import type { AssistantConversation } from "../lib/types"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; interface ConversationHistoryProps { - projectName: string - currentConversationId: number | null - isOpen: boolean - onClose: () => void - onSelectConversation: (conversationId: number) => void + projectName: string; + currentConversationId: number | null; + isOpen: boolean; + onClose: () => void; + onSelectConversation: (conversationId: number) => void; } /** * Format a relative time string from an ISO date */ function formatRelativeTime(dateString: string | null): string { - if (!dateString) return '' - - const date = new Date(dateString) - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffSeconds = Math.floor(diffMs / 1000) - const diffMinutes = Math.floor(diffSeconds / 60) - const diffHours = Math.floor(diffMinutes / 60) - const diffDays = Math.floor(diffHours / 24) - - if (diffSeconds < 60) return 'just now' - if (diffMinutes < 60) return `${diffMinutes}m ago` - if (diffHours < 24) return `${diffHours}h ago` - if (diffDays === 1) return 'yesterday' - if (diffDays < 7) return `${diffDays}d ago` - - return date.toLocaleDateString() + if (!dateString) return ""; + + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSeconds < 60) return "just now"; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays === 1) return "yesterday"; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString(); } export function ConversationHistory({ @@ -51,71 +54,72 @@ export function ConversationHistory({ onClose, onSelectConversation, }: ConversationHistoryProps) { - const [conversationToDelete, setConversationToDelete] = useState(null) - const [deleteError, setDeleteError] = useState(null) + const [conversationToDelete, setConversationToDelete] = + useState(null); + const [deleteError, setDeleteError] = useState(null); - const { data: conversations, isLoading } = useConversations(projectName) - const deleteConversation = useDeleteConversation(projectName) + const { data: conversations, isLoading } = useConversations(projectName); + const deleteConversation = useDeleteConversation(projectName); // Clear error when dropdown closes useEffect(() => { if (!isOpen) { - setDeleteError(null) + setDeleteError(null); } - }, [isOpen]) + }, [isOpen]); - const handleDeleteClick = (e: React.MouseEvent, conversation: AssistantConversation) => { - e.stopPropagation() - setConversationToDelete(conversation) - } + const handleDeleteClick = ( + e: React.MouseEvent, + conversation: AssistantConversation, + ) => { + e.stopPropagation(); + setConversationToDelete(conversation); + }; const handleConfirmDelete = async () => { - if (!conversationToDelete) return + if (!conversationToDelete) return; try { - setDeleteError(null) - await deleteConversation.mutateAsync(conversationToDelete.id) - setConversationToDelete(null) + setDeleteError(null); + await deleteConversation.mutateAsync(conversationToDelete.id); + setConversationToDelete(null); } catch { // Keep dialog open and show error to user - setDeleteError('Failed to delete conversation. Please try again.') + setDeleteError("Failed to delete conversation. Please try again."); } - } + }; const handleCancelDelete = () => { - setConversationToDelete(null) - setDeleteError(null) - } + setConversationToDelete(null); + setDeleteError(null); + }; const handleSelectConversation = (conversationId: number) => { - onSelectConversation(conversationId) - onClose() - } + onSelectConversation(conversationId); + onClose(); + }; // Handle Escape key to close dropdown useEffect(() => { - if (!isOpen) return + if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - onClose() + if (e.key === "Escape") { + e.preventDefault(); + onClose(); } - } + }; - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [isOpen, onClose]) + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); - if (!isOpen) return null + if (!isOpen) return null; return ( <> {/* Backdrop */} -
+
{/* Dropdown */} @@ -128,7 +132,10 @@ export function ConversationHistory({ {isLoading ? (
- +
) : !conversations || conversations.length === 0 ? (
@@ -137,13 +144,13 @@ export function ConversationHistory({ ) : (
{conversations.map((conversation) => { - const isCurrent = conversation.id === currentConversationId + const isCurrent = conversation.id === currentConversationId; return (
- ) + ); })}
)} @@ -193,14 +205,14 @@ export function ConversationHistory({ message={ deleteError ? (
-

{`Are you sure you want to delete "${conversationToDelete?.title || 'this conversation'}"? This action cannot be undone.`}

+

{`Are you sure you want to delete "${conversationToDelete?.title || "this conversation"}"? This action cannot be undone.`}

{deleteError}
) : ( - `Are you sure you want to delete "${conversationToDelete?.title || 'this conversation'}"? This action cannot be undone.` + `Are you sure you want to delete "${conversationToDelete?.title || "this conversation"}"? This action cannot be undone.` ) } confirmLabel="Delete" @@ -211,5 +223,5 @@ export function ConversationHistory({ onCancel={handleCancelDelete} /> - ) + ); } diff --git a/ui/src/components/DebugLogViewer.tsx b/ui/src/components/DebugLogViewer.tsx index 80b6249c..3c27dd56 100644 --- a/ui/src/components/DebugLogViewer.tsx +++ b/ui/src/components/DebugLogViewer.tsx @@ -6,37 +6,50 @@ * Features a resizable height via drag handle and tabs for different log sources. */ -import { useEffect, useRef, useState, useCallback } from 'react' -import { ChevronUp, ChevronDown, Trash2, Terminal as TerminalIcon, GripHorizontal, Cpu, Server } from 'lucide-react' -import { Terminal } from './Terminal' -import { TerminalTabs } from './TerminalTabs' -import { listTerminals, createTerminal, renameTerminal, deleteTerminal } from '@/lib/api' -import type { TerminalInfo } from '@/lib/types' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' - -const MIN_HEIGHT = 150 -const MAX_HEIGHT = 600 -const DEFAULT_HEIGHT = 288 -const STORAGE_KEY = 'debug-panel-height' -const TAB_STORAGE_KEY = 'debug-panel-tab' - -type TabType = 'agent' | 'devserver' | 'terminal' +import { useEffect, useRef, useState, useCallback } from "react"; +import { + ChevronUp, + ChevronDown, + Trash2, + Terminal as TerminalIcon, + GripHorizontal, + Cpu, + Server, +} from "lucide-react"; +import { Terminal } from "./Terminal"; +import { TerminalTabs } from "./TerminalTabs"; +import { + listTerminals, + createTerminal, + renameTerminal, + deleteTerminal, +} from "@/lib/api"; +import type { TerminalInfo } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +const MIN_HEIGHT = 150; +const MAX_HEIGHT = 600; +const DEFAULT_HEIGHT = 288; +const STORAGE_KEY = "debug-panel-height"; +const TAB_STORAGE_KEY = "debug-panel-tab"; + +type TabType = "agent" | "devserver" | "terminal"; interface DebugLogViewerProps { - logs: Array<{ line: string; timestamp: string }> - devLogs: Array<{ line: string; timestamp: string }> - isOpen: boolean - onToggle: () => void - onClear: () => void - onClearDevLogs: () => void - onHeightChange?: (height: number) => void - projectName: string - activeTab?: TabType - onTabChange?: (tab: TabType) => void + logs: Array<{ line: string; timestamp: string }>; + devLogs: Array<{ line: string; timestamp: string }>; + isOpen: boolean; + onToggle: () => void; + onClear: () => void; + onClearDevLogs: () => void; + onHeightChange?: (height: number) => void; + projectName: string; + activeTab?: TabType; + onTabChange?: (tab: TabType) => void; } -type LogLevel = 'error' | 'warn' | 'debug' | 'info' +type LogLevel = "error" | "warn" | "debug" | "info"; export function DebugLogViewer({ logs, @@ -50,265 +63,279 @@ export function DebugLogViewer({ activeTab: controlledActiveTab, onTabChange, }: DebugLogViewerProps) { - const scrollRef = useRef(null) - const devScrollRef = useRef(null) - const [autoScroll, setAutoScroll] = useState(true) - const [devAutoScroll, setDevAutoScroll] = useState(true) - const [isResizing, setIsResizing] = useState(false) + const scrollRef = useRef(null); + const devScrollRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + const [devAutoScroll, setDevAutoScroll] = useState(true); + const [isResizing, setIsResizing] = useState(false); const [panelHeight, setPanelHeight] = useState(() => { // Load saved height from localStorage - const saved = localStorage.getItem(STORAGE_KEY) - return saved ? Math.min(Math.max(parseInt(saved, 10), MIN_HEIGHT), MAX_HEIGHT) : DEFAULT_HEIGHT - }) + const saved = localStorage.getItem(STORAGE_KEY); + return saved + ? Math.min(Math.max(parseInt(saved, 10), MIN_HEIGHT), MAX_HEIGHT) + : DEFAULT_HEIGHT; + }); const [internalActiveTab, setInternalActiveTab] = useState(() => { // Load saved tab from localStorage - const saved = localStorage.getItem(TAB_STORAGE_KEY) - return (saved as TabType) || 'agent' - }) + const saved = localStorage.getItem(TAB_STORAGE_KEY); + return (saved as TabType) || "agent"; + }); // Terminal management state - const [terminals, setTerminals] = useState([]) - const [activeTerminalId, setActiveTerminalId] = useState(null) - const [isLoadingTerminals, setIsLoadingTerminals] = useState(false) + const [terminals, setTerminals] = useState([]); + const [activeTerminalId, setActiveTerminalId] = useState(null); + const [isLoadingTerminals, setIsLoadingTerminals] = useState(false); // Use controlled tab if provided, otherwise use internal state - const activeTab = controlledActiveTab ?? internalActiveTab + const activeTab = controlledActiveTab ?? internalActiveTab; const setActiveTab = (tab: TabType) => { - setInternalActiveTab(tab) - localStorage.setItem(TAB_STORAGE_KEY, tab) - onTabChange?.(tab) - } + setInternalActiveTab(tab); + localStorage.setItem(TAB_STORAGE_KEY, tab); + onTabChange?.(tab); + }; // Fetch terminals for the project const fetchTerminals = useCallback(async () => { - if (!projectName) return + if (!projectName) return; - setIsLoadingTerminals(true) + setIsLoadingTerminals(true); try { - const terminalList = await listTerminals(projectName) - setTerminals(terminalList) + const terminalList = await listTerminals(projectName); + setTerminals(terminalList); // Set active terminal to first one if not set or current one doesn't exist if (terminalList.length > 0) { - if (!activeTerminalId || !terminalList.find((t) => t.id === activeTerminalId)) { - setActiveTerminalId(terminalList[0].id) + if ( + !activeTerminalId || + !terminalList.find((t) => t.id === activeTerminalId) + ) { + setActiveTerminalId(terminalList[0].id); } } } catch (err) { - console.error('Failed to fetch terminals:', err) + console.error("Failed to fetch terminals:", err); } finally { - setIsLoadingTerminals(false) + setIsLoadingTerminals(false); } - }, [projectName, activeTerminalId]) + }, [projectName, activeTerminalId]); // Handle creating a new terminal const handleCreateTerminal = useCallback(async () => { - if (!projectName) return + if (!projectName) return; try { - const newTerminal = await createTerminal(projectName) - setTerminals((prev) => [...prev, newTerminal]) - setActiveTerminalId(newTerminal.id) + const newTerminal = await createTerminal(projectName); + setTerminals((prev) => [...prev, newTerminal]); + setActiveTerminalId(newTerminal.id); } catch (err) { - console.error('Failed to create terminal:', err) + console.error("Failed to create terminal:", err); } - }, [projectName]) + }, [projectName]); // Handle renaming a terminal const handleRenameTerminal = useCallback( async (terminalId: string, newName: string) => { - if (!projectName) return + if (!projectName) return; try { - const updated = await renameTerminal(projectName, terminalId, newName) + const updated = await renameTerminal(projectName, terminalId, newName); setTerminals((prev) => - prev.map((t) => (t.id === terminalId ? updated : t)) - ) + prev.map((t) => (t.id === terminalId ? updated : t)), + ); } catch (err) { - console.error('Failed to rename terminal:', err) + console.error("Failed to rename terminal:", err); } }, - [projectName] - ) + [projectName], + ); // Handle closing a terminal const handleCloseTerminal = useCallback( async (terminalId: string) => { - if (!projectName || terminals.length <= 1) return + if (!projectName || terminals.length <= 1) return; try { - await deleteTerminal(projectName, terminalId) - setTerminals((prev) => prev.filter((t) => t.id !== terminalId)) + await deleteTerminal(projectName, terminalId); + setTerminals((prev) => prev.filter((t) => t.id !== terminalId)); // If we closed the active terminal, switch to another one if (activeTerminalId === terminalId) { - const remaining = terminals.filter((t) => t.id !== terminalId) + const remaining = terminals.filter((t) => t.id !== terminalId); if (remaining.length > 0) { - setActiveTerminalId(remaining[0].id) + setActiveTerminalId(remaining[0].id); } } } catch (err) { - console.error('Failed to close terminal:', err) + console.error("Failed to close terminal:", err); } }, - [projectName, terminals, activeTerminalId] - ) + [projectName, terminals, activeTerminalId], + ); // Fetch terminals when project changes useEffect(() => { if (projectName) { - fetchTerminals() + fetchTerminals(); } else { - setTerminals([]) - setActiveTerminalId(null) + setTerminals([]); + setActiveTerminalId(null); } - }, [projectName]) // eslint-disable-line react-hooks/exhaustive-deps + }, [projectName]); // eslint-disable-line react-hooks/exhaustive-deps // Auto-scroll to bottom when new agent logs arrive (if user hasn't scrolled up) useEffect(() => { - if (autoScroll && scrollRef.current && isOpen && activeTab === 'agent') { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight + if (autoScroll && scrollRef.current && isOpen && activeTab === "agent") { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - }, [logs, autoScroll, isOpen, activeTab]) + }, [logs, autoScroll, isOpen, activeTab]); // Auto-scroll to bottom when new dev logs arrive (if user hasn't scrolled up) useEffect(() => { - if (devAutoScroll && devScrollRef.current && isOpen && activeTab === 'devserver') { - devScrollRef.current.scrollTop = devScrollRef.current.scrollHeight + if ( + devAutoScroll && + devScrollRef.current && + isOpen && + activeTab === "devserver" + ) { + devScrollRef.current.scrollTop = devScrollRef.current.scrollHeight; } - }, [devLogs, devAutoScroll, isOpen, activeTab]) + }, [devLogs, devAutoScroll, isOpen, activeTab]); // Notify parent of height changes useEffect(() => { if (onHeightChange && isOpen) { - onHeightChange(panelHeight) + onHeightChange(panelHeight); } - }, [panelHeight, isOpen, onHeightChange]) + }, [panelHeight, isOpen, onHeightChange]); // Handle mouse move during resize const handleMouseMove = useCallback((e: MouseEvent) => { - const newHeight = window.innerHeight - e.clientY - const clampedHeight = Math.min(Math.max(newHeight, MIN_HEIGHT), MAX_HEIGHT) - setPanelHeight(clampedHeight) - }, []) + const newHeight = window.innerHeight - e.clientY; + const clampedHeight = Math.min(Math.max(newHeight, MIN_HEIGHT), MAX_HEIGHT); + setPanelHeight(clampedHeight); + }, []); // Handle mouse up to stop resizing const handleMouseUp = useCallback(() => { - setIsResizing(false) + setIsResizing(false); // Save to localStorage - localStorage.setItem(STORAGE_KEY, panelHeight.toString()) - }, [panelHeight]) + localStorage.setItem(STORAGE_KEY, panelHeight.toString()); + }, [panelHeight]); // Set up global mouse event listeners during resize useEffect(() => { if (isResizing) { - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - document.body.style.cursor = 'ns-resize' - document.body.style.userSelect = 'none' + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "ns-resize"; + document.body.style.userSelect = "none"; } return () => { - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - document.body.style.cursor = '' - document.body.style.userSelect = '' - } - }, [isResizing, handleMouseMove, handleMouseUp]) + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + }, [isResizing, handleMouseMove, handleMouseUp]); // Start resizing const handleResizeStart = (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - setIsResizing(true) - } + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + }; // Detect if user scrolled up (agent logs) const handleScroll = (e: React.UIEvent) => { - const el = e.currentTarget - const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50 - setAutoScroll(isAtBottom) - } + const el = e.currentTarget; + const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50; + setAutoScroll(isAtBottom); + }; // Detect if user scrolled up (dev logs) const handleDevScroll = (e: React.UIEvent) => { - const el = e.currentTarget - const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50 - setDevAutoScroll(isAtBottom) - } + const el = e.currentTarget; + const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50; + setDevAutoScroll(isAtBottom); + }; // Handle clear button based on active tab const handleClear = () => { - if (activeTab === 'agent') { - onClear() - } else if (activeTab === 'devserver') { - onClearDevLogs() + if (activeTab === "agent") { + onClear(); + } else if (activeTab === "devserver") { + onClearDevLogs(); } // Terminal has no clear button (it's managed internally) - } + }; // Get the current log count based on active tab const getCurrentLogCount = () => { - if (activeTab === 'agent') return logs.length - if (activeTab === 'devserver') return devLogs.length - return 0 - } + if (activeTab === "agent") return logs.length; + if (activeTab === "devserver") return devLogs.length; + return 0; + }; // Check if current tab has auto-scroll paused const isAutoScrollPaused = () => { - if (activeTab === 'agent') return !autoScroll - if (activeTab === 'devserver') return !devAutoScroll - return false - } + if (activeTab === "agent") return !autoScroll; + if (activeTab === "devserver") return !devAutoScroll; + return false; + }; // Parse log level from line content const getLogLevel = (line: string): LogLevel => { - const lowerLine = line.toLowerCase() - if (lowerLine.includes('error') || lowerLine.includes('exception') || lowerLine.includes('traceback')) { - return 'error' + const lowerLine = line.toLowerCase(); + if ( + lowerLine.includes("error") || + lowerLine.includes("exception") || + lowerLine.includes("traceback") + ) { + return "error"; } - if (lowerLine.includes('warn') || lowerLine.includes('warning')) { - return 'warn' + if (lowerLine.includes("warn") || lowerLine.includes("warning")) { + return "warn"; } - if (lowerLine.includes('debug')) { - return 'debug' + if (lowerLine.includes("debug")) { + return "debug"; } - return 'info' - } + return "info"; + }; // Get color class for log level const getLogColor = (level: LogLevel): string => { switch (level) { - case 'error': - return 'text-red-500' - case 'warn': - return 'text-yellow-500' - case 'debug': - return 'text-blue-400' - case 'info': + case "error": + return "text-red-500"; + case "warn": + return "text-yellow-500"; + case "debug": + return "text-blue-400"; + case "info": default: - return 'text-foreground' + return "text-foreground"; } - } + }; // Format timestamp to HH:MM:SS const formatTimestamp = (timestamp: string): string => { try { - const date = new Date(timestamp) - return date.toLocaleTimeString('en-US', { + const date = new Date(timestamp); + return date.toLocaleTimeString("en-US", { hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }) + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); } catch { - return '' + return ""; } - } + }; return (
@@ -319,15 +346,16 @@ export function DebugLogViewer({ onMouseDown={handleResizeStart} >
- +
)} {/* Header bar */} -
+
{/* Collapse/expand toggle */} @@ -347,11 +379,11 @@ export function DebugLogViewer({ {isOpen && (
@@ -399,7 +435,7 @@ export function DebugLogViewer({ )} {/* Log count and status - only for log tabs */} - {isOpen && activeTab !== 'terminal' && ( + {isOpen && activeTab !== "terminal" && ( <> {getCurrentLogCount() > 0 && ( @@ -407,7 +443,10 @@ export function DebugLogViewer({ )} {isAutoScrollPaused() && ( - + Paused )} @@ -417,13 +456,13 @@ export function DebugLogViewer({
{/* Clear button - only for log tabs */} - {isOpen && activeTab !== 'terminal' && ( + {isOpen && activeTab !== "terminal" && (
- ) + ); } // Export the TabType for use in parent components -export type { TabType } +export type { TabType }; diff --git a/ui/src/components/DependencyBadge.tsx b/ui/src/components/DependencyBadge.tsx index 143f5923..6ec2cb19 100644 --- a/ui/src/components/DependencyBadge.tsx +++ b/ui/src/components/DependencyBadge.tsx @@ -1,11 +1,11 @@ -import { AlertTriangle, GitBranch, Check } from 'lucide-react' -import type { Feature } from '../lib/types' -import { Badge } from '@/components/ui/badge' +import { AlertTriangle, GitBranch, Check } from "lucide-react"; +import type { Feature } from "../lib/types"; +import { Badge } from "@/components/ui/badge"; interface DependencyBadgeProps { - feature: Feature - allFeatures?: Feature[] - compact?: boolean + feature: Feature; + allFeatures?: Feature[]; + compact?: boolean; } /** @@ -15,25 +15,33 @@ interface DependencyBadgeProps { * - Dependency count for features with satisfied dependencies * - Nothing if feature has no dependencies */ -export function DependencyBadge({ feature, allFeatures = [], compact = false }: DependencyBadgeProps) { - const dependencies = feature.dependencies || [] +export function DependencyBadge({ + feature, + allFeatures = [], + compact = false, +}: DependencyBadgeProps) { + const dependencies = feature.dependencies || []; if (dependencies.length === 0) { - return null + return null; } // Use API-computed blocked status if available, otherwise compute locally - const isBlocked = feature.blocked ?? - (feature.blocking_dependencies && feature.blocking_dependencies.length > 0) ?? - false + const isBlocked = + feature.blocked ?? + (feature.blocking_dependencies && + feature.blocking_dependencies.length > 0) ?? + false; - const blockingCount = feature.blocking_dependencies?.length ?? 0 + const blockingCount = feature.blocking_dependencies?.length ?? 0; // Compute satisfied count from allFeatures if available - let satisfiedCount = dependencies.length - blockingCount + let satisfiedCount = dependencies.length - blockingCount; if (allFeatures.length > 0 && !feature.blocking_dependencies) { - const passingIds = new Set(allFeatures.filter(f => f.passes).map(f => f.id)) - satisfiedCount = dependencies.filter(d => passingIds.has(d)).length + const passingIds = new Set( + allFeatures.filter((f) => f.passes).map((f) => f.id), + ); + satisfiedCount = dependencies.filter((d) => passingIds.has(d)).length; } if (compact) { @@ -43,12 +51,13 @@ export function DependencyBadge({ feature, allFeatures = [], compact = false }: variant="outline" className={`gap-1 font-mono text-xs ${ isBlocked - ? 'bg-destructive/10 text-destructive border-destructive/30' - : 'bg-muted text-muted-foreground' + ? "bg-destructive/10 text-destructive border-destructive/30" + : "bg-muted text-muted-foreground" }`} - title={isBlocked - ? `Blocked by ${blockingCount} ${blockingCount === 1 ? 'dependency' : 'dependencies'}` - : `${satisfiedCount}/${dependencies.length} dependencies satisfied` + title={ + isBlocked + ? `Blocked by ${blockingCount} ${blockingCount === 1 ? "dependency" : "dependencies"}` + : `${satisfiedCount}/${dependencies.length} dependencies satisfied` } > {isBlocked ? ( @@ -59,11 +68,13 @@ export function DependencyBadge({ feature, allFeatures = [], compact = false }: ) : ( <> - {satisfiedCount}/{dependencies.length} + + {satisfiedCount}/{dependencies.length} + )}
- ) + ); } // Full view with more details @@ -73,30 +84,35 @@ export function DependencyBadge({ feature, allFeatures = [], compact = false }:
- Blocked by {blockingCount} {blockingCount === 1 ? 'dependency' : 'dependencies'} + Blocked by {blockingCount}{" "} + {blockingCount === 1 ? "dependency" : "dependencies"}
) : (
- All {dependencies.length} {dependencies.length === 1 ? 'dependency' : 'dependencies'} satisfied + All {dependencies.length}{" "} + {dependencies.length === 1 ? "dependency" : "dependencies"}{" "} + satisfied
)}
- ) + ); } /** * Small inline indicator for dependency status */ export function DependencyIndicator({ feature }: { feature: Feature }) { - const dependencies = feature.dependencies || [] - const isBlocked = feature.blocked || (feature.blocking_dependencies && feature.blocking_dependencies.length > 0) + const dependencies = feature.dependencies || []; + const isBlocked = + feature.blocked || + (feature.blocking_dependencies && feature.blocking_dependencies.length > 0); if (dependencies.length === 0) { - return null + return null; } if (isBlocked) { @@ -107,7 +123,7 @@ export function DependencyIndicator({ feature }: { feature: Feature }) { > - ) + ); } return ( @@ -117,5 +133,5 @@ export function DependencyIndicator({ feature }: { feature: Feature }) { > - ) + ); } diff --git a/ui/src/components/DependencyGraph.tsx b/ui/src/components/DependencyGraph.tsx index 3061548a..3bafd6cf 100644 --- a/ui/src/components/DependencyGraph.tsx +++ b/ui/src/components/DependencyGraph.tsx @@ -1,5 +1,12 @@ -import { Component, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { ErrorInfo, ReactNode } from 'react' +import { + Component, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { ErrorInfo, ReactNode } from "react"; import { ReactFlow, Background, @@ -13,60 +20,75 @@ import { MarkerType, ConnectionMode, Handle, -} from '@xyflow/react' -import dagre from 'dagre' -import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw } from 'lucide-react' -import type { DependencyGraph as DependencyGraphData, GraphNode, ActiveAgent, AgentMascot, AgentState } from '../lib/types' -import { AgentAvatar } from './AgentAvatar' -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -import '@xyflow/react/dist/style.css' +} from "@xyflow/react"; +import dagre from "dagre"; +import { + CheckCircle2, + Circle, + Loader2, + AlertTriangle, + RefreshCw, +} from "lucide-react"; +import type { + DependencyGraph as DependencyGraphData, + GraphNode, + ActiveAgent, + AgentMascot, + AgentState, +} from "../lib/types"; +import { AgentAvatar } from "./AgentAvatar"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import "@xyflow/react/dist/style.css"; // Node dimensions -const NODE_WIDTH = 220 -const NODE_HEIGHT = 80 +const NODE_WIDTH = 220; +const NODE_HEIGHT = 80; interface DependencyGraphProps { - graphData: DependencyGraphData - onNodeClick?: (nodeId: number) => void - activeAgents?: ActiveAgent[] + graphData: DependencyGraphData; + onNodeClick?: (nodeId: number) => void; + activeAgents?: ActiveAgent[]; } // Agent info to display on a node interface NodeAgentInfo { - name: AgentMascot | 'Unknown' - state: AgentState + name: AgentMascot | "Unknown"; + state: AgentState; } // Error boundary to catch and recover from ReactFlow rendering errors interface ErrorBoundaryProps { - children: ReactNode - onReset?: () => void + children: ReactNode; + onReset?: () => void; } interface ErrorBoundaryState { - hasError: boolean - error: Error | null + hasError: boolean; + error: Error | null; } -class GraphErrorBoundary extends Component { +class GraphErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { constructor(props: ErrorBoundaryProps) { - super(props) - this.state = { hasError: false, error: null } + super(props); + this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { hasError: true, error } + return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: ErrorInfo) { - console.error('DependencyGraph error:', error, errorInfo) + console.error("DependencyGraph error:", error, errorInfo); } handleReset = () => { - this.setState({ hasError: false, error: null }) - this.props.onReset?.() - } + this.setState({ hasError: false, error: null }); + this.props.onReset?.(); + }; render() { if (this.state.hasError) { @@ -74,7 +96,9 @@ class GraphErrorBoundary extends Component
-
Graph rendering error
+
+ Graph rendering error +
The dependency graph encountered an issue.
@@ -84,45 +108,60 @@ class GraphErrorBoundary extends Component
- ) + ); } - return this.props.children + return this.props.children; } } // Custom node component -function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent?: NodeAgentInfo } }) { +function FeatureNode({ + data, +}: { + data: GraphNode & { onClick?: () => void; agent?: NodeAgentInfo }; +}) { const statusColors = { - pending: 'bg-yellow-100 border-yellow-300 dark:bg-yellow-900/30 dark:border-yellow-700', - in_progress: 'bg-cyan-100 border-cyan-300 dark:bg-cyan-900/30 dark:border-cyan-700', - done: 'bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700', - blocked: 'bg-red-50 border-red-300 dark:bg-red-900/20 dark:border-red-700', - } + pending: + "bg-yellow-100 border-yellow-300 dark:bg-yellow-900/30 dark:border-yellow-700", + in_progress: + "bg-cyan-100 border-cyan-300 dark:bg-cyan-900/30 dark:border-cyan-700", + done: "bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700", + blocked: "bg-red-50 border-red-300 dark:bg-red-900/20 dark:border-red-700", + }; const textColors = { - pending: 'text-yellow-900 dark:text-yellow-100', - in_progress: 'text-cyan-900 dark:text-cyan-100', - done: 'text-green-900 dark:text-green-100', - blocked: 'text-red-900 dark:text-red-100', - } + pending: "text-yellow-900 dark:text-yellow-100", + in_progress: "text-cyan-900 dark:text-cyan-100", + done: "text-green-900 dark:text-green-100", + blocked: "text-red-900 dark:text-red-100", + }; const StatusIcon = () => { switch (data.status) { - case 'done': - return - case 'in_progress': - return - case 'blocked': - return + case "done": + return ; + case "in_progress": + return ( + + ); + case "blocked": + return ; default: - return + return ; } - } + }; return ( <> - +
void; agent {data.agent && (
- +
)}
- + #{data.priority} {/* Show agent name inline if present */} {data.agent && ( - + {data.agent.name} )}
-
+
{data.name}
-
+
{data.category}
- + - ) + ); } const nodeTypes = { feature: FeatureNode, -} +}; // Layout nodes using dagre function getLayoutedElements( nodes: Node[], edges: Edge[], - direction: 'TB' | 'LR' = 'LR' + direction: "TB" | "LR" = "LR", ): { nodes: Node[]; edges: Edge[] } { - const dagreGraph = new dagre.graphlib.Graph() - dagreGraph.setDefaultEdgeLabel(() => ({})) + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setDefaultEdgeLabel(() => ({})); - const isHorizontal = direction === 'LR' + const isHorizontal = direction === "LR"; dagreGraph.setGraph({ rankdir: direction, nodesep: 50, ranksep: 100, marginx: 50, marginy: 50, - }) + }); nodes.forEach((node) => { - dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }) - }) + dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + }); edges.forEach((edge) => { - dagreGraph.setEdge(edge.source, edge.target) - }) + dagreGraph.setEdge(edge.source, edge.target); + }); - dagre.layout(dagreGraph) + dagre.layout(dagreGraph); const layoutedNodes = nodes.map((node) => { - const nodeWithPosition = dagreGraph.node(node.id) + const nodeWithPosition = dagreGraph.node(node.id); return { ...node, position: { @@ -206,135 +263,151 @@ function getLayoutedElements( }, sourcePosition: isHorizontal ? Position.Right : Position.Bottom, targetPosition: isHorizontal ? Position.Left : Position.Top, - } - }) + }; + }); - return { nodes: layoutedNodes, edges } + return { nodes: layoutedNodes, edges }; } -function DependencyGraphInner({ graphData, onNodeClick, activeAgents = [] }: DependencyGraphProps) { - const [direction, setDirection] = useState<'TB' | 'LR'>('LR') +function DependencyGraphInner({ + graphData, + onNodeClick, + activeAgents = [], +}: DependencyGraphProps) { + const [direction, setDirection] = useState<"TB" | "LR">("LR"); // Use ref for callback to avoid triggering re-renders when callback identity changes - const onNodeClickRef = useRef(onNodeClick) + const onNodeClickRef = useRef(onNodeClick); useEffect(() => { - onNodeClickRef.current = onNodeClick - }, [onNodeClick]) + onNodeClickRef.current = onNodeClick; + }, [onNodeClick]); // Create a stable click handler that uses the ref const handleNodeClick = useCallback((nodeId: number) => { - onNodeClickRef.current?.(nodeId) - }, []) + onNodeClickRef.current?.(nodeId); + }, []); // Create a map of featureId to agent info for quick lookup const agentByFeatureId = useMemo(() => { - const map = new Map() + const map = new Map(); for (const agent of activeAgents) { - map.set(agent.featureId, { name: agent.agentName, state: agent.state }) + map.set(agent.featureId, { name: agent.agentName, state: agent.state }); } - return map - }, [activeAgents]) + return map; + }, [activeAgents]); // Convert graph data to React Flow format // Only recalculate when graphData or direction changes (not when onNodeClick changes) const initialElements = useMemo(() => { const nodes: Node[] = graphData.nodes.map((node) => ({ id: String(node.id), - type: 'feature', + type: "feature", position: { x: 0, y: 0 }, data: { ...node, onClick: () => handleNodeClick(node.id), agent: agentByFeatureId.get(node.id), }, - })) + })); const edges: Edge[] = graphData.edges.map((edge, index) => ({ id: `e${edge.source}-${edge.target}-${index}`, source: String(edge.source), target: String(edge.target), - type: 'smoothstep', + type: "smoothstep", animated: false, - style: { stroke: '#a1a1aa', strokeWidth: 2 }, + style: { stroke: "#a1a1aa", strokeWidth: 2 }, markerEnd: { type: MarkerType.ArrowClosed, - color: '#a1a1aa', + color: "#a1a1aa", }, - })) + })); - return getLayoutedElements(nodes, edges, direction) - }, [graphData, direction, handleNodeClick, agentByFeatureId]) + return getLayoutedElements(nodes, edges, direction); + }, [graphData, direction, handleNodeClick, agentByFeatureId]); - const [nodes, setNodes, onNodesChange] = useNodesState(initialElements.nodes) - const [edges, setEdges, onEdgesChange] = useEdgesState(initialElements.edges) + const [nodes, setNodes, onNodesChange] = useNodesState(initialElements.nodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialElements.edges); // Update layout when initialElements changes // Using a ref to track previous graph data to avoid unnecessary updates - const prevGraphDataRef = useRef('') - const prevDirectionRef = useRef<'TB' | 'LR'>(direction) + const prevGraphDataRef = useRef(""); + const prevDirectionRef = useRef<"TB" | "LR">(direction); useEffect(() => { // Create a simple hash of the graph data to detect actual changes // Include agent assignments so nodes update when agents change - const agentInfo = Array.from(agentByFeatureId.entries()).map(([id, agent]) => ({ - featureId: id, - agentName: agent.name, - agentState: agent.state, - })) + const agentInfo = Array.from(agentByFeatureId.entries()).map( + ([id, agent]) => ({ + featureId: id, + agentName: agent.name, + agentState: agent.state, + }), + ); const graphHash = JSON.stringify({ - nodes: graphData.nodes.map(n => ({ id: n.id, status: n.status })), + nodes: graphData.nodes.map((n) => ({ id: n.id, status: n.status })), edges: graphData.edges, agents: agentInfo, - }) + }); // Only update if graph data or direction actually changed - if (graphHash !== prevGraphDataRef.current || direction !== prevDirectionRef.current) { - prevGraphDataRef.current = graphHash - prevDirectionRef.current = direction - - const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( - initialElements.nodes, - initialElements.edges, - direction - ) - setNodes(layoutedNodes) - setEdges(layoutedEdges) + if ( + graphHash !== prevGraphDataRef.current || + direction !== prevDirectionRef.current + ) { + prevGraphDataRef.current = graphHash; + prevDirectionRef.current = direction; + + const { nodes: layoutedNodes, edges: layoutedEdges } = + getLayoutedElements( + initialElements.nodes, + initialElements.edges, + direction, + ); + setNodes(layoutedNodes); + setEdges(layoutedEdges); } - }, [graphData, direction, setNodes, setEdges, initialElements, agentByFeatureId]) - - const onLayout = useCallback( - (newDirection: 'TB' | 'LR') => { - setDirection(newDirection) - }, - [] - ) + }, [ + graphData, + direction, + setNodes, + setEdges, + initialElements, + agentByFeatureId, + ]); + + const onLayout = useCallback((newDirection: "TB" | "LR") => { + setDirection(newDirection); + }, []); // Color nodes for minimap const nodeColor = useCallback((node: Node) => { - const status = (node.data as unknown as GraphNode).status + const status = (node.data as unknown as GraphNode).status; switch (status) { - case 'done': - return '#22c55e' // green-500 - case 'in_progress': - return '#06b6d4' // cyan-500 - case 'blocked': - return '#ef4444' // red-500 + case "done": + return "#22c55e"; // green-500 + case "in_progress": + return "#06b6d4"; // cyan-500 + case "blocked": + return "#ef4444"; // red-500 default: - return '#eab308' // yellow-500 + return "#eab308"; // yellow-500 } - }, []) + }, []); if (graphData.nodes.length === 0) { return (
-
No features to display
+
+ No features to display +
Create features to see the dependency graph
- ) + ); } return ( @@ -342,16 +415,16 @@ function DependencyGraphInner({ graphData, onNodeClick, activeAgents = [] }: Dep {/* Layout toggle */}
@@ -407,22 +480,30 @@ function DependencyGraphInner({ graphData, onNodeClick, activeAgents = [] }: Dep />
- ) + ); } // Wrapper component with error boundary for stability -export function DependencyGraph({ graphData, onNodeClick, activeAgents }: DependencyGraphProps) { +export function DependencyGraph({ + graphData, + onNodeClick, + activeAgents, +}: DependencyGraphProps) { // Use a key based on graph data length to force remount on structural changes // This helps recover from corrupted ReactFlow state - const [resetKey, setResetKey] = useState(0) + const [resetKey, setResetKey] = useState(0); const handleReset = useCallback(() => { - setResetKey(k => k + 1) - }, []) + setResetKey((k) => k + 1); + }, []); return ( - + - ) + ); } diff --git a/ui/src/components/DevServerControl.tsx b/ui/src/components/DevServerControl.tsx index 188e875b..2e2f068c 100644 --- a/ui/src/components/DevServerControl.tsx +++ b/ui/src/components/DevServerControl.tsx @@ -1,11 +1,17 @@ -import { Globe, Square, Loader2, ExternalLink, AlertTriangle } from 'lucide-react' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import type { DevServerStatus } from '../lib/types' -import { startDevServer, stopDevServer } from '../lib/api' -import { Button } from '@/components/ui/button' +import { + Globe, + Square, + Loader2, + ExternalLink, + AlertTriangle, +} from "lucide-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { DevServerStatus } from "../lib/types"; +import { startDevServer, stopDevServer } from "../lib/api"; +import { Button } from "@/components/ui/button"; // Re-export DevServerStatus from lib/types for consumers that import from here -export type { DevServerStatus } +export type { DevServerStatus }; // ============================================================================ // React Query Hooks (Internal) @@ -16,14 +22,16 @@ export type { DevServerStatus } * Invalidates the dev-server-status query on success. */ function useStartDevServer(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ mutationFn: () => startDevServer(projectName), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['dev-server-status', projectName] }) + queryClient.invalidateQueries({ + queryKey: ["dev-server-status", projectName], + }); }, - }) + }); } /** @@ -31,14 +39,16 @@ function useStartDevServer(projectName: string) { * Invalidates the dev-server-status query on success. */ function useStopDevServer(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ mutationFn: () => stopDevServer(projectName), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['dev-server-status', projectName] }) + queryClient.invalidateQueries({ + queryKey: ["dev-server-status", projectName], + }); }, - }) + }); } // ============================================================================ @@ -46,9 +56,9 @@ function useStopDevServer(projectName: string) { // ============================================================================ interface DevServerControlProps { - projectName: string - status: DevServerStatus - url: string | null + projectName: string; + status: DevServerStatus; + url: string | null; } /** @@ -60,29 +70,34 @@ interface DevServerControlProps { * - Displays clickable URL when server is running * - Uses neobrutalism design with cyan accent when running */ -export function DevServerControl({ projectName, status, url }: DevServerControlProps) { - const startDevServerMutation = useStartDevServer(projectName) - const stopDevServerMutation = useStopDevServer(projectName) +export function DevServerControl({ + projectName, + status, + url, +}: DevServerControlProps) { + const startDevServerMutation = useStartDevServer(projectName); + const stopDevServerMutation = useStopDevServer(projectName); - const isLoading = startDevServerMutation.isPending || stopDevServerMutation.isPending + const isLoading = + startDevServerMutation.isPending || stopDevServerMutation.isPending; const handleStart = () => { // Clear any previous errors before starting - stopDevServerMutation.reset() - startDevServerMutation.mutate() - } + stopDevServerMutation.reset(); + startDevServerMutation.mutate(); + }; const handleStop = () => { // Clear any previous errors before stopping - startDevServerMutation.reset() - stopDevServerMutation.mutate() - } + startDevServerMutation.reset(); + stopDevServerMutation.mutate(); + }; // Server is stopped when status is 'stopped' or 'crashed' (can restart) - const isStopped = status === 'stopped' || status === 'crashed' + const isStopped = status === "stopped" || status === "crashed"; // Server is in a running state - const isRunning = status === 'running' + const isRunning = status === "running"; // Server has crashed - const isCrashed = status === 'crashed' + const isCrashed = status === "crashed"; return (
@@ -92,8 +107,14 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP disabled={isLoading} variant={isCrashed ? "destructive" : "outline"} size="sm" - title={isCrashed ? "Dev Server Crashed - Click to Restart" : "Start Dev Server"} - aria-label={isCrashed ? "Restart Dev Server (crashed)" : "Start Dev Server"} + title={ + isCrashed + ? "Dev Server Crashed - Click to Restart" + : "Start Dev Server" + } + aria-label={ + isCrashed ? "Restart Dev Server (crashed)" : "Start Dev Server" + } > {isLoading ? ( @@ -142,9 +163,12 @@ export function DevServerControl({ projectName, status, url }: DevServerControlP {/* Error display */} {(startDevServerMutation.error || stopDevServerMutation.error) && ( - {String((startDevServerMutation.error || stopDevServerMutation.error)?.message || 'Operation failed')} + {String( + (startDevServerMutation.error || stopDevServerMutation.error) + ?.message || "Operation failed", + )} )}
- ) + ); } diff --git a/ui/src/components/EditFeatureForm.tsx b/ui/src/components/EditFeatureForm.tsx index 1095f0d8..010fea34 100644 --- a/ui/src/components/EditFeatureForm.tsx +++ b/ui/src/components/EditFeatureForm.tsx @@ -1,70 +1,76 @@ -import { useState, useId } from 'react' -import { X, Save, Plus, Trash2, Loader2, AlertCircle } from 'lucide-react' -import { useUpdateFeature } from '../hooks/useProjects' -import type { Feature } from '../lib/types' +import { useState, useId } from "react"; +import { X, Save, Plus, Trash2, Loader2, AlertCircle } from "lucide-react"; +import { useUpdateFeature } from "../hooks/useProjects"; +import type { Feature } from "../lib/types"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Textarea } from '@/components/ui/textarea' -import { Label } from '@/components/ui/label' -import { Alert, AlertDescription } from '@/components/ui/alert' +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; interface Step { - id: string - value: string + id: string; + value: string; } interface EditFeatureFormProps { - feature: Feature - projectName: string - onClose: () => void - onSaved: () => void + feature: Feature; + projectName: string; + onClose: () => void; + onSaved: () => void; } -export function EditFeatureForm({ feature, projectName, onClose, onSaved }: EditFeatureFormProps) { - const formId = useId() - const [category, setCategory] = useState(feature.category) - const [name, setName] = useState(feature.name) - const [description, setDescription] = useState(feature.description) - const [priority, setPriority] = useState(String(feature.priority)) +export function EditFeatureForm({ + feature, + projectName, + onClose, + onSaved, +}: EditFeatureFormProps) { + const formId = useId(); + const [category, setCategory] = useState(feature.category); + const [name, setName] = useState(feature.name); + const [description, setDescription] = useState(feature.description); + const [priority, setPriority] = useState(String(feature.priority)); const [steps, setSteps] = useState(() => feature.steps.length > 0 - ? feature.steps.map((step, i) => ({ id: `${formId}-step-${i}`, value: step })) - : [{ id: `${formId}-step-0`, value: '' }] - ) - const [error, setError] = useState(null) - const [stepCounter, setStepCounter] = useState(feature.steps.length || 1) + ? feature.steps.map((step, i) => ({ + id: `${formId}-step-${i}`, + value: step, + })) + : [{ id: `${formId}-step-0`, value: "" }], + ); + const [error, setError] = useState(null); + const [stepCounter, setStepCounter] = useState(feature.steps.length || 1); - const updateFeature = useUpdateFeature(projectName) + const updateFeature = useUpdateFeature(projectName); const handleAddStep = () => { - setSteps([...steps, { id: `${formId}-step-${stepCounter}`, value: '' }]) - setStepCounter(stepCounter + 1) - } + setSteps([...steps, { id: `${formId}-step-${stepCounter}`, value: "" }]); + setStepCounter(stepCounter + 1); + }; const handleRemoveStep = (id: string) => { - setSteps(steps.filter(step => step.id !== id)) - } + setSteps(steps.filter((step) => step.id !== id)); + }; const handleStepChange = (id: string, value: string) => { - setSteps(steps.map(step => - step.id === id ? { ...step, value } : step - )) - } + setSteps(steps.map((step) => (step.id === id ? { ...step, value } : step))); + }; const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError(null) + e.preventDefault(); + setError(null); const filteredSteps = steps - .map(s => s.value.trim()) - .filter(s => s.length > 0) + .map((s) => s.value.trim()) + .filter((s) => s.length > 0); try { await updateFeature.mutateAsync({ @@ -76,23 +82,23 @@ export function EditFeatureForm({ feature, projectName, onClose, onSaved }: Edit steps: filteredSteps, priority: parseInt(priority, 10), }, - }) - onSaved() + }); + onSaved(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to update feature') + setError(err instanceof Error ? err.message : "Failed to update feature"); } - } + }; - const isValid = category.trim() && name.trim() && description.trim() + const isValid = category.trim() && name.trim() && description.trim(); // Check if any changes were made - const currentSteps = steps.map(s => s.value.trim()).filter(s => s) + const currentSteps = steps.map((s) => s.value.trim()).filter((s) => s); const hasChanges = category.trim() !== feature.category || name.trim() !== feature.name || description.trim() !== feature.description || parseInt(priority, 10) !== feature.priority || - JSON.stringify(currentSteps) !== JSON.stringify(feature.steps) + JSON.stringify(currentSteps) !== JSON.stringify(feature.steps); return ( !open && onClose()}> @@ -214,11 +220,7 @@ export function EditFeatureForm({ feature, projectName, onClose, onSaved }: Edit {/* Actions */} - - ) + ); } diff --git a/ui/src/components/ExpandProjectChat.tsx b/ui/src/components/ExpandProjectChat.tsx index 52926cbf..e75a7094 100644 --- a/ui/src/components/ExpandProjectChat.tsx +++ b/ui/src/components/ExpandProjectChat.tsx @@ -5,25 +5,35 @@ * Allows users to describe new features in natural language. */ -import { useCallback, useEffect, useRef, useState } from 'react' -import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Paperclip, Plus } from 'lucide-react' -import { useExpandChat } from '../hooks/useExpandChat' -import { ChatMessage } from './ChatMessage' -import { TypingIndicator } from './TypingIndicator' -import type { ImageAttachment } from '../lib/types' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Card, CardContent } from '@/components/ui/card' -import { Alert, AlertDescription } from '@/components/ui/alert' +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Send, + X, + CheckCircle2, + AlertCircle, + Wifi, + WifiOff, + RotateCcw, + Paperclip, + Plus, +} from "lucide-react"; +import { useExpandChat } from "../hooks/useExpandChat"; +import { ChatMessage } from "./ChatMessage"; +import { TypingIndicator } from "./TypingIndicator"; +import type { ImageAttachment } from "../lib/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; // Image upload validation constants -const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB -const ALLOWED_TYPES = ['image/jpeg', 'image/png'] +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB +const ALLOWED_TYPES = ["image/jpeg", "image/png"]; interface ExpandProjectChatProps { - projectName: string - onComplete: (featuresAdded: number) => void - onCancel: () => void + projectName: string; + onComplete: (featuresAdded: number) => void; + onCancel: () => void; } export function ExpandProjectChat({ @@ -31,15 +41,17 @@ export function ExpandProjectChat({ onComplete, onCancel, }: ExpandProjectChatProps) { - const [input, setInput] = useState('') - const [error, setError] = useState(null) - const [pendingAttachments, setPendingAttachments] = useState([]) - const messagesEndRef = useRef(null) - const inputRef = useRef(null) - const fileInputRef = useRef(null) + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + const [pendingAttachments, setPendingAttachments] = useState< + ImageAttachment[] + >([]); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const fileInputRef = useRef(null); // Memoize error handler to keep hook dependencies stable - const handleError = useCallback((err: string) => setError(err), []) + const handleError = useCallback((err: string) => setError(err), []); const { messages, @@ -54,136 +66,141 @@ export function ExpandProjectChat({ projectName, onComplete, onError: handleError, - }) + }); // Start the chat session when component mounts useEffect(() => { - start() + start(); return () => { - disconnect() - } - }, []) // eslint-disable-line react-hooks/exhaustive-deps + disconnect(); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps // Scroll to bottom when messages change useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages, isLoading]) + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, isLoading]); // Focus input when not loading useEffect(() => { if (!isLoading && inputRef.current) { - inputRef.current.focus() + inputRef.current.focus(); } - }, [isLoading]) + }, [isLoading]); const handleSendMessage = () => { - const trimmed = input.trim() + const trimmed = input.trim(); // Allow sending if there's text OR attachments - if ((!trimmed && pendingAttachments.length === 0) || isLoading) return + if ((!trimmed && pendingAttachments.length === 0) || isLoading) return; - sendMessage(trimmed, pendingAttachments.length > 0 ? pendingAttachments : undefined) - setInput('') - setPendingAttachments([]) // Clear attachments after sending - } + sendMessage( + trimmed, + pendingAttachments.length > 0 ? pendingAttachments : undefined, + ); + setInput(""); + setPendingAttachments([]); // Clear attachments after sending + }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSendMessage() + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); } - } + }; // File handling for image attachments const handleFileSelect = useCallback((files: FileList | null) => { - if (!files) return + if (!files) return; Array.from(files).forEach((file) => { // Validate file type if (!ALLOWED_TYPES.includes(file.type)) { - setError(`Invalid file type: ${file.name}. Only JPEG and PNG are supported.`) - return + setError( + `Invalid file type: ${file.name}. Only JPEG and PNG are supported.`, + ); + return; } // Validate file size if (file.size > MAX_FILE_SIZE) { - setError(`File too large: ${file.name}. Maximum size is 5 MB.`) - return + setError(`File too large: ${file.name}. Maximum size is 5 MB.`); + return; } // Read and convert to base64 - const reader = new FileReader() + const reader = new FileReader(); reader.onload = (e) => { - const dataUrl = e.target?.result as string - const base64Data = dataUrl.split(',')[1] + const dataUrl = e.target?.result as string; + const base64Data = dataUrl.split(",")[1]; const attachment: ImageAttachment = { id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, filename: file.name, - mimeType: file.type as 'image/jpeg' | 'image/png', + mimeType: file.type as "image/jpeg" | "image/png", base64Data, previewUrl: dataUrl, size: file.size, - } + }; - setPendingAttachments((prev) => [...prev, attachment]) - } + setPendingAttachments((prev) => [...prev, attachment]); + }; reader.onerror = () => { - setError(`Failed to read file: ${file.name}`) - } - reader.readAsDataURL(file) - }) - }, []) + setError(`Failed to read file: ${file.name}`); + }; + reader.readAsDataURL(file); + }); + }, []); const handleRemoveAttachment = useCallback((id: string) => { - setPendingAttachments((prev) => prev.filter((a) => a.id !== id)) - }, []) + setPendingAttachments((prev) => prev.filter((a) => a.id !== id)); + }, []); const handleDrop = useCallback( (e: React.DragEvent) => { - e.preventDefault() - handleFileSelect(e.dataTransfer.files) + e.preventDefault(); + handleFileSelect(e.dataTransfer.files); }, - [handleFileSelect] - ) + [handleFileSelect], + ); const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault() - }, []) + e.preventDefault(); + }, []); // Connection status indicator const ConnectionIndicator = () => { switch (connectionStatus) { - case 'connected': + case "connected": return ( Connected - ) - case 'connecting': + ); + case "connecting": return ( Connecting... - ) - case 'error': + ); + case "error": return ( Error - ) + ); default: return ( Disconnected - ) + ); } - } + }; return (
@@ -210,12 +227,7 @@ export function ExpandProjectChat({ )} -
@@ -223,7 +235,10 @@ export function ExpandProjectChat({ {/* Error banner */} {error && ( - + {error} @@ -326,7 +338,7 @@ export function ExpandProjectChat({ {/* Attach button */}
)} @@ -375,7 +388,8 @@ export function ExpandProjectChat({
- Added {featuresCreated} new feature{featuresCreated !== 1 ? 's' : ''}! + Added {featuresCreated} new feature + {featuresCreated !== 1 ? "s" : ""}!
- ) + ); } diff --git a/ui/src/components/ExpandProjectModal.tsx b/ui/src/components/ExpandProjectModal.tsx index 79872b00..63bfd2a1 100644 --- a/ui/src/components/ExpandProjectModal.tsx +++ b/ui/src/components/ExpandProjectModal.tsx @@ -5,13 +5,13 @@ * Allows users to add multiple features to an existing project via AI. */ -import { ExpandProjectChat } from './ExpandProjectChat' +import { ExpandProjectChat } from "./ExpandProjectChat"; interface ExpandProjectModalProps { - isOpen: boolean - projectName: string - onClose: () => void - onFeaturesAdded: () => void // Called to refresh feature list + isOpen: boolean; + projectName: string; + onClose: () => void; + onFeaturesAdded: () => void; // Called to refresh feature list } export function ExpandProjectModal({ @@ -20,14 +20,14 @@ export function ExpandProjectModal({ onClose, onFeaturesAdded, }: ExpandProjectModalProps) { - if (!isOpen) return null + if (!isOpen) return null; const handleComplete = (featuresAdded: number) => { if (featuresAdded > 0) { - onFeaturesAdded() + onFeaturesAdded(); } - onClose() - } + onClose(); + }; return (
@@ -37,5 +37,5 @@ export function ExpandProjectModal({ onCancel={onClose} />
- ) + ); } diff --git a/ui/src/components/FeatureCard.tsx b/ui/src/components/FeatureCard.tsx index 1a4d523d..a65158ed 100644 --- a/ui/src/components/FeatureCard.tsx +++ b/ui/src/components/FeatureCard.tsx @@ -1,52 +1,60 @@ -import { CheckCircle2, Circle, Loader2, MessageCircle } from 'lucide-react' -import type { Feature, ActiveAgent } from '../lib/types' -import { DependencyBadge } from './DependencyBadge' -import { AgentAvatar } from './AgentAvatar' -import { Card, CardContent } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' +import { CheckCircle2, Circle, Loader2, MessageCircle } from "lucide-react"; +import type { Feature, ActiveAgent } from "../lib/types"; +import { DependencyBadge } from "./DependencyBadge"; +import { AgentAvatar } from "./AgentAvatar"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; interface FeatureCardProps { - feature: Feature - onClick: () => void - isInProgress?: boolean - allFeatures?: Feature[] - activeAgent?: ActiveAgent + feature: Feature; + onClick: () => void; + isInProgress?: boolean; + allFeatures?: Feature[]; + activeAgent?: ActiveAgent; } // Generate consistent color for category function getCategoryColor(category: string): string { const colors = [ - 'bg-pink-500', - 'bg-cyan-500', - 'bg-green-500', - 'bg-yellow-500', - 'bg-orange-500', - 'bg-purple-500', - 'bg-blue-500', - ] + "bg-pink-500", + "bg-cyan-500", + "bg-green-500", + "bg-yellow-500", + "bg-orange-500", + "bg-purple-500", + "bg-blue-500", + ]; - let hash = 0 + let hash = 0; for (let i = 0; i < category.length; i++) { - hash = category.charCodeAt(i) + ((hash << 5) - hash) + hash = category.charCodeAt(i) + ((hash << 5) - hash); } - return colors[Math.abs(hash) % colors.length] + return colors[Math.abs(hash) % colors.length]; } -export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [], activeAgent }: FeatureCardProps) { - const categoryColor = getCategoryColor(feature.category) - const isBlocked = feature.blocked || (feature.blocking_dependencies && feature.blocking_dependencies.length > 0) - const hasActiveAgent = !!activeAgent +export function FeatureCard({ + feature, + onClick, + isInProgress, + allFeatures = [], + activeAgent, +}: FeatureCardProps) { + const categoryColor = getCategoryColor(feature.category); + const isBlocked = + feature.blocked || + (feature.blocking_dependencies && feature.blocking_dependencies.length > 0); + const hasActiveAgent = !!activeAgent; return ( @@ -56,7 +64,11 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [], {feature.category} - +
#{feature.priority} @@ -64,9 +76,7 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
{/* Name */} -

- {feature.name} -

+

{feature.name}

{/* Description */}

@@ -76,14 +86,21 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [], {/* Agent working on this feature */} {activeAgent && (

- +
{activeAgent.agentName} is working on this!
{activeAgent.thought && (
- +

{activeAgent.thought}

@@ -119,5 +136,5 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
- ) + ); } diff --git a/ui/src/components/FeatureModal.tsx b/ui/src/components/FeatureModal.tsx index 25f396f2..47152d1d 100644 --- a/ui/src/components/FeatureModal.tsx +++ b/ui/src/components/FeatureModal.tsx @@ -1,92 +1,115 @@ -import { useState } from 'react' -import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle } from 'lucide-react' -import { useSkipFeature, useDeleteFeature, useFeatures } from '../hooks/useProjects' -import { EditFeatureForm } from './EditFeatureForm' -import type { Feature } from '../lib/types' +import { useState } from "react"; +import { + X, + CheckCircle2, + Circle, + SkipForward, + Trash2, + Loader2, + AlertCircle, + Pencil, + Link2, + AlertTriangle, +} from "lucide-react"; +import { + useSkipFeature, + useDeleteFeature, + useFeatures, +} from "../hooks/useProjects"; +import { EditFeatureForm } from "./EditFeatureForm"; +import type { Feature } from "../lib/types"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Separator } from '@/components/ui/separator' +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Separator } from "@/components/ui/separator"; // Generate consistent color for category function getCategoryColor(category: string): string { const colors = [ - 'bg-pink-500', - 'bg-cyan-500', - 'bg-green-500', - 'bg-yellow-500', - 'bg-orange-500', - 'bg-purple-500', - 'bg-blue-500', - ] + "bg-pink-500", + "bg-cyan-500", + "bg-green-500", + "bg-yellow-500", + "bg-orange-500", + "bg-purple-500", + "bg-blue-500", + ]; - let hash = 0 + let hash = 0; for (let i = 0; i < category.length; i++) { - hash = category.charCodeAt(i) + ((hash << 5) - hash) + hash = category.charCodeAt(i) + ((hash << 5) - hash); } - return colors[Math.abs(hash) % colors.length] + return colors[Math.abs(hash) % colors.length]; } interface FeatureModalProps { - feature: Feature - projectName: string - onClose: () => void + feature: Feature; + projectName: string; + onClose: () => void; } -export function FeatureModal({ feature, projectName, onClose }: FeatureModalProps) { - const [error, setError] = useState(null) - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - const [showEdit, setShowEdit] = useState(false) +export function FeatureModal({ + feature, + projectName, + onClose, +}: FeatureModalProps) { + const [error, setError] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showEdit, setShowEdit] = useState(false); - const skipFeature = useSkipFeature(projectName) - const deleteFeature = useDeleteFeature(projectName) - const { data: allFeatures } = useFeatures(projectName) + const skipFeature = useSkipFeature(projectName); + const deleteFeature = useDeleteFeature(projectName); + const { data: allFeatures } = useFeatures(projectName); // Build a map of feature ID to feature for looking up dependency names - const featureMap = new Map() + const featureMap = new Map(); if (allFeatures) { - ;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done].forEach(f => { - featureMap.set(f.id, f) - }) + [ + ...allFeatures.pending, + ...allFeatures.in_progress, + ...allFeatures.done, + ].forEach((f) => { + featureMap.set(f.id, f); + }); } // Get dependency features const dependencies = (feature.dependencies || []) - .map(id => featureMap.get(id)) - .filter((f): f is Feature => f !== undefined) + .map((id) => featureMap.get(id)) + .filter((f): f is Feature => f !== undefined); // Get blocking dependencies (unmet dependencies) const blockingDeps = (feature.blocking_dependencies || []) - .map(id => featureMap.get(id)) - .filter((f): f is Feature => f !== undefined) + .map((id) => featureMap.get(id)) + .filter((f): f is Feature => f !== undefined); const handleSkip = async () => { - setError(null) + setError(null); try { - await skipFeature.mutateAsync(feature.id) - onClose() + await skipFeature.mutateAsync(feature.id); + onClose(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to skip feature') + setError(err instanceof Error ? err.message : "Failed to skip feature"); } - } + }; const handleDelete = async () => { - setError(null) + setError(null); try { - await deleteFeature.mutateAsync(feature.id) - onClose() + await deleteFeature.mutateAsync(feature.id); + onClose(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to delete feature') + setError(err instanceof Error ? err.message : "Failed to delete feature"); } - } + }; // Show edit form when in edit mode if (showEdit) { @@ -97,7 +120,7 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp onClose={() => setShowEdit(false)} onSaved={onClose} /> - ) + ); } return ( @@ -106,7 +129,9 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp {/* Header */}
- + {feature.category}
@@ -144,7 +169,9 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp ) : ( <> - PENDING + + PENDING + )} @@ -162,16 +189,25 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp {/* Blocked By Warning */} {blockingDeps.length > 0 && ( - + -

Blocked By

+

+ Blocked By +

- This feature cannot start until the following dependencies are complete: + This feature cannot start until the following dependencies are + complete:

    - {blockingDeps.map(dep => ( -
  • + {blockingDeps.map((dep) => ( +
  • #{dep.id} {dep.name} @@ -190,7 +226,7 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp Depends On
      - {dependencies.map(dep => ( + {dependencies.map((dep) => (
    • )} - #{dep.id} - {dep.name} + + #{dep.id} + + + {dep.name} +
    • ))}
    @@ -216,10 +256,7 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
      {feature.steps.map((step, index) => ( -
    1. +
    2. {step}
    3. ))} @@ -248,7 +285,7 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp {deleteFeature.isPending ? ( ) : ( - 'Yes, Delete' + "Yes, Delete" )} @@ -181,11 +200,15 @@ export function FolderBrowser({ onSelect, onCancel, initialPath }: FolderBrowser {directoryData?.drives && directoryData.drives.length > 0 && (
      - Drives: + + Drives: + {directoryData.drives.map((drive) => (
      ) : error ? (
      - +

      - {error instanceof Error ? error.message : 'Failed to load directory'} + {error instanceof Error + ? error.message + : "Failed to load directory"}

      -
      @@ -229,27 +262,39 @@ export function FolderBrowser({ onSelect, onCancel, initialPath }: FolderBrowser flex items-center gap-2 hover:bg-muted border-2 border-transparent transition-colors - ${selectedPath === entry.path ? 'bg-primary/10 border-primary' : ''} + ${selectedPath === entry.path ? "bg-primary/10 border-primary" : ""} `} > {selectedPath === entry.path ? ( - + ) : ( - + )} {entry.name} {entry.has_children && ( - + )} ))} {/* Empty state */} - {directoryData?.entries.filter((e) => e.is_directory).length === 0 && ( + {directoryData?.entries.filter((e) => e.is_directory).length === + 0 && (

      No subfolders

      -

      You can create a new folder or select this directory.

      +

      + You can create a new folder or select this directory. +

      )}
      @@ -269,11 +314,11 @@ export function FolderBrowser({ onSelect, onCancel, initialPath }: FolderBrowser className="flex-1" autoFocus onKeyDown={(e) => { - if (e.key === 'Enter') handleCreateFolder() - if (e.key === 'Escape') { - setIsCreatingFolder(false) - setNewFolderName('') - setCreateError(null) + if (e.key === "Enter") handleCreateFolder(); + if (e.key === "Escape") { + setIsCreatingFolder(false); + setNewFolderName(""); + setCreateError(null); } }} /> @@ -284,9 +329,9 @@ export function FolderBrowser({ onSelect, onCancel, initialPath }: FolderBrowser variant="ghost" size="sm" onClick={() => { - setIsCreatingFolder(false) - setNewFolderName('') - setCreateError(null) + setIsCreatingFolder(false); + setNewFolderName(""); + setCreateError(null); }} > Cancel @@ -306,8 +351,12 @@ export function FolderBrowser({ onSelect, onCancel, initialPath }: FolderBrowser {/* Selected path display */} -
      Selected path:
      -
      {selectedPath || 'No folder selected'}
      +
      + Selected path: +
      +
      + {selectedPath || "No folder selected"} +
      {selectedPath && (
      This folder will contain all project files @@ -338,5 +387,5 @@ export function FolderBrowser({ onSelect, onCancel, initialPath }: FolderBrowser
- ) + ); } diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx index 207ac588..4182d153 100644 --- a/ui/src/components/KanbanBoard.tsx +++ b/ui/src/components/KanbanBoard.tsx @@ -1,42 +1,58 @@ -import { KanbanColumn } from './KanbanColumn' -import type { Feature, FeatureListResponse, ActiveAgent } from '../lib/types' -import { Card, CardContent } from '@/components/ui/card' +import { KanbanColumn } from "./KanbanColumn"; +import type { Feature, FeatureListResponse, ActiveAgent } from "../lib/types"; +import { Card, CardContent } from "@/components/ui/card"; interface KanbanBoardProps { - features: FeatureListResponse | undefined - onFeatureClick: (feature: Feature) => void - onAddFeature?: () => void - onExpandProject?: () => void - activeAgents?: ActiveAgent[] - onCreateSpec?: () => void - hasSpec?: boolean + features: FeatureListResponse | undefined; + onFeatureClick: (feature: Feature) => void; + onAddFeature?: () => void; + onExpandProject?: () => void; + activeAgents?: ActiveAgent[]; + onCreateSpec?: () => void; + hasSpec?: boolean; } -export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [], onCreateSpec, hasSpec = true }: KanbanBoardProps) { - const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length) > 0 +export function KanbanBoard({ + features, + onFeatureClick, + onAddFeature, + onExpandProject, + activeAgents = [], + onCreateSpec, + hasSpec = true, +}: KanbanBoardProps) { + const hasFeatures = + features && + features.pending.length + + features.in_progress.length + + features.done.length > + 0; // Combine all features for dependency status calculation const allFeatures = features ? [...features.pending, ...features.in_progress, ...features.done] - : [] + : []; if (!features) { return (
- {['Pending', 'In Progress', 'Done'].map(title => ( + {["Pending", "In Progress", "Done"].map((title) => (
- {[1, 2, 3].map(i => ( -
+ {[1, 2, 3].map((i) => ( +
))}
))}
- ) + ); } return ( @@ -74,5 +90,5 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr onFeatureClick={onFeatureClick} />
- ) + ); } diff --git a/ui/src/components/KanbanColumn.tsx b/ui/src/components/KanbanColumn.tsx index 9ab8902a..5eada8b5 100644 --- a/ui/src/components/KanbanColumn.tsx +++ b/ui/src/components/KanbanColumn.tsx @@ -1,30 +1,30 @@ -import { FeatureCard } from './FeatureCard' -import { Plus, Sparkles, Wand2 } from 'lucide-react' -import type { Feature, ActiveAgent } from '../lib/types' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' +import { FeatureCard } from "./FeatureCard"; +import { Plus, Sparkles, Wand2 } from "lucide-react"; +import type { Feature, ActiveAgent } from "../lib/types"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; interface KanbanColumnProps { - title: string - count: number - features: Feature[] - allFeatures?: Feature[] - activeAgents?: ActiveAgent[] - color: 'pending' | 'progress' | 'done' - onFeatureClick: (feature: Feature) => void - onAddFeature?: () => void - onExpandProject?: () => void - showExpandButton?: boolean - onCreateSpec?: () => void - showCreateSpec?: boolean + title: string; + count: number; + features: Feature[]; + allFeatures?: Feature[]; + activeAgents?: ActiveAgent[]; + color: "pending" | "progress" | "done"; + onFeatureClick: (feature: Feature) => void; + onAddFeature?: () => void; + onExpandProject?: () => void; + showExpandButton?: boolean; + onCreateSpec?: () => void; + showCreateSpec?: boolean; } const colorMap = { - pending: 'border-t-4 border-t-muted', - progress: 'border-t-4 border-t-primary', - done: 'border-t-4 border-t-primary', -} + pending: "border-t-4 border-t-muted", + progress: "border-t-4 border-t-primary", + done: "border-t-4 border-t-primary", +}; export function KanbanColumn({ title, @@ -42,8 +42,8 @@ export function KanbanColumn({ }: KanbanColumnProps) { // Create a map of feature ID to active agent for quick lookup const agentByFeatureId = new Map( - activeAgents.map(agent => [agent.featureId, agent]) - ) + activeAgents.map((agent) => [agent.featureId, agent]), + ); return ( @@ -93,7 +93,7 @@ export function KanbanColumn({
) : ( - 'No features' + "No features" )}
) : ( @@ -106,7 +106,7 @@ export function KanbanColumn({ onFeatureClick(feature)} - isInProgress={color === 'progress'} + isInProgress={color === "progress"} allFeatures={allFeatures} activeAgent={agentByFeatureId.get(feature.id)} /> @@ -117,5 +117,5 @@ export function KanbanColumn({
- ) + ); } diff --git a/ui/src/components/KeyboardShortcutsHelp.tsx b/ui/src/components/KeyboardShortcutsHelp.tsx index 8ead81fa..d9083e66 100644 --- a/ui/src/components/KeyboardShortcutsHelp.tsx +++ b/ui/src/components/KeyboardShortcutsHelp.tsx @@ -1,53 +1,60 @@ -import { useEffect, useCallback } from 'react' -import { Keyboard } from 'lucide-react' +import { useEffect, useCallback } from "react"; +import { Keyboard } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, -} from '@/components/ui/dialog' -import { Badge } from '@/components/ui/badge' +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; interface Shortcut { - key: string - description: string - context?: string + key: string; + description: string; + context?: string; } const shortcuts: Shortcut[] = [ - { key: '?', description: 'Show keyboard shortcuts' }, - { key: 'D', description: 'Toggle debug panel' }, - { key: 'T', description: 'Toggle terminal tab' }, - { key: 'N', description: 'Add new feature', context: 'with project' }, - { key: 'E', description: 'Expand project with AI', context: 'with features' }, - { key: 'A', description: 'Toggle AI assistant', context: 'with project' }, - { key: 'G', description: 'Toggle Kanban/Graph view', context: 'with project' }, - { key: ',', description: 'Open settings' }, - { key: 'Esc', description: 'Close modal/panel' }, -] + { key: "?", description: "Show keyboard shortcuts" }, + { key: "D", description: "Toggle debug panel" }, + { key: "T", description: "Toggle terminal tab" }, + { key: "N", description: "Add new feature", context: "with project" }, + { key: "E", description: "Expand project with AI", context: "with features" }, + { key: "A", description: "Toggle AI assistant", context: "with project" }, + { + key: "G", + description: "Toggle Kanban/Graph view", + context: "with project", + }, + { key: ",", description: "Open settings" }, + { key: "Esc", description: "Close modal/panel" }, +]; interface KeyboardShortcutsHelpProps { - isOpen: boolean - onClose: () => void + isOpen: boolean; + onClose: () => void; } -export function KeyboardShortcutsHelp({ isOpen, onClose }: KeyboardShortcutsHelpProps) { +export function KeyboardShortcutsHelp({ + isOpen, + onClose, +}: KeyboardShortcutsHelpProps) { const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (e.key === 'Escape' || e.key === '?') { - e.preventDefault() - onClose() + if (e.key === "Escape" || e.key === "?") { + e.preventDefault(); + onClose(); } }, - [onClose] - ) + [onClose], + ); useEffect(() => { if (isOpen) { - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); } - }, [isOpen, handleKeyDown]) + }, [isOpen, handleKeyDown]); return ( !open && onClose()}> @@ -87,5 +94,5 @@ export function KeyboardShortcutsHelp({ isOpen, onClose }: KeyboardShortcutsHelp

- ) + ); } diff --git a/ui/src/components/NewProjectModal.tsx b/ui/src/components/NewProjectModal.tsx index 38e567f6..5980da49 100644 --- a/ui/src/components/NewProjectModal.tsx +++ b/ui/src/components/NewProjectModal.tsx @@ -9,12 +9,20 @@ * 4b. If manual: Create project and close */ -import { useState } from 'react' -import { Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder } from 'lucide-react' -import { useCreateProject } from '../hooks/useProjects' -import { SpecCreationChat } from './SpecCreationChat' -import { FolderBrowser } from './FolderBrowser' -import { startAgent } from '../lib/api' +import { useState } from "react"; +import { + Bot, + FileEdit, + ArrowRight, + ArrowLeft, + Loader2, + CheckCircle2, + Folder, +} from "lucide-react"; +import { useCreateProject } from "../hooks/useProjects"; +import { SpecCreationChat } from "./SpecCreationChat"; +import { FolderBrowser } from "./FolderBrowser"; +import { startAgent } from "../lib/api"; import { Dialog, DialogContent, @@ -22,24 +30,24 @@ import { DialogTitle, DialogDescription, DialogFooter, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Badge } from '@/components/ui/badge' -import { Card, CardContent } from '@/components/ui/card' +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; -type InitializerStatus = 'idle' | 'starting' | 'error' +type InitializerStatus = "idle" | "starting" | "error"; -type Step = 'name' | 'folder' | 'method' | 'chat' | 'complete' -type SpecMethod = 'claude' | 'manual' +type Step = "name" | "folder" | "method" | "chat" | "complete"; +type SpecMethod = "claude" | "manual"; interface NewProjectModalProps { - isOpen: boolean - onClose: () => void - onProjectCreated: (projectName: string) => void - onStepChange?: (step: Step) => void + isOpen: boolean; + onClose: () => void; + onProjectCreated: (projectName: string) => void; + onStepChange?: (step: Step) => void; } export function NewProjectModal({ @@ -48,79 +56,84 @@ export function NewProjectModal({ onProjectCreated, onStepChange, }: NewProjectModalProps) { - const [step, setStep] = useState('name') - const [projectName, setProjectName] = useState('') - const [projectPath, setProjectPath] = useState(null) - const [_specMethod, setSpecMethod] = useState(null) - const [error, setError] = useState(null) - const [initializerStatus, setInitializerStatus] = useState('idle') - const [initializerError, setInitializerError] = useState(null) - const [yoloModeSelected, setYoloModeSelected] = useState(false) + const [step, setStep] = useState("name"); + const [projectName, setProjectName] = useState(""); + const [projectPath, setProjectPath] = useState(null); + const [_specMethod, setSpecMethod] = useState(null); + const [error, setError] = useState(null); + const [initializerStatus, setInitializerStatus] = + useState("idle"); + const [initializerError, setInitializerError] = useState(null); + const [yoloModeSelected, setYoloModeSelected] = useState(false); // Suppress unused variable warning - specMethod may be used in future - void _specMethod + void _specMethod; - const createProject = useCreateProject() + const createProject = useCreateProject(); // Wrapper to notify parent of step changes const changeStep = (newStep: Step) => { - setStep(newStep) - onStepChange?.(newStep) - } + setStep(newStep); + onStepChange?.(newStep); + }; - if (!isOpen) return null + if (!isOpen) return null; const handleNameSubmit = (e: React.FormEvent) => { - e.preventDefault() - const trimmed = projectName.trim() + e.preventDefault(); + const trimmed = projectName.trim(); if (!trimmed) { - setError('Please enter a project name') - return + setError("Please enter a project name"); + return; } if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) { - setError('Project name can only contain letters, numbers, hyphens, and underscores') - return + setError( + "Project name can only contain letters, numbers, hyphens, and underscores", + ); + return; } - setError(null) - changeStep('folder') - } + setError(null); + changeStep("folder"); + }; const handleFolderSelect = (path: string) => { - setProjectPath(path) - changeStep('method') - } + setProjectPath(path); + changeStep("method"); + }; const handleFolderCancel = () => { - changeStep('name') - } + changeStep("name"); + }; const handleMethodSelect = async (method: SpecMethod) => { - setSpecMethod(method) + setSpecMethod(method); if (!projectPath) { - setError('Please select a project folder first') - changeStep('folder') - return + setError("Please select a project folder first"); + changeStep("folder"); + return; } - if (method === 'manual') { + if (method === "manual") { // Create project immediately with manual method try { const project = await createProject.mutateAsync({ name: projectName.trim(), path: projectPath, - specMethod: 'manual', - }) - changeStep('complete') + specMethod: "manual", + }); + changeStep("complete"); setTimeout(() => { - onProjectCreated(project.name) - handleClose() - }, 1500) + onProjectCreated(project.name); + handleClose(); + }, 1500); } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Failed to create project') + setError( + err instanceof Error ? err.message : "Failed to create project", + ); } } else { // Create project then show chat @@ -128,80 +141,87 @@ export function NewProjectModal({ await createProject.mutateAsync({ name: projectName.trim(), path: projectPath, - specMethod: 'claude', - }) - changeStep('chat') + specMethod: "claude", + }); + changeStep("chat"); } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Failed to create project') + setError( + err instanceof Error ? err.message : "Failed to create project", + ); } } - } + }; - const handleSpecComplete = async (_specPath: string, yoloMode: boolean = false) => { + const handleSpecComplete = async ( + _specPath: string, + yoloMode: boolean = false, + ) => { // Save yoloMode for retry - setYoloModeSelected(yoloMode) + setYoloModeSelected(yoloMode); // Auto-start the initializer agent - setInitializerStatus('starting') + setInitializerStatus("starting"); try { // Use default concurrency of 3 to match AgentControl.tsx default await startAgent(projectName.trim(), { yoloMode, maxConcurrency: 3, - }) + }); // Success - navigate to project - changeStep('complete') + changeStep("complete"); setTimeout(() => { - onProjectCreated(projectName.trim()) - handleClose() - }, 1500) + onProjectCreated(projectName.trim()); + handleClose(); + }, 1500); } catch (err) { - setInitializerStatus('error') - setInitializerError(err instanceof Error ? err.message : 'Failed to start agent') + setInitializerStatus("error"); + setInitializerError( + err instanceof Error ? err.message : "Failed to start agent", + ); } - } + }; const handleRetryInitializer = () => { - setInitializerError(null) - setInitializerStatus('idle') - handleSpecComplete('', yoloModeSelected) - } + setInitializerError(null); + setInitializerStatus("idle"); + handleSpecComplete("", yoloModeSelected); + }; const handleChatCancel = () => { // Go back to method selection but keep the project - changeStep('method') - setSpecMethod(null) - } + changeStep("method"); + setSpecMethod(null); + }; const handleExitToProject = () => { // Exit chat and go directly to project - user can start agent manually - onProjectCreated(projectName.trim()) - handleClose() - } + onProjectCreated(projectName.trim()); + handleClose(); + }; const handleClose = () => { - changeStep('name') - setProjectName('') - setProjectPath(null) - setSpecMethod(null) - setError(null) - setInitializerStatus('idle') - setInitializerError(null) - setYoloModeSelected(false) - onClose() - } + changeStep("name"); + setProjectName(""); + setProjectPath(null); + setSpecMethod(null); + setError(null); + setInitializerStatus("idle"); + setInitializerError(null); + setYoloModeSelected(false); + onClose(); + }; const handleBack = () => { - if (step === 'method') { - changeStep('folder') - setSpecMethod(null) - } else if (step === 'folder') { - changeStep('name') - setProjectPath(null) + if (step === "method") { + changeStep("folder"); + setSpecMethod(null); + } else if (step === "folder") { + changeStep("name"); + setProjectPath(null); } - } + }; // Full-screen chat view - if (step === 'chat') { + if (step === "chat") { return (
- ) + ); } // Folder step uses larger modal - if (step === 'folder') { + if (step === "folder") { return ( !open && handleClose()}> @@ -229,7 +249,9 @@ export function NewProjectModal({
Select Project Location - Select the folder to use for project {projectName}. Create a new folder or choose an existing one. + Select the folder to use for project{" "} + {projectName} + . Create a new folder or choose an existing one.
@@ -244,7 +266,7 @@ export function NewProjectModal({
- ) + ); } return ( @@ -252,14 +274,14 @@ export function NewProjectModal({ - {step === 'name' && 'Create New Project'} - {step === 'method' && 'Choose Setup Method'} - {step === 'complete' && 'Project Created!'} + {step === "name" && "Create New Project"} + {step === "method" && "Choose Setup Method"} + {step === "complete" && "Project Created!"} {/* Step 1: Project Name */} - {step === 'name' && ( + {step === "name" && (
@@ -293,7 +315,7 @@ export function NewProjectModal({ )} {/* Step 2: Spec Method */} - {step === 'method' && ( + {step === "method" && (
How would you like to define your project? @@ -303,7 +325,9 @@ export function NewProjectModal({ {/* Claude option */} !createProject.isPending && handleMethodSelect('claude')} + onClick={() => + !createProject.isPending && handleMethodSelect("claude") + } >
@@ -312,11 +336,14 @@ export function NewProjectModal({
- Create with Claude + + Create with Claude + Recommended

- Interactive conversation to define features and generate your app specification automatically. + Interactive conversation to define features and generate + your app specification automatically.

@@ -326,17 +353,25 @@ export function NewProjectModal({ {/* Manual option */} !createProject.isPending && handleMethodSelect('manual')} + onClick={() => + !createProject.isPending && handleMethodSelect("manual") + } >
- +
- Edit Templates Manually + + Edit Templates Manually +

- Edit the template files directly. Best for developers who want full control. + Edit the template files directly. Best for developers + who want full control.

@@ -371,7 +406,7 @@ export function NewProjectModal({ )} {/* Step 3: Complete */} - {step === 'complete' && ( + {step === "complete" && (
@@ -382,11 +417,13 @@ export function NewProjectModal({

- Redirecting... + + Redirecting... +
)} - ) + ); } diff --git a/ui/src/components/OrchestratorAvatar.tsx b/ui/src/components/OrchestratorAvatar.tsx index bbf3dab5..25399989 100644 --- a/ui/src/components/OrchestratorAvatar.tsx +++ b/ui/src/components/OrchestratorAvatar.tsx @@ -1,52 +1,104 @@ -import type { OrchestratorState } from '../lib/types' +import type { OrchestratorState } from "../lib/types"; interface OrchestratorAvatarProps { - state: OrchestratorState - size?: 'sm' | 'md' | 'lg' + state: OrchestratorState; + size?: "sm" | "md" | "lg"; } const SIZES = { - sm: { svg: 32, font: 'text-xs' }, - md: { svg: 48, font: 'text-sm' }, - lg: { svg: 64, font: 'text-base' }, -} + sm: { svg: 32, font: "text-xs" }, + md: { svg: 48, font: "text-sm" }, + lg: { svg: 64, font: "text-base" }, +}; // Maestro color scheme - Deep violet const MAESTRO_COLORS = { - primary: '#7C3AED', // Violet-600 - secondary: '#A78BFA', // Violet-400 - accent: '#EDE9FE', // Violet-100 - baton: '#FBBF24', // Amber-400 for the baton - gold: '#F59E0B', // Amber-500 for accents -} + primary: "#7C3AED", // Violet-600 + secondary: "#A78BFA", // Violet-400 + accent: "#EDE9FE", // Violet-100 + baton: "#FBBF24", // Amber-400 for the baton + gold: "#F59E0B", // Amber-500 for accents +}; // Maestro SVG - Robot conductor with baton -function MaestroSVG({ size, state }: { size: number; state: OrchestratorState }) { +function MaestroSVG({ + size, + state, +}: { + size: number; + state: OrchestratorState; +}) { // Animation transform based on state - const batonAnimation = state === 'spawning' ? 'animate-conducting' : - state === 'scheduling' ? 'animate-baton-tap' : '' + const batonAnimation = + state === "spawning" + ? "animate-conducting" + : state === "scheduling" + ? "animate-baton-tap" + : ""; return ( {/* Conductor's podium hint */} - + {/* Robot body - formal conductor style */} - + {/* Tuxedo front / formal vest */} - + {/* Bow tie */} {/* Robot head */} - + {/* Conductor's cap */} - - + + {/* Eyes */} @@ -56,102 +108,151 @@ function MaestroSVG({ size, state }: { size: number; state: OrchestratorState }) {/* Smile */} - + {/* Arms */} - - + + {/* Hand holding baton */} - + {/* Baton */} - - + + {/* Subtle music notes when active */} - {(state === 'spawning' || state === 'monitoring') && ( + {(state === "spawning" || state === "monitoring") && ( <> - + - + )} - ) + ); } // Animation classes based on orchestrator state function getStateAnimation(state: OrchestratorState): string { switch (state) { - case 'idle': - return 'animate-bounce-gentle' - case 'initializing': - return 'animate-thinking' - case 'scheduling': - return 'animate-thinking' - case 'spawning': - return 'animate-working' - case 'monitoring': - return 'animate-bounce-gentle' - case 'complete': - return 'animate-celebrate' + case "idle": + return "animate-bounce-gentle"; + case "initializing": + return "animate-thinking"; + case "scheduling": + return "animate-thinking"; + case "spawning": + return "animate-working"; + case "monitoring": + return "animate-bounce-gentle"; + case "complete": + return "animate-celebrate"; default: - return '' + return ""; } } // Glow effect based on state function getStateGlow(state: OrchestratorState): string { switch (state) { - case 'initializing': - return 'shadow-[0_0_12px_rgba(124,58,237,0.4)]' - case 'scheduling': - return 'shadow-[0_0_10px_rgba(167,139,250,0.5)]' - case 'spawning': - return 'shadow-[0_0_16px_rgba(124,58,237,0.6)]' - case 'monitoring': - return 'shadow-[0_0_8px_rgba(167,139,250,0.4)]' - case 'complete': - return 'shadow-[0_0_20px_rgba(112,224,0,0.6)]' + case "initializing": + return "shadow-[0_0_12px_rgba(124,58,237,0.4)]"; + case "scheduling": + return "shadow-[0_0_10px_rgba(167,139,250,0.5)]"; + case "spawning": + return "shadow-[0_0_16px_rgba(124,58,237,0.6)]"; + case "monitoring": + return "shadow-[0_0_8px_rgba(167,139,250,0.4)]"; + case "complete": + return "shadow-[0_0_20px_rgba(112,224,0,0.6)]"; default: - return '' + return ""; } } // Get human-readable state description for accessibility function getStateDescription(state: OrchestratorState): string { switch (state) { - case 'idle': - return 'waiting' - case 'initializing': - return 'initializing features' - case 'scheduling': - return 'selecting next features' - case 'spawning': - return 'spawning agents' - case 'monitoring': - return 'monitoring progress' - case 'complete': - return 'all features complete' + case "idle": + return "waiting"; + case "initializing": + return "initializing features"; + case "scheduling": + return "selecting next features"; + case "spawning": + return "spawning agents"; + case "monitoring": + return "monitoring progress"; + case "complete": + return "all features complete"; default: - return state + return state; } } -export function OrchestratorAvatar({ state, size = 'md' }: OrchestratorAvatarProps) { - const { svg: svgSize } = SIZES[size] - const stateDesc = getStateDescription(state) - const ariaLabel = `Orchestrator Maestro is ${stateDesc}` +export function OrchestratorAvatar({ + state, + size = "md", +}: OrchestratorAvatarProps) { + const { svg: svgSize } = SIZES[size]; + const stateDesc = getStateDescription(state); + const ariaLabel = `Orchestrator Maestro is ${stateDesc}`; return (
- ) + ); } diff --git a/ui/src/components/OrchestratorStatusCard.tsx b/ui/src/components/OrchestratorStatusCard.tsx index 7ad8b242..07257fec 100644 --- a/ui/src/components/OrchestratorStatusCard.tsx +++ b/ui/src/components/OrchestratorStatusCard.tsx @@ -1,68 +1,78 @@ -import { useState } from 'react' -import { ChevronDown, ChevronUp, Code, FlaskConical, Clock, Lock, Sparkles } from 'lucide-react' -import { OrchestratorAvatar } from './OrchestratorAvatar' -import type { OrchestratorStatus, OrchestratorState } from '../lib/types' -import { Card, CardContent } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' +import { useState } from "react"; +import { + ChevronDown, + ChevronUp, + Code, + FlaskConical, + Clock, + Lock, + Sparkles, +} from "lucide-react"; +import { OrchestratorAvatar } from "./OrchestratorAvatar"; +import type { OrchestratorStatus, OrchestratorState } from "../lib/types"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; interface OrchestratorStatusCardProps { - status: OrchestratorStatus + status: OrchestratorStatus; } // Get a friendly state description function getStateText(state: OrchestratorState): string { switch (state) { - case 'idle': - return 'Standing by...' - case 'initializing': - return 'Setting up features...' - case 'scheduling': - return 'Planning next moves...' - case 'spawning': - return 'Deploying agents...' - case 'monitoring': - return 'Watching progress...' - case 'complete': - return 'Mission accomplished!' + case "idle": + return "Standing by..."; + case "initializing": + return "Setting up features..."; + case "scheduling": + return "Planning next moves..."; + case "spawning": + return "Deploying agents..."; + case "monitoring": + return "Watching progress..."; + case "complete": + return "Mission accomplished!"; default: - return 'Orchestrating...' + return "Orchestrating..."; } } // Get state color function getStateColor(state: OrchestratorState): string { switch (state) { - case 'complete': - return 'text-primary' - case 'spawning': - return 'text-violet-600 dark:text-violet-400' - case 'scheduling': - case 'monitoring': - return 'text-primary' - case 'initializing': - return 'text-yellow-600 dark:text-yellow-400' + case "complete": + return "text-primary"; + case "spawning": + return "text-violet-600 dark:text-violet-400"; + case "scheduling": + case "monitoring": + return "text-primary"; + case "initializing": + return "text-yellow-600 dark:text-yellow-400"; default: - return 'text-muted-foreground' + return "text-muted-foreground"; } } // Format timestamp to relative time function formatRelativeTime(timestamp: string): string { - const now = new Date() - const then = new Date(timestamp) - const diffMs = now.getTime() - then.getTime() - const diffSecs = Math.floor(diffMs / 1000) + const now = new Date(); + const then = new Date(timestamp); + const diffMs = now.getTime() - then.getTime(); + const diffSecs = Math.floor(diffMs / 1000); - if (diffSecs < 5) return 'just now' - if (diffSecs < 60) return `${diffSecs}s ago` - const diffMins = Math.floor(diffSecs / 60) - if (diffMins < 60) return `${diffMins}m ago` - return `${Math.floor(diffMins / 60)}h ago` + if (diffSecs < 5) return "just now"; + if (diffSecs < 60) return `${diffSecs}s ago`; + const diffMins = Math.floor(diffSecs / 60); + if (diffMins < 60) return `${diffMins}m ago`; + return `${Math.floor(diffMins / 60)}h ago`; } -export function OrchestratorStatusCard({ status }: OrchestratorStatusCardProps) { - const [showEvents, setShowEvents] = useState(false) +export function OrchestratorStatusCard({ + status, +}: OrchestratorStatusCardProps) { + const [showEvents, setShowEvents] = useState(false); return ( @@ -78,7 +88,9 @@ export function OrchestratorStatusCard({ status }: OrchestratorStatusCardProps) Maestro - + {getStateText(status.state)}
@@ -91,26 +103,38 @@ export function OrchestratorStatusCard({ status }: OrchestratorStatusCardProps) {/* Status badges row */}
{/* Coding agents badge */} - + Coding: {status.codingAgents} {/* Testing agents badge */} - + Testing: {status.testingAgents} {/* Ready queue badge */} - + Ready: {status.readyCount} {/* Blocked badge (only show if > 0) */} {status.blockedCount > 0 && ( - + Blocked: {status.blockedCount} @@ -145,9 +169,7 @@ export function OrchestratorStatusCard({ status }: OrchestratorStatusCardProps) {formatRelativeTime(event.timestamp)} - - {event.message} - + {event.message}
))}
@@ -155,5 +177,5 @@ export function OrchestratorStatusCard({ status }: OrchestratorStatusCardProps) )} - ) + ); } diff --git a/ui/src/components/ProgressDashboard.tsx b/ui/src/components/ProgressDashboard.tsx index 7b935db3..7b94c2ef 100644 --- a/ui/src/components/ProgressDashboard.tsx +++ b/ui/src/components/ProgressDashboard.tsx @@ -1,12 +1,12 @@ -import { Wifi, WifiOff } from 'lucide-react' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' +import { Wifi, WifiOff } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; interface ProgressDashboardProps { - passing: number - total: number - percentage: number - isConnected: boolean + passing: number; + total: number; + percentage: number; + isConnected: boolean; } export function ProgressDashboard({ @@ -21,7 +21,10 @@ export function ProgressDashboard({ Progress - + {isConnected ? ( <> @@ -69,9 +72,7 @@ export function ProgressDashboard({
/
- - {total} - + {total} Total @@ -79,5 +80,5 @@ export function ProgressDashboard({
- ) + ); } diff --git a/ui/src/components/ProjectSelector.tsx b/ui/src/components/ProjectSelector.tsx index f7ef3566..ed35033d 100644 --- a/ui/src/components/ProjectSelector.tsx +++ b/ui/src/components/ProjectSelector.tsx @@ -1,25 +1,26 @@ -import { useState } from 'react' -import { ChevronDown, Plus, FolderOpen, Loader2, Trash2 } from 'lucide-react' -import type { ProjectSummary } from '../lib/types' -import { NewProjectModal } from './NewProjectModal' -import { ConfirmDialog } from './ConfirmDialog' -import { useDeleteProject } from '../hooks/useProjects' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' +import { useState } from "react"; +import { ChevronDown, Plus, FolderOpen, Loader2, Trash2, GitBranch } from "lucide-react"; +import type { ProjectSummary } from "../lib/types"; +import { NewProjectModal } from "./NewProjectModal"; +import { CloneRepoModal } from "./CloneRepoModal"; +import { ConfirmDialog } from "./ConfirmDialog"; +import { useDeleteProject } from "../hooks/useProjects"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' +} from "@/components/ui/dropdown-menu"; interface ProjectSelectorProps { - projects: ProjectSummary[] - selectedProject: string | null - onSelectProject: (name: string | null) => void - isLoading: boolean - onSpecCreatingChange?: (isCreating: boolean) => void + projects: ProjectSummary[]; + selectedProject: string | null; + onSelectProject: (name: string | null) => void; + isLoading: boolean; + onSpecCreatingChange?: (isCreating: boolean) => void; } export function ProjectSelector({ @@ -29,43 +30,44 @@ export function ProjectSelector({ isLoading, onSpecCreatingChange, }: ProjectSelectorProps) { - const [isOpen, setIsOpen] = useState(false) - const [showNewProjectModal, setShowNewProjectModal] = useState(false) - const [projectToDelete, setProjectToDelete] = useState(null) + const [isOpen, setIsOpen] = useState(false); + const [showNewProjectModal, setShowNewProjectModal] = useState(false); + const [showCloneRepoModal, setShowCloneRepoModal] = useState(false); + const [projectToDelete, setProjectToDelete] = useState(null); - const deleteProject = useDeleteProject() + const deleteProject = useDeleteProject(); const handleProjectCreated = (projectName: string) => { - onSelectProject(projectName) - setIsOpen(false) - } + onSelectProject(projectName); + setIsOpen(false); + }; const handleDeleteClick = (e: React.MouseEvent, projectName: string) => { - e.stopPropagation() - e.preventDefault() - setProjectToDelete(projectName) - } + e.stopPropagation(); + e.preventDefault(); + setProjectToDelete(projectName); + }; const handleConfirmDelete = async () => { - if (!projectToDelete) return + if (!projectToDelete) return; try { - await deleteProject.mutateAsync(projectToDelete) + await deleteProject.mutateAsync(projectToDelete); if (selectedProject === projectToDelete) { - onSelectProject(null) + onSelectProject(null); } - setProjectToDelete(null) + setProjectToDelete(null); } catch (error) { - console.error('Failed to delete project:', error) - setProjectToDelete(null) + console.error("Failed to delete project:", error); + setProjectToDelete(null); } - } + }; const handleCancelDelete = () => { - setProjectToDelete(null) - } + setProjectToDelete(null); + }; - const selectedProjectData = projects.find(p => p.name === selectedProject) + const selectedProjectData = projects.find((p) => p.name === selectedProject); return (
@@ -85,27 +87,35 @@ export function ProjectSelector({ {selectedProject} {selectedProjectData && selectedProjectData.stats.total > 0 && ( - {selectedProjectData.stats.percentage}% + + {selectedProjectData.stats.percentage}% + )} ) : ( Select Project )} - + - + {projects.length > 0 ? (
- {projects.map(project => ( + {projects.map((project) => ( { - onSelectProject(project.name) + onSelectProject(project.name); }} > @@ -139,7 +149,17 @@ export function ProjectSelector({
{ - setShowNewProjectModal(true) + setShowCloneRepoModal(true); + }} + className="cursor-pointer font-semibold" + disabled={!selectedProject} + > + + Clone Repository + + { + setShowNewProjectModal(true); }} className="cursor-pointer font-semibold" > @@ -155,7 +175,13 @@ export function ProjectSelector({ isOpen={showNewProjectModal} onClose={() => setShowNewProjectModal(false)} onProjectCreated={handleProjectCreated} - onStepChange={(step) => onSpecCreatingChange?.(step === 'chat')} + onStepChange={(step) => onSpecCreatingChange?.(step === "chat")} + /> + + setShowCloneRepoModal(false)} + projectName={selectedProject} /> {/* Delete Confirmation Dialog */} @@ -171,5 +197,5 @@ export function ProjectSelector({ onCancel={handleCancelDelete} />
- ) + ); } diff --git a/ui/src/components/QuestionOptions.tsx b/ui/src/components/QuestionOptions.tsx index 122f5bcc..a198c7bc 100644 --- a/ui/src/components/QuestionOptions.tsx +++ b/ui/src/components/QuestionOptions.tsx @@ -5,18 +5,18 @@ * Shows clickable option buttons. */ -import { useState } from 'react' -import { Check } from 'lucide-react' -import type { SpecQuestion } from '../lib/types' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Card, CardContent } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' +import { useState } from "react"; +import { Check } from "lucide-react"; +import type { SpecQuestion } from "../lib/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; interface QuestionOptionsProps { - questions: SpecQuestion[] - onSubmit: (answers: Record) => void - disabled?: boolean + questions: SpecQuestion[]; + onSubmit: (answers: Record) => void; + disabled?: boolean; } export function QuestionOptions({ @@ -25,72 +25,82 @@ export function QuestionOptions({ disabled = false, }: QuestionOptionsProps) { // Track selected answers for each question - const [answers, setAnswers] = useState>({}) - const [customInputs, setCustomInputs] = useState>({}) - const [showCustomInput, setShowCustomInput] = useState>({}) - - const handleOptionClick = (questionIdx: number, optionLabel: string, multiSelect: boolean) => { - const key = String(questionIdx) - - if (optionLabel === 'Other') { - setShowCustomInput((prev) => ({ ...prev, [key]: true })) - return + const [answers, setAnswers] = useState>({}); + const [customInputs, setCustomInputs] = useState>({}); + const [showCustomInput, setShowCustomInput] = useState< + Record + >({}); + + const handleOptionClick = ( + questionIdx: number, + optionLabel: string, + multiSelect: boolean, + ) => { + const key = String(questionIdx); + + if (optionLabel === "Other") { + setShowCustomInput((prev) => ({ ...prev, [key]: true })); + return; } - setShowCustomInput((prev) => ({ ...prev, [key]: false })) + setShowCustomInput((prev) => ({ ...prev, [key]: false })); setAnswers((prev) => { if (multiSelect) { - const current = (prev[key] as string[]) || [] + const current = (prev[key] as string[]) || []; if (current.includes(optionLabel)) { - return { ...prev, [key]: current.filter((o) => o !== optionLabel) } + return { ...prev, [key]: current.filter((o) => o !== optionLabel) }; } else { - return { ...prev, [key]: [...current, optionLabel] } + return { ...prev, [key]: [...current, optionLabel] }; } } else { - return { ...prev, [key]: optionLabel } + return { ...prev, [key]: optionLabel }; } - }) - } + }); + }; const handleCustomInputChange = (questionIdx: number, value: string) => { - const key = String(questionIdx) - setCustomInputs((prev) => ({ ...prev, [key]: value })) - setAnswers((prev) => ({ ...prev, [key]: value })) - } + const key = String(questionIdx); + setCustomInputs((prev) => ({ ...prev, [key]: value })); + setAnswers((prev) => ({ ...prev, [key]: value })); + }; const handleSubmit = () => { // Ensure all questions have answers - const finalAnswers: Record = {} + const finalAnswers: Record = {}; questions.forEach((_, idx) => { - const key = String(idx) + const key = String(idx); if (showCustomInput[key] && customInputs[key]) { - finalAnswers[key] = customInputs[key] + finalAnswers[key] = customInputs[key]; } else if (answers[key]) { - finalAnswers[key] = answers[key] + finalAnswers[key] = answers[key]; } - }) + }); - onSubmit(finalAnswers) - } + onSubmit(finalAnswers); + }; - const isOptionSelected = (questionIdx: number, optionLabel: string, multiSelect: boolean) => { - const key = String(questionIdx) - const answer = answers[key] + const isOptionSelected = ( + questionIdx: number, + optionLabel: string, + multiSelect: boolean, + ) => { + const key = String(questionIdx); + const answer = answers[key]; if (multiSelect) { - return Array.isArray(answer) && answer.includes(optionLabel) + return Array.isArray(answer) && answer.includes(optionLabel); } - return answer === optionLabel - } + return answer === optionLabel; + }; const hasAnswer = (questionIdx: number) => { - const key = String(questionIdx) - return !!(answers[key] || (showCustomInput[key] && customInputs[key])) - } + const key = String(questionIdx); + return !!(answers[key] || (showCustomInput[key] && customInputs[key])); + }; - const allQuestionsAnswered = questions.every((_, idx) => hasAnswer(idx)) + const allQuestionsAnswered = questions.every((_, idx) => hasAnswer(idx)); return (
@@ -100,9 +110,7 @@ export function QuestionOptions({ {/* Question header */}
{q.header} - - {q.question} - + {q.question} {q.multiSelect && ( (select multiple) @@ -113,19 +121,25 @@ export function QuestionOptions({ {/* Options grid */}
{q.options.map((opt, optIdx) => { - const isSelected = isOptionSelected(questionIdx, opt.label, q.multiSelect) + const isSelected = isOptionSelected( + questionIdx, + opt.label, + q.multiSelect, + ); return (
- ) + ); })} {/* "Other" option */}
- ) + ); } diff --git a/ui/src/components/ScheduleModal.tsx b/ui/src/components/ScheduleModal.tsx index 0adbdc7a..654ac492 100644 --- a/ui/src/components/ScheduleModal.tsx +++ b/ui/src/components/ScheduleModal.tsx @@ -4,14 +4,14 @@ * Modal for managing agent schedules (create, edit, delete). */ -import { useState, useEffect, useRef } from 'react' -import { Clock, GitBranch, Trash2 } from 'lucide-react' +import { useState, useEffect, useRef } from "react"; +import { Clock, GitBranch, Trash2 } from "lucide-react"; import { useSchedules, useCreateSchedule, useDeleteSchedule, useToggleSchedule, -} from '../hooks/useSchedules' +} from "../hooks/useSchedules"; import { utcToLocalWithDayShift, localToUTCWithDayShift, @@ -20,136 +20,157 @@ import { DAYS, isDayActive, toggleDay, -} from '../lib/timeUtils' -import type { ScheduleCreate } from '../lib/types' +} from "../lib/timeUtils"; +import type { ScheduleCreate } from "../lib/types"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Card, CardContent } from '@/components/ui/card' -import { Checkbox } from '@/components/ui/checkbox' -import { Separator } from '@/components/ui/separator' +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; interface ScheduleModalProps { - projectName: string - isOpen: boolean - onClose: () => void + projectName: string; + isOpen: boolean; + onClose: () => void; } -export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalProps) { - const modalRef = useRef(null) - const firstFocusableRef = useRef(null) +export function ScheduleModal({ + projectName, + isOpen, + onClose, +}: ScheduleModalProps) { + const modalRef = useRef(null); + const firstFocusableRef = useRef(null); // Queries and mutations - const { data: schedulesData, isLoading } = useSchedules(projectName) - const createSchedule = useCreateSchedule(projectName) - const deleteSchedule = useDeleteSchedule(projectName) - const toggleSchedule = useToggleSchedule(projectName) + const { data: schedulesData, isLoading } = useSchedules(projectName); + const createSchedule = useCreateSchedule(projectName); + const deleteSchedule = useDeleteSchedule(projectName); + const toggleSchedule = useToggleSchedule(projectName); // Form state for new schedule const [newSchedule, setNewSchedule] = useState({ - start_time: '22:00', + start_time: "22:00", duration_minutes: 240, days_of_week: 31, // Weekdays by default enabled: true, yolo_mode: false, model: null, max_concurrency: 3, - }) + }); - const [error, setError] = useState(null) + const [error, setError] = useState(null); // Focus trap useEffect(() => { if (isOpen && firstFocusableRef.current) { - firstFocusableRef.current.focus() + firstFocusableRef.current.focus(); } - }, [isOpen]) + }, [isOpen]); - const schedules = schedulesData?.schedules || [] + const schedules = schedulesData?.schedules || []; const handleCreateSchedule = async () => { try { - setError(null) + setError(null); // Validate if (newSchedule.days_of_week === 0) { - setError('Please select at least one day') - return + setError("Please select at least one day"); + return; } // Validate duration - if (newSchedule.duration_minutes < 1 || newSchedule.duration_minutes > 1440) { - setError('Duration must be between 1 and 1440 minutes') - return + if ( + newSchedule.duration_minutes < 1 || + newSchedule.duration_minutes > 1440 + ) { + setError("Duration must be between 1 and 1440 minutes"); + return; } // Convert local time to UTC and get day shift - const { time: utcTime, dayShift } = localToUTCWithDayShift(newSchedule.start_time) + const { time: utcTime, dayShift } = localToUTCWithDayShift( + newSchedule.start_time, + ); // Adjust days_of_week based on day shift - const adjustedDays = adjustDaysForDayShift(newSchedule.days_of_week, dayShift) + const adjustedDays = adjustDaysForDayShift( + newSchedule.days_of_week, + dayShift, + ); const scheduleToCreate = { ...newSchedule, start_time: utcTime, days_of_week: adjustedDays, - } + }; - await createSchedule.mutateAsync(scheduleToCreate) + await createSchedule.mutateAsync(scheduleToCreate); // Reset form setNewSchedule({ - start_time: '22:00', + start_time: "22:00", duration_minutes: 240, days_of_week: 31, enabled: true, yolo_mode: false, model: null, max_concurrency: 3, - }) + }); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create schedule') + setError( + err instanceof Error ? err.message : "Failed to create schedule", + ); } - } + }; const handleToggleSchedule = async (scheduleId: number, enabled: boolean) => { try { - setError(null) - await toggleSchedule.mutateAsync({ scheduleId, enabled: !enabled }) + setError(null); + await toggleSchedule.mutateAsync({ scheduleId, enabled: !enabled }); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to toggle schedule') + setError( + err instanceof Error ? err.message : "Failed to toggle schedule", + ); } - } + }; const handleDeleteSchedule = async (scheduleId: number) => { - if (!confirm('Are you sure you want to delete this schedule?')) return + if (!confirm("Are you sure you want to delete this schedule?")) return; try { - setError(null) - await deleteSchedule.mutateAsync(scheduleId) + setError(null); + await deleteSchedule.mutateAsync(scheduleId); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to delete schedule') + setError( + err instanceof Error ? err.message : "Failed to delete schedule", + ); } - } + }; const handleToggleDay = (dayBit: number) => { setNewSchedule((prev) => ({ ...prev, days_of_week: toggleDay(prev.days_of_week, dayBit), - })) - } + })); + }; return ( !open && onClose()}> - + {/* Header */} @@ -178,9 +199,14 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro
{schedules.map((schedule) => { // Convert UTC time to local and get day shift for display - const { time: localTime, dayShift } = utcToLocalWithDayShift(schedule.start_time) - const duration = formatDuration(schedule.duration_minutes) - const displayDays = adjustDaysForDayShift(schedule.days_of_week, dayShift) + const { time: localTime, dayShift } = utcToLocalWithDayShift( + schedule.start_time, + ); + const duration = formatDuration(schedule.duration_minutes); + const displayDays = adjustDaysForDayShift( + schedule.days_of_week, + dayShift, + ); return ( @@ -189,7 +215,9 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro
{/* Time and duration */}
- {localTime} + + {localTime} + for {duration} @@ -198,34 +226,43 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro {/* Days */}
{DAYS.map((day) => { - const isActive = isDayActive(displayDays, day.bit) + const isActive = isDayActive( + displayDays, + day.bit, + ); return ( {day.label} - ) + ); })}
{/* Metadata */}
{schedule.yolo_mode && ( - YOLO mode + + YOLO mode + )} {schedule.max_concurrency}x - {schedule.model && Model: {schedule.model}} + {schedule.model && ( + Model: {schedule.model} + )} {schedule.crash_count > 0 && ( - Crashes: {schedule.crash_count} + + Crashes: {schedule.crash_count} + )}
@@ -236,11 +273,20 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro {/* Delete button */} @@ -257,7 +303,7 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro
- ) + ); })}
)} @@ -284,7 +330,10 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro type="time" value={newSchedule.start_time} onChange={(e) => - setNewSchedule((prev) => ({ ...prev, start_time: e.target.value })) + setNewSchedule((prev) => ({ + ...prev, + start_time: e.target.value, + })) } />
@@ -296,12 +345,14 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro max="1440" value={newSchedule.duration_minutes} onChange={(e) => { - const parsed = parseInt(e.target.value, 10) - const value = isNaN(parsed) ? 1 : Math.max(1, Math.min(1440, parsed)) + const parsed = parseInt(e.target.value, 10); + const value = isNaN(parsed) + ? 1 + : Math.max(1, Math.min(1440, parsed)); setNewSchedule((prev) => ({ ...prev, duration_minutes: value, - })) + })); }} />

@@ -315,17 +366,20 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro

{DAYS.map((day) => { - const isActive = isDayActive(newSchedule.days_of_week, day.bit) + const isActive = isDayActive( + newSchedule.days_of_week, + day.bit, + ); return ( - ) + ); })}
@@ -336,7 +390,10 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro id="yolo-mode" checked={newSchedule.yolo_mode} onCheckedChange={(checked) => - setNewSchedule((prev) => ({ ...prev, yolo_mode: checked === true })) + setNewSchedule((prev) => ({ + ...prev, + yolo_mode: checked === true, + })) } />
@@ -376,9 +442,12 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro - setNewSchedule((prev) => ({ ...prev, model: e.target.value || null })) + setNewSchedule((prev) => ({ + ...prev, + model: e.target.value || null, + })) } />
@@ -392,12 +461,14 @@ export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalPro - ) + ); } diff --git a/ui/src/components/SettingsModal.tsx b/ui/src/components/SettingsModal.tsx index a4b787f5..cee7bc1a 100644 --- a/ui/src/components/SettingsModal.tsx +++ b/ui/src/components/SettingsModal.tsx @@ -1,48 +1,52 @@ -import { Loader2, AlertCircle, Check, Moon, Sun } from 'lucide-react' -import { useSettings, useUpdateSettings, useAvailableModels } from '../hooks/useProjects' -import { useTheme, THEMES } from '../hooks/useTheme' +import { Loader2, AlertCircle, Check, Moon, Sun } from "lucide-react"; +import { + useSettings, + useUpdateSettings, + useAvailableModels, +} from "../hooks/useProjects"; +import { useTheme, THEMES } from "../hooks/useTheme"; import { Dialog, DialogContent, DialogHeader, DialogTitle, -} from '@/components/ui/dialog' -import { Switch } from '@/components/ui/switch' -import { Label } from '@/components/ui/label' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' +} from "@/components/ui/dialog"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; interface SettingsModalProps { - isOpen: boolean - onClose: () => void + isOpen: boolean; + onClose: () => void; } export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { - const { data: settings, isLoading, isError, refetch } = useSettings() - const { data: modelsData } = useAvailableModels() - const updateSettings = useUpdateSettings() - const { theme, setTheme, darkMode, toggleDarkMode } = useTheme() + const { data: settings, isLoading, isError, refetch } = useSettings(); + const { data: modelsData } = useAvailableModels(); + const updateSettings = useUpdateSettings(); + const { theme, setTheme, darkMode, toggleDarkMode } = useTheme(); const handleYoloToggle = () => { if (settings && !updateSettings.isPending) { - updateSettings.mutate({ yolo_mode: !settings.yolo_mode }) + updateSettings.mutate({ yolo_mode: !settings.yolo_mode }); } - } + }; const handleModelChange = (modelId: string) => { if (!updateSettings.isPending) { - updateSettings.mutate({ model: modelId }) + updateSettings.mutate({ model: modelId }); } - } + }; const handleTestingRatioChange = (ratio: number) => { if (!updateSettings.isPending) { - updateSettings.mutate({ testing_agent_ratio: ratio }) + updateSettings.mutate({ testing_agent_ratio: ratio }); } - } + }; - const models = modelsData?.models ?? [] - const isSaving = updateSettings.isPending + const models = modelsData?.models ?? []; + const isSaving = updateSettings.isPending; return ( !open && onClose()}> @@ -92,29 +96,37 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { onClick={() => setTheme(themeOption.id)} className={`flex items-center gap-3 p-3 rounded-lg border-2 transition-colors text-left ${ theme === themeOption.id - ? 'border-primary bg-primary/5' - : 'border-border hover:border-primary/50 hover:bg-muted/50' + ? "border-primary bg-primary/5" + : "border-border hover:border-primary/50 hover:bg-muted/50" }`} > {/* Color swatches */}
{/* Theme info */}
-
{themeOption.name}
+
+ {themeOption.name} +
{themeOption.description}
@@ -147,7 +159,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { className="gap-2" > {darkMode ? : } - {darkMode ? 'Light' : 'Dark'} + {darkMode ? "Light" : "Dark"}
@@ -182,9 +194,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { disabled={isSaving} className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${ settings.model === model.id - ? 'bg-primary text-primary-foreground' - : 'bg-background text-foreground hover:bg-muted' - } ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`} + ? "bg-primary text-primary-foreground" + : "bg-background text-foreground hover:bg-muted" + } ${isSaving ? "opacity-50 cursor-not-allowed" : ""}`} > {model.name} @@ -206,9 +218,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { disabled={isSaving} className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${ settings.testing_agent_ratio === ratio - ? 'bg-primary text-primary-foreground' - : 'bg-background text-foreground hover:bg-muted' - } ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`} + ? "bg-primary text-primary-foreground" + : "bg-background text-foreground hover:bg-muted" + } ${isSaving ? "opacity-50 cursor-not-allowed" : ""}`} > {ratio} @@ -228,5 +240,5 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { )}
- ) + ); } diff --git a/ui/src/components/SetupWizard.tsx b/ui/src/components/SetupWizard.tsx index 79d009ee..f16d60c2 100644 --- a/ui/src/components/SetupWizard.tsx +++ b/ui/src/components/SetupWizard.tsx @@ -1,32 +1,37 @@ -import { useEffect, useCallback } from 'react' -import { CheckCircle2, XCircle, Loader2, ExternalLink } from 'lucide-react' -import { useSetupStatus, useHealthCheck } from '../hooks/useProjects' -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { useEffect, useCallback } from "react"; +import { CheckCircle2, XCircle, Loader2, ExternalLink } from "lucide-react"; +import { useSetupStatus, useHealthCheck } from "../hooks/useProjects"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; interface SetupWizardProps { - onComplete: () => void + onComplete: () => void; } export function SetupWizard({ onComplete }: SetupWizardProps) { - const { data: setupStatus, isLoading: setupLoading, error: setupError } = useSetupStatus() - const { data: health, error: healthError } = useHealthCheck() + const { + data: setupStatus, + isLoading: setupLoading, + error: setupError, + } = useSetupStatus(); + const { data: health, error: healthError } = useHealthCheck(); - const isApiHealthy = health?.status === 'healthy' && !healthError - const isReady = isApiHealthy && setupStatus?.claude_cli && setupStatus?.credentials + const isApiHealthy = health?.status === "healthy" && !healthError; + const isReady = + isApiHealthy && setupStatus?.claude_cli && setupStatus?.credentials; // Memoize the completion check to avoid infinite loops const checkAndComplete = useCallback(() => { if (isReady) { - onComplete() + onComplete(); } - }, [isReady, onComplete]) + }, [isReady, onComplete]); // Auto-complete if everything is ready useEffect(() => { - checkAndComplete() - }, [checkAndComplete]) + checkAndComplete(); + }, [checkAndComplete]); return (
@@ -44,7 +49,9 @@ export function SetupWizard({ onComplete }: SetupWizardProps) { {/* Claude CLI */} @@ -53,12 +60,12 @@ export function SetupWizard({ onComplete }: SetupWizardProps) { description="Claude Code CLI is installed" status={ setupLoading - ? 'loading' + ? "loading" : setupError - ? 'error' - : setupStatus?.claude_cli - ? 'success' - : 'error' + ? "error" + : setupStatus?.claude_cli + ? "success" + : "error" } helpLink="https://docs.anthropic.com/claude/claude-code" helpText="Install Claude Code" @@ -70,12 +77,12 @@ export function SetupWizard({ onComplete }: SetupWizardProps) { description="API credentials are configured" status={ setupLoading - ? 'loading' + ? "loading" : setupError - ? 'error' - : setupStatus?.credentials - ? 'success' - : 'error' + ? "error" + : setupStatus?.credentials + ? "success" + : "error" } helpLink="https://console.anthropic.com/account/keys" helpText="Get API Key" @@ -87,17 +94,35 @@ export function SetupWizard({ onComplete }: SetupWizardProps) { description="Node.js is installed (for UI dev)" status={ setupLoading - ? 'loading' + ? "loading" : setupError - ? 'error' - : setupStatus?.node - ? 'success' - : 'warning' + ? "error" + : setupStatus?.node + ? "success" + : "warning" } helpLink="https://nodejs.org" helpText="Install Node.js" optional /> + + {/* Gemini (chat-only) */} +
{/* Continue Button */} @@ -116,24 +141,24 @@ export function SetupWizard({ onComplete }: SetupWizardProps) { Setup Error {healthError - ? 'Cannot connect to the backend server. Make sure to run start_ui.py first.' - : 'Failed to check setup status.'} + ? "Cannot connect to the backend server. Make sure to run start_ui.py first." + : "Failed to check setup status."} )}
- ) + ); } interface SetupItemProps { - label: string - description: string - status: 'success' | 'error' | 'warning' | 'loading' - helpLink?: string - helpText?: string - optional?: boolean + label: string; + description: string; + status: "success" | "error" | "warning" | "loading"; + helpLink?: string; + helpText?: string; + optional?: boolean; } function SetupItem({ @@ -148,11 +173,11 @@ function SetupItem({
{/* Status Icon */}
- {status === 'success' ? ( + {status === "success" ? ( - ) : status === 'error' ? ( + ) : status === "error" ? ( - ) : status === 'warning' ? ( + ) : status === "warning" ? ( ) : ( @@ -162,17 +187,15 @@ function SetupItem({ {/* Content */}
- {label} + + {label} + {optional && ( - - (optional) - + (optional) )}
-

- {description} -

- {(status === 'error' || status === 'warning') && helpLink && ( +

{description}

+ {(status === "error" || status === "warning") && helpLink && (
- ) + ); } diff --git a/ui/src/components/SpecCreationChat.tsx b/ui/src/components/SpecCreationChat.tsx index 1aa804af..e3c20143 100644 --- a/ui/src/components/SpecCreationChat.tsx +++ b/ui/src/components/SpecCreationChat.tsx @@ -5,21 +5,35 @@ * Handles the 7-phase conversation flow for creating app specifications. */ -import { useCallback, useEffect, useRef, useState } from 'react' -import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Loader2, ArrowRight, Zap, Paperclip, ExternalLink, FileText } from 'lucide-react' -import { useSpecChat } from '../hooks/useSpecChat' -import { ChatMessage } from './ChatMessage' -import { QuestionOptions } from './QuestionOptions' -import { TypingIndicator } from './TypingIndicator' -import type { ImageAttachment } from '../lib/types' -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' -import { Card, CardContent } from '@/components/ui/card' -import { Alert, AlertDescription } from '@/components/ui/alert' +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Send, + X, + CheckCircle2, + AlertCircle, + Wifi, + WifiOff, + RotateCcw, + Loader2, + ArrowRight, + Zap, + Paperclip, + ExternalLink, + FileText, +} from "lucide-react"; +import { useSpecChat } from "../hooks/useSpecChat"; +import { ChatMessage } from "./ChatMessage"; +import { QuestionOptions } from "./QuestionOptions"; +import { TypingIndicator } from "./TypingIndicator"; +import type { ImageAttachment } from "../lib/types"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Card, CardContent } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; // Image upload validation constants -const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB -const ALLOWED_TYPES = ['image/jpeg', 'image/png'] +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB +const ALLOWED_TYPES = ["image/jpeg", "image/png"]; // Sample prompt for quick testing const SAMPLE_PROMPT = `Let's call it Simple Todo. This is a really simple web app that I can use to track my to-do items using a Kanban board. I should be able to add to-dos and then drag and drop them through the Kanban board. The different columns in the Kanban board are: @@ -37,18 +51,18 @@ There is no need for user authentication either. All the to-dos will be stored i Users should have the ability to easily clear out all the completed To-Dos. They should also be able to filter and search for To-Dos as well. -You choose the rest. Keep it simple. Should be 25 features.` +You choose the rest. Keep it simple. Should be 25 features.`; -type InitializerStatus = 'idle' | 'starting' | 'error' +type InitializerStatus = "idle" | "starting" | "error"; interface SpecCreationChatProps { - projectName: string - onComplete: (specPath: string, yoloMode?: boolean) => void - onCancel: () => void - onExitToProject: () => void // Exit to project without starting agent - initializerStatus?: InitializerStatus - initializerError?: string | null - onRetryInitializer?: () => void + projectName: string; + onComplete: (specPath: string, yoloMode?: boolean) => void; + onCancel: () => void; + onExitToProject: () => void; // Exit to project without starting agent + initializerStatus?: InitializerStatus; + initializerError?: string | null; + onRetryInitializer?: () => void; } export function SpecCreationChat({ @@ -56,17 +70,19 @@ export function SpecCreationChat({ onComplete, onCancel, onExitToProject, - initializerStatus = 'idle', + initializerStatus = "idle", initializerError = null, onRetryInitializer, }: SpecCreationChatProps) { - const [input, setInput] = useState('') - const [error, setError] = useState(null) - const [yoloEnabled, setYoloEnabled] = useState(false) - const [pendingAttachments, setPendingAttachments] = useState([]) - const messagesEndRef = useRef(null) - const inputRef = useRef(null) - const fileInputRef = useRef(null) + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + const [yoloEnabled, setYoloEnabled] = useState(false); + const [pendingAttachments, setPendingAttachments] = useState< + ImageAttachment[] + >([]); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const fileInputRef = useRef(null); const { messages, @@ -82,149 +98,154 @@ export function SpecCreationChat({ projectName, onComplete, onError: (err) => setError(err), - }) + }); // Start the chat session when component mounts useEffect(() => { - start() + start(); return () => { - disconnect() - } - }, []) // eslint-disable-line react-hooks/exhaustive-deps + disconnect(); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps // Scroll to bottom when messages change useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages, currentQuestions, isLoading]) + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, currentQuestions, isLoading]); // Focus input when not loading and no questions useEffect(() => { if (!isLoading && !currentQuestions && inputRef.current) { - inputRef.current.focus() + inputRef.current.focus(); } - }, [isLoading, currentQuestions]) + }, [isLoading, currentQuestions]); const handleSendMessage = () => { - const trimmed = input.trim() + const trimmed = input.trim(); // Allow sending if there's text OR attachments - if ((!trimmed && pendingAttachments.length === 0) || isLoading) return + if ((!trimmed && pendingAttachments.length === 0) || isLoading) return; // Detect /exit command - exit to project without sending to Claude if (/^\s*\/exit\s*$/i.test(trimmed)) { - setInput('') - onExitToProject() - return + setInput(""); + onExitToProject(); + return; } - sendMessage(trimmed, pendingAttachments.length > 0 ? pendingAttachments : undefined) - setInput('') - setPendingAttachments([]) // Clear attachments after sending + sendMessage( + trimmed, + pendingAttachments.length > 0 ? pendingAttachments : undefined, + ); + setInput(""); + setPendingAttachments([]); // Clear attachments after sending // Reset textarea height after sending if (inputRef.current) { - inputRef.current.style.height = 'auto' + inputRef.current.style.height = "auto"; } - } + }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSendMessage() + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); } - } + }; const handleAnswerSubmit = (answers: Record) => { - sendAnswer(answers) - } + sendAnswer(answers); + }; // File handling for image attachments const handleFileSelect = useCallback((files: FileList | null) => { - if (!files) return + if (!files) return; Array.from(files).forEach((file) => { // Validate file type if (!ALLOWED_TYPES.includes(file.type)) { - setError(`Invalid file type: ${file.name}. Only JPEG and PNG are supported.`) - return + setError( + `Invalid file type: ${file.name}. Only JPEG and PNG are supported.`, + ); + return; } // Validate file size if (file.size > MAX_FILE_SIZE) { - setError(`File too large: ${file.name}. Maximum size is 5 MB.`) - return + setError(`File too large: ${file.name}. Maximum size is 5 MB.`); + return; } // Read and convert to base64 - const reader = new FileReader() + const reader = new FileReader(); reader.onload = (e) => { - const dataUrl = e.target?.result as string + const dataUrl = e.target?.result as string; // dataUrl is "data:image/png;base64,XXXXXX" - const base64Data = dataUrl.split(',')[1] + const base64Data = dataUrl.split(",")[1]; const attachment: ImageAttachment = { id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, filename: file.name, - mimeType: file.type as 'image/jpeg' | 'image/png', + mimeType: file.type as "image/jpeg" | "image/png", base64Data, previewUrl: dataUrl, size: file.size, - } + }; - setPendingAttachments((prev) => [...prev, attachment]) - } - reader.readAsDataURL(file) - }) - }, []) + setPendingAttachments((prev) => [...prev, attachment]); + }; + reader.readAsDataURL(file); + }); + }, []); const handleRemoveAttachment = useCallback((id: string) => { - setPendingAttachments((prev) => prev.filter((a) => a.id !== id)) - }, []) + setPendingAttachments((prev) => prev.filter((a) => a.id !== id)); + }, []); const handleDrop = useCallback( (e: React.DragEvent) => { - e.preventDefault() - handleFileSelect(e.dataTransfer.files) + e.preventDefault(); + handleFileSelect(e.dataTransfer.files); }, - [handleFileSelect] - ) + [handleFileSelect], + ); const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault() - }, []) + e.preventDefault(); + }, []); // Connection status indicator const ConnectionIndicator = () => { switch (connectionStatus) { - case 'connected': + case "connected": return ( Connected - ) - case 'connecting': + ); + case "connecting": return ( Connecting... - ) - case 'error': + ); + case "error": return ( Error - ) + ); default: return ( Disconnected - ) + ); } - } + }; return (
@@ -248,11 +269,11 @@ export function SpecCreationChat({ {/* Load Sample Prompt */} -
@@ -287,7 +303,10 @@ export function SpecCreationChat({ {/* Error banner */} {error && ( - + {error} @@ -399,7 +415,7 @@ export function SpecCreationChat({ {/* Attach button */}
)} {/* Completion footer */} {isComplete && ( -
+
- {initializerStatus === 'starting' ? ( + {initializerStatus === "starting" ? ( <> - Starting agent{yoloEnabled ? ' (YOLO mode)' : ''}... + Starting agent{yoloEnabled ? " (YOLO mode)" : ""}... - ) : initializerStatus === 'error' ? ( + ) : initializerStatus === "error" ? ( <> - {initializerError || 'Failed to start agent'} + {initializerError || "Failed to start agent"} ) : ( <> - Specification created successfully! + + Specification created successfully! + )}
- {initializerStatus === 'error' && onRetryInitializer && ( - )} - {initializerStatus === 'idle' && ( + {initializerStatus === "idle" && ( <> {/* YOLO Mode Toggle */} - @@ -514,5 +536,5 @@ export function SpecCreationChat({
)}
- ) + ); } diff --git a/ui/src/components/Terminal.tsx b/ui/src/components/Terminal.tsx index 63f47a2a..c7c10c06 100644 --- a/ui/src/components/Terminal.tsx +++ b/ui/src/components/Terminal.tsx @@ -5,136 +5,136 @@ * Supports input/output streaming, terminal resizing, and reconnection handling. */ -import { useEffect, useRef, useCallback, useState } from 'react' -import { Terminal as XTerm } from '@xterm/xterm' -import { FitAddon } from '@xterm/addon-fit' -import '@xterm/xterm/css/xterm.css' +import { useEffect, useRef, useCallback, useState } from "react"; +import { Terminal as XTerm } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import "@xterm/xterm/css/xterm.css"; interface TerminalProps { - projectName: string - terminalId: string - isActive: boolean + projectName: string; + terminalId: string; + isActive: boolean; } // WebSocket message types for terminal I/O interface TerminalInputMessage { - type: 'input' - data: string // base64 encoded + type: "input"; + data: string; // base64 encoded } interface TerminalResizeMessage { - type: 'resize' - cols: number - rows: number + type: "resize"; + cols: number; + rows: number; } interface TerminalOutputMessage { - type: 'output' - data: string // base64 encoded + type: "output"; + data: string; // base64 encoded } interface TerminalExitMessage { - type: 'exit' - code: number + type: "exit"; + code: number; } -type TerminalServerMessage = TerminalOutputMessage | TerminalExitMessage +type TerminalServerMessage = TerminalOutputMessage | TerminalExitMessage; // Clean terminal theme colors const TERMINAL_THEME = { - background: '#09090b', // zinc-950 - foreground: '#fafafa', // zinc-50 - cursor: '#3b82f6', // blue-500 - cursorAccent: '#09090b', - selectionBackground: 'rgba(59, 130, 246, 0.3)', - selectionForeground: '#ffffff', - black: '#09090b', - red: '#ef4444', - green: '#22c55e', - yellow: '#eab308', - blue: '#3b82f6', - magenta: '#a855f7', - cyan: '#06b6d4', - white: '#fafafa', - brightBlack: '#52525b', - brightRed: '#f87171', - brightGreen: '#4ade80', - brightYellow: '#facc15', - brightBlue: '#60a5fa', - brightMagenta: '#c084fc', - brightCyan: '#22d3ee', - brightWhite: '#ffffff', -} + background: "#09090b", // zinc-950 + foreground: "#fafafa", // zinc-50 + cursor: "#3b82f6", // blue-500 + cursorAccent: "#09090b", + selectionBackground: "rgba(59, 130, 246, 0.3)", + selectionForeground: "#ffffff", + black: "#09090b", + red: "#ef4444", + green: "#22c55e", + yellow: "#eab308", + blue: "#3b82f6", + magenta: "#a855f7", + cyan: "#06b6d4", + white: "#fafafa", + brightBlack: "#52525b", + brightRed: "#f87171", + brightGreen: "#4ade80", + brightYellow: "#facc15", + brightBlue: "#60a5fa", + brightMagenta: "#c084fc", + brightCyan: "#22d3ee", + brightWhite: "#ffffff", +}; // Reconnection configuration -const RECONNECT_DELAY_BASE = 1000 -const RECONNECT_DELAY_MAX = 30000 +const RECONNECT_DELAY_BASE = 1000; +const RECONNECT_DELAY_MAX = 30000; export function Terminal({ projectName, terminalId, isActive }: TerminalProps) { - const containerRef = useRef(null) - const terminalRef = useRef(null) - const fitAddonRef = useRef(null) - const wsRef = useRef(null) - const reconnectTimeoutRef = useRef(null) - const reconnectAttempts = useRef(0) - const isInitializedRef = useRef(false) - const isConnectingRef = useRef(false) - const hasExitedRef = useRef(false) + const containerRef = useRef(null); + const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const reconnectAttempts = useRef(0); + const isInitializedRef = useRef(false); + const isConnectingRef = useRef(false); + const hasExitedRef = useRef(false); // Track intentional disconnection to prevent auto-reconnect race condition - const isManualCloseRef = useRef(false) + const isManualCloseRef = useRef(false); // Store connect function in ref to avoid useEffect dependency issues - const connectRef = useRef<(() => void) | null>(null) + const connectRef = useRef<(() => void) | null>(null); // Track last project/terminal to avoid duplicate connect on initial activation - const lastProjectRef = useRef(null) - const lastTerminalIdRef = useRef(null) + const lastProjectRef = useRef(null); + const lastTerminalIdRef = useRef(null); // Track isActive in a ref to avoid stale closure issues in connect() - const isActiveRef = useRef(isActive) + const isActiveRef = useRef(isActive); - const [isConnected, setIsConnected] = useState(false) - const [hasExited, setHasExited] = useState(false) - const [exitCode, setExitCode] = useState(null) + const [isConnected, setIsConnected] = useState(false); + const [hasExited, setHasExited] = useState(false); + const [exitCode, setExitCode] = useState(null); // Keep ref in sync with state for use in callbacks without re-creating them useEffect(() => { - hasExitedRef.current = hasExited - }, [hasExited]) + hasExitedRef.current = hasExited; + }, [hasExited]); // Keep isActiveRef in sync with isActive prop to avoid stale closures useEffect(() => { - isActiveRef.current = isActive - }, [isActive]) + isActiveRef.current = isActive; + }, [isActive]); /** * Encode string to base64 */ const encodeBase64 = useCallback((str: string): string => { // Handle Unicode by encoding to UTF-8 first - const encoder = new TextEncoder() - const bytes = encoder.encode(str) - let binary = '' + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + let binary = ""; for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]) + binary += String.fromCharCode(bytes[i]); } - return btoa(binary) - }, []) + return btoa(binary); + }, []); /** * Decode base64 to string */ const decodeBase64 = useCallback((base64: string): string => { try { - const binary = atob(base64) - const bytes = new Uint8Array(binary.length) + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) + bytes[i] = binary.charCodeAt(i); } - const decoder = new TextDecoder() - return decoder.decode(bytes) + const decoder = new TextDecoder(); + return decoder.decode(bytes); } catch { - console.error('Failed to decode base64 data') - return '' + console.error("Failed to decode base64 data"); + return ""; } - }, []) + }, []); /** * Send a message through the WebSocket @@ -142,11 +142,11 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) { const sendMessage = useCallback( (message: TerminalInputMessage | TerminalResizeMessage) => { if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify(message)) + wsRef.current.send(JSON.stringify(message)); } }, - [] - ) + [], + ); /** * Send resize message to server @@ -154,14 +154,14 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) { const sendResize = useCallback( (cols: number, rows: number) => { const message: TerminalResizeMessage = { - type: 'resize', + type: "resize", cols, rows, - } - sendMessage(message) + }; + sendMessage(message); }, - [sendMessage] - ) + [sendMessage], + ); /** * Fit terminal to container and notify server of new dimensions @@ -170,31 +170,32 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) { if (fitAddonRef.current && terminalRef.current) { try { // Try to get proposed dimensions first - const dimensions = fitAddonRef.current.proposeDimensions() - const hasValidDimensions = dimensions && + const dimensions = fitAddonRef.current.proposeDimensions(); + const hasValidDimensions = + dimensions && dimensions.cols && dimensions.rows && !isNaN(dimensions.cols) && !isNaN(dimensions.rows) && dimensions.cols >= 1 && - dimensions.rows >= 1 + dimensions.rows >= 1; if (hasValidDimensions) { // Valid dimensions - fit the terminal - fitAddonRef.current.fit() + fitAddonRef.current.fit(); } // Always send resize with current terminal dimensions // This ensures the server has the correct size even if fit() was skipped - const { cols, rows } = terminalRef.current + const { cols, rows } = terminalRef.current; if (cols > 0 && rows > 0) { - sendResize(cols, rows) + sendResize(cols, rows); } } catch { // Container may not be visible yet, ignore } } - }, [sendResize]) + }, [sendResize]); /** * Connect to the terminal WebSocket @@ -202,7 +203,7 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) { const connect = useCallback(() => { // Use isActiveRef.current instead of isActive to avoid stale closure issues // when connect is called from setTimeout callbacks - if (!projectName || !terminalId || !isActiveRef.current) return + if (!projectName || !terminalId || !isActiveRef.current) return; // Prevent multiple simultaneous connection attempts if ( @@ -210,149 +211,152 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) { wsRef.current?.readyState === WebSocket.CONNECTING || wsRef.current?.readyState === WebSocket.OPEN ) { - return + return; } - isConnectingRef.current = true + isConnectingRef.current = true; // Clear any pending reconnection if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) - reconnectTimeoutRef.current = null + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; } // Build WebSocket URL with terminal ID - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - const host = window.location.host - const wsUrl = `${protocol}//${host}/api/terminal/ws/${encodeURIComponent(projectName)}/${encodeURIComponent(terminalId)}` + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const host = window.location.host; + const wsUrl = `${protocol}//${host}/api/terminal/ws/${encodeURIComponent(projectName)}/${encodeURIComponent(terminalId)}`; try { - const ws = new WebSocket(wsUrl) - wsRef.current = ws + const ws = new WebSocket(wsUrl); + wsRef.current = ws; ws.onopen = () => { - isConnectingRef.current = false - setIsConnected(true) - setHasExited(false) - setExitCode(null) - reconnectAttempts.current = 0 + isConnectingRef.current = false; + setIsConnected(true); + setHasExited(false); + setExitCode(null); + reconnectAttempts.current = 0; // Send initial size after connection if (terminalRef.current) { - const { cols, rows } = terminalRef.current - sendResize(cols, rows) + const { cols, rows } = terminalRef.current; + sendResize(cols, rows); } - } + }; ws.onmessage = (event) => { try { - const message: TerminalServerMessage = JSON.parse(event.data) + const message: TerminalServerMessage = JSON.parse(event.data); switch (message.type) { - case 'output': { - const decoded = decodeBase64(message.data) + case "output": { + const decoded = decodeBase64(message.data); if (decoded && terminalRef.current) { - terminalRef.current.write(decoded) + terminalRef.current.write(decoded); } - break + break; } - case 'exit': { - setHasExited(true) - setExitCode(message.code) + case "exit": { + setHasExited(true); + setExitCode(message.code); if (terminalRef.current) { - terminalRef.current.writeln('') + terminalRef.current.writeln(""); terminalRef.current.writeln( - `\x1b[33m[Shell exited with code ${message.code}]\x1b[0m` - ) + `\x1b[33m[Shell exited with code ${message.code}]\x1b[0m`, + ); terminalRef.current.writeln( - '\x1b[90mPress any key to reconnect...\x1b[0m' - ) + "\x1b[90mPress any key to reconnect...\x1b[0m", + ); } - break + break; } } } catch { - console.error('Failed to parse terminal WebSocket message') + console.error("Failed to parse terminal WebSocket message"); } - } + }; ws.onclose = () => { - isConnectingRef.current = false - setIsConnected(false) - wsRef.current = null + isConnectingRef.current = false; + setIsConnected(false); + wsRef.current = null; // Only reconnect if still active, not intentionally exited, and not manually closed // Use isActiveRef.current to get the current value, avoiding stale closure - const shouldReconnect = isActiveRef.current && !hasExitedRef.current && !isManualCloseRef.current + const shouldReconnect = + isActiveRef.current && + !hasExitedRef.current && + !isManualCloseRef.current; // Reset manual close flag after checking (so subsequent disconnects can auto-reconnect) - isManualCloseRef.current = false + isManualCloseRef.current = false; if (shouldReconnect) { // Exponential backoff reconnection const delay = Math.min( RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts.current), - RECONNECT_DELAY_MAX - ) - reconnectAttempts.current++ + RECONNECT_DELAY_MAX, + ); + reconnectAttempts.current++; reconnectTimeoutRef.current = window.setTimeout(() => { - connect() - }, delay) + connect(); + }, delay); } - } + }; ws.onerror = () => { // Will trigger onclose, which handles reconnection - ws.close() - } + ws.close(); + }; } catch { - isConnectingRef.current = false + isConnectingRef.current = false; // Failed to connect, attempt reconnection const delay = Math.min( RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts.current), - RECONNECT_DELAY_MAX - ) - reconnectAttempts.current++ + RECONNECT_DELAY_MAX, + ); + reconnectAttempts.current++; reconnectTimeoutRef.current = window.setTimeout(() => { - connect() - }, delay) + connect(); + }, delay); } - }, [projectName, terminalId, sendResize, decodeBase64]) + }, [projectName, terminalId, sendResize, decodeBase64]); // Keep connect ref up to date useEffect(() => { - connectRef.current = connect - }, [connect]) + connectRef.current = connect; + }, [connect]); /** * Initialize xterm.js terminal */ const initializeTerminal = useCallback(() => { - if (!containerRef.current || isInitializedRef.current) return + if (!containerRef.current || isInitializedRef.current) return; // Create terminal instance const terminal = new XTerm({ theme: TERMINAL_THEME, - fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace', + fontFamily: "JetBrains Mono, Consolas, Monaco, monospace", fontSize: 14, cursorBlink: true, - cursorStyle: 'block', + cursorStyle: "block", allowProposedApi: true, scrollback: 10000, - }) + }); // Create and load FitAddon - const fitAddon = new FitAddon() - terminal.loadAddon(fitAddon) + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); // Open terminal in container - terminal.open(containerRef.current) + terminal.open(containerRef.current); // Store references - terminalRef.current = terminal - fitAddonRef.current = fitAddon - isInitializedRef.current = true + terminalRef.current = terminal; + fitAddonRef.current = fitAddon; + isInitializedRef.current = true; // NOTE: Don't call fitTerminal() here - let the activation effect handle it // after layout is fully calculated. This avoids dimension calculation issues @@ -363,41 +367,41 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) { // If shell has exited, reconnect on any key // Use ref to avoid re-creating this callback when hasExited changes if (hasExitedRef.current) { - setHasExited(false) - setExitCode(null) - connectRef.current?.() - return + setHasExited(false); + setExitCode(null); + connectRef.current?.(); + return; } // Send input to server const message: TerminalInputMessage = { - type: 'input', + type: "input", data: encodeBase64(data), - } - sendMessage(message) - }) + }; + sendMessage(message); + }); // Handle terminal resize terminal.onResize(({ cols, rows }: { cols: number; rows: number }) => { - sendResize(cols, rows) - }) - }, [encodeBase64, sendMessage, sendResize]) + sendResize(cols, rows); + }); + }, [encodeBase64, sendMessage, sendResize]); /** * Handle window resize */ useEffect(() => { - if (!isActive) return + if (!isActive) return; const handleResize = () => { - fitTerminal() - } + fitTerminal(); + }; - window.addEventListener('resize', handleResize) + window.addEventListener("resize", handleResize); return () => { - window.removeEventListener('resize', handleResize) - } - }, [isActive, fitTerminal]) + window.removeEventListener("resize", handleResize); + }; + }, [isActive, fitTerminal]); /** * Initialize terminal and WebSocket when becoming active @@ -407,43 +411,43 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) { // When becoming inactive, just clear reconnect timeout but keep WebSocket alive // This preserves the terminal buffer and connection for when we switch back if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) - reconnectTimeoutRef.current = null + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; } // DO NOT close WebSocket here - keep it alive to preserve buffer - return + return; } // Initialize terminal if not already done if (!isInitializedRef.current) { - initializeTerminal() + initializeTerminal(); } // Connect WebSocket if not already connected // Use double rAF + timeout to ensure terminal is rendered with correct dimensions // before connecting (the fit/refresh effect handles the actual fitting) - let rafId1: number - let rafId2: number + let rafId1: number; + let rafId2: number; const connectIfNeeded = () => { rafId1 = requestAnimationFrame(() => { rafId2 = requestAnimationFrame(() => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { - connectRef.current?.() + connectRef.current?.(); } - }) - }) - } + }); + }); + }; // Delay connection to ensure terminal dimensions are calculated first - const timeoutId = window.setTimeout(connectIfNeeded, 50) + const timeoutId = window.setTimeout(connectIfNeeded, 50); return () => { - clearTimeout(timeoutId) - cancelAnimationFrame(rafId1) - cancelAnimationFrame(rafId2) - } - }, [isActive, initializeTerminal]) + clearTimeout(timeoutId); + cancelAnimationFrame(rafId1); + cancelAnimationFrame(rafId2); + }; + }, [isActive, initializeTerminal]); /** * Fit and refresh terminal when isActive becomes true @@ -454,35 +458,35 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) { // 1. First rAF: style changes are committed // 2. Second rAF: layout is recalculated // This is more reliable than setTimeout for visibility changes - let rafId1: number - let rafId2: number + let rafId1: number; + let rafId2: number; const handleActivation = () => { rafId1 = requestAnimationFrame(() => { rafId2 = requestAnimationFrame(() => { if (terminalRef.current && fitAddonRef.current) { // Fit terminal to get correct dimensions - fitTerminal() + fitTerminal(); // Refresh the terminal to redraw content after becoming visible // This fixes rendering issues when switching between terminals - terminalRef.current.refresh(0, terminalRef.current.rows - 1) + terminalRef.current.refresh(0, terminalRef.current.rows - 1); // Focus the terminal to receive keyboard input - terminalRef.current.focus() + terminalRef.current.focus(); } - }) - }) - } + }); + }); + }; // Small initial delay to ensure React has committed the style changes - const timeoutId = window.setTimeout(handleActivation, 16) + const timeoutId = window.setTimeout(handleActivation, 16); return () => { - clearTimeout(timeoutId) - cancelAnimationFrame(rafId1) - cancelAnimationFrame(rafId2) - } + clearTimeout(timeoutId); + cancelAnimationFrame(rafId1); + cancelAnimationFrame(rafId2); + }; } - }, [isActive, fitTerminal]) + }, [isActive, fitTerminal]); /** * Cleanup on unmount @@ -490,17 +494,17 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) { useEffect(() => { return () => { if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) + clearTimeout(reconnectTimeoutRef.current); } if (wsRef.current) { - wsRef.current.close() + wsRef.current.close(); } if (terminalRef.current) { - terminalRef.current.dispose() + terminalRef.current.dispose(); } - isInitializedRef.current = false - } - }, []) + isInitializedRef.current = false; + }; + }, []); /** * Reconnect when project or terminal changes @@ -509,47 +513,53 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) { if (isActive && isInitializedRef.current) { // Only reconnect if project or terminal actually changed, not on initial activation // This prevents duplicate connect calls when both isActive and projectName/terminalId effects run - if (lastProjectRef.current === null && lastTerminalIdRef.current === null) { + if ( + lastProjectRef.current === null && + lastTerminalIdRef.current === null + ) { // Initial activation - just track the project/terminal, don't reconnect (the isActive effect handles initial connect) - lastProjectRef.current = projectName - lastTerminalIdRef.current = terminalId - return + lastProjectRef.current = projectName; + lastTerminalIdRef.current = terminalId; + return; } - if (lastProjectRef.current === projectName && lastTerminalIdRef.current === terminalId) { + if ( + lastProjectRef.current === projectName && + lastTerminalIdRef.current === terminalId + ) { // Nothing changed, skip - return + return; } // Project or terminal changed - update tracking - lastProjectRef.current = projectName - lastTerminalIdRef.current = terminalId + lastProjectRef.current = projectName; + lastTerminalIdRef.current = terminalId; // Clear terminal and reset cursor position if (terminalRef.current) { - terminalRef.current.clear() - terminalRef.current.write('\x1b[H') // Move cursor to home position + terminalRef.current.clear(); + terminalRef.current.write("\x1b[H"); // Move cursor to home position } // Set manual close flag to prevent auto-reconnect race condition - isManualCloseRef.current = true + isManualCloseRef.current = true; // Close existing connection and reset connecting state if (wsRef.current) { - wsRef.current.close() - wsRef.current = null + wsRef.current.close(); + wsRef.current = null; } - isConnectingRef.current = false + isConnectingRef.current = false; // Reset state - setHasExited(false) - setExitCode(null) - reconnectAttempts.current = 0 + setHasExited(false); + setExitCode(null); + reconnectAttempts.current = 0; // Connect to new project/terminal using ref to avoid dependency on connect callback - connectRef.current?.() + connectRef.current?.(); } - }, [projectName, terminalId, isActive]) + }, [projectName, terminalId, isActive]); return (
@@ -557,12 +567,14 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) {
{!isConnected && !hasExited && ( - Connecting... + + Connecting... + )} {hasExited && exitCode !== null && ( @@ -575,12 +587,12 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) {
{ // Ensure terminal gets focus when container is clicked - terminalRef.current?.focus() + terminalRef.current?.focus(); }} />
- ) + ); } diff --git a/ui/src/components/TerminalTabs.tsx b/ui/src/components/TerminalTabs.tsx index 2771dec0..e060b5be 100644 --- a/ui/src/components/TerminalTabs.tsx +++ b/ui/src/components/TerminalTabs.tsx @@ -5,26 +5,26 @@ * Supports inline rename via double-click and context menu. */ -import { useState, useRef, useEffect, useCallback } from 'react' -import { Plus, X } from 'lucide-react' -import type { TerminalInfo } from '@/lib/types' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' +import { useState, useRef, useEffect, useCallback } from "react"; +import { Plus, X } from "lucide-react"; +import type { TerminalInfo } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; interface TerminalTabsProps { - terminals: TerminalInfo[] - activeTerminalId: string | null - onSelect: (terminalId: string) => void - onCreate: () => void - onRename: (terminalId: string, newName: string) => void - onClose: (terminalId: string) => void + terminals: TerminalInfo[]; + activeTerminalId: string | null; + onSelect: (terminalId: string) => void; + onCreate: () => void; + onRename: (terminalId: string, newName: string) => void; + onClose: (terminalId: string) => void; } interface ContextMenuState { - visible: boolean - x: number - y: number - terminalId: string | null + visible: boolean; + x: number; + y: number; + terminalId: string | null; } export function TerminalTabs({ @@ -35,24 +35,24 @@ export function TerminalTabs({ onRename, onClose, }: TerminalTabsProps) { - const [editingId, setEditingId] = useState(null) - const [editValue, setEditValue] = useState('') + const [editingId, setEditingId] = useState(null); + const [editValue, setEditValue] = useState(""); const [contextMenu, setContextMenu] = useState({ visible: false, x: 0, y: 0, terminalId: null, - }) - const inputRef = useRef(null) - const contextMenuRef = useRef(null) + }); + const inputRef = useRef(null); + const contextMenuRef = useRef(null); // Focus input when editing starts useEffect(() => { if (editingId && inputRef.current) { - inputRef.current.focus() - inputRef.current.select() + inputRef.current.focus(); + inputRef.current.select(); } - }, [editingId]) + }, [editingId]); // Close context menu when clicking outside useEffect(() => { @@ -61,99 +61,100 @@ export function TerminalTabs({ contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node) ) { - setContextMenu((prev) => ({ ...prev, visible: false })) + setContextMenu((prev) => ({ ...prev, visible: false })); } - } + }; if (contextMenu.visible) { - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); } - }, [contextMenu.visible]) + }, [contextMenu.visible]); // Start editing a terminal name const startEditing = useCallback((terminal: TerminalInfo) => { - setEditingId(terminal.id) - setEditValue(terminal.name) - setContextMenu((prev) => ({ ...prev, visible: false })) - }, []) + setEditingId(terminal.id); + setEditValue(terminal.name); + setContextMenu((prev) => ({ ...prev, visible: false })); + }, []); // Handle edit submission const submitEdit = useCallback(() => { if (editingId && editValue.trim()) { - onRename(editingId, editValue.trim()) + onRename(editingId, editValue.trim()); } - setEditingId(null) - setEditValue('') - }, [editingId, editValue, onRename]) + setEditingId(null); + setEditValue(""); + }, [editingId, editValue, onRename]); // Cancel editing const cancelEdit = useCallback(() => { - setEditingId(null) - setEditValue('') - }, []) + setEditingId(null); + setEditValue(""); + }, []); // Handle key events during editing const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - submitEdit() - } else if (e.key === 'Escape') { - e.preventDefault() - cancelEdit() + if (e.key === "Enter") { + e.preventDefault(); + submitEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelEdit(); } }, - [submitEdit, cancelEdit] - ) + [submitEdit, cancelEdit], + ); // Handle double-click to start editing const handleDoubleClick = useCallback( (terminal: TerminalInfo) => { - startEditing(terminal) + startEditing(terminal); }, - [startEditing] - ) + [startEditing], + ); // Handle context menu const handleContextMenu = useCallback( (e: React.MouseEvent, terminalId: string) => { - e.preventDefault() + e.preventDefault(); setContextMenu({ visible: true, x: e.clientX, y: e.clientY, terminalId, - }) + }); }, - [] - ) + [], + ); // Handle context menu actions const handleContextMenuRename = useCallback(() => { if (contextMenu.terminalId) { - const terminal = terminals.find((t) => t.id === contextMenu.terminalId) + const terminal = terminals.find((t) => t.id === contextMenu.terminalId); if (terminal) { - startEditing(terminal) + startEditing(terminal); } } - }, [contextMenu.terminalId, terminals, startEditing]) + }, [contextMenu.terminalId, terminals, startEditing]); const handleContextMenuClose = useCallback(() => { if (contextMenu.terminalId) { - onClose(contextMenu.terminalId) + onClose(contextMenu.terminalId); } - setContextMenu((prev) => ({ ...prev, visible: false })) - }, [contextMenu.terminalId, onClose]) + setContextMenu((prev) => ({ ...prev, visible: false })); + }, [contextMenu.terminalId, onClose]); // Handle tab close with confirmation if needed const handleClose = useCallback( (e: React.MouseEvent, terminalId: string) => { - e.stopPropagation() - onClose(terminalId) + e.stopPropagation(); + onClose(terminalId); }, - [onClose] - ) + [onClose], + ); return (
@@ -166,8 +167,8 @@ export function TerminalTabs({ transition-colors duration-100 select-none min-w-0 ${ activeTerminalId === terminal.id - ? 'bg-primary text-primary-foreground' - : 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700' + ? "bg-primary text-primary-foreground" + : "bg-zinc-800 text-zinc-300 hover:bg-zinc-700" } `} onClick={() => onSelect(terminal.id)} @@ -199,8 +200,8 @@ export function TerminalTabs({ p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity ${ activeTerminalId === terminal.id - ? 'hover:bg-black/20' - : 'hover:bg-white/20' + ? "hover:bg-black/20" + : "hover:bg-white/20" } `} title="Close terminal" @@ -246,5 +247,5 @@ export function TerminalTabs({
)}
- ) + ); } diff --git a/ui/src/components/ThemeSelector.tsx b/ui/src/components/ThemeSelector.tsx index 3ecff1a2..edad8c25 100644 --- a/ui/src/components/ThemeSelector.tsx +++ b/ui/src/components/ThemeSelector.tsx @@ -1,90 +1,107 @@ -import { useState, useRef, useEffect } from 'react' -import { Palette, Check } from 'lucide-react' -import { Button } from '@/components/ui/button' -import type { ThemeId, ThemeOption } from '../hooks/useTheme' +import { useState, useRef, useEffect } from "react"; +import { Palette, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { ThemeId, ThemeOption } from "../hooks/useTheme"; interface ThemeSelectorProps { - themes: ThemeOption[] - currentTheme: ThemeId - onThemeChange: (theme: ThemeId) => void + themes: ThemeOption[]; + currentTheme: ThemeId; + onThemeChange: (theme: ThemeId) => void; } -export function ThemeSelector({ themes, currentTheme, onThemeChange }: ThemeSelectorProps) { - const [isOpen, setIsOpen] = useState(false) - const [previewTheme, setPreviewTheme] = useState(null) - const containerRef = useRef(null) - const timeoutRef = useRef(null) +export function ThemeSelector({ + themes, + currentTheme, + onThemeChange, +}: ThemeSelectorProps) { + const [isOpen, setIsOpen] = useState(false); + const [previewTheme, setPreviewTheme] = useState(null); + const containerRef = useRef(null); + const timeoutRef = useRef(null); // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsOpen(false) - setPreviewTheme(null) + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + setPreviewTheme(null); } - } + }; - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); // Apply preview theme temporarily useEffect(() => { if (previewTheme) { - const root = document.documentElement - root.classList.remove('theme-claude', 'theme-neo-brutalism', 'theme-retro-arcade', 'theme-aurora') - if (previewTheme === 'claude') { - root.classList.add('theme-claude') - } else if (previewTheme === 'neo-brutalism') { - root.classList.add('theme-neo-brutalism') - } else if (previewTheme === 'retro-arcade') { - root.classList.add('theme-retro-arcade') - } else if (previewTheme === 'aurora') { - root.classList.add('theme-aurora') + const root = document.documentElement; + root.classList.remove( + "theme-claude", + "theme-neo-brutalism", + "theme-retro-arcade", + "theme-aurora", + ); + if (previewTheme === "claude") { + root.classList.add("theme-claude"); + } else if (previewTheme === "neo-brutalism") { + root.classList.add("theme-neo-brutalism"); + } else if (previewTheme === "retro-arcade") { + root.classList.add("theme-retro-arcade"); + } else if (previewTheme === "aurora") { + root.classList.add("theme-aurora"); } } // Cleanup: restore current theme when preview ends return () => { if (previewTheme) { - const root = document.documentElement - root.classList.remove('theme-claude', 'theme-neo-brutalism', 'theme-retro-arcade', 'theme-aurora') - if (currentTheme === 'claude') { - root.classList.add('theme-claude') - } else if (currentTheme === 'neo-brutalism') { - root.classList.add('theme-neo-brutalism') - } else if (currentTheme === 'retro-arcade') { - root.classList.add('theme-retro-arcade') - } else if (currentTheme === 'aurora') { - root.classList.add('theme-aurora') + const root = document.documentElement; + root.classList.remove( + "theme-claude", + "theme-neo-brutalism", + "theme-retro-arcade", + "theme-aurora", + ); + if (currentTheme === "claude") { + root.classList.add("theme-claude"); + } else if (currentTheme === "neo-brutalism") { + root.classList.add("theme-neo-brutalism"); + } else if (currentTheme === "retro-arcade") { + root.classList.add("theme-retro-arcade"); + } else if (currentTheme === "aurora") { + root.classList.add("theme-aurora"); } } - } - }, [previewTheme, currentTheme]) + }; + }, [previewTheme, currentTheme]); const handleMouseEnter = () => { if (timeoutRef.current) { - clearTimeout(timeoutRef.current) + clearTimeout(timeoutRef.current); } - setIsOpen(true) - } + setIsOpen(true); + }; const handleMouseLeave = () => { timeoutRef.current = setTimeout(() => { - setIsOpen(false) - setPreviewTheme(null) - }, 150) - } + setIsOpen(false); + setPreviewTheme(null); + }, 150); + }; const handleThemeHover = (themeId: ThemeId) => { - setPreviewTheme(themeId) - } + setPreviewTheme(themeId); + }; const handleThemeClick = (themeId: ThemeId) => { - onThemeChange(themeId) - setPreviewTheme(null) - setIsOpen(false) - } + onThemeChange(themeId); + setPreviewTheme(null); + setIsOpen(false); + }; return (
setPreviewTheme(null)} className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors ${ currentTheme === theme.id - ? 'bg-primary/10 text-foreground' - : 'hover:bg-muted text-foreground' + ? "bg-primary/10 text-foreground" + : "hover:bg-muted text-foreground" }`} role="menuitem" > @@ -159,5 +176,5 @@ export function ThemeSelector({ themes, currentTheme, onThemeChange }: ThemeSele
)}
- ) + ); } diff --git a/ui/src/components/TypingIndicator.tsx b/ui/src/components/TypingIndicator.tsx index 5e8b364f..2b9c4ed1 100644 --- a/ui/src/components/TypingIndicator.tsx +++ b/ui/src/components/TypingIndicator.tsx @@ -10,20 +10,20 @@ export function TypingIndicator() {
Claude is thinking...
- ) + ); } diff --git a/ui/src/components/ViewToggle.tsx b/ui/src/components/ViewToggle.tsx index c20f44b5..38565053 100644 --- a/ui/src/components/ViewToggle.tsx +++ b/ui/src/components/ViewToggle.tsx @@ -1,11 +1,11 @@ -import { LayoutGrid, GitBranch } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { LayoutGrid, GitBranch } from "lucide-react"; +import { Button } from "@/components/ui/button"; -export type ViewMode = 'kanban' | 'graph' +export type ViewMode = "kanban" | "graph"; interface ViewToggleProps { - viewMode: ViewMode - onViewModeChange: (mode: ViewMode) => void + viewMode: ViewMode; + onViewModeChange: (mode: ViewMode) => void; } /** @@ -15,23 +15,23 @@ export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) { return (
- ) + ); } diff --git a/ui/src/components/ui/alert.tsx b/ui/src/components/ui/alert.tsx index 14213546..aa7de24d 100644 --- a/ui/src/components/ui/alert.tsx +++ b/ui/src/components/ui/alert.tsx @@ -1,7 +1,7 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const alertVariants = cva( "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", @@ -16,8 +16,8 @@ const alertVariants = cva( defaultVariants: { variant: "default", }, - } -) + }, +); function Alert({ className, @@ -31,7 +31,7 @@ function Alert({ className={cn(alertVariants({ variant }), className)} {...props} /> - ) + ); } function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { @@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { data-slot="alert-title" className={cn( "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", - className + className, )} {...props} /> - ) + ); } function AlertDescription({ @@ -56,11 +56,11 @@ function AlertDescription({ data-slot="alert-description" className={cn( "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", - className + className, )} {...props} /> - ) + ); } -export { Alert, AlertTitle, AlertDescription } +export { Alert, AlertTitle, AlertDescription }; diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx index ba40cc16..ff1a2c19 100644 --- a/ui/src/components/ui/badge.tsx +++ b/ui/src/components/ui/badge.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const badgeVariants = cva( "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", @@ -23,8 +23,8 @@ const badgeVariants = cva( defaultVariants: { variant: "default", }, - } -) + }, +); function Badge({ className, @@ -33,7 +33,7 @@ function Badge({ ...props }: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot : "span" + const Comp = asChild ? Slot : "span"; return ( - ) + ); } -export { Badge, badgeVariants } +export { Badge, badgeVariants }; diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx index 915ea2a0..c9cb785b 100644 --- a/ui/src/components/ui/button.tsx +++ b/ui/src/components/ui/button.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", @@ -35,8 +35,8 @@ const buttonVariants = cva( variant: "default", size: "default", }, - } -) + }, +); function Button({ className, @@ -46,9 +46,9 @@ function Button({ ...props }: React.ComponentProps<"button"> & VariantProps & { - asChild?: boolean + asChild?: boolean; }) { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot : "button"; return ( - ) + ); } -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/ui/src/components/ui/card.tsx b/ui/src/components/ui/card.tsx index 681ad980..4f880247 100644 --- a/ui/src/components/ui/card.tsx +++ b/ui/src/components/ui/card.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Card({ className, ...props }: React.ComponentProps<"div">) { return ( @@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) { data-slot="card" className={cn( "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", - className + className, )} {...props} /> - ) + ); } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) { data-slot="card-header" className={cn( "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", - className + className, )} {...props} /> - ) + ); } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { @@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) { className={cn("leading-none font-semibold", className)} {...props} /> - ) + ); } function CardDescription({ className, ...props }: React.ComponentProps<"div">) { @@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) { className={cn("text-muted-foreground text-sm", className)} {...props} /> - ) + ); } function CardAction({ className, ...props }: React.ComponentProps<"div">) { @@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) { data-slot="card-action" className={cn( "col-start-2 row-span-2 row-start-1 self-start justify-self-end", - className + className, )} {...props} /> - ) + ); } function CardContent({ className, ...props }: React.ComponentProps<"div">) { @@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) { className={cn("px-6", className)} {...props} /> - ) + ); } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { @@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) { className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} /> - ) + ); } export { @@ -89,4 +89,4 @@ export { CardAction, CardDescription, CardContent, -} +}; diff --git a/ui/src/components/ui/checkbox.tsx b/ui/src/components/ui/checkbox.tsx index 0e2a6cd9..f24ad458 100644 --- a/ui/src/components/ui/checkbox.tsx +++ b/ui/src/components/ui/checkbox.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import * as CheckboxPrimitive from "@radix-ui/react-checkbox" -import { CheckIcon } from "lucide-react" +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Checkbox({ className, @@ -13,7 +13,7 @@ function Checkbox({ data-slot="checkbox" className={cn( "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", - className + className, )} {...props} > @@ -24,7 +24,7 @@ function Checkbox({ - ) + ); } -export { Checkbox } +export { Checkbox }; diff --git a/ui/src/components/ui/dialog.tsx b/ui/src/components/ui/dialog.tsx index daf6bf42..746e699d 100644 --- a/ui/src/components/ui/dialog.tsx +++ b/ui/src/components/ui/dialog.tsx @@ -1,32 +1,32 @@ -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { XIcon } from "lucide-react" +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; function Dialog({ ...props }: React.ComponentProps) { - return + return ; } function DialogTrigger({ ...props }: React.ComponentProps) { - return + return ; } function DialogPortal({ ...props }: React.ComponentProps) { - return + return ; } function DialogClose({ ...props }: React.ComponentProps) { - return + return ; } function DialogOverlay({ @@ -38,11 +38,11 @@ function DialogOverlay({ data-slot="dialog-overlay" className={cn( "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", - className + className, )} {...props} /> - ) + ); } function DialogContent({ @@ -51,7 +51,7 @@ function DialogContent({ showCloseButton = true, ...props }: React.ComponentProps & { - showCloseButton?: boolean + showCloseButton?: boolean; }) { return ( @@ -60,7 +60,7 @@ function DialogContent({ data-slot="dialog-content" className={cn( "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", - className + className, )} {...props} > @@ -76,7 +76,7 @@ function DialogContent({ )} - ) + ); } function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -86,7 +86,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props} /> - ) + ); } function DialogFooter({ @@ -95,14 +95,14 @@ function DialogFooter({ children, ...props }: React.ComponentProps<"div"> & { - showCloseButton?: boolean + showCloseButton?: boolean; }) { return (
@@ -113,7 +113,7 @@ function DialogFooter({ )}
- ) + ); } function DialogTitle({ @@ -126,7 +126,7 @@ function DialogTitle({ className={cn("text-lg leading-none font-semibold", className)} {...props} /> - ) + ); } function DialogDescription({ @@ -139,7 +139,7 @@ function DialogDescription({ className={cn("text-muted-foreground text-sm", className)} {...props} /> - ) + ); } export { @@ -153,4 +153,4 @@ export { DialogPortal, DialogTitle, DialogTrigger, -} +}; diff --git a/ui/src/components/ui/dropdown-menu.tsx b/ui/src/components/ui/dropdown-menu.tsx index 17aeb288..b29f9d93 100644 --- a/ui/src/components/ui/dropdown-menu.tsx +++ b/ui/src/components/ui/dropdown-menu.tsx @@ -1,13 +1,13 @@ -import * as React from "react" -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" -import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function DropdownMenu({ ...props }: React.ComponentProps) { - return + return ; } function DropdownMenuPortal({ @@ -15,7 +15,7 @@ function DropdownMenuPortal({ }: React.ComponentProps) { return ( - ) + ); } function DropdownMenuTrigger({ @@ -26,7 +26,7 @@ function DropdownMenuTrigger({ data-slot="dropdown-menu-trigger" {...props} /> - ) + ); } function DropdownMenuContent({ @@ -42,12 +42,12 @@ function DropdownMenuContent({ className={cn( "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className + className, )} {...props} /> - ) + ); } function DropdownMenuGroup({ @@ -55,7 +55,7 @@ function DropdownMenuGroup({ }: React.ComponentProps) { return ( - ) + ); } function DropdownMenuItem({ @@ -64,8 +64,8 @@ function DropdownMenuItem({ variant = "default", ...props }: React.ComponentProps & { - inset?: boolean - variant?: "default" | "destructive" + inset?: boolean; + variant?: "default" | "destructive"; }) { return ( - ) + ); } function DropdownMenuCheckboxItem({ @@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({ data-slot="dropdown-menu-checkbox-item" className={cn( "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - className + className, )} checked={checked} {...props} @@ -104,7 +104,7 @@ function DropdownMenuCheckboxItem({ {children} - ) + ); } function DropdownMenuRadioGroup({ @@ -115,7 +115,7 @@ function DropdownMenuRadioGroup({ data-slot="dropdown-menu-radio-group" {...props} /> - ) + ); } function DropdownMenuRadioItem({ @@ -128,7 +128,7 @@ function DropdownMenuRadioItem({ data-slot="dropdown-menu-radio-item" className={cn( "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - className + className, )} {...props} > @@ -139,7 +139,7 @@ function DropdownMenuRadioItem({ {children} - ) + ); } function DropdownMenuLabel({ @@ -147,7 +147,7 @@ function DropdownMenuLabel({ inset, ...props }: React.ComponentProps & { - inset?: boolean + inset?: boolean; }) { return ( - ) + ); } function DropdownMenuSeparator({ @@ -172,7 +172,7 @@ function DropdownMenuSeparator({ className={cn("bg-border -mx-1 my-1 h-px", className)} {...props} /> - ) + ); } function DropdownMenuShortcut({ @@ -184,17 +184,17 @@ function DropdownMenuShortcut({ data-slot="dropdown-menu-shortcut" className={cn( "text-muted-foreground ml-auto text-xs tracking-widest", - className + className, )} {...props} /> - ) + ); } function DropdownMenuSub({ ...props }: React.ComponentProps) { - return + return ; } function DropdownMenuSubTrigger({ @@ -203,7 +203,7 @@ function DropdownMenuSubTrigger({ children, ...props }: React.ComponentProps & { - inset?: boolean + inset?: boolean; }) { return ( {children} - ) + ); } function DropdownMenuSubContent({ @@ -231,11 +231,11 @@ function DropdownMenuSubContent({ className={cn( "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className + className, )} {...props} /> - ) + ); } export { @@ -254,4 +254,4 @@ export { DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, -} +}; diff --git a/ui/src/components/ui/input.tsx b/ui/src/components/ui/input.tsx index 89169058..f16c2c0e 100644 --- a/ui/src/components/ui/input.tsx +++ b/ui/src/components/ui/input.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( @@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - className + className, )} {...props} /> - ) + ); } -export { Input } +export { Input }; diff --git a/ui/src/components/ui/label.tsx b/ui/src/components/ui/label.tsx index ef7133a7..4ef28a9b 100644 --- a/ui/src/components/ui/label.tsx +++ b/ui/src/components/ui/label.tsx @@ -1,7 +1,7 @@ -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Label({ className, @@ -12,11 +12,11 @@ function Label({ data-slot="label" className={cn( "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", - className + className, )} {...props} /> - ) + ); } -export { Label } +export { Label }; diff --git a/ui/src/components/ui/popover.tsx b/ui/src/components/ui/popover.tsx index 0df056f6..b4138442 100644 --- a/ui/src/components/ui/popover.tsx +++ b/ui/src/components/ui/popover.tsx @@ -1,18 +1,18 @@ -import * as React from "react" -import * as PopoverPrimitive from "@radix-ui/react-popover" +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Popover({ ...props }: React.ComponentProps) { - return + return ; } function PopoverTrigger({ ...props }: React.ComponentProps) { - return + return ; } function PopoverContent({ @@ -29,18 +29,18 @@ function PopoverContent({ sideOffset={sideOffset} className={cn( "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", - className + className, )} {...props} /> - ) + ); } function PopoverAnchor({ ...props }: React.ComponentProps) { - return + return ; } function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { @@ -50,7 +50,7 @@ function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { className={cn("flex flex-col gap-1 text-sm", className)} {...props} /> - ) + ); } function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { @@ -60,7 +60,7 @@ function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { className={cn("font-medium", className)} {...props} /> - ) + ); } function PopoverDescription({ @@ -73,7 +73,7 @@ function PopoverDescription({ className={cn("text-muted-foreground", className)} {...props} /> - ) + ); } export { @@ -84,4 +84,4 @@ export { PopoverHeader, PopoverTitle, PopoverDescription, -} +}; diff --git a/ui/src/components/ui/radio-group.tsx b/ui/src/components/ui/radio-group.tsx index 5e6778cb..bc5495a5 100644 --- a/ui/src/components/ui/radio-group.tsx +++ b/ui/src/components/ui/radio-group.tsx @@ -1,10 +1,10 @@ -"use client" +"use client"; -import * as React from "react" -import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" -import { CircleIcon } from "lucide-react" +import * as React from "react"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { CircleIcon } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function RadioGroup({ className, @@ -16,7 +16,7 @@ function RadioGroup({ className={cn("grid gap-3", className)} {...props} /> - ) + ); } function RadioGroupItem({ @@ -28,7 +28,7 @@ function RadioGroupItem({ data-slot="radio-group-item" className={cn( "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", - className + className, )} {...props} > @@ -39,7 +39,7 @@ function RadioGroupItem({ - ) + ); } -export { RadioGroup, RadioGroupItem } +export { RadioGroup, RadioGroupItem }; diff --git a/ui/src/components/ui/scroll-area.tsx b/ui/src/components/ui/scroll-area.tsx index 9376f594..51ecedcd 100644 --- a/ui/src/components/ui/scroll-area.tsx +++ b/ui/src/components/ui/scroll-area.tsx @@ -1,7 +1,7 @@ -import * as React from "react" -import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function ScrollArea({ className, @@ -23,7 +23,7 @@ function ScrollArea({ - ) + ); } function ScrollBar({ @@ -41,7 +41,7 @@ function ScrollBar({ "h-full w-2.5 border-l border-l-transparent", orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent", - className + className, )} {...props} > @@ -50,7 +50,7 @@ function ScrollBar({ className="bg-border relative flex-1 rounded-full" /> - ) + ); } -export { ScrollArea, ScrollBar } +export { ScrollArea, ScrollBar }; diff --git a/ui/src/components/ui/select.tsx b/ui/src/components/ui/select.tsx index 88302a8d..c3f080d4 100644 --- a/ui/src/components/ui/select.tsx +++ b/ui/src/components/ui/select.tsx @@ -1,27 +1,27 @@ -"use client" +"use client"; -import * as React from "react" -import * as SelectPrimitive from "@radix-ui/react-select" -import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Select({ ...props }: React.ComponentProps) { - return + return ; } function SelectGroup({ ...props }: React.ComponentProps) { - return + return ; } function SelectValue({ ...props }: React.ComponentProps) { - return + return ; } function SelectTrigger({ @@ -30,7 +30,7 @@ function SelectTrigger({ children, ...props }: React.ComponentProps & { - size?: "sm" | "default" + size?: "sm" | "default"; }) { return ( @@ -47,7 +47,7 @@ function SelectTrigger({ - ) + ); } function SelectContent({ @@ -65,7 +65,7 @@ function SelectContent({ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", - className + className, )} position={position} align={align} @@ -76,7 +76,7 @@ function SelectContent({ className={cn( "p-1", position === "popper" && - "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1", )} > {children} @@ -84,7 +84,7 @@ function SelectContent({ - ) + ); } function SelectLabel({ @@ -97,7 +97,7 @@ function SelectLabel({ className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props} /> - ) + ); } function SelectItem({ @@ -110,7 +110,7 @@ function SelectItem({ data-slot="select-item" className={cn( "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", - className + className, )} {...props} > @@ -124,7 +124,7 @@ function SelectItem({ {children} - ) + ); } function SelectSeparator({ @@ -137,7 +137,7 @@ function SelectSeparator({ className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} {...props} /> - ) + ); } function SelectScrollUpButton({ @@ -149,13 +149,13 @@ function SelectScrollUpButton({ data-slot="select-scroll-up-button" className={cn( "flex cursor-default items-center justify-center py-1", - className + className, )} {...props} > - ) + ); } function SelectScrollDownButton({ @@ -167,13 +167,13 @@ function SelectScrollDownButton({ data-slot="select-scroll-down-button" className={cn( "flex cursor-default items-center justify-center py-1", - className + className, )} {...props} > - ) + ); } export { @@ -187,4 +187,4 @@ export { SelectSeparator, SelectTrigger, SelectValue, -} +}; diff --git a/ui/src/components/ui/separator.tsx b/ui/src/components/ui/separator.tsx index 275381ca..72c18e33 100644 --- a/ui/src/components/ui/separator.tsx +++ b/ui/src/components/ui/separator.tsx @@ -1,9 +1,9 @@ -"use client" +"use client"; -import * as React from "react" -import * as SeparatorPrimitive from "@radix-ui/react-separator" +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Separator({ className, @@ -18,11 +18,11 @@ function Separator({ orientation={orientation} className={cn( "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", - className + className, )} {...props} /> - ) + ); } -export { Separator } +export { Separator }; diff --git a/ui/src/components/ui/switch.tsx b/ui/src/components/ui/switch.tsx index f442c2cb..7b1866b0 100644 --- a/ui/src/components/ui/switch.tsx +++ b/ui/src/components/ui/switch.tsx @@ -1,16 +1,16 @@ -"use client" +"use client"; -import * as React from "react" -import * as SwitchPrimitive from "@radix-ui/react-switch" +import * as React from "react"; +import * as SwitchPrimitive from "@radix-ui/react-switch"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Switch({ className, size = "default", ...props }: React.ComponentProps & { - size?: "sm" | "default" + size?: "sm" | "default"; }) { return ( - ) + ); } -export { Switch } +export { Switch }; diff --git a/ui/src/components/ui/tabs.tsx b/ui/src/components/ui/tabs.tsx index bb946fc8..77b1012f 100644 --- a/ui/src/components/ui/tabs.tsx +++ b/ui/src/components/ui/tabs.tsx @@ -1,8 +1,8 @@ -import * as React from "react" -import * as TabsPrimitive from "@radix-ui/react-tabs" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Tabs({ className, @@ -16,11 +16,11 @@ function Tabs({ orientation={orientation} className={cn( "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", - className + className, )} {...props} /> - ) + ); } const tabsListVariants = cva( @@ -35,8 +35,8 @@ const tabsListVariants = cva( defaultVariants: { variant: "default", }, - } -) + }, +); function TabsList({ className, @@ -51,7 +51,7 @@ function TabsList({ className={cn(tabsListVariants({ variant }), className)} {...props} /> - ) + ); } function TabsTrigger({ @@ -66,11 +66,11 @@ function TabsTrigger({ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent", "data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground", "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100", - className + className, )} {...props} /> - ) + ); } function TabsContent({ @@ -83,7 +83,7 @@ function TabsContent({ className={cn("flex-1 outline-none", className)} {...props} /> - ) + ); } -export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }; diff --git a/ui/src/components/ui/textarea.tsx b/ui/src/components/ui/textarea.tsx index 7f21b5e7..0735a8ca 100644 --- a/ui/src/components/ui/textarea.tsx +++ b/ui/src/components/ui/textarea.tsx @@ -1,6 +1,6 @@ -import * as React from "react" +import * as React from "react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { return ( @@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { data-slot="textarea" className={cn( "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", - className + className, )} {...props} /> - ) + ); } -export { Textarea } +export { Textarea }; diff --git a/ui/src/components/ui/toggle.tsx b/ui/src/components/ui/toggle.tsx index 94ec8f58..47517bde 100644 --- a/ui/src/components/ui/toggle.tsx +++ b/ui/src/components/ui/toggle.tsx @@ -1,10 +1,10 @@ -"use client" +"use client"; -import * as React from "react" -import * as TogglePrimitive from "@radix-ui/react-toggle" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import * as TogglePrimitive from "@radix-ui/react-toggle"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const toggleVariants = cva( "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", @@ -25,8 +25,8 @@ const toggleVariants = cva( variant: "default", size: "default", }, - } -) + }, +); function Toggle({ className, @@ -41,7 +41,7 @@ function Toggle({ className={cn(toggleVariants({ variant, size, className }))} {...props} /> - ) + ); } -export { Toggle, toggleVariants } +export { Toggle, toggleVariants }; diff --git a/ui/src/components/ui/tooltip.tsx b/ui/src/components/ui/tooltip.tsx index a4e90d4e..016298cc 100644 --- a/ui/src/components/ui/tooltip.tsx +++ b/ui/src/components/ui/tooltip.tsx @@ -1,9 +1,9 @@ -"use client" +"use client"; -import * as React from "react" -import * as TooltipPrimitive from "@radix-ui/react-tooltip" +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function TooltipProvider({ delayDuration = 0, @@ -15,7 +15,7 @@ function TooltipProvider({ delayDuration={delayDuration} {...props} /> - ) + ); } function Tooltip({ @@ -25,13 +25,13 @@ function Tooltip({ - ) + ); } function TooltipTrigger({ ...props }: React.ComponentProps) { - return + return ; } function TooltipContent({ @@ -47,7 +47,7 @@ function TooltipContent({ sideOffset={sideOffset} className={cn( "bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", - className + className, )} {...props} > @@ -55,7 +55,7 @@ function TooltipContent({ - ) + ); } -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/ui/src/hooks/useAssistantChat.ts b/ui/src/hooks/useAssistantChat.ts index b8fedff4..34a4cf1a 100755 --- a/ui/src/hooks/useAssistantChat.ts +++ b/ui/src/hooks/useAssistantChat.ts @@ -121,7 +121,11 @@ export function useAssistantChat({ try { const data = JSON.parse(event.data) as AssistantChatServerMessage; if (import.meta.env.DEV) { - console.debug('[useAssistantChat] Received WebSocket message:', data.type, data); + console.debug( + "[useAssistantChat] Received WebSocket message:", + data.type, + data, + ); } switch (data.type) { @@ -281,7 +285,7 @@ export function useAssistantChat({ setConversationId(existingConversationId); } if (import.meta.env.DEV) { - console.debug('[useAssistantChat] Sending start message:', payload); + console.debug("[useAssistantChat] Sending start message:", payload); } wsRef.current.send(JSON.stringify(payload)); } else if (wsRef.current?.readyState === WebSocket.CONNECTING) { diff --git a/ui/src/hooks/useCelebration.ts b/ui/src/hooks/useCelebration.ts index 71b28a40..ea744be3 100644 --- a/ui/src/hooks/useCelebration.ts +++ b/ui/src/hooks/useCelebration.ts @@ -3,9 +3,9 @@ * Plays confetti cannons from both sides and a triumphant fanfare */ -import { useEffect, useRef } from 'react' -import confetti from 'canvas-confetti' -import type { FeatureListResponse } from '../lib/types' +import { useEffect, useRef } from "react"; +import confetti from "canvas-confetti"; +import type { FeatureListResponse } from "../lib/types"; /** * Play a triumphant fanfare using Web Audio API @@ -13,68 +13,84 @@ import type { FeatureListResponse } from '../lib/types' */ function playFanfare(): void { try { - const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)() + const audioContext = new ( + window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }) + .webkitAudioContext + )(); // Frequencies for triumphant fanfare (C major arpeggio going up) const notes = [ - { freq: 523.25, start: 0 }, // C5 - { freq: 659.25, start: 0.15 }, // E5 - { freq: 783.99, start: 0.30 }, // G5 - { freq: 1046.50, start: 0.45 }, // C6 (octave higher) - ] + { freq: 523.25, start: 0 }, // C5 + { freq: 659.25, start: 0.15 }, // E5 + { freq: 783.99, start: 0.3 }, // G5 + { freq: 1046.5, start: 0.45 }, // C6 (octave higher) + ]; - const noteDuration = 0.25 + const noteDuration = 0.25; notes.forEach(({ freq, start }) => { - const oscillator = audioContext.createOscillator() - const gainNode = audioContext.createGain() + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); - oscillator.connect(gainNode) - gainNode.connect(audioContext.destination) + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); - oscillator.type = 'sine' - oscillator.frequency.setValueAtTime(freq, audioContext.currentTime + start) + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime( + freq, + audioContext.currentTime + start, + ); // Envelope for smooth, triumphant sound - const startTime = audioContext.currentTime + start - gainNode.gain.setValueAtTime(0, startTime) - gainNode.gain.linearRampToValueAtTime(0.4, startTime + 0.03) - gainNode.gain.setValueAtTime(0.4, startTime + noteDuration * 0.6) - gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + noteDuration) - - oscillator.start(startTime) - oscillator.stop(startTime + noteDuration) - }) + const startTime = audioContext.currentTime + start; + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(0.4, startTime + 0.03); + gainNode.gain.setValueAtTime(0.4, startTime + noteDuration * 0.6); + gainNode.gain.exponentialRampToValueAtTime( + 0.01, + startTime + noteDuration, + ); + + oscillator.start(startTime); + oscillator.stop(startTime + noteDuration); + }); // Add a final sustained chord for extra triumph - const chordFreqs = [523.25, 659.25, 783.99, 1046.50] // C major chord - const chordStart = 0.65 - const chordDuration = 0.5 + const chordFreqs = [523.25, 659.25, 783.99, 1046.5]; // C major chord + const chordStart = 0.65; + const chordDuration = 0.5; chordFreqs.forEach((freq) => { - const oscillator = audioContext.createOscillator() - const gainNode = audioContext.createGain() - - oscillator.connect(gainNode) - gainNode.connect(audioContext.destination) - - oscillator.type = 'sine' - oscillator.frequency.setValueAtTime(freq, audioContext.currentTime + chordStart) - - const startTime = audioContext.currentTime + chordStart - gainNode.gain.setValueAtTime(0, startTime) - gainNode.gain.linearRampToValueAtTime(0.2, startTime + 0.05) - gainNode.gain.setValueAtTime(0.2, startTime + chordDuration * 0.5) - gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + chordDuration) - - oscillator.start(startTime) - oscillator.stop(startTime + chordDuration) - }) + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime( + freq, + audioContext.currentTime + chordStart, + ); + + const startTime = audioContext.currentTime + chordStart; + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(0.2, startTime + 0.05); + gainNode.gain.setValueAtTime(0.2, startTime + chordDuration * 0.5); + gainNode.gain.exponentialRampToValueAtTime( + 0.01, + startTime + chordDuration, + ); + + oscillator.start(startTime); + oscillator.stop(startTime + chordDuration); + }); // Clean up audio context after sounds finish setTimeout(() => { - audioContext.close() - }, 1500) + audioContext.close(); + }, 1500); } catch { // Audio not supported or blocked, fail silently } @@ -84,8 +100,8 @@ function playFanfare(): void { * Fire confetti cannons from both sides of the screen */ function fireConfetti(): void { - const duration = 2000 - const end = Date.now() + duration + const duration = 2000; + const end = Date.now() + duration; // Initial burst from both sides confetti({ @@ -93,22 +109,22 @@ function fireConfetti(): void { spread: 70, origin: { x: 0, y: 0.6 }, angle: 60, - colors: ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9'] - }) + colors: ["#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", "#ffeaa7", "#dfe6e9"], + }); confetti({ particleCount: 100, spread: 70, origin: { x: 1, y: 0.6 }, angle: 120, - colors: ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9'] - }) + colors: ["#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", "#ffeaa7", "#dfe6e9"], + }); // Continue firing for a bit const interval = setInterval(() => { if (Date.now() > end) { - clearInterval(interval) - return + clearInterval(interval); + return; } confetti({ @@ -116,29 +132,43 @@ function fireConfetti(): void { spread: 60, origin: { x: 0, y: 0.6 }, angle: 60, - colors: ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9'] - }) + colors: [ + "#ff6b6b", + "#4ecdc4", + "#45b7d1", + "#96ceb4", + "#ffeaa7", + "#dfe6e9", + ], + }); confetti({ particleCount: 30, spread: 60, origin: { x: 1, y: 0.6 }, angle: 120, - colors: ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9'] - }) - }, 250) + colors: [ + "#ff6b6b", + "#4ecdc4", + "#45b7d1", + "#96ceb4", + "#ffeaa7", + "#dfe6e9", + ], + }); + }, 250); } /** * Check if all features are complete (none pending or in progress, at least one done) */ function isAllComplete(features: FeatureListResponse | undefined): boolean { - if (!features) return false + if (!features) return false; return ( features.pending.length === 0 && features.in_progress.length === 0 && features.done.length > 0 - ) + ); } /** @@ -147,38 +177,38 @@ function isAllComplete(features: FeatureListResponse | undefined): boolean { */ export function useCelebration( features: FeatureListResponse | undefined, - projectName: string | null + projectName: string | null, ): void { // Track which projects have celebrated in this session - const celebratedProjectsRef = useRef>(new Set()) + const celebratedProjectsRef = useRef>(new Set()); // Track if we've initialized for the current project (to avoid celebrating on initial load) - const initializedForProjectRef = useRef(null) + const initializedForProjectRef = useRef(null); useEffect(() => { - if (!features || !projectName) return + if (!features || !projectName) return; - const isComplete = isAllComplete(features) + const isComplete = isAllComplete(features); // If this is a new project, mark as initialized but don't celebrate yet // This prevents celebrating when first loading an already-complete project if (initializedForProjectRef.current !== projectName) { - initializedForProjectRef.current = projectName + initializedForProjectRef.current = projectName; // If project is already complete on first load, mark it as celebrated // so we don't trigger when data refreshes if (isComplete) { - celebratedProjectsRef.current.add(projectName) + celebratedProjectsRef.current.add(projectName); } - return + return; } // Check if we should celebrate if (isComplete && !celebratedProjectsRef.current.has(projectName)) { // Mark as celebrated before firing to prevent double-triggers - celebratedProjectsRef.current.add(projectName) + celebratedProjectsRef.current.add(projectName); // Fire the celebration! - fireConfetti() - playFanfare() + fireConfetti(); + playFanfare(); } - }, [features, projectName]) + }, [features, projectName]); } diff --git a/ui/src/hooks/useConversations.ts b/ui/src/hooks/useConversations.ts index 908b22da..20aa77f5 100644 --- a/ui/src/hooks/useConversations.ts +++ b/ui/src/hooks/useConversations.ts @@ -2,47 +2,54 @@ * React Query hooks for assistant conversation management */ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import * as api from '../lib/api' +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import * as api from "../lib/api"; /** * List all conversations for a project */ export function useConversations(projectName: string | null) { return useQuery({ - queryKey: ['conversations', projectName], + queryKey: ["conversations", projectName], queryFn: () => api.listAssistantConversations(projectName!), enabled: !!projectName, staleTime: 30000, // Cache for 30 seconds - }) + }); } /** * Get a single conversation with all its messages */ -export function useConversation(projectName: string | null, conversationId: number | null) { +export function useConversation( + projectName: string | null, + conversationId: number | null, +) { return useQuery({ - queryKey: ['conversation', projectName, conversationId], + queryKey: ["conversation", projectName, conversationId], queryFn: () => api.getAssistantConversation(projectName!, conversationId!), enabled: !!projectName && !!conversationId, staleTime: 30_000, // Cache for 30 seconds - }) + }); } /** * Delete a conversation */ export function useDeleteConversation(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ mutationFn: (conversationId: number) => api.deleteAssistantConversation(projectName, conversationId), onSuccess: (_, deletedId) => { // Invalidate conversations list - queryClient.invalidateQueries({ queryKey: ['conversations', projectName] }) + queryClient.invalidateQueries({ + queryKey: ["conversations", projectName], + }); // Remove the specific conversation from cache - queryClient.removeQueries({ queryKey: ['conversation', projectName, deletedId] }) + queryClient.removeQueries({ + queryKey: ["conversation", projectName, deletedId], + }); }, - }) + }); } diff --git a/ui/src/hooks/useExpandChat.ts b/ui/src/hooks/useExpandChat.ts index 91508852..ca4674ae 100644 --- a/ui/src/hooks/useExpandChat.ts +++ b/ui/src/hooks/useExpandChat.ts @@ -2,37 +2,41 @@ * Hook for managing project expansion chat WebSocket connection */ -import { useState, useCallback, useRef, useEffect } from 'react' -import type { ChatMessage, ImageAttachment, ExpandChatServerMessage } from '../lib/types' +import { useState, useCallback, useRef, useEffect } from "react"; +import type { + ChatMessage, + ImageAttachment, + ExpandChatServerMessage, +} from "../lib/types"; -type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' +type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; interface CreatedFeature { - id: number - name: string - category: string + id: number; + name: string; + category: string; } interface UseExpandChatOptions { - projectName: string - onComplete?: (totalAdded: number) => void - onError?: (error: string) => void + projectName: string; + onComplete?: (totalAdded: number) => void; + onError?: (error: string) => void; } interface UseExpandChatReturn { - messages: ChatMessage[] - isLoading: boolean - isComplete: boolean - connectionStatus: ConnectionStatus - featuresCreated: number - recentFeatures: CreatedFeature[] - start: () => void - sendMessage: (content: string, attachments?: ImageAttachment[]) => void - disconnect: () => void + messages: ChatMessage[]; + isLoading: boolean; + isComplete: boolean; + connectionStatus: ConnectionStatus; + featuresCreated: number; + recentFeatures: CreatedFeature[]; + start: () => void; + sendMessage: (content: string, attachments?: ImageAttachment[]) => void; + disconnect: () => void; } function generateId(): string { - return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}` + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; } export function useExpandChat({ @@ -40,78 +44,79 @@ export function useExpandChat({ onComplete, onError, }: UseExpandChatOptions): UseExpandChatReturn { - const [messages, setMessages] = useState([]) - const [isLoading, setIsLoading] = useState(false) - const [isComplete, setIsComplete] = useState(false) - const [connectionStatus, setConnectionStatus] = useState('disconnected') - const [featuresCreated, setFeaturesCreated] = useState(0) - const [recentFeatures, setRecentFeatures] = useState([]) - - const wsRef = useRef(null) - const currentAssistantMessageRef = useRef(null) - const reconnectAttempts = useRef(0) - const maxReconnectAttempts = 3 - const pingIntervalRef = useRef(null) - const reconnectTimeoutRef = useRef(null) - const isCompleteRef = useRef(false) - const manuallyDisconnectedRef = useRef(false) + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isComplete, setIsComplete] = useState(false); + const [connectionStatus, setConnectionStatus] = + useState("disconnected"); + const [featuresCreated, setFeaturesCreated] = useState(0); + const [recentFeatures, setRecentFeatures] = useState([]); + + const wsRef = useRef(null); + const currentAssistantMessageRef = useRef(null); + const reconnectAttempts = useRef(0); + const maxReconnectAttempts = 3; + const pingIntervalRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const isCompleteRef = useRef(false); + const manuallyDisconnectedRef = useRef(false); // Keep isCompleteRef in sync with isComplete state useEffect(() => { - isCompleteRef.current = isComplete - }, [isComplete]) + isCompleteRef.current = isComplete; + }, [isComplete]); // Clean up on unmount useEffect(() => { return () => { if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current) + clearInterval(pingIntervalRef.current); } if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) + clearTimeout(reconnectTimeoutRef.current); } if (wsRef.current) { - wsRef.current.close() + wsRef.current.close(); } - } - }, []) + }; + }, []); const connect = useCallback(() => { // Don't reconnect if manually disconnected if (manuallyDisconnectedRef.current) { - return + return; } if (wsRef.current?.readyState === WebSocket.OPEN) { - return + return; } - setConnectionStatus('connecting') + setConnectionStatus("connecting"); - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - const host = window.location.host - const wsUrl = `${protocol}//${host}/api/expand/ws/${encodeURIComponent(projectName)}` + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const host = window.location.host; + const wsUrl = `${protocol}//${host}/api/expand/ws/${encodeURIComponent(projectName)}`; - const ws = new WebSocket(wsUrl) - wsRef.current = ws + const ws = new WebSocket(wsUrl); + wsRef.current = ws; ws.onopen = () => { - setConnectionStatus('connected') - reconnectAttempts.current = 0 - manuallyDisconnectedRef.current = false + setConnectionStatus("connected"); + reconnectAttempts.current = 0; + manuallyDisconnectedRef.current = false; // Start ping interval to keep connection alive pingIntervalRef.current = window.setInterval(() => { if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'ping' })) + ws.send(JSON.stringify({ type: "ping" })); } - }, 30000) - } + }, 30000); + }; ws.onclose = () => { - setConnectionStatus('disconnected') + setConnectionStatus("disconnected"); if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current) - pingIntervalRef.current = null + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; } // Attempt reconnection if not intentionally closed @@ -120,27 +125,33 @@ export function useExpandChat({ reconnectAttempts.current < maxReconnectAttempts && !isCompleteRef.current ) { - reconnectAttempts.current++ - const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000) - reconnectTimeoutRef.current = window.setTimeout(connect, delay) + reconnectAttempts.current++; + const delay = Math.min( + 1000 * Math.pow(2, reconnectAttempts.current), + 10000, + ); + reconnectTimeoutRef.current = window.setTimeout(connect, delay); } - } + }; ws.onerror = () => { - setConnectionStatus('error') - onError?.('WebSocket connection error') - } + setConnectionStatus("error"); + onError?.("WebSocket connection error"); + }; ws.onmessage = (event) => { try { - const data = JSON.parse(event.data) as ExpandChatServerMessage + const data = JSON.parse(event.data) as ExpandChatServerMessage; switch (data.type) { - case 'text': { + case "text": { // Append text to current assistant message or create new one setMessages((prev) => { - const lastMessage = prev[prev.length - 1] - if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.role === "assistant" && + lastMessage.isStreaming + ) { // Append to existing streaming message return [ ...prev.slice(0, -1), @@ -148,188 +159,205 @@ export function useExpandChat({ ...lastMessage, content: lastMessage.content + data.content, }, - ] + ]; } else { // Create new assistant message - currentAssistantMessageRef.current = generateId() + currentAssistantMessageRef.current = generateId(); return [ ...prev, { id: currentAssistantMessageRef.current, - role: 'assistant', + role: "assistant", content: data.content, timestamp: new Date(), isStreaming: true, }, - ] + ]; } - }) - break + }); + break; } - case 'features_created': { + case "features_created": { // Features were created - setFeaturesCreated((prev) => prev + data.count) - setRecentFeatures(data.features) + setFeaturesCreated((prev) => prev + data.count); + setRecentFeatures(data.features); // Add system message about feature creation setMessages((prev) => [ ...prev, { id: generateId(), - role: 'system', - content: `Created ${data.count} new feature${data.count !== 1 ? 's' : ''}!`, + role: "system", + content: `Created ${data.count} new feature${data.count !== 1 ? "s" : ""}!`, timestamp: new Date(), }, - ]) - break + ]); + break; } - case 'expansion_complete': { - setIsComplete(true) - setIsLoading(false) + case "expansion_complete": { + setIsComplete(true); + setIsLoading(false); // Mark current message as done setMessages((prev) => { - const lastMessage = prev[prev.length - 1] - if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.role === "assistant" && + lastMessage.isStreaming + ) { return [ ...prev.slice(0, -1), { ...lastMessage, isStreaming: false }, - ] + ]; } - return prev - }) + return prev; + }); - onComplete?.(data.total_added) - break + onComplete?.(data.total_added); + break; } - case 'error': { - setIsLoading(false) - onError?.(data.content) + case "error": { + setIsLoading(false); + onError?.(data.content); // Add error as system message setMessages((prev) => [ ...prev, { id: generateId(), - role: 'system', + role: "system", content: `Error: ${data.content}`, timestamp: new Date(), }, - ]) - break + ]); + break; } - case 'pong': { + case "pong": { // Keep-alive response, nothing to do - break + break; } - case 'response_done': { + case "response_done": { // Response complete - hide loading indicator and mark message as done - setIsLoading(false) + setIsLoading(false); // Mark current message as done streaming setMessages((prev) => { - const lastMessage = prev[prev.length - 1] - if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.role === "assistant" && + lastMessage.isStreaming + ) { return [ ...prev.slice(0, -1), { ...lastMessage, isStreaming: false }, - ] + ]; } - return prev - }) - break + return prev; + }); + break; } } } catch (e) { - console.error('Failed to parse WebSocket message:', e) + console.error("Failed to parse WebSocket message:", e); } - } - }, [projectName, onComplete, onError]) + }; + }, [projectName, onComplete, onError]); const start = useCallback(() => { - connect() + connect(); // Wait for connection then send start message (with timeout to prevent infinite loop) - let attempts = 0 - const maxAttempts = 50 // 5 seconds max (50 * 100ms) + let attempts = 0; + const maxAttempts = 50; // 5 seconds max (50 * 100ms) const checkAndSend = () => { if (wsRef.current?.readyState === WebSocket.OPEN) { - setIsLoading(true) - wsRef.current.send(JSON.stringify({ type: 'start' })) + setIsLoading(true); + wsRef.current.send(JSON.stringify({ type: "start" })); } else if (wsRef.current?.readyState === WebSocket.CONNECTING) { if (attempts++ < maxAttempts) { - setTimeout(checkAndSend, 100) + setTimeout(checkAndSend, 100); } else { - onError?.('Connection timeout') - setIsLoading(false) + onError?.("Connection timeout"); + setIsLoading(false); } } - } + }; - setTimeout(checkAndSend, 100) - }, [connect, onError]) + setTimeout(checkAndSend, 100); + }, [connect, onError]); - const sendMessage = useCallback((content: string, attachments?: ImageAttachment[]) => { - if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { - onError?.('Not connected') - return - } + const sendMessage = useCallback( + (content: string, attachments?: ImageAttachment[]) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + onError?.("Not connected"); + return; + } - // Add user message to chat (with attachments for display) - setMessages((prev) => [ - ...prev, - { - id: generateId(), - role: 'user', + // Add user message to chat (with attachments for display) + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: "user", + content, + attachments, + timestamp: new Date(), + }, + ]); + + setIsLoading(true); + + // Build message payload + const payload: { + type: string; + content: string; + attachments?: Array<{ + filename: string; + mimeType: string; + base64Data: string; + }>; + } = { + type: "message", content, - attachments, - timestamp: new Date(), - }, - ]) - - setIsLoading(true) - - // Build message payload - const payload: { type: string; content: string; attachments?: Array<{ filename: string; mimeType: string; base64Data: string }> } = { - type: 'message', - content, - } - - // Add attachments if present (send base64 data, not preview URL) - if (attachments && attachments.length > 0) { - payload.attachments = attachments.map((a) => ({ - filename: a.filename, - mimeType: a.mimeType, - base64Data: a.base64Data, - })) - } + }; + + // Add attachments if present (send base64 data, not preview URL) + if (attachments && attachments.length > 0) { + payload.attachments = attachments.map((a) => ({ + filename: a.filename, + mimeType: a.mimeType, + base64Data: a.base64Data, + })); + } - // Send to server - wsRef.current.send(JSON.stringify(payload)) - }, [onError]) + // Send to server + wsRef.current.send(JSON.stringify(payload)); + }, + [onError], + ); const disconnect = useCallback(() => { - manuallyDisconnectedRef.current = true - reconnectAttempts.current = maxReconnectAttempts // Prevent reconnection + manuallyDisconnectedRef.current = true; + reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnection if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current) - pingIntervalRef.current = null + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; } if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) - reconnectTimeoutRef.current = null + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; } if (wsRef.current) { - wsRef.current.close() - wsRef.current = null + wsRef.current.close(); + wsRef.current = null; } - setConnectionStatus('disconnected') - }, []) + setConnectionStatus("disconnected"); + }, []); return { messages, @@ -341,5 +369,5 @@ export function useExpandChat({ start, sendMessage, disconnect, - } + }; } diff --git a/ui/src/hooks/useFeatureSound.ts b/ui/src/hooks/useFeatureSound.ts index fd88df3d..51583234 100644 --- a/ui/src/hooks/useFeatureSound.ts +++ b/ui/src/hooks/useFeatureSound.ts @@ -3,8 +3,8 @@ * Uses Web Audio API to generate pleasant chime sounds */ -import { useEffect, useRef } from 'react' -import type { FeatureListResponse } from '../lib/types' +import { useEffect, useRef } from "react"; +import type { FeatureListResponse } from "../lib/types"; // Sound frequencies for different transitions (in Hz) const SOUNDS = { @@ -12,98 +12,112 @@ const SOUNDS = { started: [523.25, 659.25], // C5 -> E5 // Feature completed (in_progress -> done): pleasant major chord arpeggio completed: [523.25, 659.25, 783.99], // C5 -> E5 -> G5 -} +}; -type SoundType = keyof typeof SOUNDS +type SoundType = keyof typeof SOUNDS; function playChime(type: SoundType): void { try { - const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)() - const frequencies = SOUNDS[type] - const duration = type === 'completed' ? 0.15 : 0.12 - const totalDuration = frequencies.length * duration + const audioContext = new ( + window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }) + .webkitAudioContext + )(); + const frequencies = SOUNDS[type]; + const duration = type === "completed" ? 0.15 : 0.12; + const totalDuration = frequencies.length * duration; frequencies.forEach((freq, index) => { - const oscillator = audioContext.createOscillator() - const gainNode = audioContext.createGain() + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); - oscillator.connect(gainNode) - gainNode.connect(audioContext.destination) + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); - oscillator.type = 'sine' - oscillator.frequency.setValueAtTime(freq, audioContext.currentTime) + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime(freq, audioContext.currentTime); // Envelope for smooth sound - const startTime = audioContext.currentTime + index * duration - gainNode.gain.setValueAtTime(0, startTime) - gainNode.gain.linearRampToValueAtTime(0.3, startTime + 0.02) - gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration) + const startTime = audioContext.currentTime + index * duration; + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(0.3, startTime + 0.02); + gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration); - oscillator.start(startTime) - oscillator.stop(startTime + duration) - }) + oscillator.start(startTime); + oscillator.stop(startTime + duration); + }); // Clean up audio context after sounds finish - setTimeout(() => { - audioContext.close() - }, totalDuration * 1000 + 100) + setTimeout( + () => { + audioContext.close(); + }, + totalDuration * 1000 + 100, + ); } catch { // Audio not supported or blocked, fail silently } } interface FeatureState { - pendingIds: Set - inProgressIds: Set - doneIds: Set + pendingIds: Set; + inProgressIds: Set; + doneIds: Set; } -function getFeatureState(features: FeatureListResponse | undefined): FeatureState { +function getFeatureState( + features: FeatureListResponse | undefined, +): FeatureState { return { - pendingIds: new Set(features?.pending.map(f => f.id) ?? []), - inProgressIds: new Set(features?.in_progress.map(f => f.id) ?? []), - doneIds: new Set(features?.done.map(f => f.id) ?? []), - } + pendingIds: new Set(features?.pending.map((f) => f.id) ?? []), + inProgressIds: new Set(features?.in_progress.map((f) => f.id) ?? []), + doneIds: new Set(features?.done.map((f) => f.id) ?? []), + }; } -export function useFeatureSound(features: FeatureListResponse | undefined): void { - const prevStateRef = useRef(null) - const isInitializedRef = useRef(false) +export function useFeatureSound( + features: FeatureListResponse | undefined, +): void { + const prevStateRef = useRef(null); + const isInitializedRef = useRef(false); useEffect(() => { - if (!features) return + if (!features) return; - const currentState = getFeatureState(features) + const currentState = getFeatureState(features); // Skip sound on initial load if (!isInitializedRef.current) { - prevStateRef.current = currentState - isInitializedRef.current = true - return + prevStateRef.current = currentState; + isInitializedRef.current = true; + return; } - const prevState = prevStateRef.current + const prevState = prevStateRef.current; if (!prevState) { - prevStateRef.current = currentState - return + prevStateRef.current = currentState; + return; } // Check for features that moved to in_progress (started) for (const id of currentState.inProgressIds) { if (prevState.pendingIds.has(id) && !prevState.inProgressIds.has(id)) { - playChime('started') - break // Only play once even if multiple features moved + playChime("started"); + break; // Only play once even if multiple features moved } } // Check for features that moved to done (completed) for (const id of currentState.doneIds) { - if (!prevState.doneIds.has(id) && (prevState.inProgressIds.has(id) || prevState.pendingIds.has(id))) { - playChime('completed') - break // Only play once even if multiple features moved + if ( + !prevState.doneIds.has(id) && + (prevState.inProgressIds.has(id) || prevState.pendingIds.has(id)) + ) { + playChime("completed"); + break; // Only play once even if multiple features moved } } - prevStateRef.current = currentState - }, [features]) + prevStateRef.current = currentState; + }, [features]); } diff --git a/ui/src/hooks/useProjects.ts b/ui/src/hooks/useProjects.ts index 0af77630..498d3e96 100644 --- a/ui/src/hooks/useProjects.ts +++ b/ui/src/hooks/useProjects.ts @@ -2,9 +2,15 @@ * React Query hooks for project data */ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import * as api from '../lib/api' -import type { FeatureCreate, FeatureUpdate, ModelsResponse, Settings, SettingsUpdate } from '../lib/types' +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import * as api from "../lib/api"; +import type { + FeatureCreate, + FeatureUpdate, + ModelsResponse, + Settings, + SettingsUpdate, +} from "../lib/types"; // ============================================================================ // Projects @@ -12,40 +18,66 @@ import type { FeatureCreate, FeatureUpdate, ModelsResponse, Settings, SettingsUp export function useProjects() { return useQuery({ - queryKey: ['projects'], + queryKey: ["projects"], queryFn: api.listProjects, - }) + }); } export function useProject(name: string | null) { return useQuery({ - queryKey: ['project', name], + queryKey: ["project", name], queryFn: () => api.getProject(name!), enabled: !!name, - }) + }); } export function useCreateProject() { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ name, path, specMethod }: { name: string; path: string; specMethod?: 'claude' | 'manual' }) => - api.createProject(name, path, specMethod), + mutationFn: ({ + name, + path, + specMethod, + }: { + name: string; + path: string; + specMethod?: "claude" | "manual"; + }) => api.createProject(name, path, specMethod), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['projects'] }) + queryClient.invalidateQueries({ queryKey: ["projects"] }); }, - }) + }); } export function useDeleteProject() { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ mutationFn: (name: string) => api.deleteProject(name), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['projects'] }) + queryClient.invalidateQueries({ queryKey: ["projects"] }); }, - }) + }); +} + +export function useCloneProjectRepository(projectName: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ repoUrl, targetDir }: { repoUrl: string; targetDir?: string }) => { + if (!projectName) { + throw new Error("No project selected"); + } + return api.cloneProjectRepository(projectName, repoUrl, targetDir); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["projects"] }); + if (projectName) { + queryClient.invalidateQueries({ queryKey: ["project", projectName] }); + } + }, + }); } // ============================================================================ @@ -54,56 +86,63 @@ export function useDeleteProject() { export function useFeatures(projectName: string | null) { return useQuery({ - queryKey: ['features', projectName], + queryKey: ["features", projectName], queryFn: () => api.listFeatures(projectName!), enabled: !!projectName, refetchInterval: 5000, // Refetch every 5 seconds for real-time updates - }) + }); } export function useCreateFeature(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ - mutationFn: (feature: FeatureCreate) => api.createFeature(projectName, feature), + mutationFn: (feature: FeatureCreate) => + api.createFeature(projectName, feature), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['features', projectName] }) + queryClient.invalidateQueries({ queryKey: ["features", projectName] }); }, - }) + }); } export function useDeleteFeature(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ - mutationFn: (featureId: number) => api.deleteFeature(projectName, featureId), + mutationFn: (featureId: number) => + api.deleteFeature(projectName, featureId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['features', projectName] }) + queryClient.invalidateQueries({ queryKey: ["features", projectName] }); }, - }) + }); } export function useSkipFeature(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ mutationFn: (featureId: number) => api.skipFeature(projectName, featureId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['features', projectName] }) + queryClient.invalidateQueries({ queryKey: ["features", projectName] }); }, - }) + }); } export function useUpdateFeature(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ featureId, update }: { featureId: number; update: FeatureUpdate }) => - api.updateFeature(projectName, featureId, update), + mutationFn: ({ + featureId, + update, + }: { + featureId: number; + update: FeatureUpdate; + }) => api.updateFeature(projectName, featureId, update), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['features', projectName] }) + queryClient.invalidateQueries({ queryKey: ["features", projectName] }); }, - }) + }); } // ============================================================================ @@ -112,62 +151,72 @@ export function useUpdateFeature(projectName: string) { export function useAgentStatus(projectName: string | null) { return useQuery({ - queryKey: ['agent-status', projectName], + queryKey: ["agent-status", projectName], queryFn: () => api.getAgentStatus(projectName!), enabled: !!projectName, refetchInterval: 3000, // Poll every 3 seconds - }) + }); } export function useStartAgent(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ - mutationFn: (options: { - yoloMode?: boolean - parallelMode?: boolean - maxConcurrency?: number - testingAgentRatio?: number - } = {}) => api.startAgent(projectName, options), + mutationFn: ( + options: { + yoloMode?: boolean; + parallelMode?: boolean; + maxConcurrency?: number; + testingAgentRatio?: number; + } = {}, + ) => api.startAgent(projectName, options), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] }) + queryClient.invalidateQueries({ + queryKey: ["agent-status", projectName], + }); }, - }) + }); } export function useStopAgent(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ mutationFn: () => api.stopAgent(projectName), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] }) + queryClient.invalidateQueries({ + queryKey: ["agent-status", projectName], + }); // Invalidate schedule status to reflect manual stop override - queryClient.invalidateQueries({ queryKey: ['nextRun', projectName] }) + queryClient.invalidateQueries({ queryKey: ["nextRun", projectName] }); }, - }) + }); } export function usePauseAgent(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ mutationFn: () => api.pauseAgent(projectName), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] }) + queryClient.invalidateQueries({ + queryKey: ["agent-status", projectName], + }); }, - }) + }); } export function useResumeAgent(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ mutationFn: () => api.resumeAgent(projectName), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] }) + queryClient.invalidateQueries({ + queryKey: ["agent-status", projectName], + }); }, - }) + }); } // ============================================================================ @@ -176,18 +225,18 @@ export function useResumeAgent(projectName: string) { export function useSetupStatus() { return useQuery({ - queryKey: ['setup-status'], + queryKey: ["setup-status"], queryFn: api.getSetupStatus, staleTime: 60000, // Cache for 1 minute - }) + }); } export function useHealthCheck() { return useQuery({ - queryKey: ['health'], + queryKey: ["health"], queryFn: api.healthCheck, retry: false, - }) + }); } // ============================================================================ @@ -196,28 +245,30 @@ export function useHealthCheck() { export function useListDirectory(path?: string) { return useQuery({ - queryKey: ['filesystem', 'list', path], + queryKey: ["filesystem", "list", path], queryFn: () => api.listDirectory(path), - }) + }); } export function useCreateDirectory() { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ mutationFn: (path: string) => api.createDirectory(path), onSuccess: (_, path) => { // Invalidate parent directory listing - const parentPath = path.split('/').slice(0, -1).join('/') || undefined - queryClient.invalidateQueries({ queryKey: ['filesystem', 'list', parentPath] }) + const parentPath = path.split("/").slice(0, -1).join("/") || undefined; + queryClient.invalidateQueries({ + queryKey: ["filesystem", "list", parentPath], + }); }, - }) + }); } export function useValidatePath() { return useMutation({ mutationFn: (path: string) => api.validatePath(path), - }) + }); } // ============================================================================ @@ -227,69 +278,69 @@ export function useValidatePath() { // Default models response for placeholder (until API responds) const DEFAULT_MODELS: ModelsResponse = { models: [ - { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' }, - { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' }, + { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5" }, + { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" }, ], - default: 'claude-opus-4-5-20251101', -} + default: "claude-opus-4-5-20251101", +}; const DEFAULT_SETTINGS: Settings = { yolo_mode: false, - model: 'claude-opus-4-5-20251101', + model: "claude-opus-4-5-20251101", glm_mode: false, ollama_mode: false, testing_agent_ratio: 1, -} +}; export function useAvailableModels() { return useQuery({ - queryKey: ['available-models'], + queryKey: ["available-models"], queryFn: api.getAvailableModels, staleTime: 300000, // Cache for 5 minutes - models don't change often retry: 1, placeholderData: DEFAULT_MODELS, - }) + }); } export function useSettings() { return useQuery({ - queryKey: ['settings'], + queryKey: ["settings"], queryFn: api.getSettings, staleTime: 60000, // Cache for 1 minute retry: 1, placeholderData: DEFAULT_SETTINGS, - }) + }); } export function useUpdateSettings() { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ mutationFn: (settings: SettingsUpdate) => api.updateSettings(settings), onMutate: async (newSettings) => { // Cancel outgoing refetches - await queryClient.cancelQueries({ queryKey: ['settings'] }) + await queryClient.cancelQueries({ queryKey: ["settings"] }); // Snapshot previous value - const previous = queryClient.getQueryData(['settings']) + const previous = queryClient.getQueryData(["settings"]); // Optimistically update - queryClient.setQueryData(['settings'], (old) => ({ + queryClient.setQueryData(["settings"], (old) => ({ ...DEFAULT_SETTINGS, ...old, ...newSettings, - })) + })); - return { previous } + return { previous }; }, onError: (_err, _newSettings, context) => { // Rollback on error if (context?.previous) { - queryClient.setQueryData(['settings'], context.previous) + queryClient.setQueryData(["settings"], context.previous); } }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: ['settings'] }) + queryClient.invalidateQueries({ queryKey: ["settings"] }); }, - }) + }); } diff --git a/ui/src/hooks/useSchedules.ts b/ui/src/hooks/useSchedules.ts index 45411b0e..371674a3 100644 --- a/ui/src/hooks/useSchedules.ts +++ b/ui/src/hooks/useSchedules.ts @@ -2,9 +2,9 @@ * React Query hooks for schedule data */ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import * as api from '../lib/api' -import type { ScheduleCreate, ScheduleUpdate } from '../lib/types' +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import * as api from "../lib/api"; +import type { ScheduleCreate, ScheduleUpdate } from "../lib/types"; // ============================================================================ // Schedules @@ -15,83 +15,98 @@ import type { ScheduleCreate, ScheduleUpdate } from '../lib/types' */ export function useSchedules(projectName: string | null) { return useQuery({ - queryKey: ['schedules', projectName], + queryKey: ["schedules", projectName], queryFn: () => api.listSchedules(projectName!), enabled: !!projectName, - }) + }); } /** * Hook to fetch a single schedule. */ -export function useSchedule(projectName: string | null, scheduleId: number | null) { +export function useSchedule( + projectName: string | null, + scheduleId: number | null, +) { return useQuery({ - queryKey: ['schedule', projectName, scheduleId], + queryKey: ["schedule", projectName, scheduleId], queryFn: () => api.getSchedule(projectName!, scheduleId!), enabled: !!projectName && !!scheduleId, - }) + }); } /** * Hook to create a new schedule. */ export function useCreateSchedule(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ - mutationFn: (schedule: ScheduleCreate) => api.createSchedule(projectName, schedule), + mutationFn: (schedule: ScheduleCreate) => + api.createSchedule(projectName, schedule), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['schedules', projectName] }) - queryClient.invalidateQueries({ queryKey: ['nextRun', projectName] }) + queryClient.invalidateQueries({ queryKey: ["schedules", projectName] }); + queryClient.invalidateQueries({ queryKey: ["nextRun", projectName] }); }, - }) + }); } /** * Hook to update an existing schedule. */ export function useUpdateSchedule(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ scheduleId, update }: { scheduleId: number; update: ScheduleUpdate }) => - api.updateSchedule(projectName, scheduleId, update), + mutationFn: ({ + scheduleId, + update, + }: { + scheduleId: number; + update: ScheduleUpdate; + }) => api.updateSchedule(projectName, scheduleId, update), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['schedules', projectName] }) - queryClient.invalidateQueries({ queryKey: ['nextRun', projectName] }) + queryClient.invalidateQueries({ queryKey: ["schedules", projectName] }); + queryClient.invalidateQueries({ queryKey: ["nextRun", projectName] }); }, - }) + }); } /** * Hook to delete a schedule. */ export function useDeleteSchedule(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ - mutationFn: (scheduleId: number) => api.deleteSchedule(projectName, scheduleId), + mutationFn: (scheduleId: number) => + api.deleteSchedule(projectName, scheduleId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['schedules', projectName] }) - queryClient.invalidateQueries({ queryKey: ['nextRun', projectName] }) + queryClient.invalidateQueries({ queryKey: ["schedules", projectName] }); + queryClient.invalidateQueries({ queryKey: ["nextRun", projectName] }); }, - }) + }); } /** * Hook to toggle a schedule's enabled state. */ export function useToggleSchedule(projectName: string) { - const queryClient = useQueryClient() + const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ scheduleId, enabled }: { scheduleId: number; enabled: boolean }) => - api.updateSchedule(projectName, scheduleId, { enabled }), + mutationFn: ({ + scheduleId, + enabled, + }: { + scheduleId: number; + enabled: boolean; + }) => api.updateSchedule(projectName, scheduleId, { enabled }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['schedules', projectName] }) - queryClient.invalidateQueries({ queryKey: ['nextRun', projectName] }) + queryClient.invalidateQueries({ queryKey: ["schedules", projectName] }); + queryClient.invalidateQueries({ queryKey: ["nextRun", projectName] }); }, - }) + }); } // ============================================================================ @@ -104,9 +119,9 @@ export function useToggleSchedule(projectName: string) { */ export function useNextScheduledRun(projectName: string | null) { return useQuery({ - queryKey: ['nextRun', projectName], + queryKey: ["nextRun", projectName], queryFn: () => api.getNextScheduledRun(projectName!), enabled: !!projectName, refetchInterval: 30000, // Refresh every 30 seconds - }) + }); } diff --git a/ui/src/hooks/useSpecChat.ts b/ui/src/hooks/useSpecChat.ts index b2bac628..9e2c52ec 100644 --- a/ui/src/hooks/useSpecChat.ts +++ b/ui/src/hooks/useSpecChat.ts @@ -2,33 +2,38 @@ * Hook for managing spec creation chat WebSocket connection */ -import { useState, useCallback, useRef, useEffect } from 'react' -import type { ChatMessage, ImageAttachment, SpecChatServerMessage, SpecQuestion } from '../lib/types' -import { getSpecStatus } from '../lib/api' +import { useState, useCallback, useRef, useEffect } from "react"; +import type { + ChatMessage, + ImageAttachment, + SpecChatServerMessage, + SpecQuestion, +} from "../lib/types"; +import { getSpecStatus } from "../lib/api"; -type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' +type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; interface UseSpecChatOptions { - projectName: string - onComplete?: (specPath: string) => void - onError?: (error: string) => void + projectName: string; + onComplete?: (specPath: string) => void; + onError?: (error: string) => void; } interface UseSpecChatReturn { - messages: ChatMessage[] - isLoading: boolean - isComplete: boolean - connectionStatus: ConnectionStatus - currentQuestions: SpecQuestion[] | null - currentToolId: string | null - start: () => void - sendMessage: (content: string, attachments?: ImageAttachment[]) => void - sendAnswer: (answers: Record) => void - disconnect: () => void + messages: ChatMessage[]; + isLoading: boolean; + isComplete: boolean; + connectionStatus: ConnectionStatus; + currentQuestions: SpecQuestion[] | null; + currentToolId: string | null; + start: () => void; + sendMessage: (content: string, attachments?: ImageAttachment[]) => void; + sendAnswer: (answers: Record) => void; + disconnect: () => void; } function generateId(): string { - return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}` + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; } export function useSpecChat({ @@ -36,157 +41,172 @@ export function useSpecChat({ // onComplete intentionally not used - user clicks "Continue to Project" button instead onError, }: UseSpecChatOptions): UseSpecChatReturn { - const [messages, setMessages] = useState([]) - const [isLoading, setIsLoading] = useState(false) - const [isComplete, setIsComplete] = useState(false) - const [connectionStatus, setConnectionStatus] = useState('disconnected') - const [currentQuestions, setCurrentQuestions] = useState(null) - const [currentToolId, setCurrentToolId] = useState(null) - - const wsRef = useRef(null) - const currentAssistantMessageRef = useRef(null) - const reconnectAttempts = useRef(0) - const maxReconnectAttempts = 3 - const pingIntervalRef = useRef(null) - const reconnectTimeoutRef = useRef(null) - const isCompleteRef = useRef(false) + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isComplete, setIsComplete] = useState(false); + const [connectionStatus, setConnectionStatus] = + useState("disconnected"); + const [currentQuestions, setCurrentQuestions] = useState< + SpecQuestion[] | null + >(null); + const [currentToolId, setCurrentToolId] = useState(null); + + const wsRef = useRef(null); + const currentAssistantMessageRef = useRef(null); + const reconnectAttempts = useRef(0); + const maxReconnectAttempts = 3; + const pingIntervalRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const isCompleteRef = useRef(false); // Keep isCompleteRef in sync with isComplete state useEffect(() => { - isCompleteRef.current = isComplete - }, [isComplete]) + isCompleteRef.current = isComplete; + }, [isComplete]); // Clean up on unmount useEffect(() => { return () => { if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current) + clearInterval(pingIntervalRef.current); } if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) + clearTimeout(reconnectTimeoutRef.current); } if (wsRef.current) { - wsRef.current.close() + wsRef.current.close(); } - } - }, []) + }; + }, []); // Poll status file as fallback completion detection // Claude writes .spec_status.json when done with all spec work useEffect(() => { // Don't poll if already complete - if (isComplete) return + if (isComplete) return; // Start polling after initial delay (let WebSocket try first) const startDelay = setTimeout(() => { const pollInterval = setInterval(async () => { // Stop if already complete if (isCompleteRef.current) { - clearInterval(pollInterval) - return + clearInterval(pollInterval); + return; } try { - const status = await getSpecStatus(projectName) + const status = await getSpecStatus(projectName); - if (status.exists && status.status === 'complete') { + if (status.exists && status.status === "complete") { // Status file indicates completion - set complete state - setIsComplete(true) - setIsLoading(false) + setIsComplete(true); + setIsLoading(false); // Mark any streaming message as done setMessages((prev) => { - const lastMessage = prev[prev.length - 1] - if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.role === "assistant" && + lastMessage.isStreaming + ) { return [ ...prev.slice(0, -1), { ...lastMessage, isStreaming: false }, - ] + ]; } - return prev - }) + return prev; + }); // Add system message about completion setMessages((prev) => [ ...prev, { id: generateId(), - role: 'system', - content: `Spec creation complete! Files written: ${status.files_written.join(', ')}${status.feature_count ? ` (${status.feature_count} features)` : ''}`, + role: "system", + content: `Spec creation complete! Files written: ${status.files_written.join(", ")}${status.feature_count ? ` (${status.feature_count} features)` : ""}`, timestamp: new Date(), }, - ]) + ]); - clearInterval(pollInterval) + clearInterval(pollInterval); } } catch { // Silently ignore polling errors - WebSocket is primary mechanism } - }, 3000) // Poll every 3 seconds + }, 3000); // Poll every 3 seconds // Cleanup interval on unmount - return () => clearInterval(pollInterval) - }, 3000) // Start polling after 3 second delay + return () => clearInterval(pollInterval); + }, 3000); // Start polling after 3 second delay - return () => clearTimeout(startDelay) - }, [projectName, isComplete]) + return () => clearTimeout(startDelay); + }, [projectName, isComplete]); const connect = useCallback(() => { if (wsRef.current?.readyState === WebSocket.OPEN) { - return + return; } - setConnectionStatus('connecting') + setConnectionStatus("connecting"); - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - const host = window.location.host - const wsUrl = `${protocol}//${host}/api/spec/ws/${encodeURIComponent(projectName)}` + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const host = window.location.host; + const wsUrl = `${protocol}//${host}/api/spec/ws/${encodeURIComponent(projectName)}`; - const ws = new WebSocket(wsUrl) - wsRef.current = ws + const ws = new WebSocket(wsUrl); + wsRef.current = ws; ws.onopen = () => { - setConnectionStatus('connected') - reconnectAttempts.current = 0 + setConnectionStatus("connected"); + reconnectAttempts.current = 0; // Start ping interval to keep connection alive pingIntervalRef.current = window.setInterval(() => { if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'ping' })) + ws.send(JSON.stringify({ type: "ping" })); } - }, 30000) - } + }, 30000); + }; ws.onclose = () => { - setConnectionStatus('disconnected') + setConnectionStatus("disconnected"); if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current) - pingIntervalRef.current = null + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; } // Attempt reconnection if not intentionally closed - if (reconnectAttempts.current < maxReconnectAttempts && !isCompleteRef.current) { - reconnectAttempts.current++ - const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000) - reconnectTimeoutRef.current = window.setTimeout(connect, delay) + if ( + reconnectAttempts.current < maxReconnectAttempts && + !isCompleteRef.current + ) { + reconnectAttempts.current++; + const delay = Math.min( + 1000 * Math.pow(2, reconnectAttempts.current), + 10000, + ); + reconnectTimeoutRef.current = window.setTimeout(connect, delay); } - } + }; ws.onerror = () => { - setConnectionStatus('error') - onError?.('WebSocket connection error') - } + setConnectionStatus("error"); + onError?.("WebSocket connection error"); + }; ws.onmessage = (event) => { try { - const data = JSON.parse(event.data) as SpecChatServerMessage + const data = JSON.parse(event.data) as SpecChatServerMessage; switch (data.type) { - case 'text': { + case "text": { // Append text to current assistant message or create new one setMessages((prev) => { - const lastMessage = prev[prev.length - 1] - if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.role === "assistant" && + lastMessage.isStreaming + ) { // Append to existing streaming message return [ ...prev.slice(0, -1), @@ -194,35 +214,38 @@ export function useSpecChat({ ...lastMessage, content: lastMessage.content + data.content, }, - ] + ]; } else { // Create new assistant message - currentAssistantMessageRef.current = generateId() + currentAssistantMessageRef.current = generateId(); return [ ...prev, { id: currentAssistantMessageRef.current, - role: 'assistant', + role: "assistant", content: data.content, timestamp: new Date(), isStreaming: true, }, - ] + ]; } - }) - break + }); + break; } - case 'question': { + case "question": { // Show structured question UI - setCurrentQuestions(data.questions) - setCurrentToolId(data.tool_id || null) - setIsLoading(false) + setCurrentQuestions(data.questions); + setCurrentToolId(data.tool_id || null); + setIsLoading(false); // Mark current message as done streaming setMessages((prev) => { - const lastMessage = prev[prev.length - 1] - if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.role === "assistant" && + lastMessage.isStreaming + ) { return [ ...prev.slice(0, -1), { @@ -230,231 +253,254 @@ export function useSpecChat({ isStreaming: false, questions: data.questions, }, - ] + ]; } - return prev - }) - break + return prev; + }); + break; } - case 'spec_complete': { - setIsComplete(true) - setIsLoading(false) + case "spec_complete": { + setIsComplete(true); + setIsLoading(false); // Mark current message as done setMessages((prev) => { - const lastMessage = prev[prev.length - 1] - if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.role === "assistant" && + lastMessage.isStreaming + ) { return [ ...prev.slice(0, -1), { ...lastMessage, isStreaming: false }, - ] + ]; } - return prev - }) + return prev; + }); // Add system message about spec completion setMessages((prev) => [ ...prev, { id: generateId(), - role: 'system', + role: "system", content: `Specification file created: ${data.path}`, timestamp: new Date(), }, - ]) + ]); // NOTE: Do NOT auto-call onComplete here! // User should click "Continue to Project" button to start the agent. // This matches the CLI behavior where user closes the chat manually. - break + break; } - case 'file_written': { + case "file_written": { // Optional: notify about other files being written setMessages((prev) => [ ...prev, { id: generateId(), - role: 'system', + role: "system", content: `File created: ${data.path}`, timestamp: new Date(), }, - ]) - break + ]); + break; } - case 'complete': { - setIsComplete(true) - setIsLoading(false) + case "complete": { + setIsComplete(true); + setIsLoading(false); // Mark current message as done setMessages((prev) => { - const lastMessage = prev[prev.length - 1] - if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.role === "assistant" && + lastMessage.isStreaming + ) { return [ ...prev.slice(0, -1), { ...lastMessage, isStreaming: false }, - ] + ]; } - return prev - }) - break + return prev; + }); + break; } - case 'error': { - setIsLoading(false) - onError?.(data.content) + case "error": { + setIsLoading(false); + onError?.(data.content); // Add error as system message setMessages((prev) => [ ...prev, { id: generateId(), - role: 'system', + role: "system", content: `Error: ${data.content}`, timestamp: new Date(), }, - ]) - break + ]); + break; } - case 'pong': { + case "pong": { // Keep-alive response, nothing to do - break + break; } - case 'response_done': { + case "response_done": { // Response complete - hide loading indicator and mark message as done - setIsLoading(false) + setIsLoading(false); // Mark current message as done streaming setMessages((prev) => { - const lastMessage = prev[prev.length - 1] - if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.role === "assistant" && + lastMessage.isStreaming + ) { return [ ...prev.slice(0, -1), { ...lastMessage, isStreaming: false }, - ] + ]; } - return prev - }) - break + return prev; + }); + break; } } } catch (e) { - console.error('Failed to parse WebSocket message:', e) + console.error("Failed to parse WebSocket message:", e); } - } - }, [projectName, onError]) + }; + }, [projectName, onError]); const start = useCallback(() => { - connect() + connect(); // Wait for connection then send start message const checkAndSend = () => { if (wsRef.current?.readyState === WebSocket.OPEN) { - setIsLoading(true) - wsRef.current.send(JSON.stringify({ type: 'start' })) + setIsLoading(true); + wsRef.current.send(JSON.stringify({ type: "start" })); } else if (wsRef.current?.readyState === WebSocket.CONNECTING) { - setTimeout(checkAndSend, 100) + setTimeout(checkAndSend, 100); } - } + }; - setTimeout(checkAndSend, 100) - }, [connect]) + setTimeout(checkAndSend, 100); + }, [connect]); - const sendMessage = useCallback((content: string, attachments?: ImageAttachment[]) => { - if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { - onError?.('Not connected') - return - } + const sendMessage = useCallback( + (content: string, attachments?: ImageAttachment[]) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + onError?.("Not connected"); + return; + } - // Add user message to chat (with attachments for display) - setMessages((prev) => [ - ...prev, - { - id: generateId(), - role: 'user', + // Add user message to chat (with attachments for display) + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: "user", + content, + attachments, + timestamp: new Date(), + }, + ]); + + // Clear current questions + setCurrentQuestions(null); + setCurrentToolId(null); + setIsLoading(true); + + // Build message payload + const payload: { + type: string; + content: string; + attachments?: Array<{ + filename: string; + mimeType: string; + base64Data: string; + }>; + } = { + type: "message", content, - attachments, - timestamp: new Date(), - }, - ]) - - // Clear current questions - setCurrentQuestions(null) - setCurrentToolId(null) - setIsLoading(true) - - // Build message payload - const payload: { type: string; content: string; attachments?: Array<{ filename: string; mimeType: string; base64Data: string }> } = { - type: 'message', - content, - } - - // Add attachments if present (send base64 data, not preview URL) - if (attachments && attachments.length > 0) { - payload.attachments = attachments.map((a) => ({ - filename: a.filename, - mimeType: a.mimeType, - base64Data: a.base64Data, - })) - } - - // Send to server - wsRef.current.send(JSON.stringify(payload)) - }, [onError]) + }; + + // Add attachments if present (send base64 data, not preview URL) + if (attachments && attachments.length > 0) { + payload.attachments = attachments.map((a) => ({ + filename: a.filename, + mimeType: a.mimeType, + base64Data: a.base64Data, + })); + } - const sendAnswer = useCallback((answers: Record) => { - if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { - onError?.('Not connected') - return - } + // Send to server + wsRef.current.send(JSON.stringify(payload)); + }, + [onError], + ); + + const sendAnswer = useCallback( + (answers: Record) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + onError?.("Not connected"); + return; + } - // Format answers for display - const answerText = Object.values(answers) - .map((v) => (Array.isArray(v) ? v.join(', ') : v)) - .join('; ') - - // Add user message - setMessages((prev) => [ - ...prev, - { - id: generateId(), - role: 'user', - content: answerText, - timestamp: new Date(), - }, - ]) - - // Clear current questions - setCurrentQuestions(null) - setCurrentToolId(null) - setIsLoading(true) - - // Send to server - wsRef.current.send( - JSON.stringify({ - type: 'answer', - answers, - tool_id: currentToolId, - }) - ) - }, [currentToolId, onError]) + // Format answers for display + const answerText = Object.values(answers) + .map((v) => (Array.isArray(v) ? v.join(", ") : v)) + .join("; "); + + // Add user message + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: "user", + content: answerText, + timestamp: new Date(), + }, + ]); + + // Clear current questions + setCurrentQuestions(null); + setCurrentToolId(null); + setIsLoading(true); + + // Send to server + wsRef.current.send( + JSON.stringify({ + type: "answer", + answers, + tool_id: currentToolId, + }), + ); + }, + [currentToolId, onError], + ); const disconnect = useCallback(() => { - reconnectAttempts.current = maxReconnectAttempts // Prevent reconnection + reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnection if (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current) - pingIntervalRef.current = null + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; } if (wsRef.current) { - wsRef.current.close() - wsRef.current = null + wsRef.current.close(); + wsRef.current = null; } - setConnectionStatus('disconnected') - }, []) + setConnectionStatus("disconnected"); + }, []); return { messages, @@ -467,5 +513,5 @@ export function useSpecChat({ sendMessage, sendAnswer, disconnect, - } + }; } diff --git a/ui/src/hooks/useTheme.ts b/ui/src/hooks/useTheme.ts index 57e5936e..d4bfda3a 100644 --- a/ui/src/hooks/useTheme.ts +++ b/ui/src/hooks/useTheme.ts @@ -1,132 +1,168 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback } from "react"; -export type ThemeId = 'twitter' | 'claude' | 'neo-brutalism' | 'retro-arcade' | 'aurora' +export type ThemeId = + | "twitter" + | "claude" + | "neo-brutalism" + | "retro-arcade" + | "aurora"; export interface ThemeOption { - id: ThemeId - name: string - description: string + id: ThemeId; + name: string; + description: string; previewColors: { - primary: string - background: string - accent: string - } + primary: string; + background: string; + accent: string; + }; } export const THEMES: ThemeOption[] = [ { - id: 'twitter', - name: 'Twitter', - description: 'Clean and modern blue design', - previewColors: { primary: '#4a9eff', background: '#ffffff', accent: '#e8f4ff' } + id: "twitter", + name: "Twitter", + description: "Clean and modern blue design", + previewColors: { + primary: "#4a9eff", + background: "#ffffff", + accent: "#e8f4ff", + }, }, { - id: 'claude', - name: 'Claude', - description: 'Warm beige tones with orange accents', - previewColors: { primary: '#c75b2a', background: '#faf6f0', accent: '#f5ede4' } + id: "claude", + name: "Claude", + description: "Warm beige tones with orange accents", + previewColors: { + primary: "#c75b2a", + background: "#faf6f0", + accent: "#f5ede4", + }, }, { - id: 'neo-brutalism', - name: 'Neo Brutalism', - description: 'Bold colors with hard shadows', - previewColors: { primary: '#ff4d00', background: '#ffffff', accent: '#ffeb00' } + id: "neo-brutalism", + name: "Neo Brutalism", + description: "Bold colors with hard shadows", + previewColors: { + primary: "#ff4d00", + background: "#ffffff", + accent: "#ffeb00", + }, }, { - id: 'retro-arcade', - name: 'Retro Arcade', - description: 'Vibrant pink and teal pixel vibes', - previewColors: { primary: '#e8457c', background: '#f0e6d3', accent: '#4eb8a5' } + id: "retro-arcade", + name: "Retro Arcade", + description: "Vibrant pink and teal pixel vibes", + previewColors: { + primary: "#e8457c", + background: "#f0e6d3", + accent: "#4eb8a5", + }, }, { - id: 'aurora', - name: 'Aurora', - description: 'Deep violet and teal, like northern lights', - previewColors: { primary: '#8b5cf6', background: '#faf8ff', accent: '#2dd4bf' } - } -] + id: "aurora", + name: "Aurora", + description: "Deep violet and teal, like northern lights", + previewColors: { + primary: "#8b5cf6", + background: "#faf8ff", + accent: "#2dd4bf", + }, + }, +]; -const THEME_STORAGE_KEY = 'autocoder-theme' -const DARK_MODE_STORAGE_KEY = 'autocoder-dark-mode' +const THEME_STORAGE_KEY = "autocoder-theme"; +const DARK_MODE_STORAGE_KEY = "autocoder-dark-mode"; function getThemeClass(themeId: ThemeId): string { switch (themeId) { - case 'twitter': - return '' // Default, no class needed - case 'claude': - return 'theme-claude' - case 'neo-brutalism': - return 'theme-neo-brutalism' - case 'retro-arcade': - return 'theme-retro-arcade' - case 'aurora': - return 'theme-aurora' + case "twitter": + return ""; // Default, no class needed + case "claude": + return "theme-claude"; + case "neo-brutalism": + return "theme-neo-brutalism"; + case "retro-arcade": + return "theme-retro-arcade"; + case "aurora": + return "theme-aurora"; default: - return '' + return ""; } } export function useTheme() { const [theme, setThemeState] = useState(() => { try { - const stored = localStorage.getItem(THEME_STORAGE_KEY) - if (stored === 'twitter' || stored === 'claude' || stored === 'neo-brutalism' || stored === 'retro-arcade' || stored === 'aurora') { - return stored + const stored = localStorage.getItem(THEME_STORAGE_KEY); + if ( + stored === "twitter" || + stored === "claude" || + stored === "neo-brutalism" || + stored === "retro-arcade" || + stored === "aurora" + ) { + return stored; } } catch { // localStorage not available } - return 'twitter' - }) + return "twitter"; + }); const [darkMode, setDarkModeState] = useState(() => { try { - return localStorage.getItem(DARK_MODE_STORAGE_KEY) === 'true' + return localStorage.getItem(DARK_MODE_STORAGE_KEY) === "true"; } catch { - return false + return false; } - }) + }); // Apply theme and dark mode classes to document useEffect(() => { - const root = document.documentElement + const root = document.documentElement; // Remove all theme classes - root.classList.remove('theme-claude', 'theme-neo-brutalism', 'theme-retro-arcade', 'theme-aurora') + root.classList.remove( + "theme-claude", + "theme-neo-brutalism", + "theme-retro-arcade", + "theme-aurora", + ); // Add current theme class (if not twitter/default) - const themeClass = getThemeClass(theme) + const themeClass = getThemeClass(theme); if (themeClass) { - root.classList.add(themeClass) + root.classList.add(themeClass); } // Handle dark mode if (darkMode) { - root.classList.add('dark') + root.classList.add("dark"); } else { - root.classList.remove('dark') + root.classList.remove("dark"); } // Persist to localStorage try { - localStorage.setItem(THEME_STORAGE_KEY, theme) - localStorage.setItem(DARK_MODE_STORAGE_KEY, String(darkMode)) + localStorage.setItem(THEME_STORAGE_KEY, theme); + localStorage.setItem(DARK_MODE_STORAGE_KEY, String(darkMode)); } catch { // localStorage not available } - }, [theme, darkMode]) + }, [theme, darkMode]); const setTheme = useCallback((newTheme: ThemeId) => { - setThemeState(newTheme) - }, []) + setThemeState(newTheme); + }, []); const setDarkMode = useCallback((enabled: boolean) => { - setDarkModeState(enabled) - }, []) + setDarkModeState(enabled); + }, []); const toggleDarkMode = useCallback(() => { - setDarkModeState(prev => !prev) - }, []) + setDarkModeState((prev) => !prev); + }, []); return { theme, @@ -135,6 +171,6 @@ export function useTheme() { setDarkMode, toggleDarkMode, themes: THEMES, - currentTheme: THEMES.find(t => t.id === theme) ?? THEMES[0] - } + currentTheme: THEMES.find((t) => t.id === theme) ?? THEMES[0], + }; } diff --git a/ui/src/hooks/useWebSocket.ts b/ui/src/hooks/useWebSocket.ts index 18b117e0..1573e7ea 100644 --- a/ui/src/hooks/useWebSocket.ts +++ b/ui/src/hooks/useWebSocket.ts @@ -2,7 +2,7 @@ * WebSocket Hook for Real-time Updates */ -import { useEffect, useRef, useState, useCallback } from 'react' +import { useEffect, useRef, useState, useCallback } from "react"; import type { WSMessage, AgentStatus, @@ -12,59 +12,64 @@ import type { AgentLogEntry, OrchestratorStatus, OrchestratorEvent, -} from '../lib/types' +} from "../lib/types"; // Activity item for the feed interface ActivityItem { - agentName: string - thought: string - timestamp: string - featureId: number + agentName: string; + thought: string; + timestamp: string; + featureId: number; } // Celebration trigger for overlay interface CelebrationTrigger { - agentName: AgentMascot | 'Unknown' - featureName: string - featureId: number + agentName: AgentMascot | "Unknown"; + featureName: string; + featureId: number; } interface WebSocketState { progress: { - passing: number - in_progress: number - total: number - percentage: number - } - agentStatus: AgentStatus - logs: Array<{ line: string; timestamp: string; featureId?: number; agentIndex?: number }> - isConnected: boolean - devServerStatus: DevServerStatus - devServerUrl: string | null - devLogs: Array<{ line: string; timestamp: string }> + passing: number; + in_progress: number; + total: number; + percentage: number; + }; + agentStatus: AgentStatus; + logs: Array<{ + line: string; + timestamp: string; + featureId?: number; + agentIndex?: number; + }>; + isConnected: boolean; + devServerStatus: DevServerStatus; + devServerUrl: string | null; + devLogs: Array<{ line: string; timestamp: string }>; // Multi-agent state - activeAgents: ActiveAgent[] - recentActivity: ActivityItem[] + activeAgents: ActiveAgent[]; + recentActivity: ActivityItem[]; // Per-agent logs for debugging (indexed by agentIndex) - agentLogs: Map + agentLogs: Map; // Celebration queue to handle rapid successes without race conditions - celebrationQueue: CelebrationTrigger[] - celebration: CelebrationTrigger | null + celebrationQueue: CelebrationTrigger[]; + celebration: CelebrationTrigger | null; // Orchestrator state for Mission Control - orchestratorStatus: OrchestratorStatus | null + orchestratorStatus: OrchestratorStatus | null; } -const MAX_LOGS = 100 // Keep last 100 log lines -const MAX_ACTIVITY = 20 // Keep last 20 activity items -const MAX_AGENT_LOGS = 500 // Keep last 500 log lines per agent +const MAX_LOGS = 100; // Keep last 100 log lines +const MAX_ACTIVITY = 20; // Keep last 20 activity items +const MAX_AGENT_LOGS = 500; // Keep last 500 log lines per agent export function useProjectWebSocket(projectName: string | null) { const [state, setState] = useState({ progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 }, - agentStatus: 'loading', + agentStatus: "loading", logs: [], isConnected: false, - devServerStatus: 'stopped', + devServerStatus: "stopped", devServerUrl: null, devLogs: [], activeAgents: [], @@ -73,36 +78,36 @@ export function useProjectWebSocket(projectName: string | null) { celebrationQueue: [], celebration: null, orchestratorStatus: null, - }) + }); - const wsRef = useRef(null) - const reconnectTimeoutRef = useRef(null) - const reconnectAttempts = useRef(0) + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const reconnectAttempts = useRef(0); const connect = useCallback(() => { - if (!projectName) return + if (!projectName) return; // Build WebSocket URL - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - const host = window.location.host - const wsUrl = `${protocol}//${host}/ws/projects/${encodeURIComponent(projectName)}` + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const host = window.location.host; + const wsUrl = `${protocol}//${host}/ws/projects/${encodeURIComponent(projectName)}`; try { - const ws = new WebSocket(wsUrl) - wsRef.current = ws + const ws = new WebSocket(wsUrl); + wsRef.current = ws; ws.onopen = () => { - setState(prev => ({ ...prev, isConnected: true })) - reconnectAttempts.current = 0 - } + setState((prev) => ({ ...prev, isConnected: true })); + reconnectAttempts.current = 0; + }; ws.onmessage = (event) => { try { - const message: WSMessage = JSON.parse(event.data) + const message: WSMessage = JSON.parse(event.data); switch (message.type) { - case 'progress': - setState(prev => ({ + case "progress": + setState((prev) => ({ ...prev, progress: { passing: message.passing, @@ -110,24 +115,25 @@ export function useProjectWebSocket(projectName: string | null) { total: message.total, percentage: message.percentage, }, - })) - break + })); + break; - case 'agent_status': - setState(prev => ({ + case "agent_status": + setState((prev) => ({ ...prev, agentStatus: message.status, // Clear active agents and orchestrator status when process stops OR crashes to prevent stale UI - ...((message.status === 'stopped' || message.status === 'crashed') && { + ...((message.status === "stopped" || + message.status === "crashed") && { activeAgents: [], recentActivity: [], orchestratorStatus: null, }), - })) - break + })); + break; - case 'log': - setState(prev => { + case "log": + setState((prev) => { // Update global logs const newLogs = [ ...prev.logs.slice(-MAX_LOGS + 1), @@ -137,85 +143,87 @@ export function useProjectWebSocket(projectName: string | null) { featureId: message.featureId, agentIndex: message.agentIndex, }, - ] + ]; // Also store in per-agent logs if we have an agentIndex - let newAgentLogs = prev.agentLogs + let newAgentLogs = prev.agentLogs; if (message.agentIndex !== undefined) { - newAgentLogs = new Map(prev.agentLogs) - const existingLogs = newAgentLogs.get(message.agentIndex) || [] + newAgentLogs = new Map(prev.agentLogs); + const existingLogs = + newAgentLogs.get(message.agentIndex) || []; const logEntry: AgentLogEntry = { line: message.line, timestamp: message.timestamp, - type: 'output', - } - newAgentLogs.set( - message.agentIndex, - [...existingLogs.slice(-MAX_AGENT_LOGS + 1), logEntry] - ) + type: "output", + }; + newAgentLogs.set(message.agentIndex, [ + ...existingLogs.slice(-MAX_AGENT_LOGS + 1), + logEntry, + ]); } - return { ...prev, logs: newLogs, agentLogs: newAgentLogs } - }) - break + return { ...prev, logs: newLogs, agentLogs: newAgentLogs }; + }); + break; - case 'feature_update': + case "feature_update": // Feature updates will trigger a refetch via React Query - break + break; - case 'agent_update': - setState(prev => { + case "agent_update": + setState((prev) => { // Log state change to per-agent logs - const newAgentLogs = new Map(prev.agentLogs) - const existingLogs = newAgentLogs.get(message.agentIndex) || [] + const newAgentLogs = new Map(prev.agentLogs); + const existingLogs = newAgentLogs.get(message.agentIndex) || []; const stateLogEntry: AgentLogEntry = { - line: `[STATE] ${message.state}${message.thought ? `: ${message.thought}` : ''}`, + line: `[STATE] ${message.state}${message.thought ? `: ${message.thought}` : ""}`, timestamp: message.timestamp, - type: message.state === 'error' ? 'error' : 'state_change', - } - newAgentLogs.set( - message.agentIndex, - [...existingLogs.slice(-MAX_AGENT_LOGS + 1), stateLogEntry] - ) + type: message.state === "error" ? "error" : "state_change", + }; + newAgentLogs.set(message.agentIndex, [ + ...existingLogs.slice(-MAX_AGENT_LOGS + 1), + stateLogEntry, + ]); // Get current logs for this agent to attach to ActiveAgent - const agentLogsArray = newAgentLogs.get(message.agentIndex) || [] + const agentLogsArray = + newAgentLogs.get(message.agentIndex) || []; // Update or add the agent in activeAgents const existingAgentIdx = prev.activeAgents.findIndex( - a => a.agentIndex === message.agentIndex - ) + (a) => a.agentIndex === message.agentIndex, + ); - let newAgents: ActiveAgent[] - if (message.state === 'success' || message.state === 'error') { + let newAgents: ActiveAgent[]; + if (message.state === "success" || message.state === "error") { // Remove agent from active list on completion (success or failure) // But keep the logs in agentLogs map for debugging if (message.agentIndex === -1) { // Synthetic completion: remove by featureId // This handles agents that weren't tracked but still completed newAgents = prev.activeAgents.filter( - a => a.featureId !== message.featureId - ) + (a) => a.featureId !== message.featureId, + ); } else { // Normal completion: remove by agentIndex newAgents = prev.activeAgents.filter( - a => a.agentIndex !== message.agentIndex - ) + (a) => a.agentIndex !== message.agentIndex, + ); } } else if (existingAgentIdx >= 0) { // Update existing agent - newAgents = [...prev.activeAgents] + newAgents = [...prev.activeAgents]; newAgents[existingAgentIdx] = { agentIndex: message.agentIndex, agentName: message.agentName, - agentType: message.agentType || 'coding', // Default to coding for backwards compat + agentType: message.agentType || "coding", // Default to coding for backwards compat featureId: message.featureId, featureName: message.featureName, state: message.state, thought: message.thought, timestamp: message.timestamp, logs: agentLogsArray, - } + }; } else { // Add new agent newAgents = [ @@ -223,7 +231,7 @@ export function useProjectWebSocket(projectName: string | null) { { agentIndex: message.agentIndex, agentName: message.agentName, - agentType: message.agentType || 'coding', // Default to coding for backwards compat + agentType: message.agentType || "coding", // Default to coding for backwards compat featureId: message.featureId, featureName: message.featureName, state: message.state, @@ -231,11 +239,11 @@ export function useProjectWebSocket(projectName: string | null) { timestamp: message.timestamp, logs: agentLogsArray, }, - ] + ]; } // Add to activity feed if there's a thought - let newActivity = prev.recentActivity + let newActivity = prev.recentActivity; if (message.thought) { newActivity = [ { @@ -245,26 +253,29 @@ export function useProjectWebSocket(projectName: string | null) { featureId: message.featureId, }, ...prev.recentActivity.slice(0, MAX_ACTIVITY - 1), - ] + ]; } // Handle celebration queue on success - let newCelebrationQueue = prev.celebrationQueue - let newCelebration = prev.celebration + let newCelebrationQueue = prev.celebrationQueue; + let newCelebration = prev.celebration; - if (message.state === 'success') { + if (message.state === "success") { const newCelebrationItem: CelebrationTrigger = { agentName: message.agentName, featureName: message.featureName, featureId: message.featureId, - } + }; // If no celebration is showing, show this one immediately // Otherwise, add to queue if (!prev.celebration) { - newCelebration = newCelebrationItem + newCelebration = newCelebrationItem; } else { - newCelebrationQueue = [...prev.celebrationQueue, newCelebrationItem] + newCelebrationQueue = [ + ...prev.celebrationQueue, + newCelebrationItem, + ]; } } @@ -275,104 +286,128 @@ export function useProjectWebSocket(projectName: string | null) { recentActivity: newActivity, celebrationQueue: newCelebrationQueue, celebration: newCelebration, - } - }) - break + }; + }); + break; - case 'orchestrator_update': - setState(prev => { + case "orchestrator_update": + setState((prev) => { const newEvent: OrchestratorEvent = { eventType: message.eventType, message: message.message, timestamp: message.timestamp, featureId: message.featureId, featureName: message.featureName, - } + }; return { ...prev, orchestratorStatus: { state: message.state, message: message.message, - codingAgents: message.codingAgents ?? prev.orchestratorStatus?.codingAgents ?? 0, - testingAgents: message.testingAgents ?? prev.orchestratorStatus?.testingAgents ?? 0, - maxConcurrency: message.maxConcurrency ?? prev.orchestratorStatus?.maxConcurrency ?? 3, - readyCount: message.readyCount ?? prev.orchestratorStatus?.readyCount ?? 0, - blockedCount: message.blockedCount ?? prev.orchestratorStatus?.blockedCount ?? 0, + codingAgents: + message.codingAgents ?? + prev.orchestratorStatus?.codingAgents ?? + 0, + testingAgents: + message.testingAgents ?? + prev.orchestratorStatus?.testingAgents ?? + 0, + maxConcurrency: + message.maxConcurrency ?? + prev.orchestratorStatus?.maxConcurrency ?? + 3, + readyCount: + message.readyCount ?? + prev.orchestratorStatus?.readyCount ?? + 0, + blockedCount: + message.blockedCount ?? + prev.orchestratorStatus?.blockedCount ?? + 0, timestamp: message.timestamp, - recentEvents: [newEvent, ...(prev.orchestratorStatus?.recentEvents ?? []).slice(0, 4)], + recentEvents: [ + newEvent, + ...(prev.orchestratorStatus?.recentEvents ?? []).slice( + 0, + 4, + ), + ], }, - } - }) - break + }; + }); + break; - case 'dev_log': - setState(prev => ({ + case "dev_log": + setState((prev) => ({ ...prev, devLogs: [ ...prev.devLogs.slice(-MAX_LOGS + 1), { line: message.line, timestamp: message.timestamp }, ], - })) - break + })); + break; - case 'dev_server_status': - setState(prev => ({ + case "dev_server_status": + setState((prev) => ({ ...prev, devServerStatus: message.status, devServerUrl: message.url, - })) - break + })); + break; - case 'pong': + case "pong": // Heartbeat response - break + break; } } catch { - console.error('Failed to parse WebSocket message') + console.error("Failed to parse WebSocket message"); } - } + }; ws.onclose = () => { - setState(prev => ({ ...prev, isConnected: false })) - wsRef.current = null + setState((prev) => ({ ...prev, isConnected: false })); + wsRef.current = null; // Exponential backoff reconnection - const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000) - reconnectAttempts.current++ + const delay = Math.min( + 1000 * Math.pow(2, reconnectAttempts.current), + 30000, + ); + reconnectAttempts.current++; reconnectTimeoutRef.current = window.setTimeout(() => { - connect() - }, delay) - } + connect(); + }, delay); + }; ws.onerror = () => { - ws.close() - } + ws.close(); + }; } catch { // Failed to connect, will retry via onclose } - }, [projectName]) + }, [projectName]); // Send ping to keep connection alive const sendPing = useCallback(() => { if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ type: 'ping' })) + wsRef.current.send(JSON.stringify({ type: "ping" })); } - }, []) + }, []); // Clear celebration and show next one from queue if available const clearCelebration = useCallback(() => { - setState(prev => { + setState((prev) => { // Pop the next celebration from the queue if available - const [nextCelebration, ...remainingQueue] = prev.celebrationQueue + const [nextCelebration, ...remainingQueue] = prev.celebrationQueue; return { ...prev, celebration: nextCelebration || null, celebrationQueue: remainingQueue, - } - }) - }, []) + }; + }); + }, []); // Connect when project changes useEffect(() => { @@ -380,10 +415,10 @@ export function useProjectWebSocket(projectName: string | null) { // Use 'loading' for agentStatus to show loading indicator until WebSocket provides actual status setState({ progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 }, - agentStatus: 'loading', + agentStatus: "loading", logs: [], isConnected: false, - devServerStatus: 'stopped', + devServerStatus: "stopped", devServerUrl: null, devLogs: [], activeAgents: [], @@ -392,78 +427,81 @@ export function useProjectWebSocket(projectName: string | null) { celebrationQueue: [], celebration: null, orchestratorStatus: null, - }) + }); if (!projectName) { // Disconnect if no project if (wsRef.current) { - wsRef.current.close() - wsRef.current = null + wsRef.current.close(); + wsRef.current = null; } - return + return; } - connect() + connect(); // Ping every 30 seconds - const pingInterval = setInterval(sendPing, 30000) + const pingInterval = setInterval(sendPing, 30000); return () => { - clearInterval(pingInterval) + clearInterval(pingInterval); if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) + clearTimeout(reconnectTimeoutRef.current); } if (wsRef.current) { - wsRef.current.close() - wsRef.current = null + wsRef.current.close(); + wsRef.current = null; } - } - }, [projectName, connect, sendPing]) + }; + }, [projectName, connect, sendPing]); // Defense-in-depth: cleanup stale agents for users who leave UI open for hours // This catches edge cases where completion messages are missed useEffect(() => { - const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes + const STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes const cleanup = setInterval(() => { - setState(prev => { - const now = Date.now() - const fresh = prev.activeAgents.filter(a => - now - new Date(a.timestamp).getTime() < STALE_THRESHOLD_MS - ) + setState((prev) => { + const now = Date.now(); + const fresh = prev.activeAgents.filter( + (a) => now - new Date(a.timestamp).getTime() < STALE_THRESHOLD_MS, + ); if (fresh.length !== prev.activeAgents.length) { - return { ...prev, activeAgents: fresh } + return { ...prev, activeAgents: fresh }; } - return prev - }) - }, 60000) // Check every minute + return prev; + }); + }, 60000); // Check every minute - return () => clearInterval(cleanup) - }, []) + return () => clearInterval(cleanup); + }, []); // Clear logs function const clearLogs = useCallback(() => { - setState(prev => ({ ...prev, logs: [] })) - }, []) + setState((prev) => ({ ...prev, logs: [] })); + }, []); // Clear dev logs function const clearDevLogs = useCallback(() => { - setState(prev => ({ ...prev, devLogs: [] })) - }, []) + setState((prev) => ({ ...prev, devLogs: [] })); + }, []); // Get logs for a specific agent (useful for debugging even after agent completes/fails) - const getAgentLogs = useCallback((agentIndex: number): AgentLogEntry[] => { - return state.agentLogs.get(agentIndex) || [] - }, [state.agentLogs]) + const getAgentLogs = useCallback( + (agentIndex: number): AgentLogEntry[] => { + return state.agentLogs.get(agentIndex) || []; + }, + [state.agentLogs], + ); // Clear logs for a specific agent const clearAgentLogs = useCallback((agentIndex: number) => { - setState(prev => { - const newAgentLogs = new Map(prev.agentLogs) - newAgentLogs.delete(agentIndex) - return { ...prev, agentLogs: newAgentLogs } - }) - }, []) + setState((prev) => { + const newAgentLogs = new Map(prev.agentLogs); + newAgentLogs.delete(agentIndex); + return { ...prev, agentLogs: newAgentLogs }; + }); + }, []); return { ...state, @@ -472,5 +510,5 @@ export function useProjectWebSocket(projectName: string | null) { clearCelebration, getAgentLogs, clearAgentLogs, - } + }; } diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 7ef9a8ab..c98e34ae 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -6,6 +6,7 @@ import type { ProjectSummary, ProjectDetail, ProjectPrompts, + ProjectCloneResponse, FeatureListResponse, Feature, FeatureCreate, @@ -31,30 +32,32 @@ import type { ScheduleUpdate, ScheduleListResponse, NextRunResponse, -} from './types' +} from "./types"; -const API_BASE = '/api' +const API_BASE = "/api"; async function fetchJSON(url: string, options?: RequestInit): Promise { const response = await fetch(`${API_BASE}${url}`, { ...options, headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", ...options?.headers, }, - }) + }); if (!response.ok) { - const error = await response.json().catch(() => ({ detail: 'Unknown error' })) - throw new Error(error.detail || `HTTP ${response.status}`) + const error = await response + .json() + .catch(() => ({ detail: "Unknown error" })); + throw new Error(error.detail || `HTTP ${response.status}`); } // Handle 204 No Content responses if (response.status === 204) { - return undefined as T + return undefined as T; } - return response.json() + return response.json(); } // ============================================================================ @@ -62,184 +65,238 @@ async function fetchJSON(url: string, options?: RequestInit): Promise { // ============================================================================ export async function listProjects(): Promise { - return fetchJSON('/projects') + return fetchJSON("/projects"); } export async function createProject( name: string, path: string, - specMethod: 'claude' | 'manual' = 'manual' + specMethod: "claude" | "manual" = "manual", ): Promise { - return fetchJSON('/projects', { - method: 'POST', + return fetchJSON("/projects", { + method: "POST", body: JSON.stringify({ name, path, spec_method: specMethod }), - }) + }); } export async function getProject(name: string): Promise { - return fetchJSON(`/projects/${encodeURIComponent(name)}`) + return fetchJSON(`/projects/${encodeURIComponent(name)}`); } export async function deleteProject(name: string): Promise { await fetchJSON(`/projects/${encodeURIComponent(name)}`, { - method: 'DELETE', - }) + method: "DELETE", + }); +} + +export async function cloneProjectRepository( + name: string, + repoUrl: string, + targetDir?: string, +): Promise { + return fetchJSON(`/projects/${encodeURIComponent(name)}/clone`, { + method: "POST", + body: JSON.stringify({ repo_url: repoUrl, target_dir: targetDir }), + }); } export async function getProjectPrompts(name: string): Promise { - return fetchJSON(`/projects/${encodeURIComponent(name)}/prompts`) + return fetchJSON(`/projects/${encodeURIComponent(name)}/prompts`); } export async function updateProjectPrompts( name: string, - prompts: Partial + prompts: Partial, ): Promise { await fetchJSON(`/projects/${encodeURIComponent(name)}/prompts`, { - method: 'PUT', + method: "PUT", body: JSON.stringify(prompts), - }) + }); } // ============================================================================ // Features API // ============================================================================ -export async function listFeatures(projectName: string): Promise { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features`) +export async function listFeatures( + projectName: string, +): Promise { + return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features`); } -export async function createFeature(projectName: string, feature: FeatureCreate): Promise { +export async function createFeature( + projectName: string, + feature: FeatureCreate, +): Promise { return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features`, { - method: 'POST', + method: "POST", body: JSON.stringify(feature), - }) + }); } -export async function getFeature(projectName: string, featureId: number): Promise { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}`) +export async function getFeature( + projectName: string, + featureId: number, +): Promise { + return fetchJSON( + `/projects/${encodeURIComponent(projectName)}/features/${featureId}`, + ); } -export async function deleteFeature(projectName: string, featureId: number): Promise { - await fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}`, { - method: 'DELETE', - }) +export async function deleteFeature( + projectName: string, + featureId: number, +): Promise { + await fetchJSON( + `/projects/${encodeURIComponent(projectName)}/features/${featureId}`, + { + method: "DELETE", + }, + ); } -export async function skipFeature(projectName: string, featureId: number): Promise { - await fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}/skip`, { - method: 'PATCH', - }) +export async function skipFeature( + projectName: string, + featureId: number, +): Promise { + await fetchJSON( + `/projects/${encodeURIComponent(projectName)}/features/${featureId}/skip`, + { + method: "PATCH", + }, + ); } export async function updateFeature( projectName: string, featureId: number, - update: FeatureUpdate + update: FeatureUpdate, ): Promise { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}`, { - method: 'PATCH', - body: JSON.stringify(update), - }) + return fetchJSON( + `/projects/${encodeURIComponent(projectName)}/features/${featureId}`, + { + method: "PATCH", + body: JSON.stringify(update), + }, + ); } export async function createFeaturesBulk( projectName: string, - bulk: FeatureBulkCreate + bulk: FeatureBulkCreate, ): Promise { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/bulk`, { - method: 'POST', - body: JSON.stringify(bulk), - }) + return fetchJSON( + `/projects/${encodeURIComponent(projectName)}/features/bulk`, + { + method: "POST", + body: JSON.stringify(bulk), + }, + ); } // ============================================================================ // Dependency Graph API // ============================================================================ -export async function getDependencyGraph(projectName: string): Promise { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/graph`) +export async function getDependencyGraph( + projectName: string, +): Promise { + return fetchJSON( + `/projects/${encodeURIComponent(projectName)}/features/graph`, + ); } export async function addDependency( projectName: string, featureId: number, - dependencyId: number + dependencyId: number, ): Promise<{ success: boolean; feature_id: number; dependencies: number[] }> { return fetchJSON( `/projects/${encodeURIComponent(projectName)}/features/${featureId}/dependencies/${dependencyId}`, - { method: 'POST' } - ) + { method: "POST" }, + ); } export async function removeDependency( projectName: string, featureId: number, - dependencyId: number + dependencyId: number, ): Promise<{ success: boolean; feature_id: number; dependencies: number[] }> { return fetchJSON( `/projects/${encodeURIComponent(projectName)}/features/${featureId}/dependencies/${dependencyId}`, - { method: 'DELETE' } - ) + { method: "DELETE" }, + ); } export async function setDependencies( projectName: string, featureId: number, - dependencyIds: number[] + dependencyIds: number[], ): Promise<{ success: boolean; feature_id: number; dependencies: number[] }> { return fetchJSON( `/projects/${encodeURIComponent(projectName)}/features/${featureId}/dependencies`, { - method: 'PUT', + method: "PUT", body: JSON.stringify({ dependency_ids: dependencyIds }), - } - ) + }, + ); } // ============================================================================ // Agent API // ============================================================================ -export async function getAgentStatus(projectName: string): Promise { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/status`) +export async function getAgentStatus( + projectName: string, +): Promise { + return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/status`); } export async function startAgent( projectName: string, options: { - yoloMode?: boolean - parallelMode?: boolean - maxConcurrency?: number - testingAgentRatio?: number - } = {} + yoloMode?: boolean; + parallelMode?: boolean; + maxConcurrency?: number; + testingAgentRatio?: number; + } = {}, ): Promise { return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/start`, { - method: 'POST', + method: "POST", body: JSON.stringify({ yolo_mode: options.yoloMode ?? false, parallel_mode: options.parallelMode ?? false, max_concurrency: options.maxConcurrency, testing_agent_ratio: options.testingAgentRatio, }), - }) + }); } -export async function stopAgent(projectName: string): Promise { +export async function stopAgent( + projectName: string, +): Promise { return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/stop`, { - method: 'POST', - }) + method: "POST", + }); } -export async function pauseAgent(projectName: string): Promise { +export async function pauseAgent( + projectName: string, +): Promise { return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/pause`, { - method: 'POST', - }) + method: "POST", + }); } -export async function resumeAgent(projectName: string): Promise { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/resume`, { - method: 'POST', - }) +export async function resumeAgent( + projectName: string, +): Promise { + return fetchJSON( + `/projects/${encodeURIComponent(projectName)}/agent/resume`, + { + method: "POST", + }, + ); } // ============================================================================ @@ -247,15 +304,17 @@ export async function resumeAgent(projectName: string): Promise { - return fetchJSON(`/spec/status/${encodeURIComponent(projectName)}`) +export async function getSpecStatus( + projectName: string, +): Promise { + return fetchJSON(`/spec/status/${encodeURIComponent(projectName)}`); } // ============================================================================ @@ -263,67 +322,75 @@ export async function getSpecStatus(projectName: string): Promise { - return fetchJSON('/setup/status') + return fetchJSON("/setup/status"); } export async function healthCheck(): Promise<{ status: string }> { - return fetchJSON('/health') + return fetchJSON("/health"); } // ============================================================================ // Filesystem API // ============================================================================ -export async function listDirectory(path?: string): Promise { - const params = path ? `?path=${encodeURIComponent(path)}` : '' - return fetchJSON(`/filesystem/list${params}`) +export async function listDirectory( + path?: string, +): Promise { + const params = path ? `?path=${encodeURIComponent(path)}` : ""; + return fetchJSON(`/filesystem/list${params}`); } -export async function createDirectory(fullPath: string): Promise<{ success: boolean; path: string }> { +export async function createDirectory( + fullPath: string, +): Promise<{ success: boolean; path: string }> { // Backend expects { parent_path, name }, not { path } // Split the full path into parent directory and folder name // Remove trailing slash if present - const normalizedPath = fullPath.endsWith('/') ? fullPath.slice(0, -1) : fullPath + const normalizedPath = fullPath.endsWith("/") + ? fullPath.slice(0, -1) + : fullPath; // Find the last path separator - const lastSlash = normalizedPath.lastIndexOf('/') + const lastSlash = normalizedPath.lastIndexOf("/"); - let parentPath: string - let name: string + let parentPath: string; + let name: string; // Handle Windows drive root (e.g., "C:/newfolder") if (lastSlash === 2 && /^[A-Za-z]:/.test(normalizedPath)) { // Path like "C:/newfolder" - parent is "C:/" - parentPath = normalizedPath.substring(0, 3) // "C:/" - name = normalizedPath.substring(3) + parentPath = normalizedPath.substring(0, 3); // "C:/" + name = normalizedPath.substring(3); } else if (lastSlash > 0) { - parentPath = normalizedPath.substring(0, lastSlash) - name = normalizedPath.substring(lastSlash + 1) + parentPath = normalizedPath.substring(0, lastSlash); + name = normalizedPath.substring(lastSlash + 1); } else if (lastSlash === 0) { // Unix root path like "/newfolder" - parentPath = '/' - name = normalizedPath.substring(1) + parentPath = "/"; + name = normalizedPath.substring(1); } else { // No slash - invalid path - throw new Error('Invalid path: must be an absolute path') + throw new Error("Invalid path: must be an absolute path"); } if (!name) { - throw new Error('Invalid path: directory name is empty') + throw new Error("Invalid path: directory name is empty"); } - return fetchJSON('/filesystem/create-directory', { - method: 'POST', + return fetchJSON("/filesystem/create-directory", { + method: "POST", body: JSON.stringify({ parent_path: parentPath, name }), - }) + }); } -export async function validatePath(path: string): Promise { - return fetchJSON('/filesystem/validate', { - method: 'POST', +export async function validatePath( + path: string, +): Promise { + return fetchJSON("/filesystem/validate", { + method: "POST", body: JSON.stringify({ path }), - }) + }); } // ============================================================================ @@ -331,36 +398,41 @@ export async function validatePath(path: string): Promise { - return fetchJSON(`/assistant/conversations/${encodeURIComponent(projectName)}`) + return fetchJSON( + `/assistant/conversations/${encodeURIComponent(projectName)}`, + ); } export async function getAssistantConversation( projectName: string, - conversationId: number + conversationId: number, ): Promise { return fetchJSON( - `/assistant/conversations/${encodeURIComponent(projectName)}/${conversationId}` - ) + `/assistant/conversations/${encodeURIComponent(projectName)}/${conversationId}`, + ); } export async function createAssistantConversation( - projectName: string + projectName: string, ): Promise { - return fetchJSON(`/assistant/conversations/${encodeURIComponent(projectName)}`, { - method: 'POST', - }) + return fetchJSON( + `/assistant/conversations/${encodeURIComponent(projectName)}`, + { + method: "POST", + }, + ); } export async function deleteAssistantConversation( projectName: string, - conversationId: number + conversationId: number, ): Promise { await fetchJSON( `/assistant/conversations/${encodeURIComponent(projectName)}/${conversationId}`, - { method: 'DELETE' } - ) + { method: "DELETE" }, + ); } // ============================================================================ @@ -368,133 +440,171 @@ export async function deleteAssistantConversation( // ============================================================================ export async function getAvailableModels(): Promise { - return fetchJSON('/settings/models') + return fetchJSON("/settings/models"); } export async function getSettings(): Promise { - return fetchJSON('/settings') + return fetchJSON("/settings"); } -export async function updateSettings(settings: SettingsUpdate): Promise { - return fetchJSON('/settings', { - method: 'PATCH', +export async function updateSettings( + settings: SettingsUpdate, +): Promise { + return fetchJSON("/settings", { + method: "PATCH", body: JSON.stringify(settings), - }) + }); } // ============================================================================ // Dev Server API // ============================================================================ -export async function getDevServerStatus(projectName: string): Promise { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/status`) +export async function getDevServerStatus( + projectName: string, +): Promise { + return fetchJSON( + `/projects/${encodeURIComponent(projectName)}/devserver/status`, + ); } export async function startDevServer( projectName: string, - command?: string + command?: string, ): Promise<{ success: boolean; message: string }> { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/start`, { - method: 'POST', - body: JSON.stringify({ command }), - }) + return fetchJSON( + `/projects/${encodeURIComponent(projectName)}/devserver/start`, + { + method: "POST", + body: JSON.stringify({ command }), + }, + ); } export async function stopDevServer( - projectName: string + projectName: string, ): Promise<{ success: boolean; message: string }> { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/stop`, { - method: 'POST', - }) + return fetchJSON( + `/projects/${encodeURIComponent(projectName)}/devserver/stop`, + { + method: "POST", + }, + ); } -export async function getDevServerConfig(projectName: string): Promise { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/config`) +export async function getDevServerConfig( + projectName: string, +): Promise { + return fetchJSON( + `/projects/${encodeURIComponent(projectName)}/devserver/config`, + ); } // ============================================================================ // Terminal API // ============================================================================ -export async function listTerminals(projectName: string): Promise { - return fetchJSON(`/terminal/${encodeURIComponent(projectName)}`) +export async function listTerminals( + projectName: string, +): Promise { + return fetchJSON(`/terminal/${encodeURIComponent(projectName)}`); } export async function createTerminal( projectName: string, - name?: string + name?: string, ): Promise { return fetchJSON(`/terminal/${encodeURIComponent(projectName)}`, { - method: 'POST', + method: "POST", body: JSON.stringify({ name: name ?? null }), - }) + }); } export async function renameTerminal( projectName: string, terminalId: string, - name: string + name: string, ): Promise { - return fetchJSON(`/terminal/${encodeURIComponent(projectName)}/${terminalId}`, { - method: 'PATCH', - body: JSON.stringify({ name }), - }) + return fetchJSON( + `/terminal/${encodeURIComponent(projectName)}/${terminalId}`, + { + method: "PATCH", + body: JSON.stringify({ name }), + }, + ); } export async function deleteTerminal( projectName: string, - terminalId: string + terminalId: string, ): Promise { - await fetchJSON(`/terminal/${encodeURIComponent(projectName)}/${terminalId}`, { - method: 'DELETE', - }) + await fetchJSON( + `/terminal/${encodeURIComponent(projectName)}/${terminalId}`, + { + method: "DELETE", + }, + ); } // ============================================================================ // Schedule API // ============================================================================ -export async function listSchedules(projectName: string): Promise { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules`) +export async function listSchedules( + projectName: string, +): Promise { + return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules`); } export async function createSchedule( projectName: string, - schedule: ScheduleCreate + schedule: ScheduleCreate, ): Promise { return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules`, { - method: 'POST', + method: "POST", body: JSON.stringify(schedule), - }) + }); } export async function getSchedule( projectName: string, - scheduleId: number + scheduleId: number, ): Promise { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules/${scheduleId}`) + return fetchJSON( + `/projects/${encodeURIComponent(projectName)}/schedules/${scheduleId}`, + ); } export async function updateSchedule( projectName: string, scheduleId: number, - update: ScheduleUpdate + update: ScheduleUpdate, ): Promise { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules/${scheduleId}`, { - method: 'PATCH', - body: JSON.stringify(update), - }) + return fetchJSON( + `/projects/${encodeURIComponent(projectName)}/schedules/${scheduleId}`, + { + method: "PATCH", + body: JSON.stringify(update), + }, + ); } export async function deleteSchedule( projectName: string, - scheduleId: number + scheduleId: number, ): Promise { - await fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules/${scheduleId}`, { - method: 'DELETE', - }) + await fetchJSON( + `/projects/${encodeURIComponent(projectName)}/schedules/${scheduleId}`, + { + method: "DELETE", + }, + ); } -export async function getNextScheduledRun(projectName: string): Promise { - return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules/next`) +export async function getNextScheduledRun( + projectName: string, +): Promise { + return fetchJSON( + `/projects/${encodeURIComponent(projectName)}/schedules/next`, + ); } diff --git a/ui/src/lib/sentry.ts b/ui/src/lib/sentry.ts new file mode 100644 index 00000000..774e29ba --- /dev/null +++ b/ui/src/lib/sentry.ts @@ -0,0 +1,71 @@ +import * as Sentry from "@sentry/react"; + +const USER_KEY = "autocoder_sentry_user_id"; +const PROFILE_KEY = "autocoder_sentry_user_profile"; + +function getUserId(): string | undefined { + try { + const existing = localStorage.getItem(USER_KEY); + if (existing) return existing; + const generated = crypto.randomUUID + ? crypto.randomUUID() + : Math.random().toString(36).slice(2); + localStorage.setItem(USER_KEY, generated); + return generated; + } catch { + return undefined; + } +} + +function maybePromptUserProfile() { + const shouldPrompt = import.meta.env.VITE_SENTRY_PROMPT_USER === "1"; + if (!shouldPrompt) return undefined; + + try { + const cached = localStorage.getItem(PROFILE_KEY); + if (cached) return JSON.parse(cached) as { name?: string; email?: string }; + + const name = window.prompt("Enter your display name (optional):") || undefined; + const email = window.prompt("Enter your email (optional):") || undefined; + const profile = { name, email }; + localStorage.setItem(PROFILE_KEY, JSON.stringify(profile)); + return profile; + } catch { + return undefined; + } +} + +export function setSentryProject(project: string | null) { + if (project) { + Sentry.setTag("project", project); + } else { + Sentry.setTag("project", "none"); + } +} + +export function initSentry() { + const dsn = import.meta.env.VITE_SENTRY_DSN; + if (!dsn) return; + + Sentry.init({ + dsn, + environment: import.meta.env.VITE_SENTRY_ENV || "production", + tracesSampleRate: Number( + import.meta.env.VITE_SENTRY_TRACES_SAMPLE_RATE || "0.2", + ), + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + integrations: [ + Sentry.browserTracingIntegration(), + Sentry.replayIntegration(), + ], + }); + + const userId = getUserId(); + if (userId) { + const profile = maybePromptUserProfile(); + Sentry.setUser({ id: userId, ...profile }); + } + Sentry.setTag("app", "autocoder-ui"); + Sentry.setTag("origin", window.location.origin); +} diff --git a/ui/src/lib/timeUtils.ts b/ui/src/lib/timeUtils.ts index 5dad704f..a8dd901c 100644 --- a/ui/src/lib/timeUtils.ts +++ b/ui/src/lib/timeUtils.ts @@ -16,8 +16,8 @@ * Result of time conversion including day shift information. */ export interface TimeConversionResult { - time: string - dayShift: -1 | 0 | 1 // -1 = previous day, 0 = same day, 1 = next day + time: string; + dayShift: -1 | 0 | 1; // -1 = previous day, 0 = same day, 1 = next day } /** @@ -26,23 +26,23 @@ export interface TimeConversionResult { * @returns Object with local time string and day shift indicator */ export function utcToLocalWithDayShift(utcTime: string): TimeConversionResult { - const [hours, minutes] = utcTime.split(':').map(Number) + const [hours, minutes] = utcTime.split(":").map(Number); // Use a fixed reference date to calculate the shift - const utcDate = new Date(Date.UTC(2000, 0, 15, hours, minutes, 0, 0)) // Jan 15, 2000 - const localDay = utcDate.getDate() + const utcDate = new Date(Date.UTC(2000, 0, 15, hours, minutes, 0, 0)); // Jan 15, 2000 + const localDay = utcDate.getDate(); - let dayShift: -1 | 0 | 1 = 0 - if (localDay === 14) dayShift = -1 // Went to previous day - if (localDay === 16) dayShift = 1 // Went to next day + let dayShift: -1 | 0 | 1 = 0; + if (localDay === 14) dayShift = -1; // Went to previous day + if (localDay === 16) dayShift = 1; // Went to next day - const localHours = utcDate.getHours() - const localMinutes = utcDate.getMinutes() + const localHours = utcDate.getHours(); + const localMinutes = utcDate.getMinutes(); return { - time: `${String(localHours).padStart(2, '0')}:${String(localMinutes).padStart(2, '0')}`, + time: `${String(localHours).padStart(2, "0")}:${String(localMinutes).padStart(2, "0")}`, dayShift, - } + }; } /** @@ -51,7 +51,7 @@ export function utcToLocalWithDayShift(utcTime: string): TimeConversionResult { * @returns Time string in "HH:MM" format (local) */ export function utcToLocal(utcTime: string): string { - return utcToLocalWithDayShift(utcTime).time + return utcToLocalWithDayShift(utcTime).time; } /** @@ -59,25 +59,27 @@ export function utcToLocal(utcTime: string): string { * @param localTime Time string in "HH:MM" format (local) * @returns Object with UTC time string and day shift indicator */ -export function localToUTCWithDayShift(localTime: string): TimeConversionResult { - const [hours, minutes] = localTime.split(':').map(Number) +export function localToUTCWithDayShift( + localTime: string, +): TimeConversionResult { + const [hours, minutes] = localTime.split(":").map(Number); // Use a fixed reference date to calculate the shift // Set local time on Jan 15, then check UTC date - const localDate = new Date(2000, 0, 15, hours, minutes, 0, 0) // Jan 15, 2000 local - const utcDay = localDate.getUTCDate() + const localDate = new Date(2000, 0, 15, hours, minutes, 0, 0); // Jan 15, 2000 local + const utcDay = localDate.getUTCDate(); - let dayShift: -1 | 0 | 1 = 0 - if (utcDay === 14) dayShift = -1 // UTC is previous day - if (utcDay === 16) dayShift = 1 // UTC is next day + let dayShift: -1 | 0 | 1 = 0; + if (utcDay === 14) dayShift = -1; // UTC is previous day + if (utcDay === 16) dayShift = 1; // UTC is next day - const utcHours = localDate.getUTCHours() - const utcMinutes = localDate.getUTCMinutes() + const utcHours = localDate.getUTCHours(); + const utcMinutes = localDate.getUTCMinutes(); return { - time: `${String(utcHours).padStart(2, '0')}:${String(utcMinutes).padStart(2, '0')}`, + time: `${String(utcHours).padStart(2, "0")}:${String(utcMinutes).padStart(2, "0")}`, dayShift, - } + }; } /** @@ -86,7 +88,7 @@ export function localToUTCWithDayShift(localTime: string): TimeConversionResult * @returns Time string in "HH:MM" format (UTC) */ export function localToUTC(localTime: string): string { - return localToUTCWithDayShift(localTime).time + return localToUTCWithDayShift(localTime).time; } /** @@ -95,15 +97,15 @@ export function localToUTC(localTime: string): string { * Example: Mon(1) -> Tue(2), Sun(64) -> Mon(1) */ export function shiftDaysForward(bitfield: number): number { - let shifted = 0 - if (bitfield & 1) shifted |= 2 // Mon -> Tue - if (bitfield & 2) shifted |= 4 // Tue -> Wed - if (bitfield & 4) shifted |= 8 // Wed -> Thu - if (bitfield & 8) shifted |= 16 // Thu -> Fri - if (bitfield & 16) shifted |= 32 // Fri -> Sat - if (bitfield & 32) shifted |= 64 // Sat -> Sun - if (bitfield & 64) shifted |= 1 // Sun -> Mon - return shifted + let shifted = 0; + if (bitfield & 1) shifted |= 2; // Mon -> Tue + if (bitfield & 2) shifted |= 4; // Tue -> Wed + if (bitfield & 4) shifted |= 8; // Wed -> Thu + if (bitfield & 8) shifted |= 16; // Thu -> Fri + if (bitfield & 16) shifted |= 32; // Fri -> Sat + if (bitfield & 32) shifted |= 64; // Sat -> Sun + if (bitfield & 64) shifted |= 1; // Sun -> Mon + return shifted; } /** @@ -112,15 +114,15 @@ export function shiftDaysForward(bitfield: number): number { * Example: Tue(2) -> Mon(1), Mon(1) -> Sun(64) */ export function shiftDaysBackward(bitfield: number): number { - let shifted = 0 - if (bitfield & 1) shifted |= 64 // Mon -> Sun - if (bitfield & 2) shifted |= 1 // Tue -> Mon - if (bitfield & 4) shifted |= 2 // Wed -> Tue - if (bitfield & 8) shifted |= 4 // Thu -> Wed - if (bitfield & 16) shifted |= 8 // Fri -> Thu - if (bitfield & 32) shifted |= 16 // Sat -> Fri - if (bitfield & 64) shifted |= 32 // Sun -> Sat - return shifted + let shifted = 0; + if (bitfield & 1) shifted |= 64; // Mon -> Sun + if (bitfield & 2) shifted |= 1; // Tue -> Mon + if (bitfield & 4) shifted |= 2; // Wed -> Tue + if (bitfield & 8) shifted |= 4; // Thu -> Wed + if (bitfield & 16) shifted |= 8; // Fri -> Thu + if (bitfield & 32) shifted |= 16; // Sat -> Fri + if (bitfield & 64) shifted |= 32; // Sun -> Sat + return shifted; } /** @@ -129,10 +131,13 @@ export function shiftDaysBackward(bitfield: number): number { * @param dayShift Day shift from time conversion (-1, 0, or 1) * @returns Adjusted bitfield */ -export function adjustDaysForDayShift(bitfield: number, dayShift: -1 | 0 | 1): number { - if (dayShift === 1) return shiftDaysForward(bitfield) - if (dayShift === -1) return shiftDaysBackward(bitfield) - return bitfield +export function adjustDaysForDayShift( + bitfield: number, + dayShift: -1 | 0 | 1, +): number { + if (dayShift === 1) return shiftDaysForward(bitfield); + if (dayShift === -1) return shiftDaysBackward(bitfield); + return bitfield; } /** @@ -141,12 +146,12 @@ export function adjustDaysForDayShift(bitfield: number, dayShift: -1 | 0 | 1): n * @returns Formatted string (e.g., "4h", "1h 30m", "30m") */ export function formatDuration(minutes: number): string { - const hours = Math.floor(minutes / 60) - const mins = minutes % 60 + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; - if (hours === 0) return `${mins}m` - if (mins === 0) return `${hours}h` - return `${hours}h ${mins}m` + if (hours === 0) return `${mins}m`; + if (mins === 0) return `${hours}h`; + return `${hours}h ${mins}m`; } /** @@ -156,25 +161,25 @@ export function formatDuration(minutes: number): string { * @returns Formatted string (e.g., "22:00", "10:00 PM", "Mon 22:00") */ export function formatNextRun(isoString: string): string { - const date = new Date(isoString) - const now = new Date() - const diffMs = date.getTime() - now.getTime() - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + const date = new Date(isoString); + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); if (diffHours < 24) { // Same day or within 24 hours - just show time return date.toLocaleTimeString([], { - hour: 'numeric', - minute: '2-digit' - }) + hour: "numeric", + minute: "2-digit", + }); } // Further out - show day and time return date.toLocaleString([], { - weekday: 'short', - hour: 'numeric', - minute: '2-digit' - }) + weekday: "short", + hour: "numeric", + minute: "2-digit", + }); } /** @@ -184,11 +189,11 @@ export function formatNextRun(isoString: string): string { * @returns Formatted string (e.g., "14:00", "2:00 PM") */ export function formatEndTime(isoString: string): string { - const date = new Date(isoString) + const date = new Date(isoString); return date.toLocaleTimeString([], { - hour: 'numeric', - minute: '2-digit' - }) + hour: "numeric", + minute: "2-digit", + }); } /** @@ -202,20 +207,20 @@ export const DAY_BITS = { Fri: 16, Sat: 32, Sun: 64, -} as const +} as const; /** * Array of days with their labels and bit values. */ export const DAYS = [ - { label: 'Mon', bit: 1 }, - { label: 'Tue', bit: 2 }, - { label: 'Wed', bit: 4 }, - { label: 'Thu', bit: 8 }, - { label: 'Fri', bit: 16 }, - { label: 'Sat', bit: 32 }, - { label: 'Sun', bit: 64 }, -] as const + { label: "Mon", bit: 1 }, + { label: "Tue", bit: 2 }, + { label: "Wed", bit: 4 }, + { label: "Thu", bit: 8 }, + { label: "Fri", bit: 16 }, + { label: "Sat", bit: 32 }, + { label: "Sun", bit: 64 }, +] as const; /** * Check if a day is active in a bitfield. @@ -224,7 +229,7 @@ export const DAYS = [ * @returns True if the day is active */ export function isDayActive(bitfield: number, dayBit: number): boolean { - return (bitfield & dayBit) !== 0 + return (bitfield & dayBit) !== 0; } /** @@ -234,7 +239,7 @@ export function isDayActive(bitfield: number, dayBit: number): boolean { * @returns New bitfield with the day toggled */ export function toggleDay(bitfield: number, dayBit: number): number { - return bitfield ^ dayBit + return bitfield ^ dayBit; } /** @@ -243,10 +248,10 @@ export function toggleDay(bitfield: number, dayBit: number): number { * @returns Description string (e.g., "Every day", "Weekdays", "Mon, Wed, Fri") */ export function formatDaysDescription(bitfield: number): string { - if (bitfield === 127) return 'Every day' - if (bitfield === 31) return 'Weekdays' - if (bitfield === 96) return 'Weekends' + if (bitfield === 127) return "Every day"; + if (bitfield === 31) return "Weekdays"; + if (bitfield === 96) return "Weekends"; - const activeDays = DAYS.filter(d => isDayActive(bitfield, d.bit)) - return activeDays.map(d => d.label).join(', ') + const activeDays = DAYS.filter((d) => isDayActive(bitfield, d.bit)); + return activeDays.map((d) => d.label).join(", "); } diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index d883432f..db1e8260 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -4,312 +4,356 @@ // Project types export interface ProjectStats { - passing: number - in_progress: number - total: number - percentage: number + passing: number; + in_progress: number; + total: number; + percentage: number; } export interface ProjectSummary { - name: string - path: string - has_spec: boolean - stats: ProjectStats + name: string; + path: string; + has_spec: boolean; + stats: ProjectStats; } export interface ProjectDetail extends ProjectSummary { - prompts_dir: string + prompts_dir: string; +} + +export interface ProjectCloneResponse { + success: boolean; + message: string; + path: string; } // Filesystem types export interface DriveInfo { - letter: string - label: string - available?: boolean + letter: string; + label: string; + available?: boolean; } export interface DirectoryEntry { - name: string - path: string - is_directory: boolean - has_children: boolean + name: string; + path: string; + is_directory: boolean; + has_children: boolean; } export interface DirectoryListResponse { - current_path: string - parent_path: string | null - entries: DirectoryEntry[] - drives: DriveInfo[] | null + current_path: string; + parent_path: string | null; + entries: DirectoryEntry[]; + drives: DriveInfo[] | null; } export interface PathValidationResponse { - valid: boolean - exists: boolean - is_directory: boolean - can_write: boolean - message: string + valid: boolean; + exists: boolean; + is_directory: boolean; + can_write: boolean; + message: string; } export interface ProjectPrompts { - app_spec: string - initializer_prompt: string - coding_prompt: string + app_spec: string; + initializer_prompt: string; + coding_prompt: string; } // Feature types export interface Feature { - id: number - priority: number - category: string - name: string - description: string - steps: string[] - passes: boolean - in_progress: boolean - dependencies?: number[] // Optional for backwards compat - blocked?: boolean // Computed by API - blocking_dependencies?: number[] // Computed by API + id: number; + priority: number; + category: string; + name: string; + description: string; + steps: string[]; + passes: boolean; + in_progress: boolean; + dependencies?: number[]; // Optional for backwards compat + blocked?: boolean; // Computed by API + blocking_dependencies?: number[]; // Computed by API } // Status type for graph nodes -export type FeatureStatus = 'pending' | 'in_progress' | 'done' | 'blocked' +export type FeatureStatus = "pending" | "in_progress" | "done" | "blocked"; // Graph visualization types export interface GraphNode { - id: number - name: string - category: string - status: FeatureStatus - priority: number - dependencies: number[] + id: number; + name: string; + category: string; + status: FeatureStatus; + priority: number; + dependencies: number[]; } export interface GraphEdge { - source: number - target: number + source: number; + target: number; } export interface DependencyGraph { - nodes: GraphNode[] - edges: GraphEdge[] + nodes: GraphNode[]; + edges: GraphEdge[]; } export interface FeatureListResponse { - pending: Feature[] - in_progress: Feature[] - done: Feature[] + pending: Feature[]; + in_progress: Feature[]; + done: Feature[]; } export interface FeatureCreate { - category: string - name: string - description: string - steps: string[] - priority?: number - dependencies?: number[] + category: string; + name: string; + description: string; + steps: string[]; + priority?: number; + dependencies?: number[]; } export interface FeatureUpdate { - category?: string - name?: string - description?: string - steps?: string[] - priority?: number - dependencies?: number[] + category?: string; + name?: string; + description?: string; + steps?: string[]; + priority?: number; + dependencies?: number[]; } // Agent types -export type AgentStatus = 'stopped' | 'running' | 'paused' | 'crashed' | 'loading' +export type AgentStatus = + | "stopped" + | "running" + | "paused" + | "crashed" + | "loading"; export interface AgentStatusResponse { - status: AgentStatus - pid: number | null - started_at: string | null - yolo_mode: boolean - model: string | null // Model being used by running agent - parallel_mode: boolean // DEPRECATED: Always true now (unified orchestrator) - max_concurrency: number | null - testing_agent_ratio: number // Regression testing agents (0-3) + status: AgentStatus; + pid: number | null; + started_at: string | null; + yolo_mode: boolean; + model: string | null; // Model being used by running agent + parallel_mode: boolean; // DEPRECATED: Always true now (unified orchestrator) + max_concurrency: number | null; + testing_agent_ratio: number; // Regression testing agents (0-3) } export interface AgentActionResponse { - success: boolean - status: AgentStatus - message: string + success: boolean; + status: AgentStatus; + message: string; } // Setup types export interface SetupStatus { - claude_cli: boolean - credentials: boolean - node: boolean - npm: boolean + claude_cli: boolean; + credentials: boolean; + node: boolean; + npm: boolean; + gemini: boolean; } // Dev Server types -export type DevServerStatus = 'stopped' | 'running' | 'crashed' +export type DevServerStatus = "stopped" | "running" | "crashed"; export interface DevServerStatusResponse { - status: DevServerStatus - pid: number | null - url: string | null - command: string | null - started_at: string | null + status: DevServerStatus; + pid: number | null; + url: string | null; + command: string | null; + started_at: string | null; } export interface DevServerConfig { - detected_type: string | null - detected_command: string | null - custom_command: string | null - effective_command: string | null + detected_type: string | null; + detected_command: string | null; + custom_command: string | null; + effective_command: string | null; } // Terminal types export interface TerminalInfo { - id: string - name: string - created_at: string + id: string; + name: string; + created_at: string; } // Agent mascot names for multi-agent UI export const AGENT_MASCOTS = [ - 'Spark', 'Fizz', 'Octo', 'Hoot', 'Buzz', // Original 5 - 'Pixel', 'Byte', 'Nova', 'Chip', 'Bolt', // Tech-inspired - 'Dash', 'Zap', 'Gizmo', 'Turbo', 'Blip', // Energetic - 'Neon', 'Widget', 'Zippy', 'Quirk', 'Flux', // Playful -] as const -export type AgentMascot = typeof AGENT_MASCOTS[number] + "Spark", + "Fizz", + "Octo", + "Hoot", + "Buzz", // Original 5 + "Pixel", + "Byte", + "Nova", + "Chip", + "Bolt", // Tech-inspired + "Dash", + "Zap", + "Gizmo", + "Turbo", + "Blip", // Energetic + "Neon", + "Widget", + "Zippy", + "Quirk", + "Flux", // Playful +] as const; +export type AgentMascot = (typeof AGENT_MASCOTS)[number]; // Agent state for Mission Control -export type AgentState = 'idle' | 'thinking' | 'working' | 'testing' | 'success' | 'error' | 'struggling' +export type AgentState = + | "idle" + | "thinking" + | "working" + | "testing" + | "success" + | "error" + | "struggling"; // Agent type (coding vs testing) -export type AgentType = 'coding' | 'testing' +export type AgentType = "coding" | "testing"; // Individual log entry for an agent export interface AgentLogEntry { - line: string - timestamp: string - type: 'output' | 'state_change' | 'error' + line: string; + timestamp: string; + type: "output" | "state_change" | "error"; } // Agent update from backend export interface ActiveAgent { - agentIndex: number // -1 for synthetic completions - agentName: AgentMascot | 'Unknown' - agentType: AgentType // "coding" or "testing" - featureId: number - featureName: string - state: AgentState - thought?: string - timestamp: string - logs?: AgentLogEntry[] // Per-agent log history + agentIndex: number; // -1 for synthetic completions + agentName: AgentMascot | "Unknown"; + agentType: AgentType; // "coding" or "testing" + featureId: number; + featureName: string; + state: AgentState; + thought?: string; + timestamp: string; + logs?: AgentLogEntry[]; // Per-agent log history } // Orchestrator state for Mission Control export type OrchestratorState = - | 'idle' - | 'initializing' - | 'scheduling' - | 'spawning' - | 'monitoring' - | 'complete' + | "idle" + | "initializing" + | "scheduling" + | "spawning" + | "monitoring" + | "complete"; // Orchestrator event for recent activity export interface OrchestratorEvent { - eventType: string - message: string - timestamp: string - featureId?: number - featureName?: string + eventType: string; + message: string; + timestamp: string; + featureId?: number; + featureName?: string; } // Orchestrator status for Mission Control export interface OrchestratorStatus { - state: OrchestratorState - message: string - codingAgents: number - testingAgents: number - maxConcurrency: number - readyCount: number - blockedCount: number - timestamp: string - recentEvents: OrchestratorEvent[] + state: OrchestratorState; + message: string; + codingAgents: number; + testingAgents: number; + maxConcurrency: number; + readyCount: number; + blockedCount: number; + timestamp: string; + recentEvents: OrchestratorEvent[]; } // WebSocket message types -export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status' | 'agent_update' | 'orchestrator_update' +export type WSMessageType = + | "progress" + | "feature_update" + | "log" + | "agent_status" + | "pong" + | "dev_log" + | "dev_server_status" + | "agent_update" + | "orchestrator_update"; export interface WSProgressMessage { - type: 'progress' - passing: number - in_progress: number - total: number - percentage: number + type: "progress"; + passing: number; + in_progress: number; + total: number; + percentage: number; } export interface WSFeatureUpdateMessage { - type: 'feature_update' - feature_id: number - passes: boolean + type: "feature_update"; + feature_id: number; + passes: boolean; } export interface WSLogMessage { - type: 'log' - line: string - timestamp: string - featureId?: number - agentIndex?: number - agentName?: AgentMascot + type: "log"; + line: string; + timestamp: string; + featureId?: number; + agentIndex?: number; + agentName?: AgentMascot; } export interface WSAgentUpdateMessage { - type: 'agent_update' - agentIndex: number // -1 for synthetic completions (untracked agents) - agentName: AgentMascot | 'Unknown' - agentType: AgentType // "coding" or "testing" - featureId: number - featureName: string - state: AgentState - thought?: string - timestamp: string - synthetic?: boolean // True for synthetic completions from untracked agents + type: "agent_update"; + agentIndex: number; // -1 for synthetic completions (untracked agents) + agentName: AgentMascot | "Unknown"; + agentType: AgentType; // "coding" or "testing" + featureId: number; + featureName: string; + state: AgentState; + thought?: string; + timestamp: string; + synthetic?: boolean; // True for synthetic completions from untracked agents } export interface WSAgentStatusMessage { - type: 'agent_status' - status: AgentStatus + type: "agent_status"; + status: AgentStatus; } export interface WSPongMessage { - type: 'pong' + type: "pong"; } export interface WSDevLogMessage { - type: 'dev_log' - line: string - timestamp: string + type: "dev_log"; + line: string; + timestamp: string; } export interface WSDevServerStatusMessage { - type: 'dev_server_status' - status: DevServerStatus - url: string | null + type: "dev_server_status"; + status: DevServerStatus; + url: string | null; } export interface WSOrchestratorUpdateMessage { - type: 'orchestrator_update' - eventType: string - state: OrchestratorState - message: string - timestamp: string - codingAgents?: number - testingAgents?: number - maxConcurrency?: number - readyCount?: number - blockedCount?: number - featureId?: number - featureName?: string + type: "orchestrator_update"; + eventType: string; + state: OrchestratorState; + message: string; + timestamp: string; + codingAgents?: number; + testingAgents?: number; + maxConcurrency?: number; + readyCount?: number; + blockedCount?: number; + featureId?: number; + featureName?: string; } export type WSMessage = @@ -321,60 +365,60 @@ export type WSMessage = | WSPongMessage | WSDevLogMessage | WSDevServerStatusMessage - | WSOrchestratorUpdateMessage + | WSOrchestratorUpdateMessage; // ============================================================================ // Spec Chat Types // ============================================================================ export interface SpecQuestionOption { - label: string - description: string + label: string; + description: string; } export interface SpecQuestion { - question: string - header: string - options: SpecQuestionOption[] - multiSelect: boolean + question: string; + header: string; + options: SpecQuestionOption[]; + multiSelect: boolean; } export interface SpecChatTextMessage { - type: 'text' - content: string + type: "text"; + content: string; } export interface SpecChatQuestionMessage { - type: 'question' - questions: SpecQuestion[] - tool_id?: string + type: "question"; + questions: SpecQuestion[]; + tool_id?: string; } export interface SpecChatCompleteMessage { - type: 'spec_complete' - path: string + type: "spec_complete"; + path: string; } export interface SpecChatFileWrittenMessage { - type: 'file_written' - path: string + type: "file_written"; + path: string; } export interface SpecChatSessionCompleteMessage { - type: 'complete' + type: "complete"; } export interface SpecChatErrorMessage { - type: 'error' - content: string + type: "error"; + content: string; } export interface SpecChatPongMessage { - type: 'pong' + type: "pong"; } export interface SpecChatResponseDoneMessage { - type: 'response_done' + type: "response_done"; } export type SpecChatServerMessage = @@ -385,27 +429,27 @@ export type SpecChatServerMessage = | SpecChatSessionCompleteMessage | SpecChatErrorMessage | SpecChatPongMessage - | SpecChatResponseDoneMessage + | SpecChatResponseDoneMessage; // Image attachment for chat messages export interface ImageAttachment { - id: string - filename: string - mimeType: 'image/jpeg' | 'image/png' - base64Data: string // Raw base64 (without data: prefix) - previewUrl: string // data: URL for display - size: number // File size in bytes + id: string; + filename: string; + mimeType: "image/jpeg" | "image/png"; + base64Data: string; // Raw base64 (without data: prefix) + previewUrl: string; // data: URL for display + size: number; // File size in bytes } // UI chat message for display export interface ChatMessage { - id: string - role: 'user' | 'assistant' | 'system' - content: string - attachments?: ImageAttachment[] - timestamp: Date - questions?: SpecQuestion[] - isStreaming?: boolean + id: string; + role: "user" | "assistant" | "system"; + content: string; + attachments?: ImageAttachment[]; + timestamp: Date; + questions?: SpecQuestion[]; + isStreaming?: boolean; } // ============================================================================ @@ -413,57 +457,57 @@ export interface ChatMessage { // ============================================================================ export interface AssistantConversation { - id: number - project_name: string - title: string | null - created_at: string | null - updated_at: string | null - message_count: number + id: number; + project_name: string; + title: string | null; + created_at: string | null; + updated_at: string | null; + message_count: number; } export interface AssistantMessage { - id: number - role: 'user' | 'assistant' | 'system' - content: string - timestamp: string | null + id: number; + role: "user" | "assistant" | "system"; + content: string; + timestamp: string | null; } export interface AssistantConversationDetail { - id: number - project_name: string - title: string | null - created_at: string | null - updated_at: string | null - messages: AssistantMessage[] + id: number; + project_name: string; + title: string | null; + created_at: string | null; + updated_at: string | null; + messages: AssistantMessage[]; } export interface AssistantChatTextMessage { - type: 'text' - content: string + type: "text"; + content: string; } export interface AssistantChatToolCallMessage { - type: 'tool_call' - tool: string - input: Record + type: "tool_call"; + tool: string; + input: Record; } export interface AssistantChatResponseDoneMessage { - type: 'response_done' + type: "response_done"; } export interface AssistantChatErrorMessage { - type: 'error' - content: string + type: "error"; + content: string; } export interface AssistantChatConversationCreatedMessage { - type: 'conversation_created' - conversation_id: number + type: "conversation_created"; + conversation_id: number; } export interface AssistantChatPongMessage { - type: 'pong' + type: "pong"; } export type AssistantChatServerMessage = @@ -472,40 +516,40 @@ export type AssistantChatServerMessage = | AssistantChatResponseDoneMessage | AssistantChatErrorMessage | AssistantChatConversationCreatedMessage - | AssistantChatPongMessage + | AssistantChatPongMessage; // ============================================================================ // Expand Chat Types // ============================================================================ export interface ExpandChatFeaturesCreatedMessage { - type: 'features_created' - count: number - features: { id: number; name: string; category: string }[] + type: "features_created"; + count: number; + features: { id: number; name: string; category: string }[]; } export interface ExpandChatCompleteMessage { - type: 'expansion_complete' - total_added: number + type: "expansion_complete"; + total_added: number; } export type ExpandChatServerMessage = - | SpecChatTextMessage // Reuse text message type + | SpecChatTextMessage // Reuse text message type | ExpandChatFeaturesCreatedMessage | ExpandChatCompleteMessage - | SpecChatErrorMessage // Reuse error message type - | SpecChatPongMessage // Reuse pong message type - | SpecChatResponseDoneMessage // Reuse response_done type + | SpecChatErrorMessage // Reuse error message type + | SpecChatPongMessage // Reuse pong message type + | SpecChatResponseDoneMessage; // Reuse response_done type // Bulk feature creation export interface FeatureBulkCreate { - features: FeatureCreate[] - starting_priority?: number + features: FeatureCreate[]; + starting_priority?: number; } export interface FeatureBulkCreateResponse { - created: number - features: Feature[] + created: number; + features: Feature[]; } // ============================================================================ @@ -513,27 +557,27 @@ export interface FeatureBulkCreateResponse { // ============================================================================ export interface ModelInfo { - id: string - name: string + id: string; + name: string; } export interface ModelsResponse { - models: ModelInfo[] - default: string + models: ModelInfo[]; + default: string; } export interface Settings { - yolo_mode: boolean - model: string - glm_mode: boolean - ollama_mode: boolean - testing_agent_ratio: number // Regression testing agents (0-3) + yolo_mode: boolean; + model: string; + glm_mode: boolean; + ollama_mode: boolean; + testing_agent_ratio: number; // Regression testing agents (0-3) } export interface SettingsUpdate { - yolo_mode?: boolean - model?: string - testing_agent_ratio?: number + yolo_mode?: boolean; + model?: string; + testing_agent_ratio?: number; } // ============================================================================ @@ -541,47 +585,47 @@ export interface SettingsUpdate { // ============================================================================ export interface Schedule { - id: number - project_name: string - start_time: string // "HH:MM" in UTC - duration_minutes: number - days_of_week: number // Bitfield: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64 - enabled: boolean - yolo_mode: boolean - model: string | null - max_concurrency: number // 1-5 concurrent agents - crash_count: number - created_at: string + id: number; + project_name: string; + start_time: string; // "HH:MM" in UTC + duration_minutes: number; + days_of_week: number; // Bitfield: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64 + enabled: boolean; + yolo_mode: boolean; + model: string | null; + max_concurrency: number; // 1-5 concurrent agents + crash_count: number; + created_at: string; } export interface ScheduleCreate { - start_time: string // "HH:MM" format (local time, will be stored as UTC) - duration_minutes: number - days_of_week: number - enabled: boolean - yolo_mode: boolean - model: string | null - max_concurrency: number // 1-5 concurrent agents + start_time: string; // "HH:MM" format (local time, will be stored as UTC) + duration_minutes: number; + days_of_week: number; + enabled: boolean; + yolo_mode: boolean; + model: string | null; + max_concurrency: number; // 1-5 concurrent agents } export interface ScheduleUpdate { - start_time?: string - duration_minutes?: number - days_of_week?: number - enabled?: boolean - yolo_mode?: boolean - model?: string | null - max_concurrency?: number + start_time?: string; + duration_minutes?: number; + days_of_week?: number; + enabled?: boolean; + yolo_mode?: boolean; + model?: string | null; + max_concurrency?: number; } export interface ScheduleListResponse { - schedules: Schedule[] + schedules: Schedule[]; } export interface NextRunResponse { - has_schedules: boolean - next_start: string | null // ISO datetime in UTC - next_end: string | null // ISO datetime in UTC (latest end if overlapping) - is_currently_running: boolean - active_schedule_count: number + has_schedules: boolean; + next_start: string | null; // ISO datetime in UTC + next_end: string | null; // ISO datetime in UTC (latest end if overlapping) + is_currently_running: boolean; + active_schedule_count: number; } diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index bd0c391d..a5ef1935 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -1,6 +1,6 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/ui/src/main.tsx b/ui/src/main.tsx index fa4dad9c..8c74ae0b 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,10 +1,13 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import App from './App' -import './styles/globals.css' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import App from "./App"; +import "./styles/globals.css"; +import { initSentry } from "./lib/sentry"; // Note: Custom theme removed - using shadcn/ui theming instead +initSentry(); + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -12,12 +15,12 @@ const queryClient = new QueryClient({ refetchOnWindowFocus: false, }, }, -}) +}); -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( , -) +); diff --git a/ui/src/smoke.test.tsx b/ui/src/smoke.test.tsx new file mode 100644 index 00000000..7885b4d8 --- /dev/null +++ b/ui/src/smoke.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Button } from "./components/ui/button"; + +describe("UI smoke test", () => { + it("renders a basic component", () => { + render(); + expect(screen.getByText("Ping")).toBeInTheDocument(); + }); +}); diff --git a/ui/src/styles/globals.css b/ui/src/styles/globals.css index 233e01f1..c5a8f993 100644 --- a/ui/src/styles/globals.css +++ b/ui/src/styles/globals.css @@ -48,7 +48,8 @@ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-lg: + 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); /* Log level colors (kept for Terminal/Debug components) */ --color-log-error: #ef4444; @@ -63,8 +64,8 @@ --color-status-done: oklch(0.85 0.08 245); /* Font stacks */ - --font-sans: 'Open Sans', -apple-system, BlinkMacSystemFont, sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + --font-sans: "Open Sans", -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", "Consolas", monospace; /* Transitions */ --transition-fast: 150ms; @@ -79,7 +80,7 @@ --card-foreground: oklch(0.95 0 0); --popover: oklch(0.16 0.005 250); --popover-foreground: oklch(0.95 0 0); - --primary: oklch(0.6692 0.1607 245.0110); + --primary: oklch(0.6692 0.1607 245.011); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); @@ -89,9 +90,9 @@ --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(0.30 0 0); + --border: oklch(0.3 0 0); --input: oklch(1 0 0 / 15%); - --ring: oklch(0.6692 0.1607 245.0110); + --ring: oklch(0.6692 0.1607 245.011); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); @@ -110,7 +111,8 @@ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3); --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.4), 0 1px 2px -1px rgb(0 0 0 / 0.3); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3); - --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3); + --shadow-lg: + 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3); /* Log level colors for dark mode */ --color-log-error: #f87171; @@ -134,42 +136,46 @@ --radius: 0.5rem; --background: oklch(0.9818 0.0054 95.0986); --foreground: oklch(0.3438 0.0269 95.7226); - --card: oklch(0.9650 0.0080 90); + --card: oklch(0.965 0.008 90); --card-foreground: oklch(0.3438 0.0269 95.7226); --popover: oklch(0.9818 0.0054 95.0986); --popover-foreground: oklch(0.3438 0.0269 95.7226); --primary: oklch(0.6171 0.1375 39.0427); --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.9400 0.0120 85); + --secondary: oklch(0.94 0.012 85); --secondary-foreground: oklch(0.3438 0.0269 95.7226); - --muted: oklch(0.9300 0.0100 90); - --muted-foreground: oklch(0.5500 0.0200 95); - --accent: oklch(0.9200 0.0150 80); + --muted: oklch(0.93 0.01 90); + --muted-foreground: oklch(0.55 0.02 95); + --accent: oklch(0.92 0.015 80); --accent-foreground: oklch(0.3438 0.0269 95.7226); --destructive: oklch(0.6188 0.2376 25.7658); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(0.8900 0.0180 85); - --input: oklch(0.9500 0.0080 90); + --border: oklch(0.89 0.018 85); + --input: oklch(0.95 0.008 90); --ring: oklch(0.6171 0.1375 39.0427); --chart-1: oklch(0.6171 0.1375 39.0427); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.9700 0.0070 92); + --sidebar: oklch(0.97 0.007 92); --sidebar-foreground: oklch(0.3438 0.0269 95.7226); --sidebar-primary: oklch(0.6171 0.1375 39.0427); --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.9200 0.0150 80); + --sidebar-accent: oklch(0.92 0.015 80); --sidebar-accent-foreground: oklch(0.3438 0.0269 95.7226); - --sidebar-border: oklch(0.8900 0.0180 85); + --sidebar-border: oklch(0.89 0.018 85); --sidebar-ring: oklch(0.6171 0.1375 39.0427); /* Shadow variables - softer for Claude theme */ --shadow-sm: 0 1px 2px 0 rgb(139 115 85 / 0.05); - --shadow: 0 1px 3px 0 rgb(139 115 85 / 0.08), 0 1px 2px -1px rgb(139 115 85 / 0.06); - --shadow-md: 0 4px 6px -1px rgb(139 115 85 / 0.08), 0 2px 4px -2px rgb(139 115 85 / 0.06); - --shadow-lg: 0 10px 15px -3px rgb(139 115 85 / 0.08), 0 4px 6px -4px rgb(139 115 85 / 0.06); + --shadow: + 0 1px 3px 0 rgb(139 115 85 / 0.08), 0 1px 2px -1px rgb(139 115 85 / 0.06); + --shadow-md: + 0 4px 6px -1px rgb(139 115 85 / 0.08), 0 2px 4px -2px rgb(139 115 85 / 0.06); + --shadow-lg: + 0 10px 15px -3px rgb(139 115 85 / 0.08), + 0 4px 6px -4px rgb(139 115 85 / 0.06); /* Log level colors */ --color-log-error: #dc6b52; @@ -179,54 +185,60 @@ --color-log-success: #6b9e6b; /* Status colors for Kanban */ - --color-status-pending: oklch(0.9200 0.0300 80); - --color-status-progress: oklch(0.8800 0.0500 60); - --color-status-done: oklch(0.8800 0.0500 140); + --color-status-pending: oklch(0.92 0.03 80); + --color-status-progress: oklch(0.88 0.05 60); + --color-status-done: oklch(0.88 0.05 140); /* Font stacks - system fonts for Claude theme */ - --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - --font-mono: 'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', Menlo, Consolas, monospace; + --font-sans: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + --font-mono: + "SF Mono", SFMono-Regular, ui-monospace, "DejaVu Sans Mono", Menlo, + Consolas, monospace; } .theme-claude.dark { --background: oklch(0.2679 0.0036 106.6427); --foreground: oklch(0.8074 0.0142 93.0137); - --card: oklch(0.3200 0.0050 100); + --card: oklch(0.32 0.005 100); --card-foreground: oklch(0.8074 0.0142 93.0137); - --popover: oklch(0.3200 0.0050 100); + --popover: oklch(0.32 0.005 100); --popover-foreground: oklch(0.8074 0.0142 93.0137); - --primary: oklch(0.6800 0.1500 39); + --primary: oklch(0.68 0.15 39); --primary-foreground: oklch(0.15 0 0); - --secondary: oklch(0.3500 0.0080 100); + --secondary: oklch(0.35 0.008 100); --secondary-foreground: oklch(0.8074 0.0142 93.0137); - --muted: oklch(0.3800 0.0060 100); - --muted-foreground: oklch(0.6500 0.0120 93); - --accent: oklch(0.4000 0.0100 90); + --muted: oklch(0.38 0.006 100); + --muted-foreground: oklch(0.65 0.012 93); + --accent: oklch(0.4 0.01 90); --accent-foreground: oklch(0.8074 0.0142 93.0137); --destructive: oklch(0.704 0.191 22.216); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(0.4200 0.0080 95); - --input: oklch(0.3500 0.0050 100); - --ring: oklch(0.6800 0.1500 39); - --chart-1: oklch(0.6800 0.1500 39); + --border: oklch(0.42 0.008 95); + --input: oklch(0.35 0.005 100); + --ring: oklch(0.68 0.15 39); + --chart-1: oklch(0.68 0.15 39); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.2900 0.0040 105); + --sidebar: oklch(0.29 0.004 105); --sidebar-foreground: oklch(0.8074 0.0142 93.0137); - --sidebar-primary: oklch(0.6800 0.1500 39); + --sidebar-primary: oklch(0.68 0.15 39); --sidebar-primary-foreground: oklch(0.15 0 0); - --sidebar-accent: oklch(0.3800 0.0080 95); + --sidebar-accent: oklch(0.38 0.008 95); --sidebar-accent-foreground: oklch(0.8074 0.0142 93.0137); - --sidebar-border: oklch(0.4000 0.0060 100); - --sidebar-ring: oklch(0.6800 0.1500 39); + --sidebar-border: oklch(0.4 0.006 100); + --sidebar-ring: oklch(0.68 0.15 39); /* Shadow variables - dark mode */ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.25); --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.35), 0 1px 2px -1px rgb(0 0 0 / 0.25); - --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.35), 0 2px 4px -2px rgb(0 0 0 / 0.25); - --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.35), 0 4px 6px -4px rgb(0 0 0 / 0.25); + --shadow-md: + 0 4px 6px -1px rgb(0 0 0 / 0.35), 0 2px 4px -2px rgb(0 0 0 / 0.25); + --shadow-lg: + 0 10px 15px -3px rgb(0 0 0 / 0.35), 0 4px 6px -4px rgb(0 0 0 / 0.25); /* Log level colors for dark mode */ --color-log-error: #e8877a; @@ -236,9 +248,9 @@ --color-log-success: #8bb58b; /* Status colors for Kanban - dark mode */ - --color-status-pending: oklch(0.3500 0.0300 80); - --color-status-progress: oklch(0.4000 0.0500 60); - --color-status-done: oklch(0.4000 0.0500 140); + --color-status-pending: oklch(0.35 0.03 80); + --color-status-progress: oklch(0.4 0.05 60); + --color-status-done: oklch(0.4 0.05 140); } /* ============================================================================ @@ -248,38 +260,38 @@ .theme-neo-brutalism { --radius: 0px; - --background: oklch(1.0000 0 0); + --background: oklch(1 0 0); --foreground: oklch(0 0 0); - --card: oklch(0.9800 0.0150 95); + --card: oklch(0.98 0.015 95); --card-foreground: oklch(0 0 0); - --popover: oklch(1.0000 0 0); + --popover: oklch(1 0 0); --popover-foreground: oklch(0 0 0); - --primary: oklch(0.6489 0.2370 26.9728); + --primary: oklch(0.6489 0.237 26.9728); --primary-foreground: oklch(0 0 0); - --secondary: oklch(0.9500 0.1500 100); + --secondary: oklch(0.95 0.15 100); --secondary-foreground: oklch(0 0 0); - --muted: oklch(0.9400 0.0100 90); - --muted-foreground: oklch(0.4000 0 0); - --accent: oklch(0.8800 0.1800 85); + --muted: oklch(0.94 0.01 90); + --muted-foreground: oklch(0.4 0 0); + --accent: oklch(0.88 0.18 85); --accent-foreground: oklch(0 0 0); - --destructive: oklch(0.6500 0.2500 25); + --destructive: oklch(0.65 0.25 25); --destructive-foreground: oklch(0 0 0); --border: oklch(0 0 0); - --input: oklch(1.0000 0 0); - --ring: oklch(0.6489 0.2370 26.9728); - --chart-1: oklch(0.6489 0.2370 26.9728); - --chart-2: oklch(0.8000 0.2000 130); - --chart-3: oklch(0.7000 0.2200 280); - --chart-4: oklch(0.8800 0.1800 85); - --chart-5: oklch(0.6500 0.2500 330); - --sidebar: oklch(0.9500 0.1500 100); + --input: oklch(1 0 0); + --ring: oklch(0.6489 0.237 26.9728); + --chart-1: oklch(0.6489 0.237 26.9728); + --chart-2: oklch(0.8 0.2 130); + --chart-3: oklch(0.7 0.22 280); + --chart-4: oklch(0.88 0.18 85); + --chart-5: oklch(0.65 0.25 330); + --sidebar: oklch(0.95 0.15 100); --sidebar-foreground: oklch(0 0 0); - --sidebar-primary: oklch(0.6489 0.2370 26.9728); + --sidebar-primary: oklch(0.6489 0.237 26.9728); --sidebar-primary-foreground: oklch(0 0 0); - --sidebar-accent: oklch(0.8800 0.1800 85); + --sidebar-accent: oklch(0.88 0.18 85); --sidebar-accent-foreground: oklch(0 0 0); --sidebar-border: oklch(0 0 0); - --sidebar-ring: oklch(0.6489 0.2370 26.9728); + --sidebar-ring: oklch(0.6489 0.237 26.9728); /* Shadow variables - hard shadows */ --shadow-sm: 2px 2px 0px rgb(0 0 0); @@ -295,48 +307,48 @@ --color-log-success: #00cc00; /* Status colors for Kanban */ - --color-status-pending: oklch(0.9500 0.1500 100); - --color-status-progress: oklch(0.8200 0.1800 200); - --color-status-done: oklch(0.8000 0.2000 130); + --color-status-pending: oklch(0.95 0.15 100); + --color-status-progress: oklch(0.82 0.18 200); + --color-status-done: oklch(0.8 0.2 130); /* Font stacks - DM Sans for Neo Brutalism */ - --font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif; - --font-mono: 'Space Mono', 'JetBrains Mono', monospace; + --font-sans: "DM Sans", -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: "Space Mono", "JetBrains Mono", monospace; } .theme-neo-brutalism.dark { - --background: oklch(0.1200 0 0); - --foreground: oklch(1.0000 0 0); - --card: oklch(0.1800 0.0080 280); - --card-foreground: oklch(1.0000 0 0); - --popover: oklch(0.1500 0 0); - --popover-foreground: oklch(1.0000 0 0); - --primary: oklch(0.7200 0.2500 27); + --background: oklch(0.12 0 0); + --foreground: oklch(1 0 0); + --card: oklch(0.18 0.008 280); + --card-foreground: oklch(1 0 0); + --popover: oklch(0.15 0 0); + --popover-foreground: oklch(1 0 0); + --primary: oklch(0.72 0.25 27); --primary-foreground: oklch(0 0 0); - --secondary: oklch(0.4500 0.1200 100); - --secondary-foreground: oklch(1.0000 0 0); - --muted: oklch(0.2500 0.0050 0); - --muted-foreground: oklch(0.6500 0 0); - --accent: oklch(0.5500 0.1500 85); + --secondary: oklch(0.45 0.12 100); + --secondary-foreground: oklch(1 0 0); + --muted: oklch(0.25 0.005 0); + --muted-foreground: oklch(0.65 0 0); + --accent: oklch(0.55 0.15 85); --accent-foreground: oklch(0 0 0); - --destructive: oklch(0.6500 0.2500 25); + --destructive: oklch(0.65 0.25 25); --destructive-foreground: oklch(0 0 0); - --border: oklch(0.7000 0 0); - --input: oklch(0.2000 0 0); - --ring: oklch(0.7200 0.2500 27); - --chart-1: oklch(0.7200 0.2500 27); - --chart-2: oklch(0.7500 0.1800 130); - --chart-3: oklch(0.6500 0.2000 280); - --chart-4: oklch(0.7000 0.1500 85); - --chart-5: oklch(0.6000 0.2200 330); - --sidebar: oklch(0.1500 0.0050 280); - --sidebar-foreground: oklch(1.0000 0 0); - --sidebar-primary: oklch(0.7200 0.2500 27); + --border: oklch(0.7 0 0); + --input: oklch(0.2 0 0); + --ring: oklch(0.72 0.25 27); + --chart-1: oklch(0.72 0.25 27); + --chart-2: oklch(0.75 0.18 130); + --chart-3: oklch(0.65 0.2 280); + --chart-4: oklch(0.7 0.15 85); + --chart-5: oklch(0.6 0.22 330); + --sidebar: oklch(0.15 0.005 280); + --sidebar-foreground: oklch(1 0 0); + --sidebar-primary: oklch(0.72 0.25 27); --sidebar-primary-foreground: oklch(0 0 0); - --sidebar-accent: oklch(0.4500 0.1200 85); - --sidebar-accent-foreground: oklch(1.0000 0 0); - --sidebar-border: oklch(0.5000 0 0); - --sidebar-ring: oklch(0.7200 0.2500 27); + --sidebar-accent: oklch(0.45 0.12 85); + --sidebar-accent-foreground: oklch(1 0 0); + --sidebar-border: oklch(0.5 0 0); + --sidebar-ring: oklch(0.72 0.25 27); /* Shadow variables - hard shadows for dark mode */ --shadow-sm: 2px 2px 0px rgb(255 255 255 / 0.3); @@ -352,9 +364,9 @@ --color-log-success: #44dd44; /* Status colors for Kanban - dark mode */ - --color-status-pending: oklch(0.4500 0.1200 100); - --color-status-progress: oklch(0.4500 0.1500 200); - --color-status-done: oklch(0.4500 0.1500 130); + --color-status-pending: oklch(0.45 0.12 100); + --color-status-progress: oklch(0.45 0.15 200); + --color-status-done: oklch(0.45 0.15 130); } /* ============================================================================ @@ -366,42 +378,50 @@ --radius: 0.25rem; --background: oklch(0.9735 0.0261 90.0953); --foreground: oklch(0.3092 0.0518 219.6516); - --card: oklch(0.9306 0.0260 92.4020); + --card: oklch(0.9306 0.026 92.402); --card-foreground: oklch(0.3092 0.0518 219.6516); - --popover: oklch(0.9306 0.0260 92.4020); + --popover: oklch(0.9306 0.026 92.402); --popover-foreground: oklch(0.3092 0.0518 219.6516); --primary: oklch(0.5924 0.2025 355.8943); - --primary-foreground: oklch(1.0000 0 0); - --secondary: oklch(0.6437 0.1019 187.3840); - --secondary-foreground: oklch(1.0000 0 0); - --muted: oklch(0.6979 0.0159 196.7940); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.6437 0.1019 187.384); + --secondary-foreground: oklch(1 0 0); + --muted: oklch(0.6979 0.0159 196.794); --muted-foreground: oklch(0.3092 0.0518 219.6516); --accent: oklch(0.5808 0.1732 39.5003); - --accent-foreground: oklch(1.0000 0 0); + --accent-foreground: oklch(1 0 0); --destructive: oklch(0.5863 0.2064 27.1172); - --destructive-foreground: oklch(1.0000 0 0); + --destructive-foreground: oklch(1 0 0); --border: oklch(0.6537 0.0197 205.2618); --input: oklch(0.6537 0.0197 205.2618); --ring: oklch(0.5924 0.2025 355.8943); --chart-1: oklch(0.6149 0.1394 244.9273); - --chart-2: oklch(0.6437 0.1019 187.3840); + --chart-2: oklch(0.6437 0.1019 187.384); --chart-3: oklch(0.5924 0.2025 355.8943); --chart-4: oklch(0.5808 0.1732 39.5003); --chart-5: oklch(0.5863 0.2064 27.1172); --sidebar: oklch(0.9735 0.0261 90.0953); --sidebar-foreground: oklch(0.3092 0.0518 219.6516); --sidebar-primary: oklch(0.5924 0.2025 355.8943); - --sidebar-primary-foreground: oklch(1.0000 0 0); - --sidebar-accent: oklch(0.6437 0.1019 187.3840); - --sidebar-accent-foreground: oklch(1.0000 0 0); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.6437 0.1019 187.384); + --sidebar-accent-foreground: oklch(1 0 0); --sidebar-border: oklch(0.6537 0.0197 205.2618); --sidebar-ring: oklch(0.5924 0.2025 355.8943); /* Shadow variables - retro arcade style */ - --shadow-sm: 2px 2px 4px 0px hsl(196 83% 10% / 0.15), 2px 1px 2px -1px hsl(196 83% 10% / 0.15); - --shadow: 2px 2px 4px 0px hsl(196 83% 10% / 0.15), 2px 1px 2px -1px hsl(196 83% 10% / 0.15); - --shadow-md: 2px 2px 4px 0px hsl(196 83% 10% / 0.15), 2px 2px 4px -1px hsl(196 83% 10% / 0.15); - --shadow-lg: 2px 2px 4px 0px hsl(196 83% 10% / 0.15), 2px 4px 6px -1px hsl(196 83% 10% / 0.15); + --shadow-sm: + 2px 2px 4px 0px hsl(196 83% 10% / 0.15), + 2px 1px 2px -1px hsl(196 83% 10% / 0.15); + --shadow: + 2px 2px 4px 0px hsl(196 83% 10% / 0.15), + 2px 1px 2px -1px hsl(196 83% 10% / 0.15); + --shadow-md: + 2px 2px 4px 0px hsl(196 83% 10% / 0.15), + 2px 2px 4px -1px hsl(196 83% 10% / 0.15); + --shadow-lg: + 2px 2px 4px 0px hsl(196 83% 10% / 0.15), + 2px 4px 6px -1px hsl(196 83% 10% / 0.15); /* Log level colors */ --color-log-error: #e8457c; @@ -411,54 +431,62 @@ --color-log-success: #6bbd6b; /* Status colors for Kanban */ - --color-status-pending: oklch(0.9306 0.0260 92.4020); - --color-status-progress: oklch(0.6437 0.1019 187.3840); + --color-status-pending: oklch(0.9306 0.026 92.402); + --color-status-progress: oklch(0.6437 0.1019 187.384); --color-status-done: oklch(0.5924 0.2025 355.8943); /* Font stacks - Outfit for Retro Arcade */ - --font-sans: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif; - --font-mono: 'Space Mono', 'JetBrains Mono', monospace; + --font-sans: "Outfit", -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: "Space Mono", "JetBrains Mono", monospace; } .theme-retro-arcade.dark { --background: oklch(0.2673 0.0486 219.8169); - --foreground: oklch(0.6979 0.0159 196.7940); + --foreground: oklch(0.6979 0.0159 196.794); --card: oklch(0.3092 0.0518 219.6516); - --card-foreground: oklch(0.6979 0.0159 196.7940); + --card-foreground: oklch(0.6979 0.0159 196.794); --popover: oklch(0.3092 0.0518 219.6516); - --popover-foreground: oklch(0.6979 0.0159 196.7940); + --popover-foreground: oklch(0.6979 0.0159 196.794); --primary: oklch(0.5924 0.2025 355.8943); - --primary-foreground: oklch(1.0000 0 0); - --secondary: oklch(0.6437 0.1019 187.3840); - --secondary-foreground: oklch(1.0000 0 0); - --muted: oklch(0.5230 0.0283 219.1365); - --muted-foreground: oklch(0.6979 0.0159 196.7940); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.6437 0.1019 187.384); + --secondary-foreground: oklch(1 0 0); + --muted: oklch(0.523 0.0283 219.1365); + --muted-foreground: oklch(0.6979 0.0159 196.794); --accent: oklch(0.5808 0.1732 39.5003); - --accent-foreground: oklch(1.0000 0 0); + --accent-foreground: oklch(1 0 0); --destructive: oklch(0.5863 0.2064 27.1172); - --destructive-foreground: oklch(1.0000 0 0); - --border: oklch(0.5230 0.0283 219.1365); - --input: oklch(0.5230 0.0283 219.1365); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.523 0.0283 219.1365); + --input: oklch(0.523 0.0283 219.1365); --ring: oklch(0.5924 0.2025 355.8943); --chart-1: oklch(0.6149 0.1394 244.9273); - --chart-2: oklch(0.6437 0.1019 187.3840); + --chart-2: oklch(0.6437 0.1019 187.384); --chart-3: oklch(0.5924 0.2025 355.8943); --chart-4: oklch(0.5808 0.1732 39.5003); --chart-5: oklch(0.5863 0.2064 27.1172); --sidebar: oklch(0.2673 0.0486 219.8169); - --sidebar-foreground: oklch(0.6979 0.0159 196.7940); + --sidebar-foreground: oklch(0.6979 0.0159 196.794); --sidebar-primary: oklch(0.5924 0.2025 355.8943); - --sidebar-primary-foreground: oklch(1.0000 0 0); - --sidebar-accent: oklch(0.6437 0.1019 187.3840); - --sidebar-accent-foreground: oklch(1.0000 0 0); - --sidebar-border: oklch(0.5230 0.0283 219.1365); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.6437 0.1019 187.384); + --sidebar-accent-foreground: oklch(1 0 0); + --sidebar-border: oklch(0.523 0.0283 219.1365); --sidebar-ring: oklch(0.5924 0.2025 355.8943); /* Shadow variables - retro arcade dark mode */ - --shadow-sm: 2px 2px 4px 0px hsl(196 83% 10% / 0.15), 2px 1px 2px -1px hsl(196 83% 10% / 0.15); - --shadow: 2px 2px 4px 0px hsl(196 83% 10% / 0.15), 2px 1px 2px -1px hsl(196 83% 10% / 0.15); - --shadow-md: 2px 2px 4px 0px hsl(196 83% 10% / 0.15), 2px 2px 4px -1px hsl(196 83% 10% / 0.15); - --shadow-lg: 2px 2px 4px 0px hsl(196 83% 10% / 0.15), 2px 4px 6px -1px hsl(196 83% 10% / 0.15); + --shadow-sm: + 2px 2px 4px 0px hsl(196 83% 10% / 0.15), + 2px 1px 2px -1px hsl(196 83% 10% / 0.15); + --shadow: + 2px 2px 4px 0px hsl(196 83% 10% / 0.15), + 2px 1px 2px -1px hsl(196 83% 10% / 0.15); + --shadow-md: + 2px 2px 4px 0px hsl(196 83% 10% / 0.15), + 2px 2px 4px -1px hsl(196 83% 10% / 0.15); + --shadow-lg: + 2px 2px 4px 0px hsl(196 83% 10% / 0.15), + 2px 4px 6px -1px hsl(196 83% 10% / 0.15); /* Log level colors for dark mode */ --color-log-error: #f06b99; @@ -469,8 +497,8 @@ /* Status colors for Kanban - dark mode */ --color-status-pending: oklch(0.3092 0.0518 219.6516); - --color-status-progress: oklch(0.5230 0.0800 187); - --color-status-done: oklch(0.5000 0.1500 355); + --color-status-progress: oklch(0.523 0.08 187); + --color-status-done: oklch(0.5 0.15 355); } /* ============================================================================ @@ -481,44 +509,50 @@ .theme-aurora { --radius: 0.5rem; - --background: oklch(0.9850 0.0080 285); - --foreground: oklch(0.2500 0.0400 285); - --card: oklch(0.9700 0.0120 285); - --card-foreground: oklch(0.2500 0.0400 285); - --popover: oklch(0.9800 0.0100 285); - --popover-foreground: oklch(0.2500 0.0400 285); - --primary: oklch(0.5500 0.2200 285); - --primary-foreground: oklch(1.0000 0 0); - --secondary: oklch(0.7000 0.1400 175); - --secondary-foreground: oklch(0.1500 0.0300 175); - --muted: oklch(0.9400 0.0150 285); - --muted-foreground: oklch(0.4500 0.0300 285); - --accent: oklch(0.7500 0.1500 170); - --accent-foreground: oklch(0.1500 0.0300 170); - --destructive: oklch(0.6000 0.2000 25); - --destructive-foreground: oklch(1.0000 0 0); - --border: oklch(0.8800 0.0200 285); - --input: oklch(0.9200 0.0150 285); - --ring: oklch(0.5500 0.2200 285); - --chart-1: oklch(0.5500 0.2200 285); - --chart-2: oklch(0.7000 0.1400 175); - --chart-3: oklch(0.6500 0.1800 320); - --chart-4: oklch(0.7500 0.1500 170); - --chart-5: oklch(0.6000 0.2000 25); - --sidebar: oklch(0.9600 0.0150 285); - --sidebar-foreground: oklch(0.2500 0.0400 285); - --sidebar-primary: oklch(0.5500 0.2200 285); - --sidebar-primary-foreground: oklch(1.0000 0 0); - --sidebar-accent: oklch(0.7000 0.1400 175); - --sidebar-accent-foreground: oklch(0.1500 0.0300 175); - --sidebar-border: oklch(0.8800 0.0200 285); - --sidebar-ring: oklch(0.5500 0.2200 285); + --background: oklch(0.985 0.008 285); + --foreground: oklch(0.25 0.04 285); + --card: oklch(0.97 0.012 285); + --card-foreground: oklch(0.25 0.04 285); + --popover: oklch(0.98 0.01 285); + --popover-foreground: oklch(0.25 0.04 285); + --primary: oklch(0.55 0.22 285); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.7 0.14 175); + --secondary-foreground: oklch(0.15 0.03 175); + --muted: oklch(0.94 0.015 285); + --muted-foreground: oklch(0.45 0.03 285); + --accent: oklch(0.75 0.15 170); + --accent-foreground: oklch(0.15 0.03 170); + --destructive: oklch(0.6 0.2 25); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.88 0.02 285); + --input: oklch(0.92 0.015 285); + --ring: oklch(0.55 0.22 285); + --chart-1: oklch(0.55 0.22 285); + --chart-2: oklch(0.7 0.14 175); + --chart-3: oklch(0.65 0.18 320); + --chart-4: oklch(0.75 0.15 170); + --chart-5: oklch(0.6 0.2 25); + --sidebar: oklch(0.96 0.015 285); + --sidebar-foreground: oklch(0.25 0.04 285); + --sidebar-primary: oklch(0.55 0.22 285); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.7 0.14 175); + --sidebar-accent-foreground: oklch(0.15 0.03 175); + --sidebar-border: oklch(0.88 0.02 285); + --sidebar-ring: oklch(0.55 0.22 285); /* Shadow variables - soft violet-tinted shadows */ --shadow-sm: 0 1px 2px 0 oklch(0.3 0.05 285 / 0.08); - --shadow: 0 1px 3px 0 oklch(0.3 0.05 285 / 0.12), 0 1px 2px -1px oklch(0.3 0.05 285 / 0.08); - --shadow-md: 0 4px 6px -1px oklch(0.3 0.05 285 / 0.12), 0 2px 4px -2px oklch(0.3 0.05 285 / 0.08); - --shadow-lg: 0 10px 15px -3px oklch(0.3 0.05 285 / 0.12), 0 4px 6px -4px oklch(0.3 0.05 285 / 0.08); + --shadow: + 0 1px 3px 0 oklch(0.3 0.05 285 / 0.12), + 0 1px 2px -1px oklch(0.3 0.05 285 / 0.08); + --shadow-md: + 0 4px 6px -1px oklch(0.3 0.05 285 / 0.12), + 0 2px 4px -2px oklch(0.3 0.05 285 / 0.08); + --shadow-lg: + 0 10px 15px -3px oklch(0.3 0.05 285 / 0.12), + 0 4px 6px -4px oklch(0.3 0.05 285 / 0.08); /* Log level colors */ --color-log-error: #e879a0; @@ -528,54 +562,61 @@ --color-log-success: #2dd4bf; /* Status colors for Kanban */ - --color-status-pending: oklch(0.9400 0.0150 285); - --color-status-progress: oklch(0.8500 0.0800 175); - --color-status-done: oklch(0.8000 0.1200 285); + --color-status-pending: oklch(0.94 0.015 285); + --color-status-progress: oklch(0.85 0.08 175); + --color-status-done: oklch(0.8 0.12 285); /* Font stacks - Inter for Aurora's clean, readable feel */ - --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --font-sans: + "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", monospace; } .theme-aurora.dark { - --background: oklch(0.1600 0.0300 285); - --foreground: oklch(0.9000 0.0150 285); - --card: oklch(0.2000 0.0350 285); - --card-foreground: oklch(0.9000 0.0150 285); - --popover: oklch(0.1800 0.0320 285); - --popover-foreground: oklch(0.9000 0.0150 285); - --primary: oklch(0.6500 0.2400 285); - --primary-foreground: oklch(0.1000 0.0200 285); - --secondary: oklch(0.6500 0.1600 175); - --secondary-foreground: oklch(0.1000 0.0200 175); - --muted: oklch(0.2500 0.0280 285); - --muted-foreground: oklch(0.6500 0.0200 285); - --accent: oklch(0.6800 0.1700 170); - --accent-foreground: oklch(0.1000 0.0200 170); - --destructive: oklch(0.6500 0.2000 25); - --destructive-foreground: oklch(1.0000 0 0); - --border: oklch(0.3200 0.0350 285); - --input: oklch(0.2200 0.0300 285); - --ring: oklch(0.6500 0.2400 285); - --chart-1: oklch(0.6500 0.2400 285); - --chart-2: oklch(0.6500 0.1600 175); - --chart-3: oklch(0.7000 0.2000 320); - --chart-4: oklch(0.6800 0.1700 170); - --chart-5: oklch(0.6500 0.2000 25); - --sidebar: oklch(0.1400 0.0280 285); - --sidebar-foreground: oklch(0.9000 0.0150 285); - --sidebar-primary: oklch(0.6500 0.2400 285); - --sidebar-primary-foreground: oklch(0.1000 0.0200 285); - --sidebar-accent: oklch(0.6500 0.1600 175); - --sidebar-accent-foreground: oklch(0.1000 0.0200 175); - --sidebar-border: oklch(0.3000 0.0300 285); - --sidebar-ring: oklch(0.6500 0.2400 285); + --background: oklch(0.16 0.03 285); + --foreground: oklch(0.9 0.015 285); + --card: oklch(0.2 0.035 285); + --card-foreground: oklch(0.9 0.015 285); + --popover: oklch(0.18 0.032 285); + --popover-foreground: oklch(0.9 0.015 285); + --primary: oklch(0.65 0.24 285); + --primary-foreground: oklch(0.1 0.02 285); + --secondary: oklch(0.65 0.16 175); + --secondary-foreground: oklch(0.1 0.02 175); + --muted: oklch(0.25 0.028 285); + --muted-foreground: oklch(0.65 0.02 285); + --accent: oklch(0.68 0.17 170); + --accent-foreground: oklch(0.1 0.02 170); + --destructive: oklch(0.65 0.2 25); + --destructive-foreground: oklch(1 0 0); + --border: oklch(0.32 0.035 285); + --input: oklch(0.22 0.03 285); + --ring: oklch(0.65 0.24 285); + --chart-1: oklch(0.65 0.24 285); + --chart-2: oklch(0.65 0.16 175); + --chart-3: oklch(0.7 0.2 320); + --chart-4: oklch(0.68 0.17 170); + --chart-5: oklch(0.65 0.2 25); + --sidebar: oklch(0.14 0.028 285); + --sidebar-foreground: oklch(0.9 0.015 285); + --sidebar-primary: oklch(0.65 0.24 285); + --sidebar-primary-foreground: oklch(0.1 0.02 285); + --sidebar-accent: oklch(0.65 0.16 175); + --sidebar-accent-foreground: oklch(0.1 0.02 175); + --sidebar-border: oklch(0.3 0.03 285); + --sidebar-ring: oklch(0.65 0.24 285); /* Shadow variables - deep glowing shadows */ --shadow-sm: 0 1px 2px 0 oklch(0.1 0.03 285 / 0.4); - --shadow: 0 1px 3px 0 oklch(0.1 0.03 285 / 0.5), 0 1px 2px -1px oklch(0.1 0.03 285 / 0.4); - --shadow-md: 0 4px 6px -1px oklch(0.1 0.03 285 / 0.5), 0 2px 4px -2px oklch(0.1 0.03 285 / 0.4); - --shadow-lg: 0 10px 15px -3px oklch(0.1 0.03 285 / 0.5), 0 4px 6px -4px oklch(0.1 0.03 285 / 0.4); + --shadow: + 0 1px 3px 0 oklch(0.1 0.03 285 / 0.5), + 0 1px 2px -1px oklch(0.1 0.03 285 / 0.4); + --shadow-md: + 0 4px 6px -1px oklch(0.1 0.03 285 / 0.5), + 0 2px 4px -2px oklch(0.1 0.03 285 / 0.4); + --shadow-lg: + 0 10px 15px -3px oklch(0.1 0.03 285 / 0.5), + 0 4px 6px -4px oklch(0.1 0.03 285 / 0.4); /* Log level colors for dark mode - more luminous */ --color-log-error: #f472b6; @@ -585,9 +626,9 @@ --color-log-success: #5eead4; /* Status colors for Kanban - dark mode */ - --color-status-pending: oklch(0.2500 0.0280 285); - --color-status-progress: oklch(0.4000 0.1000 175); - --color-status-done: oklch(0.4500 0.1500 285); + --color-status-pending: oklch(0.25 0.028 285); + --color-status-progress: oklch(0.4 0.1 175); + --color-status-done: oklch(0.45 0.15 285); } /* ============================================================================ @@ -670,7 +711,9 @@ /* Smooth theme transitions */ :root { - transition: background-color 0.2s ease, color 0.2s ease; + transition: + background-color 0.2s ease, + color 0.2s ease; } } @@ -750,7 +793,8 @@ } @keyframes pulse { - 0%, 100% { + 0%, + 100% { opacity: 1; } 50% { @@ -759,7 +803,8 @@ } @keyframes bounce { - 0%, 100% { + 0%, + 100% { transform: translateY(0); } 50% { @@ -769,7 +814,8 @@ /* Agent mascot animations */ @keyframes thinking { - 0%, 100% { + 0%, + 100% { transform: translateY(0) scale(1); } 25% { @@ -784,7 +830,8 @@ } @keyframes working { - 0%, 100% { + 0%, + 100% { transform: translateX(0); } 25% { @@ -796,7 +843,8 @@ } @keyframes testing { - 0%, 100% { + 0%, + 100% { transform: rotate(0deg); } 25% { @@ -808,7 +856,8 @@ } @keyframes celebrate { - 0%, 100% { + 0%, + 100% { transform: scale(1) rotate(0deg); } 25% { @@ -823,13 +872,16 @@ } @keyframes shake { - 0%, 100% { + 0%, + 100% { transform: translateX(0); } - 20%, 60% { + 20%, + 60% { transform: translateX(-2px); } - 40%, 80% { + 40%, + 80% { transform: translateX(2px); } } @@ -847,7 +899,8 @@ /* Orchestrator (Maestro) animations */ @keyframes conducting { - 0%, 100% { + 0%, + 100% { transform: rotate(-10deg); } 25% { @@ -862,7 +915,8 @@ } @keyframes batonTap { - 0%, 100% { + 0%, + 100% { transform: translateY(0) rotate(0deg); } 25% { @@ -972,11 +1026,21 @@ } /* Stagger delays for sequential animations */ - .stagger-1 { animation-delay: 50ms; } - .stagger-2 { animation-delay: 100ms; } - .stagger-3 { animation-delay: 150ms; } - .stagger-4 { animation-delay: 200ms; } - .stagger-5 { animation-delay: 250ms; } + .stagger-1 { + animation-delay: 50ms; + } + .stagger-2 { + animation-delay: 100ms; + } + .stagger-3 { + animation-delay: 150ms; + } + .stagger-4 { + animation-delay: 200ms; + } + .stagger-5 { + animation-delay: 250ms; + } /* Font utilities */ .font-sans { diff --git a/ui/src/test/setup.ts b/ui/src/test/setup.ts new file mode 100644 index 00000000..d0de870d --- /dev/null +++ b/ui/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index f7c6aa19..51f8db60 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import path from 'path' @@ -14,6 +14,12 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + test: { + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + globals: true, + exclude: ['e2e/**', 'node_modules/**'], + }, build: { rollupOptions: { output: {