github-actions[bot] commited on
Commit ·
91bfec6
0
Parent(s):
Deploy from f861358f
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +92 -0
- README.md +80 -0
- REPO_README.md +418 -0
- frontend/.dockerignore +39 -0
- frontend/App.jsx +1295 -0
- frontend/components/AboutModal.jsx +488 -0
- frontend/components/AddRepoModal.jsx +256 -0
- frontend/components/AdminTabs/AdvancedTab.jsx +360 -0
- frontend/components/AdminTabs/IntegrationsTab.jsx +238 -0
- frontend/components/AdminTabs/MCPServersTab.jsx +337 -0
- frontend/components/AdminTabs/MatrixLabInstallModal.jsx +1013 -0
- frontend/components/AdminTabs/SandboxTab.jsx +218 -0
- frontend/components/AdminTabs/SecurityTab.jsx +341 -0
- frontend/components/AdminTabs/SessionsTab.jsx +362 -0
- frontend/components/AdminTabs/SkillsTab.jsx +266 -0
- frontend/components/AdminTabs/WorkspaceModesTab.jsx +254 -0
- frontend/components/AdminTabs/index.js +10 -0
- frontend/components/AdminTabs/mcp/CatalogList.jsx +101 -0
- frontend/components/AdminTabs/mcp/CustomInstallForm.jsx +134 -0
- frontend/components/AdminTabs/mcp/GatewayHeader.jsx +145 -0
- frontend/components/AdminTabs/mcp/ServerCard.jsx +334 -0
- frontend/components/AdminTabs/mcp/SyncReport.jsx +152 -0
- frontend/components/AdminTabs/mcp/ToolRow.jsx +149 -0
- frontend/components/AssistantMessage.jsx +429 -0
- frontend/components/BranchPicker.jsx +398 -0
- frontend/components/ChatPanel.jsx +1370 -0
- frontend/components/ContextBar.jsx +156 -0
- frontend/components/ContextMeter.jsx +410 -0
- frontend/components/CreatePRButton.jsx +159 -0
- frontend/components/DiffStats.jsx +59 -0
- frontend/components/DiffViewer.jsx +263 -0
- frontend/components/EnvironmentEditor.jsx +278 -0
- frontend/components/EnvironmentSelector.jsx +199 -0
- frontend/components/ExecutionPlanCard.jsx +275 -0
- frontend/components/FilePreviewPanel.jsx +463 -0
- frontend/components/FileTree.jsx +569 -0
- frontend/components/FlowViewer.jsx +659 -0
- frontend/components/Footer.jsx +48 -0
- frontend/components/LlmSettings.jsx +623 -0
- frontend/components/LoginPage.jsx +544 -0
- frontend/components/PlanView.jsx +143 -0
- frontend/components/ProjectContextPanel.jsx +581 -0
- frontend/components/ProjectSettings/ContextTab.jsx +352 -0
- frontend/components/ProjectSettings/ConventionsTab.jsx +151 -0
- frontend/components/ProjectSettings/UseCaseTab.jsx +637 -0
- frontend/components/ProjectSettingsModal.jsx +230 -0
- frontend/components/RepoSelector.jsx +269 -0
- frontend/components/RunnableCodeBlock.jsx +486 -0
- frontend/components/SandboxCanvas.jsx +1052 -0
- frontend/components/SandboxStatusWidget.jsx +176 -0
Dockerfile
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# GitPilot - Hugging Face Spaces Dockerfile
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# Follows the official HF Docker Spaces pattern:
|
| 5 |
+
# https://huggingface.co/docs/hub/spaces-sdks-docker
|
| 6 |
+
#
|
| 7 |
+
# Architecture:
|
| 8 |
+
# React UI (Vite build) -> FastAPI backend -> OllaBridge Cloud / any LLM
|
| 9 |
+
# =============================================================================
|
| 10 |
+
|
| 11 |
+
# -- Stage 1: Build React frontend -------------------------------------------
|
| 12 |
+
FROM node:20-slim AS frontend-builder
|
| 13 |
+
|
| 14 |
+
WORKDIR /build
|
| 15 |
+
|
| 16 |
+
COPY frontend/package.json frontend/package-lock.json ./
|
| 17 |
+
RUN npm ci --production=false
|
| 18 |
+
|
| 19 |
+
COPY frontend/ ./
|
| 20 |
+
RUN npm run build
|
| 21 |
+
|
| 22 |
+
# -- Stage 2: Python runtime -------------------------------------------------
|
| 23 |
+
FROM python:3.12-slim
|
| 24 |
+
|
| 25 |
+
# System deps needed at runtime
|
| 26 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 27 |
+
git curl ca-certificates \
|
| 28 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 29 |
+
|
| 30 |
+
# HF Spaces runs containers as UID 1000 — create user early (official pattern)
|
| 31 |
+
RUN useradd -m -u 1000 user
|
| 32 |
+
|
| 33 |
+
USER user
|
| 34 |
+
|
| 35 |
+
ENV HOME=/home/user \
|
| 36 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 37 |
+
PYTHONUNBUFFERED=1 \
|
| 38 |
+
GITPILOT_PROVIDER=ollabridge \
|
| 39 |
+
OLLABRIDGE_BASE_URL=https://ruslanmv-ollabridge.hf.space \
|
| 40 |
+
GITPILOT_OLLABRIDGE_MODEL=qwen2.5:1.5b \
|
| 41 |
+
CORS_ORIGINS="*" \
|
| 42 |
+
GITPILOT_CONFIG_DIR=/tmp/gitpilot
|
| 43 |
+
|
| 44 |
+
WORKDIR $HOME/app
|
| 45 |
+
|
| 46 |
+
# ── Install Python dependencies BEFORE copying source code ──────────
|
| 47 |
+
# This ensures pip install layers are cached even when code changes.
|
| 48 |
+
COPY --chown=user pyproject.toml README.md ./
|
| 49 |
+
|
| 50 |
+
# Step 1: lightweight deps (cached layer)
|
| 51 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 52 |
+
pip install --no-cache-dir \
|
| 53 |
+
"fastapi>=0.111.0" \
|
| 54 |
+
"uvicorn[standard]>=0.30.0" \
|
| 55 |
+
"httpx>=0.27.0" \
|
| 56 |
+
"python-dotenv>=1.1.0,<1.2.0" \
|
| 57 |
+
"typer>=0.12.0,<0.24.0" \
|
| 58 |
+
"pydantic>=2.7.0,<2.12.0" \
|
| 59 |
+
"rich>=13.0.0" \
|
| 60 |
+
"pyjwt[crypto]>=2.8.0"
|
| 61 |
+
|
| 62 |
+
# Step 2: heavy ML/agent deps (separate layer for better caching)
|
| 63 |
+
RUN pip install --no-cache-dir \
|
| 64 |
+
"litellm" \
|
| 65 |
+
"crewai[anthropic]>=0.76.9" \
|
| 66 |
+
"crewai-tools>=0.13.4" \
|
| 67 |
+
"anthropic>=0.39.0" \
|
| 68 |
+
"ibm-watsonx-ai>=1.1.0" \
|
| 69 |
+
"langchain-ibm>=0.3.0"
|
| 70 |
+
|
| 71 |
+
# ── Now copy source code (cache-busting only affects layers below) ──
|
| 72 |
+
COPY --chown=user gitpilot ./gitpilot
|
| 73 |
+
|
| 74 |
+
# Copy built frontend into gitpilot/web/
|
| 75 |
+
COPY --chown=user --from=frontend-builder /build/dist/ ./gitpilot/web/
|
| 76 |
+
|
| 77 |
+
# Step 3: editable install of gitpilot itself (deps already satisfied)
|
| 78 |
+
RUN pip install --no-cache-dir --no-deps -e .
|
| 79 |
+
|
| 80 |
+
EXPOSE 7860
|
| 81 |
+
|
| 82 |
+
# NOTE: Do NOT add a Docker HEALTHCHECK here.
|
| 83 |
+
# HF Spaces has its own HTTP probe on app_port (7860) and ignores the
|
| 84 |
+
# Docker HEALTHCHECK directive.
|
| 85 |
+
|
| 86 |
+
# Direct CMD — no shell script, fewer failure points.
|
| 87 |
+
CMD ["python", "-m", "uvicorn", "gitpilot.api:app", \
|
| 88 |
+
"--host", "0.0.0.0", \
|
| 89 |
+
"--port", "7860", \
|
| 90 |
+
"--workers", "2", \
|
| 91 |
+
"--limit-concurrency", "10", \
|
| 92 |
+
"--timeout-keep-alive", "120"]
|
README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: GitPilot
|
| 3 |
+
emoji: "\U0001F916"
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
startup_duration_timeout: 5m
|
| 9 |
+
pinned: true
|
| 10 |
+
license: mit
|
| 11 |
+
short_description: Enterprise AI Coding Assistant for GitHub Repositories
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# GitPilot — Hugging Face Spaces
|
| 15 |
+
|
| 16 |
+
**Enterprise-grade AI coding assistant** for GitHub repositories with multi-LLM support, visual workflow insights, and intelligent code analysis.
|
| 17 |
+
|
| 18 |
+
## What This Does
|
| 19 |
+
|
| 20 |
+
This Space runs the full GitPilot stack:
|
| 21 |
+
1. **React Frontend** — Professional dark-theme UI with chat, file browser, and workflow visualization
|
| 22 |
+
2. **FastAPI Backend** — 80+ API endpoints for repository management, AI chat, planning, and execution
|
| 23 |
+
3. **Multi-Agent AI** — CrewAI orchestration with 7 switchable agent topologies
|
| 24 |
+
|
| 25 |
+
## LLM Providers
|
| 26 |
+
|
| 27 |
+
GitPilot connects to your favorite LLM provider. Configure in **Admin / LLM Settings**:
|
| 28 |
+
|
| 29 |
+
| Provider | Default | API Key Required |
|
| 30 |
+
|---|---|---|
|
| 31 |
+
| **OllaBridge Cloud** (default) | `qwen2.5:1.5b` | No |
|
| 32 |
+
| OpenAI | `gpt-4o-mini` | Yes |
|
| 33 |
+
| Anthropic Claude | `claude-sonnet-4-5` | Yes |
|
| 34 |
+
| Ollama (local) | `llama3` | No |
|
| 35 |
+
| Custom endpoint | Any model | Optional |
|
| 36 |
+
|
| 37 |
+
## Quick Start
|
| 38 |
+
|
| 39 |
+
1. Open the Space UI
|
| 40 |
+
2. Enter your **GitHub Token** (Settings -> GitHub)
|
| 41 |
+
3. Select a repository from the sidebar
|
| 42 |
+
4. Start chatting with your AI coding assistant
|
| 43 |
+
|
| 44 |
+
## API Endpoints
|
| 45 |
+
|
| 46 |
+
| Endpoint | Description |
|
| 47 |
+
|---|---|
|
| 48 |
+
| `GET /api/health` | Health check |
|
| 49 |
+
| `POST /api/chat/message` | Chat with AI assistant |
|
| 50 |
+
| `POST /api/chat/plan` | Generate implementation plan |
|
| 51 |
+
| `GET /api/repos` | List repositories |
|
| 52 |
+
| `GET /api/settings` | View/update settings |
|
| 53 |
+
| `GET /docs` | Interactive API docs (Swagger) |
|
| 54 |
+
|
| 55 |
+
## Connect to OllaBridge Cloud
|
| 56 |
+
|
| 57 |
+
By default, GitPilot connects to [OllaBridge Cloud](https://huggingface.co/spaces/ruslanmv/ollabridge) for LLM inference. This provides free access to open-source models without needing API keys.
|
| 58 |
+
|
| 59 |
+
To use your own OllaBridge instance:
|
| 60 |
+
1. Go to **Admin / LLM Settings**
|
| 61 |
+
2. Select **OllaBridge** provider
|
| 62 |
+
3. Enter your OllaBridge URL and model
|
| 63 |
+
|
| 64 |
+
## Environment Variables
|
| 65 |
+
|
| 66 |
+
Configure via HF Spaces secrets:
|
| 67 |
+
|
| 68 |
+
| Variable | Description | Default |
|
| 69 |
+
|---|---|---|
|
| 70 |
+
| `GITPILOT_PROVIDER` | LLM provider | `ollabridge` |
|
| 71 |
+
| `OLLABRIDGE_BASE_URL` | OllaBridge Cloud URL | `https://ruslanmv-ollabridge.hf.space` |
|
| 72 |
+
| `GITHUB_TOKEN` | GitHub personal access token | - |
|
| 73 |
+
| `OPENAI_API_KEY` | OpenAI API key (if using OpenAI) | - |
|
| 74 |
+
| `ANTHROPIC_API_KEY` | Anthropic API key (if using Claude) | - |
|
| 75 |
+
|
| 76 |
+
## Links
|
| 77 |
+
|
| 78 |
+
- [GitPilot Repository](https://github.com/ruslanmv/gitpilot)
|
| 79 |
+
- [OllaBridge Cloud](https://huggingface.co/spaces/ruslanmv/ollabridge)
|
| 80 |
+
- [Documentation](https://github.com/ruslanmv/gitpilot#readme)
|
REPO_README.md
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
|
| 3 |
+
<img src="docs/logo.svg" alt="GitPilot" width="140" />
|
| 4 |
+
|
| 5 |
+
# GitPilot
|
| 6 |
+
|
| 7 |
+
### The first open-source multi-agent AI coding assistant.
|
| 8 |
+
|
| 9 |
+
Multiple specialized agents — including Explorer, Planner, Coder, and Reviewer — collaborate seamlessly on every task. By default, GitPilot requests confirmation before executing high-impact actions. Switch to Auto or Plan mode at any time.
|
| 10 |
+
|
| 11 |
+
[](https://pypi.org/project/gitcopilot/)
|
| 12 |
+
[](https://www.python.org/)
|
| 13 |
+
[](LICENSE)
|
| 14 |
+
[](https://marketplace.visualstudio.com/)
|
| 15 |
+
[](#contributing)
|
| 16 |
+
|
| 17 |
+
[**Get Started**](#get-started) · [VS Code](#vs-code-extension) · [Web App](#web-app) · [How It Works](#how-it-works) · [Providers](#supported-ai-providers)
|
| 18 |
+
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
<p align="center">
|
| 24 |
+
<picture>
|
| 25 |
+
<source srcset="docs/assets/flow.svg" type="image/svg+xml" />
|
| 26 |
+
<img src="docs/assets/flow.png" alt="GitPilot loop: Ask, Plan, Code, Ship — you approve every change." width="900" />
|
| 27 |
+
</picture>
|
| 28 |
+
</p>
|
| 29 |
+
|
| 30 |
+
## Why GitPilot?
|
| 31 |
+
|
| 32 |
+
Most AI coding tools are a **single model behind a chat box**. GitPilot is fundamentally different: it deploys a **team of four specialized AI agents** that collaborate on every task — just like a real engineering team.
|
| 33 |
+
|
| 34 |
+
| Agent | Role | What it does |
|
| 35 |
+
|---|---|---|
|
| 36 |
+
| **Explorer** | Context | Reads your full repo, git log, test suite, and dependencies so the plan starts with real knowledge — not guesses |
|
| 37 |
+
| **Planner** | Strategy | Drafts a safe, step-by-step plan with diffs and surfaces risks before any file is touched |
|
| 38 |
+
| **Coder** | Execution | Writes code, runs your tests, and self-corrects on failure — iterating until the suite passes |
|
| 39 |
+
| **Reviewer** | Quality | Validates the output, re-runs the suite, and drafts a commit message and PR summary |
|
| 40 |
+
|
| 41 |
+
**You control how the agent runs.** Three execution modes — selectable per session from the VS Code compose bar or backend API:
|
| 42 |
+
|
| 43 |
+
| Mode | Default? | Behavior |
|
| 44 |
+
|---|---|---|
|
| 45 |
+
| **Ask** | Yes | Prompts you before each dangerous action (write, edit, run, commit). You see the diff and click Allow / Deny. |
|
| 46 |
+
| **Auto** | | Executes all tools automatically. Fastest for experienced users who trust the plan. |
|
| 47 |
+
| **Plan** | | Read-only. Generates and displays the plan but blocks all file writes and commands. |
|
| 48 |
+
|
| 49 |
+
Diffs are shown before they're applied. Tests run before anything is committed. No surprises.
|
| 50 |
+
|
| 51 |
+
### What else sets GitPilot apart
|
| 52 |
+
|
| 53 |
+
- 🧭 **Works where you work** — VS Code, web app, and CLI share one login, one history, and one set of approvals.
|
| 54 |
+
- 🧠 **Any LLM, zero lock-in** — OpenAI, Anthropic Claude, IBM Watsonx, Ollama (local & free) or OllaBridge. Switch in settings, no code change.
|
| 55 |
+
- 🔐 **Private by default** — run the entire stack locally with Ollama. No telemetry, no data leaves your machine.
|
| 56 |
+
- 🏢 **Enterprise-ready, Apache 2.0 open source** — 854 passing tests, Docker & Hugging Face deployment recipes, audit the code yourself.
|
| 57 |
+
- 🌍 **Runs anywhere** — laptop, private cloud, air-gapped environments, or managed hosting. Your repo, your rules.
|
| 58 |
+
|
| 59 |
+
---
|
| 60 |
+
|
| 61 |
+
## What is GitPilot?
|
| 62 |
+
|
| 63 |
+
GitPilot is an AI assistant that helps you ship better code, faster — without giving up control. It understands your project, plans changes you can read before they happen, writes the code, runs your tests, and drafts the commit message and pull request for you.
|
| 64 |
+
|
| 65 |
+
**Works with any language. Runs on any LLM.** Start free and local with Ollama, or bring your own OpenAI, Claude, or Watsonx key.
|
| 66 |
+
|
| 67 |
+
```
|
| 68 |
+
You: "Add input validation to the login form"
|
| 69 |
+
|
| 70 |
+
GitPilot:
|
| 71 |
+
1. Reading src/auth/login.ts...
|
| 72 |
+
2. Planning 3 changes...
|
| 73 |
+
3. Editing login.ts → [Apply Patch] [Revert]
|
| 74 |
+
4. Running npm test... 3 passed
|
| 75 |
+
5. Done — files written to your workspace.
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## Get Started
|
| 81 |
+
|
| 82 |
+
### Option 1: VS Code Extension (recommended)
|
| 83 |
+
|
| 84 |
+
Install the extension, configure your LLM, and start chatting:
|
| 85 |
+
|
| 86 |
+
```
|
| 87 |
+
1. Open VS Code
|
| 88 |
+
2. Install "GitPilot Workspace" from Extensions
|
| 89 |
+
3. Click the GitPilot icon in the sidebar
|
| 90 |
+
4. Choose your AI provider (OpenAI, Claude, Ollama...)
|
| 91 |
+
5. Start asking questions about your code
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
### Option 2: Web App
|
| 95 |
+

|
| 96 |
+
Run the full web interface with Docker:
|
| 97 |
+
|
| 98 |
+
```bash
|
| 99 |
+
git clone https://github.com/ruslanmv/gitpilot.git
|
| 100 |
+
cd gitpilot
|
| 101 |
+
docker compose up
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
| 105 |
+
|
| 106 |
+
### Live Demo on Hugging Face
|
| 107 |
+
|
| 108 |
+
Experience the application in action through our hosted demo environment:
|
| 109 |
+
|
| 110 |
+
[](https://huggingface.co/spaces/ruslanmv/gitpilot)
|
| 111 |
+
|
| 112 |
+
🔗 **Access the live demo:**
|
| 113 |
+
[https://huggingface.co/spaces/ruslanmv/gitpilot](https://huggingface.co/spaces/ruslanmv/gitpilot)
|
| 114 |
+
|
| 115 |
+
### Option 3: Python CLI (fastest)
|
| 116 |
+
|
| 117 |
+
```bash
|
| 118 |
+
pip install gitcopilot
|
| 119 |
+
gitpilot serve
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
Open [http://localhost:8000](http://localhost:8000) and you're done.
|
| 123 |
+
|
| 124 |
+
> **Heads up:** the PyPI package is published as **`gitcopilot`** (the name `gitpilot` was already taken) but the command you run is `gitpilot`. Python **3.11** or **3.12** required.
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
## VS Code Extension
|
| 129 |
+

|
| 130 |
+
The sidebar panel gives you everything in one place:
|
| 131 |
+
|
| 132 |
+
| Feature | What it does |
|
| 133 |
+
|---|---|
|
| 134 |
+
| **Chat** | Ask questions, request changes, review code |
|
| 135 |
+
| **Execution Modes** | Bottom bar: `Auto` / `Ask` / `Plan` — controls agent permissions per session |
|
| 136 |
+
| **Plan View** | See the step-by-step plan before changes are made |
|
| 137 |
+
| **Plan Approval** | "Approve & Execute" / "Dismiss" bar — execution waits for your OK |
|
| 138 |
+
| **Tool Approvals** | Per-action Allow / Allow for session / Deny cards (Ask mode) |
|
| 139 |
+
| **Diff Preview** | Review proposed edits in VS Code's native diff viewer |
|
| 140 |
+
| **Apply / Revert** | One click to apply changes, one click to undo |
|
| 141 |
+
| **Quick Actions** | Explain, Review, Fix, Generate Tests, Security Scan |
|
| 142 |
+
| **Smart Commit** | AI-generated commit messages |
|
| 143 |
+
| **Code Lens** | Inline "Explain / Review" hints on functions |
|
| 144 |
+
| **Settings Tab** | Branded settings page (General, Provider, Agent, Editor) |
|
| 145 |
+
| **New Chat** | One click to clear chat and start a fresh session |
|
| 146 |
+
|
| 147 |
+
### Execution modes
|
| 148 |
+
|
| 149 |
+
The compose bar includes a mode selector that controls how the multi-agent pipeline runs:
|
| 150 |
+
|
| 151 |
+
```
|
| 152 |
+
[ Auto | Ask | Plan ] [ Send ] [ New Chat ]
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
| Mode | VS Code setting | Backend value | What happens |
|
| 156 |
+
|---|---|---|---|
|
| 157 |
+
| **Ask** (default) | `gitpilot.permissionMode: "normal"` | `"normal"` | Each dangerous tool (write, edit, run, commit) shows an approval card |
|
| 158 |
+
| **Auto** | `gitpilot.permissionMode: "auto"` | `"auto"` | Tools execute automatically — no approval prompts |
|
| 159 |
+
| **Plan** | `gitpilot.permissionMode: "plan"` | `"plan"` | Plan is generated and displayed, all writes/commands blocked |
|
| 160 |
+
|
| 161 |
+
Mode changes are persisted to VS Code settings and synced to the backend via `PUT /api/permissions/mode`.
|
| 162 |
+
|
| 163 |
+
### How approvals work
|
| 164 |
+
|
| 165 |
+
```
|
| 166 |
+
You send a request
|
| 167 |
+
→ Explorer reads repo context
|
| 168 |
+
→ Planner drafts step-by-step plan
|
| 169 |
+
→ Plan appears in sidebar (Approve & Execute / Dismiss)
|
| 170 |
+
→ You click Approve
|
| 171 |
+
→ Coder begins execution
|
| 172 |
+
→ Dangerous tool requested (e.g. write_file)
|
| 173 |
+
→ Ask mode: approval card shown (Allow / Allow for session / Deny)
|
| 174 |
+
→ Auto mode: executes immediately
|
| 175 |
+
→ Plan mode: blocked
|
| 176 |
+
→ Tests run, Reviewer validates
|
| 177 |
+
→ Done — Apply Patch or Revert
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
> **Note:** Simple questions (e.g. "explain this code") may return a direct answer without generating a multi-step plan. This is expected — the planner activates for tasks that require file changes or multi-step execution.
|
| 181 |
+
|
| 182 |
+
### Code generation and Apply Patch
|
| 183 |
+
|
| 184 |
+
When you ask GitPilot to create or edit files, the response includes structured `edits` — not just text. The **Apply Patch** button writes them directly to your workspace.
|
| 185 |
+
|
| 186 |
+
```
|
| 187 |
+
You: "Create a Flask app with app.py, requirements.txt, and README.md"
|
| 188 |
+
|
| 189 |
+
GitPilot:
|
| 190 |
+
→ LLM generates 3 files with content
|
| 191 |
+
→ Backend extracts structured edits (path + content)
|
| 192 |
+
→ VS Code shows [Apply Patch] [Revert]
|
| 193 |
+
→ You click Apply Patch
|
| 194 |
+
→ 3 files written to disk
|
| 195 |
+
→ Project context refreshes automatically
|
| 196 |
+
→ First file opens in the editor
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
How it works under the hood:
|
| 200 |
+
- The LLM is instructed to output code blocks with the filename on the fence line (` ```python hello.py`)
|
| 201 |
+
- The backend parses these blocks into `ProposedEdit` objects with file path, kind, and content
|
| 202 |
+
- All paths are sanitized (rejects `../` traversal, absolute paths, drive letters)
|
| 203 |
+
- The extension stores edits in `activeTask.edits` and shows Apply / Revert
|
| 204 |
+
- `PatchApplier` writes files via `vscode.workspace.fs.writeFile`
|
| 205 |
+
- After apply, project context refreshes and the first file opens
|
| 206 |
+
|
| 207 |
+
> **Note:** For folder-only sessions (no GitHub remote), code generation uses the LLM directly with structured output instructions. For GitHub-connected sessions, the full CrewAI multi-agent pipeline (Explorer → Planner → Coder → Reviewer) handles planning and execution.
|
| 208 |
+
|
| 209 |
+
### Supported AI Providers
|
| 210 |
+
|
| 211 |
+
| Provider | Setup | Free? |
|
| 212 |
+
|---|---|---|
|
| 213 |
+
| **Ollama** | Install Ollama, run `ollama pull llama3` | Yes |
|
| 214 |
+
| **OllaBridge** | Works out of the box (cloud Ollama) | Yes |
|
| 215 |
+
| **OpenAI** | Add your API key in settings | Paid |
|
| 216 |
+
| **Claude** | Add your Anthropic API key | Paid |
|
| 217 |
+
| **Watsonx** | Add IBM credentials | Paid |
|
| 218 |
+
|
| 219 |
+
---
|
| 220 |
+
|
| 221 |
+
## Web App
|
| 222 |
+
|
| 223 |
+
The web interface includes:
|
| 224 |
+
|
| 225 |
+
- Chat with real-time responses
|
| 226 |
+
- GitHub integration (connect your repos)
|
| 227 |
+
- File tree browser
|
| 228 |
+
- Diff viewer with line-by-line changes
|
| 229 |
+
- Pull request creation
|
| 230 |
+
- Session history with checkpoints
|
| 231 |
+
- Multi-repo support
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
### Example: File Deletion
|
| 236 |
+

|
| 237 |
+
|
| 238 |
+
### Example: Content Generation
|
| 239 |
+

|
| 240 |
+
|
| 241 |
+
### Example: File Creation
|
| 242 |
+

|
| 243 |
+
|
| 244 |
+
### Example multiple operations
|
| 245 |
+

|
| 246 |
+
|
| 247 |
+
### Example of multiagent topologies
|
| 248 |
+

|
| 249 |
+
|
| 250 |
+
---
|
| 251 |
+
|
| 252 |
+
## How It Works
|
| 253 |
+
|
| 254 |
+
<p align="center">
|
| 255 |
+
<picture>
|
| 256 |
+
<source srcset="docs/assets/architecture.svg" type="image/svg+xml" />
|
| 257 |
+
<img src="docs/assets/architecture.png" alt="GitPilot architecture: Web, VS Code and CLI share one FastAPI backend that orchestrates a CrewAI multi-agent pipeline (Explorer, Planner, Executor, Reviewer) over any LLM provider." width="100%" />
|
| 258 |
+
</picture>
|
| 259 |
+
</p>
|
| 260 |
+
|
| 261 |
+
GitPilot uses a multi-agent system powered by CrewAI:
|
| 262 |
+
|
| 263 |
+
1. **Explorer** reads your repo structure, git log, and key files
|
| 264 |
+
2. **Planner** creates a safe step-by-step plan with diffs
|
| 265 |
+
3. **Executor** writes code and runs tests, self-correcting on failure
|
| 266 |
+
4. **Reviewer** validates the output and summarises what changed
|
| 267 |
+
|
| 268 |
+
In **Ask** mode (default), you approve every change before it's applied. In **Auto** mode, tools execute without prompts. In **Plan** mode, only the plan is generated — no files are touched.
|
| 269 |
+
|
| 270 |
+
---
|
| 271 |
+
|
| 272 |
+
## Project Structure
|
| 273 |
+
|
| 274 |
+
```
|
| 275 |
+
gitpilot/
|
| 276 |
+
gitpilot/ Python backend (FastAPI)
|
| 277 |
+
frontend/ React web app
|
| 278 |
+
extensions/vscode/ VS Code extension
|
| 279 |
+
docs/ Documentation and assets
|
| 280 |
+
tests/ Test suite
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
---
|
| 284 |
+
|
| 285 |
+
## Configuration
|
| 286 |
+
|
| 287 |
+
GitPilot works with environment variables or the settings UI.
|
| 288 |
+
|
| 289 |
+
**Minimal setup** (Ollama, free, local):
|
| 290 |
+
|
| 291 |
+
```bash
|
| 292 |
+
# .env
|
| 293 |
+
GITPILOT_PROVIDER=ollama
|
| 294 |
+
OLLAMA_BASE_URL=http://localhost:11434
|
| 295 |
+
GITPILOT_OLLAMA_MODEL=llama3
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
**Cloud setup** (OpenAI):
|
| 299 |
+
|
| 300 |
+
```bash
|
| 301 |
+
# .env
|
| 302 |
+
GITPILOT_PROVIDER=openai
|
| 303 |
+
OPENAI_API_KEY=sk-...
|
| 304 |
+
GITPILOT_OPENAI_MODEL=gpt-4o-mini
|
| 305 |
+
```
|
| 306 |
+
|
| 307 |
+
**Cloud setup** (Claude):
|
| 308 |
+
|
| 309 |
+
```bash
|
| 310 |
+
# .env
|
| 311 |
+
GITPILOT_PROVIDER=claude
|
| 312 |
+
ANTHROPIC_API_KEY=sk-ant-...
|
| 313 |
+
GITPILOT_CLAUDE_MODEL=claude-sonnet-4-5
|
| 314 |
+
```
|
| 315 |
+
|
| 316 |
+
All settings can also be changed from the VS Code extension or web UI without editing files.
|
| 317 |
+
|
| 318 |
+
---
|
| 319 |
+
|
| 320 |
+
## API
|
| 321 |
+
|
| 322 |
+
GitPilot exposes a REST + WebSocket API:
|
| 323 |
+
|
| 324 |
+
| Endpoint | What it does |
|
| 325 |
+
|---|---|
|
| 326 |
+
| `GET /api/status` | Server health check |
|
| 327 |
+
| `POST /api/chat/send` | Send a message, get a response |
|
| 328 |
+
| `POST /api/v2/chat/stream` | Stream agent events (SSE) — accepts `permission_mode` |
|
| 329 |
+
| `WS /ws/v2/sessions/{id}` | Real-time WebSocket streaming |
|
| 330 |
+
| `POST /api/chat/plan` | Generate an execution plan |
|
| 331 |
+
| `POST /api/chat/execute` | Execute a plan |
|
| 332 |
+
| `GET /api/repos` | List connected repositories |
|
| 333 |
+
| `GET /api/sessions` | List chat sessions |
|
| 334 |
+
| `GET /api/permissions` | Current permission policy |
|
| 335 |
+
| `PUT /api/permissions/mode` | Set execution mode: `normal` / `auto` / `plan` |
|
| 336 |
+
| `POST /api/v2/approval/respond` | Approve or deny a tool execution request |
|
| 337 |
+
|
| 338 |
+
Full API docs at `http://localhost:8000/docs` (Swagger UI).
|
| 339 |
+
|
| 340 |
+
---
|
| 341 |
+
|
| 342 |
+
## Deployment
|
| 343 |
+
|
| 344 |
+
### Hugging Face Spaces
|
| 345 |
+
|
| 346 |
+
GitPilot runs on Hugging Face Spaces with OllaBridge (free):
|
| 347 |
+
|
| 348 |
+
```
|
| 349 |
+
Runtime: Docker
|
| 350 |
+
Port: 7860
|
| 351 |
+
Provider: OllaBridge (cloud Ollama)
|
| 352 |
+
```
|
| 353 |
+
|
| 354 |
+
### Docker Compose
|
| 355 |
+
|
| 356 |
+
```bash
|
| 357 |
+
docker compose up -d
|
| 358 |
+
# Backend: http://localhost:8000
|
| 359 |
+
# Frontend: http://localhost:3000
|
| 360 |
+
```
|
| 361 |
+
|
| 362 |
+
### Vercel
|
| 363 |
+
|
| 364 |
+
The frontend deploys to Vercel. Set `VITE_BACKEND_URL` to your backend.
|
| 365 |
+
|
| 366 |
+
---
|
| 367 |
+
|
| 368 |
+
## Contributing
|
| 369 |
+
|
| 370 |
+
```bash
|
| 371 |
+
# Standard install: runtime backend + frontend + MCP stack
|
| 372 |
+
make install
|
| 373 |
+
# WSL note: the Makefile defaults uv to UV_LINK_MODE=copy to avoid
|
| 374 |
+
# hardlink fallback warnings on /mnt/c checkouts. For best install speed,
|
| 375 |
+
# clone the repo inside the native WSL filesystem (for example ~/workspace).
|
| 376 |
+
|
| 377 |
+
# Developer/test tooling
|
| 378 |
+
make install-dev
|
| 379 |
+
make test
|
| 380 |
+
|
| 381 |
+
# Frontend only
|
| 382 |
+
cd frontend
|
| 383 |
+
npm ci
|
| 384 |
+
npm run dev
|
| 385 |
+
|
| 386 |
+
# VS Code Extension
|
| 387 |
+
cd extensions/vscode
|
| 388 |
+
npm install
|
| 389 |
+
make compile
|
| 390 |
+
# Press F5 in VS Code to launch debug host
|
| 391 |
+
```
|
| 392 |
+
|
| 393 |
+
---
|
| 394 |
+
|
| 395 |
+
## License
|
| 396 |
+
|
| 397 |
+
Apache License 2.0. See [LICENSE](LICENSE).
|
| 398 |
+
|
| 399 |
+
---
|
| 400 |
+
|
| 401 |
+
<div align="center">
|
| 402 |
+
|
| 403 |
+
**GitPilot** is made by [Ruslan Magana Vsevolodovna](https://github.com/ruslanmv)
|
| 404 |
+
|
| 405 |
+
[Star on GitHub](https://github.com/ruslanmv/gitpilot) • [Report a Bug](https://github.com/ruslanmv/gitpilot/issues) • [Request a Feature](https://github.com/ruslanmv/gitpilot/issues)
|
| 406 |
+
|
| 407 |
+
</div>
|
| 408 |
+
|
| 409 |
+
---
|
| 410 |
+
**MCP Context Forge integration** — GitPilot ships a default MCP stack (Forge + PostgreSQL / Milvus / Inspector servers) wired into the agents like Claude Code's built-ins; `make run` brings everything up. No Docker? Use `make run-bare` to start GitPilot core without MCP. See [docs/deploy/install-mcp.md](./docs/deploy/install-mcp.md) and [docs/deploy/production-mcp.md](./docs/deploy/production-mcp.md).
|
| 411 |
+
|
| 412 |
+
---
|
| 413 |
+
|
| 414 |
+
## What's New
|
| 415 |
+
|
| 416 |
+
> **Enterprise-ready foundation:** GitPilot now ships with safer defaults and production-grade controls, including thread-safe feature flags, strict typing, CI coverage enforcement, structured error handling, and a fast `gitpilot doctor` health check. All upgrades are additive, flag-gated, and disabled by default, so existing installations remain stable while teams can adopt new capabilities gradually.
|
| 417 |
+
|
| 418 |
+
> **Performance, onboarding, and release confidence:** GitPilot now improves runtime efficiency with prompt caching, lazy tool loading, context memoisation, SSE streaming, and safe model warmup. First-time setup is easier with `gitpilot init --wizard`, which creates configuration files atomically with rollback protection and no secret exposure. The platform also adds a stable public API, deprecation handling, MkDocs documentation, broken-link checks, SBOM generation, npm auditing, and Sigstore-based release signing.
|
frontend/.dockerignore
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Node
|
| 2 |
+
node_modules/
|
| 3 |
+
npm-debug.log*
|
| 4 |
+
yarn-debug.log*
|
| 5 |
+
yarn-error.log*
|
| 6 |
+
.pnpm-debug.log*
|
| 7 |
+
|
| 8 |
+
# Build
|
| 9 |
+
dist/
|
| 10 |
+
build/
|
| 11 |
+
|
| 12 |
+
# Environment
|
| 13 |
+
.env
|
| 14 |
+
.env.local
|
| 15 |
+
.env.development
|
| 16 |
+
.env.test
|
| 17 |
+
.env.production.local
|
| 18 |
+
|
| 19 |
+
# IDE
|
| 20 |
+
.vscode/
|
| 21 |
+
.idea/
|
| 22 |
+
*.swp
|
| 23 |
+
*.swo
|
| 24 |
+
*~
|
| 25 |
+
|
| 26 |
+
# OS
|
| 27 |
+
.DS_Store
|
| 28 |
+
Thumbs.db
|
| 29 |
+
|
| 30 |
+
# Git
|
| 31 |
+
.git
|
| 32 |
+
.gitignore
|
| 33 |
+
|
| 34 |
+
# Testing
|
| 35 |
+
coverage/
|
| 36 |
+
.nyc_output/
|
| 37 |
+
|
| 38 |
+
# Misc
|
| 39 |
+
*.log
|
frontend/App.jsx
ADDED
|
@@ -0,0 +1,1295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
+
import StartupScreen from "./components/StartupScreen.jsx";
|
| 3 |
+
import LoginPage from "./components/LoginPage.jsx";
|
| 4 |
+
import RepoSelector from "./components/RepoSelector.jsx";
|
| 5 |
+
import ProjectContextPanel from "./components/ProjectContextPanel.jsx";
|
| 6 |
+
import ChatPanel from "./components/ChatPanel.jsx";
|
| 7 |
+
import LlmSettings from "./components/LlmSettings.jsx";
|
| 8 |
+
import FlowViewer from "./components/FlowViewer.jsx";
|
| 9 |
+
import Footer from "./components/Footer.jsx";
|
| 10 |
+
import ProjectSettingsModal from "./components/ProjectSettingsModal.jsx";
|
| 11 |
+
import SessionSidebar from "./components/SessionSidebar.jsx";
|
| 12 |
+
import ContextBar from "./components/ContextBar.jsx";
|
| 13 |
+
import AddRepoModal from "./components/AddRepoModal.jsx";
|
| 14 |
+
import UserMenu from "./components/UserMenu.jsx";
|
| 15 |
+
import AboutModal from "./components/AboutModal.jsx";
|
| 16 |
+
import {
|
| 17 |
+
WorkspaceModesTab,
|
| 18 |
+
SecurityTab,
|
| 19 |
+
IntegrationsTab,
|
| 20 |
+
MCPServersTab,
|
| 21 |
+
SkillsTab,
|
| 22 |
+
SessionsTab,
|
| 23 |
+
AdvancedTab,
|
| 24 |
+
SandboxTab,
|
| 25 |
+
} from "./components/AdminTabs";
|
| 26 |
+
import { apiUrl, safeFetchJSON, fetchStatus } from "./utils/api.js";
|
| 27 |
+
import { initApp } from "./utils/appInit.js";
|
| 28 |
+
|
| 29 |
+
function makeRepoKey(repo) {
|
| 30 |
+
if (!repo) return null;
|
| 31 |
+
return repo.full_name || `${repo.owner}/${repo.name}`;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function uniq(arr) {
|
| 35 |
+
return Array.from(new Set((arr || []).filter(Boolean)));
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function getProviderLabel(status) {
|
| 39 |
+
if (!status) return "Checking...";
|
| 40 |
+
return (
|
| 41 |
+
status?.provider?.name ||
|
| 42 |
+
status?.provider_name ||
|
| 43 |
+
status?.provider?.provider ||
|
| 44 |
+
"Checking..."
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function getBackendVersion(status) {
|
| 49 |
+
if (!status) return "Checking...";
|
| 50 |
+
return status?.version || status?.app_version || "Checking...";
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export default function App() {
|
| 54 |
+
const frontendVersion = __APP_VERSION__ || "unknown";
|
| 55 |
+
|
| 56 |
+
// ---- Multi-repo context state ----
|
| 57 |
+
const [contextRepos, setContextRepos] = useState([]);
|
| 58 |
+
// Each entry: { repoKey: "owner/repo", repo: {...}, branch: "main" }
|
| 59 |
+
const [activeRepoKey, setActiveRepoKey] = useState(null);
|
| 60 |
+
const [addRepoOpen, setAddRepoOpen] = useState(false);
|
| 61 |
+
|
| 62 |
+
const [activePage, setActivePage] = useState("workspace");
|
| 63 |
+
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
| 64 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 65 |
+
const [userInfo, setUserInfo] = useState(null);
|
| 66 |
+
|
| 67 |
+
// Startup / enterprise loader state
|
| 68 |
+
const [startupPhase, setStartupPhase] = useState("booting");
|
| 69 |
+
const [startupStatusMessage, setStartupStatusMessage] = useState("Starting application...");
|
| 70 |
+
const [startupDetailMessage, setStartupDetailMessage] = useState(
|
| 71 |
+
"Initializing authentication, provider, and workspace context."
|
| 72 |
+
);
|
| 73 |
+
const [startupStatusSnapshot, setStartupStatusSnapshot] = useState(null);
|
| 74 |
+
|
| 75 |
+
// Repo + Session State Machine
|
| 76 |
+
const [repoStateByKey, setRepoStateByKey] = useState({});
|
| 77 |
+
const [toast, setToast] = useState(null);
|
| 78 |
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 79 |
+
const [aboutOpen, setAboutOpen] = useState(false);
|
| 80 |
+
const [adminTab, setAdminTab] = useState("overview");
|
| 81 |
+
const [adminStatus, setAdminStatus] = useState(null);
|
| 82 |
+
|
| 83 |
+
// Fetch admin status when overview tab is active
|
| 84 |
+
useEffect(() => {
|
| 85 |
+
if (activePage === "admin" && adminTab === "overview") {
|
| 86 |
+
fetchStatus()
|
| 87 |
+
.then((data) => setAdminStatus(data))
|
| 88 |
+
.catch(() => setAdminStatus(null));
|
| 89 |
+
}
|
| 90 |
+
}, [activePage, adminTab]);
|
| 91 |
+
|
| 92 |
+
// Claude-Code-on-Web: Session sidebar + Environment state
|
| 93 |
+
const [activeSessionId, setActiveSessionId] = useState(null);
|
| 94 |
+
const [activeEnvId, setActiveEnvId] = useState("default");
|
| 95 |
+
const [sessionRefreshNonce, setSessionRefreshNonce] = useState(0);
|
| 96 |
+
|
| 97 |
+
// Sidebar collapse state (persisted in localStorage)
|
| 98 |
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
| 99 |
+
try {
|
| 100 |
+
return localStorage.getItem("gitpilot_sidebar_collapsed") === "true";
|
| 101 |
+
} catch {
|
| 102 |
+
return false;
|
| 103 |
+
}
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
const toggleSidebar = useCallback(() => {
|
| 107 |
+
setSidebarCollapsed((prev) => {
|
| 108 |
+
const next = !prev;
|
| 109 |
+
try {
|
| 110 |
+
localStorage.setItem("gitpilot_sidebar_collapsed", String(next));
|
| 111 |
+
} catch {}
|
| 112 |
+
return next;
|
| 113 |
+
});
|
| 114 |
+
}, []);
|
| 115 |
+
|
| 116 |
+
// Keyboard shortcut: Cmd/Ctrl + B to toggle sidebar
|
| 117 |
+
useEffect(() => {
|
| 118 |
+
const handler = (e) => {
|
| 119 |
+
if ((e.metaKey || e.ctrlKey) && e.key === "b") {
|
| 120 |
+
e.preventDefault();
|
| 121 |
+
toggleSidebar();
|
| 122 |
+
}
|
| 123 |
+
};
|
| 124 |
+
window.addEventListener("keydown", handler);
|
| 125 |
+
return () => window.removeEventListener("keydown", handler);
|
| 126 |
+
}, [toggleSidebar]);
|
| 127 |
+
|
| 128 |
+
// ---- Derived `repo` — keeps all downstream consumers unchanged ----
|
| 129 |
+
const repo = useMemo(() => {
|
| 130 |
+
const entry = contextRepos.find((r) => r.repoKey === activeRepoKey);
|
| 131 |
+
return entry?.repo || null;
|
| 132 |
+
}, [contextRepos, activeRepoKey]);
|
| 133 |
+
|
| 134 |
+
const repoKey = activeRepoKey;
|
| 135 |
+
|
| 136 |
+
// Convenient selectors
|
| 137 |
+
const currentRepoState = repoKey ? repoStateByKey[repoKey] : null;
|
| 138 |
+
|
| 139 |
+
const defaultBranch = currentRepoState?.defaultBranch || repo?.default_branch || "main";
|
| 140 |
+
const currentBranch = currentRepoState?.currentBranch || defaultBranch;
|
| 141 |
+
const sessionBranches = currentRepoState?.sessionBranches || [];
|
| 142 |
+
const lastExecution = currentRepoState?.lastExecution || null;
|
| 143 |
+
const pulseNonce = currentRepoState?.pulseNonce || 0;
|
| 144 |
+
const chatByBranch = currentRepoState?.chatByBranch || {};
|
| 145 |
+
|
| 146 |
+
// ---------------------------------------------------------------------------
|
| 147 |
+
// Multi-repo context management
|
| 148 |
+
// ---------------------------------------------------------------------------
|
| 149 |
+
const addRepoToContext = useCallback((r) => {
|
| 150 |
+
const key = makeRepoKey(r);
|
| 151 |
+
if (!key) return;
|
| 152 |
+
|
| 153 |
+
setContextRepos((prev) => {
|
| 154 |
+
if (prev.some((e) => e.repoKey === key)) {
|
| 155 |
+
setActiveRepoKey(key);
|
| 156 |
+
return prev;
|
| 157 |
+
}
|
| 158 |
+
const entry = { repoKey: key, repo: r, branch: r.default_branch || "main" };
|
| 159 |
+
return [...prev, entry];
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
setActiveRepoKey(key);
|
| 163 |
+
setAddRepoOpen(false);
|
| 164 |
+
}, []);
|
| 165 |
+
|
| 166 |
+
const removeRepoFromContext = useCallback((key) => {
|
| 167 |
+
setContextRepos((prev) => {
|
| 168 |
+
const next = prev.filter((e) => e.repoKey !== key);
|
| 169 |
+
setActiveRepoKey((curActive) => {
|
| 170 |
+
if (curActive === key) {
|
| 171 |
+
return next.length > 0 ? next[0].repoKey : null;
|
| 172 |
+
}
|
| 173 |
+
return curActive;
|
| 174 |
+
});
|
| 175 |
+
return next;
|
| 176 |
+
});
|
| 177 |
+
}, []);
|
| 178 |
+
|
| 179 |
+
const clearAllContext = useCallback(() => {
|
| 180 |
+
setContextRepos([]);
|
| 181 |
+
setActiveRepoKey(null);
|
| 182 |
+
}, []);
|
| 183 |
+
|
| 184 |
+
const handleContextBranchChange = useCallback((targetRepoKey, newBranch) => {
|
| 185 |
+
setContextRepos((prev) =>
|
| 186 |
+
prev.map((e) =>
|
| 187 |
+
e.repoKey === targetRepoKey ? { ...e, branch: newBranch } : e
|
| 188 |
+
)
|
| 189 |
+
);
|
| 190 |
+
|
| 191 |
+
setRepoStateByKey((prev) => {
|
| 192 |
+
const cur = prev[targetRepoKey];
|
| 193 |
+
if (!cur) return prev;
|
| 194 |
+
return {
|
| 195 |
+
...prev,
|
| 196 |
+
[targetRepoKey]: { ...cur, currentBranch: newBranch },
|
| 197 |
+
};
|
| 198 |
+
});
|
| 199 |
+
}, []);
|
| 200 |
+
|
| 201 |
+
// Init / reconcile repo state when active repo changes
|
| 202 |
+
useEffect(() => {
|
| 203 |
+
if (!repoKey || !repo) return;
|
| 204 |
+
|
| 205 |
+
setRepoStateByKey((prev) => {
|
| 206 |
+
const existing = prev[repoKey];
|
| 207 |
+
const d = repo.default_branch || "main";
|
| 208 |
+
|
| 209 |
+
if (!existing) {
|
| 210 |
+
return {
|
| 211 |
+
...prev,
|
| 212 |
+
[repoKey]: {
|
| 213 |
+
defaultBranch: d,
|
| 214 |
+
currentBranch: d,
|
| 215 |
+
sessionBranches: [],
|
| 216 |
+
lastExecution: null,
|
| 217 |
+
pulseNonce: 0,
|
| 218 |
+
chatByBranch: {
|
| 219 |
+
[d]: { messages: [], plan: null },
|
| 220 |
+
},
|
| 221 |
+
},
|
| 222 |
+
};
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
const next = { ...existing };
|
| 226 |
+
next.defaultBranch = d;
|
| 227 |
+
|
| 228 |
+
if (!next.chatByBranch?.[d]) {
|
| 229 |
+
next.chatByBranch = {
|
| 230 |
+
...(next.chatByBranch || {}),
|
| 231 |
+
[d]: { messages: [], plan: null },
|
| 232 |
+
};
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
if (!next.currentBranch) next.currentBranch = d;
|
| 236 |
+
|
| 237 |
+
return { ...prev, [repoKey]: next };
|
| 238 |
+
});
|
| 239 |
+
}, [repoKey, repo?.id, repo?.default_branch]);
|
| 240 |
+
|
| 241 |
+
const showToast = (title, message) => {
|
| 242 |
+
setToast({ title, message });
|
| 243 |
+
window.setTimeout(() => setToast(null), 5000);
|
| 244 |
+
};
|
| 245 |
+
|
| 246 |
+
// ---------------------------------------------------------------------------
|
| 247 |
+
// Session management — every chat is backed by a Session (Claude Code parity)
|
| 248 |
+
// ---------------------------------------------------------------------------
|
| 249 |
+
|
| 250 |
+
const _creatingSessionRef = useRef(false);
|
| 251 |
+
|
| 252 |
+
const [chatBySession, setChatBySession] = useState({});
|
| 253 |
+
|
| 254 |
+
const ensureSession = useCallback(
|
| 255 |
+
async (sessionName, seedMessages) => {
|
| 256 |
+
if (activeSessionId) return activeSessionId;
|
| 257 |
+
if (!repo) return null;
|
| 258 |
+
if (_creatingSessionRef.current) return null;
|
| 259 |
+
_creatingSessionRef.current = true;
|
| 260 |
+
|
| 261 |
+
try {
|
| 262 |
+
const token = localStorage.getItem("github_token");
|
| 263 |
+
const headers = {
|
| 264 |
+
"Content-Type": "application/json",
|
| 265 |
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
| 266 |
+
};
|
| 267 |
+
|
| 268 |
+
const res = await fetch("/api/sessions", {
|
| 269 |
+
method: "POST",
|
| 270 |
+
headers,
|
| 271 |
+
body: JSON.stringify({
|
| 272 |
+
repo_full_name: repoKey,
|
| 273 |
+
branch: currentBranch,
|
| 274 |
+
name: sessionName || undefined,
|
| 275 |
+
repos: contextRepos.map((e) => ({
|
| 276 |
+
full_name: e.repoKey,
|
| 277 |
+
branch: e.branch,
|
| 278 |
+
mode: e.repoKey === activeRepoKey ? "write" : "read",
|
| 279 |
+
})),
|
| 280 |
+
active_repo: activeRepoKey,
|
| 281 |
+
}),
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
+
if (!res.ok) return null;
|
| 285 |
+
const data = await res.json();
|
| 286 |
+
const newId = data.session_id;
|
| 287 |
+
|
| 288 |
+
if (seedMessages && seedMessages.length > 0) {
|
| 289 |
+
setChatBySession((prev) => ({
|
| 290 |
+
...prev,
|
| 291 |
+
[newId]: { messages: seedMessages, plan: null },
|
| 292 |
+
}));
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
setActiveSessionId(newId);
|
| 296 |
+
setSessionRefreshNonce((n) => n + 1);
|
| 297 |
+
return newId;
|
| 298 |
+
} catch (err) {
|
| 299 |
+
console.warn("Failed to create session:", err);
|
| 300 |
+
return null;
|
| 301 |
+
} finally {
|
| 302 |
+
_creatingSessionRef.current = false;
|
| 303 |
+
}
|
| 304 |
+
},
|
| 305 |
+
[activeSessionId, repo, repoKey, currentBranch, contextRepos, activeRepoKey]
|
| 306 |
+
);
|
| 307 |
+
|
| 308 |
+
const handleNewSession = async () => {
|
| 309 |
+
setActiveSessionId(null);
|
| 310 |
+
|
| 311 |
+
try {
|
| 312 |
+
const token = localStorage.getItem("github_token");
|
| 313 |
+
const headers = {
|
| 314 |
+
"Content-Type": "application/json",
|
| 315 |
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
| 316 |
+
};
|
| 317 |
+
|
| 318 |
+
const res = await fetch("/api/sessions", {
|
| 319 |
+
method: "POST",
|
| 320 |
+
headers,
|
| 321 |
+
body: JSON.stringify({
|
| 322 |
+
repo_full_name: repoKey,
|
| 323 |
+
branch: currentBranch,
|
| 324 |
+
repos: contextRepos.map((e) => ({
|
| 325 |
+
full_name: e.repoKey,
|
| 326 |
+
branch: e.branch,
|
| 327 |
+
mode: e.repoKey === activeRepoKey ? "write" : "read",
|
| 328 |
+
})),
|
| 329 |
+
active_repo: activeRepoKey,
|
| 330 |
+
}),
|
| 331 |
+
});
|
| 332 |
+
|
| 333 |
+
if (!res.ok) return;
|
| 334 |
+
const data = await res.json();
|
| 335 |
+
setActiveSessionId(data.session_id);
|
| 336 |
+
setSessionRefreshNonce((n) => n + 1);
|
| 337 |
+
showToast("Session Created", "New session started.");
|
| 338 |
+
} catch (err) {
|
| 339 |
+
console.warn("Failed to create session:", err);
|
| 340 |
+
}
|
| 341 |
+
};
|
| 342 |
+
|
| 343 |
+
/**
|
| 344 |
+
* Convert a backend Message object to the frontend chat UI shape.
|
| 345 |
+
* Backend: { role: "user|assistant|system", content: "...", timestamp, metadata }
|
| 346 |
+
* Frontend: { from: "user|ai", role: "user|assistant|system", content, answer, ... }
|
| 347 |
+
*/
|
| 348 |
+
const normalizeBackendMessage = (m) => {
|
| 349 |
+
const role = m.role || "assistant";
|
| 350 |
+
const content = m.content || "";
|
| 351 |
+
if (role === "user") {
|
| 352 |
+
return { from: "user", role: "user", content, text: content };
|
| 353 |
+
}
|
| 354 |
+
if (role === "system") {
|
| 355 |
+
return { from: "ai", role: "system", content };
|
| 356 |
+
}
|
| 357 |
+
// assistant
|
| 358 |
+
return {
|
| 359 |
+
from: "ai",
|
| 360 |
+
role: "assistant",
|
| 361 |
+
content,
|
| 362 |
+
answer: content,
|
| 363 |
+
// Preserve any structured metadata the backend stored (plan, diff, etc.)
|
| 364 |
+
...(m.metadata && typeof m.metadata === "object" ? m.metadata : {}),
|
| 365 |
+
};
|
| 366 |
+
};
|
| 367 |
+
|
| 368 |
+
/**
|
| 369 |
+
* Fetch persisted messages for a session from the backend.
|
| 370 |
+
* Returns an array of normalized frontend messages (ready for ChatPanel),
|
| 371 |
+
* or an empty array on failure.
|
| 372 |
+
*/
|
| 373 |
+
const fetchSessionMessages = useCallback(async (sessionId) => {
|
| 374 |
+
if (!sessionId) return [];
|
| 375 |
+
try {
|
| 376 |
+
const token = localStorage.getItem("github_token");
|
| 377 |
+
const headers = { "Content-Type": "application/json" };
|
| 378 |
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
| 379 |
+
|
| 380 |
+
const res = await fetch(apiUrl(`/api/sessions/${sessionId}/messages`), {
|
| 381 |
+
headers,
|
| 382 |
+
});
|
| 383 |
+
if (!res.ok) {
|
| 384 |
+
console.warn(`[fetchSessionMessages] ${res.status} for ${sessionId}`);
|
| 385 |
+
return [];
|
| 386 |
+
}
|
| 387 |
+
const data = await res.json();
|
| 388 |
+
const backendMessages = Array.isArray(data.messages) ? data.messages : [];
|
| 389 |
+
return backendMessages.map(normalizeBackendMessage);
|
| 390 |
+
} catch (err) {
|
| 391 |
+
console.warn(`[fetchSessionMessages] Failed to fetch ${sessionId}:`, err);
|
| 392 |
+
return [];
|
| 393 |
+
}
|
| 394 |
+
}, []);
|
| 395 |
+
|
| 396 |
+
/**
|
| 397 |
+
* Handle click on a session in the sidebar.
|
| 398 |
+
*
|
| 399 |
+
* Critical ordering: we must hydrate chatBySession BEFORE setting
|
| 400 |
+
* activeSessionId, because ChatPanel's session-sync useEffect reads
|
| 401 |
+
* sessionChatState only when sessionId changes (it does NOT depend on
|
| 402 |
+
* chatBySession to avoid prop/state loops). If we set activeSessionId
|
| 403 |
+
* first, ChatPanel would see an empty messages array, then our async
|
| 404 |
+
* hydration would complete but ChatPanel wouldn't re-sync.
|
| 405 |
+
*/
|
| 406 |
+
// Resolve the branch we should jump to when reopening a session.
|
| 407 |
+
// Preference order:
|
| 408 |
+
// 1. session.repos[i].branch for the active_repo (multi-repo)
|
| 409 |
+
// 2. session.branch (legacy single-repo field)
|
| 410 |
+
// Returns ``null`` when nothing is recorded.
|
| 411 |
+
const resolveSessionBranch = (session) => {
|
| 412 |
+
if (!session) return null;
|
| 413 |
+
if (Array.isArray(session.repos) && session.repos.length > 0) {
|
| 414 |
+
const target =
|
| 415 |
+
session.repos.find(
|
| 416 |
+
(r) => session.active_repo && r?.full_name === session.active_repo,
|
| 417 |
+
) || session.repos[0];
|
| 418 |
+
if (target?.branch) return target.branch;
|
| 419 |
+
}
|
| 420 |
+
return session.branch || null;
|
| 421 |
+
};
|
| 422 |
+
|
| 423 |
+
// Probe whether a branch still exists on GitHub. We deliberately
|
| 424 |
+
// reuse the existing tree endpoint instead of adding a new one — a
|
| 425 |
+
// 200 means the ref resolves, anything else (most importantly 404)
|
| 426 |
+
// means the branch is gone or otherwise unreachable. Failure
|
| 427 |
+
// degrades to "branch unknown" so a transient network blip falls
|
| 428 |
+
// back gracefully rather than misleading the user.
|
| 429 |
+
const probeBranchExists = async (repoFullName, branch) => {
|
| 430 |
+
if (!repoFullName || !branch) return false;
|
| 431 |
+
try {
|
| 432 |
+
const token = localStorage.getItem("github_token");
|
| 433 |
+
const headers = {};
|
| 434 |
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
| 435 |
+
const res = await fetch(
|
| 436 |
+
apiUrl(
|
| 437 |
+
`/api/repos/${repoFullName}/tree?ref=${encodeURIComponent(branch)}`,
|
| 438 |
+
),
|
| 439 |
+
{ headers },
|
| 440 |
+
);
|
| 441 |
+
return res.ok;
|
| 442 |
+
} catch {
|
| 443 |
+
return false;
|
| 444 |
+
}
|
| 445 |
+
};
|
| 446 |
+
|
| 447 |
+
const handleSelectSession = useCallback(async (session) => {
|
| 448 |
+
// 1. Fetch persisted messages first
|
| 449 |
+
const messages = await fetchSessionMessages(session.id);
|
| 450 |
+
|
| 451 |
+
// 2. Seed the chat cache (ChatPanel will read this via sessionChatState)
|
| 452 |
+
setChatBySession((prev) => ({
|
| 453 |
+
...prev,
|
| 454 |
+
[session.id]: {
|
| 455 |
+
...(prev[session.id] || { plan: null }),
|
| 456 |
+
messages,
|
| 457 |
+
},
|
| 458 |
+
}));
|
| 459 |
+
|
| 460 |
+
// 3. NOW activate the session — ChatPanel's sync effect will read
|
| 461 |
+
// the hydrated messages from chatBySession[session.id]
|
| 462 |
+
setActiveSessionId(session.id);
|
| 463 |
+
|
| 464 |
+
// 4. Jump to the branch this session last published to, but verify
|
| 465 |
+
// it still exists on GitHub first. When the branch was deleted
|
| 466 |
+
// (rebased away, merged-and-pruned, …) fall back to the
|
| 467 |
+
// repository's default branch and tell the user what happened —
|
| 468 |
+
// silently landing on the default would mask data loss.
|
| 469 |
+
const target = resolveSessionBranch(session);
|
| 470 |
+
if (target && target !== currentBranch) {
|
| 471 |
+
const repoFullName =
|
| 472 |
+
session.repo ||
|
| 473 |
+
(Array.isArray(session.repos) && session.repos[0]?.full_name);
|
| 474 |
+
const exists = await probeBranchExists(repoFullName, target);
|
| 475 |
+
if (exists) {
|
| 476 |
+
handleBranchChange(target);
|
| 477 |
+
} else {
|
| 478 |
+
const fallback = defaultBranch || "main";
|
| 479 |
+
showToast(
|
| 480 |
+
"Branch not found",
|
| 481 |
+
`'${target}' was not found on GitHub. Switched to ${fallback}.`,
|
| 482 |
+
);
|
| 483 |
+
if (fallback !== currentBranch) handleBranchChange(fallback);
|
| 484 |
+
}
|
| 485 |
+
}
|
| 486 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 487 |
+
}, [fetchSessionMessages, currentBranch, defaultBranch]);
|
| 488 |
+
|
| 489 |
+
const handleDeleteSession = useCallback(
|
| 490 |
+
(deletedId) => {
|
| 491 |
+
if (deletedId === activeSessionId) {
|
| 492 |
+
setActiveSessionId(null);
|
| 493 |
+
|
| 494 |
+
setChatBySession((prev) => {
|
| 495 |
+
const next = { ...prev };
|
| 496 |
+
delete next[deletedId];
|
| 497 |
+
return next;
|
| 498 |
+
});
|
| 499 |
+
|
| 500 |
+
if (repoKey) {
|
| 501 |
+
setRepoStateByKey((prev) => {
|
| 502 |
+
const cur = prev[repoKey];
|
| 503 |
+
if (!cur) return prev;
|
| 504 |
+
const branchKey = cur.currentBranch || cur.defaultBranch || defaultBranch;
|
| 505 |
+
return {
|
| 506 |
+
...prev,
|
| 507 |
+
[repoKey]: {
|
| 508 |
+
...cur,
|
| 509 |
+
chatByBranch: {
|
| 510 |
+
...(cur.chatByBranch || {}),
|
| 511 |
+
[branchKey]: { messages: [], plan: null },
|
| 512 |
+
},
|
| 513 |
+
},
|
| 514 |
+
};
|
| 515 |
+
});
|
| 516 |
+
}
|
| 517 |
+
}
|
| 518 |
+
},
|
| 519 |
+
[activeSessionId, repoKey, defaultBranch]
|
| 520 |
+
);
|
| 521 |
+
|
| 522 |
+
// ---------------------------------------------------------------------------
|
| 523 |
+
// Chat persistence helpers
|
| 524 |
+
// ---------------------------------------------------------------------------
|
| 525 |
+
const updateChatForCurrentBranch = (patch) => {
|
| 526 |
+
if (!repoKey) return;
|
| 527 |
+
|
| 528 |
+
setRepoStateByKey((prev) => {
|
| 529 |
+
const cur = prev[repoKey];
|
| 530 |
+
if (!cur) return prev;
|
| 531 |
+
|
| 532 |
+
const branchKey = cur.currentBranch || cur.defaultBranch || defaultBranch;
|
| 533 |
+
|
| 534 |
+
const existing = cur.chatByBranch?.[branchKey] || {
|
| 535 |
+
messages: [],
|
| 536 |
+
plan: null,
|
| 537 |
+
};
|
| 538 |
+
|
| 539 |
+
return {
|
| 540 |
+
...prev,
|
| 541 |
+
[repoKey]: {
|
| 542 |
+
...cur,
|
| 543 |
+
chatByBranch: {
|
| 544 |
+
...(cur.chatByBranch || {}),
|
| 545 |
+
[branchKey]: { ...existing, ...patch },
|
| 546 |
+
},
|
| 547 |
+
},
|
| 548 |
+
};
|
| 549 |
+
});
|
| 550 |
+
};
|
| 551 |
+
|
| 552 |
+
const currentChatState = useMemo(() => {
|
| 553 |
+
const b = currentBranch || defaultBranch;
|
| 554 |
+
return chatByBranch[b] || { messages: [], plan: null };
|
| 555 |
+
}, [chatByBranch, currentBranch, defaultBranch]);
|
| 556 |
+
|
| 557 |
+
const sessionChatState = useMemo(() => {
|
| 558 |
+
if (!activeSessionId) {
|
| 559 |
+
return currentChatState;
|
| 560 |
+
}
|
| 561 |
+
return chatBySession[activeSessionId] || { messages: [], plan: null };
|
| 562 |
+
}, [activeSessionId, chatBySession, currentChatState]);
|
| 563 |
+
|
| 564 |
+
const updateSessionChat = (patch) => {
|
| 565 |
+
if (activeSessionId) {
|
| 566 |
+
setChatBySession((prev) => ({
|
| 567 |
+
...prev,
|
| 568 |
+
[activeSessionId]: {
|
| 569 |
+
...(prev[activeSessionId] || { messages: [], plan: null }),
|
| 570 |
+
...patch,
|
| 571 |
+
},
|
| 572 |
+
}));
|
| 573 |
+
} else {
|
| 574 |
+
updateChatForCurrentBranch(patch);
|
| 575 |
+
}
|
| 576 |
+
};
|
| 577 |
+
|
| 578 |
+
// ---------------------------------------------------------------------------
|
| 579 |
+
// Branch change (manual — for active repo)
|
| 580 |
+
// ---------------------------------------------------------------------------
|
| 581 |
+
const handleBranchChange = (nextBranch) => {
|
| 582 |
+
if (!repoKey) return;
|
| 583 |
+
if (!nextBranch || nextBranch === currentBranch) return;
|
| 584 |
+
|
| 585 |
+
setRepoStateByKey((prev) => {
|
| 586 |
+
const cur = prev[repoKey];
|
| 587 |
+
if (!cur) return prev;
|
| 588 |
+
|
| 589 |
+
const nextState = { ...cur, currentBranch: nextBranch };
|
| 590 |
+
|
| 591 |
+
if (nextBranch === cur.defaultBranch) {
|
| 592 |
+
nextState.chatByBranch = {
|
| 593 |
+
...nextState.chatByBranch,
|
| 594 |
+
[nextBranch]: { messages: [], plan: null },
|
| 595 |
+
};
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
return { ...prev, [repoKey]: nextState };
|
| 599 |
+
});
|
| 600 |
+
|
| 601 |
+
setContextRepos((prev) =>
|
| 602 |
+
prev.map((e) =>
|
| 603 |
+
e.repoKey === repoKey ? { ...e, branch: nextBranch } : e
|
| 604 |
+
)
|
| 605 |
+
);
|
| 606 |
+
|
| 607 |
+
if (nextBranch === defaultBranch) {
|
| 608 |
+
showToast("New Session", `Switched to ${defaultBranch}. Chat cleared.`);
|
| 609 |
+
} else {
|
| 610 |
+
showToast("Context Switched", `Now viewing ${nextBranch}.`);
|
| 611 |
+
}
|
| 612 |
+
};
|
| 613 |
+
|
| 614 |
+
// ---------------------------------------------------------------------------
|
| 615 |
+
// Execution complete
|
| 616 |
+
// ---------------------------------------------------------------------------
|
| 617 |
+
const handleExecutionComplete = ({
|
| 618 |
+
branch,
|
| 619 |
+
mode,
|
| 620 |
+
commit_url,
|
| 621 |
+
completionMsg,
|
| 622 |
+
sourceBranch,
|
| 623 |
+
}) => {
|
| 624 |
+
if (!repoKey || !branch) return;
|
| 625 |
+
|
| 626 |
+
// Clear the session-keyed chat cache's ``plan`` AND append the
|
| 627 |
+
// completion message synchronously, before any branch change can
|
| 628 |
+
// trigger ChatPanel's session-sync effect. Two bugs need to be
|
| 629 |
+
// fixed in the same write:
|
| 630 |
+
//
|
| 631 |
+
// 1. Stale plan: without clearing, the sync effect re-reads the
|
| 632 |
+
// old approved plan and restores the Approve & execute / Reject
|
| 633 |
+
// plan buttons, enabling accidental double-execution.
|
| 634 |
+
//
|
| 635 |
+
// 2. Wiped completion: in hard-switch mode the sync effect runs
|
| 636 |
+
// BEFORE the persistence effect (declared earlier in
|
| 637 |
+
// ChatPanel), so it overwrites local ``messages`` with
|
| 638 |
+
// ``sessionChatState.messages`` — which doesn't yet contain
|
| 639 |
+
// completionMsg. The user's "Answer / Execution Log" block
|
| 640 |
+
// then vanishes from the session view.
|
| 641 |
+
//
|
| 642 |
+
// By appending normalizedCompletion here, sessionChatState already
|
| 643 |
+
// carries the completion when the sync effect reads it. No
|
| 644 |
+
// duplicate is introduced: local ``messages`` already has the same
|
| 645 |
+
// entry, so the subsequent persistence pass is a no-op write.
|
| 646 |
+
if (activeSessionId) {
|
| 647 |
+
const normalizedCompletion =
|
| 648 |
+
completionMsg &&
|
| 649 |
+
(completionMsg.answer || completionMsg.content || completionMsg.executionLog)
|
| 650 |
+
? {
|
| 651 |
+
from: completionMsg.from || "ai",
|
| 652 |
+
role: completionMsg.role || "assistant",
|
| 653 |
+
answer: completionMsg.answer,
|
| 654 |
+
content: completionMsg.content,
|
| 655 |
+
executionLog: completionMsg.executionLog,
|
| 656 |
+
diff: completionMsg.diff,
|
| 657 |
+
}
|
| 658 |
+
: null;
|
| 659 |
+
setChatBySession((prev) => {
|
| 660 |
+
const existing = prev[activeSessionId];
|
| 661 |
+
if (!existing) return prev;
|
| 662 |
+
const noPlanChange = existing.plan == null;
|
| 663 |
+
if (noPlanChange && !normalizedCompletion) return prev;
|
| 664 |
+
return {
|
| 665 |
+
...prev,
|
| 666 |
+
[activeSessionId]: {
|
| 667 |
+
...existing,
|
| 668 |
+
messages: normalizedCompletion
|
| 669 |
+
? [...(existing.messages || []), normalizedCompletion]
|
| 670 |
+
: existing.messages,
|
| 671 |
+
plan: null,
|
| 672 |
+
},
|
| 673 |
+
};
|
| 674 |
+
});
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
setRepoStateByKey((prev) => {
|
| 678 |
+
const cur =
|
| 679 |
+
prev[repoKey] || {
|
| 680 |
+
defaultBranch,
|
| 681 |
+
currentBranch: defaultBranch,
|
| 682 |
+
sessionBranches: [],
|
| 683 |
+
lastExecution: null,
|
| 684 |
+
pulseNonce: 0,
|
| 685 |
+
chatByBranch: { [defaultBranch]: { messages: [], plan: null } },
|
| 686 |
+
};
|
| 687 |
+
|
| 688 |
+
const next = { ...cur };
|
| 689 |
+
next.lastExecution = { mode, branch, ts: Date.now() };
|
| 690 |
+
|
| 691 |
+
if (!next.chatByBranch) next.chatByBranch = {};
|
| 692 |
+
|
| 693 |
+
const prevBranchKey =
|
| 694 |
+
sourceBranch || cur.currentBranch || cur.defaultBranch || defaultBranch;
|
| 695 |
+
|
| 696 |
+
const successSystemMsg = {
|
| 697 |
+
role: "system",
|
| 698 |
+
isSuccess: true,
|
| 699 |
+
link: commit_url,
|
| 700 |
+
content:
|
| 701 |
+
mode === "hard-switch"
|
| 702 |
+
? `🌱 **Session Started:** Created branch \`${branch}\`.`
|
| 703 |
+
: `✅ **Update Published:** Commits pushed to \`${branch}\`.`,
|
| 704 |
+
};
|
| 705 |
+
|
| 706 |
+
const normalizedCompletion =
|
| 707 |
+
completionMsg &&
|
| 708 |
+
(completionMsg.answer || completionMsg.content || completionMsg.executionLog)
|
| 709 |
+
? {
|
| 710 |
+
from: completionMsg.from || "ai",
|
| 711 |
+
role: completionMsg.role || "assistant",
|
| 712 |
+
answer: completionMsg.answer,
|
| 713 |
+
content: completionMsg.content,
|
| 714 |
+
executionLog: completionMsg.executionLog,
|
| 715 |
+
}
|
| 716 |
+
: null;
|
| 717 |
+
|
| 718 |
+
if (mode === "hard-switch") {
|
| 719 |
+
next.sessionBranches = uniq([...(next.sessionBranches || []), branch]);
|
| 720 |
+
next.currentBranch = branch;
|
| 721 |
+
next.pulseNonce = (next.pulseNonce || 0) + 1;
|
| 722 |
+
|
| 723 |
+
const existingTargetChat = next.chatByBranch[branch];
|
| 724 |
+
const isExistingSession =
|
| 725 |
+
existingTargetChat && (existingTargetChat.messages || []).length > 0;
|
| 726 |
+
|
| 727 |
+
if (isExistingSession) {
|
| 728 |
+
const appended = [
|
| 729 |
+
...(existingTargetChat.messages || []),
|
| 730 |
+
...(normalizedCompletion ? [normalizedCompletion] : []),
|
| 731 |
+
successSystemMsg,
|
| 732 |
+
];
|
| 733 |
+
|
| 734 |
+
next.chatByBranch[branch] = {
|
| 735 |
+
...existingTargetChat,
|
| 736 |
+
messages: appended,
|
| 737 |
+
plan: null,
|
| 738 |
+
};
|
| 739 |
+
} else {
|
| 740 |
+
const prevChat =
|
| 741 |
+
(cur.chatByBranch && cur.chatByBranch[prevBranchKey]) || {
|
| 742 |
+
messages: [],
|
| 743 |
+
plan: null,
|
| 744 |
+
};
|
| 745 |
+
|
| 746 |
+
next.chatByBranch[branch] = {
|
| 747 |
+
messages: [
|
| 748 |
+
...(prevChat.messages || []),
|
| 749 |
+
...(normalizedCompletion ? [normalizedCompletion] : []),
|
| 750 |
+
successSystemMsg,
|
| 751 |
+
],
|
| 752 |
+
plan: null,
|
| 753 |
+
};
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
if (!next.chatByBranch[next.defaultBranch]) {
|
| 757 |
+
next.chatByBranch[next.defaultBranch] = { messages: [], plan: null };
|
| 758 |
+
}
|
| 759 |
+
} else if (mode === "sticky") {
|
| 760 |
+
next.currentBranch = cur.currentBranch || branch;
|
| 761 |
+
|
| 762 |
+
const targetChat = next.chatByBranch[branch] || { messages: [], plan: null };
|
| 763 |
+
|
| 764 |
+
next.chatByBranch[branch] = {
|
| 765 |
+
messages: [
|
| 766 |
+
...(targetChat.messages || []),
|
| 767 |
+
...(normalizedCompletion ? [normalizedCompletion] : []),
|
| 768 |
+
successSystemMsg,
|
| 769 |
+
],
|
| 770 |
+
plan: null,
|
| 771 |
+
};
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
return { ...prev, [repoKey]: next };
|
| 775 |
+
});
|
| 776 |
+
|
| 777 |
+
if (mode === "hard-switch") {
|
| 778 |
+
showToast("Context Switched", `Active on ${branch}.`);
|
| 779 |
+
} else {
|
| 780 |
+
showToast("Changes Committed", `Updated ${branch}.`);
|
| 781 |
+
}
|
| 782 |
+
};
|
| 783 |
+
|
| 784 |
+
// ---------------------------------------------------------------------------
|
| 785 |
+
// Auth & startup render
|
| 786 |
+
// ---------------------------------------------------------------------------
|
| 787 |
+
useEffect(() => {
|
| 788 |
+
checkAuthentication();
|
| 789 |
+
}, []);
|
| 790 |
+
|
| 791 |
+
const checkAuthentication = async () => {
|
| 792 |
+
setStartupPhase("booting");
|
| 793 |
+
setStartupStatusMessage("Starting application...");
|
| 794 |
+
setStartupDetailMessage(
|
| 795 |
+
"Initializing authentication, provider, and workspace context."
|
| 796 |
+
);
|
| 797 |
+
|
| 798 |
+
try {
|
| 799 |
+
setStartupPhase("checking-backend");
|
| 800 |
+
setStartupStatusMessage("Connecting to backend...");
|
| 801 |
+
setStartupDetailMessage(
|
| 802 |
+
"Preparing your workspace. First launch may take a few seconds."
|
| 803 |
+
);
|
| 804 |
+
|
| 805 |
+
// Single-source-of-truth init: combines /api/status + /api/auth/status
|
| 806 |
+
// in one request. Runs exactly once per page load (StrictMode-safe).
|
| 807 |
+
const initResult = await initApp();
|
| 808 |
+
const status = initResult.status;
|
| 809 |
+
if (status) {
|
| 810 |
+
setStartupStatusSnapshot(status);
|
| 811 |
+
setAdminStatus(status);
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
const token = localStorage.getItem("github_token");
|
| 815 |
+
const user = localStorage.getItem("github_user");
|
| 816 |
+
|
| 817 |
+
if (token && user) {
|
| 818 |
+
setStartupPhase("validating-auth");
|
| 819 |
+
setStartupStatusMessage("Validating authentication...");
|
| 820 |
+
setStartupDetailMessage(
|
| 821 |
+
"Restoring your GitHub session and confirming access."
|
| 822 |
+
);
|
| 823 |
+
|
| 824 |
+
try {
|
| 825 |
+
const data = await safeFetchJSON(apiUrl("/api/auth/validate"), {
|
| 826 |
+
method: "POST",
|
| 827 |
+
headers: { "Content-Type": "application/json" },
|
| 828 |
+
body: JSON.stringify({ access_token: token }),
|
| 829 |
+
timeout: 20000, // 20s — first-load GitHub API validation can be slow
|
| 830 |
+
});
|
| 831 |
+
|
| 832 |
+
if (data.authenticated) {
|
| 833 |
+
setStartupPhase("restoring-session");
|
| 834 |
+
setStartupStatusMessage("Restoring workspace...");
|
| 835 |
+
setStartupDetailMessage(
|
| 836 |
+
"Loading user profile, reconnecting provider state, and preparing the workspace."
|
| 837 |
+
);
|
| 838 |
+
|
| 839 |
+
setIsAuthenticated(true);
|
| 840 |
+
setUserInfo(JSON.parse(user));
|
| 841 |
+
setIsLoading(false);
|
| 842 |
+
return;
|
| 843 |
+
}
|
| 844 |
+
} catch (err) {
|
| 845 |
+
console.error(err);
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
localStorage.removeItem("github_token");
|
| 849 |
+
localStorage.removeItem("github_user");
|
| 850 |
+
}
|
| 851 |
+
|
| 852 |
+
setStartupPhase("ready");
|
| 853 |
+
setStartupStatusMessage("Preparing sign-in...");
|
| 854 |
+
setStartupDetailMessage(
|
| 855 |
+
"GitPilot is ready. Please authenticate to continue."
|
| 856 |
+
);
|
| 857 |
+
|
| 858 |
+
setIsAuthenticated(false);
|
| 859 |
+
setIsLoading(false);
|
| 860 |
+
} catch (err) {
|
| 861 |
+
console.error(err);
|
| 862 |
+
setStartupPhase("fallback");
|
| 863 |
+
setStartupStatusMessage("Starting application...");
|
| 864 |
+
setStartupDetailMessage(
|
| 865 |
+
"Continuing with basic startup while backend status is still loading."
|
| 866 |
+
);
|
| 867 |
+
setIsAuthenticated(false);
|
| 868 |
+
setIsLoading(false);
|
| 869 |
+
}
|
| 870 |
+
};
|
| 871 |
+
|
| 872 |
+
const handleAuthenticated = (session) => {
|
| 873 |
+
setIsAuthenticated(true);
|
| 874 |
+
setUserInfo(session.user);
|
| 875 |
+
};
|
| 876 |
+
|
| 877 |
+
const handleLogout = () => {
|
| 878 |
+
localStorage.removeItem("github_token");
|
| 879 |
+
localStorage.removeItem("github_user");
|
| 880 |
+
setIsAuthenticated(false);
|
| 881 |
+
setUserInfo(null);
|
| 882 |
+
clearAllContext();
|
| 883 |
+
};
|
| 884 |
+
|
| 885 |
+
if (isLoading) {
|
| 886 |
+
return (
|
| 887 |
+
<StartupScreen
|
| 888 |
+
appName="GitPilot"
|
| 889 |
+
subtitle="Enterprise Workspace Copilot"
|
| 890 |
+
frontendVersion={frontendVersion}
|
| 891 |
+
backendVersion={getBackendVersion(startupStatusSnapshot)}
|
| 892 |
+
provider={getProviderLabel(startupStatusSnapshot)}
|
| 893 |
+
statusMessage={startupStatusMessage}
|
| 894 |
+
detailMessage={startupDetailMessage}
|
| 895 |
+
phase={startupPhase}
|
| 896 |
+
/>
|
| 897 |
+
);
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
if (!isAuthenticated) {
|
| 901 |
+
return (
|
| 902 |
+
<LoginPage
|
| 903 |
+
onAuthenticated={handleAuthenticated}
|
| 904 |
+
backendReady={!!startupStatusSnapshot}
|
| 905 |
+
/>
|
| 906 |
+
);
|
| 907 |
+
}
|
| 908 |
+
|
| 909 |
+
const hasContext = contextRepos.length > 0;
|
| 910 |
+
|
| 911 |
+
return (
|
| 912 |
+
<div className="app-root">
|
| 913 |
+
<div className="main-wrapper">
|
| 914 |
+
<aside className={`sidebar${sidebarCollapsed ? " sidebar--collapsed" : ""}`}>
|
| 915 |
+
<div
|
| 916 |
+
className="sidebar-top-row"
|
| 917 |
+
>
|
| 918 |
+
<div
|
| 919 |
+
className="logo-row"
|
| 920 |
+
onClick={sidebarCollapsed ? toggleSidebar : undefined}
|
| 921 |
+
style={sidebarCollapsed ? { cursor: "pointer" } : undefined}
|
| 922 |
+
>
|
| 923 |
+
<div className="logo-square">GP</div>
|
| 924 |
+
{!sidebarCollapsed && (
|
| 925 |
+
<div>
|
| 926 |
+
<div className="logo-title">GitPilot</div>
|
| 927 |
+
<div className="logo-subtitle">Agentic GitHub Copilot</div>
|
| 928 |
+
</div>
|
| 929 |
+
)}
|
| 930 |
+
</div>
|
| 931 |
+
|
| 932 |
+
{!sidebarCollapsed && (
|
| 933 |
+
<button
|
| 934 |
+
className="sidebar-toggle-btn"
|
| 935 |
+
onClick={toggleSidebar}
|
| 936 |
+
title="Collapse sidebar (Ctrl+B)"
|
| 937 |
+
>
|
| 938 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
| 939 |
+
<path
|
| 940 |
+
d="M10 3L5 8L10 13"
|
| 941 |
+
stroke="currentColor"
|
| 942 |
+
strokeWidth="1.5"
|
| 943 |
+
strokeLinecap="round"
|
| 944 |
+
strokeLinejoin="round"
|
| 945 |
+
/>
|
| 946 |
+
</svg>
|
| 947 |
+
</button>
|
| 948 |
+
)}
|
| 949 |
+
</div>
|
| 950 |
+
|
| 951 |
+
<div className="main-nav">
|
| 952 |
+
<button
|
| 953 |
+
className={"nav-btn" + (activePage === "workspace" ? " nav-btn-active" : "")}
|
| 954 |
+
onClick={() => setActivePage("workspace")}
|
| 955 |
+
title="Workspace"
|
| 956 |
+
>
|
| 957 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
| 958 |
+
<rect x="2" y="2" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.3" />
|
| 959 |
+
<rect x="9" y="2" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.3" />
|
| 960 |
+
<rect x="2" y="9" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.3" />
|
| 961 |
+
<rect x="9" y="9" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.3" />
|
| 962 |
+
</svg>
|
| 963 |
+
{!sidebarCollapsed && <span>Workspace</span>}
|
| 964 |
+
</button>
|
| 965 |
+
|
| 966 |
+
<button
|
| 967 |
+
className={"nav-btn" + (activePage === "flow" ? " nav-btn-active" : "")}
|
| 968 |
+
onClick={() => setActivePage("flow")}
|
| 969 |
+
title="Agent Workflow"
|
| 970 |
+
>
|
| 971 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
| 972 |
+
<circle cx="4" cy="4" r="2" stroke="currentColor" strokeWidth="1.3" />
|
| 973 |
+
<circle cx="12" cy="4" r="2" stroke="currentColor" strokeWidth="1.3" />
|
| 974 |
+
<circle cx="8" cy="12" r="2" stroke="currentColor" strokeWidth="1.3" />
|
| 975 |
+
<path d="M5.5 5.5L7 10.5" stroke="currentColor" strokeWidth="1.3" />
|
| 976 |
+
<path d="M10.5 5.5L9 10.5" stroke="currentColor" strokeWidth="1.3" />
|
| 977 |
+
</svg>
|
| 978 |
+
{!sidebarCollapsed && <span>Agent Workflow</span>}
|
| 979 |
+
</button>
|
| 980 |
+
|
| 981 |
+
<button
|
| 982 |
+
className={"nav-btn" + (activePage === "admin" ? " nav-btn-active" : "")}
|
| 983 |
+
onClick={() => setActivePage("admin")}
|
| 984 |
+
title="Admin"
|
| 985 |
+
>
|
| 986 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
| 987 |
+
<path
|
| 988 |
+
d="M8 2C8 2 9.5 4 9.5 6C9.5 6.8 9.2 7.5 8.7 8L10 14H6L7.3 8C6.8 7.5 6.5 6.8 6.5 6C6.5 4 8 2 8 2Z"
|
| 989 |
+
stroke="currentColor"
|
| 990 |
+
strokeWidth="1.3"
|
| 991 |
+
strokeLinejoin="round"
|
| 992 |
+
/>
|
| 993 |
+
<circle cx="8" cy="6" r="1.5" stroke="currentColor" strokeWidth="1.3" />
|
| 994 |
+
</svg>
|
| 995 |
+
{!sidebarCollapsed && <span>Admin</span>}
|
| 996 |
+
</button>
|
| 997 |
+
</div>
|
| 998 |
+
|
| 999 |
+
{!sidebarCollapsed && (
|
| 1000 |
+
<>
|
| 1001 |
+
{!hasContext && (
|
| 1002 |
+
<RepoSelector onSelect={(r) => addRepoToContext(r)} />
|
| 1003 |
+
)}
|
| 1004 |
+
|
| 1005 |
+
{repo && (
|
| 1006 |
+
<SessionSidebar
|
| 1007 |
+
repo={repo}
|
| 1008 |
+
activeSessionId={activeSessionId}
|
| 1009 |
+
onSelectSession={handleSelectSession}
|
| 1010 |
+
onNewSession={handleNewSession}
|
| 1011 |
+
onDeleteSession={handleDeleteSession}
|
| 1012 |
+
refreshNonce={sessionRefreshNonce}
|
| 1013 |
+
/>
|
| 1014 |
+
)}
|
| 1015 |
+
</>
|
| 1016 |
+
)}
|
| 1017 |
+
|
| 1018 |
+
{userInfo && (
|
| 1019 |
+
<div className="user-profile">
|
| 1020 |
+
<UserMenu
|
| 1021 |
+
userInfo={userInfo}
|
| 1022 |
+
sidebarCollapsed={sidebarCollapsed}
|
| 1023 |
+
onOpenSettings={() => {
|
| 1024 |
+
setActivePage("admin");
|
| 1025 |
+
setAdminTab("advanced");
|
| 1026 |
+
}}
|
| 1027 |
+
onOpenAbout={() => setAboutOpen(true)}
|
| 1028 |
+
onLogout={handleLogout}
|
| 1029 |
+
/>
|
| 1030 |
+
</div>
|
| 1031 |
+
)}
|
| 1032 |
+
</aside>
|
| 1033 |
+
|
| 1034 |
+
<main className="workspace">
|
| 1035 |
+
{activePage === "admin" && (
|
| 1036 |
+
<div style={{ padding: "24px", maxWidth: "960px", margin: "0 auto" }}>
|
| 1037 |
+
<div style={{ display: "flex", gap: "8px", marginBottom: "24px", flexWrap: "wrap" }}>
|
| 1038 |
+
{["overview", "providers", "workspace-modes", "integrations", "mcp-servers", "sandbox", "sessions", "skills", "security", "advanced"].map((tab) => (
|
| 1039 |
+
<button
|
| 1040 |
+
key={tab}
|
| 1041 |
+
onClick={() => setAdminTab(tab)}
|
| 1042 |
+
style={{
|
| 1043 |
+
padding: "8px 16px",
|
| 1044 |
+
borderRadius: "6px",
|
| 1045 |
+
border: adminTab === tab ? "1px solid #3B82F6" : "1px solid #333",
|
| 1046 |
+
background: adminTab === tab ? "#1e3a5f" : "#1a1b26",
|
| 1047 |
+
color: adminTab === tab ? "#93c5fd" : "#a0a0b0",
|
| 1048 |
+
cursor: "pointer",
|
| 1049 |
+
fontSize: "13px",
|
| 1050 |
+
textTransform: "capitalize",
|
| 1051 |
+
}}
|
| 1052 |
+
>
|
| 1053 |
+
{tab.replace("-", " ")}
|
| 1054 |
+
</button>
|
| 1055 |
+
))}
|
| 1056 |
+
</div>
|
| 1057 |
+
|
| 1058 |
+
{adminTab === "overview" && (
|
| 1059 |
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "16px" }}>
|
| 1060 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 1061 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Server</div>
|
| 1062 |
+
<div style={{ fontSize: "16px", fontWeight: 600 }}>
|
| 1063 |
+
{adminStatus?.server_ready ? "Connected" : "Checking..."}
|
| 1064 |
+
</div>
|
| 1065 |
+
<div style={{ fontSize: "12px", opacity: 0.5 }}>127.0.0.1:8000</div>
|
| 1066 |
+
</div>
|
| 1067 |
+
|
| 1068 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 1069 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Provider</div>
|
| 1070 |
+
<div style={{ fontSize: "16px", fontWeight: 600 }}>
|
| 1071 |
+
{adminStatus?.provider?.name || "Loading..."}
|
| 1072 |
+
</div>
|
| 1073 |
+
<div style={{ fontSize: "12px", opacity: 0.5 }}>
|
| 1074 |
+
{adminStatus?.provider?.configured
|
| 1075 |
+
? `${adminStatus.provider.model || "Ready"}`
|
| 1076 |
+
: "Not configured"}
|
| 1077 |
+
</div>
|
| 1078 |
+
</div>
|
| 1079 |
+
|
| 1080 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 1081 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Workspace Modes</div>
|
| 1082 |
+
<div style={{ fontSize: "12px" }}>
|
| 1083 |
+
Folder: {adminStatus?.workspace?.folder_mode_available ? "Yes" : "—"}
|
| 1084 |
+
</div>
|
| 1085 |
+
<div style={{ fontSize: "12px" }}>
|
| 1086 |
+
Local Git: {adminStatus?.workspace?.local_git_available ? "Yes" : "—"}
|
| 1087 |
+
</div>
|
| 1088 |
+
<div style={{ fontSize: "12px" }}>
|
| 1089 |
+
GitHub: {adminStatus?.workspace?.github_mode_available ? "Yes" : "Optional"}
|
| 1090 |
+
</div>
|
| 1091 |
+
</div>
|
| 1092 |
+
|
| 1093 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 1094 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>GitHub</div>
|
| 1095 |
+
<div style={{ fontSize: "14px" }}>
|
| 1096 |
+
{adminStatus?.github?.connected ? "Connected" : "Optional"}
|
| 1097 |
+
</div>
|
| 1098 |
+
<div style={{ fontSize: "12px", opacity: 0.5 }}>
|
| 1099 |
+
{adminStatus?.github?.username || "Not linked"}
|
| 1100 |
+
</div>
|
| 1101 |
+
</div>
|
| 1102 |
+
|
| 1103 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 1104 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Sessions</div>
|
| 1105 |
+
<div style={{ fontSize: "14px" }}>—</div>
|
| 1106 |
+
</div>
|
| 1107 |
+
|
| 1108 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 1109 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Get Started</div>
|
| 1110 |
+
<button
|
| 1111 |
+
onClick={() => setAdminTab("providers")}
|
| 1112 |
+
style={{
|
| 1113 |
+
padding: "6px 12px",
|
| 1114 |
+
background: "#3B82F6",
|
| 1115 |
+
color: "#fff",
|
| 1116 |
+
border: "none",
|
| 1117 |
+
borderRadius: "4px",
|
| 1118 |
+
cursor: "pointer",
|
| 1119 |
+
fontSize: "12px",
|
| 1120 |
+
marginRight: "4px",
|
| 1121 |
+
}}
|
| 1122 |
+
>
|
| 1123 |
+
Configure Provider
|
| 1124 |
+
</button>
|
| 1125 |
+
</div>
|
| 1126 |
+
</div>
|
| 1127 |
+
)}
|
| 1128 |
+
|
| 1129 |
+
{adminTab === "providers" && (
|
| 1130 |
+
<div>
|
| 1131 |
+
<h3 style={{ marginBottom: "16px" }}>AI Providers</h3>
|
| 1132 |
+
<LlmSettings />
|
| 1133 |
+
</div>
|
| 1134 |
+
)}
|
| 1135 |
+
|
| 1136 |
+
{adminTab === "workspace-modes" && (
|
| 1137 |
+
<WorkspaceModesTab
|
| 1138 |
+
showToast={showToast}
|
| 1139 |
+
onSessionStarted={(result) => {
|
| 1140 |
+
setActiveSessionId(result.session_id);
|
| 1141 |
+
setSessionRefreshNonce((n) => n + 1);
|
| 1142 |
+
setActivePage("workspace");
|
| 1143 |
+
}}
|
| 1144 |
+
/>
|
| 1145 |
+
)}
|
| 1146 |
+
|
| 1147 |
+
{adminTab === "integrations" && (
|
| 1148 |
+
<IntegrationsTab
|
| 1149 |
+
userInfo={userInfo}
|
| 1150 |
+
onDisconnect={handleLogout}
|
| 1151 |
+
showToast={showToast}
|
| 1152 |
+
/>
|
| 1153 |
+
)}
|
| 1154 |
+
|
| 1155 |
+
{adminTab === "mcp-servers" && (
|
| 1156 |
+
<MCPServersTab showToast={showToast} />
|
| 1157 |
+
)}
|
| 1158 |
+
|
| 1159 |
+
{adminTab === "sandbox" && (
|
| 1160 |
+
<SandboxTab showToast={showToast} />
|
| 1161 |
+
)}
|
| 1162 |
+
|
| 1163 |
+
{adminTab === "security" && (
|
| 1164 |
+
<SecurityTab showToast={showToast} />
|
| 1165 |
+
)}
|
| 1166 |
+
|
| 1167 |
+
{adminTab === "sessions" && (
|
| 1168 |
+
<SessionsTab
|
| 1169 |
+
showToast={showToast}
|
| 1170 |
+
onSelectSession={(s) => {
|
| 1171 |
+
handleSelectSession(s);
|
| 1172 |
+
setActivePage("workspace");
|
| 1173 |
+
}}
|
| 1174 |
+
/>
|
| 1175 |
+
)}
|
| 1176 |
+
|
| 1177 |
+
{adminTab === "skills" && <SkillsTab showToast={showToast} />}
|
| 1178 |
+
|
| 1179 |
+
{adminTab === "advanced" && (
|
| 1180 |
+
<AdvancedTab
|
| 1181 |
+
showToast={showToast}
|
| 1182 |
+
onOpenFullSettings={() => setSettingsOpen(true)}
|
| 1183 |
+
/>
|
| 1184 |
+
)}
|
| 1185 |
+
</div>
|
| 1186 |
+
)}
|
| 1187 |
+
|
| 1188 |
+
{activePage === "flow" && <FlowViewer />}
|
| 1189 |
+
|
| 1190 |
+
{activePage === "workspace" &&
|
| 1191 |
+
(repo ? (
|
| 1192 |
+
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
| 1193 |
+
<ContextBar
|
| 1194 |
+
contextRepos={contextRepos}
|
| 1195 |
+
activeRepoKey={activeRepoKey}
|
| 1196 |
+
repoStateByKey={repoStateByKey}
|
| 1197 |
+
onActivate={setActiveRepoKey}
|
| 1198 |
+
onRemove={removeRepoFromContext}
|
| 1199 |
+
onAdd={() => setAddRepoOpen(true)}
|
| 1200 |
+
onBranchChange={handleContextBranchChange}
|
| 1201 |
+
/>
|
| 1202 |
+
|
| 1203 |
+
<div className="workspace-grid" style={{ flex: 1 }}>
|
| 1204 |
+
<aside className="gp-context-column">
|
| 1205 |
+
<ProjectContextPanel
|
| 1206 |
+
repo={repo}
|
| 1207 |
+
defaultBranch={defaultBranch}
|
| 1208 |
+
currentBranch={currentBranch}
|
| 1209 |
+
sessionBranches={sessionBranches}
|
| 1210 |
+
onBranchChange={handleBranchChange}
|
| 1211 |
+
pulseNonce={pulseNonce}
|
| 1212 |
+
lastExecution={lastExecution}
|
| 1213 |
+
onSettingsClick={() => setSettingsOpen(true)}
|
| 1214 |
+
/>
|
| 1215 |
+
</aside>
|
| 1216 |
+
|
| 1217 |
+
<main className="gp-chat-column">
|
| 1218 |
+
<div className="panel-header">
|
| 1219 |
+
<span>GitPilot chat</span>
|
| 1220 |
+
</div>
|
| 1221 |
+
|
| 1222 |
+
<ChatPanel
|
| 1223 |
+
repo={repo}
|
| 1224 |
+
defaultBranch={defaultBranch}
|
| 1225 |
+
currentBranch={currentBranch}
|
| 1226 |
+
onExecutionComplete={handleExecutionComplete}
|
| 1227 |
+
sessionChatState={sessionChatState}
|
| 1228 |
+
onSessionChatStateChange={updateSessionChat}
|
| 1229 |
+
sessionId={activeSessionId}
|
| 1230 |
+
onEnsureSession={ensureSession}
|
| 1231 |
+
/>
|
| 1232 |
+
</main>
|
| 1233 |
+
</div>
|
| 1234 |
+
</div>
|
| 1235 |
+
) : (
|
| 1236 |
+
<div className="empty-state">
|
| 1237 |
+
<div className="empty-bot">🤖</div>
|
| 1238 |
+
<h1>Select a repository</h1>
|
| 1239 |
+
<p>Select a repo to begin agentic workflow.</p>
|
| 1240 |
+
</div>
|
| 1241 |
+
))}
|
| 1242 |
+
</main>
|
| 1243 |
+
</div>
|
| 1244 |
+
|
| 1245 |
+
<Footer />
|
| 1246 |
+
|
| 1247 |
+
{repo && (
|
| 1248 |
+
<ProjectSettingsModal
|
| 1249 |
+
owner={repo.full_name?.split("/")[0] || repo.owner}
|
| 1250 |
+
repo={repo.full_name?.split("/")[1] || repo.name}
|
| 1251 |
+
isOpen={settingsOpen}
|
| 1252 |
+
onClose={() => setSettingsOpen(false)}
|
| 1253 |
+
activeEnvId={activeEnvId}
|
| 1254 |
+
onEnvChange={setActiveEnvId}
|
| 1255 |
+
/>
|
| 1256 |
+
)}
|
| 1257 |
+
|
| 1258 |
+
<AddRepoModal
|
| 1259 |
+
isOpen={addRepoOpen}
|
| 1260 |
+
onSelect={addRepoToContext}
|
| 1261 |
+
onClose={() => setAddRepoOpen(false)}
|
| 1262 |
+
excludeKeys={contextRepos.map((e) => e.repoKey)}
|
| 1263 |
+
/>
|
| 1264 |
+
|
| 1265 |
+
<AboutModal
|
| 1266 |
+
isOpen={aboutOpen}
|
| 1267 |
+
onClose={() => setAboutOpen(false)}
|
| 1268 |
+
/>
|
| 1269 |
+
|
| 1270 |
+
{toast && (
|
| 1271 |
+
<div className="toast-notification">
|
| 1272 |
+
<div style={{ fontSize: 12, fontWeight: 700 }}>{toast.title}</div>
|
| 1273 |
+
<div style={{ fontSize: 12, opacity: 0.82 }}>{toast.message}</div>
|
| 1274 |
+
</div>
|
| 1275 |
+
)}
|
| 1276 |
+
|
| 1277 |
+
<style>{`
|
| 1278 |
+
.toast-notification {
|
| 1279 |
+
position: fixed;
|
| 1280 |
+
top: 72px;
|
| 1281 |
+
right: 18px;
|
| 1282 |
+
z-index: 9999;
|
| 1283 |
+
background: #0b0b0d;
|
| 1284 |
+
color: #EDEDED;
|
| 1285 |
+
border: 1px solid rgba(255,255,255,0.12);
|
| 1286 |
+
border-left: 3px solid #3B82F6;
|
| 1287 |
+
border-radius: 10px;
|
| 1288 |
+
padding: 12px 14px;
|
| 1289 |
+
min-width: 320px;
|
| 1290 |
+
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
| 1291 |
+
}
|
| 1292 |
+
`}</style>
|
| 1293 |
+
</div>
|
| 1294 |
+
);
|
| 1295 |
+
}
|
frontend/components/AboutModal.jsx
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AboutModal.jsx
|
| 2 |
+
import React, { useEffect, useCallback, useState } from "react";
|
| 3 |
+
import { apiUrl, safeFetchJSON } from "../utils/api.js";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* AboutModal — "About GitPilot" dialog shown from the user menu.
|
| 7 |
+
*
|
| 8 |
+
* Enterprise design goals:
|
| 9 |
+
* - Prominent brand mark matching docs/logo.svg (orange ring + GP monogram)
|
| 10 |
+
* - Clear identity: name, tagline, version (frontend + backend)
|
| 11 |
+
* - Credits the creator (Ruslan Magana Vsevolodovna) as a link to GitHub
|
| 12 |
+
* - Open-source positioning: Apache 2.0 license + GitHub repo link
|
| 13 |
+
* - Action row: View on GitHub, Report Issue, Documentation
|
| 14 |
+
* - Accessible: role="dialog", aria-modal, aria-labelledby, Escape to close,
|
| 15 |
+
* focus trap via initial focus on close button
|
| 16 |
+
* - Brand palette: #D95C3D accent, #1C1C1F card, #27272A border, #EDEDED text
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
const FRONTEND_VERSION =
|
| 20 |
+
typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "0.1.5";
|
| 21 |
+
|
| 22 |
+
export default function AboutModal({ isOpen, onClose }) {
|
| 23 |
+
const [backendVersion, setBackendVersion] = useState(null);
|
| 24 |
+
|
| 25 |
+
// Fetch backend version when opened
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
if (!isOpen) return;
|
| 28 |
+
let cancelled = false;
|
| 29 |
+
(async () => {
|
| 30 |
+
try {
|
| 31 |
+
const data = await safeFetchJSON(apiUrl("/api/ping"), { timeout: 4000 });
|
| 32 |
+
if (!cancelled) {
|
| 33 |
+
setBackendVersion(data?.version || null);
|
| 34 |
+
}
|
| 35 |
+
} catch {
|
| 36 |
+
if (!cancelled) setBackendVersion(null);
|
| 37 |
+
}
|
| 38 |
+
})();
|
| 39 |
+
return () => {
|
| 40 |
+
cancelled = true;
|
| 41 |
+
};
|
| 42 |
+
}, [isOpen]);
|
| 43 |
+
|
| 44 |
+
// Escape to close
|
| 45 |
+
useEffect(() => {
|
| 46 |
+
if (!isOpen) return;
|
| 47 |
+
const handleKey = (e) => {
|
| 48 |
+
if (e.key === "Escape") onClose?.();
|
| 49 |
+
};
|
| 50 |
+
document.addEventListener("keydown", handleKey);
|
| 51 |
+
return () => document.removeEventListener("keydown", handleKey);
|
| 52 |
+
}, [isOpen, onClose]);
|
| 53 |
+
|
| 54 |
+
// Lock body scroll while open
|
| 55 |
+
useEffect(() => {
|
| 56 |
+
if (!isOpen) return;
|
| 57 |
+
const prev = document.body.style.overflow;
|
| 58 |
+
document.body.style.overflow = "hidden";
|
| 59 |
+
return () => {
|
| 60 |
+
document.body.style.overflow = prev;
|
| 61 |
+
};
|
| 62 |
+
}, [isOpen]);
|
| 63 |
+
|
| 64 |
+
const handleBackdropClick = useCallback(
|
| 65 |
+
(e) => {
|
| 66 |
+
if (e.target === e.currentTarget) onClose?.();
|
| 67 |
+
},
|
| 68 |
+
[onClose]
|
| 69 |
+
);
|
| 70 |
+
|
| 71 |
+
if (!isOpen) return null;
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
<div
|
| 75 |
+
role="dialog"
|
| 76 |
+
aria-modal="true"
|
| 77 |
+
aria-labelledby="about-modal-title"
|
| 78 |
+
onClick={handleBackdropClick}
|
| 79 |
+
style={{
|
| 80 |
+
position: "fixed",
|
| 81 |
+
inset: 0,
|
| 82 |
+
zIndex: 2000,
|
| 83 |
+
display: "flex",
|
| 84 |
+
alignItems: "center",
|
| 85 |
+
justifyContent: "center",
|
| 86 |
+
padding: 20,
|
| 87 |
+
background: "rgba(0, 0, 0, 0.65)",
|
| 88 |
+
backdropFilter: "blur(4px)",
|
| 89 |
+
WebkitBackdropFilter: "blur(4px)",
|
| 90 |
+
animation: "aboutBackdropIn 180ms ease-out",
|
| 91 |
+
}}
|
| 92 |
+
>
|
| 93 |
+
<div
|
| 94 |
+
style={{
|
| 95 |
+
position: "relative",
|
| 96 |
+
width: "100%",
|
| 97 |
+
maxWidth: 520,
|
| 98 |
+
background: "#1C1C1F",
|
| 99 |
+
border: "1px solid #27272A",
|
| 100 |
+
borderRadius: 16,
|
| 101 |
+
boxShadow:
|
| 102 |
+
"0 32px 64px -16px rgba(0, 0, 0, 0.8), 0 8px 24px rgba(0, 0, 0, 0.4)",
|
| 103 |
+
color: "#EDEDED",
|
| 104 |
+
fontFamily:
|
| 105 |
+
'"Söhne", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
| 106 |
+
letterSpacing: "-0.01em",
|
| 107 |
+
overflow: "hidden",
|
| 108 |
+
animation: "aboutCardIn 220ms cubic-bezier(0.16, 1, 0.3, 1)",
|
| 109 |
+
}}
|
| 110 |
+
>
|
| 111 |
+
{/* Close button */}
|
| 112 |
+
<button
|
| 113 |
+
type="button"
|
| 114 |
+
onClick={onClose}
|
| 115 |
+
aria-label="Close About dialog"
|
| 116 |
+
autoFocus
|
| 117 |
+
style={{
|
| 118 |
+
position: "absolute",
|
| 119 |
+
top: 14,
|
| 120 |
+
right: 14,
|
| 121 |
+
width: 32,
|
| 122 |
+
height: 32,
|
| 123 |
+
background: "transparent",
|
| 124 |
+
border: "1px solid transparent",
|
| 125 |
+
borderRadius: 8,
|
| 126 |
+
color: "#A1A1AA",
|
| 127 |
+
cursor: "pointer",
|
| 128 |
+
display: "inline-flex",
|
| 129 |
+
alignItems: "center",
|
| 130 |
+
justifyContent: "center",
|
| 131 |
+
transition: "all 120ms ease",
|
| 132 |
+
zIndex: 1,
|
| 133 |
+
}}
|
| 134 |
+
onMouseEnter={(e) => {
|
| 135 |
+
e.currentTarget.style.background = "#27272A";
|
| 136 |
+
e.currentTarget.style.color = "#EDEDED";
|
| 137 |
+
}}
|
| 138 |
+
onMouseLeave={(e) => {
|
| 139 |
+
e.currentTarget.style.background = "transparent";
|
| 140 |
+
e.currentTarget.style.color = "#A1A1AA";
|
| 141 |
+
}}
|
| 142 |
+
>
|
| 143 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| 144 |
+
<path
|
| 145 |
+
d="M4 4l8 8M12 4l-8 8"
|
| 146 |
+
stroke="currentColor"
|
| 147 |
+
strokeWidth="1.5"
|
| 148 |
+
strokeLinecap="round"
|
| 149 |
+
/>
|
| 150 |
+
</svg>
|
| 151 |
+
</button>
|
| 152 |
+
|
| 153 |
+
{/* Hero: brand mark + name */}
|
| 154 |
+
<div
|
| 155 |
+
style={{
|
| 156 |
+
padding: "40px 32px 24px",
|
| 157 |
+
textAlign: "center",
|
| 158 |
+
background:
|
| 159 |
+
"radial-gradient(circle at 50% 0%, rgba(217, 92, 61, 0.12), transparent 70%)",
|
| 160 |
+
}}
|
| 161 |
+
>
|
| 162 |
+
<BrandMark />
|
| 163 |
+
|
| 164 |
+
<h2
|
| 165 |
+
id="about-modal-title"
|
| 166 |
+
style={{
|
| 167 |
+
margin: "20px 0 6px",
|
| 168 |
+
fontSize: 24,
|
| 169 |
+
fontWeight: 700,
|
| 170 |
+
color: "#EDEDED",
|
| 171 |
+
letterSpacing: "-0.02em",
|
| 172 |
+
}}
|
| 173 |
+
>
|
| 174 |
+
GitPilot
|
| 175 |
+
</h2>
|
| 176 |
+
<div
|
| 177 |
+
style={{
|
| 178 |
+
fontSize: 13,
|
| 179 |
+
color: "#A1A1AA",
|
| 180 |
+
marginBottom: 10,
|
| 181 |
+
}}
|
| 182 |
+
>
|
| 183 |
+
Enterprise Workspace Copilot
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<div
|
| 187 |
+
style={{
|
| 188 |
+
display: "inline-flex",
|
| 189 |
+
alignItems: "center",
|
| 190 |
+
gap: 8,
|
| 191 |
+
padding: "4px 12px",
|
| 192 |
+
background: "rgba(217, 92, 61, 0.12)",
|
| 193 |
+
border: "1px solid rgba(217, 92, 61, 0.25)",
|
| 194 |
+
borderRadius: 999,
|
| 195 |
+
fontSize: 11,
|
| 196 |
+
fontWeight: 600,
|
| 197 |
+
color: "#ff7a3c",
|
| 198 |
+
letterSpacing: "0.04em",
|
| 199 |
+
textTransform: "uppercase",
|
| 200 |
+
}}
|
| 201 |
+
>
|
| 202 |
+
<span
|
| 203 |
+
aria-hidden="true"
|
| 204 |
+
style={{
|
| 205 |
+
width: 6,
|
| 206 |
+
height: 6,
|
| 207 |
+
borderRadius: "50%",
|
| 208 |
+
background: "#ff7a3c",
|
| 209 |
+
boxShadow: "0 0 8px rgba(255, 122, 60, 0.8)",
|
| 210 |
+
}}
|
| 211 |
+
/>
|
| 212 |
+
Open Source · Apache 2.0
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
{/* Body */}
|
| 217 |
+
<div style={{ padding: "8px 32px 0" }}>
|
| 218 |
+
<p
|
| 219 |
+
style={{
|
| 220 |
+
fontSize: 14,
|
| 221 |
+
lineHeight: 1.6,
|
| 222 |
+
color: "#A1A1AA",
|
| 223 |
+
textAlign: "center",
|
| 224 |
+
margin: "0 0 24px",
|
| 225 |
+
}}
|
| 226 |
+
>
|
| 227 |
+
An agentic AI coding companion for your repositories. Ask, plan,
|
| 228 |
+
code, and ship — with multi-LLM support, security scanning, and
|
| 229 |
+
VS Code integration.
|
| 230 |
+
</p>
|
| 231 |
+
|
| 232 |
+
{/* Meta table */}
|
| 233 |
+
<div
|
| 234 |
+
style={{
|
| 235 |
+
background: "#131316",
|
| 236 |
+
border: "1px solid #27272A",
|
| 237 |
+
borderRadius: 10,
|
| 238 |
+
padding: "4px 0",
|
| 239 |
+
marginBottom: 24,
|
| 240 |
+
}}
|
| 241 |
+
>
|
| 242 |
+
<MetaRow label="Version" value={`v${FRONTEND_VERSION}`} />
|
| 243 |
+
<MetaRow
|
| 244 |
+
label="Backend"
|
| 245 |
+
value={backendVersion ? `v${backendVersion}` : "—"}
|
| 246 |
+
/>
|
| 247 |
+
<MetaRow label="License" value="Apache 2.0" />
|
| 248 |
+
<MetaRow
|
| 249 |
+
label="Created by"
|
| 250 |
+
value={
|
| 251 |
+
<a
|
| 252 |
+
href="https://github.com/ruslanmv"
|
| 253 |
+
target="_blank"
|
| 254 |
+
rel="noopener noreferrer"
|
| 255 |
+
style={{
|
| 256 |
+
color: "#ff7a3c",
|
| 257 |
+
textDecoration: "none",
|
| 258 |
+
fontWeight: 600,
|
| 259 |
+
}}
|
| 260 |
+
onMouseEnter={(e) =>
|
| 261 |
+
(e.currentTarget.style.textDecoration = "underline")
|
| 262 |
+
}
|
| 263 |
+
onMouseLeave={(e) =>
|
| 264 |
+
(e.currentTarget.style.textDecoration = "none")
|
| 265 |
+
}
|
| 266 |
+
>
|
| 267 |
+
Ruslan Magana Vsevolodovna
|
| 268 |
+
</a>
|
| 269 |
+
}
|
| 270 |
+
isLast
|
| 271 |
+
/>
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
|
| 275 |
+
{/* Action row */}
|
| 276 |
+
<div
|
| 277 |
+
style={{
|
| 278 |
+
padding: "0 32px 32px",
|
| 279 |
+
display: "grid",
|
| 280 |
+
gridTemplateColumns: "1fr 1fr 1fr",
|
| 281 |
+
gap: 8,
|
| 282 |
+
}}
|
| 283 |
+
>
|
| 284 |
+
<ActionButton
|
| 285 |
+
href="https://github.com/ruslanmv/gitpilot"
|
| 286 |
+
icon={<GitHubIcon />}
|
| 287 |
+
label="GitHub"
|
| 288 |
+
/>
|
| 289 |
+
<ActionButton
|
| 290 |
+
href="https://github.com/ruslanmv/gitpilot#readme"
|
| 291 |
+
icon={<DocsIcon />}
|
| 292 |
+
label="Docs"
|
| 293 |
+
/>
|
| 294 |
+
<ActionButton
|
| 295 |
+
href="https://github.com/ruslanmv/gitpilot/issues"
|
| 296 |
+
icon={<BugIcon />}
|
| 297 |
+
label="Report"
|
| 298 |
+
/>
|
| 299 |
+
</div>
|
| 300 |
+
|
| 301 |
+
{/* Footer */}
|
| 302 |
+
<div
|
| 303 |
+
style={{
|
| 304 |
+
padding: "16px 32px",
|
| 305 |
+
background: "#131316",
|
| 306 |
+
borderTop: "1px solid #27272A",
|
| 307 |
+
fontSize: 11,
|
| 308 |
+
color: "#71717a",
|
| 309 |
+
textAlign: "center",
|
| 310 |
+
lineHeight: 1.5,
|
| 311 |
+
}}
|
| 312 |
+
>
|
| 313 |
+
© {new Date().getFullYear()} GitPilot · Made with care for
|
| 314 |
+
developers everywhere
|
| 315 |
+
</div>
|
| 316 |
+
</div>
|
| 317 |
+
|
| 318 |
+
<style>{`
|
| 319 |
+
@keyframes aboutBackdropIn {
|
| 320 |
+
from { opacity: 0; }
|
| 321 |
+
to { opacity: 1; }
|
| 322 |
+
}
|
| 323 |
+
@keyframes aboutCardIn {
|
| 324 |
+
from { opacity: 0; transform: translateY(12px) scale(0.97); }
|
| 325 |
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
| 326 |
+
}
|
| 327 |
+
`}</style>
|
| 328 |
+
</div>
|
| 329 |
+
);
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
// ── Brand mark (mirrors docs/logo.svg) ──────────────────────────────
|
| 333 |
+
function BrandMark() {
|
| 334 |
+
return (
|
| 335 |
+
<div
|
| 336 |
+
aria-hidden="true"
|
| 337 |
+
style={{
|
| 338 |
+
position: "relative",
|
| 339 |
+
width: 88,
|
| 340 |
+
height: 88,
|
| 341 |
+
margin: "0 auto",
|
| 342 |
+
}}
|
| 343 |
+
>
|
| 344 |
+
{/* Outer subtle ring */}
|
| 345 |
+
<div
|
| 346 |
+
style={{
|
| 347 |
+
position: "absolute",
|
| 348 |
+
inset: 0,
|
| 349 |
+
borderRadius: "50%",
|
| 350 |
+
border: "2px solid rgba(255, 122, 60, 0.22)",
|
| 351 |
+
}}
|
| 352 |
+
/>
|
| 353 |
+
{/* Active arc (top-right, uses conic gradient for smooth arc) */}
|
| 354 |
+
<div
|
| 355 |
+
style={{
|
| 356 |
+
position: "absolute",
|
| 357 |
+
inset: -2,
|
| 358 |
+
borderRadius: "50%",
|
| 359 |
+
background:
|
| 360 |
+
"conic-gradient(from -90deg, #ff7a3c 0deg, #D95C3D 90deg, transparent 91deg, transparent 360deg)",
|
| 361 |
+
mask: "radial-gradient(circle, transparent 40px, black 42px, black 44px, transparent 46px)",
|
| 362 |
+
WebkitMask:
|
| 363 |
+
"radial-gradient(circle, transparent 40px, black 42px, black 44px, transparent 46px)",
|
| 364 |
+
}}
|
| 365 |
+
/>
|
| 366 |
+
{/* Soft core glow */}
|
| 367 |
+
<div
|
| 368 |
+
style={{
|
| 369 |
+
position: "absolute",
|
| 370 |
+
inset: 14,
|
| 371 |
+
borderRadius: "50%",
|
| 372 |
+
background:
|
| 373 |
+
"radial-gradient(circle, rgba(255, 122, 60, 0.22) 0%, rgba(255, 122, 60, 0.06) 60%, transparent 100%)",
|
| 374 |
+
}}
|
| 375 |
+
/>
|
| 376 |
+
{/* GP monogram */}
|
| 377 |
+
<div
|
| 378 |
+
style={{
|
| 379 |
+
position: "absolute",
|
| 380 |
+
inset: 0,
|
| 381 |
+
display: "flex",
|
| 382 |
+
alignItems: "center",
|
| 383 |
+
justifyContent: "center",
|
| 384 |
+
fontSize: 28,
|
| 385 |
+
fontWeight: 700,
|
| 386 |
+
color: "#EDEDED",
|
| 387 |
+
letterSpacing: "-1px",
|
| 388 |
+
}}
|
| 389 |
+
>
|
| 390 |
+
GP
|
| 391 |
+
</div>
|
| 392 |
+
</div>
|
| 393 |
+
);
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
// ── Meta row ────────────────────────────────────────────────────────
|
| 397 |
+
function MetaRow({ label, value, isLast = false }) {
|
| 398 |
+
return (
|
| 399 |
+
<div
|
| 400 |
+
style={{
|
| 401 |
+
display: "flex",
|
| 402 |
+
justifyContent: "space-between",
|
| 403 |
+
alignItems: "center",
|
| 404 |
+
padding: "10px 16px",
|
| 405 |
+
borderBottom: isLast ? "none" : "1px solid #27272A",
|
| 406 |
+
fontSize: 13,
|
| 407 |
+
}}
|
| 408 |
+
>
|
| 409 |
+
<span style={{ color: "#71717a", fontWeight: 500 }}>{label}</span>
|
| 410 |
+
<span style={{ color: "#EDEDED", fontWeight: 600, textAlign: "right" }}>
|
| 411 |
+
{value}
|
| 412 |
+
</span>
|
| 413 |
+
</div>
|
| 414 |
+
);
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
// ── Action button ───────────────────────────────────────────────────
|
| 418 |
+
function ActionButton({ href, icon, label }) {
|
| 419 |
+
return (
|
| 420 |
+
<a
|
| 421 |
+
href={href}
|
| 422 |
+
target="_blank"
|
| 423 |
+
rel="noopener noreferrer"
|
| 424 |
+
style={{
|
| 425 |
+
display: "flex",
|
| 426 |
+
flexDirection: "column",
|
| 427 |
+
alignItems: "center",
|
| 428 |
+
justifyContent: "center",
|
| 429 |
+
gap: 6,
|
| 430 |
+
padding: "12px 8px",
|
| 431 |
+
background: "#131316",
|
| 432 |
+
border: "1px solid #27272A",
|
| 433 |
+
borderRadius: 10,
|
| 434 |
+
color: "#EDEDED",
|
| 435 |
+
fontSize: 12,
|
| 436 |
+
fontWeight: 600,
|
| 437 |
+
textDecoration: "none",
|
| 438 |
+
transition: "all 140ms ease",
|
| 439 |
+
}}
|
| 440 |
+
onMouseEnter={(e) => {
|
| 441 |
+
e.currentTarget.style.borderColor = "#D95C3D";
|
| 442 |
+
e.currentTarget.style.background = "rgba(217, 92, 61, 0.08)";
|
| 443 |
+
}}
|
| 444 |
+
onMouseLeave={(e) => {
|
| 445 |
+
e.currentTarget.style.borderColor = "#27272A";
|
| 446 |
+
e.currentTarget.style.background = "#131316";
|
| 447 |
+
}}
|
| 448 |
+
>
|
| 449 |
+
<span
|
| 450 |
+
aria-hidden="true"
|
| 451 |
+
style={{
|
| 452 |
+
color: "#ff7a3c",
|
| 453 |
+
display: "inline-flex",
|
| 454 |
+
}}
|
| 455 |
+
>
|
| 456 |
+
{icon}
|
| 457 |
+
</span>
|
| 458 |
+
<span>{label}</span>
|
| 459 |
+
</a>
|
| 460 |
+
);
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
// ── Icons ───────────────────────────────────────────────────────────
|
| 464 |
+
function GitHubIcon() {
|
| 465 |
+
return (
|
| 466 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
| 467 |
+
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405 1.02 0 2.04.135 3 .405 2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
| 468 |
+
</svg>
|
| 469 |
+
);
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
function DocsIcon() {
|
| 473 |
+
return (
|
| 474 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
| 475 |
+
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
| 476 |
+
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
| 477 |
+
</svg>
|
| 478 |
+
);
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
function BugIcon() {
|
| 482 |
+
return (
|
| 483 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
| 484 |
+
<rect x="8" y="6" width="8" height="14" rx="4" />
|
| 485 |
+
<path d="M19 7l-3 2M5 7l3 2M19 13h-3M5 13h3M19 19l-3-2M5 19l3-2M12 6V2" />
|
| 486 |
+
</svg>
|
| 487 |
+
);
|
| 488 |
+
}
|
frontend/components/AddRepoModal.jsx
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useCallback, useEffect, useState } from "react";
|
| 2 |
+
import { createPortal } from "react-dom";
|
| 3 |
+
import { authFetch } from "../utils/api.js";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* AddRepoModal — lightweight portal modal for adding repos to context.
|
| 7 |
+
*
|
| 8 |
+
* Embeds a minimal repo search/list (not the full RepoSelector) to keep
|
| 9 |
+
* the modal focused. Filters out repos already in context.
|
| 10 |
+
*/
|
| 11 |
+
export default function AddRepoModal({ isOpen, onSelect, onClose, excludeKeys = [] }) {
|
| 12 |
+
const [query, setQuery] = useState("");
|
| 13 |
+
const [repos, setRepos] = useState([]);
|
| 14 |
+
const [loading, setLoading] = useState(false);
|
| 15 |
+
|
| 16 |
+
const fetchRepos = useCallback(
|
| 17 |
+
async (searchQuery) => {
|
| 18 |
+
setLoading(true);
|
| 19 |
+
try {
|
| 20 |
+
const params = new URLSearchParams({ per_page: "50" });
|
| 21 |
+
if (searchQuery) params.set("query", searchQuery);
|
| 22 |
+
const res = await authFetch(`/api/repos?${params}`);
|
| 23 |
+
if (!res.ok) return;
|
| 24 |
+
const data = await res.json();
|
| 25 |
+
setRepos(data.repositories || []);
|
| 26 |
+
} catch (err) {
|
| 27 |
+
console.warn("AddRepoModal: fetch failed:", err);
|
| 28 |
+
} finally {
|
| 29 |
+
setLoading(false);
|
| 30 |
+
}
|
| 31 |
+
},
|
| 32 |
+
[]
|
| 33 |
+
);
|
| 34 |
+
|
| 35 |
+
useEffect(() => {
|
| 36 |
+
if (isOpen) {
|
| 37 |
+
setQuery("");
|
| 38 |
+
fetchRepos("");
|
| 39 |
+
}
|
| 40 |
+
}, [isOpen, fetchRepos]);
|
| 41 |
+
|
| 42 |
+
// Debounced search
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
if (!isOpen) return;
|
| 45 |
+
const t = setTimeout(() => fetchRepos(query), 300);
|
| 46 |
+
return () => clearTimeout(t);
|
| 47 |
+
}, [query, isOpen, fetchRepos]);
|
| 48 |
+
|
| 49 |
+
const excludeSet = new Set(excludeKeys);
|
| 50 |
+
const filtered = repos.filter((r) => {
|
| 51 |
+
const key = r.full_name || `${r.owner}/${r.name}`;
|
| 52 |
+
return !excludeSet.has(key);
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
if (!isOpen) return null;
|
| 56 |
+
|
| 57 |
+
return createPortal(
|
| 58 |
+
<div
|
| 59 |
+
style={styles.overlay}
|
| 60 |
+
onMouseDown={(e) => {
|
| 61 |
+
if (e.target === e.currentTarget) onClose();
|
| 62 |
+
}}
|
| 63 |
+
>
|
| 64 |
+
<div style={styles.modal} onMouseDown={(e) => e.stopPropagation()}>
|
| 65 |
+
<div style={styles.header}>
|
| 66 |
+
<span style={styles.headerTitle}>Add Repository</span>
|
| 67 |
+
<button type="button" style={styles.closeBtn} onClick={onClose}>
|
| 68 |
+
×
|
| 69 |
+
</button>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div style={styles.searchBox}>
|
| 73 |
+
<input
|
| 74 |
+
type="text"
|
| 75 |
+
placeholder="Search repositories..."
|
| 76 |
+
value={query}
|
| 77 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 78 |
+
style={styles.searchInput}
|
| 79 |
+
autoFocus
|
| 80 |
+
onKeyDown={(e) => {
|
| 81 |
+
if (e.key === "Escape") onClose();
|
| 82 |
+
}}
|
| 83 |
+
/>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div style={styles.list}>
|
| 87 |
+
{loading && filtered.length === 0 && (
|
| 88 |
+
<div style={styles.statusRow}>Loading...</div>
|
| 89 |
+
)}
|
| 90 |
+
{!loading && filtered.length === 0 && (
|
| 91 |
+
<div style={styles.statusRow}>
|
| 92 |
+
{excludeKeys.length > 0 && repos.length > 0
|
| 93 |
+
? "All matching repos are already in context"
|
| 94 |
+
: "No repositories found"}
|
| 95 |
+
</div>
|
| 96 |
+
)}
|
| 97 |
+
{filtered.map((r) => {
|
| 98 |
+
const key = r.full_name || `${r.owner}/${r.name}`;
|
| 99 |
+
return (
|
| 100 |
+
<button
|
| 101 |
+
key={r.id || key}
|
| 102 |
+
type="button"
|
| 103 |
+
style={styles.repoRow}
|
| 104 |
+
onClick={() => onSelect(r)}
|
| 105 |
+
>
|
| 106 |
+
<div style={styles.repoInfo}>
|
| 107 |
+
<span style={styles.repoName}>{r.name}</span>
|
| 108 |
+
<span style={styles.repoOwner}>{r.owner}</span>
|
| 109 |
+
</div>
|
| 110 |
+
<div style={styles.repoMeta}>
|
| 111 |
+
{r.private && <span style={styles.privateBadge}>Private</span>}
|
| 112 |
+
<span style={styles.branchHint}>{r.default_branch || "main"}</span>
|
| 113 |
+
</div>
|
| 114 |
+
</button>
|
| 115 |
+
);
|
| 116 |
+
})}
|
| 117 |
+
{loading && filtered.length > 0 && (
|
| 118 |
+
<div style={styles.statusRow}>Updating...</div>
|
| 119 |
+
)}
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>,
|
| 123 |
+
document.body
|
| 124 |
+
);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
const styles = {
|
| 128 |
+
overlay: {
|
| 129 |
+
position: "fixed",
|
| 130 |
+
top: 0,
|
| 131 |
+
left: 0,
|
| 132 |
+
right: 0,
|
| 133 |
+
bottom: 0,
|
| 134 |
+
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
| 135 |
+
zIndex: 10000,
|
| 136 |
+
display: "flex",
|
| 137 |
+
alignItems: "center",
|
| 138 |
+
justifyContent: "center",
|
| 139 |
+
},
|
| 140 |
+
modal: {
|
| 141 |
+
width: 440,
|
| 142 |
+
maxHeight: "70vh",
|
| 143 |
+
backgroundColor: "#131316",
|
| 144 |
+
border: "1px solid #27272A",
|
| 145 |
+
borderRadius: 12,
|
| 146 |
+
display: "flex",
|
| 147 |
+
flexDirection: "column",
|
| 148 |
+
overflow: "hidden",
|
| 149 |
+
boxShadow: "0 12px 40px rgba(0,0,0,0.5)",
|
| 150 |
+
},
|
| 151 |
+
header: {
|
| 152 |
+
display: "flex",
|
| 153 |
+
justifyContent: "space-between",
|
| 154 |
+
alignItems: "center",
|
| 155 |
+
padding: "12px 14px",
|
| 156 |
+
borderBottom: "1px solid #27272A",
|
| 157 |
+
backgroundColor: "#18181B",
|
| 158 |
+
},
|
| 159 |
+
headerTitle: {
|
| 160 |
+
fontSize: 14,
|
| 161 |
+
fontWeight: 600,
|
| 162 |
+
color: "#E4E4E7",
|
| 163 |
+
},
|
| 164 |
+
closeBtn: {
|
| 165 |
+
width: 26,
|
| 166 |
+
height: 26,
|
| 167 |
+
borderRadius: 6,
|
| 168 |
+
border: "1px solid #3F3F46",
|
| 169 |
+
background: "transparent",
|
| 170 |
+
color: "#A1A1AA",
|
| 171 |
+
fontSize: 16,
|
| 172 |
+
cursor: "pointer",
|
| 173 |
+
display: "flex",
|
| 174 |
+
alignItems: "center",
|
| 175 |
+
justifyContent: "center",
|
| 176 |
+
},
|
| 177 |
+
searchBox: {
|
| 178 |
+
padding: "10px 12px",
|
| 179 |
+
borderBottom: "1px solid #27272A",
|
| 180 |
+
},
|
| 181 |
+
searchInput: {
|
| 182 |
+
width: "100%",
|
| 183 |
+
padding: "8px 10px",
|
| 184 |
+
borderRadius: 6,
|
| 185 |
+
border: "1px solid #3F3F46",
|
| 186 |
+
background: "#18181B",
|
| 187 |
+
color: "#E4E4E7",
|
| 188 |
+
fontSize: 13,
|
| 189 |
+
outline: "none",
|
| 190 |
+
fontFamily: "monospace",
|
| 191 |
+
boxSizing: "border-box",
|
| 192 |
+
},
|
| 193 |
+
list: {
|
| 194 |
+
flex: 1,
|
| 195 |
+
overflowY: "auto",
|
| 196 |
+
maxHeight: 360,
|
| 197 |
+
},
|
| 198 |
+
statusRow: {
|
| 199 |
+
padding: "16px 12px",
|
| 200 |
+
textAlign: "center",
|
| 201 |
+
fontSize: 12,
|
| 202 |
+
color: "#71717A",
|
| 203 |
+
},
|
| 204 |
+
repoRow: {
|
| 205 |
+
display: "flex",
|
| 206 |
+
alignItems: "center",
|
| 207 |
+
justifyContent: "space-between",
|
| 208 |
+
width: "100%",
|
| 209 |
+
padding: "10px 14px",
|
| 210 |
+
border: "none",
|
| 211 |
+
borderBottom: "1px solid rgba(39, 39, 42, 0.5)",
|
| 212 |
+
background: "transparent",
|
| 213 |
+
color: "#E4E4E7",
|
| 214 |
+
cursor: "pointer",
|
| 215 |
+
textAlign: "left",
|
| 216 |
+
transition: "background-color 0.1s",
|
| 217 |
+
},
|
| 218 |
+
repoInfo: {
|
| 219 |
+
display: "flex",
|
| 220 |
+
flexDirection: "column",
|
| 221 |
+
gap: 2,
|
| 222 |
+
minWidth: 0,
|
| 223 |
+
},
|
| 224 |
+
repoName: {
|
| 225 |
+
fontSize: 13,
|
| 226 |
+
fontWeight: 600,
|
| 227 |
+
fontFamily: "monospace",
|
| 228 |
+
overflow: "hidden",
|
| 229 |
+
textOverflow: "ellipsis",
|
| 230 |
+
whiteSpace: "nowrap",
|
| 231 |
+
},
|
| 232 |
+
repoOwner: {
|
| 233 |
+
fontSize: 11,
|
| 234 |
+
color: "#71717A",
|
| 235 |
+
},
|
| 236 |
+
repoMeta: {
|
| 237 |
+
display: "flex",
|
| 238 |
+
alignItems: "center",
|
| 239 |
+
gap: 8,
|
| 240 |
+
flexShrink: 0,
|
| 241 |
+
},
|
| 242 |
+
privateBadge: {
|
| 243 |
+
fontSize: 9,
|
| 244 |
+
padding: "1px 5px",
|
| 245 |
+
borderRadius: 8,
|
| 246 |
+
backgroundColor: "rgba(239, 68, 68, 0.12)",
|
| 247 |
+
color: "#F87171",
|
| 248 |
+
fontWeight: 600,
|
| 249 |
+
textTransform: "uppercase",
|
| 250 |
+
},
|
| 251 |
+
branchHint: {
|
| 252 |
+
fontSize: 10,
|
| 253 |
+
color: "#52525B",
|
| 254 |
+
fontFamily: "monospace",
|
| 255 |
+
},
|
| 256 |
+
};
|
frontend/components/AdminTabs/AdvancedTab.jsx
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/AdvancedTab.jsx
|
| 2 |
+
import React, { useEffect, useState, useCallback } from "react";
|
| 3 |
+
import { apiUrl, safeFetchJSON } from "../../utils/api.js";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Advanced tab — inline toggles for:
|
| 7 |
+
* - Lite Mode (via /api/settings/topology — sets topology to "lite_mode")
|
| 8 |
+
* - Permission Mode (normal | auto | plan via /api/permissions/mode)
|
| 9 |
+
* - Link to full Settings modal for power users
|
| 10 |
+
*
|
| 11 |
+
* Best practices applied:
|
| 12 |
+
* - Optimistic UI with rollback on error
|
| 13 |
+
* - Each setting has its own loading indicator (no global lock)
|
| 14 |
+
* - Descriptions explain what each mode does
|
| 15 |
+
* - ARIA-labeled toggle switches for accessibility
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
const PERMISSION_MODES = [
|
| 19 |
+
{
|
| 20 |
+
value: "normal",
|
| 21 |
+
label: "Normal",
|
| 22 |
+
description:
|
| 23 |
+
"Ask before writing files or running commands (recommended).",
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
value: "auto",
|
| 27 |
+
label: "Auto",
|
| 28 |
+
description:
|
| 29 |
+
"Approve all tool calls automatically. Use only when you trust the agent.",
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
value: "plan",
|
| 33 |
+
label: "Plan Only",
|
| 34 |
+
description:
|
| 35 |
+
"Read-only mode. Agent cannot write files or run commands.",
|
| 36 |
+
},
|
| 37 |
+
];
|
| 38 |
+
|
| 39 |
+
function ToggleSwitch({ checked, onChange, disabled, ariaLabel }) {
|
| 40 |
+
return (
|
| 41 |
+
<button
|
| 42 |
+
type="button"
|
| 43 |
+
role="switch"
|
| 44 |
+
aria-checked={checked}
|
| 45 |
+
aria-label={ariaLabel}
|
| 46 |
+
onClick={() => !disabled && onChange(!checked)}
|
| 47 |
+
disabled={disabled}
|
| 48 |
+
style={{
|
| 49 |
+
position: "relative",
|
| 50 |
+
width: "44px",
|
| 51 |
+
height: "24px",
|
| 52 |
+
borderRadius: "12px",
|
| 53 |
+
background: checked ? "#3B82F6" : "#374151",
|
| 54 |
+
border: "none",
|
| 55 |
+
cursor: disabled ? "not-allowed" : "pointer",
|
| 56 |
+
transition: "background 150ms ease",
|
| 57 |
+
padding: 0,
|
| 58 |
+
opacity: disabled ? 0.5 : 1,
|
| 59 |
+
}}
|
| 60 |
+
>
|
| 61 |
+
<div
|
| 62 |
+
style={{
|
| 63 |
+
position: "absolute",
|
| 64 |
+
top: "2px",
|
| 65 |
+
left: checked ? "22px" : "2px",
|
| 66 |
+
width: "20px",
|
| 67 |
+
height: "20px",
|
| 68 |
+
borderRadius: "50%",
|
| 69 |
+
background: "#fff",
|
| 70 |
+
transition: "left 150ms ease",
|
| 71 |
+
boxShadow: "0 1px 3px rgba(0,0,0,0.3)",
|
| 72 |
+
}}
|
| 73 |
+
/>
|
| 74 |
+
</button>
|
| 75 |
+
);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export default function AdvancedTab({ showToast, onOpenFullSettings }) {
|
| 79 |
+
const [liteMode, setLiteMode] = useState(false);
|
| 80 |
+
const [permissionMode, setPermissionMode] = useState("normal");
|
| 81 |
+
const [loading, setLoading] = useState(true);
|
| 82 |
+
const [updatingLite, setUpdatingLite] = useState(false);
|
| 83 |
+
const [updatingPerm, setUpdatingPerm] = useState(false);
|
| 84 |
+
const [error, setError] = useState(null);
|
| 85 |
+
|
| 86 |
+
// Initial fetch: topology preference + permission mode
|
| 87 |
+
useEffect(() => {
|
| 88 |
+
let cancelled = false;
|
| 89 |
+
(async () => {
|
| 90 |
+
try {
|
| 91 |
+
const [topo, perms] = await Promise.all([
|
| 92 |
+
safeFetchJSON(apiUrl("/api/settings/topology"), { timeout: 5000 })
|
| 93 |
+
.catch(() => ({ topology: null })),
|
| 94 |
+
safeFetchJSON(apiUrl("/api/permissions"), { timeout: 5000 })
|
| 95 |
+
.catch(() => ({ mode: "normal" })),
|
| 96 |
+
]);
|
| 97 |
+
if (cancelled) return;
|
| 98 |
+
setLiteMode(topo?.topology === "lite_mode");
|
| 99 |
+
setPermissionMode(perms?.mode || perms?.policy?.mode || "normal");
|
| 100 |
+
} catch (err) {
|
| 101 |
+
if (!cancelled) setError(err?.message || "Failed to load settings");
|
| 102 |
+
} finally {
|
| 103 |
+
if (!cancelled) setLoading(false);
|
| 104 |
+
}
|
| 105 |
+
})();
|
| 106 |
+
return () => {
|
| 107 |
+
cancelled = true;
|
| 108 |
+
};
|
| 109 |
+
}, []);
|
| 110 |
+
|
| 111 |
+
const handleLiteToggle = useCallback(async (next) => {
|
| 112 |
+
setUpdatingLite(true);
|
| 113 |
+
setError(null);
|
| 114 |
+
const previous = liteMode;
|
| 115 |
+
setLiteMode(next); // optimistic
|
| 116 |
+
try {
|
| 117 |
+
await safeFetchJSON(apiUrl("/api/settings/topology"), {
|
| 118 |
+
method: "POST",
|
| 119 |
+
headers: { "Content-Type": "application/json" },
|
| 120 |
+
body: JSON.stringify({ topology: next ? "lite_mode" : null }),
|
| 121 |
+
timeout: 5000,
|
| 122 |
+
});
|
| 123 |
+
showToast?.(
|
| 124 |
+
"Lite Mode " + (next ? "enabled" : "disabled"),
|
| 125 |
+
next
|
| 126 |
+
? "Single-agent path — better for small local models."
|
| 127 |
+
: "Multi-agent path — uses full CrewAI orchestration."
|
| 128 |
+
);
|
| 129 |
+
} catch (err) {
|
| 130 |
+
setLiteMode(previous); // rollback
|
| 131 |
+
setError(err?.message || "Failed to update lite mode");
|
| 132 |
+
} finally {
|
| 133 |
+
setUpdatingLite(false);
|
| 134 |
+
}
|
| 135 |
+
}, [liteMode, showToast]);
|
| 136 |
+
|
| 137 |
+
const handlePermissionChange = useCallback(async (next) => {
|
| 138 |
+
setUpdatingPerm(true);
|
| 139 |
+
setError(null);
|
| 140 |
+
const previous = permissionMode;
|
| 141 |
+
setPermissionMode(next); // optimistic
|
| 142 |
+
try {
|
| 143 |
+
const res = await fetch(apiUrl("/api/permissions/mode"), {
|
| 144 |
+
method: "PUT",
|
| 145 |
+
headers: { "Content-Type": "application/json" },
|
| 146 |
+
body: JSON.stringify({ mode: next }),
|
| 147 |
+
});
|
| 148 |
+
if (!res.ok) {
|
| 149 |
+
const body = await res.json().catch(() => ({}));
|
| 150 |
+
throw new Error(body.detail || `HTTP ${res.status}`);
|
| 151 |
+
}
|
| 152 |
+
showToast?.(
|
| 153 |
+
"Permission mode updated",
|
| 154 |
+
`Set to ${next}.`
|
| 155 |
+
);
|
| 156 |
+
} catch (err) {
|
| 157 |
+
setPermissionMode(previous); // rollback
|
| 158 |
+
setError(err?.message || "Failed to update permission mode");
|
| 159 |
+
} finally {
|
| 160 |
+
setUpdatingPerm(false);
|
| 161 |
+
}
|
| 162 |
+
}, [permissionMode, showToast]);
|
| 163 |
+
|
| 164 |
+
if (loading) {
|
| 165 |
+
return (
|
| 166 |
+
<div>
|
| 167 |
+
<h3 style={{ marginBottom: "16px" }}>Advanced</h3>
|
| 168 |
+
<div
|
| 169 |
+
style={{
|
| 170 |
+
background: "#1a1b26",
|
| 171 |
+
borderRadius: "8px",
|
| 172 |
+
padding: "40px 20px",
|
| 173 |
+
textAlign: "center",
|
| 174 |
+
border: "1px solid #2a2b36",
|
| 175 |
+
fontSize: "12px",
|
| 176 |
+
opacity: 0.6,
|
| 177 |
+
}}
|
| 178 |
+
>
|
| 179 |
+
Loading advanced settings...
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
return (
|
| 186 |
+
<div>
|
| 187 |
+
<h3 style={{ marginBottom: "16px" }}>Advanced</h3>
|
| 188 |
+
<p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "16px" }}>
|
| 189 |
+
Fine-tune GitPilot's agent behavior and safety settings.
|
| 190 |
+
</p>
|
| 191 |
+
|
| 192 |
+
{error && (
|
| 193 |
+
<div
|
| 194 |
+
role="alert"
|
| 195 |
+
style={{
|
| 196 |
+
background: "#7f1d1d",
|
| 197 |
+
color: "#fecaca",
|
| 198 |
+
border: "1px solid #991b1b",
|
| 199 |
+
borderRadius: "8px",
|
| 200 |
+
padding: "12px",
|
| 201 |
+
fontSize: "12px",
|
| 202 |
+
marginBottom: "16px",
|
| 203 |
+
}}
|
| 204 |
+
>
|
| 205 |
+
{error}
|
| 206 |
+
</div>
|
| 207 |
+
)}
|
| 208 |
+
|
| 209 |
+
{/* Lite Mode toggle */}
|
| 210 |
+
<div
|
| 211 |
+
style={{
|
| 212 |
+
background: "#1a1b26",
|
| 213 |
+
borderRadius: "8px",
|
| 214 |
+
padding: "16px",
|
| 215 |
+
border: "1px solid #2a2b36",
|
| 216 |
+
marginBottom: "12px",
|
| 217 |
+
}}
|
| 218 |
+
>
|
| 219 |
+
<div
|
| 220 |
+
style={{
|
| 221 |
+
display: "flex",
|
| 222 |
+
justifyContent: "space-between",
|
| 223 |
+
alignItems: "flex-start",
|
| 224 |
+
gap: "16px",
|
| 225 |
+
}}
|
| 226 |
+
>
|
| 227 |
+
<div style={{ flex: 1 }}>
|
| 228 |
+
<h4 style={{ marginBottom: "4px", fontSize: "14px" }}>Lite Mode</h4>
|
| 229 |
+
<p style={{ fontSize: "12px", opacity: 0.7, lineHeight: 1.5 }}>
|
| 230 |
+
Use a simplified single-agent prompt instead of the multi-agent
|
| 231 |
+
CrewAI pipeline. Recommended for small local models
|
| 232 |
+
(qwen2.5:1.5b, deepseek-r1, phi3:mini) that struggle with the
|
| 233 |
+
ReAct format.
|
| 234 |
+
</p>
|
| 235 |
+
</div>
|
| 236 |
+
<ToggleSwitch
|
| 237 |
+
checked={liteMode}
|
| 238 |
+
onChange={handleLiteToggle}
|
| 239 |
+
disabled={updatingLite}
|
| 240 |
+
ariaLabel="Toggle Lite Mode"
|
| 241 |
+
/>
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
|
| 245 |
+
{/* Permission Mode selector */}
|
| 246 |
+
<div
|
| 247 |
+
style={{
|
| 248 |
+
background: "#1a1b26",
|
| 249 |
+
borderRadius: "8px",
|
| 250 |
+
padding: "16px",
|
| 251 |
+
border: "1px solid #2a2b36",
|
| 252 |
+
marginBottom: "12px",
|
| 253 |
+
}}
|
| 254 |
+
>
|
| 255 |
+
<h4 style={{ marginBottom: "4px", fontSize: "14px" }}>Permission Mode</h4>
|
| 256 |
+
<p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "12px" }}>
|
| 257 |
+
Controls when the agent needs your approval before writing files or
|
| 258 |
+
running commands.
|
| 259 |
+
</p>
|
| 260 |
+
|
| 261 |
+
<div style={{ display: "grid", gap: "8px" }}>
|
| 262 |
+
{PERMISSION_MODES.map((mode) => {
|
| 263 |
+
const selected = permissionMode === mode.value;
|
| 264 |
+
return (
|
| 265 |
+
<label
|
| 266 |
+
key={mode.value}
|
| 267 |
+
style={{
|
| 268 |
+
display: "flex",
|
| 269 |
+
alignItems: "flex-start",
|
| 270 |
+
gap: "10px",
|
| 271 |
+
padding: "10px 12px",
|
| 272 |
+
background: selected ? "#1e3a5f" : "#0d0e15",
|
| 273 |
+
border: selected ? "1px solid #3B82F6" : "1px solid #2a2b36",
|
| 274 |
+
borderRadius: "6px",
|
| 275 |
+
cursor: updatingPerm ? "not-allowed" : "pointer",
|
| 276 |
+
opacity: updatingPerm && !selected ? 0.5 : 1,
|
| 277 |
+
}}
|
| 278 |
+
>
|
| 279 |
+
<input
|
| 280 |
+
type="radio"
|
| 281 |
+
name="permission-mode"
|
| 282 |
+
value={mode.value}
|
| 283 |
+
checked={selected}
|
| 284 |
+
onChange={() => handlePermissionChange(mode.value)}
|
| 285 |
+
disabled={updatingPerm}
|
| 286 |
+
style={{ marginTop: "2px", cursor: "inherit" }}
|
| 287 |
+
/>
|
| 288 |
+
<div>
|
| 289 |
+
<div
|
| 290 |
+
style={{
|
| 291 |
+
fontSize: "13px",
|
| 292 |
+
fontWeight: 600,
|
| 293 |
+
color: selected ? "#93c5fd" : "#fff",
|
| 294 |
+
}}
|
| 295 |
+
>
|
| 296 |
+
{mode.label}
|
| 297 |
+
</div>
|
| 298 |
+
<div
|
| 299 |
+
style={{
|
| 300 |
+
fontSize: "11px",
|
| 301 |
+
opacity: 0.7,
|
| 302 |
+
marginTop: "2px",
|
| 303 |
+
}}
|
| 304 |
+
>
|
| 305 |
+
{mode.description}
|
| 306 |
+
</div>
|
| 307 |
+
</div>
|
| 308 |
+
</label>
|
| 309 |
+
);
|
| 310 |
+
})}
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
|
| 314 |
+
{/* Link to full settings modal */}
|
| 315 |
+
<div
|
| 316 |
+
style={{
|
| 317 |
+
background: "#1a1b26",
|
| 318 |
+
borderRadius: "8px",
|
| 319 |
+
padding: "16px",
|
| 320 |
+
border: "1px solid #2a2b36",
|
| 321 |
+
}}
|
| 322 |
+
>
|
| 323 |
+
<div
|
| 324 |
+
style={{
|
| 325 |
+
display: "flex",
|
| 326 |
+
justifyContent: "space-between",
|
| 327 |
+
alignItems: "center",
|
| 328 |
+
gap: "16px",
|
| 329 |
+
}}
|
| 330 |
+
>
|
| 331 |
+
<div>
|
| 332 |
+
<h4 style={{ marginBottom: "4px", fontSize: "14px" }}>
|
| 333 |
+
Full Settings
|
| 334 |
+
</h4>
|
| 335 |
+
<p style={{ fontSize: "12px", opacity: 0.7 }}>
|
| 336 |
+
Server URL, telemetry, debug logs, environment variables, and more.
|
| 337 |
+
</p>
|
| 338 |
+
</div>
|
| 339 |
+
<button
|
| 340 |
+
type="button"
|
| 341 |
+
onClick={onOpenFullSettings}
|
| 342 |
+
style={{
|
| 343 |
+
padding: "8px 16px",
|
| 344 |
+
background: "transparent",
|
| 345 |
+
color: "#93c5fd",
|
| 346 |
+
border: "1px solid #3B82F6",
|
| 347 |
+
borderRadius: "4px",
|
| 348 |
+
cursor: "pointer",
|
| 349 |
+
fontSize: "12px",
|
| 350 |
+
fontWeight: 600,
|
| 351 |
+
whiteSpace: "nowrap",
|
| 352 |
+
}}
|
| 353 |
+
>
|
| 354 |
+
Open Settings Modal
|
| 355 |
+
</button>
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
);
|
| 360 |
+
}
|
frontend/components/AdminTabs/IntegrationsTab.jsx
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/IntegrationsTab.jsx
|
| 2 |
+
import React, { useEffect, useState } from "react";
|
| 3 |
+
import { apiUrl, safeFetchJSON } from "../../utils/api.js";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Integrations tab — shows connection status for GitHub (and future
|
| 7 |
+
* third-party integrations) with Connect/Disconnect actions.
|
| 8 |
+
*
|
| 9 |
+
* Best practices applied:
|
| 10 |
+
* - Fetch current status on mount via /api/auth/status
|
| 11 |
+
* - Show connected user info if already authenticated
|
| 12 |
+
* - "Connect GitHub" button opens /api/auth/url in the same window
|
| 13 |
+
* (OAuth flow will redirect back with ?code=...)
|
| 14 |
+
* - Disconnect clears localStorage token and re-fetches status
|
| 15 |
+
* - Handles both Web OAuth and Device Flow modes
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
export default function IntegrationsTab({ userInfo, onDisconnect, showToast }) {
|
| 19 |
+
const [authStatus, setAuthStatus] = useState(null);
|
| 20 |
+
const [loading, setLoading] = useState(true);
|
| 21 |
+
const [connecting, setConnecting] = useState(false);
|
| 22 |
+
const [error, setError] = useState(null);
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
let cancelled = false;
|
| 26 |
+
(async () => {
|
| 27 |
+
try {
|
| 28 |
+
const data = await safeFetchJSON(apiUrl("/api/auth/status"), { timeout: 5000 });
|
| 29 |
+
if (!cancelled) setAuthStatus(data);
|
| 30 |
+
} catch (err) {
|
| 31 |
+
if (!cancelled) setError(err?.message || "Failed to check auth status");
|
| 32 |
+
} finally {
|
| 33 |
+
if (!cancelled) setLoading(false);
|
| 34 |
+
}
|
| 35 |
+
})();
|
| 36 |
+
return () => {
|
| 37 |
+
cancelled = true;
|
| 38 |
+
};
|
| 39 |
+
}, []);
|
| 40 |
+
|
| 41 |
+
const handleConnect = async () => {
|
| 42 |
+
setConnecting(true);
|
| 43 |
+
setError(null);
|
| 44 |
+
try {
|
| 45 |
+
if (authStatus?.mode === "web") {
|
| 46 |
+
// Web OAuth flow — redirect to GitHub authorization URL
|
| 47 |
+
const { authorization_url, state } = await safeFetchJSON(
|
| 48 |
+
apiUrl("/api/auth/url"),
|
| 49 |
+
{ timeout: 5000 }
|
| 50 |
+
);
|
| 51 |
+
if (state) {
|
| 52 |
+
sessionStorage.setItem("gitpilot_oauth_state", state);
|
| 53 |
+
}
|
| 54 |
+
// Full page redirect (OAuth providers don't support iframes)
|
| 55 |
+
window.location.href = authorization_url;
|
| 56 |
+
} else {
|
| 57 |
+
// Device flow — the LoginPage already handles this.
|
| 58 |
+
showToast?.(
|
| 59 |
+
"Device flow",
|
| 60 |
+
"GitHub device flow is configured. Sign out and sign in again to reconnect."
|
| 61 |
+
);
|
| 62 |
+
}
|
| 63 |
+
} catch (err) {
|
| 64 |
+
setError(err?.message || "Failed to start OAuth flow");
|
| 65 |
+
setConnecting(false);
|
| 66 |
+
}
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
const handleDisconnect = () => {
|
| 70 |
+
if (!window.confirm("Disconnect GitHub? You will be signed out.")) return;
|
| 71 |
+
localStorage.removeItem("github_token");
|
| 72 |
+
localStorage.removeItem("github_user");
|
| 73 |
+
onDisconnect?.();
|
| 74 |
+
showToast?.("Disconnected", "GitHub token removed.");
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
const isConnected = !!(userInfo && userInfo.login);
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<div>
|
| 81 |
+
<h3 style={{ marginBottom: "16px" }}>Integrations</h3>
|
| 82 |
+
<p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "16px" }}>
|
| 83 |
+
Connect third-party services to unlock additional GitPilot features.
|
| 84 |
+
</p>
|
| 85 |
+
|
| 86 |
+
{/* GitHub integration card */}
|
| 87 |
+
<div
|
| 88 |
+
style={{
|
| 89 |
+
background: "#1a1b26",
|
| 90 |
+
borderRadius: "8px",
|
| 91 |
+
padding: "20px",
|
| 92 |
+
border: "1px solid #2a2b36",
|
| 93 |
+
marginBottom: "16px",
|
| 94 |
+
}}
|
| 95 |
+
>
|
| 96 |
+
<div
|
| 97 |
+
style={{
|
| 98 |
+
display: "flex",
|
| 99 |
+
justifyContent: "space-between",
|
| 100 |
+
alignItems: "flex-start",
|
| 101 |
+
marginBottom: "12px",
|
| 102 |
+
}}
|
| 103 |
+
>
|
| 104 |
+
<div>
|
| 105 |
+
<h4 style={{ marginBottom: "4px" }}>GitHub</h4>
|
| 106 |
+
<p style={{ fontSize: "12px", opacity: 0.7 }}>
|
| 107 |
+
Pull requests, issues, and remote repository workflows.
|
| 108 |
+
</p>
|
| 109 |
+
</div>
|
| 110 |
+
<span
|
| 111 |
+
style={{
|
| 112 |
+
padding: "2px 10px",
|
| 113 |
+
borderRadius: "10px",
|
| 114 |
+
fontSize: "11px",
|
| 115 |
+
fontWeight: 600,
|
| 116 |
+
background: isConnected ? "#064e3b" : "#374151",
|
| 117 |
+
color: isConnected ? "#a7f3d0" : "#9ca3af",
|
| 118 |
+
border: `1px solid ${isConnected ? "#065f46" : "#4b5563"}`,
|
| 119 |
+
}}
|
| 120 |
+
>
|
| 121 |
+
{loading ? "CHECKING..." : isConnected ? "CONNECTED" : "NOT CONNECTED"}
|
| 122 |
+
</span>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
{isConnected && userInfo && (
|
| 126 |
+
<div
|
| 127 |
+
style={{
|
| 128 |
+
display: "flex",
|
| 129 |
+
alignItems: "center",
|
| 130 |
+
gap: "12px",
|
| 131 |
+
padding: "12px",
|
| 132 |
+
background: "#0d0e15",
|
| 133 |
+
borderRadius: "6px",
|
| 134 |
+
marginBottom: "12px",
|
| 135 |
+
}}
|
| 136 |
+
>
|
| 137 |
+
{userInfo.avatar_url && (
|
| 138 |
+
<img
|
| 139 |
+
src={userInfo.avatar_url}
|
| 140 |
+
alt={userInfo.login}
|
| 141 |
+
style={{ width: "36px", height: "36px", borderRadius: "50%" }}
|
| 142 |
+
/>
|
| 143 |
+
)}
|
| 144 |
+
<div>
|
| 145 |
+
<div style={{ fontSize: "13px", fontWeight: 600 }}>
|
| 146 |
+
{userInfo.name || userInfo.login}
|
| 147 |
+
</div>
|
| 148 |
+
<div style={{ fontSize: "11px", opacity: 0.6 }}>@{userInfo.login}</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
)}
|
| 152 |
+
|
| 153 |
+
{error && (
|
| 154 |
+
<div
|
| 155 |
+
role="alert"
|
| 156 |
+
style={{
|
| 157 |
+
padding: "8px 12px",
|
| 158 |
+
background: "#7f1d1d",
|
| 159 |
+
color: "#fecaca",
|
| 160 |
+
border: "1px solid #991b1b",
|
| 161 |
+
borderRadius: "4px",
|
| 162 |
+
fontSize: "11px",
|
| 163 |
+
marginBottom: "12px",
|
| 164 |
+
}}
|
| 165 |
+
>
|
| 166 |
+
{error}
|
| 167 |
+
</div>
|
| 168 |
+
)}
|
| 169 |
+
|
| 170 |
+
<div style={{ display: "flex", gap: "8px" }}>
|
| 171 |
+
{isConnected ? (
|
| 172 |
+
<button
|
| 173 |
+
type="button"
|
| 174 |
+
onClick={handleDisconnect}
|
| 175 |
+
style={{
|
| 176 |
+
padding: "8px 16px",
|
| 177 |
+
background: "transparent",
|
| 178 |
+
color: "#f87171",
|
| 179 |
+
border: "1px solid #991b1b",
|
| 180 |
+
borderRadius: "4px",
|
| 181 |
+
cursor: "pointer",
|
| 182 |
+
fontSize: "12px",
|
| 183 |
+
fontWeight: 600,
|
| 184 |
+
}}
|
| 185 |
+
>
|
| 186 |
+
Disconnect
|
| 187 |
+
</button>
|
| 188 |
+
) : (
|
| 189 |
+
<button
|
| 190 |
+
type="button"
|
| 191 |
+
onClick={handleConnect}
|
| 192 |
+
disabled={connecting || loading}
|
| 193 |
+
style={{
|
| 194 |
+
padding: "8px 16px",
|
| 195 |
+
background: connecting ? "#555" : "#3B82F6",
|
| 196 |
+
color: "#fff",
|
| 197 |
+
border: "none",
|
| 198 |
+
borderRadius: "4px",
|
| 199 |
+
cursor: connecting || loading ? "not-allowed" : "pointer",
|
| 200 |
+
fontSize: "12px",
|
| 201 |
+
fontWeight: 600,
|
| 202 |
+
display: "inline-flex",
|
| 203 |
+
alignItems: "center",
|
| 204 |
+
gap: "6px",
|
| 205 |
+
}}
|
| 206 |
+
>
|
| 207 |
+
{connecting ? "Connecting..." : "Connect GitHub"}
|
| 208 |
+
</button>
|
| 209 |
+
)}
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
{authStatus && !isConnected && (
|
| 213 |
+
<div style={{ fontSize: "10px", opacity: 0.5, marginTop: "10px" }}>
|
| 214 |
+
Auth mode: {authStatus.mode || "unknown"}
|
| 215 |
+
{authStatus.oauth_configured && " (Web OAuth)"}
|
| 216 |
+
{authStatus.pat_configured && " (Personal Access Token)"}
|
| 217 |
+
</div>
|
| 218 |
+
)}
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
{/* Placeholder for future integrations */}
|
| 222 |
+
<div
|
| 223 |
+
style={{
|
| 224 |
+
background: "#1a1b26",
|
| 225 |
+
borderRadius: "8px",
|
| 226 |
+
padding: "20px",
|
| 227 |
+
border: "1px dashed #2a2b36",
|
| 228 |
+
opacity: 0.5,
|
| 229 |
+
textAlign: "center",
|
| 230 |
+
}}
|
| 231 |
+
>
|
| 232 |
+
<p style={{ fontSize: "12px", margin: 0 }}>
|
| 233 |
+
More integrations coming soon (GitLab, Bitbucket, Jira, Slack)
|
| 234 |
+
</p>
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
);
|
| 238 |
+
}
|
frontend/components/AdminTabs/MCPServersTab.jsx
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/MCPServersTab.jsx
|
| 2 |
+
//
|
| 3 |
+
// Settings tab for managing MCP Context Forge servers.
|
| 4 |
+
//
|
| 5 |
+
// UX layout (mirrors industry-standard plugin/extension managers):
|
| 6 |
+
//
|
| 7 |
+
// ┌─ Header ─ gateway pill, totals, global "MCP enabled" toggle ─┐
|
| 8 |
+
// ├─ Sub-tabs: Installed · Catalog · Custom ────────────────────┤
|
| 9 |
+
// ├─ ServerCard list (Installed) │
|
| 10 |
+
// │ ▸ status / description / tags / tool count │
|
| 11 |
+
// │ ▸ Test · Configure · Disable · Uninstall │
|
| 12 |
+
// │ ▸ Expandable per-tool list with risk badges + toggles │
|
| 13 |
+
// └──────────────────────────────────────────────────────────────┘
|
| 14 |
+
|
| 15 |
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
| 16 |
+
import { apiUrl, safeFetchJSON } from "../../utils/api.js";
|
| 17 |
+
|
| 18 |
+
import ServerCard from "./mcp/ServerCard.jsx";
|
| 19 |
+
import CatalogList from "./mcp/CatalogList.jsx";
|
| 20 |
+
import CustomInstallForm from "./mcp/CustomInstallForm.jsx";
|
| 21 |
+
import GatewayHeader from "./mcp/GatewayHeader.jsx";
|
| 22 |
+
import SyncReport from "./mcp/SyncReport.jsx";
|
| 23 |
+
|
| 24 |
+
const TAB_INSTALLED = "installed";
|
| 25 |
+
const TAB_CATALOG = "catalog";
|
| 26 |
+
const TAB_CUSTOM = "custom";
|
| 27 |
+
|
| 28 |
+
export default function MCPServersTab({ showToast }) {
|
| 29 |
+
const [activeSubTab, setActiveSubTab] = useState(TAB_INSTALLED);
|
| 30 |
+
const [status, setStatus] = useState(null);
|
| 31 |
+
const [servers, setServers] = useState([]);
|
| 32 |
+
const [catalog, setCatalog] = useState([]);
|
| 33 |
+
const [loading, setLoading] = useState(true);
|
| 34 |
+
const [error, setError] = useState(null);
|
| 35 |
+
const [syncing, setSyncing] = useState(false);
|
| 36 |
+
const [syncReport, setSyncReport] = useState(null);
|
| 37 |
+
|
| 38 |
+
const refresh = useCallback(async () => {
|
| 39 |
+
setLoading(true);
|
| 40 |
+
setError(null);
|
| 41 |
+
try {
|
| 42 |
+
const [statusData, serversData, catalogData] = await Promise.all([
|
| 43 |
+
safeFetchJSON(apiUrl("/api/mcp/status"), { timeout: 5000 }),
|
| 44 |
+
safeFetchJSON(apiUrl("/api/mcp/servers"), { timeout: 5000 }),
|
| 45 |
+
safeFetchJSON(apiUrl("/api/mcp/catalog"), { timeout: 5000 }),
|
| 46 |
+
]);
|
| 47 |
+
setStatus(statusData);
|
| 48 |
+
setServers(serversData?.servers || []);
|
| 49 |
+
setCatalog(catalogData?.items || []);
|
| 50 |
+
} catch (err) {
|
| 51 |
+
setError(err?.message || "Failed to load MCP server state");
|
| 52 |
+
} finally {
|
| 53 |
+
setLoading(false);
|
| 54 |
+
}
|
| 55 |
+
}, []);
|
| 56 |
+
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
refresh();
|
| 59 |
+
}, [refresh]);
|
| 60 |
+
|
| 61 |
+
const post = useCallback(
|
| 62 |
+
async (path, body) => {
|
| 63 |
+
try {
|
| 64 |
+
const res = await fetch(apiUrl(path), {
|
| 65 |
+
method: "POST",
|
| 66 |
+
headers: { "Content-Type": "application/json" },
|
| 67 |
+
body: body ? JSON.stringify(body) : undefined,
|
| 68 |
+
});
|
| 69 |
+
if (!res.ok) {
|
| 70 |
+
const detail = await res.json().catch(() => ({}));
|
| 71 |
+
throw new Error(detail?.detail || `HTTP ${res.status}`);
|
| 72 |
+
}
|
| 73 |
+
return await res.json();
|
| 74 |
+
} catch (err) {
|
| 75 |
+
showToast?.("MCP error", err?.message || String(err));
|
| 76 |
+
throw err;
|
| 77 |
+
}
|
| 78 |
+
},
|
| 79 |
+
[showToast]
|
| 80 |
+
);
|
| 81 |
+
|
| 82 |
+
// ---- Server actions -----------------------------------------------------
|
| 83 |
+
const onEnableServer = async (id) => {
|
| 84 |
+
await post(`/api/mcp/servers/${id}/enable`);
|
| 85 |
+
showToast?.("Server enabled", id);
|
| 86 |
+
await refresh();
|
| 87 |
+
};
|
| 88 |
+
const onDisableServer = async (id) => {
|
| 89 |
+
await post(`/api/mcp/servers/${id}/disable`);
|
| 90 |
+
showToast?.("Server disabled", id);
|
| 91 |
+
await refresh();
|
| 92 |
+
};
|
| 93 |
+
const onUninstallServer = async (id) => {
|
| 94 |
+
if (
|
| 95 |
+
!window.confirm(
|
| 96 |
+
`Uninstall ${id}? GitPilot will stop calling its tools immediately.`
|
| 97 |
+
)
|
| 98 |
+
)
|
| 99 |
+
return;
|
| 100 |
+
await post(`/api/mcp/servers/${id}/uninstall`);
|
| 101 |
+
showToast?.("Server uninstalled", id);
|
| 102 |
+
await refresh();
|
| 103 |
+
};
|
| 104 |
+
const onTestServer = async (id) => {
|
| 105 |
+
const result = await post(`/api/mcp/servers/${id}/test`);
|
| 106 |
+
if (result?.ok) {
|
| 107 |
+
showToast?.("Server healthy", id);
|
| 108 |
+
} else {
|
| 109 |
+
showToast?.(
|
| 110 |
+
"Server unreachable",
|
| 111 |
+
result?.reason || result?.error || "Unknown error"
|
| 112 |
+
);
|
| 113 |
+
}
|
| 114 |
+
return result;
|
| 115 |
+
};
|
| 116 |
+
const onSync = useCallback(async () => {
|
| 117 |
+
setSyncing(true);
|
| 118 |
+
setSyncReport(null);
|
| 119 |
+
try {
|
| 120 |
+
const report = await post("/api/mcp/sync", {});
|
| 121 |
+
setSyncReport(report);
|
| 122 |
+
const total =
|
| 123 |
+
(report.added?.length || 0) +
|
| 124 |
+
(report.kept?.length || 0) +
|
| 125 |
+
(report.orphaned?.length || 0);
|
| 126 |
+
showToast?.(
|
| 127 |
+
report.forge_unreachable ? "Sync failed" : "Sync complete",
|
| 128 |
+
report.forge_unreachable
|
| 129 |
+
? report.error || "forge unreachable"
|
| 130 |
+
: `+${report.added?.length || 0} added · ${total} total`
|
| 131 |
+
);
|
| 132 |
+
await refresh();
|
| 133 |
+
} catch {
|
| 134 |
+
// post() already toasted; nothing more to do.
|
| 135 |
+
} finally {
|
| 136 |
+
setSyncing(false);
|
| 137 |
+
}
|
| 138 |
+
}, [post, refresh, showToast]);
|
| 139 |
+
|
| 140 |
+
const onForgetOrphan = async (id) => {
|
| 141 |
+
if (
|
| 142 |
+
!window.confirm(
|
| 143 |
+
`Forget ${id}? It will be removed from the local list.\n` +
|
| 144 |
+
"Re-attach it to MCP Context Forge then click Sync to bring it back."
|
| 145 |
+
)
|
| 146 |
+
)
|
| 147 |
+
return;
|
| 148 |
+
await post(`/api/mcp/servers/${id}/forget`);
|
| 149 |
+
showToast?.("Server forgotten", id);
|
| 150 |
+
await refresh();
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
const onToggleTool = async (serverId, toolName, enabled) => {
|
| 154 |
+
await post(
|
| 155 |
+
`/api/mcp/servers/${serverId}/tools/${encodeURIComponent(
|
| 156 |
+
toolName
|
| 157 |
+
)}/toggle`,
|
| 158 |
+
{ enabled }
|
| 159 |
+
);
|
| 160 |
+
await refresh();
|
| 161 |
+
};
|
| 162 |
+
|
| 163 |
+
const onInstallFromCatalog = async (serverId) => {
|
| 164 |
+
await post("/api/mcp/servers/install", { server_id: serverId });
|
| 165 |
+
showToast?.("Installed", `${serverId} (disabled until you enable it)`);
|
| 166 |
+
await refresh();
|
| 167 |
+
setActiveSubTab(TAB_INSTALLED);
|
| 168 |
+
};
|
| 169 |
+
|
| 170 |
+
const onInstallCustom = async (registerJson) => {
|
| 171 |
+
await post("/api/mcp/servers/install-custom", {
|
| 172 |
+
register_json: registerJson,
|
| 173 |
+
});
|
| 174 |
+
showToast?.("Custom server added", registerJson.name);
|
| 175 |
+
await refresh();
|
| 176 |
+
setActiveSubTab(TAB_INSTALLED);
|
| 177 |
+
};
|
| 178 |
+
|
| 179 |
+
// ---- Derived totals -----------------------------------------------------
|
| 180 |
+
const installedCount = servers.filter((s) => s.installed).length;
|
| 181 |
+
const enabledCount = servers.filter((s) => s.installed && s.enabled).length;
|
| 182 |
+
const totalTools = useMemo(
|
| 183 |
+
() => servers.reduce((acc, s) => acc + (s.tool_count || 0), 0),
|
| 184 |
+
[servers]
|
| 185 |
+
);
|
| 186 |
+
|
| 187 |
+
return (
|
| 188 |
+
<div>
|
| 189 |
+
<GatewayHeader
|
| 190 |
+
status={status}
|
| 191 |
+
installedCount={installedCount}
|
| 192 |
+
enabledCount={enabledCount}
|
| 193 |
+
totalTools={totalTools}
|
| 194 |
+
onRefresh={refresh}
|
| 195 |
+
onSync={onSync}
|
| 196 |
+
syncing={syncing}
|
| 197 |
+
/>
|
| 198 |
+
|
| 199 |
+
{syncReport && (
|
| 200 |
+
<SyncReport
|
| 201 |
+
report={syncReport}
|
| 202 |
+
onDismiss={() => setSyncReport(null)}
|
| 203 |
+
/>
|
| 204 |
+
)}
|
| 205 |
+
|
| 206 |
+
{/* Sub-tab strip */}
|
| 207 |
+
<div
|
| 208 |
+
role="tablist"
|
| 209 |
+
style={{
|
| 210 |
+
display: "flex",
|
| 211 |
+
gap: "4px",
|
| 212 |
+
marginBottom: "16px",
|
| 213 |
+
borderBottom: "1px solid #2a2b36",
|
| 214 |
+
}}
|
| 215 |
+
>
|
| 216 |
+
{[
|
| 217 |
+
{ id: TAB_INSTALLED, label: `Installed (${installedCount})` },
|
| 218 |
+
{ id: TAB_CATALOG, label: `Catalog (${catalog.length})` },
|
| 219 |
+
{ id: TAB_CUSTOM, label: "Custom" },
|
| 220 |
+
].map((t) => (
|
| 221 |
+
<button
|
| 222 |
+
key={t.id}
|
| 223 |
+
role="tab"
|
| 224 |
+
aria-selected={activeSubTab === t.id}
|
| 225 |
+
onClick={() => setActiveSubTab(t.id)}
|
| 226 |
+
style={{
|
| 227 |
+
padding: "10px 16px",
|
| 228 |
+
border: "none",
|
| 229 |
+
background: "transparent",
|
| 230 |
+
color: activeSubTab === t.id ? "#93c5fd" : "#a0a0b0",
|
| 231 |
+
borderBottom:
|
| 232 |
+
activeSubTab === t.id
|
| 233 |
+
? "2px solid #3B82F6"
|
| 234 |
+
: "2px solid transparent",
|
| 235 |
+
cursor: "pointer",
|
| 236 |
+
fontSize: "13px",
|
| 237 |
+
fontWeight: activeSubTab === t.id ? 600 : 400,
|
| 238 |
+
}}
|
| 239 |
+
>
|
| 240 |
+
{t.label}
|
| 241 |
+
</button>
|
| 242 |
+
))}
|
| 243 |
+
</div>
|
| 244 |
+
|
| 245 |
+
{error && (
|
| 246 |
+
<div
|
| 247 |
+
role="alert"
|
| 248 |
+
style={{
|
| 249 |
+
padding: "12px",
|
| 250 |
+
background: "#5c1a1a",
|
| 251 |
+
border: "1px solid #8a2a2a",
|
| 252 |
+
borderRadius: "6px",
|
| 253 |
+
marginBottom: "16px",
|
| 254 |
+
color: "#fecaca",
|
| 255 |
+
fontSize: "13px",
|
| 256 |
+
}}
|
| 257 |
+
>
|
| 258 |
+
{error}
|
| 259 |
+
</div>
|
| 260 |
+
)}
|
| 261 |
+
|
| 262 |
+
{loading && !servers.length && (
|
| 263 |
+
<p style={{ color: "#a0a0b0", fontSize: "13px" }}>Loading…</p>
|
| 264 |
+
)}
|
| 265 |
+
|
| 266 |
+
{activeSubTab === TAB_INSTALLED && !loading && (
|
| 267 |
+
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
| 268 |
+
{servers.length === 0 && (
|
| 269 |
+
<EmptyState
|
| 270 |
+
title="No MCP servers installed"
|
| 271 |
+
hint="Browse the catalog or paste a register.json under Custom."
|
| 272 |
+
actionLabel="Browse catalog"
|
| 273 |
+
onAction={() => setActiveSubTab(TAB_CATALOG)}
|
| 274 |
+
/>
|
| 275 |
+
)}
|
| 276 |
+
{servers.map((s) => (
|
| 277 |
+
<ServerCard
|
| 278 |
+
key={s.id}
|
| 279 |
+
server={s}
|
| 280 |
+
onEnable={() => onEnableServer(s.id)}
|
| 281 |
+
onDisable={() => onDisableServer(s.id)}
|
| 282 |
+
onUninstall={() => onUninstallServer(s.id)}
|
| 283 |
+
onTest={() => onTestServer(s.id)}
|
| 284 |
+
onForget={s.orphan ? () => onForgetOrphan(s.id) : undefined}
|
| 285 |
+
onToggleTool={(tool, enabled) =>
|
| 286 |
+
onToggleTool(s.id, tool, enabled)
|
| 287 |
+
}
|
| 288 |
+
/>
|
| 289 |
+
))}
|
| 290 |
+
</div>
|
| 291 |
+
)}
|
| 292 |
+
|
| 293 |
+
{activeSubTab === TAB_CATALOG && !loading && (
|
| 294 |
+
<CatalogList items={catalog} onInstall={onInstallFromCatalog} />
|
| 295 |
+
)}
|
| 296 |
+
|
| 297 |
+
{activeSubTab === TAB_CUSTOM && (
|
| 298 |
+
<CustomInstallForm onSubmit={onInstallCustom} />
|
| 299 |
+
)}
|
| 300 |
+
</div>
|
| 301 |
+
);
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
function EmptyState({ title, hint, actionLabel, onAction }) {
|
| 305 |
+
return (
|
| 306 |
+
<div
|
| 307 |
+
style={{
|
| 308 |
+
padding: "32px",
|
| 309 |
+
background: "#1a1b26",
|
| 310 |
+
border: "1px dashed #2a2b36",
|
| 311 |
+
borderRadius: "8px",
|
| 312 |
+
textAlign: "center",
|
| 313 |
+
}}
|
| 314 |
+
>
|
| 315 |
+
<h4 style={{ margin: "0 0 8px 0", color: "#e0e0e7" }}>{title}</h4>
|
| 316 |
+
<p style={{ margin: "0 0 16px 0", color: "#a0a0b0", fontSize: "13px" }}>
|
| 317 |
+
{hint}
|
| 318 |
+
</p>
|
| 319 |
+
{actionLabel && (
|
| 320 |
+
<button
|
| 321 |
+
onClick={onAction}
|
| 322 |
+
style={{
|
| 323 |
+
padding: "8px 16px",
|
| 324 |
+
background: "#3B82F6",
|
| 325 |
+
color: "#fff",
|
| 326 |
+
border: "none",
|
| 327 |
+
borderRadius: "6px",
|
| 328 |
+
cursor: "pointer",
|
| 329 |
+
fontSize: "13px",
|
| 330 |
+
}}
|
| 331 |
+
>
|
| 332 |
+
{actionLabel}
|
| 333 |
+
</button>
|
| 334 |
+
)}
|
| 335 |
+
</div>
|
| 336 |
+
);
|
| 337 |
+
}
|
frontend/components/AdminTabs/MatrixLabInstallModal.jsx
ADDED
|
@@ -0,0 +1,1013 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/MatrixLabInstallModal.jsx
|
| 2 |
+
import React, { useCallback, useEffect, useState } from "react";
|
| 3 |
+
import { apiUrl } from "../../utils/api.js";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* MatrixLab install modal — the "addon store" experience.
|
| 7 |
+
*
|
| 8 |
+
* Drives a single state machine off /api/matrixlab/* so the user sees
|
| 9 |
+
* one coherent state at a time (not "Unreachable" + "Running" + raw
|
| 10 |
+
* stack-trace at once). Default view exposes a single primary button;
|
| 11 |
+
* Runner URL / token / image / network / timeout live behind an
|
| 12 |
+
* Advanced disclosure.
|
| 13 |
+
*
|
| 14 |
+
* Status names mirror the backend MatrixLabStatus enum:
|
| 15 |
+
* not_installed | installing | starting | stopping | checking |
|
| 16 |
+
* ready | needs_attention | failed
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
// Progress checklists vary by which lifecycle the operator launched.
|
| 20 |
+
// The active "current step" key is set on the busy state so the
|
| 21 |
+
// checklist tracks the user's chosen journey, not a generic install.
|
| 22 |
+
const PROGRESS_STEPS = {
|
| 23 |
+
install: [
|
| 24 |
+
{ key: "system", label: "Checking system" },
|
| 25 |
+
{ key: "install", label: "Downloading MatrixLab" },
|
| 26 |
+
{ key: "start", label: "Starting runner" },
|
| 27 |
+
{ key: "test", label: "Testing connection" },
|
| 28 |
+
{ key: "activate", label: "Activating MatrixLab" },
|
| 29 |
+
],
|
| 30 |
+
repair: [
|
| 31 |
+
{ key: "system", label: "Checking runner URL" },
|
| 32 |
+
{ key: "repair", label: "Restarting runner" },
|
| 33 |
+
{ key: "test", label: "Testing connection" },
|
| 34 |
+
{ key: "activate", label: "Activating MatrixLab" },
|
| 35 |
+
],
|
| 36 |
+
reinstall: [
|
| 37 |
+
{ key: "system", label: "Stopping current runner" },
|
| 38 |
+
{ key: "remove", label: "Removing old addon" },
|
| 39 |
+
{ key: "install", label: "Downloading fresh version" },
|
| 40 |
+
{ key: "start", label: "Starting runner" },
|
| 41 |
+
{ key: "test", label: "Testing connection" },
|
| 42 |
+
{ key: "activate", label: "Activating MatrixLab" },
|
| 43 |
+
],
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
// Primary action label and the journey it triggers, keyed by the
|
| 47 |
+
// addon's normalised status. Mapping is exhaustive so the modal
|
| 48 |
+
// always has a clear "next thing to do" button.
|
| 49 |
+
const PRIMARY_BY_STATUS = {
|
| 50 |
+
not_installed: { label: "Install and Start", action: "install" },
|
| 51 |
+
installing: { label: "Installing…", action: null },
|
| 52 |
+
starting: { label: "Starting…", action: null },
|
| 53 |
+
stopping: { label: "Stopping…", action: null },
|
| 54 |
+
checking: { label: "Checking…", action: null },
|
| 55 |
+
needs_attention: { label: "Repair connection", action: "repair" },
|
| 56 |
+
failed: { label: "Retry installation", action: "install" },
|
| 57 |
+
ready: { label: "Done", action: "done" },
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
function statusPill(status) {
|
| 61 |
+
const map = {
|
| 62 |
+
not_installed: { label: "Not installed", bg: "#374151", fg: "#d1d5db" },
|
| 63 |
+
installing: { label: "Installing", bg: "#0d3320", fg: "#86efac" },
|
| 64 |
+
starting: { label: "Starting", bg: "#0d3320", fg: "#86efac" },
|
| 65 |
+
stopping: { label: "Stopping", bg: "#3d2d11", fg: "#fde68a" },
|
| 66 |
+
checking: { label: "Checking", bg: "#0d3320", fg: "#86efac" },
|
| 67 |
+
ready: { label: "Ready", bg: "#0d3320", fg: "#86efac" },
|
| 68 |
+
needs_attention: { label: "Needs attention", bg: "#3d2d11", fg: "#fde68a" },
|
| 69 |
+
failed: { label: "Failed", bg: "#3d1111", fg: "#fca5a5" },
|
| 70 |
+
};
|
| 71 |
+
return map[status] || map.not_installed;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export default function MatrixLabInstallModal({ onClose, onActivated }) {
|
| 75 |
+
const [status, setStatus] = useState(null); // MatrixLabStatus from backend
|
| 76 |
+
const [busy, setBusy] = useState(false);
|
| 77 |
+
// Which journey is currently in flight ("install" | "repair" |
|
| 78 |
+
// "reinstall") — drives the progress checklist's stage list.
|
| 79 |
+
const [journey, setJourney] = useState(null);
|
| 80 |
+
const [progressStep, setProgressStep] = useState(null);
|
| 81 |
+
const [showAdvanced, setShowAdvanced] = useState(false);
|
| 82 |
+
const [showDetails, setShowDetails] = useState(false);
|
| 83 |
+
const [logs, setLogs] = useState(null);
|
| 84 |
+
const [showLogs, setShowLogs] = useState(false);
|
| 85 |
+
// Reinstall confirmation prompt + the destructive "wipe images" opt-in.
|
| 86 |
+
const [reinstallConfirm, setReinstallConfirm] = useState(false);
|
| 87 |
+
const [reinstallWipe, setReinstallWipe] = useState(false);
|
| 88 |
+
|
| 89 |
+
// Mirror of /api/sandbox/status for the Advanced panel.
|
| 90 |
+
const [advanced, setAdvanced] = useState(null);
|
| 91 |
+
const [tokenInput, setTokenInput] = useState("");
|
| 92 |
+
|
| 93 |
+
const refresh = useCallback(async () => {
|
| 94 |
+
try {
|
| 95 |
+
const r = await fetch(apiUrl("/api/matrixlab/status"));
|
| 96 |
+
const data = await r.json();
|
| 97 |
+
setStatus(data);
|
| 98 |
+
} catch (err) {
|
| 99 |
+
setStatus({
|
| 100 |
+
status: "failed",
|
| 101 |
+
message: "GitPilot backend could not return a MatrixLab status.",
|
| 102 |
+
errorCode: "BACKEND_UNREACHABLE",
|
| 103 |
+
technicalDetails: { rawError: err?.message || String(err) },
|
| 104 |
+
});
|
| 105 |
+
}
|
| 106 |
+
}, []);
|
| 107 |
+
|
| 108 |
+
const refreshAdvanced = useCallback(async () => {
|
| 109 |
+
try {
|
| 110 |
+
const r = await fetch(apiUrl("/api/sandbox/status"));
|
| 111 |
+
const data = await r.json();
|
| 112 |
+
setAdvanced(data);
|
| 113 |
+
} catch (err) {
|
| 114 |
+
// Non-fatal — Advanced just stays empty.
|
| 115 |
+
}
|
| 116 |
+
}, []);
|
| 117 |
+
|
| 118 |
+
useEffect(() => {
|
| 119 |
+
refresh();
|
| 120 |
+
refreshAdvanced();
|
| 121 |
+
}, [refresh, refreshAdvanced]);
|
| 122 |
+
|
| 123 |
+
// Run a sequence of phased POSTs against /api/matrixlab/* and walk
|
| 124 |
+
// the progress checklist as each one returns. Used for install,
|
| 125 |
+
// repair, and reinstall — they differ only in which endpoint kicks
|
| 126 |
+
// off the journey.
|
| 127 |
+
const runJourney = useCallback(async (journeyKey, kickoffPath, kickoffBody) => {
|
| 128 |
+
setBusy(true);
|
| 129 |
+
setJourney(journeyKey);
|
| 130 |
+
setShowDetails(false);
|
| 131 |
+
try {
|
| 132 |
+
// Phase 1: kickoff (install / repair / reinstall). Each backend
|
| 133 |
+
// endpoint is itself idempotent and aggregates several docker
|
| 134 |
+
// commands; the modal walks the checklist on the way.
|
| 135 |
+
setProgressStep(journeyKey === "reinstall" ? "system" : journeyKey);
|
| 136 |
+
let r = await fetch(apiUrl(kickoffPath), {
|
| 137 |
+
method: "POST",
|
| 138 |
+
headers: kickoffBody ? { "Content-Type": "application/json" } : undefined,
|
| 139 |
+
body: kickoffBody ? JSON.stringify(kickoffBody) : undefined,
|
| 140 |
+
});
|
| 141 |
+
let data = await r.json();
|
| 142 |
+
setStatus(data);
|
| 143 |
+
if (data.status === "failed") return;
|
| 144 |
+
|
| 145 |
+
// Phase 2: explicit connection test so we never claim "ready"
|
| 146 |
+
// without a green health probe.
|
| 147 |
+
setProgressStep("test");
|
| 148 |
+
r = await fetch(apiUrl("/api/matrixlab/test"), { method: "POST" });
|
| 149 |
+
data = await r.json();
|
| 150 |
+
setStatus(data);
|
| 151 |
+
if (data.status !== "ready") return;
|
| 152 |
+
|
| 153 |
+
// Phase 3: activate (always safe to call when ready).
|
| 154 |
+
setProgressStep("activate");
|
| 155 |
+
r = await fetch(apiUrl("/api/matrixlab/activate"), { method: "POST" });
|
| 156 |
+
data = await r.json();
|
| 157 |
+
setStatus(data);
|
| 158 |
+
if (data.status === "ready" && data.activeSandbox === "matrixlab") {
|
| 159 |
+
onActivated?.(data);
|
| 160 |
+
}
|
| 161 |
+
} catch (err) {
|
| 162 |
+
setStatus({
|
| 163 |
+
status: "failed",
|
| 164 |
+
message: `MatrixLab ${journeyKey} could not complete.`,
|
| 165 |
+
errorCode: "NETWORK_ERROR",
|
| 166 |
+
technicalDetails: { rawError: err?.message || String(err) },
|
| 167 |
+
});
|
| 168 |
+
} finally {
|
| 169 |
+
setBusy(false);
|
| 170 |
+
setProgressStep(null);
|
| 171 |
+
setJourney(null);
|
| 172 |
+
refreshAdvanced();
|
| 173 |
+
}
|
| 174 |
+
}, [onActivated, refreshAdvanced]);
|
| 175 |
+
|
| 176 |
+
const runInstall = useCallback(() => runJourney("install", "/api/matrixlab/install"), [runJourney]);
|
| 177 |
+
const runRepair = useCallback(() => runJourney("repair", "/api/matrixlab/repair"), [runJourney]);
|
| 178 |
+
const runReinstall = useCallback((removeData) => runJourney(
|
| 179 |
+
"reinstall", "/api/matrixlab/reinstall", { remove_data: !!removeData },
|
| 180 |
+
), [runJourney]);
|
| 181 |
+
// Cheap recovery: persist the discovered URL without restarting the
|
| 182 |
+
// runner. Most "needs_attention" cases are stale URLs from the
|
| 183 |
+
// :8000 → :8765 port move, and this resolves them in <1s.
|
| 184 |
+
const runUrlAutoDetect = useCallback(
|
| 185 |
+
() => runJourney("repair", "/api/matrixlab/url/auto-detect"),
|
| 186 |
+
[runJourney],
|
| 187 |
+
);
|
| 188 |
+
|
| 189 |
+
const openLogs = useCallback(async () => {
|
| 190 |
+
setShowLogs(true);
|
| 191 |
+
try {
|
| 192 |
+
const r = await fetch(apiUrl("/api/matrixlab/logs?tail=200"));
|
| 193 |
+
const data = await r.json();
|
| 194 |
+
setLogs(data);
|
| 195 |
+
} catch (err) {
|
| 196 |
+
setLogs({ ok: false, error: err?.message || String(err), lines: [] });
|
| 197 |
+
}
|
| 198 |
+
}, []);
|
| 199 |
+
|
| 200 |
+
const onPrimary = () => {
|
| 201 |
+
if (!status) return;
|
| 202 |
+
const next = (PRIMARY_BY_STATUS[status.status] || PRIMARY_BY_STATUS.not_installed).action;
|
| 203 |
+
if (!next) return; // in-flight states have no primary action
|
| 204 |
+
if (next === "done") return onClose?.();
|
| 205 |
+
if (next === "install") return runInstall();
|
| 206 |
+
if (next === "repair") return runRepair();
|
| 207 |
+
};
|
| 208 |
+
|
| 209 |
+
const updateAdvanced = async (patch) => {
|
| 210 |
+
try {
|
| 211 |
+
const r = await fetch(apiUrl("/api/sandbox/config"), {
|
| 212 |
+
method: "PUT",
|
| 213 |
+
headers: { "Content-Type": "application/json" },
|
| 214 |
+
body: JSON.stringify(patch),
|
| 215 |
+
});
|
| 216 |
+
const data = await r.json();
|
| 217 |
+
if (r.ok) {
|
| 218 |
+
setAdvanced((prev) => ({ ...(prev || {}), ...data }));
|
| 219 |
+
if ("matrixlab_token" in patch) setTokenInput("");
|
| 220 |
+
// Re-probe the addon status — URL / token changes may make us
|
| 221 |
+
// reachable again.
|
| 222 |
+
refresh();
|
| 223 |
+
}
|
| 224 |
+
} catch (err) {
|
| 225 |
+
// surfaced through the next refresh
|
| 226 |
+
}
|
| 227 |
+
};
|
| 228 |
+
|
| 229 |
+
if (!status) {
|
| 230 |
+
return (
|
| 231 |
+
<Backdrop onClose={onClose}>
|
| 232 |
+
<ModalShell title="Install MatrixLab Addon" subtitle="Loading…" onClose={onClose}>
|
| 233 |
+
<div style={{ padding: 40, textAlign: "center", opacity: 0.6 }}>
|
| 234 |
+
Checking MatrixLab status…
|
| 235 |
+
</div>
|
| 236 |
+
</ModalShell>
|
| 237 |
+
</Backdrop>
|
| 238 |
+
);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
const pill = statusPill(status.status);
|
| 242 |
+
|
| 243 |
+
return (
|
| 244 |
+
<Backdrop onClose={onClose}>
|
| 245 |
+
<ModalShell
|
| 246 |
+
title="Install MatrixLab Addon"
|
| 247 |
+
subtitle="Run code safely in isolated, temporary containers."
|
| 248 |
+
onClose={onClose}
|
| 249 |
+
>
|
| 250 |
+
{/* Status row */}
|
| 251 |
+
<div style={{
|
| 252 |
+
display: "flex", alignItems: "center", gap: 10,
|
| 253 |
+
padding: "12px 14px", background: "#0e0f24",
|
| 254 |
+
border: "1px solid #2c2d46", borderRadius: 6, marginBottom: 14,
|
| 255 |
+
}}>
|
| 256 |
+
<span style={{
|
| 257 |
+
display: "inline-flex", alignItems: "center", gap: 6,
|
| 258 |
+
fontSize: 11, fontWeight: 600, padding: "2px 10px", borderRadius: 12,
|
| 259 |
+
background: pill.bg, color: pill.fg,
|
| 260 |
+
}}>
|
| 261 |
+
<span style={{ width: 6, height: 6, borderRadius: "50%", background: pill.fg }} />
|
| 262 |
+
{pill.label}
|
| 263 |
+
</span>
|
| 264 |
+
<div style={{ flex: 1 }}>
|
| 265 |
+
<div style={{ fontSize: 13, color: "#e6e8ff" }}>{status.message}</div>
|
| 266 |
+
{status.runnerUrl && status.status !== "ready" && (
|
| 267 |
+
<div style={{ fontSize: 11, color: "#9092b5", marginTop: 2 }}>
|
| 268 |
+
Runner URL: <code style={{ color: "#c3c5dd" }}>{status.runnerUrl}</code>
|
| 269 |
+
</div>
|
| 270 |
+
)}
|
| 271 |
+
{/* Stale-URL recovery: backend probes candidate ports
|
| 272 |
+
when the configured URL is down. When a live one is
|
| 273 |
+
found we offer a one-click switch — this fixes the
|
| 274 |
+
very common "MatrixLab is installed but GitPilot
|
| 275 |
+
cannot connect" after the :8000 → :8765 port move. */}
|
| 276 |
+
{status.discoveredUrl && status.discoveredUrl !== status.runnerUrl && (
|
| 277 |
+
<div style={{
|
| 278 |
+
marginTop: 6, padding: "6px 10px", fontSize: 12,
|
| 279 |
+
background: "#0d3320", color: "#86efac",
|
| 280 |
+
border: "1px solid #166534", borderRadius: 4,
|
| 281 |
+
display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap",
|
| 282 |
+
}}>
|
| 283 |
+
<span>
|
| 284 |
+
Found a live runner at{" "}
|
| 285 |
+
<code style={{ background: "#000", padding: "1px 4px", borderRadius: 3 }}>
|
| 286 |
+
{status.discoveredUrl}
|
| 287 |
+
</code>
|
| 288 |
+
</span>
|
| 289 |
+
<button
|
| 290 |
+
type="button"
|
| 291 |
+
onClick={runUrlAutoDetect}
|
| 292 |
+
disabled={busy}
|
| 293 |
+
style={{
|
| 294 |
+
padding: "3px 10px", fontSize: 11, fontWeight: 600,
|
| 295 |
+
background: "#10B981", color: "#052e1c",
|
| 296 |
+
border: "0", borderRadius: 4, cursor: "pointer",
|
| 297 |
+
}}
|
| 298 |
+
>
|
| 299 |
+
Use this URL
|
| 300 |
+
</button>
|
| 301 |
+
</div>
|
| 302 |
+
)}
|
| 303 |
+
</div>
|
| 304 |
+
</div>
|
| 305 |
+
|
| 306 |
+
{/* Inline body — copy depends on state */}
|
| 307 |
+
{status.status === "not_installed" && (
|
| 308 |
+
<p style={{ fontSize: 13, opacity: 0.8, lineHeight: 1.55, marginBottom: 16 }}>
|
| 309 |
+
MatrixLab gives GitPilot an isolated sandbox for running code,
|
| 310 |
+
testing snippets, and executing agent actions safely. It will be
|
| 311 |
+
downloaded, started, and connected automatically.
|
| 312 |
+
</p>
|
| 313 |
+
)}
|
| 314 |
+
|
| 315 |
+
{(busy || progressStep) && (
|
| 316 |
+
<ProgressChecklist journey={journey} current={progressStep} />
|
| 317 |
+
)}
|
| 318 |
+
|
| 319 |
+
{status.status === "ready" && (
|
| 320 |
+
<Checklist
|
| 321 |
+
items={[
|
| 322 |
+
["Installed", true],
|
| 323 |
+
["Running", true],
|
| 324 |
+
["Connection verified", true],
|
| 325 |
+
["Set as active sandbox", status.activeSandbox === "matrixlab"],
|
| 326 |
+
]}
|
| 327 |
+
/>
|
| 328 |
+
)}
|
| 329 |
+
|
| 330 |
+
{/* Lifecycle disabled hint — friendly copy, admin detail under disclosure */}
|
| 331 |
+
{status.errorCode === "LIFECYCLE_DISABLED" && (
|
| 332 |
+
<div style={{
|
| 333 |
+
background: "#2a210d", border: "1px solid #854d0e",
|
| 334 |
+
borderRadius: 6, padding: 10, fontSize: 12,
|
| 335 |
+
color: "#fde68a", marginBottom: 12,
|
| 336 |
+
}}>
|
| 337 |
+
MatrixLab lifecycle automation is disabled. This GitPilot backend
|
| 338 |
+
was started with{" "}
|
| 339 |
+
<code style={{ background: "#1a1b26", padding: "1px 4px", borderRadius: 3 }}>
|
| 340 |
+
GITPILOT_ENABLE_MATRIXLAB_LIFECYCLE=0
|
| 341 |
+
</code>{" "}
|
| 342 |
+
— restart it without that variable (the default is enabled), or
|
| 343 |
+
use Manual setup under Advanced options.
|
| 344 |
+
</div>
|
| 345 |
+
)}
|
| 346 |
+
|
| 347 |
+
{/* Technical details disclosure — only visible when there's an error */}
|
| 348 |
+
{status.technicalDetails && (status.status === "needs_attention" || status.status === "failed") && (
|
| 349 |
+
<details
|
| 350 |
+
open={showDetails}
|
| 351 |
+
onToggle={(e) => setShowDetails(e.target.open)}
|
| 352 |
+
style={{ marginBottom: 12 }}
|
| 353 |
+
>
|
| 354 |
+
<summary style={{ cursor: "pointer", fontSize: 12, color: "#9092b5" }}>
|
| 355 |
+
Technical details
|
| 356 |
+
</summary>
|
| 357 |
+
<pre style={{
|
| 358 |
+
marginTop: 8, padding: 10, background: "#000",
|
| 359 |
+
border: "1px solid #2c2d46", borderRadius: 4,
|
| 360 |
+
fontSize: 11, color: "#fca5a5",
|
| 361 |
+
fontFamily: "ui-monospace, monospace",
|
| 362 |
+
whiteSpace: "pre-wrap", overflow: "auto", maxHeight: 200,
|
| 363 |
+
}}>
|
| 364 |
+
{status.technicalDetails.expected &&
|
| 365 |
+
`Expected: ${status.technicalDetails.expected}\n`}
|
| 366 |
+
{status.technicalDetails.actual &&
|
| 367 |
+
`Actual: ${status.technicalDetails.actual}\n`}
|
| 368 |
+
{status.technicalDetails.rawError &&
|
| 369 |
+
`\n${status.technicalDetails.rawError}`}
|
| 370 |
+
</pre>
|
| 371 |
+
</details>
|
| 372 |
+
)}
|
| 373 |
+
|
| 374 |
+
{/* Action buttons — state-aware. Reinstall is offered as a
|
| 375 |
+
normal recovery action (not buried in Advanced) once the
|
| 376 |
+
addon exists in some form; Open logs surfaces only when
|
| 377 |
+
there's something to diagnose. */}
|
| 378 |
+
{(() => {
|
| 379 |
+
const primary = PRIMARY_BY_STATUS[status.status] || PRIMARY_BY_STATUS.not_installed;
|
| 380 |
+
const canReinstall = status.installed === true ||
|
| 381 |
+
["needs_attention", "failed", "ready"].includes(status.status);
|
| 382 |
+
const canSeeLogs = ["needs_attention", "failed"].includes(status.status);
|
| 383 |
+
return (
|
| 384 |
+
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
|
| 385 |
+
<button
|
| 386 |
+
type="button"
|
| 387 |
+
onClick={onPrimary}
|
| 388 |
+
disabled={busy || primary.action === null}
|
| 389 |
+
style={{
|
| 390 |
+
padding: "10px 20px", fontSize: 13, fontWeight: 600,
|
| 391 |
+
background: busy ? "#1e3a5f" : "#3B82F6",
|
| 392 |
+
color: "#fff", border: "none", borderRadius: 6,
|
| 393 |
+
cursor: busy ? "wait" : "pointer",
|
| 394 |
+
minWidth: 180,
|
| 395 |
+
}}
|
| 396 |
+
>
|
| 397 |
+
{primary.label}
|
| 398 |
+
</button>
|
| 399 |
+
|
| 400 |
+
{canReinstall && (
|
| 401 |
+
<button
|
| 402 |
+
type="button"
|
| 403 |
+
onClick={() => setReinstallConfirm(true)}
|
| 404 |
+
disabled={busy}
|
| 405 |
+
style={status.status === "ready" ? btnDanger : btnSecondary}
|
| 406 |
+
title="Stop, remove, and pull a fresh copy of MatrixLab"
|
| 407 |
+
>
|
| 408 |
+
Reinstall addon
|
| 409 |
+
</button>
|
| 410 |
+
)}
|
| 411 |
+
|
| 412 |
+
{canSeeLogs && (
|
| 413 |
+
<button type="button" onClick={openLogs} disabled={busy} style={btnSecondary}>
|
| 414 |
+
Open logs
|
| 415 |
+
</button>
|
| 416 |
+
)}
|
| 417 |
+
|
| 418 |
+
{status.status === "ready" && (
|
| 419 |
+
<button type="button" onClick={runRepair} disabled={busy} style={btnSecondary}>
|
| 420 |
+
Run test snippet
|
| 421 |
+
</button>
|
| 422 |
+
)}
|
| 423 |
+
</div>
|
| 424 |
+
);
|
| 425 |
+
})()}
|
| 426 |
+
|
| 427 |
+
{/* Logs viewer */}
|
| 428 |
+
{showLogs && (
|
| 429 |
+
<div style={{ marginTop: 14 }}>
|
| 430 |
+
<div style={{
|
| 431 |
+
display: "flex", justifyContent: "space-between",
|
| 432 |
+
alignItems: "baseline", marginBottom: 6,
|
| 433 |
+
}}>
|
| 434 |
+
<div style={{ fontSize: 12, fontWeight: 600, color: "#c3c5dd" }}>
|
| 435 |
+
MatrixLab logs
|
| 436 |
+
{logs?.container && (
|
| 437 |
+
<span style={{
|
| 438 |
+
marginLeft: 8, fontSize: 11, color: "#9092b5",
|
| 439 |
+
fontFamily: "ui-monospace, monospace", fontWeight: 400,
|
| 440 |
+
}}>
|
| 441 |
+
· {logs.container}
|
| 442 |
+
</span>
|
| 443 |
+
)}
|
| 444 |
+
</div>
|
| 445 |
+
<button
|
| 446 |
+
type="button"
|
| 447 |
+
onClick={openLogs}
|
| 448 |
+
disabled={busy}
|
| 449 |
+
style={{
|
| 450 |
+
fontSize: 11, padding: "2px 8px",
|
| 451 |
+
background: "transparent", color: "#9092b5",
|
| 452 |
+
border: "1px solid #2c2d46", borderRadius: 4, cursor: "pointer",
|
| 453 |
+
}}
|
| 454 |
+
>
|
| 455 |
+
↻ Refresh
|
| 456 |
+
</button>
|
| 457 |
+
</div>
|
| 458 |
+
|
| 459 |
+
{/* Friendly error + hint when the backend can't read logs.
|
| 460 |
+
We show the hint as an actionable next step (run
|
| 461 |
+
"make install-matrixlab") and list any matrixlab-
|
| 462 |
+
shaped containers we found so the user can see whether
|
| 463 |
+
the runner is up under a different name. */}
|
| 464 |
+
{logs?.ok === false && (
|
| 465 |
+
<div style={{
|
| 466 |
+
marginBottom: 6, padding: 10,
|
| 467 |
+
background: "#2a210d", border: "1px solid #854d0e",
|
| 468 |
+
borderRadius: 4, fontSize: 12, color: "#fde68a",
|
| 469 |
+
}}>
|
| 470 |
+
<div style={{ fontWeight: 600 }}>{logs.error || "Could not read MatrixLab logs."}</div>
|
| 471 |
+
{logs.hint && (
|
| 472 |
+
<div style={{ marginTop: 4, fontSize: 11, color: "#fcd34d" }}>
|
| 473 |
+
Next step:{" "}
|
| 474 |
+
<code style={{
|
| 475 |
+
background: "#000", padding: "1px 6px",
|
| 476 |
+
borderRadius: 3, color: "#86efac",
|
| 477 |
+
}}>
|
| 478 |
+
{logs.hint}
|
| 479 |
+
</code>
|
| 480 |
+
</div>
|
| 481 |
+
)}
|
| 482 |
+
{Array.isArray(logs.candidates) && logs.candidates.length > 0 && (
|
| 483 |
+
<div style={{ marginTop: 6, fontSize: 11 }}>
|
| 484 |
+
Found other matrixlab-shaped containers:
|
| 485 |
+
<ul style={{ margin: "4px 0 0", paddingLeft: 18 }}>
|
| 486 |
+
{logs.candidates.map((c, i) => (
|
| 487 |
+
<li key={i}>
|
| 488 |
+
<code style={{ color: "#c3c5dd" }}>{c}</code>
|
| 489 |
+
</li>
|
| 490 |
+
))}
|
| 491 |
+
</ul>
|
| 492 |
+
<div style={{ marginTop: 4, fontSize: 10, color: "#a1a1aa" }}>
|
| 493 |
+
Set <code>GITPILOT_MATRIXLAB_CONTAINER</code> to use one of these,
|
| 494 |
+
or run Reinstall to recreate the expected container.
|
| 495 |
+
</div>
|
| 496 |
+
</div>
|
| 497 |
+
)}
|
| 498 |
+
{logs.rawError && (
|
| 499 |
+
<details style={{ marginTop: 6 }}>
|
| 500 |
+
<summary style={{ cursor: "pointer", fontSize: 11, color: "#fbbf24" }}>
|
| 501 |
+
docker stderr
|
| 502 |
+
</summary>
|
| 503 |
+
<pre style={{
|
| 504 |
+
marginTop: 4, padding: 6, fontSize: 10,
|
| 505 |
+
background: "#000", color: "#fca5a5",
|
| 506 |
+
borderRadius: 4, whiteSpace: "pre-wrap",
|
| 507 |
+
}}>
|
| 508 |
+
{logs.rawError}
|
| 509 |
+
</pre>
|
| 510 |
+
</details>
|
| 511 |
+
)}
|
| 512 |
+
</div>
|
| 513 |
+
)}
|
| 514 |
+
|
| 515 |
+
<pre style={{
|
| 516 |
+
margin: 0, padding: 10, background: "#000",
|
| 517 |
+
border: "1px solid #2c2d46", borderRadius: 4,
|
| 518 |
+
fontSize: 11, color: "#D4D4D8",
|
| 519 |
+
fontFamily: "ui-monospace, monospace",
|
| 520 |
+
maxHeight: 220, overflow: "auto", whiteSpace: "pre-wrap",
|
| 521 |
+
}}>
|
| 522 |
+
{logs == null
|
| 523 |
+
? "Loading logs…"
|
| 524 |
+
: (logs.lines && logs.lines.length > 0)
|
| 525 |
+
? logs.lines.join("\n")
|
| 526 |
+
: (logs.ok === false ? "(no log output captured)" : "Loading logs…")}
|
| 527 |
+
</pre>
|
| 528 |
+
</div>
|
| 529 |
+
)}
|
| 530 |
+
|
| 531 |
+
{/* Advanced options — collapsed by default so first-time
|
| 532 |
+
users see the install/repair flow, not a wall of knobs.
|
| 533 |
+
Operators who need runner URL / token / image / network /
|
| 534 |
+
timeout / manual setup / local clone install / unsafe
|
| 535 |
+
modes click the disclosure to expand. */}
|
| 536 |
+
<details
|
| 537 |
+
open={showAdvanced}
|
| 538 |
+
onToggle={(e) => setShowAdvanced(e.target.open)}
|
| 539 |
+
style={{ marginTop: 14 }}
|
| 540 |
+
>
|
| 541 |
+
<summary style={{
|
| 542 |
+
cursor: "pointer", fontSize: 12, color: "#9092b5",
|
| 543 |
+
padding: "6px 0", listStyle: "none",
|
| 544 |
+
}}>
|
| 545 |
+
Advanced options
|
| 546 |
+
</summary>
|
| 547 |
+
<AdvancedOptions
|
| 548 |
+
advanced={advanced}
|
| 549 |
+
tokenInput={tokenInput}
|
| 550 |
+
setTokenInput={setTokenInput}
|
| 551 |
+
onUpdate={updateAdvanced}
|
| 552 |
+
disabled={busy}
|
| 553 |
+
onLocalStatus={(data) => {
|
| 554 |
+
setStatus(data);
|
| 555 |
+
refreshAdvanced();
|
| 556 |
+
}}
|
| 557 |
+
/>
|
| 558 |
+
</details>
|
| 559 |
+
|
| 560 |
+
{reinstallConfirm && (
|
| 561 |
+
<ReinstallConfirm
|
| 562 |
+
wipe={reinstallWipe}
|
| 563 |
+
setWipe={setReinstallWipe}
|
| 564 |
+
onCancel={() => {
|
| 565 |
+
setReinstallConfirm(false);
|
| 566 |
+
setReinstallWipe(false);
|
| 567 |
+
}}
|
| 568 |
+
onConfirm={() => {
|
| 569 |
+
const wipe = reinstallWipe;
|
| 570 |
+
setReinstallConfirm(false);
|
| 571 |
+
setReinstallWipe(false);
|
| 572 |
+
runReinstall(wipe);
|
| 573 |
+
}}
|
| 574 |
+
/>
|
| 575 |
+
)}
|
| 576 |
+
</ModalShell>
|
| 577 |
+
</Backdrop>
|
| 578 |
+
);
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
function ReinstallConfirm({ wipe, setWipe, onCancel, onConfirm }) {
|
| 582 |
+
// Modal-in-modal stacked at zIndex 110 (Backdrop is 100). Apes
|
| 583 |
+
// GitHub's "Are you absolutely sure?" pattern: explicit destructive
|
| 584 |
+
// checkbox stays off by default so a misclick can't wipe images.
|
| 585 |
+
return (
|
| 586 |
+
<div onClick={onCancel} style={{
|
| 587 |
+
position: "fixed", inset: 0, background: "rgba(0,0,0,0.65)",
|
| 588 |
+
display: "flex", alignItems: "center", justifyContent: "center",
|
| 589 |
+
zIndex: 110,
|
| 590 |
+
}}>
|
| 591 |
+
<div onClick={(e) => e.stopPropagation()} style={{
|
| 592 |
+
width: "min(440px, 92vw)", background: "#1a1b26",
|
| 593 |
+
border: "1px solid #2a2b36", borderRadius: 10, padding: 20,
|
| 594 |
+
color: "#e6e8ff",
|
| 595 |
+
}}>
|
| 596 |
+
<h4 style={{ margin: "0 0 6px", fontSize: 15 }}>Reinstall MatrixLab Addon?</h4>
|
| 597 |
+
<p style={{ fontSize: 13, opacity: 0.8, lineHeight: 1.55, marginBottom: 14 }}>
|
| 598 |
+
This will stop MatrixLab, remove the current addon container, download a
|
| 599 |
+
fresh copy, start it again, and reconnect GitPilot. Your GitPilot
|
| 600 |
+
settings will be kept.
|
| 601 |
+
</p>
|
| 602 |
+
<label style={{
|
| 603 |
+
display: "flex", gap: 8, alignItems: "flex-start",
|
| 604 |
+
padding: "8px 10px", borderRadius: 6, marginBottom: 14,
|
| 605 |
+
border: "1px solid #2c2d46", background: "#0e0f24",
|
| 606 |
+
fontSize: 12, cursor: "pointer",
|
| 607 |
+
}}>
|
| 608 |
+
<input
|
| 609 |
+
type="checkbox"
|
| 610 |
+
checked={wipe}
|
| 611 |
+
onChange={(e) => setWipe(e.target.checked)}
|
| 612 |
+
style={{ marginTop: 2 }}
|
| 613 |
+
/>
|
| 614 |
+
<span>
|
| 615 |
+
<strong style={{ color: "#fde68a" }}>Also remove local MatrixLab data</strong>
|
| 616 |
+
<span style={{ display: "block", color: "#9092b5", marginTop: 2 }}>
|
| 617 |
+
Deletes the cached MatrixLab runner image. Sandbox images and
|
| 618 |
+
workspace data are preserved.
|
| 619 |
+
</span>
|
| 620 |
+
</span>
|
| 621 |
+
</label>
|
| 622 |
+
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
| 623 |
+
<button type="button" onClick={onCancel} style={btnSecondary}>Cancel</button>
|
| 624 |
+
<button type="button" onClick={onConfirm} style={wipe ? btnDanger : btnPrimary}>
|
| 625 |
+
{wipe ? "Reinstall and remove data" : "Reinstall MatrixLab"}
|
| 626 |
+
</button>
|
| 627 |
+
</div>
|
| 628 |
+
</div>
|
| 629 |
+
</div>
|
| 630 |
+
);
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
function ProgressChecklist({ journey, current }) {
|
| 634 |
+
// Pick the right stage list for the journey in flight; default to
|
| 635 |
+
// the install list (the most informative one) when no journey is
|
| 636 |
+
// active yet. Once ``current`` advances past the kickoff key, the
|
| 637 |
+
// visited rows render with a ✓.
|
| 638 |
+
const steps = PROGRESS_STEPS[journey] || PROGRESS_STEPS.install;
|
| 639 |
+
const phaseIndex = current ? Math.max(0, steps.findIndex(s => s.key === current)) : 0;
|
| 640 |
+
return (
|
| 641 |
+
<div style={{ marginBottom: 14 }}>
|
| 642 |
+
{steps.map((s, i) => {
|
| 643 |
+
const done = i < phaseIndex;
|
| 644 |
+
const active = i === phaseIndex;
|
| 645 |
+
return (
|
| 646 |
+
<div key={s.key} style={{
|
| 647 |
+
display: "flex", alignItems: "center", gap: 8,
|
| 648 |
+
fontSize: 12, padding: "3px 0",
|
| 649 |
+
color: done ? "#86efac" : active ? "#e6e8ff" : "#9092b5",
|
| 650 |
+
}}>
|
| 651 |
+
<span style={{ width: 16, textAlign: "center" }}>
|
| 652 |
+
{done ? "✓" : active ? "⏳" : "○"}
|
| 653 |
+
</span>
|
| 654 |
+
{s.label}
|
| 655 |
+
</div>
|
| 656 |
+
);
|
| 657 |
+
})}
|
| 658 |
+
</div>
|
| 659 |
+
);
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
function Checklist({ items }) {
|
| 663 |
+
return (
|
| 664 |
+
<div style={{ marginBottom: 14 }}>
|
| 665 |
+
{items.map(([label, ok]) => (
|
| 666 |
+
<div key={label} style={{
|
| 667 |
+
display: "flex", alignItems: "center", gap: 8,
|
| 668 |
+
fontSize: 12, padding: "3px 0",
|
| 669 |
+
color: ok ? "#86efac" : "#9092b5",
|
| 670 |
+
}}>
|
| 671 |
+
<span style={{ width: 16, textAlign: "center" }}>{ok ? "✓" : "○"}</span>
|
| 672 |
+
{label}
|
| 673 |
+
</div>
|
| 674 |
+
))}
|
| 675 |
+
</div>
|
| 676 |
+
);
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
function LocalCloneSection({ disabled, onStatus }) {
|
| 680 |
+
const [native, setNative] = useState(null);
|
| 681 |
+
const [busy, setBusy] = useState(null); // "install" | "start" | "stop"
|
| 682 |
+
|
| 683 |
+
const refresh = useCallback(async () => {
|
| 684 |
+
try {
|
| 685 |
+
const r = await fetch(apiUrl("/api/matrixlab/native/status"));
|
| 686 |
+
setNative(await r.json());
|
| 687 |
+
} catch (err) {
|
| 688 |
+
// non-fatal — leave section empty
|
| 689 |
+
}
|
| 690 |
+
}, []);
|
| 691 |
+
|
| 692 |
+
useEffect(() => {
|
| 693 |
+
refresh();
|
| 694 |
+
}, [refresh]);
|
| 695 |
+
|
| 696 |
+
const runAction = useCallback(async (action) => {
|
| 697 |
+
setBusy(action);
|
| 698 |
+
try {
|
| 699 |
+
const path = {
|
| 700 |
+
install: "/api/matrixlab/install_local",
|
| 701 |
+
start: "/api/matrixlab/start_local",
|
| 702 |
+
stop: "/api/matrixlab/stop_local",
|
| 703 |
+
}[action];
|
| 704 |
+
const r = await fetch(apiUrl(path), { method: "POST" });
|
| 705 |
+
const data = await r.json();
|
| 706 |
+
onStatus?.(data);
|
| 707 |
+
} finally {
|
| 708 |
+
setBusy(null);
|
| 709 |
+
refresh();
|
| 710 |
+
}
|
| 711 |
+
}, [onStatus, refresh]);
|
| 712 |
+
|
| 713 |
+
if (!native) return null;
|
| 714 |
+
|
| 715 |
+
return (
|
| 716 |
+
<details style={{ marginTop: 12 }}>
|
| 717 |
+
<summary style={{ cursor: "pointer", fontSize: 11, color: "#9092b5" }}>
|
| 718 |
+
Local clone install (no Docker for the runner itself)
|
| 719 |
+
</summary>
|
| 720 |
+
<div style={{ marginTop: 8, fontSize: 11, color: "#9092b5", lineHeight: 1.6 }}>
|
| 721 |
+
Clones MatrixLab into <code style={{ color: "#c3c5dd" }}>{native.local_dir}</code>,
|
| 722 |
+
creates a dedicated Python virtualenv, and runs <code style={{ color: "#c3c5dd" }}>uvicorn app.main:app</code>
|
| 723 |
+
from the runner directory. The Runner still spawns per-language sandboxes
|
| 724 |
+
via Docker, so the host needs <code style={{ color: "#c3c5dd" }}>docker</code>{" "}
|
| 725 |
+
on PATH for code execution.
|
| 726 |
+
</div>
|
| 727 |
+
<div style={{ marginTop: 10, display: "flex", flexWrap: "wrap", gap: 8 }}>
|
| 728 |
+
<span style={{
|
| 729 |
+
display: "inline-flex", alignItems: "center", gap: 6,
|
| 730 |
+
fontSize: 11, padding: "2px 10px", borderRadius: 10,
|
| 731 |
+
background: native.running ? "#0d3320" : native.installed ? "#3d2d11" : "#374151",
|
| 732 |
+
color: native.running ? "#86efac" : native.installed ? "#fde68a" : "#d1d5db",
|
| 733 |
+
}}>
|
| 734 |
+
<span style={{
|
| 735 |
+
width: 6, height: 6, borderRadius: "50%",
|
| 736 |
+
background: native.running ? "#10B981" : native.installed ? "#f59e0b" : "#9ca3af",
|
| 737 |
+
}} />
|
| 738 |
+
{native.running ? "Running (PID " + native.pid + ")"
|
| 739 |
+
: native.installed ? "Installed · stopped"
|
| 740 |
+
: "Not installed"}
|
| 741 |
+
</span>
|
| 742 |
+
{!native.installed && (
|
| 743 |
+
<button
|
| 744 |
+
type="button"
|
| 745 |
+
disabled={disabled || busy != null || !native.lifecycleEnabled}
|
| 746 |
+
onClick={() => runAction("install")}
|
| 747 |
+
style={btnPrimarySmall}
|
| 748 |
+
>
|
| 749 |
+
{busy === "install" ? "Cloning + installing…" : "Clone and install"}
|
| 750 |
+
</button>
|
| 751 |
+
)}
|
| 752 |
+
{native.installed && !native.running && (
|
| 753 |
+
<button
|
| 754 |
+
type="button"
|
| 755 |
+
disabled={disabled || busy != null || !native.lifecycleEnabled}
|
| 756 |
+
onClick={() => runAction("start")}
|
| 757 |
+
style={btnPrimarySmall}
|
| 758 |
+
>
|
| 759 |
+
{busy === "start" ? "Starting…" : "Start runner"}
|
| 760 |
+
</button>
|
| 761 |
+
)}
|
| 762 |
+
{native.running && (
|
| 763 |
+
<button
|
| 764 |
+
type="button"
|
| 765 |
+
disabled={disabled || busy != null || !native.lifecycleEnabled}
|
| 766 |
+
onClick={() => runAction("stop")}
|
| 767 |
+
style={btnSecondarySmall}
|
| 768 |
+
>
|
| 769 |
+
{busy === "stop" ? "Stopping…" : "Stop runner"}
|
| 770 |
+
</button>
|
| 771 |
+
)}
|
| 772 |
+
<button
|
| 773 |
+
type="button"
|
| 774 |
+
disabled={busy != null}
|
| 775 |
+
onClick={refresh}
|
| 776 |
+
style={btnSecondarySmall}
|
| 777 |
+
>
|
| 778 |
+
Refresh
|
| 779 |
+
</button>
|
| 780 |
+
</div>
|
| 781 |
+
{!native.lifecycleEnabled && (
|
| 782 |
+
<div style={{
|
| 783 |
+
marginTop: 8, padding: "6px 8px",
|
| 784 |
+
background: "#2a210d", border: "1px solid #854d0e",
|
| 785 |
+
borderRadius: 4, fontSize: 11, color: "#fde68a",
|
| 786 |
+
}}>
|
| 787 |
+
Local clone install needs lifecycle automation enabled on the GitPilot
|
| 788 |
+
backend.
|
| 789 |
+
</div>
|
| 790 |
+
)}
|
| 791 |
+
</details>
|
| 792 |
+
);
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
function AdvancedOptions({ advanced, tokenInput, setTokenInput, onUpdate, disabled, onLocalStatus }) {
|
| 796 |
+
if (!advanced) return null;
|
| 797 |
+
return (
|
| 798 |
+
<div style={{
|
| 799 |
+
marginTop: 14, padding: 12,
|
| 800 |
+
background: "#0e0f24", border: "1px solid #2c2d46",
|
| 801 |
+
borderRadius: 6,
|
| 802 |
+
}}>
|
| 803 |
+
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 10, color: "#c3c5dd" }}>
|
| 804 |
+
Advanced options
|
| 805 |
+
</div>
|
| 806 |
+
<div style={{ display: "grid", gridTemplateColumns: "120px 1fr", gap: 8, alignItems: "center" }}>
|
| 807 |
+
<label style={fieldLabel}>Runner URL</label>
|
| 808 |
+
<input
|
| 809 |
+
type="text"
|
| 810 |
+
defaultValue={advanced.matrixlab_url || ""}
|
| 811 |
+
onBlur={(e) => onUpdate({ matrixlab_url: e.target.value })}
|
| 812 |
+
placeholder="http://localhost:8765"
|
| 813 |
+
disabled={disabled}
|
| 814 |
+
style={fieldInput}
|
| 815 |
+
/>
|
| 816 |
+
|
| 817 |
+
<label style={fieldLabel}>Bearer token</label>
|
| 818 |
+
<div style={{ display: "flex", gap: 6 }}>
|
| 819 |
+
<input
|
| 820 |
+
type="password"
|
| 821 |
+
value={tokenInput}
|
| 822 |
+
onChange={(e) => setTokenInput(e.target.value)}
|
| 823 |
+
placeholder={advanced.has_token ? "•••••••• (saved)" : "Optional"}
|
| 824 |
+
disabled={disabled}
|
| 825 |
+
style={{ ...fieldInput, flex: 1 }}
|
| 826 |
+
/>
|
| 827 |
+
<button
|
| 828 |
+
type="button"
|
| 829 |
+
onClick={() => onUpdate({ matrixlab_token: tokenInput })}
|
| 830 |
+
disabled={disabled}
|
| 831 |
+
style={btnSecondary}
|
| 832 |
+
>
|
| 833 |
+
Save token
|
| 834 |
+
</button>
|
| 835 |
+
</div>
|
| 836 |
+
|
| 837 |
+
<label style={fieldLabel}>Default image</label>
|
| 838 |
+
<input
|
| 839 |
+
type="text"
|
| 840 |
+
defaultValue={advanced.matrixlab_image || ""}
|
| 841 |
+
onBlur={(e) => onUpdate({ matrixlab_image: e.target.value })}
|
| 842 |
+
placeholder="matrixlab-python"
|
| 843 |
+
disabled={disabled}
|
| 844 |
+
style={fieldInput}
|
| 845 |
+
/>
|
| 846 |
+
|
| 847 |
+
<label style={fieldLabel}>Network access</label>
|
| 848 |
+
<label style={{ display: "flex", gap: 6, alignItems: "center", fontSize: 12, color: "#c3c5dd" }}>
|
| 849 |
+
<input
|
| 850 |
+
type="checkbox"
|
| 851 |
+
checked={!!advanced.allow_network}
|
| 852 |
+
disabled={disabled}
|
| 853 |
+
onChange={(e) => onUpdate({ allow_network: e.target.checked })}
|
| 854 |
+
/>
|
| 855 |
+
Allow network egress
|
| 856 |
+
</label>
|
| 857 |
+
|
| 858 |
+
<label style={fieldLabel}>Timeout</label>
|
| 859 |
+
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
| 860 |
+
<input
|
| 861 |
+
type="number"
|
| 862 |
+
min={1}
|
| 863 |
+
max={600}
|
| 864 |
+
defaultValue={advanced.timeout_sec || 120}
|
| 865 |
+
onBlur={(e) => onUpdate({ timeout_sec: Number(e.target.value) || 120 })}
|
| 866 |
+
disabled={disabled}
|
| 867 |
+
style={{ ...fieldInput, width: 80 }}
|
| 868 |
+
/>
|
| 869 |
+
<span style={{ fontSize: 11, color: "#9092b5" }}>seconds</span>
|
| 870 |
+
</div>
|
| 871 |
+
</div>
|
| 872 |
+
|
| 873 |
+
<LocalCloneSection disabled={disabled} onStatus={onLocalStatus} />
|
| 874 |
+
|
| 875 |
+
<details style={{ marginTop: 12 }}>
|
| 876 |
+
<summary style={{ cursor: "pointer", fontSize: 11, color: "#9092b5" }}>
|
| 877 |
+
Manual setup
|
| 878 |
+
</summary>
|
| 879 |
+
<pre style={{
|
| 880 |
+
margin: "8px 0 0", padding: 10, background: "#000",
|
| 881 |
+
border: "1px solid #2c2d46", borderRadius: 4,
|
| 882 |
+
fontSize: 11, color: "#D4D4D8",
|
| 883 |
+
fontFamily: "ui-monospace, monospace",
|
| 884 |
+
}}>{`# In a MatrixLab checkout:
|
| 885 |
+
docker compose up -d
|
| 886 |
+
|
| 887 |
+
# Or directly:
|
| 888 |
+
docker run -d --name gitpilot-matrixlab \\
|
| 889 |
+
-p 8000:8000 \\
|
| 890 |
+
-v /var/run/docker.sock:/var/run/docker.sock \\
|
| 891 |
+
ruslanmv/matrixlab-runner:latest`}</pre>
|
| 892 |
+
</details>
|
| 893 |
+
|
| 894 |
+
<details style={{ marginTop: 12 }}>
|
| 895 |
+
<summary style={{ cursor: "pointer", fontSize: 11, color: "#9092b5" }}>
|
| 896 |
+
Developer options · Unsafe modes
|
| 897 |
+
</summary>
|
| 898 |
+
<div style={{ marginTop: 8, fontSize: 11, color: "#fca5a5" }}>
|
| 899 |
+
Pass-through runs code directly on the host without isolation. Use
|
| 900 |
+
only for local development.
|
| 901 |
+
</div>
|
| 902 |
+
</details>
|
| 903 |
+
</div>
|
| 904 |
+
);
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
function Backdrop({ children, onClose }) {
|
| 908 |
+
return (
|
| 909 |
+
<div
|
| 910 |
+
onClick={onClose}
|
| 911 |
+
style={{
|
| 912 |
+
position: "fixed", inset: 0,
|
| 913 |
+
background: "rgba(0,0,0,0.6)",
|
| 914 |
+
display: "flex", alignItems: "center", justifyContent: "center",
|
| 915 |
+
zIndex: 100,
|
| 916 |
+
}}
|
| 917 |
+
>
|
| 918 |
+
<div onClick={(e) => e.stopPropagation()}>{children}</div>
|
| 919 |
+
</div>
|
| 920 |
+
);
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
function ModalShell({ title, subtitle, onClose, children }) {
|
| 924 |
+
return (
|
| 925 |
+
<div style={{
|
| 926 |
+
width: "min(640px, 92vw)",
|
| 927 |
+
maxHeight: "90vh", overflow: "auto",
|
| 928 |
+
background: "#1a1b26",
|
| 929 |
+
border: "1px solid #2a2b36",
|
| 930 |
+
borderRadius: 10,
|
| 931 |
+
padding: 20,
|
| 932 |
+
color: "#e6e8ff",
|
| 933 |
+
}}>
|
| 934 |
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 4 }}>
|
| 935 |
+
<h3 style={{ margin: 0, fontSize: 18 }}>{title}</h3>
|
| 936 |
+
<button
|
| 937 |
+
type="button"
|
| 938 |
+
onClick={onClose}
|
| 939 |
+
style={{
|
| 940 |
+
background: "transparent", border: "none",
|
| 941 |
+
color: "#9092b5", cursor: "pointer", fontSize: 18,
|
| 942 |
+
}}
|
| 943 |
+
aria-label="Close"
|
| 944 |
+
>
|
| 945 |
+
✕
|
| 946 |
+
</button>
|
| 947 |
+
</div>
|
| 948 |
+
<div style={{ fontSize: 12, color: "#9092b5", marginBottom: 16 }}>{subtitle}</div>
|
| 949 |
+
{children}
|
| 950 |
+
</div>
|
| 951 |
+
);
|
| 952 |
+
}
|
| 953 |
+
|
| 954 |
+
const btnSecondary = {
|
| 955 |
+
padding: "8px 14px",
|
| 956 |
+
fontSize: 12,
|
| 957 |
+
background: "transparent",
|
| 958 |
+
color: "#c3c5dd",
|
| 959 |
+
border: "1px solid #2c2d46",
|
| 960 |
+
borderRadius: 6,
|
| 961 |
+
cursor: "pointer",
|
| 962 |
+
};
|
| 963 |
+
|
| 964 |
+
const btnPrimary = {
|
| 965 |
+
padding: "8px 14px",
|
| 966 |
+
fontSize: 12,
|
| 967 |
+
fontWeight: 600,
|
| 968 |
+
background: "#3B82F6",
|
| 969 |
+
color: "#fff",
|
| 970 |
+
border: "none",
|
| 971 |
+
borderRadius: 6,
|
| 972 |
+
cursor: "pointer",
|
| 973 |
+
};
|
| 974 |
+
|
| 975 |
+
// "Reinstall and remove data" is destructive — surface it with a
|
| 976 |
+
// danger tone so the operator pauses before clicking.
|
| 977 |
+
const btnDanger = {
|
| 978 |
+
padding: "8px 14px",
|
| 979 |
+
fontSize: 12,
|
| 980 |
+
fontWeight: 600,
|
| 981 |
+
background: "#7f1d1d",
|
| 982 |
+
color: "#fecaca",
|
| 983 |
+
border: "1px solid #991b1b",
|
| 984 |
+
borderRadius: 6,
|
| 985 |
+
cursor: "pointer",
|
| 986 |
+
};
|
| 987 |
+
|
| 988 |
+
const btnGhost = {
|
| 989 |
+
padding: "8px 14px",
|
| 990 |
+
fontSize: 12,
|
| 991 |
+
background: "transparent",
|
| 992 |
+
color: "#9092b5",
|
| 993 |
+
border: "none",
|
| 994 |
+
cursor: "pointer",
|
| 995 |
+
};
|
| 996 |
+
|
| 997 |
+
const fieldLabel = { fontSize: 12, color: "#c3c5dd" };
|
| 998 |
+
const fieldInput = {
|
| 999 |
+
fontSize: 12, padding: "4px 6px",
|
| 1000 |
+
background: "#14152a", color: "#e6e8ff",
|
| 1001 |
+
border: "1px solid #2c2d46", borderRadius: 4,
|
| 1002 |
+
};
|
| 1003 |
+
|
| 1004 |
+
const btnPrimarySmall = {
|
| 1005 |
+
padding: "4px 10px", fontSize: 11, fontWeight: 600,
|
| 1006 |
+
background: "#3B82F6", color: "#fff",
|
| 1007 |
+
border: "none", borderRadius: 4, cursor: "pointer",
|
| 1008 |
+
};
|
| 1009 |
+
const btnSecondarySmall = {
|
| 1010 |
+
padding: "4px 10px", fontSize: 11,
|
| 1011 |
+
background: "transparent", color: "#c3c5dd",
|
| 1012 |
+
border: "1px solid #2c2d46", borderRadius: 4, cursor: "pointer",
|
| 1013 |
+
};
|
frontend/components/AdminTabs/SandboxTab.jsx
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/SandboxTab.jsx
|
| 2 |
+
import React, { useCallback, useEffect, useState } from "react";
|
| 3 |
+
import { apiUrl } from "../../utils/api.js";
|
| 4 |
+
import MatrixLabInstallModal from "./MatrixLabInstallModal.jsx";
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Sandbox tab — enterprise default view.
|
| 8 |
+
*
|
| 9 |
+
* Surfaces a single MatrixLab addon card plus a clean Local-sandbox
|
| 10 |
+
* card. Implementation knobs (Runner URL, token, image, network,
|
| 11 |
+
* timeout, pass-through, lifecycle env flag) live behind the install
|
| 12 |
+
* modal's Advanced disclosure — the primary view never shows them.
|
| 13 |
+
*
|
| 14 |
+
* Status pill is driven off /api/matrixlab/status so we never end up
|
| 15 |
+
* with the "Unreachable + Running" contradiction the legacy panel
|
| 16 |
+
* produced.
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
function StatusPill({ status }) {
|
| 20 |
+
const map = {
|
| 21 |
+
not_installed: { label: "Not installed", bg: "#374151", fg: "#d1d5db" },
|
| 22 |
+
installing: { label: "Installing", bg: "#0d3320", fg: "#86efac" },
|
| 23 |
+
starting: { label: "Starting", bg: "#0d3320", fg: "#86efac" },
|
| 24 |
+
stopping: { label: "Stopping", bg: "#3d2d11", fg: "#fde68a" },
|
| 25 |
+
checking: { label: "Checking", bg: "#0d3320", fg: "#86efac" },
|
| 26 |
+
ready: { label: "Ready", bg: "#0d3320", fg: "#86efac" },
|
| 27 |
+
needs_attention: { label: "Needs attention", bg: "#3d2d11", fg: "#fde68a" },
|
| 28 |
+
failed: { label: "Failed", bg: "#3d1111", fg: "#fca5a5" },
|
| 29 |
+
};
|
| 30 |
+
const pill = map[status] || map.not_installed;
|
| 31 |
+
return (
|
| 32 |
+
<span style={{
|
| 33 |
+
display: "inline-flex", alignItems: "center", gap: 6,
|
| 34 |
+
fontSize: 11, fontWeight: 600, padding: "2px 10px", borderRadius: 12,
|
| 35 |
+
background: pill.bg, color: pill.fg,
|
| 36 |
+
}}>
|
| 37 |
+
<span style={{ width: 6, height: 6, borderRadius: "50%", background: pill.fg }} />
|
| 38 |
+
{pill.label}
|
| 39 |
+
</span>
|
| 40 |
+
);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export default function SandboxTab({ showToast }) {
|
| 44 |
+
const [matrixlab, setMatrixlab] = useState(null); // /api/matrixlab/status payload
|
| 45 |
+
const [sandbox, setSandbox] = useState(null); // /api/sandbox/status payload
|
| 46 |
+
const [loading, setLoading] = useState(true);
|
| 47 |
+
const [modalOpen, setModalOpen] = useState(false);
|
| 48 |
+
|
| 49 |
+
const load = useCallback(async () => {
|
| 50 |
+
try {
|
| 51 |
+
const [ml, sb] = await Promise.all([
|
| 52 |
+
fetch(apiUrl("/api/matrixlab/status")).then((r) => r.json()).catch(() => null),
|
| 53 |
+
fetch(apiUrl("/api/sandbox/status")).then((r) => r.json()).catch(() => null),
|
| 54 |
+
]);
|
| 55 |
+
setMatrixlab(ml);
|
| 56 |
+
setSandbox(sb);
|
| 57 |
+
} finally {
|
| 58 |
+
setLoading(false);
|
| 59 |
+
}
|
| 60 |
+
}, []);
|
| 61 |
+
|
| 62 |
+
useEffect(() => {
|
| 63 |
+
load();
|
| 64 |
+
}, [load]);
|
| 65 |
+
|
| 66 |
+
const useLocal = useCallback(async () => {
|
| 67 |
+
try {
|
| 68 |
+
const r = await fetch(apiUrl("/api/sandbox/config"), {
|
| 69 |
+
method: "PUT",
|
| 70 |
+
headers: { "Content-Type": "application/json" },
|
| 71 |
+
body: JSON.stringify({ backend: "subprocess" }),
|
| 72 |
+
});
|
| 73 |
+
const data = await r.json();
|
| 74 |
+
if (r.ok) {
|
| 75 |
+
setSandbox(data);
|
| 76 |
+
showToast?.("Switched to Local sandbox", "Code runs in a host subprocess with a workspace jail.");
|
| 77 |
+
}
|
| 78 |
+
} catch (err) {
|
| 79 |
+
// surfaced through the next load()
|
| 80 |
+
}
|
| 81 |
+
}, [showToast]);
|
| 82 |
+
|
| 83 |
+
if (loading) {
|
| 84 |
+
return (
|
| 85 |
+
<div>
|
| 86 |
+
<h3 style={{ marginBottom: 16 }}>Sandbox</h3>
|
| 87 |
+
<div style={{
|
| 88 |
+
background: "#1a1b26", borderRadius: 8,
|
| 89 |
+
padding: "40px 20px", textAlign: "center",
|
| 90 |
+
border: "1px solid #2a2b36", fontSize: 12, opacity: 0.6,
|
| 91 |
+
}}>
|
| 92 |
+
Loading sandbox status…
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const activeBackend = (sandbox?.backend || "subprocess").toLowerCase();
|
| 99 |
+
const mlStatus = matrixlab?.status || "not_installed";
|
| 100 |
+
|
| 101 |
+
return (
|
| 102 |
+
<div>
|
| 103 |
+
<h3 style={{ marginBottom: 8 }}>Sandbox</h3>
|
| 104 |
+
<p style={{ fontSize: 12, opacity: 0.7, marginBottom: 20 }}>
|
| 105 |
+
Pick where GitPilot executes code: the local subprocess sandbox or
|
| 106 |
+
the MatrixLab Runner. The choice applies to the Run button on chat
|
| 107 |
+
code blocks and the agent's EXECUTE action.
|
| 108 |
+
</p>
|
| 109 |
+
|
| 110 |
+
{/* MatrixLab addon card */}
|
| 111 |
+
<div style={cardStyle}>
|
| 112 |
+
<div style={{ display: "flex", alignItems: "flex-start", gap: 14 }}>
|
| 113 |
+
<div style={{ flex: 1 }}>
|
| 114 |
+
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 6 }}>
|
| 115 |
+
<h4 style={{ margin: 0, fontSize: 15 }}>MatrixLab Addon</h4>
|
| 116 |
+
<StatusPill status={mlStatus} />
|
| 117 |
+
{activeBackend === "matrixlab" && (
|
| 118 |
+
<span style={{
|
| 119 |
+
fontSize: 10, fontWeight: 600,
|
| 120 |
+
padding: "1px 6px", borderRadius: 8,
|
| 121 |
+
background: "#1e3a5f", color: "#93c5fd",
|
| 122 |
+
border: "1px solid #3B82F6",
|
| 123 |
+
}}>ACTIVE</span>
|
| 124 |
+
)}
|
| 125 |
+
</div>
|
| 126 |
+
<p style={{ fontSize: 12, opacity: 0.75, margin: 0, lineHeight: 1.55 }}>
|
| 127 |
+
Run code safely in isolated, temporary containers. Recommended
|
| 128 |
+
for enterprise deployments and untrusted code.
|
| 129 |
+
</p>
|
| 130 |
+
{mlStatus !== "not_installed" && matrixlab?.message && (
|
| 131 |
+
<p style={{ fontSize: 11, opacity: 0.6, marginTop: 6, marginBottom: 0 }}>
|
| 132 |
+
{matrixlab.message}
|
| 133 |
+
</p>
|
| 134 |
+
)}
|
| 135 |
+
</div>
|
| 136 |
+
<button
|
| 137 |
+
type="button"
|
| 138 |
+
onClick={() => setModalOpen(true)}
|
| 139 |
+
style={{
|
| 140 |
+
padding: "8px 16px", fontSize: 12, fontWeight: 600,
|
| 141 |
+
background: mlStatus === "ready" ? "transparent" : "#3B82F6",
|
| 142 |
+
color: mlStatus === "ready" ? "#93c5fd" : "#fff",
|
| 143 |
+
border: mlStatus === "ready" ? "1px solid #3B82F6" : "none",
|
| 144 |
+
borderRadius: 6, cursor: "pointer",
|
| 145 |
+
whiteSpace: "nowrap",
|
| 146 |
+
}}
|
| 147 |
+
>
|
| 148 |
+
{mlStatus === "ready" ? "Manage" :
|
| 149 |
+
mlStatus === "needs_attention" ? "Fix connection" :
|
| 150 |
+
mlStatus === "failed" ? "Retry install" :
|
| 151 |
+
"Install and Start"}
|
| 152 |
+
</button>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
{/* Local sandbox card */}
|
| 157 |
+
<div style={cardStyle}>
|
| 158 |
+
<div style={{ display: "flex", alignItems: "flex-start", gap: 14 }}>
|
| 159 |
+
<div style={{ flex: 1 }}>
|
| 160 |
+
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 6 }}>
|
| 161 |
+
<h4 style={{ margin: 0, fontSize: 15 }}>Local sandbox</h4>
|
| 162 |
+
{activeBackend === "subprocess" && (
|
| 163 |
+
<span style={{
|
| 164 |
+
fontSize: 10, fontWeight: 600,
|
| 165 |
+
padding: "1px 6px", borderRadius: 8,
|
| 166 |
+
background: "#1e3a5f", color: "#93c5fd",
|
| 167 |
+
border: "1px solid #3B82F6",
|
| 168 |
+
}}>ACTIVE</span>
|
| 169 |
+
)}
|
| 170 |
+
</div>
|
| 171 |
+
<p style={{ fontSize: 12, opacity: 0.75, margin: 0, lineHeight: 1.55 }}>
|
| 172 |
+
Host subprocess with a workspace jail. No Docker required —
|
| 173 |
+
best for trying simple snippets quickly.
|
| 174 |
+
</p>
|
| 175 |
+
</div>
|
| 176 |
+
<button
|
| 177 |
+
type="button"
|
| 178 |
+
onClick={useLocal}
|
| 179 |
+
disabled={activeBackend === "subprocess"}
|
| 180 |
+
style={{
|
| 181 |
+
padding: "8px 16px", fontSize: 12, fontWeight: 600,
|
| 182 |
+
background: activeBackend === "subprocess" ? "transparent" : "#374151",
|
| 183 |
+
color: activeBackend === "subprocess" ? "#9092b5" : "#fff",
|
| 184 |
+
border: activeBackend === "subprocess" ? "1px solid #2c2d46" : "none",
|
| 185 |
+
borderRadius: 6,
|
| 186 |
+
cursor: activeBackend === "subprocess" ? "default" : "pointer",
|
| 187 |
+
whiteSpace: "nowrap",
|
| 188 |
+
}}
|
| 189 |
+
>
|
| 190 |
+
{activeBackend === "subprocess" ? "Active" : "Use Local"}
|
| 191 |
+
</button>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
{modalOpen && (
|
| 196 |
+
<MatrixLabInstallModal
|
| 197 |
+
onClose={() => {
|
| 198 |
+
setModalOpen(false);
|
| 199 |
+
load();
|
| 200 |
+
}}
|
| 201 |
+
onActivated={(data) => {
|
| 202 |
+
showToast?.("MatrixLab ready", "Code now runs in MatrixLab sandboxes.");
|
| 203 |
+
// Refresh both panels so the ACTIVE badge moves to the addon card.
|
| 204 |
+
load();
|
| 205 |
+
}}
|
| 206 |
+
/>
|
| 207 |
+
)}
|
| 208 |
+
</div>
|
| 209 |
+
);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
const cardStyle = {
|
| 213 |
+
background: "#1a1b26",
|
| 214 |
+
borderRadius: 8,
|
| 215 |
+
padding: 16,
|
| 216 |
+
border: "1px solid #2a2b36",
|
| 217 |
+
marginBottom: 12,
|
| 218 |
+
};
|
frontend/components/AdminTabs/SecurityTab.jsx
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/SecurityTab.jsx
|
| 2 |
+
import React, { useState } from "react";
|
| 3 |
+
import { scanWorkspace } from "../../utils/api.js";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Security tab — runs a workspace scan via /api/security/scan-workspace
|
| 7 |
+
* and renders findings grouped by severity.
|
| 8 |
+
*
|
| 9 |
+
* Best practices applied:
|
| 10 |
+
* - Custom path input (defaults to ".")
|
| 11 |
+
* - Loading spinner while scanning
|
| 12 |
+
* - Error state with retry
|
| 13 |
+
* - Empty state ("No findings") with green checkmark
|
| 14 |
+
* - Findings grouped by severity (critical → info)
|
| 15 |
+
* - Each finding shows file, line, CWE, recommendation
|
| 16 |
+
* - Color-coded severity badges
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
const SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"];
|
| 20 |
+
|
| 21 |
+
const SEVERITY_COLORS = {
|
| 22 |
+
critical: { bg: "#7f1d1d", text: "#fecaca", border: "#991b1b" },
|
| 23 |
+
high: { bg: "#9a3412", text: "#fed7aa", border: "#c2410c" },
|
| 24 |
+
medium: { bg: "#78350f", text: "#fde68a", border: "#a16207" },
|
| 25 |
+
low: { bg: "#164e63", text: "#a5f3fc", border: "#0e7490" },
|
| 26 |
+
info: { bg: "#1e3a5f", text: "#93c5fd", border: "#3B82F6" },
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
function SeverityBadge({ severity }) {
|
| 30 |
+
const c = SEVERITY_COLORS[severity] || SEVERITY_COLORS.info;
|
| 31 |
+
return (
|
| 32 |
+
<span
|
| 33 |
+
style={{
|
| 34 |
+
display: "inline-block",
|
| 35 |
+
padding: "2px 8px",
|
| 36 |
+
background: c.bg,
|
| 37 |
+
color: c.text,
|
| 38 |
+
border: `1px solid ${c.border}`,
|
| 39 |
+
borderRadius: "10px",
|
| 40 |
+
fontSize: "10px",
|
| 41 |
+
fontWeight: 700,
|
| 42 |
+
textTransform: "uppercase",
|
| 43 |
+
letterSpacing: "0.5px",
|
| 44 |
+
}}
|
| 45 |
+
>
|
| 46 |
+
{severity}
|
| 47 |
+
</span>
|
| 48 |
+
);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export default function SecurityTab({ showToast }) {
|
| 52 |
+
const [path, setPath] = useState(".");
|
| 53 |
+
const [scanning, setScanning] = useState(false);
|
| 54 |
+
const [result, setResult] = useState(null);
|
| 55 |
+
const [error, setError] = useState(null);
|
| 56 |
+
|
| 57 |
+
const handleScan = async () => {
|
| 58 |
+
setScanning(true);
|
| 59 |
+
setError(null);
|
| 60 |
+
setResult(null);
|
| 61 |
+
try {
|
| 62 |
+
const data = await scanWorkspace(path.trim() || ".");
|
| 63 |
+
setResult(data);
|
| 64 |
+
const findingsCount = data.findings?.length || 0;
|
| 65 |
+
showToast?.(
|
| 66 |
+
"Scan complete",
|
| 67 |
+
findingsCount === 0
|
| 68 |
+
? "No security findings."
|
| 69 |
+
: `Found ${findingsCount} issue${findingsCount !== 1 ? "s" : ""}.`
|
| 70 |
+
);
|
| 71 |
+
} catch (err) {
|
| 72 |
+
setError(err?.message || "Scan failed");
|
| 73 |
+
} finally {
|
| 74 |
+
setScanning(false);
|
| 75 |
+
}
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
// Group findings by severity
|
| 79 |
+
const grouped = React.useMemo(() => {
|
| 80 |
+
const out = {};
|
| 81 |
+
if (result?.findings) {
|
| 82 |
+
for (const f of result.findings) {
|
| 83 |
+
const sev = f.severity || "info";
|
| 84 |
+
if (!out[sev]) out[sev] = [];
|
| 85 |
+
out[sev].push(f);
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
return out;
|
| 89 |
+
}, [result]);
|
| 90 |
+
|
| 91 |
+
const totalFindings = result?.findings?.length || 0;
|
| 92 |
+
|
| 93 |
+
return (
|
| 94 |
+
<div>
|
| 95 |
+
<h3 style={{ marginBottom: "16px" }}>Security Scanning</h3>
|
| 96 |
+
<p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "16px" }}>
|
| 97 |
+
Scan your workspace for vulnerabilities, secrets, and insecure patterns (OWASP Top 10).
|
| 98 |
+
</p>
|
| 99 |
+
|
| 100 |
+
{/* Scan controls */}
|
| 101 |
+
<div
|
| 102 |
+
style={{
|
| 103 |
+
background: "#1a1b26",
|
| 104 |
+
borderRadius: "8px",
|
| 105 |
+
padding: "16px",
|
| 106 |
+
border: "1px solid #2a2b36",
|
| 107 |
+
marginBottom: "16px",
|
| 108 |
+
display: "flex",
|
| 109 |
+
gap: "8px",
|
| 110 |
+
alignItems: "flex-end",
|
| 111 |
+
}}
|
| 112 |
+
>
|
| 113 |
+
<div style={{ flex: 1 }}>
|
| 114 |
+
<label
|
| 115 |
+
htmlFor="security-scan-path"
|
| 116 |
+
style={{
|
| 117 |
+
fontSize: "11px",
|
| 118 |
+
opacity: 0.7,
|
| 119 |
+
display: "block",
|
| 120 |
+
marginBottom: "4px",
|
| 121 |
+
}}
|
| 122 |
+
>
|
| 123 |
+
Path to scan (relative or absolute)
|
| 124 |
+
</label>
|
| 125 |
+
<input
|
| 126 |
+
id="security-scan-path"
|
| 127 |
+
type="text"
|
| 128 |
+
value={path}
|
| 129 |
+
onChange={(e) => setPath(e.target.value)}
|
| 130 |
+
disabled={scanning}
|
| 131 |
+
placeholder="."
|
| 132 |
+
style={{
|
| 133 |
+
width: "100%",
|
| 134 |
+
padding: "8px 10px",
|
| 135 |
+
background: "#0d0e15",
|
| 136 |
+
border: "1px solid #2a2b36",
|
| 137 |
+
borderRadius: "4px",
|
| 138 |
+
color: "#fff",
|
| 139 |
+
fontSize: "12px",
|
| 140 |
+
fontFamily: "monospace",
|
| 141 |
+
}}
|
| 142 |
+
/>
|
| 143 |
+
</div>
|
| 144 |
+
<button
|
| 145 |
+
type="button"
|
| 146 |
+
onClick={handleScan}
|
| 147 |
+
disabled={scanning}
|
| 148 |
+
style={{
|
| 149 |
+
padding: "8px 16px",
|
| 150 |
+
background: scanning ? "#555" : "#3B82F6",
|
| 151 |
+
color: "#fff",
|
| 152 |
+
border: "none",
|
| 153 |
+
borderRadius: "4px",
|
| 154 |
+
cursor: scanning ? "not-allowed" : "pointer",
|
| 155 |
+
fontSize: "12px",
|
| 156 |
+
fontWeight: 600,
|
| 157 |
+
whiteSpace: "nowrap",
|
| 158 |
+
}}
|
| 159 |
+
>
|
| 160 |
+
{scanning ? "Scanning..." : "Scan Workspace"}
|
| 161 |
+
</button>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
{/* Error state */}
|
| 165 |
+
{error && (
|
| 166 |
+
<div
|
| 167 |
+
role="alert"
|
| 168 |
+
style={{
|
| 169 |
+
background: "#7f1d1d",
|
| 170 |
+
color: "#fecaca",
|
| 171 |
+
border: "1px solid #991b1b",
|
| 172 |
+
borderRadius: "8px",
|
| 173 |
+
padding: "12px",
|
| 174 |
+
fontSize: "12px",
|
| 175 |
+
marginBottom: "16px",
|
| 176 |
+
}}
|
| 177 |
+
>
|
| 178 |
+
<strong>Scan failed: </strong>
|
| 179 |
+
{error}
|
| 180 |
+
</div>
|
| 181 |
+
)}
|
| 182 |
+
|
| 183 |
+
{/* Results summary */}
|
| 184 |
+
{result && (
|
| 185 |
+
<div
|
| 186 |
+
style={{
|
| 187 |
+
background: "#1a1b26",
|
| 188 |
+
borderRadius: "8px",
|
| 189 |
+
padding: "16px",
|
| 190 |
+
border: "1px solid #2a2b36",
|
| 191 |
+
marginBottom: "16px",
|
| 192 |
+
}}
|
| 193 |
+
>
|
| 194 |
+
<div style={{ display: "flex", gap: "24px", fontSize: "12px" }}>
|
| 195 |
+
<div>
|
| 196 |
+
<div style={{ opacity: 0.6 }}>Files Scanned</div>
|
| 197 |
+
<div style={{ fontSize: "18px", fontWeight: 600 }}>
|
| 198 |
+
{result.files_scanned ?? 0}
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
<div>
|
| 202 |
+
<div style={{ opacity: 0.6 }}>Total Findings</div>
|
| 203 |
+
<div
|
| 204 |
+
style={{
|
| 205 |
+
fontSize: "18px",
|
| 206 |
+
fontWeight: 600,
|
| 207 |
+
color: totalFindings === 0 ? "#4ade80" : "#fcd34d",
|
| 208 |
+
}}
|
| 209 |
+
>
|
| 210 |
+
{totalFindings}
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
<div>
|
| 214 |
+
<div style={{ opacity: 0.6 }}>Duration</div>
|
| 215 |
+
<div style={{ fontSize: "18px", fontWeight: 600 }}>
|
| 216 |
+
{result.scan_duration_ms ?? 0}ms
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
)}
|
| 222 |
+
|
| 223 |
+
{/* Empty state — no findings */}
|
| 224 |
+
{result && totalFindings === 0 && (
|
| 225 |
+
<div
|
| 226 |
+
style={{
|
| 227 |
+
background: "#064e3b",
|
| 228 |
+
color: "#a7f3d0",
|
| 229 |
+
border: "1px solid #065f46",
|
| 230 |
+
borderRadius: "8px",
|
| 231 |
+
padding: "20px",
|
| 232 |
+
textAlign: "center",
|
| 233 |
+
}}
|
| 234 |
+
>
|
| 235 |
+
<div style={{ fontSize: "32px", marginBottom: "8px" }}>✓</div>
|
| 236 |
+
<div style={{ fontSize: "14px", fontWeight: 600 }}>
|
| 237 |
+
No security issues found
|
| 238 |
+
</div>
|
| 239 |
+
<div style={{ fontSize: "12px", opacity: 0.8, marginTop: "4px" }}>
|
| 240 |
+
Your workspace passed all {result.files_scanned ?? 0} file checks.
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
)}
|
| 244 |
+
|
| 245 |
+
{/* Findings grouped by severity */}
|
| 246 |
+
{totalFindings > 0 &&
|
| 247 |
+
SEVERITY_ORDER.filter((sev) => grouped[sev]?.length > 0).map((sev) => (
|
| 248 |
+
<div key={sev} style={{ marginBottom: "16px" }}>
|
| 249 |
+
<h4
|
| 250 |
+
style={{
|
| 251 |
+
fontSize: "13px",
|
| 252 |
+
marginBottom: "8px",
|
| 253 |
+
display: "flex",
|
| 254 |
+
alignItems: "center",
|
| 255 |
+
gap: "8px",
|
| 256 |
+
}}
|
| 257 |
+
>
|
| 258 |
+
<SeverityBadge severity={sev} />
|
| 259 |
+
<span>
|
| 260 |
+
{grouped[sev].length} {sev} issue{grouped[sev].length !== 1 ? "s" : ""}
|
| 261 |
+
</span>
|
| 262 |
+
</h4>
|
| 263 |
+
<div style={{ display: "grid", gap: "8px" }}>
|
| 264 |
+
{grouped[sev].map((f, idx) => (
|
| 265 |
+
<div
|
| 266 |
+
key={`${f.rule_id}-${f.file_path}-${f.line_number}-${idx}`}
|
| 267 |
+
style={{
|
| 268 |
+
background: "#1a1b26",
|
| 269 |
+
borderRadius: "8px",
|
| 270 |
+
padding: "12px",
|
| 271 |
+
border: `1px solid ${SEVERITY_COLORS[sev]?.border || "#2a2b36"}`,
|
| 272 |
+
}}
|
| 273 |
+
>
|
| 274 |
+
<div
|
| 275 |
+
style={{
|
| 276 |
+
display: "flex",
|
| 277 |
+
justifyContent: "space-between",
|
| 278 |
+
alignItems: "flex-start",
|
| 279 |
+
marginBottom: "6px",
|
| 280 |
+
}}
|
| 281 |
+
>
|
| 282 |
+
<div style={{ fontSize: "13px", fontWeight: 600 }}>{f.title}</div>
|
| 283 |
+
{f.cwe_id && (
|
| 284 |
+
<span
|
| 285 |
+
style={{
|
| 286 |
+
fontSize: "10px",
|
| 287 |
+
opacity: 0.6,
|
| 288 |
+
fontFamily: "monospace",
|
| 289 |
+
}}
|
| 290 |
+
>
|
| 291 |
+
{f.cwe_id}
|
| 292 |
+
</span>
|
| 293 |
+
)}
|
| 294 |
+
</div>
|
| 295 |
+
<div
|
| 296 |
+
style={{
|
| 297 |
+
fontSize: "11px",
|
| 298 |
+
fontFamily: "monospace",
|
| 299 |
+
opacity: 0.7,
|
| 300 |
+
marginBottom: "6px",
|
| 301 |
+
}}
|
| 302 |
+
>
|
| 303 |
+
{f.file_path}:{f.line_number}
|
| 304 |
+
</div>
|
| 305 |
+
{f.snippet && (
|
| 306 |
+
<pre
|
| 307 |
+
style={{
|
| 308 |
+
fontSize: "11px",
|
| 309 |
+
background: "#0d0e15",
|
| 310 |
+
padding: "8px",
|
| 311 |
+
borderRadius: "4px",
|
| 312 |
+
overflowX: "auto",
|
| 313 |
+
margin: "6px 0",
|
| 314 |
+
color: "#e0e7ff",
|
| 315 |
+
}}
|
| 316 |
+
>
|
| 317 |
+
{f.snippet}
|
| 318 |
+
</pre>
|
| 319 |
+
)}
|
| 320 |
+
{f.recommendation && (
|
| 321 |
+
<div
|
| 322 |
+
style={{
|
| 323 |
+
fontSize: "11px",
|
| 324 |
+
opacity: 0.8,
|
| 325 |
+
marginTop: "6px",
|
| 326 |
+
paddingTop: "6px",
|
| 327 |
+
borderTop: "1px solid #2a2b36",
|
| 328 |
+
}}
|
| 329 |
+
>
|
| 330 |
+
<strong>Fix: </strong>
|
| 331 |
+
{f.recommendation}
|
| 332 |
+
</div>
|
| 333 |
+
)}
|
| 334 |
+
</div>
|
| 335 |
+
))}
|
| 336 |
+
</div>
|
| 337 |
+
</div>
|
| 338 |
+
))}
|
| 339 |
+
</div>
|
| 340 |
+
);
|
| 341 |
+
}
|
frontend/components/AdminTabs/SessionsTab.jsx
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/SessionsTab.jsx
|
| 2 |
+
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
| 3 |
+
import { apiUrl, safeFetchJSON } from "../../utils/api.js";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Sessions tab — admin-level table view of all saved sessions with
|
| 7 |
+
* search, sort, and delete actions.
|
| 8 |
+
*
|
| 9 |
+
* Best practices applied:
|
| 10 |
+
* - Fetch all sessions on mount
|
| 11 |
+
* - Client-side search (useMemo for filtered list)
|
| 12 |
+
* - Confirmation dialog before delete
|
| 13 |
+
* - Row hover effect
|
| 14 |
+
* - Empty / loading / error states
|
| 15 |
+
* - Relative timestamps ("2 hours ago")
|
| 16 |
+
* - Click row to open in workspace view
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
function formatRelativeTime(iso) {
|
| 20 |
+
if (!iso) return "—";
|
| 21 |
+
try {
|
| 22 |
+
const d = new Date(iso);
|
| 23 |
+
const diff = Date.now() - d.getTime();
|
| 24 |
+
if (diff < 60_000) return "just now";
|
| 25 |
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
| 26 |
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
| 27 |
+
if (diff < 2_592_000_000) return `${Math.floor(diff / 86_400_000)}d ago`;
|
| 28 |
+
return d.toLocaleDateString();
|
| 29 |
+
} catch {
|
| 30 |
+
return "—";
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export default function SessionsTab({ onSelectSession, showToast }) {
|
| 35 |
+
const [sessions, setSessions] = useState([]);
|
| 36 |
+
const [loading, setLoading] = useState(true);
|
| 37 |
+
const [error, setError] = useState(null);
|
| 38 |
+
const [query, setQuery] = useState("");
|
| 39 |
+
const [deletingId, setDeletingId] = useState(null);
|
| 40 |
+
|
| 41 |
+
const fetchSessions = useCallback(async () => {
|
| 42 |
+
setError(null);
|
| 43 |
+
try {
|
| 44 |
+
const data = await safeFetchJSON(apiUrl("/api/sessions"), { timeout: 10000 });
|
| 45 |
+
setSessions(Array.isArray(data.sessions) ? data.sessions : []);
|
| 46 |
+
} catch (err) {
|
| 47 |
+
setError(err?.message || "Failed to load sessions");
|
| 48 |
+
} finally {
|
| 49 |
+
setLoading(false);
|
| 50 |
+
}
|
| 51 |
+
}, []);
|
| 52 |
+
|
| 53 |
+
useEffect(() => {
|
| 54 |
+
fetchSessions();
|
| 55 |
+
}, [fetchSessions]);
|
| 56 |
+
|
| 57 |
+
const handleDelete = async (session) => {
|
| 58 |
+
if (
|
| 59 |
+
!window.confirm(
|
| 60 |
+
`Delete session "${session.name || session.id?.slice(0, 8)}"? This cannot be undone.`
|
| 61 |
+
)
|
| 62 |
+
) {
|
| 63 |
+
return;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
setDeletingId(session.id);
|
| 67 |
+
try {
|
| 68 |
+
const res = await fetch(apiUrl(`/api/sessions/${session.id}`), {
|
| 69 |
+
method: "DELETE",
|
| 70 |
+
});
|
| 71 |
+
if (!res.ok) {
|
| 72 |
+
throw new Error(`Delete failed (${res.status})`);
|
| 73 |
+
}
|
| 74 |
+
showToast?.("Session deleted", session.name || session.id);
|
| 75 |
+
// Optimistic removal
|
| 76 |
+
setSessions((prev) => prev.filter((s) => s.id !== session.id));
|
| 77 |
+
} catch (err) {
|
| 78 |
+
setError(err?.message || "Failed to delete session");
|
| 79 |
+
} finally {
|
| 80 |
+
setDeletingId(null);
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const filtered = useMemo(() => {
|
| 85 |
+
if (!query.trim()) return sessions;
|
| 86 |
+
const q = query.toLowerCase();
|
| 87 |
+
return sessions.filter((s) => {
|
| 88 |
+
return (
|
| 89 |
+
(s.name || "").toLowerCase().includes(q) ||
|
| 90 |
+
(s.repo || "").toLowerCase().includes(q) ||
|
| 91 |
+
(s.branch || "").toLowerCase().includes(q) ||
|
| 92 |
+
(s.id || "").toLowerCase().includes(q)
|
| 93 |
+
);
|
| 94 |
+
});
|
| 95 |
+
}, [sessions, query]);
|
| 96 |
+
|
| 97 |
+
return (
|
| 98 |
+
<div>
|
| 99 |
+
<div
|
| 100 |
+
style={{
|
| 101 |
+
display: "flex",
|
| 102 |
+
justifyContent: "space-between",
|
| 103 |
+
alignItems: "center",
|
| 104 |
+
marginBottom: "16px",
|
| 105 |
+
gap: "12px",
|
| 106 |
+
flexWrap: "wrap",
|
| 107 |
+
}}
|
| 108 |
+
>
|
| 109 |
+
<div>
|
| 110 |
+
<h3 style={{ marginBottom: "4px" }}>Sessions</h3>
|
| 111 |
+
<p style={{ fontSize: "12px", opacity: 0.7 }}>
|
| 112 |
+
All saved chat sessions ({sessions.length} total
|
| 113 |
+
{query ? `, ${filtered.length} matching` : ""}).
|
| 114 |
+
</p>
|
| 115 |
+
</div>
|
| 116 |
+
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
| 117 |
+
<input
|
| 118 |
+
type="text"
|
| 119 |
+
value={query}
|
| 120 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 121 |
+
placeholder="Search sessions..."
|
| 122 |
+
style={{
|
| 123 |
+
padding: "6px 10px",
|
| 124 |
+
background: "#0d0e15",
|
| 125 |
+
border: "1px solid #2a2b36",
|
| 126 |
+
borderRadius: "4px",
|
| 127 |
+
color: "#fff",
|
| 128 |
+
fontSize: "12px",
|
| 129 |
+
width: "220px",
|
| 130 |
+
}}
|
| 131 |
+
/>
|
| 132 |
+
<button
|
| 133 |
+
type="button"
|
| 134 |
+
onClick={fetchSessions}
|
| 135 |
+
disabled={loading}
|
| 136 |
+
style={{
|
| 137 |
+
padding: "6px 12px",
|
| 138 |
+
background: "transparent",
|
| 139 |
+
color: "#a0a0b0",
|
| 140 |
+
border: "1px solid #2a2b36",
|
| 141 |
+
borderRadius: "4px",
|
| 142 |
+
cursor: loading ? "not-allowed" : "pointer",
|
| 143 |
+
fontSize: "12px",
|
| 144 |
+
}}
|
| 145 |
+
>
|
| 146 |
+
Refresh
|
| 147 |
+
</button>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
{/* Loading state */}
|
| 152 |
+
{loading && (
|
| 153 |
+
<div
|
| 154 |
+
style={{
|
| 155 |
+
background: "#1a1b26",
|
| 156 |
+
borderRadius: "8px",
|
| 157 |
+
padding: "40px 20px",
|
| 158 |
+
textAlign: "center",
|
| 159 |
+
border: "1px solid #2a2b36",
|
| 160 |
+
fontSize: "12px",
|
| 161 |
+
opacity: 0.6,
|
| 162 |
+
}}
|
| 163 |
+
>
|
| 164 |
+
Loading sessions...
|
| 165 |
+
</div>
|
| 166 |
+
)}
|
| 167 |
+
|
| 168 |
+
{/* Error state */}
|
| 169 |
+
{error && !loading && (
|
| 170 |
+
<div
|
| 171 |
+
role="alert"
|
| 172 |
+
style={{
|
| 173 |
+
background: "#7f1d1d",
|
| 174 |
+
color: "#fecaca",
|
| 175 |
+
border: "1px solid #991b1b",
|
| 176 |
+
borderRadius: "8px",
|
| 177 |
+
padding: "12px",
|
| 178 |
+
fontSize: "12px",
|
| 179 |
+
marginBottom: "12px",
|
| 180 |
+
}}
|
| 181 |
+
>
|
| 182 |
+
<strong>Error: </strong>
|
| 183 |
+
{error}
|
| 184 |
+
</div>
|
| 185 |
+
)}
|
| 186 |
+
|
| 187 |
+
{/* Empty state */}
|
| 188 |
+
{!loading && !error && sessions.length === 0 && (
|
| 189 |
+
<div
|
| 190 |
+
style={{
|
| 191 |
+
background: "#1a1b26",
|
| 192 |
+
borderRadius: "8px",
|
| 193 |
+
padding: "40px 20px",
|
| 194 |
+
textAlign: "center",
|
| 195 |
+
border: "1px dashed #2a2b36",
|
| 196 |
+
}}
|
| 197 |
+
>
|
| 198 |
+
<div style={{ fontSize: "32px", marginBottom: "8px" }}>💬</div>
|
| 199 |
+
<div style={{ fontSize: "14px", fontWeight: 600, marginBottom: "4px" }}>
|
| 200 |
+
No sessions yet
|
| 201 |
+
</div>
|
| 202 |
+
<div style={{ fontSize: "12px", opacity: 0.6 }}>
|
| 203 |
+
Start chatting with GitPilot to create your first session.
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
)}
|
| 207 |
+
|
| 208 |
+
{/* Table */}
|
| 209 |
+
{!loading && filtered.length > 0 && (
|
| 210 |
+
<div
|
| 211 |
+
style={{
|
| 212 |
+
background: "#1a1b26",
|
| 213 |
+
borderRadius: "8px",
|
| 214 |
+
border: "1px solid #2a2b36",
|
| 215 |
+
overflow: "hidden",
|
| 216 |
+
}}
|
| 217 |
+
>
|
| 218 |
+
<table
|
| 219 |
+
style={{
|
| 220 |
+
width: "100%",
|
| 221 |
+
borderCollapse: "collapse",
|
| 222 |
+
fontSize: "12px",
|
| 223 |
+
}}
|
| 224 |
+
>
|
| 225 |
+
<thead>
|
| 226 |
+
<tr style={{ background: "#0d0e15" }}>
|
| 227 |
+
<th style={thStyle}>Name</th>
|
| 228 |
+
<th style={thStyle}>Repository</th>
|
| 229 |
+
<th style={thStyle}>Branch</th>
|
| 230 |
+
<th style={thStyle}>Messages</th>
|
| 231 |
+
<th style={thStyle}>Status</th>
|
| 232 |
+
<th style={thStyle}>Updated</th>
|
| 233 |
+
<th style={{ ...thStyle, textAlign: "right" }}>Actions</th>
|
| 234 |
+
</tr>
|
| 235 |
+
</thead>
|
| 236 |
+
<tbody>
|
| 237 |
+
{filtered.map((s) => (
|
| 238 |
+
<tr
|
| 239 |
+
key={s.id}
|
| 240 |
+
style={{
|
| 241 |
+
borderTop: "1px solid #2a2b36",
|
| 242 |
+
cursor: onSelectSession ? "pointer" : "default",
|
| 243 |
+
}}
|
| 244 |
+
onMouseEnter={(e) =>
|
| 245 |
+
(e.currentTarget.style.background = "#22232e")
|
| 246 |
+
}
|
| 247 |
+
onMouseLeave={(e) =>
|
| 248 |
+
(e.currentTarget.style.background = "transparent")
|
| 249 |
+
}
|
| 250 |
+
onClick={() => onSelectSession?.(s)}
|
| 251 |
+
>
|
| 252 |
+
<td style={tdStyle}>
|
| 253 |
+
<div style={{ fontWeight: 600 }}>
|
| 254 |
+
{s.name || <span style={{ opacity: 0.4 }}>(unnamed)</span>}
|
| 255 |
+
</div>
|
| 256 |
+
<div
|
| 257 |
+
style={{
|
| 258 |
+
fontSize: "10px",
|
| 259 |
+
opacity: 0.4,
|
| 260 |
+
fontFamily: "monospace",
|
| 261 |
+
}}
|
| 262 |
+
>
|
| 263 |
+
{s.id?.slice(0, 12)}
|
| 264 |
+
</div>
|
| 265 |
+
</td>
|
| 266 |
+
<td style={{ ...tdStyle, fontFamily: "monospace" }}>
|
| 267 |
+
{s.repo || <span style={{ opacity: 0.4 }}>—</span>}
|
| 268 |
+
</td>
|
| 269 |
+
<td style={{ ...tdStyle, fontFamily: "monospace" }}>
|
| 270 |
+
{s.branch || <span style={{ opacity: 0.4 }}>—</span>}
|
| 271 |
+
</td>
|
| 272 |
+
<td style={tdStyle}>{s.message_count ?? 0}</td>
|
| 273 |
+
<td style={tdStyle}>
|
| 274 |
+
<span
|
| 275 |
+
style={{
|
| 276 |
+
padding: "2px 8px",
|
| 277 |
+
background:
|
| 278 |
+
s.status === "active"
|
| 279 |
+
? "#064e3b"
|
| 280 |
+
: s.status === "completed"
|
| 281 |
+
? "#1e3a5f"
|
| 282 |
+
: "#374151",
|
| 283 |
+
color:
|
| 284 |
+
s.status === "active"
|
| 285 |
+
? "#a7f3d0"
|
| 286 |
+
: s.status === "completed"
|
| 287 |
+
? "#93c5fd"
|
| 288 |
+
: "#9ca3af",
|
| 289 |
+
borderRadius: "10px",
|
| 290 |
+
fontSize: "10px",
|
| 291 |
+
fontWeight: 600,
|
| 292 |
+
textTransform: "uppercase",
|
| 293 |
+
}}
|
| 294 |
+
>
|
| 295 |
+
{s.status || "unknown"}
|
| 296 |
+
</span>
|
| 297 |
+
</td>
|
| 298 |
+
<td style={{ ...tdStyle, opacity: 0.7 }}>
|
| 299 |
+
{formatRelativeTime(s.updated_at)}
|
| 300 |
+
</td>
|
| 301 |
+
<td style={{ ...tdStyle, textAlign: "right" }}>
|
| 302 |
+
<button
|
| 303 |
+
type="button"
|
| 304 |
+
onClick={(e) => {
|
| 305 |
+
e.stopPropagation();
|
| 306 |
+
handleDelete(s);
|
| 307 |
+
}}
|
| 308 |
+
disabled={deletingId === s.id}
|
| 309 |
+
style={{
|
| 310 |
+
padding: "4px 10px",
|
| 311 |
+
background: "transparent",
|
| 312 |
+
color: "#f87171",
|
| 313 |
+
border: "1px solid #991b1b",
|
| 314 |
+
borderRadius: "4px",
|
| 315 |
+
cursor: deletingId === s.id ? "not-allowed" : "pointer",
|
| 316 |
+
fontSize: "11px",
|
| 317 |
+
}}
|
| 318 |
+
>
|
| 319 |
+
{deletingId === s.id ? "..." : "Delete"}
|
| 320 |
+
</button>
|
| 321 |
+
</td>
|
| 322 |
+
</tr>
|
| 323 |
+
))}
|
| 324 |
+
</tbody>
|
| 325 |
+
</table>
|
| 326 |
+
</div>
|
| 327 |
+
)}
|
| 328 |
+
|
| 329 |
+
{/* No matches for search */}
|
| 330 |
+
{!loading && sessions.length > 0 && filtered.length === 0 && (
|
| 331 |
+
<div
|
| 332 |
+
style={{
|
| 333 |
+
background: "#1a1b26",
|
| 334 |
+
borderRadius: "8px",
|
| 335 |
+
padding: "20px",
|
| 336 |
+
textAlign: "center",
|
| 337 |
+
border: "1px dashed #2a2b36",
|
| 338 |
+
fontSize: "12px",
|
| 339 |
+
opacity: 0.7,
|
| 340 |
+
}}
|
| 341 |
+
>
|
| 342 |
+
No sessions match "{query}"
|
| 343 |
+
</div>
|
| 344 |
+
)}
|
| 345 |
+
</div>
|
| 346 |
+
);
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
const thStyle = {
|
| 350 |
+
padding: "10px 12px",
|
| 351 |
+
textAlign: "left",
|
| 352 |
+
fontSize: "11px",
|
| 353 |
+
fontWeight: 600,
|
| 354 |
+
textTransform: "uppercase",
|
| 355 |
+
letterSpacing: "0.5px",
|
| 356 |
+
opacity: 0.7,
|
| 357 |
+
};
|
| 358 |
+
|
| 359 |
+
const tdStyle = {
|
| 360 |
+
padding: "10px 12px",
|
| 361 |
+
verticalAlign: "middle",
|
| 362 |
+
};
|
frontend/components/AdminTabs/SkillsTab.jsx
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/SkillsTab.jsx
|
| 2 |
+
import React, { useEffect, useState, useCallback } from "react";
|
| 3 |
+
import { apiUrl, safeFetchJSON } from "../../utils/api.js";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Skills tab — lists all loaded skills from /api/skills and allows
|
| 7 |
+
* reloading them from disk via /api/skills/reload.
|
| 8 |
+
*
|
| 9 |
+
* Best practices applied:
|
| 10 |
+
* - Fetch on mount
|
| 11 |
+
* - Explicit reload button (skills are loaded from .md files on disk)
|
| 12 |
+
* - Loading / empty / error states
|
| 13 |
+
* - Auto-trigger indicator badge
|
| 14 |
+
* - Required tools list per skill
|
| 15 |
+
* - Source file path for debugging
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
export default function SkillsTab({ showToast }) {
|
| 19 |
+
const [skills, setSkills] = useState([]);
|
| 20 |
+
const [loading, setLoading] = useState(true);
|
| 21 |
+
const [reloading, setReloading] = useState(false);
|
| 22 |
+
const [error, setError] = useState(null);
|
| 23 |
+
|
| 24 |
+
const fetchSkills = useCallback(async () => {
|
| 25 |
+
setError(null);
|
| 26 |
+
try {
|
| 27 |
+
const data = await safeFetchJSON(apiUrl("/api/skills"), { timeout: 10000 });
|
| 28 |
+
setSkills(Array.isArray(data.skills) ? data.skills : []);
|
| 29 |
+
} catch (err) {
|
| 30 |
+
setError(err?.message || "Failed to load skills");
|
| 31 |
+
} finally {
|
| 32 |
+
setLoading(false);
|
| 33 |
+
}
|
| 34 |
+
}, []);
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
fetchSkills();
|
| 38 |
+
}, [fetchSkills]);
|
| 39 |
+
|
| 40 |
+
const handleReload = async () => {
|
| 41 |
+
setReloading(true);
|
| 42 |
+
setError(null);
|
| 43 |
+
try {
|
| 44 |
+
const data = await safeFetchJSON(apiUrl("/api/skills/reload"), {
|
| 45 |
+
method: "POST",
|
| 46 |
+
timeout: 10000,
|
| 47 |
+
});
|
| 48 |
+
showToast?.(
|
| 49 |
+
"Skills reloaded",
|
| 50 |
+
`${data.count ?? 0} skill${data.count !== 1 ? "s" : ""} loaded from disk.`
|
| 51 |
+
);
|
| 52 |
+
await fetchSkills();
|
| 53 |
+
} catch (err) {
|
| 54 |
+
setError(err?.message || "Failed to reload skills");
|
| 55 |
+
} finally {
|
| 56 |
+
setReloading(false);
|
| 57 |
+
}
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
return (
|
| 61 |
+
<div>
|
| 62 |
+
<div
|
| 63 |
+
style={{
|
| 64 |
+
display: "flex",
|
| 65 |
+
justifyContent: "space-between",
|
| 66 |
+
alignItems: "flex-start",
|
| 67 |
+
marginBottom: "16px",
|
| 68 |
+
}}
|
| 69 |
+
>
|
| 70 |
+
<div>
|
| 71 |
+
<h3 style={{ marginBottom: "4px" }}>Skills</h3>
|
| 72 |
+
<p style={{ fontSize: "12px", opacity: 0.7 }}>
|
| 73 |
+
Reusable prompt templates loaded from{" "}
|
| 74 |
+
<code style={{ fontSize: "11px" }}>.gitpilot/skills/*.md</code> files.
|
| 75 |
+
</p>
|
| 76 |
+
</div>
|
| 77 |
+
<button
|
| 78 |
+
type="button"
|
| 79 |
+
onClick={handleReload}
|
| 80 |
+
disabled={reloading || loading}
|
| 81 |
+
style={{
|
| 82 |
+
padding: "6px 12px",
|
| 83 |
+
background: reloading ? "#555" : "#3B82F6",
|
| 84 |
+
color: "#fff",
|
| 85 |
+
border: "none",
|
| 86 |
+
borderRadius: "4px",
|
| 87 |
+
cursor: reloading || loading ? "not-allowed" : "pointer",
|
| 88 |
+
fontSize: "12px",
|
| 89 |
+
fontWeight: 600,
|
| 90 |
+
}}
|
| 91 |
+
>
|
| 92 |
+
{reloading ? "Reloading..." : "Reload Skills"}
|
| 93 |
+
</button>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
{/* Loading state */}
|
| 97 |
+
{loading && (
|
| 98 |
+
<div
|
| 99 |
+
style={{
|
| 100 |
+
background: "#1a1b26",
|
| 101 |
+
borderRadius: "8px",
|
| 102 |
+
padding: "40px 20px",
|
| 103 |
+
textAlign: "center",
|
| 104 |
+
border: "1px solid #2a2b36",
|
| 105 |
+
fontSize: "12px",
|
| 106 |
+
opacity: 0.6,
|
| 107 |
+
}}
|
| 108 |
+
>
|
| 109 |
+
Loading skills...
|
| 110 |
+
</div>
|
| 111 |
+
)}
|
| 112 |
+
|
| 113 |
+
{/* Error state */}
|
| 114 |
+
{error && !loading && (
|
| 115 |
+
<div
|
| 116 |
+
role="alert"
|
| 117 |
+
style={{
|
| 118 |
+
background: "#7f1d1d",
|
| 119 |
+
color: "#fecaca",
|
| 120 |
+
border: "1px solid #991b1b",
|
| 121 |
+
borderRadius: "8px",
|
| 122 |
+
padding: "12px",
|
| 123 |
+
fontSize: "12px",
|
| 124 |
+
}}
|
| 125 |
+
>
|
| 126 |
+
<strong>Error: </strong>
|
| 127 |
+
{error}
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
|
| 131 |
+
{/* Empty state */}
|
| 132 |
+
{!loading && !error && skills.length === 0 && (
|
| 133 |
+
<div
|
| 134 |
+
style={{
|
| 135 |
+
background: "#1a1b26",
|
| 136 |
+
borderRadius: "8px",
|
| 137 |
+
padding: "40px 20px",
|
| 138 |
+
textAlign: "center",
|
| 139 |
+
border: "1px dashed #2a2b36",
|
| 140 |
+
}}
|
| 141 |
+
>
|
| 142 |
+
<div style={{ fontSize: "32px", marginBottom: "8px" }}>📚</div>
|
| 143 |
+
<div style={{ fontSize: "14px", fontWeight: 600, marginBottom: "4px" }}>
|
| 144 |
+
No skills loaded
|
| 145 |
+
</div>
|
| 146 |
+
<div style={{ fontSize: "12px", opacity: 0.6 }}>
|
| 147 |
+
Create a <code>.gitpilot/skills/my-skill.md</code> file with YAML
|
| 148 |
+
frontmatter to add custom skills.
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
)}
|
| 152 |
+
|
| 153 |
+
{/* Skills grid */}
|
| 154 |
+
{!loading && skills.length > 0 && (
|
| 155 |
+
<div
|
| 156 |
+
style={{
|
| 157 |
+
display: "grid",
|
| 158 |
+
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
| 159 |
+
gap: "12px",
|
| 160 |
+
}}
|
| 161 |
+
>
|
| 162 |
+
{skills.map((skill) => (
|
| 163 |
+
<div
|
| 164 |
+
key={skill.name}
|
| 165 |
+
style={{
|
| 166 |
+
background: "#1a1b26",
|
| 167 |
+
borderRadius: "8px",
|
| 168 |
+
padding: "16px",
|
| 169 |
+
border: "1px solid #2a2b36",
|
| 170 |
+
display: "flex",
|
| 171 |
+
flexDirection: "column",
|
| 172 |
+
gap: "8px",
|
| 173 |
+
}}
|
| 174 |
+
>
|
| 175 |
+
<div
|
| 176 |
+
style={{
|
| 177 |
+
display: "flex",
|
| 178 |
+
justifyContent: "space-between",
|
| 179 |
+
alignItems: "flex-start",
|
| 180 |
+
gap: "8px",
|
| 181 |
+
}}
|
| 182 |
+
>
|
| 183 |
+
<h4
|
| 184 |
+
style={{
|
| 185 |
+
fontSize: "14px",
|
| 186 |
+
fontWeight: 600,
|
| 187 |
+
margin: 0,
|
| 188 |
+
color: "#fff",
|
| 189 |
+
}}
|
| 190 |
+
>
|
| 191 |
+
{skill.name}
|
| 192 |
+
</h4>
|
| 193 |
+
{skill.auto_trigger && (
|
| 194 |
+
<span
|
| 195 |
+
title="Auto-triggered by matching context"
|
| 196 |
+
style={{
|
| 197 |
+
padding: "2px 8px",
|
| 198 |
+
background: "#1e3a5f",
|
| 199 |
+
color: "#93c5fd",
|
| 200 |
+
border: "1px solid #3B82F6",
|
| 201 |
+
borderRadius: "10px",
|
| 202 |
+
fontSize: "9px",
|
| 203 |
+
fontWeight: 700,
|
| 204 |
+
textTransform: "uppercase",
|
| 205 |
+
whiteSpace: "nowrap",
|
| 206 |
+
}}
|
| 207 |
+
>
|
| 208 |
+
Auto
|
| 209 |
+
</span>
|
| 210 |
+
)}
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
<p
|
| 214 |
+
style={{
|
| 215 |
+
fontSize: "12px",
|
| 216 |
+
opacity: 0.7,
|
| 217 |
+
lineHeight: 1.5,
|
| 218 |
+
margin: 0,
|
| 219 |
+
minHeight: "36px",
|
| 220 |
+
}}
|
| 221 |
+
>
|
| 222 |
+
{skill.description || "No description"}
|
| 223 |
+
</p>
|
| 224 |
+
|
| 225 |
+
{Array.isArray(skill.required_tools) && skill.required_tools.length > 0 && (
|
| 226 |
+
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
| 227 |
+
{skill.required_tools.map((t) => (
|
| 228 |
+
<span
|
| 229 |
+
key={t}
|
| 230 |
+
style={{
|
| 231 |
+
padding: "2px 6px",
|
| 232 |
+
background: "#0d0e15",
|
| 233 |
+
border: "1px solid #2a2b36",
|
| 234 |
+
borderRadius: "4px",
|
| 235 |
+
fontSize: "10px",
|
| 236 |
+
fontFamily: "monospace",
|
| 237 |
+
opacity: 0.8,
|
| 238 |
+
}}
|
| 239 |
+
>
|
| 240 |
+
{t}
|
| 241 |
+
</span>
|
| 242 |
+
))}
|
| 243 |
+
</div>
|
| 244 |
+
)}
|
| 245 |
+
|
| 246 |
+
{skill.source && (
|
| 247 |
+
<div
|
| 248 |
+
style={{
|
| 249 |
+
fontSize: "10px",
|
| 250 |
+
opacity: 0.4,
|
| 251 |
+
fontFamily: "monospace",
|
| 252 |
+
borderTop: "1px solid #2a2b36",
|
| 253 |
+
paddingTop: "8px",
|
| 254 |
+
wordBreak: "break-all",
|
| 255 |
+
}}
|
| 256 |
+
>
|
| 257 |
+
{skill.source}
|
| 258 |
+
</div>
|
| 259 |
+
)}
|
| 260 |
+
</div>
|
| 261 |
+
))}
|
| 262 |
+
</div>
|
| 263 |
+
)}
|
| 264 |
+
</div>
|
| 265 |
+
);
|
| 266 |
+
}
|
frontend/components/AdminTabs/WorkspaceModesTab.jsx
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/WorkspaceModesTab.jsx
|
| 2 |
+
import React, { useState } from "react";
|
| 3 |
+
import { startSession } from "../../utils/api.js";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Workspace Modes tab — allows the user to start a session in one of
|
| 7 |
+
* three modes (folder, local_git, github). Calls POST /api/session/start.
|
| 8 |
+
*
|
| 9 |
+
* Best practices applied:
|
| 10 |
+
* - Loading state while the request is in flight
|
| 11 |
+
* - Per-mode error state (not a global error)
|
| 12 |
+
* - Disabled card during submission to prevent double-click
|
| 13 |
+
* - ARIA role="button" + aria-disabled for accessibility
|
| 14 |
+
* - Toast notification on success
|
| 15 |
+
* - Success callback so App.jsx can set activeSessionId and switch to workspace view
|
| 16 |
+
*/
|
| 17 |
+
|
| 18 |
+
const MODES = [
|
| 19 |
+
{
|
| 20 |
+
id: "folder",
|
| 21 |
+
title: "Folder Mode",
|
| 22 |
+
description: "Work with any local folder. No Git required.",
|
| 23 |
+
requires: "A local folder path",
|
| 24 |
+
enables: "Chat, explain, review",
|
| 25 |
+
promptKey: "folder_path",
|
| 26 |
+
promptLabel: "Folder path (absolute)",
|
| 27 |
+
promptPlaceholder: "/home/you/myproject",
|
| 28 |
+
buildPayload: (value) => ({ mode: "folder", folder_path: value }),
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
id: "local_git",
|
| 32 |
+
title: "Local Git Mode",
|
| 33 |
+
description: "Full repo + branch context for AI assistance.",
|
| 34 |
+
requires: "A local Git repository",
|
| 35 |
+
enables: "All local features (branches, diff, commit)",
|
| 36 |
+
promptKey: "repo_root",
|
| 37 |
+
promptLabel: "Repository root (absolute path)",
|
| 38 |
+
promptPlaceholder: "/home/you/my-git-repo",
|
| 39 |
+
buildPayload: (value) => ({ mode: "local_git", repo_root: value }),
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
id: "github",
|
| 43 |
+
title: "GitHub Mode",
|
| 44 |
+
description: "PRs, issues, remote workflows via GitHub API.",
|
| 45 |
+
requires: "GitHub token (already signed in)",
|
| 46 |
+
enables: "Full platform features",
|
| 47 |
+
promptKey: "repo_full_name",
|
| 48 |
+
promptLabel: "Repository (owner/repo)",
|
| 49 |
+
promptPlaceholder: "octocat/hello-world",
|
| 50 |
+
buildPayload: (value) => ({ mode: "github", repo_full_name: value }),
|
| 51 |
+
},
|
| 52 |
+
];
|
| 53 |
+
|
| 54 |
+
export default function WorkspaceModesTab({ onSessionStarted, showToast }) {
|
| 55 |
+
const [activeModeId, setActiveModeId] = useState(null);
|
| 56 |
+
const [inputValue, setInputValue] = useState("");
|
| 57 |
+
const [submittingId, setSubmittingId] = useState(null);
|
| 58 |
+
const [errorByMode, setErrorByMode] = useState({});
|
| 59 |
+
|
| 60 |
+
const handleCardClick = (mode) => {
|
| 61 |
+
if (submittingId) return;
|
| 62 |
+
setActiveModeId(mode.id);
|
| 63 |
+
setInputValue("");
|
| 64 |
+
setErrorByMode((prev) => ({ ...prev, [mode.id]: null }));
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const handleStart = async (mode) => {
|
| 68 |
+
const trimmed = inputValue.trim();
|
| 69 |
+
if (!trimmed) {
|
| 70 |
+
setErrorByMode((prev) => ({
|
| 71 |
+
...prev,
|
| 72 |
+
[mode.id]: `${mode.promptLabel} is required`,
|
| 73 |
+
}));
|
| 74 |
+
return;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
setSubmittingId(mode.id);
|
| 78 |
+
setErrorByMode((prev) => ({ ...prev, [mode.id]: null }));
|
| 79 |
+
|
| 80 |
+
try {
|
| 81 |
+
const payload = mode.buildPayload(trimmed);
|
| 82 |
+
const result = await startSession(payload);
|
| 83 |
+
|
| 84 |
+
showToast?.(
|
| 85 |
+
`${mode.title} started`,
|
| 86 |
+
`Session ${result.session_id?.slice(0, 8) || ""} is now active.`
|
| 87 |
+
);
|
| 88 |
+
|
| 89 |
+
onSessionStarted?.(result);
|
| 90 |
+
setActiveModeId(null);
|
| 91 |
+
setInputValue("");
|
| 92 |
+
} catch (err) {
|
| 93 |
+
setErrorByMode((prev) => ({
|
| 94 |
+
...prev,
|
| 95 |
+
[mode.id]: err?.message || "Failed to start session",
|
| 96 |
+
}));
|
| 97 |
+
} finally {
|
| 98 |
+
setSubmittingId(null);
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
const handleCancel = () => {
|
| 103 |
+
if (submittingId) return;
|
| 104 |
+
setActiveModeId(null);
|
| 105 |
+
setInputValue("");
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
return (
|
| 109 |
+
<div>
|
| 110 |
+
<h3 style={{ marginBottom: "16px" }}>Workspace Modes</h3>
|
| 111 |
+
<p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "16px" }}>
|
| 112 |
+
Choose how you want GitPilot to interact with your code. You can switch modes at any time.
|
| 113 |
+
</p>
|
| 114 |
+
|
| 115 |
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "16px" }}>
|
| 116 |
+
{MODES.map((mode) => {
|
| 117 |
+
const isActive = activeModeId === mode.id;
|
| 118 |
+
const isSubmitting = submittingId === mode.id;
|
| 119 |
+
const error = errorByMode[mode.id];
|
| 120 |
+
|
| 121 |
+
return (
|
| 122 |
+
<div
|
| 123 |
+
key={mode.id}
|
| 124 |
+
role="button"
|
| 125 |
+
tabIndex={isSubmitting ? -1 : 0}
|
| 126 |
+
aria-disabled={!!submittingId && !isSubmitting}
|
| 127 |
+
onClick={() => !isActive && handleCardClick(mode)}
|
| 128 |
+
onKeyDown={(e) => {
|
| 129 |
+
if ((e.key === "Enter" || e.key === " ") && !isActive) {
|
| 130 |
+
e.preventDefault();
|
| 131 |
+
handleCardClick(mode);
|
| 132 |
+
}
|
| 133 |
+
}}
|
| 134 |
+
style={{
|
| 135 |
+
background: isActive ? "#1e3a5f" : "#1a1b26",
|
| 136 |
+
borderRadius: "8px",
|
| 137 |
+
padding: "20px",
|
| 138 |
+
border: isActive ? "1px solid #3B82F6" : "1px solid #2a2b36",
|
| 139 |
+
cursor: submittingId && !isSubmitting ? "not-allowed" : "pointer",
|
| 140 |
+
opacity: submittingId && !isSubmitting ? 0.5 : 1,
|
| 141 |
+
transition: "all 150ms ease",
|
| 142 |
+
}}
|
| 143 |
+
>
|
| 144 |
+
<h4 style={{ marginBottom: "8px", color: isActive ? "#93c5fd" : "#fff" }}>
|
| 145 |
+
{mode.title}
|
| 146 |
+
</h4>
|
| 147 |
+
<p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "12px" }}>
|
| 148 |
+
{mode.description}
|
| 149 |
+
</p>
|
| 150 |
+
<div style={{ fontSize: "12px", marginBottom: "4px" }}>
|
| 151 |
+
<span style={{ opacity: 0.6 }}>Requires: </span>
|
| 152 |
+
{mode.requires}
|
| 153 |
+
</div>
|
| 154 |
+
<div style={{ fontSize: "12px", marginBottom: "12px" }}>
|
| 155 |
+
<span style={{ opacity: 0.6 }}>Enables: </span>
|
| 156 |
+
{mode.enables}
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
{isActive && (
|
| 160 |
+
<div onClick={(e) => e.stopPropagation()} style={{ marginTop: "12px" }}>
|
| 161 |
+
<label
|
| 162 |
+
htmlFor={`mode-input-${mode.id}`}
|
| 163 |
+
style={{
|
| 164 |
+
fontSize: "11px",
|
| 165 |
+
opacity: 0.7,
|
| 166 |
+
display: "block",
|
| 167 |
+
marginBottom: "4px",
|
| 168 |
+
}}
|
| 169 |
+
>
|
| 170 |
+
{mode.promptLabel}
|
| 171 |
+
</label>
|
| 172 |
+
<input
|
| 173 |
+
id={`mode-input-${mode.id}`}
|
| 174 |
+
type="text"
|
| 175 |
+
value={inputValue}
|
| 176 |
+
onChange={(e) => setInputValue(e.target.value)}
|
| 177 |
+
onKeyDown={(e) => {
|
| 178 |
+
if (e.key === "Enter") {
|
| 179 |
+
e.preventDefault();
|
| 180 |
+
handleStart(mode);
|
| 181 |
+
} else if (e.key === "Escape") {
|
| 182 |
+
handleCancel();
|
| 183 |
+
}
|
| 184 |
+
}}
|
| 185 |
+
placeholder={mode.promptPlaceholder}
|
| 186 |
+
disabled={isSubmitting}
|
| 187 |
+
autoFocus
|
| 188 |
+
style={{
|
| 189 |
+
width: "100%",
|
| 190 |
+
padding: "6px 8px",
|
| 191 |
+
background: "#0d0e15",
|
| 192 |
+
border: "1px solid #2a2b36",
|
| 193 |
+
borderRadius: "4px",
|
| 194 |
+
color: "#fff",
|
| 195 |
+
fontSize: "12px",
|
| 196 |
+
fontFamily: "monospace",
|
| 197 |
+
}}
|
| 198 |
+
/>
|
| 199 |
+
{error && (
|
| 200 |
+
<div
|
| 201 |
+
style={{
|
| 202 |
+
fontSize: "11px",
|
| 203 |
+
color: "#f87171",
|
| 204 |
+
marginTop: "6px",
|
| 205 |
+
}}
|
| 206 |
+
role="alert"
|
| 207 |
+
>
|
| 208 |
+
{error}
|
| 209 |
+
</div>
|
| 210 |
+
)}
|
| 211 |
+
<div style={{ display: "flex", gap: "6px", marginTop: "10px" }}>
|
| 212 |
+
<button
|
| 213 |
+
type="button"
|
| 214 |
+
onClick={() => handleStart(mode)}
|
| 215 |
+
disabled={isSubmitting || !inputValue.trim()}
|
| 216 |
+
style={{
|
| 217 |
+
padding: "6px 12px",
|
| 218 |
+
background: isSubmitting ? "#555" : "#3B82F6",
|
| 219 |
+
color: "#fff",
|
| 220 |
+
border: "none",
|
| 221 |
+
borderRadius: "4px",
|
| 222 |
+
cursor: isSubmitting || !inputValue.trim() ? "not-allowed" : "pointer",
|
| 223 |
+
fontSize: "12px",
|
| 224 |
+
fontWeight: 600,
|
| 225 |
+
}}
|
| 226 |
+
>
|
| 227 |
+
{isSubmitting ? "Starting..." : "Start Session"}
|
| 228 |
+
</button>
|
| 229 |
+
<button
|
| 230 |
+
type="button"
|
| 231 |
+
onClick={handleCancel}
|
| 232 |
+
disabled={isSubmitting}
|
| 233 |
+
style={{
|
| 234 |
+
padding: "6px 12px",
|
| 235 |
+
background: "transparent",
|
| 236 |
+
color: "#a0a0b0",
|
| 237 |
+
border: "1px solid #2a2b36",
|
| 238 |
+
borderRadius: "4px",
|
| 239 |
+
cursor: isSubmitting ? "not-allowed" : "pointer",
|
| 240 |
+
fontSize: "12px",
|
| 241 |
+
}}
|
| 242 |
+
>
|
| 243 |
+
Cancel
|
| 244 |
+
</button>
|
| 245 |
+
</div>
|
| 246 |
+
</div>
|
| 247 |
+
)}
|
| 248 |
+
</div>
|
| 249 |
+
);
|
| 250 |
+
})}
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
);
|
| 254 |
+
}
|
frontend/components/AdminTabs/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/index.js
|
| 2 |
+
// Barrel export — all admin tab components in one place
|
| 3 |
+
export { default as WorkspaceModesTab } from "./WorkspaceModesTab.jsx";
|
| 4 |
+
export { default as SecurityTab } from "./SecurityTab.jsx";
|
| 5 |
+
export { default as IntegrationsTab } from "./IntegrationsTab.jsx";
|
| 6 |
+
export { default as MCPServersTab } from "./MCPServersTab.jsx";
|
| 7 |
+
export { default as SkillsTab } from "./SkillsTab.jsx";
|
| 8 |
+
export { default as SessionsTab } from "./SessionsTab.jsx";
|
| 9 |
+
export { default as AdvancedTab } from "./AdvancedTab.jsx";
|
| 10 |
+
export { default as SandboxTab } from "./SandboxTab.jsx";
|
frontend/components/AdminTabs/mcp/CatalogList.jsx
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/mcp/CatalogList.jsx
|
| 2 |
+
// Browse curated MCP servers shipped with GitPilot. Each item has a
|
| 3 |
+
// one-click "Install" that lands the server in the Installed tab,
|
| 4 |
+
// disabled by default.
|
| 5 |
+
|
| 6 |
+
import React from "react";
|
| 7 |
+
|
| 8 |
+
export default function CatalogList({ items, onInstall }) {
|
| 9 |
+
if (!items?.length) {
|
| 10 |
+
return (
|
| 11 |
+
<p style={{ color: "#a0a0b0", fontSize: "13px" }}>
|
| 12 |
+
No catalog entries shipped with this build.
|
| 13 |
+
</p>
|
| 14 |
+
);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
return (
|
| 18 |
+
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "1fr" }}>
|
| 19 |
+
{items.map((item) => (
|
| 20 |
+
<div
|
| 21 |
+
key={item.id}
|
| 22 |
+
style={{
|
| 23 |
+
background: "#1a1b26",
|
| 24 |
+
border: "1px solid #2a2b36",
|
| 25 |
+
borderRadius: "8px",
|
| 26 |
+
padding: "16px 20px",
|
| 27 |
+
display: "flex",
|
| 28 |
+
justifyContent: "space-between",
|
| 29 |
+
alignItems: "center",
|
| 30 |
+
gap: "16px",
|
| 31 |
+
}}
|
| 32 |
+
>
|
| 33 |
+
<div style={{ flex: "1 1 auto", minWidth: 0 }}>
|
| 34 |
+
<div
|
| 35 |
+
style={{
|
| 36 |
+
display: "flex",
|
| 37 |
+
gap: "8px",
|
| 38 |
+
alignItems: "center",
|
| 39 |
+
marginBottom: "4px",
|
| 40 |
+
}}
|
| 41 |
+
>
|
| 42 |
+
<strong style={{ fontSize: "14px", color: "#e0e0e7" }}>
|
| 43 |
+
{item.id}
|
| 44 |
+
</strong>
|
| 45 |
+
{item.installed && (
|
| 46 |
+
<span
|
| 47 |
+
style={{
|
| 48 |
+
padding: "2px 8px",
|
| 49 |
+
background: "#0f3a26",
|
| 50 |
+
color: "#86efac",
|
| 51 |
+
borderRadius: "4px",
|
| 52 |
+
fontSize: "10px",
|
| 53 |
+
fontWeight: 600,
|
| 54 |
+
}}
|
| 55 |
+
>
|
| 56 |
+
installed
|
| 57 |
+
</span>
|
| 58 |
+
)}
|
| 59 |
+
</div>
|
| 60 |
+
<div style={{ fontSize: "12px", color: "#a0a0b0", marginBottom: "6px" }}>
|
| 61 |
+
{item.description}
|
| 62 |
+
</div>
|
| 63 |
+
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
| 64 |
+
{(item.tags || []).map((t) => (
|
| 65 |
+
<span
|
| 66 |
+
key={t}
|
| 67 |
+
style={{
|
| 68 |
+
padding: "1px 6px",
|
| 69 |
+
background: "#252634",
|
| 70 |
+
border: "1px solid #2a2b36",
|
| 71 |
+
borderRadius: "10px",
|
| 72 |
+
fontSize: "10px",
|
| 73 |
+
color: "#cdd0d8",
|
| 74 |
+
}}
|
| 75 |
+
>
|
| 76 |
+
{t}
|
| 77 |
+
</span>
|
| 78 |
+
))}
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
<button
|
| 82 |
+
onClick={() => onInstall(item.id)}
|
| 83 |
+
disabled={item.installed}
|
| 84 |
+
style={{
|
| 85 |
+
padding: "6px 14px",
|
| 86 |
+
background: item.installed ? "#252634" : "#3B82F6",
|
| 87 |
+
color: item.installed ? "#7a7d8a" : "#fff",
|
| 88 |
+
border: "none",
|
| 89 |
+
borderRadius: "4px",
|
| 90 |
+
cursor: item.installed ? "not-allowed" : "pointer",
|
| 91 |
+
fontSize: "12px",
|
| 92 |
+
fontWeight: 600,
|
| 93 |
+
}}
|
| 94 |
+
>
|
| 95 |
+
{item.installed ? "Installed" : "Install"}
|
| 96 |
+
</button>
|
| 97 |
+
</div>
|
| 98 |
+
))}
|
| 99 |
+
</div>
|
| 100 |
+
);
|
| 101 |
+
}
|
frontend/components/AdminTabs/mcp/CustomInstallForm.jsx
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/mcp/CustomInstallForm.jsx
|
| 2 |
+
// Paste-a-register.json form for installing a custom MCP server.
|
| 3 |
+
|
| 4 |
+
import React, { useState } from "react";
|
| 5 |
+
|
| 6 |
+
const SAMPLE = `{
|
| 7 |
+
"name": "mcp-neo4j-server",
|
| 8 |
+
"endpoint": "http://mcp-neo4j-server:8083/mcp",
|
| 9 |
+
"description": "Neo4j MCP server for graph schema discovery",
|
| 10 |
+
"tags": ["graph", "neo4j"],
|
| 11 |
+
"auth": { "type": "bearer", "env": "MCP_NEO4J_SERVER_TOKEN" }
|
| 12 |
+
}`;
|
| 13 |
+
|
| 14 |
+
export default function CustomInstallForm({ onSubmit }) {
|
| 15 |
+
const [text, setText] = useState(SAMPLE);
|
| 16 |
+
const [err, setErr] = useState(null);
|
| 17 |
+
const [submitting, setSubmitting] = useState(false);
|
| 18 |
+
|
| 19 |
+
const handleSubmit = async () => {
|
| 20 |
+
setErr(null);
|
| 21 |
+
let parsed;
|
| 22 |
+
try {
|
| 23 |
+
parsed = JSON.parse(text);
|
| 24 |
+
} catch (e) {
|
| 25 |
+
setErr("Invalid JSON: " + (e?.message || ""));
|
| 26 |
+
return;
|
| 27 |
+
}
|
| 28 |
+
if (!parsed.name || !parsed.endpoint) {
|
| 29 |
+
setErr("register.json must include 'name' and 'endpoint'.");
|
| 30 |
+
return;
|
| 31 |
+
}
|
| 32 |
+
setSubmitting(true);
|
| 33 |
+
try {
|
| 34 |
+
await onSubmit(parsed);
|
| 35 |
+
setText(SAMPLE);
|
| 36 |
+
} catch (e) {
|
| 37 |
+
setErr(e?.message || "Install failed");
|
| 38 |
+
} finally {
|
| 39 |
+
setSubmitting(false);
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<div
|
| 45 |
+
style={{
|
| 46 |
+
background: "#1a1b26",
|
| 47 |
+
border: "1px solid #2a2b36",
|
| 48 |
+
borderRadius: "8px",
|
| 49 |
+
padding: "20px",
|
| 50 |
+
}}
|
| 51 |
+
>
|
| 52 |
+
<h4 style={{ margin: "0 0 6px 0", color: "#e0e0e7" }}>Install custom server</h4>
|
| 53 |
+
<p style={{ margin: "0 0 12px 0", fontSize: "12px", color: "#a0a0b0" }}>
|
| 54 |
+
Paste a Context Forge <code>register.json</code>. The server lands
|
| 55 |
+
disabled — turn it on from the Installed tab once you have set its
|
| 56 |
+
auth token.
|
| 57 |
+
</p>
|
| 58 |
+
<textarea
|
| 59 |
+
value={text}
|
| 60 |
+
onChange={(e) => setText(e.target.value)}
|
| 61 |
+
rows={12}
|
| 62 |
+
spellCheck={false}
|
| 63 |
+
aria-label="register.json"
|
| 64 |
+
style={{
|
| 65 |
+
width: "100%",
|
| 66 |
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
| 67 |
+
fontSize: "12px",
|
| 68 |
+
background: "#0f1018",
|
| 69 |
+
color: "#cdd0d8",
|
| 70 |
+
border: "1px solid #2a2b36",
|
| 71 |
+
borderRadius: "6px",
|
| 72 |
+
padding: "12px",
|
| 73 |
+
resize: "vertical",
|
| 74 |
+
boxSizing: "border-box",
|
| 75 |
+
}}
|
| 76 |
+
/>
|
| 77 |
+
{err && (
|
| 78 |
+
<div
|
| 79 |
+
role="alert"
|
| 80 |
+
style={{
|
| 81 |
+
marginTop: "8px",
|
| 82 |
+
padding: "8px 12px",
|
| 83 |
+
background: "#3a0f0f",
|
| 84 |
+
border: "1px solid #5a1f1f",
|
| 85 |
+
borderRadius: "6px",
|
| 86 |
+
color: "#fca5a5",
|
| 87 |
+
fontSize: "12px",
|
| 88 |
+
}}
|
| 89 |
+
>
|
| 90 |
+
{err}
|
| 91 |
+
</div>
|
| 92 |
+
)}
|
| 93 |
+
<div
|
| 94 |
+
style={{
|
| 95 |
+
display: "flex",
|
| 96 |
+
justifyContent: "flex-end",
|
| 97 |
+
gap: "8px",
|
| 98 |
+
marginTop: "12px",
|
| 99 |
+
}}
|
| 100 |
+
>
|
| 101 |
+
<button
|
| 102 |
+
onClick={() => setText(SAMPLE)}
|
| 103 |
+
style={{
|
| 104 |
+
padding: "6px 12px",
|
| 105 |
+
background: "transparent",
|
| 106 |
+
color: "#cdd0d8",
|
| 107 |
+
border: "1px solid #3a3b4a",
|
| 108 |
+
borderRadius: "4px",
|
| 109 |
+
cursor: "pointer",
|
| 110 |
+
fontSize: "12px",
|
| 111 |
+
}}
|
| 112 |
+
>
|
| 113 |
+
Reset
|
| 114 |
+
</button>
|
| 115 |
+
<button
|
| 116 |
+
onClick={handleSubmit}
|
| 117 |
+
disabled={submitting}
|
| 118 |
+
style={{
|
| 119 |
+
padding: "6px 14px",
|
| 120 |
+
background: "#3B82F6",
|
| 121 |
+
color: "#fff",
|
| 122 |
+
border: "none",
|
| 123 |
+
borderRadius: "4px",
|
| 124 |
+
cursor: submitting ? "not-allowed" : "pointer",
|
| 125 |
+
fontSize: "12px",
|
| 126 |
+
fontWeight: 600,
|
| 127 |
+
}}
|
| 128 |
+
>
|
| 129 |
+
{submitting ? "Installing…" : "Install server"}
|
| 130 |
+
</button>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
);
|
| 134 |
+
}
|
frontend/components/AdminTabs/mcp/GatewayHeader.jsx
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/mcp/GatewayHeader.jsx
|
| 2 |
+
// Top strip of the MCP Servers tab: gateway pill + roll-up counters.
|
| 3 |
+
|
| 4 |
+
import React from "react";
|
| 5 |
+
|
| 6 |
+
export default function GatewayHeader({
|
| 7 |
+
status,
|
| 8 |
+
installedCount,
|
| 9 |
+
enabledCount,
|
| 10 |
+
totalTools,
|
| 11 |
+
onRefresh,
|
| 12 |
+
onSync,
|
| 13 |
+
syncing,
|
| 14 |
+
}) {
|
| 15 |
+
const reachable = status?.gateway_reachable;
|
| 16 |
+
const dotColor = reachable ? "#10b981" : "#ef4444";
|
| 17 |
+
const dotLabel = reachable ? "Connected" : "Unreachable";
|
| 18 |
+
const gatewayUrl = status?.gateway_url || "—";
|
| 19 |
+
|
| 20 |
+
return (
|
| 21 |
+
<div
|
| 22 |
+
style={{
|
| 23 |
+
background: "#1a1b26",
|
| 24 |
+
border: "1px solid #2a2b36",
|
| 25 |
+
borderRadius: "8px",
|
| 26 |
+
padding: "16px 20px",
|
| 27 |
+
marginBottom: "16px",
|
| 28 |
+
display: "flex",
|
| 29 |
+
justifyContent: "space-between",
|
| 30 |
+
alignItems: "center",
|
| 31 |
+
flexWrap: "wrap",
|
| 32 |
+
gap: "12px",
|
| 33 |
+
}}
|
| 34 |
+
>
|
| 35 |
+
<div>
|
| 36 |
+
<div
|
| 37 |
+
style={{
|
| 38 |
+
display: "flex",
|
| 39 |
+
alignItems: "center",
|
| 40 |
+
gap: "8px",
|
| 41 |
+
marginBottom: "4px",
|
| 42 |
+
}}
|
| 43 |
+
>
|
| 44 |
+
<span
|
| 45 |
+
aria-hidden
|
| 46 |
+
style={{
|
| 47 |
+
width: "10px",
|
| 48 |
+
height: "10px",
|
| 49 |
+
background: dotColor,
|
| 50 |
+
borderRadius: "50%",
|
| 51 |
+
boxShadow: `0 0 8px ${dotColor}`,
|
| 52 |
+
}}
|
| 53 |
+
/>
|
| 54 |
+
<strong style={{ fontSize: "14px", color: "#e0e0e7" }}>
|
| 55 |
+
MCP Context Forge — {dotLabel}
|
| 56 |
+
</strong>
|
| 57 |
+
{status?.plugin_enabled === false && (
|
| 58 |
+
<span
|
| 59 |
+
title="Set GITPILOT_MCP_ENABLED=true to let agents call MCP tools."
|
| 60 |
+
style={{
|
| 61 |
+
marginLeft: "8px",
|
| 62 |
+
padding: "2px 8px",
|
| 63 |
+
background: "#5c1a1a",
|
| 64 |
+
color: "#fecaca",
|
| 65 |
+
borderRadius: "4px",
|
| 66 |
+
fontSize: "11px",
|
| 67 |
+
fontWeight: 600,
|
| 68 |
+
}}
|
| 69 |
+
>
|
| 70 |
+
plugin disabled
|
| 71 |
+
</span>
|
| 72 |
+
)}
|
| 73 |
+
</div>
|
| 74 |
+
<div style={{ fontSize: "12px", color: "#a0a0b0" }}>
|
| 75 |
+
Gateway: <code>{gatewayUrl}</code>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div
|
| 80 |
+
style={{
|
| 81 |
+
display: "flex",
|
| 82 |
+
gap: "20px",
|
| 83 |
+
alignItems: "center",
|
| 84 |
+
fontSize: "12px",
|
| 85 |
+
color: "#cdd0d8",
|
| 86 |
+
}}
|
| 87 |
+
>
|
| 88 |
+
<Counter label="Installed" value={installedCount} />
|
| 89 |
+
<Counter label="Enabled" value={enabledCount} />
|
| 90 |
+
<Counter label="Tools" value={totalTools} />
|
| 91 |
+
<button
|
| 92 |
+
onClick={onRefresh}
|
| 93 |
+
style={{
|
| 94 |
+
padding: "6px 12px",
|
| 95 |
+
background: "#252634",
|
| 96 |
+
color: "#e0e0e7",
|
| 97 |
+
border: "1px solid #3a3b4a",
|
| 98 |
+
borderRadius: "4px",
|
| 99 |
+
cursor: "pointer",
|
| 100 |
+
fontSize: "12px",
|
| 101 |
+
}}
|
| 102 |
+
>
|
| 103 |
+
Refresh
|
| 104 |
+
</button>
|
| 105 |
+
{onSync && (
|
| 106 |
+
<button
|
| 107 |
+
onClick={onSync}
|
| 108 |
+
disabled={!reachable || syncing}
|
| 109 |
+
title={
|
| 110 |
+
reachable
|
| 111 |
+
? "Pull the server registry from MCP Context Forge"
|
| 112 |
+
: "Gateway unreachable — start MCP Context Forge first (make run)"
|
| 113 |
+
}
|
| 114 |
+
style={{
|
| 115 |
+
padding: "6px 12px",
|
| 116 |
+
background: reachable ? "#1e3a5f" : "#252634",
|
| 117 |
+
color: reachable ? "#93c5fd" : "#7a7d8a",
|
| 118 |
+
border: `1px solid ${reachable ? "#3B82F6" : "#3a3b4a"}`,
|
| 119 |
+
borderRadius: "4px",
|
| 120 |
+
cursor: reachable && !syncing ? "pointer" : "not-allowed",
|
| 121 |
+
fontSize: "12px",
|
| 122 |
+
fontWeight: 600,
|
| 123 |
+
opacity: !reachable || syncing ? 0.6 : 1,
|
| 124 |
+
}}
|
| 125 |
+
>
|
| 126 |
+
{syncing ? "Syncing…" : "Sync"}
|
| 127 |
+
</button>
|
| 128 |
+
)}
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function Counter({ label, value }) {
|
| 135 |
+
return (
|
| 136 |
+
<div style={{ textAlign: "center" }}>
|
| 137 |
+
<div style={{ fontSize: "18px", fontWeight: 700, color: "#e0e0e7" }}>
|
| 138 |
+
{value ?? "—"}
|
| 139 |
+
</div>
|
| 140 |
+
<div style={{ fontSize: "11px", color: "#7a7d8a", textTransform: "uppercase" }}>
|
| 141 |
+
{label}
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
);
|
| 145 |
+
}
|
frontend/components/AdminTabs/mcp/ServerCard.jsx
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/mcp/ServerCard.jsx
|
| 2 |
+
// One installed MCP server. Collapsed shows summary + actions; expanded
|
| 3 |
+
// reveals the per-tool list with risk badges and individual toggles.
|
| 4 |
+
|
| 5 |
+
import React, { useState } from "react";
|
| 6 |
+
import ToolRow from "./ToolRow.jsx";
|
| 7 |
+
|
| 8 |
+
const RISK_PALETTE = {
|
| 9 |
+
low: { bg: "#0f3a26", border: "#1f5a3e", text: "#86efac" },
|
| 10 |
+
medium: { bg: "#3a2e0f", border: "#5a4a1f", text: "#fcd34d" },
|
| 11 |
+
high: { bg: "#3a0f0f", border: "#5a1f1f", text: "#fca5a5" },
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
export default function ServerCard({
|
| 15 |
+
server,
|
| 16 |
+
onEnable,
|
| 17 |
+
onDisable,
|
| 18 |
+
onUninstall,
|
| 19 |
+
onTest,
|
| 20 |
+
onToggleTool,
|
| 21 |
+
onForget,
|
| 22 |
+
}) {
|
| 23 |
+
const [expanded, setExpanded] = useState(false);
|
| 24 |
+
const [testResult, setTestResult] = useState(null);
|
| 25 |
+
const [testing, setTesting] = useState(false);
|
| 26 |
+
|
| 27 |
+
const handleTest = async () => {
|
| 28 |
+
setTesting(true);
|
| 29 |
+
try {
|
| 30 |
+
const result = await onTest();
|
| 31 |
+
setTestResult(result);
|
| 32 |
+
} finally {
|
| 33 |
+
setTesting(false);
|
| 34 |
+
}
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
const statusDot = server.enabled ? "#10b981" : "#6b7280";
|
| 38 |
+
const statusLabel = server.enabled ? "Enabled" : "Disabled";
|
| 39 |
+
|
| 40 |
+
// Risk roll-up shown next to the tool count.
|
| 41 |
+
const riskCounts = server.tools?.reduce(
|
| 42 |
+
(acc, t) => ({ ...acc, [t.risk]: (acc[t.risk] || 0) + 1 }),
|
| 43 |
+
{}
|
| 44 |
+
) || {};
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<div
|
| 48 |
+
style={{
|
| 49 |
+
background: "#1a1b26",
|
| 50 |
+
border: "1px solid #2a2b36",
|
| 51 |
+
borderRadius: "8px",
|
| 52 |
+
overflow: "hidden",
|
| 53 |
+
}}
|
| 54 |
+
>
|
| 55 |
+
<div style={{ padding: "16px 20px" }}>
|
| 56 |
+
<div
|
| 57 |
+
style={{
|
| 58 |
+
display: "flex",
|
| 59 |
+
justifyContent: "space-between",
|
| 60 |
+
alignItems: "flex-start",
|
| 61 |
+
gap: "16px",
|
| 62 |
+
flexWrap: "wrap",
|
| 63 |
+
}}
|
| 64 |
+
>
|
| 65 |
+
<div style={{ flex: "1 1 320px", minWidth: 0 }}>
|
| 66 |
+
<div
|
| 67 |
+
style={{
|
| 68 |
+
display: "flex",
|
| 69 |
+
alignItems: "center",
|
| 70 |
+
gap: "8px",
|
| 71 |
+
marginBottom: "6px",
|
| 72 |
+
}}
|
| 73 |
+
>
|
| 74 |
+
<span
|
| 75 |
+
aria-hidden
|
| 76 |
+
style={{
|
| 77 |
+
width: "8px",
|
| 78 |
+
height: "8px",
|
| 79 |
+
background: statusDot,
|
| 80 |
+
borderRadius: "50%",
|
| 81 |
+
}}
|
| 82 |
+
/>
|
| 83 |
+
<strong
|
| 84 |
+
style={{
|
| 85 |
+
fontSize: "15px",
|
| 86 |
+
color: "#e0e0e7",
|
| 87 |
+
overflow: "hidden",
|
| 88 |
+
textOverflow: "ellipsis",
|
| 89 |
+
whiteSpace: "nowrap",
|
| 90 |
+
}}
|
| 91 |
+
>
|
| 92 |
+
{server.id}
|
| 93 |
+
</strong>
|
| 94 |
+
<span
|
| 95 |
+
style={{
|
| 96 |
+
padding: "2px 8px",
|
| 97 |
+
background: server.enabled ? "#0f3a26" : "#252634",
|
| 98 |
+
color: server.enabled ? "#86efac" : "#a0a0b0",
|
| 99 |
+
borderRadius: "4px",
|
| 100 |
+
fontSize: "11px",
|
| 101 |
+
fontWeight: 600,
|
| 102 |
+
}}
|
| 103 |
+
>
|
| 104 |
+
{statusLabel}
|
| 105 |
+
</span>
|
| 106 |
+
{!server.is_known && (
|
| 107 |
+
<span
|
| 108 |
+
title="Custom server (not part of the GitPilot catalog)"
|
| 109 |
+
style={{
|
| 110 |
+
padding: "2px 8px",
|
| 111 |
+
background: "#1e3a5f",
|
| 112 |
+
color: "#93c5fd",
|
| 113 |
+
borderRadius: "4px",
|
| 114 |
+
fontSize: "11px",
|
| 115 |
+
}}
|
| 116 |
+
>
|
| 117 |
+
custom
|
| 118 |
+
</span>
|
| 119 |
+
)}
|
| 120 |
+
{server.orphan && (
|
| 121 |
+
<span
|
| 122 |
+
title="No longer registered in MCP Context Forge. Still works locally; click Forget to drop it, or re-attach it in Forge."
|
| 123 |
+
style={{
|
| 124 |
+
padding: "2px 8px",
|
| 125 |
+
background: "#3a2e0f",
|
| 126 |
+
color: "#fcd34d",
|
| 127 |
+
borderRadius: "4px",
|
| 128 |
+
fontSize: "11px",
|
| 129 |
+
fontWeight: 600,
|
| 130 |
+
}}
|
| 131 |
+
>
|
| 132 |
+
orphan
|
| 133 |
+
</span>
|
| 134 |
+
)}
|
| 135 |
+
{server.source === "forge-sync" && !server.orphan && (
|
| 136 |
+
<span
|
| 137 |
+
title="Added by the most recent forge sync."
|
| 138 |
+
style={{
|
| 139 |
+
padding: "2px 8px",
|
| 140 |
+
background: "#1e3a5f",
|
| 141 |
+
color: "#93c5fd",
|
| 142 |
+
borderRadius: "4px",
|
| 143 |
+
fontSize: "11px",
|
| 144 |
+
}}
|
| 145 |
+
>
|
| 146 |
+
via sync
|
| 147 |
+
</span>
|
| 148 |
+
)}
|
| 149 |
+
</div>
|
| 150 |
+
<div
|
| 151 |
+
style={{
|
| 152 |
+
fontSize: "12px",
|
| 153 |
+
color: "#a0a0b0",
|
| 154 |
+
marginBottom: "8px",
|
| 155 |
+
}}
|
| 156 |
+
>
|
| 157 |
+
{server.description || "—"}
|
| 158 |
+
</div>
|
| 159 |
+
<div
|
| 160 |
+
style={{
|
| 161 |
+
fontSize: "11px",
|
| 162 |
+
color: "#7a7d8a",
|
| 163 |
+
marginBottom: "8px",
|
| 164 |
+
wordBreak: "break-all",
|
| 165 |
+
}}
|
| 166 |
+
>
|
| 167 |
+
<code>{server.endpoint || "—"}</code>
|
| 168 |
+
</div>
|
| 169 |
+
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
|
| 170 |
+
{(server.tags || []).map((t) => (
|
| 171 |
+
<span
|
| 172 |
+
key={t}
|
| 173 |
+
style={{
|
| 174 |
+
padding: "2px 8px",
|
| 175 |
+
background: "#252634",
|
| 176 |
+
border: "1px solid #2a2b36",
|
| 177 |
+
borderRadius: "10px",
|
| 178 |
+
fontSize: "11px",
|
| 179 |
+
color: "#cdd0d8",
|
| 180 |
+
}}
|
| 181 |
+
>
|
| 182 |
+
{t}
|
| 183 |
+
</span>
|
| 184 |
+
))}
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
<div
|
| 189 |
+
style={{
|
| 190 |
+
display: "flex",
|
| 191 |
+
flexDirection: "column",
|
| 192 |
+
gap: "8px",
|
| 193 |
+
alignItems: "flex-end",
|
| 194 |
+
}}
|
| 195 |
+
>
|
| 196 |
+
<div style={{ display: "flex", gap: "6px" }}>
|
| 197 |
+
{server.enabled ? (
|
| 198 |
+
<Btn variant="ghost" onClick={onDisable}>
|
| 199 |
+
Disable
|
| 200 |
+
</Btn>
|
| 201 |
+
) : (
|
| 202 |
+
<Btn variant="primary" onClick={onEnable}>
|
| 203 |
+
Enable
|
| 204 |
+
</Btn>
|
| 205 |
+
)}
|
| 206 |
+
<Btn onClick={handleTest} disabled={testing}>
|
| 207 |
+
{testing ? "Testing…" : "Test"}
|
| 208 |
+
</Btn>
|
| 209 |
+
{onForget ? (
|
| 210 |
+
<Btn variant="danger" onClick={onForget}>
|
| 211 |
+
Forget
|
| 212 |
+
</Btn>
|
| 213 |
+
) : (
|
| 214 |
+
<Btn variant="danger" onClick={onUninstall}>
|
| 215 |
+
Uninstall
|
| 216 |
+
</Btn>
|
| 217 |
+
)}
|
| 218 |
+
</div>
|
| 219 |
+
<div style={{ fontSize: "11px", color: "#a0a0b0" }}>
|
| 220 |
+
{server.tool_count} tool{server.tool_count === 1 ? "" : "s"}
|
| 221 |
+
{Object.entries(riskCounts).map(([risk, count]) => (
|
| 222 |
+
<span
|
| 223 |
+
key={risk}
|
| 224 |
+
title={`${count} ${risk}-risk tools`}
|
| 225 |
+
style={{
|
| 226 |
+
marginLeft: "6px",
|
| 227 |
+
padding: "1px 6px",
|
| 228 |
+
borderRadius: "10px",
|
| 229 |
+
background: RISK_PALETTE[risk]?.bg,
|
| 230 |
+
color: RISK_PALETTE[risk]?.text,
|
| 231 |
+
border: `1px solid ${RISK_PALETTE[risk]?.border}`,
|
| 232 |
+
fontSize: "10px",
|
| 233 |
+
}}
|
| 234 |
+
>
|
| 235 |
+
{count} {risk}
|
| 236 |
+
</span>
|
| 237 |
+
))}
|
| 238 |
+
</div>
|
| 239 |
+
<button
|
| 240 |
+
onClick={() => setExpanded((v) => !v)}
|
| 241 |
+
style={{
|
| 242 |
+
padding: "4px 8px",
|
| 243 |
+
background: "transparent",
|
| 244 |
+
color: "#93c5fd",
|
| 245 |
+
border: "none",
|
| 246 |
+
cursor: "pointer",
|
| 247 |
+
fontSize: "12px",
|
| 248 |
+
}}
|
| 249 |
+
>
|
| 250 |
+
{expanded ? "Hide tools ▴" : "Show tools ▾"}
|
| 251 |
+
</button>
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
|
| 255 |
+
{testResult && (
|
| 256 |
+
<div
|
| 257 |
+
role="status"
|
| 258 |
+
style={{
|
| 259 |
+
marginTop: "12px",
|
| 260 |
+
padding: "8px 12px",
|
| 261 |
+
fontSize: "12px",
|
| 262 |
+
borderRadius: "6px",
|
| 263 |
+
background: testResult.ok ? "#0f3a26" : "#3a0f0f",
|
| 264 |
+
border: `1px solid ${testResult.ok ? "#1f5a3e" : "#5a1f1f"}`,
|
| 265 |
+
color: testResult.ok ? "#86efac" : "#fca5a5",
|
| 266 |
+
}}
|
| 267 |
+
>
|
| 268 |
+
{testResult.ok
|
| 269 |
+
? "Healthy. Inspector confirmed the server is reachable and advertises its expected tools."
|
| 270 |
+
: `Failed: ${testResult.reason || testResult.error || "unknown error"}`}
|
| 271 |
+
</div>
|
| 272 |
+
)}
|
| 273 |
+
</div>
|
| 274 |
+
|
| 275 |
+
{expanded && (
|
| 276 |
+
<div
|
| 277 |
+
style={{
|
| 278 |
+
borderTop: "1px solid #2a2b36",
|
| 279 |
+
background: "#16171f",
|
| 280 |
+
padding: "8px 0",
|
| 281 |
+
}}
|
| 282 |
+
>
|
| 283 |
+
{server.tools?.length ? (
|
| 284 |
+
server.tools.map((t) => (
|
| 285 |
+
<ToolRow
|
| 286 |
+
key={t.name}
|
| 287 |
+
tool={t}
|
| 288 |
+
disabled={!server.enabled}
|
| 289 |
+
onToggle={(enabled) => onToggleTool(t.name, enabled)}
|
| 290 |
+
/>
|
| 291 |
+
))
|
| 292 |
+
) : (
|
| 293 |
+
<div
|
| 294 |
+
style={{
|
| 295 |
+
padding: "12px 20px",
|
| 296 |
+
fontSize: "12px",
|
| 297 |
+
color: "#a0a0b0",
|
| 298 |
+
}}
|
| 299 |
+
>
|
| 300 |
+
No tools advertised by this server.
|
| 301 |
+
</div>
|
| 302 |
+
)}
|
| 303 |
+
</div>
|
| 304 |
+
)}
|
| 305 |
+
</div>
|
| 306 |
+
);
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
function Btn({ children, variant = "default", ...props }) {
|
| 310 |
+
const palettes = {
|
| 311 |
+
default: { bg: "#252634", color: "#e0e0e7", border: "#3a3b4a" },
|
| 312 |
+
primary: { bg: "#3B82F6", color: "#fff", border: "#3B82F6" },
|
| 313 |
+
ghost: { bg: "transparent", color: "#cdd0d8", border: "#3a3b4a" },
|
| 314 |
+
danger: { bg: "transparent", color: "#fca5a5", border: "#5a1f1f" },
|
| 315 |
+
};
|
| 316 |
+
const p = palettes[variant];
|
| 317 |
+
return (
|
| 318 |
+
<button
|
| 319 |
+
{...props}
|
| 320 |
+
style={{
|
| 321 |
+
padding: "6px 12px",
|
| 322 |
+
background: p.bg,
|
| 323 |
+
color: p.color,
|
| 324 |
+
border: `1px solid ${p.border}`,
|
| 325 |
+
borderRadius: "4px",
|
| 326 |
+
cursor: props.disabled ? "not-allowed" : "pointer",
|
| 327 |
+
fontSize: "12px",
|
| 328 |
+
opacity: props.disabled ? 0.6 : 1,
|
| 329 |
+
}}
|
| 330 |
+
>
|
| 331 |
+
{children}
|
| 332 |
+
</button>
|
| 333 |
+
);
|
| 334 |
+
}
|
frontend/components/AdminTabs/mcp/SyncReport.jsx
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/mcp/SyncReport.jsx
|
| 2 |
+
// Renders the SyncReport returned by POST /api/mcp/sync as a compact
|
| 3 |
+
// dismissible banner. Best practices: structured + colour-coded counts,
|
| 4 |
+
// truncated id lists with a "View" toggle, single primary "Dismiss"
|
| 5 |
+
// action, ARIA "status" so a screen reader announces the result once.
|
| 6 |
+
|
| 7 |
+
import React, { useState } from "react";
|
| 8 |
+
|
| 9 |
+
export default function SyncReport({ report, onDismiss }) {
|
| 10 |
+
const [open, setOpen] = useState(false);
|
| 11 |
+
if (!report) return null;
|
| 12 |
+
|
| 13 |
+
const { added = [], kept = [], orphaned = [], forge_unreachable, error } = report;
|
| 14 |
+
|
| 15 |
+
if (forge_unreachable) {
|
| 16 |
+
return (
|
| 17 |
+
<Banner
|
| 18 |
+
kind="bad"
|
| 19 |
+
title="Sync failed: forge unreachable"
|
| 20 |
+
body={error || "Could not reach MCP Context Forge."}
|
| 21 |
+
onDismiss={onDismiss}
|
| 22 |
+
/>
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const noChanges = added.length === 0 && orphaned.length === 0;
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<Banner
|
| 30 |
+
kind={noChanges ? "info" : added.length ? "ok" : "warn"}
|
| 31 |
+
title={
|
| 32 |
+
noChanges
|
| 33 |
+
? "Sync complete — no changes"
|
| 34 |
+
: `Sync complete · +${added.length} added · ${kept.length} refreshed · ${orphaned.length} orphan`
|
| 35 |
+
}
|
| 36 |
+
onDismiss={onDismiss}
|
| 37 |
+
footer={
|
| 38 |
+
<button
|
| 39 |
+
onClick={() => setOpen((v) => !v)}
|
| 40 |
+
style={{
|
| 41 |
+
background: "transparent",
|
| 42 |
+
border: "none",
|
| 43 |
+
color: "#93c5fd",
|
| 44 |
+
cursor: "pointer",
|
| 45 |
+
fontSize: 12,
|
| 46 |
+
padding: 0,
|
| 47 |
+
}}
|
| 48 |
+
>
|
| 49 |
+
{open ? "Hide details ▴" : "View details ▾"}
|
| 50 |
+
</button>
|
| 51 |
+
}
|
| 52 |
+
>
|
| 53 |
+
{open && (
|
| 54 |
+
<div
|
| 55 |
+
style={{ fontSize: 12, color: "#cdd0d8", marginTop: 6, lineHeight: 1.5 }}
|
| 56 |
+
>
|
| 57 |
+
{added.length > 0 && (
|
| 58 |
+
<Detail label="Added" items={added} colour="#86efac" />
|
| 59 |
+
)}
|
| 60 |
+
{kept.length > 0 && (
|
| 61 |
+
<Detail label="Refreshed" items={kept} colour="#93c5fd" />
|
| 62 |
+
)}
|
| 63 |
+
{orphaned.length > 0 && (
|
| 64 |
+
<Detail
|
| 65 |
+
label="Orphaned"
|
| 66 |
+
items={orphaned}
|
| 67 |
+
colour="#fcd34d"
|
| 68 |
+
hint="Still works locally — Forge no longer advertises them. Use 'Forget' to drop, or re-attach them in Forge."
|
| 69 |
+
/>
|
| 70 |
+
)}
|
| 71 |
+
{report.correlation_id && (
|
| 72 |
+
<div style={{ marginTop: 6, color: "#7a7d8a" }}>
|
| 73 |
+
correlation_id: <code>{report.correlation_id}</code>
|
| 74 |
+
</div>
|
| 75 |
+
)}
|
| 76 |
+
</div>
|
| 77 |
+
)}
|
| 78 |
+
</Banner>
|
| 79 |
+
);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
function Banner({ kind, title, body, footer, onDismiss, children }) {
|
| 83 |
+
const palette = {
|
| 84 |
+
ok: { bg: "#0f3a26", border: "#1f5a3e", text: "#86efac" },
|
| 85 |
+
info: { bg: "#1e3a5f", border: "#3B82F6", text: "#93c5fd" },
|
| 86 |
+
warn: { bg: "#3a2e0f", border: "#5a4a1f", text: "#fcd34d" },
|
| 87 |
+
bad: { bg: "#3a0f0f", border: "#5a1f1f", text: "#fca5a5" },
|
| 88 |
+
}[kind] || { bg: "#1a1b26", border: "#2a2b36", text: "#cdd0d8" };
|
| 89 |
+
|
| 90 |
+
return (
|
| 91 |
+
<div
|
| 92 |
+
role="status"
|
| 93 |
+
aria-live="polite"
|
| 94 |
+
style={{
|
| 95 |
+
background: palette.bg,
|
| 96 |
+
border: `1px solid ${palette.border}`,
|
| 97 |
+
borderRadius: 6,
|
| 98 |
+
padding: "10px 14px",
|
| 99 |
+
marginBottom: 12,
|
| 100 |
+
color: palette.text,
|
| 101 |
+
fontSize: 13,
|
| 102 |
+
display: "flex",
|
| 103 |
+
flexDirection: "column",
|
| 104 |
+
gap: 4,
|
| 105 |
+
}}
|
| 106 |
+
>
|
| 107 |
+
<div
|
| 108 |
+
style={{
|
| 109 |
+
display: "flex",
|
| 110 |
+
justifyContent: "space-between",
|
| 111 |
+
alignItems: "center",
|
| 112 |
+
gap: 8,
|
| 113 |
+
}}
|
| 114 |
+
>
|
| 115 |
+
<strong>{title}</strong>
|
| 116 |
+
<button
|
| 117 |
+
onClick={onDismiss}
|
| 118 |
+
aria-label="Dismiss"
|
| 119 |
+
style={{
|
| 120 |
+
background: "transparent",
|
| 121 |
+
border: "none",
|
| 122 |
+
color: palette.text,
|
| 123 |
+
cursor: "pointer",
|
| 124 |
+
fontSize: 16,
|
| 125 |
+
lineHeight: 1,
|
| 126 |
+
padding: 0,
|
| 127 |
+
}}
|
| 128 |
+
>
|
| 129 |
+
×
|
| 130 |
+
</button>
|
| 131 |
+
</div>
|
| 132 |
+
{body && <div>{body}</div>}
|
| 133 |
+
{children}
|
| 134 |
+
{footer && <div>{footer}</div>}
|
| 135 |
+
</div>
|
| 136 |
+
);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function Detail({ label, items, colour, hint }) {
|
| 140 |
+
return (
|
| 141 |
+
<div style={{ marginTop: 4 }}>
|
| 142 |
+
<span style={{ color: colour, fontWeight: 600 }}>{label}:</span>{" "}
|
| 143 |
+
<span>
|
| 144 |
+
{items.slice(0, 6).join(", ")}
|
| 145 |
+
{items.length > 6 ? ` … (+${items.length - 6} more)` : ""}
|
| 146 |
+
</span>
|
| 147 |
+
{hint && (
|
| 148 |
+
<div style={{ marginTop: 2, fontSize: 11, color: "#7a7d8a" }}>{hint}</div>
|
| 149 |
+
)}
|
| 150 |
+
</div>
|
| 151 |
+
);
|
| 152 |
+
}
|
frontend/components/AdminTabs/mcp/ToolRow.jsx
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/AdminTabs/mcp/ToolRow.jsx
|
| 2 |
+
// One row inside an expanded ServerCard. Tool name, risk badge,
|
| 3 |
+
// "used by" agent chips, and a per-tool enable toggle.
|
| 4 |
+
|
| 5 |
+
import React from "react";
|
| 6 |
+
|
| 7 |
+
const RISK_PALETTE = {
|
| 8 |
+
low: { bg: "#0f3a26", text: "#86efac", label: "low" },
|
| 9 |
+
medium: { bg: "#3a2e0f", text: "#fcd34d", label: "med" },
|
| 10 |
+
high: { bg: "#3a0f0f", text: "#fca5a5", label: "high" },
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
export default function ToolRow({ tool, disabled, onToggle }) {
|
| 14 |
+
const risk = RISK_PALETTE[tool.risk] || RISK_PALETTE.low;
|
| 15 |
+
|
| 16 |
+
// Destructive tools cannot be toggled on by the UI; the backend's
|
| 17 |
+
// PolicyEngine will reject a toggle PUT anyway, but disabling the
|
| 18 |
+
// control surfaces the constraint up front.
|
| 19 |
+
const lockEnable = tool.destructive;
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<div
|
| 23 |
+
style={{
|
| 24 |
+
display: "flex",
|
| 25 |
+
alignItems: "center",
|
| 26 |
+
justifyContent: "space-between",
|
| 27 |
+
gap: "12px",
|
| 28 |
+
padding: "8px 20px",
|
| 29 |
+
borderBottom: "1px solid #1a1b26",
|
| 30 |
+
}}
|
| 31 |
+
>
|
| 32 |
+
<div style={{ flex: "1 1 auto", minWidth: 0 }}>
|
| 33 |
+
<div
|
| 34 |
+
style={{
|
| 35 |
+
display: "flex",
|
| 36 |
+
alignItems: "center",
|
| 37 |
+
gap: "8px",
|
| 38 |
+
marginBottom: "2px",
|
| 39 |
+
}}
|
| 40 |
+
>
|
| 41 |
+
<code
|
| 42 |
+
style={{
|
| 43 |
+
fontSize: "12px",
|
| 44 |
+
color: "#e0e0e7",
|
| 45 |
+
overflow: "hidden",
|
| 46 |
+
textOverflow: "ellipsis",
|
| 47 |
+
whiteSpace: "nowrap",
|
| 48 |
+
}}
|
| 49 |
+
>
|
| 50 |
+
{tool.name}
|
| 51 |
+
</code>
|
| 52 |
+
<span
|
| 53 |
+
title={`risk: ${tool.risk}`}
|
| 54 |
+
style={{
|
| 55 |
+
padding: "1px 6px",
|
| 56 |
+
borderRadius: "10px",
|
| 57 |
+
background: risk.bg,
|
| 58 |
+
color: risk.text,
|
| 59 |
+
fontSize: "10px",
|
| 60 |
+
fontWeight: 600,
|
| 61 |
+
textTransform: "uppercase",
|
| 62 |
+
}}
|
| 63 |
+
>
|
| 64 |
+
{risk.label}
|
| 65 |
+
</span>
|
| 66 |
+
</div>
|
| 67 |
+
<div
|
| 68 |
+
style={{
|
| 69 |
+
display: "flex",
|
| 70 |
+
gap: "4px",
|
| 71 |
+
flexWrap: "wrap",
|
| 72 |
+
fontSize: "10px",
|
| 73 |
+
color: "#7a7d8a",
|
| 74 |
+
}}
|
| 75 |
+
>
|
| 76 |
+
{(tool.used_by || []).length > 0 && <span>used by</span>}
|
| 77 |
+
{(tool.used_by || []).map((agent) => (
|
| 78 |
+
<span
|
| 79 |
+
key={agent}
|
| 80 |
+
style={{
|
| 81 |
+
padding: "1px 6px",
|
| 82 |
+
borderRadius: "10px",
|
| 83 |
+
background: "#1e3a5f",
|
| 84 |
+
color: "#93c5fd",
|
| 85 |
+
}}
|
| 86 |
+
>
|
| 87 |
+
{agent}
|
| 88 |
+
</span>
|
| 89 |
+
))}
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<Toggle
|
| 94 |
+
checked={tool.enabled}
|
| 95 |
+
disabled={disabled || lockEnable}
|
| 96 |
+
title={
|
| 97 |
+
lockEnable
|
| 98 |
+
? "Destructive tool — blocked by policy"
|
| 99 |
+
: disabled
|
| 100 |
+
? "Enable the server first"
|
| 101 |
+
: tool.enabled
|
| 102 |
+
? "Disable this tool for GitPilot agents"
|
| 103 |
+
: "Allow GitPilot agents to call this tool"
|
| 104 |
+
}
|
| 105 |
+
onChange={(checked) => onToggle(checked)}
|
| 106 |
+
/>
|
| 107 |
+
</div>
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function Toggle({ checked, disabled, title, onChange }) {
|
| 112 |
+
return (
|
| 113 |
+
<label title={title} style={{ display: "inline-flex", alignItems: "center" }}>
|
| 114 |
+
<input
|
| 115 |
+
type="checkbox"
|
| 116 |
+
checked={!!checked}
|
| 117 |
+
disabled={disabled}
|
| 118 |
+
onChange={(e) => onChange(e.target.checked)}
|
| 119 |
+
style={{ position: "absolute", opacity: 0, pointerEvents: "none" }}
|
| 120 |
+
aria-label={title}
|
| 121 |
+
/>
|
| 122 |
+
<span
|
| 123 |
+
style={{
|
| 124 |
+
width: "32px",
|
| 125 |
+
height: "18px",
|
| 126 |
+
borderRadius: "9px",
|
| 127 |
+
background: checked && !disabled ? "#3B82F6" : "#2a2b36",
|
| 128 |
+
position: "relative",
|
| 129 |
+
transition: "background 0.15s",
|
| 130 |
+
opacity: disabled ? 0.5 : 1,
|
| 131 |
+
cursor: disabled ? "not-allowed" : "pointer",
|
| 132 |
+
}}
|
| 133 |
+
>
|
| 134 |
+
<span
|
| 135 |
+
style={{
|
| 136 |
+
width: "14px",
|
| 137 |
+
height: "14px",
|
| 138 |
+
borderRadius: "50%",
|
| 139 |
+
background: "#fff",
|
| 140 |
+
position: "absolute",
|
| 141 |
+
top: "2px",
|
| 142 |
+
left: checked ? "16px" : "2px",
|
| 143 |
+
transition: "left 0.15s",
|
| 144 |
+
}}
|
| 145 |
+
/>
|
| 146 |
+
</span>
|
| 147 |
+
</label>
|
| 148 |
+
);
|
| 149 |
+
}
|
frontend/components/AssistantMessage.jsx
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from "react";
|
| 2 |
+
import PlanView from "./PlanView.jsx";
|
| 3 |
+
import RunnableCodeBlock, { splitFences } from "./RunnableCodeBlock.jsx";
|
| 4 |
+
import ExecutionPlanCard from "./ExecutionPlanCard.jsx";
|
| 5 |
+
|
| 6 |
+
export default function AssistantMessage({
|
| 7 |
+
answer,
|
| 8 |
+
plan,
|
| 9 |
+
executionLog,
|
| 10 |
+
planStatus,
|
| 11 |
+
owner,
|
| 12 |
+
repo,
|
| 13 |
+
onApproveExecution,
|
| 14 |
+
nextActions,
|
| 15 |
+
relatedPlan,
|
| 16 |
+
diff,
|
| 17 |
+
branch,
|
| 18 |
+
}) {
|
| 19 |
+
// Approval-first sandbox: when the planner returns an execution_plan,
|
| 20 |
+
// render the green ExecutionPlanCard instead of the orange Action Plan.
|
| 21 |
+
const executionPlan = plan?.execution_plan || null;
|
| 22 |
+
const [runResult, setRunResult] = useState(null);
|
| 23 |
+
const [runError, setRunError] = useState(null);
|
| 24 |
+
const [runBusy, setRunBusy] = useState(false);
|
| 25 |
+
|
| 26 |
+
const approveExecution = async (ep) => {
|
| 27 |
+
if (onApproveExecution) {
|
| 28 |
+
onApproveExecution(ep, plan);
|
| 29 |
+
return;
|
| 30 |
+
}
|
| 31 |
+
setRunBusy(true);
|
| 32 |
+
setRunError(null);
|
| 33 |
+
setRunResult(null);
|
| 34 |
+
try {
|
| 35 |
+
const body = ep.file
|
| 36 |
+
? { language: ep.language, code: null }
|
| 37 |
+
: { language: ep.language, code: ep.inline_code, timeout_sec: ep.timeout_sec };
|
| 38 |
+
const res = await fetch("/api/sandbox/run", {
|
| 39 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 40 |
+
body: JSON.stringify(body),
|
| 41 |
+
});
|
| 42 |
+
const data = await res.json();
|
| 43 |
+
if (!res.ok) setRunError(data.detail || `HTTP ${res.status}`);
|
| 44 |
+
else setRunResult(data);
|
| 45 |
+
} catch (e) { setRunError(e.message); }
|
| 46 |
+
finally { setRunBusy(false); }
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
const hasFileActions = plan?.steps?.some((s) => s.files?.length > 0);
|
| 50 |
+
const answerText = typeof answer === "string" ? answer.trim() : "";
|
| 51 |
+
// Suppress the answer prose when it would just duplicate what the
|
| 52 |
+
// plan card or success receipt already says.
|
| 53 |
+
const showAnswerProse =
|
| 54 |
+
answerText &&
|
| 55 |
+
!executionLog &&
|
| 56 |
+
!(plan && hasFileActions && !executionPlan && answerText === plan.summary);
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div className="chat-message-ai assistant-card">
|
| 60 |
+
{/* Free-form answer prose. Rendered without a section header so
|
| 61 |
+
short answers feel like a chat reply, not a debug log. */}
|
| 62 |
+
{showAnswerProse && (
|
| 63 |
+
<div className="assistant-answer">
|
| 64 |
+
{splitFences(answerText).map((seg, i) =>
|
| 65 |
+
seg.type === "code" ? (
|
| 66 |
+
<RunnableCodeBlock
|
| 67 |
+
key={i}
|
| 68 |
+
language={seg.language}
|
| 69 |
+
code={seg.code}
|
| 70 |
+
owner={owner}
|
| 71 |
+
repo={repo}
|
| 72 |
+
/>
|
| 73 |
+
) : (
|
| 74 |
+
<p key={i} className="assistant-answer__p">
|
| 75 |
+
{seg.value}
|
| 76 |
+
</p>
|
| 77 |
+
),
|
| 78 |
+
)}
|
| 79 |
+
</div>
|
| 80 |
+
)}
|
| 81 |
+
|
| 82 |
+
{/* Sandbox approval card. */}
|
| 83 |
+
{executionPlan && (
|
| 84 |
+
<div className="assistant-block">
|
| 85 |
+
<ExecutionPlanCard
|
| 86 |
+
plan={executionPlan}
|
| 87 |
+
variant="full"
|
| 88 |
+
busy={runBusy}
|
| 89 |
+
onApprove={approveExecution}
|
| 90 |
+
onCancel={() => { /* no-op */ }}
|
| 91 |
+
/>
|
| 92 |
+
{runError && (
|
| 93 |
+
<div className="assistant-run-error">Run error: {runError}</div>
|
| 94 |
+
)}
|
| 95 |
+
{runResult && (
|
| 96 |
+
<pre className="assistant-run-output">
|
| 97 |
+
{runResult.stdout || runResult.stderr || "(no output)"}
|
| 98 |
+
</pre>
|
| 99 |
+
)}
|
| 100 |
+
</div>
|
| 101 |
+
)}
|
| 102 |
+
|
| 103 |
+
{/* Proposed execution plan — only when there are file changes
|
| 104 |
+
and no execution log is present yet. */}
|
| 105 |
+
{plan && hasFileActions && !executionPlan && !executionLog && (
|
| 106 |
+
<div className="assistant-block">
|
| 107 |
+
<PlanView plan={plan} planStatus={planStatus} />
|
| 108 |
+
</div>
|
| 109 |
+
)}
|
| 110 |
+
|
| 111 |
+
{/* Completed execution receipt. */}
|
| 112 |
+
{executionLog && (
|
| 113 |
+
<SuccessReceipt
|
| 114 |
+
executionLog={executionLog}
|
| 115 |
+
relatedPlan={relatedPlan}
|
| 116 |
+
owner={owner}
|
| 117 |
+
repo={repo}
|
| 118 |
+
branch={branch}
|
| 119 |
+
diff={diff}
|
| 120 |
+
nextActions={nextActions}
|
| 121 |
+
/>
|
| 122 |
+
)}
|
| 123 |
+
|
| 124 |
+
{/* Next actions when there is no execution log (e.g. simple answers). */}
|
| 125 |
+
{!executionLog && Array.isArray(nextActions) && nextActions.length > 0 && (
|
| 126 |
+
<div className="assistant-next-row">
|
| 127 |
+
{nextActions.map((a, i) => (
|
| 128 |
+
<NextActionButton key={i} action={a} />
|
| 129 |
+
))}
|
| 130 |
+
</div>
|
| 131 |
+
)}
|
| 132 |
+
</div>
|
| 133 |
+
);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function NextActionButton({ action }) {
|
| 137 |
+
const onClick = () => {
|
| 138 |
+
if (action.kind === "run_file" && action.payload?.file) {
|
| 139 |
+
window.dispatchEvent(
|
| 140 |
+
new CustomEvent("gitpilot:run-file", {
|
| 141 |
+
detail: { path: action.payload.file },
|
| 142 |
+
}),
|
| 143 |
+
);
|
| 144 |
+
} else if (action.kind === "open_workspace" && action.payload?.file) {
|
| 145 |
+
window.dispatchEvent(
|
| 146 |
+
new CustomEvent("gitpilot:open-workspace", {
|
| 147 |
+
detail: { path: action.payload.file },
|
| 148 |
+
}),
|
| 149 |
+
);
|
| 150 |
+
} else if (action.kind === "open_in_canvas" && action.payload?.file) {
|
| 151 |
+
window.dispatchEvent(
|
| 152 |
+
new CustomEvent("gitpilot:open-in-canvas", {
|
| 153 |
+
detail: { path: action.payload.file },
|
| 154 |
+
}),
|
| 155 |
+
);
|
| 156 |
+
}
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
const isPrimary = action.kind === "run_file";
|
| 160 |
+
return (
|
| 161 |
+
<button
|
| 162 |
+
type="button"
|
| 163 |
+
onClick={onClick}
|
| 164 |
+
className={
|
| 165 |
+
"next-action-btn" +
|
| 166 |
+
(isPrimary ? " next-action-btn--primary" : " next-action-btn--ghost")
|
| 167 |
+
}
|
| 168 |
+
>
|
| 169 |
+
<span className="next-action-btn__glyph" aria-hidden="true">
|
| 170 |
+
{action.kind === "run_file"
|
| 171 |
+
? "▶"
|
| 172 |
+
: action.kind === "open_workspace"
|
| 173 |
+
? "📂"
|
| 174 |
+
: "⊞"}
|
| 175 |
+
</span>
|
| 176 |
+
{action.label}
|
| 177 |
+
</button>
|
| 178 |
+
);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
function SuccessReceipt({
|
| 182 |
+
executionLog,
|
| 183 |
+
relatedPlan,
|
| 184 |
+
owner,
|
| 185 |
+
repo,
|
| 186 |
+
branch,
|
| 187 |
+
diff,
|
| 188 |
+
nextActions,
|
| 189 |
+
}) {
|
| 190 |
+
const steps = executionLog?.steps || [];
|
| 191 |
+
const totalSteps = steps.length;
|
| 192 |
+
|
| 193 |
+
// Aggregate every file action across steps so "Files changed (N)" can
|
| 194 |
+
// be a single top-level section instead of per-step duplication.
|
| 195 |
+
const allFiles = [];
|
| 196 |
+
if (relatedPlan?.steps?.length) {
|
| 197 |
+
for (const s of relatedPlan.steps) {
|
| 198 |
+
for (const f of s.files || []) {
|
| 199 |
+
if (f.action !== "INDEX") allFiles.push(f);
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
const totalDuration = steps.reduce((acc, s) => {
|
| 204 |
+
return acc + (s.executions || []).reduce(
|
| 205 |
+
(a, ex) => a + (typeof ex.duration_ms === "number" ? ex.duration_ms : 0),
|
| 206 |
+
0,
|
| 207 |
+
);
|
| 208 |
+
}, 0);
|
| 209 |
+
|
| 210 |
+
// Short headline outcome lines (max 2): "Created X", "Modified Y".
|
| 211 |
+
const headlines = [];
|
| 212 |
+
for (const action of ["CREATE", "MODIFY", "DELETE"]) {
|
| 213 |
+
for (const f of allFiles) {
|
| 214 |
+
if (f.action === action && headlines.length < 2) {
|
| 215 |
+
headlines.push({ verb: verbFor(action), path: f.path });
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// The bottom-bar already owns Create PR. Only show a pointer text here
|
| 221 |
+
// when there is a meaningful PR path (branch + file changes).
|
| 222 |
+
const hasPRPath = Boolean(branch && allFiles.length > 0);
|
| 223 |
+
|
| 224 |
+
// Filter out create-PR-style next-actions (handled by bottom bar);
|
| 225 |
+
// keep ▶ run / 📂 open as inline buttons.
|
| 226 |
+
const inlineNextActions = (nextActions || []).filter(
|
| 227 |
+
(a) => a && a.kind && a.kind !== "create_pr",
|
| 228 |
+
);
|
| 229 |
+
|
| 230 |
+
const hasTechLog =
|
| 231 |
+
totalDuration > 0 ||
|
| 232 |
+
steps.some((s) => s.summary || (s.executions || []).length > 0);
|
| 233 |
+
|
| 234 |
+
return (
|
| 235 |
+
<div className="success-card">
|
| 236 |
+
<div className="success-card__head">
|
| 237 |
+
<div className="success-card__icon" aria-hidden="true">
|
| 238 |
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"
|
| 239 |
+
stroke="currentColor" strokeWidth="2.4" strokeLinecap="round"
|
| 240 |
+
strokeLinejoin="round">
|
| 241 |
+
<polyline points="20 6 9 17 4 12" />
|
| 242 |
+
</svg>
|
| 243 |
+
</div>
|
| 244 |
+
<div className="success-card__head-text">
|
| 245 |
+
<div className="success-card__title">Execution completed</div>
|
| 246 |
+
<div className="success-card__subtitle">
|
| 247 |
+
Successfully executed {totalSteps} step{totalSteps === 1 ? "" : "s"}
|
| 248 |
+
<span className="success-card__dot">·</span>
|
| 249 |
+
<span className="success-card__time-inline">Just now</span>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
<span className="exec-status-badge exec-status-badge--ok success-card__badge">
|
| 253 |
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
| 254 |
+
stroke="currentColor" strokeWidth="3" strokeLinecap="round"
|
| 255 |
+
strokeLinejoin="round" aria-hidden="true">
|
| 256 |
+
<polyline points="20 6 9 17 4 12" />
|
| 257 |
+
</svg>
|
| 258 |
+
Executed
|
| 259 |
+
</span>
|
| 260 |
+
</div>
|
| 261 |
+
|
| 262 |
+
{headlines.length > 0 && (
|
| 263 |
+
<ul className="success-headlines">
|
| 264 |
+
{headlines.map((h, i) => (
|
| 265 |
+
<li key={i} className="success-headline">
|
| 266 |
+
<span className="success-headline__verb">{h.verb}</span>
|
| 267 |
+
<code className="success-headline__path">{h.path}</code>
|
| 268 |
+
</li>
|
| 269 |
+
))}
|
| 270 |
+
</ul>
|
| 271 |
+
)}
|
| 272 |
+
|
| 273 |
+
{(owner || branch) && (
|
| 274 |
+
<div className="success-meta">
|
| 275 |
+
{owner && repo && (
|
| 276 |
+
<div className="success-meta__item">
|
| 277 |
+
<span className="success-meta__label">Repository</span>
|
| 278 |
+
<span className="success-meta__value">{owner}/{repo}</span>
|
| 279 |
+
</div>
|
| 280 |
+
)}
|
| 281 |
+
{branch && (
|
| 282 |
+
<div className="success-meta__item">
|
| 283 |
+
<span className="success-meta__label">Branch</span>
|
| 284 |
+
<span className="success-meta__value success-meta__value--mono">
|
| 285 |
+
{branch}
|
| 286 |
+
</span>
|
| 287 |
+
</div>
|
| 288 |
+
)}
|
| 289 |
+
</div>
|
| 290 |
+
)}
|
| 291 |
+
|
| 292 |
+
{allFiles.length > 0 && (
|
| 293 |
+
<div className="success-files">
|
| 294 |
+
<div className="success-files__label">
|
| 295 |
+
Files changed ({allFiles.length})
|
| 296 |
+
</div>
|
| 297 |
+
<ul className="success-action-list">
|
| 298 |
+
{allFiles.map((f, i) => (
|
| 299 |
+
<li key={i} className="success-action-row">
|
| 300 |
+
<span className="success-action__check" aria-hidden="true">✓</span>
|
| 301 |
+
<span
|
| 302 |
+
className={`exec-badge exec-badge--${f.action.toLowerCase()}`}
|
| 303 |
+
>
|
| 304 |
+
{f.action}
|
| 305 |
+
</span>
|
| 306 |
+
<code className="success-action__path">{f.path}</code>
|
| 307 |
+
<span className="success-action__meta">
|
| 308 |
+
{actionMeta(f.action)}
|
| 309 |
+
</span>
|
| 310 |
+
</li>
|
| 311 |
+
))}
|
| 312 |
+
</ul>
|
| 313 |
+
</div>
|
| 314 |
+
)}
|
| 315 |
+
|
| 316 |
+
{inlineNextActions.length > 0 && (
|
| 317 |
+
<div className="success-next-row">
|
| 318 |
+
{inlineNextActions.map((a, i) => (
|
| 319 |
+
<NextActionButton key={i} action={a} />
|
| 320 |
+
))}
|
| 321 |
+
</div>
|
| 322 |
+
)}
|
| 323 |
+
|
| 324 |
+
{hasTechLog && (
|
| 325 |
+
<details className="success-techlog">
|
| 326 |
+
<summary>
|
| 327 |
+
View execution log
|
| 328 |
+
{totalDuration > 0 && (
|
| 329 |
+
<span className="success-techlog__time">
|
| 330 |
+
· {(totalDuration / 1000).toFixed(1)}s
|
| 331 |
+
</span>
|
| 332 |
+
)}
|
| 333 |
+
</summary>
|
| 334 |
+
{steps.map((s) => (
|
| 335 |
+
<div key={s.step_number} className="success-techlog__step">
|
| 336 |
+
<div className="success-techlog__step-title">
|
| 337 |
+
Step {s.step_number}
|
| 338 |
+
{totalSteps > 1 ? ` of ${totalSteps}` : ""}
|
| 339 |
+
</div>
|
| 340 |
+
{s.summary && (
|
| 341 |
+
<pre className="success-techlog__pre">{s.summary}</pre>
|
| 342 |
+
)}
|
| 343 |
+
{Array.isArray(s.executions) && s.executions.map((ex, i) => (
|
| 344 |
+
<ExecutionCard key={i} ex={ex} />
|
| 345 |
+
))}
|
| 346 |
+
</div>
|
| 347 |
+
))}
|
| 348 |
+
</details>
|
| 349 |
+
)}
|
| 350 |
+
|
| 351 |
+
{hasPRPath && (
|
| 352 |
+
<div className="success-hint">
|
| 353 |
+
Next: Create a pull request when ready.
|
| 354 |
+
</div>
|
| 355 |
+
)}
|
| 356 |
+
</div>
|
| 357 |
+
);
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
function actionMeta(action) {
|
| 361 |
+
switch (action) {
|
| 362 |
+
case "READ": return "Read-only";
|
| 363 |
+
case "CREATE": return "Created";
|
| 364 |
+
case "MODIFY": return "Modified";
|
| 365 |
+
case "DELETE": return "Deleted";
|
| 366 |
+
case "INDEX": return "Indexed";
|
| 367 |
+
default: return "";
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
function verbFor(action) {
|
| 372 |
+
switch (action) {
|
| 373 |
+
case "CREATE": return "Created";
|
| 374 |
+
case "MODIFY": return "Modified";
|
| 375 |
+
case "DELETE": return "Deleted";
|
| 376 |
+
default: return action;
|
| 377 |
+
}
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
function ExecutionCard({ ex }) {
|
| 381 |
+
const status = ex.status || "pending";
|
| 382 |
+
const statusClass =
|
| 383 |
+
status === "completed" ? "exec-card-inner--ok" :
|
| 384 |
+
status === "failed" ? "exec-card-inner--bad" :
|
| 385 |
+
status === "skipped" ? "exec-card-inner--warn" :
|
| 386 |
+
"exec-card-inner--info";
|
| 387 |
+
return (
|
| 388 |
+
<div className={`exec-card-inner ${statusClass}`}>
|
| 389 |
+
<div className="exec-card-inner__head">
|
| 390 |
+
<div>
|
| 391 |
+
<strong className="exec-card-inner__path">{ex.path}</strong>
|
| 392 |
+
{ex.sandbox && (
|
| 393 |
+
<span className="exec-card-inner__sandbox">· {ex.sandbox}</span>
|
| 394 |
+
)}
|
| 395 |
+
</div>
|
| 396 |
+
<span className="exec-card-inner__status">
|
| 397 |
+
{status === "completed" && `Exit ${ex.exit_code} · ${ex.duration_ms} ms`}
|
| 398 |
+
{status === "failed" && (typeof ex.exit_code === "number"
|
| 399 |
+
? `Failed · exit ${ex.exit_code}` : "Failed")}
|
| 400 |
+
{status === "skipped" && "Skipped"}
|
| 401 |
+
{status === "pending" && "Running…"}
|
| 402 |
+
</span>
|
| 403 |
+
</div>
|
| 404 |
+
{ex.command && (
|
| 405 |
+
<div className="exec-card-inner__cmd">$ {ex.command}</div>
|
| 406 |
+
)}
|
| 407 |
+
{ex.stdout && (
|
| 408 |
+
<details open style={{ marginTop: 4 }}>
|
| 409 |
+
<summary className="exec-card-inner__stdout-summary">stdout</summary>
|
| 410 |
+
<pre className="exec-card-inner__pre">{ex.stdout}</pre>
|
| 411 |
+
</details>
|
| 412 |
+
)}
|
| 413 |
+
{ex.stderr && (
|
| 414 |
+
<details open={status === "failed"} style={{ marginTop: 4 }}>
|
| 415 |
+
<summary className="exec-card-inner__stderr-summary">stderr</summary>
|
| 416 |
+
<pre className="exec-card-inner__pre exec-card-inner__pre--err">
|
| 417 |
+
{ex.stderr}
|
| 418 |
+
</pre>
|
| 419 |
+
</details>
|
| 420 |
+
)}
|
| 421 |
+
{ex.error && !ex.stderr && (
|
| 422 |
+
<div className="exec-card-inner__error">{ex.error}</div>
|
| 423 |
+
)}
|
| 424 |
+
{ex.reason && (
|
| 425 |
+
<div className="exec-card-inner__reason">{ex.reason}</div>
|
| 426 |
+
)}
|
| 427 |
+
</div>
|
| 428 |
+
);
|
| 429 |
+
}
|
frontend/components/BranchPicker.jsx
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
+
import { createPortal } from "react-dom";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* BranchPicker — Claude-Code-on-Web parity branch selector.
|
| 6 |
+
*
|
| 7 |
+
* Fetches branches from the new /api/repos/{owner}/{repo}/branches endpoint.
|
| 8 |
+
* Shows search, default branch badge, AI session branch highlighting.
|
| 9 |
+
*
|
| 10 |
+
* Fixes applied:
|
| 11 |
+
* - Dropdown portaled to document.body (avoids overflow:hidden clipping)
|
| 12 |
+
* - Branches cached per repo (no "No branches found" flash)
|
| 13 |
+
* - Shows "Loading..." only on first fetch, keeps stale data otherwise
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
// Simple per-repo branch cache so reopening the dropdown is instant
|
| 17 |
+
const branchCache = {};
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Props:
|
| 21 |
+
* repo, currentBranch, defaultBranch, sessionBranches, onBranchChange
|
| 22 |
+
* — standard branch-picker props
|
| 23 |
+
*
|
| 24 |
+
* externalAnchorRef (optional) — a React ref pointing to an external DOM
|
| 25 |
+
* element to anchor the dropdown to. When provided:
|
| 26 |
+
* - BranchPicker skips rendering its own trigger button
|
| 27 |
+
* - the dropdown opens immediately on mount
|
| 28 |
+
* - closing the dropdown calls onClose()
|
| 29 |
+
*
|
| 30 |
+
* onClose (optional) — called when the dropdown is dismissed (outside
|
| 31 |
+
* click or Escape). Only meaningful with externalAnchorRef.
|
| 32 |
+
*/
|
| 33 |
+
export default function BranchPicker({
|
| 34 |
+
repo,
|
| 35 |
+
currentBranch,
|
| 36 |
+
defaultBranch,
|
| 37 |
+
sessionBranches = [],
|
| 38 |
+
onBranchChange,
|
| 39 |
+
externalAnchorRef,
|
| 40 |
+
onClose,
|
| 41 |
+
}) {
|
| 42 |
+
const isExternalMode = !!externalAnchorRef;
|
| 43 |
+
const [open, setOpen] = useState(isExternalMode);
|
| 44 |
+
const [query, setQuery] = useState("");
|
| 45 |
+
const [branches, setBranches] = useState([]);
|
| 46 |
+
const [loading, setLoading] = useState(false);
|
| 47 |
+
const [error, setError] = useState(null);
|
| 48 |
+
const triggerRef = useRef(null);
|
| 49 |
+
const dropdownRef = useRef(null);
|
| 50 |
+
const inputRef = useRef(null);
|
| 51 |
+
|
| 52 |
+
const branch = currentBranch || defaultBranch || "main";
|
| 53 |
+
const isAiSession = sessionBranches.includes(branch) && branch !== defaultBranch;
|
| 54 |
+
|
| 55 |
+
// The element used for dropdown positioning
|
| 56 |
+
const anchorRef = isExternalMode ? externalAnchorRef : triggerRef;
|
| 57 |
+
|
| 58 |
+
const cacheKey = repo ? `${repo.owner}/${repo.name}` : null;
|
| 59 |
+
|
| 60 |
+
// Seed from cache on mount / repo change
|
| 61 |
+
useEffect(() => {
|
| 62 |
+
if (cacheKey && branchCache[cacheKey]) {
|
| 63 |
+
setBranches(branchCache[cacheKey]);
|
| 64 |
+
}
|
| 65 |
+
}, [cacheKey]);
|
| 66 |
+
|
| 67 |
+
// Fetch branches from GitHub via backend
|
| 68 |
+
const fetchBranches = useCallback(async (searchQuery) => {
|
| 69 |
+
if (!repo) return;
|
| 70 |
+
setLoading(true);
|
| 71 |
+
setError(null);
|
| 72 |
+
try {
|
| 73 |
+
const token = localStorage.getItem("github_token");
|
| 74 |
+
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
| 75 |
+
const params = new URLSearchParams({ per_page: "100" });
|
| 76 |
+
if (searchQuery) params.set("query", searchQuery);
|
| 77 |
+
|
| 78 |
+
const res = await fetch(
|
| 79 |
+
`/api/repos/${repo.owner}/${repo.name}/branches?${params}`,
|
| 80 |
+
{ headers, cache: "no-cache" }
|
| 81 |
+
);
|
| 82 |
+
if (!res.ok) {
|
| 83 |
+
const errData = await res.json().catch(() => ({}));
|
| 84 |
+
const detail = errData.detail || `HTTP ${res.status}`;
|
| 85 |
+
console.warn("BranchPicker: fetch failed:", detail);
|
| 86 |
+
setError(detail);
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
const data = await res.json();
|
| 90 |
+
const fetched = data.branches || [];
|
| 91 |
+
setBranches(fetched);
|
| 92 |
+
|
| 93 |
+
// Only cache the unfiltered result
|
| 94 |
+
if (!searchQuery && cacheKey) {
|
| 95 |
+
branchCache[cacheKey] = fetched;
|
| 96 |
+
}
|
| 97 |
+
} catch (err) {
|
| 98 |
+
console.warn("Failed to fetch branches:", err);
|
| 99 |
+
} finally {
|
| 100 |
+
setLoading(false);
|
| 101 |
+
}
|
| 102 |
+
}, [repo, cacheKey]);
|
| 103 |
+
|
| 104 |
+
// Fetch + focus when opened
|
| 105 |
+
useEffect(() => {
|
| 106 |
+
if (open) {
|
| 107 |
+
fetchBranches(query);
|
| 108 |
+
setTimeout(() => inputRef.current?.focus(), 50);
|
| 109 |
+
}
|
| 110 |
+
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 111 |
+
|
| 112 |
+
// Debounced search
|
| 113 |
+
useEffect(() => {
|
| 114 |
+
if (!open) return;
|
| 115 |
+
const t = setTimeout(() => fetchBranches(query), 300);
|
| 116 |
+
return () => clearTimeout(t);
|
| 117 |
+
}, [query, open, fetchBranches]);
|
| 118 |
+
|
| 119 |
+
// Close on outside click
|
| 120 |
+
useEffect(() => {
|
| 121 |
+
if (!open) return;
|
| 122 |
+
const handler = (e) => {
|
| 123 |
+
const inAnchor = anchorRef.current && anchorRef.current.contains(e.target);
|
| 124 |
+
const inDropdown = dropdownRef.current && dropdownRef.current.contains(e.target);
|
| 125 |
+
if (!inAnchor && !inDropdown) {
|
| 126 |
+
handleClose();
|
| 127 |
+
}
|
| 128 |
+
};
|
| 129 |
+
document.addEventListener("mousedown", handler);
|
| 130 |
+
return () => document.removeEventListener("mousedown", handler);
|
| 131 |
+
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 132 |
+
|
| 133 |
+
const handleClose = useCallback(() => {
|
| 134 |
+
setOpen(false);
|
| 135 |
+
setQuery("");
|
| 136 |
+
onClose?.();
|
| 137 |
+
}, [onClose]);
|
| 138 |
+
|
| 139 |
+
const handleSelect = (branchName) => {
|
| 140 |
+
handleClose();
|
| 141 |
+
if (branchName !== branch) {
|
| 142 |
+
onBranchChange?.(branchName);
|
| 143 |
+
}
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
// Merge API branches with session branches (AI branches might not show in GitHub API)
|
| 147 |
+
const allBranches = [...branches];
|
| 148 |
+
for (const sb of sessionBranches) {
|
| 149 |
+
if (!allBranches.find((b) => b.name === sb)) {
|
| 150 |
+
allBranches.push({ name: sb, is_default: false, protected: false });
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// Calculate portal position from anchor element
|
| 155 |
+
const getDropdownPosition = () => {
|
| 156 |
+
if (!anchorRef.current) return { top: 0, left: 0 };
|
| 157 |
+
const rect = anchorRef.current.getBoundingClientRect();
|
| 158 |
+
return {
|
| 159 |
+
top: rect.bottom + 4,
|
| 160 |
+
left: rect.left,
|
| 161 |
+
};
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
const pos = open ? getDropdownPosition() : { top: 0, left: 0 };
|
| 165 |
+
|
| 166 |
+
return (
|
| 167 |
+
<div style={styles.container}>
|
| 168 |
+
{/* Trigger button — hidden when using external anchor */}
|
| 169 |
+
{!isExternalMode && (
|
| 170 |
+
<button
|
| 171 |
+
ref={triggerRef}
|
| 172 |
+
type="button"
|
| 173 |
+
style={{
|
| 174 |
+
...styles.trigger,
|
| 175 |
+
borderColor: isAiSession ? "rgba(59, 130, 246, 0.3)" : "#3F3F46",
|
| 176 |
+
color: isAiSession ? "#60a5fa" : "#E4E4E7",
|
| 177 |
+
backgroundColor: isAiSession ? "rgba(59, 130, 246, 0.05)" : "transparent",
|
| 178 |
+
}}
|
| 179 |
+
onClick={() => setOpen((v) => !v)}
|
| 180 |
+
>
|
| 181 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 182 |
+
<line x1="6" y1="3" x2="6" y2="15" />
|
| 183 |
+
<circle cx="18" cy="6" r="3" />
|
| 184 |
+
<circle cx="6" cy="18" r="3" />
|
| 185 |
+
<path d="M18 9a9 9 0 0 1-9 9" />
|
| 186 |
+
</svg>
|
| 187 |
+
<span style={styles.branchName}>{branch}</span>
|
| 188 |
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 189 |
+
<polyline points="6 9 12 15 18 9" />
|
| 190 |
+
</svg>
|
| 191 |
+
</button>
|
| 192 |
+
)}
|
| 193 |
+
|
| 194 |
+
{/* Dropdown — portaled to document.body to escape overflow:hidden */}
|
| 195 |
+
{open && createPortal(
|
| 196 |
+
<div
|
| 197 |
+
ref={dropdownRef}
|
| 198 |
+
style={{
|
| 199 |
+
...styles.dropdown,
|
| 200 |
+
top: pos.top,
|
| 201 |
+
left: pos.left,
|
| 202 |
+
}}
|
| 203 |
+
>
|
| 204 |
+
{/* Search input */}
|
| 205 |
+
<div style={styles.searchBox}>
|
| 206 |
+
<input
|
| 207 |
+
ref={inputRef}
|
| 208 |
+
type="text"
|
| 209 |
+
placeholder="Search branches..."
|
| 210 |
+
value={query}
|
| 211 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 212 |
+
style={styles.searchInput}
|
| 213 |
+
onKeyDown={(e) => {
|
| 214 |
+
if (e.key === "Escape") {
|
| 215 |
+
handleClose();
|
| 216 |
+
}
|
| 217 |
+
}}
|
| 218 |
+
/>
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
{/* Branch list */}
|
| 222 |
+
<div style={styles.branchList}>
|
| 223 |
+
{loading && allBranches.length === 0 && (
|
| 224 |
+
<div style={styles.loadingRow}>Loading...</div>
|
| 225 |
+
)}
|
| 226 |
+
|
| 227 |
+
{!loading && error && (
|
| 228 |
+
<div style={styles.errorRow}>{error}</div>
|
| 229 |
+
)}
|
| 230 |
+
|
| 231 |
+
{!loading && !error && allBranches.length === 0 && (
|
| 232 |
+
<div style={styles.loadingRow}>No branches found</div>
|
| 233 |
+
)}
|
| 234 |
+
|
| 235 |
+
{allBranches.map((b) => {
|
| 236 |
+
const isDefault = b.is_default || b.name === defaultBranch;
|
| 237 |
+
const isAi = sessionBranches.includes(b.name);
|
| 238 |
+
const isCurrent = b.name === branch;
|
| 239 |
+
|
| 240 |
+
return (
|
| 241 |
+
<div
|
| 242 |
+
key={b.name}
|
| 243 |
+
style={{
|
| 244 |
+
...styles.branchRow,
|
| 245 |
+
backgroundColor: isCurrent
|
| 246 |
+
? isAi
|
| 247 |
+
? "rgba(59, 130, 246, 0.10)"
|
| 248 |
+
: "#27272A"
|
| 249 |
+
: "transparent",
|
| 250 |
+
}}
|
| 251 |
+
onMouseDown={() => handleSelect(b.name)}
|
| 252 |
+
>
|
| 253 |
+
<span style={{ opacity: isCurrent ? 1 : 0, width: 16, flexShrink: 0 }}>
|
| 254 |
+
✓
|
| 255 |
+
</span>
|
| 256 |
+
<span
|
| 257 |
+
style={{
|
| 258 |
+
flex: 1,
|
| 259 |
+
fontFamily: "monospace",
|
| 260 |
+
fontSize: 12,
|
| 261 |
+
color: isAi ? "#60a5fa" : "#E4E4E7",
|
| 262 |
+
whiteSpace: "nowrap",
|
| 263 |
+
overflow: "hidden",
|
| 264 |
+
textOverflow: "ellipsis",
|
| 265 |
+
}}
|
| 266 |
+
>
|
| 267 |
+
{b.name}
|
| 268 |
+
</span>
|
| 269 |
+
{isDefault && (
|
| 270 |
+
<span style={styles.defaultBadge}>default</span>
|
| 271 |
+
)}
|
| 272 |
+
{isAi && !isDefault && (
|
| 273 |
+
<span style={styles.aiBadge}>AI</span>
|
| 274 |
+
)}
|
| 275 |
+
{b.protected && (
|
| 276 |
+
<span style={styles.protectedBadge}>
|
| 277 |
+
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor">
|
| 278 |
+
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z" />
|
| 279 |
+
</svg>
|
| 280 |
+
</span>
|
| 281 |
+
)}
|
| 282 |
+
</div>
|
| 283 |
+
);
|
| 284 |
+
})}
|
| 285 |
+
|
| 286 |
+
{/* Subtle loading indicator when refreshing with cached data visible */}
|
| 287 |
+
{loading && allBranches.length > 0 && (
|
| 288 |
+
<div style={styles.loadingRow}>Updating...</div>
|
| 289 |
+
)}
|
| 290 |
+
</div>
|
| 291 |
+
</div>,
|
| 292 |
+
document.body
|
| 293 |
+
)}
|
| 294 |
+
</div>
|
| 295 |
+
);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
const styles = {
|
| 299 |
+
container: {
|
| 300 |
+
position: "relative",
|
| 301 |
+
},
|
| 302 |
+
trigger: {
|
| 303 |
+
display: "flex",
|
| 304 |
+
alignItems: "center",
|
| 305 |
+
gap: 6,
|
| 306 |
+
padding: "4px 8px",
|
| 307 |
+
borderRadius: 4,
|
| 308 |
+
border: "1px solid #3F3F46",
|
| 309 |
+
background: "transparent",
|
| 310 |
+
fontSize: 13,
|
| 311 |
+
cursor: "pointer",
|
| 312 |
+
fontFamily: "monospace",
|
| 313 |
+
maxWidth: 200,
|
| 314 |
+
},
|
| 315 |
+
branchName: {
|
| 316 |
+
whiteSpace: "nowrap",
|
| 317 |
+
overflow: "hidden",
|
| 318 |
+
textOverflow: "ellipsis",
|
| 319 |
+
maxWidth: 140,
|
| 320 |
+
},
|
| 321 |
+
dropdown: {
|
| 322 |
+
position: "fixed",
|
| 323 |
+
width: 280,
|
| 324 |
+
backgroundColor: "#1F1F23",
|
| 325 |
+
border: "1px solid #27272A",
|
| 326 |
+
borderRadius: 8,
|
| 327 |
+
boxShadow: "0 8px 24px rgba(0,0,0,0.6)",
|
| 328 |
+
zIndex: 9999,
|
| 329 |
+
overflow: "hidden",
|
| 330 |
+
},
|
| 331 |
+
searchBox: {
|
| 332 |
+
padding: "8px 10px",
|
| 333 |
+
borderBottom: "1px solid #27272A",
|
| 334 |
+
},
|
| 335 |
+
searchInput: {
|
| 336 |
+
width: "100%",
|
| 337 |
+
padding: "6px 8px",
|
| 338 |
+
borderRadius: 4,
|
| 339 |
+
border: "1px solid #3F3F46",
|
| 340 |
+
background: "#131316",
|
| 341 |
+
color: "#E4E4E7",
|
| 342 |
+
fontSize: 12,
|
| 343 |
+
outline: "none",
|
| 344 |
+
fontFamily: "monospace",
|
| 345 |
+
boxSizing: "border-box",
|
| 346 |
+
},
|
| 347 |
+
branchList: {
|
| 348 |
+
maxHeight: 260,
|
| 349 |
+
overflowY: "auto",
|
| 350 |
+
},
|
| 351 |
+
branchRow: {
|
| 352 |
+
display: "flex",
|
| 353 |
+
alignItems: "center",
|
| 354 |
+
gap: 6,
|
| 355 |
+
padding: "7px 10px",
|
| 356 |
+
cursor: "pointer",
|
| 357 |
+
transition: "background-color 0.1s",
|
| 358 |
+
borderBottom: "1px solid rgba(39, 39, 42, 0.5)",
|
| 359 |
+
},
|
| 360 |
+
loadingRow: {
|
| 361 |
+
padding: "12px 10px",
|
| 362 |
+
textAlign: "center",
|
| 363 |
+
fontSize: 12,
|
| 364 |
+
color: "#71717A",
|
| 365 |
+
},
|
| 366 |
+
errorRow: {
|
| 367 |
+
padding: "12px 10px",
|
| 368 |
+
textAlign: "center",
|
| 369 |
+
fontSize: 11,
|
| 370 |
+
color: "#F59E0B",
|
| 371 |
+
},
|
| 372 |
+
defaultBadge: {
|
| 373 |
+
fontSize: 9,
|
| 374 |
+
padding: "1px 5px",
|
| 375 |
+
borderRadius: 8,
|
| 376 |
+
backgroundColor: "rgba(16, 185, 129, 0.15)",
|
| 377 |
+
color: "#10B981",
|
| 378 |
+
fontWeight: 600,
|
| 379 |
+
textTransform: "uppercase",
|
| 380 |
+
letterSpacing: "0.04em",
|
| 381 |
+
flexShrink: 0,
|
| 382 |
+
},
|
| 383 |
+
aiBadge: {
|
| 384 |
+
fontSize: 9,
|
| 385 |
+
padding: "1px 5px",
|
| 386 |
+
borderRadius: 8,
|
| 387 |
+
backgroundColor: "rgba(59, 130, 246, 0.15)",
|
| 388 |
+
color: "#60a5fa",
|
| 389 |
+
fontWeight: 700,
|
| 390 |
+
flexShrink: 0,
|
| 391 |
+
},
|
| 392 |
+
protectedBadge: {
|
| 393 |
+
color: "#F59E0B",
|
| 394 |
+
flexShrink: 0,
|
| 395 |
+
display: "flex",
|
| 396 |
+
alignItems: "center",
|
| 397 |
+
},
|
| 398 |
+
};
|
frontend/components/ChatPanel.jsx
ADDED
|
@@ -0,0 +1,1370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/ChatPanel.jsx
|
| 2 |
+
import React, { useEffect, useRef, useState } from "react";
|
| 3 |
+
import AssistantMessage from "./AssistantMessage.jsx";
|
| 4 |
+
import ThinkingIndicator from "./ThinkingIndicator.jsx";
|
| 5 |
+
import ContextMeter from "./ContextMeter.jsx";
|
| 6 |
+
import TasksPanel from "./TasksPanel.jsx";
|
| 7 |
+
import DiffStats from "./DiffStats.jsx";
|
| 8 |
+
import DiffViewer from "./DiffViewer.jsx";
|
| 9 |
+
import CreatePRButton from "./CreatePRButton.jsx";
|
| 10 |
+
import StreamingMessage from "./StreamingMessage.jsx";
|
| 11 |
+
import SandboxCanvas from "./SandboxCanvas.jsx";
|
| 12 |
+
import FilePreviewPanel from "./FilePreviewPanel.jsx";
|
| 13 |
+
import { SessionWebSocket } from "../utils/ws.js";
|
| 14 |
+
|
| 15 |
+
// Map a file extension to the canonical sandbox language tag. Used
|
| 16 |
+
// when "Open in Canvas" needs to seed SandboxCanvas with the right
|
| 17 |
+
// language hint pulled straight from a repo file path.
|
| 18 |
+
const _LANG_FROM_EXT = {
|
| 19 |
+
py: "python", js: "javascript", mjs: "javascript", cjs: "javascript",
|
| 20 |
+
sh: "bash", bash: "bash",
|
| 21 |
+
};
|
| 22 |
+
function languageFromPath(path) {
|
| 23 |
+
if (!path || !path.includes(".")) return "python";
|
| 24 |
+
return _LANG_FROM_EXT[path.split(".").pop().toLowerCase()] || "python";
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// Helper to get headers (inline safety if utility is missing)
|
| 28 |
+
const getHeaders = () => ({
|
| 29 |
+
"Content-Type": "application/json",
|
| 30 |
+
Authorization: `Bearer ${localStorage.getItem("github_token") || ""}`,
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
export default function ChatPanel({
|
| 34 |
+
repo,
|
| 35 |
+
defaultBranch = "main",
|
| 36 |
+
currentBranch, // do NOT default here; parent must pass the real one
|
| 37 |
+
onExecutionComplete,
|
| 38 |
+
sessionChatState,
|
| 39 |
+
onSessionChatStateChange,
|
| 40 |
+
sessionId,
|
| 41 |
+
onEnsureSession,
|
| 42 |
+
canChat = true, // readiness gate: false disables composer and shows blocker
|
| 43 |
+
chatBlocker = null, // { message: string, cta?: string, onCta?: () => void }
|
| 44 |
+
}) {
|
| 45 |
+
// Initialize state from props or defaults
|
| 46 |
+
const [messages, setMessages] = useState(sessionChatState?.messages || []);
|
| 47 |
+
const [goal, setGoal] = useState("");
|
| 48 |
+
const [plan, setPlan] = useState(sessionChatState?.plan || null);
|
| 49 |
+
|
| 50 |
+
const [loadingPlan, setLoadingPlan] = useState(false);
|
| 51 |
+
const [executing, setExecuting] = useState(false);
|
| 52 |
+
const [status, setStatus] = useState("");
|
| 53 |
+
// Batch B9 — populated when a plan whose first step was INDEX is
|
| 54 |
+
// rejected. Lets us render a small "Run with grep instead?" prompt
|
| 55 |
+
// so the user doesn't have to retype the goal.
|
| 56 |
+
const [retryAfterIndexReject, setRetryAfterIndexReject] = useState(null);
|
| 57 |
+
|
| 58 |
+
// Claude-Code-on-Web: WebSocket streaming + diff + PR
|
| 59 |
+
const [wsConnected, setWsConnected] = useState(false);
|
| 60 |
+
const [streamingEvents, setStreamingEvents] = useState([]);
|
| 61 |
+
const [diffData, setDiffData] = useState(null);
|
| 62 |
+
const [showDiffViewer, setShowDiffViewer] = useState(false);
|
| 63 |
+
// SandboxCanvas state — opened by the "Open in Canvas" CTA on
|
| 64 |
+
// post-CREATE next_actions and ExecutionCard footers. ``canvasSpec``
|
| 65 |
+
// is { filename, language, code } or null when closed.
|
| 66 |
+
const [canvasSpec, setCanvasSpec] = useState(null);
|
| 67 |
+
const [canvasError, setCanvasError] = useState(null);
|
| 68 |
+
// FilePreviewPanel state — opened by clicking a file row in the
|
| 69 |
+
// sidebar (gitpilot:open-file). Read-first surface; users can pick
|
| 70 |
+
// "Prepare Run" / "Open Workspace" / "Ask GitPilot" from there.
|
| 71 |
+
const [previewPath, setPreviewPath] = useState(null);
|
| 72 |
+
const [previewContent, setPreviewContent] = useState(null);
|
| 73 |
+
const [previewLoading, setPreviewLoading] = useState(false);
|
| 74 |
+
const [previewError, setPreviewError] = useState(null);
|
| 75 |
+
const [previewErrorCode, setPreviewErrorCode] = useState(null);
|
| 76 |
+
// "preview" (narrow drawer) or "workspace" (wide editor).
|
| 77 |
+
const [previewMode, setPreviewMode] = useState("preview");
|
| 78 |
+
const wsRef = useRef(null);
|
| 79 |
+
|
| 80 |
+
// Ref mirrors streamingEvents so WS callbacks avoid stale closures
|
| 81 |
+
const streamingEventsRef = useRef([]);
|
| 82 |
+
useEffect(() => { streamingEventsRef.current = streamingEvents; }, [streamingEvents]);
|
| 83 |
+
|
| 84 |
+
// Tracks files that were just CREATE'd / MODIFY'd by a fresh execution.
|
| 85 |
+
// Used to (a) auto-retry once on 404 (GitHub contents API has brief
|
| 86 |
+
// eventual-consistency lag) and (b) classify the file viewer's empty
|
| 87 |
+
// state as "still syncing" instead of a generic 404.
|
| 88 |
+
const fileWasJustCreatedRef = useRef(new Set());
|
| 89 |
+
const fileWasJustDeletedRef = useRef(new Set());
|
| 90 |
+
|
| 91 |
+
// Skip the session-sync useEffect reset when we just created a session
|
| 92 |
+
// (the parent already seeded the messages into chatBySession)
|
| 93 |
+
const skipNextSyncRef = useRef(false);
|
| 94 |
+
|
| 95 |
+
const messagesEndRef = useRef(null);
|
| 96 |
+
const prevMsgCountRef = useRef((sessionChatState?.messages || []).length);
|
| 97 |
+
|
| 98 |
+
// ---------------------------------------------------------------------------
|
| 99 |
+
// WebSocket connection management
|
| 100 |
+
// ---------------------------------------------------------------------------
|
| 101 |
+
useEffect(() => {
|
| 102 |
+
// Clean up previous connection
|
| 103 |
+
if (wsRef.current) {
|
| 104 |
+
wsRef.current.close();
|
| 105 |
+
wsRef.current = null;
|
| 106 |
+
setWsConnected(false);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
if (!sessionId) return;
|
| 110 |
+
|
| 111 |
+
// Wait for backend to be reachable before opening WebSocket.
|
| 112 |
+
// Without this, the WS connects immediately on session creation
|
| 113 |
+
// and fails repeatedly with "closed before established" when the
|
| 114 |
+
// backend is still starting up (common on WSL cold start).
|
| 115 |
+
let cancelled = false;
|
| 116 |
+
const backendUrl = import.meta.env.VITE_BACKEND_URL || '';
|
| 117 |
+
const pingUrl = backendUrl ? `${backendUrl}/api/ping` : '/api/ping';
|
| 118 |
+
const waitForBackend = async () => {
|
| 119 |
+
for (let i = 0; i < 10 && !cancelled; i++) {
|
| 120 |
+
try {
|
| 121 |
+
const res = await fetch(pingUrl, { method: 'GET', signal: AbortSignal.timeout(2000) });
|
| 122 |
+
if (res.ok) return true;
|
| 123 |
+
} catch { /* retry */ }
|
| 124 |
+
await new Promise(r => setTimeout(r, 1500));
|
| 125 |
+
}
|
| 126 |
+
return false;
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
waitForBackend().then((ok) => {
|
| 130 |
+
if (cancelled || !ok) return;
|
| 131 |
+
connectWs();
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
function connectWs() {
|
| 135 |
+
const ws = new SessionWebSocket(sessionId, {
|
| 136 |
+
onConnect: () => setWsConnected(true),
|
| 137 |
+
onDisconnect: () => setWsConnected(false),
|
| 138 |
+
onMessage: (data) => {
|
| 139 |
+
if (data.type === "agent_message") {
|
| 140 |
+
setStreamingEvents((prev) => [...prev, data]);
|
| 141 |
+
} else if (data.type === "tool_use" || data.type === "tool_result") {
|
| 142 |
+
setStreamingEvents((prev) => [...prev, data]);
|
| 143 |
+
} else if (data.type === "diff_update") {
|
| 144 |
+
setDiffData(data.stats || data);
|
| 145 |
+
} else if (data.type === "session_restored") {
|
| 146 |
+
// Session loaded
|
| 147 |
+
}
|
| 148 |
+
},
|
| 149 |
+
onStatusChange: (newStatus) => {
|
| 150 |
+
if (newStatus === "waiting") {
|
| 151 |
+
// Always clear loading state when agent finishes
|
| 152 |
+
setLoadingPlan(false);
|
| 153 |
+
|
| 154 |
+
// Consolidate streaming events into a chat message (use ref to
|
| 155 |
+
// avoid stale closure — streamingEvents state would be stale here).
|
| 156 |
+
//
|
| 157 |
+
// We also commit the FINAL consolidated text to the backend session
|
| 158 |
+
// here. Previously this branch never called persistMessage, so the
|
| 159 |
+
// assistant turn looked correct in the live view but vanished on the
|
| 160 |
+
// next session reload — the canonical "streaming truncation" symptom.
|
| 161 |
+
const events = streamingEventsRef.current;
|
| 162 |
+
if (events.length > 0) {
|
| 163 |
+
const textParts = events
|
| 164 |
+
.filter((e) => e.type === "agent_message")
|
| 165 |
+
.map((e) => e.content);
|
| 166 |
+
if (textParts.length > 0) {
|
| 167 |
+
const consolidated = {
|
| 168 |
+
from: "ai",
|
| 169 |
+
role: "assistant",
|
| 170 |
+
answer: textParts.join(""),
|
| 171 |
+
content: textParts.join(""),
|
| 172 |
+
};
|
| 173 |
+
setMessages((prev) => [...prev, consolidated]);
|
| 174 |
+
persistMessage(sessionId, "assistant", consolidated.content);
|
| 175 |
+
}
|
| 176 |
+
setStreamingEvents([]);
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
},
|
| 180 |
+
onError: (err) => {
|
| 181 |
+
console.warn("[ws] Error:", err);
|
| 182 |
+
setLoadingPlan(false);
|
| 183 |
+
},
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
ws.connect();
|
| 187 |
+
wsRef.current = ws;
|
| 188 |
+
} // end connectWs
|
| 189 |
+
|
| 190 |
+
return () => {
|
| 191 |
+
cancelled = true;
|
| 192 |
+
if (wsRef.current) wsRef.current.close();
|
| 193 |
+
};
|
| 194 |
+
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 195 |
+
|
| 196 |
+
// ---------------------------------------------------------------------------
|
| 197 |
+
// 1) SESSION SYNC: Restore chat when branch, repo, OR session changes
|
| 198 |
+
// IMPORTANT: Do NOT depend on sessionChatState here (prevents prop/state loop)
|
| 199 |
+
// ---------------------------------------------------------------------------
|
| 200 |
+
useEffect(() => {
|
| 201 |
+
// When send() just created a session, the parent seeded the messages
|
| 202 |
+
// into chatBySession already. Skip the reset so we don't wipe
|
| 203 |
+
// the optimistic user message that was already rendered.
|
| 204 |
+
if (skipNextSyncRef.current) {
|
| 205 |
+
skipNextSyncRef.current = false;
|
| 206 |
+
return;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
const nextMessages = sessionChatState?.messages || [];
|
| 210 |
+
const nextPlan = sessionChatState?.plan || null;
|
| 211 |
+
|
| 212 |
+
setMessages(nextMessages);
|
| 213 |
+
setPlan(nextPlan);
|
| 214 |
+
|
| 215 |
+
// Reset transient UI state on branch/repo/session switch
|
| 216 |
+
setGoal("");
|
| 217 |
+
setStatus("");
|
| 218 |
+
setLoadingPlan(false);
|
| 219 |
+
setExecuting(false);
|
| 220 |
+
setStreamingEvents([]);
|
| 221 |
+
setDiffData(null);
|
| 222 |
+
|
| 223 |
+
// Update msg count tracker so auto-scroll doesn't "jump" on switch
|
| 224 |
+
prevMsgCountRef.current = nextMessages.length;
|
| 225 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 226 |
+
}, [currentBranch, repo?.full_name, sessionId]);
|
| 227 |
+
|
| 228 |
+
// ---------------------------------------------------------------------------
|
| 229 |
+
// 1b) FILE ▶ RUN: listen for run-file events from the sidebar.
|
| 230 |
+
// ---------------------------------------------------------------------------
|
| 231 |
+
//
|
| 232 |
+
// FileTree dispatches ``gitpilot:run-file`` with the clicked file's
|
| 233 |
+
// path. We turn that into a normal chat message ("run <path>")
|
| 234 |
+
// which goes through /api/chat/plan, hits the deterministic
|
| 235 |
+
// short-circuit, and renders an ExecutionPlanCard — exactly the
|
| 236 |
+
// same flow as typing the command. One handler, one approval surface,
|
| 237 |
+
// zero duplicated logic.
|
| 238 |
+
useEffect(() => {
|
| 239 |
+
const onRunFile = (e) => {
|
| 240 |
+
const path = e?.detail?.path;
|
| 241 |
+
if (!path || !repo) return;
|
| 242 |
+
send({ goal: `run ${path}` });
|
| 243 |
+
};
|
| 244 |
+
// "Open in Canvas" handler — fetches the file's content from the
|
| 245 |
+
// active branch and opens SandboxCanvas seeded with it. Logs a
|
| 246 |
+
// friendly error banner when the fetch fails so a misconfigured
|
| 247 |
+
// token / wrong branch doesn't silently swallow the click.
|
| 248 |
+
const onOpenInCanvas = async (e) => {
|
| 249 |
+
const path = e?.detail?.path;
|
| 250 |
+
if (!path || !repo) return;
|
| 251 |
+
setCanvasError(null);
|
| 252 |
+
const branch = currentBranch || "HEAD";
|
| 253 |
+
try {
|
| 254 |
+
const url = `/api/repos/${repo.owner}/${repo.name}/file`
|
| 255 |
+
+ `?path=${encodeURIComponent(path)}`
|
| 256 |
+
+ `&ref=${encodeURIComponent(branch)}`;
|
| 257 |
+
const res = await fetch(url, { headers: getHeaders() });
|
| 258 |
+
const data = await res.json().catch(() => ({}));
|
| 259 |
+
if (!res.ok) {
|
| 260 |
+
setCanvasError(data.detail || `Could not load ${path} (HTTP ${res.status})`);
|
| 261 |
+
// Still open the canvas with empty content so the user can
|
| 262 |
+
// paste something — better than nothing happening on click.
|
| 263 |
+
setCanvasSpec({
|
| 264 |
+
filename: path, language: languageFromPath(path), code: "",
|
| 265 |
+
});
|
| 266 |
+
return;
|
| 267 |
+
}
|
| 268 |
+
setCanvasSpec({
|
| 269 |
+
filename: path,
|
| 270 |
+
language: languageFromPath(path),
|
| 271 |
+
code: data.content || "",
|
| 272 |
+
});
|
| 273 |
+
} catch (err) {
|
| 274 |
+
setCanvasError(err?.message || "Could not load file for Canvas");
|
| 275 |
+
setCanvasSpec({
|
| 276 |
+
filename: path, language: languageFromPath(path), code: "",
|
| 277 |
+
});
|
| 278 |
+
}
|
| 279 |
+
};
|
| 280 |
+
// "Open file" — clicking a file row in the sidebar mounts the
|
| 281 |
+
// read-first FilePreviewPanel. Calmer than dropping straight
|
| 282 |
+
// into Canvas: the user sees the file, can pick "Prepare Run"
|
| 283 |
+
// when they're ready (or "Open Workspace" for a wider editing
|
| 284 |
+
// surface). The ``mode`` detail toggles the panel's geometry:
|
| 285 |
+
// "preview" ── narrow right drawer for a quick look
|
| 286 |
+
// "workspace" ── wide right-side editor for serious review
|
| 287 |
+
const openFile = async (path, mode = "preview") => {
|
| 288 |
+
if (!path || !repo) return;
|
| 289 |
+
setPreviewPath(path);
|
| 290 |
+
setPreviewMode(mode);
|
| 291 |
+
setPreviewContent(null);
|
| 292 |
+
setPreviewError(null);
|
| 293 |
+
setPreviewErrorCode(null);
|
| 294 |
+
setPreviewLoading(true);
|
| 295 |
+
// Tell the sidebar which file is currently focused so it can
|
| 296 |
+
// light up the row with the ◄ marker.
|
| 297 |
+
try {
|
| 298 |
+
window.dispatchEvent(new CustomEvent("gitpilot:file-opened", { detail: { path } }));
|
| 299 |
+
} catch (_e) { /* old browser */ }
|
| 300 |
+
const branch = currentBranch || "HEAD";
|
| 301 |
+
const fetchOnce = async () => {
|
| 302 |
+
const url = `/api/repos/${repo.owner}/${repo.name}/file`
|
| 303 |
+
+ `?path=${encodeURIComponent(path)}`
|
| 304 |
+
+ `&ref=${encodeURIComponent(branch)}`;
|
| 305 |
+
const res = await fetch(url, { headers: getHeaders() });
|
| 306 |
+
const data = await res.json().catch(() => ({}));
|
| 307 |
+
return { res, data };
|
| 308 |
+
};
|
| 309 |
+
try {
|
| 310 |
+
let { res, data } = await fetchOnce();
|
| 311 |
+
// Auto-retry once on 404 for recently created files — GitHub
|
| 312 |
+
// contents API has brief eventual-consistency lag after a
|
| 313 |
+
// freshly published commit.
|
| 314 |
+
if (res.status === 404 && fileWasJustCreatedRef.current?.has(path)) {
|
| 315 |
+
await new Promise((r) => setTimeout(r, 900));
|
| 316 |
+
({ res, data } = await fetchOnce());
|
| 317 |
+
}
|
| 318 |
+
if (!res.ok) {
|
| 319 |
+
setPreviewError(data.detail || `HTTP ${res.status}`);
|
| 320 |
+
setPreviewErrorCode(res.status);
|
| 321 |
+
} else {
|
| 322 |
+
setPreviewContent(data.content || "");
|
| 323 |
+
}
|
| 324 |
+
} catch (err) {
|
| 325 |
+
setPreviewError(err?.message || "Could not load file");
|
| 326 |
+
setPreviewErrorCode(null);
|
| 327 |
+
} finally {
|
| 328 |
+
setPreviewLoading(false);
|
| 329 |
+
}
|
| 330 |
+
};
|
| 331 |
+
const onOpenFile = (e) => openFile(e?.detail?.path, "preview");
|
| 332 |
+
const onOpenWorkspace = (e) => openFile(e?.detail?.path, "workspace");
|
| 333 |
+
// "Ask GitPilot" — seed the chat input with a contextual question
|
| 334 |
+
// about the clicked file. Pure additive: focuses the input and
|
| 335 |
+
// pre-fills it; the user can edit or send as-is.
|
| 336 |
+
const onAskAboutFile = (e) => {
|
| 337 |
+
const path = e?.detail?.path;
|
| 338 |
+
if (!path) return;
|
| 339 |
+
setGoal(`Tell me about ${path}.`);
|
| 340 |
+
const ta = document.querySelector(".chat-input");
|
| 341 |
+
if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }
|
| 342 |
+
};
|
| 343 |
+
window.addEventListener("gitpilot:run-file", onRunFile);
|
| 344 |
+
window.addEventListener("gitpilot:open-in-canvas", onOpenInCanvas);
|
| 345 |
+
window.addEventListener("gitpilot:open-file", onOpenFile);
|
| 346 |
+
window.addEventListener("gitpilot:open-workspace", onOpenWorkspace);
|
| 347 |
+
window.addEventListener("gitpilot:ask-about-file", onAskAboutFile);
|
| 348 |
+
return () => {
|
| 349 |
+
window.removeEventListener("gitpilot:run-file", onRunFile);
|
| 350 |
+
window.removeEventListener("gitpilot:open-in-canvas", onOpenInCanvas);
|
| 351 |
+
window.removeEventListener("gitpilot:open-file", onOpenFile);
|
| 352 |
+
window.removeEventListener("gitpilot:open-workspace", onOpenWorkspace);
|
| 353 |
+
window.removeEventListener("gitpilot:ask-about-file", onAskAboutFile);
|
| 354 |
+
};
|
| 355 |
+
// ``send`` is stable enough across renders for this use case —
|
| 356 |
+
// we don't want to re-bind on every keystroke.
|
| 357 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 358 |
+
}, [repo?.full_name, currentBranch, sessionId]);
|
| 359 |
+
|
| 360 |
+
// ---------------------------------------------------------------------------
|
| 361 |
+
// 2) PERSISTENCE: Save chat to Parent (no loop now because sync only on branch)
|
| 362 |
+
// ---------------------------------------------------------------------------
|
| 363 |
+
useEffect(() => {
|
| 364 |
+
if (typeof onSessionChatStateChange === "function") {
|
| 365 |
+
// Avoid wiping parent state on mount
|
| 366 |
+
if (messages.length > 0 || plan) {
|
| 367 |
+
onSessionChatStateChange({ messages, plan });
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 371 |
+
}, [messages, plan]);
|
| 372 |
+
|
| 373 |
+
// ---------------------------------------------------------------------------
|
| 374 |
+
// 3) AUTO-SCROLL: Only scroll when a message is appended (reduces flicker)
|
| 375 |
+
// ---------------------------------------------------------------------------
|
| 376 |
+
useEffect(() => {
|
| 377 |
+
const curCount = messages.length + streamingEvents.length;
|
| 378 |
+
const prevCount = prevMsgCountRef.current;
|
| 379 |
+
|
| 380 |
+
// Only scroll when new messages are added
|
| 381 |
+
if (curCount > prevCount) {
|
| 382 |
+
prevMsgCountRef.current = curCount;
|
| 383 |
+
requestAnimationFrame(() => {
|
| 384 |
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 385 |
+
});
|
| 386 |
+
} else {
|
| 387 |
+
prevMsgCountRef.current = curCount;
|
| 388 |
+
}
|
| 389 |
+
}, [messages.length, streamingEvents.length]);
|
| 390 |
+
|
| 391 |
+
// ---------------------------------------------------------------------------
|
| 392 |
+
// HANDLERS
|
| 393 |
+
// ---------------------------------------------------------------------------
|
| 394 |
+
// ---------------------------------------------------------------------------
|
| 395 |
+
// Persist a message to the backend session (fire-and-forget).
|
| 396 |
+
//
|
| 397 |
+
// The fourth argument carries the *structured* payload of the assistant
|
| 398 |
+
// response — the Action Plan, the Execution Log, diff stats, etc. The
|
| 399 |
+
// backend stores it on Message.metadata; on session reload App.jsx
|
| 400 |
+
// spreads metadata back into the local message via normalizeBackendMessage,
|
| 401 |
+
// so the same AssistantMessage renderer can re-draw the Plan / Steps /
|
| 402 |
+
// Create buttons identically to the live view.
|
| 403 |
+
//
|
| 404 |
+
// Before this fix the structured payload was dropped at persist time —
|
| 405 |
+
// the session reloaded as raw text, and the UI degraded to a plain
|
| 406 |
+
// paragraph. This is the canonical "state loss during hydration" bug.
|
| 407 |
+
// ---------------------------------------------------------------------------
|
| 408 |
+
const persistMessage = (sid, role, content, metadata = null) => {
|
| 409 |
+
if (!sid) return;
|
| 410 |
+
const body = { role, content };
|
| 411 |
+
if (metadata && typeof metadata === "object" && Object.keys(metadata).length > 0) {
|
| 412 |
+
body.metadata = metadata;
|
| 413 |
+
}
|
| 414 |
+
fetch(`/api/sessions/${sid}/message`, {
|
| 415 |
+
method: "POST",
|
| 416 |
+
headers: getHeaders(),
|
| 417 |
+
body: JSON.stringify(body),
|
| 418 |
+
}).catch(() => {}); // best-effort
|
| 419 |
+
};
|
| 420 |
+
|
| 421 |
+
// Pick the structured fields a message can carry across a reload.
|
| 422 |
+
// Keep this in one place so every call-site stores the same shape and
|
| 423 |
+
// the renderer never has to guess.
|
| 424 |
+
const pickAssistantMetadata = (m) => {
|
| 425 |
+
if (!m || typeof m !== "object") return null;
|
| 426 |
+
const meta = {};
|
| 427 |
+
if (m.plan) meta.plan = m.plan;
|
| 428 |
+
if (m.executionLog) meta.executionLog = m.executionLog;
|
| 429 |
+
if (m.diff) meta.diff = m.diff;
|
| 430 |
+
if (m.actions) meta.actions = m.actions;
|
| 431 |
+
if (m.nextActions) meta.nextActions = m.nextActions;
|
| 432 |
+
if (m.branch) meta.branch = m.branch;
|
| 433 |
+
// Informational plans (READ-only answers to "what does X do?" style
|
| 434 |
+
// questions) carry no Approve/Reject controls — pin the flag so the
|
| 435 |
+
// session reload re-renders the same shape.
|
| 436 |
+
if (m.informational) meta.informational = true;
|
| 437 |
+
return Object.keys(meta).length > 0 ? meta : null;
|
| 438 |
+
};
|
| 439 |
+
|
| 440 |
+
const send = async (overrides = {}) => {
|
| 441 |
+
if (!repo) return;
|
| 442 |
+
// Allow callers (e.g. the "Retry with grep" button on a rejected
|
| 443 |
+
// INDEX plan) to drive send() with a fixed goal and a router flag.
|
| 444 |
+
const overrideGoal = overrides.goal;
|
| 445 |
+
const force_no_rag = Boolean(overrides.force_no_rag);
|
| 446 |
+
const sourceText = overrideGoal != null ? overrideGoal : goal;
|
| 447 |
+
if (!sourceText || !sourceText.trim()) return;
|
| 448 |
+
|
| 449 |
+
const text = sourceText.trim();
|
| 450 |
+
|
| 451 |
+
// Clear input immediately (Claude Code behavior) — but only when
|
| 452 |
+
// the user typed; programmatic retries leave the input alone.
|
| 453 |
+
if (overrideGoal == null) setGoal("");
|
| 454 |
+
// Reset textarea height
|
| 455 |
+
const ta = document.querySelector(".chat-input");
|
| 456 |
+
if (ta) ta.style.height = "40px";
|
| 457 |
+
|
| 458 |
+
// Optimistic update (user bubble appears immediately)
|
| 459 |
+
const userMsg = { from: "user", role: "user", text, content: text };
|
| 460 |
+
setMessages((prev) => [...prev, userMsg]);
|
| 461 |
+
|
| 462 |
+
setLoadingPlan(true);
|
| 463 |
+
setStatus("");
|
| 464 |
+
setPlan(null);
|
| 465 |
+
setStreamingEvents([]);
|
| 466 |
+
|
| 467 |
+
// ------- Implicit session creation (Claude Code parity) -------
|
| 468 |
+
// Every chat must be backed by a session. If none exists yet,
|
| 469 |
+
// create one on-demand before sending the plan request.
|
| 470 |
+
let sid = sessionId;
|
| 471 |
+
if (!sid && typeof onEnsureSession === "function") {
|
| 472 |
+
// Derive a short title from the first message
|
| 473 |
+
const sessionName = text.length > 60 ? text.slice(0, 57) + "..." : text;
|
| 474 |
+
|
| 475 |
+
// Tell the sync useEffect to skip the reset that would otherwise
|
| 476 |
+
// wipe the optimistic user message when activeSessionId changes.
|
| 477 |
+
skipNextSyncRef.current = true;
|
| 478 |
+
|
| 479 |
+
sid = await onEnsureSession(sessionName, [userMsg]);
|
| 480 |
+
if (!sid) {
|
| 481 |
+
// Session creation failed — continue without session
|
| 482 |
+
skipNextSyncRef.current = false;
|
| 483 |
+
}
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
// Persist user message to backend session
|
| 487 |
+
persistMessage(sid, "user", text);
|
| 488 |
+
|
| 489 |
+
// Always use HTTP for plan generation (the original reliable flow).
|
| 490 |
+
// WebSocket is only used for real-time streaming feedback display.
|
| 491 |
+
const effectiveBranch = currentBranch || defaultBranch || "HEAD";
|
| 492 |
+
|
| 493 |
+
try {
|
| 494 |
+
// Timeout after 5 minutes (CrewAI agent can be slow with small models)
|
| 495 |
+
const planController = new AbortController();
|
| 496 |
+
const planTimer = setTimeout(() => planController.abort(), 300000);
|
| 497 |
+
|
| 498 |
+
let res;
|
| 499 |
+
try {
|
| 500 |
+
res = await fetch("/api/chat/plan", {
|
| 501 |
+
method: "POST",
|
| 502 |
+
headers: getHeaders(),
|
| 503 |
+
body: JSON.stringify({
|
| 504 |
+
repo_owner: repo.owner,
|
| 505 |
+
repo_name: repo.name,
|
| 506 |
+
goal: text,
|
| 507 |
+
branch_name: effectiveBranch,
|
| 508 |
+
// Lets the backend record this plan as a Task on the
|
| 509 |
+
// session so the right-sidebar Tasks panel can trace it.
|
| 510 |
+
session_id: sid,
|
| 511 |
+
// Batch B9 — set on the "Retry with grep" path after the
|
| 512 |
+
// user rejects an INDEX-plan. Tells the router to
|
| 513 |
+
// suppress RAG / INDEX recommendations.
|
| 514 |
+
force_no_rag,
|
| 515 |
+
}),
|
| 516 |
+
signal: planController.signal,
|
| 517 |
+
});
|
| 518 |
+
} catch (fetchErr) {
|
| 519 |
+
if (fetchErr.name === "AbortError") {
|
| 520 |
+
throw new Error("Request timed out after 5 minutes. The LLM may be too slow. Try a faster model.");
|
| 521 |
+
}
|
| 522 |
+
throw fetchErr;
|
| 523 |
+
} finally {
|
| 524 |
+
clearTimeout(planTimer);
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
let data;
|
| 528 |
+
try {
|
| 529 |
+
data = await res.json();
|
| 530 |
+
} catch {
|
| 531 |
+
throw new Error(`Server error (${res.status}). The LLM may have returned an invalid response. Try a different model or enable Lite Mode in Settings.`);
|
| 532 |
+
}
|
| 533 |
+
if (!res.ok) {
|
| 534 |
+
const detail = data?.detail || data?.error || data?.message || "";
|
| 535 |
+
// Friendly message for common LLM failures
|
| 536 |
+
if (detail.includes("None or empty") || detail.includes("Invalid response from LLM")) {
|
| 537 |
+
throw new Error(
|
| 538 |
+
"The LLM returned an empty response. This often happens with small models (deepseek, qwen 0.5b). " +
|
| 539 |
+
"Try a larger model (llama3, qwen2.5:7b) or enable Lite Mode in Settings."
|
| 540 |
+
);
|
| 541 |
+
}
|
| 542 |
+
throw new Error(detail || "Failed to generate plan");
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
// Classify the plan into one of three kinds so we can render the
|
| 546 |
+
// right shape — not just "valid or banner":
|
| 547 |
+
//
|
| 548 |
+
// * executable — at least one CREATE/MODIFY/DELETE → plan card
|
| 549 |
+
// with Approve & execute / Reject controls.
|
| 550 |
+
// * informational — every file is READ (or no files at all on a
|
| 551 |
+
// step that still has a meaningful description)
|
| 552 |
+
// AND the summary is a real answer, not the
|
| 553 |
+
// placeholder. This is what happens when the
|
| 554 |
+
// user asks "what do you think about this
|
| 555 |
+
// project?" — the planner correctly READs the
|
| 556 |
+
// relevant files and the summary IS the answer.
|
| 557 |
+
// Render the summary as a normal assistant
|
| 558 |
+
// message; do not show plan controls.
|
| 559 |
+
// * empty — no steps OR no actionable signal at all →
|
| 560 |
+
// honest failure banner.
|
| 561 |
+
//
|
| 562 |
+
// Before this classifier the second case was treated as the
|
| 563 |
+
// third, surfacing "I couldn't produce a plan" on perfectly
|
| 564 |
+
// valid READ-only plans.
|
| 565 |
+
const planSteps = Array.isArray(data?.steps)
|
| 566 |
+
? data.steps
|
| 567 |
+
: Array.isArray(data?.plan?.steps)
|
| 568 |
+
? data.plan.steps
|
| 569 |
+
: [];
|
| 570 |
+
const PLACEHOLDER_SUMMARY = "Here is the proposed plan for your request.";
|
| 571 |
+
const summary =
|
| 572 |
+
data.plan?.summary || data.summary || data.message || PLACEHOLDER_SUMMARY;
|
| 573 |
+
const hasExecutable = planSteps.some(
|
| 574 |
+
(s) =>
|
| 575 |
+
Array.isArray(s?.files) &&
|
| 576 |
+
s.files.some((f) => ["CREATE", "MODIFY", "DELETE"].includes(f?.action)),
|
| 577 |
+
);
|
| 578 |
+
const isReadOnly =
|
| 579 |
+
planSteps.length > 0 &&
|
| 580 |
+
!hasExecutable &&
|
| 581 |
+
planSteps.every(
|
| 582 |
+
(s) =>
|
| 583 |
+
!Array.isArray(s?.files) ||
|
| 584 |
+
s.files.length === 0 ||
|
| 585 |
+
s.files.every((f) => f?.action === "READ"),
|
| 586 |
+
);
|
| 587 |
+
const hasRealSummary = Boolean(summary) && summary !== PLACEHOLDER_SUMMARY;
|
| 588 |
+
const planKind = hasExecutable
|
| 589 |
+
? "executable"
|
| 590 |
+
: isReadOnly && hasRealSummary
|
| 591 |
+
? "informational"
|
| 592 |
+
: "empty";
|
| 593 |
+
|
| 594 |
+
if (planKind === "executable") {
|
| 595 |
+
setPlan(data);
|
| 596 |
+
const assistantMsg = {
|
| 597 |
+
from: "ai",
|
| 598 |
+
role: "assistant",
|
| 599 |
+
answer: summary,
|
| 600 |
+
content: summary,
|
| 601 |
+
plan: data,
|
| 602 |
+
};
|
| 603 |
+
setMessages((prev) => [...prev, assistantMsg]);
|
| 604 |
+
persistMessage(sid, "assistant", summary, pickAssistantMetadata(assistantMsg));
|
| 605 |
+
} else if (planKind === "informational") {
|
| 606 |
+
// The summary is the answer. No plan card, no Approve/Reject —
|
| 607 |
+
// there is nothing to execute. We deliberately do NOT attach
|
| 608 |
+
// ``plan: data`` here so AssistantMessage renders this turn
|
| 609 |
+
// exactly like a chat reply.
|
| 610 |
+
setPlan(null);
|
| 611 |
+
const assistantMsg = {
|
| 612 |
+
from: "ai",
|
| 613 |
+
role: "assistant",
|
| 614 |
+
answer: summary,
|
| 615 |
+
content: summary,
|
| 616 |
+
informational: true,
|
| 617 |
+
};
|
| 618 |
+
setMessages((prev) => [...prev, assistantMsg]);
|
| 619 |
+
persistMessage(sid, "assistant", summary, pickAssistantMetadata(assistantMsg));
|
| 620 |
+
} else {
|
| 621 |
+
// empty — be honest about what we know. The earlier wording
|
| 622 |
+
// ("got stuck reading the same file twice") was a guess from
|
| 623 |
+
// an older bug; for the cases that actually still hit this
|
| 624 |
+
// branch the real signal is just "no actionable steps".
|
| 625 |
+
setPlan(null);
|
| 626 |
+
const failureText =
|
| 627 |
+
"The model returned an empty plan. Try rephrasing more concretely, " +
|
| 628 |
+
"or pick a stronger model in Settings → Provider.";
|
| 629 |
+
const failureMsg = {
|
| 630 |
+
from: "ai",
|
| 631 |
+
role: "system",
|
| 632 |
+
content: failureText,
|
| 633 |
+
};
|
| 634 |
+
setMessages((prev) => [...prev, failureMsg]);
|
| 635 |
+
persistMessage(sid, "system", failureText);
|
| 636 |
+
setStatus("No actionable plan produced.");
|
| 637 |
+
return;
|
| 638 |
+
}
|
| 639 |
+
} catch (err) {
|
| 640 |
+
const msg = String(err?.message || err);
|
| 641 |
+
console.error(err);
|
| 642 |
+
setStatus(msg);
|
| 643 |
+
setMessages((prev) => [
|
| 644 |
+
...prev,
|
| 645 |
+
{ from: "ai", role: "system", content: `Error: ${msg}` },
|
| 646 |
+
]);
|
| 647 |
+
} finally {
|
| 648 |
+
setLoadingPlan(false);
|
| 649 |
+
}
|
| 650 |
+
};
|
| 651 |
+
|
| 652 |
+
// ---------------------------------------------------------------------------
|
| 653 |
+
// Reject the active plan — minimal first cut.
|
| 654 |
+
//
|
| 655 |
+
// Industry rule we follow from the start: never write to disk on a path the
|
| 656 |
+
// user did not approve. Rejecting is the cheapest expression of that —
|
| 657 |
+
// discard the proposed plan locally, leave the workspace untouched, record
|
| 658 |
+
// the rejection in chat history so the user sees it after a session reload.
|
| 659 |
+
//
|
| 660 |
+
// No backend endpoint is needed yet because plans are not persisted as
|
| 661 |
+
// first-class objects today; they ride along on the assistant message's
|
| 662 |
+
// metadata. When we later add per-plan state tracking, this handler will
|
| 663 |
+
// also POST /api/chat/plan/{id}/reject — leaving that for a follow-up.
|
| 664 |
+
// ---------------------------------------------------------------------------
|
| 665 |
+
const rejectPlan = () => {
|
| 666 |
+
if (!plan || executing) return;
|
| 667 |
+
|
| 668 |
+
// Batch B9 — if the rejected plan contained an INDEX step, the
|
| 669 |
+
// user is implicitly saying "I don't want to build the semantic
|
| 670 |
+
// index right now". Stash the original goal so we can offer a
|
| 671 |
+
// one-click "retry with grep" path on the next render.
|
| 672 |
+
const hadIndexStep = Array.isArray(plan?.steps) &&
|
| 673 |
+
plan.steps.some((s) =>
|
| 674 |
+
Array.isArray(s?.files) && s.files.some((f) => f?.action === "INDEX"),
|
| 675 |
+
);
|
| 676 |
+
const rejectedGoal = plan?.goal || "";
|
| 677 |
+
|
| 678 |
+
setPlan(null);
|
| 679 |
+
setStatus("Plan rejected. No files were changed.");
|
| 680 |
+
|
| 681 |
+
const rejectionMsg = {
|
| 682 |
+
from: "ai",
|
| 683 |
+
role: "system",
|
| 684 |
+
content: "Plan rejected. No files were changed.",
|
| 685 |
+
};
|
| 686 |
+
setMessages((prev) => [...prev, rejectionMsg]);
|
| 687 |
+
|
| 688 |
+
if (sessionId) {
|
| 689 |
+
persistMessage(sessionId, "system", rejectionMsg.content);
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
if (hadIndexStep && rejectedGoal) {
|
| 693 |
+
setRetryAfterIndexReject({ goal: rejectedGoal });
|
| 694 |
+
} else {
|
| 695 |
+
setRetryAfterIndexReject(null);
|
| 696 |
+
}
|
| 697 |
+
};
|
| 698 |
+
|
| 699 |
+
const execute = async () => {
|
| 700 |
+
if (!repo || !plan) return;
|
| 701 |
+
|
| 702 |
+
setExecuting(true);
|
| 703 |
+
setStatus("");
|
| 704 |
+
|
| 705 |
+
try {
|
| 706 |
+
// Guard: currentBranch might be missing if parent didn't pass it yet
|
| 707 |
+
const safeCurrent = currentBranch || defaultBranch || "HEAD";
|
| 708 |
+
const safeDefault = defaultBranch || "main";
|
| 709 |
+
|
| 710 |
+
// Sticky vs Hard Switch:
|
| 711 |
+
// - If on default branch -> undefined (backend creates new branch)
|
| 712 |
+
// - If already on AI branch -> currentBranch (backend updates existing)
|
| 713 |
+
const branch_name = safeCurrent === safeDefault ? undefined : safeCurrent;
|
| 714 |
+
|
| 715 |
+
const res = await fetch("/api/chat/execute", {
|
| 716 |
+
method: "POST",
|
| 717 |
+
headers: getHeaders(),
|
| 718 |
+
body: JSON.stringify({
|
| 719 |
+
repo_owner: repo.owner,
|
| 720 |
+
repo_name: repo.name,
|
| 721 |
+
plan,
|
| 722 |
+
branch_name,
|
| 723 |
+
// Lets the backend persist the new branch on the session
|
| 724 |
+
// record so reopening this session lands on the published
|
| 725 |
+
// branch, not the one it was created on.
|
| 726 |
+
session_id: sessionId,
|
| 727 |
+
}),
|
| 728 |
+
});
|
| 729 |
+
|
| 730 |
+
const data = await res.json();
|
| 731 |
+
if (!res.ok) throw new Error(data.detail || "Execution failed");
|
| 732 |
+
|
| 733 |
+
setStatus(data.message || "Execution completed.");
|
| 734 |
+
|
| 735 |
+
// Track files touched by this execution so the file viewer can
|
| 736 |
+
// give "still syncing" / "deleted" classifications and so the
|
| 737 |
+
// sidebar refreshes off the freshly-pushed branch tree.
|
| 738 |
+
if (plan?.steps) {
|
| 739 |
+
for (const step of plan.steps) {
|
| 740 |
+
for (const f of step.files || []) {
|
| 741 |
+
if (f.action === "CREATE" || f.action === "MODIFY") {
|
| 742 |
+
fileWasJustCreatedRef.current.add(f.path);
|
| 743 |
+
} else if (f.action === "DELETE") {
|
| 744 |
+
fileWasJustDeletedRef.current.add(f.path);
|
| 745 |
+
}
|
| 746 |
+
}
|
| 747 |
+
}
|
| 748 |
+
}
|
| 749 |
+
// Forget the marker after 30 s so older "syncing" badges don't
|
| 750 |
+
// stick around forever.
|
| 751 |
+
window.setTimeout(() => {
|
| 752 |
+
fileWasJustCreatedRef.current.clear();
|
| 753 |
+
fileWasJustDeletedRef.current.clear();
|
| 754 |
+
}, 30000);
|
| 755 |
+
|
| 756 |
+
// Ask the sidebar's file tree to refetch off the newly-published
|
| 757 |
+
// branch. Fires after a small delay so GitHub's contents API has
|
| 758 |
+
// a chance to catch up.
|
| 759 |
+
window.setTimeout(() => {
|
| 760 |
+
try {
|
| 761 |
+
window.dispatchEvent(new CustomEvent("gitpilot:refresh-tree"));
|
| 762 |
+
} catch (_e) { /* old browser */ }
|
| 763 |
+
}, 600);
|
| 764 |
+
|
| 765 |
+
const completionMsg = {
|
| 766 |
+
from: "ai",
|
| 767 |
+
role: "assistant",
|
| 768 |
+
answer: data.message || "Execution completed.",
|
| 769 |
+
content: data.message || "Execution completed.",
|
| 770 |
+
executionLog: data.executionLog,
|
| 771 |
+
diff: data.diff,
|
| 772 |
+
// Backend-suggested follow-ups (e.g. "Run demo.py" after CREATE
|
| 773 |
+
// of a runnable file). Rendered as a button row in the
|
| 774 |
+
// completion message — one click, no typing.
|
| 775 |
+
nextActions: data.next_actions,
|
| 776 |
+
branch: data.branch || data.branch_name,
|
| 777 |
+
};
|
| 778 |
+
|
| 779 |
+
// Show completion immediately (keeps old "Execution Log" section)
|
| 780 |
+
setMessages((prev) => [...prev, completionMsg]);
|
| 781 |
+
|
| 782 |
+
// Persist the execution log + diff alongside the message text so
|
| 783 |
+
// the History view re-renders the green "Execution Log" panel and
|
| 784 |
+
// the "View diff" affordance. Without this, reloading the session
|
| 785 |
+
// shows just the one-line "Execution completed." summary.
|
| 786 |
+
persistMessage(
|
| 787 |
+
sessionId,
|
| 788 |
+
"assistant",
|
| 789 |
+
completionMsg.content,
|
| 790 |
+
pickAssistantMetadata(completionMsg),
|
| 791 |
+
);
|
| 792 |
+
|
| 793 |
+
// Clear active plan UI
|
| 794 |
+
setPlan(null);
|
| 795 |
+
|
| 796 |
+
// Pass completionMsg upward for seeding branch history
|
| 797 |
+
if (typeof onExecutionComplete === "function") {
|
| 798 |
+
onExecutionComplete({
|
| 799 |
+
branch: data.branch || data.branch_name,
|
| 800 |
+
mode: data.mode,
|
| 801 |
+
commit_url: data.commit_url || data.html_url,
|
| 802 |
+
message: data.message,
|
| 803 |
+
completionMsg,
|
| 804 |
+
sourceBranch: safeCurrent,
|
| 805 |
+
});
|
| 806 |
+
}
|
| 807 |
+
} catch (err) {
|
| 808 |
+
console.error(err);
|
| 809 |
+
setStatus(String(err?.message || err));
|
| 810 |
+
} finally {
|
| 811 |
+
setExecuting(false);
|
| 812 |
+
}
|
| 813 |
+
};
|
| 814 |
+
|
| 815 |
+
// ---------------------------------------------------------------------------
|
| 816 |
+
// RENDER
|
| 817 |
+
// ---------------------------------------------------------------------------
|
| 818 |
+
const isOnSessionBranch = currentBranch && currentBranch !== defaultBranch;
|
| 819 |
+
|
| 820 |
+
return (
|
| 821 |
+
<div className="chat-container">
|
| 822 |
+
<style>{`
|
| 823 |
+
.chat-container { display: flex; flex-direction: column; height: 100%; }
|
| 824 |
+
|
| 825 |
+
.chat-messages {
|
| 826 |
+
flex: 1; overflow-y: auto;
|
| 827 |
+
padding: 20px;
|
| 828 |
+
display: flex; flex-direction: column; gap: 16px;
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
.chat-message-user {
|
| 832 |
+
align-self: flex-end;
|
| 833 |
+
background: #27272A;
|
| 834 |
+
color: #fff;
|
| 835 |
+
padding: 12px 16px;
|
| 836 |
+
border-radius: 10px;
|
| 837 |
+
max-width: 85%;
|
| 838 |
+
font-size: 14px;
|
| 839 |
+
line-height: 1.5;
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
/* Success System Message Styling */
|
| 843 |
+
.chat-msg-success {
|
| 844 |
+
align-self: flex-start;
|
| 845 |
+
width: 100%;
|
| 846 |
+
background: rgba(16, 185, 129, 0.10);
|
| 847 |
+
border: 1px solid rgba(16, 185, 129, 0.20);
|
| 848 |
+
color: #D1FAE5;
|
| 849 |
+
padding: 12px 16px;
|
| 850 |
+
border-radius: 10px;
|
| 851 |
+
display: flex;
|
| 852 |
+
gap: 12px;
|
| 853 |
+
font-size: 14px;
|
| 854 |
+
}
|
| 855 |
+
.success-icon { font-size: 18px; }
|
| 856 |
+
.success-link {
|
| 857 |
+
display: inline-block;
|
| 858 |
+
margin-top: 6px;
|
| 859 |
+
font-weight: 600;
|
| 860 |
+
color: #34D399;
|
| 861 |
+
text-decoration: none;
|
| 862 |
+
}
|
| 863 |
+
.success-link:hover { text-decoration: underline; }
|
| 864 |
+
|
| 865 |
+
.chat-input-box {
|
| 866 |
+
padding: 16px;
|
| 867 |
+
border-top: 1px solid #27272A;
|
| 868 |
+
background: #131316;
|
| 869 |
+
}
|
| 870 |
+
|
| 871 |
+
.chat-input-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
| 872 |
+
|
| 873 |
+
.chat-input {
|
| 874 |
+
flex: 1;
|
| 875 |
+
min-width: 200px;
|
| 876 |
+
background: #18181B;
|
| 877 |
+
border: 1px solid #27272A;
|
| 878 |
+
color: white;
|
| 879 |
+
padding: 10px 12px;
|
| 880 |
+
border-radius: 8px;
|
| 881 |
+
outline: none;
|
| 882 |
+
font-size: 14px;
|
| 883 |
+
font-family: inherit;
|
| 884 |
+
resize: none;
|
| 885 |
+
min-height: 40px;
|
| 886 |
+
max-height: 160px;
|
| 887 |
+
line-height: 1.4;
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
/* Enterprise controls (restored) */
|
| 891 |
+
.chat-btn {
|
| 892 |
+
height: 38px;
|
| 893 |
+
padding: 0 14px;
|
| 894 |
+
border-radius: 8px;
|
| 895 |
+
font-weight: 700;
|
| 896 |
+
cursor: pointer;
|
| 897 |
+
border: 1px solid transparent;
|
| 898 |
+
font-size: 13px;
|
| 899 |
+
white-space: nowrap;
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
/* Orange primary (old style) */
|
| 903 |
+
.chat-btn.primary { background: #D95C3D; color: #fff; }
|
| 904 |
+
.chat-btn.primary:hover { filter: brightness(0.98); }
|
| 905 |
+
.chat-btn.primary:disabled { opacity: 0.55; cursor: not-allowed; }
|
| 906 |
+
|
| 907 |
+
/* Secondary outline */
|
| 908 |
+
.chat-btn.secondary {
|
| 909 |
+
background: transparent;
|
| 910 |
+
border: 1px solid #3F3F46;
|
| 911 |
+
color: #A1A1AA;
|
| 912 |
+
}
|
| 913 |
+
.chat-btn.secondary:hover { background: rgba(255,255,255,0.04); }
|
| 914 |
+
.chat-btn.secondary:disabled { opacity: 0.55; cursor: not-allowed; }
|
| 915 |
+
|
| 916 |
+
.chat-empty-state {
|
| 917 |
+
text-align: center;
|
| 918 |
+
color: #52525B;
|
| 919 |
+
margin-top: 40px;
|
| 920 |
+
font-size: 14px;
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
/* WebSocket connection indicator */
|
| 924 |
+
.ws-indicator {
|
| 925 |
+
display: inline-flex;
|
| 926 |
+
align-items: center;
|
| 927 |
+
gap: 4px;
|
| 928 |
+
font-size: 10px;
|
| 929 |
+
color: #71717A;
|
| 930 |
+
padding: 2px 6px;
|
| 931 |
+
border-radius: 4px;
|
| 932 |
+
background: rgba(24, 24, 27, 0.6);
|
| 933 |
+
}
|
| 934 |
+
.ws-dot {
|
| 935 |
+
width: 6px;
|
| 936 |
+
height: 6px;
|
| 937 |
+
border-radius: 50%;
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
@keyframes blink {
|
| 941 |
+
0%, 100% { opacity: 1; }
|
| 942 |
+
50% { opacity: 0; }
|
| 943 |
+
}
|
| 944 |
+
`}</style>
|
| 945 |
+
|
| 946 |
+
<div className="chat-messages">
|
| 947 |
+
{messages.map((m, idx) => {
|
| 948 |
+
// Success message (App.jsx injected)
|
| 949 |
+
if (m.isSuccess) {
|
| 950 |
+
return (
|
| 951 |
+
<div key={idx} className="chat-msg-success">
|
| 952 |
+
<div className="success-icon">🚀</div>
|
| 953 |
+
<div>
|
| 954 |
+
<div style={{ whiteSpace: "pre-wrap" }}>{m.content}</div>
|
| 955 |
+
{m.link && (
|
| 956 |
+
<a href={m.link} target="_blank" rel="noreferrer" className="success-link">
|
| 957 |
+
View Changes on GitHub →
|
| 958 |
+
</a>
|
| 959 |
+
)}
|
| 960 |
+
</div>
|
| 961 |
+
</div>
|
| 962 |
+
);
|
| 963 |
+
}
|
| 964 |
+
|
| 965 |
+
// User message
|
| 966 |
+
if (m.from === "user" || m.role === "user") {
|
| 967 |
+
return (
|
| 968 |
+
<div key={idx} className="chat-message-user">
|
| 969 |
+
<span>{m.text || m.content}</span>
|
| 970 |
+
</div>
|
| 971 |
+
);
|
| 972 |
+
}
|
| 973 |
+
|
| 974 |
+
// Assistant message (Answer / Plan / Execution Log).
|
| 975 |
+
//
|
| 976 |
+
// Lifecycle audit signal: if this message carries a plan, look
|
| 977 |
+
// ahead in the timeline for any subsequent message that
|
| 978 |
+
// records an execution log (=> the plan was approved+executed)
|
| 979 |
+
// or a system "Plan rejected" entry (=> the plan was
|
| 980 |
+
// rejected). The status is rendered as a small green/grey
|
| 981 |
+
// badge next to the Action Plan header so users can tell at a
|
| 982 |
+
// glance — in history — whether a previous plan was acted on.
|
| 983 |
+
let planStatus = null;
|
| 984 |
+
if (m.plan) {
|
| 985 |
+
const after = messages.slice(idx + 1);
|
| 986 |
+
if (after.some((later) => later.executionLog)) {
|
| 987 |
+
planStatus = "executed";
|
| 988 |
+
} else if (
|
| 989 |
+
after.some(
|
| 990 |
+
(later) =>
|
| 991 |
+
later.role === "system" &&
|
| 992 |
+
typeof later.content === "string" &&
|
| 993 |
+
later.content.includes("Plan rejected"),
|
| 994 |
+
)
|
| 995 |
+
) {
|
| 996 |
+
planStatus = "rejected";
|
| 997 |
+
}
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
// Find the plan that was approved for this completion, so the
|
| 1001 |
+
// success receipt can label actions (READ/CREATE/...) instead of
|
| 1002 |
+
// showing only an opaque execution dump.
|
| 1003 |
+
let linkedPlan = null;
|
| 1004 |
+
if (m.executionLog) {
|
| 1005 |
+
for (let i = idx - 1; i >= 0; i--) {
|
| 1006 |
+
if (messages[i].plan?.steps) {
|
| 1007 |
+
linkedPlan = messages[i].plan;
|
| 1008 |
+
break;
|
| 1009 |
+
}
|
| 1010 |
+
}
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
return (
|
| 1014 |
+
<div key={idx}>
|
| 1015 |
+
<AssistantMessage
|
| 1016 |
+
answer={m.answer || m.content}
|
| 1017 |
+
plan={m.plan}
|
| 1018 |
+
executionLog={m.executionLog}
|
| 1019 |
+
planStatus={planStatus}
|
| 1020 |
+
owner={repo?.owner}
|
| 1021 |
+
repo={repo?.name}
|
| 1022 |
+
onApproveExecution={() => execute()}
|
| 1023 |
+
nextActions={m.nextActions}
|
| 1024 |
+
relatedPlan={linkedPlan}
|
| 1025 |
+
diff={m.diff}
|
| 1026 |
+
branch={m.branch || currentBranch}
|
| 1027 |
+
/>
|
| 1028 |
+
{/* Diff stats indicator (Claude-Code-on-Web parity) */}
|
| 1029 |
+
{m.diff && (
|
| 1030 |
+
<DiffStats diff={m.diff} onClick={() => {
|
| 1031 |
+
setDiffData(m.diff);
|
| 1032 |
+
setShowDiffViewer(true);
|
| 1033 |
+
}} />
|
| 1034 |
+
)}
|
| 1035 |
+
</div>
|
| 1036 |
+
);
|
| 1037 |
+
})}
|
| 1038 |
+
|
| 1039 |
+
{/* Streaming events (real-time agent output) */}
|
| 1040 |
+
{streamingEvents.length > 0 && (
|
| 1041 |
+
<div>
|
| 1042 |
+
<StreamingMessage events={streamingEvents} />
|
| 1043 |
+
</div>
|
| 1044 |
+
)}
|
| 1045 |
+
|
| 1046 |
+
{/* Enterprise Pulse — agentic thinking state shown after the user
|
| 1047 |
+
hits Send and before the first streamed/planned chunk arrives.
|
| 1048 |
+
Falls back gracefully to nothing once streamingEvents start
|
| 1049 |
+
flowing in (StreamingMessage takes over the live feedback). */}
|
| 1050 |
+
{loadingPlan && streamingEvents.length === 0 && (
|
| 1051 |
+
<ThinkingIndicator />
|
| 1052 |
+
)}
|
| 1053 |
+
|
| 1054 |
+
{/* Live execution status — visible in the chat timeline while
|
| 1055 |
+
``executing`` is true, sits between the Action Plan card and
|
| 1056 |
+
where the Execution Log (green panel in AssistantMessage)
|
| 1057 |
+
will land once the backend returns. Removes the "did the
|
| 1058 |
+
app freeze?" feeling caused by only the bottom button
|
| 1059 |
+
saying "Executing…".
|
| 1060 |
+
|
| 1061 |
+
Reuses the ThinkingIndicator with execution-specific labels.
|
| 1062 |
+
When the executor finishes, ``setExecuting(false)`` removes
|
| 1063 |
+
this bubble and the completionMsg lands in the timeline as
|
| 1064 |
+
a normal assistant message with its green Execution Log
|
| 1065 |
+
block — already rendered by AssistantMessage today. */}
|
| 1066 |
+
{executing && (
|
| 1067 |
+
<ThinkingIndicator
|
| 1068 |
+
labels={[
|
| 1069 |
+
"Executing plan",
|
| 1070 |
+
"Applying changes",
|
| 1071 |
+
"Verifying result",
|
| 1072 |
+
]}
|
| 1073 |
+
/>
|
| 1074 |
+
)}
|
| 1075 |
+
|
| 1076 |
+
{!messages.length && !plan && !loadingPlan && streamingEvents.length === 0 && (
|
| 1077 |
+
<div className="chat-empty-state">
|
| 1078 |
+
<div className="chat-empty-icon">💬</div>
|
| 1079 |
+
<p>Tell GitPilot what you want to do with this repository.</p>
|
| 1080 |
+
<p style={{ fontSize: 12, color: "#676883", marginTop: 4 }}>
|
| 1081 |
+
It will propose a safe step-by-step plan before any execution.
|
| 1082 |
+
</p>
|
| 1083 |
+
</div>
|
| 1084 |
+
)}
|
| 1085 |
+
|
| 1086 |
+
<div ref={messagesEndRef} />
|
| 1087 |
+
</div>
|
| 1088 |
+
|
| 1089 |
+
{/* Batch B9 — post-Reject "retry with grep" prompt. Renders
|
| 1090 |
+
only when the user rejected a plan whose first step was an
|
| 1091 |
+
INDEX action. One click re-issues the same goal with
|
| 1092 |
+
force_no_rag so the router falls back to grep. */}
|
| 1093 |
+
{retryAfterIndexReject && !loadingPlan && (
|
| 1094 |
+
<div
|
| 1095 |
+
style={{
|
| 1096 |
+
padding: "10px 16px",
|
| 1097 |
+
borderTop: "1px solid #27272A",
|
| 1098 |
+
background: "rgba(217, 92, 61, 0.06)",
|
| 1099 |
+
display: "flex",
|
| 1100 |
+
alignItems: "center",
|
| 1101 |
+
justifyContent: "space-between",
|
| 1102 |
+
gap: 12,
|
| 1103 |
+
flexWrap: "wrap",
|
| 1104 |
+
}}
|
| 1105 |
+
>
|
| 1106 |
+
<span style={{ fontSize: 13, color: "#D4D4D8" }}>
|
| 1107 |
+
Index skipped. Run the same goal with grep instead?
|
| 1108 |
+
</span>
|
| 1109 |
+
<span style={{ display: "inline-flex", gap: 8 }}>
|
| 1110 |
+
<button
|
| 1111 |
+
type="button"
|
| 1112 |
+
className="chat-btn primary"
|
| 1113 |
+
onClick={() => {
|
| 1114 |
+
const g = retryAfterIndexReject.goal;
|
| 1115 |
+
setRetryAfterIndexReject(null);
|
| 1116 |
+
send({ goal: g, force_no_rag: true });
|
| 1117 |
+
}}
|
| 1118 |
+
>
|
| 1119 |
+
Yes, use grep
|
| 1120 |
+
</button>
|
| 1121 |
+
<button
|
| 1122 |
+
type="button"
|
| 1123 |
+
className="chat-btn ghost"
|
| 1124 |
+
onClick={() => setRetryAfterIndexReject(null)}
|
| 1125 |
+
style={{
|
| 1126 |
+
color: "#9CA3AF",
|
| 1127 |
+
borderColor: "rgba(156, 163, 175, 0.35)",
|
| 1128 |
+
background: "transparent",
|
| 1129 |
+
}}
|
| 1130 |
+
>
|
| 1131 |
+
No, dismiss
|
| 1132 |
+
</button>
|
| 1133 |
+
</span>
|
| 1134 |
+
</div>
|
| 1135 |
+
)}
|
| 1136 |
+
|
| 1137 |
+
{/* Diff stats bar (when agent has made changes) */}
|
| 1138 |
+
{diffData && (
|
| 1139 |
+
<div style={{
|
| 1140 |
+
padding: "8px 16px",
|
| 1141 |
+
borderTop: "1px solid #27272A",
|
| 1142 |
+
background: "#18181B",
|
| 1143 |
+
}}>
|
| 1144 |
+
<DiffStats diff={diffData} onClick={() => setShowDiffViewer(true)} />
|
| 1145 |
+
</div>
|
| 1146 |
+
)}
|
| 1147 |
+
|
| 1148 |
+
<div className="chat-input-box">
|
| 1149 |
+
{/* Readiness blocker banner */}
|
| 1150 |
+
{!canChat && chatBlocker && (
|
| 1151 |
+
<div style={{
|
| 1152 |
+
fontSize: 12,
|
| 1153 |
+
color: "#F59E0B",
|
| 1154 |
+
background: "rgba(245, 158, 11, 0.08)",
|
| 1155 |
+
border: "1px solid rgba(245, 158, 11, 0.2)",
|
| 1156 |
+
borderRadius: 6,
|
| 1157 |
+
padding: "8px 12px",
|
| 1158 |
+
marginBottom: 8,
|
| 1159 |
+
display: "flex",
|
| 1160 |
+
alignItems: "center",
|
| 1161 |
+
justifyContent: "space-between",
|
| 1162 |
+
}}>
|
| 1163 |
+
<span>{chatBlocker.message || "Chat is not ready yet."}</span>
|
| 1164 |
+
{chatBlocker.cta && chatBlocker.onCta && (
|
| 1165 |
+
<button
|
| 1166 |
+
type="button"
|
| 1167 |
+
onClick={chatBlocker.onCta}
|
| 1168 |
+
style={{
|
| 1169 |
+
fontSize: 11,
|
| 1170 |
+
fontWeight: 600,
|
| 1171 |
+
color: "#F59E0B",
|
| 1172 |
+
background: "transparent",
|
| 1173 |
+
border: "1px solid rgba(245, 158, 11, 0.3)",
|
| 1174 |
+
borderRadius: 4,
|
| 1175 |
+
padding: "2px 8px",
|
| 1176 |
+
cursor: "pointer",
|
| 1177 |
+
}}
|
| 1178 |
+
>
|
| 1179 |
+
{chatBlocker.cta}
|
| 1180 |
+
</button>
|
| 1181 |
+
)}
|
| 1182 |
+
</div>
|
| 1183 |
+
)}
|
| 1184 |
+
{status && (
|
| 1185 |
+
<div style={{ fontSize: 11, color: "#ffb3b7", marginBottom: 8 }}>
|
| 1186 |
+
{status}
|
| 1187 |
+
</div>
|
| 1188 |
+
)}
|
| 1189 |
+
|
| 1190 |
+
<div className="chat-input-row">
|
| 1191 |
+
<textarea
|
| 1192 |
+
className="chat-input"
|
| 1193 |
+
placeholder={wsConnected ? "Send feedback or instructions..." : "Describe the change you want to make..."}
|
| 1194 |
+
value={goal}
|
| 1195 |
+
rows={1}
|
| 1196 |
+
onChange={(e) => {
|
| 1197 |
+
setGoal(e.target.value);
|
| 1198 |
+
e.target.style.height = "40px";
|
| 1199 |
+
e.target.style.height = Math.min(e.target.scrollHeight, 160) + "px";
|
| 1200 |
+
}}
|
| 1201 |
+
onKeyDown={(e) => {
|
| 1202 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 1203 |
+
e.preventDefault();
|
| 1204 |
+
if (!loadingPlan && !executing) send();
|
| 1205 |
+
}
|
| 1206 |
+
}}
|
| 1207 |
+
disabled={!canChat || loadingPlan || executing}
|
| 1208 |
+
/>
|
| 1209 |
+
|
| 1210 |
+
{/* Always show both buttons (old UX) */}
|
| 1211 |
+
<button
|
| 1212 |
+
className="chat-btn primary"
|
| 1213 |
+
type="button"
|
| 1214 |
+
onClick={send}
|
| 1215 |
+
disabled={!canChat || loadingPlan || executing || !goal.trim()}
|
| 1216 |
+
>
|
| 1217 |
+
{loadingPlan ? "Planning..." : wsConnected ? "Send" : "Generate plan"}
|
| 1218 |
+
</button>
|
| 1219 |
+
|
| 1220 |
+
{/* Approve & execute — visible only while a plan is awaiting
|
| 1221 |
+
approval, or while an execution is already in flight (so
|
| 1222 |
+
the user sees the "Executing…" label, not a missing
|
| 1223 |
+
button). Previously this was always rendered with
|
| 1224 |
+
``disabled={!plan}``, which meant after a successful
|
| 1225 |
+
execute() the button stayed on screen as a dimmed ghost
|
| 1226 |
+
and a second click could trigger a duplicate run —
|
| 1227 |
+
causing the executor to re-write the same file with the
|
| 1228 |
+
same content (~50 s of wasted LLM time per accidental
|
| 1229 |
+
click). Hiding the button entirely once ``plan`` is
|
| 1230 |
+
null makes the bug impossible. */}
|
| 1231 |
+
{(plan || executing) && (
|
| 1232 |
+
<button
|
| 1233 |
+
className="chat-btn secondary"
|
| 1234 |
+
type="button"
|
| 1235 |
+
onClick={execute}
|
| 1236 |
+
disabled={executing || loadingPlan}
|
| 1237 |
+
>
|
| 1238 |
+
{executing ? "Executing..." : "Approve & execute"}
|
| 1239 |
+
</button>
|
| 1240 |
+
)}
|
| 1241 |
+
|
| 1242 |
+
{/* Reject plan — same visibility window as Approve. */}
|
| 1243 |
+
{plan && !executing && !loadingPlan && (
|
| 1244 |
+
<button
|
| 1245 |
+
className="chat-btn ghost"
|
| 1246 |
+
type="button"
|
| 1247 |
+
onClick={rejectPlan}
|
| 1248 |
+
title="Discard this plan. No files will be changed."
|
| 1249 |
+
style={{
|
| 1250 |
+
color: "#F87171",
|
| 1251 |
+
borderColor: "rgba(248, 113, 113, 0.35)",
|
| 1252 |
+
background: "transparent",
|
| 1253 |
+
}}
|
| 1254 |
+
>
|
| 1255 |
+
Reject plan
|
| 1256 |
+
</button>
|
| 1257 |
+
)}
|
| 1258 |
+
|
| 1259 |
+
{/* Create PR button (Claude-Code-on-Web parity) */}
|
| 1260 |
+
{isOnSessionBranch && (
|
| 1261 |
+
<CreatePRButton
|
| 1262 |
+
repo={repo}
|
| 1263 |
+
sessionId={sessionId}
|
| 1264 |
+
branch={currentBranch}
|
| 1265 |
+
defaultBranch={defaultBranch}
|
| 1266 |
+
disabled={executing || loadingPlan}
|
| 1267 |
+
/>
|
| 1268 |
+
)}
|
| 1269 |
+
</div>
|
| 1270 |
+
|
| 1271 |
+
{/* WebSocket connection indicator + context-window meter */}
|
| 1272 |
+
<div style={{ marginTop: 6, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
| 1273 |
+
<span>
|
| 1274 |
+
{sessionId && (
|
| 1275 |
+
<span className="ws-indicator">
|
| 1276 |
+
<span className="ws-dot" style={{
|
| 1277 |
+
backgroundColor: wsConnected ? "#10B981" : "#EF4444",
|
| 1278 |
+
}} />
|
| 1279 |
+
{wsConnected ? "Live" : "Connecting..."}
|
| 1280 |
+
</span>
|
| 1281 |
+
)}
|
| 1282 |
+
</span>
|
| 1283 |
+
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
| 1284 |
+
<TasksPanel sessionId={sessionId} />
|
| 1285 |
+
<ContextMeter sessionId={sessionId} />
|
| 1286 |
+
</span>
|
| 1287 |
+
</div>
|
| 1288 |
+
</div>
|
| 1289 |
+
|
| 1290 |
+
{/* Diff Viewer overlay */}
|
| 1291 |
+
{showDiffViewer && (
|
| 1292 |
+
<DiffViewer
|
| 1293 |
+
diff={diffData}
|
| 1294 |
+
onClose={() => setShowDiffViewer(false)}
|
| 1295 |
+
/>
|
| 1296 |
+
)}
|
| 1297 |
+
|
| 1298 |
+
{/* FilePreviewPanel — read-first viewer. Opens on a file row
|
| 1299 |
+
click in the sidebar. Header carries Prepare Run (runnable
|
| 1300 |
+
only), Open Workspace, and an overflow menu. */}
|
| 1301 |
+
{previewPath && (
|
| 1302 |
+
<FilePreviewPanel
|
| 1303 |
+
path={previewPath}
|
| 1304 |
+
content={previewContent}
|
| 1305 |
+
loading={previewLoading}
|
| 1306 |
+
error={previewError}
|
| 1307 |
+
errorCode={previewErrorCode}
|
| 1308 |
+
notFoundKind={
|
| 1309 |
+
fileWasJustDeletedRef.current.has(previewPath)
|
| 1310 |
+
? "deleted"
|
| 1311 |
+
: fileWasJustCreatedRef.current.has(previewPath)
|
| 1312 |
+
? "syncing"
|
| 1313 |
+
: "unavailable"
|
| 1314 |
+
}
|
| 1315 |
+
mode={previewMode}
|
| 1316 |
+
branch={currentBranch}
|
| 1317 |
+
onModeChange={setPreviewMode}
|
| 1318 |
+
onRefreshTree={() => {
|
| 1319 |
+
try {
|
| 1320 |
+
window.dispatchEvent(new CustomEvent("gitpilot:refresh-tree"));
|
| 1321 |
+
} catch (_e) { /* old browser */ }
|
| 1322 |
+
}}
|
| 1323 |
+
onRetry={() => {
|
| 1324 |
+
const p = previewPath;
|
| 1325 |
+
const m = previewMode;
|
| 1326 |
+
setPreviewPath(null);
|
| 1327 |
+
// Fire the same window event we listened to — keeps the
|
| 1328 |
+
// retry path identical to the original load and lets any
|
| 1329 |
+
// future side-effects (e.g. analytics) see one event class.
|
| 1330 |
+
setTimeout(() => window.dispatchEvent(new CustomEvent(
|
| 1331 |
+
m === "workspace" ? "gitpilot:open-workspace" : "gitpilot:open-file",
|
| 1332 |
+
{ detail: { path: p } },
|
| 1333 |
+
)), 0);
|
| 1334 |
+
}}
|
| 1335 |
+
onClose={() => {
|
| 1336 |
+
try {
|
| 1337 |
+
window.dispatchEvent(new CustomEvent("gitpilot:file-closed"));
|
| 1338 |
+
} catch (_e) {/* old browser */}
|
| 1339 |
+
setPreviewPath(null);
|
| 1340 |
+
setPreviewContent(null);
|
| 1341 |
+
setPreviewError(null);
|
| 1342 |
+
setPreviewErrorCode(null);
|
| 1343 |
+
}}
|
| 1344 |
+
/>
|
| 1345 |
+
)}
|
| 1346 |
+
|
| 1347 |
+
{/* SandboxCanvas overlay — opened by "Open in Canvas" next_action
|
| 1348 |
+
buttons and ExecutionCard footers via the
|
| 1349 |
+
gitpilot:open-in-canvas window event. */}
|
| 1350 |
+
{canvasSpec && (
|
| 1351 |
+
<SandboxCanvas
|
| 1352 |
+
initialLanguage={canvasSpec.language}
|
| 1353 |
+
initialCode={canvasSpec.code}
|
| 1354 |
+
filename={canvasSpec.filename}
|
| 1355 |
+
onClose={() => { setCanvasSpec(null); setCanvasError(null); }}
|
| 1356 |
+
/>
|
| 1357 |
+
)}
|
| 1358 |
+
{canvasError && canvasSpec && (
|
| 1359 |
+
<div style={{
|
| 1360 |
+
position: "fixed", bottom: 16, right: 16, zIndex: 110,
|
| 1361 |
+
padding: "8px 12px", maxWidth: 380, fontSize: 12,
|
| 1362 |
+
color: "#fca5a5", background: "#3d1111",
|
| 1363 |
+
border: "1px solid #7f1d1d", borderRadius: 6,
|
| 1364 |
+
}}>
|
| 1365 |
+
{canvasError}
|
| 1366 |
+
</div>
|
| 1367 |
+
)}
|
| 1368 |
+
</div>
|
| 1369 |
+
);
|
| 1370 |
+
}
|
frontend/components/ContextBar.jsx
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useCallback, useRef, useState } from "react";
|
| 2 |
+
import BranchPicker from "./BranchPicker.jsx";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* ContextBar — horizontal repo chip bar for multi-repo workspace context.
|
| 6 |
+
*
|
| 7 |
+
* Uses CSS classes for hover-reveal X (Claude-style: subtle by default,
|
| 8 |
+
* visible on chip hover, red on X hover). Each chip owns its own remove
|
| 9 |
+
* button — removing one repo never affects the others.
|
| 10 |
+
*/
|
| 11 |
+
export default function ContextBar({
|
| 12 |
+
contextRepos,
|
| 13 |
+
activeRepoKey,
|
| 14 |
+
repoStateByKey,
|
| 15 |
+
onActivate,
|
| 16 |
+
onRemove,
|
| 17 |
+
onAdd,
|
| 18 |
+
onBranchChange,
|
| 19 |
+
mode, // workspace mode: "github", "local-git", "folder" (optional)
|
| 20 |
+
}) {
|
| 21 |
+
if (!contextRepos || contextRepos.length === 0) return null;
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<div className="ctxbar">
|
| 25 |
+
{/* Workspace mode indicator */}
|
| 26 |
+
{mode && (
|
| 27 |
+
<span className="ctxbar-mode" title={`Workspace mode: ${mode}`}>
|
| 28 |
+
{mode === "github" ? "GH" : mode === "local-git" ? "Git" : "Dir"}
|
| 29 |
+
</span>
|
| 30 |
+
)}
|
| 31 |
+
<div className="ctxbar-scroll">
|
| 32 |
+
{contextRepos.map((entry) => {
|
| 33 |
+
const isActive = entry.repoKey === activeRepoKey;
|
| 34 |
+
return (
|
| 35 |
+
<RepoChip
|
| 36 |
+
key={entry.repoKey}
|
| 37 |
+
entry={entry}
|
| 38 |
+
isActive={isActive}
|
| 39 |
+
repoState={repoStateByKey?.[entry.repoKey]}
|
| 40 |
+
onActivate={() => onActivate(entry.repoKey)}
|
| 41 |
+
onRemove={() => onRemove(entry.repoKey)}
|
| 42 |
+
onBranchChange={(newBranch) =>
|
| 43 |
+
onBranchChange(entry.repoKey, newBranch)
|
| 44 |
+
}
|
| 45 |
+
/>
|
| 46 |
+
);
|
| 47 |
+
})}
|
| 48 |
+
|
| 49 |
+
<button
|
| 50 |
+
type="button"
|
| 51 |
+
className="ctxbar-add"
|
| 52 |
+
onClick={onAdd}
|
| 53 |
+
title="Add repository to context"
|
| 54 |
+
>
|
| 55 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 56 |
+
<line x1="12" y1="5" x2="12" y2="19" />
|
| 57 |
+
<line x1="5" y1="12" x2="19" y2="12" />
|
| 58 |
+
</svg>
|
| 59 |
+
</button>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div className="ctxbar-meta">
|
| 63 |
+
{contextRepos.length} {contextRepos.length === 1 ? "repo" : "repos"}
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function RepoChip({ entry, isActive, repoState, onActivate, onRemove, onBranchChange }) {
|
| 70 |
+
const [branchOpen, setBranchOpen] = useState(false);
|
| 71 |
+
const [hovered, setHovered] = useState(false);
|
| 72 |
+
const branchBtnRef = useRef(null);
|
| 73 |
+
const repo = entry.repo;
|
| 74 |
+
const branch = repoState?.currentBranch || entry.branch || repo?.default_branch || "main";
|
| 75 |
+
const defaultBranch = repoState?.defaultBranch || repo?.default_branch || "main";
|
| 76 |
+
const sessionBranches = repoState?.sessionBranches || [];
|
| 77 |
+
const displayName = repo?.name || entry.repoKey?.split("/")[1] || entry.repoKey;
|
| 78 |
+
|
| 79 |
+
const handleChipClick = useCallback(
|
| 80 |
+
(e) => {
|
| 81 |
+
if (e.target.closest("[data-chip-action]")) return;
|
| 82 |
+
onActivate();
|
| 83 |
+
},
|
| 84 |
+
[onActivate]
|
| 85 |
+
);
|
| 86 |
+
|
| 87 |
+
return (
|
| 88 |
+
<div
|
| 89 |
+
className={"ctxbar-chip" + (isActive ? " ctxbar-chip-active" : "")}
|
| 90 |
+
onClick={handleChipClick}
|
| 91 |
+
onMouseEnter={() => setHovered(true)}
|
| 92 |
+
onMouseLeave={() => setHovered(false)}
|
| 93 |
+
title={isActive ? `Active (write): ${entry.repoKey}` : `Click to activate ${entry.repoKey}`}
|
| 94 |
+
>
|
| 95 |
+
{/* Active indicator bar */}
|
| 96 |
+
{isActive && <div className="ctxbar-chip-indicator" />}
|
| 97 |
+
|
| 98 |
+
{/* Repo name */}
|
| 99 |
+
<span className="ctxbar-chip-name">{displayName}</span>
|
| 100 |
+
|
| 101 |
+
{/* Separator dot */}
|
| 102 |
+
<span className="ctxbar-chip-dot" />
|
| 103 |
+
|
| 104 |
+
{/* Branch name — single click opens GitHub branch list */}
|
| 105 |
+
<button
|
| 106 |
+
ref={branchBtnRef}
|
| 107 |
+
type="button"
|
| 108 |
+
data-chip-action="branch"
|
| 109 |
+
className={"ctxbar-chip-branch" + (isActive ? " ctxbar-chip-branch-active" : "")}
|
| 110 |
+
onClick={(e) => {
|
| 111 |
+
e.stopPropagation();
|
| 112 |
+
setBranchOpen((v) => !v);
|
| 113 |
+
}}
|
| 114 |
+
>
|
| 115 |
+
{branch}
|
| 116 |
+
</button>
|
| 117 |
+
|
| 118 |
+
{/* Write badge for active repo */}
|
| 119 |
+
{isActive && <span className="ctxbar-chip-write">write</span>}
|
| 120 |
+
|
| 121 |
+
{/* Remove button: hidden by default, revealed on hover */}
|
| 122 |
+
<button
|
| 123 |
+
type="button"
|
| 124 |
+
data-chip-action="remove"
|
| 125 |
+
className={"ctxbar-chip-remove" + (hovered ? " ctxbar-chip-remove-visible" : "")}
|
| 126 |
+
onClick={(e) => {
|
| 127 |
+
e.stopPropagation();
|
| 128 |
+
onRemove();
|
| 129 |
+
}}
|
| 130 |
+
title={`Remove ${displayName} from context`}
|
| 131 |
+
>
|
| 132 |
+
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
| 133 |
+
<line x1="18" y1="6" x2="6" y2="18" />
|
| 134 |
+
<line x1="6" y1="6" x2="18" y2="18" />
|
| 135 |
+
</svg>
|
| 136 |
+
</button>
|
| 137 |
+
|
| 138 |
+
{/* BranchPicker in external-anchor mode: dropdown opens immediately,
|
| 139 |
+
positioned from the branch button, fetches all branches from GitHub */}
|
| 140 |
+
{branchOpen && (
|
| 141 |
+
<BranchPicker
|
| 142 |
+
repo={repo}
|
| 143 |
+
currentBranch={branch}
|
| 144 |
+
defaultBranch={defaultBranch}
|
| 145 |
+
sessionBranches={sessionBranches}
|
| 146 |
+
externalAnchorRef={branchBtnRef}
|
| 147 |
+
onBranchChange={(newBranch) => {
|
| 148 |
+
onBranchChange(newBranch);
|
| 149 |
+
setBranchOpen(false);
|
| 150 |
+
}}
|
| 151 |
+
onClose={() => setBranchOpen(false)}
|
| 152 |
+
/>
|
| 153 |
+
)}
|
| 154 |
+
</div>
|
| 155 |
+
);
|
| 156 |
+
}
|
frontend/components/ContextMeter.jsx
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/ContextMeter.jsx
|
| 2 |
+
//
|
| 3 |
+
// Small bottom-right control that shows the active LLM's context-window
|
| 4 |
+
// utilisation. Collapsed: a single ⓘ icon (no number — keeps the UI
|
| 5 |
+
// quiet during normal use). Expanded: a compact popover with the
|
| 6 |
+
// breakdown, topology line, and a manual refresh button.
|
| 7 |
+
//
|
| 8 |
+
// Refresh model: lazy — fetched only when the popover opens, plus the
|
| 9 |
+
// explicit ↻ button. Zero idle traffic.
|
| 10 |
+
//
|
| 11 |
+
// Token-count estimate flag: when the backend reports is_estimate=true
|
| 12 |
+
// (Ollama / OllaBridge — no real tokenizer available) every number is
|
| 13 |
+
// prefixed with ≈ so the imprecision is visible.
|
| 14 |
+
//
|
| 15 |
+
// Colours: GitPilot orange #D95C3D for ≥60% (warning), red #B91C1C for
|
| 16 |
+
// ≥85% (saturated). No new dependencies; inline styles + a scoped
|
| 17 |
+
// <style> block for animations / focus rings.
|
| 18 |
+
|
| 19 |
+
import React, { useEffect, useRef, useState } from "react";
|
| 20 |
+
|
| 21 |
+
const GITPILOT_ORANGE = "#D95C3D";
|
| 22 |
+
const SATURATED_RED = "#B91C1C";
|
| 23 |
+
const DIM = "#9aa0b4";
|
| 24 |
+
const SLATE = "#6b7280";
|
| 25 |
+
|
| 26 |
+
const fmt = (n) => {
|
| 27 |
+
if (n == null) return "—";
|
| 28 |
+
return new Intl.NumberFormat("en-US").format(n);
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const pct = (used, total) => {
|
| 32 |
+
if (!total) return 0;
|
| 33 |
+
return Math.max(0, Math.min(100, (100 * used) / total));
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const colourFor = (percent) => {
|
| 37 |
+
if (percent >= 85) return SATURATED_RED;
|
| 38 |
+
if (percent >= 60) return GITPILOT_ORANGE;
|
| 39 |
+
return SLATE;
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
function Bar({ percent, colour }) {
|
| 43 |
+
// 16-segment monochrome bar, matching the ASCII design.
|
| 44 |
+
const filled = Math.round((percent / 100) * 16);
|
| 45 |
+
const segs = [];
|
| 46 |
+
for (let i = 0; i < 16; i++) {
|
| 47 |
+
segs.push(
|
| 48 |
+
<span
|
| 49 |
+
key={i}
|
| 50 |
+
aria-hidden="true"
|
| 51 |
+
style={{
|
| 52 |
+
display: "inline-block",
|
| 53 |
+
width: 6,
|
| 54 |
+
height: 8,
|
| 55 |
+
marginRight: 1,
|
| 56 |
+
background: i < filled ? colour : "rgba(255,255,255,0.08)",
|
| 57 |
+
borderRadius: 1,
|
| 58 |
+
}}
|
| 59 |
+
/>,
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
return (
|
| 63 |
+
<span style={{ display: "inline-flex", alignItems: "center", lineHeight: 1 }}>
|
| 64 |
+
{segs}
|
| 65 |
+
</span>
|
| 66 |
+
);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function Row({ label, tokens, total, estimate, accent }) {
|
| 70 |
+
const p = pct(tokens, total);
|
| 71 |
+
const prefix = estimate ? "≈ " : "";
|
| 72 |
+
return (
|
| 73 |
+
<div
|
| 74 |
+
style={{
|
| 75 |
+
display: "grid",
|
| 76 |
+
gridTemplateColumns: "1fr auto auto",
|
| 77 |
+
gap: 12,
|
| 78 |
+
padding: "4px 0",
|
| 79 |
+
fontSize: 12,
|
| 80 |
+
color: accent ? "#e5e7eb" : DIM,
|
| 81 |
+
fontVariantNumeric: "tabular-nums",
|
| 82 |
+
}}
|
| 83 |
+
>
|
| 84 |
+
<span>{label}</span>
|
| 85 |
+
<span style={{ color: accent ? "#e5e7eb" : "#cbd1e3" }}>
|
| 86 |
+
{prefix}
|
| 87 |
+
{fmt(tokens)}
|
| 88 |
+
</span>
|
| 89 |
+
<span style={{ width: 48, textAlign: "right" }}>{p.toFixed(1)}%</span>
|
| 90 |
+
</div>
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
export default function ContextMeter({ sessionId = null }) {
|
| 95 |
+
const [open, setOpen] = useState(false);
|
| 96 |
+
const [data, setData] = useState(null);
|
| 97 |
+
const [loading, setLoading] = useState(false);
|
| 98 |
+
const [error, setError] = useState(null);
|
| 99 |
+
const popoverRef = useRef(null);
|
| 100 |
+
const triggerRef = useRef(null);
|
| 101 |
+
|
| 102 |
+
const fetchUsage = async () => {
|
| 103 |
+
setLoading(true);
|
| 104 |
+
setError(null);
|
| 105 |
+
try {
|
| 106 |
+
const qs = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : "";
|
| 107 |
+
const r = await fetch(`/api/context/usage${qs}`);
|
| 108 |
+
if (!r.ok) {
|
| 109 |
+
// 404 means the feature flag is off — render nothing in that case.
|
| 110 |
+
if (r.status === 404) {
|
| 111 |
+
setError("disabled");
|
| 112 |
+
setData(null);
|
| 113 |
+
} else {
|
| 114 |
+
setError(`http ${r.status}`);
|
| 115 |
+
}
|
| 116 |
+
} else {
|
| 117 |
+
setData(await r.json());
|
| 118 |
+
}
|
| 119 |
+
} catch (e) {
|
| 120 |
+
setError(String(e?.message || e));
|
| 121 |
+
} finally {
|
| 122 |
+
setLoading(false);
|
| 123 |
+
}
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
+
// Refetch every time the popover opens so the user sees the *current*
|
| 127 |
+
// numbers after each plan/execute cycle — not a frozen snapshot from
|
| 128 |
+
// first open. The endpoint is cheap (single-digit-ms after the first
|
| 129 |
+
// provider probe), so re-fetch-on-open is the honest default.
|
| 130 |
+
useEffect(() => {
|
| 131 |
+
if (open) {
|
| 132 |
+
fetchUsage();
|
| 133 |
+
}
|
| 134 |
+
}, [open, sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 135 |
+
|
| 136 |
+
// Invalidate the displayed snapshot when the active session changes
|
| 137 |
+
// so we don't briefly show another session's numbers.
|
| 138 |
+
useEffect(() => {
|
| 139 |
+
setData(null);
|
| 140 |
+
}, [sessionId]);
|
| 141 |
+
|
| 142 |
+
// Click-outside + Esc to close.
|
| 143 |
+
useEffect(() => {
|
| 144 |
+
if (!open) return;
|
| 145 |
+
const onDocClick = (e) => {
|
| 146 |
+
if (
|
| 147 |
+
popoverRef.current &&
|
| 148 |
+
!popoverRef.current.contains(e.target) &&
|
| 149 |
+
triggerRef.current &&
|
| 150 |
+
!triggerRef.current.contains(e.target)
|
| 151 |
+
) {
|
| 152 |
+
setOpen(false);
|
| 153 |
+
}
|
| 154 |
+
};
|
| 155 |
+
const onKey = (e) => {
|
| 156 |
+
if (e.key === "Escape") setOpen(false);
|
| 157 |
+
};
|
| 158 |
+
document.addEventListener("mousedown", onDocClick);
|
| 159 |
+
document.addEventListener("keydown", onKey);
|
| 160 |
+
return () => {
|
| 161 |
+
document.removeEventListener("mousedown", onDocClick);
|
| 162 |
+
document.removeEventListener("keydown", onKey);
|
| 163 |
+
};
|
| 164 |
+
}, [open]);
|
| 165 |
+
|
| 166 |
+
// Feature flag off — render nothing.
|
| 167 |
+
if (error === "disabled") return null;
|
| 168 |
+
|
| 169 |
+
const percent = data ? data.percent_used : 0;
|
| 170 |
+
const bar = colourFor(percent);
|
| 171 |
+
const estimate = data?.is_estimate;
|
| 172 |
+
const prefix = estimate ? "≈ " : "";
|
| 173 |
+
|
| 174 |
+
return (
|
| 175 |
+
<span
|
| 176 |
+
className="gitpilot-ctx-meter"
|
| 177 |
+
style={{ position: "relative", display: "inline-flex" }}
|
| 178 |
+
>
|
| 179 |
+
<style>{`
|
| 180 |
+
.gitpilot-ctx-meter .ctx-trigger {
|
| 181 |
+
background: transparent;
|
| 182 |
+
border: 1px solid rgba(255,255,255,0.12);
|
| 183 |
+
color: ${DIM};
|
| 184 |
+
width: 22px;
|
| 185 |
+
height: 22px;
|
| 186 |
+
border-radius: 11px;
|
| 187 |
+
display: inline-flex;
|
| 188 |
+
align-items: center;
|
| 189 |
+
justify-content: center;
|
| 190 |
+
font-size: 12px;
|
| 191 |
+
line-height: 1;
|
| 192 |
+
cursor: pointer;
|
| 193 |
+
padding: 0;
|
| 194 |
+
transition: color 120ms ease, border-color 120ms ease;
|
| 195 |
+
}
|
| 196 |
+
.gitpilot-ctx-meter .ctx-trigger:hover,
|
| 197 |
+
.gitpilot-ctx-meter .ctx-trigger:focus-visible {
|
| 198 |
+
color: #e5e7eb;
|
| 199 |
+
border-color: rgba(255,255,255,0.28);
|
| 200 |
+
outline: none;
|
| 201 |
+
}
|
| 202 |
+
.gitpilot-ctx-meter .ctx-trigger[data-warn="1"] { color: ${GITPILOT_ORANGE}; border-color: ${GITPILOT_ORANGE}55; }
|
| 203 |
+
.gitpilot-ctx-meter .ctx-trigger[data-sat="1"] { color: ${SATURATED_RED}; border-color: ${SATURATED_RED}55; }
|
| 204 |
+
.gitpilot-ctx-meter .ctx-popover {
|
| 205 |
+
position: absolute;
|
| 206 |
+
right: 0;
|
| 207 |
+
bottom: calc(100% + 8px);
|
| 208 |
+
width: 360px;
|
| 209 |
+
background: #1a1c25;
|
| 210 |
+
border: 1px solid rgba(255,255,255,0.10);
|
| 211 |
+
border-radius: 8px;
|
| 212 |
+
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
|
| 213 |
+
padding: 14px 16px;
|
| 214 |
+
z-index: 50;
|
| 215 |
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
| 216 |
+
}
|
| 217 |
+
.gitpilot-ctx-meter .ctx-popover h4 {
|
| 218 |
+
margin: 0 0 10px 0;
|
| 219 |
+
font-size: 12px;
|
| 220 |
+
font-weight: 600;
|
| 221 |
+
letter-spacing: 0.04em;
|
| 222 |
+
text-transform: uppercase;
|
| 223 |
+
color: ${DIM};
|
| 224 |
+
}
|
| 225 |
+
.gitpilot-ctx-meter .ctx-meta {
|
| 226 |
+
display: grid;
|
| 227 |
+
grid-template-columns: 84px 1fr;
|
| 228 |
+
gap: 2px 12px;
|
| 229 |
+
font-size: 12px;
|
| 230 |
+
color: #cbd1e3;
|
| 231 |
+
margin-bottom: 12px;
|
| 232 |
+
font-variant-numeric: tabular-nums;
|
| 233 |
+
}
|
| 234 |
+
.gitpilot-ctx-meter .ctx-meta .k { color: ${DIM}; }
|
| 235 |
+
.gitpilot-ctx-meter .ctx-divider {
|
| 236 |
+
height: 1px;
|
| 237 |
+
background: rgba(255,255,255,0.08);
|
| 238 |
+
margin: 6px 0;
|
| 239 |
+
}
|
| 240 |
+
.gitpilot-ctx-meter .ctx-footer {
|
| 241 |
+
display: flex;
|
| 242 |
+
justify-content: space-between;
|
| 243 |
+
align-items: center;
|
| 244 |
+
margin-top: 10px;
|
| 245 |
+
font-size: 11px;
|
| 246 |
+
color: ${DIM};
|
| 247 |
+
}
|
| 248 |
+
.gitpilot-ctx-meter .ctx-refresh {
|
| 249 |
+
background: transparent;
|
| 250 |
+
border: 1px solid rgba(255,255,255,0.14);
|
| 251 |
+
color: #cbd1e3;
|
| 252 |
+
font-size: 11px;
|
| 253 |
+
padding: 2px 8px;
|
| 254 |
+
border-radius: 4px;
|
| 255 |
+
cursor: pointer;
|
| 256 |
+
}
|
| 257 |
+
.gitpilot-ctx-meter .ctx-refresh:hover { color: #fff; border-color: rgba(255,255,255,0.3); }
|
| 258 |
+
.gitpilot-ctx-meter .ctx-refresh:disabled { opacity: 0.5; cursor: default; }
|
| 259 |
+
.gitpilot-ctx-meter .ctx-warn {
|
| 260 |
+
margin-top: 10px;
|
| 261 |
+
padding: 8px 10px;
|
| 262 |
+
border: 1px solid ${GITPILOT_ORANGE}55;
|
| 263 |
+
background: ${GITPILOT_ORANGE}14;
|
| 264 |
+
color: ${GITPILOT_ORANGE};
|
| 265 |
+
border-radius: 4px;
|
| 266 |
+
font-size: 11px;
|
| 267 |
+
line-height: 1.5;
|
| 268 |
+
}
|
| 269 |
+
.gitpilot-ctx-meter .ctx-warn[data-sat="1"] {
|
| 270 |
+
border-color: ${SATURATED_RED}66;
|
| 271 |
+
background: ${SATURATED_RED}14;
|
| 272 |
+
color: ${SATURATED_RED};
|
| 273 |
+
}
|
| 274 |
+
.gitpilot-ctx-meter .ctx-warn ul { margin: 4px 0 0 18px; padding: 0; }
|
| 275 |
+
`}</style>
|
| 276 |
+
|
| 277 |
+
<button
|
| 278 |
+
ref={triggerRef}
|
| 279 |
+
type="button"
|
| 280 |
+
className="ctx-trigger"
|
| 281 |
+
aria-label="Context window usage"
|
| 282 |
+
aria-haspopup="dialog"
|
| 283 |
+
aria-expanded={open}
|
| 284 |
+
data-warn={data && percent >= 60 && percent < 85 ? "1" : "0"}
|
| 285 |
+
data-sat={data && percent >= 85 ? "1" : "0"}
|
| 286 |
+
onClick={() => setOpen((v) => !v)}
|
| 287 |
+
title="Context window usage"
|
| 288 |
+
>
|
| 289 |
+
{"ⓘ"}
|
| 290 |
+
</button>
|
| 291 |
+
|
| 292 |
+
{open && (
|
| 293 |
+
<div
|
| 294 |
+
ref={popoverRef}
|
| 295 |
+
className="ctx-popover"
|
| 296 |
+
role="dialog"
|
| 297 |
+
aria-label="Context window usage details"
|
| 298 |
+
>
|
| 299 |
+
<h4>Context window</h4>
|
| 300 |
+
|
| 301 |
+
{loading && !data && (
|
| 302 |
+
<div style={{ color: DIM, fontSize: 12 }}>Loading…</div>
|
| 303 |
+
)}
|
| 304 |
+
{error && error !== "disabled" && (
|
| 305 |
+
<div style={{ color: "#ffb3b7", fontSize: 12 }}>
|
| 306 |
+
Couldn't load: {error}
|
| 307 |
+
</div>
|
| 308 |
+
)}
|
| 309 |
+
|
| 310 |
+
{data && (
|
| 311 |
+
<>
|
| 312 |
+
<div className="ctx-meta">
|
| 313 |
+
<span className="k">Provider</span>
|
| 314 |
+
<span>{data.provider}</span>
|
| 315 |
+
<span className="k">Model</span>
|
| 316 |
+
<span>{data.model || "—"}</span>
|
| 317 |
+
<span className="k">Topology</span>
|
| 318 |
+
<span>{data.topology}</span>
|
| 319 |
+
</div>
|
| 320 |
+
|
| 321 |
+
<div
|
| 322 |
+
style={{
|
| 323 |
+
display: "flex",
|
| 324 |
+
justifyContent: "space-between",
|
| 325 |
+
alignItems: "center",
|
| 326 |
+
fontSize: 12,
|
| 327 |
+
color: "#cbd1e3",
|
| 328 |
+
fontVariantNumeric: "tabular-nums",
|
| 329 |
+
marginBottom: 8,
|
| 330 |
+
}}
|
| 331 |
+
>
|
| 332 |
+
<Bar percent={percent} colour={bar} />
|
| 333 |
+
<span>
|
| 334 |
+
{prefix}
|
| 335 |
+
{fmt(data.used)} / {fmt(data.context_window)}{" "}
|
| 336 |
+
<span style={{ color: bar }}>({percent.toFixed(1)}%)</span>
|
| 337 |
+
</span>
|
| 338 |
+
</div>
|
| 339 |
+
|
| 340 |
+
<Row
|
| 341 |
+
label="Conversation messages"
|
| 342 |
+
tokens={data.breakdown?.messages || 0}
|
| 343 |
+
total={data.context_window}
|
| 344 |
+
estimate={estimate}
|
| 345 |
+
/>
|
| 346 |
+
<Row
|
| 347 |
+
label="Planner system prompt"
|
| 348 |
+
tokens={data.breakdown?.system_prompt || 0}
|
| 349 |
+
total={data.context_window}
|
| 350 |
+
estimate={estimate}
|
| 351 |
+
/>
|
| 352 |
+
<Row
|
| 353 |
+
label="Repo context summary"
|
| 354 |
+
tokens={data.breakdown?.repo_context || 0}
|
| 355 |
+
total={data.context_window}
|
| 356 |
+
estimate={estimate}
|
| 357 |
+
/>
|
| 358 |
+
<Row
|
| 359 |
+
label={`Tool schemas (${data.tool_count || 0})`}
|
| 360 |
+
tokens={data.breakdown?.tool_schemas || 0}
|
| 361 |
+
total={data.context_window}
|
| 362 |
+
estimate={estimate}
|
| 363 |
+
/>
|
| 364 |
+
<Row
|
| 365 |
+
label="Reserved for response"
|
| 366 |
+
tokens={data.reserved_response}
|
| 367 |
+
total={data.context_window}
|
| 368 |
+
estimate={false}
|
| 369 |
+
/>
|
| 370 |
+
|
| 371 |
+
<div className="ctx-divider" />
|
| 372 |
+
|
| 373 |
+
<Row
|
| 374 |
+
label="Free space"
|
| 375 |
+
tokens={data.free}
|
| 376 |
+
total={data.context_window}
|
| 377 |
+
estimate={estimate}
|
| 378 |
+
accent
|
| 379 |
+
/>
|
| 380 |
+
|
| 381 |
+
{percent >= 85 && (
|
| 382 |
+
<div className="ctx-warn" data-sat={percent >= 95 ? "1" : "0"}>
|
| 383 |
+
Context near saturation. Consider:
|
| 384 |
+
<ul>
|
| 385 |
+
<li>Resetting the conversation</li>
|
| 386 |
+
<li>Switching to a larger-context model</li>
|
| 387 |
+
<li>Reducing repository scope</li>
|
| 388 |
+
</ul>
|
| 389 |
+
</div>
|
| 390 |
+
)}
|
| 391 |
+
|
| 392 |
+
<div className="ctx-footer">
|
| 393 |
+
<span>{estimate ? "Token counts are estimated" : "Token counts via tiktoken"}</span>
|
| 394 |
+
<button
|
| 395 |
+
type="button"
|
| 396 |
+
className="ctx-refresh"
|
| 397 |
+
onClick={fetchUsage}
|
| 398 |
+
disabled={loading}
|
| 399 |
+
aria-label="Refresh context usage"
|
| 400 |
+
>
|
| 401 |
+
{loading ? "…" : "↻ refresh"}
|
| 402 |
+
</button>
|
| 403 |
+
</div>
|
| 404 |
+
</>
|
| 405 |
+
)}
|
| 406 |
+
</div>
|
| 407 |
+
)}
|
| 408 |
+
</span>
|
| 409 |
+
);
|
| 410 |
+
}
|
frontend/components/CreatePRButton.jsx
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from "react";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* CreatePRButton — Claude-Code-on-Web parity PR creation action.
|
| 5 |
+
*
|
| 6 |
+
* When clicked, pushes session changes to a new branch and opens a PR.
|
| 7 |
+
* Shows loading state and links to the created PR on GitHub.
|
| 8 |
+
*/
|
| 9 |
+
export default function CreatePRButton({
|
| 10 |
+
repo,
|
| 11 |
+
sessionId,
|
| 12 |
+
branch,
|
| 13 |
+
defaultBranch,
|
| 14 |
+
disabled,
|
| 15 |
+
onPRCreated,
|
| 16 |
+
}) {
|
| 17 |
+
const [creating, setCreating] = useState(false);
|
| 18 |
+
const [prUrl, setPrUrl] = useState(null);
|
| 19 |
+
const [error, setError] = useState(null);
|
| 20 |
+
|
| 21 |
+
const handleCreate = async () => {
|
| 22 |
+
if (!repo || !branch || branch === defaultBranch) return;
|
| 23 |
+
|
| 24 |
+
setCreating(true);
|
| 25 |
+
setError(null);
|
| 26 |
+
|
| 27 |
+
try {
|
| 28 |
+
const token = localStorage.getItem("github_token");
|
| 29 |
+
const headers = {
|
| 30 |
+
"Content-Type": "application/json",
|
| 31 |
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const owner = repo.full_name?.split("/")[0] || repo.owner;
|
| 35 |
+
const name = repo.full_name?.split("/")[1] || repo.name;
|
| 36 |
+
|
| 37 |
+
const res = await fetch(`/api/repos/${owner}/${name}/pulls`, {
|
| 38 |
+
method: "POST",
|
| 39 |
+
headers,
|
| 40 |
+
body: JSON.stringify({
|
| 41 |
+
title: `[GitPilot] Changes from session ${sessionId ? sessionId.slice(0, 8) : branch}`,
|
| 42 |
+
head: branch,
|
| 43 |
+
base: defaultBranch || "main",
|
| 44 |
+
body: [
|
| 45 |
+
"## Summary",
|
| 46 |
+
"",
|
| 47 |
+
`Changes created by GitPilot AI assistant on branch \`${branch}\`.`,
|
| 48 |
+
"",
|
| 49 |
+
sessionId ? `Session ID: \`${sessionId}\`` : "",
|
| 50 |
+
"",
|
| 51 |
+
"---",
|
| 52 |
+
"*This PR was generated by [GitPilot](https://github.com/ruslanmv/gitpilot).*",
|
| 53 |
+
]
|
| 54 |
+
.filter(Boolean)
|
| 55 |
+
.join("\n"),
|
| 56 |
+
}),
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
const data = await res.json();
|
| 60 |
+
if (!res.ok) throw new Error(data.detail || "Failed to create PR");
|
| 61 |
+
|
| 62 |
+
const url = data.html_url || data.url;
|
| 63 |
+
setPrUrl(url);
|
| 64 |
+
onPRCreated?.({ pr_url: url, pr_number: data.number, branch });
|
| 65 |
+
} catch (err) {
|
| 66 |
+
setError(err.message);
|
| 67 |
+
} finally {
|
| 68 |
+
setCreating(false);
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
if (prUrl) {
|
| 73 |
+
return (
|
| 74 |
+
<a
|
| 75 |
+
href={prUrl}
|
| 76 |
+
target="_blank"
|
| 77 |
+
rel="noreferrer"
|
| 78 |
+
style={styles.prLink}
|
| 79 |
+
>
|
| 80 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 81 |
+
<circle cx="18" cy="18" r="3" />
|
| 82 |
+
<circle cx="6" cy="6" r="3" />
|
| 83 |
+
<path d="M13 6h3a2 2 0 0 1 2 2v7" />
|
| 84 |
+
<line x1="6" y1="9" x2="6" y2="21" />
|
| 85 |
+
</svg>
|
| 86 |
+
View PR on GitHub →
|
| 87 |
+
</a>
|
| 88 |
+
);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
return (
|
| 92 |
+
<div>
|
| 93 |
+
<button
|
| 94 |
+
type="button"
|
| 95 |
+
style={{
|
| 96 |
+
...styles.btn,
|
| 97 |
+
opacity: disabled || creating ? 0.55 : 1,
|
| 98 |
+
cursor: disabled || creating ? "not-allowed" : "pointer",
|
| 99 |
+
}}
|
| 100 |
+
onClick={handleCreate}
|
| 101 |
+
disabled={disabled || creating || !branch || branch === defaultBranch}
|
| 102 |
+
title={
|
| 103 |
+
!branch || branch === defaultBranch
|
| 104 |
+
? "Create a session branch first"
|
| 105 |
+
: "Create a pull request from session changes"
|
| 106 |
+
}
|
| 107 |
+
>
|
| 108 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 109 |
+
<circle cx="18" cy="18" r="3" />
|
| 110 |
+
<circle cx="6" cy="6" r="3" />
|
| 111 |
+
<path d="M13 6h3a2 2 0 0 1 2 2v7" />
|
| 112 |
+
<line x1="6" y1="9" x2="6" y2="21" />
|
| 113 |
+
</svg>
|
| 114 |
+
{creating ? "Creating PR..." : "Create PR"}
|
| 115 |
+
</button>
|
| 116 |
+
{error && (
|
| 117 |
+
<div style={styles.error}>{error}</div>
|
| 118 |
+
)}
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
const styles = {
|
| 124 |
+
btn: {
|
| 125 |
+
display: "flex",
|
| 126 |
+
alignItems: "center",
|
| 127 |
+
gap: 6,
|
| 128 |
+
height: 38,
|
| 129 |
+
padding: "0 14px",
|
| 130 |
+
borderRadius: 8,
|
| 131 |
+
border: "1px solid rgba(16, 185, 129, 0.3)",
|
| 132 |
+
background: "rgba(16, 185, 129, 0.08)",
|
| 133 |
+
color: "#10B981",
|
| 134 |
+
fontSize: 13,
|
| 135 |
+
fontWeight: 600,
|
| 136 |
+
cursor: "pointer",
|
| 137 |
+
whiteSpace: "nowrap",
|
| 138 |
+
transition: "background-color 0.15s",
|
| 139 |
+
},
|
| 140 |
+
prLink: {
|
| 141 |
+
display: "flex",
|
| 142 |
+
alignItems: "center",
|
| 143 |
+
gap: 6,
|
| 144 |
+
height: 38,
|
| 145 |
+
padding: "0 14px",
|
| 146 |
+
borderRadius: 8,
|
| 147 |
+
background: "rgba(16, 185, 129, 0.10)",
|
| 148 |
+
color: "#10B981",
|
| 149 |
+
fontSize: 13,
|
| 150 |
+
fontWeight: 600,
|
| 151 |
+
textDecoration: "none",
|
| 152 |
+
whiteSpace: "nowrap",
|
| 153 |
+
},
|
| 154 |
+
error: {
|
| 155 |
+
fontSize: 11,
|
| 156 |
+
color: "#EF4444",
|
| 157 |
+
marginTop: 4,
|
| 158 |
+
},
|
| 159 |
+
};
|
frontend/components/DiffStats.jsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* DiffStats — Claude-Code-on-Web parity inline diff indicator.
|
| 5 |
+
*
|
| 6 |
+
* Clickable "+N -N in M files" badge that appears in agent messages.
|
| 7 |
+
* Clicking opens the DiffViewer overlay.
|
| 8 |
+
*/
|
| 9 |
+
export default function DiffStats({ diff, onClick }) {
|
| 10 |
+
if (!diff || (!diff.additions && !diff.deletions && !diff.files_changed)) {
|
| 11 |
+
return null;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<button type="button" style={styles.container} onClick={onClick}>
|
| 16 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 17 |
+
<path d="M12 3v18M3 12h18" opacity="0.3" />
|
| 18 |
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
| 19 |
+
</svg>
|
| 20 |
+
<span style={styles.additions}>+{diff.additions || 0}</span>
|
| 21 |
+
<span style={styles.deletions}>-{diff.deletions || 0}</span>
|
| 22 |
+
<span style={styles.files}>
|
| 23 |
+
in {diff.files_changed || (diff.files || []).length} file{(diff.files_changed || (diff.files || []).length) !== 1 ? "s" : ""}
|
| 24 |
+
</span>
|
| 25 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ opacity: 0.5 }}>
|
| 26 |
+
<polyline points="9 18 15 12 9 6" />
|
| 27 |
+
</svg>
|
| 28 |
+
</button>
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const styles = {
|
| 33 |
+
container: {
|
| 34 |
+
display: "inline-flex",
|
| 35 |
+
alignItems: "center",
|
| 36 |
+
gap: 6,
|
| 37 |
+
padding: "5px 10px",
|
| 38 |
+
borderRadius: 6,
|
| 39 |
+
border: "1px solid #27272A",
|
| 40 |
+
backgroundColor: "rgba(24, 24, 27, 0.8)",
|
| 41 |
+
cursor: "pointer",
|
| 42 |
+
fontSize: 12,
|
| 43 |
+
fontFamily: "monospace",
|
| 44 |
+
color: "#A1A1AA",
|
| 45 |
+
transition: "border-color 0.15s, background-color 0.15s",
|
| 46 |
+
marginTop: 8,
|
| 47 |
+
},
|
| 48 |
+
additions: {
|
| 49 |
+
color: "#10B981",
|
| 50 |
+
fontWeight: 600,
|
| 51 |
+
},
|
| 52 |
+
deletions: {
|
| 53 |
+
color: "#EF4444",
|
| 54 |
+
fontWeight: 600,
|
| 55 |
+
},
|
| 56 |
+
files: {
|
| 57 |
+
color: "#71717A",
|
| 58 |
+
},
|
| 59 |
+
};
|
frontend/components/DiffViewer.jsx
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from "react";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* DiffViewer — Claude-Code-on-Web parity diff overlay.
|
| 5 |
+
*
|
| 6 |
+
* Shows a file list on the left and unified diff on the right.
|
| 7 |
+
* Green = additions, red = deletions. Additive component.
|
| 8 |
+
*/
|
| 9 |
+
export default function DiffViewer({ diff, onClose }) {
|
| 10 |
+
const [selectedFile, setSelectedFile] = useState(0);
|
| 11 |
+
|
| 12 |
+
if (!diff || !diff.files || diff.files.length === 0) {
|
| 13 |
+
return (
|
| 14 |
+
<div style={styles.overlay}>
|
| 15 |
+
<div style={styles.panel}>
|
| 16 |
+
<div style={styles.header}>
|
| 17 |
+
<span style={styles.headerTitle}>Diff Viewer</span>
|
| 18 |
+
<button type="button" style={styles.closeBtn} onClick={onClose}>
|
| 19 |
+
×
|
| 20 |
+
</button>
|
| 21 |
+
</div>
|
| 22 |
+
<div style={styles.emptyState}>No changes to display.</div>
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const files = diff.files || [];
|
| 29 |
+
const currentFile = files[selectedFile] || files[0];
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<div style={styles.overlay}>
|
| 33 |
+
<div style={styles.panel}>
|
| 34 |
+
{/* Header */}
|
| 35 |
+
<div style={styles.header}>
|
| 36 |
+
<div style={styles.headerLeft}>
|
| 37 |
+
<span style={styles.headerTitle}>Diff Viewer</span>
|
| 38 |
+
<span style={styles.statBadge}>
|
| 39 |
+
<span style={{ color: "#10B981" }}>+{diff.additions || 0}</span>
|
| 40 |
+
{" "}
|
| 41 |
+
<span style={{ color: "#EF4444" }}>-{diff.deletions || 0}</span>
|
| 42 |
+
{" in "}
|
| 43 |
+
{diff.files_changed || files.length} files
|
| 44 |
+
</span>
|
| 45 |
+
</div>
|
| 46 |
+
<button type="button" style={styles.closeBtn} onClick={onClose}>
|
| 47 |
+
×
|
| 48 |
+
</button>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
{/* Body */}
|
| 52 |
+
<div style={styles.body}>
|
| 53 |
+
{/* File list */}
|
| 54 |
+
<div style={styles.fileList}>
|
| 55 |
+
{files.map((f, idx) => (
|
| 56 |
+
<div
|
| 57 |
+
key={f.path}
|
| 58 |
+
style={{
|
| 59 |
+
...styles.fileItem,
|
| 60 |
+
backgroundColor:
|
| 61 |
+
idx === selectedFile ? "rgba(59, 130, 246, 0.10)" : "transparent",
|
| 62 |
+
borderLeft:
|
| 63 |
+
idx === selectedFile
|
| 64 |
+
? "2px solid #3B82F6"
|
| 65 |
+
: "2px solid transparent",
|
| 66 |
+
}}
|
| 67 |
+
onClick={() => setSelectedFile(idx)}
|
| 68 |
+
>
|
| 69 |
+
<span style={styles.fileName}>{f.path}</span>
|
| 70 |
+
<span style={styles.fileStats}>
|
| 71 |
+
<span style={{ color: "#10B981" }}>+{f.additions || 0}</span>
|
| 72 |
+
{" "}
|
| 73 |
+
<span style={{ color: "#EF4444" }}>-{f.deletions || 0}</span>
|
| 74 |
+
</span>
|
| 75 |
+
</div>
|
| 76 |
+
))}
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
{/* Diff content */}
|
| 80 |
+
<div style={styles.diffContent}>
|
| 81 |
+
<div style={styles.diffPath}>{currentFile.path}</div>
|
| 82 |
+
<div style={styles.diffCode}>
|
| 83 |
+
{(currentFile.hunks || []).map((hunk, hi) => (
|
| 84 |
+
<div key={hi}>
|
| 85 |
+
<div style={styles.hunkHeader}>{hunk.header || `@@ hunk ${hi + 1} @@`}</div>
|
| 86 |
+
{(hunk.lines || []).map((line, li) => {
|
| 87 |
+
let bg = "transparent";
|
| 88 |
+
let color = "#D4D4D8";
|
| 89 |
+
if (line.startsWith("+")) {
|
| 90 |
+
bg = "rgba(16, 185, 129, 0.10)";
|
| 91 |
+
color = "#6EE7B7";
|
| 92 |
+
} else if (line.startsWith("-")) {
|
| 93 |
+
bg = "rgba(239, 68, 68, 0.10)";
|
| 94 |
+
color = "#FCA5A5";
|
| 95 |
+
}
|
| 96 |
+
return (
|
| 97 |
+
<div
|
| 98 |
+
key={li}
|
| 99 |
+
style={{
|
| 100 |
+
...styles.diffLine,
|
| 101 |
+
backgroundColor: bg,
|
| 102 |
+
color,
|
| 103 |
+
}}
|
| 104 |
+
>
|
| 105 |
+
{line}
|
| 106 |
+
</div>
|
| 107 |
+
);
|
| 108 |
+
})}
|
| 109 |
+
</div>
|
| 110 |
+
))}
|
| 111 |
+
|
| 112 |
+
{(!currentFile.hunks || currentFile.hunks.length === 0) && (
|
| 113 |
+
<div style={styles.diffPlaceholder}>
|
| 114 |
+
Diff content will appear here when the agent modifies files.
|
| 115 |
+
</div>
|
| 116 |
+
)}
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
const styles = {
|
| 126 |
+
overlay: {
|
| 127 |
+
position: "fixed",
|
| 128 |
+
top: 0,
|
| 129 |
+
left: 0,
|
| 130 |
+
right: 0,
|
| 131 |
+
bottom: 0,
|
| 132 |
+
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
| 133 |
+
zIndex: 200,
|
| 134 |
+
display: "flex",
|
| 135 |
+
alignItems: "center",
|
| 136 |
+
justifyContent: "center",
|
| 137 |
+
},
|
| 138 |
+
panel: {
|
| 139 |
+
width: "90vw",
|
| 140 |
+
maxWidth: 1100,
|
| 141 |
+
height: "80vh",
|
| 142 |
+
backgroundColor: "#131316",
|
| 143 |
+
border: "1px solid #27272A",
|
| 144 |
+
borderRadius: 12,
|
| 145 |
+
display: "flex",
|
| 146 |
+
flexDirection: "column",
|
| 147 |
+
overflow: "hidden",
|
| 148 |
+
},
|
| 149 |
+
header: {
|
| 150 |
+
display: "flex",
|
| 151 |
+
justifyContent: "space-between",
|
| 152 |
+
alignItems: "center",
|
| 153 |
+
padding: "12px 16px",
|
| 154 |
+
borderBottom: "1px solid #27272A",
|
| 155 |
+
backgroundColor: "#18181B",
|
| 156 |
+
},
|
| 157 |
+
headerLeft: {
|
| 158 |
+
display: "flex",
|
| 159 |
+
alignItems: "center",
|
| 160 |
+
gap: 12,
|
| 161 |
+
},
|
| 162 |
+
headerTitle: {
|
| 163 |
+
fontSize: 14,
|
| 164 |
+
fontWeight: 600,
|
| 165 |
+
color: "#E4E4E7",
|
| 166 |
+
},
|
| 167 |
+
statBadge: {
|
| 168 |
+
fontSize: 12,
|
| 169 |
+
color: "#A1A1AA",
|
| 170 |
+
},
|
| 171 |
+
closeBtn: {
|
| 172 |
+
width: 28,
|
| 173 |
+
height: 28,
|
| 174 |
+
borderRadius: 6,
|
| 175 |
+
border: "1px solid #3F3F46",
|
| 176 |
+
background: "transparent",
|
| 177 |
+
color: "#A1A1AA",
|
| 178 |
+
fontSize: 18,
|
| 179 |
+
cursor: "pointer",
|
| 180 |
+
display: "flex",
|
| 181 |
+
alignItems: "center",
|
| 182 |
+
justifyContent: "center",
|
| 183 |
+
},
|
| 184 |
+
body: {
|
| 185 |
+
flex: 1,
|
| 186 |
+
display: "flex",
|
| 187 |
+
overflow: "hidden",
|
| 188 |
+
},
|
| 189 |
+
fileList: {
|
| 190 |
+
width: 240,
|
| 191 |
+
borderRight: "1px solid #27272A",
|
| 192 |
+
overflowY: "auto",
|
| 193 |
+
flexShrink: 0,
|
| 194 |
+
},
|
| 195 |
+
fileItem: {
|
| 196 |
+
padding: "8px 10px",
|
| 197 |
+
cursor: "pointer",
|
| 198 |
+
borderBottom: "1px solid rgba(39, 39, 42, 0.5)",
|
| 199 |
+
transition: "background-color 0.1s",
|
| 200 |
+
},
|
| 201 |
+
fileName: {
|
| 202 |
+
display: "block",
|
| 203 |
+
fontSize: 12,
|
| 204 |
+
fontFamily: "monospace",
|
| 205 |
+
color: "#E4E4E7",
|
| 206 |
+
whiteSpace: "nowrap",
|
| 207 |
+
overflow: "hidden",
|
| 208 |
+
textOverflow: "ellipsis",
|
| 209 |
+
},
|
| 210 |
+
fileStats: {
|
| 211 |
+
display: "block",
|
| 212 |
+
fontSize: 10,
|
| 213 |
+
marginTop: 2,
|
| 214 |
+
},
|
| 215 |
+
diffContent: {
|
| 216 |
+
flex: 1,
|
| 217 |
+
overflow: "auto",
|
| 218 |
+
display: "flex",
|
| 219 |
+
flexDirection: "column",
|
| 220 |
+
},
|
| 221 |
+
diffPath: {
|
| 222 |
+
padding: "8px 12px",
|
| 223 |
+
fontSize: 12,
|
| 224 |
+
fontFamily: "monospace",
|
| 225 |
+
color: "#A1A1AA",
|
| 226 |
+
borderBottom: "1px solid #27272A",
|
| 227 |
+
backgroundColor: "#18181B",
|
| 228 |
+
position: "sticky",
|
| 229 |
+
top: 0,
|
| 230 |
+
zIndex: 1,
|
| 231 |
+
},
|
| 232 |
+
diffCode: {
|
| 233 |
+
padding: "4px 0",
|
| 234 |
+
fontFamily: "monospace",
|
| 235 |
+
fontSize: 12,
|
| 236 |
+
lineHeight: 1.6,
|
| 237 |
+
},
|
| 238 |
+
hunkHeader: {
|
| 239 |
+
padding: "4px 12px",
|
| 240 |
+
color: "#6B7280",
|
| 241 |
+
backgroundColor: "rgba(59, 130, 246, 0.05)",
|
| 242 |
+
fontSize: 11,
|
| 243 |
+
fontStyle: "italic",
|
| 244 |
+
},
|
| 245 |
+
diffLine: {
|
| 246 |
+
padding: "0 12px",
|
| 247 |
+
whiteSpace: "pre",
|
| 248 |
+
},
|
| 249 |
+
diffPlaceholder: {
|
| 250 |
+
padding: 20,
|
| 251 |
+
textAlign: "center",
|
| 252 |
+
color: "#52525B",
|
| 253 |
+
fontSize: 13,
|
| 254 |
+
},
|
| 255 |
+
emptyState: {
|
| 256 |
+
flex: 1,
|
| 257 |
+
display: "flex",
|
| 258 |
+
alignItems: "center",
|
| 259 |
+
justifyContent: "center",
|
| 260 |
+
color: "#52525B",
|
| 261 |
+
fontSize: 14,
|
| 262 |
+
},
|
| 263 |
+
};
|
frontend/components/EnvironmentEditor.jsx
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from "react";
|
| 2 |
+
import { createPortal } from "react-dom";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* EnvironmentEditor — Claude-Code-on-Web parity environment config modal.
|
| 6 |
+
*
|
| 7 |
+
* Allows setting name, network access level, and environment variables.
|
| 8 |
+
*/
|
| 9 |
+
export default function EnvironmentEditor({ environment, onSave, onDelete, onClose }) {
|
| 10 |
+
const [name, setName] = useState(environment?.name || "");
|
| 11 |
+
const [networkAccess, setNetworkAccess] = useState(environment?.network_access || "limited");
|
| 12 |
+
const [envVarsText, setEnvVarsText] = useState(
|
| 13 |
+
environment?.env_vars
|
| 14 |
+
? Object.entries(environment.env_vars)
|
| 15 |
+
.map(([k, v]) => `${k}=${v}`)
|
| 16 |
+
.join("\n")
|
| 17 |
+
: ""
|
| 18 |
+
);
|
| 19 |
+
|
| 20 |
+
const handleSave = () => {
|
| 21 |
+
const envVars = {};
|
| 22 |
+
envVarsText
|
| 23 |
+
.split("\n")
|
| 24 |
+
.map((line) => line.trim())
|
| 25 |
+
.filter((line) => line && line.includes("="))
|
| 26 |
+
.forEach((line) => {
|
| 27 |
+
const idx = line.indexOf("=");
|
| 28 |
+
const key = line.slice(0, idx).trim();
|
| 29 |
+
const val = line.slice(idx + 1).trim();
|
| 30 |
+
if (key) envVars[key] = val;
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
onSave({
|
| 34 |
+
id: environment?.id || null,
|
| 35 |
+
name: name.trim() || "Default",
|
| 36 |
+
network_access: networkAccess,
|
| 37 |
+
env_vars: envVars,
|
| 38 |
+
});
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
return createPortal(
|
| 42 |
+
<div style={styles.overlay} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
| 43 |
+
<div style={styles.modal} onMouseDown={(e) => e.stopPropagation()}>
|
| 44 |
+
<div style={styles.header}>
|
| 45 |
+
<span style={styles.headerTitle}>
|
| 46 |
+
{environment?.id ? "Edit Environment" : "New Environment"}
|
| 47 |
+
</span>
|
| 48 |
+
<button type="button" style={styles.closeBtn} onClick={onClose}>
|
| 49 |
+
×
|
| 50 |
+
</button>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<div style={styles.body}>
|
| 54 |
+
{/* Name */}
|
| 55 |
+
<label style={styles.label}>Environment Name</label>
|
| 56 |
+
<input
|
| 57 |
+
type="text"
|
| 58 |
+
value={name}
|
| 59 |
+
onChange={(e) => setName(e.target.value)}
|
| 60 |
+
placeholder="e.g. Development, Staging, Production"
|
| 61 |
+
style={styles.input}
|
| 62 |
+
/>
|
| 63 |
+
|
| 64 |
+
{/* Network Access */}
|
| 65 |
+
<label style={styles.label}>Network Access</label>
|
| 66 |
+
<div style={styles.radioGroup}>
|
| 67 |
+
{[
|
| 68 |
+
{ value: "limited", label: "Limited", desc: "Allowlisted domains only (package managers, APIs)" },
|
| 69 |
+
{ value: "full", label: "Full", desc: "Unrestricted internet access" },
|
| 70 |
+
{ value: "none", label: "None", desc: "Air-gapped — no external network" },
|
| 71 |
+
].map((opt) => (
|
| 72 |
+
<label
|
| 73 |
+
key={opt.value}
|
| 74 |
+
style={{
|
| 75 |
+
...styles.radioItem,
|
| 76 |
+
borderColor:
|
| 77 |
+
networkAccess === opt.value ? "#3B82F6" : "#27272A",
|
| 78 |
+
backgroundColor:
|
| 79 |
+
networkAccess === opt.value
|
| 80 |
+
? "rgba(59, 130, 246, 0.05)"
|
| 81 |
+
: "transparent",
|
| 82 |
+
}}
|
| 83 |
+
>
|
| 84 |
+
<input
|
| 85 |
+
type="radio"
|
| 86 |
+
name="network"
|
| 87 |
+
value={opt.value}
|
| 88 |
+
checked={networkAccess === opt.value}
|
| 89 |
+
onChange={(e) => setNetworkAccess(e.target.value)}
|
| 90 |
+
style={{ display: "none" }}
|
| 91 |
+
/>
|
| 92 |
+
<div>
|
| 93 |
+
<div style={{
|
| 94 |
+
fontSize: 13,
|
| 95 |
+
fontWeight: 500,
|
| 96 |
+
color: networkAccess === opt.value ? "#E4E4E7" : "#A1A1AA",
|
| 97 |
+
}}>
|
| 98 |
+
{opt.label}
|
| 99 |
+
</div>
|
| 100 |
+
<div style={{ fontSize: 11, color: "#71717A", marginTop: 2 }}>
|
| 101 |
+
{opt.desc}
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</label>
|
| 105 |
+
))}
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
{/* Environment Variables */}
|
| 109 |
+
<label style={styles.label}>Environment Variables</label>
|
| 110 |
+
<textarea
|
| 111 |
+
value={envVarsText}
|
| 112 |
+
onChange={(e) => setEnvVarsText(e.target.value)}
|
| 113 |
+
placeholder={"NODE_ENV=development\nDEBUG=true\nAPI_KEY=your-key-here"}
|
| 114 |
+
rows={6}
|
| 115 |
+
style={styles.textarea}
|
| 116 |
+
/>
|
| 117 |
+
<div style={{ fontSize: 10, color: "#52525B", marginTop: 4 }}>
|
| 118 |
+
One KEY=VALUE per line. Secrets are stored locally.
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<div style={styles.footer}>
|
| 123 |
+
{onDelete && (
|
| 124 |
+
<button type="button" style={styles.deleteBtn} onClick={onDelete}>
|
| 125 |
+
Delete
|
| 126 |
+
</button>
|
| 127 |
+
)}
|
| 128 |
+
<div style={{ flex: 1 }} />
|
| 129 |
+
<button type="button" style={styles.cancelBtn} onClick={onClose}>
|
| 130 |
+
Cancel
|
| 131 |
+
</button>
|
| 132 |
+
<button type="button" style={styles.saveBtn} onClick={handleSave}>
|
| 133 |
+
Save
|
| 134 |
+
</button>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>,
|
| 138 |
+
document.body
|
| 139 |
+
);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
const styles = {
|
| 143 |
+
overlay: {
|
| 144 |
+
position: "fixed",
|
| 145 |
+
top: 0,
|
| 146 |
+
left: 0,
|
| 147 |
+
right: 0,
|
| 148 |
+
bottom: 0,
|
| 149 |
+
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
| 150 |
+
zIndex: 10000,
|
| 151 |
+
display: "flex",
|
| 152 |
+
alignItems: "center",
|
| 153 |
+
justifyContent: "center",
|
| 154 |
+
},
|
| 155 |
+
modal: {
|
| 156 |
+
width: 480,
|
| 157 |
+
maxHeight: "80vh",
|
| 158 |
+
backgroundColor: "#131316",
|
| 159 |
+
border: "1px solid #27272A",
|
| 160 |
+
borderRadius: 12,
|
| 161 |
+
display: "flex",
|
| 162 |
+
flexDirection: "column",
|
| 163 |
+
overflow: "hidden",
|
| 164 |
+
},
|
| 165 |
+
header: {
|
| 166 |
+
display: "flex",
|
| 167 |
+
justifyContent: "space-between",
|
| 168 |
+
alignItems: "center",
|
| 169 |
+
padding: "14px 16px",
|
| 170 |
+
borderBottom: "1px solid #27272A",
|
| 171 |
+
backgroundColor: "#18181B",
|
| 172 |
+
},
|
| 173 |
+
headerTitle: {
|
| 174 |
+
fontSize: 14,
|
| 175 |
+
fontWeight: 600,
|
| 176 |
+
color: "#E4E4E7",
|
| 177 |
+
},
|
| 178 |
+
closeBtn: {
|
| 179 |
+
width: 26,
|
| 180 |
+
height: 26,
|
| 181 |
+
borderRadius: 6,
|
| 182 |
+
border: "1px solid #3F3F46",
|
| 183 |
+
background: "transparent",
|
| 184 |
+
color: "#A1A1AA",
|
| 185 |
+
fontSize: 16,
|
| 186 |
+
cursor: "pointer",
|
| 187 |
+
display: "flex",
|
| 188 |
+
alignItems: "center",
|
| 189 |
+
justifyContent: "center",
|
| 190 |
+
},
|
| 191 |
+
body: {
|
| 192 |
+
padding: "16px",
|
| 193 |
+
overflowY: "auto",
|
| 194 |
+
flex: 1,
|
| 195 |
+
},
|
| 196 |
+
label: {
|
| 197 |
+
display: "block",
|
| 198 |
+
fontSize: 12,
|
| 199 |
+
fontWeight: 600,
|
| 200 |
+
color: "#A1A1AA",
|
| 201 |
+
marginBottom: 6,
|
| 202 |
+
marginTop: 14,
|
| 203 |
+
},
|
| 204 |
+
input: {
|
| 205 |
+
width: "100%",
|
| 206 |
+
padding: "8px 10px",
|
| 207 |
+
borderRadius: 6,
|
| 208 |
+
border: "1px solid #3F3F46",
|
| 209 |
+
background: "#18181B",
|
| 210 |
+
color: "#E4E4E7",
|
| 211 |
+
fontSize: 13,
|
| 212 |
+
outline: "none",
|
| 213 |
+
boxSizing: "border-box",
|
| 214 |
+
},
|
| 215 |
+
radioGroup: {
|
| 216 |
+
display: "flex",
|
| 217 |
+
flexDirection: "column",
|
| 218 |
+
gap: 6,
|
| 219 |
+
},
|
| 220 |
+
radioItem: {
|
| 221 |
+
display: "flex",
|
| 222 |
+
alignItems: "flex-start",
|
| 223 |
+
gap: 10,
|
| 224 |
+
padding: "8px 10px",
|
| 225 |
+
borderRadius: 6,
|
| 226 |
+
border: "1px solid #27272A",
|
| 227 |
+
cursor: "pointer",
|
| 228 |
+
transition: "border-color 0.15s, background-color 0.15s",
|
| 229 |
+
},
|
| 230 |
+
textarea: {
|
| 231 |
+
width: "100%",
|
| 232 |
+
padding: "8px 10px",
|
| 233 |
+
borderRadius: 6,
|
| 234 |
+
border: "1px solid #3F3F46",
|
| 235 |
+
background: "#18181B",
|
| 236 |
+
color: "#E4E4E7",
|
| 237 |
+
fontSize: 12,
|
| 238 |
+
fontFamily: "monospace",
|
| 239 |
+
outline: "none",
|
| 240 |
+
resize: "vertical",
|
| 241 |
+
boxSizing: "border-box",
|
| 242 |
+
},
|
| 243 |
+
footer: {
|
| 244 |
+
display: "flex",
|
| 245 |
+
alignItems: "center",
|
| 246 |
+
gap: 8,
|
| 247 |
+
padding: "12px 16px",
|
| 248 |
+
borderTop: "1px solid #27272A",
|
| 249 |
+
},
|
| 250 |
+
cancelBtn: {
|
| 251 |
+
padding: "6px 14px",
|
| 252 |
+
borderRadius: 6,
|
| 253 |
+
border: "1px solid #3F3F46",
|
| 254 |
+
background: "transparent",
|
| 255 |
+
color: "#A1A1AA",
|
| 256 |
+
fontSize: 12,
|
| 257 |
+
cursor: "pointer",
|
| 258 |
+
},
|
| 259 |
+
saveBtn: {
|
| 260 |
+
padding: "6px 14px",
|
| 261 |
+
borderRadius: 6,
|
| 262 |
+
border: "none",
|
| 263 |
+
background: "#3B82F6",
|
| 264 |
+
color: "#fff",
|
| 265 |
+
fontSize: 12,
|
| 266 |
+
fontWeight: 600,
|
| 267 |
+
cursor: "pointer",
|
| 268 |
+
},
|
| 269 |
+
deleteBtn: {
|
| 270 |
+
padding: "6px 14px",
|
| 271 |
+
borderRadius: 6,
|
| 272 |
+
border: "1px solid rgba(239, 68, 68, 0.3)",
|
| 273 |
+
background: "transparent",
|
| 274 |
+
color: "#EF4444",
|
| 275 |
+
fontSize: 12,
|
| 276 |
+
cursor: "pointer",
|
| 277 |
+
},
|
| 278 |
+
};
|
frontend/components/EnvironmentSelector.jsx
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from "react";
|
| 2 |
+
import EnvironmentEditor from "./EnvironmentEditor.jsx";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* EnvironmentSelector — Claude-Code-on-Web parity environment dropdown.
|
| 6 |
+
*
|
| 7 |
+
* Shows current environment name + gear icon. Gear opens the editor modal.
|
| 8 |
+
* Fetches environments from /api/environments.
|
| 9 |
+
*/
|
| 10 |
+
export default function EnvironmentSelector({ activeEnvId, onEnvChange }) {
|
| 11 |
+
const [envs, setEnvs] = useState([]);
|
| 12 |
+
const [editorOpen, setEditorOpen] = useState(false);
|
| 13 |
+
const [editingEnv, setEditingEnv] = useState(null);
|
| 14 |
+
|
| 15 |
+
const fetchEnvs = async () => {
|
| 16 |
+
try {
|
| 17 |
+
const res = await fetch("/api/environments", { cache: "no-cache" });
|
| 18 |
+
if (!res.ok) return;
|
| 19 |
+
const data = await res.json();
|
| 20 |
+
setEnvs(data.environments || []);
|
| 21 |
+
} catch (err) {
|
| 22 |
+
console.warn("Failed to fetch environments:", err);
|
| 23 |
+
}
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
fetchEnvs();
|
| 28 |
+
}, []);
|
| 29 |
+
|
| 30 |
+
const activeEnv =
|
| 31 |
+
envs.find((e) => e.id === activeEnvId) || envs[0] || { name: "Default", id: "default" };
|
| 32 |
+
|
| 33 |
+
const handleSave = async (config) => {
|
| 34 |
+
try {
|
| 35 |
+
const method = config.id ? "PUT" : "POST";
|
| 36 |
+
const url = config.id ? `/api/environments/${config.id}` : "/api/environments";
|
| 37 |
+
await fetch(url, {
|
| 38 |
+
method,
|
| 39 |
+
headers: { "Content-Type": "application/json" },
|
| 40 |
+
body: JSON.stringify(config),
|
| 41 |
+
});
|
| 42 |
+
await fetchEnvs();
|
| 43 |
+
setEditorOpen(false);
|
| 44 |
+
setEditingEnv(null);
|
| 45 |
+
} catch (err) {
|
| 46 |
+
console.warn("Failed to save environment:", err);
|
| 47 |
+
}
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
const handleDelete = async (envId) => {
|
| 51 |
+
try {
|
| 52 |
+
await fetch(`/api/environments/${envId}`, { method: "DELETE" });
|
| 53 |
+
await fetchEnvs();
|
| 54 |
+
if (activeEnvId === envId) {
|
| 55 |
+
onEnvChange?.(null);
|
| 56 |
+
}
|
| 57 |
+
} catch (err) {
|
| 58 |
+
console.warn("Failed to delete environment:", err);
|
| 59 |
+
}
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div style={styles.container}>
|
| 64 |
+
<div style={styles.label}>ENVIRONMENT</div>
|
| 65 |
+
<div style={styles.row}>
|
| 66 |
+
<div style={styles.envCard}>
|
| 67 |
+
{/* Env selector */}
|
| 68 |
+
<select
|
| 69 |
+
value={activeEnv.id || "default"}
|
| 70 |
+
onChange={(e) => onEnvChange?.(e.target.value)}
|
| 71 |
+
style={styles.select}
|
| 72 |
+
>
|
| 73 |
+
{envs.map((env) => (
|
| 74 |
+
<option key={env.id} value={env.id}>
|
| 75 |
+
{env.name}
|
| 76 |
+
</option>
|
| 77 |
+
))}
|
| 78 |
+
</select>
|
| 79 |
+
|
| 80 |
+
{/* Network badge */}
|
| 81 |
+
<span style={{
|
| 82 |
+
...styles.networkBadge,
|
| 83 |
+
color: activeEnv.network_access === "full"
|
| 84 |
+
? "#10B981"
|
| 85 |
+
: activeEnv.network_access === "none"
|
| 86 |
+
? "#EF4444"
|
| 87 |
+
: "#F59E0B",
|
| 88 |
+
}}>
|
| 89 |
+
{activeEnv.network_access || "limited"}
|
| 90 |
+
</span>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
{/* Gear icon */}
|
| 94 |
+
<button
|
| 95 |
+
type="button"
|
| 96 |
+
style={styles.gearBtn}
|
| 97 |
+
onClick={() => {
|
| 98 |
+
setEditingEnv(activeEnv);
|
| 99 |
+
setEditorOpen(true);
|
| 100 |
+
}}
|
| 101 |
+
title="Configure environment"
|
| 102 |
+
>
|
| 103 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 104 |
+
<circle cx="12" cy="12" r="3" />
|
| 105 |
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
| 106 |
+
</svg>
|
| 107 |
+
</button>
|
| 108 |
+
|
| 109 |
+
{/* Add new */}
|
| 110 |
+
<button
|
| 111 |
+
type="button"
|
| 112 |
+
style={styles.gearBtn}
|
| 113 |
+
onClick={() => {
|
| 114 |
+
setEditingEnv(null);
|
| 115 |
+
setEditorOpen(true);
|
| 116 |
+
}}
|
| 117 |
+
title="Add environment"
|
| 118 |
+
>
|
| 119 |
+
+
|
| 120 |
+
</button>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
{/* Editor modal */}
|
| 124 |
+
{editorOpen && (
|
| 125 |
+
<EnvironmentEditor
|
| 126 |
+
environment={editingEnv}
|
| 127 |
+
onSave={handleSave}
|
| 128 |
+
onDelete={editingEnv?.id ? () => handleDelete(editingEnv.id) : null}
|
| 129 |
+
onClose={() => {
|
| 130 |
+
setEditorOpen(false);
|
| 131 |
+
setEditingEnv(null);
|
| 132 |
+
}}
|
| 133 |
+
/>
|
| 134 |
+
)}
|
| 135 |
+
</div>
|
| 136 |
+
);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const styles = {
|
| 140 |
+
container: {
|
| 141 |
+
padding: "10px 14px",
|
| 142 |
+
},
|
| 143 |
+
label: {
|
| 144 |
+
fontSize: 10,
|
| 145 |
+
fontWeight: 700,
|
| 146 |
+
letterSpacing: "0.08em",
|
| 147 |
+
color: "#71717A",
|
| 148 |
+
textTransform: "uppercase",
|
| 149 |
+
marginBottom: 6,
|
| 150 |
+
},
|
| 151 |
+
row: {
|
| 152 |
+
display: "flex",
|
| 153 |
+
alignItems: "center",
|
| 154 |
+
gap: 6,
|
| 155 |
+
},
|
| 156 |
+
envCard: {
|
| 157 |
+
flex: 1,
|
| 158 |
+
display: "flex",
|
| 159 |
+
alignItems: "center",
|
| 160 |
+
gap: 8,
|
| 161 |
+
padding: "4px 8px",
|
| 162 |
+
borderRadius: 6,
|
| 163 |
+
border: "1px solid #27272A",
|
| 164 |
+
backgroundColor: "#18181B",
|
| 165 |
+
minWidth: 0,
|
| 166 |
+
},
|
| 167 |
+
select: {
|
| 168 |
+
flex: 1,
|
| 169 |
+
background: "transparent",
|
| 170 |
+
border: "none",
|
| 171 |
+
color: "#E4E4E7",
|
| 172 |
+
fontSize: 12,
|
| 173 |
+
fontWeight: 500,
|
| 174 |
+
outline: "none",
|
| 175 |
+
cursor: "pointer",
|
| 176 |
+
minWidth: 0,
|
| 177 |
+
},
|
| 178 |
+
networkBadge: {
|
| 179 |
+
fontSize: 9,
|
| 180 |
+
fontWeight: 600,
|
| 181 |
+
textTransform: "uppercase",
|
| 182 |
+
letterSpacing: "0.04em",
|
| 183 |
+
flexShrink: 0,
|
| 184 |
+
},
|
| 185 |
+
gearBtn: {
|
| 186 |
+
width: 28,
|
| 187 |
+
height: 28,
|
| 188 |
+
borderRadius: 6,
|
| 189 |
+
border: "1px solid #27272A",
|
| 190 |
+
background: "transparent",
|
| 191 |
+
color: "#71717A",
|
| 192 |
+
cursor: "pointer",
|
| 193 |
+
display: "flex",
|
| 194 |
+
alignItems: "center",
|
| 195 |
+
justifyContent: "center",
|
| 196 |
+
fontSize: 14,
|
| 197 |
+
flexShrink: 0,
|
| 198 |
+
},
|
| 199 |
+
};
|
frontend/components/ExecutionPlanCard.jsx
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/ExecutionPlanCard.jsx
|
| 2 |
+
//
|
| 3 |
+
// The approval-first surface for sandbox runs. Renders a
|
| 4 |
+
// deterministic ExecutionPlan returned by POST /api/sandbox/plan
|
| 5 |
+
// and gates the actual run on an explicit user click.
|
| 6 |
+
//
|
| 7 |
+
// Two visual variants, same component:
|
| 8 |
+
//
|
| 9 |
+
// variant = "full" — used in chat for file-run and chat-command
|
| 10 |
+
// plans. Big card, every safety check / warning
|
| 11 |
+
// visible, primary CTA "Run in Sandbox".
|
| 12 |
+
// variant = "compact" — used as a popover above a code-block ▶ click.
|
| 13 |
+
// Same info, less chrome.
|
| 14 |
+
//
|
| 15 |
+
// The component is stateless about the run itself: it produces an
|
| 16 |
+
// approval event (onApprove with the plan object) and lets the
|
| 17 |
+
// parent decide how to execute. This keeps the streaming/state
|
| 18 |
+
// machine work for Batch 3 — here we only render and consent.
|
| 19 |
+
|
| 20 |
+
import React from "react";
|
| 21 |
+
|
| 22 |
+
const SEVERITY_STYLE = {
|
| 23 |
+
high: { bg: "#3d1111", fg: "#fca5a5", border: "#7f1d1d", icon: "⛔" },
|
| 24 |
+
medium: { bg: "#3d2d11", fg: "#fde68a", border: "#854d0e", icon: "⚠" },
|
| 25 |
+
low: { bg: "#1f2937", fg: "#a5b4fc", border: "#3730a3", icon: "ⓘ" },
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const BACKEND_LABELS = {
|
| 29 |
+
subprocess: "Local",
|
| 30 |
+
matrixlab: "MatrixLab",
|
| 31 |
+
off: "Pass-through",
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
export default function ExecutionPlanCard({
|
| 35 |
+
plan,
|
| 36 |
+
variant = "full",
|
| 37 |
+
busy = false,
|
| 38 |
+
onApprove,
|
| 39 |
+
onCancel,
|
| 40 |
+
onOpenFile,
|
| 41 |
+
}) {
|
| 42 |
+
if (!plan) return null;
|
| 43 |
+
const isCompact = variant === "compact";
|
| 44 |
+
const styles = isCompact ? compactStyles : fullStyles;
|
| 45 |
+
|
| 46 |
+
const commandStr = Array.isArray(plan.command)
|
| 47 |
+
? plan.command.join(" ")
|
| 48 |
+
: String(plan.command || "");
|
| 49 |
+
|
| 50 |
+
return (
|
| 51 |
+
<div style={styles.card} role="region" aria-label="Execution plan">
|
| 52 |
+
<header style={styles.header}>
|
| 53 |
+
<span style={styles.badge}>EXECUTION PLAN</span>
|
| 54 |
+
<h3 style={styles.title}>{plan.goal || "Run in sandbox"}</h3>
|
| 55 |
+
</header>
|
| 56 |
+
|
| 57 |
+
<dl style={styles.fields}>
|
| 58 |
+
{plan.file && (
|
| 59 |
+
<Field label="File" value={<code>{plan.file}</code>} />
|
| 60 |
+
)}
|
| 61 |
+
<Field label="Command" value={<code>{commandStr}</code>} />
|
| 62 |
+
<Field
|
| 63 |
+
label="Sandbox"
|
| 64 |
+
value={BACKEND_LABELS[plan.sandbox] || plan.sandbox}
|
| 65 |
+
/>
|
| 66 |
+
<Field label="Timeout" value={`${plan.timeout_sec}s`} />
|
| 67 |
+
<Field
|
| 68 |
+
label="Network"
|
| 69 |
+
value={plan.network ? "Enabled" : "Disabled"}
|
| 70 |
+
/>
|
| 71 |
+
{plan.workdir && plan.workdir !== "." && (
|
| 72 |
+
<Field label="Working dir" value={<code>{plan.workdir}</code>} />
|
| 73 |
+
)}
|
| 74 |
+
</dl>
|
| 75 |
+
|
| 76 |
+
{/* Safety checks — always green, no opinion */}
|
| 77 |
+
{Array.isArray(plan.safety?.checks) && plan.safety.checks.length > 0 && (
|
| 78 |
+
<ul style={styles.checks}>
|
| 79 |
+
{plan.safety.checks.map((c, i) => (
|
| 80 |
+
<li key={i} style={styles.check}>
|
| 81 |
+
<span style={styles.checkIcon}>✓</span>
|
| 82 |
+
{c.label}
|
| 83 |
+
</li>
|
| 84 |
+
))}
|
| 85 |
+
</ul>
|
| 86 |
+
)}
|
| 87 |
+
|
| 88 |
+
{/* Warnings — non-blocking, sorted high → low by the backend */}
|
| 89 |
+
{Array.isArray(plan.safety?.warnings) && plan.safety.warnings.length > 0 && (
|
| 90 |
+
<ul style={styles.warnings}>
|
| 91 |
+
{plan.safety.warnings.map((w, i) => {
|
| 92 |
+
const st = SEVERITY_STYLE[w.severity] || SEVERITY_STYLE.low;
|
| 93 |
+
return (
|
| 94 |
+
<li key={i} style={{
|
| 95 |
+
...styles.warning,
|
| 96 |
+
background: st.bg, color: st.fg, borderColor: st.border,
|
| 97 |
+
}}>
|
| 98 |
+
<span style={styles.warnIcon}>{st.icon}</span>
|
| 99 |
+
<span>
|
| 100 |
+
<strong>{w.label}</strong>
|
| 101 |
+
{w.detail && <span style={styles.warnDetail}> — {w.detail}</span>}
|
| 102 |
+
</span>
|
| 103 |
+
</li>
|
| 104 |
+
);
|
| 105 |
+
})}
|
| 106 |
+
</ul>
|
| 107 |
+
)}
|
| 108 |
+
|
| 109 |
+
{plan.inline_code && (
|
| 110 |
+
<details style={styles.snippetWrap}>
|
| 111 |
+
<summary style={styles.snippetLabel}>
|
| 112 |
+
Snippet to run ({plan.inline_code.length} chars)
|
| 113 |
+
</summary>
|
| 114 |
+
<pre style={styles.snippet}>{plan.inline_code}</pre>
|
| 115 |
+
</details>
|
| 116 |
+
)}
|
| 117 |
+
|
| 118 |
+
<footer style={styles.footer}>
|
| 119 |
+
<button
|
| 120 |
+
type="button"
|
| 121 |
+
style={{ ...styles.primary, opacity: busy ? 0.6 : 1 }}
|
| 122 |
+
onClick={() => onApprove?.(plan)}
|
| 123 |
+
disabled={busy}
|
| 124 |
+
autoFocus
|
| 125 |
+
>
|
| 126 |
+
{busy ? "Starting…" : "▶ Run in Sandbox"}
|
| 127 |
+
</button>
|
| 128 |
+
{plan.file && onOpenFile && (
|
| 129 |
+
<button type="button" style={styles.secondary}
|
| 130 |
+
onClick={() => onOpenFile(plan.file)}>
|
| 131 |
+
Open {plan.file.split("/").pop()}
|
| 132 |
+
</button>
|
| 133 |
+
)}
|
| 134 |
+
<button
|
| 135 |
+
type="button"
|
| 136 |
+
style={styles.tertiary}
|
| 137 |
+
onClick={() => onCancel?.(plan)}
|
| 138 |
+
disabled={busy}
|
| 139 |
+
>
|
| 140 |
+
Cancel
|
| 141 |
+
</button>
|
| 142 |
+
</footer>
|
| 143 |
+
</div>
|
| 144 |
+
);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
function Field({ label, value }) {
|
| 148 |
+
return (
|
| 149 |
+
<>
|
| 150 |
+
<dt style={dtStyle}>{label}</dt>
|
| 151 |
+
<dd style={ddStyle}>{value}</dd>
|
| 152 |
+
</>
|
| 153 |
+
);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
// ---------------------------------------------------------------------------
|
| 157 |
+
// Approve helper — single source of truth so chat, codeblock, canvas
|
| 158 |
+
// all build the plan the same way. Returns the plan object or throws.
|
| 159 |
+
// ---------------------------------------------------------------------------
|
| 160 |
+
|
| 161 |
+
export async function fetchExecutionPlan(payload) {
|
| 162 |
+
const res = await fetch("/api/sandbox/plan", {
|
| 163 |
+
method: "POST",
|
| 164 |
+
headers: { "Content-Type": "application/json" },
|
| 165 |
+
body: JSON.stringify(payload),
|
| 166 |
+
});
|
| 167 |
+
const data = await res.json().catch(() => ({}));
|
| 168 |
+
if (!res.ok) {
|
| 169 |
+
throw new Error(data.detail || `Plan failed (HTTP ${res.status})`);
|
| 170 |
+
}
|
| 171 |
+
return data.plan;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// ---------------------------------------------------------------------------
|
| 175 |
+
// Styles
|
| 176 |
+
// ---------------------------------------------------------------------------
|
| 177 |
+
|
| 178 |
+
const dtStyle = {
|
| 179 |
+
fontSize: 11, color: "#9092b5", textTransform: "uppercase",
|
| 180 |
+
letterSpacing: "0.05em", margin: 0,
|
| 181 |
+
};
|
| 182 |
+
const ddStyle = { margin: "0 0 6px", fontSize: 13, color: "#e4e4e7" };
|
| 183 |
+
|
| 184 |
+
const fullStyles = {
|
| 185 |
+
card: {
|
| 186 |
+
margin: "8px 0",
|
| 187 |
+
background: "#0d1117",
|
| 188 |
+
border: "1px solid #1f2937",
|
| 189 |
+
borderLeft: "3px solid #10B981",
|
| 190 |
+
borderRadius: 10,
|
| 191 |
+
padding: 16,
|
| 192 |
+
color: "#e4e4e7",
|
| 193 |
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
| 194 |
+
},
|
| 195 |
+
header: { display: "flex", alignItems: "center", gap: 10, marginBottom: 10 },
|
| 196 |
+
badge: {
|
| 197 |
+
fontSize: 10, fontWeight: 700, letterSpacing: "0.06em",
|
| 198 |
+
padding: "2px 8px", borderRadius: 4,
|
| 199 |
+
background: "#0d3320", color: "#86efac", textTransform: "uppercase",
|
| 200 |
+
},
|
| 201 |
+
title: { margin: 0, fontSize: 15, fontWeight: 600 },
|
| 202 |
+
fields: {
|
| 203 |
+
display: "grid", gridTemplateColumns: "120px 1fr",
|
| 204 |
+
rowGap: 4, columnGap: 12, margin: "10px 0",
|
| 205 |
+
},
|
| 206 |
+
checks: {
|
| 207 |
+
listStyle: "none", padding: 0, margin: "8px 0",
|
| 208 |
+
display: "flex", flexWrap: "wrap", gap: 6,
|
| 209 |
+
},
|
| 210 |
+
check: {
|
| 211 |
+
fontSize: 11, color: "#86efac",
|
| 212 |
+
background: "rgba(16,185,129,0.08)",
|
| 213 |
+
border: "1px solid rgba(16,185,129,0.25)",
|
| 214 |
+
borderRadius: 4, padding: "2px 8px",
|
| 215 |
+
},
|
| 216 |
+
checkIcon: { marginRight: 4 },
|
| 217 |
+
warnings: { listStyle: "none", padding: 0, margin: "10px 0 4px" },
|
| 218 |
+
warning: {
|
| 219 |
+
fontSize: 12,
|
| 220 |
+
padding: "6px 10px", borderRadius: 4, border: "1px solid",
|
| 221 |
+
marginBottom: 4, display: "flex", gap: 8, alignItems: "flex-start",
|
| 222 |
+
},
|
| 223 |
+
warnIcon: { fontSize: 14, lineHeight: 1 },
|
| 224 |
+
warnDetail: { opacity: 0.8 },
|
| 225 |
+
snippetWrap: {
|
| 226 |
+
margin: "8px 0",
|
| 227 |
+
border: "1px solid #1f2937", borderRadius: 6, padding: "6px 10px",
|
| 228 |
+
},
|
| 229 |
+
snippetLabel: { fontSize: 11, color: "#9092b5", cursor: "pointer" },
|
| 230 |
+
snippet: {
|
| 231 |
+
margin: "6px 0 0", padding: 8, fontSize: 12,
|
| 232 |
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
| 233 |
+
background: "#000", color: "#d4d4d8", borderRadius: 4,
|
| 234 |
+
maxHeight: 240, overflow: "auto", whiteSpace: "pre-wrap",
|
| 235 |
+
},
|
| 236 |
+
footer: { display: "flex", gap: 8, marginTop: 12 },
|
| 237 |
+
primary: {
|
| 238 |
+
background: "#10B981", color: "#052e1c", border: "0",
|
| 239 |
+
borderRadius: 6, padding: "8px 16px", fontSize: 13, fontWeight: 600,
|
| 240 |
+
cursor: "pointer",
|
| 241 |
+
},
|
| 242 |
+
secondary: {
|
| 243 |
+
background: "transparent", color: "#a1a1aa",
|
| 244 |
+
border: "1px solid #3F3F46", borderRadius: 6,
|
| 245 |
+
padding: "7px 14px", fontSize: 13, cursor: "pointer",
|
| 246 |
+
},
|
| 247 |
+
tertiary: {
|
| 248 |
+
background: "transparent", color: "#71717a",
|
| 249 |
+
border: "1px solid #27272a", borderRadius: 6,
|
| 250 |
+
padding: "7px 14px", fontSize: 13, cursor: "pointer",
|
| 251 |
+
marginLeft: "auto",
|
| 252 |
+
},
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
// Compact variant — same structure, denser padding, no inline snippet
|
| 256 |
+
// expander. Used for code-block Run confirmation popovers.
|
| 257 |
+
const compactStyles = {
|
| 258 |
+
...fullStyles,
|
| 259 |
+
card: {
|
| 260 |
+
...fullStyles.card,
|
| 261 |
+
padding: 10,
|
| 262 |
+
margin: "6px 0",
|
| 263 |
+
borderRadius: 8,
|
| 264 |
+
},
|
| 265 |
+
title: { margin: 0, fontSize: 13, fontWeight: 600 },
|
| 266 |
+
fields: {
|
| 267 |
+
...fullStyles.fields,
|
| 268 |
+
gridTemplateColumns: "90px 1fr",
|
| 269 |
+
rowGap: 2,
|
| 270 |
+
margin: "6px 0",
|
| 271 |
+
},
|
| 272 |
+
primary: { ...fullStyles.primary, padding: "6px 12px", fontSize: 12 },
|
| 273 |
+
secondary: { ...fullStyles.secondary, padding: "5px 10px", fontSize: 12 },
|
| 274 |
+
tertiary: { ...fullStyles.tertiary, padding: "5px 10px", fontSize: 12 },
|
| 275 |
+
};
|
frontend/components/FilePreviewPanel.jsx
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/FilePreviewPanel.jsx
|
| 2 |
+
//
|
| 3 |
+
// Read-first file viewer with two density modes.
|
| 4 |
+
//
|
| 5 |
+
// mode="preview" Narrow right-side drawer for a quick look.
|
| 6 |
+
// ~520px wide, primary CTA "Prepare Run".
|
| 7 |
+
// mode="workspace" Wide right-side editor for serious review.
|
| 8 |
+
// ~85vw, same actions, much more reading room.
|
| 9 |
+
//
|
| 10 |
+
// Header vocabulary follows the enterprise verbs the design review
|
| 11 |
+
// nailed down:
|
| 12 |
+
//
|
| 13 |
+
// [▶ Prepare Run] only on runnable files (hidden on README etc.)
|
| 14 |
+
// [Open Workspace] upgrades preview → workspace (or back)
|
| 15 |
+
// [⋯] overflow: Ask GitPilot · Open Canvas ·
|
| 16 |
+
// Copy path · Copy contents
|
| 17 |
+
//
|
| 18 |
+
// "Prepare Run" never runs anything directly — it dispatches the
|
| 19 |
+
// gitpilot:run-file event which lands on the green ExecutionPlanCard
|
| 20 |
+
// in chat, where the user approves before the sandbox starts.
|
| 21 |
+
//
|
| 22 |
+
// Error state: when the file content fetch fails we hide every action
|
| 23 |
+
// that depends on having content (Prepare Run / Canvas / Ask). Only
|
| 24 |
+
// safe actions remain (Retry / Copy path).
|
| 25 |
+
|
| 26 |
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
| 27 |
+
|
| 28 |
+
const RUNNABLE_FILE_EXTS = new Set(["py", "js", "mjs", "cjs", "sh", "bash"]);
|
| 29 |
+
|
| 30 |
+
const LANG_LABEL = {
|
| 31 |
+
py: "Python", js: "JavaScript", mjs: "JavaScript", cjs: "JavaScript",
|
| 32 |
+
sh: "Shell", bash: "Shell",
|
| 33 |
+
md: "Markdown", json: "JSON", yml: "YAML", yaml: "YAML", toml: "TOML",
|
| 34 |
+
html: "HTML", css: "CSS", ts: "TypeScript", tsx: "TypeScript",
|
| 35 |
+
rs: "Rust", go: "Go", java: "Java", c: "C", cpp: "C++", h: "C/C++",
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
function extOf(path) {
|
| 39 |
+
if (!path || !path.includes(".")) return "";
|
| 40 |
+
return path.split(".").pop().toLowerCase();
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function bytesPretty(n) {
|
| 44 |
+
if (n == null) return "";
|
| 45 |
+
if (n < 1024) return `${n} B`;
|
| 46 |
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
| 47 |
+
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// ---------------------------------------------------------------------------
|
| 51 |
+
// Overflow menu — secondary actions live here so the header stays calm.
|
| 52 |
+
// ---------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
function OverflowMenu({ path, content, runnable, onClose }) {
|
| 55 |
+
useEffect(() => {
|
| 56 |
+
const onKey = (e) => { if (e.key === "Escape") onClose?.(); };
|
| 57 |
+
const onDown = () => onClose?.();
|
| 58 |
+
window.addEventListener("keydown", onKey);
|
| 59 |
+
window.addEventListener("mousedown", onDown);
|
| 60 |
+
return () => {
|
| 61 |
+
window.removeEventListener("keydown", onKey);
|
| 62 |
+
window.removeEventListener("mousedown", onDown);
|
| 63 |
+
};
|
| 64 |
+
}, [onClose]);
|
| 65 |
+
|
| 66 |
+
const copy = (text) => {
|
| 67 |
+
if (navigator?.clipboard && text != null) navigator.clipboard.writeText(text).catch(() => {});
|
| 68 |
+
};
|
| 69 |
+
const items = [
|
| 70 |
+
{ label: "Ask GitPilot",
|
| 71 |
+
onClick: () => window.dispatchEvent(new CustomEvent("gitpilot:ask-about-file", { detail: { path } })) },
|
| 72 |
+
{ label: "Open in Canvas", runnable: true,
|
| 73 |
+
onClick: () => window.dispatchEvent(new CustomEvent("gitpilot:open-in-canvas", { detail: { path } })) },
|
| 74 |
+
{ divider: true },
|
| 75 |
+
{ label: "Copy path", onClick: () => copy(path) },
|
| 76 |
+
{ label: "Copy contents", onClick: () => copy(content), disabled: !content },
|
| 77 |
+
];
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<div role="menu"
|
| 81 |
+
onMouseDown={(e) => e.stopPropagation()}
|
| 82 |
+
style={{
|
| 83 |
+
position: "absolute", right: 6, top: 36, zIndex: 50,
|
| 84 |
+
minWidth: 200,
|
| 85 |
+
background: "#18181B", border: "1px solid #3F3F46",
|
| 86 |
+
borderRadius: 6, padding: "4px 0",
|
| 87 |
+
boxShadow: "0 8px 20px rgba(0,0,0,0.45)",
|
| 88 |
+
}}>
|
| 89 |
+
{items.map((it, i) => {
|
| 90 |
+
if (it.divider) return <div key={i} style={{ height: 1, background: "#27272A", margin: "4px 0" }} />;
|
| 91 |
+
if (it.runnable && !runnable) return null;
|
| 92 |
+
return (
|
| 93 |
+
<button
|
| 94 |
+
key={i}
|
| 95 |
+
role="menuitem"
|
| 96 |
+
type="button"
|
| 97 |
+
disabled={!!it.disabled}
|
| 98 |
+
onClick={(e) => { e.stopPropagation(); it.onClick(); onClose?.(); }}
|
| 99 |
+
style={{
|
| 100 |
+
width: "100%", textAlign: "left",
|
| 101 |
+
background: "transparent",
|
| 102 |
+
color: it.disabled ? "#52525B" : "#E4E4E7",
|
| 103 |
+
border: "none",
|
| 104 |
+
padding: "6px 12px", fontSize: 12,
|
| 105 |
+
cursor: it.disabled ? "not-allowed" : "pointer",
|
| 106 |
+
}}
|
| 107 |
+
onMouseEnter={(e) => { if (!it.disabled) e.currentTarget.style.background = "#27272A"; }}
|
| 108 |
+
onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}
|
| 109 |
+
>
|
| 110 |
+
{it.label}
|
| 111 |
+
</button>
|
| 112 |
+
);
|
| 113 |
+
})}
|
| 114 |
+
</div>
|
| 115 |
+
);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// ---------------------------------------------------------------------------
|
| 119 |
+
// Main panel
|
| 120 |
+
// ---------------------------------------------------------------------------
|
| 121 |
+
|
| 122 |
+
export default function FilePreviewPanel({
|
| 123 |
+
path,
|
| 124 |
+
content,
|
| 125 |
+
loading,
|
| 126 |
+
error,
|
| 127 |
+
errorCode, // numeric HTTP status when known (e.g. 404)
|
| 128 |
+
notFoundKind, // "deleted" | "syncing" | "unavailable"
|
| 129 |
+
mode = "preview", // "preview" | "workspace"
|
| 130 |
+
branch,
|
| 131 |
+
onModeChange,
|
| 132 |
+
onClose,
|
| 133 |
+
onRetry,
|
| 134 |
+
onRefreshTree,
|
| 135 |
+
}) {
|
| 136 |
+
const ext = extOf(path);
|
| 137 |
+
const lang = LANG_LABEL[ext] || ext.toUpperCase() || "Text";
|
| 138 |
+
const runnable = RUNNABLE_FILE_EXTS.has(ext);
|
| 139 |
+
const size = content ? new Blob([content]).size : null;
|
| 140 |
+
const filename = path ? path.split("/").pop() : "";
|
| 141 |
+
const [menuOpen, setMenuOpen] = useState(false);
|
| 142 |
+
const menuBtnRef = useRef(null);
|
| 143 |
+
|
| 144 |
+
// Esc closes — keyboard contract every modal/drawer in GitPilot uses.
|
| 145 |
+
useEffect(() => {
|
| 146 |
+
const onKey = (e) => { if (e.key === "Escape") onClose?.(); };
|
| 147 |
+
window.addEventListener("keydown", onKey);
|
| 148 |
+
return () => window.removeEventListener("keydown", onKey);
|
| 149 |
+
}, [onClose]);
|
| 150 |
+
|
| 151 |
+
const prepareRun = () => {
|
| 152 |
+
window.dispatchEvent(new CustomEvent("gitpilot:run-file", { detail: { path } }));
|
| 153 |
+
onClose?.();
|
| 154 |
+
};
|
| 155 |
+
const toggleWorkspace = () => {
|
| 156 |
+
if (onModeChange) onModeChange(mode === "workspace" ? "preview" : "workspace");
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
const lines = useMemo(() => (content || "").split("\n"), [content]);
|
| 160 |
+
|
| 161 |
+
// Width contract: preview = peek, workspace = serious reading.
|
| 162 |
+
const widthCss = mode === "workspace"
|
| 163 |
+
? "min(1280px, 86vw)"
|
| 164 |
+
: "min(520px, 44vw)";
|
| 165 |
+
|
| 166 |
+
// Decide which actions to show. Two rules:
|
| 167 |
+
// 1. While loading, secondary actions exist but disabled.
|
| 168 |
+
// 2. On error, hide every action that requires content; we show
|
| 169 |
+
// Retry + Copy path only.
|
| 170 |
+
const showActions = !error;
|
| 171 |
+
|
| 172 |
+
return (
|
| 173 |
+
<aside style={{ ...s.shell, width: widthCss }} role="region" aria-label="File preview">
|
| 174 |
+
<header style={s.header}>
|
| 175 |
+
<div style={s.titleRow}>
|
| 176 |
+
<div style={{ minWidth: 0 }}>
|
| 177 |
+
<div style={s.filename}>{filename || "Untitled"}</div>
|
| 178 |
+
<div style={s.subpath}>
|
| 179 |
+
<span style={{ opacity: 0.8 }}>Path:</span> {path}
|
| 180 |
+
{branch && (
|
| 181 |
+
<>
|
| 182 |
+
<span style={s.dot}>·</span>
|
| 183 |
+
<span style={{ opacity: 0.8 }}>Branch:</span> {branch}
|
| 184 |
+
</>
|
| 185 |
+
)}
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
<div style={s.metaPills}>
|
| 189 |
+
<span style={s.pill}>{lang}</span>
|
| 190 |
+
{size != null && <span style={s.pillDim}>{bytesPretty(size)}</span>}
|
| 191 |
+
<button type="button" style={s.btnClose} onClick={onClose} aria-label="Close preview">
|
| 192 |
+
✕
|
| 193 |
+
</button>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
<div style={s.actions}>
|
| 198 |
+
{showActions && runnable && (
|
| 199 |
+
<button
|
| 200 |
+
type="button" style={s.btnPrimary} onClick={prepareRun}
|
| 201 |
+
disabled={loading || !content}
|
| 202 |
+
title="Build an Execution Plan you can review before running"
|
| 203 |
+
>
|
| 204 |
+
▶ Prepare Run
|
| 205 |
+
</button>
|
| 206 |
+
)}
|
| 207 |
+
{showActions && (
|
| 208 |
+
<button type="button" style={s.btnSecondary} onClick={toggleWorkspace}
|
| 209 |
+
title={mode === "workspace" ? "Collapse to preview" : "Open a wider editor view"}>
|
| 210 |
+
{mode === "workspace" ? "Collapse" : "Open Workspace"}
|
| 211 |
+
</button>
|
| 212 |
+
)}
|
| 213 |
+
{/* Error-only actions */}
|
| 214 |
+
{error && (
|
| 215 |
+
<button type="button" style={s.btnPrimary} onClick={onRetry}>
|
| 216 |
+
Retry
|
| 217 |
+
</button>
|
| 218 |
+
)}
|
| 219 |
+
{error && (
|
| 220 |
+
<button
|
| 221 |
+
type="button" style={s.btnSecondary}
|
| 222 |
+
onClick={() => {
|
| 223 |
+
if (navigator?.clipboard && path) navigator.clipboard.writeText(path).catch(() => {});
|
| 224 |
+
}}
|
| 225 |
+
>
|
| 226 |
+
Copy path
|
| 227 |
+
</button>
|
| 228 |
+
)}
|
| 229 |
+
|
| 230 |
+
{/* Overflow menu — only on the happy path; on error we
|
| 231 |
+
already trimmed the safe-only action set above. */}
|
| 232 |
+
{showActions && (
|
| 233 |
+
<div style={{ marginLeft: "auto", position: "relative" }}>
|
| 234 |
+
<button
|
| 235 |
+
type="button" ref={menuBtnRef}
|
| 236 |
+
style={s.btnSecondary}
|
| 237 |
+
aria-haspopup="menu" aria-expanded={menuOpen}
|
| 238 |
+
onMouseDown={(e) => e.stopPropagation()}
|
| 239 |
+
onClick={() => setMenuOpen((v) => !v)}
|
| 240 |
+
title="More actions"
|
| 241 |
+
>
|
| 242 |
+
⋯
|
| 243 |
+
</button>
|
| 244 |
+
{menuOpen && (
|
| 245 |
+
<OverflowMenu
|
| 246 |
+
path={path}
|
| 247 |
+
content={content}
|
| 248 |
+
runnable={runnable}
|
| 249 |
+
onClose={() => setMenuOpen(false)}
|
| 250 |
+
/>
|
| 251 |
+
)}
|
| 252 |
+
</div>
|
| 253 |
+
)}
|
| 254 |
+
</div>
|
| 255 |
+
</header>
|
| 256 |
+
|
| 257 |
+
<div style={s.body}>
|
| 258 |
+
{loading && <div style={s.empty}>Loading {path}…</div>}
|
| 259 |
+
|
| 260 |
+
{error && !loading && (
|
| 261 |
+
<NotAvailableCard
|
| 262 |
+
path={path}
|
| 263 |
+
branch={branch}
|
| 264 |
+
error={error}
|
| 265 |
+
errorCode={errorCode}
|
| 266 |
+
kind={notFoundKind}
|
| 267 |
+
onRetry={onRetry}
|
| 268 |
+
onRefreshTree={onRefreshTree}
|
| 269 |
+
/>
|
| 270 |
+
)}
|
| 271 |
+
|
| 272 |
+
{!loading && !error && (
|
| 273 |
+
<pre style={s.code}>
|
| 274 |
+
{lines.map((line, i) => (
|
| 275 |
+
<div key={i} style={s.lineRow}>
|
| 276 |
+
<span style={s.lineNo}>{i + 1}</span>
|
| 277 |
+
<span style={s.lineCode}>{line || " "}</span>
|
| 278 |
+
</div>
|
| 279 |
+
))}
|
| 280 |
+
</pre>
|
| 281 |
+
)}
|
| 282 |
+
</div>
|
| 283 |
+
</aside>
|
| 284 |
+
);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
// ---------------------------------------------------------------------------
|
| 288 |
+
// Calm empty state — replaces the harsh red "Couldn't load this file"
|
| 289 |
+
// box. Same component, classifies the situation into one of:
|
| 290 |
+
// deleted → execution removed the file from this branch
|
| 291 |
+
// syncing → execution created the file but content isn't ready yet
|
| 292 |
+
// unavailable → file simply isn't on this branch
|
| 293 |
+
// ---------------------------------------------------------------------------
|
| 294 |
+
|
| 295 |
+
function NotAvailableCard({ path, branch, error, errorCode, kind, onRetry, onRefreshTree }) {
|
| 296 |
+
const isNotFound =
|
| 297 |
+
errorCode === 404 ||
|
| 298 |
+
/not\s*found/i.test(String(error || "")) ||
|
| 299 |
+
/HTTP\s*404/i.test(String(error || ""));
|
| 300 |
+
|
| 301 |
+
const effectiveKind = isNotFound ? (kind || "unavailable") : "error";
|
| 302 |
+
|
| 303 |
+
const copy = {
|
| 304 |
+
deleted: {
|
| 305 |
+
icon: "🗑",
|
| 306 |
+
title: "File deleted",
|
| 307 |
+
body: `${path ? path.split("/").pop() : "This file"} was removed by the latest execution. View the execution summary for details.`,
|
| 308 |
+
tone: "muted",
|
| 309 |
+
},
|
| 310 |
+
syncing: {
|
| 311 |
+
icon: "↻",
|
| 312 |
+
title: "File still syncing",
|
| 313 |
+
body: "GitPilot just created this file. Its content is being published to the active branch — try again in a moment.",
|
| 314 |
+
tone: "info",
|
| 315 |
+
},
|
| 316 |
+
unavailable: {
|
| 317 |
+
icon: "○",
|
| 318 |
+
title: "File unavailable",
|
| 319 |
+
body: "This file isn't available on the current branch. It may have been moved, renamed, or never committed here.",
|
| 320 |
+
tone: "muted",
|
| 321 |
+
},
|
| 322 |
+
error: {
|
| 323 |
+
icon: "!",
|
| 324 |
+
title: "Couldn't load this file",
|
| 325 |
+
body: "Something went wrong fetching the file content. Try again, or refresh the file tree.",
|
| 326 |
+
tone: "warn",
|
| 327 |
+
},
|
| 328 |
+
}[effectiveKind];
|
| 329 |
+
|
| 330 |
+
return (
|
| 331 |
+
<div
|
| 332 |
+
className={`file-notavail file-notavail--${copy.tone}`}
|
| 333 |
+
role="status"
|
| 334 |
+
aria-live="polite"
|
| 335 |
+
>
|
| 336 |
+
<div className="file-notavail__icon" aria-hidden="true">{copy.icon}</div>
|
| 337 |
+
<div className="file-notavail__title">{copy.title}</div>
|
| 338 |
+
<div className="file-notavail__body">{copy.body}</div>
|
| 339 |
+
|
| 340 |
+
<dl className="file-notavail__fields">
|
| 341 |
+
<dt>Path</dt>
|
| 342 |
+
<dd><code>{path}</code></dd>
|
| 343 |
+
{branch && (
|
| 344 |
+
<>
|
| 345 |
+
<dt>Branch</dt>
|
| 346 |
+
<dd><code>{branch}</code></dd>
|
| 347 |
+
</>
|
| 348 |
+
)}
|
| 349 |
+
</dl>
|
| 350 |
+
|
| 351 |
+
<div className="file-notavail__actions">
|
| 352 |
+
<button type="button" className="file-notavail__btn file-notavail__btn--primary" onClick={onRetry}>
|
| 353 |
+
Retry
|
| 354 |
+
</button>
|
| 355 |
+
{onRefreshTree && (
|
| 356 |
+
<button type="button" className="file-notavail__btn" onClick={onRefreshTree}>
|
| 357 |
+
Refresh files
|
| 358 |
+
</button>
|
| 359 |
+
)}
|
| 360 |
+
<button
|
| 361 |
+
type="button"
|
| 362 |
+
className="file-notavail__btn"
|
| 363 |
+
onClick={() => {
|
| 364 |
+
if (navigator?.clipboard && path) {
|
| 365 |
+
navigator.clipboard.writeText(path).catch(() => {});
|
| 366 |
+
}
|
| 367 |
+
}}
|
| 368 |
+
>
|
| 369 |
+
Copy path
|
| 370 |
+
</button>
|
| 371 |
+
</div>
|
| 372 |
+
|
| 373 |
+
{effectiveKind === "error" && error && (
|
| 374 |
+
<details className="file-notavail__details">
|
| 375 |
+
<summary>Technical details</summary>
|
| 376 |
+
<pre>{String(error)}</pre>
|
| 377 |
+
</details>
|
| 378 |
+
)}
|
| 379 |
+
</div>
|
| 380 |
+
);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
// ---------------------------------------------------------------------------
|
| 384 |
+
// Styles
|
| 385 |
+
// ---------------------------------------------------------------------------
|
| 386 |
+
|
| 387 |
+
const s = {
|
| 388 |
+
shell: {
|
| 389 |
+
position: "fixed",
|
| 390 |
+
top: 0, right: 0, bottom: 0,
|
| 391 |
+
// ``width`` is set per-mode in the component.
|
| 392 |
+
background: "#0d0e17",
|
| 393 |
+
borderLeft: "1px solid #2a2b36",
|
| 394 |
+
display: "flex", flexDirection: "column",
|
| 395 |
+
zIndex: 90,
|
| 396 |
+
color: "#e4e4e7",
|
| 397 |
+
fontFamily: "system-ui, sans-serif",
|
| 398 |
+
boxShadow: "-12px 0 32px rgba(0,0,0,0.45)",
|
| 399 |
+
transition: "width 0.15s ease",
|
| 400 |
+
},
|
| 401 |
+
header: {
|
| 402 |
+
padding: "12px 16px 10px",
|
| 403 |
+
borderBottom: "1px solid #2a2b36",
|
| 404 |
+
background: "#14152a",
|
| 405 |
+
},
|
| 406 |
+
titleRow: { display: "flex", justifyContent: "space-between", gap: 12 },
|
| 407 |
+
filename: {
|
| 408 |
+
fontSize: 16, fontWeight: 600,
|
| 409 |
+
fontFamily: "ui-monospace, monospace",
|
| 410 |
+
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
| 411 |
+
},
|
| 412 |
+
subpath: {
|
| 413 |
+
fontSize: 11, color: "#9092b5", marginTop: 2,
|
| 414 |
+
fontFamily: "ui-monospace, monospace",
|
| 415 |
+
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
| 416 |
+
},
|
| 417 |
+
dot: { margin: "0 6px", color: "#52525B" },
|
| 418 |
+
metaPills: { display: "flex", alignItems: "flex-start", gap: 6, flexShrink: 0 },
|
| 419 |
+
pill: {
|
| 420 |
+
fontSize: 11, padding: "2px 8px", borderRadius: 4,
|
| 421 |
+
background: "#1e3a5f", color: "#93c5fd",
|
| 422 |
+
border: "1px solid #3B82F6",
|
| 423 |
+
},
|
| 424 |
+
pillDim: {
|
| 425 |
+
fontSize: 11, padding: "2px 8px", borderRadius: 4,
|
| 426 |
+
background: "#0d0e17", color: "#9092b5",
|
| 427 |
+
border: "1px solid #2c2d46",
|
| 428 |
+
},
|
| 429 |
+
actions: { display: "flex", gap: 8, marginTop: 10, flexWrap: "wrap", alignItems: "center" },
|
| 430 |
+
btnPrimary: {
|
| 431 |
+
padding: "6px 14px", fontSize: 12, fontWeight: 600,
|
| 432 |
+
background: "#10B981", color: "#052e1c",
|
| 433 |
+
border: "0", borderRadius: 6, cursor: "pointer",
|
| 434 |
+
},
|
| 435 |
+
btnSecondary: {
|
| 436 |
+
padding: "6px 12px", fontSize: 12,
|
| 437 |
+
background: "transparent", color: "#c3c5dd",
|
| 438 |
+
border: "1px solid #2c2d46", borderRadius: 6, cursor: "pointer",
|
| 439 |
+
},
|
| 440 |
+
btnClose: {
|
| 441 |
+
marginLeft: 6,
|
| 442 |
+
padding: "2px 8px", fontSize: 14,
|
| 443 |
+
background: "transparent", color: "#9092b5",
|
| 444 |
+
border: "1px solid #2c2d46", borderRadius: 6, cursor: "pointer",
|
| 445 |
+
},
|
| 446 |
+
body: { flex: 1, overflow: "auto", padding: "12px 0" },
|
| 447 |
+
empty: { padding: 30, textAlign: "center", color: "#9092b5", fontSize: 13 },
|
| 448 |
+
code: {
|
| 449 |
+
margin: 0, padding: 0,
|
| 450 |
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
| 451 |
+
fontSize: 12.5, lineHeight: 1.55,
|
| 452 |
+
color: "#D4D4D8",
|
| 453 |
+
background: "transparent",
|
| 454 |
+
whiteSpace: "pre",
|
| 455 |
+
},
|
| 456 |
+
lineRow: { display: "grid", gridTemplateColumns: "48px 1fr", gap: 12, padding: "0 18px" },
|
| 457 |
+
lineNo: {
|
| 458 |
+
color: "#52525B", textAlign: "right",
|
| 459 |
+
userSelect: "none",
|
| 460 |
+
fontVariantNumeric: "tabular-nums",
|
| 461 |
+
},
|
| 462 |
+
lineCode: { whiteSpace: "pre-wrap", wordBreak: "break-word" },
|
| 463 |
+
};
|
frontend/components/FileTree.jsx
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from "react";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Simple recursive file tree viewer with refresh support
|
| 5 |
+
* Fetches tree data directly using the API.
|
| 6 |
+
*/
|
| 7 |
+
export default function FileTree({ repo, refreshTrigger, branch }) {
|
| 8 |
+
const [tree, setTree] = useState([]);
|
| 9 |
+
const [loading, setLoading] = useState(false);
|
| 10 |
+
const [isSwitchingBranch, setIsSwitchingBranch] = useState(false);
|
| 11 |
+
const [error, setError] = useState(null);
|
| 12 |
+
const [localRefresh, setLocalRefresh] = useState(0);
|
| 13 |
+
// Search query for the in-sidebar filter. Empty string == no filter.
|
| 14 |
+
// Filter runs case-insensitively on path basenames *and* full paths
|
| 15 |
+
// so users can pinpoint a file even in deeply nested folders.
|
| 16 |
+
const [query, setQuery] = useState("");
|
| 17 |
+
// Which file is currently focused in the preview panel — populated
|
| 18 |
+
// by the ``gitpilot:file-opened`` event ChatPanel emits when the
|
| 19 |
+
// preview finishes loading. Drives the ◄ marker + active row tint.
|
| 20 |
+
const [selectedPath, setSelectedPath] = useState(null);
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
const onOpened = (e) => setSelectedPath(e?.detail?.path || null);
|
| 24 |
+
const onClosed = () => setSelectedPath(null);
|
| 25 |
+
const onRefresh = () => setLocalRefresh((n) => n + 1);
|
| 26 |
+
window.addEventListener("gitpilot:file-opened", onOpened);
|
| 27 |
+
window.addEventListener("gitpilot:file-closed", onClosed);
|
| 28 |
+
window.addEventListener("gitpilot:refresh-tree", onRefresh);
|
| 29 |
+
return () => {
|
| 30 |
+
window.removeEventListener("gitpilot:file-opened", onOpened);
|
| 31 |
+
window.removeEventListener("gitpilot:file-closed", onClosed);
|
| 32 |
+
window.removeEventListener("gitpilot:refresh-tree", onRefresh);
|
| 33 |
+
};
|
| 34 |
+
}, []);
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
if (!repo) return;
|
| 38 |
+
|
| 39 |
+
// Determine if this is a branch switch (we already have data)
|
| 40 |
+
const hasExistingData = tree.length > 0;
|
| 41 |
+
if (hasExistingData) {
|
| 42 |
+
setIsSwitchingBranch(true);
|
| 43 |
+
} else {
|
| 44 |
+
setLoading(true);
|
| 45 |
+
}
|
| 46 |
+
setError(null);
|
| 47 |
+
|
| 48 |
+
// Construct headers manually
|
| 49 |
+
let headers = {};
|
| 50 |
+
try {
|
| 51 |
+
const token = localStorage.getItem("github_token");
|
| 52 |
+
if (token) {
|
| 53 |
+
headers = { Authorization: `Bearer ${token}` };
|
| 54 |
+
}
|
| 55 |
+
} catch (e) {
|
| 56 |
+
console.warn("Unable to read github_token", e);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// Add cache busting + selected branch ref
|
| 60 |
+
const refParam = branch ? `&ref=${encodeURIComponent(branch)}` : "";
|
| 61 |
+
const cacheBuster = `?_t=${Date.now()}${refParam}`;
|
| 62 |
+
|
| 63 |
+
let cancelled = false;
|
| 64 |
+
|
| 65 |
+
fetch(`/api/repos/${repo.owner}/${repo.name}/tree${cacheBuster}`, { headers })
|
| 66 |
+
.then(async (res) => {
|
| 67 |
+
if (!res.ok) {
|
| 68 |
+
const errData = await res.json().catch(() => ({}));
|
| 69 |
+
throw new Error(errData.detail || "Failed to load files");
|
| 70 |
+
}
|
| 71 |
+
return res.json();
|
| 72 |
+
})
|
| 73 |
+
.then((data) => {
|
| 74 |
+
if (cancelled) return;
|
| 75 |
+
if (data.files && Array.isArray(data.files)) {
|
| 76 |
+
setTree(buildTree(data.files));
|
| 77 |
+
setError(null);
|
| 78 |
+
} else {
|
| 79 |
+
setError("No files found in repository");
|
| 80 |
+
}
|
| 81 |
+
})
|
| 82 |
+
.catch((err) => {
|
| 83 |
+
if (cancelled) return;
|
| 84 |
+
setError(err.message);
|
| 85 |
+
console.error("FileTree error:", err);
|
| 86 |
+
})
|
| 87 |
+
.finally(() => {
|
| 88 |
+
if (cancelled) return;
|
| 89 |
+
setIsSwitchingBranch(false);
|
| 90 |
+
setLoading(false);
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
return () => { cancelled = true; };
|
| 94 |
+
}, [repo, branch, refreshTrigger, localRefresh]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 95 |
+
|
| 96 |
+
const handleRefresh = () => {
|
| 97 |
+
setLocalRefresh(prev => prev + 1);
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
// Theme matching parent component
|
| 101 |
+
const theme = {
|
| 102 |
+
border: "#27272A",
|
| 103 |
+
textPrimary: "#EDEDED",
|
| 104 |
+
textSecondary: "#A1A1AA",
|
| 105 |
+
accent: "#D95C3D",
|
| 106 |
+
warningText: "#F59E0B",
|
| 107 |
+
warningBg: "rgba(245, 158, 11, 0.1)",
|
| 108 |
+
warningBorder: "rgba(245, 158, 11, 0.2)",
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
const styles = {
|
| 112 |
+
header: {
|
| 113 |
+
display: "flex",
|
| 114 |
+
alignItems: "center",
|
| 115 |
+
justifyContent: "space-between",
|
| 116 |
+
padding: "8px 20px 8px 10px",
|
| 117 |
+
marginBottom: "8px",
|
| 118 |
+
borderBottom: `1px solid ${theme.border}`,
|
| 119 |
+
},
|
| 120 |
+
headerTitle: {
|
| 121 |
+
fontSize: "12px",
|
| 122 |
+
fontWeight: "600",
|
| 123 |
+
color: theme.textSecondary,
|
| 124 |
+
textTransform: "uppercase",
|
| 125 |
+
letterSpacing: "0.5px",
|
| 126 |
+
},
|
| 127 |
+
refreshButton: {
|
| 128 |
+
backgroundColor: "transparent",
|
| 129 |
+
border: `1px solid ${theme.border}`,
|
| 130 |
+
color: theme.textSecondary,
|
| 131 |
+
padding: "4px 8px",
|
| 132 |
+
borderRadius: "4px",
|
| 133 |
+
fontSize: "11px",
|
| 134 |
+
cursor: loading ? "not-allowed" : "pointer",
|
| 135 |
+
display: "flex",
|
| 136 |
+
alignItems: "center",
|
| 137 |
+
gap: "4px",
|
| 138 |
+
transition: "all 0.2s",
|
| 139 |
+
opacity: loading ? 0.5 : 1,
|
| 140 |
+
},
|
| 141 |
+
switchingBar: {
|
| 142 |
+
padding: "6px 20px",
|
| 143 |
+
fontSize: "11px",
|
| 144 |
+
color: theme.textSecondary,
|
| 145 |
+
backgroundColor: "rgba(59, 130, 246, 0.06)",
|
| 146 |
+
borderBottom: `1px solid ${theme.border}`,
|
| 147 |
+
},
|
| 148 |
+
loadingText: {
|
| 149 |
+
padding: "0 20px",
|
| 150 |
+
color: theme.textSecondary,
|
| 151 |
+
fontSize: "13px",
|
| 152 |
+
},
|
| 153 |
+
errorBox: {
|
| 154 |
+
padding: "12px 20px",
|
| 155 |
+
color: theme.warningText,
|
| 156 |
+
fontSize: "12px",
|
| 157 |
+
backgroundColor: theme.warningBg,
|
| 158 |
+
border: `1px solid ${theme.warningBorder}`,
|
| 159 |
+
borderRadius: "6px",
|
| 160 |
+
margin: "0 10px",
|
| 161 |
+
},
|
| 162 |
+
emptyText: {
|
| 163 |
+
padding: "0 20px",
|
| 164 |
+
color: theme.textSecondary,
|
| 165 |
+
fontSize: "13px",
|
| 166 |
+
},
|
| 167 |
+
treeContainer: {
|
| 168 |
+
fontSize: "13px",
|
| 169 |
+
color: theme.textSecondary,
|
| 170 |
+
padding: "0 10px 20px 10px",
|
| 171 |
+
},
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
return (
|
| 175 |
+
<div>
|
| 176 |
+
{/* Header with Refresh Button */}
|
| 177 |
+
<div style={styles.header}>
|
| 178 |
+
<span style={styles.headerTitle}>Files</span>
|
| 179 |
+
<button
|
| 180 |
+
type="button"
|
| 181 |
+
style={styles.refreshButton}
|
| 182 |
+
onClick={handleRefresh}
|
| 183 |
+
disabled={loading}
|
| 184 |
+
onMouseOver={(e) => {
|
| 185 |
+
if (!loading) {
|
| 186 |
+
e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.05)";
|
| 187 |
+
}
|
| 188 |
+
}}
|
| 189 |
+
onMouseOut={(e) => {
|
| 190 |
+
e.currentTarget.style.backgroundColor = "transparent";
|
| 191 |
+
}}
|
| 192 |
+
>
|
| 193 |
+
<svg
|
| 194 |
+
width="12"
|
| 195 |
+
height="12"
|
| 196 |
+
viewBox="0 0 24 24"
|
| 197 |
+
fill="none"
|
| 198 |
+
stroke="currentColor"
|
| 199 |
+
strokeWidth="2"
|
| 200 |
+
style={{
|
| 201 |
+
transform: loading ? "rotate(360deg)" : "rotate(0deg)",
|
| 202 |
+
transition: "transform 0.6s ease",
|
| 203 |
+
}}
|
| 204 |
+
>
|
| 205 |
+
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
|
| 206 |
+
</svg>
|
| 207 |
+
{loading ? "..." : "Refresh"}
|
| 208 |
+
</button>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
{/* Branch switch indicator (shown above existing tree, doesn't clear it) */}
|
| 212 |
+
{isSwitchingBranch && (
|
| 213 |
+
<div style={styles.switchingBar}>Loading branch...</div>
|
| 214 |
+
)}
|
| 215 |
+
|
| 216 |
+
{/* Content */}
|
| 217 |
+
{loading && tree.length === 0 && (
|
| 218 |
+
<div style={styles.loadingText}>Loading files...</div>
|
| 219 |
+
)}
|
| 220 |
+
|
| 221 |
+
{!loading && !isSwitchingBranch && error && (
|
| 222 |
+
<div style={styles.errorBox}>{error}</div>
|
| 223 |
+
)}
|
| 224 |
+
|
| 225 |
+
{!loading && !isSwitchingBranch && !error && tree.length === 0 && (
|
| 226 |
+
<div style={styles.emptyText}>No files found</div>
|
| 227 |
+
)}
|
| 228 |
+
|
| 229 |
+
{tree.length > 0 && (
|
| 230 |
+
<>
|
| 231 |
+
{/* In-sidebar file search. Enterprise repos run to hundreds
|
| 232 |
+
of files; the explorer needs a quick narrowing affordance
|
| 233 |
+
before any of the contextual menus matter. */}
|
| 234 |
+
<input
|
| 235 |
+
type="text"
|
| 236 |
+
value={query}
|
| 237 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 238 |
+
placeholder="🔍 Search files…"
|
| 239 |
+
spellCheck={false}
|
| 240 |
+
style={{
|
| 241 |
+
width: "100%", boxSizing: "border-box",
|
| 242 |
+
margin: "4px 0 8px",
|
| 243 |
+
padding: "6px 10px", fontSize: 12,
|
| 244 |
+
background: "#0d0e17", color: "#e4e4e7",
|
| 245 |
+
border: "1px solid #27272A", borderRadius: 6,
|
| 246 |
+
outline: "none",
|
| 247 |
+
fontFamily: "system-ui, sans-serif",
|
| 248 |
+
}}
|
| 249 |
+
/>
|
| 250 |
+
|
| 251 |
+
<div style={{
|
| 252 |
+
...styles.treeContainer,
|
| 253 |
+
opacity: isSwitchingBranch ? 0.5 : 1,
|
| 254 |
+
transition: "opacity 0.15s ease",
|
| 255 |
+
}}>
|
| 256 |
+
{tree.map((node) => (
|
| 257 |
+
<TreeNode
|
| 258 |
+
key={node.path}
|
| 259 |
+
node={node}
|
| 260 |
+
level={0}
|
| 261 |
+
filter={query.trim().toLowerCase()}
|
| 262 |
+
selectedPath={selectedPath}
|
| 263 |
+
/>
|
| 264 |
+
))}
|
| 265 |
+
</div>
|
| 266 |
+
</>
|
| 267 |
+
)}
|
| 268 |
+
</div>
|
| 269 |
+
);
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
// Files with these extensions get a "Prepare Run" menu item. Mirrors
|
| 273 |
+
// the backend's _RUNNABLE_EXTENSIONS so the menu only offers a run
|
| 274 |
+
// where the sandbox planner would actually accept the file.
|
| 275 |
+
const RUNNABLE_FILE_EXTS = new Set(["py", "js", "mjs", "cjs", "sh", "bash"]);
|
| 276 |
+
|
| 277 |
+
function isRunnableFile(name, type) {
|
| 278 |
+
if (type === "tree") return false;
|
| 279 |
+
if (!name || !name.includes(".")) return false;
|
| 280 |
+
const ext = name.split(".").pop().toLowerCase();
|
| 281 |
+
return RUNNABLE_FILE_EXTS.has(ext);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
// Window-event dispatch helpers — keep FileTree decoupled from
|
| 285 |
+
// ChatPanel. Same pattern as the existing approval-flow events.
|
| 286 |
+
function dispatchOpenFile(path) {
|
| 287 |
+
try {
|
| 288 |
+
window.dispatchEvent(new CustomEvent("gitpilot:open-file", { detail: { path } }));
|
| 289 |
+
} catch (_e) { /* old browser */ }
|
| 290 |
+
}
|
| 291 |
+
function dispatchOpenInCanvas(path) {
|
| 292 |
+
try {
|
| 293 |
+
window.dispatchEvent(new CustomEvent("gitpilot:open-in-canvas", { detail: { path } }));
|
| 294 |
+
} catch (_e) { /* old browser */ }
|
| 295 |
+
}
|
| 296 |
+
function dispatchRunFile(path) {
|
| 297 |
+
try {
|
| 298 |
+
window.dispatchEvent(new CustomEvent("gitpilot:run-file", { detail: { path } }));
|
| 299 |
+
} catch (_e) { /* old browser */ }
|
| 300 |
+
}
|
| 301 |
+
function dispatchAskAboutFile(path) {
|
| 302 |
+
try {
|
| 303 |
+
window.dispatchEvent(new CustomEvent("gitpilot:ask-about-file", { detail: { path } }));
|
| 304 |
+
} catch (_e) { /* old browser */ }
|
| 305 |
+
}
|
| 306 |
+
function copyToClipboard(text) {
|
| 307 |
+
if (navigator?.clipboard) navigator.clipboard.writeText(text).catch(() => {});
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// Tiny dropdown — positioned absolutely below the row's ⋯ trigger.
|
| 311 |
+
// Closes on outside click or Escape. Built-in <select> would be
|
| 312 |
+
// faster to write but the visual contract for an enterprise file
|
| 313 |
+
// explorer demands a real menu (icons, separators, descriptions).
|
| 314 |
+
function FileActionsMenu({ path, runnable, onClose }) {
|
| 315 |
+
React.useEffect(() => {
|
| 316 |
+
const onKey = (e) => { if (e.key === "Escape") onClose?.(); };
|
| 317 |
+
const onDown = () => onClose?.();
|
| 318 |
+
window.addEventListener("keydown", onKey);
|
| 319 |
+
window.addEventListener("mousedown", onDown);
|
| 320 |
+
return () => {
|
| 321 |
+
window.removeEventListener("keydown", onKey);
|
| 322 |
+
window.removeEventListener("mousedown", onDown);
|
| 323 |
+
};
|
| 324 |
+
}, [onClose]);
|
| 325 |
+
|
| 326 |
+
// Menu vocabulary maps 1:1 to the design's recommended verbs. "Open
|
| 327 |
+
// Preview" is the calm default; "Open Workspace" widens the panel
|
| 328 |
+
// for serious reading/editing; "Prepare Run…" surfaces the
|
| 329 |
+
// ExecutionPlanCard. Canvas (the runnable split editor) is moved
|
| 330 |
+
// to the overflow because it's the heaviest mode and shouldn't
|
| 331 |
+
// compete with Preview / Workspace for attention.
|
| 332 |
+
const items = [
|
| 333 |
+
{ label: "Open Preview", onClick: () => dispatchOpenFile(path) },
|
| 334 |
+
{
|
| 335 |
+
label: "Open Workspace",
|
| 336 |
+
onClick: () => window.dispatchEvent(
|
| 337 |
+
new CustomEvent("gitpilot:open-workspace", { detail: { path } })
|
| 338 |
+
),
|
| 339 |
+
},
|
| 340 |
+
{
|
| 341 |
+
label: "Prepare Run…",
|
| 342 |
+
onClick: () => dispatchRunFile(path),
|
| 343 |
+
runnable: true,
|
| 344 |
+
primary: true,
|
| 345 |
+
},
|
| 346 |
+
{ divider: true },
|
| 347 |
+
{ label: "Ask GitPilot", onClick: () => dispatchAskAboutFile(path) },
|
| 348 |
+
{ label: "Open in Canvas", onClick: () => dispatchOpenInCanvas(path), runnable: true },
|
| 349 |
+
{ label: "Copy path", onClick: () => copyToClipboard(path) },
|
| 350 |
+
];
|
| 351 |
+
|
| 352 |
+
return (
|
| 353 |
+
<div
|
| 354 |
+
role="menu"
|
| 355 |
+
onMouseDown={(e) => e.stopPropagation()}
|
| 356 |
+
style={{
|
| 357 |
+
position: "absolute", right: 4, top: 22, zIndex: 50,
|
| 358 |
+
minWidth: 180,
|
| 359 |
+
background: "#18181B", border: "1px solid #3F3F46",
|
| 360 |
+
borderRadius: 6, padding: "4px 0",
|
| 361 |
+
boxShadow: "0 8px 20px rgba(0,0,0,0.45)",
|
| 362 |
+
fontFamily: "system-ui, sans-serif",
|
| 363 |
+
}}
|
| 364 |
+
>
|
| 365 |
+
{items.map((it, i) => {
|
| 366 |
+
if (it.divider) {
|
| 367 |
+
return <div key={i} style={{ height: 1, background: "#27272A", margin: "4px 0" }} />;
|
| 368 |
+
}
|
| 369 |
+
if (it.runnable && !runnable) return null;
|
| 370 |
+
return (
|
| 371 |
+
<button
|
| 372 |
+
key={i}
|
| 373 |
+
role="menuitem"
|
| 374 |
+
type="button"
|
| 375 |
+
onClick={(e) => { e.stopPropagation(); it.onClick(); onClose?.(); }}
|
| 376 |
+
style={{
|
| 377 |
+
width: "100%", textAlign: "left",
|
| 378 |
+
background: "transparent",
|
| 379 |
+
color: it.primary ? "#86efac" : "#E4E4E7",
|
| 380 |
+
border: "none",
|
| 381 |
+
padding: "6px 12px", fontSize: 12, cursor: "pointer",
|
| 382 |
+
}}
|
| 383 |
+
onMouseEnter={(e) => e.currentTarget.style.background = "#27272A"}
|
| 384 |
+
onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}
|
| 385 |
+
>
|
| 386 |
+
{it.label}
|
| 387 |
+
</button>
|
| 388 |
+
);
|
| 389 |
+
})}
|
| 390 |
+
</div>
|
| 391 |
+
);
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
// True when ``node`` or any descendant path/basename contains ``filter``.
|
| 395 |
+
// Folders are rendered if any descendant matches so the filter looks
|
| 396 |
+
// like a flat search even though the tree is recursive.
|
| 397 |
+
function _matchesFilter(node, filter) {
|
| 398 |
+
if (!filter) return true;
|
| 399 |
+
const inThis =
|
| 400 |
+
(node.path && node.path.toLowerCase().includes(filter)) ||
|
| 401 |
+
(node.name && node.name.toLowerCase().includes(filter));
|
| 402 |
+
if (inThis) return true;
|
| 403 |
+
if (!node.children) return false;
|
| 404 |
+
return node.children.some((c) => _matchesFilter(c, filter));
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
// Recursive Node Component
|
| 408 |
+
function TreeNode({ node, level, filter = "", selectedPath = null }) {
|
| 409 |
+
const [userExpanded, setUserExpanded] = useState(false);
|
| 410 |
+
const [hovered, setHovered] = useState(false);
|
| 411 |
+
const [menuOpen, setMenuOpen] = useState(false);
|
| 412 |
+
const isFolder = node.children && node.children.length > 0;
|
| 413 |
+
const runnable = !isFolder && isRunnableFile(node.name, node.type);
|
| 414 |
+
const isSelected = !isFolder && selectedPath === node.path;
|
| 415 |
+
|
| 416 |
+
// Honor the filter: drop subtrees that don't match anywhere. When
|
| 417 |
+
// the filter is active, folders auto-expand so users see the matches
|
| 418 |
+
// without having to chase carets.
|
| 419 |
+
if (filter && !_matchesFilter(node, filter)) return null;
|
| 420 |
+
const expanded = userExpanded || Boolean(filter);
|
| 421 |
+
|
| 422 |
+
const icon = isFolder ? (expanded ? "📂" : "📁") : "📄";
|
| 423 |
+
|
| 424 |
+
const onRowClick = () => {
|
| 425 |
+
if (isFolder) setUserExpanded(!userExpanded);
|
| 426 |
+
else dispatchOpenFile(node.path);
|
| 427 |
+
};
|
| 428 |
+
|
| 429 |
+
return (
|
| 430 |
+
<div style={{ position: "relative" }}>
|
| 431 |
+
<div
|
| 432 |
+
onClick={onRowClick}
|
| 433 |
+
onMouseEnter={() => setHovered(true)}
|
| 434 |
+
onMouseLeave={() => { setHovered(false); /* menu closes on outside-click */ }}
|
| 435 |
+
style={{
|
| 436 |
+
padding: "4px 0",
|
| 437 |
+
paddingLeft: `${level * 12}px`,
|
| 438 |
+
paddingRight: 6,
|
| 439 |
+
cursor: "pointer",
|
| 440 |
+
display: "flex",
|
| 441 |
+
alignItems: "center",
|
| 442 |
+
gap: "6px",
|
| 443 |
+
color: isFolder
|
| 444 |
+
? "#EDEDED"
|
| 445 |
+
: (isSelected ? "#86efac" : "#D4D4D8"),
|
| 446 |
+
whiteSpace: "nowrap",
|
| 447 |
+
borderRadius: 4,
|
| 448 |
+
background: isSelected
|
| 449 |
+
? "rgba(16,185,129,0.10)"
|
| 450 |
+
: (hovered ? "#1f1f23" : "transparent"),
|
| 451 |
+
borderLeft: isSelected ? "2px solid #10B981" : "2px solid transparent",
|
| 452 |
+
}}
|
| 453 |
+
title={isFolder ? "" : "Click to open · ⋯ for actions"}
|
| 454 |
+
>
|
| 455 |
+
<span style={{ fontSize: "14px", opacity: 0.7 }}>{icon}</span>
|
| 456 |
+
<span style={{
|
| 457 |
+
flex: 1, overflow: "hidden", textOverflow: "ellipsis",
|
| 458 |
+
fontWeight: isSelected ? 600 : 400,
|
| 459 |
+
}}>
|
| 460 |
+
{node.name}
|
| 461 |
+
</span>
|
| 462 |
+
{/* Selected marker — orient users when scanning a long list. */}
|
| 463 |
+
{isSelected && (
|
| 464 |
+
<span style={{ color: "#10B981", fontSize: 11 }}>◄</span>
|
| 465 |
+
)}
|
| 466 |
+
|
| 467 |
+
{/* Per-row ⋯ menu trigger — visible on hover only so the
|
| 468 |
+
list reads as a clean inventory at rest. */}
|
| 469 |
+
{!isFolder && hovered && (
|
| 470 |
+
<button
|
| 471 |
+
type="button"
|
| 472 |
+
aria-haspopup="menu"
|
| 473 |
+
aria-expanded={menuOpen}
|
| 474 |
+
onMouseDown={(e) => e.stopPropagation()}
|
| 475 |
+
onClick={(e) => {
|
| 476 |
+
e.stopPropagation();
|
| 477 |
+
setMenuOpen((v) => !v);
|
| 478 |
+
}}
|
| 479 |
+
title="File actions"
|
| 480 |
+
style={{
|
| 481 |
+
background: "transparent",
|
| 482 |
+
border: "1px solid transparent",
|
| 483 |
+
color: "#A1A1AA",
|
| 484 |
+
cursor: "pointer",
|
| 485 |
+
fontSize: 14,
|
| 486 |
+
lineHeight: 1,
|
| 487 |
+
padding: "0 6px",
|
| 488 |
+
borderRadius: 4,
|
| 489 |
+
}}
|
| 490 |
+
>
|
| 491 |
+
⋯
|
| 492 |
+
</button>
|
| 493 |
+
)}
|
| 494 |
+
</div>
|
| 495 |
+
|
| 496 |
+
{menuOpen && (
|
| 497 |
+
<FileActionsMenu
|
| 498 |
+
path={node.path}
|
| 499 |
+
runnable={runnable}
|
| 500 |
+
onClose={() => setMenuOpen(false)}
|
| 501 |
+
/>
|
| 502 |
+
)}
|
| 503 |
+
|
| 504 |
+
{isFolder && expanded && (
|
| 505 |
+
<div>
|
| 506 |
+
{node.children.map(child => (
|
| 507 |
+
<TreeNode
|
| 508 |
+
key={child.path}
|
| 509 |
+
node={child}
|
| 510 |
+
level={level + 1}
|
| 511 |
+
filter={filter}
|
| 512 |
+
selectedPath={selectedPath}
|
| 513 |
+
/>
|
| 514 |
+
))}
|
| 515 |
+
</div>
|
| 516 |
+
)}
|
| 517 |
+
</div>
|
| 518 |
+
);
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
// Helper to build tree structure from flat file list
|
| 522 |
+
function buildTree(files) {
|
| 523 |
+
const root = [];
|
| 524 |
+
|
| 525 |
+
files.forEach(file => {
|
| 526 |
+
const parts = file.path.split('/');
|
| 527 |
+
let currentLevel = root;
|
| 528 |
+
let currentPath = "";
|
| 529 |
+
|
| 530 |
+
parts.forEach((part, idx) => {
|
| 531 |
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
| 532 |
+
|
| 533 |
+
// Check if node exists at this level
|
| 534 |
+
let existingNode = currentLevel.find(n => n.name === part);
|
| 535 |
+
|
| 536 |
+
if (!existingNode) {
|
| 537 |
+
const newNode = {
|
| 538 |
+
name: part,
|
| 539 |
+
path: currentPath,
|
| 540 |
+
type: idx === parts.length - 1 ? file.type : 'tree',
|
| 541 |
+
children: []
|
| 542 |
+
};
|
| 543 |
+
currentLevel.push(newNode);
|
| 544 |
+
existingNode = newNode;
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
if (idx < parts.length - 1) {
|
| 548 |
+
currentLevel = existingNode.children;
|
| 549 |
+
}
|
| 550 |
+
});
|
| 551 |
+
});
|
| 552 |
+
|
| 553 |
+
// Sort folders first, then files
|
| 554 |
+
const sortNodes = (nodes) => {
|
| 555 |
+
nodes.sort((a, b) => {
|
| 556 |
+
const aIsFolder = a.children.length > 0;
|
| 557 |
+
const bIsFolder = b.children.length > 0;
|
| 558 |
+
if (aIsFolder && !bIsFolder) return -1;
|
| 559 |
+
if (!aIsFolder && bIsFolder) return 1;
|
| 560 |
+
return a.name.localeCompare(b.name);
|
| 561 |
+
});
|
| 562 |
+
nodes.forEach(n => {
|
| 563 |
+
if (n.children.length > 0) sortNodes(n.children);
|
| 564 |
+
});
|
| 565 |
+
};
|
| 566 |
+
|
| 567 |
+
sortNodes(root);
|
| 568 |
+
return root;
|
| 569 |
+
}
|
frontend/components/FlowViewer.jsx
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useCallback, useRef } from "react";
|
| 2 |
+
import ReactFlow, { Background, Controls, MiniMap } from "reactflow";
|
| 3 |
+
import "reactflow/dist/style.css";
|
| 4 |
+
|
| 5 |
+
/* ------------------------------------------------------------------ */
|
| 6 |
+
/* Node type → colour mapping */
|
| 7 |
+
/* ------------------------------------------------------------------ */
|
| 8 |
+
const NODE_COLOURS = {
|
| 9 |
+
agent: { border: "#ff7a3c", bg: "#20141a" },
|
| 10 |
+
router: { border: "#6c8cff", bg: "#141828" },
|
| 11 |
+
tool: { border: "#3a3b4d", bg: "#141821" },
|
| 12 |
+
tool_group: { border: "#3a3b4d", bg: "#141821" },
|
| 13 |
+
user: { border: "#4caf88", bg: "#14211a" },
|
| 14 |
+
output: { border: "#9c6cff", bg: "#1a1428" },
|
| 15 |
+
};
|
| 16 |
+
const DEFAULT_COLOUR = { border: "#3a3b4d", bg: "#141821" };
|
| 17 |
+
|
| 18 |
+
function colourFor(type) {
|
| 19 |
+
return NODE_COLOURS[type] || DEFAULT_COLOUR;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const STYLE_COLOURS = {
|
| 23 |
+
single_task: "#6c8cff",
|
| 24 |
+
react_loop: "#ff7a3c",
|
| 25 |
+
crew_pipeline: "#4caf88",
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const STYLE_LABELS = {
|
| 29 |
+
single_task: "Dispatch",
|
| 30 |
+
react_loop: "ReAct Loop",
|
| 31 |
+
crew_pipeline: "Pipeline",
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
/* ------------------------------------------------------------------ */
|
| 35 |
+
/* TopologyCard — single clickable topology card */
|
| 36 |
+
/* ------------------------------------------------------------------ */
|
| 37 |
+
function TopologyCard({ topology, isActive, onClick }) {
|
| 38 |
+
const styleColor = STYLE_COLOURS[topology.execution_style] || "#9a9bb0";
|
| 39 |
+
const agentCount = topology.agents_used?.length || 0;
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<button
|
| 43 |
+
type="button"
|
| 44 |
+
onClick={onClick}
|
| 45 |
+
style={{
|
| 46 |
+
...cardStyles.card,
|
| 47 |
+
borderColor: isActive ? styleColor : "#1e1f30",
|
| 48 |
+
backgroundColor: isActive ? `${styleColor}0D` : "#0c0d14",
|
| 49 |
+
}}
|
| 50 |
+
>
|
| 51 |
+
<div style={cardStyles.cardTop}>
|
| 52 |
+
<span style={cardStyles.icon}>{topology.icon}</span>
|
| 53 |
+
<span
|
| 54 |
+
style={{
|
| 55 |
+
...cardStyles.styleBadge,
|
| 56 |
+
color: styleColor,
|
| 57 |
+
borderColor: `${styleColor}40`,
|
| 58 |
+
}}
|
| 59 |
+
>
|
| 60 |
+
{STYLE_LABELS[topology.execution_style] || topology.execution_style}
|
| 61 |
+
</span>
|
| 62 |
+
</div>
|
| 63 |
+
<div
|
| 64 |
+
style={{
|
| 65 |
+
...cardStyles.name,
|
| 66 |
+
color: isActive ? "#f5f5f7" : "#c3c5dd",
|
| 67 |
+
}}
|
| 68 |
+
>
|
| 69 |
+
{topology.name}
|
| 70 |
+
</div>
|
| 71 |
+
<div style={cardStyles.desc}>{topology.description}</div>
|
| 72 |
+
<div style={cardStyles.agentCount}>
|
| 73 |
+
{agentCount} agent{agentCount !== 1 ? "s" : ""}
|
| 74 |
+
</div>
|
| 75 |
+
</button>
|
| 76 |
+
);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const cardStyles = {
|
| 80 |
+
card: {
|
| 81 |
+
display: "flex",
|
| 82 |
+
flexDirection: "column",
|
| 83 |
+
gap: 4,
|
| 84 |
+
padding: "10px 12px",
|
| 85 |
+
borderRadius: 8,
|
| 86 |
+
border: "1px solid #1e1f30",
|
| 87 |
+
cursor: "pointer",
|
| 88 |
+
textAlign: "left",
|
| 89 |
+
minWidth: 170,
|
| 90 |
+
maxWidth: 200,
|
| 91 |
+
flexShrink: 0,
|
| 92 |
+
transition: "border-color 0.2s, background-color 0.2s",
|
| 93 |
+
},
|
| 94 |
+
cardTop: {
|
| 95 |
+
display: "flex",
|
| 96 |
+
alignItems: "center",
|
| 97 |
+
justifyContent: "space-between",
|
| 98 |
+
gap: 6,
|
| 99 |
+
},
|
| 100 |
+
icon: {
|
| 101 |
+
fontSize: 18,
|
| 102 |
+
},
|
| 103 |
+
styleBadge: {
|
| 104 |
+
fontSize: 9,
|
| 105 |
+
fontWeight: 700,
|
| 106 |
+
textTransform: "uppercase",
|
| 107 |
+
letterSpacing: "0.05em",
|
| 108 |
+
padding: "1px 6px",
|
| 109 |
+
borderRadius: 4,
|
| 110 |
+
border: "1px solid",
|
| 111 |
+
},
|
| 112 |
+
name: {
|
| 113 |
+
fontSize: 12,
|
| 114 |
+
fontWeight: 600,
|
| 115 |
+
lineHeight: 1.3,
|
| 116 |
+
},
|
| 117 |
+
desc: {
|
| 118 |
+
fontSize: 10,
|
| 119 |
+
color: "#71717A",
|
| 120 |
+
lineHeight: 1.3,
|
| 121 |
+
overflow: "hidden",
|
| 122 |
+
display: "-webkit-box",
|
| 123 |
+
WebkitLineClamp: 2,
|
| 124 |
+
WebkitBoxOrient: "vertical",
|
| 125 |
+
},
|
| 126 |
+
agentCount: {
|
| 127 |
+
fontSize: 9,
|
| 128 |
+
color: "#52525B",
|
| 129 |
+
fontWeight: 600,
|
| 130 |
+
marginTop: 2,
|
| 131 |
+
},
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
/* ------------------------------------------------------------------ */
|
| 135 |
+
/* TopologyPanel — card grid grouped by category */
|
| 136 |
+
/* ------------------------------------------------------------------ */
|
| 137 |
+
function TopologyPanel({
|
| 138 |
+
topologies,
|
| 139 |
+
activeTopology,
|
| 140 |
+
autoMode,
|
| 141 |
+
autoResult,
|
| 142 |
+
onSelect,
|
| 143 |
+
onToggleAuto,
|
| 144 |
+
}) {
|
| 145 |
+
const systems = topologies.filter((t) => t.category === "system");
|
| 146 |
+
const pipelines = topologies.filter((t) => t.category === "pipeline");
|
| 147 |
+
|
| 148 |
+
return (
|
| 149 |
+
<div style={panelStyles.root}>
|
| 150 |
+
{/* Auto-detect toggle */}
|
| 151 |
+
<div style={panelStyles.autoRow}>
|
| 152 |
+
<button
|
| 153 |
+
type="button"
|
| 154 |
+
onClick={onToggleAuto}
|
| 155 |
+
style={{
|
| 156 |
+
...panelStyles.autoBtn,
|
| 157 |
+
borderColor: autoMode ? "#ff7a3c" : "#27272A",
|
| 158 |
+
color: autoMode ? "#ff7a3c" : "#71717A",
|
| 159 |
+
backgroundColor: autoMode ? "rgba(255, 122, 60, 0.06)" : "transparent",
|
| 160 |
+
}}
|
| 161 |
+
>
|
| 162 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 163 |
+
<circle cx="12" cy="12" r="10" />
|
| 164 |
+
<path d="M12 6v6l4 2" />
|
| 165 |
+
</svg>
|
| 166 |
+
Auto
|
| 167 |
+
</button>
|
| 168 |
+
{autoMode && autoResult && (
|
| 169 |
+
<span style={panelStyles.autoHint}>
|
| 170 |
+
Detected: {autoResult.icon} {autoResult.name}
|
| 171 |
+
{autoResult.confidence != null && (
|
| 172 |
+
<span style={{ opacity: 0.6 }}>
|
| 173 |
+
{" "}({Math.round(autoResult.confidence * 100)}%)
|
| 174 |
+
</span>
|
| 175 |
+
)}
|
| 176 |
+
</span>
|
| 177 |
+
)}
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
{/* System architectures */}
|
| 181 |
+
<div style={panelStyles.section}>
|
| 182 |
+
<div style={panelStyles.sectionLabel}>System Architectures</div>
|
| 183 |
+
<div style={panelStyles.cardRow}>
|
| 184 |
+
{systems.map((t) => (
|
| 185 |
+
<TopologyCard
|
| 186 |
+
key={t.id}
|
| 187 |
+
topology={t}
|
| 188 |
+
isActive={activeTopology === t.id}
|
| 189 |
+
onClick={() => onSelect(t.id)}
|
| 190 |
+
/>
|
| 191 |
+
))}
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
{/* Task pipelines */}
|
| 196 |
+
<div style={panelStyles.section}>
|
| 197 |
+
<div style={panelStyles.sectionLabel}>Task Pipelines</div>
|
| 198 |
+
<div style={panelStyles.cardRow}>
|
| 199 |
+
{pipelines.map((t) => (
|
| 200 |
+
<TopologyCard
|
| 201 |
+
key={t.id}
|
| 202 |
+
topology={t}
|
| 203 |
+
isActive={activeTopology === t.id}
|
| 204 |
+
onClick={() => onSelect(t.id)}
|
| 205 |
+
/>
|
| 206 |
+
))}
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
const panelStyles = {
|
| 214 |
+
root: {
|
| 215 |
+
padding: "8px 16px 12px",
|
| 216 |
+
borderBottom: "1px solid #1e1f30",
|
| 217 |
+
backgroundColor: "#08090e",
|
| 218 |
+
},
|
| 219 |
+
autoRow: {
|
| 220 |
+
display: "flex",
|
| 221 |
+
alignItems: "center",
|
| 222 |
+
gap: 10,
|
| 223 |
+
marginBottom: 10,
|
| 224 |
+
},
|
| 225 |
+
autoBtn: {
|
| 226 |
+
display: "flex",
|
| 227 |
+
alignItems: "center",
|
| 228 |
+
gap: 5,
|
| 229 |
+
padding: "4px 10px",
|
| 230 |
+
borderRadius: 6,
|
| 231 |
+
border: "1px solid #27272A",
|
| 232 |
+
background: "transparent",
|
| 233 |
+
fontSize: 11,
|
| 234 |
+
fontWeight: 600,
|
| 235 |
+
cursor: "pointer",
|
| 236 |
+
transition: "border-color 0.15s, color 0.15s",
|
| 237 |
+
},
|
| 238 |
+
autoHint: {
|
| 239 |
+
fontSize: 11,
|
| 240 |
+
color: "#9a9bb0",
|
| 241 |
+
},
|
| 242 |
+
section: {
|
| 243 |
+
marginBottom: 8,
|
| 244 |
+
},
|
| 245 |
+
sectionLabel: {
|
| 246 |
+
fontSize: 9,
|
| 247 |
+
fontWeight: 700,
|
| 248 |
+
textTransform: "uppercase",
|
| 249 |
+
letterSpacing: "0.08em",
|
| 250 |
+
color: "#52525B",
|
| 251 |
+
marginBottom: 6,
|
| 252 |
+
},
|
| 253 |
+
cardRow: {
|
| 254 |
+
display: "flex",
|
| 255 |
+
gap: 8,
|
| 256 |
+
overflowX: "auto",
|
| 257 |
+
scrollbarWidth: "none",
|
| 258 |
+
paddingBottom: 2,
|
| 259 |
+
},
|
| 260 |
+
};
|
| 261 |
+
|
| 262 |
+
/* ------------------------------------------------------------------ */
|
| 263 |
+
/* Main FlowViewer component */
|
| 264 |
+
/* ------------------------------------------------------------------ */
|
| 265 |
+
export default function FlowViewer() {
|
| 266 |
+
const [nodes, setNodes] = useState([]);
|
| 267 |
+
const [edges, setEdges] = useState([]);
|
| 268 |
+
const [loading, setLoading] = useState(false);
|
| 269 |
+
const [error, setError] = useState("");
|
| 270 |
+
|
| 271 |
+
// Topology state
|
| 272 |
+
const [topologies, setTopologies] = useState([]);
|
| 273 |
+
const [activeTopology, setActiveTopology] = useState(null);
|
| 274 |
+
const [topologyMeta, setTopologyMeta] = useState(null);
|
| 275 |
+
|
| 276 |
+
// Auto-detection state
|
| 277 |
+
const [autoMode, setAutoMode] = useState(false);
|
| 278 |
+
const [autoResult, setAutoResult] = useState(null);
|
| 279 |
+
const [autoTestMessage, setAutoTestMessage] = useState("");
|
| 280 |
+
|
| 281 |
+
const initialLoadDone = useRef(false);
|
| 282 |
+
|
| 283 |
+
/* ---------- Load topology list on mount ---------- */
|
| 284 |
+
useEffect(() => {
|
| 285 |
+
(async () => {
|
| 286 |
+
try {
|
| 287 |
+
const [topoRes, prefRes] = await Promise.all([
|
| 288 |
+
fetch("/api/flow/topologies"),
|
| 289 |
+
fetch("/api/settings/topology"),
|
| 290 |
+
]);
|
| 291 |
+
if (topoRes.ok) {
|
| 292 |
+
const data = await topoRes.json();
|
| 293 |
+
setTopologies(data);
|
| 294 |
+
}
|
| 295 |
+
if (prefRes.ok) {
|
| 296 |
+
const { topology } = await prefRes.json();
|
| 297 |
+
if (topology) {
|
| 298 |
+
setActiveTopology(topology);
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
} catch (e) {
|
| 302 |
+
console.warn("Failed to load topologies:", e);
|
| 303 |
+
}
|
| 304 |
+
initialLoadDone.current = true;
|
| 305 |
+
})();
|
| 306 |
+
}, []);
|
| 307 |
+
|
| 308 |
+
/* ---------- Load graph when topology changes ---------- */
|
| 309 |
+
const loadGraph = useCallback(async (topologyId) => {
|
| 310 |
+
setLoading(true);
|
| 311 |
+
setError("");
|
| 312 |
+
try {
|
| 313 |
+
const url = topologyId
|
| 314 |
+
? `/api/flow/current?topology=${encodeURIComponent(topologyId)}`
|
| 315 |
+
: "/api/flow/current";
|
| 316 |
+
const res = await fetch(url);
|
| 317 |
+
const data = await res.json();
|
| 318 |
+
if (!res.ok) throw new Error(data.error || "Failed to load flow");
|
| 319 |
+
|
| 320 |
+
// Track topology metadata from response
|
| 321 |
+
if (data.topology_id) {
|
| 322 |
+
setTopologyMeta({
|
| 323 |
+
id: data.topology_id,
|
| 324 |
+
name: data.topology_name,
|
| 325 |
+
icon: data.topology_icon,
|
| 326 |
+
description: data.topology_description,
|
| 327 |
+
execution_style: data.execution_style,
|
| 328 |
+
agents_used: topologies.find((t) => t.id === data.topology_id)?.agents_used || [],
|
| 329 |
+
});
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
// Build ReactFlow nodes
|
| 333 |
+
const RFnodes = data.nodes.map((n, i) => {
|
| 334 |
+
const nodeType = n.type || "default";
|
| 335 |
+
const colour = colourFor(nodeType);
|
| 336 |
+
const d = n.data || {};
|
| 337 |
+
|
| 338 |
+
const label = d.label || n.label || n.id;
|
| 339 |
+
const description = d.description || n.description || "";
|
| 340 |
+
const model = d.model;
|
| 341 |
+
const mode = d.mode;
|
| 342 |
+
|
| 343 |
+
const pos = n.position || {
|
| 344 |
+
x: 50 + (i % 3) * 250,
|
| 345 |
+
y: 50 + Math.floor(i / 3) * 180,
|
| 346 |
+
};
|
| 347 |
+
|
| 348 |
+
return {
|
| 349 |
+
id: n.id,
|
| 350 |
+
data: {
|
| 351 |
+
label: (
|
| 352 |
+
<div style={{ textAlign: "center" }}>
|
| 353 |
+
<div style={{ fontWeight: 600, marginBottom: 2 }}>
|
| 354 |
+
{label}
|
| 355 |
+
</div>
|
| 356 |
+
{model && (
|
| 357 |
+
<div style={{ fontSize: 9, color: "#6c8cff", marginBottom: 2, fontFamily: "monospace" }}>
|
| 358 |
+
{model}
|
| 359 |
+
</div>
|
| 360 |
+
)}
|
| 361 |
+
{mode && (
|
| 362 |
+
<div
|
| 363 |
+
style={{
|
| 364 |
+
fontSize: 9,
|
| 365 |
+
color: mode === "read-only" ? "#4caf88" : mode === "git-ops" ? "#9c6cff" : "#ff7a3c",
|
| 366 |
+
marginBottom: 2,
|
| 367 |
+
}}
|
| 368 |
+
>
|
| 369 |
+
{mode}
|
| 370 |
+
</div>
|
| 371 |
+
)}
|
| 372 |
+
<div style={{ fontSize: 10, color: "#9a9bb0", maxWidth: 160, lineHeight: 1.3 }}>
|
| 373 |
+
{description}
|
| 374 |
+
</div>
|
| 375 |
+
</div>
|
| 376 |
+
),
|
| 377 |
+
},
|
| 378 |
+
position: pos,
|
| 379 |
+
type: "default",
|
| 380 |
+
style: {
|
| 381 |
+
borderRadius: 12,
|
| 382 |
+
padding: "12px 16px",
|
| 383 |
+
border: `2px solid ${colour.border}`,
|
| 384 |
+
background: colour.bg,
|
| 385 |
+
color: "#f5f5f7",
|
| 386 |
+
fontSize: 13,
|
| 387 |
+
minWidth: 180,
|
| 388 |
+
maxWidth: 220,
|
| 389 |
+
},
|
| 390 |
+
};
|
| 391 |
+
});
|
| 392 |
+
|
| 393 |
+
// Build ReactFlow edges
|
| 394 |
+
const RFedges = data.edges.map((e) => ({
|
| 395 |
+
id: e.id,
|
| 396 |
+
source: e.source,
|
| 397 |
+
target: e.target,
|
| 398 |
+
label: e.label,
|
| 399 |
+
animated: e.animated !== false,
|
| 400 |
+
style: { stroke: "#7a7b8e", strokeWidth: 2 },
|
| 401 |
+
labelStyle: { fill: "#c3c5dd", fontSize: 11, fontWeight: 500 },
|
| 402 |
+
labelBgStyle: { fill: "#101117", fillOpacity: 0.9 },
|
| 403 |
+
...(e.type === "bidirectional" && {
|
| 404 |
+
markerEnd: { type: "arrowclosed", color: "#7a7b8e" },
|
| 405 |
+
markerStart: { type: "arrowclosed", color: "#7a7b8e" },
|
| 406 |
+
animated: false,
|
| 407 |
+
style: { stroke: "#555670", strokeWidth: 1.5, strokeDasharray: "5 5" },
|
| 408 |
+
}),
|
| 409 |
+
}));
|
| 410 |
+
|
| 411 |
+
setNodes(RFnodes);
|
| 412 |
+
setEdges(RFedges);
|
| 413 |
+
} catch (e) {
|
| 414 |
+
console.error(e);
|
| 415 |
+
setError(e.message);
|
| 416 |
+
} finally {
|
| 417 |
+
setLoading(false);
|
| 418 |
+
}
|
| 419 |
+
}, [topologies]);
|
| 420 |
+
|
| 421 |
+
// Load graph whenever activeTopology changes
|
| 422 |
+
useEffect(() => {
|
| 423 |
+
loadGraph(activeTopology);
|
| 424 |
+
}, [activeTopology, loadGraph]);
|
| 425 |
+
|
| 426 |
+
/* ---------- Topology selection handler ---------- */
|
| 427 |
+
const handleTopologyChange = useCallback(
|
| 428 |
+
async (newTopologyId) => {
|
| 429 |
+
setActiveTopology(newTopologyId);
|
| 430 |
+
setAutoMode(false); // Manual selection disables auto
|
| 431 |
+
// Persist preference (fire-and-forget)
|
| 432 |
+
try {
|
| 433 |
+
await fetch("/api/settings/topology", {
|
| 434 |
+
method: "POST",
|
| 435 |
+
headers: { "Content-Type": "application/json" },
|
| 436 |
+
body: JSON.stringify({ topology: newTopologyId }),
|
| 437 |
+
});
|
| 438 |
+
} catch (e) {
|
| 439 |
+
console.warn("Failed to save topology preference:", e);
|
| 440 |
+
}
|
| 441 |
+
},
|
| 442 |
+
[]
|
| 443 |
+
);
|
| 444 |
+
|
| 445 |
+
/* ---------- Auto-detection ---------- */
|
| 446 |
+
const handleToggleAuto = useCallback(() => {
|
| 447 |
+
setAutoMode((prev) => !prev);
|
| 448 |
+
if (!autoMode) {
|
| 449 |
+
setAutoResult(null);
|
| 450 |
+
}
|
| 451 |
+
}, [autoMode]);
|
| 452 |
+
|
| 453 |
+
const handleAutoClassify = useCallback(
|
| 454 |
+
async (message) => {
|
| 455 |
+
if (!message.trim()) return;
|
| 456 |
+
try {
|
| 457 |
+
const res = await fetch("/api/flow/classify", {
|
| 458 |
+
method: "POST",
|
| 459 |
+
headers: { "Content-Type": "application/json" },
|
| 460 |
+
body: JSON.stringify({ message }),
|
| 461 |
+
});
|
| 462 |
+
if (!res.ok) return;
|
| 463 |
+
const data = await res.json();
|
| 464 |
+
const recommendedId = data.recommended_topology;
|
| 465 |
+
const topo = topologies.find((t) => t.id === recommendedId);
|
| 466 |
+
setAutoResult({
|
| 467 |
+
id: recommendedId,
|
| 468 |
+
name: topo?.name || recommendedId,
|
| 469 |
+
icon: topo?.icon || "",
|
| 470 |
+
confidence: data.confidence,
|
| 471 |
+
alternatives: data.alternatives || [],
|
| 472 |
+
});
|
| 473 |
+
setActiveTopology(recommendedId);
|
| 474 |
+
} catch (e) {
|
| 475 |
+
console.warn("Auto-classify failed:", e);
|
| 476 |
+
}
|
| 477 |
+
},
|
| 478 |
+
[topologies]
|
| 479 |
+
);
|
| 480 |
+
|
| 481 |
+
// Debounced auto-classify when test message changes
|
| 482 |
+
useEffect(() => {
|
| 483 |
+
if (!autoMode || !autoTestMessage.trim()) return;
|
| 484 |
+
const t = setTimeout(() => handleAutoClassify(autoTestMessage), 500);
|
| 485 |
+
return () => clearTimeout(t);
|
| 486 |
+
}, [autoTestMessage, autoMode, handleAutoClassify]);
|
| 487 |
+
|
| 488 |
+
/* ---------- Render ---------- */
|
| 489 |
+
const activeStyleColor = STYLE_COLOURS[topologyMeta?.execution_style] || "#9a9bb0";
|
| 490 |
+
|
| 491 |
+
return (
|
| 492 |
+
<div className="flow-root">
|
| 493 |
+
{/* Header */}
|
| 494 |
+
<div className="flow-header">
|
| 495 |
+
<div>
|
| 496 |
+
<h1>Agent Workflow</h1>
|
| 497 |
+
<p>
|
| 498 |
+
Visual view of the multi-agent system that GitPilot uses to
|
| 499 |
+
plan and apply changes to your repositories.
|
| 500 |
+
</p>
|
| 501 |
+
</div>
|
| 502 |
+
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
| 503 |
+
{topologyMeta && (
|
| 504 |
+
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "#9a9bb0" }}>
|
| 505 |
+
<span style={{ fontSize: 18 }}>{topologyMeta.icon}</span>
|
| 506 |
+
<span style={{ fontWeight: 600, color: "#e0e1f0" }}>{topologyMeta.name}</span>
|
| 507 |
+
<span
|
| 508 |
+
style={{
|
| 509 |
+
padding: "2px 8px",
|
| 510 |
+
borderRadius: 6,
|
| 511 |
+
border: `1px solid ${activeStyleColor}40`,
|
| 512 |
+
color: activeStyleColor,
|
| 513 |
+
fontSize: 10,
|
| 514 |
+
fontWeight: 700,
|
| 515 |
+
textTransform: "uppercase",
|
| 516 |
+
}}
|
| 517 |
+
>
|
| 518 |
+
{STYLE_LABELS[topologyMeta.execution_style] || topologyMeta.execution_style}
|
| 519 |
+
</span>
|
| 520 |
+
<span>{topologyMeta.agents_used?.length || 0} agents</span>
|
| 521 |
+
</div>
|
| 522 |
+
)}
|
| 523 |
+
{loading && <span style={{ fontSize: 11, color: "#6c8cff" }}>Loading...</span>}
|
| 524 |
+
</div>
|
| 525 |
+
</div>
|
| 526 |
+
|
| 527 |
+
{/* Topology selector panel */}
|
| 528 |
+
{topologies.length > 0 && (
|
| 529 |
+
<TopologyPanel
|
| 530 |
+
topologies={topologies}
|
| 531 |
+
activeTopology={activeTopology}
|
| 532 |
+
autoMode={autoMode}
|
| 533 |
+
autoResult={autoResult}
|
| 534 |
+
onSelect={handleTopologyChange}
|
| 535 |
+
onToggleAuto={handleToggleAuto}
|
| 536 |
+
/>
|
| 537 |
+
)}
|
| 538 |
+
|
| 539 |
+
{/* Auto-detection test input (shown when auto mode is on) */}
|
| 540 |
+
{autoMode && (
|
| 541 |
+
<div style={autoInputStyles.wrap}>
|
| 542 |
+
<div style={autoInputStyles.label}>
|
| 543 |
+
Test auto-detection: type a task description to see which topology is recommended
|
| 544 |
+
</div>
|
| 545 |
+
<input
|
| 546 |
+
type="text"
|
| 547 |
+
placeholder='e.g. "Fix the 403 error in auth middleware" or "Add a REST endpoint for users"'
|
| 548 |
+
value={autoTestMessage}
|
| 549 |
+
onChange={(e) => setAutoTestMessage(e.target.value)}
|
| 550 |
+
style={autoInputStyles.input}
|
| 551 |
+
/>
|
| 552 |
+
{autoResult && autoResult.alternatives?.length > 0 && (
|
| 553 |
+
<div style={autoInputStyles.altRow}>
|
| 554 |
+
<span style={{ color: "#52525B", fontSize: 10 }}>Alternatives:</span>
|
| 555 |
+
{autoResult.alternatives.slice(0, 3).map((alt) => {
|
| 556 |
+
const altTopo = topologies.find((t) => t.id === alt.id);
|
| 557 |
+
return (
|
| 558 |
+
<button
|
| 559 |
+
key={alt.id}
|
| 560 |
+
type="button"
|
| 561 |
+
style={autoInputStyles.altBtn}
|
| 562 |
+
onClick={() => handleTopologyChange(alt.id)}
|
| 563 |
+
>
|
| 564 |
+
{altTopo?.icon} {altTopo?.name || alt.id}
|
| 565 |
+
<span style={{ opacity: 0.5 }}>
|
| 566 |
+
{alt.confidence != null ? ` ${Math.round(alt.confidence * 100)}%` : ""}
|
| 567 |
+
</span>
|
| 568 |
+
</button>
|
| 569 |
+
);
|
| 570 |
+
})}
|
| 571 |
+
</div>
|
| 572 |
+
)}
|
| 573 |
+
</div>
|
| 574 |
+
)}
|
| 575 |
+
|
| 576 |
+
{/* Description bar */}
|
| 577 |
+
{topologyMeta && topologyMeta.description && !autoMode && (
|
| 578 |
+
<div
|
| 579 |
+
style={{
|
| 580 |
+
padding: "8px 16px",
|
| 581 |
+
fontSize: 12,
|
| 582 |
+
color: "#9a9bb0",
|
| 583 |
+
background: "#0a0b12",
|
| 584 |
+
borderBottom: "1px solid #1e1f30",
|
| 585 |
+
}}
|
| 586 |
+
>
|
| 587 |
+
{topologyMeta.icon} {topologyMeta.description}
|
| 588 |
+
</div>
|
| 589 |
+
)}
|
| 590 |
+
|
| 591 |
+
{/* ReactFlow canvas */}
|
| 592 |
+
<div className="flow-canvas">
|
| 593 |
+
{error ? (
|
| 594 |
+
<div className="flow-error">
|
| 595 |
+
<div className="error-icon">!!!</div>
|
| 596 |
+
<div className="error-text">{error}</div>
|
| 597 |
+
</div>
|
| 598 |
+
) : (
|
| 599 |
+
<ReactFlow nodes={nodes} edges={edges} fitView>
|
| 600 |
+
<Background color="#272832" gap={16} />
|
| 601 |
+
<MiniMap
|
| 602 |
+
nodeColor={(node) => {
|
| 603 |
+
const border = node.style?.border || "";
|
| 604 |
+
if (border.includes("#ff7a3c")) return "#ff7a3c";
|
| 605 |
+
if (border.includes("#6c8cff")) return "#6c8cff";
|
| 606 |
+
if (border.includes("#4caf88")) return "#4caf88";
|
| 607 |
+
if (border.includes("#9c6cff")) return "#9c6cff";
|
| 608 |
+
return "#3a3b4d";
|
| 609 |
+
}}
|
| 610 |
+
maskColor="rgba(0, 0, 0, 0.6)"
|
| 611 |
+
/>
|
| 612 |
+
<Controls />
|
| 613 |
+
</ReactFlow>
|
| 614 |
+
)}
|
| 615 |
+
</div>
|
| 616 |
+
</div>
|
| 617 |
+
);
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
const autoInputStyles = {
|
| 621 |
+
wrap: {
|
| 622 |
+
padding: "8px 16px 10px",
|
| 623 |
+
borderBottom: "1px solid #1e1f30",
|
| 624 |
+
backgroundColor: "#0c0d14",
|
| 625 |
+
},
|
| 626 |
+
label: {
|
| 627 |
+
fontSize: 10,
|
| 628 |
+
color: "#71717A",
|
| 629 |
+
marginBottom: 6,
|
| 630 |
+
},
|
| 631 |
+
input: {
|
| 632 |
+
width: "100%",
|
| 633 |
+
padding: "8px 12px",
|
| 634 |
+
borderRadius: 6,
|
| 635 |
+
border: "1px solid #27272A",
|
| 636 |
+
background: "#08090e",
|
| 637 |
+
color: "#e0e1f0",
|
| 638 |
+
fontSize: 12,
|
| 639 |
+
fontFamily: "monospace",
|
| 640 |
+
outline: "none",
|
| 641 |
+
boxSizing: "border-box",
|
| 642 |
+
},
|
| 643 |
+
altRow: {
|
| 644 |
+
display: "flex",
|
| 645 |
+
alignItems: "center",
|
| 646 |
+
gap: 6,
|
| 647 |
+
marginTop: 6,
|
| 648 |
+
flexWrap: "wrap",
|
| 649 |
+
},
|
| 650 |
+
altBtn: {
|
| 651 |
+
padding: "2px 8px",
|
| 652 |
+
borderRadius: 4,
|
| 653 |
+
border: "1px solid #27272A",
|
| 654 |
+
background: "transparent",
|
| 655 |
+
color: "#9a9bb0",
|
| 656 |
+
fontSize: 10,
|
| 657 |
+
cursor: "pointer",
|
| 658 |
+
},
|
| 659 |
+
};
|
frontend/components/Footer.jsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
|
| 3 |
+
export default function Footer() {
|
| 4 |
+
return (
|
| 5 |
+
<footer className="gp-footer">
|
| 6 |
+
<div className="gp-footer-left">
|
| 7 |
+
<a
|
| 8 |
+
href="https://github.com/ruslanmv/gitpilot"
|
| 9 |
+
target="_blank"
|
| 10 |
+
rel="noopener noreferrer"
|
| 11 |
+
style={{
|
| 12 |
+
color: "inherit",
|
| 13 |
+
textDecoration: "none",
|
| 14 |
+
display: "flex",
|
| 15 |
+
alignItems: "center",
|
| 16 |
+
gap: "6px",
|
| 17 |
+
transition: "color 0.2s ease",
|
| 18 |
+
}}
|
| 19 |
+
onMouseOver={(e) => {
|
| 20 |
+
e.currentTarget.style.color = "#ff7a3c";
|
| 21 |
+
}}
|
| 22 |
+
onMouseOut={(e) => {
|
| 23 |
+
e.currentTarget.style.color = "#c3c5dd";
|
| 24 |
+
}}
|
| 25 |
+
>
|
| 26 |
+
⭐ Star our GitHub project
|
| 27 |
+
</a>
|
| 28 |
+
</div>
|
| 29 |
+
<div className="gp-footer-right">
|
| 30 |
+
<span>© 2025 GitPilot</span>
|
| 31 |
+
<a
|
| 32 |
+
href="https://github.com/ruslanmv/gitpilot"
|
| 33 |
+
target="_blank"
|
| 34 |
+
rel="noopener noreferrer"
|
| 35 |
+
>
|
| 36 |
+
Docs
|
| 37 |
+
</a>
|
| 38 |
+
<a
|
| 39 |
+
href="https://github.com/ruslanmv/gitpilot"
|
| 40 |
+
target="_blank"
|
| 41 |
+
rel="noopener noreferrer"
|
| 42 |
+
>
|
| 43 |
+
GitHub
|
| 44 |
+
</a>
|
| 45 |
+
</div>
|
| 46 |
+
</footer>
|
| 47 |
+
);
|
| 48 |
+
}
|
frontend/components/LlmSettings.jsx
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useMemo, useState } from "react";
|
| 2 |
+
import { testProvider } from "../utils/api";
|
| 3 |
+
|
| 4 |
+
const PROVIDERS = ["ollabridge", "openai", "claude", "watsonx", "ollama"];
|
| 5 |
+
|
| 6 |
+
const PROVIDER_LABELS = {
|
| 7 |
+
ollabridge: "OllaBridge Cloud",
|
| 8 |
+
openai: "OpenAI",
|
| 9 |
+
claude: "Claude",
|
| 10 |
+
watsonx: "Watsonx",
|
| 11 |
+
ollama: "Ollama",
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
const AUTH_MODES = [
|
| 15 |
+
{ id: "device", label: "Device Pairing", icon: "📱" },
|
| 16 |
+
{ id: "apikey", label: "API Key", icon: "🔑" },
|
| 17 |
+
{ id: "local", label: "Local Trust", icon: "🏠" },
|
| 18 |
+
];
|
| 19 |
+
|
| 20 |
+
function LoadingState({ loadingMessage, loadingSlow, onRetry }) {
|
| 21 |
+
return (
|
| 22 |
+
<div className="settings-loading-shell">
|
| 23 |
+
<div className="settings-loading-card">
|
| 24 |
+
<div className="settings-loading-spinner" aria-hidden="true" />
|
| 25 |
+
<h1>AI Providers</h1>
|
| 26 |
+
<div className="settings-loading-subtitle">Admin / LLM Settings</div>
|
| 27 |
+
<p className="settings-loading-text">{loadingMessage}</p>
|
| 28 |
+
|
| 29 |
+
{loadingSlow && (
|
| 30 |
+
<div className="settings-loading-slow">
|
| 31 |
+
<p>
|
| 32 |
+
This is taking longer than expected. The backend may still be
|
| 33 |
+
starting or the settings endpoint may be slow.
|
| 34 |
+
</p>
|
| 35 |
+
<button
|
| 36 |
+
type="button"
|
| 37 |
+
className="settings-secondary-btn"
|
| 38 |
+
onClick={onRetry}
|
| 39 |
+
>
|
| 40 |
+
Retry
|
| 41 |
+
</button>
|
| 42 |
+
</div>
|
| 43 |
+
)}
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export default function LlmSettings() {
|
| 50 |
+
const [settings, setSettings] = useState(null);
|
| 51 |
+
const [initialLoading, setInitialLoading] = useState(true);
|
| 52 |
+
const [loadingSlow, setLoadingSlow] = useState(false);
|
| 53 |
+
|
| 54 |
+
const [saving, setSaving] = useState(false);
|
| 55 |
+
const [error, setError] = useState("");
|
| 56 |
+
const [savedMsg, setSavedMsg] = useState("");
|
| 57 |
+
|
| 58 |
+
const [modelsByProvider, setModelsByProvider] = useState({});
|
| 59 |
+
const [modelsError, setModelsError] = useState("");
|
| 60 |
+
const [loadingModelsFor, setLoadingModelsFor] = useState("");
|
| 61 |
+
|
| 62 |
+
const [testResult, setTestResult] = useState(null);
|
| 63 |
+
const [testing, setTesting] = useState(false);
|
| 64 |
+
|
| 65 |
+
const [authMode, setAuthMode] = useState("local");
|
| 66 |
+
const [pairCode, setPairCode] = useState("");
|
| 67 |
+
const [pairing, setPairing] = useState(false);
|
| 68 |
+
const [pairResult, setPairResult] = useState(null);
|
| 69 |
+
|
| 70 |
+
const loadingMessage = useMemo(() => {
|
| 71 |
+
if (loadingSlow) {
|
| 72 |
+
return "Still loading provider configuration…";
|
| 73 |
+
}
|
| 74 |
+
return "Loading current configuration…";
|
| 75 |
+
}, [loadingSlow]);
|
| 76 |
+
|
| 77 |
+
const loadSettings = async () => {
|
| 78 |
+
setInitialLoading(true);
|
| 79 |
+
setError("");
|
| 80 |
+
setLoadingSlow(false);
|
| 81 |
+
|
| 82 |
+
let slowTimer;
|
| 83 |
+
try {
|
| 84 |
+
slowTimer = window.setTimeout(() => {
|
| 85 |
+
setLoadingSlow(true);
|
| 86 |
+
}, 1500);
|
| 87 |
+
|
| 88 |
+
const res = await fetch("/api/settings");
|
| 89 |
+
const data = await res.json();
|
| 90 |
+
|
| 91 |
+
if (!res.ok) {
|
| 92 |
+
throw new Error(data.error || "Failed to load settings");
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
setSettings(data);
|
| 96 |
+
} catch (e) {
|
| 97 |
+
console.error(e);
|
| 98 |
+
setError(e.message || "Failed to load settings");
|
| 99 |
+
} finally {
|
| 100 |
+
window.clearTimeout(slowTimer);
|
| 101 |
+
setInitialLoading(false);
|
| 102 |
+
}
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
useEffect(() => {
|
| 106 |
+
loadSettings();
|
| 107 |
+
}, []);
|
| 108 |
+
|
| 109 |
+
const updateField = (section, field, value) => {
|
| 110 |
+
setSettings((prev) => ({
|
| 111 |
+
...prev,
|
| 112 |
+
[section]: {
|
| 113 |
+
...prev[section],
|
| 114 |
+
[field]: value,
|
| 115 |
+
},
|
| 116 |
+
}));
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
const handleSave = async () => {
|
| 120 |
+
setSaving(true);
|
| 121 |
+
setError("");
|
| 122 |
+
setSavedMsg("");
|
| 123 |
+
|
| 124 |
+
try {
|
| 125 |
+
const res = await fetch("/api/settings/llm", {
|
| 126 |
+
method: "PUT",
|
| 127 |
+
headers: { "Content-Type": "application/json" },
|
| 128 |
+
body: JSON.stringify(settings),
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
const data = await res.json();
|
| 132 |
+
if (!res.ok) throw new Error(data.error || "Failed to save settings");
|
| 133 |
+
|
| 134 |
+
setSettings(data);
|
| 135 |
+
setSavedMsg("Settings saved successfully!");
|
| 136 |
+
setTimeout(() => setSavedMsg(""), 3000);
|
| 137 |
+
} catch (e) {
|
| 138 |
+
console.error(e);
|
| 139 |
+
setError(e.message || "Failed to save settings");
|
| 140 |
+
} finally {
|
| 141 |
+
setSaving(false);
|
| 142 |
+
}
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
const loadModelsForProvider = async (provider) => {
|
| 146 |
+
setModelsError("");
|
| 147 |
+
setLoadingModelsFor(provider);
|
| 148 |
+
|
| 149 |
+
try {
|
| 150 |
+
const res = await fetch(`/api/settings/models?provider=${provider}`);
|
| 151 |
+
const data = await res.json();
|
| 152 |
+
|
| 153 |
+
if (!res.ok || data.error) {
|
| 154 |
+
throw new Error(data.error || "Failed to load models");
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
setModelsByProvider((prev) => ({
|
| 158 |
+
...prev,
|
| 159 |
+
[provider]: data.models || [],
|
| 160 |
+
}));
|
| 161 |
+
} catch (e) {
|
| 162 |
+
console.error(e);
|
| 163 |
+
setModelsError(e.message || "Failed to load models");
|
| 164 |
+
} finally {
|
| 165 |
+
setLoadingModelsFor("");
|
| 166 |
+
}
|
| 167 |
+
};
|
| 168 |
+
|
| 169 |
+
const handlePair = async () => {
|
| 170 |
+
if (!pairCode.trim()) return;
|
| 171 |
+
|
| 172 |
+
setPairing(true);
|
| 173 |
+
setPairResult(null);
|
| 174 |
+
|
| 175 |
+
try {
|
| 176 |
+
const baseUrl =
|
| 177 |
+
settings?.ollabridge?.base_url || "https://ruslanmv-ollabridge.hf.space";
|
| 178 |
+
|
| 179 |
+
const res = await fetch("/api/ollabridge/pair", {
|
| 180 |
+
method: "POST",
|
| 181 |
+
headers: { "Content-Type": "application/json" },
|
| 182 |
+
body: JSON.stringify({ base_url: baseUrl, code: pairCode.trim() }),
|
| 183 |
+
});
|
| 184 |
+
|
| 185 |
+
const data = await res.json();
|
| 186 |
+
|
| 187 |
+
if (data.success) {
|
| 188 |
+
setPairResult({ ok: true, message: "Paired successfully!" });
|
| 189 |
+
if (data.token) {
|
| 190 |
+
updateField("ollabridge", "api_key", data.token);
|
| 191 |
+
}
|
| 192 |
+
} else {
|
| 193 |
+
setPairResult({
|
| 194 |
+
ok: false,
|
| 195 |
+
message: data.error || "Pairing failed",
|
| 196 |
+
});
|
| 197 |
+
}
|
| 198 |
+
} catch (e) {
|
| 199 |
+
setPairResult({ ok: false, message: e.message || "Pairing failed" });
|
| 200 |
+
} finally {
|
| 201 |
+
setPairing(false);
|
| 202 |
+
}
|
| 203 |
+
};
|
| 204 |
+
|
| 205 |
+
const handleTestConnection = async () => {
|
| 206 |
+
setTesting(true);
|
| 207 |
+
setTestResult(null);
|
| 208 |
+
|
| 209 |
+
try {
|
| 210 |
+
const activeProvider = settings?.provider || "ollama";
|
| 211 |
+
const config = { provider: activeProvider };
|
| 212 |
+
|
| 213 |
+
if (activeProvider === "openai" && settings?.openai) {
|
| 214 |
+
config.openai = {
|
| 215 |
+
api_key: settings.openai.api_key,
|
| 216 |
+
base_url: settings.openai.base_url,
|
| 217 |
+
model: settings.openai.model,
|
| 218 |
+
};
|
| 219 |
+
} else if (activeProvider === "claude" && settings?.claude) {
|
| 220 |
+
config.claude = {
|
| 221 |
+
api_key: settings.claude.api_key,
|
| 222 |
+
base_url: settings.claude.base_url,
|
| 223 |
+
model: settings.claude.model,
|
| 224 |
+
};
|
| 225 |
+
} else if (activeProvider === "watsonx" && settings?.watsonx) {
|
| 226 |
+
config.watsonx = {
|
| 227 |
+
api_key: settings.watsonx.api_key,
|
| 228 |
+
project_id: settings.watsonx.project_id,
|
| 229 |
+
base_url: settings.watsonx.base_url,
|
| 230 |
+
model_id: settings.watsonx.model_id,
|
| 231 |
+
};
|
| 232 |
+
} else if (activeProvider === "ollama" && settings?.ollama) {
|
| 233 |
+
config.ollama = {
|
| 234 |
+
base_url: settings.ollama.base_url,
|
| 235 |
+
model: settings.ollama.model,
|
| 236 |
+
};
|
| 237 |
+
} else if (activeProvider === "ollabridge" && settings?.ollabridge) {
|
| 238 |
+
config.ollabridge = {
|
| 239 |
+
base_url: settings.ollabridge.base_url,
|
| 240 |
+
model: settings.ollabridge.model,
|
| 241 |
+
api_key: settings.ollabridge.api_key,
|
| 242 |
+
};
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
const result = await testProvider(config);
|
| 246 |
+
setTestResult(result);
|
| 247 |
+
} catch (err) {
|
| 248 |
+
setTestResult({
|
| 249 |
+
health: "error",
|
| 250 |
+
warning: err.message || "Test failed",
|
| 251 |
+
});
|
| 252 |
+
} finally {
|
| 253 |
+
setTesting(false);
|
| 254 |
+
}
|
| 255 |
+
};
|
| 256 |
+
|
| 257 |
+
if (initialLoading) {
|
| 258 |
+
return (
|
| 259 |
+
<LoadingState
|
| 260 |
+
loadingMessage={loadingMessage}
|
| 261 |
+
loadingSlow={loadingSlow}
|
| 262 |
+
onRetry={loadSettings}
|
| 263 |
+
/>
|
| 264 |
+
);
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
if (!settings) {
|
| 268 |
+
return (
|
| 269 |
+
<div className="settings-root">
|
| 270 |
+
<div className="settings-inline-error-card">
|
| 271 |
+
<h1>AI Providers</h1>
|
| 272 |
+
<div className="settings-loading-subtitle">Admin / LLM Settings</div>
|
| 273 |
+
<p className="settings-error-text">
|
| 274 |
+
{error || "Unable to load current configuration."}
|
| 275 |
+
</p>
|
| 276 |
+
<button
|
| 277 |
+
type="button"
|
| 278 |
+
className="settings-secondary-btn"
|
| 279 |
+
onClick={loadSettings}
|
| 280 |
+
>
|
| 281 |
+
Retry
|
| 282 |
+
</button>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
const { provider } = settings;
|
| 289 |
+
const availableModels = modelsByProvider[provider] || [];
|
| 290 |
+
|
| 291 |
+
return (
|
| 292 |
+
<div className="settings-root">
|
| 293 |
+
<h1>AI Providers</h1>
|
| 294 |
+
<p className="settings-muted">
|
| 295 |
+
Choose which LLM provider GitPilot should use for planning and agent
|
| 296 |
+
workflows. Provider settings are stored on the server.
|
| 297 |
+
</p>
|
| 298 |
+
|
| 299 |
+
{error && <div className="settings-error-banner">{error}</div>}
|
| 300 |
+
{savedMsg && <div className="settings-success-banner">{savedMsg}</div>}
|
| 301 |
+
|
| 302 |
+
<div className="settings-card">
|
| 303 |
+
<label className="settings-label">Active provider</label>
|
| 304 |
+
<div className="settings-provider-tabs">
|
| 305 |
+
{PROVIDERS.map((p) => (
|
| 306 |
+
<button
|
| 307 |
+
key={p}
|
| 308 |
+
type="button"
|
| 309 |
+
className={
|
| 310 |
+
"settings-provider-tab" +
|
| 311 |
+
(provider === p ? " settings-provider-tab-active" : "")
|
| 312 |
+
}
|
| 313 |
+
onClick={() => setSettings((prev) => ({ ...prev, provider: p }))}
|
| 314 |
+
>
|
| 315 |
+
{PROVIDER_LABELS[p] || p}
|
| 316 |
+
</button>
|
| 317 |
+
))}
|
| 318 |
+
</div>
|
| 319 |
+
</div>
|
| 320 |
+
|
| 321 |
+
{provider === "ollabridge" && (
|
| 322 |
+
<div className="settings-card">
|
| 323 |
+
<div className="settings-title">OllaBridge Cloud Configuration</div>
|
| 324 |
+
<div className="settings-hint" style={{ marginBottom: 12 }}>
|
| 325 |
+
Connect to OllaBridge Cloud or any OllaBridge instance for LLM
|
| 326 |
+
inference. No API key required for public endpoints.
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
<label className="settings-label">Authentication Mode</label>
|
| 330 |
+
<div className="ob-auth-tabs">
|
| 331 |
+
{AUTH_MODES.map((m) => (
|
| 332 |
+
<button
|
| 333 |
+
key={m.id}
|
| 334 |
+
type="button"
|
| 335 |
+
className={
|
| 336 |
+
"ob-auth-tab" +
|
| 337 |
+
(authMode === m.id ? " ob-auth-tab-active" : "")
|
| 338 |
+
}
|
| 339 |
+
onClick={() => setAuthMode(m.id)}
|
| 340 |
+
>
|
| 341 |
+
<span className="ob-auth-tab-icon">{m.icon}</span>
|
| 342 |
+
<span>{m.label}</span>
|
| 343 |
+
</button>
|
| 344 |
+
))}
|
| 345 |
+
</div>
|
| 346 |
+
|
| 347 |
+
{authMode === "device" && (
|
| 348 |
+
<div className="ob-auth-panel">
|
| 349 |
+
<div className="ob-auth-desc">
|
| 350 |
+
Enter the pairing code from your OllaBridge console and click
|
| 351 |
+
Pair.
|
| 352 |
+
</div>
|
| 353 |
+
<div className="ob-pair-row">
|
| 354 |
+
<input
|
| 355 |
+
className="settings-input ob-pair-input"
|
| 356 |
+
type="text"
|
| 357 |
+
maxLength={9}
|
| 358 |
+
placeholder="ABCD-1234"
|
| 359 |
+
value={pairCode}
|
| 360 |
+
onChange={(e) => setPairCode(e.target.value.toUpperCase())}
|
| 361 |
+
onKeyDown={(e) => e.key === "Enter" && handlePair()}
|
| 362 |
+
/>
|
| 363 |
+
<button
|
| 364 |
+
type="button"
|
| 365 |
+
className="ob-pair-btn"
|
| 366 |
+
onClick={handlePair}
|
| 367 |
+
disabled={pairing || !pairCode.trim()}
|
| 368 |
+
>
|
| 369 |
+
{pairing ? "Pairing…" : "Pair"}
|
| 370 |
+
</button>
|
| 371 |
+
</div>
|
| 372 |
+
{pairResult && (
|
| 373 |
+
<div
|
| 374 |
+
className={
|
| 375 |
+
pairResult.ok ? "settings-success-banner" : "settings-error-banner"
|
| 376 |
+
}
|
| 377 |
+
>
|
| 378 |
+
{pairResult.message}
|
| 379 |
+
</div>
|
| 380 |
+
)}
|
| 381 |
+
</div>
|
| 382 |
+
)}
|
| 383 |
+
|
| 384 |
+
<label className="settings-label">Base URL</label>
|
| 385 |
+
<input
|
| 386 |
+
className="settings-input"
|
| 387 |
+
value={settings.ollabridge?.base_url || ""}
|
| 388 |
+
onChange={(e) =>
|
| 389 |
+
updateField("ollabridge", "base_url", e.target.value)
|
| 390 |
+
}
|
| 391 |
+
placeholder="https://your-ollabridge-endpoint"
|
| 392 |
+
/>
|
| 393 |
+
|
| 394 |
+
{(authMode === "apikey" || authMode === "local") && (
|
| 395 |
+
<>
|
| 396 |
+
<label className="settings-label">API Key</label>
|
| 397 |
+
<input
|
| 398 |
+
className="settings-input"
|
| 399 |
+
type="password"
|
| 400 |
+
value={settings.ollabridge?.api_key || ""}
|
| 401 |
+
onChange={(e) =>
|
| 402 |
+
updateField("ollabridge", "api_key", e.target.value)
|
| 403 |
+
}
|
| 404 |
+
placeholder="Optional API key"
|
| 405 |
+
/>
|
| 406 |
+
</>
|
| 407 |
+
)}
|
| 408 |
+
|
| 409 |
+
<label className="settings-label">Model</label>
|
| 410 |
+
<div className="settings-inline-row">
|
| 411 |
+
<input
|
| 412 |
+
className="settings-input"
|
| 413 |
+
value={settings.ollabridge?.model || ""}
|
| 414 |
+
onChange={(e) =>
|
| 415 |
+
updateField("ollabridge", "model", e.target.value)
|
| 416 |
+
}
|
| 417 |
+
placeholder="qwen2.5:1.5b"
|
| 418 |
+
/>
|
| 419 |
+
<button
|
| 420 |
+
type="button"
|
| 421 |
+
className="settings-secondary-btn"
|
| 422 |
+
onClick={() => loadModelsForProvider("ollabridge")}
|
| 423 |
+
disabled={loadingModelsFor === "ollabridge"}
|
| 424 |
+
>
|
| 425 |
+
{loadingModelsFor === "ollabridge" ? "Loading…" : "Load Models"}
|
| 426 |
+
</button>
|
| 427 |
+
</div>
|
| 428 |
+
</div>
|
| 429 |
+
)}
|
| 430 |
+
|
| 431 |
+
{provider === "openai" && (
|
| 432 |
+
<div className="settings-card">
|
| 433 |
+
<div className="settings-title">OpenAI Configuration</div>
|
| 434 |
+
|
| 435 |
+
<label className="settings-label">API Key</label>
|
| 436 |
+
<input
|
| 437 |
+
className="settings-input"
|
| 438 |
+
type="password"
|
| 439 |
+
value={settings.openai?.api_key || ""}
|
| 440 |
+
onChange={(e) => updateField("openai", "api_key", e.target.value)}
|
| 441 |
+
placeholder="sk-..."
|
| 442 |
+
/>
|
| 443 |
+
|
| 444 |
+
<label className="settings-label">Base URL</label>
|
| 445 |
+
<input
|
| 446 |
+
className="settings-input"
|
| 447 |
+
value={settings.openai?.base_url || ""}
|
| 448 |
+
onChange={(e) => updateField("openai", "base_url", e.target.value)}
|
| 449 |
+
placeholder="Optional custom base URL"
|
| 450 |
+
/>
|
| 451 |
+
|
| 452 |
+
<label className="settings-label">Model</label>
|
| 453 |
+
<input
|
| 454 |
+
className="settings-input"
|
| 455 |
+
value={settings.openai?.model || ""}
|
| 456 |
+
onChange={(e) => updateField("openai", "model", e.target.value)}
|
| 457 |
+
placeholder="gpt-4o-mini"
|
| 458 |
+
/>
|
| 459 |
+
</div>
|
| 460 |
+
)}
|
| 461 |
+
|
| 462 |
+
{provider === "claude" && (
|
| 463 |
+
<div className="settings-card">
|
| 464 |
+
<div className="settings-title">Claude Configuration</div>
|
| 465 |
+
|
| 466 |
+
<label className="settings-label">API Key</label>
|
| 467 |
+
<input
|
| 468 |
+
className="settings-input"
|
| 469 |
+
type="password"
|
| 470 |
+
value={settings.claude?.api_key || ""}
|
| 471 |
+
onChange={(e) => updateField("claude", "api_key", e.target.value)}
|
| 472 |
+
placeholder="Anthropic API key"
|
| 473 |
+
/>
|
| 474 |
+
|
| 475 |
+
<label className="settings-label">Base URL</label>
|
| 476 |
+
<input
|
| 477 |
+
className="settings-input"
|
| 478 |
+
value={settings.claude?.base_url || ""}
|
| 479 |
+
onChange={(e) => updateField("claude", "base_url", e.target.value)}
|
| 480 |
+
placeholder="Optional custom base URL"
|
| 481 |
+
/>
|
| 482 |
+
|
| 483 |
+
<label className="settings-label">Model</label>
|
| 484 |
+
<input
|
| 485 |
+
className="settings-input"
|
| 486 |
+
value={settings.claude?.model || ""}
|
| 487 |
+
onChange={(e) => updateField("claude", "model", e.target.value)}
|
| 488 |
+
placeholder="claude-sonnet-4-5"
|
| 489 |
+
/>
|
| 490 |
+
</div>
|
| 491 |
+
)}
|
| 492 |
+
|
| 493 |
+
{provider === "watsonx" && (
|
| 494 |
+
<div className="settings-card">
|
| 495 |
+
<div className="settings-title">Watsonx Configuration</div>
|
| 496 |
+
|
| 497 |
+
<label className="settings-label">API Key</label>
|
| 498 |
+
<input
|
| 499 |
+
className="settings-input"
|
| 500 |
+
type="password"
|
| 501 |
+
value={settings.watsonx?.api_key || ""}
|
| 502 |
+
onChange={(e) => updateField("watsonx", "api_key", e.target.value)}
|
| 503 |
+
placeholder="Watsonx API key"
|
| 504 |
+
/>
|
| 505 |
+
|
| 506 |
+
<label className="settings-label">Project ID</label>
|
| 507 |
+
<input
|
| 508 |
+
className="settings-input"
|
| 509 |
+
value={settings.watsonx?.project_id || ""}
|
| 510 |
+
onChange={(e) =>
|
| 511 |
+
updateField("watsonx", "project_id", e.target.value)
|
| 512 |
+
}
|
| 513 |
+
placeholder="Watsonx project ID"
|
| 514 |
+
/>
|
| 515 |
+
|
| 516 |
+
<label className="settings-label">Base URL</label>
|
| 517 |
+
<input
|
| 518 |
+
className="settings-input"
|
| 519 |
+
value={settings.watsonx?.base_url || ""}
|
| 520 |
+
onChange={(e) => updateField("watsonx", "base_url", e.target.value)}
|
| 521 |
+
placeholder="https://api.watsonx.ai/v1"
|
| 522 |
+
/>
|
| 523 |
+
|
| 524 |
+
<label className="settings-label">Model</label>
|
| 525 |
+
<input
|
| 526 |
+
className="settings-input"
|
| 527 |
+
value={settings.watsonx?.model_id || ""}
|
| 528 |
+
onChange={(e) =>
|
| 529 |
+
updateField("watsonx", "model_id", e.target.value)
|
| 530 |
+
}
|
| 531 |
+
placeholder="meta-llama/llama-3-3-70b-instruct"
|
| 532 |
+
/>
|
| 533 |
+
</div>
|
| 534 |
+
)}
|
| 535 |
+
|
| 536 |
+
{provider === "ollama" && (
|
| 537 |
+
<div className="settings-card">
|
| 538 |
+
<div className="settings-title">Ollama Configuration</div>
|
| 539 |
+
|
| 540 |
+
<label className="settings-label">Base URL</label>
|
| 541 |
+
<input
|
| 542 |
+
className="settings-input"
|
| 543 |
+
value={settings.ollama?.base_url || ""}
|
| 544 |
+
onChange={(e) => updateField("ollama", "base_url", e.target.value)}
|
| 545 |
+
placeholder="http://localhost:11434"
|
| 546 |
+
/>
|
| 547 |
+
|
| 548 |
+
<label className="settings-label">Model</label>
|
| 549 |
+
<div className="settings-inline-row">
|
| 550 |
+
<input
|
| 551 |
+
className="settings-input"
|
| 552 |
+
value={settings.ollama?.model || ""}
|
| 553 |
+
onChange={(e) => updateField("ollama", "model", e.target.value)}
|
| 554 |
+
placeholder="llama3"
|
| 555 |
+
/>
|
| 556 |
+
<button
|
| 557 |
+
type="button"
|
| 558 |
+
className="settings-secondary-btn"
|
| 559 |
+
onClick={() => loadModelsForProvider("ollama")}
|
| 560 |
+
disabled={loadingModelsFor === "ollama"}
|
| 561 |
+
>
|
| 562 |
+
{loadingModelsFor === "ollama" ? "Loading…" : "Load Models"}
|
| 563 |
+
</button>
|
| 564 |
+
</div>
|
| 565 |
+
</div>
|
| 566 |
+
)}
|
| 567 |
+
|
| 568 |
+
{availableModels.length > 0 && (
|
| 569 |
+
<div className="settings-card">
|
| 570 |
+
<div className="settings-title">Available Models</div>
|
| 571 |
+
<div className="settings-model-list">
|
| 572 |
+
{availableModels.map((model) => (
|
| 573 |
+
<button
|
| 574 |
+
key={model}
|
| 575 |
+
type="button"
|
| 576 |
+
className="settings-model-chip"
|
| 577 |
+
onClick={() => updateField(provider, "model", model)}
|
| 578 |
+
>
|
| 579 |
+
{model}
|
| 580 |
+
</button>
|
| 581 |
+
))}
|
| 582 |
+
</div>
|
| 583 |
+
</div>
|
| 584 |
+
)}
|
| 585 |
+
|
| 586 |
+
{modelsError && <div className="settings-error-banner">{modelsError}</div>}
|
| 587 |
+
|
| 588 |
+
{testResult && (
|
| 589 |
+
<div
|
| 590 |
+
className={
|
| 591 |
+
testResult.health === "ok"
|
| 592 |
+
? "settings-success-banner"
|
| 593 |
+
: "settings-error-banner"
|
| 594 |
+
}
|
| 595 |
+
>
|
| 596 |
+
{testResult.health === "ok"
|
| 597 |
+
? testResult.details || "Provider connection successful."
|
| 598 |
+
: testResult.warning || "Provider connection failed."}
|
| 599 |
+
</div>
|
| 600 |
+
)}
|
| 601 |
+
|
| 602 |
+
<div className="settings-actions">
|
| 603 |
+
<button
|
| 604 |
+
type="button"
|
| 605 |
+
className="settings-save-btn"
|
| 606 |
+
onClick={handleSave}
|
| 607 |
+
disabled={saving}
|
| 608 |
+
>
|
| 609 |
+
{saving ? "Saving…" : "Save Settings"}
|
| 610 |
+
</button>
|
| 611 |
+
|
| 612 |
+
<button
|
| 613 |
+
type="button"
|
| 614 |
+
className="settings-secondary-btn"
|
| 615 |
+
onClick={handleTestConnection}
|
| 616 |
+
disabled={testing}
|
| 617 |
+
>
|
| 618 |
+
{testing ? "Testing…" : "Test Connection"}
|
| 619 |
+
</button>
|
| 620 |
+
</div>
|
| 621 |
+
</div>
|
| 622 |
+
);
|
| 623 |
+
}
|
frontend/components/LoginPage.jsx
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/LoginPage.jsx
|
| 2 |
+
import React, { useState, useEffect, useRef } from "react";
|
| 3 |
+
import { apiUrl, safeFetchJSON } from "../utils/api.js";
|
| 4 |
+
import { initApp } from "../utils/appInit.js";
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* GitPilot – Enterprise Agentic Login
|
| 8 |
+
* Theme: "Claude Code" / Anthropic Enterprise (Dark + Warm Orange)
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
export default function LoginPage({ onAuthenticated, backendReady = false }) {
|
| 12 |
+
// Auth State
|
| 13 |
+
const [authProcessing, setAuthProcessing] = useState(false);
|
| 14 |
+
const [error, setError] = useState("");
|
| 15 |
+
|
| 16 |
+
// Mode State: 'loading' | 'web' (Has Secret) | 'device' (No Secret)
|
| 17 |
+
const [mode, setMode] = useState("loading");
|
| 18 |
+
|
| 19 |
+
// Device Flow State
|
| 20 |
+
const [deviceData, setDeviceData] = useState(null);
|
| 21 |
+
const pollTimer = useRef(null);
|
| 22 |
+
const stopPolling = useRef(false); // Flag to safely stop async polling
|
| 23 |
+
|
| 24 |
+
// Web Flow State
|
| 25 |
+
const [missingClientId, setMissingClientId] = useState(false);
|
| 26 |
+
|
| 27 |
+
// REF FIX: Prevents React StrictMode from running the auth exchange twice
|
| 28 |
+
const processingRef = useRef(false);
|
| 29 |
+
const authCheckDone = useRef(false);
|
| 30 |
+
|
| 31 |
+
// 1. Initialization Effect — runs once on mount AND when backendReady changes
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
// Skip if already resolved
|
| 34 |
+
if (authCheckDone.current && mode !== "loading") return;
|
| 35 |
+
|
| 36 |
+
const params = new URLSearchParams(window.location.search);
|
| 37 |
+
const code = params.get("code");
|
| 38 |
+
const state = params.get("state");
|
| 39 |
+
|
| 40 |
+
// A. If returning from GitHub (Web Flow Callback)
|
| 41 |
+
if (code) {
|
| 42 |
+
if (!processingRef.current) {
|
| 43 |
+
processingRef.current = true;
|
| 44 |
+
setMode("web");
|
| 45 |
+
consumeOAuthCallback(code, state);
|
| 46 |
+
}
|
| 47 |
+
return;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// B. Use the shared singleton init — reuses App.jsx's result.
|
| 51 |
+
// No duplicate /api/auth/status calls, no separate retry loops.
|
| 52 |
+
initApp().then((result) => {
|
| 53 |
+
authCheckDone.current = true;
|
| 54 |
+
if (result.ready) {
|
| 55 |
+
setError("");
|
| 56 |
+
setMode(result.authMode === "web" ? "web" : "device");
|
| 57 |
+
} else {
|
| 58 |
+
// Backend unreachable — allow device flow as fallback
|
| 59 |
+
setError(result.error || "Backend unavailable");
|
| 60 |
+
setMode("device");
|
| 61 |
+
}
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
// Cleanup polling on unmount
|
| 65 |
+
return () => {
|
| 66 |
+
stopPolling.current = true;
|
| 67 |
+
if (pollTimer.current) clearTimeout(pollTimer.current);
|
| 68 |
+
};
|
| 69 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 70 |
+
}, [backendReady]);
|
| 71 |
+
|
| 72 |
+
// ===========================================================================
|
| 73 |
+
// WEB FLOW LOGIC (Standard OAuth2)
|
| 74 |
+
// ===========================================================================
|
| 75 |
+
|
| 76 |
+
async function consumeOAuthCallback(code, state) {
|
| 77 |
+
const expectedState = sessionStorage.getItem("gitpilot_oauth_state");
|
| 78 |
+
if (state && expectedState && expectedState !== state) {
|
| 79 |
+
console.warn("OAuth state mismatch - proceeding with caution.");
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
setAuthProcessing(true);
|
| 83 |
+
setError("");
|
| 84 |
+
window.history.replaceState({}, document.title, window.location.pathname);
|
| 85 |
+
|
| 86 |
+
try {
|
| 87 |
+
const data = await safeFetchJSON(apiUrl("/api/auth/callback"), {
|
| 88 |
+
method: "POST",
|
| 89 |
+
headers: { "Content-Type": "application/json" },
|
| 90 |
+
body: JSON.stringify({ code, state: state || "" }),
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
handleSuccess(data);
|
| 94 |
+
} catch (err) {
|
| 95 |
+
console.error("Login Error:", err);
|
| 96 |
+
setError(err instanceof Error ? err.message : "Login failed.");
|
| 97 |
+
setAuthProcessing(false);
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
async function handleSignInWithGitHub() {
|
| 102 |
+
setError("");
|
| 103 |
+
setMissingClientId(false);
|
| 104 |
+
setAuthProcessing(true);
|
| 105 |
+
|
| 106 |
+
try {
|
| 107 |
+
const data = await safeFetchJSON(apiUrl("/api/auth/url"));
|
| 108 |
+
|
| 109 |
+
if (data.state) {
|
| 110 |
+
sessionStorage.setItem("gitpilot_oauth_state", data.state);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
window.location.href = data.authorization_url;
|
| 114 |
+
} catch (err) {
|
| 115 |
+
console.error("Auth Start Error:", err);
|
| 116 |
+
// Check for missing client ID (404/500 errors)
|
| 117 |
+
if (err.message && (err.message.includes('404') || err.message.includes('500'))) {
|
| 118 |
+
setMissingClientId(true);
|
| 119 |
+
} else {
|
| 120 |
+
setError(err instanceof Error ? err.message : "Could not start sign-in.");
|
| 121 |
+
}
|
| 122 |
+
setAuthProcessing(false);
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// ===========================================================================
|
| 127 |
+
// DEVICE FLOW LOGIC (No Client Secret Required)
|
| 128 |
+
// ===========================================================================
|
| 129 |
+
|
| 130 |
+
const startDeviceFlow = async () => {
|
| 131 |
+
setError("");
|
| 132 |
+
setAuthProcessing(true);
|
| 133 |
+
stopPolling.current = false; // Reset stop flag
|
| 134 |
+
|
| 135 |
+
try {
|
| 136 |
+
const data = await safeFetchJSON(apiUrl("/api/auth/device/code"), { method: "POST" });
|
| 137 |
+
|
| 138 |
+
// Handle Errors
|
| 139 |
+
if (data.error) {
|
| 140 |
+
if (data.error.includes("400") || data.error.includes("Bad Request")) {
|
| 141 |
+
throw new Error("Device Flow is disabled in GitHub. Please go to your GitHub App Settings > 'General' > 'Identifying and authorizing users' and check the box 'Enable Device Flow'.");
|
| 142 |
+
}
|
| 143 |
+
throw new Error(data.error);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
if (!data.device_code) throw new Error("Invalid device code response");
|
| 147 |
+
|
| 148 |
+
setDeviceData(data);
|
| 149 |
+
setAuthProcessing(false);
|
| 150 |
+
|
| 151 |
+
// Start Polling (Recursive Timeout Pattern)
|
| 152 |
+
pollDeviceToken(data.device_code, data.interval || 5);
|
| 153 |
+
|
| 154 |
+
} catch (err) {
|
| 155 |
+
setError(err.message);
|
| 156 |
+
setAuthProcessing(false);
|
| 157 |
+
}
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
const pollDeviceToken = async (deviceCode, interval) => {
|
| 161 |
+
if (stopPolling.current) return;
|
| 162 |
+
|
| 163 |
+
try {
|
| 164 |
+
const response = await fetch(apiUrl("/api/auth/device/poll"), {
|
| 165 |
+
method: "POST",
|
| 166 |
+
headers: { "Content-Type": "application/json" },
|
| 167 |
+
body: JSON.stringify({ device_code: deviceCode })
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
// 1. Success (200)
|
| 171 |
+
if (response.status === 200) {
|
| 172 |
+
const data = await response.json();
|
| 173 |
+
handleSuccess(data);
|
| 174 |
+
return;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// 2. Pending (202) -> Continue Polling
|
| 178 |
+
if (response.status === 202) {
|
| 179 |
+
// Schedule next poll
|
| 180 |
+
pollTimer.current = setTimeout(
|
| 181 |
+
() => pollDeviceToken(deviceCode, interval),
|
| 182 |
+
interval * 1000
|
| 183 |
+
);
|
| 184 |
+
return;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// 3. Error (4xx/5xx) -> Stop Polling & Show Error
|
| 188 |
+
const errData = await response.json().catch(() => ({ error: "Unknown polling error" }));
|
| 189 |
+
|
| 190 |
+
// Special case: If it's just a 'slow_down' warning (sometimes 400), we just wait longer
|
| 191 |
+
if (errData.error === "slow_down") {
|
| 192 |
+
pollTimer.current = setTimeout(
|
| 193 |
+
() => pollDeviceToken(deviceCode, interval + 5),
|
| 194 |
+
(interval + 5) * 1000
|
| 195 |
+
);
|
| 196 |
+
return;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
// Terminal errors
|
| 200 |
+
throw new Error(errData.error || `Polling failed: ${response.status}`);
|
| 201 |
+
|
| 202 |
+
} catch (e) {
|
| 203 |
+
console.error("Poll error:", e);
|
| 204 |
+
if (!stopPolling.current) {
|
| 205 |
+
setError(e.message || "Failed to connect to authentication server.");
|
| 206 |
+
setDeviceData(null); // Return to initial state
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
};
|
| 210 |
+
|
| 211 |
+
const handleManualCheck = async () => {
|
| 212 |
+
if (!deviceData?.device_code) return;
|
| 213 |
+
|
| 214 |
+
try {
|
| 215 |
+
const response = await fetch(apiUrl("/api/auth/device/poll"), {
|
| 216 |
+
method: "POST",
|
| 217 |
+
headers: { "Content-Type": "application/json" },
|
| 218 |
+
body: JSON.stringify({ device_code: deviceData.device_code })
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
if (response.status === 200) {
|
| 222 |
+
const data = await response.json();
|
| 223 |
+
handleSuccess(data);
|
| 224 |
+
} else if (response.status === 202) {
|
| 225 |
+
// Visual feedback for pending state
|
| 226 |
+
const btn = document.getElementById("manual-check-btn");
|
| 227 |
+
if (btn) {
|
| 228 |
+
const originalText = btn.innerText;
|
| 229 |
+
btn.innerText = "Still Pending...";
|
| 230 |
+
btn.disabled = true;
|
| 231 |
+
setTimeout(() => {
|
| 232 |
+
btn.innerText = originalText;
|
| 233 |
+
btn.disabled = false;
|
| 234 |
+
}, 2000);
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
} catch (e) {
|
| 238 |
+
console.error("Manual check failed", e);
|
| 239 |
+
}
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
const handleCancelDeviceFlow = () => {
|
| 243 |
+
stopPolling.current = true;
|
| 244 |
+
if (pollTimer.current) clearTimeout(pollTimer.current);
|
| 245 |
+
setDeviceData(null);
|
| 246 |
+
setError("");
|
| 247 |
+
};
|
| 248 |
+
|
| 249 |
+
// ===========================================================================
|
| 250 |
+
// SHARED HELPERS
|
| 251 |
+
// ===========================================================================
|
| 252 |
+
|
| 253 |
+
function handleSuccess(data) {
|
| 254 |
+
stopPolling.current = true; // Ensure polling stops
|
| 255 |
+
if (pollTimer.current) clearTimeout(pollTimer.current);
|
| 256 |
+
|
| 257 |
+
if (!data.access_token || !data.user) {
|
| 258 |
+
setError("Server returned incomplete session data.");
|
| 259 |
+
return;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
try {
|
| 263 |
+
localStorage.setItem("github_token", data.access_token);
|
| 264 |
+
localStorage.setItem("github_user", JSON.stringify(data.user));
|
| 265 |
+
} catch (e) {
|
| 266 |
+
console.warn("LocalStorage access denied:", e);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
if (typeof onAuthenticated === "function") {
|
| 270 |
+
onAuthenticated({
|
| 271 |
+
access_token: data.access_token,
|
| 272 |
+
user: data.user,
|
| 273 |
+
});
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
// --- Design Token System ---
|
| 278 |
+
const theme = {
|
| 279 |
+
bg: "#131316",
|
| 280 |
+
cardBg: "#1C1C1F",
|
| 281 |
+
border: "#27272A",
|
| 282 |
+
accent: "#D95C3D",
|
| 283 |
+
accentHover: "#C44F32",
|
| 284 |
+
textPrimary: "#EDEDED",
|
| 285 |
+
textSecondary: "#A1A1AA",
|
| 286 |
+
font: '"Söhne", "Inter", -apple-system, sans-serif',
|
| 287 |
+
};
|
| 288 |
+
|
| 289 |
+
const styles = {
|
| 290 |
+
container: {
|
| 291 |
+
minHeight: "100vh",
|
| 292 |
+
display: "flex",
|
| 293 |
+
alignItems: "center",
|
| 294 |
+
justifyContent: "center",
|
| 295 |
+
backgroundColor: theme.bg,
|
| 296 |
+
fontFamily: theme.font,
|
| 297 |
+
color: theme.textPrimary,
|
| 298 |
+
letterSpacing: "-0.01em",
|
| 299 |
+
},
|
| 300 |
+
card: {
|
| 301 |
+
backgroundColor: theme.cardBg,
|
| 302 |
+
width: "100%",
|
| 303 |
+
maxWidth: "440px",
|
| 304 |
+
borderRadius: "12px",
|
| 305 |
+
border: `1px solid ${theme.border}`,
|
| 306 |
+
boxShadow: "0 24px 48px -12px rgba(0, 0, 0, 0.6)",
|
| 307 |
+
padding: "48px 40px",
|
| 308 |
+
textAlign: "center",
|
| 309 |
+
position: "relative",
|
| 310 |
+
},
|
| 311 |
+
logoBadge: {
|
| 312 |
+
width: "48px",
|
| 313 |
+
height: "48px",
|
| 314 |
+
backgroundColor: "rgba(217, 92, 61, 0.15)",
|
| 315 |
+
color: theme.accent,
|
| 316 |
+
borderRadius: "10px",
|
| 317 |
+
display: "flex",
|
| 318 |
+
alignItems: "center",
|
| 319 |
+
justifyContent: "center",
|
| 320 |
+
fontSize: "22px",
|
| 321 |
+
fontWeight: "700",
|
| 322 |
+
margin: "0 auto 32px auto",
|
| 323 |
+
border: "1px solid rgba(217, 92, 61, 0.2)",
|
| 324 |
+
},
|
| 325 |
+
h1: {
|
| 326 |
+
fontSize: "24px",
|
| 327 |
+
fontWeight: "600",
|
| 328 |
+
marginBottom: "12px",
|
| 329 |
+
color: theme.textPrimary,
|
| 330 |
+
},
|
| 331 |
+
p: {
|
| 332 |
+
fontSize: "14px",
|
| 333 |
+
color: theme.textSecondary,
|
| 334 |
+
lineHeight: "1.6",
|
| 335 |
+
marginBottom: "40px",
|
| 336 |
+
},
|
| 337 |
+
button: {
|
| 338 |
+
width: "100%",
|
| 339 |
+
height: "48px",
|
| 340 |
+
backgroundColor: theme.accent,
|
| 341 |
+
color: "#FFFFFF",
|
| 342 |
+
border: "none",
|
| 343 |
+
borderRadius: "8px",
|
| 344 |
+
fontSize: "14px",
|
| 345 |
+
fontWeight: "500",
|
| 346 |
+
cursor: (authProcessing || (mode === 'loading')) ? "not-allowed" : "pointer",
|
| 347 |
+
opacity: (authProcessing || (mode === 'loading')) ? 0.7 : 1,
|
| 348 |
+
transition: "background-color 0.2s ease",
|
| 349 |
+
display: "flex",
|
| 350 |
+
alignItems: "center",
|
| 351 |
+
justifyContent: "center",
|
| 352 |
+
gap: "10px",
|
| 353 |
+
boxShadow: "0 4px 12px rgba(217, 92, 61, 0.25)",
|
| 354 |
+
},
|
| 355 |
+
secondaryButton: {
|
| 356 |
+
backgroundColor: "transparent",
|
| 357 |
+
color: "#A1A1AA",
|
| 358 |
+
border: "1px solid #3F3F46",
|
| 359 |
+
padding: "8px 16px",
|
| 360 |
+
borderRadius: "6px",
|
| 361 |
+
fontSize: "12px",
|
| 362 |
+
cursor: "pointer",
|
| 363 |
+
marginTop: "16px",
|
| 364 |
+
minWidth: "100px"
|
| 365 |
+
},
|
| 366 |
+
errorBox: {
|
| 367 |
+
backgroundColor: "rgba(185, 28, 28, 0.15)",
|
| 368 |
+
border: "1px solid rgba(185, 28, 28, 0.3)",
|
| 369 |
+
color: "#FCA5A5",
|
| 370 |
+
padding: "12px",
|
| 371 |
+
borderRadius: "8px",
|
| 372 |
+
fontSize: "13px",
|
| 373 |
+
marginBottom: "24px",
|
| 374 |
+
textAlign: "left",
|
| 375 |
+
},
|
| 376 |
+
configCard: {
|
| 377 |
+
textAlign: "left",
|
| 378 |
+
backgroundColor: "#111",
|
| 379 |
+
border: "1px solid #333",
|
| 380 |
+
padding: "24px",
|
| 381 |
+
borderRadius: "8px",
|
| 382 |
+
marginBottom: "24px",
|
| 383 |
+
},
|
| 384 |
+
codeDisplay: {
|
| 385 |
+
backgroundColor: "#27272A",
|
| 386 |
+
color: theme.accent,
|
| 387 |
+
fontSize: "20px",
|
| 388 |
+
fontWeight: "700",
|
| 389 |
+
padding: "12px",
|
| 390 |
+
borderRadius: "6px",
|
| 391 |
+
textAlign: "center",
|
| 392 |
+
letterSpacing: "2px",
|
| 393 |
+
margin: "12px 0",
|
| 394 |
+
border: `1px dashed ${theme.accent}`,
|
| 395 |
+
cursor: "pointer",
|
| 396 |
+
},
|
| 397 |
+
footer: {
|
| 398 |
+
marginTop: "48px",
|
| 399 |
+
fontSize: "12px",
|
| 400 |
+
color: "#52525B",
|
| 401 |
+
}
|
| 402 |
+
};
|
| 403 |
+
|
| 404 |
+
// --- RENDER: Device Flow UI ---
|
| 405 |
+
const renderDeviceFlow = () => {
|
| 406 |
+
if (!deviceData) {
|
| 407 |
+
return (
|
| 408 |
+
<button
|
| 409 |
+
onClick={startDeviceFlow}
|
| 410 |
+
disabled={authProcessing}
|
| 411 |
+
style={styles.button}
|
| 412 |
+
onMouseOver={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accentHover)}
|
| 413 |
+
onMouseOut={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accent)}
|
| 414 |
+
>
|
| 415 |
+
{authProcessing ? "Connecting..." : "Sign in with GitHub"}
|
| 416 |
+
</button>
|
| 417 |
+
);
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
return (
|
| 421 |
+
<div style={styles.configCard}>
|
| 422 |
+
<h3 style={{marginTop:0, color: '#FFF', fontSize: '16px'}}>Authorize Device</h3>
|
| 423 |
+
<p style={{color: '#AAA', fontSize: '13px', marginBottom:'16px'}}>
|
| 424 |
+
GitPilot needs authorization to access your repositories.
|
| 425 |
+
</p>
|
| 426 |
+
|
| 427 |
+
<div style={{marginBottom: '16px'}}>
|
| 428 |
+
<div style={{color: '#AAA', fontSize: '12px', marginBottom: '4px'}}>1. Copy code:</div>
|
| 429 |
+
<div
|
| 430 |
+
style={styles.codeDisplay}
|
| 431 |
+
onClick={() => {
|
| 432 |
+
navigator.clipboard.writeText(deviceData.user_code);
|
| 433 |
+
}}
|
| 434 |
+
title="Click to copy"
|
| 435 |
+
>
|
| 436 |
+
{deviceData.user_code}
|
| 437 |
+
</div>
|
| 438 |
+
</div>
|
| 439 |
+
|
| 440 |
+
<div>
|
| 441 |
+
<div style={{color: '#AAA', fontSize: '12px', marginBottom: '4px'}}>2. Paste at GitHub:</div>
|
| 442 |
+
<a
|
| 443 |
+
href={deviceData.verification_uri}
|
| 444 |
+
target="_blank"
|
| 445 |
+
rel="noreferrer"
|
| 446 |
+
style={{
|
| 447 |
+
display: 'block',
|
| 448 |
+
backgroundColor: '#FFF',
|
| 449 |
+
color: '#000',
|
| 450 |
+
textDecoration: 'none',
|
| 451 |
+
padding: '10px',
|
| 452 |
+
borderRadius: '6px',
|
| 453 |
+
textAlign: 'center',
|
| 454 |
+
fontWeight: '600',
|
| 455 |
+
fontSize: '14px'
|
| 456 |
+
}}
|
| 457 |
+
>
|
| 458 |
+
Open Activation Page ↗
|
| 459 |
+
</a>
|
| 460 |
+
</div>
|
| 461 |
+
|
| 462 |
+
<div style={{marginTop: '20px', fontSize: '12px', color: '#666', textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px'}}>
|
| 463 |
+
<span style={{animation: 'spin 1s linear infinite', display: 'inline-block'}}>↻</span>
|
| 464 |
+
Waiting for authorization...
|
| 465 |
+
<style>{`@keyframes spin { 100% { transform: rotate(360deg); } }`}</style>
|
| 466 |
+
</div>
|
| 467 |
+
|
| 468 |
+
<div style={{textAlign: 'center', display: 'flex', gap: '10px', justifyContent: 'center'}}>
|
| 469 |
+
<button
|
| 470 |
+
id="manual-check-btn"
|
| 471 |
+
onClick={handleManualCheck}
|
| 472 |
+
style={styles.secondaryButton}
|
| 473 |
+
>
|
| 474 |
+
Check Status
|
| 475 |
+
</button>
|
| 476 |
+
<button
|
| 477 |
+
onClick={handleCancelDeviceFlow}
|
| 478 |
+
style={styles.secondaryButton}
|
| 479 |
+
>
|
| 480 |
+
Cancel
|
| 481 |
+
</button>
|
| 482 |
+
</div>
|
| 483 |
+
</div>
|
| 484 |
+
);
|
| 485 |
+
};
|
| 486 |
+
|
| 487 |
+
// --- RENDER: Config Error ---
|
| 488 |
+
if (missingClientId) {
|
| 489 |
+
return (
|
| 490 |
+
<div style={styles.container}>
|
| 491 |
+
<div style={styles.card}>
|
| 492 |
+
<div style={{...styles.logoBadge, color: "#F59E0B", backgroundColor: "rgba(245, 158, 11, 0.1)", borderColor: "rgba(245, 158, 11, 0.2)"}}>⚠️</div>
|
| 493 |
+
<h1 style={styles.h1}>Configuration Error</h1>
|
| 494 |
+
<p style={styles.p}>Could not connect to GitHub Authentication services.</p>
|
| 495 |
+
<button onClick={() => setMissingClientId(false)} style={{...styles.button, backgroundColor: "#3F3F46"}}>Retry</button>
|
| 496 |
+
</div>
|
| 497 |
+
</div>
|
| 498 |
+
);
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
// --- RENDER: Main ---
|
| 502 |
+
return (
|
| 503 |
+
<div style={styles.container}>
|
| 504 |
+
<div style={styles.card}>
|
| 505 |
+
<div style={styles.logoBadge}>GP</div>
|
| 506 |
+
|
| 507 |
+
<h1 style={styles.h1}>GitPilot Enterprise</h1>
|
| 508 |
+
<p style={styles.p}>
|
| 509 |
+
Agentic AI workflow for your repositories.<br/>
|
| 510 |
+
Secure. Context-aware. Automated.
|
| 511 |
+
</p>
|
| 512 |
+
|
| 513 |
+
{error && <div style={styles.errorBox}>{error}</div>}
|
| 514 |
+
|
| 515 |
+
{mode === "loading" && (
|
| 516 |
+
<div style={{color: '#666', fontSize: '14px'}}>Initializing...</div>
|
| 517 |
+
)}
|
| 518 |
+
|
| 519 |
+
{mode === "web" && (
|
| 520 |
+
<button
|
| 521 |
+
onClick={handleSignInWithGitHub}
|
| 522 |
+
disabled={authProcessing}
|
| 523 |
+
style={styles.button}
|
| 524 |
+
onMouseOver={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accentHover)}
|
| 525 |
+
onMouseOut={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accent)}
|
| 526 |
+
>
|
| 527 |
+
{authProcessing ? "Connecting..." : (
|
| 528 |
+
<>
|
| 529 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405 1.02 0 2.04.135 3 .405 2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" /></svg>
|
| 530 |
+
Sign in with GitHub
|
| 531 |
+
</>
|
| 532 |
+
)}
|
| 533 |
+
</button>
|
| 534 |
+
)}
|
| 535 |
+
|
| 536 |
+
{mode === "device" && renderDeviceFlow()}
|
| 537 |
+
|
| 538 |
+
<div style={styles.footer}>
|
| 539 |
+
© {new Date().getFullYear()} GitPilot Inc.
|
| 540 |
+
</div>
|
| 541 |
+
</div>
|
| 542 |
+
</div>
|
| 543 |
+
);
|
| 544 |
+
}
|
frontend/components/PlanView.jsx
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* PlanView — enterprise "Proposed execution plan" card.
|
| 5 |
+
*
|
| 6 |
+
* One main card. Subtle internal spacing instead of bordered nested boxes.
|
| 7 |
+
* Steps are rendered as a vertical numbered timeline; file actions become
|
| 8 |
+
* compact pill badges (READ / CREATE / MODIFY / DELETE / INDEX).
|
| 9 |
+
*
|
| 10 |
+
* The card itself is borderless — the surrounding AssistantMessage
|
| 11 |
+
* already provides the outer container.
|
| 12 |
+
*/
|
| 13 |
+
export default function PlanView({ plan, planStatus }) {
|
| 14 |
+
if (!plan) return null;
|
| 15 |
+
|
| 16 |
+
const totals = { CREATE: 0, MODIFY: 0, DELETE: 0, INDEX: 0, READ: 0 };
|
| 17 |
+
plan.steps.forEach((step) => {
|
| 18 |
+
step.files.forEach((file) => {
|
| 19 |
+
totals[file.action] = (totals[file.action] || 0) + 1;
|
| 20 |
+
});
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<div className="exec-card exec-card--plan">
|
| 25 |
+
<div className="exec-card__head">
|
| 26 |
+
<div className="exec-card__icon exec-card__icon--plan" aria-hidden="true">
|
| 27 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
|
| 28 |
+
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
| 29 |
+
strokeLinejoin="round">
|
| 30 |
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
| 31 |
+
<polyline points="14 2 14 8 20 8" />
|
| 32 |
+
<line x1="8" y1="13" x2="16" y2="13" />
|
| 33 |
+
<line x1="8" y1="17" x2="13" y2="17" />
|
| 34 |
+
</svg>
|
| 35 |
+
</div>
|
| 36 |
+
<div className="exec-card__head-text">
|
| 37 |
+
<div className="exec-card__eyebrow">Proposed execution plan</div>
|
| 38 |
+
<div className="exec-card__title">{plan.goal}</div>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<div className="exec-card__head-right">
|
| 42 |
+
{planStatus === "executed" && (
|
| 43 |
+
<span className="exec-status-badge exec-status-badge--ok">
|
| 44 |
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
| 45 |
+
stroke="currentColor" strokeWidth="3" strokeLinecap="round"
|
| 46 |
+
strokeLinejoin="round" aria-hidden="true">
|
| 47 |
+
<polyline points="20 6 9 17 4 12" />
|
| 48 |
+
</svg>
|
| 49 |
+
Executed
|
| 50 |
+
</span>
|
| 51 |
+
)}
|
| 52 |
+
{planStatus === "rejected" && (
|
| 53 |
+
<span className="exec-status-badge exec-status-badge--muted">
|
| 54 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
| 55 |
+
stroke="currentColor" strokeWidth="2.6" strokeLinecap="round"
|
| 56 |
+
strokeLinejoin="round" aria-hidden="true">
|
| 57 |
+
<line x1="18" y1="6" x2="6" y2="18" />
|
| 58 |
+
<line x1="6" y1="6" x2="18" y2="18" />
|
| 59 |
+
</svg>
|
| 60 |
+
Rejected
|
| 61 |
+
</span>
|
| 62 |
+
)}
|
| 63 |
+
|
| 64 |
+
<div className="exec-card__totals">
|
| 65 |
+
{totals.CREATE > 0 && (
|
| 66 |
+
<span className="exec-total exec-total--create">
|
| 67 |
+
{totals.CREATE} create
|
| 68 |
+
</span>
|
| 69 |
+
)}
|
| 70 |
+
{totals.MODIFY > 0 && (
|
| 71 |
+
<span className="exec-total exec-total--modify">
|
| 72 |
+
{totals.MODIFY} modify
|
| 73 |
+
</span>
|
| 74 |
+
)}
|
| 75 |
+
{totals.DELETE > 0 && (
|
| 76 |
+
<span className="exec-total exec-total--delete">
|
| 77 |
+
{totals.DELETE} delete
|
| 78 |
+
</span>
|
| 79 |
+
)}
|
| 80 |
+
{totals.INDEX > 0 && (
|
| 81 |
+
<span className="exec-total exec-total--index">
|
| 82 |
+
{totals.INDEX === 1 ? "1 setup" : `${totals.INDEX} setup`}
|
| 83 |
+
</span>
|
| 84 |
+
)}
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<ol className="exec-timeline">
|
| 90 |
+
{plan.steps.map((s, idx) => (
|
| 91 |
+
<li key={s.step_number} className="exec-step">
|
| 92 |
+
<div className="exec-step__rail" aria-hidden="true">
|
| 93 |
+
<div className="exec-step__num">{s.step_number}</div>
|
| 94 |
+
{idx < plan.steps.length - 1 && (
|
| 95 |
+
<div className="exec-step__line" />
|
| 96 |
+
)}
|
| 97 |
+
</div>
|
| 98 |
+
<div className="exec-step__body">
|
| 99 |
+
<div className="exec-step__title">{s.title}</div>
|
| 100 |
+
{s.description && (
|
| 101 |
+
<p className="exec-step__desc">{s.description}</p>
|
| 102 |
+
)}
|
| 103 |
+
|
| 104 |
+
{s.files && s.files.length > 0 && (
|
| 105 |
+
<ul className="exec-actions">
|
| 106 |
+
{s.files.map((file, fi) => (
|
| 107 |
+
<li key={fi} className="exec-action-row">
|
| 108 |
+
<span
|
| 109 |
+
className={`exec-badge exec-badge--${file.action.toLowerCase()}`}
|
| 110 |
+
>
|
| 111 |
+
{file.action}
|
| 112 |
+
</span>
|
| 113 |
+
<code className="exec-action__path">
|
| 114 |
+
{file.action === "INDEX"
|
| 115 |
+
? "Build semantic index for this repo"
|
| 116 |
+
: file.path}
|
| 117 |
+
</code>
|
| 118 |
+
</li>
|
| 119 |
+
))}
|
| 120 |
+
</ul>
|
| 121 |
+
)}
|
| 122 |
+
|
| 123 |
+
{s.files && s.files.some((f) => f.action === "INDEX") && (
|
| 124 |
+
<div className="exec-notice">
|
| 125 |
+
One-time semantic index build (~80 MB model, ~30 s, ~12 MB on
|
| 126 |
+
disk). No cloud calls. Click <strong>Reject plan</strong> to
|
| 127 |
+
skip — you'll be offered the grep fallback.
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
|
| 131 |
+
{s.risks && (
|
| 132 |
+
<div className="exec-risk">
|
| 133 |
+
<span aria-hidden="true">⚠</span>
|
| 134 |
+
<span>{s.risks}</span>
|
| 135 |
+
</div>
|
| 136 |
+
)}
|
| 137 |
+
</div>
|
| 138 |
+
</li>
|
| 139 |
+
))}
|
| 140 |
+
</ol>
|
| 141 |
+
</div>
|
| 142 |
+
);
|
| 143 |
+
}
|
frontend/components/ProjectContextPanel.jsx
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
+
import FileTree from "./FileTree.jsx";
|
| 3 |
+
import BranchPicker from "./BranchPicker.jsx";
|
| 4 |
+
import SandboxStatusWidget from "./SandboxStatusWidget.jsx";
|
| 5 |
+
|
| 6 |
+
// --- INJECTED STYLES FOR ANIMATIONS ---
|
| 7 |
+
const animationStyles = `
|
| 8 |
+
@keyframes highlight-pulse {
|
| 9 |
+
0% { background-color: rgba(59, 130, 246, 0.10); }
|
| 10 |
+
50% { background-color: rgba(59, 130, 246, 0.22); }
|
| 11 |
+
100% { background-color: transparent; }
|
| 12 |
+
}
|
| 13 |
+
.pulse-context {
|
| 14 |
+
animation: highlight-pulse 1.1s ease-out;
|
| 15 |
+
}
|
| 16 |
+
`;
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* ProjectContextPanel (Production-ready)
|
| 20 |
+
*
|
| 21 |
+
* Controlled component:
|
| 22 |
+
* - Branch source of truth is App.jsx:
|
| 23 |
+
* - defaultBranch (prod)
|
| 24 |
+
* - currentBranch (what user sees)
|
| 25 |
+
* - sessionBranches (list of all active AI session branches)
|
| 26 |
+
*
|
| 27 |
+
* Responsibilities:
|
| 28 |
+
* - Show project context + branch dropdown + AI badge/banner
|
| 29 |
+
* - Fetch access status + file count for the currentBranch
|
| 30 |
+
* - Trigger visual pulse on pulseNonce (Hard Switch)
|
| 31 |
+
*/
|
| 32 |
+
export default function ProjectContextPanel({
|
| 33 |
+
repo,
|
| 34 |
+
defaultBranch,
|
| 35 |
+
currentBranch,
|
| 36 |
+
sessionBranch, // Active session branch (optional, for specific highlighting)
|
| 37 |
+
sessionBranches = [], // List of all AI branches
|
| 38 |
+
onBranchChange,
|
| 39 |
+
pulseNonce,
|
| 40 |
+
onSettingsClick,
|
| 41 |
+
}) {
|
| 42 |
+
const [appUrl, setAppUrl] = useState("");
|
| 43 |
+
const [fileCount, setFileCount] = useState(0);
|
| 44 |
+
|
| 45 |
+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
| 46 |
+
|
| 47 |
+
// Data Loading State
|
| 48 |
+
const [analyzing, setAnalyzing] = useState(false);
|
| 49 |
+
const [accessInfo, setAccessInfo] = useState(null);
|
| 50 |
+
const [treeError, setTreeError] = useState(null);
|
| 51 |
+
|
| 52 |
+
// Retry / Refresh Logic
|
| 53 |
+
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
| 54 |
+
const [retryCount, setRetryCount] = useState(0);
|
| 55 |
+
const retryTimeoutRef = useRef(null);
|
| 56 |
+
|
| 57 |
+
// UX State
|
| 58 |
+
const [animateHeader, setAnimateHeader] = useState(false);
|
| 59 |
+
const [toast, setToast] = useState({ visible: false, title: "", msg: "" });
|
| 60 |
+
|
| 61 |
+
// Calculate effective default to prevent 'main' fallback errors
|
| 62 |
+
const effectiveDefaultBranch = defaultBranch || repo?.default_branch || "main";
|
| 63 |
+
const branch = currentBranch || effectiveDefaultBranch;
|
| 64 |
+
|
| 65 |
+
// Determine if we are currently viewing an AI Session branch
|
| 66 |
+
const isAiSession = (sessionBranches.includes(branch)) || (sessionBranch === branch && branch !== effectiveDefaultBranch);
|
| 67 |
+
|
| 68 |
+
// Fetch App URL on mount
|
| 69 |
+
useEffect(() => {
|
| 70 |
+
fetch("/api/auth/app-url")
|
| 71 |
+
.then((res) => res.json())
|
| 72 |
+
.then((data) => {
|
| 73 |
+
if (data.app_url) setAppUrl(data.app_url);
|
| 74 |
+
})
|
| 75 |
+
.catch((err) => console.error("Failed to fetch App URL:", err));
|
| 76 |
+
}, []);
|
| 77 |
+
|
| 78 |
+
// Hard Switch pulse: whenever App increments pulseNonce
|
| 79 |
+
useEffect(() => {
|
| 80 |
+
if (!pulseNonce) return;
|
| 81 |
+
setAnimateHeader(true);
|
| 82 |
+
const t = window.setTimeout(() => setAnimateHeader(false), 1100);
|
| 83 |
+
return () => window.clearTimeout(t);
|
| 84 |
+
}, [pulseNonce]);
|
| 85 |
+
|
| 86 |
+
// Main data fetcher (Access + Tree stats) for currentBranch
|
| 87 |
+
// Stale-while-revalidate: keep previous data visible during fetch
|
| 88 |
+
useEffect(() => {
|
| 89 |
+
if (!repo) return;
|
| 90 |
+
|
| 91 |
+
// Only show full "analyzing" spinner if we have no data yet
|
| 92 |
+
if (!accessInfo) setAnalyzing(true);
|
| 93 |
+
setTreeError(null);
|
| 94 |
+
|
| 95 |
+
if (retryTimeoutRef.current) {
|
| 96 |
+
clearTimeout(retryTimeoutRef.current);
|
| 97 |
+
retryTimeoutRef.current = null;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
let headers = {};
|
| 101 |
+
try {
|
| 102 |
+
const token = localStorage.getItem("github_token");
|
| 103 |
+
if (token) headers = { Authorization: `Bearer ${token}` };
|
| 104 |
+
} catch (e) {
|
| 105 |
+
console.warn("Unable to read github_token:", e);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
let cancelled = false;
|
| 109 |
+
const cacheBuster = `&_t=${Date.now()}&retry=${retryCount}`;
|
| 110 |
+
|
| 111 |
+
// A) Access Check (with Stale Cache Fix)
|
| 112 |
+
fetch(`/api/auth/repo-access?owner=${repo.owner}&repo=${repo.name}${cacheBuster}`, {
|
| 113 |
+
headers,
|
| 114 |
+
cache: "no-cache",
|
| 115 |
+
})
|
| 116 |
+
.then(async (res) => {
|
| 117 |
+
if (cancelled) return;
|
| 118 |
+
const data = await res.json().catch(() => ({}));
|
| 119 |
+
|
| 120 |
+
if (!res.ok) {
|
| 121 |
+
setAccessInfo({ can_write: false, app_installed: false, auth_type: "none" });
|
| 122 |
+
return;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
setAccessInfo(data);
|
| 126 |
+
|
| 127 |
+
// Auto-retry if user has push access but App is not detected yet (Stale Cache)
|
| 128 |
+
if (data.can_write && !data.app_installed && retryCount === 0) {
|
| 129 |
+
retryTimeoutRef.current = setTimeout(() => {
|
| 130 |
+
setRetryCount(1);
|
| 131 |
+
}, 1000);
|
| 132 |
+
}
|
| 133 |
+
})
|
| 134 |
+
.catch(() => {
|
| 135 |
+
if (!cancelled) setAccessInfo({ can_write: false, app_installed: false, auth_type: "none" });
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
// B) Tree count for the selected branch
|
| 139 |
+
// Don't clear fileCount — keep stale value visible until new one arrives
|
| 140 |
+
const hadFileCount = fileCount > 0;
|
| 141 |
+
if (!hadFileCount) setAnalyzing(true);
|
| 142 |
+
|
| 143 |
+
fetch(`/api/repos/${repo.owner}/${repo.name}/tree?ref=${encodeURIComponent(branch)}&_t=${Date.now()}`, {
|
| 144 |
+
headers,
|
| 145 |
+
cache: "no-cache",
|
| 146 |
+
})
|
| 147 |
+
.then(async (res) => {
|
| 148 |
+
if (cancelled) return;
|
| 149 |
+
const data = await res.json().catch(() => ({}));
|
| 150 |
+
if (!res.ok) {
|
| 151 |
+
setTreeError(data.detail || "Failed to load tree");
|
| 152 |
+
setFileCount(0);
|
| 153 |
+
return;
|
| 154 |
+
}
|
| 155 |
+
setFileCount(Array.isArray(data.files) ? data.files.length : 0);
|
| 156 |
+
})
|
| 157 |
+
.catch((err) => {
|
| 158 |
+
if (cancelled) return;
|
| 159 |
+
setTreeError(err.message);
|
| 160 |
+
setFileCount(0);
|
| 161 |
+
})
|
| 162 |
+
.finally(() => { if (!cancelled) setAnalyzing(false); });
|
| 163 |
+
|
| 164 |
+
return () => {
|
| 165 |
+
cancelled = true;
|
| 166 |
+
if (retryTimeoutRef.current) clearTimeout(retryTimeoutRef.current);
|
| 167 |
+
};
|
| 168 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 169 |
+
}, [repo?.owner, repo?.name, branch, refreshTrigger, retryCount]);
|
| 170 |
+
|
| 171 |
+
const showToast = (title, msg) => {
|
| 172 |
+
setToast({ visible: true, title, msg });
|
| 173 |
+
setTimeout(() => setToast((prev) => ({ ...prev, visible: false })), 3000);
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
const handleManualSwitch = (targetBranch) => {
|
| 177 |
+
if (!targetBranch || targetBranch === branch) {
|
| 178 |
+
setIsDropdownOpen(false);
|
| 179 |
+
return;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// Local UI feedback (App.jsx will handle the actual state change)
|
| 183 |
+
const goingAi = sessionBranches.includes(targetBranch);
|
| 184 |
+
showToast(
|
| 185 |
+
goingAi ? "Context Switched" : "Switched to Production",
|
| 186 |
+
goingAi ? `Viewing AI Session: ${targetBranch}` : `Viewing ${targetBranch}.`
|
| 187 |
+
);
|
| 188 |
+
|
| 189 |
+
setIsDropdownOpen(false);
|
| 190 |
+
if (onBranchChange) onBranchChange(targetBranch);
|
| 191 |
+
};
|
| 192 |
+
|
| 193 |
+
const handleRefresh = () => {
|
| 194 |
+
setAnalyzing(true);
|
| 195 |
+
setRetryCount(0);
|
| 196 |
+
setRefreshTrigger((prev) => prev + 1);
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
const handleInstallClick = () => {
|
| 200 |
+
if (!appUrl) return;
|
| 201 |
+
const targetUrl = appUrl.endsWith("/") ? `${appUrl}installations/new` : `${appUrl}/installations/new`;
|
| 202 |
+
window.open(targetUrl, "_blank", "noopener,noreferrer");
|
| 203 |
+
};
|
| 204 |
+
|
| 205 |
+
// --- STYLES ---
|
| 206 |
+
const theme = useMemo(
|
| 207 |
+
() => ({
|
| 208 |
+
bg: "#131316",
|
| 209 |
+
border: "#27272A",
|
| 210 |
+
textPrimary: "#EDEDED",
|
| 211 |
+
textSecondary: "#A1A1AA",
|
| 212 |
+
accent: "#3b82f6",
|
| 213 |
+
warningBorder: "rgba(245, 158, 11, 0.2)",
|
| 214 |
+
warningText: "#F59E0B",
|
| 215 |
+
successColor: "#10B981",
|
| 216 |
+
cardBg: "#18181B",
|
| 217 |
+
aiBg: "rgba(59, 130, 246, 0.10)",
|
| 218 |
+
aiBorder: "rgba(59, 130, 246, 0.30)",
|
| 219 |
+
aiText: "#60a5fa",
|
| 220 |
+
}),
|
| 221 |
+
[]
|
| 222 |
+
);
|
| 223 |
+
|
| 224 |
+
const styles = useMemo(
|
| 225 |
+
() => ({
|
| 226 |
+
container: {
|
| 227 |
+
height: "100%",
|
| 228 |
+
borderRight: `1px solid ${theme.border}`,
|
| 229 |
+
backgroundColor: theme.bg,
|
| 230 |
+
display: "flex",
|
| 231 |
+
flexDirection: "column",
|
| 232 |
+
fontFamily: '"Söhne", "Inter", sans-serif',
|
| 233 |
+
position: "relative",
|
| 234 |
+
overflow: "hidden",
|
| 235 |
+
},
|
| 236 |
+
header: {
|
| 237 |
+
padding: "16px 20px",
|
| 238 |
+
borderBottom: `1px solid ${theme.border}`,
|
| 239 |
+
display: "flex",
|
| 240 |
+
alignItems: "center",
|
| 241 |
+
justifyContent: "space-between",
|
| 242 |
+
transition: "background-color 0.3s ease",
|
| 243 |
+
},
|
| 244 |
+
titleGroup: { display: "flex", alignItems: "center", gap: "8px" },
|
| 245 |
+
title: { fontSize: "13px", fontWeight: "600", color: theme.textPrimary },
|
| 246 |
+
repoBadge: {
|
| 247 |
+
backgroundColor: "#27272A",
|
| 248 |
+
color: theme.textSecondary,
|
| 249 |
+
fontSize: "11px",
|
| 250 |
+
padding: "2px 8px",
|
| 251 |
+
borderRadius: "12px",
|
| 252 |
+
border: `1px solid ${theme.border}`,
|
| 253 |
+
fontFamily: "monospace",
|
| 254 |
+
},
|
| 255 |
+
aiBadge: {
|
| 256 |
+
display: "flex",
|
| 257 |
+
alignItems: "center",
|
| 258 |
+
gap: "6px",
|
| 259 |
+
backgroundColor: theme.aiBg,
|
| 260 |
+
color: theme.aiText,
|
| 261 |
+
fontSize: "10px",
|
| 262 |
+
fontWeight: "bold",
|
| 263 |
+
padding: "2px 8px",
|
| 264 |
+
borderRadius: "12px",
|
| 265 |
+
border: `1px solid ${theme.aiBorder}`,
|
| 266 |
+
textTransform: "uppercase",
|
| 267 |
+
letterSpacing: "0.5px",
|
| 268 |
+
},
|
| 269 |
+
content: {
|
| 270 |
+
padding: "16px 20px 12px 20px",
|
| 271 |
+
display: "flex",
|
| 272 |
+
flexDirection: "column",
|
| 273 |
+
gap: "12px",
|
| 274 |
+
},
|
| 275 |
+
statRow: { display: "flex", justifyContent: "space-between", fontSize: "13px", marginBottom: "4px" },
|
| 276 |
+
label: { color: theme.textSecondary },
|
| 277 |
+
value: { color: theme.textPrimary, fontWeight: "500" },
|
| 278 |
+
dropdownContainer: { position: "relative" },
|
| 279 |
+
branchButton: {
|
| 280 |
+
display: "flex",
|
| 281 |
+
alignItems: "center",
|
| 282 |
+
gap: "6px",
|
| 283 |
+
padding: "4px 8px",
|
| 284 |
+
borderRadius: "4px",
|
| 285 |
+
border: `1px solid ${isAiSession ? theme.aiBorder : theme.border}`,
|
| 286 |
+
backgroundColor: isAiSession ? "rgba(59, 130, 246, 0.05)" : "transparent",
|
| 287 |
+
color: isAiSession ? theme.aiText : theme.textPrimary,
|
| 288 |
+
fontSize: "13px",
|
| 289 |
+
cursor: "pointer",
|
| 290 |
+
fontFamily: "monospace",
|
| 291 |
+
},
|
| 292 |
+
dropdownMenu: {
|
| 293 |
+
position: "absolute",
|
| 294 |
+
top: "100%",
|
| 295 |
+
left: 0,
|
| 296 |
+
marginTop: "4px",
|
| 297 |
+
width: "240px",
|
| 298 |
+
backgroundColor: "#1F1F23",
|
| 299 |
+
border: `1px solid ${theme.border}`,
|
| 300 |
+
borderRadius: "6px",
|
| 301 |
+
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
|
| 302 |
+
zIndex: 50,
|
| 303 |
+
display: isDropdownOpen ? "block" : "none",
|
| 304 |
+
overflow: "hidden",
|
| 305 |
+
},
|
| 306 |
+
dropdownItem: {
|
| 307 |
+
padding: "8px 12px",
|
| 308 |
+
fontSize: "13px",
|
| 309 |
+
color: theme.textSecondary,
|
| 310 |
+
cursor: "pointer",
|
| 311 |
+
display: "flex",
|
| 312 |
+
alignItems: "center",
|
| 313 |
+
gap: "8px",
|
| 314 |
+
borderBottom: `1px solid ${theme.border}`,
|
| 315 |
+
},
|
| 316 |
+
contextBanner: {
|
| 317 |
+
backgroundColor: theme.aiBg,
|
| 318 |
+
borderTop: `1px solid ${theme.aiBorder}`,
|
| 319 |
+
padding: "8px 20px",
|
| 320 |
+
fontSize: "11px",
|
| 321 |
+
color: theme.aiText,
|
| 322 |
+
display: "flex",
|
| 323 |
+
justifyContent: "space-between",
|
| 324 |
+
alignItems: "center",
|
| 325 |
+
},
|
| 326 |
+
toast: {
|
| 327 |
+
position: "absolute",
|
| 328 |
+
top: "16px",
|
| 329 |
+
right: "16px",
|
| 330 |
+
backgroundColor: "#18181B",
|
| 331 |
+
border: `1px solid ${theme.border}`,
|
| 332 |
+
borderLeft: `3px solid ${theme.accent}`,
|
| 333 |
+
borderRadius: "6px",
|
| 334 |
+
padding: "12px",
|
| 335 |
+
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
|
| 336 |
+
zIndex: 100,
|
| 337 |
+
minWidth: "240px",
|
| 338 |
+
transition: "all 0.3s cubic-bezier(0.16, 1, 0.3, 1)",
|
| 339 |
+
transform: toast.visible ? "translateX(0)" : "translateX(120%)",
|
| 340 |
+
opacity: toast.visible ? 1 : 0,
|
| 341 |
+
},
|
| 342 |
+
toastTitle: { fontSize: "13px", fontWeight: "bold", color: theme.textPrimary, marginBottom: "2px" },
|
| 343 |
+
toastMsg: { fontSize: "11px", color: theme.textSecondary },
|
| 344 |
+
refreshButton: {
|
| 345 |
+
marginTop: "8px",
|
| 346 |
+
height: "32px",
|
| 347 |
+
padding: "0 12px",
|
| 348 |
+
backgroundColor: "transparent",
|
| 349 |
+
color: theme.textSecondary,
|
| 350 |
+
border: `1px solid ${theme.border}`,
|
| 351 |
+
borderRadius: "6px",
|
| 352 |
+
fontSize: "12px",
|
| 353 |
+
cursor: analyzing ? "not-allowed" : "pointer",
|
| 354 |
+
display: "flex",
|
| 355 |
+
alignItems: "center",
|
| 356 |
+
justifyContent: "center",
|
| 357 |
+
gap: "6px",
|
| 358 |
+
},
|
| 359 |
+
settingsBtn: {
|
| 360 |
+
display: "flex",
|
| 361 |
+
alignItems: "center",
|
| 362 |
+
justifyContent: "center",
|
| 363 |
+
width: "28px",
|
| 364 |
+
height: "28px",
|
| 365 |
+
borderRadius: "6px",
|
| 366 |
+
border: `1px solid ${theme.border}`,
|
| 367 |
+
backgroundColor: "transparent",
|
| 368 |
+
color: theme.textSecondary,
|
| 369 |
+
cursor: "pointer",
|
| 370 |
+
padding: 0,
|
| 371 |
+
transition: "color 0.15s, border-color 0.15s",
|
| 372 |
+
},
|
| 373 |
+
treeWrapper: { flex: 1, overflow: "auto", borderTop: `1px solid ${theme.border}` },
|
| 374 |
+
installCard: {
|
| 375 |
+
marginTop: "8px",
|
| 376 |
+
padding: "12px",
|
| 377 |
+
borderRadius: "8px",
|
| 378 |
+
backgroundColor: theme.cardBg,
|
| 379 |
+
border: `1px solid ${theme.warningBorder}`,
|
| 380 |
+
},
|
| 381 |
+
installHeader: {
|
| 382 |
+
display: "flex",
|
| 383 |
+
alignItems: "center",
|
| 384 |
+
gap: "10px",
|
| 385 |
+
fontSize: "14px",
|
| 386 |
+
fontWeight: "600",
|
| 387 |
+
color: theme.textPrimary,
|
| 388 |
+
},
|
| 389 |
+
installText: {
|
| 390 |
+
fontSize: "13px",
|
| 391 |
+
color: theme.textSecondary,
|
| 392 |
+
lineHeight: "1.5",
|
| 393 |
+
},
|
| 394 |
+
}),
|
| 395 |
+
[analyzing, isAiSession, isDropdownOpen, theme, toast.visible]
|
| 396 |
+
);
|
| 397 |
+
|
| 398 |
+
// Determine status text
|
| 399 |
+
let statusText = "Checking...";
|
| 400 |
+
let statusColor = theme.textSecondary;
|
| 401 |
+
let showInstallCard = false;
|
| 402 |
+
|
| 403 |
+
if (!analyzing && accessInfo) {
|
| 404 |
+
if (accessInfo.app_installed) {
|
| 405 |
+
statusText = "Write Access ✓";
|
| 406 |
+
statusColor = theme.successColor;
|
| 407 |
+
} else if (accessInfo.can_write && retryCount === 0) {
|
| 408 |
+
statusText = "Verifying...";
|
| 409 |
+
} else if (accessInfo.can_write) {
|
| 410 |
+
statusText = "Push Access (No App)";
|
| 411 |
+
statusColor = theme.warningText;
|
| 412 |
+
showInstallCard = true;
|
| 413 |
+
} else {
|
| 414 |
+
statusText = "Read Only";
|
| 415 |
+
statusColor = theme.warningText;
|
| 416 |
+
showInstallCard = true;
|
| 417 |
+
}
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
if (!repo) {
|
| 421 |
+
return (
|
| 422 |
+
<div style={styles.container}>
|
| 423 |
+
<div style={styles.content}>Select a Repo</div>
|
| 424 |
+
</div>
|
| 425 |
+
);
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
return (
|
| 429 |
+
<div style={styles.container}>
|
| 430 |
+
<style>{animationStyles}</style>
|
| 431 |
+
|
| 432 |
+
{/* TOAST */}
|
| 433 |
+
<div style={styles.toast}>
|
| 434 |
+
<div style={styles.toastTitle}>{toast.title}</div>
|
| 435 |
+
<div style={styles.toastMsg}>{toast.msg}</div>
|
| 436 |
+
</div>
|
| 437 |
+
|
| 438 |
+
{/* HEADER */}
|
| 439 |
+
<div style={styles.header} className={animateHeader ? "pulse-context" : ""}>
|
| 440 |
+
<div style={styles.titleGroup}>
|
| 441 |
+
<span style={styles.title}>Project context</span>
|
| 442 |
+
{isAiSession && (
|
| 443 |
+
<span style={styles.aiBadge}>
|
| 444 |
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
| 445 |
+
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
| 446 |
+
</svg>
|
| 447 |
+
AI Session
|
| 448 |
+
</span>
|
| 449 |
+
)}
|
| 450 |
+
</div>
|
| 451 |
+
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
| 452 |
+
{!isAiSession && <span style={styles.repoBadge}>{repo.name}</span>}
|
| 453 |
+
{onSettingsClick && (
|
| 454 |
+
<button
|
| 455 |
+
type="button"
|
| 456 |
+
onClick={onSettingsClick}
|
| 457 |
+
title="Project settings"
|
| 458 |
+
style={styles.settingsBtn}
|
| 459 |
+
>
|
| 460 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 461 |
+
<circle cx="12" cy="12" r="3" />
|
| 462 |
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
| 463 |
+
</svg>
|
| 464 |
+
</button>
|
| 465 |
+
)}
|
| 466 |
+
</div>
|
| 467 |
+
</div>
|
| 468 |
+
|
| 469 |
+
{/* CONTENT */}
|
| 470 |
+
<div style={styles.content}>
|
| 471 |
+
{/* Branch selector (Claude-Code-on-Web parity — uses BranchPicker with search) */}
|
| 472 |
+
<div style={styles.statRow}>
|
| 473 |
+
<span style={styles.label}>Branch:</span>
|
| 474 |
+
<BranchPicker
|
| 475 |
+
repo={repo}
|
| 476 |
+
currentBranch={branch}
|
| 477 |
+
defaultBranch={effectiveDefaultBranch}
|
| 478 |
+
sessionBranches={sessionBranches}
|
| 479 |
+
onBranchChange={handleManualSwitch}
|
| 480 |
+
/>
|
| 481 |
+
</div>
|
| 482 |
+
|
| 483 |
+
{/* Stats */}
|
| 484 |
+
<div style={styles.statRow}>
|
| 485 |
+
<span style={styles.label}>Files:</span>
|
| 486 |
+
<span style={styles.value}>{analyzing ? "…" : fileCount}</span>
|
| 487 |
+
</div>
|
| 488 |
+
|
| 489 |
+
<div style={styles.statRow}>
|
| 490 |
+
<span style={styles.label}>Status:</span>
|
| 491 |
+
<span style={{ ...styles.value, color: statusColor }}>{statusText}</span>
|
| 492 |
+
</div>
|
| 493 |
+
|
| 494 |
+
{/* Tree error (optional display) */}
|
| 495 |
+
{treeError && (
|
| 496 |
+
<div style={{ fontSize: 11, color: theme.warningText }}>
|
| 497 |
+
{treeError}
|
| 498 |
+
</div>
|
| 499 |
+
)}
|
| 500 |
+
|
| 501 |
+
{/* Refresh */}
|
| 502 |
+
<button type="button" style={styles.refreshButton} onClick={handleRefresh} disabled={analyzing}>
|
| 503 |
+
<svg
|
| 504 |
+
width="14"
|
| 505 |
+
height="14"
|
| 506 |
+
viewBox="0 0 24 24"
|
| 507 |
+
fill="none"
|
| 508 |
+
stroke="currentColor"
|
| 509 |
+
strokeWidth="2"
|
| 510 |
+
style={{
|
| 511 |
+
transform: analyzing ? "rotate(360deg)" : "rotate(0deg)",
|
| 512 |
+
transition: "transform 0.6s ease",
|
| 513 |
+
}}
|
| 514 |
+
>
|
| 515 |
+
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
|
| 516 |
+
</svg>
|
| 517 |
+
{analyzing ? "Refreshing..." : "Refresh"}
|
| 518 |
+
</button>
|
| 519 |
+
|
| 520 |
+
{/* Install card */}
|
| 521 |
+
{showInstallCard && (
|
| 522 |
+
<div style={styles.installCard}>
|
| 523 |
+
<div style={styles.installHeader}>
|
| 524 |
+
<span>⚡</span>
|
| 525 |
+
<span>Enable Write Access</span>
|
| 526 |
+
</div>
|
| 527 |
+
<p style={{ ...styles.installText, margin: "8px 0" }}>
|
| 528 |
+
Install the GitPilot App to enable AI agent operations.
|
| 529 |
+
</p>
|
| 530 |
+
<p style={{ ...styles.installText, margin: "0 0 8px 0", fontSize: "11px", opacity: 0.7 }}>
|
| 531 |
+
Alternatively, use Folder or Local Git mode for local-first workflows without GitHub.
|
| 532 |
+
</p>
|
| 533 |
+
<button
|
| 534 |
+
type="button"
|
| 535 |
+
style={{
|
| 536 |
+
...styles.refreshButton,
|
| 537 |
+
width: "100%",
|
| 538 |
+
backgroundColor: theme.accent,
|
| 539 |
+
color: "#fff",
|
| 540 |
+
border: "none",
|
| 541 |
+
}}
|
| 542 |
+
onClick={handleInstallClick}
|
| 543 |
+
>
|
| 544 |
+
Install App
|
| 545 |
+
</button>
|
| 546 |
+
</div>
|
| 547 |
+
)}
|
| 548 |
+
</div>
|
| 549 |
+
|
| 550 |
+
{/* Context banner */}
|
| 551 |
+
{isAiSession && (
|
| 552 |
+
<div style={styles.contextBanner}>
|
| 553 |
+
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
| 554 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 555 |
+
<circle cx="12" cy="12" r="10"></circle>
|
| 556 |
+
<line x1="12" y1="16" x2="12" y2="12"></line>
|
| 557 |
+
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
| 558 |
+
</svg>
|
| 559 |
+
You are viewing an AI Session branch.
|
| 560 |
+
</span>
|
| 561 |
+
<span style={{ textDecoration: "underline", cursor: "pointer" }} onClick={() => handleManualSwitch(effectiveDefaultBranch)}>
|
| 562 |
+
Return to {effectiveDefaultBranch}
|
| 563 |
+
</span>
|
| 564 |
+
</div>
|
| 565 |
+
)}
|
| 566 |
+
|
| 567 |
+
{/* File tree (branch-aware) */}
|
| 568 |
+
<div style={styles.treeWrapper}>
|
| 569 |
+
<FileTree repo={repo} refreshTrigger={refreshTrigger} branch={branch} />
|
| 570 |
+
</div>
|
| 571 |
+
|
| 572 |
+
{/* Sandbox status — always visible. Click "Change" / "Repair"
|
| 573 |
+
to open Settings (matches the existing settings affordance
|
| 574 |
+
at the top), or "Use Local" to one-click flip the backend
|
| 575 |
+
when MatrixLab is unreachable. */}
|
| 576 |
+
<div style={{ marginTop: 12 }}>
|
| 577 |
+
<SandboxStatusWidget onOpenSettings={onSettingsClick} />
|
| 578 |
+
</div>
|
| 579 |
+
</div>
|
| 580 |
+
);
|
| 581 |
+
}
|
frontend/components/ProjectSettings/ContextTab.jsx
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
+
|
| 3 |
+
export default function ContextTab({ owner, repo }) {
|
| 4 |
+
const [assets, setAssets] = useState([]);
|
| 5 |
+
const [busy, setBusy] = useState(false);
|
| 6 |
+
const [error, setError] = useState("");
|
| 7 |
+
const [uploadHint, setUploadHint] = useState("");
|
| 8 |
+
const inputRef = useRef(null);
|
| 9 |
+
|
| 10 |
+
const canUse = useMemo(() => Boolean(owner && repo), [owner, repo]);
|
| 11 |
+
|
| 12 |
+
async function loadAssets() {
|
| 13 |
+
if (!canUse) return;
|
| 14 |
+
setError("");
|
| 15 |
+
try {
|
| 16 |
+
const res = await fetch(`/api/repos/${owner}/${repo}/context/assets`);
|
| 17 |
+
if (!res.ok) throw new Error(`Failed to list assets (${res.status})`);
|
| 18 |
+
const data = await res.json();
|
| 19 |
+
setAssets(data.assets || []);
|
| 20 |
+
} catch (e) {
|
| 21 |
+
setError(e?.message || "Failed to load assets");
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
useEffect(() => {
|
| 26 |
+
loadAssets();
|
| 27 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 28 |
+
}, [owner, repo]);
|
| 29 |
+
|
| 30 |
+
async function uploadFiles(fileList) {
|
| 31 |
+
if (!canUse) return;
|
| 32 |
+
const files = Array.from(fileList || []);
|
| 33 |
+
if (!files.length) return;
|
| 34 |
+
|
| 35 |
+
setBusy(true);
|
| 36 |
+
setError("");
|
| 37 |
+
setUploadHint(`Uploading ${files.length} file(s)...`);
|
| 38 |
+
|
| 39 |
+
try {
|
| 40 |
+
for (const f of files) {
|
| 41 |
+
const form = new FormData();
|
| 42 |
+
form.append("file", f);
|
| 43 |
+
|
| 44 |
+
const res = await fetch(
|
| 45 |
+
`/api/repos/${owner}/${repo}/context/assets/upload`,
|
| 46 |
+
{ method: "POST", body: form }
|
| 47 |
+
);
|
| 48 |
+
if (!res.ok) {
|
| 49 |
+
const txt = await res.text().catch(() => "");
|
| 50 |
+
throw new Error(`Upload failed (${res.status}) ${txt}`);
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
setUploadHint("Upload complete. Refreshing list...");
|
| 54 |
+
await loadAssets();
|
| 55 |
+
setUploadHint("");
|
| 56 |
+
} catch (e) {
|
| 57 |
+
setError(e?.message || "Upload failed");
|
| 58 |
+
setUploadHint("");
|
| 59 |
+
} finally {
|
| 60 |
+
setBusy(false);
|
| 61 |
+
if (inputRef.current) inputRef.current.value = "";
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async function deleteAsset(assetId) {
|
| 66 |
+
if (!canUse) return;
|
| 67 |
+
const ok = window.confirm("Delete this asset? This cannot be undone.");
|
| 68 |
+
if (!ok) return;
|
| 69 |
+
|
| 70 |
+
setBusy(true);
|
| 71 |
+
setError("");
|
| 72 |
+
try {
|
| 73 |
+
const res = await fetch(
|
| 74 |
+
`/api/repos/${owner}/${repo}/context/assets/${assetId}`,
|
| 75 |
+
{ method: "DELETE" }
|
| 76 |
+
);
|
| 77 |
+
if (!res.ok) throw new Error(`Delete failed (${res.status})`);
|
| 78 |
+
await loadAssets();
|
| 79 |
+
} catch (e) {
|
| 80 |
+
setError(e?.message || "Delete failed");
|
| 81 |
+
} finally {
|
| 82 |
+
setBusy(false);
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
function downloadAsset(assetId) {
|
| 87 |
+
if (!canUse) return;
|
| 88 |
+
window.open(
|
| 89 |
+
`/api/repos/${owner}/${repo}/context/assets/${assetId}/download`,
|
| 90 |
+
"_blank"
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
const empty = !assets || assets.length === 0;
|
| 95 |
+
|
| 96 |
+
return (
|
| 97 |
+
<div style={styles.wrap}>
|
| 98 |
+
<div style={styles.topRow}>
|
| 99 |
+
<div style={styles.left}>
|
| 100 |
+
<div style={styles.h1}>Project Context</div>
|
| 101 |
+
<div style={styles.h2}>
|
| 102 |
+
Upload documents, transcripts, screenshots, etc. (non-destructive,
|
| 103 |
+
additive).
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<div style={styles.right}>
|
| 108 |
+
<input
|
| 109 |
+
ref={inputRef}
|
| 110 |
+
type="file"
|
| 111 |
+
multiple
|
| 112 |
+
disabled={!canUse || busy}
|
| 113 |
+
onChange={(e) => uploadFiles(e.target.files)}
|
| 114 |
+
style={styles.fileInput}
|
| 115 |
+
/>
|
| 116 |
+
<button
|
| 117 |
+
style={styles.btn}
|
| 118 |
+
disabled={!canUse || busy}
|
| 119 |
+
onClick={() => inputRef.current?.click()}
|
| 120 |
+
>
|
| 121 |
+
Upload
|
| 122 |
+
</button>
|
| 123 |
+
<button
|
| 124 |
+
style={styles.btn}
|
| 125 |
+
disabled={!canUse || busy}
|
| 126 |
+
onClick={loadAssets}
|
| 127 |
+
>
|
| 128 |
+
Refresh
|
| 129 |
+
</button>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<div
|
| 134 |
+
style={styles.dropzone}
|
| 135 |
+
onDragOver={(e) => {
|
| 136 |
+
e.preventDefault();
|
| 137 |
+
e.stopPropagation();
|
| 138 |
+
}}
|
| 139 |
+
onDrop={(e) => {
|
| 140 |
+
e.preventDefault();
|
| 141 |
+
e.stopPropagation();
|
| 142 |
+
if (busy) return;
|
| 143 |
+
uploadFiles(e.dataTransfer.files);
|
| 144 |
+
}}
|
| 145 |
+
>
|
| 146 |
+
<div style={styles.dropText}>
|
| 147 |
+
Drag & drop files here, or click <b>Upload</b>.
|
| 148 |
+
</div>
|
| 149 |
+
<div style={styles.dropSub}>
|
| 150 |
+
Tip: For audio/video, upload a transcript file too.
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
{uploadHint ? <div style={styles.hint}>{uploadHint}</div> : null}
|
| 155 |
+
{error ? <div style={styles.error}>{error}</div> : null}
|
| 156 |
+
|
| 157 |
+
<div style={styles.tableWrap}>
|
| 158 |
+
<div style={styles.tableHeader}>
|
| 159 |
+
<div style={{ ...styles.col, ...styles.colName }}>File</div>
|
| 160 |
+
<div style={{ ...styles.col, ...styles.colMeta }}>Type</div>
|
| 161 |
+
<div style={{ ...styles.col, ...styles.colMeta }}>Size</div>
|
| 162 |
+
<div style={{ ...styles.col, ...styles.colMeta }}>Indexed</div>
|
| 163 |
+
<div style={{ ...styles.col, ...styles.colActions }}>Actions</div>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
{empty ? (
|
| 167 |
+
<div style={styles.empty}>
|
| 168 |
+
No context assets yet. Upload docs, transcripts, and screenshots to
|
| 169 |
+
improve planning quality.
|
| 170 |
+
</div>
|
| 171 |
+
) : (
|
| 172 |
+
assets.map((a) => (
|
| 173 |
+
<div key={a.asset_id} style={styles.row}>
|
| 174 |
+
<div style={{ ...styles.col, ...styles.colName }}>
|
| 175 |
+
<div style={styles.fileName}>{a.filename}</div>
|
| 176 |
+
<div style={styles.small}>
|
| 177 |
+
Added: {a.created_at || "-"} | Extracted:{" "}
|
| 178 |
+
{Number(a.extracted_chars || 0).toLocaleString()} chars
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
<div style={{ ...styles.col, ...styles.colMeta }}>
|
| 183 |
+
<span style={styles.badge}>{a.mime || "unknown"}</span>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<div style={{ ...styles.col, ...styles.colMeta }}>
|
| 187 |
+
{formatBytes(a.size_bytes || 0)}
|
| 188 |
+
</div>
|
| 189 |
+
|
| 190 |
+
<div style={{ ...styles.col, ...styles.colMeta }}>
|
| 191 |
+
{a.indexed_chunks || 0} chunks
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<div style={{ ...styles.col, ...styles.colActions }}>
|
| 195 |
+
<button
|
| 196 |
+
style={styles.smallBtn}
|
| 197 |
+
disabled={busy}
|
| 198 |
+
onClick={() => downloadAsset(a.asset_id)}
|
| 199 |
+
>
|
| 200 |
+
Download
|
| 201 |
+
</button>
|
| 202 |
+
<button
|
| 203 |
+
style={{ ...styles.smallBtn, ...styles.dangerBtn }}
|
| 204 |
+
disabled={busy}
|
| 205 |
+
onClick={() => deleteAsset(a.asset_id)}
|
| 206 |
+
>
|
| 207 |
+
Delete
|
| 208 |
+
</button>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
))
|
| 212 |
+
)}
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function formatBytes(bytes) {
|
| 219 |
+
const b = Number(bytes || 0);
|
| 220 |
+
if (!b) return "0 B";
|
| 221 |
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
| 222 |
+
let i = 0;
|
| 223 |
+
let v = b;
|
| 224 |
+
while (v >= 1024 && i < units.length - 1) {
|
| 225 |
+
v /= 1024;
|
| 226 |
+
i += 1;
|
| 227 |
+
}
|
| 228 |
+
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
const styles = {
|
| 232 |
+
wrap: { display: "flex", flexDirection: "column", gap: 12 },
|
| 233 |
+
topRow: {
|
| 234 |
+
display: "flex",
|
| 235 |
+
justifyContent: "space-between",
|
| 236 |
+
gap: 12,
|
| 237 |
+
alignItems: "flex-start",
|
| 238 |
+
flexWrap: "wrap",
|
| 239 |
+
},
|
| 240 |
+
left: { minWidth: 280 },
|
| 241 |
+
right: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" },
|
| 242 |
+
h1: { fontSize: 14, fontWeight: 800, color: "#fff" },
|
| 243 |
+
h2: { fontSize: 12, color: "rgba(255,255,255,0.65)", marginTop: 4 },
|
| 244 |
+
fileInput: { display: "none" },
|
| 245 |
+
btn: {
|
| 246 |
+
background: "rgba(255,255,255,0.10)",
|
| 247 |
+
border: "1px solid rgba(255,255,255,0.18)",
|
| 248 |
+
color: "#fff",
|
| 249 |
+
borderRadius: 10,
|
| 250 |
+
padding: "8px 10px",
|
| 251 |
+
cursor: "pointer",
|
| 252 |
+
fontSize: 13,
|
| 253 |
+
},
|
| 254 |
+
dropzone: {
|
| 255 |
+
border: "1px dashed rgba(255,255,255,0.22)",
|
| 256 |
+
borderRadius: 12,
|
| 257 |
+
padding: 16,
|
| 258 |
+
background: "rgba(255,255,255,0.03)",
|
| 259 |
+
},
|
| 260 |
+
dropText: { color: "rgba(255,255,255,0.85)", fontSize: 13 },
|
| 261 |
+
dropSub: { color: "rgba(255,255,255,0.55)", fontSize: 12, marginTop: 6 },
|
| 262 |
+
hint: {
|
| 263 |
+
color: "rgba(255,255,255,0.75)",
|
| 264 |
+
fontSize: 12,
|
| 265 |
+
padding: "8px 10px",
|
| 266 |
+
border: "1px solid rgba(255,255,255,0.12)",
|
| 267 |
+
borderRadius: 10,
|
| 268 |
+
background: "rgba(255,255,255,0.03)",
|
| 269 |
+
},
|
| 270 |
+
error: {
|
| 271 |
+
color: "#ffb3b3",
|
| 272 |
+
fontSize: 12,
|
| 273 |
+
padding: "8px 10px",
|
| 274 |
+
border: "1px solid rgba(255,120,120,0.25)",
|
| 275 |
+
borderRadius: 10,
|
| 276 |
+
background: "rgba(255,80,80,0.08)",
|
| 277 |
+
},
|
| 278 |
+
tableWrap: {
|
| 279 |
+
border: "1px solid rgba(255,255,255,0.12)",
|
| 280 |
+
borderRadius: 12,
|
| 281 |
+
overflow: "hidden",
|
| 282 |
+
},
|
| 283 |
+
tableHeader: {
|
| 284 |
+
display: "grid",
|
| 285 |
+
gridTemplateColumns: "1.6fr 1fr 0.6fr 0.6fr 0.8fr",
|
| 286 |
+
gap: 0,
|
| 287 |
+
padding: "10px 12px",
|
| 288 |
+
background: "rgba(255,255,255,0.03)",
|
| 289 |
+
borderBottom: "1px solid rgba(255,255,255,0.10)",
|
| 290 |
+
fontSize: 12,
|
| 291 |
+
color: "rgba(255,255,255,0.65)",
|
| 292 |
+
},
|
| 293 |
+
row: {
|
| 294 |
+
display: "grid",
|
| 295 |
+
gridTemplateColumns: "1.6fr 1fr 0.6fr 0.6fr 0.8fr",
|
| 296 |
+
padding: "10px 12px",
|
| 297 |
+
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
| 298 |
+
alignItems: "center",
|
| 299 |
+
},
|
| 300 |
+
col: { minWidth: 0 },
|
| 301 |
+
colName: {},
|
| 302 |
+
colMeta: { color: "rgba(255,255,255,0.75)", fontSize: 12 },
|
| 303 |
+
colActions: { display: "flex", gap: 8, justifyContent: "flex-end" },
|
| 304 |
+
fileName: {
|
| 305 |
+
color: "#fff",
|
| 306 |
+
fontSize: 13,
|
| 307 |
+
fontWeight: 700,
|
| 308 |
+
overflow: "hidden",
|
| 309 |
+
textOverflow: "ellipsis",
|
| 310 |
+
whiteSpace: "nowrap",
|
| 311 |
+
},
|
| 312 |
+
small: {
|
| 313 |
+
color: "rgba(255,255,255,0.55)",
|
| 314 |
+
fontSize: 11,
|
| 315 |
+
marginTop: 4,
|
| 316 |
+
overflow: "hidden",
|
| 317 |
+
textOverflow: "ellipsis",
|
| 318 |
+
whiteSpace: "nowrap",
|
| 319 |
+
},
|
| 320 |
+
badge: {
|
| 321 |
+
display: "inline-flex",
|
| 322 |
+
alignItems: "center",
|
| 323 |
+
padding: "2px 8px",
|
| 324 |
+
borderRadius: 999,
|
| 325 |
+
border: "1px solid rgba(255,255,255,0.16)",
|
| 326 |
+
background: "rgba(255,255,255,0.04)",
|
| 327 |
+
fontSize: 11,
|
| 328 |
+
color: "rgba(255,255,255,0.80)",
|
| 329 |
+
maxWidth: "100%",
|
| 330 |
+
overflow: "hidden",
|
| 331 |
+
textOverflow: "ellipsis",
|
| 332 |
+
whiteSpace: "nowrap",
|
| 333 |
+
},
|
| 334 |
+
smallBtn: {
|
| 335 |
+
background: "rgba(255,255,255,0.08)",
|
| 336 |
+
border: "1px solid rgba(255,255,255,0.16)",
|
| 337 |
+
color: "#fff",
|
| 338 |
+
borderRadius: 10,
|
| 339 |
+
padding: "6px 8px",
|
| 340 |
+
cursor: "pointer",
|
| 341 |
+
fontSize: 12,
|
| 342 |
+
},
|
| 343 |
+
dangerBtn: {
|
| 344 |
+
border: "1px solid rgba(255,90,90,0.35)",
|
| 345 |
+
background: "rgba(255,90,90,0.10)",
|
| 346 |
+
},
|
| 347 |
+
empty: {
|
| 348 |
+
padding: 14,
|
| 349 |
+
color: "rgba(255,255,255,0.65)",
|
| 350 |
+
fontSize: 13,
|
| 351 |
+
},
|
| 352 |
+
};
|
frontend/components/ProjectSettings/ConventionsTab.jsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useMemo, useState } from "react";
|
| 2 |
+
|
| 3 |
+
export default function ConventionsTab({ owner, repo }) {
|
| 4 |
+
const [content, setContent] = useState("");
|
| 5 |
+
const [busy, setBusy] = useState(false);
|
| 6 |
+
const [error, setError] = useState("");
|
| 7 |
+
|
| 8 |
+
const canUse = useMemo(() => Boolean(owner && repo), [owner, repo]);
|
| 9 |
+
|
| 10 |
+
async function load() {
|
| 11 |
+
if (!canUse) return;
|
| 12 |
+
setError("");
|
| 13 |
+
setBusy(true);
|
| 14 |
+
try {
|
| 15 |
+
const res = await fetch(`/api/repos/${owner}/${repo}/context`);
|
| 16 |
+
if (!res.ok) throw new Error(`Failed to load conventions (${res.status})`);
|
| 17 |
+
const data = await res.json();
|
| 18 |
+
// backend may return { context: "..."} or { conventions: "..."} depending on implementation
|
| 19 |
+
setContent(data.context || data.conventions || data.memory || data.text || "");
|
| 20 |
+
} catch (e) {
|
| 21 |
+
setError(e?.message || "Failed to load conventions");
|
| 22 |
+
} finally {
|
| 23 |
+
setBusy(false);
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
async function initialize() {
|
| 28 |
+
if (!canUse) return;
|
| 29 |
+
setError("");
|
| 30 |
+
setBusy(true);
|
| 31 |
+
try {
|
| 32 |
+
const res = await fetch(`/api/repos/${owner}/${repo}/context/init`, {
|
| 33 |
+
method: "POST",
|
| 34 |
+
});
|
| 35 |
+
if (!res.ok) {
|
| 36 |
+
const txt = await res.text().catch(() => "");
|
| 37 |
+
throw new Error(`Init failed (${res.status}) ${txt}`);
|
| 38 |
+
}
|
| 39 |
+
await load();
|
| 40 |
+
} catch (e) {
|
| 41 |
+
setError(e?.message || "Init failed");
|
| 42 |
+
} finally {
|
| 43 |
+
setBusy(false);
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
useEffect(() => {
|
| 48 |
+
load();
|
| 49 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 50 |
+
}, [owner, repo]);
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div style={styles.wrap}>
|
| 54 |
+
<div style={styles.topRow}>
|
| 55 |
+
<div>
|
| 56 |
+
<div style={styles.h1}>Project Conventions</div>
|
| 57 |
+
<div style={styles.h2}>
|
| 58 |
+
This is the project memory/conventions file used by GitPilot.
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
<div style={styles.actions}>
|
| 62 |
+
<button style={styles.btn} disabled={!canUse || busy} onClick={load}>
|
| 63 |
+
Refresh
|
| 64 |
+
</button>
|
| 65 |
+
<button
|
| 66 |
+
style={styles.btn}
|
| 67 |
+
disabled={!canUse || busy}
|
| 68 |
+
onClick={initialize}
|
| 69 |
+
>
|
| 70 |
+
Initialize
|
| 71 |
+
</button>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
{error ? <div style={styles.error}>{error}</div> : null}
|
| 76 |
+
|
| 77 |
+
<div style={styles.box}>
|
| 78 |
+
{content ? (
|
| 79 |
+
<pre style={styles.pre}>{content}</pre>
|
| 80 |
+
) : (
|
| 81 |
+
<div style={styles.empty}>
|
| 82 |
+
No conventions found yet. Click <b>Initialize</b> to create default
|
| 83 |
+
project memory if supported.
|
| 84 |
+
</div>
|
| 85 |
+
)}
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div style={styles.note}>
|
| 89 |
+
Editing conventions is intentionally not included here to keep this
|
| 90 |
+
feature additive/non-destructive. You can extend this later with an
|
| 91 |
+
explicit "Edit" mode.
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const styles = {
|
| 98 |
+
wrap: { display: "flex", flexDirection: "column", gap: 12 },
|
| 99 |
+
topRow: {
|
| 100 |
+
display: "flex",
|
| 101 |
+
justifyContent: "space-between",
|
| 102 |
+
gap: 12,
|
| 103 |
+
alignItems: "flex-start",
|
| 104 |
+
flexWrap: "wrap",
|
| 105 |
+
},
|
| 106 |
+
actions: { display: "flex", gap: 8, flexWrap: "wrap" },
|
| 107 |
+
h1: { fontSize: 14, fontWeight: 800, color: "#fff" },
|
| 108 |
+
h2: { fontSize: 12, color: "rgba(255,255,255,0.65)", marginTop: 4 },
|
| 109 |
+
btn: {
|
| 110 |
+
background: "rgba(255,255,255,0.10)",
|
| 111 |
+
border: "1px solid rgba(255,255,255,0.18)",
|
| 112 |
+
color: "#fff",
|
| 113 |
+
borderRadius: 10,
|
| 114 |
+
padding: "8px 10px",
|
| 115 |
+
cursor: "pointer",
|
| 116 |
+
fontSize: 13,
|
| 117 |
+
},
|
| 118 |
+
error: {
|
| 119 |
+
color: "#ffb3b3",
|
| 120 |
+
fontSize: 12,
|
| 121 |
+
padding: "8px 10px",
|
| 122 |
+
border: "1px solid rgba(255,120,120,0.25)",
|
| 123 |
+
borderRadius: 10,
|
| 124 |
+
background: "rgba(255,80,80,0.08)",
|
| 125 |
+
},
|
| 126 |
+
box: {
|
| 127 |
+
border: "1px solid rgba(255,255,255,0.12)",
|
| 128 |
+
borderRadius: 12,
|
| 129 |
+
overflow: "hidden",
|
| 130 |
+
background: "rgba(0,0,0,0.22)",
|
| 131 |
+
},
|
| 132 |
+
pre: {
|
| 133 |
+
margin: 0,
|
| 134 |
+
padding: 12,
|
| 135 |
+
color: "rgba(255,255,255,0.85)",
|
| 136 |
+
fontSize: 12,
|
| 137 |
+
lineHeight: 1.35,
|
| 138 |
+
whiteSpace: "pre-wrap",
|
| 139 |
+
overflow: "auto",
|
| 140 |
+
maxHeight: 520,
|
| 141 |
+
},
|
| 142 |
+
empty: {
|
| 143 |
+
padding: 12,
|
| 144 |
+
color: "rgba(255,255,255,0.65)",
|
| 145 |
+
fontSize: 13,
|
| 146 |
+
},
|
| 147 |
+
note: {
|
| 148 |
+
color: "rgba(255,255,255,0.55)",
|
| 149 |
+
fontSize: 12,
|
| 150 |
+
},
|
| 151 |
+
};
|
frontend/components/ProjectSettings/UseCaseTab.jsx
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
+
|
| 3 |
+
export default function UseCaseTab({ owner, repo }) {
|
| 4 |
+
const [useCases, setUseCases] = useState([]);
|
| 5 |
+
const [selectedId, setSelectedId] = useState("");
|
| 6 |
+
const [useCase, setUseCase] = useState(null);
|
| 7 |
+
const [busy, setBusy] = useState(false);
|
| 8 |
+
const [error, setError] = useState("");
|
| 9 |
+
const [draftTitle, setDraftTitle] = useState("New Use Case");
|
| 10 |
+
const [message, setMessage] = useState("");
|
| 11 |
+
const messagesEndRef = useRef(null);
|
| 12 |
+
|
| 13 |
+
const canUse = useMemo(() => Boolean(owner && repo), [owner, repo]);
|
| 14 |
+
const spec = useCase?.spec || {};
|
| 15 |
+
|
| 16 |
+
function scrollToBottom() {
|
| 17 |
+
requestAnimationFrame(() => {
|
| 18 |
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 19 |
+
});
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
async function loadUseCases() {
|
| 23 |
+
if (!canUse) return;
|
| 24 |
+
setError("");
|
| 25 |
+
try {
|
| 26 |
+
const res = await fetch(`/api/repos/${owner}/${repo}/use-cases`);
|
| 27 |
+
if (!res.ok) throw new Error(`Failed to list use cases (${res.status})`);
|
| 28 |
+
const data = await res.json();
|
| 29 |
+
const list = data.use_cases || [];
|
| 30 |
+
setUseCases(list);
|
| 31 |
+
|
| 32 |
+
// auto select active or first
|
| 33 |
+
const active = list.find((x) => x.is_active);
|
| 34 |
+
const nextId = active?.use_case_id || list[0]?.use_case_id || "";
|
| 35 |
+
if (!selectedId && nextId) setSelectedId(nextId);
|
| 36 |
+
} catch (e) {
|
| 37 |
+
setError(e?.message || "Failed to load use cases");
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
async function loadUseCase(id) {
|
| 42 |
+
if (!canUse || !id) return;
|
| 43 |
+
setError("");
|
| 44 |
+
try {
|
| 45 |
+
const res = await fetch(`/api/repos/${owner}/${repo}/use-cases/${id}`);
|
| 46 |
+
if (!res.ok) throw new Error(`Failed to load use case (${res.status})`);
|
| 47 |
+
const data = await res.json();
|
| 48 |
+
setUseCase(data.use_case || null);
|
| 49 |
+
scrollToBottom();
|
| 50 |
+
} catch (e) {
|
| 51 |
+
setError(e?.message || "Failed to load use case");
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
useEffect(() => {
|
| 56 |
+
loadUseCases();
|
| 57 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 58 |
+
}, [owner, repo]);
|
| 59 |
+
|
| 60 |
+
useEffect(() => {
|
| 61 |
+
if (!selectedId) return;
|
| 62 |
+
loadUseCase(selectedId);
|
| 63 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 64 |
+
}, [selectedId]);
|
| 65 |
+
|
| 66 |
+
async function createUseCase() {
|
| 67 |
+
if (!canUse) return;
|
| 68 |
+
setBusy(true);
|
| 69 |
+
setError("");
|
| 70 |
+
try {
|
| 71 |
+
const res = await fetch(`/api/repos/${owner}/${repo}/use-cases`, {
|
| 72 |
+
method: "POST",
|
| 73 |
+
headers: { "Content-Type": "application/json" },
|
| 74 |
+
body: JSON.stringify({ title: draftTitle || "New Use Case" }),
|
| 75 |
+
});
|
| 76 |
+
if (!res.ok) {
|
| 77 |
+
const txt = await res.text().catch(() => "");
|
| 78 |
+
throw new Error(`Create failed (${res.status}) ${txt}`);
|
| 79 |
+
}
|
| 80 |
+
const data = await res.json();
|
| 81 |
+
const id = data?.use_case?.use_case_id;
|
| 82 |
+
await loadUseCases();
|
| 83 |
+
if (id) setSelectedId(id);
|
| 84 |
+
setDraftTitle("New Use Case");
|
| 85 |
+
} catch (e) {
|
| 86 |
+
setError(e?.message || "Create failed");
|
| 87 |
+
} finally {
|
| 88 |
+
setBusy(false);
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
async function sendMessage() {
|
| 93 |
+
if (!canUse || !selectedId) return;
|
| 94 |
+
const msg = (message || "").trim();
|
| 95 |
+
if (!msg) return;
|
| 96 |
+
|
| 97 |
+
setBusy(true);
|
| 98 |
+
setError("");
|
| 99 |
+
|
| 100 |
+
// optimistic UI: append user message immediately
|
| 101 |
+
setUseCase((prev) => {
|
| 102 |
+
if (!prev) return prev;
|
| 103 |
+
const next = { ...prev };
|
| 104 |
+
next.messages = Array.isArray(next.messages) ? [...next.messages] : [];
|
| 105 |
+
next.messages.push({ role: "user", content: msg, ts: new Date().toISOString() });
|
| 106 |
+
return next;
|
| 107 |
+
});
|
| 108 |
+
setMessage("");
|
| 109 |
+
scrollToBottom();
|
| 110 |
+
|
| 111 |
+
try {
|
| 112 |
+
const res = await fetch(
|
| 113 |
+
`/api/repos/${owner}/${repo}/use-cases/${selectedId}/chat`,
|
| 114 |
+
{
|
| 115 |
+
method: "POST",
|
| 116 |
+
headers: { "Content-Type": "application/json" },
|
| 117 |
+
body: JSON.stringify({ message: msg }),
|
| 118 |
+
}
|
| 119 |
+
);
|
| 120 |
+
if (!res.ok) {
|
| 121 |
+
const txt = await res.text().catch(() => "");
|
| 122 |
+
throw new Error(`Chat failed (${res.status}) ${txt}`);
|
| 123 |
+
}
|
| 124 |
+
const data = await res.json();
|
| 125 |
+
setUseCase(data.use_case || null);
|
| 126 |
+
await loadUseCases();
|
| 127 |
+
scrollToBottom();
|
| 128 |
+
} catch (e) {
|
| 129 |
+
setError(e?.message || "Chat failed");
|
| 130 |
+
} finally {
|
| 131 |
+
setBusy(false);
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
async function finalizeUseCase() {
|
| 136 |
+
if (!canUse || !selectedId) return;
|
| 137 |
+
setBusy(true);
|
| 138 |
+
setError("");
|
| 139 |
+
try {
|
| 140 |
+
const res = await fetch(
|
| 141 |
+
`/api/repos/${owner}/${repo}/use-cases/${selectedId}/finalize`,
|
| 142 |
+
{ method: "POST" }
|
| 143 |
+
);
|
| 144 |
+
if (!res.ok) {
|
| 145 |
+
const txt = await res.text().catch(() => "");
|
| 146 |
+
throw new Error(`Finalize failed (${res.status}) ${txt}`);
|
| 147 |
+
}
|
| 148 |
+
const data = await res.json();
|
| 149 |
+
setUseCase(data.use_case || null);
|
| 150 |
+
await loadUseCases();
|
| 151 |
+
alert(
|
| 152 |
+
"Use Case finalized and marked active.\n\nA Markdown export was saved in the repo workspace .gitpilot/context/use_cases/."
|
| 153 |
+
);
|
| 154 |
+
} catch (e) {
|
| 155 |
+
setError(e?.message || "Finalize failed");
|
| 156 |
+
} finally {
|
| 157 |
+
setBusy(false);
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
const activeId = useCases.find((x) => x.is_active)?.use_case_id;
|
| 162 |
+
|
| 163 |
+
return (
|
| 164 |
+
<div style={styles.wrap}>
|
| 165 |
+
<div style={styles.topRow}>
|
| 166 |
+
<div style={styles.left}>
|
| 167 |
+
<div style={styles.h1}>Use Case</div>
|
| 168 |
+
<div style={styles.h2}>
|
| 169 |
+
Guided chat to clarify requirements and produce a versioned spec.
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<div style={styles.right}>
|
| 174 |
+
<input
|
| 175 |
+
value={draftTitle}
|
| 176 |
+
onChange={(e) => setDraftTitle(e.target.value)}
|
| 177 |
+
placeholder="New use case title..."
|
| 178 |
+
style={styles.titleInput}
|
| 179 |
+
disabled={!canUse || busy}
|
| 180 |
+
/>
|
| 181 |
+
<button
|
| 182 |
+
style={styles.btn}
|
| 183 |
+
onClick={createUseCase}
|
| 184 |
+
disabled={!canUse || busy}
|
| 185 |
+
>
|
| 186 |
+
New
|
| 187 |
+
</button>
|
| 188 |
+
<button
|
| 189 |
+
style={styles.btn}
|
| 190 |
+
onClick={finalizeUseCase}
|
| 191 |
+
disabled={!canUse || busy || !selectedId}
|
| 192 |
+
>
|
| 193 |
+
Finalize
|
| 194 |
+
</button>
|
| 195 |
+
<button
|
| 196 |
+
style={styles.btn}
|
| 197 |
+
onClick={loadUseCases}
|
| 198 |
+
disabled={!canUse || busy}
|
| 199 |
+
>
|
| 200 |
+
Refresh
|
| 201 |
+
</button>
|
| 202 |
+
</div>
|
| 203 |
+
</div>
|
| 204 |
+
|
| 205 |
+
{error ? <div style={styles.error}>{error}</div> : null}
|
| 206 |
+
|
| 207 |
+
<div style={styles.grid}>
|
| 208 |
+
<div style={styles.sidebar}>
|
| 209 |
+
<div style={styles.sidebarTitle}>Use Cases</div>
|
| 210 |
+
<div style={styles.sidebarList}>
|
| 211 |
+
{useCases.length === 0 ? (
|
| 212 |
+
<div style={styles.sidebarEmpty}>
|
| 213 |
+
No use cases yet. Create one with <b>New</b>.
|
| 214 |
+
</div>
|
| 215 |
+
) : (
|
| 216 |
+
useCases.map((uc) => (
|
| 217 |
+
<button
|
| 218 |
+
key={uc.use_case_id}
|
| 219 |
+
style={{
|
| 220 |
+
...styles.ucItem,
|
| 221 |
+
...(selectedId === uc.use_case_id ? styles.ucItemActive : {}),
|
| 222 |
+
}}
|
| 223 |
+
onClick={() => setSelectedId(uc.use_case_id)}
|
| 224 |
+
>
|
| 225 |
+
<div style={styles.ucTitleRow}>
|
| 226 |
+
<div style={styles.ucTitle}>
|
| 227 |
+
{uc.title || "(untitled)"}
|
| 228 |
+
</div>
|
| 229 |
+
{uc.use_case_id === activeId ? (
|
| 230 |
+
<span style={styles.activePill}>ACTIVE</span>
|
| 231 |
+
) : null}
|
| 232 |
+
</div>
|
| 233 |
+
<div style={styles.ucMeta}>
|
| 234 |
+
Updated: {uc.updated_at || uc.created_at || "-"}
|
| 235 |
+
</div>
|
| 236 |
+
</button>
|
| 237 |
+
))
|
| 238 |
+
)}
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
<div style={styles.chatCol}>
|
| 243 |
+
<div style={styles.panelTitle}>Guided Chat</div>
|
| 244 |
+
<div style={styles.chatBox}>
|
| 245 |
+
{Array.isArray(useCase?.messages) && useCase.messages.length ? (
|
| 246 |
+
useCase.messages.map((m, idx) => (
|
| 247 |
+
<div
|
| 248 |
+
key={idx}
|
| 249 |
+
style={{
|
| 250 |
+
...styles.msg,
|
| 251 |
+
...(m.role === "user" ? styles.msgUser : styles.msgAsst),
|
| 252 |
+
}}
|
| 253 |
+
>
|
| 254 |
+
<div style={styles.msgRole}>
|
| 255 |
+
{m.role === "user" ? "You" : "Assistant"}
|
| 256 |
+
</div>
|
| 257 |
+
<div style={styles.msgContent}>{m.content}</div>
|
| 258 |
+
</div>
|
| 259 |
+
))
|
| 260 |
+
) : (
|
| 261 |
+
<div style={styles.chatEmpty}>
|
| 262 |
+
Select a use case and start chatting. You can paste structured
|
| 263 |
+
info like:
|
| 264 |
+
<pre style={styles.pre}>
|
| 265 |
+
{`Summary: ...
|
| 266 |
+
Problem: ...
|
| 267 |
+
Users: ...
|
| 268 |
+
Requirements:
|
| 269 |
+
- ...
|
| 270 |
+
Acceptance Criteria:
|
| 271 |
+
- ...`}
|
| 272 |
+
</pre>
|
| 273 |
+
</div>
|
| 274 |
+
)}
|
| 275 |
+
<div ref={messagesEndRef} />
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<div style={styles.composer}>
|
| 279 |
+
<textarea
|
| 280 |
+
value={message}
|
| 281 |
+
onChange={(e) => setMessage(e.target.value)}
|
| 282 |
+
placeholder="Type your answer... (Shift+Enter for newline)"
|
| 283 |
+
style={styles.textarea}
|
| 284 |
+
disabled={!canUse || busy || !selectedId}
|
| 285 |
+
onKeyDown={(e) => {
|
| 286 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 287 |
+
e.preventDefault();
|
| 288 |
+
sendMessage();
|
| 289 |
+
}
|
| 290 |
+
}}
|
| 291 |
+
/>
|
| 292 |
+
<button
|
| 293 |
+
style={styles.sendBtn}
|
| 294 |
+
disabled={!canUse || busy || !selectedId}
|
| 295 |
+
onClick={sendMessage}
|
| 296 |
+
>
|
| 297 |
+
Send
|
| 298 |
+
</button>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
|
| 302 |
+
<div style={styles.specCol}>
|
| 303 |
+
<div style={styles.panelTitle}>Spec Preview</div>
|
| 304 |
+
<div style={styles.specBox}>
|
| 305 |
+
<Section title="Title" value={spec.title || useCase?.title || ""} />
|
| 306 |
+
<Section title="Summary" value={spec.summary || ""} />
|
| 307 |
+
<Section title="Problem" value={spec.problem || ""} />
|
| 308 |
+
<Section title="Users / Personas" value={spec.users || ""} />
|
| 309 |
+
<ListSection title="Requirements" items={spec.requirements || []} />
|
| 310 |
+
<ListSection
|
| 311 |
+
title="Acceptance Criteria"
|
| 312 |
+
items={spec.acceptance_criteria || []}
|
| 313 |
+
/>
|
| 314 |
+
<ListSection title="Constraints" items={spec.constraints || []} />
|
| 315 |
+
<ListSection title="Open Questions" items={spec.open_questions || []} />
|
| 316 |
+
<Section title="Notes" value={spec.notes || ""} />
|
| 317 |
+
</div>
|
| 318 |
+
|
| 319 |
+
<div style={styles.specFooter}>
|
| 320 |
+
<div style={styles.specHint}>
|
| 321 |
+
Finalize will save a Markdown spec and mark it ACTIVE for context.
|
| 322 |
+
</div>
|
| 323 |
+
<button
|
| 324 |
+
style={styles.primaryBtn}
|
| 325 |
+
disabled={!canUse || busy || !selectedId}
|
| 326 |
+
onClick={finalizeUseCase}
|
| 327 |
+
>
|
| 328 |
+
Finalize Spec
|
| 329 |
+
</button>
|
| 330 |
+
</div>
|
| 331 |
+
</div>
|
| 332 |
+
</div>
|
| 333 |
+
</div>
|
| 334 |
+
);
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
function Section({ title, value }) {
|
| 338 |
+
return (
|
| 339 |
+
<div style={styles.section}>
|
| 340 |
+
<div style={styles.sectionTitle}>{title}</div>
|
| 341 |
+
<div style={styles.sectionBody}>
|
| 342 |
+
{String(value || "").trim() ? (
|
| 343 |
+
<div style={styles.sectionText}>{value}</div>
|
| 344 |
+
) : (
|
| 345 |
+
<div style={styles.sectionEmpty}>(empty)</div>
|
| 346 |
+
)}
|
| 347 |
+
</div>
|
| 348 |
+
</div>
|
| 349 |
+
);
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
function ListSection({ title, items }) {
|
| 353 |
+
const list = Array.isArray(items) ? items : [];
|
| 354 |
+
return (
|
| 355 |
+
<div style={styles.section}>
|
| 356 |
+
<div style={styles.sectionTitle}>{title}</div>
|
| 357 |
+
<div style={styles.sectionBody}>
|
| 358 |
+
{list.length ? (
|
| 359 |
+
<ul style={styles.ul}>
|
| 360 |
+
{list.map((x, i) => (
|
| 361 |
+
<li key={i} style={styles.li}>
|
| 362 |
+
{x}
|
| 363 |
+
</li>
|
| 364 |
+
))}
|
| 365 |
+
</ul>
|
| 366 |
+
) : (
|
| 367 |
+
<div style={styles.sectionEmpty}>(empty)</div>
|
| 368 |
+
)}
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
const styles = {
|
| 375 |
+
wrap: { display: "flex", flexDirection: "column", gap: 12 },
|
| 376 |
+
topRow: {
|
| 377 |
+
display: "flex",
|
| 378 |
+
justifyContent: "space-between",
|
| 379 |
+
gap: 12,
|
| 380 |
+
alignItems: "flex-start",
|
| 381 |
+
flexWrap: "wrap",
|
| 382 |
+
},
|
| 383 |
+
left: { minWidth: 280 },
|
| 384 |
+
right: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" },
|
| 385 |
+
h1: { fontSize: 14, fontWeight: 800, color: "#fff" },
|
| 386 |
+
h2: { fontSize: 12, color: "rgba(255,255,255,0.65)", marginTop: 4 },
|
| 387 |
+
titleInput: {
|
| 388 |
+
width: 260,
|
| 389 |
+
maxWidth: "70vw",
|
| 390 |
+
padding: "8px 10px",
|
| 391 |
+
borderRadius: 10,
|
| 392 |
+
border: "1px solid rgba(255,255,255,0.18)",
|
| 393 |
+
background: "rgba(0,0,0,0.25)",
|
| 394 |
+
color: "#fff",
|
| 395 |
+
fontSize: 13,
|
| 396 |
+
outline: "none",
|
| 397 |
+
},
|
| 398 |
+
btn: {
|
| 399 |
+
background: "rgba(255,255,255,0.10)",
|
| 400 |
+
border: "1px solid rgba(255,255,255,0.18)",
|
| 401 |
+
color: "#fff",
|
| 402 |
+
borderRadius: 10,
|
| 403 |
+
padding: "8px 10px",
|
| 404 |
+
cursor: "pointer",
|
| 405 |
+
fontSize: 13,
|
| 406 |
+
},
|
| 407 |
+
primaryBtn: {
|
| 408 |
+
background: "rgba(255,255,255,0.12)",
|
| 409 |
+
border: "1px solid rgba(255,255,255,0.22)",
|
| 410 |
+
color: "#fff",
|
| 411 |
+
borderRadius: 10,
|
| 412 |
+
padding: "8px 12px",
|
| 413 |
+
cursor: "pointer",
|
| 414 |
+
fontSize: 13,
|
| 415 |
+
fontWeight: 700,
|
| 416 |
+
},
|
| 417 |
+
error: {
|
| 418 |
+
color: "#ffb3b3",
|
| 419 |
+
fontSize: 12,
|
| 420 |
+
padding: "8px 10px",
|
| 421 |
+
border: "1px solid rgba(255,120,120,0.25)",
|
| 422 |
+
borderRadius: 10,
|
| 423 |
+
background: "rgba(255,80,80,0.08)",
|
| 424 |
+
},
|
| 425 |
+
grid: {
|
| 426 |
+
display: "grid",
|
| 427 |
+
gridTemplateColumns: "300px 1.2fr 0.9fr",
|
| 428 |
+
gap: 12,
|
| 429 |
+
alignItems: "stretch",
|
| 430 |
+
},
|
| 431 |
+
sidebar: {
|
| 432 |
+
border: "1px solid rgba(255,255,255,0.12)",
|
| 433 |
+
borderRadius: 12,
|
| 434 |
+
overflow: "hidden",
|
| 435 |
+
background: "rgba(255,255,255,0.02)",
|
| 436 |
+
display: "flex",
|
| 437 |
+
flexDirection: "column",
|
| 438 |
+
minHeight: 520,
|
| 439 |
+
},
|
| 440 |
+
sidebarTitle: {
|
| 441 |
+
padding: 10,
|
| 442 |
+
borderBottom: "1px solid rgba(255,255,255,0.10)",
|
| 443 |
+
fontSize: 12,
|
| 444 |
+
fontWeight: 800,
|
| 445 |
+
color: "rgba(255,255,255,0.85)",
|
| 446 |
+
},
|
| 447 |
+
sidebarList: {
|
| 448 |
+
padding: 8,
|
| 449 |
+
display: "flex",
|
| 450 |
+
flexDirection: "column",
|
| 451 |
+
gap: 8,
|
| 452 |
+
overflow: "auto",
|
| 453 |
+
},
|
| 454 |
+
sidebarEmpty: {
|
| 455 |
+
color: "rgba(255,255,255,0.65)",
|
| 456 |
+
fontSize: 12,
|
| 457 |
+
padding: 8,
|
| 458 |
+
},
|
| 459 |
+
ucItem: {
|
| 460 |
+
textAlign: "left",
|
| 461 |
+
background: "rgba(0,0,0,0.25)",
|
| 462 |
+
border: "1px solid rgba(255,255,255,0.12)",
|
| 463 |
+
color: "#fff",
|
| 464 |
+
borderRadius: 12,
|
| 465 |
+
padding: 10,
|
| 466 |
+
cursor: "pointer",
|
| 467 |
+
},
|
| 468 |
+
ucItemActive: {
|
| 469 |
+
border: "1px solid rgba(255,255,255,0.25)",
|
| 470 |
+
background: "rgba(255,255,255,0.06)",
|
| 471 |
+
},
|
| 472 |
+
ucTitleRow: { display: "flex", alignItems: "center", gap: 8 },
|
| 473 |
+
ucTitle: {
|
| 474 |
+
fontSize: 13,
|
| 475 |
+
fontWeight: 800,
|
| 476 |
+
overflow: "hidden",
|
| 477 |
+
textOverflow: "ellipsis",
|
| 478 |
+
whiteSpace: "nowrap",
|
| 479 |
+
flex: 1,
|
| 480 |
+
},
|
| 481 |
+
activePill: {
|
| 482 |
+
fontSize: 10,
|
| 483 |
+
fontWeight: 800,
|
| 484 |
+
padding: "2px 8px",
|
| 485 |
+
borderRadius: 999,
|
| 486 |
+
border: "1px solid rgba(120,255,180,0.30)",
|
| 487 |
+
background: "rgba(120,255,180,0.10)",
|
| 488 |
+
color: "rgba(200,255,220,0.95)",
|
| 489 |
+
},
|
| 490 |
+
ucMeta: {
|
| 491 |
+
marginTop: 6,
|
| 492 |
+
fontSize: 11,
|
| 493 |
+
color: "rgba(255,255,255,0.60)",
|
| 494 |
+
},
|
| 495 |
+
chatCol: {
|
| 496 |
+
border: "1px solid rgba(255,255,255,0.12)",
|
| 497 |
+
borderRadius: 12,
|
| 498 |
+
overflow: "hidden",
|
| 499 |
+
display: "flex",
|
| 500 |
+
flexDirection: "column",
|
| 501 |
+
background: "rgba(255,255,255,0.02)",
|
| 502 |
+
minHeight: 520,
|
| 503 |
+
},
|
| 504 |
+
specCol: {
|
| 505 |
+
border: "1px solid rgba(255,255,255,0.12)",
|
| 506 |
+
borderRadius: 12,
|
| 507 |
+
overflow: "hidden",
|
| 508 |
+
display: "flex",
|
| 509 |
+
flexDirection: "column",
|
| 510 |
+
background: "rgba(255,255,255,0.02)",
|
| 511 |
+
minHeight: 520,
|
| 512 |
+
},
|
| 513 |
+
panelTitle: {
|
| 514 |
+
padding: 10,
|
| 515 |
+
borderBottom: "1px solid rgba(255,255,255,0.10)",
|
| 516 |
+
fontSize: 12,
|
| 517 |
+
fontWeight: 800,
|
| 518 |
+
color: "rgba(255,255,255,0.85)",
|
| 519 |
+
},
|
| 520 |
+
chatBox: {
|
| 521 |
+
flex: 1,
|
| 522 |
+
overflow: "auto",
|
| 523 |
+
padding: 10,
|
| 524 |
+
display: "flex",
|
| 525 |
+
flexDirection: "column",
|
| 526 |
+
gap: 10,
|
| 527 |
+
},
|
| 528 |
+
chatEmpty: {
|
| 529 |
+
color: "rgba(255,255,255,0.65)",
|
| 530 |
+
fontSize: 12,
|
| 531 |
+
padding: 6,
|
| 532 |
+
},
|
| 533 |
+
pre: {
|
| 534 |
+
marginTop: 10,
|
| 535 |
+
padding: 10,
|
| 536 |
+
borderRadius: 10,
|
| 537 |
+
border: "1px solid rgba(255,255,255,0.12)",
|
| 538 |
+
background: "rgba(0,0,0,0.25)",
|
| 539 |
+
color: "rgba(255,255,255,0.8)",
|
| 540 |
+
overflow: "auto",
|
| 541 |
+
fontSize: 11,
|
| 542 |
+
},
|
| 543 |
+
msg: {
|
| 544 |
+
border: "1px solid rgba(255,255,255,0.12)",
|
| 545 |
+
borderRadius: 12,
|
| 546 |
+
padding: 10,
|
| 547 |
+
background: "rgba(0,0,0,0.25)",
|
| 548 |
+
},
|
| 549 |
+
msgUser: {
|
| 550 |
+
border: "1px solid rgba(255,255,255,0.18)",
|
| 551 |
+
background: "rgba(255,255,255,0.04)",
|
| 552 |
+
},
|
| 553 |
+
msgAsst: {},
|
| 554 |
+
msgRole: {
|
| 555 |
+
fontSize: 11,
|
| 556 |
+
fontWeight: 800,
|
| 557 |
+
color: "rgba(255,255,255,0.70)",
|
| 558 |
+
marginBottom: 6,
|
| 559 |
+
},
|
| 560 |
+
msgContent: {
|
| 561 |
+
whiteSpace: "pre-wrap",
|
| 562 |
+
fontSize: 13,
|
| 563 |
+
color: "rgba(255,255,255,0.90)",
|
| 564 |
+
lineHeight: 1.35,
|
| 565 |
+
},
|
| 566 |
+
composer: {
|
| 567 |
+
borderTop: "1px solid rgba(255,255,255,0.10)",
|
| 568 |
+
padding: 10,
|
| 569 |
+
display: "flex",
|
| 570 |
+
gap: 10,
|
| 571 |
+
alignItems: "flex-end",
|
| 572 |
+
},
|
| 573 |
+
textarea: {
|
| 574 |
+
flex: 1,
|
| 575 |
+
minHeight: 52,
|
| 576 |
+
maxHeight: 120,
|
| 577 |
+
resize: "vertical",
|
| 578 |
+
padding: 10,
|
| 579 |
+
borderRadius: 12,
|
| 580 |
+
border: "1px solid rgba(255,255,255,0.18)",
|
| 581 |
+
background: "rgba(0,0,0,0.25)",
|
| 582 |
+
color: "#fff",
|
| 583 |
+
fontSize: 13,
|
| 584 |
+
outline: "none",
|
| 585 |
+
},
|
| 586 |
+
sendBtn: {
|
| 587 |
+
background: "rgba(255,255,255,0.12)",
|
| 588 |
+
border: "1px solid rgba(255,255,255,0.22)",
|
| 589 |
+
color: "#fff",
|
| 590 |
+
borderRadius: 12,
|
| 591 |
+
padding: "10px 12px",
|
| 592 |
+
cursor: "pointer",
|
| 593 |
+
fontSize: 13,
|
| 594 |
+
fontWeight: 800,
|
| 595 |
+
},
|
| 596 |
+
specBox: {
|
| 597 |
+
flex: 1,
|
| 598 |
+
overflow: "auto",
|
| 599 |
+
padding: 10,
|
| 600 |
+
display: "flex",
|
| 601 |
+
flexDirection: "column",
|
| 602 |
+
gap: 10,
|
| 603 |
+
},
|
| 604 |
+
specFooter: {
|
| 605 |
+
borderTop: "1px solid rgba(255,255,255,0.10)",
|
| 606 |
+
padding: 10,
|
| 607 |
+
display: "flex",
|
| 608 |
+
gap: 10,
|
| 609 |
+
alignItems: "center",
|
| 610 |
+
justifyContent: "space-between",
|
| 611 |
+
},
|
| 612 |
+
specHint: { fontSize: 12, color: "rgba(255,255,255,0.60)" },
|
| 613 |
+
section: {
|
| 614 |
+
border: "1px solid rgba(255,255,255,0.10)",
|
| 615 |
+
borderRadius: 12,
|
| 616 |
+
background: "rgba(0,0,0,0.22)",
|
| 617 |
+
overflow: "hidden",
|
| 618 |
+
},
|
| 619 |
+
sectionTitle: {
|
| 620 |
+
padding: "8px 10px",
|
| 621 |
+
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
| 622 |
+
fontSize: 12,
|
| 623 |
+
fontWeight: 800,
|
| 624 |
+
color: "rgba(255,255,255,0.80)",
|
| 625 |
+
background: "rgba(255,255,255,0.02)",
|
| 626 |
+
},
|
| 627 |
+
sectionBody: { padding: "8px 10px" },
|
| 628 |
+
sectionText: {
|
| 629 |
+
whiteSpace: "pre-wrap",
|
| 630 |
+
fontSize: 12,
|
| 631 |
+
color: "rgba(255,255,255,0.90)",
|
| 632 |
+
lineHeight: 1.35,
|
| 633 |
+
},
|
| 634 |
+
sectionEmpty: { fontSize: 12, color: "rgba(255,255,255,0.45)" },
|
| 635 |
+
ul: { margin: 0, paddingLeft: 18 },
|
| 636 |
+
li: { color: "rgba(255,255,255,0.90)", fontSize: 12, lineHeight: 1.35 },
|
| 637 |
+
};
|
frontend/components/ProjectSettingsModal.jsx
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useMemo, useState } from "react";
|
| 2 |
+
import ContextTab from "./ProjectSettings/ContextTab.jsx";
|
| 3 |
+
import UseCaseTab from "./ProjectSettings/UseCaseTab.jsx";
|
| 4 |
+
import ConventionsTab from "./ProjectSettings/ConventionsTab.jsx";
|
| 5 |
+
import EnvironmentSelector from "./EnvironmentSelector.jsx";
|
| 6 |
+
|
| 7 |
+
export default function ProjectSettingsModal({
|
| 8 |
+
owner,
|
| 9 |
+
repo,
|
| 10 |
+
isOpen,
|
| 11 |
+
onClose,
|
| 12 |
+
activeEnvId,
|
| 13 |
+
onEnvChange,
|
| 14 |
+
}) {
|
| 15 |
+
const [activeTab, setActiveTab] = useState("context");
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
if (!isOpen) return;
|
| 19 |
+
// reset to Context each time opened (safe default)
|
| 20 |
+
setActiveTab("context");
|
| 21 |
+
}, [isOpen]);
|
| 22 |
+
|
| 23 |
+
const title = useMemo(() => {
|
| 24 |
+
const repoLabel = owner && repo ? `${owner}/${repo}` : "Project";
|
| 25 |
+
return `Project Settings — ${repoLabel}`;
|
| 26 |
+
}, [owner, repo]);
|
| 27 |
+
|
| 28 |
+
if (!isOpen) return null;
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div
|
| 32 |
+
style={styles.backdrop}
|
| 33 |
+
onMouseDown={(e) => {
|
| 34 |
+
// click outside closes
|
| 35 |
+
if (e.target === e.currentTarget) onClose?.();
|
| 36 |
+
}}
|
| 37 |
+
>
|
| 38 |
+
<div style={styles.modal} onMouseDown={(e) => e.stopPropagation()}>
|
| 39 |
+
<div style={styles.header}>
|
| 40 |
+
<div style={styles.headerLeft}>
|
| 41 |
+
<div style={styles.title}>{title}</div>
|
| 42 |
+
<div style={styles.subtitle}>
|
| 43 |
+
Manage context, use cases, and project conventions (additive only).
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
<button style={styles.closeBtn} onClick={onClose}>
|
| 47 |
+
✕
|
| 48 |
+
</button>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div style={styles.tabsRow}>
|
| 52 |
+
<TabButton
|
| 53 |
+
label="Context"
|
| 54 |
+
isActive={activeTab === "context"}
|
| 55 |
+
onClick={() => setActiveTab("context")}
|
| 56 |
+
/>
|
| 57 |
+
<TabButton
|
| 58 |
+
label="Use Case"
|
| 59 |
+
isActive={activeTab === "usecase"}
|
| 60 |
+
onClick={() => setActiveTab("usecase")}
|
| 61 |
+
/>
|
| 62 |
+
<TabButton
|
| 63 |
+
label="Conventions"
|
| 64 |
+
isActive={activeTab === "conventions"}
|
| 65 |
+
onClick={() => setActiveTab("conventions")}
|
| 66 |
+
/>
|
| 67 |
+
<TabButton
|
| 68 |
+
label="Environment"
|
| 69 |
+
isActive={activeTab === "environment"}
|
| 70 |
+
onClick={() => setActiveTab("environment")}
|
| 71 |
+
/>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div style={styles.body}>
|
| 75 |
+
{activeTab === "context" && <ContextTab owner={owner} repo={repo} />}
|
| 76 |
+
{activeTab === "usecase" && <UseCaseTab owner={owner} repo={repo} />}
|
| 77 |
+
{activeTab === "conventions" && (
|
| 78 |
+
<ConventionsTab owner={owner} repo={repo} />
|
| 79 |
+
)}
|
| 80 |
+
{activeTab === "environment" && (
|
| 81 |
+
<div style={{ maxWidth: 480 }}>
|
| 82 |
+
<div style={{ marginBottom: 12, fontSize: 13, color: "rgba(255,255,255,0.65)" }}>
|
| 83 |
+
Select and configure the execution environment for agent operations.
|
| 84 |
+
</div>
|
| 85 |
+
<EnvironmentSelector
|
| 86 |
+
activeEnvId={activeEnvId}
|
| 87 |
+
onEnvChange={onEnvChange}
|
| 88 |
+
/>
|
| 89 |
+
</div>
|
| 90 |
+
)}
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<div style={styles.footer}>
|
| 94 |
+
<div style={styles.footerHint}>
|
| 95 |
+
Tip: Upload meeting notes/transcripts in Context, then finalize a Use
|
| 96 |
+
Case spec.
|
| 97 |
+
</div>
|
| 98 |
+
<button style={styles.primaryBtn} onClick={onClose}>
|
| 99 |
+
Done
|
| 100 |
+
</button>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function TabButton({ label, isActive, onClick }) {
|
| 108 |
+
return (
|
| 109 |
+
<button
|
| 110 |
+
onClick={onClick}
|
| 111 |
+
style={{
|
| 112 |
+
...styles.tabBtn,
|
| 113 |
+
...(isActive ? styles.tabBtnActive : {}),
|
| 114 |
+
}}
|
| 115 |
+
>
|
| 116 |
+
{label}
|
| 117 |
+
</button>
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
const styles = {
|
| 122 |
+
backdrop: {
|
| 123 |
+
position: "fixed",
|
| 124 |
+
inset: 0,
|
| 125 |
+
background: "rgba(0,0,0,0.45)",
|
| 126 |
+
display: "flex",
|
| 127 |
+
justifyContent: "center",
|
| 128 |
+
alignItems: "center",
|
| 129 |
+
zIndex: 9999,
|
| 130 |
+
padding: 16,
|
| 131 |
+
},
|
| 132 |
+
modal: {
|
| 133 |
+
width: "min(1100px, 96vw)",
|
| 134 |
+
height: "min(760px, 90vh)",
|
| 135 |
+
background: "#111",
|
| 136 |
+
border: "1px solid rgba(255,255,255,0.12)",
|
| 137 |
+
borderRadius: 12,
|
| 138 |
+
overflow: "hidden",
|
| 139 |
+
display: "flex",
|
| 140 |
+
flexDirection: "column",
|
| 141 |
+
boxShadow: "0 12px 40px rgba(0,0,0,0.35)",
|
| 142 |
+
},
|
| 143 |
+
header: {
|
| 144 |
+
padding: "14px 14px 10px",
|
| 145 |
+
display: "flex",
|
| 146 |
+
gap: 12,
|
| 147 |
+
alignItems: "flex-start",
|
| 148 |
+
justifyContent: "space-between",
|
| 149 |
+
borderBottom: "1px solid rgba(255,255,255,0.10)",
|
| 150 |
+
background: "linear-gradient(180deg, rgba(255,255,255,0.04), transparent)",
|
| 151 |
+
},
|
| 152 |
+
headerLeft: {
|
| 153 |
+
display: "flex",
|
| 154 |
+
flexDirection: "column",
|
| 155 |
+
gap: 4,
|
| 156 |
+
minWidth: 0,
|
| 157 |
+
},
|
| 158 |
+
title: {
|
| 159 |
+
fontSize: 16,
|
| 160 |
+
fontWeight: 700,
|
| 161 |
+
color: "#fff",
|
| 162 |
+
lineHeight: 1.2,
|
| 163 |
+
whiteSpace: "nowrap",
|
| 164 |
+
overflow: "hidden",
|
| 165 |
+
textOverflow: "ellipsis",
|
| 166 |
+
maxWidth: "88vw",
|
| 167 |
+
},
|
| 168 |
+
subtitle: {
|
| 169 |
+
fontSize: 12,
|
| 170 |
+
color: "rgba(255,255,255,0.65)",
|
| 171 |
+
},
|
| 172 |
+
closeBtn: {
|
| 173 |
+
background: "transparent",
|
| 174 |
+
border: "1px solid rgba(255,255,255,0.18)",
|
| 175 |
+
color: "rgba(255,255,255,0.85)",
|
| 176 |
+
borderRadius: 10,
|
| 177 |
+
padding: "6px 10px",
|
| 178 |
+
cursor: "pointer",
|
| 179 |
+
},
|
| 180 |
+
tabsRow: {
|
| 181 |
+
display: "flex",
|
| 182 |
+
gap: 8,
|
| 183 |
+
padding: 10,
|
| 184 |
+
borderBottom: "1px solid rgba(255,255,255,0.10)",
|
| 185 |
+
background: "rgba(255,255,255,0.02)",
|
| 186 |
+
},
|
| 187 |
+
tabBtn: {
|
| 188 |
+
background: "transparent",
|
| 189 |
+
border: "1px solid rgba(255,255,255,0.14)",
|
| 190 |
+
color: "rgba(255,255,255,0.75)",
|
| 191 |
+
borderRadius: 999,
|
| 192 |
+
padding: "8px 12px",
|
| 193 |
+
cursor: "pointer",
|
| 194 |
+
fontSize: 13,
|
| 195 |
+
},
|
| 196 |
+
tabBtnActive: {
|
| 197 |
+
border: "1px solid rgba(255,255,255,0.28)",
|
| 198 |
+
color: "#fff",
|
| 199 |
+
background: "rgba(255,255,255,0.06)",
|
| 200 |
+
},
|
| 201 |
+
body: {
|
| 202 |
+
flex: 1,
|
| 203 |
+
overflow: "auto",
|
| 204 |
+
padding: 12,
|
| 205 |
+
},
|
| 206 |
+
footer: {
|
| 207 |
+
padding: 12,
|
| 208 |
+
borderTop: "1px solid rgba(255,255,255,0.10)",
|
| 209 |
+
display: "flex",
|
| 210 |
+
alignItems: "center",
|
| 211 |
+
justifyContent: "space-between",
|
| 212 |
+
gap: 12,
|
| 213 |
+
background: "rgba(255,255,255,0.02)",
|
| 214 |
+
},
|
| 215 |
+
footerHint: {
|
| 216 |
+
color: "rgba(255,255,255,0.6)",
|
| 217 |
+
fontSize: 12,
|
| 218 |
+
overflow: "hidden",
|
| 219 |
+
textOverflow: "ellipsis",
|
| 220 |
+
whiteSpace: "nowrap",
|
| 221 |
+
},
|
| 222 |
+
primaryBtn: {
|
| 223 |
+
background: "rgba(255,255,255,0.10)",
|
| 224 |
+
border: "1px solid rgba(255,255,255,0.20)",
|
| 225 |
+
color: "#fff",
|
| 226 |
+
borderRadius: 10,
|
| 227 |
+
padding: "8px 12px",
|
| 228 |
+
cursor: "pointer",
|
| 229 |
+
},
|
| 230 |
+
};
|
frontend/components/RepoSelector.jsx
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState, useCallback } from "react";
|
| 2 |
+
import { authFetch } from "../utils/api.js";
|
| 3 |
+
|
| 4 |
+
export default function RepoSelector({ onSelect }) {
|
| 5 |
+
const [query, setQuery] = useState("");
|
| 6 |
+
const [repos, setRepos] = useState([]);
|
| 7 |
+
const [loading, setLoading] = useState(false);
|
| 8 |
+
const [loadingMore, setLoadingMore] = useState(false);
|
| 9 |
+
const [status, setStatus] = useState("");
|
| 10 |
+
const [page, setPage] = useState(1);
|
| 11 |
+
const [hasMore, setHasMore] = useState(false);
|
| 12 |
+
const [totalCount, setTotalCount] = useState(null);
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Fetch repositories with pagination and optional search
|
| 16 |
+
* @param {number} pageNum - Page number to fetch
|
| 17 |
+
* @param {boolean} append - Whether to append or replace results
|
| 18 |
+
* @param {string} searchQuery - Search query (uses current query if not provided)
|
| 19 |
+
*/
|
| 20 |
+
const fetchRepos = useCallback(async (pageNum = 1, append = false, searchQuery = query) => {
|
| 21 |
+
// Set appropriate loading state
|
| 22 |
+
if (pageNum === 1) {
|
| 23 |
+
setLoading(true);
|
| 24 |
+
setStatus("");
|
| 25 |
+
} else {
|
| 26 |
+
setLoadingMore(true);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
try {
|
| 30 |
+
// Build URL with query parameters
|
| 31 |
+
const params = new URLSearchParams();
|
| 32 |
+
params.append("page", pageNum);
|
| 33 |
+
params.append("per_page", "100");
|
| 34 |
+
if (searchQuery) {
|
| 35 |
+
params.append("query", searchQuery);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const url = `/api/repos?${params.toString()}`;
|
| 39 |
+
const res = await authFetch(url);
|
| 40 |
+
const data = await res.json();
|
| 41 |
+
|
| 42 |
+
if (!res.ok) {
|
| 43 |
+
throw new Error(data.detail || data.error || "Failed to load repositories");
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Update repositories - append or replace
|
| 47 |
+
if (append) {
|
| 48 |
+
setRepos((prev) => [...prev, ...data.repositories]);
|
| 49 |
+
} else {
|
| 50 |
+
setRepos(data.repositories);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// Update pagination state
|
| 54 |
+
setPage(pageNum);
|
| 55 |
+
setHasMore(data.has_more);
|
| 56 |
+
setTotalCount(data.total_count);
|
| 57 |
+
|
| 58 |
+
// Show status if no results
|
| 59 |
+
if (!append && data.repositories.length === 0) {
|
| 60 |
+
if (searchQuery) {
|
| 61 |
+
setStatus(`No repositories matching "${searchQuery}"`);
|
| 62 |
+
} else {
|
| 63 |
+
setStatus("No repositories found");
|
| 64 |
+
}
|
| 65 |
+
} else {
|
| 66 |
+
setStatus("");
|
| 67 |
+
}
|
| 68 |
+
} catch (err) {
|
| 69 |
+
console.error("Error fetching repositories:", err);
|
| 70 |
+
setStatus(err.message || "Failed to load repositories");
|
| 71 |
+
} finally {
|
| 72 |
+
setLoading(false);
|
| 73 |
+
setLoadingMore(false);
|
| 74 |
+
}
|
| 75 |
+
}, [query]);
|
| 76 |
+
|
| 77 |
+
/**
|
| 78 |
+
* Load more repositories (next page)
|
| 79 |
+
*/
|
| 80 |
+
const loadMore = () => {
|
| 81 |
+
fetchRepos(page + 1, true);
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* Handle search - resets to page 1
|
| 86 |
+
*/
|
| 87 |
+
const handleSearch = () => {
|
| 88 |
+
setPage(1);
|
| 89 |
+
fetchRepos(1, false, query);
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* Handle input change - trigger search on Enter key
|
| 94 |
+
*/
|
| 95 |
+
const handleKeyDown = (e) => {
|
| 96 |
+
if (e.key === "Enter") {
|
| 97 |
+
handleSearch();
|
| 98 |
+
}
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* Clear search and show all repos
|
| 103 |
+
*/
|
| 104 |
+
const clearSearch = () => {
|
| 105 |
+
setQuery("");
|
| 106 |
+
setPage(1);
|
| 107 |
+
fetchRepos(1, false, "");
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
// Initial load on mount
|
| 111 |
+
useEffect(() => {
|
| 112 |
+
fetchRepos(1, false, "");
|
| 113 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 114 |
+
}, []);
|
| 115 |
+
|
| 116 |
+
/**
|
| 117 |
+
* Format repository count for display
|
| 118 |
+
*/
|
| 119 |
+
const getCountText = () => {
|
| 120 |
+
if (totalCount !== null) {
|
| 121 |
+
// Search mode - show filtered count
|
| 122 |
+
return `${repos.length} of ${totalCount} repositories`;
|
| 123 |
+
} else {
|
| 124 |
+
// Pagination mode - show loaded count
|
| 125 |
+
return `${repos.length} ${repos.length === 1 ? "repository" : "repositories"}${hasMore ? "+" : ""}`;
|
| 126 |
+
}
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
return (
|
| 130 |
+
<div className="repo-search-box">
|
| 131 |
+
<div style={{ fontSize: "11px", opacity: 0.6, padding: "4px 8px", marginBottom: "8px" }}>
|
| 132 |
+
GitHub repos are optional. Use Folder or Local Git mode for local-first workflows.
|
| 133 |
+
</div>
|
| 134 |
+
{/* Search Header */}
|
| 135 |
+
<div className="repo-search-header">
|
| 136 |
+
<div className="repo-search-row">
|
| 137 |
+
<input
|
| 138 |
+
className="repo-search-input"
|
| 139 |
+
placeholder="Search repositories..."
|
| 140 |
+
value={query}
|
| 141 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 142 |
+
onKeyDown={handleKeyDown}
|
| 143 |
+
disabled={loading}
|
| 144 |
+
/>
|
| 145 |
+
<button
|
| 146 |
+
className="repo-search-btn"
|
| 147 |
+
onClick={handleSearch}
|
| 148 |
+
type="button"
|
| 149 |
+
disabled={loading}
|
| 150 |
+
>
|
| 151 |
+
{loading ? "..." : "Search"}
|
| 152 |
+
</button>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
{/* Search Info Bar */}
|
| 156 |
+
{(query || repos.length > 0) && (
|
| 157 |
+
<div className="repo-info-bar">
|
| 158 |
+
<span className="repo-count">{getCountText()}</span>
|
| 159 |
+
{query && (
|
| 160 |
+
<button
|
| 161 |
+
className="repo-clear-btn"
|
| 162 |
+
onClick={clearSearch}
|
| 163 |
+
type="button"
|
| 164 |
+
disabled={loading}
|
| 165 |
+
>
|
| 166 |
+
Clear search
|
| 167 |
+
</button>
|
| 168 |
+
)}
|
| 169 |
+
</div>
|
| 170 |
+
)}
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
{/* Status Message */}
|
| 174 |
+
{status && !loading && (
|
| 175 |
+
<div className="repo-status">
|
| 176 |
+
{status}
|
| 177 |
+
</div>
|
| 178 |
+
)}
|
| 179 |
+
|
| 180 |
+
{/* Repository List */}
|
| 181 |
+
<div className="repo-list">
|
| 182 |
+
{repos.map((r) => (
|
| 183 |
+
<button
|
| 184 |
+
key={r.id}
|
| 185 |
+
type="button"
|
| 186 |
+
className="repo-item"
|
| 187 |
+
onClick={() => onSelect(r)}
|
| 188 |
+
>
|
| 189 |
+
<div className="repo-item-content">
|
| 190 |
+
<span className="repo-name">{r.name}</span>
|
| 191 |
+
<span className="repo-owner">{r.owner}</span>
|
| 192 |
+
</div>
|
| 193 |
+
{r.private && (
|
| 194 |
+
<span className="repo-badge-private">Private</span>
|
| 195 |
+
)}
|
| 196 |
+
</button>
|
| 197 |
+
))}
|
| 198 |
+
|
| 199 |
+
{/* Loading Indicator */}
|
| 200 |
+
{loading && repos.length === 0 && (
|
| 201 |
+
<div className="repo-loading">
|
| 202 |
+
<div className="repo-loading-spinner"></div>
|
| 203 |
+
<span>Loading repositories...</span>
|
| 204 |
+
</div>
|
| 205 |
+
)}
|
| 206 |
+
|
| 207 |
+
{/* Load More Button */}
|
| 208 |
+
{hasMore && !loading && repos.length > 0 && (
|
| 209 |
+
<button
|
| 210 |
+
type="button"
|
| 211 |
+
className="repo-load-more"
|
| 212 |
+
onClick={loadMore}
|
| 213 |
+
disabled={loadingMore}
|
| 214 |
+
>
|
| 215 |
+
{loadingMore ? (
|
| 216 |
+
<>
|
| 217 |
+
<div className="repo-loading-spinner-small"></div>
|
| 218 |
+
Loading more...
|
| 219 |
+
</>
|
| 220 |
+
) : (
|
| 221 |
+
<>
|
| 222 |
+
Load more repositories
|
| 223 |
+
<span className="repo-load-more-count">({repos.length} loaded)</span>
|
| 224 |
+
</>
|
| 225 |
+
)}
|
| 226 |
+
</button>
|
| 227 |
+
)}
|
| 228 |
+
|
| 229 |
+
{/* All Loaded Message */}
|
| 230 |
+
{!hasMore && !loading && repos.length > 0 && (
|
| 231 |
+
<div className="repo-all-loaded">
|
| 232 |
+
✓ All repositories loaded ({repos.length} total)
|
| 233 |
+
</div>
|
| 234 |
+
)}
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
{/* GitHub App Installation Notice */}
|
| 238 |
+
<div className="repo-github-notice">
|
| 239 |
+
<svg
|
| 240 |
+
className="repo-github-icon"
|
| 241 |
+
height="20"
|
| 242 |
+
width="20"
|
| 243 |
+
viewBox="0 0 16 16"
|
| 244 |
+
fill="currentColor"
|
| 245 |
+
>
|
| 246 |
+
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
| 247 |
+
</svg>
|
| 248 |
+
|
| 249 |
+
<div className="repo-github-notice-content">
|
| 250 |
+
<div className="repo-github-notice-title">
|
| 251 |
+
Repository missing?
|
| 252 |
+
</div>
|
| 253 |
+
<div className="repo-github-notice-text">
|
| 254 |
+
Install the{" "}
|
| 255 |
+
<a
|
| 256 |
+
href="https://github.com/apps/gitpilota"
|
| 257 |
+
target="_blank"
|
| 258 |
+
rel="noopener noreferrer"
|
| 259 |
+
className="repo-github-link"
|
| 260 |
+
>
|
| 261 |
+
GitPilot GitHub App
|
| 262 |
+
</a>{" "}
|
| 263 |
+
to access private repositories.
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
);
|
| 269 |
+
}
|
frontend/components/RunnableCodeBlock.jsx
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from "react";
|
| 2 |
+
import SandboxCanvas from "./SandboxCanvas.jsx";
|
| 3 |
+
import ExecutionPlanCard, { fetchExecutionPlan } from "./ExecutionPlanCard.jsx";
|
| 4 |
+
|
| 5 |
+
// Languages the Run button supports. Anything not in this set still
|
| 6 |
+
// renders as a normal code block (no button) — keeps the visual contract
|
| 7 |
+
// honest: if there's a button, the snippet really is executable.
|
| 8 |
+
const RUNNABLE = new Set([
|
| 9 |
+
"python", "py",
|
| 10 |
+
"javascript", "js", "node",
|
| 11 |
+
"bash", "sh", "shell",
|
| 12 |
+
]);
|
| 13 |
+
|
| 14 |
+
// Friendly badge text per backend, surfaced so the user always knows
|
| 15 |
+
// which sandbox actually ran their code. Mirrors the labels in
|
| 16 |
+
// SettingsModal so the two views agree.
|
| 17 |
+
const BACKEND_LABELS = {
|
| 18 |
+
subprocess: "Local",
|
| 19 |
+
matrixlab: "MatrixLab",
|
| 20 |
+
off: "Pass-through",
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
// Map "py" → "python" etc. so the badge always shows the canonical
|
| 24 |
+
// language name rather than whatever alias the LLM tagged the fence
|
| 25 |
+
// with.
|
| 26 |
+
const LANG_DISPLAY = {
|
| 27 |
+
py: "python",
|
| 28 |
+
js: "javascript",
|
| 29 |
+
node: "javascript",
|
| 30 |
+
sh: "bash",
|
| 31 |
+
shell: "bash",
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
// Default file extension per language — used when the user clicks
|
| 35 |
+
// "Save to repo" and we need to seed a plausible filename.
|
| 36 |
+
const LANG_EXT = {
|
| 37 |
+
python: "py", py: "py",
|
| 38 |
+
javascript: "js", js: "js", node: "js",
|
| 39 |
+
bash: "sh", sh: "sh", shell: "sh",
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
// Mirror executor's _looks_like_matplotlib heuristic so plt.show()
|
| 43 |
+
// snippets don't hang the headless sandbox. False positives are
|
| 44 |
+
// harmless (Agg is a valid backend for any Python script).
|
| 45 |
+
function looksLikeMatplotlib(code) {
|
| 46 |
+
if (!code) return false;
|
| 47 |
+
const lower = code.toLowerCase();
|
| 48 |
+
return /import\s+matplotlib|from\s+matplotlib|plt\.show|pyplot/.test(lower);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function applyMatplotlibShim(language, code) {
|
| 52 |
+
if (language !== "python" && language !== "py") return code;
|
| 53 |
+
if (!looksLikeMatplotlib(code)) return code;
|
| 54 |
+
return (
|
| 55 |
+
"import os as _gp_os\n" +
|
| 56 |
+
'_gp_os.environ.setdefault("MPLBACKEND", "Agg")\n' +
|
| 57 |
+
code
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/** A single fenced code block with a per-block Run button.
|
| 62 |
+
*
|
| 63 |
+
* Optional ``owner``/``repo`` props enable the "Save to repo" button by
|
| 64 |
+
* giving the save call a real target. When absent the button is
|
| 65 |
+
* hidden — keeps the contract honest: no button without somewhere to
|
| 66 |
+
* save. */
|
| 67 |
+
export default function RunnableCodeBlock({ language, code, owner, repo }) {
|
| 68 |
+
const lang = (language || "").trim().toLowerCase();
|
| 69 |
+
const canRun = RUNNABLE.has(lang);
|
| 70 |
+
const [busy, setBusy] = useState(false);
|
| 71 |
+
const [result, setResult] = useState(null);
|
| 72 |
+
const [error, setError] = useState(null);
|
| 73 |
+
const [canvasOpen, setCanvasOpen] = useState(false);
|
| 74 |
+
const [saving, setSaving] = useState(false);
|
| 75 |
+
const [saveMsg, setSaveMsg] = useState(null);
|
| 76 |
+
// Approval-first: clicking ▶ Run first fetches a deterministic
|
| 77 |
+
// ExecutionPlan and surfaces it inline. The actual sandbox call
|
| 78 |
+
// is gated on the user clicking "Run in Sandbox" inside the plan.
|
| 79 |
+
const [pendingPlan, setPendingPlan] = useState(null);
|
| 80 |
+
const [planError, setPlanError] = useState(null);
|
| 81 |
+
const display = LANG_DISPLAY[lang] || lang || "text";
|
| 82 |
+
|
| 83 |
+
const onRunClick = async () => {
|
| 84 |
+
setPlanError(null);
|
| 85 |
+
setResult(null);
|
| 86 |
+
setError(null);
|
| 87 |
+
setBusy(true);
|
| 88 |
+
try {
|
| 89 |
+
const shipped = applyMatplotlibShim(lang, code);
|
| 90 |
+
const plan = await fetchExecutionPlan({
|
| 91 |
+
code: shipped,
|
| 92 |
+
language: lang,
|
| 93 |
+
source: "code_block",
|
| 94 |
+
});
|
| 95 |
+
setPendingPlan(plan);
|
| 96 |
+
} catch (err) {
|
| 97 |
+
setPlanError(err.message || "Could not build execution plan");
|
| 98 |
+
} finally {
|
| 99 |
+
setBusy(false);
|
| 100 |
+
}
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
const onApprovePlan = async (plan) => {
|
| 104 |
+
setBusy(true);
|
| 105 |
+
setResult(null);
|
| 106 |
+
setError(null);
|
| 107 |
+
try {
|
| 108 |
+
const res = await fetch("/api/sandbox/run", {
|
| 109 |
+
method: "POST",
|
| 110 |
+
headers: { "Content-Type": "application/json" },
|
| 111 |
+
body: JSON.stringify({
|
| 112 |
+
language: plan.language,
|
| 113 |
+
code: plan.inline_code,
|
| 114 |
+
timeout_sec: plan.timeout_sec,
|
| 115 |
+
}),
|
| 116 |
+
});
|
| 117 |
+
const data = await res.json();
|
| 118 |
+
if (!res.ok) {
|
| 119 |
+
setError(data.detail || `HTTP ${res.status}`);
|
| 120 |
+
return;
|
| 121 |
+
}
|
| 122 |
+
setResult(data);
|
| 123 |
+
setPendingPlan(null);
|
| 124 |
+
} catch (err) {
|
| 125 |
+
setError(err.message || "Run failed");
|
| 126 |
+
} finally {
|
| 127 |
+
setBusy(false);
|
| 128 |
+
}
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
const onCancelPlan = () => {
|
| 132 |
+
setPendingPlan(null);
|
| 133 |
+
setPlanError(null);
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
const copy = () => {
|
| 137 |
+
if (navigator?.clipboard) navigator.clipboard.writeText(code).catch(() => {});
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
// POST the snippet to /api/repos/{owner}/{repo}/file with a path
|
| 141 |
+
// chosen by the user. Pure client-side prompt — no new backend
|
| 142 |
+
// wiring needed because the endpoint already exists.
|
| 143 |
+
const saveToRepo = async (snippet, snippetLang) => {
|
| 144 |
+
if (!owner || !repo) {
|
| 145 |
+
setSaveMsg("No repository context — open this chat inside a repo to save.");
|
| 146 |
+
return;
|
| 147 |
+
}
|
| 148 |
+
const ext = LANG_EXT[(snippetLang || lang).toLowerCase()] || "txt";
|
| 149 |
+
const suggested = `snippets/inline.${ext}`;
|
| 150 |
+
const path = window.prompt("Save snippet to path (inside repo):", suggested);
|
| 151 |
+
if (!path) return;
|
| 152 |
+
setSaving(true);
|
| 153 |
+
setSaveMsg(null);
|
| 154 |
+
try {
|
| 155 |
+
const res = await fetch(`/api/repos/${owner}/${repo}/file`, {
|
| 156 |
+
method: "POST",
|
| 157 |
+
headers: { "Content-Type": "application/json" },
|
| 158 |
+
body: JSON.stringify({
|
| 159 |
+
path,
|
| 160 |
+
content: snippet,
|
| 161 |
+
message: `Save snippet from chat (${path})`,
|
| 162 |
+
}),
|
| 163 |
+
});
|
| 164 |
+
const data = await res.json().catch(() => ({}));
|
| 165 |
+
if (!res.ok) {
|
| 166 |
+
setSaveMsg(data.detail || `Save failed (HTTP ${res.status})`);
|
| 167 |
+
return;
|
| 168 |
+
}
|
| 169 |
+
setSaveMsg(`Saved to ${path}`);
|
| 170 |
+
} catch (err) {
|
| 171 |
+
setSaveMsg(err.message || "Save failed");
|
| 172 |
+
} finally {
|
| 173 |
+
setSaving(false);
|
| 174 |
+
}
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
return (
|
| 178 |
+
<div style={styles.wrap}>
|
| 179 |
+
<div style={styles.head}>
|
| 180 |
+
<span style={styles.lang}>{display}</span>
|
| 181 |
+
<div style={styles.headRight}>
|
| 182 |
+
<button type="button" style={styles.iconBtn} onClick={copy} title="Copy code">
|
| 183 |
+
Copy
|
| 184 |
+
</button>
|
| 185 |
+
{canRun && (
|
| 186 |
+
<button
|
| 187 |
+
type="button"
|
| 188 |
+
style={styles.iconBtn}
|
| 189 |
+
onClick={() => setCanvasOpen(true)}
|
| 190 |
+
title="Open the snippet in the Canvas split-view editor"
|
| 191 |
+
>
|
| 192 |
+
⊞ Canvas
|
| 193 |
+
</button>
|
| 194 |
+
)}
|
| 195 |
+
{canRun && owner && repo && (
|
| 196 |
+
<button
|
| 197 |
+
type="button"
|
| 198 |
+
style={{ ...styles.iconBtn, opacity: saving ? 0.6 : 1 }}
|
| 199 |
+
onClick={() => saveToRepo(code, lang)}
|
| 200 |
+
disabled={saving}
|
| 201 |
+
title="Save this snippet as a file in the current repository"
|
| 202 |
+
>
|
| 203 |
+
{saving ? "Saving…" : "Save"}
|
| 204 |
+
</button>
|
| 205 |
+
)}
|
| 206 |
+
{canRun && (
|
| 207 |
+
<button
|
| 208 |
+
type="button"
|
| 209 |
+
style={{ ...styles.runBtn, opacity: busy ? 0.6 : 1 }}
|
| 210 |
+
onClick={onRunClick}
|
| 211 |
+
disabled={busy || !!pendingPlan}
|
| 212 |
+
title="Review an execution plan before running this snippet"
|
| 213 |
+
>
|
| 214 |
+
{busy && !pendingPlan ? "Preparing…" : "▶ Run"}
|
| 215 |
+
</button>
|
| 216 |
+
)}
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
<pre style={styles.code}>{code}</pre>
|
| 220 |
+
|
| 221 |
+
{pendingPlan && (
|
| 222 |
+
<ExecutionPlanCard
|
| 223 |
+
plan={pendingPlan}
|
| 224 |
+
variant="compact"
|
| 225 |
+
busy={busy}
|
| 226 |
+
onApprove={onApprovePlan}
|
| 227 |
+
onCancel={onCancelPlan}
|
| 228 |
+
/>
|
| 229 |
+
)}
|
| 230 |
+
{planError && (
|
| 231 |
+
<div style={styles.errorBanner}>Plan error: {planError}</div>
|
| 232 |
+
)}
|
| 233 |
+
|
| 234 |
+
{(result || error) && (
|
| 235 |
+
<div style={styles.output}>
|
| 236 |
+
<div style={styles.outputHead}>
|
| 237 |
+
<span style={styles.outputLabel}>Output</span>
|
| 238 |
+
{result && (
|
| 239 |
+
<span style={styles.metaRow}>
|
| 240 |
+
<span style={result.exit_code === 0 ? styles.okPill : styles.failPill}>
|
| 241 |
+
exit {result.exit_code}
|
| 242 |
+
</span>
|
| 243 |
+
<span style={styles.backendPill}>
|
| 244 |
+
{BACKEND_LABELS[result.backend] || result.backend}
|
| 245 |
+
</span>
|
| 246 |
+
{typeof result.duration_ms === "number" && (
|
| 247 |
+
<span style={styles.dim}>{result.duration_ms} ms</span>
|
| 248 |
+
)}
|
| 249 |
+
{result.timed_out && <span style={styles.failPill}>timed out</span>}
|
| 250 |
+
{result.truncated && <span style={styles.warnPill}>truncated</span>}
|
| 251 |
+
</span>
|
| 252 |
+
)}
|
| 253 |
+
</div>
|
| 254 |
+
{error && <pre style={styles.stderr}>{error}</pre>}
|
| 255 |
+
{result?.stdout && <pre style={styles.stdout}>{result.stdout}</pre>}
|
| 256 |
+
{result?.stderr && <pre style={styles.stderr}>{result.stderr}</pre>}
|
| 257 |
+
{Array.isArray(result?.artifacts) && result.artifacts.length > 0 && (
|
| 258 |
+
<div style={styles.artifactsBox}>
|
| 259 |
+
<div style={styles.outputLabel}>Artifacts ({result.artifacts.length})</div>
|
| 260 |
+
<ul style={styles.artifactList}>
|
| 261 |
+
{result.artifacts.map((a, i) => (
|
| 262 |
+
<li key={i} style={styles.artifactItem}>
|
| 263 |
+
<code>{a.name || a.id}</code>
|
| 264 |
+
{a.size && <span style={styles.dim}> · {a.size} bytes</span>}
|
| 265 |
+
{a.mime && <span style={styles.dim}> · {a.mime}</span>}
|
| 266 |
+
</li>
|
| 267 |
+
))}
|
| 268 |
+
</ul>
|
| 269 |
+
</div>
|
| 270 |
+
)}
|
| 271 |
+
{result && !result.stdout && !result.stderr && (
|
| 272 |
+
<div style={styles.dim}>(no output)</div>
|
| 273 |
+
)}
|
| 274 |
+
</div>
|
| 275 |
+
)}
|
| 276 |
+
|
| 277 |
+
{saveMsg && <div style={styles.saveBanner}>{saveMsg}</div>}
|
| 278 |
+
|
| 279 |
+
{canvasOpen && (
|
| 280 |
+
<SandboxCanvas
|
| 281 |
+
initialLanguage={lang}
|
| 282 |
+
initialCode={code}
|
| 283 |
+
onClose={() => setCanvasOpen(false)}
|
| 284 |
+
onSaveAsFile={owner && repo ? saveToRepo : undefined}
|
| 285 |
+
/>
|
| 286 |
+
)}
|
| 287 |
+
</div>
|
| 288 |
+
);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
/** Split a markdown-ish string into text and fenced-code segments.
|
| 292 |
+
*
|
| 293 |
+
* Returned shape: ``[{type: 'text', value} | {type: 'code', language, code}]``.
|
| 294 |
+
*
|
| 295 |
+
* Kept deliberately small — full markdown rendering is out of scope; this
|
| 296 |
+
* only needs to recognise ```lang fences so the Run button can attach to
|
| 297 |
+
* code blocks the model emits. */
|
| 298 |
+
export function splitFences(input) {
|
| 299 |
+
if (!input) return [];
|
| 300 |
+
const out = [];
|
| 301 |
+
const re = /```([a-zA-Z0-9_+-]*)\s*\n([\s\S]*?)```/g;
|
| 302 |
+
let last = 0;
|
| 303 |
+
let m;
|
| 304 |
+
while ((m = re.exec(input)) !== null) {
|
| 305 |
+
if (m.index > last) {
|
| 306 |
+
out.push({ type: "text", value: input.slice(last, m.index) });
|
| 307 |
+
}
|
| 308 |
+
out.push({ type: "code", language: m[1] || "", code: m[2].replace(/\s+$/, "") });
|
| 309 |
+
last = m.index + m[0].length;
|
| 310 |
+
}
|
| 311 |
+
if (last < input.length) {
|
| 312 |
+
out.push({ type: "text", value: input.slice(last) });
|
| 313 |
+
}
|
| 314 |
+
return out;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
const styles = {
|
| 318 |
+
wrap: {
|
| 319 |
+
margin: "8px 0",
|
| 320 |
+
background: "#09090B",
|
| 321 |
+
border: "1px solid #27272A",
|
| 322 |
+
borderRadius: 8,
|
| 323 |
+
overflow: "hidden",
|
| 324 |
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
| 325 |
+
},
|
| 326 |
+
head: {
|
| 327 |
+
display: "flex",
|
| 328 |
+
alignItems: "center",
|
| 329 |
+
justifyContent: "space-between",
|
| 330 |
+
padding: "6px 12px",
|
| 331 |
+
background: "#18181B",
|
| 332 |
+
borderBottom: "1px solid #27272A",
|
| 333 |
+
fontSize: 11,
|
| 334 |
+
},
|
| 335 |
+
headRight: { display: "flex", gap: 6, alignItems: "center" },
|
| 336 |
+
lang: {
|
| 337 |
+
color: "#A1A1AA",
|
| 338 |
+
fontWeight: 600,
|
| 339 |
+
textTransform: "uppercase",
|
| 340 |
+
letterSpacing: "0.05em",
|
| 341 |
+
fontSize: 10,
|
| 342 |
+
},
|
| 343 |
+
iconBtn: {
|
| 344 |
+
background: "transparent",
|
| 345 |
+
color: "#A1A1AA",
|
| 346 |
+
border: "1px solid #3F3F46",
|
| 347 |
+
borderRadius: 4,
|
| 348 |
+
padding: "2px 8px",
|
| 349 |
+
fontSize: 11,
|
| 350 |
+
cursor: "pointer",
|
| 351 |
+
},
|
| 352 |
+
runBtn: {
|
| 353 |
+
background: "#10B981",
|
| 354 |
+
color: "#052e1c",
|
| 355 |
+
border: "0",
|
| 356 |
+
borderRadius: 4,
|
| 357 |
+
padding: "2px 10px",
|
| 358 |
+
fontSize: 11,
|
| 359 |
+
fontWeight: 600,
|
| 360 |
+
cursor: "pointer",
|
| 361 |
+
},
|
| 362 |
+
code: {
|
| 363 |
+
margin: 0,
|
| 364 |
+
padding: "12px 14px",
|
| 365 |
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
| 366 |
+
fontSize: 12.5,
|
| 367 |
+
lineHeight: 1.55,
|
| 368 |
+
color: "#E4E4E7",
|
| 369 |
+
whiteSpace: "pre-wrap",
|
| 370 |
+
wordBreak: "break-word",
|
| 371 |
+
overflowX: "auto",
|
| 372 |
+
},
|
| 373 |
+
output: {
|
| 374 |
+
background: "#0c0c10",
|
| 375 |
+
borderTop: "1px solid #27272A",
|
| 376 |
+
padding: "8px 14px 10px",
|
| 377 |
+
},
|
| 378 |
+
outputHead: {
|
| 379 |
+
display: "flex",
|
| 380 |
+
alignItems: "center",
|
| 381 |
+
justifyContent: "space-between",
|
| 382 |
+
marginBottom: 6,
|
| 383 |
+
},
|
| 384 |
+
outputLabel: {
|
| 385 |
+
fontSize: 10,
|
| 386 |
+
fontWeight: 600,
|
| 387 |
+
color: "#A1A1AA",
|
| 388 |
+
textTransform: "uppercase",
|
| 389 |
+
letterSpacing: "0.05em",
|
| 390 |
+
},
|
| 391 |
+
metaRow: { display: "flex", gap: 6, alignItems: "center" },
|
| 392 |
+
okPill: {
|
| 393 |
+
fontSize: 10,
|
| 394 |
+
fontWeight: 600,
|
| 395 |
+
padding: "1px 6px",
|
| 396 |
+
borderRadius: 9,
|
| 397 |
+
background: "rgba(16, 185, 129, 0.12)",
|
| 398 |
+
color: "#10B981",
|
| 399 |
+
border: "1px solid rgba(16, 185, 129, 0.35)",
|
| 400 |
+
},
|
| 401 |
+
failPill: {
|
| 402 |
+
fontSize: 10,
|
| 403 |
+
fontWeight: 600,
|
| 404 |
+
padding: "1px 6px",
|
| 405 |
+
borderRadius: 9,
|
| 406 |
+
background: "rgba(239, 68, 68, 0.12)",
|
| 407 |
+
color: "#ef4444",
|
| 408 |
+
border: "1px solid rgba(239, 68, 68, 0.35)",
|
| 409 |
+
},
|
| 410 |
+
warnPill: {
|
| 411 |
+
fontSize: 10,
|
| 412 |
+
fontWeight: 600,
|
| 413 |
+
padding: "1px 6px",
|
| 414 |
+
borderRadius: 9,
|
| 415 |
+
background: "rgba(217, 119, 6, 0.12)",
|
| 416 |
+
color: "#f59e0b",
|
| 417 |
+
border: "1px solid rgba(217, 119, 6, 0.35)",
|
| 418 |
+
},
|
| 419 |
+
backendPill: {
|
| 420 |
+
fontSize: 10,
|
| 421 |
+
fontWeight: 600,
|
| 422 |
+
padding: "1px 6px",
|
| 423 |
+
borderRadius: 9,
|
| 424 |
+
background: "rgba(79, 70, 229, 0.12)",
|
| 425 |
+
color: "#a5b4fc",
|
| 426 |
+
border: "1px solid rgba(79, 70, 229, 0.35)",
|
| 427 |
+
},
|
| 428 |
+
dim: { color: "#71717A", fontSize: 11 },
|
| 429 |
+
stdout: {
|
| 430 |
+
margin: "4px 0 0",
|
| 431 |
+
padding: "6px 8px",
|
| 432 |
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
| 433 |
+
fontSize: 12,
|
| 434 |
+
color: "#D4D4D8",
|
| 435 |
+
background: "#000",
|
| 436 |
+
borderRadius: 4,
|
| 437 |
+
whiteSpace: "pre-wrap",
|
| 438 |
+
wordBreak: "break-word",
|
| 439 |
+
},
|
| 440 |
+
stderr: {
|
| 441 |
+
margin: "4px 0 0",
|
| 442 |
+
padding: "6px 8px",
|
| 443 |
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
| 444 |
+
fontSize: 12,
|
| 445 |
+
color: "#fca5a5",
|
| 446 |
+
background: "#0a0000",
|
| 447 |
+
borderRadius: 4,
|
| 448 |
+
whiteSpace: "pre-wrap",
|
| 449 |
+
wordBreak: "break-word",
|
| 450 |
+
},
|
| 451 |
+
artifactsBox: {
|
| 452 |
+
marginTop: 8,
|
| 453 |
+
padding: "6px 8px",
|
| 454 |
+
background: "#0a0a0f",
|
| 455 |
+
border: "1px solid #27272A",
|
| 456 |
+
borderRadius: 4,
|
| 457 |
+
},
|
| 458 |
+
artifactList: {
|
| 459 |
+
listStyle: "none",
|
| 460 |
+
padding: 0,
|
| 461 |
+
margin: "4px 0 0",
|
| 462 |
+
},
|
| 463 |
+
artifactItem: {
|
| 464 |
+
padding: "2px 0",
|
| 465 |
+
fontSize: 12,
|
| 466 |
+
color: "#D4D4D8",
|
| 467 |
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
| 468 |
+
},
|
| 469 |
+
saveBanner: {
|
| 470 |
+
margin: "0",
|
| 471 |
+
padding: "6px 12px",
|
| 472 |
+
fontSize: 11,
|
| 473 |
+
color: "#A1A1AA",
|
| 474 |
+
background: "#0c0c10",
|
| 475 |
+
borderTop: "1px solid #27272A",
|
| 476 |
+
},
|
| 477 |
+
errorBanner: {
|
| 478 |
+
margin: "6px 0",
|
| 479 |
+
padding: "8px 10px",
|
| 480 |
+
fontSize: 12,
|
| 481 |
+
color: "#fca5a5",
|
| 482 |
+
background: "#3d1111",
|
| 483 |
+
border: "1px solid #7f1d1d",
|
| 484 |
+
borderRadius: 6,
|
| 485 |
+
},
|
| 486 |
+
};
|
frontend/components/SandboxCanvas.jsx
ADDED
|
@@ -0,0 +1,1052 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/SandboxCanvas.jsx
|
| 2 |
+
//
|
| 3 |
+
// File-aware Canvas: a modal that adapts to the file type it was
|
| 4 |
+
// opened with. Three modes:
|
| 5 |
+
//
|
| 6 |
+
// code → split editor + sandbox output. Prepare-Run flow.
|
| 7 |
+
// Used for .py / .js / .sh / inline snippets.
|
| 8 |
+
// markdown → split source + rendered preview. No Run button.
|
| 9 |
+
// Used for .md / .markdown.
|
| 10 |
+
// text → source-only viewer (read-only by default).
|
| 11 |
+
// Used for everything else: .json, .yml, .csv,
|
| 12 |
+
// .html, .txt, ...
|
| 13 |
+
//
|
| 14 |
+
// Mode is computed from the ``filename`` prop's extension. When
|
| 15 |
+
// ``filename`` is null (an anonymous inline snippet from a chat code
|
| 16 |
+
// block) we fall back to code mode using the ``initialLanguage`` tag.
|
| 17 |
+
//
|
| 18 |
+
// The sandbox approval contract is unchanged: Prepare Run dispatches
|
| 19 |
+
// /api/sandbox/plan, the user approves a green ExecutionPlanCard,
|
| 20 |
+
// only then /api/sandbox/run actually executes.
|
| 21 |
+
|
| 22 |
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
| 23 |
+
import { fetchExecutionPlan } from "./ExecutionPlanCard.jsx";
|
| 24 |
+
|
| 25 |
+
const BACKEND_LABELS = {
|
| 26 |
+
subprocess: "Local",
|
| 27 |
+
matrixlab: "MatrixLab",
|
| 28 |
+
off: "Pass-through",
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const LANG_DISPLAY = {
|
| 32 |
+
py: "python", js: "javascript", node: "javascript",
|
| 33 |
+
sh: "bash", shell: "bash",
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
// Extensions that route through the sandbox. Everything else gets
|
| 37 |
+
// a viewer-only canvas — README.md must NOT show a Python execution
|
| 38 |
+
// plan, JSON must NOT pretend it can run.
|
| 39 |
+
const RUNNABLE_EXTS = new Set(["py", "js", "mjs", "cjs", "sh", "bash"]);
|
| 40 |
+
const MARKDOWN_EXTS = new Set(["md", "markdown", "mdx"]);
|
| 41 |
+
|
| 42 |
+
// Map extension → canonical sandbox language tag used by the planner.
|
| 43 |
+
const EXT_TO_LANG = {
|
| 44 |
+
py: "python", js: "javascript", mjs: "javascript", cjs: "javascript",
|
| 45 |
+
sh: "bash", bash: "bash",
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
// Display label per file type — keeps the header honest about what
|
| 49 |
+
// the Canvas is showing.
|
| 50 |
+
const TYPE_LABEL = {
|
| 51 |
+
python: "Python", javascript: "JavaScript", bash: "Shell",
|
| 52 |
+
markdown: "Markdown", json: "JSON", yaml: "YAML", csv: "CSV",
|
| 53 |
+
html: "HTML", text: "Text",
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
function extOf(name) {
|
| 57 |
+
if (!name || !name.includes(".")) return "";
|
| 58 |
+
return name.split(".").pop().toLowerCase();
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// Decide which Canvas branch to render based on the opening context.
|
| 62 |
+
// Pure function so tests can pin it.
|
| 63 |
+
function resolveCanvasMode({ filename, initialLanguage }) {
|
| 64 |
+
const ext = extOf(filename || "");
|
| 65 |
+
if (filename && MARKDOWN_EXTS.has(ext)) {
|
| 66 |
+
return { kind: "markdown", typeLabel: "Markdown", language: "markdown" };
|
| 67 |
+
}
|
| 68 |
+
if (filename && RUNNABLE_EXTS.has(ext)) {
|
| 69 |
+
const lang = EXT_TO_LANG[ext];
|
| 70 |
+
return { kind: "code", typeLabel: TYPE_LABEL[lang], language: lang };
|
| 71 |
+
}
|
| 72 |
+
if (filename) {
|
| 73 |
+
// Known-but-non-runnable: json / yaml / csv / html / txt / etc.
|
| 74 |
+
const known = { json: "json", yml: "yaml", yaml: "yaml",
|
| 75 |
+
csv: "csv", html: "html", txt: "text" }[ext];
|
| 76 |
+
if (known) {
|
| 77 |
+
return { kind: "text", typeLabel: TYPE_LABEL[known], language: known };
|
| 78 |
+
}
|
| 79 |
+
return { kind: "text", typeLabel: ext.toUpperCase() || "Text", language: "text" };
|
| 80 |
+
}
|
| 81 |
+
// No filename → inline snippet from a chat code block. Honor the
|
| 82 |
+
// language tag the chat fence carried.
|
| 83 |
+
const tag = (initialLanguage || "python").toLowerCase();
|
| 84 |
+
if (tag === "py" || tag === "python") return { kind: "code", typeLabel: "Python snippet", language: "python" };
|
| 85 |
+
if (tag === "js" || tag === "javascript" || tag === "node")
|
| 86 |
+
return { kind: "code", typeLabel: "JavaScript snippet", language: "javascript" };
|
| 87 |
+
if (tag === "sh" || tag === "bash" || tag === "shell")
|
| 88 |
+
return { kind: "code", typeLabel: "Shell snippet", language: "bash" };
|
| 89 |
+
return { kind: "text", typeLabel: "Text", language: "text" };
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// Minimal, dependency-free Markdown → HTML renderer. Covers the
|
| 93 |
+
// patterns that matter for README files: headings, bold, italic,
|
| 94 |
+
// inline code, fenced code blocks, lists, links. Escapes HTML to
|
| 95 |
+
// avoid XSS when README contains <script> or similar.
|
| 96 |
+
function escapeHtml(s) {
|
| 97 |
+
return s
|
| 98 |
+
.replace(/&/g, "&")
|
| 99 |
+
.replace(/</g, "<")
|
| 100 |
+
.replace(/>/g, ">")
|
| 101 |
+
.replace(/"/g, """);
|
| 102 |
+
}
|
| 103 |
+
function renderMarkdown(src) {
|
| 104 |
+
if (!src) return "";
|
| 105 |
+
let s = escapeHtml(src);
|
| 106 |
+
// Fenced code blocks first so their content isn't re-processed.
|
| 107 |
+
s = s.replace(/```([\w-]*)\n([\s\S]*?)```/g, (_m, _lang, code) =>
|
| 108 |
+
`<pre class="md-code"><code>${code}</code></pre>`,
|
| 109 |
+
);
|
| 110 |
+
// Headings — process before line-based replacements.
|
| 111 |
+
s = s.replace(/^######\s+(.+)$/gm, "<h6>$1</h6>");
|
| 112 |
+
s = s.replace(/^#####\s+(.+)$/gm, "<h5>$1</h5>");
|
| 113 |
+
s = s.replace(/^####\s+(.+)$/gm, "<h4>$1</h4>");
|
| 114 |
+
s = s.replace(/^###\s+(.+)$/gm, "<h3>$1</h3>");
|
| 115 |
+
s = s.replace(/^##\s+(.+)$/gm, "<h2>$1</h2>");
|
| 116 |
+
s = s.replace(/^#\s+(.+)$/gm, "<h1>$1</h1>");
|
| 117 |
+
// Bold / italic / inline code.
|
| 118 |
+
s = s.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
|
| 119 |
+
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
|
| 120 |
+
s = s.replace(/`([^`\n]+)`/g, "<code>$1</code>");
|
| 121 |
+
// Links: [text](url) — only allow http(s) + mailto + relative paths.
|
| 122 |
+
s = s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:|\/|\.)[^)\s]+)\)/g,
|
| 123 |
+
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
| 124 |
+
// Unordered list items.
|
| 125 |
+
s = s.replace(/^[ \t]*[-*]\s+(.+)$/gm, "<li>$1</li>");
|
| 126 |
+
s = s.replace(/(<li>[\s\S]*?<\/li>(?:\n<li>[\s\S]*?<\/li>)*)/g,
|
| 127 |
+
(m) => `<ul>${m.replace(/\n/g, "")}</ul>`);
|
| 128 |
+
// Paragraphs — blank-line separated runs that aren't already block-tagged.
|
| 129 |
+
s = s.split(/\n{2,}/).map((para) => {
|
| 130 |
+
if (/^<(h\d|ul|pre|p|blockquote)/.test(para.trim())) return para;
|
| 131 |
+
return `<p>${para.replace(/\n/g, " ")}</p>`;
|
| 132 |
+
}).join("\n");
|
| 133 |
+
return s;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// Mirrors the executor's _looks_like_matplotlib heuristic so plt.show()
|
| 137 |
+
// snippets don't hang the headless sandbox. False positives are
|
| 138 |
+
// harmless (Agg is a valid backend for any Python script); false
|
| 139 |
+
// negatives cause a hang on a plot window that never opens.
|
| 140 |
+
function looksLikeMatplotlib(code) {
|
| 141 |
+
if (!code) return false;
|
| 142 |
+
const lower = code.toLowerCase();
|
| 143 |
+
return /import\s+matplotlib|from\s+matplotlib|plt\.show|pyplot/.test(lower);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
function applyMatplotlibShim(language, code) {
|
| 147 |
+
if (language !== "python" && language !== "py") return code;
|
| 148 |
+
if (!looksLikeMatplotlib(code)) return code;
|
| 149 |
+
return (
|
| 150 |
+
"import os as _gp_os\n" +
|
| 151 |
+
'_gp_os.environ.setdefault("MPLBACKEND", "Agg")\n' +
|
| 152 |
+
code
|
| 153 |
+
);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
export default function SandboxCanvas(props) {
|
| 157 |
+
// Branch by mode at the top so non-runnable file types never see
|
| 158 |
+
// the sandbox plumbing. Each branch is a focused subcomponent —
|
| 159 |
+
// easier to read and easier to test than a single 400-line function
|
| 160 |
+
// with three conditional render paths.
|
| 161 |
+
const mode = resolveCanvasMode({
|
| 162 |
+
filename: props.filename,
|
| 163 |
+
initialLanguage: props.initialLanguage,
|
| 164 |
+
});
|
| 165 |
+
if (mode.kind === "markdown") return <MarkdownCanvas {...props} mode={mode} />;
|
| 166 |
+
if (mode.kind === "text") return <TextCanvas {...props} mode={mode} />;
|
| 167 |
+
return <CodeCanvas {...props} mode={mode} />;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
// ---------------------------------------------------------------------------
|
| 171 |
+
// CodeCanvas — the original sandbox-enabled Canvas, now scoped to
|
| 172 |
+
// runnable code. Uses ``filename`` (when present and not "inline.*")
|
| 173 |
+
// as the ``display_filename`` on the ExecutionPlan so the command
|
| 174 |
+
// reads ``python demo.py`` instead of ``python inline.py``.
|
| 175 |
+
// ---------------------------------------------------------------------------
|
| 176 |
+
|
| 177 |
+
function CodeCanvas({
|
| 178 |
+
initialCode,
|
| 179 |
+
filename,
|
| 180 |
+
onClose,
|
| 181 |
+
onSaveAsFile,
|
| 182 |
+
mode,
|
| 183 |
+
}) {
|
| 184 |
+
const language = mode.language;
|
| 185 |
+
const [code, setCode] = useState(initialCode || "");
|
| 186 |
+
const [busy, setBusy] = useState(false);
|
| 187 |
+
const [result, setResult] = useState(null);
|
| 188 |
+
const [error, setError] = useState(null);
|
| 189 |
+
const [startTime, setStartTime] = useState(null);
|
| 190 |
+
const [elapsed, setElapsed] = useState(0);
|
| 191 |
+
const tickRef = useRef(null);
|
| 192 |
+
// Approval-first: clicking Run fetches a deterministic plan first;
|
| 193 |
+
// the slim confirmation strip lets the user back out before any code
|
| 194 |
+
// actually executes in the sandbox.
|
| 195 |
+
const [pendingPlan, setPendingPlan] = useState(null);
|
| 196 |
+
const [planError, setPlanError] = useState(null);
|
| 197 |
+
const [showRunDetails, setShowRunDetails] = useState(false);
|
| 198 |
+
const [showMenu, setShowMenu] = useState(false);
|
| 199 |
+
const [finishedAt, setFinishedAt] = useState(null);
|
| 200 |
+
const [phase, setPhase] = useState("idle"); // idle | preparing | running | done | failed
|
| 201 |
+
const menuRef = useRef(null);
|
| 202 |
+
|
| 203 |
+
useEffect(() => {
|
| 204 |
+
if (!busy || !startTime) return;
|
| 205 |
+
tickRef.current = setInterval(() => {
|
| 206 |
+
setElapsed(Date.now() - startTime);
|
| 207 |
+
}, 100);
|
| 208 |
+
return () => {
|
| 209 |
+
if (tickRef.current) clearInterval(tickRef.current);
|
| 210 |
+
tickRef.current = null;
|
| 211 |
+
};
|
| 212 |
+
}, [busy, startTime]);
|
| 213 |
+
|
| 214 |
+
useEffect(() => {
|
| 215 |
+
if (!showMenu) return;
|
| 216 |
+
const onDown = (e) => {
|
| 217 |
+
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
| 218 |
+
setShowMenu(false);
|
| 219 |
+
}
|
| 220 |
+
};
|
| 221 |
+
window.addEventListener("mousedown", onDown);
|
| 222 |
+
return () => window.removeEventListener("mousedown", onDown);
|
| 223 |
+
}, [showMenu]);
|
| 224 |
+
|
| 225 |
+
const requestPlan = useCallback(async () => {
|
| 226 |
+
setPlanError(null);
|
| 227 |
+
setError(null);
|
| 228 |
+
setBusy(true);
|
| 229 |
+
setPhase("preparing");
|
| 230 |
+
try {
|
| 231 |
+
const shipped = applyMatplotlibShim(language, code);
|
| 232 |
+
const isInlineName = (filename || "").toLowerCase().startsWith("inline.");
|
| 233 |
+
const displayFilename = filename && !isInlineName ? filename : undefined;
|
| 234 |
+
const plan = await fetchExecutionPlan({
|
| 235 |
+
code: shipped,
|
| 236 |
+
language,
|
| 237 |
+
source: "canvas",
|
| 238 |
+
display_filename: displayFilename,
|
| 239 |
+
});
|
| 240 |
+
setPendingPlan(plan);
|
| 241 |
+
} catch (err) {
|
| 242 |
+
setPlanError(err.message || "Could not build execution plan");
|
| 243 |
+
setPhase("failed");
|
| 244 |
+
} finally {
|
| 245 |
+
setBusy(false);
|
| 246 |
+
}
|
| 247 |
+
}, [language, code, filename]);
|
| 248 |
+
|
| 249 |
+
const approveRun = useCallback(async (plan) => {
|
| 250 |
+
setBusy(true);
|
| 251 |
+
setResult(null);
|
| 252 |
+
setError(null);
|
| 253 |
+
setStartTime(Date.now());
|
| 254 |
+
setElapsed(0);
|
| 255 |
+
setPhase("running");
|
| 256 |
+
try {
|
| 257 |
+
const res = await fetch("/api/sandbox/run", {
|
| 258 |
+
method: "POST",
|
| 259 |
+
headers: { "Content-Type": "application/json" },
|
| 260 |
+
body: JSON.stringify({
|
| 261 |
+
language: plan.language,
|
| 262 |
+
code: plan.inline_code,
|
| 263 |
+
timeout_sec: plan.timeout_sec,
|
| 264 |
+
}),
|
| 265 |
+
});
|
| 266 |
+
const data = await res.json();
|
| 267 |
+
if (!res.ok) {
|
| 268 |
+
setError(data.detail || `HTTP ${res.status}`);
|
| 269 |
+
setPhase("failed");
|
| 270 |
+
return;
|
| 271 |
+
}
|
| 272 |
+
setResult(data);
|
| 273 |
+
setPendingPlan(null);
|
| 274 |
+
setFinishedAt(Date.now());
|
| 275 |
+
setPhase(data.exit_code === 0 ? "done" : "failed");
|
| 276 |
+
} catch (err) {
|
| 277 |
+
setError(err.message || "Run failed");
|
| 278 |
+
setPhase("failed");
|
| 279 |
+
} finally {
|
| 280 |
+
setBusy(false);
|
| 281 |
+
}
|
| 282 |
+
}, []);
|
| 283 |
+
|
| 284 |
+
const run = requestPlan;
|
| 285 |
+
const cancelPending = () => { setPendingPlan(null); setPhase("idle"); };
|
| 286 |
+
|
| 287 |
+
useEffect(() => {
|
| 288 |
+
const onKey = (e) => {
|
| 289 |
+
if (e.key === "Escape" && !busy) onClose?.();
|
| 290 |
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") run();
|
| 291 |
+
};
|
| 292 |
+
window.addEventListener("keydown", onKey);
|
| 293 |
+
return () => window.removeEventListener("keydown", onKey);
|
| 294 |
+
}, [run, busy, onClose]);
|
| 295 |
+
|
| 296 |
+
const exitOk = result?.exit_code === 0;
|
| 297 |
+
const displayLang = LANG_DISPLAY[language] || language;
|
| 298 |
+
const headerFilename = filename
|
| 299 |
+
|| `inline.${displayLang === "javascript" ? "js" : displayLang === "bash" ? "sh" : "py"}`;
|
| 300 |
+
|
| 301 |
+
const isPython = language === "python";
|
| 302 |
+
const showMplNotice = isPython && looksLikeMatplotlib(code);
|
| 303 |
+
// Crude but useful: flag scripts that read env vars so users can spot
|
| 304 |
+
// sandbox-scoped variable scoping issues before they run.
|
| 305 |
+
const readsEnvVars = /os\.environ|process\.env|getenv/.test(code || "");
|
| 306 |
+
|
| 307 |
+
const runCommand = useMemo(() => {
|
| 308 |
+
const base = headerFilename.split("/").pop();
|
| 309 |
+
if (language === "python") return `python ${base}`;
|
| 310 |
+
if (language === "javascript") return `node ${base}`;
|
| 311 |
+
if (language === "bash") return `bash ${base}`;
|
| 312 |
+
return base;
|
| 313 |
+
}, [headerFilename, language]);
|
| 314 |
+
|
| 315 |
+
const lineCount = (code || "").split("\n").length;
|
| 316 |
+
const charCount = (code || "").length;
|
| 317 |
+
|
| 318 |
+
const statusText =
|
| 319 |
+
phase === "preparing" ? "Preparing sandbox…" :
|
| 320 |
+
phase === "running" ? `Running ${runCommand}…` :
|
| 321 |
+
phase === "done" ? `Completed in ${((finishedAt - startTime) / 1000).toFixed(1)}s` :
|
| 322 |
+
phase === "failed" ? "Execution failed" :
|
| 323 |
+
"Ready to run";
|
| 324 |
+
|
| 325 |
+
const statusTone =
|
| 326 |
+
phase === "preparing" || phase === "running" ? "info" :
|
| 327 |
+
phase === "done" ? "ok" :
|
| 328 |
+
phase === "failed" ? "bad" :
|
| 329 |
+
"ok";
|
| 330 |
+
|
| 331 |
+
return (
|
| 332 |
+
<div style={s.backdrop} onClick={onClose}>
|
| 333 |
+
<div className="canvas-modal" style={s.modal} onClick={(e) => e.stopPropagation()}>
|
| 334 |
+
<header className="canvas-header">
|
| 335 |
+
<div className="canvas-header__left">
|
| 336 |
+
<div className="canvas-header__glyph" aria-hidden="true">
|
| 337 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
| 338 |
+
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
| 339 |
+
strokeLinejoin="round">
|
| 340 |
+
<polyline points="4 17 10 11 4 5" />
|
| 341 |
+
<line x1="12" y1="19" x2="20" y2="19" />
|
| 342 |
+
</svg>
|
| 343 |
+
</div>
|
| 344 |
+
<div className="canvas-header__title">{headerFilename.split("/").pop()}</div>
|
| 345 |
+
<span className="canvas-pill canvas-pill--lang">
|
| 346 |
+
<span className="canvas-pill__dot" />
|
| 347 |
+
{mode.typeLabel}
|
| 348 |
+
</span>
|
| 349 |
+
<span className="canvas-pill canvas-pill--sandbox">
|
| 350 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
| 351 |
+
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
| 352 |
+
strokeLinejoin="round" aria-hidden="true">
|
| 353 |
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
| 354 |
+
</svg>
|
| 355 |
+
Local sandbox
|
| 356 |
+
</span>
|
| 357 |
+
</div>
|
| 358 |
+
<div className="canvas-header__right">
|
| 359 |
+
<button
|
| 360 |
+
type="button"
|
| 361 |
+
className="canvas-run-btn"
|
| 362 |
+
onClick={pendingPlan ? () => approveRun(pendingPlan) : requestPlan}
|
| 363 |
+
disabled={busy && phase !== "running"}
|
| 364 |
+
>
|
| 365 |
+
{phase === "running" ? (
|
| 366 |
+
<>
|
| 367 |
+
<span className="canvas-run-btn__spinner" />
|
| 368 |
+
Running {(elapsed / 1000).toFixed(1)}s
|
| 369 |
+
</>
|
| 370 |
+
) : phase === "preparing" ? (
|
| 371 |
+
"Preparing…"
|
| 372 |
+
) : pendingPlan ? (
|
| 373 |
+
<>▶ Confirm & run</>
|
| 374 |
+
) : (
|
| 375 |
+
<>▶ Run</>
|
| 376 |
+
)}
|
| 377 |
+
</button>
|
| 378 |
+
<div className="canvas-menu" ref={menuRef}>
|
| 379 |
+
<button
|
| 380 |
+
type="button"
|
| 381 |
+
className="canvas-icon-btn"
|
| 382 |
+
onClick={() => setShowMenu((v) => !v)}
|
| 383 |
+
aria-label="More actions"
|
| 384 |
+
aria-haspopup="menu"
|
| 385 |
+
aria-expanded={showMenu}
|
| 386 |
+
>
|
| 387 |
+
⋮
|
| 388 |
+
</button>
|
| 389 |
+
{showMenu && (
|
| 390 |
+
<div className="canvas-menu__panel" role="menu">
|
| 391 |
+
{onSaveAsFile && (
|
| 392 |
+
<button
|
| 393 |
+
type="button"
|
| 394 |
+
className="canvas-menu__item"
|
| 395 |
+
onClick={() => { setShowMenu(false); onSaveAsFile(code, language); }}
|
| 396 |
+
>
|
| 397 |
+
Save to repo…
|
| 398 |
+
</button>
|
| 399 |
+
)}
|
| 400 |
+
<button
|
| 401 |
+
type="button"
|
| 402 |
+
className="canvas-menu__item"
|
| 403 |
+
onClick={() => {
|
| 404 |
+
if (navigator?.clipboard) {
|
| 405 |
+
navigator.clipboard.writeText(code).catch(() => {});
|
| 406 |
+
}
|
| 407 |
+
setShowMenu(false);
|
| 408 |
+
}}
|
| 409 |
+
>
|
| 410 |
+
Copy source
|
| 411 |
+
</button>
|
| 412 |
+
<button
|
| 413 |
+
type="button"
|
| 414 |
+
className="canvas-menu__item"
|
| 415 |
+
onClick={() => { setShowMenu(false); onClose?.(); }}
|
| 416 |
+
>
|
| 417 |
+
Close canvas
|
| 418 |
+
</button>
|
| 419 |
+
</div>
|
| 420 |
+
)}
|
| 421 |
+
</div>
|
| 422 |
+
<button type="button" className="canvas-icon-btn canvas-icon-btn--close"
|
| 423 |
+
onClick={onClose} aria-label="Close">
|
| 424 |
+
✕
|
| 425 |
+
</button>
|
| 426 |
+
</div>
|
| 427 |
+
</header>
|
| 428 |
+
|
| 429 |
+
<div className="canvas-status">
|
| 430 |
+
<div className={`canvas-status__dot canvas-status__dot--${statusTone}`} aria-hidden="true">
|
| 431 |
+
{phase === "done" ? "✓" :
|
| 432 |
+
phase === "failed" ? "✕" :
|
| 433 |
+
phase === "preparing" || phase === "running" ? "" :
|
| 434 |
+
"▸"}
|
| 435 |
+
</div>
|
| 436 |
+
<div className="canvas-status__text">
|
| 437 |
+
<div className="canvas-status__title">{statusText}</div>
|
| 438 |
+
<div className="canvas-status__sub">
|
| 439 |
+
<code>{runCommand}</code>
|
| 440 |
+
<span className="canvas-status__sep">·</span>
|
| 441 |
+
Timeout 120s
|
| 442 |
+
<span className="canvas-status__sep">·</span>
|
| 443 |
+
Network disabled
|
| 444 |
+
</div>
|
| 445 |
+
</div>
|
| 446 |
+
<button
|
| 447 |
+
type="button"
|
| 448 |
+
className="canvas-status__details"
|
| 449 |
+
onClick={() => setShowRunDetails((v) => !v)}
|
| 450 |
+
aria-expanded={showRunDetails}
|
| 451 |
+
>
|
| 452 |
+
Details {showRunDetails ? "▴" : "▾"}
|
| 453 |
+
</button>
|
| 454 |
+
</div>
|
| 455 |
+
|
| 456 |
+
{showRunDetails && (
|
| 457 |
+
<div className="canvas-run-config">
|
| 458 |
+
<div className="canvas-run-config__row">
|
| 459 |
+
<span className="canvas-run-config__label">Command</span>
|
| 460 |
+
<code className="canvas-run-config__val">{runCommand}</code>
|
| 461 |
+
</div>
|
| 462 |
+
<div className="canvas-run-config__row">
|
| 463 |
+
<span className="canvas-run-config__label">Environment</span>
|
| 464 |
+
<span className="canvas-run-config__val">Local sandbox (isolated)</span>
|
| 465 |
+
</div>
|
| 466 |
+
<div className="canvas-run-config__row">
|
| 467 |
+
<span className="canvas-run-config__label">Timeout</span>
|
| 468 |
+
<span className="canvas-run-config__val">120s</span>
|
| 469 |
+
</div>
|
| 470 |
+
<div className="canvas-run-config__row">
|
| 471 |
+
<span className="canvas-run-config__label">Network</span>
|
| 472 |
+
<span className="canvas-run-config__val">Disabled</span>
|
| 473 |
+
</div>
|
| 474 |
+
<div className="canvas-run-config__row">
|
| 475 |
+
<span className="canvas-run-config__label">Snippet</span>
|
| 476 |
+
<span className="canvas-run-config__val">{lineCount} lines · {charCount} chars</span>
|
| 477 |
+
</div>
|
| 478 |
+
</div>
|
| 479 |
+
)}
|
| 480 |
+
|
| 481 |
+
{(showMplNotice || readsEnvVars) && (
|
| 482 |
+
<div className="canvas-notices">
|
| 483 |
+
<div className="canvas-notices__label">Notices</div>
|
| 484 |
+
<div className="canvas-notices__row">
|
| 485 |
+
{readsEnvVars && (
|
| 486 |
+
<div className="canvas-notice canvas-notice--info">
|
| 487 |
+
<div className="canvas-notice__icon" aria-hidden="true">ⓘ</div>
|
| 488 |
+
<div className="canvas-notice__body">
|
| 489 |
+
<div className="canvas-notice__title">Reads environment variables</div>
|
| 490 |
+
<div className="canvas-notice__desc">
|
| 491 |
+
Script reads env vars — only sandbox-scoped variables are present.
|
| 492 |
+
</div>
|
| 493 |
+
</div>
|
| 494 |
+
</div>
|
| 495 |
+
)}
|
| 496 |
+
{showMplNotice && (
|
| 497 |
+
<div className="canvas-notice canvas-notice--accent">
|
| 498 |
+
<div className="canvas-notice__icon" aria-hidden="true">
|
| 499 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
| 500 |
+
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
| 501 |
+
strokeLinejoin="round">
|
| 502 |
+
<polyline points="3 17 9 11 13 15 21 7" />
|
| 503 |
+
</svg>
|
| 504 |
+
</div>
|
| 505 |
+
<div className="canvas-notice__body">
|
| 506 |
+
<div className="canvas-notice__title">Matplotlib detected</div>
|
| 507 |
+
<div className="canvas-notice__desc">
|
| 508 |
+
Plots will render inline in the sandbox output.
|
| 509 |
+
</div>
|
| 510 |
+
</div>
|
| 511 |
+
</div>
|
| 512 |
+
)}
|
| 513 |
+
</div>
|
| 514 |
+
</div>
|
| 515 |
+
)}
|
| 516 |
+
|
| 517 |
+
{pendingPlan && (
|
| 518 |
+
<div className="canvas-confirm">
|
| 519 |
+
<div className="canvas-confirm__text">
|
| 520 |
+
GitPilot will run <code>{runCommand}</code> in the local sandbox.
|
| 521 |
+
Click <strong>Confirm & run</strong> to proceed.
|
| 522 |
+
</div>
|
| 523 |
+
<button type="button" className="canvas-confirm__cancel" onClick={cancelPending}>
|
| 524 |
+
Cancel
|
| 525 |
+
</button>
|
| 526 |
+
</div>
|
| 527 |
+
)}
|
| 528 |
+
{planError && (
|
| 529 |
+
<div className="canvas-plan-error">Plan error: {planError}</div>
|
| 530 |
+
)}
|
| 531 |
+
|
| 532 |
+
<div className="canvas-body">
|
| 533 |
+
<div className="canvas-pane canvas-pane--source">
|
| 534 |
+
<div className="canvas-pane__head">
|
| 535 |
+
<div className="canvas-pane__title">Source</div>
|
| 536 |
+
<div className="canvas-pane__head-right">
|
| 537 |
+
<span className="canvas-pane__meta">{mode.typeLabel}</span>
|
| 538 |
+
<button
|
| 539 |
+
type="button"
|
| 540 |
+
className="canvas-pane__icon"
|
| 541 |
+
title="Copy source"
|
| 542 |
+
onClick={() => {
|
| 543 |
+
if (navigator?.clipboard) {
|
| 544 |
+
navigator.clipboard.writeText(code).catch(() => {});
|
| 545 |
+
}
|
| 546 |
+
}}
|
| 547 |
+
>
|
| 548 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
| 549 |
+
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
| 550 |
+
strokeLinejoin="round">
|
| 551 |
+
<rect x="9" y="9" width="13" height="13" rx="2" />
|
| 552 |
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
| 553 |
+
</svg>
|
| 554 |
+
</button>
|
| 555 |
+
</div>
|
| 556 |
+
</div>
|
| 557 |
+
<div className="canvas-editor-wrap">
|
| 558 |
+
<pre className="canvas-editor-gutter" aria-hidden="true">
|
| 559 |
+
{Array.from({ length: Math.max(lineCount, 1) }, (_, i) => (
|
| 560 |
+
<div key={i} className="canvas-editor-gutter__line">{i + 1}</div>
|
| 561 |
+
))}
|
| 562 |
+
</pre>
|
| 563 |
+
<textarea
|
| 564 |
+
className="canvas-editor"
|
| 565 |
+
value={code}
|
| 566 |
+
onChange={(e) => setCode(e.target.value)}
|
| 567 |
+
spellCheck={false}
|
| 568 |
+
/>
|
| 569 |
+
</div>
|
| 570 |
+
<div className="canvas-statusbar">
|
| 571 |
+
<span>{lineCount} lines</span>
|
| 572 |
+
<span className="canvas-statusbar__sep">·</span>
|
| 573 |
+
<span>{mode.typeLabel}</span>
|
| 574 |
+
<span className="canvas-statusbar__spacer" />
|
| 575 |
+
<span className="canvas-statusbar__kbd">⌘/Ctrl+Enter to run · Esc to close</span>
|
| 576 |
+
</div>
|
| 577 |
+
</div>
|
| 578 |
+
|
| 579 |
+
<div className="canvas-pane canvas-pane--output">
|
| 580 |
+
<div className="canvas-pane__head">
|
| 581 |
+
<div className="canvas-pane__title">Sandbox output</div>
|
| 582 |
+
{result && (
|
| 583 |
+
<button
|
| 584 |
+
type="button"
|
| 585 |
+
className="canvas-pane__icon-btn"
|
| 586 |
+
title="Clear output"
|
| 587 |
+
onClick={() => { setResult(null); setError(null); setPhase("idle"); }}
|
| 588 |
+
>
|
| 589 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
| 590 |
+
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
| 591 |
+
strokeLinejoin="round" aria-hidden="true">
|
| 592 |
+
<polyline points="3 6 5 6 21 6" />
|
| 593 |
+
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
| 594 |
+
</svg>
|
| 595 |
+
Clear
|
| 596 |
+
</button>
|
| 597 |
+
)}
|
| 598 |
+
</div>
|
| 599 |
+
|
| 600 |
+
<div className="canvas-output-body">
|
| 601 |
+
{!result && !error && phase === "idle" && (
|
| 602 |
+
<div className="canvas-output-empty">
|
| 603 |
+
<div className="canvas-output-empty__title">Ready to run</div>
|
| 604 |
+
<div className="canvas-output-empty__sub">
|
| 605 |
+
Click <strong>▶ Run</strong> to execute this snippet in the local sandbox.
|
| 606 |
+
</div>
|
| 607 |
+
</div>
|
| 608 |
+
)}
|
| 609 |
+
|
| 610 |
+
{(phase === "preparing" || phase === "running") && (
|
| 611 |
+
<div className="canvas-output-running">
|
| 612 |
+
<span className="canvas-output-running__spinner" aria-hidden="true" />
|
| 613 |
+
<span>
|
| 614 |
+
{phase === "preparing" ? "Preparing sandbox…" : `Running… ${(elapsed / 1000).toFixed(1)}s`}
|
| 615 |
+
</span>
|
| 616 |
+
</div>
|
| 617 |
+
)}
|
| 618 |
+
|
| 619 |
+
{error && phase === "failed" && (
|
| 620 |
+
<div className="canvas-result-head canvas-result-head--bad">
|
| 621 |
+
<span className="canvas-result-icon">✕</span>
|
| 622 |
+
<div>
|
| 623 |
+
<div className="canvas-result-title">Execution failed</div>
|
| 624 |
+
<pre className="canvas-result-err">{error}</pre>
|
| 625 |
+
</div>
|
| 626 |
+
</div>
|
| 627 |
+
)}
|
| 628 |
+
|
| 629 |
+
{result && (
|
| 630 |
+
<>
|
| 631 |
+
<div className={`canvas-result-head canvas-result-head--${exitOk ? "ok" : "bad"}`}>
|
| 632 |
+
<span className="canvas-result-icon">{exitOk ? "✓" : "✕"}</span>
|
| 633 |
+
<div className="canvas-result-headtext">
|
| 634 |
+
<div className="canvas-result-title">
|
| 635 |
+
{exitOk ? `Completed in ${((finishedAt - startTime) / 1000).toFixed(1)}s` : `Failed (exit ${result.exit_code})`}
|
| 636 |
+
</div>
|
| 637 |
+
<div className="canvas-result-sub">
|
| 638 |
+
{BACKEND_LABELS[result.backend] || result.backend}
|
| 639 |
+
{typeof result.duration_ms === "number" && ` · ${result.duration_ms} ms`}
|
| 640 |
+
{result.timed_out && " · timed out"}
|
| 641 |
+
{result.truncated && " · truncated"}
|
| 642 |
+
</div>
|
| 643 |
+
</div>
|
| 644 |
+
<span className="canvas-result-time">
|
| 645 |
+
{new Date(finishedAt || Date.now()).toLocaleTimeString([], {
|
| 646 |
+
hour: "numeric", minute: "2-digit",
|
| 647 |
+
})}
|
| 648 |
+
</span>
|
| 649 |
+
</div>
|
| 650 |
+
|
| 651 |
+
{result.stdout && (
|
| 652 |
+
<div className="canvas-output-section">
|
| 653 |
+
<div className="canvas-output-section__label">
|
| 654 |
+
<span>STDOUT</span>
|
| 655 |
+
</div>
|
| 656 |
+
<pre className="canvas-output-pre">{result.stdout}</pre>
|
| 657 |
+
</div>
|
| 658 |
+
)}
|
| 659 |
+
|
| 660 |
+
{Array.isArray(result.artifacts) && result.artifacts.length > 0 && (
|
| 661 |
+
<div className="canvas-output-section">
|
| 662 |
+
<div className="canvas-output-section__label">
|
| 663 |
+
Artifacts <span className="canvas-output-section__count">{result.artifacts.length}</span>
|
| 664 |
+
</div>
|
| 665 |
+
<ul className="canvas-artifacts">
|
| 666 |
+
{result.artifacts.map((a, i) => (
|
| 667 |
+
<ArtifactItem key={i} artifact={a} />
|
| 668 |
+
))}
|
| 669 |
+
</ul>
|
| 670 |
+
</div>
|
| 671 |
+
)}
|
| 672 |
+
|
| 673 |
+
{result.stderr && (
|
| 674 |
+
<div className="canvas-output-section">
|
| 675 |
+
<div className="canvas-output-section__label canvas-output-section__label--err">
|
| 676 |
+
STDERR
|
| 677 |
+
</div>
|
| 678 |
+
<pre className="canvas-output-pre canvas-output-pre--err">{result.stderr}</pre>
|
| 679 |
+
</div>
|
| 680 |
+
)}
|
| 681 |
+
|
| 682 |
+
{!result.stdout && !result.stderr && (!result.artifacts || result.artifacts.length === 0) && (
|
| 683 |
+
<div className="canvas-output-empty">
|
| 684 |
+
<div className="canvas-output-empty__sub">
|
| 685 |
+
{exitOk
|
| 686 |
+
? "The script completed without producing any output."
|
| 687 |
+
: "The script failed without producing any output."}
|
| 688 |
+
</div>
|
| 689 |
+
</div>
|
| 690 |
+
)}
|
| 691 |
+
</>
|
| 692 |
+
)}
|
| 693 |
+
</div>
|
| 694 |
+
</div>
|
| 695 |
+
</div>
|
| 696 |
+
</div>
|
| 697 |
+
</div>
|
| 698 |
+
);
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
function ArtifactItem({ artifact }) {
|
| 702 |
+
const name = artifact.name || artifact.id || "artifact";
|
| 703 |
+
const isImage =
|
| 704 |
+
(artifact.mime && artifact.mime.startsWith("image/")) ||
|
| 705 |
+
/\.(png|jpg|jpeg|gif|svg|webp)$/i.test(name);
|
| 706 |
+
const src =
|
| 707 |
+
artifact.data_url ||
|
| 708 |
+
artifact.url ||
|
| 709 |
+
(artifact.content_b64 && artifact.mime
|
| 710 |
+
? `data:${artifact.mime};base64,${artifact.content_b64}`
|
| 711 |
+
: null);
|
| 712 |
+
|
| 713 |
+
return (
|
| 714 |
+
<li className="canvas-artifact">
|
| 715 |
+
{isImage && src && (
|
| 716 |
+
<img src={src} alt={name} className="canvas-artifact__img" />
|
| 717 |
+
)}
|
| 718 |
+
<div className="canvas-artifact__row">
|
| 719 |
+
<div className="canvas-artifact__icon" aria-hidden="true">
|
| 720 |
+
{isImage ? (
|
| 721 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
| 722 |
+
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
| 723 |
+
strokeLinejoin="round">
|
| 724 |
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
| 725 |
+
<circle cx="8.5" cy="8.5" r="1.5" />
|
| 726 |
+
<polyline points="21 15 16 10 5 21" />
|
| 727 |
+
</svg>
|
| 728 |
+
) : (
|
| 729 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
| 730 |
+
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
| 731 |
+
strokeLinejoin="round">
|
| 732 |
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
| 733 |
+
<polyline points="14 2 14 8 20 8" />
|
| 734 |
+
</svg>
|
| 735 |
+
)}
|
| 736 |
+
</div>
|
| 737 |
+
<code className="canvas-artifact__name">{name}</code>
|
| 738 |
+
{isImage && src && (
|
| 739 |
+
<span className="canvas-artifact__badge">Plot rendered inline</span>
|
| 740 |
+
)}
|
| 741 |
+
{artifact.size != null && (
|
| 742 |
+
<span className="canvas-artifact__meta">
|
| 743 |
+
{typeof artifact.size === "number" ? `${artifact.size} bytes` : artifact.size}
|
| 744 |
+
</span>
|
| 745 |
+
)}
|
| 746 |
+
{src && (
|
| 747 |
+
<a
|
| 748 |
+
href={src}
|
| 749 |
+
download={name}
|
| 750 |
+
className="canvas-artifact__download"
|
| 751 |
+
title="Download artifact"
|
| 752 |
+
>
|
| 753 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
| 754 |
+
stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
| 755 |
+
strokeLinejoin="round">
|
| 756 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
| 757 |
+
<polyline points="7 10 12 15 17 10" />
|
| 758 |
+
<line x1="12" y1="15" x2="12" y2="3" />
|
| 759 |
+
</svg>
|
| 760 |
+
</a>
|
| 761 |
+
)}
|
| 762 |
+
</div>
|
| 763 |
+
</li>
|
| 764 |
+
);
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
// ---------------------------------------------------------------------------
|
| 768 |
+
// MarkdownCanvas — split source / rendered preview. No Run button,
|
| 769 |
+
// no execution plan. Mode switch lets users pick Source / Preview /
|
| 770 |
+
// Split. Renderer is dependency-free (escapes HTML, then formats
|
| 771 |
+
// the patterns README files actually use).
|
| 772 |
+
// ---------------------------------------------------------------------------
|
| 773 |
+
|
| 774 |
+
function MarkdownCanvas({ initialCode, filename, onClose, mode }) {
|
| 775 |
+
const [view, setView] = useState("split"); // "source" | "preview" | "split"
|
| 776 |
+
const html = useMemo(() => renderMarkdown(initialCode || ""), [initialCode]);
|
| 777 |
+
// Inject the rendered-markdown stylesheet once. Cheaper than a
|
| 778 |
+
// styled-component dance and keeps the renderer dependency-free.
|
| 779 |
+
useEffect(() => {
|
| 780 |
+
const id = "gitpilot-md-style";
|
| 781 |
+
if (document.getElementById(id)) return;
|
| 782 |
+
const style = document.createElement("style");
|
| 783 |
+
style.id = id;
|
| 784 |
+
style.textContent = `
|
| 785 |
+
.gp-md h1 { font-size: 22px; margin: 18px 0 8px; padding-bottom: 6px; border-bottom: 1px solid #2c2d46; color: #f4f4f5; }
|
| 786 |
+
.gp-md h2 { font-size: 18px; margin: 16px 0 6px; padding-bottom: 4px; border-bottom: 1px solid #1f2937; color: #f4f4f5; }
|
| 787 |
+
.gp-md h3 { font-size: 15px; margin: 14px 0 4px; color: #f4f4f5; }
|
| 788 |
+
.gp-md h4, .gp-md h5, .gp-md h6 { font-size: 13px; margin: 12px 0 4px; color: #e4e4e7; }
|
| 789 |
+
.gp-md p { margin: 8px 0; color: #d4d4d8; }
|
| 790 |
+
.gp-md ul { margin: 6px 0 6px 18px; padding: 0; color: #d4d4d8; }
|
| 791 |
+
.gp-md li { margin: 3px 0; }
|
| 792 |
+
.gp-md a { color: #93c5fd; }
|
| 793 |
+
.gp-md code { background: #000; color: #86efac; padding: 1px 5px; border-radius: 3px; font-family: ui-monospace, monospace; font-size: 12.5px; }
|
| 794 |
+
.gp-md pre.md-code { background: #000; color: #d4d4d8; padding: 10px 12px; border-radius: 6px; overflow: auto; }
|
| 795 |
+
.gp-md pre.md-code code { background: transparent; color: inherit; padding: 0; }
|
| 796 |
+
.gp-md strong { color: #f4f4f5; }
|
| 797 |
+
`;
|
| 798 |
+
document.head.appendChild(style);
|
| 799 |
+
}, []);
|
| 800 |
+
useEffect(() => {
|
| 801 |
+
const onKey = (e) => { if (e.key === "Escape") onClose?.(); };
|
| 802 |
+
window.addEventListener("keydown", onKey);
|
| 803 |
+
return () => window.removeEventListener("keydown", onKey);
|
| 804 |
+
}, [onClose]);
|
| 805 |
+
|
| 806 |
+
const copy = () => {
|
| 807 |
+
if (navigator?.clipboard) navigator.clipboard.writeText(initialCode || "").catch(() => {});
|
| 808 |
+
};
|
| 809 |
+
|
| 810 |
+
return (
|
| 811 |
+
<div style={s.backdrop} onClick={onClose}>
|
| 812 |
+
<div style={s.modal} onClick={(e) => e.stopPropagation()}>
|
| 813 |
+
<header style={s.header}>
|
| 814 |
+
<div style={s.headerLeft}>
|
| 815 |
+
<span style={s.canvasBadge}>Canvas</span>
|
| 816 |
+
<span style={s.filename}>{filename || "untitled.md"}</span>
|
| 817 |
+
<span style={s.langPill}>{mode.typeLabel}</span>
|
| 818 |
+
</div>
|
| 819 |
+
<div style={s.headerRight}>
|
| 820 |
+
{/* Mode switch — Source / Preview / Split. Default split
|
| 821 |
+
because users often want to compare raw markdown to
|
| 822 |
+
rendered output side-by-side. */}
|
| 823 |
+
<div style={s.segGroup} role="tablist">
|
| 824 |
+
{["source", "preview", "split"].map((v) => (
|
| 825 |
+
<button
|
| 826 |
+
key={v}
|
| 827 |
+
type="button"
|
| 828 |
+
role="tab"
|
| 829 |
+
aria-selected={view === v}
|
| 830 |
+
onClick={() => setView(v)}
|
| 831 |
+
style={view === v ? s.segActive : s.seg}
|
| 832 |
+
>
|
| 833 |
+
{v[0].toUpperCase() + v.slice(1)}
|
| 834 |
+
</button>
|
| 835 |
+
))}
|
| 836 |
+
</div>
|
| 837 |
+
<button type="button" style={s.btn} onClick={copy}>
|
| 838 |
+
Copy
|
| 839 |
+
</button>
|
| 840 |
+
<button type="button" style={s.btnClose} onClick={onClose} aria-label="Close">
|
| 841 |
+
✕
|
| 842 |
+
</button>
|
| 843 |
+
</div>
|
| 844 |
+
</header>
|
| 845 |
+
|
| 846 |
+
<div style={view === "split" ? s.body : s.bodySingle}>
|
| 847 |
+
{(view === "source" || view === "split") && (
|
| 848 |
+
<div style={s.editorPane}>
|
| 849 |
+
<div style={s.paneLabel}>Markdown source</div>
|
| 850 |
+
<pre style={s.mdSource}>{initialCode || ""}</pre>
|
| 851 |
+
</div>
|
| 852 |
+
)}
|
| 853 |
+
{(view === "preview" || view === "split") && (
|
| 854 |
+
<div style={s.outputPane}>
|
| 855 |
+
<div style={s.paneLabel}>Rendered preview</div>
|
| 856 |
+
<div
|
| 857 |
+
className="gp-md"
|
| 858 |
+
style={s.mdRendered}
|
| 859 |
+
// Renderer escapes HTML before formatting, so the
|
| 860 |
+
// injected string is safe. No <script>, no event
|
| 861 |
+
// handlers slip through.
|
| 862 |
+
dangerouslySetInnerHTML={{ __html: html }}
|
| 863 |
+
/>
|
| 864 |
+
</div>
|
| 865 |
+
)}
|
| 866 |
+
</div>
|
| 867 |
+
</div>
|
| 868 |
+
</div>
|
| 869 |
+
);
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
// ---------------------------------------------------------------------------
|
| 873 |
+
// TextCanvas — viewer-only Canvas for non-runnable, non-markdown
|
| 874 |
+
// file types (JSON, YAML, CSV, HTML, plain text, ...). No Run, no
|
| 875 |
+
// plan, no markdown rendering. Useful when a user clicks Open in
|
| 876 |
+
// Canvas on, say, a config file just to read it.
|
| 877 |
+
// ---------------------------------------------------------------------------
|
| 878 |
+
|
| 879 |
+
function TextCanvas({ initialCode, filename, onClose, mode }) {
|
| 880 |
+
useEffect(() => {
|
| 881 |
+
const onKey = (e) => { if (e.key === "Escape") onClose?.(); };
|
| 882 |
+
window.addEventListener("keydown", onKey);
|
| 883 |
+
return () => window.removeEventListener("keydown", onKey);
|
| 884 |
+
}, [onClose]);
|
| 885 |
+
|
| 886 |
+
const copy = () => {
|
| 887 |
+
if (navigator?.clipboard) navigator.clipboard.writeText(initialCode || "").catch(() => {});
|
| 888 |
+
};
|
| 889 |
+
|
| 890 |
+
return (
|
| 891 |
+
<div style={s.backdrop} onClick={onClose}>
|
| 892 |
+
<div style={s.modal} onClick={(e) => e.stopPropagation()}>
|
| 893 |
+
<header style={s.header}>
|
| 894 |
+
<div style={s.headerLeft}>
|
| 895 |
+
<span style={s.canvasBadge}>Canvas</span>
|
| 896 |
+
<span style={s.filename}>{filename || "untitled"}</span>
|
| 897 |
+
<span style={s.langPill}>{mode.typeLabel}</span>
|
| 898 |
+
</div>
|
| 899 |
+
<div style={s.headerRight}>
|
| 900 |
+
<button type="button" style={s.btn} onClick={copy}>
|
| 901 |
+
Copy
|
| 902 |
+
</button>
|
| 903 |
+
<button type="button" style={s.btnClose} onClick={onClose} aria-label="Close">
|
| 904 |
+
✕
|
| 905 |
+
</button>
|
| 906 |
+
</div>
|
| 907 |
+
</header>
|
| 908 |
+
|
| 909 |
+
<div style={s.bodySingle}>
|
| 910 |
+
<div style={s.editorPane}>
|
| 911 |
+
<div style={s.paneLabel}>Source</div>
|
| 912 |
+
<pre style={s.mdSource}>{initialCode || ""}</pre>
|
| 913 |
+
</div>
|
| 914 |
+
</div>
|
| 915 |
+
</div>
|
| 916 |
+
</div>
|
| 917 |
+
);
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
const s = {
|
| 921 |
+
backdrop: {
|
| 922 |
+
position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)",
|
| 923 |
+
display: "flex", alignItems: "center", justifyContent: "center",
|
| 924 |
+
zIndex: 100,
|
| 925 |
+
},
|
| 926 |
+
modal: {
|
| 927 |
+
width: "min(1200px, 96vw)", height: "min(800px, 92vh)",
|
| 928 |
+
background: "#1a1b26", border: "1px solid #2a2b36", borderRadius: 10,
|
| 929 |
+
display: "flex", flexDirection: "column",
|
| 930 |
+
color: "#e6e8ff", overflow: "hidden",
|
| 931 |
+
},
|
| 932 |
+
header: {
|
| 933 |
+
display: "flex", alignItems: "center", justifyContent: "space-between",
|
| 934 |
+
padding: "10px 14px", borderBottom: "1px solid #2a2b36",
|
| 935 |
+
background: "#14152a",
|
| 936 |
+
},
|
| 937 |
+
headerLeft: { display: "flex", alignItems: "center", gap: 10 },
|
| 938 |
+
headerRight: { display: "flex", alignItems: "center", gap: 8 },
|
| 939 |
+
canvasBadge: {
|
| 940 |
+
fontSize: 10, fontWeight: 700, letterSpacing: "0.06em",
|
| 941 |
+
padding: "2px 8px", borderRadius: 4,
|
| 942 |
+
background: "#1e3a5f", color: "#93c5fd", textTransform: "uppercase",
|
| 943 |
+
},
|
| 944 |
+
filename: { fontFamily: "ui-monospace, monospace", fontSize: 13 },
|
| 945 |
+
langPill: {
|
| 946 |
+
fontSize: 11, color: "#9092b5",
|
| 947 |
+
padding: "1px 6px", borderRadius: 4, background: "#0e0f24",
|
| 948 |
+
border: "1px solid #2c2d46",
|
| 949 |
+
},
|
| 950 |
+
btn: {
|
| 951 |
+
padding: "6px 12px", fontSize: 12, fontWeight: 600,
|
| 952 |
+
background: "#3B82F6", color: "#fff",
|
| 953 |
+
border: "none", borderRadius: 6, cursor: "pointer",
|
| 954 |
+
},
|
| 955 |
+
btnClose: {
|
| 956 |
+
padding: "4px 10px", fontSize: 14,
|
| 957 |
+
background: "transparent", color: "#9092b5",
|
| 958 |
+
border: "1px solid #2c2d46", borderRadius: 6, cursor: "pointer",
|
| 959 |
+
},
|
| 960 |
+
body: { flex: 1, display: "grid", gridTemplateColumns: "1fr 1fr", overflow: "hidden" },
|
| 961 |
+
editorPane: {
|
| 962 |
+
display: "flex", flexDirection: "column",
|
| 963 |
+
borderRight: "1px solid #2a2b36", padding: 12, gap: 6,
|
| 964 |
+
},
|
| 965 |
+
outputPane: {
|
| 966 |
+
display: "flex", flexDirection: "column",
|
| 967 |
+
padding: 12, gap: 6, overflow: "auto",
|
| 968 |
+
},
|
| 969 |
+
paneLabel: { fontSize: 11, color: "#9092b5", textTransform: "uppercase", letterSpacing: "0.06em" },
|
| 970 |
+
editor: {
|
| 971 |
+
flex: 1, resize: "none",
|
| 972 |
+
background: "#0d0e17", color: "#e6e8ff",
|
| 973 |
+
border: "1px solid #2c2d46", borderRadius: 6, padding: 10,
|
| 974 |
+
fontFamily: "ui-monospace, SFMono-Regular, monospace", fontSize: 13, lineHeight: 1.5,
|
| 975 |
+
},
|
| 976 |
+
editorFoot: { display: "flex", justifyContent: "space-between", fontSize: 11, color: "#9092b5" },
|
| 977 |
+
shimHint: { color: "#86efac" },
|
| 978 |
+
kbdHint: { color: "#6b7280" },
|
| 979 |
+
empty: { color: "#9092b5", fontSize: 13, padding: "20px 8px", textAlign: "center" },
|
| 980 |
+
resultHead: { display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 8 },
|
| 981 |
+
outputSection: { marginTop: 6 },
|
| 982 |
+
outputLabel: { cursor: "pointer", fontSize: 11, color: "#9092b5" },
|
| 983 |
+
stdout: {
|
| 984 |
+
margin: "4px 0 0", padding: "8px 10px", borderRadius: 4,
|
| 985 |
+
background: "#000", color: "#d4d4d8", fontSize: 12,
|
| 986 |
+
fontFamily: "ui-monospace, monospace", whiteSpace: "pre-wrap",
|
| 987 |
+
},
|
| 988 |
+
stderr: {
|
| 989 |
+
margin: "4px 0 0", padding: "8px 10px", borderRadius: 4,
|
| 990 |
+
background: "#000", color: "#fca5a5", fontSize: 12,
|
| 991 |
+
fontFamily: "ui-monospace, monospace", whiteSpace: "pre-wrap",
|
| 992 |
+
},
|
| 993 |
+
errorBox: {
|
| 994 |
+
padding: "8px 10px", borderRadius: 6,
|
| 995 |
+
background: "#3d1111", border: "1px solid #7f1d1d", color: "#fca5a5", fontSize: 12,
|
| 996 |
+
},
|
| 997 |
+
errorPre: { margin: "4px 0 0", whiteSpace: "pre-wrap" },
|
| 998 |
+
okPill: { padding: "2px 8px", borderRadius: 10, fontSize: 11, fontWeight: 600, background: "#0d3320", color: "#86efac", border: "1px solid #166534" },
|
| 999 |
+
failPill: { padding: "2px 8px", borderRadius: 10, fontSize: 11, fontWeight: 600, background: "#3d1111", color: "#fca5a5", border: "1px solid #7f1d1d" },
|
| 1000 |
+
warnPill: { padding: "2px 8px", borderRadius: 10, fontSize: 11, fontWeight: 600, background: "#3d2d11", color: "#fde68a", border: "1px solid #854d0e" },
|
| 1001 |
+
backendPill: { padding: "2px 8px", borderRadius: 10, fontSize: 11, color: "#c3c5dd", border: "1px solid #2c2d46" },
|
| 1002 |
+
dim: { fontSize: 11, color: "#9092b5" },
|
| 1003 |
+
artifactList: { listStyle: "none", padding: 0, margin: "6px 0 0" },
|
| 1004 |
+
artifactItem: { padding: "3px 0", fontSize: 12 },
|
| 1005 |
+
planStrip: {
|
| 1006 |
+
padding: "8px 12px",
|
| 1007 |
+
background: "#0d0e17",
|
| 1008 |
+
borderBottom: "1px solid #2a2b36",
|
| 1009 |
+
},
|
| 1010 |
+
planErrorStrip: {
|
| 1011 |
+
padding: "8px 12px",
|
| 1012 |
+
fontSize: 12,
|
| 1013 |
+
color: "#fca5a5",
|
| 1014 |
+
background: "#3d1111",
|
| 1015 |
+
borderBottom: "1px solid #7f1d1d",
|
| 1016 |
+
},
|
| 1017 |
+
// Single-pane layout used by MarkdownCanvas (when not in split) and
|
| 1018 |
+
// TextCanvas. Stretches the single child column across the full
|
| 1019 |
+
// modal body width.
|
| 1020 |
+
bodySingle: { flex: 1, display: "grid", gridTemplateColumns: "1fr", overflow: "hidden" },
|
| 1021 |
+
// Source / Preview / Split segmented control.
|
| 1022 |
+
segGroup: { display: "inline-flex", background: "#0d0e17", border: "1px solid #2c2d46", borderRadius: 6, overflow: "hidden" },
|
| 1023 |
+
seg: {
|
| 1024 |
+
padding: "5px 10px", fontSize: 11,
|
| 1025 |
+
background: "transparent", color: "#9092b5",
|
| 1026 |
+
border: "none", cursor: "pointer",
|
| 1027 |
+
},
|
| 1028 |
+
segActive: {
|
| 1029 |
+
padding: "5px 10px", fontSize: 11, fontWeight: 600,
|
| 1030 |
+
background: "#1e3a5f", color: "#fff",
|
| 1031 |
+
border: "none", cursor: "pointer",
|
| 1032 |
+
},
|
| 1033 |
+
// Markdown source pane — read-only preformatted view, distinct from
|
| 1034 |
+
// the editable <textarea> the CodeCanvas uses.
|
| 1035 |
+
mdSource: {
|
| 1036 |
+
flex: 1, margin: 0, padding: 12,
|
| 1037 |
+
background: "#0d0e17", color: "#e6e8ff",
|
| 1038 |
+
border: "1px solid #2c2d46", borderRadius: 6,
|
| 1039 |
+
fontFamily: "ui-monospace, SFMono-Regular, monospace", fontSize: 13, lineHeight: 1.55,
|
| 1040 |
+
whiteSpace: "pre-wrap", wordBreak: "break-word",
|
| 1041 |
+
overflow: "auto",
|
| 1042 |
+
},
|
| 1043 |
+
// Rendered preview pane. Subset of CSS that matches the look of
|
| 1044 |
+
// GitHub's rendered README without pulling in a markdown library.
|
| 1045 |
+
mdRendered: {
|
| 1046 |
+
flex: 1, padding: "8px 16px 24px",
|
| 1047 |
+
background: "#0d0e17", color: "#e6e8ff",
|
| 1048 |
+
border: "1px solid #2c2d46", borderRadius: 6,
|
| 1049 |
+
overflow: "auto",
|
| 1050 |
+
fontSize: 14, lineHeight: 1.6,
|
| 1051 |
+
},
|
| 1052 |
+
};
|
frontend/components/SandboxStatusWidget.jsx
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/SandboxStatusWidget.jsx
|
| 2 |
+
//
|
| 3 |
+
// Always-visible sandbox health pill for the sidebar. Polls
|
| 4 |
+
// /api/sandbox/status every 30s and surfaces:
|
| 5 |
+
//
|
| 6 |
+
// ● MatrixLab Ready (backend up, /health green)
|
| 7 |
+
// ⚠ MatrixLab unavailable (network/timeout on /health)
|
| 8 |
+
// ● Local active (using subprocess by choice)
|
| 9 |
+
//
|
| 10 |
+
// When degraded, offers two one-click recoveries:
|
| 11 |
+
// * Repair — opens Settings → Sandbox to run the install/repair flow
|
| 12 |
+
// * Use Local — flips backend to subprocess via PUT /api/sandbox/config
|
| 13 |
+
//
|
| 14 |
+
// Purely informational: failures here never block the chat / planner.
|
| 15 |
+
|
| 16 |
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
| 17 |
+
|
| 18 |
+
const POLL_MS = 30_000;
|
| 19 |
+
|
| 20 |
+
const BACKEND_LABEL = {
|
| 21 |
+
subprocess: "Local",
|
| 22 |
+
matrixlab: "MatrixLab",
|
| 23 |
+
off: "Pass-through",
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export default function SandboxStatusWidget({ onOpenSettings }) {
|
| 27 |
+
const [status, setStatus] = useState(null);
|
| 28 |
+
const [error, setError] = useState(null);
|
| 29 |
+
const [switching, setSwitching] = useState(false);
|
| 30 |
+
const timerRef = useRef(null);
|
| 31 |
+
|
| 32 |
+
const refresh = useCallback(async () => {
|
| 33 |
+
try {
|
| 34 |
+
const res = await fetch("/api/sandbox/status");
|
| 35 |
+
const data = await res.json();
|
| 36 |
+
if (!res.ok) {
|
| 37 |
+
setError(data.detail || `HTTP ${res.status}`);
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
setStatus(data);
|
| 41 |
+
setError(null);
|
| 42 |
+
} catch (err) {
|
| 43 |
+
setError(err.message || "Unable to reach sandbox status");
|
| 44 |
+
}
|
| 45 |
+
}, []);
|
| 46 |
+
|
| 47 |
+
useEffect(() => {
|
| 48 |
+
refresh();
|
| 49 |
+
timerRef.current = setInterval(refresh, POLL_MS);
|
| 50 |
+
return () => clearInterval(timerRef.current);
|
| 51 |
+
}, [refresh]);
|
| 52 |
+
|
| 53 |
+
const switchToLocal = async () => {
|
| 54 |
+
setSwitching(true);
|
| 55 |
+
try {
|
| 56 |
+
await fetch("/api/sandbox/config", {
|
| 57 |
+
method: "PUT",
|
| 58 |
+
headers: { "Content-Type": "application/json" },
|
| 59 |
+
body: JSON.stringify({ backend: "subprocess" }),
|
| 60 |
+
});
|
| 61 |
+
await refresh();
|
| 62 |
+
} finally {
|
| 63 |
+
setSwitching(false);
|
| 64 |
+
}
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
if (!status && !error) {
|
| 68 |
+
return (
|
| 69 |
+
<div style={s.shell}>
|
| 70 |
+
<div style={s.title}>Sandbox</div>
|
| 71 |
+
<div style={s.dim}>Checking…</div>
|
| 72 |
+
</div>
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
if (error) {
|
| 77 |
+
return (
|
| 78 |
+
<div style={s.shell}>
|
| 79 |
+
<div style={s.title}>Sandbox</div>
|
| 80 |
+
<div style={{ ...s.statusRow, color: "#fca5a5" }}>
|
| 81 |
+
<span>⚠</span> Status check failed
|
| 82 |
+
</div>
|
| 83 |
+
<div style={s.dim}>{error}</div>
|
| 84 |
+
</div>
|
| 85 |
+
);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const backend = status.backend || "subprocess";
|
| 89 |
+
const ok = !!status.ok;
|
| 90 |
+
const label = BACKEND_LABEL[backend] || backend;
|
| 91 |
+
const dot = ok ? "●" : "⚠";
|
| 92 |
+
const dotColor = ok ? "#10B981" : "#f59e0b";
|
| 93 |
+
const stateText = ok ? "Ready" : "Unavailable";
|
| 94 |
+
|
| 95 |
+
return (
|
| 96 |
+
<div style={s.shell}>
|
| 97 |
+
<div style={s.titleRow}>
|
| 98 |
+
<span style={s.title}>Sandbox</span>
|
| 99 |
+
<button type="button" onClick={refresh} style={s.refresh}
|
| 100 |
+
title="Refresh sandbox status">↻</button>
|
| 101 |
+
</div>
|
| 102 |
+
<div style={s.statusRow}>
|
| 103 |
+
<span style={{ color: dotColor, marginRight: 6 }}>{dot}</span>
|
| 104 |
+
<strong>{label}</strong>
|
| 105 |
+
<span style={s.dim}> · {stateText}</span>
|
| 106 |
+
</div>
|
| 107 |
+
{status.matrixlab_url && backend === "matrixlab" && (
|
| 108 |
+
<div style={s.metaRow}>
|
| 109 |
+
<span style={s.metaKey}>URL</span>
|
| 110 |
+
<span style={s.metaVal}>{status.matrixlab_url}</span>
|
| 111 |
+
</div>
|
| 112 |
+
)}
|
| 113 |
+
<div style={s.metaRow}>
|
| 114 |
+
<span style={s.metaKey}>Timeout</span>
|
| 115 |
+
<span style={s.metaVal}>{status.timeout_sec}s</span>
|
| 116 |
+
</div>
|
| 117 |
+
<div style={s.metaRow}>
|
| 118 |
+
<span style={s.metaKey}>Network</span>
|
| 119 |
+
<span style={s.metaVal}>
|
| 120 |
+
{status.allow_network ? "Enabled" : "Disabled"}
|
| 121 |
+
</span>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<div style={s.actions}>
|
| 125 |
+
{onOpenSettings && (
|
| 126 |
+
<button type="button" onClick={onOpenSettings} style={s.action}>
|
| 127 |
+
{ok ? "Change" : "Repair"}
|
| 128 |
+
</button>
|
| 129 |
+
)}
|
| 130 |
+
{!ok && backend === "matrixlab" && (
|
| 131 |
+
<button
|
| 132 |
+
type="button"
|
| 133 |
+
onClick={switchToLocal}
|
| 134 |
+
disabled={switching}
|
| 135 |
+
style={{ ...s.action, opacity: switching ? 0.6 : 1 }}
|
| 136 |
+
title="Switch the sandbox backend to Local subprocess"
|
| 137 |
+
>
|
| 138 |
+
{switching ? "Switching…" : "Use Local"}
|
| 139 |
+
</button>
|
| 140 |
+
)}
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const s = {
|
| 147 |
+
shell: {
|
| 148 |
+
padding: "10px 12px",
|
| 149 |
+
background: "#0d0e17",
|
| 150 |
+
border: "1px solid #1f2937",
|
| 151 |
+
borderRadius: 8,
|
| 152 |
+
fontFamily: "system-ui, sans-serif",
|
| 153 |
+
fontSize: 12,
|
| 154 |
+
color: "#e4e4e7",
|
| 155 |
+
},
|
| 156 |
+
titleRow: { display: "flex", justifyContent: "space-between", alignItems: "center" },
|
| 157 |
+
title: {
|
| 158 |
+
fontSize: 10, fontWeight: 700, letterSpacing: "0.06em",
|
| 159 |
+
color: "#9092b5", textTransform: "uppercase",
|
| 160 |
+
},
|
| 161 |
+
refresh: {
|
| 162 |
+
background: "transparent", color: "#71717a", border: "none",
|
| 163 |
+
cursor: "pointer", fontSize: 14, padding: 0,
|
| 164 |
+
},
|
| 165 |
+
statusRow: { marginTop: 6, display: "flex", alignItems: "center" },
|
| 166 |
+
metaRow: { marginTop: 4, display: "flex", justifyContent: "space-between" },
|
| 167 |
+
metaKey: { color: "#9092b5", fontSize: 11 },
|
| 168 |
+
metaVal: { color: "#d4d4d8", fontSize: 11, fontFamily: "ui-monospace, monospace" },
|
| 169 |
+
actions: { display: "flex", gap: 6, marginTop: 8 },
|
| 170 |
+
action: {
|
| 171 |
+
flex: 1, padding: "4px 8px", fontSize: 11,
|
| 172 |
+
background: "transparent", color: "#a1a1aa",
|
| 173 |
+
border: "1px solid #3F3F46", borderRadius: 4, cursor: "pointer",
|
| 174 |
+
},
|
| 175 |
+
dim: { color: "#71717a", fontSize: 11 },
|
| 176 |
+
};
|