github-actions[bot] commited on
Commit Β·
e4d01aa
0
Parent(s):
Deploy from 900d69de
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 +293 -0
- frontend/.dockerignore +39 -0
- frontend/App.jsx +1173 -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/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 +8 -0
- frontend/components/AssistantMessage.jsx +121 -0
- frontend/components/BranchPicker.jsx +398 -0
- frontend/components/ChatPanel.jsx +719 -0
- frontend/components/ContextBar.jsx +156 -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/FileTree.jsx +307 -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 +231 -0
- frontend/components/ProjectContextPanel.jsx +572 -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/SessionItem.jsx +183 -0
- frontend/components/SessionSidebar.jsx +181 -0
- frontend/components/SettingsModal.jsx +333 -0
- frontend/components/StartupScreen.jsx +92 -0
- frontend/components/StreamingMessage.jsx +182 -0
- frontend/components/UserMenu.jsx +424 -0
- frontend/index.html +12 -0
- frontend/main.jsx +11 -0
- frontend/nginx.conf +58 -0
- frontend/ollabridge.css +222 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +21 -0
- frontend/styles.css +3288 -0
- frontend/utils/api.js +251 -0
- frontend/utils/appInit.js +157 -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,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
|
| 3 |
+
<img src="docs/logo.svg" alt="GitPilot" width="140" />
|
| 4 |
+
|
| 5 |
+
# GitPilot
|
| 6 |
+
|
| 7 |
+
### The open-source AI coding companion your team can actually trust.
|
| 8 |
+
|
| 9 |
+
**Ask. Plan. Code. Ship.** Β· You approve every change.
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
[](https://pypi.org/project/gitcopilot/)
|
| 14 |
+
[](https://www.python.org/)
|
| 15 |
+
[](LICENSE)
|
| 16 |
+
[](https://marketplace.visualstudio.com/)
|
| 17 |
+
[](#contributing)
|
| 18 |
+
|
| 19 |
+
[**Get Started**](#get-started) Β· [VS Code](#vs-code-extension) Β· [Web App](#web-app) Β· [How It Works](#how-it-works) Β· [Providers](#supported-ai-providers)
|
| 20 |
+
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
<p align="center">
|
| 26 |
+
<picture>
|
| 27 |
+
<source srcset="docs/assets/flow.svg" type="image/svg+xml" />
|
| 28 |
+
<img src="docs/assets/flow.png" alt="GitPilot loop: Ask, Plan, Code, Ship β you approve every change." width="900" />
|
| 29 |
+
</picture>
|
| 30 |
+
</p>
|
| 31 |
+
|
| 32 |
+
## Why GitPilot?
|
| 33 |
+
|
| 34 |
+
GitPilot is the AI pair programmer built for teams that take code seriously. It reads your repository, drafts a safe plan, writes the code, runs your tests β and **waits for your approval before touching a single file**. No surprises, no silent commits, no lock-in.
|
| 35 |
+
|
| 36 |
+
- π§ **Works where you work** β the same experience in VS Code, on the web, and from the terminal. One login, one history, one set of approvals.
|
| 37 |
+
- π **Safe by default** β every file edit, shell command, and git operation asks for permission first. Diffs are shown before they're applied, tests run before anything is committed.
|
| 38 |
+
- π§ **Your model, your rules** β drop in OpenAI, Anthropic Claude, IBM Watsonx, Ollama (local) or OllaBridge (free cloud). Switch providers in settings without changing a line of code.
|
| 39 |
+
- π’ **Enterprise-ready, open source** β MIT licensed, 854 passing tests, Docker & Hugging Face deployment recipes, no telemetry, no vendor lock-in.
|
| 40 |
+
- π **Runs anywhere** β your laptop, your private cloud, air-gapped environments, or a managed host. Your repo stays your repo.
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## What is GitPilot?
|
| 45 |
+
|
| 46 |
+
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.
|
| 47 |
+
|
| 48 |
+
**Works with any language. Runs on any LLM.** Start free and local with Ollama, or bring your own OpenAI, Claude, or Watsonx key.
|
| 49 |
+
|
| 50 |
+
```
|
| 51 |
+
You: "Add input validation to the login form"
|
| 52 |
+
|
| 53 |
+
GitPilot:
|
| 54 |
+
1. Reading src/auth/login.ts...
|
| 55 |
+
2. Planning 3 changes...
|
| 56 |
+
3. Editing login.ts (Allow? [Yes] [No])
|
| 57 |
+
4. Running npm test... 3 passed
|
| 58 |
+
5. Done.
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
## Get Started
|
| 64 |
+
|
| 65 |
+
### Option 1: VS Code Extension (recommended)
|
| 66 |
+
|
| 67 |
+
Install the extension, configure your LLM, and start chatting:
|
| 68 |
+
|
| 69 |
+
```
|
| 70 |
+
1. Open VS Code
|
| 71 |
+
2. Install "GitPilot Workspace" from Extensions
|
| 72 |
+
3. Click the GitPilot icon in the sidebar
|
| 73 |
+
4. Choose your AI provider (OpenAI, Claude, Ollama...)
|
| 74 |
+
5. Start asking questions about your code
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
### Option 2: Web App
|
| 78 |
+
|
| 79 |
+
Run the full web interface with Docker:
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
git clone https://github.com/ruslanmv/gitpilot.git
|
| 83 |
+
cd gitpilot
|
| 84 |
+
docker compose up
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
| 88 |
+
|
| 89 |
+
### Option 3: Python CLI (fastest)
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
pip install gitcopilot
|
| 93 |
+
gitpilot serve
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
Open [http://localhost:8000](http://localhost:8000) and you're done.
|
| 97 |
+
|
| 98 |
+
> **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.
|
| 99 |
+
|
| 100 |
+
---
|
| 101 |
+
|
| 102 |
+
## VS Code Extension
|
| 103 |
+
|
| 104 |
+
The sidebar panel gives you everything in one place:
|
| 105 |
+
|
| 106 |
+
| Feature | What it does |
|
| 107 |
+
|---|---|
|
| 108 |
+
| **Chat** | Ask questions, request changes, review code |
|
| 109 |
+
| **Plan View** | See the step-by-step plan before changes are made |
|
| 110 |
+
| **Diff Preview** | Review proposed edits in VS Code's native diff viewer |
|
| 111 |
+
| **Apply / Revert** | One click to apply changes, one click to undo |
|
| 112 |
+
| **Quick Actions** | Explain, Review, Fix, Generate Tests, Security Scan |
|
| 113 |
+
| **Smart Commit** | AI-generated commit messages |
|
| 114 |
+
| **Code Lens** | Inline "Explain / Review" hints on functions |
|
| 115 |
+
|
| 116 |
+
### Supported AI Providers
|
| 117 |
+
|
| 118 |
+
| Provider | Setup | Free? |
|
| 119 |
+
|---|---|---|
|
| 120 |
+
| **Ollama** | Install Ollama, run `ollama pull llama3` | Yes |
|
| 121 |
+
| **OllaBridge** | Works out of the box (cloud Ollama) | Yes |
|
| 122 |
+
| **OpenAI** | Add your API key in settings | Paid |
|
| 123 |
+
| **Claude** | Add your Anthropic API key | Paid |
|
| 124 |
+
| **Watsonx** | Add IBM credentials | Paid |
|
| 125 |
+
|
| 126 |
+
---
|
| 127 |
+
|
| 128 |
+
## Web App
|
| 129 |
+
|
| 130 |
+
The web interface includes:
|
| 131 |
+
|
| 132 |
+
- Chat with real-time responses
|
| 133 |
+
- GitHub integration (connect your repos)
|
| 134 |
+
- File tree browser
|
| 135 |
+
- Diff viewer with line-by-line changes
|
| 136 |
+
- Pull request creation
|
| 137 |
+
- Session history with checkpoints
|
| 138 |
+
- Multi-repo support
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
## How It Works
|
| 143 |
+
|
| 144 |
+
<p align="center">
|
| 145 |
+
<picture>
|
| 146 |
+
<source srcset="docs/assets/architecture.svg" type="image/svg+xml" />
|
| 147 |
+
<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%" />
|
| 148 |
+
</picture>
|
| 149 |
+
</p>
|
| 150 |
+
|
| 151 |
+
GitPilot uses a multi-agent system powered by CrewAI:
|
| 152 |
+
|
| 153 |
+
1. **Explorer** reads your repo structure, git log, and key files
|
| 154 |
+
2. **Planner** creates a safe step-by-step plan with diffs
|
| 155 |
+
3. **Executor** writes code and runs tests, self-correcting on failure
|
| 156 |
+
4. **Reviewer** validates the output and summarises what changed
|
| 157 |
+
|
| 158 |
+
You approve every change before it's applied.
|
| 159 |
+
|
| 160 |
+
---
|
| 161 |
+
|
| 162 |
+
## Project Structure
|
| 163 |
+
|
| 164 |
+
```
|
| 165 |
+
gitpilot/
|
| 166 |
+
gitpilot/ Python backend (FastAPI)
|
| 167 |
+
frontend/ React web app
|
| 168 |
+
extensions/vscode/ VS Code extension
|
| 169 |
+
docs/ Documentation and assets
|
| 170 |
+
tests/ Test suite
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
---
|
| 174 |
+
|
| 175 |
+
## Configuration
|
| 176 |
+
|
| 177 |
+
GitPilot works with environment variables or the settings UI.
|
| 178 |
+
|
| 179 |
+
**Minimal setup** (Ollama, free, local):
|
| 180 |
+
|
| 181 |
+
```bash
|
| 182 |
+
# .env
|
| 183 |
+
GITPILOT_PROVIDER=ollama
|
| 184 |
+
OLLAMA_BASE_URL=http://localhost:11434
|
| 185 |
+
GITPILOT_OLLAMA_MODEL=llama3
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
**Cloud setup** (OpenAI):
|
| 189 |
+
|
| 190 |
+
```bash
|
| 191 |
+
# .env
|
| 192 |
+
GITPILOT_PROVIDER=openai
|
| 193 |
+
OPENAI_API_KEY=sk-...
|
| 194 |
+
GITPILOT_OPENAI_MODEL=gpt-4o-mini
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
**Cloud setup** (Claude):
|
| 198 |
+
|
| 199 |
+
```bash
|
| 200 |
+
# .env
|
| 201 |
+
GITPILOT_PROVIDER=claude
|
| 202 |
+
ANTHROPIC_API_KEY=sk-ant-...
|
| 203 |
+
GITPILOT_CLAUDE_MODEL=claude-sonnet-4-5
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
All settings can also be changed from the VS Code extension or web UI without editing files.
|
| 207 |
+
|
| 208 |
+
---
|
| 209 |
+
|
| 210 |
+
## API
|
| 211 |
+
|
| 212 |
+
GitPilot exposes a REST + WebSocket API:
|
| 213 |
+
|
| 214 |
+
| Endpoint | What it does |
|
| 215 |
+
|---|---|
|
| 216 |
+
| `GET /api/status` | Server health check |
|
| 217 |
+
| `POST /api/chat/send` | Send a message, get a response |
|
| 218 |
+
| `POST /api/v2/chat/stream` | Stream agent events (SSE) |
|
| 219 |
+
| `WS /ws/v2/sessions/{id}` | Real-time WebSocket streaming |
|
| 220 |
+
| `POST /api/chat/plan` | Generate an execution plan |
|
| 221 |
+
| `POST /api/chat/execute` | Execute a plan |
|
| 222 |
+
| `GET /api/repos` | List connected repositories |
|
| 223 |
+
| `GET /api/sessions` | List chat sessions |
|
| 224 |
+
|
| 225 |
+
Full API docs at `http://localhost:8000/docs` (Swagger UI).
|
| 226 |
+
|
| 227 |
+
---
|
| 228 |
+
|
| 229 |
+
## Deployment
|
| 230 |
+
|
| 231 |
+
### Hugging Face Spaces <p>
|
| 232 |
+
<a href="https://huggingface.co/spaces/ruslanmv/gitpilot">
|
| 233 |
+
<img src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg" alt="Hugging Face Space" width="28" />
|
| 234 |
+
</a>
|
| 235 |
+
</p>
|
| 236 |
+
|
| 237 |
+
GitPilot runs on Hugging Face Spaces with OllaBridge (free):
|
| 238 |
+
|
| 239 |
+
```
|
| 240 |
+
Runtime: Docker
|
| 241 |
+
Port: 7860
|
| 242 |
+
Provider: OllaBridge (cloud Ollama)
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
### Docker Compose
|
| 246 |
+
|
| 247 |
+
```bash
|
| 248 |
+
docker compose up -d
|
| 249 |
+
# Backend: http://localhost:8000
|
| 250 |
+
# Frontend: http://localhost:3000
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
### Vercel
|
| 254 |
+
|
| 255 |
+
The frontend deploys to Vercel. Set `VITE_BACKEND_URL` to your backend.
|
| 256 |
+
|
| 257 |
+
---
|
| 258 |
+
|
| 259 |
+
## Contributing
|
| 260 |
+
|
| 261 |
+
```bash
|
| 262 |
+
# Backend
|
| 263 |
+
cd gitpilot
|
| 264 |
+
pip install -e ".[dev]"
|
| 265 |
+
pytest
|
| 266 |
+
|
| 267 |
+
# Frontend
|
| 268 |
+
cd frontend
|
| 269 |
+
npm install
|
| 270 |
+
npm run dev
|
| 271 |
+
|
| 272 |
+
# VS Code Extension
|
| 273 |
+
cd extensions/vscode
|
| 274 |
+
npm install
|
| 275 |
+
make compile
|
| 276 |
+
# Press F5 in VS Code to launch debug host
|
| 277 |
+
```
|
| 278 |
+
|
| 279 |
+
---
|
| 280 |
+
|
| 281 |
+
## License
|
| 282 |
+
|
| 283 |
+
MIT License. See [LICENSE](LICENSE).
|
| 284 |
+
|
| 285 |
+
---
|
| 286 |
+
|
| 287 |
+
<div align="center">
|
| 288 |
+
|
| 289 |
+
**GitPilot** is made by [Ruslan Magana Vsevolodovna](https://github.com/ruslanmv)
|
| 290 |
+
|
| 291 |
+
[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)
|
| 292 |
+
|
| 293 |
+
</div>
|
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,1173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
SkillsTab,
|
| 21 |
+
SessionsTab,
|
| 22 |
+
AdvancedTab,
|
| 23 |
+
} from "./components/AdminTabs";
|
| 24 |
+
import { apiUrl, safeFetchJSON, fetchStatus } from "./utils/api.js";
|
| 25 |
+
import { initApp } from "./utils/appInit.js";
|
| 26 |
+
|
| 27 |
+
function makeRepoKey(repo) {
|
| 28 |
+
if (!repo) return null;
|
| 29 |
+
return repo.full_name || `${repo.owner}/${repo.name}`;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function uniq(arr) {
|
| 33 |
+
return Array.from(new Set((arr || []).filter(Boolean)));
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function getProviderLabel(status) {
|
| 37 |
+
if (!status) return "Checking...";
|
| 38 |
+
return (
|
| 39 |
+
status?.provider?.name ||
|
| 40 |
+
status?.provider_name ||
|
| 41 |
+
status?.provider?.provider ||
|
| 42 |
+
"Checking..."
|
| 43 |
+
);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function getBackendVersion(status) {
|
| 47 |
+
if (!status) return "Checking...";
|
| 48 |
+
return status?.version || status?.app_version || "Checking...";
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export default function App() {
|
| 52 |
+
const frontendVersion = __APP_VERSION__ || "unknown";
|
| 53 |
+
|
| 54 |
+
// ---- Multi-repo context state ----
|
| 55 |
+
const [contextRepos, setContextRepos] = useState([]);
|
| 56 |
+
// Each entry: { repoKey: "owner/repo", repo: {...}, branch: "main" }
|
| 57 |
+
const [activeRepoKey, setActiveRepoKey] = useState(null);
|
| 58 |
+
const [addRepoOpen, setAddRepoOpen] = useState(false);
|
| 59 |
+
|
| 60 |
+
const [activePage, setActivePage] = useState("workspace");
|
| 61 |
+
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
| 62 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 63 |
+
const [userInfo, setUserInfo] = useState(null);
|
| 64 |
+
|
| 65 |
+
// Startup / enterprise loader state
|
| 66 |
+
const [startupPhase, setStartupPhase] = useState("booting");
|
| 67 |
+
const [startupStatusMessage, setStartupStatusMessage] = useState("Starting application...");
|
| 68 |
+
const [startupDetailMessage, setStartupDetailMessage] = useState(
|
| 69 |
+
"Initializing authentication, provider, and workspace context."
|
| 70 |
+
);
|
| 71 |
+
const [startupStatusSnapshot, setStartupStatusSnapshot] = useState(null);
|
| 72 |
+
|
| 73 |
+
// Repo + Session State Machine
|
| 74 |
+
const [repoStateByKey, setRepoStateByKey] = useState({});
|
| 75 |
+
const [toast, setToast] = useState(null);
|
| 76 |
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 77 |
+
const [aboutOpen, setAboutOpen] = useState(false);
|
| 78 |
+
const [adminTab, setAdminTab] = useState("overview");
|
| 79 |
+
const [adminStatus, setAdminStatus] = useState(null);
|
| 80 |
+
|
| 81 |
+
// Fetch admin status when overview tab is active
|
| 82 |
+
useEffect(() => {
|
| 83 |
+
if (activePage === "admin" && adminTab === "overview") {
|
| 84 |
+
fetchStatus()
|
| 85 |
+
.then((data) => setAdminStatus(data))
|
| 86 |
+
.catch(() => setAdminStatus(null));
|
| 87 |
+
}
|
| 88 |
+
}, [activePage, adminTab]);
|
| 89 |
+
|
| 90 |
+
// Claude-Code-on-Web: Session sidebar + Environment state
|
| 91 |
+
const [activeSessionId, setActiveSessionId] = useState(null);
|
| 92 |
+
const [activeEnvId, setActiveEnvId] = useState("default");
|
| 93 |
+
const [sessionRefreshNonce, setSessionRefreshNonce] = useState(0);
|
| 94 |
+
|
| 95 |
+
// Sidebar collapse state (persisted in localStorage)
|
| 96 |
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
| 97 |
+
try {
|
| 98 |
+
return localStorage.getItem("gitpilot_sidebar_collapsed") === "true";
|
| 99 |
+
} catch {
|
| 100 |
+
return false;
|
| 101 |
+
}
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
const toggleSidebar = useCallback(() => {
|
| 105 |
+
setSidebarCollapsed((prev) => {
|
| 106 |
+
const next = !prev;
|
| 107 |
+
try {
|
| 108 |
+
localStorage.setItem("gitpilot_sidebar_collapsed", String(next));
|
| 109 |
+
} catch {}
|
| 110 |
+
return next;
|
| 111 |
+
});
|
| 112 |
+
}, []);
|
| 113 |
+
|
| 114 |
+
// Keyboard shortcut: Cmd/Ctrl + B to toggle sidebar
|
| 115 |
+
useEffect(() => {
|
| 116 |
+
const handler = (e) => {
|
| 117 |
+
if ((e.metaKey || e.ctrlKey) && e.key === "b") {
|
| 118 |
+
e.preventDefault();
|
| 119 |
+
toggleSidebar();
|
| 120 |
+
}
|
| 121 |
+
};
|
| 122 |
+
window.addEventListener("keydown", handler);
|
| 123 |
+
return () => window.removeEventListener("keydown", handler);
|
| 124 |
+
}, [toggleSidebar]);
|
| 125 |
+
|
| 126 |
+
// ---- Derived `repo` β keeps all downstream consumers unchanged ----
|
| 127 |
+
const repo = useMemo(() => {
|
| 128 |
+
const entry = contextRepos.find((r) => r.repoKey === activeRepoKey);
|
| 129 |
+
return entry?.repo || null;
|
| 130 |
+
}, [contextRepos, activeRepoKey]);
|
| 131 |
+
|
| 132 |
+
const repoKey = activeRepoKey;
|
| 133 |
+
|
| 134 |
+
// Convenient selectors
|
| 135 |
+
const currentRepoState = repoKey ? repoStateByKey[repoKey] : null;
|
| 136 |
+
|
| 137 |
+
const defaultBranch = currentRepoState?.defaultBranch || repo?.default_branch || "main";
|
| 138 |
+
const currentBranch = currentRepoState?.currentBranch || defaultBranch;
|
| 139 |
+
const sessionBranches = currentRepoState?.sessionBranches || [];
|
| 140 |
+
const lastExecution = currentRepoState?.lastExecution || null;
|
| 141 |
+
const pulseNonce = currentRepoState?.pulseNonce || 0;
|
| 142 |
+
const chatByBranch = currentRepoState?.chatByBranch || {};
|
| 143 |
+
|
| 144 |
+
// ---------------------------------------------------------------------------
|
| 145 |
+
// Multi-repo context management
|
| 146 |
+
// ---------------------------------------------------------------------------
|
| 147 |
+
const addRepoToContext = useCallback((r) => {
|
| 148 |
+
const key = makeRepoKey(r);
|
| 149 |
+
if (!key) return;
|
| 150 |
+
|
| 151 |
+
setContextRepos((prev) => {
|
| 152 |
+
if (prev.some((e) => e.repoKey === key)) {
|
| 153 |
+
setActiveRepoKey(key);
|
| 154 |
+
return prev;
|
| 155 |
+
}
|
| 156 |
+
const entry = { repoKey: key, repo: r, branch: r.default_branch || "main" };
|
| 157 |
+
return [...prev, entry];
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
setActiveRepoKey(key);
|
| 161 |
+
setAddRepoOpen(false);
|
| 162 |
+
}, []);
|
| 163 |
+
|
| 164 |
+
const removeRepoFromContext = useCallback((key) => {
|
| 165 |
+
setContextRepos((prev) => {
|
| 166 |
+
const next = prev.filter((e) => e.repoKey !== key);
|
| 167 |
+
setActiveRepoKey((curActive) => {
|
| 168 |
+
if (curActive === key) {
|
| 169 |
+
return next.length > 0 ? next[0].repoKey : null;
|
| 170 |
+
}
|
| 171 |
+
return curActive;
|
| 172 |
+
});
|
| 173 |
+
return next;
|
| 174 |
+
});
|
| 175 |
+
}, []);
|
| 176 |
+
|
| 177 |
+
const clearAllContext = useCallback(() => {
|
| 178 |
+
setContextRepos([]);
|
| 179 |
+
setActiveRepoKey(null);
|
| 180 |
+
}, []);
|
| 181 |
+
|
| 182 |
+
const handleContextBranchChange = useCallback((targetRepoKey, newBranch) => {
|
| 183 |
+
setContextRepos((prev) =>
|
| 184 |
+
prev.map((e) =>
|
| 185 |
+
e.repoKey === targetRepoKey ? { ...e, branch: newBranch } : e
|
| 186 |
+
)
|
| 187 |
+
);
|
| 188 |
+
|
| 189 |
+
setRepoStateByKey((prev) => {
|
| 190 |
+
const cur = prev[targetRepoKey];
|
| 191 |
+
if (!cur) return prev;
|
| 192 |
+
return {
|
| 193 |
+
...prev,
|
| 194 |
+
[targetRepoKey]: { ...cur, currentBranch: newBranch },
|
| 195 |
+
};
|
| 196 |
+
});
|
| 197 |
+
}, []);
|
| 198 |
+
|
| 199 |
+
// Init / reconcile repo state when active repo changes
|
| 200 |
+
useEffect(() => {
|
| 201 |
+
if (!repoKey || !repo) return;
|
| 202 |
+
|
| 203 |
+
setRepoStateByKey((prev) => {
|
| 204 |
+
const existing = prev[repoKey];
|
| 205 |
+
const d = repo.default_branch || "main";
|
| 206 |
+
|
| 207 |
+
if (!existing) {
|
| 208 |
+
return {
|
| 209 |
+
...prev,
|
| 210 |
+
[repoKey]: {
|
| 211 |
+
defaultBranch: d,
|
| 212 |
+
currentBranch: d,
|
| 213 |
+
sessionBranches: [],
|
| 214 |
+
lastExecution: null,
|
| 215 |
+
pulseNonce: 0,
|
| 216 |
+
chatByBranch: {
|
| 217 |
+
[d]: { messages: [], plan: null },
|
| 218 |
+
},
|
| 219 |
+
},
|
| 220 |
+
};
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
const next = { ...existing };
|
| 224 |
+
next.defaultBranch = d;
|
| 225 |
+
|
| 226 |
+
if (!next.chatByBranch?.[d]) {
|
| 227 |
+
next.chatByBranch = {
|
| 228 |
+
...(next.chatByBranch || {}),
|
| 229 |
+
[d]: { messages: [], plan: null },
|
| 230 |
+
};
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
if (!next.currentBranch) next.currentBranch = d;
|
| 234 |
+
|
| 235 |
+
return { ...prev, [repoKey]: next };
|
| 236 |
+
});
|
| 237 |
+
}, [repoKey, repo?.id, repo?.default_branch]);
|
| 238 |
+
|
| 239 |
+
const showToast = (title, message) => {
|
| 240 |
+
setToast({ title, message });
|
| 241 |
+
window.setTimeout(() => setToast(null), 5000);
|
| 242 |
+
};
|
| 243 |
+
|
| 244 |
+
// ---------------------------------------------------------------------------
|
| 245 |
+
// Session management β every chat is backed by a Session (Claude Code parity)
|
| 246 |
+
// ---------------------------------------------------------------------------
|
| 247 |
+
|
| 248 |
+
const _creatingSessionRef = useRef(false);
|
| 249 |
+
|
| 250 |
+
const [chatBySession, setChatBySession] = useState({});
|
| 251 |
+
|
| 252 |
+
const ensureSession = useCallback(
|
| 253 |
+
async (sessionName, seedMessages) => {
|
| 254 |
+
if (activeSessionId) return activeSessionId;
|
| 255 |
+
if (!repo) return null;
|
| 256 |
+
if (_creatingSessionRef.current) return null;
|
| 257 |
+
_creatingSessionRef.current = true;
|
| 258 |
+
|
| 259 |
+
try {
|
| 260 |
+
const token = localStorage.getItem("github_token");
|
| 261 |
+
const headers = {
|
| 262 |
+
"Content-Type": "application/json",
|
| 263 |
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
| 264 |
+
};
|
| 265 |
+
|
| 266 |
+
const res = await fetch("/api/sessions", {
|
| 267 |
+
method: "POST",
|
| 268 |
+
headers,
|
| 269 |
+
body: JSON.stringify({
|
| 270 |
+
repo_full_name: repoKey,
|
| 271 |
+
branch: currentBranch,
|
| 272 |
+
name: sessionName || undefined,
|
| 273 |
+
repos: contextRepos.map((e) => ({
|
| 274 |
+
full_name: e.repoKey,
|
| 275 |
+
branch: e.branch,
|
| 276 |
+
mode: e.repoKey === activeRepoKey ? "write" : "read",
|
| 277 |
+
})),
|
| 278 |
+
active_repo: activeRepoKey,
|
| 279 |
+
}),
|
| 280 |
+
});
|
| 281 |
+
|
| 282 |
+
if (!res.ok) return null;
|
| 283 |
+
const data = await res.json();
|
| 284 |
+
const newId = data.session_id;
|
| 285 |
+
|
| 286 |
+
if (seedMessages && seedMessages.length > 0) {
|
| 287 |
+
setChatBySession((prev) => ({
|
| 288 |
+
...prev,
|
| 289 |
+
[newId]: { messages: seedMessages, plan: null },
|
| 290 |
+
}));
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
setActiveSessionId(newId);
|
| 294 |
+
setSessionRefreshNonce((n) => n + 1);
|
| 295 |
+
return newId;
|
| 296 |
+
} catch (err) {
|
| 297 |
+
console.warn("Failed to create session:", err);
|
| 298 |
+
return null;
|
| 299 |
+
} finally {
|
| 300 |
+
_creatingSessionRef.current = false;
|
| 301 |
+
}
|
| 302 |
+
},
|
| 303 |
+
[activeSessionId, repo, repoKey, currentBranch, contextRepos, activeRepoKey]
|
| 304 |
+
);
|
| 305 |
+
|
| 306 |
+
const handleNewSession = async () => {
|
| 307 |
+
setActiveSessionId(null);
|
| 308 |
+
|
| 309 |
+
try {
|
| 310 |
+
const token = localStorage.getItem("github_token");
|
| 311 |
+
const headers = {
|
| 312 |
+
"Content-Type": "application/json",
|
| 313 |
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
| 314 |
+
};
|
| 315 |
+
|
| 316 |
+
const res = await fetch("/api/sessions", {
|
| 317 |
+
method: "POST",
|
| 318 |
+
headers,
|
| 319 |
+
body: JSON.stringify({
|
| 320 |
+
repo_full_name: repoKey,
|
| 321 |
+
branch: currentBranch,
|
| 322 |
+
repos: contextRepos.map((e) => ({
|
| 323 |
+
full_name: e.repoKey,
|
| 324 |
+
branch: e.branch,
|
| 325 |
+
mode: e.repoKey === activeRepoKey ? "write" : "read",
|
| 326 |
+
})),
|
| 327 |
+
active_repo: activeRepoKey,
|
| 328 |
+
}),
|
| 329 |
+
});
|
| 330 |
+
|
| 331 |
+
if (!res.ok) return;
|
| 332 |
+
const data = await res.json();
|
| 333 |
+
setActiveSessionId(data.session_id);
|
| 334 |
+
setSessionRefreshNonce((n) => n + 1);
|
| 335 |
+
showToast("Session Created", "New session started.");
|
| 336 |
+
} catch (err) {
|
| 337 |
+
console.warn("Failed to create session:", err);
|
| 338 |
+
}
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
/**
|
| 342 |
+
* Convert a backend Message object to the frontend chat UI shape.
|
| 343 |
+
* Backend: { role: "user|assistant|system", content: "...", timestamp, metadata }
|
| 344 |
+
* Frontend: { from: "user|ai", role: "user|assistant|system", content, answer, ... }
|
| 345 |
+
*/
|
| 346 |
+
const normalizeBackendMessage = (m) => {
|
| 347 |
+
const role = m.role || "assistant";
|
| 348 |
+
const content = m.content || "";
|
| 349 |
+
if (role === "user") {
|
| 350 |
+
return { from: "user", role: "user", content, text: content };
|
| 351 |
+
}
|
| 352 |
+
if (role === "system") {
|
| 353 |
+
return { from: "ai", role: "system", content };
|
| 354 |
+
}
|
| 355 |
+
// assistant
|
| 356 |
+
return {
|
| 357 |
+
from: "ai",
|
| 358 |
+
role: "assistant",
|
| 359 |
+
content,
|
| 360 |
+
answer: content,
|
| 361 |
+
// Preserve any structured metadata the backend stored (plan, diff, etc.)
|
| 362 |
+
...(m.metadata && typeof m.metadata === "object" ? m.metadata : {}),
|
| 363 |
+
};
|
| 364 |
+
};
|
| 365 |
+
|
| 366 |
+
/**
|
| 367 |
+
* Fetch persisted messages for a session from the backend.
|
| 368 |
+
* Returns an array of normalized frontend messages (ready for ChatPanel),
|
| 369 |
+
* or an empty array on failure.
|
| 370 |
+
*/
|
| 371 |
+
const fetchSessionMessages = useCallback(async (sessionId) => {
|
| 372 |
+
if (!sessionId) return [];
|
| 373 |
+
try {
|
| 374 |
+
const token = localStorage.getItem("github_token");
|
| 375 |
+
const headers = { "Content-Type": "application/json" };
|
| 376 |
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
| 377 |
+
|
| 378 |
+
const res = await fetch(apiUrl(`/api/sessions/${sessionId}/messages`), {
|
| 379 |
+
headers,
|
| 380 |
+
});
|
| 381 |
+
if (!res.ok) {
|
| 382 |
+
console.warn(`[fetchSessionMessages] ${res.status} for ${sessionId}`);
|
| 383 |
+
return [];
|
| 384 |
+
}
|
| 385 |
+
const data = await res.json();
|
| 386 |
+
const backendMessages = Array.isArray(data.messages) ? data.messages : [];
|
| 387 |
+
return backendMessages.map(normalizeBackendMessage);
|
| 388 |
+
} catch (err) {
|
| 389 |
+
console.warn(`[fetchSessionMessages] Failed to fetch ${sessionId}:`, err);
|
| 390 |
+
return [];
|
| 391 |
+
}
|
| 392 |
+
}, []);
|
| 393 |
+
|
| 394 |
+
/**
|
| 395 |
+
* Handle click on a session in the sidebar.
|
| 396 |
+
*
|
| 397 |
+
* Critical ordering: we must hydrate chatBySession BEFORE setting
|
| 398 |
+
* activeSessionId, because ChatPanel's session-sync useEffect reads
|
| 399 |
+
* sessionChatState only when sessionId changes (it does NOT depend on
|
| 400 |
+
* chatBySession to avoid prop/state loops). If we set activeSessionId
|
| 401 |
+
* first, ChatPanel would see an empty messages array, then our async
|
| 402 |
+
* hydration would complete but ChatPanel wouldn't re-sync.
|
| 403 |
+
*/
|
| 404 |
+
const handleSelectSession = useCallback(async (session) => {
|
| 405 |
+
// 1. Fetch persisted messages first
|
| 406 |
+
const messages = await fetchSessionMessages(session.id);
|
| 407 |
+
|
| 408 |
+
// 2. Seed the chat cache (ChatPanel will read this via sessionChatState)
|
| 409 |
+
setChatBySession((prev) => ({
|
| 410 |
+
...prev,
|
| 411 |
+
[session.id]: {
|
| 412 |
+
...(prev[session.id] || { plan: null }),
|
| 413 |
+
messages,
|
| 414 |
+
},
|
| 415 |
+
}));
|
| 416 |
+
|
| 417 |
+
// 3. NOW activate the session β ChatPanel's sync effect will read
|
| 418 |
+
// the hydrated messages from chatBySession[session.id]
|
| 419 |
+
setActiveSessionId(session.id);
|
| 420 |
+
if (session.branch && session.branch !== currentBranch) {
|
| 421 |
+
handleBranchChange(session.branch);
|
| 422 |
+
}
|
| 423 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 424 |
+
}, [fetchSessionMessages, currentBranch]);
|
| 425 |
+
|
| 426 |
+
const handleDeleteSession = useCallback(
|
| 427 |
+
(deletedId) => {
|
| 428 |
+
if (deletedId === activeSessionId) {
|
| 429 |
+
setActiveSessionId(null);
|
| 430 |
+
|
| 431 |
+
setChatBySession((prev) => {
|
| 432 |
+
const next = { ...prev };
|
| 433 |
+
delete next[deletedId];
|
| 434 |
+
return next;
|
| 435 |
+
});
|
| 436 |
+
|
| 437 |
+
if (repoKey) {
|
| 438 |
+
setRepoStateByKey((prev) => {
|
| 439 |
+
const cur = prev[repoKey];
|
| 440 |
+
if (!cur) return prev;
|
| 441 |
+
const branchKey = cur.currentBranch || cur.defaultBranch || defaultBranch;
|
| 442 |
+
return {
|
| 443 |
+
...prev,
|
| 444 |
+
[repoKey]: {
|
| 445 |
+
...cur,
|
| 446 |
+
chatByBranch: {
|
| 447 |
+
...(cur.chatByBranch || {}),
|
| 448 |
+
[branchKey]: { messages: [], plan: null },
|
| 449 |
+
},
|
| 450 |
+
},
|
| 451 |
+
};
|
| 452 |
+
});
|
| 453 |
+
}
|
| 454 |
+
}
|
| 455 |
+
},
|
| 456 |
+
[activeSessionId, repoKey, defaultBranch]
|
| 457 |
+
);
|
| 458 |
+
|
| 459 |
+
// ---------------------------------------------------------------------------
|
| 460 |
+
// Chat persistence helpers
|
| 461 |
+
// ---------------------------------------------------------------------------
|
| 462 |
+
const updateChatForCurrentBranch = (patch) => {
|
| 463 |
+
if (!repoKey) return;
|
| 464 |
+
|
| 465 |
+
setRepoStateByKey((prev) => {
|
| 466 |
+
const cur = prev[repoKey];
|
| 467 |
+
if (!cur) return prev;
|
| 468 |
+
|
| 469 |
+
const branchKey = cur.currentBranch || cur.defaultBranch || defaultBranch;
|
| 470 |
+
|
| 471 |
+
const existing = cur.chatByBranch?.[branchKey] || {
|
| 472 |
+
messages: [],
|
| 473 |
+
plan: null,
|
| 474 |
+
};
|
| 475 |
+
|
| 476 |
+
return {
|
| 477 |
+
...prev,
|
| 478 |
+
[repoKey]: {
|
| 479 |
+
...cur,
|
| 480 |
+
chatByBranch: {
|
| 481 |
+
...(cur.chatByBranch || {}),
|
| 482 |
+
[branchKey]: { ...existing, ...patch },
|
| 483 |
+
},
|
| 484 |
+
},
|
| 485 |
+
};
|
| 486 |
+
});
|
| 487 |
+
};
|
| 488 |
+
|
| 489 |
+
const currentChatState = useMemo(() => {
|
| 490 |
+
const b = currentBranch || defaultBranch;
|
| 491 |
+
return chatByBranch[b] || { messages: [], plan: null };
|
| 492 |
+
}, [chatByBranch, currentBranch, defaultBranch]);
|
| 493 |
+
|
| 494 |
+
const sessionChatState = useMemo(() => {
|
| 495 |
+
if (!activeSessionId) {
|
| 496 |
+
return currentChatState;
|
| 497 |
+
}
|
| 498 |
+
return chatBySession[activeSessionId] || { messages: [], plan: null };
|
| 499 |
+
}, [activeSessionId, chatBySession, currentChatState]);
|
| 500 |
+
|
| 501 |
+
const updateSessionChat = (patch) => {
|
| 502 |
+
if (activeSessionId) {
|
| 503 |
+
setChatBySession((prev) => ({
|
| 504 |
+
...prev,
|
| 505 |
+
[activeSessionId]: {
|
| 506 |
+
...(prev[activeSessionId] || { messages: [], plan: null }),
|
| 507 |
+
...patch,
|
| 508 |
+
},
|
| 509 |
+
}));
|
| 510 |
+
} else {
|
| 511 |
+
updateChatForCurrentBranch(patch);
|
| 512 |
+
}
|
| 513 |
+
};
|
| 514 |
+
|
| 515 |
+
// ---------------------------------------------------------------------------
|
| 516 |
+
// Branch change (manual β for active repo)
|
| 517 |
+
// ---------------------------------------------------------------------------
|
| 518 |
+
const handleBranchChange = (nextBranch) => {
|
| 519 |
+
if (!repoKey) return;
|
| 520 |
+
if (!nextBranch || nextBranch === currentBranch) return;
|
| 521 |
+
|
| 522 |
+
setRepoStateByKey((prev) => {
|
| 523 |
+
const cur = prev[repoKey];
|
| 524 |
+
if (!cur) return prev;
|
| 525 |
+
|
| 526 |
+
const nextState = { ...cur, currentBranch: nextBranch };
|
| 527 |
+
|
| 528 |
+
if (nextBranch === cur.defaultBranch) {
|
| 529 |
+
nextState.chatByBranch = {
|
| 530 |
+
...nextState.chatByBranch,
|
| 531 |
+
[nextBranch]: { messages: [], plan: null },
|
| 532 |
+
};
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
return { ...prev, [repoKey]: nextState };
|
| 536 |
+
});
|
| 537 |
+
|
| 538 |
+
setContextRepos((prev) =>
|
| 539 |
+
prev.map((e) =>
|
| 540 |
+
e.repoKey === repoKey ? { ...e, branch: nextBranch } : e
|
| 541 |
+
)
|
| 542 |
+
);
|
| 543 |
+
|
| 544 |
+
if (nextBranch === defaultBranch) {
|
| 545 |
+
showToast("New Session", `Switched to ${defaultBranch}. Chat cleared.`);
|
| 546 |
+
} else {
|
| 547 |
+
showToast("Context Switched", `Now viewing ${nextBranch}.`);
|
| 548 |
+
}
|
| 549 |
+
};
|
| 550 |
+
|
| 551 |
+
// ---------------------------------------------------------------------------
|
| 552 |
+
// Execution complete
|
| 553 |
+
// ---------------------------------------------------------------------------
|
| 554 |
+
const handleExecutionComplete = ({
|
| 555 |
+
branch,
|
| 556 |
+
mode,
|
| 557 |
+
commit_url,
|
| 558 |
+
completionMsg,
|
| 559 |
+
sourceBranch,
|
| 560 |
+
}) => {
|
| 561 |
+
if (!repoKey || !branch) return;
|
| 562 |
+
|
| 563 |
+
setRepoStateByKey((prev) => {
|
| 564 |
+
const cur =
|
| 565 |
+
prev[repoKey] || {
|
| 566 |
+
defaultBranch,
|
| 567 |
+
currentBranch: defaultBranch,
|
| 568 |
+
sessionBranches: [],
|
| 569 |
+
lastExecution: null,
|
| 570 |
+
pulseNonce: 0,
|
| 571 |
+
chatByBranch: { [defaultBranch]: { messages: [], plan: null } },
|
| 572 |
+
};
|
| 573 |
+
|
| 574 |
+
const next = { ...cur };
|
| 575 |
+
next.lastExecution = { mode, branch, ts: Date.now() };
|
| 576 |
+
|
| 577 |
+
if (!next.chatByBranch) next.chatByBranch = {};
|
| 578 |
+
|
| 579 |
+
const prevBranchKey =
|
| 580 |
+
sourceBranch || cur.currentBranch || cur.defaultBranch || defaultBranch;
|
| 581 |
+
|
| 582 |
+
const successSystemMsg = {
|
| 583 |
+
role: "system",
|
| 584 |
+
isSuccess: true,
|
| 585 |
+
link: commit_url,
|
| 586 |
+
content:
|
| 587 |
+
mode === "hard-switch"
|
| 588 |
+
? `π± **Session Started:** Created branch \`${branch}\`.`
|
| 589 |
+
: `β
**Update Published:** Commits pushed to \`${branch}\`.`,
|
| 590 |
+
};
|
| 591 |
+
|
| 592 |
+
const normalizedCompletion =
|
| 593 |
+
completionMsg &&
|
| 594 |
+
(completionMsg.answer || completionMsg.content || completionMsg.executionLog)
|
| 595 |
+
? {
|
| 596 |
+
from: completionMsg.from || "ai",
|
| 597 |
+
role: completionMsg.role || "assistant",
|
| 598 |
+
answer: completionMsg.answer,
|
| 599 |
+
content: completionMsg.content,
|
| 600 |
+
executionLog: completionMsg.executionLog,
|
| 601 |
+
}
|
| 602 |
+
: null;
|
| 603 |
+
|
| 604 |
+
if (mode === "hard-switch") {
|
| 605 |
+
next.sessionBranches = uniq([...(next.sessionBranches || []), branch]);
|
| 606 |
+
next.currentBranch = branch;
|
| 607 |
+
next.pulseNonce = (next.pulseNonce || 0) + 1;
|
| 608 |
+
|
| 609 |
+
const existingTargetChat = next.chatByBranch[branch];
|
| 610 |
+
const isExistingSession =
|
| 611 |
+
existingTargetChat && (existingTargetChat.messages || []).length > 0;
|
| 612 |
+
|
| 613 |
+
if (isExistingSession) {
|
| 614 |
+
const appended = [
|
| 615 |
+
...(existingTargetChat.messages || []),
|
| 616 |
+
...(normalizedCompletion ? [normalizedCompletion] : []),
|
| 617 |
+
successSystemMsg,
|
| 618 |
+
];
|
| 619 |
+
|
| 620 |
+
next.chatByBranch[branch] = {
|
| 621 |
+
...existingTargetChat,
|
| 622 |
+
messages: appended,
|
| 623 |
+
plan: null,
|
| 624 |
+
};
|
| 625 |
+
} else {
|
| 626 |
+
const prevChat =
|
| 627 |
+
(cur.chatByBranch && cur.chatByBranch[prevBranchKey]) || {
|
| 628 |
+
messages: [],
|
| 629 |
+
plan: null,
|
| 630 |
+
};
|
| 631 |
+
|
| 632 |
+
next.chatByBranch[branch] = {
|
| 633 |
+
messages: [
|
| 634 |
+
...(prevChat.messages || []),
|
| 635 |
+
...(normalizedCompletion ? [normalizedCompletion] : []),
|
| 636 |
+
successSystemMsg,
|
| 637 |
+
],
|
| 638 |
+
plan: null,
|
| 639 |
+
};
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
if (!next.chatByBranch[next.defaultBranch]) {
|
| 643 |
+
next.chatByBranch[next.defaultBranch] = { messages: [], plan: null };
|
| 644 |
+
}
|
| 645 |
+
} else if (mode === "sticky") {
|
| 646 |
+
next.currentBranch = cur.currentBranch || branch;
|
| 647 |
+
|
| 648 |
+
const targetChat = next.chatByBranch[branch] || { messages: [], plan: null };
|
| 649 |
+
|
| 650 |
+
next.chatByBranch[branch] = {
|
| 651 |
+
messages: [
|
| 652 |
+
...(targetChat.messages || []),
|
| 653 |
+
...(normalizedCompletion ? [normalizedCompletion] : []),
|
| 654 |
+
successSystemMsg,
|
| 655 |
+
],
|
| 656 |
+
plan: null,
|
| 657 |
+
};
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
return { ...prev, [repoKey]: next };
|
| 661 |
+
});
|
| 662 |
+
|
| 663 |
+
if (mode === "hard-switch") {
|
| 664 |
+
showToast("Context Switched", `Active on ${branch}.`);
|
| 665 |
+
} else {
|
| 666 |
+
showToast("Changes Committed", `Updated ${branch}.`);
|
| 667 |
+
}
|
| 668 |
+
};
|
| 669 |
+
|
| 670 |
+
// ---------------------------------------------------------------------------
|
| 671 |
+
// Auth & startup render
|
| 672 |
+
// ---------------------------------------------------------------------------
|
| 673 |
+
useEffect(() => {
|
| 674 |
+
checkAuthentication();
|
| 675 |
+
}, []);
|
| 676 |
+
|
| 677 |
+
const checkAuthentication = async () => {
|
| 678 |
+
setStartupPhase("booting");
|
| 679 |
+
setStartupStatusMessage("Starting application...");
|
| 680 |
+
setStartupDetailMessage(
|
| 681 |
+
"Initializing authentication, provider, and workspace context."
|
| 682 |
+
);
|
| 683 |
+
|
| 684 |
+
try {
|
| 685 |
+
setStartupPhase("checking-backend");
|
| 686 |
+
setStartupStatusMessage("Connecting to backend...");
|
| 687 |
+
setStartupDetailMessage(
|
| 688 |
+
"Waiting for the server to be ready. This may take a few seconds on first start."
|
| 689 |
+
);
|
| 690 |
+
|
| 691 |
+
// Single-source-of-truth init: combines /api/status + /api/auth/status
|
| 692 |
+
// in one request. Runs exactly once per page load (StrictMode-safe).
|
| 693 |
+
const initResult = await initApp();
|
| 694 |
+
const status = initResult.status;
|
| 695 |
+
if (status) {
|
| 696 |
+
setStartupStatusSnapshot(status);
|
| 697 |
+
setAdminStatus(status);
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
const token = localStorage.getItem("github_token");
|
| 701 |
+
const user = localStorage.getItem("github_user");
|
| 702 |
+
|
| 703 |
+
if (token && user) {
|
| 704 |
+
setStartupPhase("validating-auth");
|
| 705 |
+
setStartupStatusMessage("Validating authentication...");
|
| 706 |
+
setStartupDetailMessage(
|
| 707 |
+
"Restoring your GitHub session and confirming access."
|
| 708 |
+
);
|
| 709 |
+
|
| 710 |
+
try {
|
| 711 |
+
const data = await safeFetchJSON(apiUrl("/api/auth/validate"), {
|
| 712 |
+
method: "POST",
|
| 713 |
+
headers: { "Content-Type": "application/json" },
|
| 714 |
+
body: JSON.stringify({ access_token: token }),
|
| 715 |
+
timeout: 20000, // 20s β first-load GitHub API validation can be slow
|
| 716 |
+
});
|
| 717 |
+
|
| 718 |
+
if (data.authenticated) {
|
| 719 |
+
setStartupPhase("restoring-session");
|
| 720 |
+
setStartupStatusMessage("Restoring workspace...");
|
| 721 |
+
setStartupDetailMessage(
|
| 722 |
+
"Loading user profile, reconnecting provider state, and preparing the workspace."
|
| 723 |
+
);
|
| 724 |
+
|
| 725 |
+
setIsAuthenticated(true);
|
| 726 |
+
setUserInfo(JSON.parse(user));
|
| 727 |
+
setIsLoading(false);
|
| 728 |
+
return;
|
| 729 |
+
}
|
| 730 |
+
} catch (err) {
|
| 731 |
+
console.error(err);
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
localStorage.removeItem("github_token");
|
| 735 |
+
localStorage.removeItem("github_user");
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
setStartupPhase("ready");
|
| 739 |
+
setStartupStatusMessage("Preparing sign-in...");
|
| 740 |
+
setStartupDetailMessage(
|
| 741 |
+
"GitPilot is ready. Please authenticate to continue."
|
| 742 |
+
);
|
| 743 |
+
|
| 744 |
+
setIsAuthenticated(false);
|
| 745 |
+
setIsLoading(false);
|
| 746 |
+
} catch (err) {
|
| 747 |
+
console.error(err);
|
| 748 |
+
setStartupPhase("fallback");
|
| 749 |
+
setStartupStatusMessage("Starting application...");
|
| 750 |
+
setStartupDetailMessage(
|
| 751 |
+
"Continuing with basic startup while backend status is still loading."
|
| 752 |
+
);
|
| 753 |
+
setIsAuthenticated(false);
|
| 754 |
+
setIsLoading(false);
|
| 755 |
+
}
|
| 756 |
+
};
|
| 757 |
+
|
| 758 |
+
const handleAuthenticated = (session) => {
|
| 759 |
+
setIsAuthenticated(true);
|
| 760 |
+
setUserInfo(session.user);
|
| 761 |
+
};
|
| 762 |
+
|
| 763 |
+
const handleLogout = () => {
|
| 764 |
+
localStorage.removeItem("github_token");
|
| 765 |
+
localStorage.removeItem("github_user");
|
| 766 |
+
setIsAuthenticated(false);
|
| 767 |
+
setUserInfo(null);
|
| 768 |
+
clearAllContext();
|
| 769 |
+
};
|
| 770 |
+
|
| 771 |
+
if (isLoading) {
|
| 772 |
+
return (
|
| 773 |
+
<StartupScreen
|
| 774 |
+
appName="GitPilot"
|
| 775 |
+
subtitle="Enterprise Workspace Copilot"
|
| 776 |
+
frontendVersion={frontendVersion}
|
| 777 |
+
backendVersion={getBackendVersion(startupStatusSnapshot)}
|
| 778 |
+
provider={getProviderLabel(startupStatusSnapshot)}
|
| 779 |
+
statusMessage={startupStatusMessage}
|
| 780 |
+
detailMessage={startupDetailMessage}
|
| 781 |
+
phase={startupPhase}
|
| 782 |
+
/>
|
| 783 |
+
);
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
if (!isAuthenticated) {
|
| 787 |
+
return (
|
| 788 |
+
<LoginPage
|
| 789 |
+
onAuthenticated={handleAuthenticated}
|
| 790 |
+
backendReady={!!startupStatusSnapshot}
|
| 791 |
+
/>
|
| 792 |
+
);
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
const hasContext = contextRepos.length > 0;
|
| 796 |
+
|
| 797 |
+
return (
|
| 798 |
+
<div className="app-root">
|
| 799 |
+
<div className="main-wrapper">
|
| 800 |
+
<aside className={`sidebar${sidebarCollapsed ? " sidebar--collapsed" : ""}`}>
|
| 801 |
+
<div
|
| 802 |
+
className="sidebar-top-row"
|
| 803 |
+
>
|
| 804 |
+
<div
|
| 805 |
+
className="logo-row"
|
| 806 |
+
onClick={sidebarCollapsed ? toggleSidebar : undefined}
|
| 807 |
+
style={sidebarCollapsed ? { cursor: "pointer" } : undefined}
|
| 808 |
+
>
|
| 809 |
+
<div className="logo-square">GP</div>
|
| 810 |
+
{!sidebarCollapsed && (
|
| 811 |
+
<div>
|
| 812 |
+
<div className="logo-title">GitPilot</div>
|
| 813 |
+
<div className="logo-subtitle">Agentic GitHub Copilot</div>
|
| 814 |
+
</div>
|
| 815 |
+
)}
|
| 816 |
+
</div>
|
| 817 |
+
|
| 818 |
+
{!sidebarCollapsed && (
|
| 819 |
+
<button
|
| 820 |
+
className="sidebar-toggle-btn"
|
| 821 |
+
onClick={toggleSidebar}
|
| 822 |
+
title="Collapse sidebar (Ctrl+B)"
|
| 823 |
+
>
|
| 824 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
| 825 |
+
<path
|
| 826 |
+
d="M10 3L5 8L10 13"
|
| 827 |
+
stroke="currentColor"
|
| 828 |
+
strokeWidth="1.5"
|
| 829 |
+
strokeLinecap="round"
|
| 830 |
+
strokeLinejoin="round"
|
| 831 |
+
/>
|
| 832 |
+
</svg>
|
| 833 |
+
</button>
|
| 834 |
+
)}
|
| 835 |
+
</div>
|
| 836 |
+
|
| 837 |
+
<div className="main-nav">
|
| 838 |
+
<button
|
| 839 |
+
className={"nav-btn" + (activePage === "workspace" ? " nav-btn-active" : "")}
|
| 840 |
+
onClick={() => setActivePage("workspace")}
|
| 841 |
+
title="Workspace"
|
| 842 |
+
>
|
| 843 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
| 844 |
+
<rect x="2" y="2" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.3" />
|
| 845 |
+
<rect x="9" y="2" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.3" />
|
| 846 |
+
<rect x="2" y="9" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.3" />
|
| 847 |
+
<rect x="9" y="9" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.3" />
|
| 848 |
+
</svg>
|
| 849 |
+
{!sidebarCollapsed && <span>Workspace</span>}
|
| 850 |
+
</button>
|
| 851 |
+
|
| 852 |
+
<button
|
| 853 |
+
className={"nav-btn" + (activePage === "flow" ? " nav-btn-active" : "")}
|
| 854 |
+
onClick={() => setActivePage("flow")}
|
| 855 |
+
title="Agent Workflow"
|
| 856 |
+
>
|
| 857 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
| 858 |
+
<circle cx="4" cy="4" r="2" stroke="currentColor" strokeWidth="1.3" />
|
| 859 |
+
<circle cx="12" cy="4" r="2" stroke="currentColor" strokeWidth="1.3" />
|
| 860 |
+
<circle cx="8" cy="12" r="2" stroke="currentColor" strokeWidth="1.3" />
|
| 861 |
+
<path d="M5.5 5.5L7 10.5" stroke="currentColor" strokeWidth="1.3" />
|
| 862 |
+
<path d="M10.5 5.5L9 10.5" stroke="currentColor" strokeWidth="1.3" />
|
| 863 |
+
</svg>
|
| 864 |
+
{!sidebarCollapsed && <span>Agent Workflow</span>}
|
| 865 |
+
</button>
|
| 866 |
+
|
| 867 |
+
<button
|
| 868 |
+
className={"nav-btn" + (activePage === "admin" ? " nav-btn-active" : "")}
|
| 869 |
+
onClick={() => setActivePage("admin")}
|
| 870 |
+
title="Admin"
|
| 871 |
+
>
|
| 872 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
| 873 |
+
<path
|
| 874 |
+
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"
|
| 875 |
+
stroke="currentColor"
|
| 876 |
+
strokeWidth="1.3"
|
| 877 |
+
strokeLinejoin="round"
|
| 878 |
+
/>
|
| 879 |
+
<circle cx="8" cy="6" r="1.5" stroke="currentColor" strokeWidth="1.3" />
|
| 880 |
+
</svg>
|
| 881 |
+
{!sidebarCollapsed && <span>Admin</span>}
|
| 882 |
+
</button>
|
| 883 |
+
</div>
|
| 884 |
+
|
| 885 |
+
{!sidebarCollapsed && (
|
| 886 |
+
<>
|
| 887 |
+
{!hasContext && (
|
| 888 |
+
<RepoSelector onSelect={(r) => addRepoToContext(r)} />
|
| 889 |
+
)}
|
| 890 |
+
|
| 891 |
+
{repo && (
|
| 892 |
+
<SessionSidebar
|
| 893 |
+
repo={repo}
|
| 894 |
+
activeSessionId={activeSessionId}
|
| 895 |
+
onSelectSession={handleSelectSession}
|
| 896 |
+
onNewSession={handleNewSession}
|
| 897 |
+
onDeleteSession={handleDeleteSession}
|
| 898 |
+
refreshNonce={sessionRefreshNonce}
|
| 899 |
+
/>
|
| 900 |
+
)}
|
| 901 |
+
</>
|
| 902 |
+
)}
|
| 903 |
+
|
| 904 |
+
{userInfo && (
|
| 905 |
+
<div className="user-profile">
|
| 906 |
+
<UserMenu
|
| 907 |
+
userInfo={userInfo}
|
| 908 |
+
sidebarCollapsed={sidebarCollapsed}
|
| 909 |
+
onOpenSettings={() => {
|
| 910 |
+
setActivePage("admin");
|
| 911 |
+
setAdminTab("advanced");
|
| 912 |
+
}}
|
| 913 |
+
onOpenAbout={() => setAboutOpen(true)}
|
| 914 |
+
onLogout={handleLogout}
|
| 915 |
+
/>
|
| 916 |
+
</div>
|
| 917 |
+
)}
|
| 918 |
+
</aside>
|
| 919 |
+
|
| 920 |
+
<main className="workspace">
|
| 921 |
+
{activePage === "admin" && (
|
| 922 |
+
<div style={{ padding: "24px", maxWidth: "960px", margin: "0 auto" }}>
|
| 923 |
+
<div style={{ display: "flex", gap: "8px", marginBottom: "24px", flexWrap: "wrap" }}>
|
| 924 |
+
{["overview", "providers", "workspace-modes", "integrations", "sessions", "skills", "security", "advanced"].map((tab) => (
|
| 925 |
+
<button
|
| 926 |
+
key={tab}
|
| 927 |
+
onClick={() => setAdminTab(tab)}
|
| 928 |
+
style={{
|
| 929 |
+
padding: "8px 16px",
|
| 930 |
+
borderRadius: "6px",
|
| 931 |
+
border: adminTab === tab ? "1px solid #3B82F6" : "1px solid #333",
|
| 932 |
+
background: adminTab === tab ? "#1e3a5f" : "#1a1b26",
|
| 933 |
+
color: adminTab === tab ? "#93c5fd" : "#a0a0b0",
|
| 934 |
+
cursor: "pointer",
|
| 935 |
+
fontSize: "13px",
|
| 936 |
+
textTransform: "capitalize",
|
| 937 |
+
}}
|
| 938 |
+
>
|
| 939 |
+
{tab.replace("-", " ")}
|
| 940 |
+
</button>
|
| 941 |
+
))}
|
| 942 |
+
</div>
|
| 943 |
+
|
| 944 |
+
{adminTab === "overview" && (
|
| 945 |
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "16px" }}>
|
| 946 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 947 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Server</div>
|
| 948 |
+
<div style={{ fontSize: "16px", fontWeight: 600 }}>
|
| 949 |
+
{adminStatus?.server_ready ? "Connected" : "Checking..."}
|
| 950 |
+
</div>
|
| 951 |
+
<div style={{ fontSize: "12px", opacity: 0.5 }}>127.0.0.1:8000</div>
|
| 952 |
+
</div>
|
| 953 |
+
|
| 954 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 955 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Provider</div>
|
| 956 |
+
<div style={{ fontSize: "16px", fontWeight: 600 }}>
|
| 957 |
+
{adminStatus?.provider?.name || "Loading..."}
|
| 958 |
+
</div>
|
| 959 |
+
<div style={{ fontSize: "12px", opacity: 0.5 }}>
|
| 960 |
+
{adminStatus?.provider?.configured
|
| 961 |
+
? `${adminStatus.provider.model || "Ready"}`
|
| 962 |
+
: "Not configured"}
|
| 963 |
+
</div>
|
| 964 |
+
</div>
|
| 965 |
+
|
| 966 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 967 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Workspace Modes</div>
|
| 968 |
+
<div style={{ fontSize: "12px" }}>
|
| 969 |
+
Folder: {adminStatus?.workspace?.folder_mode_available ? "Yes" : "β"}
|
| 970 |
+
</div>
|
| 971 |
+
<div style={{ fontSize: "12px" }}>
|
| 972 |
+
Local Git: {adminStatus?.workspace?.local_git_available ? "Yes" : "β"}
|
| 973 |
+
</div>
|
| 974 |
+
<div style={{ fontSize: "12px" }}>
|
| 975 |
+
GitHub: {adminStatus?.workspace?.github_mode_available ? "Yes" : "Optional"}
|
| 976 |
+
</div>
|
| 977 |
+
</div>
|
| 978 |
+
|
| 979 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 980 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>GitHub</div>
|
| 981 |
+
<div style={{ fontSize: "14px" }}>
|
| 982 |
+
{adminStatus?.github?.connected ? "Connected" : "Optional"}
|
| 983 |
+
</div>
|
| 984 |
+
<div style={{ fontSize: "12px", opacity: 0.5 }}>
|
| 985 |
+
{adminStatus?.github?.username || "Not linked"}
|
| 986 |
+
</div>
|
| 987 |
+
</div>
|
| 988 |
+
|
| 989 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 990 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Sessions</div>
|
| 991 |
+
<div style={{ fontSize: "14px" }}>β</div>
|
| 992 |
+
</div>
|
| 993 |
+
|
| 994 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 995 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Get Started</div>
|
| 996 |
+
<button
|
| 997 |
+
onClick={() => setAdminTab("providers")}
|
| 998 |
+
style={{
|
| 999 |
+
padding: "6px 12px",
|
| 1000 |
+
background: "#3B82F6",
|
| 1001 |
+
color: "#fff",
|
| 1002 |
+
border: "none",
|
| 1003 |
+
borderRadius: "4px",
|
| 1004 |
+
cursor: "pointer",
|
| 1005 |
+
fontSize: "12px",
|
| 1006 |
+
marginRight: "4px",
|
| 1007 |
+
}}
|
| 1008 |
+
>
|
| 1009 |
+
Configure Provider
|
| 1010 |
+
</button>
|
| 1011 |
+
</div>
|
| 1012 |
+
</div>
|
| 1013 |
+
)}
|
| 1014 |
+
|
| 1015 |
+
{adminTab === "providers" && (
|
| 1016 |
+
<div>
|
| 1017 |
+
<h3 style={{ marginBottom: "16px" }}>AI Providers</h3>
|
| 1018 |
+
<LlmSettings />
|
| 1019 |
+
</div>
|
| 1020 |
+
)}
|
| 1021 |
+
|
| 1022 |
+
{adminTab === "workspace-modes" && (
|
| 1023 |
+
<WorkspaceModesTab
|
| 1024 |
+
showToast={showToast}
|
| 1025 |
+
onSessionStarted={(result) => {
|
| 1026 |
+
setActiveSessionId(result.session_id);
|
| 1027 |
+
setSessionRefreshNonce((n) => n + 1);
|
| 1028 |
+
setActivePage("workspace");
|
| 1029 |
+
}}
|
| 1030 |
+
/>
|
| 1031 |
+
)}
|
| 1032 |
+
|
| 1033 |
+
{adminTab === "integrations" && (
|
| 1034 |
+
<IntegrationsTab
|
| 1035 |
+
userInfo={userInfo}
|
| 1036 |
+
onDisconnect={handleLogout}
|
| 1037 |
+
showToast={showToast}
|
| 1038 |
+
/>
|
| 1039 |
+
)}
|
| 1040 |
+
|
| 1041 |
+
{adminTab === "security" && (
|
| 1042 |
+
<SecurityTab showToast={showToast} />
|
| 1043 |
+
)}
|
| 1044 |
+
|
| 1045 |
+
{adminTab === "sessions" && (
|
| 1046 |
+
<SessionsTab
|
| 1047 |
+
showToast={showToast}
|
| 1048 |
+
onSelectSession={(s) => {
|
| 1049 |
+
handleSelectSession(s);
|
| 1050 |
+
setActivePage("workspace");
|
| 1051 |
+
}}
|
| 1052 |
+
/>
|
| 1053 |
+
)}
|
| 1054 |
+
|
| 1055 |
+
{adminTab === "skills" && <SkillsTab showToast={showToast} />}
|
| 1056 |
+
|
| 1057 |
+
{adminTab === "advanced" && (
|
| 1058 |
+
<AdvancedTab
|
| 1059 |
+
showToast={showToast}
|
| 1060 |
+
onOpenFullSettings={() => setSettingsOpen(true)}
|
| 1061 |
+
/>
|
| 1062 |
+
)}
|
| 1063 |
+
</div>
|
| 1064 |
+
)}
|
| 1065 |
+
|
| 1066 |
+
{activePage === "flow" && <FlowViewer />}
|
| 1067 |
+
|
| 1068 |
+
{activePage === "workspace" &&
|
| 1069 |
+
(repo ? (
|
| 1070 |
+
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
| 1071 |
+
<ContextBar
|
| 1072 |
+
contextRepos={contextRepos}
|
| 1073 |
+
activeRepoKey={activeRepoKey}
|
| 1074 |
+
repoStateByKey={repoStateByKey}
|
| 1075 |
+
onActivate={setActiveRepoKey}
|
| 1076 |
+
onRemove={removeRepoFromContext}
|
| 1077 |
+
onAdd={() => setAddRepoOpen(true)}
|
| 1078 |
+
onBranchChange={handleContextBranchChange}
|
| 1079 |
+
/>
|
| 1080 |
+
|
| 1081 |
+
<div className="workspace-grid" style={{ flex: 1 }}>
|
| 1082 |
+
<aside className="gp-context-column">
|
| 1083 |
+
<ProjectContextPanel
|
| 1084 |
+
repo={repo}
|
| 1085 |
+
defaultBranch={defaultBranch}
|
| 1086 |
+
currentBranch={currentBranch}
|
| 1087 |
+
sessionBranches={sessionBranches}
|
| 1088 |
+
onBranchChange={handleBranchChange}
|
| 1089 |
+
pulseNonce={pulseNonce}
|
| 1090 |
+
lastExecution={lastExecution}
|
| 1091 |
+
onSettingsClick={() => setSettingsOpen(true)}
|
| 1092 |
+
/>
|
| 1093 |
+
</aside>
|
| 1094 |
+
|
| 1095 |
+
<main className="gp-chat-column">
|
| 1096 |
+
<div className="panel-header">
|
| 1097 |
+
<span>GitPilot chat</span>
|
| 1098 |
+
</div>
|
| 1099 |
+
|
| 1100 |
+
<ChatPanel
|
| 1101 |
+
repo={repo}
|
| 1102 |
+
defaultBranch={defaultBranch}
|
| 1103 |
+
currentBranch={currentBranch}
|
| 1104 |
+
onExecutionComplete={handleExecutionComplete}
|
| 1105 |
+
sessionChatState={sessionChatState}
|
| 1106 |
+
onSessionChatStateChange={updateSessionChat}
|
| 1107 |
+
sessionId={activeSessionId}
|
| 1108 |
+
onEnsureSession={ensureSession}
|
| 1109 |
+
/>
|
| 1110 |
+
</main>
|
| 1111 |
+
</div>
|
| 1112 |
+
</div>
|
| 1113 |
+
) : (
|
| 1114 |
+
<div className="empty-state">
|
| 1115 |
+
<div className="empty-bot">π€</div>
|
| 1116 |
+
<h1>Select a repository</h1>
|
| 1117 |
+
<p>Select a repo to begin agentic workflow.</p>
|
| 1118 |
+
</div>
|
| 1119 |
+
))}
|
| 1120 |
+
</main>
|
| 1121 |
+
</div>
|
| 1122 |
+
|
| 1123 |
+
<Footer />
|
| 1124 |
+
|
| 1125 |
+
{repo && (
|
| 1126 |
+
<ProjectSettingsModal
|
| 1127 |
+
owner={repo.full_name?.split("/")[0] || repo.owner}
|
| 1128 |
+
repo={repo.full_name?.split("/")[1] || repo.name}
|
| 1129 |
+
isOpen={settingsOpen}
|
| 1130 |
+
onClose={() => setSettingsOpen(false)}
|
| 1131 |
+
activeEnvId={activeEnvId}
|
| 1132 |
+
onEnvChange={setActiveEnvId}
|
| 1133 |
+
/>
|
| 1134 |
+
)}
|
| 1135 |
+
|
| 1136 |
+
<AddRepoModal
|
| 1137 |
+
isOpen={addRepoOpen}
|
| 1138 |
+
onSelect={addRepoToContext}
|
| 1139 |
+
onClose={() => setAddRepoOpen(false)}
|
| 1140 |
+
excludeKeys={contextRepos.map((e) => e.repoKey)}
|
| 1141 |
+
/>
|
| 1142 |
+
|
| 1143 |
+
<AboutModal
|
| 1144 |
+
isOpen={aboutOpen}
|
| 1145 |
+
onClose={() => setAboutOpen(false)}
|
| 1146 |
+
/>
|
| 1147 |
+
|
| 1148 |
+
{toast && (
|
| 1149 |
+
<div className="toast-notification">
|
| 1150 |
+
<div style={{ fontSize: 12, fontWeight: 700 }}>{toast.title}</div>
|
| 1151 |
+
<div style={{ fontSize: 12, opacity: 0.82 }}>{toast.message}</div>
|
| 1152 |
+
</div>
|
| 1153 |
+
)}
|
| 1154 |
+
|
| 1155 |
+
<style>{`
|
| 1156 |
+
.toast-notification {
|
| 1157 |
+
position: fixed;
|
| 1158 |
+
top: 72px;
|
| 1159 |
+
right: 18px;
|
| 1160 |
+
z-index: 9999;
|
| 1161 |
+
background: #0b0b0d;
|
| 1162 |
+
color: #EDEDED;
|
| 1163 |
+
border: 1px solid rgba(255,255,255,0.12);
|
| 1164 |
+
border-left: 3px solid #3B82F6;
|
| 1165 |
+
border-radius: 10px;
|
| 1166 |
+
padding: 12px 14px;
|
| 1167 |
+
min-width: 320px;
|
| 1168 |
+
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
| 1169 |
+
}
|
| 1170 |
+
`}</style>
|
| 1171 |
+
</div>
|
| 1172 |
+
);
|
| 1173 |
+
}
|
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: MIT 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.2.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 Β· MIT
|
| 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="MIT" />
|
| 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/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,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 SkillsTab } from "./SkillsTab.jsx";
|
| 7 |
+
export { default as SessionsTab } from "./SessionsTab.jsx";
|
| 8 |
+
export { default as AdvancedTab } from "./AdvancedTab.jsx";
|
frontend/components/AssistantMessage.jsx
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import PlanView from "./PlanView.jsx";
|
| 3 |
+
|
| 4 |
+
export default function AssistantMessage({ answer, plan, executionLog }) {
|
| 5 |
+
const styles = {
|
| 6 |
+
container: {
|
| 7 |
+
marginBottom: "20px",
|
| 8 |
+
padding: "20px",
|
| 9 |
+
backgroundColor: "#18181B", // Zinc-900
|
| 10 |
+
borderRadius: "12px",
|
| 11 |
+
border: "1px solid #27272A", // Zinc-800
|
| 12 |
+
color: "#F4F4F5", // Zinc-100
|
| 13 |
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
| 14 |
+
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
| 15 |
+
},
|
| 16 |
+
section: {
|
| 17 |
+
marginBottom: "20px",
|
| 18 |
+
},
|
| 19 |
+
lastSection: {
|
| 20 |
+
marginBottom: "0",
|
| 21 |
+
},
|
| 22 |
+
header: {
|
| 23 |
+
display: "flex",
|
| 24 |
+
alignItems: "center",
|
| 25 |
+
marginBottom: "12px",
|
| 26 |
+
paddingBottom: "8px",
|
| 27 |
+
borderBottom: "1px solid #3F3F46", // Zinc-700
|
| 28 |
+
},
|
| 29 |
+
title: {
|
| 30 |
+
fontSize: "12px",
|
| 31 |
+
fontWeight: "600",
|
| 32 |
+
textTransform: "uppercase",
|
| 33 |
+
letterSpacing: "0.05em",
|
| 34 |
+
color: "#A1A1AA", // Zinc-400
|
| 35 |
+
margin: 0,
|
| 36 |
+
},
|
| 37 |
+
content: {
|
| 38 |
+
fontSize: "14px",
|
| 39 |
+
lineHeight: "1.6",
|
| 40 |
+
whiteSpace: "pre-wrap",
|
| 41 |
+
},
|
| 42 |
+
executionList: {
|
| 43 |
+
listStyle: "none",
|
| 44 |
+
padding: 0,
|
| 45 |
+
margin: 0,
|
| 46 |
+
display: "flex",
|
| 47 |
+
flexDirection: "column",
|
| 48 |
+
gap: "8px",
|
| 49 |
+
},
|
| 50 |
+
executionStep: {
|
| 51 |
+
display: "flex",
|
| 52 |
+
flexDirection: "column",
|
| 53 |
+
gap: "4px",
|
| 54 |
+
padding: "10px",
|
| 55 |
+
backgroundColor: "#09090B", // Zinc-950
|
| 56 |
+
borderRadius: "6px",
|
| 57 |
+
border: "1px solid #27272A",
|
| 58 |
+
fontSize: "13px",
|
| 59 |
+
},
|
| 60 |
+
stepNumber: {
|
| 61 |
+
fontSize: "11px",
|
| 62 |
+
fontWeight: "600",
|
| 63 |
+
color: "#10B981", // Emerald-500
|
| 64 |
+
textTransform: "uppercase",
|
| 65 |
+
},
|
| 66 |
+
stepSummary: {
|
| 67 |
+
color: "#D4D4D8", // Zinc-300
|
| 68 |
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
| 69 |
+
},
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
// Only show Action Plan section when there are actual file actions.
|
| 73 |
+
// For Lite Mode Q&A responses (all steps have 0 files), the plan
|
| 74 |
+
// just duplicates the answer β hiding it avoids showing the same text 3x.
|
| 75 |
+
const hasFileActions = plan?.steps?.some(s => s.files?.length > 0);
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<div className="chat-message-ai" style={styles.container}>
|
| 79 |
+
{/* Answer section */}
|
| 80 |
+
<section style={styles.section}>
|
| 81 |
+
<header style={styles.header}>
|
| 82 |
+
<h3 style={styles.title}>Answer</h3>
|
| 83 |
+
</header>
|
| 84 |
+
<div style={styles.content}>
|
| 85 |
+
<p style={{ margin: 0 }}>{answer}</p>
|
| 86 |
+
</div>
|
| 87 |
+
</section>
|
| 88 |
+
|
| 89 |
+
{/* Action Plan section β only when there are file changes */}
|
| 90 |
+
{plan && hasFileActions && (
|
| 91 |
+
<section style={styles.section}>
|
| 92 |
+
<header style={styles.header}>
|
| 93 |
+
<h3 style={{ ...styles.title, color: "#D95C3D" }}>Action Plan</h3>
|
| 94 |
+
</header>
|
| 95 |
+
<div>
|
| 96 |
+
<PlanView plan={plan} />
|
| 97 |
+
</div>
|
| 98 |
+
</section>
|
| 99 |
+
)}
|
| 100 |
+
|
| 101 |
+
{/* Execution Log section (shown after execution) */}
|
| 102 |
+
{executionLog && (
|
| 103 |
+
<section style={styles.lastSection}>
|
| 104 |
+
<header style={styles.header}>
|
| 105 |
+
<h3 style={{ ...styles.title, color: "#10B981" }}>Execution Log</h3>
|
| 106 |
+
</header>
|
| 107 |
+
<div>
|
| 108 |
+
<ul style={styles.executionList}>
|
| 109 |
+
{executionLog.steps.map((s) => (
|
| 110 |
+
<li key={s.step_number} style={styles.executionStep}>
|
| 111 |
+
<span style={styles.stepNumber}>Step {s.step_number}</span>
|
| 112 |
+
<span style={styles.stepSummary}>{s.summary}</span>
|
| 113 |
+
</li>
|
| 114 |
+
))}
|
| 115 |
+
</ul>
|
| 116 |
+
</div>
|
| 117 |
+
</section>
|
| 118 |
+
)}
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
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,719 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/ChatPanel.jsx
|
| 2 |
+
import React, { useEffect, useRef, useState } from "react";
|
| 3 |
+
import AssistantMessage from "./AssistantMessage.jsx";
|
| 4 |
+
import DiffStats from "./DiffStats.jsx";
|
| 5 |
+
import DiffViewer from "./DiffViewer.jsx";
|
| 6 |
+
import CreatePRButton from "./CreatePRButton.jsx";
|
| 7 |
+
import StreamingMessage from "./StreamingMessage.jsx";
|
| 8 |
+
import { SessionWebSocket } from "../utils/ws.js";
|
| 9 |
+
|
| 10 |
+
// Helper to get headers (inline safety if utility is missing)
|
| 11 |
+
const getHeaders = () => ({
|
| 12 |
+
"Content-Type": "application/json",
|
| 13 |
+
Authorization: `Bearer ${localStorage.getItem("github_token") || ""}`,
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
export default function ChatPanel({
|
| 17 |
+
repo,
|
| 18 |
+
defaultBranch = "main",
|
| 19 |
+
currentBranch, // do NOT default here; parent must pass the real one
|
| 20 |
+
onExecutionComplete,
|
| 21 |
+
sessionChatState,
|
| 22 |
+
onSessionChatStateChange,
|
| 23 |
+
sessionId,
|
| 24 |
+
onEnsureSession,
|
| 25 |
+
canChat = true, // readiness gate: false disables composer and shows blocker
|
| 26 |
+
chatBlocker = null, // { message: string, cta?: string, onCta?: () => void }
|
| 27 |
+
}) {
|
| 28 |
+
// Initialize state from props or defaults
|
| 29 |
+
const [messages, setMessages] = useState(sessionChatState?.messages || []);
|
| 30 |
+
const [goal, setGoal] = useState("");
|
| 31 |
+
const [plan, setPlan] = useState(sessionChatState?.plan || null);
|
| 32 |
+
|
| 33 |
+
const [loadingPlan, setLoadingPlan] = useState(false);
|
| 34 |
+
const [executing, setExecuting] = useState(false);
|
| 35 |
+
const [status, setStatus] = useState("");
|
| 36 |
+
|
| 37 |
+
// Claude-Code-on-Web: WebSocket streaming + diff + PR
|
| 38 |
+
const [wsConnected, setWsConnected] = useState(false);
|
| 39 |
+
const [streamingEvents, setStreamingEvents] = useState([]);
|
| 40 |
+
const [diffData, setDiffData] = useState(null);
|
| 41 |
+
const [showDiffViewer, setShowDiffViewer] = useState(false);
|
| 42 |
+
const wsRef = useRef(null);
|
| 43 |
+
|
| 44 |
+
// Ref mirrors streamingEvents so WS callbacks avoid stale closures
|
| 45 |
+
const streamingEventsRef = useRef([]);
|
| 46 |
+
useEffect(() => { streamingEventsRef.current = streamingEvents; }, [streamingEvents]);
|
| 47 |
+
|
| 48 |
+
// Skip the session-sync useEffect reset when we just created a session
|
| 49 |
+
// (the parent already seeded the messages into chatBySession)
|
| 50 |
+
const skipNextSyncRef = useRef(false);
|
| 51 |
+
|
| 52 |
+
const messagesEndRef = useRef(null);
|
| 53 |
+
const prevMsgCountRef = useRef((sessionChatState?.messages || []).length);
|
| 54 |
+
|
| 55 |
+
// ---------------------------------------------------------------------------
|
| 56 |
+
// WebSocket connection management
|
| 57 |
+
// ---------------------------------------------------------------------------
|
| 58 |
+
useEffect(() => {
|
| 59 |
+
// Clean up previous connection
|
| 60 |
+
if (wsRef.current) {
|
| 61 |
+
wsRef.current.close();
|
| 62 |
+
wsRef.current = null;
|
| 63 |
+
setWsConnected(false);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
if (!sessionId) return;
|
| 67 |
+
|
| 68 |
+
const ws = new SessionWebSocket(sessionId, {
|
| 69 |
+
onConnect: () => setWsConnected(true),
|
| 70 |
+
onDisconnect: () => setWsConnected(false),
|
| 71 |
+
onMessage: (data) => {
|
| 72 |
+
if (data.type === "agent_message") {
|
| 73 |
+
setStreamingEvents((prev) => [...prev, data]);
|
| 74 |
+
} else if (data.type === "tool_use" || data.type === "tool_result") {
|
| 75 |
+
setStreamingEvents((prev) => [...prev, data]);
|
| 76 |
+
} else if (data.type === "diff_update") {
|
| 77 |
+
setDiffData(data.stats || data);
|
| 78 |
+
} else if (data.type === "session_restored") {
|
| 79 |
+
// Session loaded
|
| 80 |
+
}
|
| 81 |
+
},
|
| 82 |
+
onStatusChange: (newStatus) => {
|
| 83 |
+
if (newStatus === "waiting") {
|
| 84 |
+
// Always clear loading state when agent finishes
|
| 85 |
+
setLoadingPlan(false);
|
| 86 |
+
|
| 87 |
+
// Consolidate streaming events into a chat message (use ref to
|
| 88 |
+
// avoid stale closure β streamingEvents state would be stale here)
|
| 89 |
+
const events = streamingEventsRef.current;
|
| 90 |
+
if (events.length > 0) {
|
| 91 |
+
const textParts = events
|
| 92 |
+
.filter((e) => e.type === "agent_message")
|
| 93 |
+
.map((e) => e.content);
|
| 94 |
+
if (textParts.length > 0) {
|
| 95 |
+
const consolidated = {
|
| 96 |
+
from: "ai",
|
| 97 |
+
role: "assistant",
|
| 98 |
+
answer: textParts.join(""),
|
| 99 |
+
content: textParts.join(""),
|
| 100 |
+
};
|
| 101 |
+
setMessages((prev) => [...prev, consolidated]);
|
| 102 |
+
}
|
| 103 |
+
setStreamingEvents([]);
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
},
|
| 107 |
+
onError: (err) => {
|
| 108 |
+
console.warn("[ws] Error:", err);
|
| 109 |
+
setLoadingPlan(false);
|
| 110 |
+
},
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
ws.connect();
|
| 114 |
+
wsRef.current = ws;
|
| 115 |
+
|
| 116 |
+
return () => {
|
| 117 |
+
ws.close();
|
| 118 |
+
};
|
| 119 |
+
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 120 |
+
|
| 121 |
+
// ---------------------------------------------------------------------------
|
| 122 |
+
// 1) SESSION SYNC: Restore chat when branch, repo, OR session changes
|
| 123 |
+
// IMPORTANT: Do NOT depend on sessionChatState here (prevents prop/state loop)
|
| 124 |
+
// ---------------------------------------------------------------------------
|
| 125 |
+
useEffect(() => {
|
| 126 |
+
// When send() just created a session, the parent seeded the messages
|
| 127 |
+
// into chatBySession already. Skip the reset so we don't wipe
|
| 128 |
+
// the optimistic user message that was already rendered.
|
| 129 |
+
if (skipNextSyncRef.current) {
|
| 130 |
+
skipNextSyncRef.current = false;
|
| 131 |
+
return;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const nextMessages = sessionChatState?.messages || [];
|
| 135 |
+
const nextPlan = sessionChatState?.plan || null;
|
| 136 |
+
|
| 137 |
+
setMessages(nextMessages);
|
| 138 |
+
setPlan(nextPlan);
|
| 139 |
+
|
| 140 |
+
// Reset transient UI state on branch/repo/session switch
|
| 141 |
+
setGoal("");
|
| 142 |
+
setStatus("");
|
| 143 |
+
setLoadingPlan(false);
|
| 144 |
+
setExecuting(false);
|
| 145 |
+
setStreamingEvents([]);
|
| 146 |
+
setDiffData(null);
|
| 147 |
+
|
| 148 |
+
// Update msg count tracker so auto-scroll doesn't "jump" on switch
|
| 149 |
+
prevMsgCountRef.current = nextMessages.length;
|
| 150 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 151 |
+
}, [currentBranch, repo?.full_name, sessionId]);
|
| 152 |
+
|
| 153 |
+
// ---------------------------------------------------------------------------
|
| 154 |
+
// 2) PERSISTENCE: Save chat to Parent (no loop now because sync only on branch)
|
| 155 |
+
// ---------------------------------------------------------------------------
|
| 156 |
+
useEffect(() => {
|
| 157 |
+
if (typeof onSessionChatStateChange === "function") {
|
| 158 |
+
// Avoid wiping parent state on mount
|
| 159 |
+
if (messages.length > 0 || plan) {
|
| 160 |
+
onSessionChatStateChange({ messages, plan });
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 164 |
+
}, [messages, plan]);
|
| 165 |
+
|
| 166 |
+
// ---------------------------------------------------------------------------
|
| 167 |
+
// 3) AUTO-SCROLL: Only scroll when a message is appended (reduces flicker)
|
| 168 |
+
// ---------------------------------------------------------------------------
|
| 169 |
+
useEffect(() => {
|
| 170 |
+
const curCount = messages.length + streamingEvents.length;
|
| 171 |
+
const prevCount = prevMsgCountRef.current;
|
| 172 |
+
|
| 173 |
+
// Only scroll when new messages are added
|
| 174 |
+
if (curCount > prevCount) {
|
| 175 |
+
prevMsgCountRef.current = curCount;
|
| 176 |
+
requestAnimationFrame(() => {
|
| 177 |
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 178 |
+
});
|
| 179 |
+
} else {
|
| 180 |
+
prevMsgCountRef.current = curCount;
|
| 181 |
+
}
|
| 182 |
+
}, [messages.length, streamingEvents.length]);
|
| 183 |
+
|
| 184 |
+
// ---------------------------------------------------------------------------
|
| 185 |
+
// HANDLERS
|
| 186 |
+
// ---------------------------------------------------------------------------
|
| 187 |
+
// ---------------------------------------------------------------------------
|
| 188 |
+
// Persist a message to the backend session (fire-and-forget)
|
| 189 |
+
// ---------------------------------------------------------------------------
|
| 190 |
+
const persistMessage = (sid, role, content) => {
|
| 191 |
+
if (!sid) return;
|
| 192 |
+
fetch(`/api/sessions/${sid}/message`, {
|
| 193 |
+
method: "POST",
|
| 194 |
+
headers: getHeaders(),
|
| 195 |
+
body: JSON.stringify({ role, content }),
|
| 196 |
+
}).catch(() => {}); // best-effort
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
const send = async () => {
|
| 200 |
+
if (!repo || !goal.trim()) return;
|
| 201 |
+
|
| 202 |
+
const text = goal.trim();
|
| 203 |
+
|
| 204 |
+
// Clear input immediately (Claude Code behavior)
|
| 205 |
+
setGoal("");
|
| 206 |
+
// Reset textarea height
|
| 207 |
+
const ta = document.querySelector(".chat-input");
|
| 208 |
+
if (ta) ta.style.height = "40px";
|
| 209 |
+
|
| 210 |
+
// Optimistic update (user bubble appears immediately)
|
| 211 |
+
const userMsg = { from: "user", role: "user", text, content: text };
|
| 212 |
+
setMessages((prev) => [...prev, userMsg]);
|
| 213 |
+
|
| 214 |
+
setLoadingPlan(true);
|
| 215 |
+
setStatus("");
|
| 216 |
+
setPlan(null);
|
| 217 |
+
setStreamingEvents([]);
|
| 218 |
+
|
| 219 |
+
// ------- Implicit session creation (Claude Code parity) -------
|
| 220 |
+
// Every chat must be backed by a session. If none exists yet,
|
| 221 |
+
// create one on-demand before sending the plan request.
|
| 222 |
+
let sid = sessionId;
|
| 223 |
+
if (!sid && typeof onEnsureSession === "function") {
|
| 224 |
+
// Derive a short title from the first message
|
| 225 |
+
const sessionName = text.length > 60 ? text.slice(0, 57) + "..." : text;
|
| 226 |
+
|
| 227 |
+
// Tell the sync useEffect to skip the reset that would otherwise
|
| 228 |
+
// wipe the optimistic user message when activeSessionId changes.
|
| 229 |
+
skipNextSyncRef.current = true;
|
| 230 |
+
|
| 231 |
+
sid = await onEnsureSession(sessionName, [userMsg]);
|
| 232 |
+
if (!sid) {
|
| 233 |
+
// Session creation failed β continue without session
|
| 234 |
+
skipNextSyncRef.current = false;
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
// Persist user message to backend session
|
| 239 |
+
persistMessage(sid, "user", text);
|
| 240 |
+
|
| 241 |
+
// Always use HTTP for plan generation (the original reliable flow).
|
| 242 |
+
// WebSocket is only used for real-time streaming feedback display.
|
| 243 |
+
const effectiveBranch = currentBranch || defaultBranch || "HEAD";
|
| 244 |
+
|
| 245 |
+
try {
|
| 246 |
+
// Timeout after 5 minutes (CrewAI agent can be slow with small models)
|
| 247 |
+
const planController = new AbortController();
|
| 248 |
+
const planTimer = setTimeout(() => planController.abort(), 300000);
|
| 249 |
+
|
| 250 |
+
let res;
|
| 251 |
+
try {
|
| 252 |
+
res = await fetch("/api/chat/plan", {
|
| 253 |
+
method: "POST",
|
| 254 |
+
headers: getHeaders(),
|
| 255 |
+
body: JSON.stringify({
|
| 256 |
+
repo_owner: repo.owner,
|
| 257 |
+
repo_name: repo.name,
|
| 258 |
+
goal: text,
|
| 259 |
+
branch_name: effectiveBranch,
|
| 260 |
+
}),
|
| 261 |
+
signal: planController.signal,
|
| 262 |
+
});
|
| 263 |
+
} catch (fetchErr) {
|
| 264 |
+
if (fetchErr.name === "AbortError") {
|
| 265 |
+
throw new Error("Request timed out after 5 minutes. The LLM may be too slow. Try a faster model.");
|
| 266 |
+
}
|
| 267 |
+
throw fetchErr;
|
| 268 |
+
} finally {
|
| 269 |
+
clearTimeout(planTimer);
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
let data;
|
| 273 |
+
try {
|
| 274 |
+
data = await res.json();
|
| 275 |
+
} catch {
|
| 276 |
+
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.`);
|
| 277 |
+
}
|
| 278 |
+
if (!res.ok) {
|
| 279 |
+
const detail = data?.detail || data?.error || data?.message || "";
|
| 280 |
+
// Friendly message for common LLM failures
|
| 281 |
+
if (detail.includes("None or empty") || detail.includes("Invalid response from LLM")) {
|
| 282 |
+
throw new Error(
|
| 283 |
+
"The LLM returned an empty response. This often happens with small models (deepseek, qwen 0.5b). " +
|
| 284 |
+
"Try a larger model (llama3, qwen2.5:7b) or enable Lite Mode in Settings."
|
| 285 |
+
);
|
| 286 |
+
}
|
| 287 |
+
throw new Error(detail || "Failed to generate plan");
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
setPlan(data);
|
| 291 |
+
|
| 292 |
+
// Extract summary from nested plan structure or top-level
|
| 293 |
+
const summary =
|
| 294 |
+
data.plan?.summary || data.summary || data.message ||
|
| 295 |
+
"Here is the proposed plan for your request.";
|
| 296 |
+
|
| 297 |
+
// Assistant response (Answer + Action Plan)
|
| 298 |
+
setMessages((prev) => [
|
| 299 |
+
...prev,
|
| 300 |
+
{
|
| 301 |
+
from: "ai",
|
| 302 |
+
role: "assistant",
|
| 303 |
+
answer: summary,
|
| 304 |
+
content: summary,
|
| 305 |
+
plan: data,
|
| 306 |
+
},
|
| 307 |
+
]);
|
| 308 |
+
|
| 309 |
+
// Persist assistant response to backend session
|
| 310 |
+
persistMessage(sid, "assistant", summary);
|
| 311 |
+
} catch (err) {
|
| 312 |
+
const msg = String(err?.message || err);
|
| 313 |
+
console.error(err);
|
| 314 |
+
setStatus(msg);
|
| 315 |
+
setMessages((prev) => [
|
| 316 |
+
...prev,
|
| 317 |
+
{ from: "ai", role: "system", content: `Error: ${msg}` },
|
| 318 |
+
]);
|
| 319 |
+
} finally {
|
| 320 |
+
setLoadingPlan(false);
|
| 321 |
+
}
|
| 322 |
+
};
|
| 323 |
+
|
| 324 |
+
const execute = async () => {
|
| 325 |
+
if (!repo || !plan) return;
|
| 326 |
+
|
| 327 |
+
setExecuting(true);
|
| 328 |
+
setStatus("");
|
| 329 |
+
|
| 330 |
+
try {
|
| 331 |
+
// Guard: currentBranch might be missing if parent didn't pass it yet
|
| 332 |
+
const safeCurrent = currentBranch || defaultBranch || "HEAD";
|
| 333 |
+
const safeDefault = defaultBranch || "main";
|
| 334 |
+
|
| 335 |
+
// Sticky vs Hard Switch:
|
| 336 |
+
// - If on default branch -> undefined (backend creates new branch)
|
| 337 |
+
// - If already on AI branch -> currentBranch (backend updates existing)
|
| 338 |
+
const branch_name = safeCurrent === safeDefault ? undefined : safeCurrent;
|
| 339 |
+
|
| 340 |
+
const res = await fetch("/api/chat/execute", {
|
| 341 |
+
method: "POST",
|
| 342 |
+
headers: getHeaders(),
|
| 343 |
+
body: JSON.stringify({
|
| 344 |
+
repo_owner: repo.owner,
|
| 345 |
+
repo_name: repo.name,
|
| 346 |
+
plan,
|
| 347 |
+
branch_name,
|
| 348 |
+
}),
|
| 349 |
+
});
|
| 350 |
+
|
| 351 |
+
const data = await res.json();
|
| 352 |
+
if (!res.ok) throw new Error(data.detail || "Execution failed");
|
| 353 |
+
|
| 354 |
+
setStatus(data.message || "Execution completed.");
|
| 355 |
+
|
| 356 |
+
const completionMsg = {
|
| 357 |
+
from: "ai",
|
| 358 |
+
role: "assistant",
|
| 359 |
+
answer: data.message || "Execution completed.",
|
| 360 |
+
content: data.message || "Execution completed.",
|
| 361 |
+
executionLog: data.executionLog,
|
| 362 |
+
};
|
| 363 |
+
|
| 364 |
+
// Show completion immediately (keeps old "Execution Log" section)
|
| 365 |
+
setMessages((prev) => [...prev, completionMsg]);
|
| 366 |
+
|
| 367 |
+
// Clear active plan UI
|
| 368 |
+
setPlan(null);
|
| 369 |
+
|
| 370 |
+
// Pass completionMsg upward for seeding branch history
|
| 371 |
+
if (typeof onExecutionComplete === "function") {
|
| 372 |
+
onExecutionComplete({
|
| 373 |
+
branch: data.branch || data.branch_name,
|
| 374 |
+
mode: data.mode,
|
| 375 |
+
commit_url: data.commit_url || data.html_url,
|
| 376 |
+
message: data.message,
|
| 377 |
+
completionMsg,
|
| 378 |
+
sourceBranch: safeCurrent,
|
| 379 |
+
});
|
| 380 |
+
}
|
| 381 |
+
} catch (err) {
|
| 382 |
+
console.error(err);
|
| 383 |
+
setStatus(String(err?.message || err));
|
| 384 |
+
} finally {
|
| 385 |
+
setExecuting(false);
|
| 386 |
+
}
|
| 387 |
+
};
|
| 388 |
+
|
| 389 |
+
// ---------------------------------------------------------------------------
|
| 390 |
+
// RENDER
|
| 391 |
+
// ---------------------------------------------------------------------------
|
| 392 |
+
const isOnSessionBranch = currentBranch && currentBranch !== defaultBranch;
|
| 393 |
+
|
| 394 |
+
return (
|
| 395 |
+
<div className="chat-container">
|
| 396 |
+
<style>{`
|
| 397 |
+
.chat-container { display: flex; flex-direction: column; height: 100%; }
|
| 398 |
+
|
| 399 |
+
.chat-messages {
|
| 400 |
+
flex: 1; overflow-y: auto;
|
| 401 |
+
padding: 20px;
|
| 402 |
+
display: flex; flex-direction: column; gap: 16px;
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
.chat-message-user {
|
| 406 |
+
align-self: flex-end;
|
| 407 |
+
background: #27272A;
|
| 408 |
+
color: #fff;
|
| 409 |
+
padding: 12px 16px;
|
| 410 |
+
border-radius: 10px;
|
| 411 |
+
max-width: 85%;
|
| 412 |
+
font-size: 14px;
|
| 413 |
+
line-height: 1.5;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
/* Success System Message Styling */
|
| 417 |
+
.chat-msg-success {
|
| 418 |
+
align-self: flex-start;
|
| 419 |
+
width: 100%;
|
| 420 |
+
background: rgba(16, 185, 129, 0.10);
|
| 421 |
+
border: 1px solid rgba(16, 185, 129, 0.20);
|
| 422 |
+
color: #D1FAE5;
|
| 423 |
+
padding: 12px 16px;
|
| 424 |
+
border-radius: 10px;
|
| 425 |
+
display: flex;
|
| 426 |
+
gap: 12px;
|
| 427 |
+
font-size: 14px;
|
| 428 |
+
}
|
| 429 |
+
.success-icon { font-size: 18px; }
|
| 430 |
+
.success-link {
|
| 431 |
+
display: inline-block;
|
| 432 |
+
margin-top: 6px;
|
| 433 |
+
font-weight: 600;
|
| 434 |
+
color: #34D399;
|
| 435 |
+
text-decoration: none;
|
| 436 |
+
}
|
| 437 |
+
.success-link:hover { text-decoration: underline; }
|
| 438 |
+
|
| 439 |
+
.chat-input-box {
|
| 440 |
+
padding: 16px;
|
| 441 |
+
border-top: 1px solid #27272A;
|
| 442 |
+
background: #131316;
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
.chat-input-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
| 446 |
+
|
| 447 |
+
.chat-input {
|
| 448 |
+
flex: 1;
|
| 449 |
+
min-width: 200px;
|
| 450 |
+
background: #18181B;
|
| 451 |
+
border: 1px solid #27272A;
|
| 452 |
+
color: white;
|
| 453 |
+
padding: 10px 12px;
|
| 454 |
+
border-radius: 8px;
|
| 455 |
+
outline: none;
|
| 456 |
+
font-size: 14px;
|
| 457 |
+
font-family: inherit;
|
| 458 |
+
resize: none;
|
| 459 |
+
min-height: 40px;
|
| 460 |
+
max-height: 160px;
|
| 461 |
+
line-height: 1.4;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
/* Enterprise controls (restored) */
|
| 465 |
+
.chat-btn {
|
| 466 |
+
height: 38px;
|
| 467 |
+
padding: 0 14px;
|
| 468 |
+
border-radius: 8px;
|
| 469 |
+
font-weight: 700;
|
| 470 |
+
cursor: pointer;
|
| 471 |
+
border: 1px solid transparent;
|
| 472 |
+
font-size: 13px;
|
| 473 |
+
white-space: nowrap;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
/* Orange primary (old style) */
|
| 477 |
+
.chat-btn.primary { background: #D95C3D; color: #fff; }
|
| 478 |
+
.chat-btn.primary:hover { filter: brightness(0.98); }
|
| 479 |
+
.chat-btn.primary:disabled { opacity: 0.55; cursor: not-allowed; }
|
| 480 |
+
|
| 481 |
+
/* Secondary outline */
|
| 482 |
+
.chat-btn.secondary {
|
| 483 |
+
background: transparent;
|
| 484 |
+
border: 1px solid #3F3F46;
|
| 485 |
+
color: #A1A1AA;
|
| 486 |
+
}
|
| 487 |
+
.chat-btn.secondary:hover { background: rgba(255,255,255,0.04); }
|
| 488 |
+
.chat-btn.secondary:disabled { opacity: 0.55; cursor: not-allowed; }
|
| 489 |
+
|
| 490 |
+
.chat-empty-state {
|
| 491 |
+
text-align: center;
|
| 492 |
+
color: #52525B;
|
| 493 |
+
margin-top: 40px;
|
| 494 |
+
font-size: 14px;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
/* WebSocket connection indicator */
|
| 498 |
+
.ws-indicator {
|
| 499 |
+
display: inline-flex;
|
| 500 |
+
align-items: center;
|
| 501 |
+
gap: 4px;
|
| 502 |
+
font-size: 10px;
|
| 503 |
+
color: #71717A;
|
| 504 |
+
padding: 2px 6px;
|
| 505 |
+
border-radius: 4px;
|
| 506 |
+
background: rgba(24, 24, 27, 0.6);
|
| 507 |
+
}
|
| 508 |
+
.ws-dot {
|
| 509 |
+
width: 6px;
|
| 510 |
+
height: 6px;
|
| 511 |
+
border-radius: 50%;
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
@keyframes blink {
|
| 515 |
+
0%, 100% { opacity: 1; }
|
| 516 |
+
50% { opacity: 0; }
|
| 517 |
+
}
|
| 518 |
+
`}</style>
|
| 519 |
+
|
| 520 |
+
<div className="chat-messages">
|
| 521 |
+
{messages.map((m, idx) => {
|
| 522 |
+
// Success message (App.jsx injected)
|
| 523 |
+
if (m.isSuccess) {
|
| 524 |
+
return (
|
| 525 |
+
<div key={idx} className="chat-msg-success">
|
| 526 |
+
<div className="success-icon">π</div>
|
| 527 |
+
<div>
|
| 528 |
+
<div style={{ whiteSpace: "pre-wrap" }}>{m.content}</div>
|
| 529 |
+
{m.link && (
|
| 530 |
+
<a href={m.link} target="_blank" rel="noreferrer" className="success-link">
|
| 531 |
+
View Changes on GitHub →
|
| 532 |
+
</a>
|
| 533 |
+
)}
|
| 534 |
+
</div>
|
| 535 |
+
</div>
|
| 536 |
+
);
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
// User message
|
| 540 |
+
if (m.from === "user" || m.role === "user") {
|
| 541 |
+
return (
|
| 542 |
+
<div key={idx} className="chat-message-user">
|
| 543 |
+
<span>{m.text || m.content}</span>
|
| 544 |
+
</div>
|
| 545 |
+
);
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
// Assistant message (Answer / Plan / Execution Log)
|
| 549 |
+
return (
|
| 550 |
+
<div key={idx}>
|
| 551 |
+
<AssistantMessage
|
| 552 |
+
answer={m.answer || m.content}
|
| 553 |
+
plan={m.plan}
|
| 554 |
+
executionLog={m.executionLog}
|
| 555 |
+
/>
|
| 556 |
+
{/* Diff stats indicator (Claude-Code-on-Web parity) */}
|
| 557 |
+
{m.diff && (
|
| 558 |
+
<DiffStats diff={m.diff} onClick={() => {
|
| 559 |
+
setDiffData(m.diff);
|
| 560 |
+
setShowDiffViewer(true);
|
| 561 |
+
}} />
|
| 562 |
+
)}
|
| 563 |
+
</div>
|
| 564 |
+
);
|
| 565 |
+
})}
|
| 566 |
+
|
| 567 |
+
{/* Streaming events (real-time agent output) */}
|
| 568 |
+
{streamingEvents.length > 0 && (
|
| 569 |
+
<div>
|
| 570 |
+
<StreamingMessage events={streamingEvents} />
|
| 571 |
+
</div>
|
| 572 |
+
)}
|
| 573 |
+
|
| 574 |
+
{loadingPlan && streamingEvents.length === 0 && (
|
| 575 |
+
<div className="chat-message-ai" style={{ color: "#A1A1AA", fontStyle: "italic", padding: "10px" }}>
|
| 576 |
+
Thinking...
|
| 577 |
+
</div>
|
| 578 |
+
)}
|
| 579 |
+
|
| 580 |
+
{!messages.length && !plan && !loadingPlan && streamingEvents.length === 0 && (
|
| 581 |
+
<div className="chat-empty-state">
|
| 582 |
+
<div className="chat-empty-icon">π¬</div>
|
| 583 |
+
<p>Tell GitPilot what you want to do with this repository.</p>
|
| 584 |
+
<p style={{ fontSize: 12, color: "#676883", marginTop: 4 }}>
|
| 585 |
+
It will propose a safe step-by-step plan before any execution.
|
| 586 |
+
</p>
|
| 587 |
+
</div>
|
| 588 |
+
)}
|
| 589 |
+
|
| 590 |
+
<div ref={messagesEndRef} />
|
| 591 |
+
</div>
|
| 592 |
+
|
| 593 |
+
{/* Diff stats bar (when agent has made changes) */}
|
| 594 |
+
{diffData && (
|
| 595 |
+
<div style={{
|
| 596 |
+
padding: "8px 16px",
|
| 597 |
+
borderTop: "1px solid #27272A",
|
| 598 |
+
background: "#18181B",
|
| 599 |
+
}}>
|
| 600 |
+
<DiffStats diff={diffData} onClick={() => setShowDiffViewer(true)} />
|
| 601 |
+
</div>
|
| 602 |
+
)}
|
| 603 |
+
|
| 604 |
+
<div className="chat-input-box">
|
| 605 |
+
{/* Readiness blocker banner */}
|
| 606 |
+
{!canChat && chatBlocker && (
|
| 607 |
+
<div style={{
|
| 608 |
+
fontSize: 12,
|
| 609 |
+
color: "#F59E0B",
|
| 610 |
+
background: "rgba(245, 158, 11, 0.08)",
|
| 611 |
+
border: "1px solid rgba(245, 158, 11, 0.2)",
|
| 612 |
+
borderRadius: 6,
|
| 613 |
+
padding: "8px 12px",
|
| 614 |
+
marginBottom: 8,
|
| 615 |
+
display: "flex",
|
| 616 |
+
alignItems: "center",
|
| 617 |
+
justifyContent: "space-between",
|
| 618 |
+
}}>
|
| 619 |
+
<span>{chatBlocker.message || "Chat is not ready yet."}</span>
|
| 620 |
+
{chatBlocker.cta && chatBlocker.onCta && (
|
| 621 |
+
<button
|
| 622 |
+
type="button"
|
| 623 |
+
onClick={chatBlocker.onCta}
|
| 624 |
+
style={{
|
| 625 |
+
fontSize: 11,
|
| 626 |
+
fontWeight: 600,
|
| 627 |
+
color: "#F59E0B",
|
| 628 |
+
background: "transparent",
|
| 629 |
+
border: "1px solid rgba(245, 158, 11, 0.3)",
|
| 630 |
+
borderRadius: 4,
|
| 631 |
+
padding: "2px 8px",
|
| 632 |
+
cursor: "pointer",
|
| 633 |
+
}}
|
| 634 |
+
>
|
| 635 |
+
{chatBlocker.cta}
|
| 636 |
+
</button>
|
| 637 |
+
)}
|
| 638 |
+
</div>
|
| 639 |
+
)}
|
| 640 |
+
{status && (
|
| 641 |
+
<div style={{ fontSize: 11, color: "#ffb3b7", marginBottom: 8 }}>
|
| 642 |
+
{status}
|
| 643 |
+
</div>
|
| 644 |
+
)}
|
| 645 |
+
|
| 646 |
+
<div className="chat-input-row">
|
| 647 |
+
<textarea
|
| 648 |
+
className="chat-input"
|
| 649 |
+
placeholder={wsConnected ? "Send feedback or instructions..." : "Describe the change you want to make..."}
|
| 650 |
+
value={goal}
|
| 651 |
+
rows={1}
|
| 652 |
+
onChange={(e) => {
|
| 653 |
+
setGoal(e.target.value);
|
| 654 |
+
e.target.style.height = "40px";
|
| 655 |
+
e.target.style.height = Math.min(e.target.scrollHeight, 160) + "px";
|
| 656 |
+
}}
|
| 657 |
+
onKeyDown={(e) => {
|
| 658 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 659 |
+
e.preventDefault();
|
| 660 |
+
if (!loadingPlan && !executing) send();
|
| 661 |
+
}
|
| 662 |
+
}}
|
| 663 |
+
disabled={!canChat || loadingPlan || executing}
|
| 664 |
+
/>
|
| 665 |
+
|
| 666 |
+
{/* Always show both buttons (old UX) */}
|
| 667 |
+
<button
|
| 668 |
+
className="chat-btn primary"
|
| 669 |
+
type="button"
|
| 670 |
+
onClick={send}
|
| 671 |
+
disabled={!canChat || loadingPlan || executing || !goal.trim()}
|
| 672 |
+
>
|
| 673 |
+
{loadingPlan ? "Planning..." : wsConnected ? "Send" : "Generate plan"}
|
| 674 |
+
</button>
|
| 675 |
+
|
| 676 |
+
<button
|
| 677 |
+
className="chat-btn secondary"
|
| 678 |
+
type="button"
|
| 679 |
+
onClick={execute}
|
| 680 |
+
disabled={!plan || executing || loadingPlan}
|
| 681 |
+
>
|
| 682 |
+
{executing ? "Executing..." : "Approve & execute"}
|
| 683 |
+
</button>
|
| 684 |
+
|
| 685 |
+
{/* Create PR button (Claude-Code-on-Web parity) */}
|
| 686 |
+
{isOnSessionBranch && (
|
| 687 |
+
<CreatePRButton
|
| 688 |
+
repo={repo}
|
| 689 |
+
sessionId={sessionId}
|
| 690 |
+
branch={currentBranch}
|
| 691 |
+
defaultBranch={defaultBranch}
|
| 692 |
+
disabled={executing || loadingPlan}
|
| 693 |
+
/>
|
| 694 |
+
)}
|
| 695 |
+
</div>
|
| 696 |
+
|
| 697 |
+
{/* WebSocket connection indicator */}
|
| 698 |
+
{sessionId && (
|
| 699 |
+
<div style={{ marginTop: 6, display: "flex", alignItems: "center", gap: 8 }}>
|
| 700 |
+
<span className="ws-indicator">
|
| 701 |
+
<span className="ws-dot" style={{
|
| 702 |
+
backgroundColor: wsConnected ? "#10B981" : "#EF4444",
|
| 703 |
+
}} />
|
| 704 |
+
{wsConnected ? "Live" : "Connecting..."}
|
| 705 |
+
</span>
|
| 706 |
+
</div>
|
| 707 |
+
)}
|
| 708 |
+
</div>
|
| 709 |
+
|
| 710 |
+
{/* Diff Viewer overlay */}
|
| 711 |
+
{showDiffViewer && (
|
| 712 |
+
<DiffViewer
|
| 713 |
+
diff={diffData}
|
| 714 |
+
onClose={() => setShowDiffViewer(false)}
|
| 715 |
+
/>
|
| 716 |
+
)}
|
| 717 |
+
</div>
|
| 718 |
+
);
|
| 719 |
+
}
|
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/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/FileTree.jsx
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
if (!repo) return;
|
| 16 |
+
|
| 17 |
+
// Determine if this is a branch switch (we already have data)
|
| 18 |
+
const hasExistingData = tree.length > 0;
|
| 19 |
+
if (hasExistingData) {
|
| 20 |
+
setIsSwitchingBranch(true);
|
| 21 |
+
} else {
|
| 22 |
+
setLoading(true);
|
| 23 |
+
}
|
| 24 |
+
setError(null);
|
| 25 |
+
|
| 26 |
+
// Construct headers manually
|
| 27 |
+
let headers = {};
|
| 28 |
+
try {
|
| 29 |
+
const token = localStorage.getItem("github_token");
|
| 30 |
+
if (token) {
|
| 31 |
+
headers = { Authorization: `Bearer ${token}` };
|
| 32 |
+
}
|
| 33 |
+
} catch (e) {
|
| 34 |
+
console.warn("Unable to read github_token", e);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// Add cache busting + selected branch ref
|
| 38 |
+
const refParam = branch ? `&ref=${encodeURIComponent(branch)}` : "";
|
| 39 |
+
const cacheBuster = `?_t=${Date.now()}${refParam}`;
|
| 40 |
+
|
| 41 |
+
let cancelled = false;
|
| 42 |
+
|
| 43 |
+
fetch(`/api/repos/${repo.owner}/${repo.name}/tree${cacheBuster}`, { headers })
|
| 44 |
+
.then(async (res) => {
|
| 45 |
+
if (!res.ok) {
|
| 46 |
+
const errData = await res.json().catch(() => ({}));
|
| 47 |
+
throw new Error(errData.detail || "Failed to load files");
|
| 48 |
+
}
|
| 49 |
+
return res.json();
|
| 50 |
+
})
|
| 51 |
+
.then((data) => {
|
| 52 |
+
if (cancelled) return;
|
| 53 |
+
if (data.files && Array.isArray(data.files)) {
|
| 54 |
+
setTree(buildTree(data.files));
|
| 55 |
+
setError(null);
|
| 56 |
+
} else {
|
| 57 |
+
setError("No files found in repository");
|
| 58 |
+
}
|
| 59 |
+
})
|
| 60 |
+
.catch((err) => {
|
| 61 |
+
if (cancelled) return;
|
| 62 |
+
setError(err.message);
|
| 63 |
+
console.error("FileTree error:", err);
|
| 64 |
+
})
|
| 65 |
+
.finally(() => {
|
| 66 |
+
if (cancelled) return;
|
| 67 |
+
setIsSwitchingBranch(false);
|
| 68 |
+
setLoading(false);
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
return () => { cancelled = true; };
|
| 72 |
+
}, [repo, branch, refreshTrigger, localRefresh]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 73 |
+
|
| 74 |
+
const handleRefresh = () => {
|
| 75 |
+
setLocalRefresh(prev => prev + 1);
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
// Theme matching parent component
|
| 79 |
+
const theme = {
|
| 80 |
+
border: "#27272A",
|
| 81 |
+
textPrimary: "#EDEDED",
|
| 82 |
+
textSecondary: "#A1A1AA",
|
| 83 |
+
accent: "#D95C3D",
|
| 84 |
+
warningText: "#F59E0B",
|
| 85 |
+
warningBg: "rgba(245, 158, 11, 0.1)",
|
| 86 |
+
warningBorder: "rgba(245, 158, 11, 0.2)",
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
const styles = {
|
| 90 |
+
header: {
|
| 91 |
+
display: "flex",
|
| 92 |
+
alignItems: "center",
|
| 93 |
+
justifyContent: "space-between",
|
| 94 |
+
padding: "8px 20px 8px 10px",
|
| 95 |
+
marginBottom: "8px",
|
| 96 |
+
borderBottom: `1px solid ${theme.border}`,
|
| 97 |
+
},
|
| 98 |
+
headerTitle: {
|
| 99 |
+
fontSize: "12px",
|
| 100 |
+
fontWeight: "600",
|
| 101 |
+
color: theme.textSecondary,
|
| 102 |
+
textTransform: "uppercase",
|
| 103 |
+
letterSpacing: "0.5px",
|
| 104 |
+
},
|
| 105 |
+
refreshButton: {
|
| 106 |
+
backgroundColor: "transparent",
|
| 107 |
+
border: `1px solid ${theme.border}`,
|
| 108 |
+
color: theme.textSecondary,
|
| 109 |
+
padding: "4px 8px",
|
| 110 |
+
borderRadius: "4px",
|
| 111 |
+
fontSize: "11px",
|
| 112 |
+
cursor: loading ? "not-allowed" : "pointer",
|
| 113 |
+
display: "flex",
|
| 114 |
+
alignItems: "center",
|
| 115 |
+
gap: "4px",
|
| 116 |
+
transition: "all 0.2s",
|
| 117 |
+
opacity: loading ? 0.5 : 1,
|
| 118 |
+
},
|
| 119 |
+
switchingBar: {
|
| 120 |
+
padding: "6px 20px",
|
| 121 |
+
fontSize: "11px",
|
| 122 |
+
color: theme.textSecondary,
|
| 123 |
+
backgroundColor: "rgba(59, 130, 246, 0.06)",
|
| 124 |
+
borderBottom: `1px solid ${theme.border}`,
|
| 125 |
+
},
|
| 126 |
+
loadingText: {
|
| 127 |
+
padding: "0 20px",
|
| 128 |
+
color: theme.textSecondary,
|
| 129 |
+
fontSize: "13px",
|
| 130 |
+
},
|
| 131 |
+
errorBox: {
|
| 132 |
+
padding: "12px 20px",
|
| 133 |
+
color: theme.warningText,
|
| 134 |
+
fontSize: "12px",
|
| 135 |
+
backgroundColor: theme.warningBg,
|
| 136 |
+
border: `1px solid ${theme.warningBorder}`,
|
| 137 |
+
borderRadius: "6px",
|
| 138 |
+
margin: "0 10px",
|
| 139 |
+
},
|
| 140 |
+
emptyText: {
|
| 141 |
+
padding: "0 20px",
|
| 142 |
+
color: theme.textSecondary,
|
| 143 |
+
fontSize: "13px",
|
| 144 |
+
},
|
| 145 |
+
treeContainer: {
|
| 146 |
+
fontSize: "13px",
|
| 147 |
+
color: theme.textSecondary,
|
| 148 |
+
padding: "0 10px 20px 10px",
|
| 149 |
+
},
|
| 150 |
+
};
|
| 151 |
+
|
| 152 |
+
return (
|
| 153 |
+
<div>
|
| 154 |
+
{/* Header with Refresh Button */}
|
| 155 |
+
<div style={styles.header}>
|
| 156 |
+
<span style={styles.headerTitle}>Files</span>
|
| 157 |
+
<button
|
| 158 |
+
type="button"
|
| 159 |
+
style={styles.refreshButton}
|
| 160 |
+
onClick={handleRefresh}
|
| 161 |
+
disabled={loading}
|
| 162 |
+
onMouseOver={(e) => {
|
| 163 |
+
if (!loading) {
|
| 164 |
+
e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.05)";
|
| 165 |
+
}
|
| 166 |
+
}}
|
| 167 |
+
onMouseOut={(e) => {
|
| 168 |
+
e.currentTarget.style.backgroundColor = "transparent";
|
| 169 |
+
}}
|
| 170 |
+
>
|
| 171 |
+
<svg
|
| 172 |
+
width="12"
|
| 173 |
+
height="12"
|
| 174 |
+
viewBox="0 0 24 24"
|
| 175 |
+
fill="none"
|
| 176 |
+
stroke="currentColor"
|
| 177 |
+
strokeWidth="2"
|
| 178 |
+
style={{
|
| 179 |
+
transform: loading ? "rotate(360deg)" : "rotate(0deg)",
|
| 180 |
+
transition: "transform 0.6s ease",
|
| 181 |
+
}}
|
| 182 |
+
>
|
| 183 |
+
<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" />
|
| 184 |
+
</svg>
|
| 185 |
+
{loading ? "..." : "Refresh"}
|
| 186 |
+
</button>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
{/* Branch switch indicator (shown above existing tree, doesn't clear it) */}
|
| 190 |
+
{isSwitchingBranch && (
|
| 191 |
+
<div style={styles.switchingBar}>Loading branch...</div>
|
| 192 |
+
)}
|
| 193 |
+
|
| 194 |
+
{/* Content */}
|
| 195 |
+
{loading && tree.length === 0 && (
|
| 196 |
+
<div style={styles.loadingText}>Loading files...</div>
|
| 197 |
+
)}
|
| 198 |
+
|
| 199 |
+
{!loading && !isSwitchingBranch && error && (
|
| 200 |
+
<div style={styles.errorBox}>{error}</div>
|
| 201 |
+
)}
|
| 202 |
+
|
| 203 |
+
{!loading && !isSwitchingBranch && !error && tree.length === 0 && (
|
| 204 |
+
<div style={styles.emptyText}>No files found</div>
|
| 205 |
+
)}
|
| 206 |
+
|
| 207 |
+
{tree.length > 0 && (
|
| 208 |
+
<div style={{
|
| 209 |
+
...styles.treeContainer,
|
| 210 |
+
opacity: isSwitchingBranch ? 0.5 : 1,
|
| 211 |
+
transition: "opacity 0.15s ease",
|
| 212 |
+
}}>
|
| 213 |
+
{tree.map((node) => (
|
| 214 |
+
<TreeNode key={node.path} node={node} level={0} />
|
| 215 |
+
))}
|
| 216 |
+
</div>
|
| 217 |
+
)}
|
| 218 |
+
</div>
|
| 219 |
+
);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// Recursive Node Component
|
| 223 |
+
function TreeNode({ node, level }) {
|
| 224 |
+
const [expanded, setExpanded] = useState(false);
|
| 225 |
+
const isFolder = node.children && node.children.length > 0;
|
| 226 |
+
|
| 227 |
+
const icon = isFolder ? (expanded ? "π" : "π") : "π";
|
| 228 |
+
|
| 229 |
+
return (
|
| 230 |
+
<div>
|
| 231 |
+
<div
|
| 232 |
+
onClick={() => isFolder && setExpanded(!expanded)}
|
| 233 |
+
style={{
|
| 234 |
+
padding: "4px 0",
|
| 235 |
+
paddingLeft: `${level * 12}px`,
|
| 236 |
+
cursor: isFolder ? "pointer" : "default",
|
| 237 |
+
display: "flex",
|
| 238 |
+
alignItems: "center",
|
| 239 |
+
gap: "6px",
|
| 240 |
+
color: isFolder ? "#EDEDED" : "#A1A1AA",
|
| 241 |
+
whiteSpace: "nowrap"
|
| 242 |
+
}}
|
| 243 |
+
>
|
| 244 |
+
<span style={{ fontSize: "14px", opacity: 0.7 }}>{icon}</span>
|
| 245 |
+
<span>{node.name}</span>
|
| 246 |
+
</div>
|
| 247 |
+
|
| 248 |
+
{isFolder && expanded && (
|
| 249 |
+
<div>
|
| 250 |
+
{node.children.map(child => (
|
| 251 |
+
<TreeNode key={child.path} node={child} level={level + 1} />
|
| 252 |
+
))}
|
| 253 |
+
</div>
|
| 254 |
+
)}
|
| 255 |
+
</div>
|
| 256 |
+
);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// Helper to build tree structure from flat file list
|
| 260 |
+
function buildTree(files) {
|
| 261 |
+
const root = [];
|
| 262 |
+
|
| 263 |
+
files.forEach(file => {
|
| 264 |
+
const parts = file.path.split('/');
|
| 265 |
+
let currentLevel = root;
|
| 266 |
+
let currentPath = "";
|
| 267 |
+
|
| 268 |
+
parts.forEach((part, idx) => {
|
| 269 |
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
| 270 |
+
|
| 271 |
+
// Check if node exists at this level
|
| 272 |
+
let existingNode = currentLevel.find(n => n.name === part);
|
| 273 |
+
|
| 274 |
+
if (!existingNode) {
|
| 275 |
+
const newNode = {
|
| 276 |
+
name: part,
|
| 277 |
+
path: currentPath,
|
| 278 |
+
type: idx === parts.length - 1 ? file.type : 'tree',
|
| 279 |
+
children: []
|
| 280 |
+
};
|
| 281 |
+
currentLevel.push(newNode);
|
| 282 |
+
existingNode = newNode;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
if (idx < parts.length - 1) {
|
| 286 |
+
currentLevel = existingNode.children;
|
| 287 |
+
}
|
| 288 |
+
});
|
| 289 |
+
});
|
| 290 |
+
|
| 291 |
+
// Sort folders first, then files
|
| 292 |
+
const sortNodes = (nodes) => {
|
| 293 |
+
nodes.sort((a, b) => {
|
| 294 |
+
const aIsFolder = a.children.length > 0;
|
| 295 |
+
const bIsFolder = b.children.length > 0;
|
| 296 |
+
if (aIsFolder && !bIsFolder) return -1;
|
| 297 |
+
if (!aIsFolder && bIsFolder) return 1;
|
| 298 |
+
return a.name.localeCompare(b.name);
|
| 299 |
+
});
|
| 300 |
+
nodes.forEach(n => {
|
| 301 |
+
if (n.children.length > 0) sortNodes(n.children);
|
| 302 |
+
});
|
| 303 |
+
};
|
| 304 |
+
|
| 305 |
+
sortNodes(root);
|
| 306 |
+
return root;
|
| 307 |
+
}
|
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,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
|
| 3 |
+
export default function PlanView({ plan }) {
|
| 4 |
+
if (!plan) return null;
|
| 5 |
+
|
| 6 |
+
// Calculate totals for each action type
|
| 7 |
+
const totals = { CREATE: 0, MODIFY: 0, DELETE: 0 };
|
| 8 |
+
plan.steps.forEach((step) => {
|
| 9 |
+
step.files.forEach((file) => {
|
| 10 |
+
totals[file.action] = (totals[file.action] || 0) + 1;
|
| 11 |
+
});
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
const theme = {
|
| 15 |
+
bg: "#18181B",
|
| 16 |
+
border: "#27272A",
|
| 17 |
+
textPrimary: "#EDEDED",
|
| 18 |
+
textSecondary: "#A1A1AA",
|
| 19 |
+
successBg: "rgba(16, 185, 129, 0.1)",
|
| 20 |
+
successText: "#10B981",
|
| 21 |
+
warningBg: "rgba(245, 158, 11, 0.1)",
|
| 22 |
+
warningText: "#F59E0B",
|
| 23 |
+
dangerBg: "rgba(239, 68, 68, 0.1)",
|
| 24 |
+
dangerText: "#EF4444",
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
const styles = {
|
| 28 |
+
container: {
|
| 29 |
+
display: "flex",
|
| 30 |
+
flexDirection: "column",
|
| 31 |
+
gap: "20px",
|
| 32 |
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
| 33 |
+
},
|
| 34 |
+
header: {
|
| 35 |
+
display: "flex",
|
| 36 |
+
flexDirection: "column",
|
| 37 |
+
gap: "8px",
|
| 38 |
+
paddingBottom: "16px",
|
| 39 |
+
borderBottom: `1px solid ${theme.border}`,
|
| 40 |
+
},
|
| 41 |
+
goal: {
|
| 42 |
+
fontSize: "14px",
|
| 43 |
+
fontWeight: "600",
|
| 44 |
+
color: theme.textPrimary,
|
| 45 |
+
},
|
| 46 |
+
summary: {
|
| 47 |
+
fontSize: "13px",
|
| 48 |
+
color: theme.textSecondary,
|
| 49 |
+
lineHeight: "1.5",
|
| 50 |
+
},
|
| 51 |
+
totals: {
|
| 52 |
+
display: "flex",
|
| 53 |
+
gap: "12px",
|
| 54 |
+
flexWrap: "wrap",
|
| 55 |
+
},
|
| 56 |
+
totalBadge: {
|
| 57 |
+
fontSize: "11px",
|
| 58 |
+
fontWeight: "500",
|
| 59 |
+
padding: "4px 8px",
|
| 60 |
+
borderRadius: "4px",
|
| 61 |
+
border: "1px solid transparent",
|
| 62 |
+
},
|
| 63 |
+
totalCreate: {
|
| 64 |
+
backgroundColor: theme.successBg,
|
| 65 |
+
color: theme.successText,
|
| 66 |
+
borderColor: "rgba(16, 185, 129, 0.2)",
|
| 67 |
+
},
|
| 68 |
+
totalModify: {
|
| 69 |
+
backgroundColor: theme.warningBg,
|
| 70 |
+
color: theme.warningText,
|
| 71 |
+
borderColor: "rgba(245, 158, 11, 0.2)",
|
| 72 |
+
},
|
| 73 |
+
totalDelete: {
|
| 74 |
+
backgroundColor: theme.dangerBg,
|
| 75 |
+
color: theme.dangerText,
|
| 76 |
+
borderColor: "rgba(239, 68, 68, 0.2)",
|
| 77 |
+
},
|
| 78 |
+
stepsList: {
|
| 79 |
+
listStyle: "none",
|
| 80 |
+
padding: 0,
|
| 81 |
+
margin: 0,
|
| 82 |
+
display: "flex",
|
| 83 |
+
flexDirection: "column",
|
| 84 |
+
gap: "24px",
|
| 85 |
+
},
|
| 86 |
+
step: {
|
| 87 |
+
display: "flex",
|
| 88 |
+
flexDirection: "column",
|
| 89 |
+
gap: "8px",
|
| 90 |
+
position: "relative",
|
| 91 |
+
},
|
| 92 |
+
stepHeader: {
|
| 93 |
+
display: "flex",
|
| 94 |
+
alignItems: "baseline",
|
| 95 |
+
gap: "8px",
|
| 96 |
+
fontSize: "13px",
|
| 97 |
+
fontWeight: "600",
|
| 98 |
+
color: theme.textPrimary,
|
| 99 |
+
},
|
| 100 |
+
stepNumber: {
|
| 101 |
+
color: theme.textSecondary,
|
| 102 |
+
fontSize: "11px",
|
| 103 |
+
textTransform: "uppercase",
|
| 104 |
+
letterSpacing: "0.05em",
|
| 105 |
+
},
|
| 106 |
+
stepDescription: {
|
| 107 |
+
fontSize: "13px",
|
| 108 |
+
color: theme.textSecondary,
|
| 109 |
+
lineHeight: "1.5",
|
| 110 |
+
margin: 0,
|
| 111 |
+
},
|
| 112 |
+
fileList: {
|
| 113 |
+
marginTop: "8px",
|
| 114 |
+
display: "flex",
|
| 115 |
+
flexDirection: "column",
|
| 116 |
+
gap: "4px",
|
| 117 |
+
backgroundColor: "#131316",
|
| 118 |
+
padding: "8px 12px",
|
| 119 |
+
borderRadius: "6px",
|
| 120 |
+
border: `1px solid ${theme.border}`,
|
| 121 |
+
},
|
| 122 |
+
fileItem: {
|
| 123 |
+
display: "flex",
|
| 124 |
+
alignItems: "center",
|
| 125 |
+
gap: "10px",
|
| 126 |
+
fontSize: "12px",
|
| 127 |
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
| 128 |
+
},
|
| 129 |
+
actionBadge: {
|
| 130 |
+
padding: "2px 6px",
|
| 131 |
+
borderRadius: "4px",
|
| 132 |
+
fontSize: "10px",
|
| 133 |
+
fontWeight: "bold",
|
| 134 |
+
textTransform: "uppercase",
|
| 135 |
+
minWidth: "55px",
|
| 136 |
+
textAlign: "center",
|
| 137 |
+
letterSpacing: "0.02em",
|
| 138 |
+
},
|
| 139 |
+
path: {
|
| 140 |
+
color: "#D4D4D8",
|
| 141 |
+
whiteSpace: "nowrap",
|
| 142 |
+
overflow: "hidden",
|
| 143 |
+
textOverflow: "ellipsis",
|
| 144 |
+
},
|
| 145 |
+
risks: {
|
| 146 |
+
marginTop: "8px",
|
| 147 |
+
fontSize: "12px",
|
| 148 |
+
color: theme.warningText,
|
| 149 |
+
backgroundColor: "rgba(245, 158, 11, 0.05)",
|
| 150 |
+
padding: "8px 12px",
|
| 151 |
+
borderRadius: "6px",
|
| 152 |
+
border: "1px solid rgba(245, 158, 11, 0.1)",
|
| 153 |
+
display: "flex",
|
| 154 |
+
gap: "6px",
|
| 155 |
+
alignItems: "flex-start",
|
| 156 |
+
},
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
const getActionStyle = (action) => {
|
| 160 |
+
switch (action) {
|
| 161 |
+
case "CREATE": return styles.totalCreate;
|
| 162 |
+
case "MODIFY": return styles.totalModify;
|
| 163 |
+
case "DELETE": return styles.totalDelete;
|
| 164 |
+
default: return {};
|
| 165 |
+
}
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
return (
|
| 169 |
+
<div style={styles.container}>
|
| 170 |
+
{/* Header & Summary */}
|
| 171 |
+
<div style={styles.header}>
|
| 172 |
+
<div style={styles.goal}>Goal: {plan.goal}</div>
|
| 173 |
+
<div style={styles.summary}>{plan.summary}</div>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
{/* Totals Summary */}
|
| 177 |
+
<div style={styles.totals}>
|
| 178 |
+
{totals.CREATE > 0 && (
|
| 179 |
+
<span style={{ ...styles.totalBadge, ...styles.totalCreate }}>
|
| 180 |
+
{totals.CREATE} to create
|
| 181 |
+
</span>
|
| 182 |
+
)}
|
| 183 |
+
{totals.MODIFY > 0 && (
|
| 184 |
+
<span style={{ ...styles.totalBadge, ...styles.totalModify }}>
|
| 185 |
+
{totals.MODIFY} to modify
|
| 186 |
+
</span>
|
| 187 |
+
)}
|
| 188 |
+
{totals.DELETE > 0 && (
|
| 189 |
+
<span style={{ ...styles.totalBadge, ...styles.totalDelete }}>
|
| 190 |
+
{totals.DELETE} to delete
|
| 191 |
+
</span>
|
| 192 |
+
)}
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
{/* Steps List */}
|
| 196 |
+
<ol style={styles.stepsList}>
|
| 197 |
+
{plan.steps.map((s) => (
|
| 198 |
+
<li key={s.step_number} style={styles.step}>
|
| 199 |
+
<div style={styles.stepHeader}>
|
| 200 |
+
<span style={styles.stepNumber}>Step {s.step_number}</span>
|
| 201 |
+
<span>{s.title}</span>
|
| 202 |
+
</div>
|
| 203 |
+
<p style={styles.stepDescription}>{s.description}</p>
|
| 204 |
+
|
| 205 |
+
{/* Files List */}
|
| 206 |
+
{s.files && s.files.length > 0 && (
|
| 207 |
+
<div style={styles.fileList}>
|
| 208 |
+
{s.files.map((file, idx) => (
|
| 209 |
+
<div key={idx} style={styles.fileItem}>
|
| 210 |
+
<span style={{ ...styles.actionBadge, ...getActionStyle(file.action) }}>
|
| 211 |
+
{file.action}
|
| 212 |
+
</span>
|
| 213 |
+
<span style={styles.path}>{file.path}</span>
|
| 214 |
+
</div>
|
| 215 |
+
))}
|
| 216 |
+
</div>
|
| 217 |
+
)}
|
| 218 |
+
|
| 219 |
+
{/* Risks */}
|
| 220 |
+
{s.risks && (
|
| 221 |
+
<div style={styles.risks}>
|
| 222 |
+
<span>β οΈ</span>
|
| 223 |
+
<span>{s.risks}</span>
|
| 224 |
+
</div>
|
| 225 |
+
)}
|
| 226 |
+
</li>
|
| 227 |
+
))}
|
| 228 |
+
</ol>
|
| 229 |
+
</div>
|
| 230 |
+
);
|
| 231 |
+
}
|
frontend/components/ProjectContextPanel.jsx
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
+
import FileTree from "./FileTree.jsx";
|
| 3 |
+
import BranchPicker from "./BranchPicker.jsx";
|
| 4 |
+
|
| 5 |
+
// --- INJECTED STYLES FOR ANIMATIONS ---
|
| 6 |
+
const animationStyles = `
|
| 7 |
+
@keyframes highlight-pulse {
|
| 8 |
+
0% { background-color: rgba(59, 130, 246, 0.10); }
|
| 9 |
+
50% { background-color: rgba(59, 130, 246, 0.22); }
|
| 10 |
+
100% { background-color: transparent; }
|
| 11 |
+
}
|
| 12 |
+
.pulse-context {
|
| 13 |
+
animation: highlight-pulse 1.1s ease-out;
|
| 14 |
+
}
|
| 15 |
+
`;
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* ProjectContextPanel (Production-ready)
|
| 19 |
+
*
|
| 20 |
+
* Controlled component:
|
| 21 |
+
* - Branch source of truth is App.jsx:
|
| 22 |
+
* - defaultBranch (prod)
|
| 23 |
+
* - currentBranch (what user sees)
|
| 24 |
+
* - sessionBranches (list of all active AI session branches)
|
| 25 |
+
*
|
| 26 |
+
* Responsibilities:
|
| 27 |
+
* - Show project context + branch dropdown + AI badge/banner
|
| 28 |
+
* - Fetch access status + file count for the currentBranch
|
| 29 |
+
* - Trigger visual pulse on pulseNonce (Hard Switch)
|
| 30 |
+
*/
|
| 31 |
+
export default function ProjectContextPanel({
|
| 32 |
+
repo,
|
| 33 |
+
defaultBranch,
|
| 34 |
+
currentBranch,
|
| 35 |
+
sessionBranch, // Active session branch (optional, for specific highlighting)
|
| 36 |
+
sessionBranches = [], // List of all AI branches
|
| 37 |
+
onBranchChange,
|
| 38 |
+
pulseNonce,
|
| 39 |
+
onSettingsClick,
|
| 40 |
+
}) {
|
| 41 |
+
const [appUrl, setAppUrl] = useState("");
|
| 42 |
+
const [fileCount, setFileCount] = useState(0);
|
| 43 |
+
|
| 44 |
+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
| 45 |
+
|
| 46 |
+
// Data Loading State
|
| 47 |
+
const [analyzing, setAnalyzing] = useState(false);
|
| 48 |
+
const [accessInfo, setAccessInfo] = useState(null);
|
| 49 |
+
const [treeError, setTreeError] = useState(null);
|
| 50 |
+
|
| 51 |
+
// Retry / Refresh Logic
|
| 52 |
+
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
| 53 |
+
const [retryCount, setRetryCount] = useState(0);
|
| 54 |
+
const retryTimeoutRef = useRef(null);
|
| 55 |
+
|
| 56 |
+
// UX State
|
| 57 |
+
const [animateHeader, setAnimateHeader] = useState(false);
|
| 58 |
+
const [toast, setToast] = useState({ visible: false, title: "", msg: "" });
|
| 59 |
+
|
| 60 |
+
// Calculate effective default to prevent 'main' fallback errors
|
| 61 |
+
const effectiveDefaultBranch = defaultBranch || repo?.default_branch || "main";
|
| 62 |
+
const branch = currentBranch || effectiveDefaultBranch;
|
| 63 |
+
|
| 64 |
+
// Determine if we are currently viewing an AI Session branch
|
| 65 |
+
const isAiSession = (sessionBranches.includes(branch)) || (sessionBranch === branch && branch !== effectiveDefaultBranch);
|
| 66 |
+
|
| 67 |
+
// Fetch App URL on mount
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
fetch("/api/auth/app-url")
|
| 70 |
+
.then((res) => res.json())
|
| 71 |
+
.then((data) => {
|
| 72 |
+
if (data.app_url) setAppUrl(data.app_url);
|
| 73 |
+
})
|
| 74 |
+
.catch((err) => console.error("Failed to fetch App URL:", err));
|
| 75 |
+
}, []);
|
| 76 |
+
|
| 77 |
+
// Hard Switch pulse: whenever App increments pulseNonce
|
| 78 |
+
useEffect(() => {
|
| 79 |
+
if (!pulseNonce) return;
|
| 80 |
+
setAnimateHeader(true);
|
| 81 |
+
const t = window.setTimeout(() => setAnimateHeader(false), 1100);
|
| 82 |
+
return () => window.clearTimeout(t);
|
| 83 |
+
}, [pulseNonce]);
|
| 84 |
+
|
| 85 |
+
// Main data fetcher (Access + Tree stats) for currentBranch
|
| 86 |
+
// Stale-while-revalidate: keep previous data visible during fetch
|
| 87 |
+
useEffect(() => {
|
| 88 |
+
if (!repo) return;
|
| 89 |
+
|
| 90 |
+
// Only show full "analyzing" spinner if we have no data yet
|
| 91 |
+
if (!accessInfo) setAnalyzing(true);
|
| 92 |
+
setTreeError(null);
|
| 93 |
+
|
| 94 |
+
if (retryTimeoutRef.current) {
|
| 95 |
+
clearTimeout(retryTimeoutRef.current);
|
| 96 |
+
retryTimeoutRef.current = null;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
let headers = {};
|
| 100 |
+
try {
|
| 101 |
+
const token = localStorage.getItem("github_token");
|
| 102 |
+
if (token) headers = { Authorization: `Bearer ${token}` };
|
| 103 |
+
} catch (e) {
|
| 104 |
+
console.warn("Unable to read github_token:", e);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
let cancelled = false;
|
| 108 |
+
const cacheBuster = `&_t=${Date.now()}&retry=${retryCount}`;
|
| 109 |
+
|
| 110 |
+
// A) Access Check (with Stale Cache Fix)
|
| 111 |
+
fetch(`/api/auth/repo-access?owner=${repo.owner}&repo=${repo.name}${cacheBuster}`, {
|
| 112 |
+
headers,
|
| 113 |
+
cache: "no-cache",
|
| 114 |
+
})
|
| 115 |
+
.then(async (res) => {
|
| 116 |
+
if (cancelled) return;
|
| 117 |
+
const data = await res.json().catch(() => ({}));
|
| 118 |
+
|
| 119 |
+
if (!res.ok) {
|
| 120 |
+
setAccessInfo({ can_write: false, app_installed: false, auth_type: "none" });
|
| 121 |
+
return;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
setAccessInfo(data);
|
| 125 |
+
|
| 126 |
+
// Auto-retry if user has push access but App is not detected yet (Stale Cache)
|
| 127 |
+
if (data.can_write && !data.app_installed && retryCount === 0) {
|
| 128 |
+
retryTimeoutRef.current = setTimeout(() => {
|
| 129 |
+
setRetryCount(1);
|
| 130 |
+
}, 1000);
|
| 131 |
+
}
|
| 132 |
+
})
|
| 133 |
+
.catch(() => {
|
| 134 |
+
if (!cancelled) setAccessInfo({ can_write: false, app_installed: false, auth_type: "none" });
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
// B) Tree count for the selected branch
|
| 138 |
+
// Don't clear fileCount β keep stale value visible until new one arrives
|
| 139 |
+
const hadFileCount = fileCount > 0;
|
| 140 |
+
if (!hadFileCount) setAnalyzing(true);
|
| 141 |
+
|
| 142 |
+
fetch(`/api/repos/${repo.owner}/${repo.name}/tree?ref=${encodeURIComponent(branch)}&_t=${Date.now()}`, {
|
| 143 |
+
headers,
|
| 144 |
+
cache: "no-cache",
|
| 145 |
+
})
|
| 146 |
+
.then(async (res) => {
|
| 147 |
+
if (cancelled) return;
|
| 148 |
+
const data = await res.json().catch(() => ({}));
|
| 149 |
+
if (!res.ok) {
|
| 150 |
+
setTreeError(data.detail || "Failed to load tree");
|
| 151 |
+
setFileCount(0);
|
| 152 |
+
return;
|
| 153 |
+
}
|
| 154 |
+
setFileCount(Array.isArray(data.files) ? data.files.length : 0);
|
| 155 |
+
})
|
| 156 |
+
.catch((err) => {
|
| 157 |
+
if (cancelled) return;
|
| 158 |
+
setTreeError(err.message);
|
| 159 |
+
setFileCount(0);
|
| 160 |
+
})
|
| 161 |
+
.finally(() => { if (!cancelled) setAnalyzing(false); });
|
| 162 |
+
|
| 163 |
+
return () => {
|
| 164 |
+
cancelled = true;
|
| 165 |
+
if (retryTimeoutRef.current) clearTimeout(retryTimeoutRef.current);
|
| 166 |
+
};
|
| 167 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 168 |
+
}, [repo?.owner, repo?.name, branch, refreshTrigger, retryCount]);
|
| 169 |
+
|
| 170 |
+
const showToast = (title, msg) => {
|
| 171 |
+
setToast({ visible: true, title, msg });
|
| 172 |
+
setTimeout(() => setToast((prev) => ({ ...prev, visible: false })), 3000);
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
const handleManualSwitch = (targetBranch) => {
|
| 176 |
+
if (!targetBranch || targetBranch === branch) {
|
| 177 |
+
setIsDropdownOpen(false);
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// Local UI feedback (App.jsx will handle the actual state change)
|
| 182 |
+
const goingAi = sessionBranches.includes(targetBranch);
|
| 183 |
+
showToast(
|
| 184 |
+
goingAi ? "Context Switched" : "Switched to Production",
|
| 185 |
+
goingAi ? `Viewing AI Session: ${targetBranch}` : `Viewing ${targetBranch}.`
|
| 186 |
+
);
|
| 187 |
+
|
| 188 |
+
setIsDropdownOpen(false);
|
| 189 |
+
if (onBranchChange) onBranchChange(targetBranch);
|
| 190 |
+
};
|
| 191 |
+
|
| 192 |
+
const handleRefresh = () => {
|
| 193 |
+
setAnalyzing(true);
|
| 194 |
+
setRetryCount(0);
|
| 195 |
+
setRefreshTrigger((prev) => prev + 1);
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
const handleInstallClick = () => {
|
| 199 |
+
if (!appUrl) return;
|
| 200 |
+
const targetUrl = appUrl.endsWith("/") ? `${appUrl}installations/new` : `${appUrl}/installations/new`;
|
| 201 |
+
window.open(targetUrl, "_blank", "noopener,noreferrer");
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
// --- STYLES ---
|
| 205 |
+
const theme = useMemo(
|
| 206 |
+
() => ({
|
| 207 |
+
bg: "#131316",
|
| 208 |
+
border: "#27272A",
|
| 209 |
+
textPrimary: "#EDEDED",
|
| 210 |
+
textSecondary: "#A1A1AA",
|
| 211 |
+
accent: "#3b82f6",
|
| 212 |
+
warningBorder: "rgba(245, 158, 11, 0.2)",
|
| 213 |
+
warningText: "#F59E0B",
|
| 214 |
+
successColor: "#10B981",
|
| 215 |
+
cardBg: "#18181B",
|
| 216 |
+
aiBg: "rgba(59, 130, 246, 0.10)",
|
| 217 |
+
aiBorder: "rgba(59, 130, 246, 0.30)",
|
| 218 |
+
aiText: "#60a5fa",
|
| 219 |
+
}),
|
| 220 |
+
[]
|
| 221 |
+
);
|
| 222 |
+
|
| 223 |
+
const styles = useMemo(
|
| 224 |
+
() => ({
|
| 225 |
+
container: {
|
| 226 |
+
height: "100%",
|
| 227 |
+
borderRight: `1px solid ${theme.border}`,
|
| 228 |
+
backgroundColor: theme.bg,
|
| 229 |
+
display: "flex",
|
| 230 |
+
flexDirection: "column",
|
| 231 |
+
fontFamily: '"SΓΆhne", "Inter", sans-serif',
|
| 232 |
+
position: "relative",
|
| 233 |
+
overflow: "hidden",
|
| 234 |
+
},
|
| 235 |
+
header: {
|
| 236 |
+
padding: "16px 20px",
|
| 237 |
+
borderBottom: `1px solid ${theme.border}`,
|
| 238 |
+
display: "flex",
|
| 239 |
+
alignItems: "center",
|
| 240 |
+
justifyContent: "space-between",
|
| 241 |
+
transition: "background-color 0.3s ease",
|
| 242 |
+
},
|
| 243 |
+
titleGroup: { display: "flex", alignItems: "center", gap: "8px" },
|
| 244 |
+
title: { fontSize: "13px", fontWeight: "600", color: theme.textPrimary },
|
| 245 |
+
repoBadge: {
|
| 246 |
+
backgroundColor: "#27272A",
|
| 247 |
+
color: theme.textSecondary,
|
| 248 |
+
fontSize: "11px",
|
| 249 |
+
padding: "2px 8px",
|
| 250 |
+
borderRadius: "12px",
|
| 251 |
+
border: `1px solid ${theme.border}`,
|
| 252 |
+
fontFamily: "monospace",
|
| 253 |
+
},
|
| 254 |
+
aiBadge: {
|
| 255 |
+
display: "flex",
|
| 256 |
+
alignItems: "center",
|
| 257 |
+
gap: "6px",
|
| 258 |
+
backgroundColor: theme.aiBg,
|
| 259 |
+
color: theme.aiText,
|
| 260 |
+
fontSize: "10px",
|
| 261 |
+
fontWeight: "bold",
|
| 262 |
+
padding: "2px 8px",
|
| 263 |
+
borderRadius: "12px",
|
| 264 |
+
border: `1px solid ${theme.aiBorder}`,
|
| 265 |
+
textTransform: "uppercase",
|
| 266 |
+
letterSpacing: "0.5px",
|
| 267 |
+
},
|
| 268 |
+
content: {
|
| 269 |
+
padding: "16px 20px 12px 20px",
|
| 270 |
+
display: "flex",
|
| 271 |
+
flexDirection: "column",
|
| 272 |
+
gap: "12px",
|
| 273 |
+
},
|
| 274 |
+
statRow: { display: "flex", justifyContent: "space-between", fontSize: "13px", marginBottom: "4px" },
|
| 275 |
+
label: { color: theme.textSecondary },
|
| 276 |
+
value: { color: theme.textPrimary, fontWeight: "500" },
|
| 277 |
+
dropdownContainer: { position: "relative" },
|
| 278 |
+
branchButton: {
|
| 279 |
+
display: "flex",
|
| 280 |
+
alignItems: "center",
|
| 281 |
+
gap: "6px",
|
| 282 |
+
padding: "4px 8px",
|
| 283 |
+
borderRadius: "4px",
|
| 284 |
+
border: `1px solid ${isAiSession ? theme.aiBorder : theme.border}`,
|
| 285 |
+
backgroundColor: isAiSession ? "rgba(59, 130, 246, 0.05)" : "transparent",
|
| 286 |
+
color: isAiSession ? theme.aiText : theme.textPrimary,
|
| 287 |
+
fontSize: "13px",
|
| 288 |
+
cursor: "pointer",
|
| 289 |
+
fontFamily: "monospace",
|
| 290 |
+
},
|
| 291 |
+
dropdownMenu: {
|
| 292 |
+
position: "absolute",
|
| 293 |
+
top: "100%",
|
| 294 |
+
left: 0,
|
| 295 |
+
marginTop: "4px",
|
| 296 |
+
width: "240px",
|
| 297 |
+
backgroundColor: "#1F1F23",
|
| 298 |
+
border: `1px solid ${theme.border}`,
|
| 299 |
+
borderRadius: "6px",
|
| 300 |
+
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
|
| 301 |
+
zIndex: 50,
|
| 302 |
+
display: isDropdownOpen ? "block" : "none",
|
| 303 |
+
overflow: "hidden",
|
| 304 |
+
},
|
| 305 |
+
dropdownItem: {
|
| 306 |
+
padding: "8px 12px",
|
| 307 |
+
fontSize: "13px",
|
| 308 |
+
color: theme.textSecondary,
|
| 309 |
+
cursor: "pointer",
|
| 310 |
+
display: "flex",
|
| 311 |
+
alignItems: "center",
|
| 312 |
+
gap: "8px",
|
| 313 |
+
borderBottom: `1px solid ${theme.border}`,
|
| 314 |
+
},
|
| 315 |
+
contextBanner: {
|
| 316 |
+
backgroundColor: theme.aiBg,
|
| 317 |
+
borderTop: `1px solid ${theme.aiBorder}`,
|
| 318 |
+
padding: "8px 20px",
|
| 319 |
+
fontSize: "11px",
|
| 320 |
+
color: theme.aiText,
|
| 321 |
+
display: "flex",
|
| 322 |
+
justifyContent: "space-between",
|
| 323 |
+
alignItems: "center",
|
| 324 |
+
},
|
| 325 |
+
toast: {
|
| 326 |
+
position: "absolute",
|
| 327 |
+
top: "16px",
|
| 328 |
+
right: "16px",
|
| 329 |
+
backgroundColor: "#18181B",
|
| 330 |
+
border: `1px solid ${theme.border}`,
|
| 331 |
+
borderLeft: `3px solid ${theme.accent}`,
|
| 332 |
+
borderRadius: "6px",
|
| 333 |
+
padding: "12px",
|
| 334 |
+
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
|
| 335 |
+
zIndex: 100,
|
| 336 |
+
minWidth: "240px",
|
| 337 |
+
transition: "all 0.3s cubic-bezier(0.16, 1, 0.3, 1)",
|
| 338 |
+
transform: toast.visible ? "translateX(0)" : "translateX(120%)",
|
| 339 |
+
opacity: toast.visible ? 1 : 0,
|
| 340 |
+
},
|
| 341 |
+
toastTitle: { fontSize: "13px", fontWeight: "bold", color: theme.textPrimary, marginBottom: "2px" },
|
| 342 |
+
toastMsg: { fontSize: "11px", color: theme.textSecondary },
|
| 343 |
+
refreshButton: {
|
| 344 |
+
marginTop: "8px",
|
| 345 |
+
height: "32px",
|
| 346 |
+
padding: "0 12px",
|
| 347 |
+
backgroundColor: "transparent",
|
| 348 |
+
color: theme.textSecondary,
|
| 349 |
+
border: `1px solid ${theme.border}`,
|
| 350 |
+
borderRadius: "6px",
|
| 351 |
+
fontSize: "12px",
|
| 352 |
+
cursor: analyzing ? "not-allowed" : "pointer",
|
| 353 |
+
display: "flex",
|
| 354 |
+
alignItems: "center",
|
| 355 |
+
justifyContent: "center",
|
| 356 |
+
gap: "6px",
|
| 357 |
+
},
|
| 358 |
+
settingsBtn: {
|
| 359 |
+
display: "flex",
|
| 360 |
+
alignItems: "center",
|
| 361 |
+
justifyContent: "center",
|
| 362 |
+
width: "28px",
|
| 363 |
+
height: "28px",
|
| 364 |
+
borderRadius: "6px",
|
| 365 |
+
border: `1px solid ${theme.border}`,
|
| 366 |
+
backgroundColor: "transparent",
|
| 367 |
+
color: theme.textSecondary,
|
| 368 |
+
cursor: "pointer",
|
| 369 |
+
padding: 0,
|
| 370 |
+
transition: "color 0.15s, border-color 0.15s",
|
| 371 |
+
},
|
| 372 |
+
treeWrapper: { flex: 1, overflow: "auto", borderTop: `1px solid ${theme.border}` },
|
| 373 |
+
installCard: {
|
| 374 |
+
marginTop: "8px",
|
| 375 |
+
padding: "12px",
|
| 376 |
+
borderRadius: "8px",
|
| 377 |
+
backgroundColor: theme.cardBg,
|
| 378 |
+
border: `1px solid ${theme.warningBorder}`,
|
| 379 |
+
},
|
| 380 |
+
installHeader: {
|
| 381 |
+
display: "flex",
|
| 382 |
+
alignItems: "center",
|
| 383 |
+
gap: "10px",
|
| 384 |
+
fontSize: "14px",
|
| 385 |
+
fontWeight: "600",
|
| 386 |
+
color: theme.textPrimary,
|
| 387 |
+
},
|
| 388 |
+
installText: {
|
| 389 |
+
fontSize: "13px",
|
| 390 |
+
color: theme.textSecondary,
|
| 391 |
+
lineHeight: "1.5",
|
| 392 |
+
},
|
| 393 |
+
}),
|
| 394 |
+
[analyzing, isAiSession, isDropdownOpen, theme, toast.visible]
|
| 395 |
+
);
|
| 396 |
+
|
| 397 |
+
// Determine status text
|
| 398 |
+
let statusText = "Checking...";
|
| 399 |
+
let statusColor = theme.textSecondary;
|
| 400 |
+
let showInstallCard = false;
|
| 401 |
+
|
| 402 |
+
if (!analyzing && accessInfo) {
|
| 403 |
+
if (accessInfo.app_installed) {
|
| 404 |
+
statusText = "Write Access β";
|
| 405 |
+
statusColor = theme.successColor;
|
| 406 |
+
} else if (accessInfo.can_write && retryCount === 0) {
|
| 407 |
+
statusText = "Verifying...";
|
| 408 |
+
} else if (accessInfo.can_write) {
|
| 409 |
+
statusText = "Push Access (No App)";
|
| 410 |
+
statusColor = theme.warningText;
|
| 411 |
+
showInstallCard = true;
|
| 412 |
+
} else {
|
| 413 |
+
statusText = "Read Only";
|
| 414 |
+
statusColor = theme.warningText;
|
| 415 |
+
showInstallCard = true;
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
if (!repo) {
|
| 420 |
+
return (
|
| 421 |
+
<div style={styles.container}>
|
| 422 |
+
<div style={styles.content}>Select a Repo</div>
|
| 423 |
+
</div>
|
| 424 |
+
);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
return (
|
| 428 |
+
<div style={styles.container}>
|
| 429 |
+
<style>{animationStyles}</style>
|
| 430 |
+
|
| 431 |
+
{/* TOAST */}
|
| 432 |
+
<div style={styles.toast}>
|
| 433 |
+
<div style={styles.toastTitle}>{toast.title}</div>
|
| 434 |
+
<div style={styles.toastMsg}>{toast.msg}</div>
|
| 435 |
+
</div>
|
| 436 |
+
|
| 437 |
+
{/* HEADER */}
|
| 438 |
+
<div style={styles.header} className={animateHeader ? "pulse-context" : ""}>
|
| 439 |
+
<div style={styles.titleGroup}>
|
| 440 |
+
<span style={styles.title}>Project context</span>
|
| 441 |
+
{isAiSession && (
|
| 442 |
+
<span style={styles.aiBadge}>
|
| 443 |
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
| 444 |
+
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
| 445 |
+
</svg>
|
| 446 |
+
AI Session
|
| 447 |
+
</span>
|
| 448 |
+
)}
|
| 449 |
+
</div>
|
| 450 |
+
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
| 451 |
+
{!isAiSession && <span style={styles.repoBadge}>{repo.name}</span>}
|
| 452 |
+
{onSettingsClick && (
|
| 453 |
+
<button
|
| 454 |
+
type="button"
|
| 455 |
+
onClick={onSettingsClick}
|
| 456 |
+
title="Project settings"
|
| 457 |
+
style={styles.settingsBtn}
|
| 458 |
+
>
|
| 459 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 460 |
+
<circle cx="12" cy="12" r="3" />
|
| 461 |
+
<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" />
|
| 462 |
+
</svg>
|
| 463 |
+
</button>
|
| 464 |
+
)}
|
| 465 |
+
</div>
|
| 466 |
+
</div>
|
| 467 |
+
|
| 468 |
+
{/* CONTENT */}
|
| 469 |
+
<div style={styles.content}>
|
| 470 |
+
{/* Branch selector (Claude-Code-on-Web parity β uses BranchPicker with search) */}
|
| 471 |
+
<div style={styles.statRow}>
|
| 472 |
+
<span style={styles.label}>Branch:</span>
|
| 473 |
+
<BranchPicker
|
| 474 |
+
repo={repo}
|
| 475 |
+
currentBranch={branch}
|
| 476 |
+
defaultBranch={effectiveDefaultBranch}
|
| 477 |
+
sessionBranches={sessionBranches}
|
| 478 |
+
onBranchChange={handleManualSwitch}
|
| 479 |
+
/>
|
| 480 |
+
</div>
|
| 481 |
+
|
| 482 |
+
{/* Stats */}
|
| 483 |
+
<div style={styles.statRow}>
|
| 484 |
+
<span style={styles.label}>Files:</span>
|
| 485 |
+
<span style={styles.value}>{analyzing ? "β¦" : fileCount}</span>
|
| 486 |
+
</div>
|
| 487 |
+
|
| 488 |
+
<div style={styles.statRow}>
|
| 489 |
+
<span style={styles.label}>Status:</span>
|
| 490 |
+
<span style={{ ...styles.value, color: statusColor }}>{statusText}</span>
|
| 491 |
+
</div>
|
| 492 |
+
|
| 493 |
+
{/* Tree error (optional display) */}
|
| 494 |
+
{treeError && (
|
| 495 |
+
<div style={{ fontSize: 11, color: theme.warningText }}>
|
| 496 |
+
{treeError}
|
| 497 |
+
</div>
|
| 498 |
+
)}
|
| 499 |
+
|
| 500 |
+
{/* Refresh */}
|
| 501 |
+
<button type="button" style={styles.refreshButton} onClick={handleRefresh} disabled={analyzing}>
|
| 502 |
+
<svg
|
| 503 |
+
width="14"
|
| 504 |
+
height="14"
|
| 505 |
+
viewBox="0 0 24 24"
|
| 506 |
+
fill="none"
|
| 507 |
+
stroke="currentColor"
|
| 508 |
+
strokeWidth="2"
|
| 509 |
+
style={{
|
| 510 |
+
transform: analyzing ? "rotate(360deg)" : "rotate(0deg)",
|
| 511 |
+
transition: "transform 0.6s ease",
|
| 512 |
+
}}
|
| 513 |
+
>
|
| 514 |
+
<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" />
|
| 515 |
+
</svg>
|
| 516 |
+
{analyzing ? "Refreshing..." : "Refresh"}
|
| 517 |
+
</button>
|
| 518 |
+
|
| 519 |
+
{/* Install card */}
|
| 520 |
+
{showInstallCard && (
|
| 521 |
+
<div style={styles.installCard}>
|
| 522 |
+
<div style={styles.installHeader}>
|
| 523 |
+
<span>β‘</span>
|
| 524 |
+
<span>Enable Write Access</span>
|
| 525 |
+
</div>
|
| 526 |
+
<p style={{ ...styles.installText, margin: "8px 0" }}>
|
| 527 |
+
Install the GitPilot App to enable AI agent operations.
|
| 528 |
+
</p>
|
| 529 |
+
<p style={{ ...styles.installText, margin: "0 0 8px 0", fontSize: "11px", opacity: 0.7 }}>
|
| 530 |
+
Alternatively, use Folder or Local Git mode for local-first workflows without GitHub.
|
| 531 |
+
</p>
|
| 532 |
+
<button
|
| 533 |
+
type="button"
|
| 534 |
+
style={{
|
| 535 |
+
...styles.refreshButton,
|
| 536 |
+
width: "100%",
|
| 537 |
+
backgroundColor: theme.accent,
|
| 538 |
+
color: "#fff",
|
| 539 |
+
border: "none",
|
| 540 |
+
}}
|
| 541 |
+
onClick={handleInstallClick}
|
| 542 |
+
>
|
| 543 |
+
Install App
|
| 544 |
+
</button>
|
| 545 |
+
</div>
|
| 546 |
+
)}
|
| 547 |
+
</div>
|
| 548 |
+
|
| 549 |
+
{/* Context banner */}
|
| 550 |
+
{isAiSession && (
|
| 551 |
+
<div style={styles.contextBanner}>
|
| 552 |
+
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
| 553 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 554 |
+
<circle cx="12" cy="12" r="10"></circle>
|
| 555 |
+
<line x1="12" y1="16" x2="12" y2="12"></line>
|
| 556 |
+
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
| 557 |
+
</svg>
|
| 558 |
+
You are viewing an AI Session branch.
|
| 559 |
+
</span>
|
| 560 |
+
<span style={{ textDecoration: "underline", cursor: "pointer" }} onClick={() => handleManualSwitch(effectiveDefaultBranch)}>
|
| 561 |
+
Return to {effectiveDefaultBranch}
|
| 562 |
+
</span>
|
| 563 |
+
</div>
|
| 564 |
+
)}
|
| 565 |
+
|
| 566 |
+
{/* File tree (branch-aware) */}
|
| 567 |
+
<div style={styles.treeWrapper}>
|
| 568 |
+
<FileTree repo={repo} refreshTrigger={refreshTrigger} branch={branch} />
|
| 569 |
+
</div>
|
| 570 |
+
</div>
|
| 571 |
+
);
|
| 572 |
+
}
|
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/SessionItem.jsx
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from "react";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* SessionItem β a single row in the sessions sidebar.
|
| 5 |
+
*
|
| 6 |
+
* Shows status dot (pulsing/static), title, timestamp, message count.
|
| 7 |
+
* Claude-Code-on-Web parity: active=amber pulse, completed=green,
|
| 8 |
+
* failed=red, waiting=blue.
|
| 9 |
+
*/
|
| 10 |
+
export default function SessionItem({ session, isActive, onSelect, onDelete }) {
|
| 11 |
+
const [hovering, setHovering] = useState(false);
|
| 12 |
+
|
| 13 |
+
const status = session.status || "active";
|
| 14 |
+
|
| 15 |
+
const dotColor = {
|
| 16 |
+
active: "#F59E0B",
|
| 17 |
+
completed: "#10B981",
|
| 18 |
+
failed: "#EF4444",
|
| 19 |
+
waiting: "#3B82F6",
|
| 20 |
+
paused: "#6B7280",
|
| 21 |
+
}[status] || "#6B7280";
|
| 22 |
+
|
| 23 |
+
const isPulsing = status === "active";
|
| 24 |
+
|
| 25 |
+
const timeAgo = formatTimeAgo(session.updated_at);
|
| 26 |
+
|
| 27 |
+
// Prefer name (set from first user prompt) over generic fallback
|
| 28 |
+
const title =
|
| 29 |
+
session.name ||
|
| 30 |
+
(session.branch ? `${session.branch}` : `Session ${session.id?.slice(0, 8)}`);
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div
|
| 34 |
+
style={{
|
| 35 |
+
...styles.row,
|
| 36 |
+
backgroundColor: isActive
|
| 37 |
+
? "rgba(59, 130, 246, 0.08)"
|
| 38 |
+
: hovering
|
| 39 |
+
? "rgba(255,255,255,0.03)"
|
| 40 |
+
: "transparent",
|
| 41 |
+
borderLeft: isActive ? "2px solid #3B82F6" : "2px solid transparent",
|
| 42 |
+
}}
|
| 43 |
+
onClick={onSelect}
|
| 44 |
+
onMouseEnter={() => setHovering(true)}
|
| 45 |
+
onMouseLeave={() => setHovering(false)}
|
| 46 |
+
>
|
| 47 |
+
<style>{`
|
| 48 |
+
@keyframes session-pulse {
|
| 49 |
+
0%, 100% { opacity: 1; }
|
| 50 |
+
50% { opacity: 0.4; }
|
| 51 |
+
}
|
| 52 |
+
`}</style>
|
| 53 |
+
|
| 54 |
+
{/* Status dot */}
|
| 55 |
+
<div
|
| 56 |
+
style={{
|
| 57 |
+
...styles.dot,
|
| 58 |
+
backgroundColor: dotColor,
|
| 59 |
+
animation: isPulsing ? "session-pulse 1.5s ease-in-out infinite" : "none",
|
| 60 |
+
}}
|
| 61 |
+
/>
|
| 62 |
+
|
| 63 |
+
{/* Content */}
|
| 64 |
+
<div style={styles.content}>
|
| 65 |
+
<div style={styles.title}>{title}</div>
|
| 66 |
+
<div style={styles.meta}>
|
| 67 |
+
{timeAgo}
|
| 68 |
+
{session.mode && (
|
| 69 |
+
<span style={{
|
| 70 |
+
...styles.badge,
|
| 71 |
+
background: session.mode === "github" ? "#1e3a5f" : "#2d2d1f",
|
| 72 |
+
color: session.mode === "github" ? "#60a5fa" : "#d4d48a",
|
| 73 |
+
}}>
|
| 74 |
+
{session.mode === "github" ? "GH" : session.mode === "local-git" ? "Git" : "Dir"}
|
| 75 |
+
</span>
|
| 76 |
+
)}
|
| 77 |
+
{session.message_count > 0 && (
|
| 78 |
+
<span style={styles.badge}>{session.message_count} msgs</span>
|
| 79 |
+
)}
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
{/* Delete button (on hover) */}
|
| 84 |
+
{hovering && (
|
| 85 |
+
<button
|
| 86 |
+
type="button"
|
| 87 |
+
style={styles.deleteBtn}
|
| 88 |
+
onClick={(e) => {
|
| 89 |
+
e.stopPropagation();
|
| 90 |
+
onDelete?.();
|
| 91 |
+
}}
|
| 92 |
+
title="Delete session"
|
| 93 |
+
>
|
| 94 |
+
×
|
| 95 |
+
</button>
|
| 96 |
+
)}
|
| 97 |
+
</div>
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function formatTimeAgo(isoStr) {
|
| 102 |
+
if (!isoStr) return "";
|
| 103 |
+
try {
|
| 104 |
+
const date = new Date(isoStr);
|
| 105 |
+
const now = new Date();
|
| 106 |
+
const diffMs = now - date;
|
| 107 |
+
const diffMin = Math.floor(diffMs / 60000);
|
| 108 |
+
if (diffMin < 1) return "just now";
|
| 109 |
+
if (diffMin < 60) return `${diffMin}m ago`;
|
| 110 |
+
const diffHr = Math.floor(diffMin / 60);
|
| 111 |
+
if (diffHr < 24) return `${diffHr}h ago`;
|
| 112 |
+
const diffDay = Math.floor(diffHr / 24);
|
| 113 |
+
return `${diffDay}d ago`;
|
| 114 |
+
} catch {
|
| 115 |
+
return "";
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const styles = {
|
| 120 |
+
row: {
|
| 121 |
+
display: "flex",
|
| 122 |
+
alignItems: "center",
|
| 123 |
+
gap: 8,
|
| 124 |
+
padding: "8px 10px",
|
| 125 |
+
borderRadius: 6,
|
| 126 |
+
cursor: "pointer",
|
| 127 |
+
transition: "background-color 0.15s",
|
| 128 |
+
position: "relative",
|
| 129 |
+
marginBottom: 2,
|
| 130 |
+
animation: "session-fade-in 0.25s ease-out",
|
| 131 |
+
},
|
| 132 |
+
dot: {
|
| 133 |
+
width: 8,
|
| 134 |
+
height: 8,
|
| 135 |
+
borderRadius: "50%",
|
| 136 |
+
flexShrink: 0,
|
| 137 |
+
},
|
| 138 |
+
content: {
|
| 139 |
+
flex: 1,
|
| 140 |
+
minWidth: 0,
|
| 141 |
+
overflow: "hidden",
|
| 142 |
+
},
|
| 143 |
+
title: {
|
| 144 |
+
fontSize: 12,
|
| 145 |
+
fontWeight: 500,
|
| 146 |
+
color: "#E4E4E7",
|
| 147 |
+
whiteSpace: "nowrap",
|
| 148 |
+
overflow: "hidden",
|
| 149 |
+
textOverflow: "ellipsis",
|
| 150 |
+
},
|
| 151 |
+
meta: {
|
| 152 |
+
fontSize: 10,
|
| 153 |
+
color: "#71717A",
|
| 154 |
+
marginTop: 2,
|
| 155 |
+
display: "flex",
|
| 156 |
+
alignItems: "center",
|
| 157 |
+
gap: 6,
|
| 158 |
+
},
|
| 159 |
+
badge: {
|
| 160 |
+
fontSize: 9,
|
| 161 |
+
background: "#27272A",
|
| 162 |
+
padding: "1px 5px",
|
| 163 |
+
borderRadius: 8,
|
| 164 |
+
color: "#A1A1AA",
|
| 165 |
+
},
|
| 166 |
+
deleteBtn: {
|
| 167 |
+
position: "absolute",
|
| 168 |
+
right: 6,
|
| 169 |
+
top: 6,
|
| 170 |
+
width: 18,
|
| 171 |
+
height: 18,
|
| 172 |
+
borderRadius: 3,
|
| 173 |
+
border: "none",
|
| 174 |
+
background: "rgba(239, 68, 68, 0.15)",
|
| 175 |
+
color: "#EF4444",
|
| 176 |
+
fontSize: 14,
|
| 177 |
+
cursor: "pointer",
|
| 178 |
+
display: "flex",
|
| 179 |
+
alignItems: "center",
|
| 180 |
+
justifyContent: "center",
|
| 181 |
+
lineHeight: 1,
|
| 182 |
+
},
|
| 183 |
+
};
|
frontend/components/SessionSidebar.jsx
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useRef, useState } from "react";
|
| 2 |
+
import SessionItem from "./SessionItem.jsx";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* SessionSidebar β Claude-Code-on-Web parity.
|
| 6 |
+
*
|
| 7 |
+
* Shows a scrollable list of coding sessions with status indicators,
|
| 8 |
+
* timestamps, and a "New Session" button. Additive β does not modify
|
| 9 |
+
* any existing component.
|
| 10 |
+
*/
|
| 11 |
+
export default function SessionSidebar({
|
| 12 |
+
repo,
|
| 13 |
+
activeSessionId,
|
| 14 |
+
onSelectSession,
|
| 15 |
+
onNewSession,
|
| 16 |
+
onDeleteSession,
|
| 17 |
+
refreshNonce = 0,
|
| 18 |
+
}) {
|
| 19 |
+
const [sessions, setSessions] = useState([]);
|
| 20 |
+
const [loading, setLoading] = useState(false);
|
| 21 |
+
const pollRef = useRef(null);
|
| 22 |
+
|
| 23 |
+
const repoFullName = repo?.full_name || (repo ? `${repo.owner}/${repo.name}` : null);
|
| 24 |
+
|
| 25 |
+
// Fetch sessions
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
if (!repoFullName) {
|
| 28 |
+
setSessions([]);
|
| 29 |
+
return;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
let cancelled = false;
|
| 33 |
+
|
| 34 |
+
const fetchSessions = async () => {
|
| 35 |
+
setLoading(true);
|
| 36 |
+
try {
|
| 37 |
+
const token = localStorage.getItem("github_token");
|
| 38 |
+
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
| 39 |
+
const res = await fetch(`/api/sessions`, { headers, cache: "no-cache" });
|
| 40 |
+
if (!res.ok) return;
|
| 41 |
+
const data = await res.json();
|
| 42 |
+
if (cancelled) return;
|
| 43 |
+
|
| 44 |
+
// Filter to current repo
|
| 45 |
+
const filtered = (data.sessions || []).filter(
|
| 46 |
+
(s) => s.repo === repoFullName
|
| 47 |
+
);
|
| 48 |
+
setSessions(filtered);
|
| 49 |
+
} catch (err) {
|
| 50 |
+
console.warn("Failed to fetch sessions:", err);
|
| 51 |
+
} finally {
|
| 52 |
+
if (!cancelled) setLoading(false);
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
fetchSessions();
|
| 57 |
+
|
| 58 |
+
// Poll every 15s for status updates
|
| 59 |
+
pollRef.current = setInterval(fetchSessions, 15000);
|
| 60 |
+
|
| 61 |
+
return () => {
|
| 62 |
+
cancelled = true;
|
| 63 |
+
if (pollRef.current) clearInterval(pollRef.current);
|
| 64 |
+
};
|
| 65 |
+
}, [repoFullName, refreshNonce]);
|
| 66 |
+
|
| 67 |
+
const handleDelete = async (sessionId) => {
|
| 68 |
+
try {
|
| 69 |
+
const token = localStorage.getItem("github_token");
|
| 70 |
+
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
| 71 |
+
await fetch(`/api/sessions/${sessionId}`, { method: "DELETE", headers });
|
| 72 |
+
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
| 73 |
+
// Notify parent so it can clear the chat if this was the active session
|
| 74 |
+
onDeleteSession?.(sessionId);
|
| 75 |
+
} catch (err) {
|
| 76 |
+
console.warn("Failed to delete session:", err);
|
| 77 |
+
}
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
return (
|
| 81 |
+
<div style={styles.container}>
|
| 82 |
+
<style>{animStyles}</style>
|
| 83 |
+
|
| 84 |
+
{/* Header */}
|
| 85 |
+
<div style={styles.header}>
|
| 86 |
+
<span style={styles.label}>SESSIONS</span>
|
| 87 |
+
<button
|
| 88 |
+
type="button"
|
| 89 |
+
style={styles.newBtn}
|
| 90 |
+
onClick={onNewSession}
|
| 91 |
+
title="New session"
|
| 92 |
+
>
|
| 93 |
+
+
|
| 94 |
+
</button>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
{/* Session list */}
|
| 98 |
+
<div style={styles.list}>
|
| 99 |
+
{loading && sessions.length === 0 && (
|
| 100 |
+
<div style={styles.empty}>Loading...</div>
|
| 101 |
+
)}
|
| 102 |
+
|
| 103 |
+
{!loading && sessions.length === 0 && (
|
| 104 |
+
<div style={styles.empty}>
|
| 105 |
+
No sessions yet.
|
| 106 |
+
<br />
|
| 107 |
+
<span style={{ fontSize: 11, opacity: 0.6 }}>
|
| 108 |
+
Your first message will create one automatically.
|
| 109 |
+
</span>
|
| 110 |
+
</div>
|
| 111 |
+
)}
|
| 112 |
+
|
| 113 |
+
{sessions.map((s) => (
|
| 114 |
+
<SessionItem
|
| 115 |
+
key={s.id}
|
| 116 |
+
session={s}
|
| 117 |
+
isActive={s.id === activeSessionId}
|
| 118 |
+
onSelect={() => onSelectSession?.(s)}
|
| 119 |
+
onDelete={() => handleDelete(s.id)}
|
| 120 |
+
/>
|
| 121 |
+
))}
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
const animStyles = `
|
| 128 |
+
@keyframes session-fade-in {
|
| 129 |
+
from { opacity: 0; transform: translateY(4px); }
|
| 130 |
+
to { opacity: 1; transform: translateY(0); }
|
| 131 |
+
}
|
| 132 |
+
`;
|
| 133 |
+
|
| 134 |
+
const styles = {
|
| 135 |
+
container: {
|
| 136 |
+
display: "flex",
|
| 137 |
+
flexDirection: "column",
|
| 138 |
+
borderTop: "1px solid #27272A",
|
| 139 |
+
flex: 1,
|
| 140 |
+
minHeight: 0,
|
| 141 |
+
},
|
| 142 |
+
header: {
|
| 143 |
+
display: "flex",
|
| 144 |
+
justifyContent: "space-between",
|
| 145 |
+
alignItems: "center",
|
| 146 |
+
padding: "10px 14px 6px",
|
| 147 |
+
},
|
| 148 |
+
label: {
|
| 149 |
+
fontSize: 10,
|
| 150 |
+
fontWeight: 700,
|
| 151 |
+
letterSpacing: "0.08em",
|
| 152 |
+
color: "#71717A",
|
| 153 |
+
textTransform: "uppercase",
|
| 154 |
+
},
|
| 155 |
+
newBtn: {
|
| 156 |
+
width: 22,
|
| 157 |
+
height: 22,
|
| 158 |
+
borderRadius: 4,
|
| 159 |
+
border: "1px dashed #3F3F46",
|
| 160 |
+
background: "transparent",
|
| 161 |
+
color: "#A1A1AA",
|
| 162 |
+
fontSize: 14,
|
| 163 |
+
cursor: "pointer",
|
| 164 |
+
display: "flex",
|
| 165 |
+
alignItems: "center",
|
| 166 |
+
justifyContent: "center",
|
| 167 |
+
lineHeight: 1,
|
| 168 |
+
},
|
| 169 |
+
list: {
|
| 170 |
+
flex: 1,
|
| 171 |
+
overflowY: "auto",
|
| 172 |
+
padding: "0 6px 8px",
|
| 173 |
+
},
|
| 174 |
+
empty: {
|
| 175 |
+
textAlign: "center",
|
| 176 |
+
color: "#52525B",
|
| 177 |
+
fontSize: 12,
|
| 178 |
+
padding: "20px 8px",
|
| 179 |
+
lineHeight: 1.5,
|
| 180 |
+
},
|
| 181 |
+
};
|
frontend/components/SettingsModal.jsx
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from "react";
|
| 2 |
+
|
| 3 |
+
export default function SettingsModal({ onClose }) {
|
| 4 |
+
const [settings, setSettings] = useState(null);
|
| 5 |
+
const [models, setModels] = useState([]);
|
| 6 |
+
const [modelsError, setModelsError] = useState(null);
|
| 7 |
+
const [loadingModels, setLoadingModels] = useState(false);
|
| 8 |
+
const [testResult, setTestResult] = useState(null); // { ok: bool, message: string }
|
| 9 |
+
const [testing, setTesting] = useState(false);
|
| 10 |
+
|
| 11 |
+
const loadSettings = async () => {
|
| 12 |
+
const res = await fetch("/api/settings");
|
| 13 |
+
const data = await res.json();
|
| 14 |
+
setSettings(data);
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
loadSettings();
|
| 19 |
+
}, []);
|
| 20 |
+
|
| 21 |
+
const changeProvider = async (provider) => {
|
| 22 |
+
const res = await fetch("/api/settings/provider", {
|
| 23 |
+
method: "POST",
|
| 24 |
+
headers: { "Content-Type": "application/json" },
|
| 25 |
+
body: JSON.stringify({ provider }),
|
| 26 |
+
});
|
| 27 |
+
const data = await res.json();
|
| 28 |
+
setSettings(data);
|
| 29 |
+
|
| 30 |
+
// Reset models state when provider changes
|
| 31 |
+
setModels([]);
|
| 32 |
+
setModelsError(null);
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const loadModels = async () => {
|
| 36 |
+
if (!settings) return;
|
| 37 |
+
setLoadingModels(true);
|
| 38 |
+
setModelsError(null);
|
| 39 |
+
try {
|
| 40 |
+
const res = await fetch(
|
| 41 |
+
`/api/settings/models?provider=${settings.provider}`
|
| 42 |
+
);
|
| 43 |
+
const data = await res.json();
|
| 44 |
+
if (data.error) {
|
| 45 |
+
setModelsError(data.error);
|
| 46 |
+
setModels([]);
|
| 47 |
+
} else {
|
| 48 |
+
setModels(data.models || []);
|
| 49 |
+
}
|
| 50 |
+
} catch (err) {
|
| 51 |
+
console.error(err);
|
| 52 |
+
setModelsError("Failed to load models");
|
| 53 |
+
setModels([]);
|
| 54 |
+
} finally {
|
| 55 |
+
setLoadingModels(false);
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const currentModelForActiveProvider = () => {
|
| 60 |
+
if (!settings) return "";
|
| 61 |
+
const p = settings.provider;
|
| 62 |
+
if (p === "openai") return settings.openai?.model || "";
|
| 63 |
+
if (p === "claude") return settings.claude?.model || "";
|
| 64 |
+
if (p === "watsonx") return settings.watsonx?.model_id || "";
|
| 65 |
+
if (p === "ollama") return settings.ollama?.model || "";
|
| 66 |
+
return "";
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
const changeModel = async (model) => {
|
| 70 |
+
if (!settings) return;
|
| 71 |
+
const provider = settings.provider;
|
| 72 |
+
|
| 73 |
+
let payload = {};
|
| 74 |
+
if (provider === "openai") {
|
| 75 |
+
payload = {
|
| 76 |
+
openai: {
|
| 77 |
+
...settings.openai,
|
| 78 |
+
model,
|
| 79 |
+
},
|
| 80 |
+
};
|
| 81 |
+
} else if (provider === "claude") {
|
| 82 |
+
payload = {
|
| 83 |
+
claude: {
|
| 84 |
+
...settings.claude,
|
| 85 |
+
model,
|
| 86 |
+
},
|
| 87 |
+
};
|
| 88 |
+
} else if (provider === "watsonx") {
|
| 89 |
+
payload = {
|
| 90 |
+
watsonx: {
|
| 91 |
+
...settings.watsonx,
|
| 92 |
+
model_id: model,
|
| 93 |
+
},
|
| 94 |
+
};
|
| 95 |
+
} else if (provider === "ollama") {
|
| 96 |
+
payload = {
|
| 97 |
+
ollama: {
|
| 98 |
+
...settings.ollama,
|
| 99 |
+
model,
|
| 100 |
+
},
|
| 101 |
+
};
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const res = await fetch("/api/settings/llm", {
|
| 105 |
+
method: "PUT",
|
| 106 |
+
headers: { "Content-Type": "application/json" },
|
| 107 |
+
body: JSON.stringify(payload),
|
| 108 |
+
});
|
| 109 |
+
const data = await res.json();
|
| 110 |
+
setSettings(data);
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
const testConnection = async () => {
|
| 114 |
+
if (!settings) return;
|
| 115 |
+
setTesting(true);
|
| 116 |
+
setTestResult(null);
|
| 117 |
+
try {
|
| 118 |
+
const res = await fetch(`/api/settings/test?provider=${settings.provider}`);
|
| 119 |
+
const data = await res.json();
|
| 120 |
+
if (!res.ok || data.error) {
|
| 121 |
+
setTestResult({ ok: false, message: data.error || data.detail || "Connection failed" });
|
| 122 |
+
} else {
|
| 123 |
+
setTestResult({ ok: true, message: data.message || "Connection successful" });
|
| 124 |
+
}
|
| 125 |
+
} catch (err) {
|
| 126 |
+
setTestResult({ ok: false, message: err.message || "Connection test failed" });
|
| 127 |
+
} finally {
|
| 128 |
+
setTesting(false);
|
| 129 |
+
}
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
const toggleLiteMode = async () => {
|
| 133 |
+
if (!settings) return;
|
| 134 |
+
const newValue = !settings.lite_mode;
|
| 135 |
+
try {
|
| 136 |
+
const res = await fetch("/api/settings/lite-mode", {
|
| 137 |
+
method: "POST",
|
| 138 |
+
headers: { "Content-Type": "application/json" },
|
| 139 |
+
body: JSON.stringify({ lite_mode: newValue }),
|
| 140 |
+
});
|
| 141 |
+
if (res.ok) {
|
| 142 |
+
setSettings((prev) => ({ ...prev, lite_mode: newValue }));
|
| 143 |
+
}
|
| 144 |
+
} catch (err) {
|
| 145 |
+
console.error("Failed to toggle lite mode:", err);
|
| 146 |
+
}
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
if (!settings) return null;
|
| 150 |
+
|
| 151 |
+
const activeModel = currentModelForActiveProvider();
|
| 152 |
+
|
| 153 |
+
return (
|
| 154 |
+
<div className="modal-backdrop" onClick={onClose}>
|
| 155 |
+
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
| 156 |
+
<div className="modal-header">
|
| 157 |
+
<div className="modal-title">Settings</div>
|
| 158 |
+
<button className="modal-close" type="button" onClick={onClose}>
|
| 159 |
+
β
|
| 160 |
+
</button>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div style={{ fontSize: 13, color: "#c3c5dd" }}>
|
| 164 |
+
Select which LLM provider GitPilot should use for planning and chat.
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
<div className="provider-list">
|
| 168 |
+
{settings.providers.map((p) => (
|
| 169 |
+
<div
|
| 170 |
+
key={p}
|
| 171 |
+
className={
|
| 172 |
+
"provider-item" + (settings.provider === p ? " active" : "")
|
| 173 |
+
}
|
| 174 |
+
>
|
| 175 |
+
<div className="provider-name">{p}</div>
|
| 176 |
+
<button
|
| 177 |
+
type="button"
|
| 178 |
+
className="chat-btn secondary"
|
| 179 |
+
style={{ padding: "4px 8px", fontSize: 11 }}
|
| 180 |
+
onClick={() => changeProvider(p)}
|
| 181 |
+
disabled={settings.provider === p}
|
| 182 |
+
>
|
| 183 |
+
{settings.provider === p ? "Active" : "Use"}
|
| 184 |
+
</button>
|
| 185 |
+
</div>
|
| 186 |
+
))}
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
{/* Models section */}
|
| 190 |
+
<div
|
| 191 |
+
style={{
|
| 192 |
+
marginTop: 16,
|
| 193 |
+
paddingTop: 12,
|
| 194 |
+
borderTop: "1px solid #2c2d46",
|
| 195 |
+
fontSize: 13,
|
| 196 |
+
}}
|
| 197 |
+
>
|
| 198 |
+
<div style={{ marginBottom: 6, color: "#c3c5dd" }}>
|
| 199 |
+
Active provider: <strong>{settings.provider}</strong>
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
| 203 |
+
<button
|
| 204 |
+
type="button"
|
| 205 |
+
className="chat-btn secondary"
|
| 206 |
+
style={{ padding: "4px 8px", fontSize: 11 }}
|
| 207 |
+
onClick={testConnection}
|
| 208 |
+
disabled={testing}
|
| 209 |
+
>
|
| 210 |
+
{testing ? "Testingβ¦" : "Test Connection"}
|
| 211 |
+
</button>
|
| 212 |
+
<button
|
| 213 |
+
type="button"
|
| 214 |
+
className="chat-btn secondary"
|
| 215 |
+
style={{ padding: "4px 8px", fontSize: 11 }}
|
| 216 |
+
onClick={loadModels}
|
| 217 |
+
disabled={loadingModels}
|
| 218 |
+
>
|
| 219 |
+
{loadingModels ? "Loadingβ¦" : "Display models"}
|
| 220 |
+
</button>
|
| 221 |
+
|
| 222 |
+
{activeModel && (
|
| 223 |
+
<span style={{ fontSize: 12, color: "#9092b5" }}>
|
| 224 |
+
Current model: <code>{activeModel}</code>
|
| 225 |
+
</span>
|
| 226 |
+
)}
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
{modelsError && (
|
| 230 |
+
<div style={{ marginTop: 8, color: "#ff8080", fontSize: 12 }}>
|
| 231 |
+
{modelsError}
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
+
|
| 235 |
+
{testResult && (
|
| 236 |
+
<div style={{
|
| 237 |
+
marginTop: 8,
|
| 238 |
+
padding: "6px 10px",
|
| 239 |
+
borderRadius: 6,
|
| 240 |
+
background: testResult.ok ? "#0d3320" : "#3d1111",
|
| 241 |
+
border: `1px solid ${testResult.ok ? "#166534" : "#7f1d1d"}`,
|
| 242 |
+
color: testResult.ok ? "#86efac" : "#fca5a5",
|
| 243 |
+
fontSize: 12,
|
| 244 |
+
}}>
|
| 245 |
+
{testResult.ok ? "β " : "β "}{testResult.message}
|
| 246 |
+
</div>
|
| 247 |
+
)}
|
| 248 |
+
|
| 249 |
+
{models.length > 0 && (
|
| 250 |
+
<div style={{ marginTop: 10 }}>
|
| 251 |
+
<label
|
| 252 |
+
style={{
|
| 253 |
+
display: "block",
|
| 254 |
+
marginBottom: 4,
|
| 255 |
+
fontSize: 12,
|
| 256 |
+
color: "#c3c5dd",
|
| 257 |
+
}}
|
| 258 |
+
>
|
| 259 |
+
Select model for {settings.provider}:
|
| 260 |
+
</label>
|
| 261 |
+
<select
|
| 262 |
+
style={{
|
| 263 |
+
width: "100%",
|
| 264 |
+
fontSize: 12,
|
| 265 |
+
padding: "4px 6px",
|
| 266 |
+
background: "#14152a",
|
| 267 |
+
color: "#e6e8ff",
|
| 268 |
+
border: "1px solid #2c2d46",
|
| 269 |
+
borderRadius: 4,
|
| 270 |
+
}}
|
| 271 |
+
value={activeModel}
|
| 272 |
+
onChange={(e) => changeModel(e.target.value)}
|
| 273 |
+
>
|
| 274 |
+
<option value="">-- select a model --</option>
|
| 275 |
+
{models.map((m) => (
|
| 276 |
+
<option key={m} value={m}>
|
| 277 |
+
{m}
|
| 278 |
+
</option>
|
| 279 |
+
))}
|
| 280 |
+
</select>
|
| 281 |
+
</div>
|
| 282 |
+
)}
|
| 283 |
+
</div>
|
| 284 |
+
|
| 285 |
+
{/* Lite Mode section */}
|
| 286 |
+
<div
|
| 287 |
+
style={{
|
| 288 |
+
marginTop: 16,
|
| 289 |
+
paddingTop: 12,
|
| 290 |
+
borderTop: "1px solid #2c2d46",
|
| 291 |
+
fontSize: 13,
|
| 292 |
+
}}
|
| 293 |
+
>
|
| 294 |
+
<div
|
| 295 |
+
style={{
|
| 296 |
+
display: "flex",
|
| 297 |
+
alignItems: "center",
|
| 298 |
+
justifyContent: "space-between",
|
| 299 |
+
marginBottom: 6,
|
| 300 |
+
}}
|
| 301 |
+
>
|
| 302 |
+
<div style={{ color: "#c3c5dd", fontWeight: 600 }}>
|
| 303 |
+
Lite Mode
|
| 304 |
+
</div>
|
| 305 |
+
<button
|
| 306 |
+
type="button"
|
| 307 |
+
onClick={toggleLiteMode}
|
| 308 |
+
style={{
|
| 309 |
+
padding: "4px 14px",
|
| 310 |
+
fontSize: 11,
|
| 311 |
+
fontWeight: 600,
|
| 312 |
+
borderRadius: 12,
|
| 313 |
+
border: "none",
|
| 314 |
+
cursor: "pointer",
|
| 315 |
+
background: settings.lite_mode ? "#166534" : "#2c2d46",
|
| 316 |
+
color: settings.lite_mode ? "#86efac" : "#9092b5",
|
| 317 |
+
transition: "background 0.2s, color 0.2s",
|
| 318 |
+
}}
|
| 319 |
+
>
|
| 320 |
+
{settings.lite_mode ? "ON" : "OFF"}
|
| 321 |
+
</button>
|
| 322 |
+
</div>
|
| 323 |
+
<div style={{ fontSize: 11, color: "#9092b5", lineHeight: 1.5 }}>
|
| 324 |
+
Optimized for small models (under 7B parameters).
|
| 325 |
+
Uses simplified prompts and single-agent execution instead
|
| 326 |
+
of multi-agent pipelines. Recommended for: qwen2.5:1.5b,
|
| 327 |
+
phi-3-mini, gemma-2b, tinyllama, etc.
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
</div>
|
| 331 |
+
</div>
|
| 332 |
+
);
|
| 333 |
+
}
|
frontend/components/StartupScreen.jsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
|
| 3 |
+
function normalizeProvider(provider) {
|
| 4 |
+
if (!provider) return "Checking...";
|
| 5 |
+
if (typeof provider === "string") return provider.toUpperCase();
|
| 6 |
+
if (typeof provider === "object") {
|
| 7 |
+
return (
|
| 8 |
+
provider.name ||
|
| 9 |
+
provider.provider ||
|
| 10 |
+
provider.type ||
|
| 11 |
+
provider.label ||
|
| 12 |
+
"Checking..."
|
| 13 |
+
);
|
| 14 |
+
}
|
| 15 |
+
return "Checking...";
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function normalizeVersion(version) {
|
| 19 |
+
if (!version) return "Checking...";
|
| 20 |
+
return String(version);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export default function StartupScreen({
|
| 24 |
+
appName = "GitPilot",
|
| 25 |
+
subtitle = "Enterprise Workspace Copilot",
|
| 26 |
+
frontendVersion = "Checking...",
|
| 27 |
+
backendVersion = "Checking...",
|
| 28 |
+
provider = "Checking...",
|
| 29 |
+
statusMessage = "Starting application...",
|
| 30 |
+
detailMessage = "Initializing authentication, provider, and workspace context.",
|
| 31 |
+
phase = "booting",
|
| 32 |
+
}) {
|
| 33 |
+
const providerLabel = normalizeProvider(provider);
|
| 34 |
+
const frontendLabel = normalizeVersion(frontendVersion);
|
| 35 |
+
const backendLabel = normalizeVersion(backendVersion);
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<div className="startup-screen" role="status" aria-live="polite">
|
| 39 |
+
<div className="startup-card">
|
| 40 |
+
<div className="startup-brand-row">
|
| 41 |
+
<div className="startup-brand-mark" aria-hidden="true">
|
| 42 |
+
<div className="startup-brand-ring" />
|
| 43 |
+
<div className="startup-brand-core" />
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<div className="startup-brand-copy">
|
| 47 |
+
<div className="startup-title">{appName}</div>
|
| 48 |
+
<div className="startup-subtitle">{subtitle}</div>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<div className="startup-loader-wrap" aria-hidden="true">
|
| 53 |
+
<div className="startup-loader">
|
| 54 |
+
<div className="startup-loader-ring startup-loader-ring-outer" />
|
| 55 |
+
<div className="startup-loader-ring startup-loader-ring-inner" />
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<div className="startup-status-block">
|
| 60 |
+
<div className="startup-status">{statusMessage}</div>
|
| 61 |
+
<div className="startup-detail">{detailMessage}</div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div className="startup-phase-row">
|
| 65 |
+
<span className="startup-phase-badge">{phase}</span>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div className="startup-meta-grid">
|
| 69 |
+
<div className="startup-meta-item">
|
| 70 |
+
<div className="startup-meta-label">Frontend</div>
|
| 71 |
+
<div className="startup-meta-value">v{frontendLabel}</div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div className="startup-meta-item">
|
| 75 |
+
<div className="startup-meta-label">Backend</div>
|
| 76 |
+
<div className="startup-meta-value">v{backendLabel}</div>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div className="startup-meta-item startup-meta-item-wide">
|
| 80 |
+
<div className="startup-meta-label">Provider</div>
|
| 81 |
+
<div className="startup-meta-value">{providerLabel}</div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<div className="startup-footer">
|
| 86 |
+
Preparing workspace services, restoring session state, and checking
|
| 87 |
+
platform readiness.
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
);
|
| 92 |
+
}
|
frontend/components/StreamingMessage.jsx
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* StreamingMessage β Claude-Code-on-Web parity streaming renderer.
|
| 5 |
+
*
|
| 6 |
+
* Renders agent messages incrementally as they arrive via WebSocket.
|
| 7 |
+
* Shows tool use blocks (bash commands + output), explanatory text,
|
| 8 |
+
* and status indicators.
|
| 9 |
+
*/
|
| 10 |
+
export default function StreamingMessage({ events }) {
|
| 11 |
+
if (!events || events.length === 0) return null;
|
| 12 |
+
|
| 13 |
+
return (
|
| 14 |
+
<div style={styles.container}>
|
| 15 |
+
{events.map((evt, idx) => (
|
| 16 |
+
<StreamingEvent key={idx} event={evt} isLast={idx === events.length - 1} />
|
| 17 |
+
))}
|
| 18 |
+
</div>
|
| 19 |
+
);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function StreamingEvent({ event, isLast }) {
|
| 23 |
+
const { type } = event;
|
| 24 |
+
|
| 25 |
+
if (type === "agent_message") {
|
| 26 |
+
return (
|
| 27 |
+
<div style={styles.textBlock}>
|
| 28 |
+
<span>{event.content}</span>
|
| 29 |
+
{isLast && <span style={styles.cursor}>|</span>}
|
| 30 |
+
</div>
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
if (type === "tool_use") {
|
| 35 |
+
return (
|
| 36 |
+
<div style={styles.toolBlock}>
|
| 37 |
+
<div style={styles.toolHeader}>
|
| 38 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 39 |
+
<polyline points="4 17 10 11 4 5" />
|
| 40 |
+
<line x1="12" y1="19" x2="20" y2="19" />
|
| 41 |
+
</svg>
|
| 42 |
+
<span style={styles.toolName}>{event.tool || "terminal"}</span>
|
| 43 |
+
</div>
|
| 44 |
+
<div style={styles.toolInput}>
|
| 45 |
+
<code>$ {event.input}</code>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
if (type === "tool_result") {
|
| 52 |
+
return (
|
| 53 |
+
<div style={styles.toolBlock}>
|
| 54 |
+
<div style={styles.toolOutput}>
|
| 55 |
+
<pre style={styles.toolOutputPre}>{event.output}</pre>
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
if (type === "status_change") {
|
| 62 |
+
const statusLabels = {
|
| 63 |
+
active: "Working...",
|
| 64 |
+
waiting: "Waiting for input",
|
| 65 |
+
completed: "Completed",
|
| 66 |
+
failed: "Failed",
|
| 67 |
+
};
|
| 68 |
+
return (
|
| 69 |
+
<div style={styles.statusLine}>
|
| 70 |
+
<div style={{
|
| 71 |
+
...styles.statusDot,
|
| 72 |
+
backgroundColor: {
|
| 73 |
+
active: "#F59E0B",
|
| 74 |
+
waiting: "#3B82F6",
|
| 75 |
+
completed: "#10B981",
|
| 76 |
+
failed: "#EF4444",
|
| 77 |
+
}[event.status] || "#6B7280",
|
| 78 |
+
}} />
|
| 79 |
+
<span>{statusLabels[event.status] || event.status}</span>
|
| 80 |
+
</div>
|
| 81 |
+
);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
if (type === "diff_update") {
|
| 85 |
+
return null; // Handled by DiffStats in parent
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
if (type === "error") {
|
| 89 |
+
return (
|
| 90 |
+
<div style={styles.errorBlock}>
|
| 91 |
+
{event.message}
|
| 92 |
+
</div>
|
| 93 |
+
);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return null;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const styles = {
|
| 100 |
+
container: {
|
| 101 |
+
display: "flex",
|
| 102 |
+
flexDirection: "column",
|
| 103 |
+
gap: 4,
|
| 104 |
+
},
|
| 105 |
+
textBlock: {
|
| 106 |
+
fontSize: 14,
|
| 107 |
+
lineHeight: 1.6,
|
| 108 |
+
color: "#D4D4D8",
|
| 109 |
+
whiteSpace: "pre-wrap",
|
| 110 |
+
wordBreak: "break-word",
|
| 111 |
+
},
|
| 112 |
+
cursor: {
|
| 113 |
+
display: "inline-block",
|
| 114 |
+
animation: "blink 1s step-end infinite",
|
| 115 |
+
color: "#3B82F6",
|
| 116 |
+
fontWeight: 700,
|
| 117 |
+
},
|
| 118 |
+
toolBlock: {
|
| 119 |
+
margin: "4px 0",
|
| 120 |
+
borderRadius: 6,
|
| 121 |
+
border: "1px solid #27272A",
|
| 122 |
+
overflow: "hidden",
|
| 123 |
+
},
|
| 124 |
+
toolHeader: {
|
| 125 |
+
display: "flex",
|
| 126 |
+
alignItems: "center",
|
| 127 |
+
gap: 6,
|
| 128 |
+
padding: "6px 10px",
|
| 129 |
+
backgroundColor: "#18181B",
|
| 130 |
+
fontSize: 11,
|
| 131 |
+
color: "#71717A",
|
| 132 |
+
fontFamily: "monospace",
|
| 133 |
+
},
|
| 134 |
+
toolName: {
|
| 135 |
+
fontWeight: 600,
|
| 136 |
+
},
|
| 137 |
+
toolInput: {
|
| 138 |
+
padding: "8px 10px",
|
| 139 |
+
backgroundColor: "#0D0D0F",
|
| 140 |
+
fontFamily: "monospace",
|
| 141 |
+
fontSize: 12,
|
| 142 |
+
color: "#10B981",
|
| 143 |
+
whiteSpace: "pre-wrap",
|
| 144 |
+
wordBreak: "break-all",
|
| 145 |
+
},
|
| 146 |
+
toolOutput: {
|
| 147 |
+
padding: "8px 10px",
|
| 148 |
+
backgroundColor: "#0D0D0F",
|
| 149 |
+
maxHeight: 300,
|
| 150 |
+
overflowY: "auto",
|
| 151 |
+
},
|
| 152 |
+
toolOutputPre: {
|
| 153 |
+
margin: 0,
|
| 154 |
+
fontFamily: "monospace",
|
| 155 |
+
fontSize: 11,
|
| 156 |
+
color: "#A1A1AA",
|
| 157 |
+
whiteSpace: "pre-wrap",
|
| 158 |
+
wordBreak: "break-all",
|
| 159 |
+
},
|
| 160 |
+
statusLine: {
|
| 161 |
+
display: "flex",
|
| 162 |
+
alignItems: "center",
|
| 163 |
+
gap: 6,
|
| 164 |
+
padding: "4px 0",
|
| 165 |
+
fontSize: 12,
|
| 166 |
+
color: "#71717A",
|
| 167 |
+
fontStyle: "italic",
|
| 168 |
+
},
|
| 169 |
+
statusDot: {
|
| 170 |
+
width: 6,
|
| 171 |
+
height: 6,
|
| 172 |
+
borderRadius: "50%",
|
| 173 |
+
},
|
| 174 |
+
errorBlock: {
|
| 175 |
+
padding: "8px 12px",
|
| 176 |
+
borderRadius: 6,
|
| 177 |
+
backgroundColor: "rgba(239, 68, 68, 0.08)",
|
| 178 |
+
border: "1px solid rgba(239, 68, 68, 0.2)",
|
| 179 |
+
color: "#FCA5A5",
|
| 180 |
+
fontSize: 13,
|
| 181 |
+
},
|
| 182 |
+
};
|
frontend/components/UserMenu.jsx
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/UserMenu.jsx
|
| 2 |
+
import React, { useEffect, useRef, useState, useCallback } from "react";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* UserMenu β account dropdown attached to the profile avatar in the
|
| 6 |
+
* bottom-left of the sidebar. Follows the Claude Code / ChatGPT pattern:
|
| 7 |
+
* click avatar β popover with Settings, About, Logout.
|
| 8 |
+
*
|
| 9 |
+
* Best practices applied:
|
| 10 |
+
* - Click outside to close (mousedown listener on document)
|
| 11 |
+
* - Escape key closes
|
| 12 |
+
* - ARIA: role="menu" + aria-haspopup + aria-expanded on trigger
|
| 13 |
+
* - Keyboard navigation (Tab / Shift+Tab cycles items, Enter activates)
|
| 14 |
+
* - Position: absolute popover anchored to trigger, opens upward
|
| 15 |
+
* - Brand palette: #D95C3D accent, #1C1C1F card, #27272A border
|
| 16 |
+
* - Respects sidebarCollapsed: when collapsed, only avatar is shown
|
| 17 |
+
* - Animation: subtle fade+translate for polish
|
| 18 |
+
*/
|
| 19 |
+
|
| 20 |
+
export default function UserMenu({
|
| 21 |
+
userInfo,
|
| 22 |
+
sidebarCollapsed = false,
|
| 23 |
+
onOpenSettings,
|
| 24 |
+
onOpenAbout,
|
| 25 |
+
onLogout,
|
| 26 |
+
}) {
|
| 27 |
+
const [open, setOpen] = useState(false);
|
| 28 |
+
const [fixedPos, setFixedPos] = useState(null);
|
| 29 |
+
const containerRef = useRef(null);
|
| 30 |
+
const triggerRef = useRef(null);
|
| 31 |
+
const menuRef = useRef(null);
|
| 32 |
+
|
| 33 |
+
// When the sidebar is collapsed, the parent .sidebar has overflow-x:hidden
|
| 34 |
+
// which clips an absolutely-positioned popover. Escape the clip by using
|
| 35 |
+
// position:fixed with coordinates measured from the trigger's bounding
|
| 36 |
+
// rect. Recompute on open, window resize, and scroll.
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
if (!open || !sidebarCollapsed) {
|
| 39 |
+
setFixedPos(null);
|
| 40 |
+
return;
|
| 41 |
+
}
|
| 42 |
+
const compute = () => {
|
| 43 |
+
const el = triggerRef.current;
|
| 44 |
+
if (!el) return;
|
| 45 |
+
const rect = el.getBoundingClientRect();
|
| 46 |
+
setFixedPos({
|
| 47 |
+
left: Math.round(rect.right + 8),
|
| 48 |
+
bottom: Math.round(window.innerHeight - rect.bottom),
|
| 49 |
+
});
|
| 50 |
+
};
|
| 51 |
+
compute();
|
| 52 |
+
window.addEventListener("resize", compute);
|
| 53 |
+
window.addEventListener("scroll", compute, true);
|
| 54 |
+
return () => {
|
| 55 |
+
window.removeEventListener("resize", compute);
|
| 56 |
+
window.removeEventListener("scroll", compute, true);
|
| 57 |
+
};
|
| 58 |
+
}, [open, sidebarCollapsed]);
|
| 59 |
+
|
| 60 |
+
// Close on click outside
|
| 61 |
+
useEffect(() => {
|
| 62 |
+
if (!open) return;
|
| 63 |
+
const handleDocMouseDown = (e) => {
|
| 64 |
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
| 65 |
+
setOpen(false);
|
| 66 |
+
}
|
| 67 |
+
};
|
| 68 |
+
document.addEventListener("mousedown", handleDocMouseDown);
|
| 69 |
+
return () => document.removeEventListener("mousedown", handleDocMouseDown);
|
| 70 |
+
}, [open]);
|
| 71 |
+
|
| 72 |
+
// Close on Escape
|
| 73 |
+
useEffect(() => {
|
| 74 |
+
if (!open) return;
|
| 75 |
+
const handleKey = (e) => {
|
| 76 |
+
if (e.key === "Escape") {
|
| 77 |
+
setOpen(false);
|
| 78 |
+
triggerRef.current?.focus();
|
| 79 |
+
}
|
| 80 |
+
};
|
| 81 |
+
document.addEventListener("keydown", handleKey);
|
| 82 |
+
return () => document.removeEventListener("keydown", handleKey);
|
| 83 |
+
}, [open]);
|
| 84 |
+
|
| 85 |
+
// Focus the first menu item when opened
|
| 86 |
+
useEffect(() => {
|
| 87 |
+
if (open && menuRef.current) {
|
| 88 |
+
const firstItem = menuRef.current.querySelector('[role="menuitem"]');
|
| 89 |
+
firstItem?.focus();
|
| 90 |
+
}
|
| 91 |
+
}, [open]);
|
| 92 |
+
|
| 93 |
+
const handleItemClick = useCallback((action) => {
|
| 94 |
+
setOpen(false);
|
| 95 |
+
// Defer to next tick so the dropdown close animation doesn't jitter
|
| 96 |
+
// against the modal open animation.
|
| 97 |
+
window.setTimeout(() => action?.(), 0);
|
| 98 |
+
}, []);
|
| 99 |
+
|
| 100 |
+
if (!userInfo) return null;
|
| 101 |
+
|
| 102 |
+
const displayName = userInfo.name || userInfo.login;
|
| 103 |
+
const login = userInfo.login || "";
|
| 104 |
+
|
| 105 |
+
return (
|
| 106 |
+
<div
|
| 107 |
+
ref={containerRef}
|
| 108 |
+
style={{ position: "relative", width: "100%" }}
|
| 109 |
+
>
|
| 110 |
+
{/* Trigger: avatar + optional name */}
|
| 111 |
+
<button
|
| 112 |
+
ref={triggerRef}
|
| 113 |
+
type="button"
|
| 114 |
+
onClick={() => setOpen((v) => !v)}
|
| 115 |
+
aria-haspopup="menu"
|
| 116 |
+
aria-expanded={open}
|
| 117 |
+
aria-label={`Account menu for ${displayName}`}
|
| 118 |
+
className="user-menu-trigger"
|
| 119 |
+
style={{
|
| 120 |
+
display: "flex",
|
| 121 |
+
alignItems: "center",
|
| 122 |
+
gap: sidebarCollapsed ? 0 : 10,
|
| 123 |
+
width: "100%",
|
| 124 |
+
padding: sidebarCollapsed ? "6px" : "8px 10px",
|
| 125 |
+
background: open ? "#27272A" : "transparent",
|
| 126 |
+
border: "1px solid",
|
| 127 |
+
borderColor: open ? "#D95C3D" : "transparent",
|
| 128 |
+
borderRadius: 10,
|
| 129 |
+
cursor: "pointer",
|
| 130 |
+
color: "#EDEDED",
|
| 131 |
+
textAlign: "left",
|
| 132 |
+
transition: "background 120ms ease, border-color 120ms ease",
|
| 133 |
+
fontFamily: "inherit",
|
| 134 |
+
}}
|
| 135 |
+
onMouseEnter={(e) => {
|
| 136 |
+
if (!open) e.currentTarget.style.background = "#1C1C1F";
|
| 137 |
+
}}
|
| 138 |
+
onMouseLeave={(e) => {
|
| 139 |
+
if (!open) e.currentTarget.style.background = "transparent";
|
| 140 |
+
}}
|
| 141 |
+
>
|
| 142 |
+
{userInfo.avatar_url ? (
|
| 143 |
+
<img
|
| 144 |
+
src={userInfo.avatar_url}
|
| 145 |
+
alt=""
|
| 146 |
+
style={{
|
| 147 |
+
width: 32,
|
| 148 |
+
height: 32,
|
| 149 |
+
borderRadius: "50%",
|
| 150 |
+
flexShrink: 0,
|
| 151 |
+
border: "1px solid #27272A",
|
| 152 |
+
}}
|
| 153 |
+
/>
|
| 154 |
+
) : (
|
| 155 |
+
<div
|
| 156 |
+
aria-hidden="true"
|
| 157 |
+
style={{
|
| 158 |
+
width: 32,
|
| 159 |
+
height: 32,
|
| 160 |
+
borderRadius: "50%",
|
| 161 |
+
flexShrink: 0,
|
| 162 |
+
background: "rgba(217, 92, 61, 0.15)",
|
| 163 |
+
color: "#D95C3D",
|
| 164 |
+
border: "1px solid rgba(217, 92, 61, 0.3)",
|
| 165 |
+
display: "flex",
|
| 166 |
+
alignItems: "center",
|
| 167 |
+
justifyContent: "center",
|
| 168 |
+
fontWeight: 700,
|
| 169 |
+
fontSize: 13,
|
| 170 |
+
}}
|
| 171 |
+
>
|
| 172 |
+
{(displayName || "?").slice(0, 2).toUpperCase()}
|
| 173 |
+
</div>
|
| 174 |
+
)}
|
| 175 |
+
|
| 176 |
+
{!sidebarCollapsed && (
|
| 177 |
+
<div style={{ flex: 1, minWidth: 0, lineHeight: 1.25 }}>
|
| 178 |
+
<div
|
| 179 |
+
style={{
|
| 180 |
+
fontSize: 13,
|
| 181 |
+
fontWeight: 600,
|
| 182 |
+
color: "#EDEDED",
|
| 183 |
+
overflow: "hidden",
|
| 184 |
+
textOverflow: "ellipsis",
|
| 185 |
+
whiteSpace: "nowrap",
|
| 186 |
+
}}
|
| 187 |
+
>
|
| 188 |
+
{displayName}
|
| 189 |
+
</div>
|
| 190 |
+
{login && (
|
| 191 |
+
<div
|
| 192 |
+
style={{
|
| 193 |
+
fontSize: 11,
|
| 194 |
+
color: "#A1A1AA",
|
| 195 |
+
overflow: "hidden",
|
| 196 |
+
textOverflow: "ellipsis",
|
| 197 |
+
whiteSpace: "nowrap",
|
| 198 |
+
}}
|
| 199 |
+
>
|
| 200 |
+
@{login}
|
| 201 |
+
</div>
|
| 202 |
+
)}
|
| 203 |
+
</div>
|
| 204 |
+
)}
|
| 205 |
+
|
| 206 |
+
{!sidebarCollapsed && (
|
| 207 |
+
<svg
|
| 208 |
+
aria-hidden="true"
|
| 209 |
+
width="14"
|
| 210 |
+
height="14"
|
| 211 |
+
viewBox="0 0 16 16"
|
| 212 |
+
fill="none"
|
| 213 |
+
style={{
|
| 214 |
+
flexShrink: 0,
|
| 215 |
+
color: "#A1A1AA",
|
| 216 |
+
transform: open ? "rotate(180deg)" : "rotate(0deg)",
|
| 217 |
+
transition: "transform 120ms ease",
|
| 218 |
+
}}
|
| 219 |
+
>
|
| 220 |
+
<path
|
| 221 |
+
d="M4 6l4 4 4-4"
|
| 222 |
+
stroke="currentColor"
|
| 223 |
+
strokeWidth="1.5"
|
| 224 |
+
strokeLinecap="round"
|
| 225 |
+
strokeLinejoin="round"
|
| 226 |
+
/>
|
| 227 |
+
</svg>
|
| 228 |
+
)}
|
| 229 |
+
</button>
|
| 230 |
+
|
| 231 |
+
{/* Dropdown popover */}
|
| 232 |
+
{open && (
|
| 233 |
+
<div
|
| 234 |
+
ref={menuRef}
|
| 235 |
+
role="menu"
|
| 236 |
+
aria-label="Account actions"
|
| 237 |
+
style={
|
| 238 |
+
sidebarCollapsed && fixedPos
|
| 239 |
+
? {
|
| 240 |
+
position: "fixed",
|
| 241 |
+
left: fixedPos.left,
|
| 242 |
+
bottom: fixedPos.bottom,
|
| 243 |
+
width: 240,
|
| 244 |
+
minWidth: 220,
|
| 245 |
+
background: "#1C1C1F",
|
| 246 |
+
border: "1px solid #27272A",
|
| 247 |
+
borderRadius: 12,
|
| 248 |
+
boxShadow:
|
| 249 |
+
"0 18px 38px -12px rgba(0, 0, 0, 0.7), 0 4px 12px rgba(0, 0, 0, 0.4)",
|
| 250 |
+
padding: 6,
|
| 251 |
+
zIndex: 1000,
|
| 252 |
+
animation: "userMenuFadeIn 140ms ease-out",
|
| 253 |
+
}
|
| 254 |
+
: {
|
| 255 |
+
position: "absolute",
|
| 256 |
+
bottom: "calc(100% + 8px)",
|
| 257 |
+
left: 0,
|
| 258 |
+
right: 0,
|
| 259 |
+
minWidth: 220,
|
| 260 |
+
background: "#1C1C1F",
|
| 261 |
+
border: "1px solid #27272A",
|
| 262 |
+
borderRadius: 12,
|
| 263 |
+
boxShadow:
|
| 264 |
+
"0 18px 38px -12px rgba(0, 0, 0, 0.7), 0 4px 12px rgba(0, 0, 0, 0.4)",
|
| 265 |
+
padding: 6,
|
| 266 |
+
zIndex: 1000,
|
| 267 |
+
animation: "userMenuFadeIn 140ms ease-out",
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
>
|
| 271 |
+
{/* Header: show full email/username for context */}
|
| 272 |
+
<div
|
| 273 |
+
style={{
|
| 274 |
+
padding: "8px 12px 10px",
|
| 275 |
+
borderBottom: "1px solid #27272A",
|
| 276 |
+
marginBottom: 6,
|
| 277 |
+
}}
|
| 278 |
+
>
|
| 279 |
+
<div
|
| 280 |
+
style={{
|
| 281 |
+
fontSize: 12,
|
| 282 |
+
color: "#A1A1AA",
|
| 283 |
+
fontWeight: 500,
|
| 284 |
+
overflow: "hidden",
|
| 285 |
+
textOverflow: "ellipsis",
|
| 286 |
+
whiteSpace: "nowrap",
|
| 287 |
+
}}
|
| 288 |
+
>
|
| 289 |
+
Signed in as
|
| 290 |
+
</div>
|
| 291 |
+
<div
|
| 292 |
+
style={{
|
| 293 |
+
fontSize: 13,
|
| 294 |
+
color: "#EDEDED",
|
| 295 |
+
fontWeight: 600,
|
| 296 |
+
overflow: "hidden",
|
| 297 |
+
textOverflow: "ellipsis",
|
| 298 |
+
whiteSpace: "nowrap",
|
| 299 |
+
marginTop: 2,
|
| 300 |
+
}}
|
| 301 |
+
title={displayName}
|
| 302 |
+
>
|
| 303 |
+
{displayName}
|
| 304 |
+
</div>
|
| 305 |
+
</div>
|
| 306 |
+
|
| 307 |
+
<MenuItem
|
| 308 |
+
icon={<SettingsIcon />}
|
| 309 |
+
label="Settings"
|
| 310 |
+
onClick={() => handleItemClick(onOpenSettings)}
|
| 311 |
+
/>
|
| 312 |
+
<MenuItem
|
| 313 |
+
icon={<InfoIcon />}
|
| 314 |
+
label="About GitPilot"
|
| 315 |
+
onClick={() => handleItemClick(onOpenAbout)}
|
| 316 |
+
/>
|
| 317 |
+
|
| 318 |
+
<div
|
| 319 |
+
role="separator"
|
| 320 |
+
style={{
|
| 321 |
+
height: 1,
|
| 322 |
+
background: "#27272A",
|
| 323 |
+
margin: "6px 4px",
|
| 324 |
+
}}
|
| 325 |
+
/>
|
| 326 |
+
|
| 327 |
+
<MenuItem
|
| 328 |
+
icon={<LogoutIcon />}
|
| 329 |
+
label="Log out"
|
| 330 |
+
onClick={() => handleItemClick(onLogout)}
|
| 331 |
+
danger
|
| 332 |
+
/>
|
| 333 |
+
</div>
|
| 334 |
+
)}
|
| 335 |
+
|
| 336 |
+
{/* Scoped keyframe animation */}
|
| 337 |
+
<style>{`
|
| 338 |
+
@keyframes userMenuFadeIn {
|
| 339 |
+
from { opacity: 0; transform: translateY(4px); }
|
| 340 |
+
to { opacity: 1; transform: translateY(0); }
|
| 341 |
+
}
|
| 342 |
+
`}</style>
|
| 343 |
+
</div>
|
| 344 |
+
);
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
// ββ Menu item primitive ββββββββββββββββββββββββββββββββββββββββββββ
|
| 348 |
+
function MenuItem({ icon, label, onClick, danger = false }) {
|
| 349 |
+
const [hover, setHover] = useState(false);
|
| 350 |
+
const color = danger ? "#f87171" : "#EDEDED";
|
| 351 |
+
return (
|
| 352 |
+
<button
|
| 353 |
+
type="button"
|
| 354 |
+
role="menuitem"
|
| 355 |
+
onClick={onClick}
|
| 356 |
+
onMouseEnter={() => setHover(true)}
|
| 357 |
+
onMouseLeave={() => setHover(false)}
|
| 358 |
+
style={{
|
| 359 |
+
display: "flex",
|
| 360 |
+
alignItems: "center",
|
| 361 |
+
gap: 12,
|
| 362 |
+
width: "100%",
|
| 363 |
+
padding: "9px 12px",
|
| 364 |
+
background: hover ? "#27272A" : "transparent",
|
| 365 |
+
border: "none",
|
| 366 |
+
borderRadius: 8,
|
| 367 |
+
cursor: "pointer",
|
| 368 |
+
color: color,
|
| 369 |
+
fontSize: 13,
|
| 370 |
+
fontWeight: 500,
|
| 371 |
+
textAlign: "left",
|
| 372 |
+
fontFamily: "inherit",
|
| 373 |
+
transition: "background 80ms ease",
|
| 374 |
+
}}
|
| 375 |
+
>
|
| 376 |
+
<span
|
| 377 |
+
aria-hidden="true"
|
| 378 |
+
style={{
|
| 379 |
+
display: "inline-flex",
|
| 380 |
+
alignItems: "center",
|
| 381 |
+
justifyContent: "center",
|
| 382 |
+
width: 16,
|
| 383 |
+
height: 16,
|
| 384 |
+
color: hover && !danger ? "#D95C3D" : color,
|
| 385 |
+
flexShrink: 0,
|
| 386 |
+
transition: "color 80ms ease",
|
| 387 |
+
}}
|
| 388 |
+
>
|
| 389 |
+
{icon}
|
| 390 |
+
</span>
|
| 391 |
+
<span>{label}</span>
|
| 392 |
+
</button>
|
| 393 |
+
);
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
// ββ Inline icons (no extra asset loads) ββββββββββββββββββββββββββββ
|
| 397 |
+
function SettingsIcon() {
|
| 398 |
+
return (
|
| 399 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 400 |
+
<circle cx="12" cy="12" r="3" />
|
| 401 |
+
<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-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 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.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 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.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 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 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
| 402 |
+
</svg>
|
| 403 |
+
);
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
function InfoIcon() {
|
| 407 |
+
return (
|
| 408 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 409 |
+
<circle cx="12" cy="12" r="10" />
|
| 410 |
+
<line x1="12" y1="16" x2="12" y2="12" />
|
| 411 |
+
<line x1="12" y1="8" x2="12.01" y2="8" />
|
| 412 |
+
</svg>
|
| 413 |
+
);
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
function LogoutIcon() {
|
| 417 |
+
return (
|
| 418 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 419 |
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
| 420 |
+
<polyline points="16 17 21 12 16 7" />
|
| 421 |
+
<line x1="21" y1="12" x2="9" y2="12" />
|
| 422 |
+
</svg>
|
| 423 |
+
);
|
| 424 |
+
}
|
frontend/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<title>GitPilot</title>
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
</head>
|
| 8 |
+
<body>
|
| 9 |
+
<div id="root"></div>
|
| 10 |
+
<script type="module" src="main.jsx"></script>
|
| 11 |
+
</body>
|
| 12 |
+
</html>
|
frontend/main.jsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import ReactDOM from "react-dom/client";
|
| 3 |
+
import App from "./App.jsx";
|
| 4 |
+
import "./styles.css";
|
| 5 |
+
import "./ollabridge.css";
|
| 6 |
+
|
| 7 |
+
ReactDOM.createRoot(document.getElementById("root")).render(
|
| 8 |
+
<React.StrictMode>
|
| 9 |
+
<App />
|
| 10 |
+
</React.StrictMode>
|
| 11 |
+
);
|
frontend/nginx.conf
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
server {
|
| 2 |
+
listen 80;
|
| 3 |
+
server_name _;
|
| 4 |
+
root /usr/share/nginx/html;
|
| 5 |
+
index index.html;
|
| 6 |
+
|
| 7 |
+
# DNS resolver for dynamic upstream resolution
|
| 8 |
+
# This allows nginx to start even if backend doesn't exist yet
|
| 9 |
+
resolver 127.0.0.11 valid=30s ipv6=off;
|
| 10 |
+
|
| 11 |
+
# Gzip compression
|
| 12 |
+
gzip on;
|
| 13 |
+
gzip_vary on;
|
| 14 |
+
gzip_min_length 1024;
|
| 15 |
+
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
| 16 |
+
|
| 17 |
+
# Security headers
|
| 18 |
+
add_header X-Frame-Options "SAMEORIGIN" always;
|
| 19 |
+
add_header X-Content-Type-Options "nosniff" always;
|
| 20 |
+
add_header X-XSS-Protection "1; mode=block" always;
|
| 21 |
+
|
| 22 |
+
# Handle API requests - proxy to backend (docker-compose only)
|
| 23 |
+
# Uses variables to force runtime DNS resolution instead of startup
|
| 24 |
+
location /api/ {
|
| 25 |
+
# Use variable to force runtime DNS resolution
|
| 26 |
+
set $backend "backend:8000";
|
| 27 |
+
proxy_pass http://$backend;
|
| 28 |
+
proxy_http_version 1.1;
|
| 29 |
+
proxy_set_header Upgrade $http_upgrade;
|
| 30 |
+
proxy_set_header Connection 'upgrade';
|
| 31 |
+
proxy_set_header Host $host;
|
| 32 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 33 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 34 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 35 |
+
proxy_cache_bypass $http_upgrade;
|
| 36 |
+
|
| 37 |
+
# Handle backend connection errors gracefully
|
| 38 |
+
proxy_intercept_errors on;
|
| 39 |
+
error_page 502 503 504 = @backend_unavailable;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# Fallback for when backend is unavailable
|
| 43 |
+
location @backend_unavailable {
|
| 44 |
+
add_header Content-Type application/json;
|
| 45 |
+
return 503 '{"error": "Backend service unavailable. Configure VITE_BACKEND_URL in frontend or ensure backend container is running."}';
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
# Serve static files
|
| 49 |
+
location / {
|
| 50 |
+
try_files $uri $uri/ /index.html;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
# Cache static assets
|
| 54 |
+
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
| 55 |
+
expires 1y;
|
| 56 |
+
add_header Cache-Control "public, immutable";
|
| 57 |
+
}
|
| 58 |
+
}
|
frontend/ollabridge.css
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================================
|
| 2 |
+
OLLABRIDGE CLOUD - Provider Tabs & Pairing UI
|
| 3 |
+
============================================================================ */
|
| 4 |
+
|
| 5 |
+
/* Provider selection tabs (replaces dropdown) */
|
| 6 |
+
.settings-provider-tabs {
|
| 7 |
+
display: flex;
|
| 8 |
+
gap: 4px;
|
| 9 |
+
flex-wrap: wrap;
|
| 10 |
+
margin-top: 4px;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
.settings-provider-tab {
|
| 14 |
+
border: 1px solid #272832;
|
| 15 |
+
outline: none;
|
| 16 |
+
background: #0a0b0f;
|
| 17 |
+
color: #9a9bb0;
|
| 18 |
+
border-radius: 8px;
|
| 19 |
+
padding: 8px 14px;
|
| 20 |
+
font-size: 13px;
|
| 21 |
+
font-weight: 500;
|
| 22 |
+
cursor: pointer;
|
| 23 |
+
transition: all 0.2s ease;
|
| 24 |
+
font-family: inherit;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.settings-provider-tab:hover {
|
| 28 |
+
background: #1a1b26;
|
| 29 |
+
color: #c3c5dd;
|
| 30 |
+
border-color: #3a3b4d;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.settings-provider-tab-active {
|
| 34 |
+
background: rgba(255, 122, 60, 0.12);
|
| 35 |
+
color: #ff7a3c;
|
| 36 |
+
border-color: #ff7a3c;
|
| 37 |
+
font-weight: 600;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.settings-provider-tab-active:hover {
|
| 41 |
+
background: rgba(255, 122, 60, 0.18);
|
| 42 |
+
color: #ff8b52;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/* Auth mode tabs (Device Pairing / API Key / Local Trust) */
|
| 46 |
+
.ob-auth-tabs {
|
| 47 |
+
display: flex;
|
| 48 |
+
gap: 4px;
|
| 49 |
+
margin-top: 4px;
|
| 50 |
+
margin-bottom: 8px;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.ob-auth-tab {
|
| 54 |
+
display: flex;
|
| 55 |
+
align-items: center;
|
| 56 |
+
gap: 6px;
|
| 57 |
+
border: 1px solid #272832;
|
| 58 |
+
outline: none;
|
| 59 |
+
background: #0a0b0f;
|
| 60 |
+
color: #9a9bb0;
|
| 61 |
+
border-radius: 8px;
|
| 62 |
+
padding: 7px 12px;
|
| 63 |
+
font-size: 12px;
|
| 64 |
+
font-weight: 500;
|
| 65 |
+
cursor: pointer;
|
| 66 |
+
transition: all 0.2s ease;
|
| 67 |
+
font-family: inherit;
|
| 68 |
+
white-space: nowrap;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.ob-auth-tab:hover {
|
| 72 |
+
background: #1a1b26;
|
| 73 |
+
color: #c3c5dd;
|
| 74 |
+
border-color: #3a3b4d;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.ob-auth-tab-active {
|
| 78 |
+
background: rgba(59, 130, 246, 0.1);
|
| 79 |
+
color: #60a5fa;
|
| 80 |
+
border-color: #3B82F6;
|
| 81 |
+
font-weight: 600;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.ob-auth-tab-active:hover {
|
| 85 |
+
background: rgba(59, 130, 246, 0.15);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.ob-auth-tab-icon {
|
| 89 |
+
font-size: 14px;
|
| 90 |
+
line-height: 1;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/* Auth panel (content below tabs) */
|
| 94 |
+
.ob-auth-panel {
|
| 95 |
+
padding: 12px;
|
| 96 |
+
background: #0a0b0f;
|
| 97 |
+
border: 1px solid #1e1f30;
|
| 98 |
+
border-radius: 8px;
|
| 99 |
+
margin-bottom: 4px;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.ob-auth-desc {
|
| 103 |
+
font-size: 12px;
|
| 104 |
+
color: #9a9bb0;
|
| 105 |
+
line-height: 1.5;
|
| 106 |
+
margin-bottom: 10px;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/* Pairing row */
|
| 110 |
+
.ob-pair-row {
|
| 111 |
+
display: flex;
|
| 112 |
+
gap: 8px;
|
| 113 |
+
align-items: center;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.ob-pair-input {
|
| 117 |
+
flex: 1;
|
| 118 |
+
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
|
| 119 |
+
font-size: 16px !important;
|
| 120 |
+
letter-spacing: 2px;
|
| 121 |
+
text-align: center;
|
| 122 |
+
text-transform: uppercase;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.ob-pair-btn {
|
| 126 |
+
display: flex;
|
| 127 |
+
align-items: center;
|
| 128 |
+
gap: 6px;
|
| 129 |
+
border: none;
|
| 130 |
+
outline: none;
|
| 131 |
+
background: #3B82F6;
|
| 132 |
+
color: #fff;
|
| 133 |
+
border-radius: 8px;
|
| 134 |
+
padding: 9px 16px;
|
| 135 |
+
font-size: 13px;
|
| 136 |
+
font-weight: 600;
|
| 137 |
+
cursor: pointer;
|
| 138 |
+
transition: all 0.2s ease;
|
| 139 |
+
white-space: nowrap;
|
| 140 |
+
font-family: inherit;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.ob-pair-btn:hover:not(:disabled) {
|
| 144 |
+
background: #4d93f7;
|
| 145 |
+
transform: translateY(-1px);
|
| 146 |
+
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.ob-pair-btn:disabled {
|
| 150 |
+
opacity: 0.5;
|
| 151 |
+
cursor: not-allowed;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/* Pair spinner */
|
| 155 |
+
.ob-pair-spinner {
|
| 156 |
+
display: inline-block;
|
| 157 |
+
width: 14px;
|
| 158 |
+
height: 14px;
|
| 159 |
+
border: 2px solid rgba(255, 255, 255, 0.3);
|
| 160 |
+
border-top-color: #fff;
|
| 161 |
+
border-radius: 50%;
|
| 162 |
+
animation: spin 0.6s linear infinite;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/* Pair result feedback */
|
| 166 |
+
.ob-pair-result {
|
| 167 |
+
margin-top: 8px;
|
| 168 |
+
padding: 8px 12px;
|
| 169 |
+
border-radius: 6px;
|
| 170 |
+
font-size: 12px;
|
| 171 |
+
font-weight: 500;
|
| 172 |
+
animation: fadeIn 0.3s ease;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.ob-pair-result-ok {
|
| 176 |
+
background: rgba(76, 175, 136, 0.12);
|
| 177 |
+
border: 1px solid rgba(76, 175, 136, 0.3);
|
| 178 |
+
color: #7cffb3;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.ob-pair-result-err {
|
| 182 |
+
background: rgba(255, 82, 82, 0.1);
|
| 183 |
+
border: 1px solid rgba(255, 82, 82, 0.3);
|
| 184 |
+
color: #ff8a8a;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
/* Model row (input + fetch button) */
|
| 188 |
+
.ob-model-row {
|
| 189 |
+
display: flex;
|
| 190 |
+
gap: 8px;
|
| 191 |
+
align-items: center;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.ob-fetch-btn {
|
| 195 |
+
display: flex;
|
| 196 |
+
align-items: center;
|
| 197 |
+
gap: 5px;
|
| 198 |
+
border: 1px solid #272832;
|
| 199 |
+
outline: none;
|
| 200 |
+
background: #1a1b26;
|
| 201 |
+
color: #c3c5dd;
|
| 202 |
+
border-radius: 8px;
|
| 203 |
+
padding: 8px 12px;
|
| 204 |
+
font-size: 12px;
|
| 205 |
+
font-weight: 500;
|
| 206 |
+
cursor: pointer;
|
| 207 |
+
transition: all 0.2s ease;
|
| 208 |
+
white-space: nowrap;
|
| 209 |
+
font-family: inherit;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.ob-fetch-btn:hover:not(:disabled) {
|
| 213 |
+
background: #222335;
|
| 214 |
+
border-color: #3a3b4d;
|
| 215 |
+
color: #f5f5f7;
|
| 216 |
+
transform: translateY(-1px);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.ob-fetch-btn:disabled {
|
| 220 |
+
opacity: 0.5;
|
| 221 |
+
cursor: not-allowed;
|
| 222 |
+
}
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "gitpilot-frontend",
|
| 3 |
+
"version": "0.2.5",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "vite --host",
|
| 7 |
+
"build": "vite build",
|
| 8 |
+
"vercel-build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"react": "^18.2.0",
|
| 13 |
+
"react-dom": "^18.2.0",
|
| 14 |
+
"react-markdown": "^10.1.0",
|
| 15 |
+
"reactflow": "^11.11.4"
|
| 16 |
+
},
|
| 17 |
+
"devDependencies": {
|
| 18 |
+
"@vitejs/plugin-react": "^4.0.0",
|
| 19 |
+
"vite": "^5.0.0"
|
| 20 |
+
}
|
| 21 |
+
}
|
frontend/styles.css
ADDED
|
@@ -0,0 +1,3288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
color-scheme: dark;
|
| 3 |
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
| 4 |
+
sans-serif;
|
| 5 |
+
background: #050608;
|
| 6 |
+
color: #f5f5f7;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
*,
|
| 10 |
+
*::before,
|
| 11 |
+
*::after {
|
| 12 |
+
box-sizing: border-box;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
body {
|
| 16 |
+
margin: 0;
|
| 17 |
+
overflow: hidden;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/* Custom scrollbar styling - Claude Code style */
|
| 21 |
+
::-webkit-scrollbar {
|
| 22 |
+
width: 8px;
|
| 23 |
+
height: 8px;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
::-webkit-scrollbar-track {
|
| 27 |
+
background: transparent;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
::-webkit-scrollbar-thumb {
|
| 31 |
+
background: #272832;
|
| 32 |
+
border-radius: 4px;
|
| 33 |
+
transition: background 0.2s ease;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
::-webkit-scrollbar-thumb:hover {
|
| 37 |
+
background: #3a3b4d;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/* App Root - Fixed height with footer accommodation */
|
| 41 |
+
.app-root {
|
| 42 |
+
display: flex;
|
| 43 |
+
flex-direction: column;
|
| 44 |
+
height: 100vh;
|
| 45 |
+
background: radial-gradient(circle at top, #171823 0, #050608 55%);
|
| 46 |
+
color: #f5f5f7;
|
| 47 |
+
overflow: hidden;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* Main content wrapper (sidebar + workspace) */
|
| 51 |
+
.main-wrapper {
|
| 52 |
+
display: flex;
|
| 53 |
+
flex: 1;
|
| 54 |
+
min-height: 0;
|
| 55 |
+
overflow: hidden;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/* Sidebar */
|
| 59 |
+
.sidebar {
|
| 60 |
+
width: 320px;
|
| 61 |
+
min-width: 320px;
|
| 62 |
+
padding: 16px 14px;
|
| 63 |
+
border-right: 1px solid #272832;
|
| 64 |
+
background: linear-gradient(180deg, #101117 0, #050608 100%);
|
| 65 |
+
display: flex;
|
| 66 |
+
flex-direction: column;
|
| 67 |
+
gap: 16px;
|
| 68 |
+
overflow-y: auto;
|
| 69 |
+
overflow-x: hidden;
|
| 70 |
+
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
| 71 |
+
min-width 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
| 72 |
+
padding 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.sidebar--collapsed {
|
| 76 |
+
width: 52px;
|
| 77 |
+
min-width: 52px;
|
| 78 |
+
padding: 16px 8px;
|
| 79 |
+
gap: 8px;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/* ---- Sidebar top row: logo + toggle ---- */
|
| 83 |
+
.sidebar-top-row {
|
| 84 |
+
display: flex;
|
| 85 |
+
align-items: center;
|
| 86 |
+
justify-content: space-between;
|
| 87 |
+
gap: 6px;
|
| 88 |
+
min-height: 32px;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.sidebar--collapsed .sidebar-top-row {
|
| 92 |
+
justify-content: center;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.sidebar-toggle-btn {
|
| 96 |
+
display: flex;
|
| 97 |
+
align-items: center;
|
| 98 |
+
justify-content: center;
|
| 99 |
+
width: 28px;
|
| 100 |
+
height: 28px;
|
| 101 |
+
border-radius: 6px;
|
| 102 |
+
border: none;
|
| 103 |
+
background: transparent;
|
| 104 |
+
color: #6b6d82;
|
| 105 |
+
cursor: pointer;
|
| 106 |
+
transition: background 0.15s, color 0.15s;
|
| 107 |
+
flex-shrink: 0;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.sidebar-toggle-btn:hover {
|
| 111 |
+
background: #1e1f2e;
|
| 112 |
+
color: #e0e1eb;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.sidebar--collapsed .sidebar-toggle-btn {
|
| 116 |
+
display: none;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* ---- Nav buttons: icon + label layout ---- */
|
| 120 |
+
.sidebar .nav-btn {
|
| 121 |
+
display: flex;
|
| 122 |
+
align-items: center;
|
| 123 |
+
gap: 10px;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.sidebar .nav-btn svg {
|
| 127 |
+
flex-shrink: 0;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.sidebar--collapsed .nav-btn {
|
| 131 |
+
justify-content: center;
|
| 132 |
+
padding: 8px;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/* ---- User profile in collapsed state ---- */
|
| 136 |
+
.sidebar--collapsed .user-profile {
|
| 137 |
+
align-items: center;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.sidebar--collapsed .user-avatar {
|
| 141 |
+
width: 28px;
|
| 142 |
+
height: 28px;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
/* User Profile Section */
|
| 146 |
+
.user-profile {
|
| 147 |
+
margin-top: auto;
|
| 148 |
+
padding-top: 16px;
|
| 149 |
+
border-top: 1px solid #272832;
|
| 150 |
+
display: flex;
|
| 151 |
+
flex-direction: column;
|
| 152 |
+
gap: 12px;
|
| 153 |
+
animation: fadeIn 0.3s ease;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.user-profile-header {
|
| 157 |
+
display: flex;
|
| 158 |
+
align-items: center;
|
| 159 |
+
gap: 10px;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.user-avatar {
|
| 163 |
+
width: 40px;
|
| 164 |
+
height: 40px;
|
| 165 |
+
border-radius: 10px;
|
| 166 |
+
border: 2px solid #272832;
|
| 167 |
+
transition: all 0.2s ease;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.user-avatar:hover {
|
| 171 |
+
border-color: #ff7a3c;
|
| 172 |
+
transform: scale(1.05);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.user-info {
|
| 176 |
+
flex: 1;
|
| 177 |
+
min-width: 0;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.user-name {
|
| 181 |
+
font-size: 13px;
|
| 182 |
+
font-weight: 600;
|
| 183 |
+
color: #f5f5f7;
|
| 184 |
+
white-space: nowrap;
|
| 185 |
+
overflow: hidden;
|
| 186 |
+
text-overflow: ellipsis;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.user-login {
|
| 190 |
+
font-size: 11px;
|
| 191 |
+
color: #9a9bb0;
|
| 192 |
+
white-space: nowrap;
|
| 193 |
+
overflow: hidden;
|
| 194 |
+
text-overflow: ellipsis;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.btn-logout {
|
| 198 |
+
border: none;
|
| 199 |
+
outline: none;
|
| 200 |
+
background: #1a1b26;
|
| 201 |
+
color: #c3c5dd;
|
| 202 |
+
border-radius: 8px;
|
| 203 |
+
padding: 8px 12px;
|
| 204 |
+
font-size: 12px;
|
| 205 |
+
font-weight: 500;
|
| 206 |
+
cursor: pointer;
|
| 207 |
+
transition: all 0.2s ease;
|
| 208 |
+
border: 1px solid #272832;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.btn-logout:hover {
|
| 212 |
+
background: #2a2b3c;
|
| 213 |
+
border-color: #ff7a3c;
|
| 214 |
+
color: #ff7a3c;
|
| 215 |
+
transform: translateY(-1px);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.logo-row {
|
| 219 |
+
display: flex;
|
| 220 |
+
align-items: center;
|
| 221 |
+
gap: 10px;
|
| 222 |
+
animation: fadeIn 0.3s ease;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
@keyframes fadeIn {
|
| 226 |
+
from {
|
| 227 |
+
opacity: 0;
|
| 228 |
+
transform: translateY(-10px);
|
| 229 |
+
}
|
| 230 |
+
to {
|
| 231 |
+
opacity: 1;
|
| 232 |
+
transform: translateY(0);
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.logo-square {
|
| 237 |
+
width: 32px;
|
| 238 |
+
height: 32px;
|
| 239 |
+
border-radius: 8px;
|
| 240 |
+
background: #ff7a3c;
|
| 241 |
+
display: flex;
|
| 242 |
+
align-items: center;
|
| 243 |
+
justify-content: center;
|
| 244 |
+
font-weight: 700;
|
| 245 |
+
color: #050608;
|
| 246 |
+
transition: transform 0.2s ease;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.logo-square:hover {
|
| 250 |
+
transform: scale(1.05);
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.logo-title {
|
| 254 |
+
font-size: 16px;
|
| 255 |
+
font-weight: 600;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.logo-subtitle {
|
| 259 |
+
font-size: 12px;
|
| 260 |
+
color: #a1a2b3;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
/* Active context card */
|
| 264 |
+
.sidebar-context-card {
|
| 265 |
+
padding: 10px 12px;
|
| 266 |
+
border-radius: 10px;
|
| 267 |
+
background: #151622;
|
| 268 |
+
border: 1px solid #272832;
|
| 269 |
+
display: flex;
|
| 270 |
+
flex-direction: column;
|
| 271 |
+
gap: 6px;
|
| 272 |
+
animation: slideIn 0.3s ease;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.sidebar-context-header {
|
| 276 |
+
display: flex;
|
| 277 |
+
align-items: center;
|
| 278 |
+
justify-content: space-between;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.sidebar-context-close {
|
| 282 |
+
width: 22px;
|
| 283 |
+
height: 22px;
|
| 284 |
+
border-radius: 4px;
|
| 285 |
+
border: none;
|
| 286 |
+
background: transparent;
|
| 287 |
+
color: #71717a;
|
| 288 |
+
cursor: pointer;
|
| 289 |
+
display: flex;
|
| 290 |
+
align-items: center;
|
| 291 |
+
justify-content: center;
|
| 292 |
+
padding: 0;
|
| 293 |
+
transition: all 0.15s ease;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.sidebar-context-close:hover {
|
| 297 |
+
background: #272832;
|
| 298 |
+
color: #f5f5f7;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.sidebar-section-label {
|
| 302 |
+
font-size: 10px;
|
| 303 |
+
font-weight: 700;
|
| 304 |
+
letter-spacing: 0.08em;
|
| 305 |
+
color: #71717a;
|
| 306 |
+
text-transform: uppercase;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.sidebar-context-body {
|
| 310 |
+
display: flex;
|
| 311 |
+
flex-direction: column;
|
| 312 |
+
gap: 2px;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.sidebar-context-repo {
|
| 316 |
+
font-size: 13px;
|
| 317 |
+
font-weight: 600;
|
| 318 |
+
color: #f5f5f7;
|
| 319 |
+
white-space: nowrap;
|
| 320 |
+
overflow: hidden;
|
| 321 |
+
text-overflow: ellipsis;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.sidebar-context-meta {
|
| 325 |
+
font-size: 11px;
|
| 326 |
+
color: #9a9bb0;
|
| 327 |
+
display: flex;
|
| 328 |
+
align-items: center;
|
| 329 |
+
gap: 6px;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.sidebar-context-dot {
|
| 333 |
+
width: 3px;
|
| 334 |
+
height: 3px;
|
| 335 |
+
border-radius: 50%;
|
| 336 |
+
background: #4a4b5e;
|
| 337 |
+
display: inline-block;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.sidebar-context-actions {
|
| 341 |
+
display: flex;
|
| 342 |
+
gap: 6px;
|
| 343 |
+
margin-top: 2px;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.sidebar-context-btn {
|
| 347 |
+
border: none;
|
| 348 |
+
outline: none;
|
| 349 |
+
background: #1a1b26;
|
| 350 |
+
color: #9a9bb0;
|
| 351 |
+
border-radius: 6px;
|
| 352 |
+
padding: 4px 10px;
|
| 353 |
+
font-size: 11px;
|
| 354 |
+
font-weight: 500;
|
| 355 |
+
cursor: pointer;
|
| 356 |
+
transition: all 0.15s ease;
|
| 357 |
+
border: 1px solid #272832;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.sidebar-context-btn:hover {
|
| 361 |
+
background: #222335;
|
| 362 |
+
color: #c3c5dd;
|
| 363 |
+
border-color: #3a3b4d;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
/* Per-repo chip list in sidebar context card */
|
| 367 |
+
.sidebar-repo-chips {
|
| 368 |
+
display: flex;
|
| 369 |
+
flex-direction: column;
|
| 370 |
+
gap: 3px;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
.sidebar-repo-chip {
|
| 374 |
+
display: flex;
|
| 375 |
+
align-items: center;
|
| 376 |
+
gap: 5px;
|
| 377 |
+
padding: 5px 6px 5px 8px;
|
| 378 |
+
border-radius: 6px;
|
| 379 |
+
border: 1px solid #272832;
|
| 380 |
+
background: #111220;
|
| 381 |
+
cursor: pointer;
|
| 382 |
+
white-space: nowrap;
|
| 383 |
+
overflow: hidden;
|
| 384 |
+
transition: border-color 0.15s, background-color 0.15s;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.sidebar-repo-chip:hover {
|
| 388 |
+
border-color: #3a3b4d;
|
| 389 |
+
background: #1a1b2e;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
.sidebar-repo-chip-active {
|
| 393 |
+
border-color: #3B82F6;
|
| 394 |
+
background: rgba(59, 130, 246, 0.06);
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
.sidebar-chip-name {
|
| 398 |
+
font-size: 12px;
|
| 399 |
+
font-weight: 600;
|
| 400 |
+
color: #c3c5dd;
|
| 401 |
+
font-family: monospace;
|
| 402 |
+
overflow: hidden;
|
| 403 |
+
text-overflow: ellipsis;
|
| 404 |
+
flex: 1;
|
| 405 |
+
min-width: 0;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.sidebar-repo-chip-active .sidebar-chip-name {
|
| 409 |
+
color: #f5f5f7;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.sidebar-chip-dot {
|
| 413 |
+
width: 2px;
|
| 414 |
+
height: 2px;
|
| 415 |
+
border-radius: 50%;
|
| 416 |
+
background: #4a4b5e;
|
| 417 |
+
flex-shrink: 0;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.sidebar-chip-branch {
|
| 421 |
+
font-size: 10px;
|
| 422 |
+
color: #71717a;
|
| 423 |
+
font-family: monospace;
|
| 424 |
+
flex-shrink: 0;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
.sidebar-repo-chip-active .sidebar-chip-branch {
|
| 428 |
+
color: #60a5fa;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
.sidebar-chip-write-badge {
|
| 432 |
+
font-size: 8px;
|
| 433 |
+
font-weight: 700;
|
| 434 |
+
text-transform: uppercase;
|
| 435 |
+
letter-spacing: 0.06em;
|
| 436 |
+
color: #4caf88;
|
| 437 |
+
padding: 0 4px;
|
| 438 |
+
border-radius: 3px;
|
| 439 |
+
border: 1px solid rgba(76, 175, 136, 0.25);
|
| 440 |
+
flex-shrink: 0;
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
/* Per-chip remove button: subtle by default, visible on hover */
|
| 444 |
+
.sidebar-chip-remove {
|
| 445 |
+
display: flex;
|
| 446 |
+
align-items: center;
|
| 447 |
+
justify-content: center;
|
| 448 |
+
width: 16px;
|
| 449 |
+
height: 16px;
|
| 450 |
+
border-radius: 3px;
|
| 451 |
+
border: none;
|
| 452 |
+
background: transparent;
|
| 453 |
+
color: #52525B;
|
| 454 |
+
cursor: pointer;
|
| 455 |
+
flex-shrink: 0;
|
| 456 |
+
padding: 0;
|
| 457 |
+
opacity: 0;
|
| 458 |
+
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
.sidebar-repo-chip:hover .sidebar-chip-remove {
|
| 462 |
+
opacity: 1;
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
.sidebar-chip-remove:hover {
|
| 466 |
+
color: #f87171;
|
| 467 |
+
background: rgba(248, 113, 113, 0.1);
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
/* "clear all" link-style button */
|
| 471 |
+
.sidebar-clear-all {
|
| 472 |
+
font-size: 9px;
|
| 473 |
+
color: #52525B;
|
| 474 |
+
width: auto;
|
| 475 |
+
height: auto;
|
| 476 |
+
padding: 2px 6px;
|
| 477 |
+
font-weight: 600;
|
| 478 |
+
text-transform: uppercase;
|
| 479 |
+
letter-spacing: 0.04em;
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
.sidebar-clear-all:hover {
|
| 483 |
+
color: #f87171;
|
| 484 |
+
background: rgba(248, 113, 113, 0.08);
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
@keyframes slideIn {
|
| 488 |
+
from {
|
| 489 |
+
opacity: 0;
|
| 490 |
+
transform: translateX(-10px);
|
| 491 |
+
}
|
| 492 |
+
to {
|
| 493 |
+
opacity: 1;
|
| 494 |
+
transform: translateX(0);
|
| 495 |
+
}
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
/* ContextBar β horizontal chip bar above workspace */
|
| 499 |
+
.ctxbar {
|
| 500 |
+
display: flex;
|
| 501 |
+
align-items: center;
|
| 502 |
+
gap: 8px;
|
| 503 |
+
padding: 6px 12px;
|
| 504 |
+
border-bottom: 1px solid #1E1F23;
|
| 505 |
+
background-color: #0D0D10;
|
| 506 |
+
min-height: 40px;
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
.ctxbar-scroll {
|
| 510 |
+
display: flex;
|
| 511 |
+
align-items: center;
|
| 512 |
+
gap: 6px;
|
| 513 |
+
flex: 1;
|
| 514 |
+
overflow-x: auto;
|
| 515 |
+
scrollbar-width: none;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
.ctxbar-scroll::-webkit-scrollbar {
|
| 519 |
+
display: none;
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
.ctxbar-chip {
|
| 523 |
+
display: flex;
|
| 524 |
+
align-items: center;
|
| 525 |
+
gap: 5px;
|
| 526 |
+
padding: 4px 6px 4px 8px;
|
| 527 |
+
border-radius: 6px;
|
| 528 |
+
border: 1px solid #27272A;
|
| 529 |
+
background: #18181B;
|
| 530 |
+
cursor: pointer;
|
| 531 |
+
white-space: nowrap;
|
| 532 |
+
position: relative;
|
| 533 |
+
flex-shrink: 0;
|
| 534 |
+
transition: border-color 0.15s, background-color 0.15s;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
.ctxbar-chip:hover {
|
| 538 |
+
border-color: #3a3b4d;
|
| 539 |
+
background: #1e1f30;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
.ctxbar-chip-active {
|
| 543 |
+
border-color: #3B82F6;
|
| 544 |
+
background: rgba(59, 130, 246, 0.08);
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
.ctxbar-chip-indicator {
|
| 548 |
+
position: absolute;
|
| 549 |
+
left: 0;
|
| 550 |
+
top: 25%;
|
| 551 |
+
bottom: 25%;
|
| 552 |
+
width: 2px;
|
| 553 |
+
border-radius: 1px;
|
| 554 |
+
background-color: #3B82F6;
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
.ctxbar-chip-name {
|
| 558 |
+
font-size: 12px;
|
| 559 |
+
font-weight: 600;
|
| 560 |
+
font-family: monospace;
|
| 561 |
+
color: #A1A1AA;
|
| 562 |
+
max-width: 120px;
|
| 563 |
+
overflow: hidden;
|
| 564 |
+
text-overflow: ellipsis;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
.ctxbar-chip-active .ctxbar-chip-name {
|
| 568 |
+
color: #E4E4E7;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
.ctxbar-chip-dot {
|
| 572 |
+
width: 2px;
|
| 573 |
+
height: 2px;
|
| 574 |
+
border-radius: 50%;
|
| 575 |
+
background: #4a4b5e;
|
| 576 |
+
flex-shrink: 0;
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
.ctxbar-chip-branch {
|
| 580 |
+
font-size: 10px;
|
| 581 |
+
font-family: monospace;
|
| 582 |
+
background: none;
|
| 583 |
+
border: 1px solid transparent;
|
| 584 |
+
border-radius: 3px;
|
| 585 |
+
padding: 1px 4px;
|
| 586 |
+
cursor: pointer;
|
| 587 |
+
color: #71717A;
|
| 588 |
+
transition: border-color 0.15s, color 0.15s;
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
.ctxbar-chip-branch:hover {
|
| 592 |
+
border-color: #3a3b4d;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
.ctxbar-chip-branch-active {
|
| 596 |
+
color: #60a5fa;
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
.ctxbar-chip-write {
|
| 600 |
+
font-size: 8px;
|
| 601 |
+
font-weight: 700;
|
| 602 |
+
text-transform: uppercase;
|
| 603 |
+
letter-spacing: 0.06em;
|
| 604 |
+
color: #4caf88;
|
| 605 |
+
padding: 0 4px;
|
| 606 |
+
border-radius: 3px;
|
| 607 |
+
border: 1px solid rgba(76, 175, 136, 0.25);
|
| 608 |
+
flex-shrink: 0;
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
/* Hover-reveal remove button (Claude-style: hidden β visible on chip hover β red on X hover) */
|
| 612 |
+
.ctxbar-chip-remove {
|
| 613 |
+
display: flex;
|
| 614 |
+
align-items: center;
|
| 615 |
+
justify-content: center;
|
| 616 |
+
width: 16px;
|
| 617 |
+
height: 16px;
|
| 618 |
+
border-radius: 3px;
|
| 619 |
+
border: none;
|
| 620 |
+
background: transparent;
|
| 621 |
+
color: #52525B;
|
| 622 |
+
cursor: pointer;
|
| 623 |
+
flex-shrink: 0;
|
| 624 |
+
padding: 0;
|
| 625 |
+
opacity: 0;
|
| 626 |
+
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
.ctxbar-chip-remove-visible,
|
| 630 |
+
.ctxbar-chip:hover .ctxbar-chip-remove {
|
| 631 |
+
opacity: 1;
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
.ctxbar-chip-remove:hover {
|
| 635 |
+
color: #f87171;
|
| 636 |
+
background: rgba(248, 113, 113, 0.1);
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
.ctxbar-add {
|
| 640 |
+
display: flex;
|
| 641 |
+
align-items: center;
|
| 642 |
+
justify-content: center;
|
| 643 |
+
width: 28px;
|
| 644 |
+
height: 28px;
|
| 645 |
+
border-radius: 6px;
|
| 646 |
+
border: 1px dashed #3F3F46;
|
| 647 |
+
background: transparent;
|
| 648 |
+
color: #71717A;
|
| 649 |
+
cursor: pointer;
|
| 650 |
+
flex-shrink: 0;
|
| 651 |
+
transition: border-color 0.15s, color 0.15s;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
.ctxbar-add:hover {
|
| 655 |
+
border-color: #60a5fa;
|
| 656 |
+
color: #60a5fa;
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
.ctxbar-meta {
|
| 660 |
+
font-size: 10px;
|
| 661 |
+
color: #52525B;
|
| 662 |
+
white-space: nowrap;
|
| 663 |
+
flex-shrink: 0;
|
| 664 |
+
font-weight: 600;
|
| 665 |
+
text-transform: uppercase;
|
| 666 |
+
letter-spacing: 0.04em;
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
.ctxbar-branch-picker {
|
| 670 |
+
position: absolute;
|
| 671 |
+
top: 100%;
|
| 672 |
+
left: 0;
|
| 673 |
+
z-index: 100;
|
| 674 |
+
margin-top: 4px;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
/* Legacy compat β kept for other uses */
|
| 678 |
+
.sidebar-repo-info {
|
| 679 |
+
padding: 10px 12px;
|
| 680 |
+
border-radius: 10px;
|
| 681 |
+
background: #151622;
|
| 682 |
+
border: 1px solid #272832;
|
| 683 |
+
animation: slideIn 0.3s ease;
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
.sidebar-repo-name {
|
| 687 |
+
font-size: 13px;
|
| 688 |
+
font-weight: 500;
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
.sidebar-repo-meta {
|
| 692 |
+
font-size: 11px;
|
| 693 |
+
color: #9a9bb0;
|
| 694 |
+
margin-top: 2px;
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
.settings-button {
|
| 698 |
+
border: none;
|
| 699 |
+
outline: none;
|
| 700 |
+
background: #1a1b26;
|
| 701 |
+
color: #f5f5f7;
|
| 702 |
+
border-radius: 8px;
|
| 703 |
+
padding: 8px 10px;
|
| 704 |
+
cursor: pointer;
|
| 705 |
+
font-size: 13px;
|
| 706 |
+
transition: all 0.2s ease;
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
.settings-button:hover {
|
| 710 |
+
background: #222335;
|
| 711 |
+
transform: translateY(-1px);
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
/* Repo search */
|
| 715 |
+
.repo-search-box {
|
| 716 |
+
border-radius: 12px;
|
| 717 |
+
background: #101117;
|
| 718 |
+
border: 1px solid #272832;
|
| 719 |
+
padding: 8px;
|
| 720 |
+
display: flex;
|
| 721 |
+
flex-direction: column;
|
| 722 |
+
gap: 8px;
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
/* Search header wrapper */
|
| 726 |
+
.repo-search-header {
|
| 727 |
+
display: flex;
|
| 728 |
+
flex-direction: column;
|
| 729 |
+
gap: 8px;
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
/* Search row with input and button */
|
| 733 |
+
.repo-search-row {
|
| 734 |
+
display: flex;
|
| 735 |
+
gap: 6px;
|
| 736 |
+
align-items: center;
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
/* Search input */
|
| 740 |
+
.repo-search-input {
|
| 741 |
+
flex: 1;
|
| 742 |
+
border-radius: 7px;
|
| 743 |
+
padding: 8px 10px;
|
| 744 |
+
border: 1px solid #272832;
|
| 745 |
+
background: #050608;
|
| 746 |
+
color: #f5f5f7;
|
| 747 |
+
font-size: 13px;
|
| 748 |
+
transition: all 0.2s ease;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
.repo-search-input:focus {
|
| 752 |
+
outline: none;
|
| 753 |
+
border-color: #ff7a3c;
|
| 754 |
+
background: #0a0b0f;
|
| 755 |
+
box-shadow: 0 0 0 3px rgba(255, 122, 60, 0.08);
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
+
.repo-search-input::placeholder {
|
| 759 |
+
color: #676883;
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
.repo-search-input:disabled {
|
| 763 |
+
opacity: 0.5;
|
| 764 |
+
cursor: not-allowed;
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
/* Search button */
|
| 768 |
+
.repo-search-btn {
|
| 769 |
+
border-radius: 7px;
|
| 770 |
+
border: none;
|
| 771 |
+
outline: none;
|
| 772 |
+
padding: 8px 14px;
|
| 773 |
+
background: #1a1b26;
|
| 774 |
+
color: #f5f5f7;
|
| 775 |
+
cursor: pointer;
|
| 776 |
+
font-size: 13px;
|
| 777 |
+
font-weight: 500;
|
| 778 |
+
transition: all 0.2s ease;
|
| 779 |
+
white-space: nowrap;
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
.repo-search-btn:hover:not(:disabled) {
|
| 783 |
+
background: #222335;
|
| 784 |
+
transform: translateY(-1px);
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
.repo-search-btn:active:not(:disabled) {
|
| 788 |
+
transform: translateY(0);
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
.repo-search-btn:disabled {
|
| 792 |
+
opacity: 0.5;
|
| 793 |
+
cursor: not-allowed;
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
/* Info bar (shows count and clear button) */
|
| 797 |
+
.repo-info-bar {
|
| 798 |
+
display: flex;
|
| 799 |
+
justify-content: space-between;
|
| 800 |
+
align-items: center;
|
| 801 |
+
padding: 6px 10px;
|
| 802 |
+
background: #0a0b0f;
|
| 803 |
+
border: 1px solid #272832;
|
| 804 |
+
border-radius: 7px;
|
| 805 |
+
font-size: 11px;
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
.repo-count {
|
| 809 |
+
color: #9a9bb0;
|
| 810 |
+
font-weight: 500;
|
| 811 |
+
}
|
| 812 |
+
|
| 813 |
+
.repo-clear-btn {
|
| 814 |
+
padding: 3px 10px;
|
| 815 |
+
background: transparent;
|
| 816 |
+
border: 1px solid #272832;
|
| 817 |
+
border-radius: 5px;
|
| 818 |
+
color: #9a9bb0;
|
| 819 |
+
font-size: 11px;
|
| 820 |
+
font-weight: 500;
|
| 821 |
+
cursor: pointer;
|
| 822 |
+
transition: all 0.2s ease;
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
.repo-clear-btn:hover:not(:disabled) {
|
| 826 |
+
background: #1a1b26;
|
| 827 |
+
color: #c3c5dd;
|
| 828 |
+
border-color: #3a3b4d;
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
.repo-clear-btn:disabled {
|
| 832 |
+
opacity: 0.5;
|
| 833 |
+
cursor: not-allowed;
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
/* Status message */
|
| 837 |
+
.repo-status {
|
| 838 |
+
padding: 8px 10px;
|
| 839 |
+
background: #1a1b26;
|
| 840 |
+
border: 1px solid #272832;
|
| 841 |
+
border-radius: 7px;
|
| 842 |
+
color: #9a9bb0;
|
| 843 |
+
font-size: 11px;
|
| 844 |
+
text-align: center;
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
/* Repository list */
|
| 848 |
+
.repo-list {
|
| 849 |
+
max-height: 220px;
|
| 850 |
+
overflow-y: auto;
|
| 851 |
+
overflow-x: hidden;
|
| 852 |
+
padding-right: 2px;
|
| 853 |
+
display: flex;
|
| 854 |
+
flex-direction: column;
|
| 855 |
+
gap: 4px;
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
/* Custom scrollbar for repo list */
|
| 859 |
+
.repo-list::-webkit-scrollbar {
|
| 860 |
+
width: 6px;
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
.repo-list::-webkit-scrollbar-track {
|
| 864 |
+
background: transparent;
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
.repo-list::-webkit-scrollbar-thumb {
|
| 868 |
+
background: #272832;
|
| 869 |
+
border-radius: 3px;
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
.repo-list::-webkit-scrollbar-thumb:hover {
|
| 873 |
+
background: #3a3b4d;
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
/* Repository item */
|
| 877 |
+
.repo-item {
|
| 878 |
+
width: 100%;
|
| 879 |
+
text-align: left;
|
| 880 |
+
border: none;
|
| 881 |
+
outline: none;
|
| 882 |
+
background: transparent;
|
| 883 |
+
color: #f5f5f7;
|
| 884 |
+
padding: 8px 8px;
|
| 885 |
+
border-radius: 7px;
|
| 886 |
+
cursor: pointer;
|
| 887 |
+
display: flex;
|
| 888 |
+
align-items: center;
|
| 889 |
+
justify-content: space-between;
|
| 890 |
+
gap: 8px;
|
| 891 |
+
transition: all 0.15s ease;
|
| 892 |
+
border: 1px solid transparent;
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
.repo-item:hover {
|
| 896 |
+
background: #1a1b26;
|
| 897 |
+
border-color: #272832;
|
| 898 |
+
transform: translateX(2px);
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
.repo-item-content {
|
| 902 |
+
display: flex;
|
| 903 |
+
flex-direction: column;
|
| 904 |
+
gap: 2px;
|
| 905 |
+
flex: 1;
|
| 906 |
+
min-width: 0;
|
| 907 |
+
}
|
| 908 |
+
|
| 909 |
+
.repo-name {
|
| 910 |
+
font-size: 13px;
|
| 911 |
+
font-weight: 500;
|
| 912 |
+
overflow: hidden;
|
| 913 |
+
text-overflow: ellipsis;
|
| 914 |
+
white-space: nowrap;
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
.repo-owner {
|
| 918 |
+
font-size: 11px;
|
| 919 |
+
color: #8e8fac;
|
| 920 |
+
overflow: hidden;
|
| 921 |
+
text-overflow: ellipsis;
|
| 922 |
+
white-space: nowrap;
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
/* Private badge */
|
| 926 |
+
.repo-badge-private {
|
| 927 |
+
padding: 2px 6px;
|
| 928 |
+
background: #1a1b26;
|
| 929 |
+
border: 1px solid #3a3b4d;
|
| 930 |
+
border-radius: 4px;
|
| 931 |
+
color: #9a9bb0;
|
| 932 |
+
font-size: 9px;
|
| 933 |
+
font-weight: 600;
|
| 934 |
+
text-transform: uppercase;
|
| 935 |
+
letter-spacing: 0.3px;
|
| 936 |
+
white-space: nowrap;
|
| 937 |
+
flex-shrink: 0;
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
/* Loading states */
|
| 941 |
+
.repo-loading {
|
| 942 |
+
display: flex;
|
| 943 |
+
flex-direction: column;
|
| 944 |
+
align-items: center;
|
| 945 |
+
gap: 10px;
|
| 946 |
+
padding: 30px 20px;
|
| 947 |
+
color: #9a9bb0;
|
| 948 |
+
font-size: 12px;
|
| 949 |
+
}
|
| 950 |
+
|
| 951 |
+
.repo-loading-spinner {
|
| 952 |
+
width: 24px;
|
| 953 |
+
height: 24px;
|
| 954 |
+
border: 2px solid #272832;
|
| 955 |
+
border-top-color: #ff7a3c;
|
| 956 |
+
border-radius: 50%;
|
| 957 |
+
animation: repo-spin 0.8s linear infinite;
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
.repo-loading-spinner-small {
|
| 961 |
+
width: 14px;
|
| 962 |
+
height: 14px;
|
| 963 |
+
border: 2px solid rgba(255, 122, 60, 0.3);
|
| 964 |
+
border-top-color: #ff7a3c;
|
| 965 |
+
border-radius: 50%;
|
| 966 |
+
animation: repo-spin 0.8s linear infinite;
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
@keyframes repo-spin {
|
| 970 |
+
to {
|
| 971 |
+
transform: rotate(360deg);
|
| 972 |
+
}
|
| 973 |
+
}
|
| 974 |
+
|
| 975 |
+
/* Load more button */
|
| 976 |
+
.repo-load-more {
|
| 977 |
+
display: flex;
|
| 978 |
+
align-items: center;
|
| 979 |
+
justify-content: center;
|
| 980 |
+
gap: 8px;
|
| 981 |
+
width: 100%;
|
| 982 |
+
padding: 10px 12px;
|
| 983 |
+
margin: 4px 0;
|
| 984 |
+
background: #0a0b0f;
|
| 985 |
+
border: 1px solid #272832;
|
| 986 |
+
border-radius: 7px;
|
| 987 |
+
color: #c3c5dd;
|
| 988 |
+
font-size: 12px;
|
| 989 |
+
font-weight: 500;
|
| 990 |
+
cursor: pointer;
|
| 991 |
+
transition: all 0.2s ease;
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
.repo-load-more:hover:not(:disabled) {
|
| 995 |
+
background: #1a1b26;
|
| 996 |
+
border-color: #3a3b4d;
|
| 997 |
+
transform: translateY(-1px);
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
.repo-load-more:active:not(:disabled) {
|
| 1001 |
+
transform: translateY(0);
|
| 1002 |
+
}
|
| 1003 |
+
|
| 1004 |
+
.repo-load-more:disabled {
|
| 1005 |
+
opacity: 0.6;
|
| 1006 |
+
cursor: not-allowed;
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
.repo-load-more-count {
|
| 1010 |
+
color: #7779a0;
|
| 1011 |
+
font-weight: 400;
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
+
/* All loaded message */
|
| 1015 |
+
.repo-all-loaded {
|
| 1016 |
+
padding: 10px 12px;
|
| 1017 |
+
margin: 4px 0;
|
| 1018 |
+
background: rgba(124, 255, 179, 0.08);
|
| 1019 |
+
border: 1px solid rgba(124, 255, 179, 0.2);
|
| 1020 |
+
border-radius: 7px;
|
| 1021 |
+
color: #7cffb3;
|
| 1022 |
+
font-size: 11px;
|
| 1023 |
+
text-align: center;
|
| 1024 |
+
font-weight: 500;
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
/* GitHub App installation notice */
|
| 1028 |
+
.repo-github-notice {
|
| 1029 |
+
display: flex;
|
| 1030 |
+
align-items: flex-start;
|
| 1031 |
+
gap: 10px;
|
| 1032 |
+
padding: 10px 12px;
|
| 1033 |
+
background: #0a0b0f;
|
| 1034 |
+
border: 1px solid #272832;
|
| 1035 |
+
border-radius: 7px;
|
| 1036 |
+
font-size: 11px;
|
| 1037 |
+
line-height: 1.5;
|
| 1038 |
+
margin-top: 4px;
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
.repo-github-icon {
|
| 1042 |
+
flex-shrink: 0;
|
| 1043 |
+
margin-top: 1px;
|
| 1044 |
+
opacity: 0.6;
|
| 1045 |
+
color: #9a9bb0;
|
| 1046 |
+
width: 16px;
|
| 1047 |
+
height: 16px;
|
| 1048 |
+
}
|
| 1049 |
+
|
| 1050 |
+
.repo-github-notice-content {
|
| 1051 |
+
flex: 1;
|
| 1052 |
+
display: flex;
|
| 1053 |
+
flex-direction: column;
|
| 1054 |
+
gap: 3px;
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
.repo-github-notice-title {
|
| 1058 |
+
color: #c3c5dd;
|
| 1059 |
+
font-weight: 600;
|
| 1060 |
+
font-size: 11px;
|
| 1061 |
+
}
|
| 1062 |
+
|
| 1063 |
+
.repo-github-notice-text {
|
| 1064 |
+
color: #9a9bb0;
|
| 1065 |
+
}
|
| 1066 |
+
|
| 1067 |
+
.repo-github-link {
|
| 1068 |
+
color: #ff7a3c;
|
| 1069 |
+
text-decoration: none;
|
| 1070 |
+
font-weight: 500;
|
| 1071 |
+
transition: color 0.2s ease;
|
| 1072 |
+
}
|
| 1073 |
+
|
| 1074 |
+
.repo-github-link:hover {
|
| 1075 |
+
color: #ff8b52;
|
| 1076 |
+
text-decoration: underline;
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
/* Focus visible for accessibility */
|
| 1080 |
+
.repo-item:focus-visible,
|
| 1081 |
+
.repo-search-btn:focus-visible,
|
| 1082 |
+
.repo-load-more:focus-visible,
|
| 1083 |
+
.repo-clear-btn:focus-visible {
|
| 1084 |
+
outline: 2px solid #ff7a3c;
|
| 1085 |
+
outline-offset: 2px;
|
| 1086 |
+
}
|
| 1087 |
+
|
| 1088 |
+
/* Reduced motion support */
|
| 1089 |
+
@media (prefers-reduced-motion: reduce) {
|
| 1090 |
+
.repo-item,
|
| 1091 |
+
.repo-search-btn,
|
| 1092 |
+
.repo-load-more,
|
| 1093 |
+
.repo-clear-btn {
|
| 1094 |
+
transition: none;
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
.repo-loading-spinner,
|
| 1098 |
+
.repo-loading-spinner-small {
|
| 1099 |
+
animation: none;
|
| 1100 |
+
}
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
/* Mobile responsive adjustments */
|
| 1104 |
+
@media (max-width: 768px) {
|
| 1105 |
+
.repo-search-input {
|
| 1106 |
+
font-size: 16px; /* Prevents zoom on iOS */
|
| 1107 |
+
}
|
| 1108 |
+
|
| 1109 |
+
.repo-item {
|
| 1110 |
+
padding: 7px 7px;
|
| 1111 |
+
}
|
| 1112 |
+
|
| 1113 |
+
.repo-name {
|
| 1114 |
+
font-size: 12px;
|
| 1115 |
+
}
|
| 1116 |
+
|
| 1117 |
+
.repo-owner {
|
| 1118 |
+
font-size: 10px;
|
| 1119 |
+
}
|
| 1120 |
+
}
|
| 1121 |
+
|
| 1122 |
+
/* Workspace */
|
| 1123 |
+
.workspace {
|
| 1124 |
+
flex: 1;
|
| 1125 |
+
display: flex;
|
| 1126 |
+
flex-direction: column;
|
| 1127 |
+
position: relative;
|
| 1128 |
+
overflow: hidden;
|
| 1129 |
+
min-height: 0;
|
| 1130 |
+
}
|
| 1131 |
+
|
| 1132 |
+
.empty-state {
|
| 1133 |
+
margin: auto;
|
| 1134 |
+
max-width: 420px;
|
| 1135 |
+
text-align: center;
|
| 1136 |
+
color: #c3c5dd;
|
| 1137 |
+
animation: fadeIn 0.5s ease;
|
| 1138 |
+
}
|
| 1139 |
+
|
| 1140 |
+
.empty-bot {
|
| 1141 |
+
font-size: 36px;
|
| 1142 |
+
margin-bottom: 12px;
|
| 1143 |
+
animation: bounce 2s ease infinite;
|
| 1144 |
+
}
|
| 1145 |
+
|
| 1146 |
+
@keyframes bounce {
|
| 1147 |
+
0%, 100% {
|
| 1148 |
+
transform: translateY(0);
|
| 1149 |
+
}
|
| 1150 |
+
50% {
|
| 1151 |
+
transform: translateY(-10px);
|
| 1152 |
+
}
|
| 1153 |
+
}
|
| 1154 |
+
|
| 1155 |
+
.empty-state h1 {
|
| 1156 |
+
font-size: 24px;
|
| 1157 |
+
margin-bottom: 6px;
|
| 1158 |
+
}
|
| 1159 |
+
|
| 1160 |
+
.empty-state p {
|
| 1161 |
+
font-size: 14px;
|
| 1162 |
+
color: #9a9bb0;
|
| 1163 |
+
}
|
| 1164 |
+
|
| 1165 |
+
/* Workspace grid - Properly constrained */
|
| 1166 |
+
.workspace-grid {
|
| 1167 |
+
display: grid;
|
| 1168 |
+
grid-template-columns: 320px minmax(340px, 1fr);
|
| 1169 |
+
height: 100%;
|
| 1170 |
+
overflow: hidden;
|
| 1171 |
+
flex: 1;
|
| 1172 |
+
min-height: 0;
|
| 1173 |
+
}
|
| 1174 |
+
|
| 1175 |
+
/* Panels */
|
| 1176 |
+
.panel-header {
|
| 1177 |
+
height: 40px;
|
| 1178 |
+
padding: 0 16px;
|
| 1179 |
+
border-bottom: 1px solid #272832;
|
| 1180 |
+
display: flex;
|
| 1181 |
+
align-items: center;
|
| 1182 |
+
justify-content: space-between;
|
| 1183 |
+
font-size: 13px;
|
| 1184 |
+
font-weight: 500;
|
| 1185 |
+
color: #c3c5dd;
|
| 1186 |
+
background: #0a0b0f;
|
| 1187 |
+
flex-shrink: 0;
|
| 1188 |
+
}
|
| 1189 |
+
|
| 1190 |
+
.badge {
|
| 1191 |
+
padding: 2px 6px;
|
| 1192 |
+
border-radius: 999px;
|
| 1193 |
+
border: 1px solid #3a3b4d;
|
| 1194 |
+
font-size: 10px;
|
| 1195 |
+
}
|
| 1196 |
+
|
| 1197 |
+
/* Files */
|
| 1198 |
+
.files-panel {
|
| 1199 |
+
border-right: 1px solid #272832;
|
| 1200 |
+
background: #101117;
|
| 1201 |
+
display: flex;
|
| 1202 |
+
flex-direction: column;
|
| 1203 |
+
overflow: hidden;
|
| 1204 |
+
}
|
| 1205 |
+
|
| 1206 |
+
.files-list {
|
| 1207 |
+
flex: 1;
|
| 1208 |
+
overflow-y: auto;
|
| 1209 |
+
overflow-x: hidden;
|
| 1210 |
+
padding: 6px 4px;
|
| 1211 |
+
min-height: 0;
|
| 1212 |
+
}
|
| 1213 |
+
|
| 1214 |
+
.files-item {
|
| 1215 |
+
border: none;
|
| 1216 |
+
outline: none;
|
| 1217 |
+
width: 100%;
|
| 1218 |
+
background: transparent;
|
| 1219 |
+
color: #f5f5f7;
|
| 1220 |
+
display: flex;
|
| 1221 |
+
align-items: center;
|
| 1222 |
+
gap: 8px;
|
| 1223 |
+
padding: 4px 8px;
|
| 1224 |
+
border-radius: 6px;
|
| 1225 |
+
cursor: pointer;
|
| 1226 |
+
font-size: 12px;
|
| 1227 |
+
transition: all 0.15s ease;
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
.files-item:hover {
|
| 1231 |
+
background: #1a1b26;
|
| 1232 |
+
transform: translateX(2px);
|
| 1233 |
+
}
|
| 1234 |
+
|
| 1235 |
+
.files-item-active {
|
| 1236 |
+
background: #2a2b3c;
|
| 1237 |
+
}
|
| 1238 |
+
|
| 1239 |
+
.file-icon {
|
| 1240 |
+
width: 16px;
|
| 1241 |
+
flex-shrink: 0;
|
| 1242 |
+
}
|
| 1243 |
+
|
| 1244 |
+
.file-path {
|
| 1245 |
+
white-space: nowrap;
|
| 1246 |
+
overflow: hidden;
|
| 1247 |
+
text-overflow: ellipsis;
|
| 1248 |
+
}
|
| 1249 |
+
|
| 1250 |
+
.files-empty {
|
| 1251 |
+
padding: 10px 12px;
|
| 1252 |
+
font-size: 12px;
|
| 1253 |
+
color: #9a9bb0;
|
| 1254 |
+
}
|
| 1255 |
+
|
| 1256 |
+
/* Chat panel */
|
| 1257 |
+
.editor-panel {
|
| 1258 |
+
display: flex;
|
| 1259 |
+
flex-direction: column;
|
| 1260 |
+
background: #050608;
|
| 1261 |
+
}
|
| 1262 |
+
|
| 1263 |
+
.chat-container {
|
| 1264 |
+
display: flex;
|
| 1265 |
+
flex-direction: column;
|
| 1266 |
+
flex: 1;
|
| 1267 |
+
min-height: 0;
|
| 1268 |
+
overflow: hidden;
|
| 1269 |
+
}
|
| 1270 |
+
|
| 1271 |
+
.chat-messages {
|
| 1272 |
+
flex: 1;
|
| 1273 |
+
padding: 12px 16px;
|
| 1274 |
+
overflow-y: auto;
|
| 1275 |
+
overflow-x: hidden;
|
| 1276 |
+
font-size: 13px;
|
| 1277 |
+
min-height: 0;
|
| 1278 |
+
scroll-behavior: smooth;
|
| 1279 |
+
}
|
| 1280 |
+
|
| 1281 |
+
.chat-message-user {
|
| 1282 |
+
margin-bottom: 16px;
|
| 1283 |
+
animation: slideInRight 0.3s ease;
|
| 1284 |
+
}
|
| 1285 |
+
|
| 1286 |
+
@keyframes slideInRight {
|
| 1287 |
+
from {
|
| 1288 |
+
opacity: 0;
|
| 1289 |
+
transform: translateX(20px);
|
| 1290 |
+
}
|
| 1291 |
+
to {
|
| 1292 |
+
opacity: 1;
|
| 1293 |
+
transform: translateX(0);
|
| 1294 |
+
}
|
| 1295 |
+
}
|
| 1296 |
+
|
| 1297 |
+
.chat-message-ai {
|
| 1298 |
+
margin-bottom: 16px;
|
| 1299 |
+
animation: slideInLeft 0.3s ease;
|
| 1300 |
+
}
|
| 1301 |
+
|
| 1302 |
+
@keyframes slideInLeft {
|
| 1303 |
+
from {
|
| 1304 |
+
opacity: 0;
|
| 1305 |
+
transform: translateX(-20px);
|
| 1306 |
+
}
|
| 1307 |
+
to {
|
| 1308 |
+
opacity: 1;
|
| 1309 |
+
transform: translateX(0);
|
| 1310 |
+
}
|
| 1311 |
+
}
|
| 1312 |
+
|
| 1313 |
+
.chat-message-ai span {
|
| 1314 |
+
display: inline-block;
|
| 1315 |
+
padding: 10px 14px;
|
| 1316 |
+
border-radius: 12px;
|
| 1317 |
+
max-width: 80%;
|
| 1318 |
+
line-height: 1.5;
|
| 1319 |
+
}
|
| 1320 |
+
|
| 1321 |
+
.chat-message-user span {
|
| 1322 |
+
display: inline;
|
| 1323 |
+
padding: 0;
|
| 1324 |
+
border-radius: 0;
|
| 1325 |
+
background: transparent;
|
| 1326 |
+
border: none;
|
| 1327 |
+
max-width: none;
|
| 1328 |
+
line-height: inherit;
|
| 1329 |
+
}
|
| 1330 |
+
|
| 1331 |
+
.chat-message-ai span {
|
| 1332 |
+
background: #151622;
|
| 1333 |
+
border: 1px solid #272832;
|
| 1334 |
+
}
|
| 1335 |
+
|
| 1336 |
+
.chat-empty-state {
|
| 1337 |
+
display: flex;
|
| 1338 |
+
flex-direction: column;
|
| 1339 |
+
align-items: center;
|
| 1340 |
+
justify-content: center;
|
| 1341 |
+
min-height: 300px;
|
| 1342 |
+
padding: 40px 20px;
|
| 1343 |
+
text-align: center;
|
| 1344 |
+
}
|
| 1345 |
+
|
| 1346 |
+
.chat-empty-icon {
|
| 1347 |
+
font-size: 48px;
|
| 1348 |
+
margin-bottom: 16px;
|
| 1349 |
+
opacity: 0.6;
|
| 1350 |
+
animation: pulse 2s ease infinite;
|
| 1351 |
+
}
|
| 1352 |
+
|
| 1353 |
+
@keyframes pulse {
|
| 1354 |
+
0%, 100% {
|
| 1355 |
+
opacity: 0.6;
|
| 1356 |
+
}
|
| 1357 |
+
50% {
|
| 1358 |
+
opacity: 0.8;
|
| 1359 |
+
}
|
| 1360 |
+
}
|
| 1361 |
+
|
| 1362 |
+
.chat-empty-state p {
|
| 1363 |
+
margin: 0;
|
| 1364 |
+
font-size: 13px;
|
| 1365 |
+
color: #9a9bb0;
|
| 1366 |
+
max-width: 400px;
|
| 1367 |
+
}
|
| 1368 |
+
|
| 1369 |
+
.chat-input-box {
|
| 1370 |
+
padding: 12px 16px;
|
| 1371 |
+
border-top: 1px solid #272832;
|
| 1372 |
+
display: flex;
|
| 1373 |
+
flex-direction: column;
|
| 1374 |
+
gap: 10px;
|
| 1375 |
+
background: #050608;
|
| 1376 |
+
flex-shrink: 0;
|
| 1377 |
+
min-height: fit-content;
|
| 1378 |
+
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.3);
|
| 1379 |
+
}
|
| 1380 |
+
|
| 1381 |
+
.chat-input-row {
|
| 1382 |
+
display: flex;
|
| 1383 |
+
gap: 10px;
|
| 1384 |
+
align-items: center;
|
| 1385 |
+
flex-wrap: wrap;
|
| 1386 |
+
}
|
| 1387 |
+
|
| 1388 |
+
.chat-input {
|
| 1389 |
+
flex: 1;
|
| 1390 |
+
min-width: 200px;
|
| 1391 |
+
border-radius: 8px;
|
| 1392 |
+
padding: 10px 12px;
|
| 1393 |
+
border: 1px solid #272832;
|
| 1394 |
+
background: #0a0b0f;
|
| 1395 |
+
color: #f5f5f7;
|
| 1396 |
+
font-size: 13px;
|
| 1397 |
+
line-height: 1.5;
|
| 1398 |
+
transition: all 0.2s ease;
|
| 1399 |
+
}
|
| 1400 |
+
|
| 1401 |
+
.chat-input:focus {
|
| 1402 |
+
outline: none;
|
| 1403 |
+
border-color: #ff7a3c;
|
| 1404 |
+
background: #101117;
|
| 1405 |
+
box-shadow: 0 0 0 3px rgba(255, 122, 60, 0.1);
|
| 1406 |
+
}
|
| 1407 |
+
|
| 1408 |
+
.chat-input::placeholder {
|
| 1409 |
+
color: #676883;
|
| 1410 |
+
}
|
| 1411 |
+
|
| 1412 |
+
.chat-btn {
|
| 1413 |
+
border-radius: 8px;
|
| 1414 |
+
border: none;
|
| 1415 |
+
outline: none;
|
| 1416 |
+
padding: 10px 16px;
|
| 1417 |
+
background: #ff7a3c;
|
| 1418 |
+
color: #050608;
|
| 1419 |
+
cursor: pointer;
|
| 1420 |
+
font-size: 13px;
|
| 1421 |
+
font-weight: 600;
|
| 1422 |
+
transition: all 0.2s ease;
|
| 1423 |
+
white-space: nowrap;
|
| 1424 |
+
min-height: 40px;
|
| 1425 |
+
}
|
| 1426 |
+
|
| 1427 |
+
.chat-btn:hover:not(:disabled) {
|
| 1428 |
+
background: #ff8c52;
|
| 1429 |
+
transform: translateY(-1px);
|
| 1430 |
+
box-shadow: 0 4px 12px rgba(255, 122, 60, 0.3);
|
| 1431 |
+
}
|
| 1432 |
+
|
| 1433 |
+
.chat-btn:active:not(:disabled) {
|
| 1434 |
+
transform: translateY(0);
|
| 1435 |
+
}
|
| 1436 |
+
|
| 1437 |
+
.chat-btn.secondary {
|
| 1438 |
+
background: #1a1b26;
|
| 1439 |
+
color: #f5f5f7;
|
| 1440 |
+
border: 1px solid #272832;
|
| 1441 |
+
}
|
| 1442 |
+
|
| 1443 |
+
.chat-btn.secondary:hover:not(:disabled) {
|
| 1444 |
+
background: #222335;
|
| 1445 |
+
border-color: #3a3b4d;
|
| 1446 |
+
}
|
| 1447 |
+
|
| 1448 |
+
.chat-btn:disabled {
|
| 1449 |
+
opacity: 0.5;
|
| 1450 |
+
cursor: not-allowed;
|
| 1451 |
+
}
|
| 1452 |
+
|
| 1453 |
+
/* Plan rendering */
|
| 1454 |
+
.plan-card {
|
| 1455 |
+
border-radius: 12px;
|
| 1456 |
+
background: #101117;
|
| 1457 |
+
border: 1px solid #272832;
|
| 1458 |
+
padding: 10px 12px;
|
| 1459 |
+
margin-top: 6px;
|
| 1460 |
+
animation: fadeIn 0.3s ease;
|
| 1461 |
+
}
|
| 1462 |
+
|
| 1463 |
+
.plan-steps {
|
| 1464 |
+
margin: 6px 0 0;
|
| 1465 |
+
padding-left: 18px;
|
| 1466 |
+
font-size: 12px;
|
| 1467 |
+
}
|
| 1468 |
+
|
| 1469 |
+
.plan-steps li {
|
| 1470 |
+
margin-bottom: 4px;
|
| 1471 |
+
}
|
| 1472 |
+
|
| 1473 |
+
/* Modal */
|
| 1474 |
+
.modal-backdrop {
|
| 1475 |
+
position: fixed;
|
| 1476 |
+
inset: 0;
|
| 1477 |
+
background: rgba(0, 0, 0, 0.55);
|
| 1478 |
+
display: flex;
|
| 1479 |
+
align-items: center;
|
| 1480 |
+
justify-content: center;
|
| 1481 |
+
z-index: 20;
|
| 1482 |
+
animation: fadeIn 0.2s ease;
|
| 1483 |
+
}
|
| 1484 |
+
|
| 1485 |
+
.modal {
|
| 1486 |
+
background: #101117;
|
| 1487 |
+
border-radius: 16px;
|
| 1488 |
+
border: 1px solid #272832;
|
| 1489 |
+
padding: 16px 18px;
|
| 1490 |
+
width: 360px;
|
| 1491 |
+
animation: scaleIn 0.3s ease;
|
| 1492 |
+
}
|
| 1493 |
+
|
| 1494 |
+
@keyframes scaleIn {
|
| 1495 |
+
from {
|
| 1496 |
+
opacity: 0;
|
| 1497 |
+
transform: scale(0.9);
|
| 1498 |
+
}
|
| 1499 |
+
to {
|
| 1500 |
+
opacity: 1;
|
| 1501 |
+
transform: scale(1);
|
| 1502 |
+
}
|
| 1503 |
+
}
|
| 1504 |
+
|
| 1505 |
+
.modal-header {
|
| 1506 |
+
display: flex;
|
| 1507 |
+
justify-content: space-between;
|
| 1508 |
+
align-items: center;
|
| 1509 |
+
margin-bottom: 10px;
|
| 1510 |
+
}
|
| 1511 |
+
|
| 1512 |
+
.modal-title {
|
| 1513 |
+
font-size: 15px;
|
| 1514 |
+
font-weight: 600;
|
| 1515 |
+
}
|
| 1516 |
+
|
| 1517 |
+
.modal-close {
|
| 1518 |
+
border: none;
|
| 1519 |
+
outline: none;
|
| 1520 |
+
background: transparent;
|
| 1521 |
+
color: #9a9bb0;
|
| 1522 |
+
cursor: pointer;
|
| 1523 |
+
transition: color 0.2s ease;
|
| 1524 |
+
}
|
| 1525 |
+
|
| 1526 |
+
.modal-close:hover {
|
| 1527 |
+
color: #ff7a3c;
|
| 1528 |
+
}
|
| 1529 |
+
|
| 1530 |
+
.provider-list {
|
| 1531 |
+
display: flex;
|
| 1532 |
+
flex-direction: column;
|
| 1533 |
+
gap: 6px;
|
| 1534 |
+
margin-top: 8px;
|
| 1535 |
+
}
|
| 1536 |
+
|
| 1537 |
+
.provider-item {
|
| 1538 |
+
display: flex;
|
| 1539 |
+
align-items: center;
|
| 1540 |
+
justify-content: space-between;
|
| 1541 |
+
padding: 6px 8px;
|
| 1542 |
+
border-radius: 8px;
|
| 1543 |
+
background: #151622;
|
| 1544 |
+
border: 1px solid #272832;
|
| 1545 |
+
font-size: 13px;
|
| 1546 |
+
transition: all 0.2s ease;
|
| 1547 |
+
}
|
| 1548 |
+
|
| 1549 |
+
.provider-item:hover {
|
| 1550 |
+
border-color: #3a3b4d;
|
| 1551 |
+
}
|
| 1552 |
+
|
| 1553 |
+
.provider-item.active {
|
| 1554 |
+
border-color: #ff7a3c;
|
| 1555 |
+
background: rgba(255, 122, 60, 0.1);
|
| 1556 |
+
}
|
| 1557 |
+
|
| 1558 |
+
.provider-name {
|
| 1559 |
+
font-weight: 500;
|
| 1560 |
+
}
|
| 1561 |
+
|
| 1562 |
+
.provider-badge {
|
| 1563 |
+
font-size: 11px;
|
| 1564 |
+
color: #9a9bb0;
|
| 1565 |
+
}
|
| 1566 |
+
|
| 1567 |
+
/* Navigation */
|
| 1568 |
+
.main-nav {
|
| 1569 |
+
display: flex;
|
| 1570 |
+
flex-direction: column;
|
| 1571 |
+
gap: 2px;
|
| 1572 |
+
margin-top: 10px;
|
| 1573 |
+
margin-bottom: 10px;
|
| 1574 |
+
}
|
| 1575 |
+
|
| 1576 |
+
.nav-btn {
|
| 1577 |
+
border: none;
|
| 1578 |
+
outline: none;
|
| 1579 |
+
background: transparent;
|
| 1580 |
+
color: #9a9bb0;
|
| 1581 |
+
border-radius: 8px;
|
| 1582 |
+
font-size: 13px;
|
| 1583 |
+
font-weight: 500;
|
| 1584 |
+
padding: 8px 12px;
|
| 1585 |
+
text-align: left;
|
| 1586 |
+
cursor: pointer;
|
| 1587 |
+
transition: all 0.15s ease;
|
| 1588 |
+
}
|
| 1589 |
+
|
| 1590 |
+
.nav-btn:hover {
|
| 1591 |
+
background: #1a1b26;
|
| 1592 |
+
color: #c3c5dd;
|
| 1593 |
+
}
|
| 1594 |
+
|
| 1595 |
+
.nav-btn-active {
|
| 1596 |
+
background: #1a1b26;
|
| 1597 |
+
color: #f5f5f7;
|
| 1598 |
+
font-weight: 600;
|
| 1599 |
+
border-left: 2px solid #ff7a3c;
|
| 1600 |
+
padding-left: 10px;
|
| 1601 |
+
}
|
| 1602 |
+
|
| 1603 |
+
/* Settings page */
|
| 1604 |
+
.settings-root {
|
| 1605 |
+
padding: 20px 24px;
|
| 1606 |
+
overflow-y: auto;
|
| 1607 |
+
max-width: 800px;
|
| 1608 |
+
}
|
| 1609 |
+
|
| 1610 |
+
.settings-root h1 {
|
| 1611 |
+
margin-top: 0;
|
| 1612 |
+
font-size: 24px;
|
| 1613 |
+
margin-bottom: 8px;
|
| 1614 |
+
}
|
| 1615 |
+
|
| 1616 |
+
.settings-muted {
|
| 1617 |
+
font-size: 13px;
|
| 1618 |
+
color: #9a9bb0;
|
| 1619 |
+
margin-bottom: 20px;
|
| 1620 |
+
line-height: 1.5;
|
| 1621 |
+
}
|
| 1622 |
+
|
| 1623 |
+
.settings-card {
|
| 1624 |
+
background: #101117;
|
| 1625 |
+
border-radius: 12px;
|
| 1626 |
+
border: 1px solid #272832;
|
| 1627 |
+
padding: 14px 16px;
|
| 1628 |
+
margin-bottom: 14px;
|
| 1629 |
+
display: flex;
|
| 1630 |
+
flex-direction: column;
|
| 1631 |
+
gap: 8px;
|
| 1632 |
+
transition: all 0.2s ease;
|
| 1633 |
+
}
|
| 1634 |
+
|
| 1635 |
+
.settings-card:hover {
|
| 1636 |
+
border-color: #3a3b4d;
|
| 1637 |
+
}
|
| 1638 |
+
|
| 1639 |
+
.settings-title {
|
| 1640 |
+
font-size: 15px;
|
| 1641 |
+
font-weight: 600;
|
| 1642 |
+
margin-bottom: 4px;
|
| 1643 |
+
}
|
| 1644 |
+
|
| 1645 |
+
.settings-label {
|
| 1646 |
+
font-size: 12px;
|
| 1647 |
+
color: #9a9bb0;
|
| 1648 |
+
font-weight: 500;
|
| 1649 |
+
margin-top: 4px;
|
| 1650 |
+
}
|
| 1651 |
+
|
| 1652 |
+
.settings-input,
|
| 1653 |
+
.settings-select {
|
| 1654 |
+
background: #050608;
|
| 1655 |
+
border-radius: 8px;
|
| 1656 |
+
border: 1px solid #272832;
|
| 1657 |
+
padding: 8px 10px;
|
| 1658 |
+
color: #f5f5f7;
|
| 1659 |
+
font-size: 13px;
|
| 1660 |
+
font-family: inherit;
|
| 1661 |
+
transition: all 0.2s ease;
|
| 1662 |
+
}
|
| 1663 |
+
|
| 1664 |
+
.settings-input:focus,
|
| 1665 |
+
.settings-select:focus {
|
| 1666 |
+
outline: none;
|
| 1667 |
+
border-color: #ff7a3c;
|
| 1668 |
+
box-shadow: 0 0 0 3px rgba(255, 122, 60, 0.1);
|
| 1669 |
+
}
|
| 1670 |
+
|
| 1671 |
+
.settings-input::placeholder {
|
| 1672 |
+
color: #676883;
|
| 1673 |
+
}
|
| 1674 |
+
|
| 1675 |
+
.settings-hint {
|
| 1676 |
+
font-size: 11px;
|
| 1677 |
+
color: #7a7b8e;
|
| 1678 |
+
margin-top: -2px;
|
| 1679 |
+
}
|
| 1680 |
+
|
| 1681 |
+
.settings-actions {
|
| 1682 |
+
margin-top: 12px;
|
| 1683 |
+
display: flex;
|
| 1684 |
+
align-items: center;
|
| 1685 |
+
gap: 12px;
|
| 1686 |
+
}
|
| 1687 |
+
|
| 1688 |
+
.settings-save-btn {
|
| 1689 |
+
background: #ff7a3c;
|
| 1690 |
+
border-radius: 999px;
|
| 1691 |
+
border: none;
|
| 1692 |
+
outline: none;
|
| 1693 |
+
padding: 9px 18px;
|
| 1694 |
+
font-size: 13px;
|
| 1695 |
+
cursor: pointer;
|
| 1696 |
+
color: #050608;
|
| 1697 |
+
font-weight: 600;
|
| 1698 |
+
transition: all 0.2s ease;
|
| 1699 |
+
}
|
| 1700 |
+
|
| 1701 |
+
.settings-save-btn:hover {
|
| 1702 |
+
background: #ff8b52;
|
| 1703 |
+
transform: translateY(-1px);
|
| 1704 |
+
box-shadow: 0 4px 12px rgba(255, 122, 60, 0.3);
|
| 1705 |
+
}
|
| 1706 |
+
|
| 1707 |
+
.settings-save-btn:disabled {
|
| 1708 |
+
opacity: 0.5;
|
| 1709 |
+
cursor: not-allowed;
|
| 1710 |
+
transform: none;
|
| 1711 |
+
}
|
| 1712 |
+
|
| 1713 |
+
.settings-success {
|
| 1714 |
+
font-size: 12px;
|
| 1715 |
+
color: #7cffb3;
|
| 1716 |
+
font-weight: 500;
|
| 1717 |
+
}
|
| 1718 |
+
|
| 1719 |
+
.settings-error {
|
| 1720 |
+
font-size: 12px;
|
| 1721 |
+
color: #ff8a8a;
|
| 1722 |
+
font-weight: 500;
|
| 1723 |
+
}
|
| 1724 |
+
|
| 1725 |
+
/* Flow viewer */
|
| 1726 |
+
.flow-root {
|
| 1727 |
+
display: flex;
|
| 1728 |
+
flex-direction: column;
|
| 1729 |
+
height: 100%;
|
| 1730 |
+
overflow: hidden;
|
| 1731 |
+
}
|
| 1732 |
+
|
| 1733 |
+
.flow-header {
|
| 1734 |
+
padding: 16px 20px;
|
| 1735 |
+
border-bottom: 1px solid #272832;
|
| 1736 |
+
display: flex;
|
| 1737 |
+
align-items: center;
|
| 1738 |
+
justify-content: space-between;
|
| 1739 |
+
}
|
| 1740 |
+
|
| 1741 |
+
.flow-header h1 {
|
| 1742 |
+
margin: 0;
|
| 1743 |
+
font-size: 22px;
|
| 1744 |
+
margin-bottom: 4px;
|
| 1745 |
+
}
|
| 1746 |
+
|
| 1747 |
+
.flow-header p {
|
| 1748 |
+
margin: 0;
|
| 1749 |
+
font-size: 12px;
|
| 1750 |
+
color: #9a9bb0;
|
| 1751 |
+
max-width: 600px;
|
| 1752 |
+
line-height: 1.5;
|
| 1753 |
+
}
|
| 1754 |
+
|
| 1755 |
+
.flow-canvas {
|
| 1756 |
+
flex: 1;
|
| 1757 |
+
background: #050608;
|
| 1758 |
+
position: relative;
|
| 1759 |
+
}
|
| 1760 |
+
|
| 1761 |
+
.flow-error {
|
| 1762 |
+
position: absolute;
|
| 1763 |
+
inset: 0;
|
| 1764 |
+
display: flex;
|
| 1765 |
+
flex-direction: column;
|
| 1766 |
+
align-items: center;
|
| 1767 |
+
justify-content: center;
|
| 1768 |
+
gap: 12px;
|
| 1769 |
+
}
|
| 1770 |
+
|
| 1771 |
+
.error-icon {
|
| 1772 |
+
font-size: 48px;
|
| 1773 |
+
}
|
| 1774 |
+
|
| 1775 |
+
.error-text {
|
| 1776 |
+
font-size: 14px;
|
| 1777 |
+
color: #ff8a8a;
|
| 1778 |
+
}
|
| 1779 |
+
|
| 1780 |
+
/* Assistant Message Sections */
|
| 1781 |
+
.gp-section {
|
| 1782 |
+
margin-bottom: 16px;
|
| 1783 |
+
border-radius: 12px;
|
| 1784 |
+
background: #101117;
|
| 1785 |
+
border: 1px solid #272832;
|
| 1786 |
+
overflow: hidden;
|
| 1787 |
+
animation: fadeIn 0.3s ease;
|
| 1788 |
+
}
|
| 1789 |
+
|
| 1790 |
+
.gp-section-header {
|
| 1791 |
+
padding: 8px 12px;
|
| 1792 |
+
background: #151622;
|
| 1793 |
+
border-bottom: 1px solid #272832;
|
| 1794 |
+
}
|
| 1795 |
+
|
| 1796 |
+
.gp-section-header h3 {
|
| 1797 |
+
margin: 0;
|
| 1798 |
+
font-size: 13px;
|
| 1799 |
+
font-weight: 600;
|
| 1800 |
+
color: #c3c5dd;
|
| 1801 |
+
}
|
| 1802 |
+
|
| 1803 |
+
.gp-section-content {
|
| 1804 |
+
padding: 12px;
|
| 1805 |
+
}
|
| 1806 |
+
|
| 1807 |
+
.gp-section-answer .gp-section-content p {
|
| 1808 |
+
margin: 0;
|
| 1809 |
+
font-size: 13px;
|
| 1810 |
+
line-height: 1.6;
|
| 1811 |
+
color: #f5f5f7;
|
| 1812 |
+
}
|
| 1813 |
+
|
| 1814 |
+
.gp-section-plan {
|
| 1815 |
+
background: #0a0b0f;
|
| 1816 |
+
}
|
| 1817 |
+
|
| 1818 |
+
/* Plan View Enhanced */
|
| 1819 |
+
.plan-header {
|
| 1820 |
+
margin-bottom: 12px;
|
| 1821 |
+
}
|
| 1822 |
+
|
| 1823 |
+
.plan-goal {
|
| 1824 |
+
font-size: 13px;
|
| 1825 |
+
font-weight: 600;
|
| 1826 |
+
margin-bottom: 4px;
|
| 1827 |
+
color: #f5f5f7;
|
| 1828 |
+
}
|
| 1829 |
+
|
| 1830 |
+
.plan-summary {
|
| 1831 |
+
font-size: 12px;
|
| 1832 |
+
color: #c3c5dd;
|
| 1833 |
+
line-height: 1.5;
|
| 1834 |
+
}
|
| 1835 |
+
|
| 1836 |
+
.plan-totals {
|
| 1837 |
+
display: flex;
|
| 1838 |
+
gap: 8px;
|
| 1839 |
+
margin-bottom: 12px;
|
| 1840 |
+
flex-wrap: wrap;
|
| 1841 |
+
}
|
| 1842 |
+
|
| 1843 |
+
.plan-total {
|
| 1844 |
+
padding: 4px 8px;
|
| 1845 |
+
border-radius: 6px;
|
| 1846 |
+
font-size: 11px;
|
| 1847 |
+
font-weight: 500;
|
| 1848 |
+
animation: fadeIn 0.3s ease;
|
| 1849 |
+
}
|
| 1850 |
+
|
| 1851 |
+
.plan-total-create {
|
| 1852 |
+
background: rgba(76, 175, 80, 0.15);
|
| 1853 |
+
color: #81c784;
|
| 1854 |
+
border: 1px solid rgba(76, 175, 80, 0.3);
|
| 1855 |
+
}
|
| 1856 |
+
|
| 1857 |
+
.plan-total-modify {
|
| 1858 |
+
background: rgba(33, 150, 243, 0.15);
|
| 1859 |
+
color: #64b5f6;
|
| 1860 |
+
border: 1px solid rgba(33, 150, 243, 0.3);
|
| 1861 |
+
}
|
| 1862 |
+
|
| 1863 |
+
.plan-total-delete {
|
| 1864 |
+
background: rgba(244, 67, 54, 0.15);
|
| 1865 |
+
color: #e57373;
|
| 1866 |
+
border: 1px solid rgba(244, 67, 54, 0.3);
|
| 1867 |
+
}
|
| 1868 |
+
|
| 1869 |
+
.plan-step {
|
| 1870 |
+
margin-bottom: 12px;
|
| 1871 |
+
padding-bottom: 12px;
|
| 1872 |
+
border-bottom: 1px solid #1a1b26;
|
| 1873 |
+
}
|
| 1874 |
+
|
| 1875 |
+
.plan-step:last-child {
|
| 1876 |
+
border-bottom: none;
|
| 1877 |
+
padding-bottom: 0;
|
| 1878 |
+
margin-bottom: 0;
|
| 1879 |
+
}
|
| 1880 |
+
|
| 1881 |
+
.plan-step-header {
|
| 1882 |
+
margin-bottom: 6px;
|
| 1883 |
+
}
|
| 1884 |
+
|
| 1885 |
+
.plan-step-description {
|
| 1886 |
+
font-size: 12px;
|
| 1887 |
+
color: #9a9bb0;
|
| 1888 |
+
margin-bottom: 8px;
|
| 1889 |
+
}
|
| 1890 |
+
|
| 1891 |
+
.plan-files {
|
| 1892 |
+
list-style: none;
|
| 1893 |
+
padding: 0;
|
| 1894 |
+
margin: 8px 0;
|
| 1895 |
+
}
|
| 1896 |
+
|
| 1897 |
+
.plan-file {
|
| 1898 |
+
display: flex;
|
| 1899 |
+
align-items: center;
|
| 1900 |
+
gap: 8px;
|
| 1901 |
+
padding: 4px 0;
|
| 1902 |
+
}
|
| 1903 |
+
|
| 1904 |
+
.gp-pill {
|
| 1905 |
+
padding: 2px 6px;
|
| 1906 |
+
border-radius: 4px;
|
| 1907 |
+
font-size: 10px;
|
| 1908 |
+
font-weight: 600;
|
| 1909 |
+
text-transform: uppercase;
|
| 1910 |
+
letter-spacing: 0.3px;
|
| 1911 |
+
}
|
| 1912 |
+
|
| 1913 |
+
.gp-pill-create {
|
| 1914 |
+
background: rgba(76, 175, 80, 0.2);
|
| 1915 |
+
color: #81c784;
|
| 1916 |
+
border: 1px solid rgba(76, 175, 80, 0.4);
|
| 1917 |
+
}
|
| 1918 |
+
|
| 1919 |
+
.gp-pill-modify {
|
| 1920 |
+
background: rgba(33, 150, 243, 0.2);
|
| 1921 |
+
color: #64b5f6;
|
| 1922 |
+
border: 1px solid rgba(33, 150, 243, 0.4);
|
| 1923 |
+
}
|
| 1924 |
+
|
| 1925 |
+
.gp-pill-delete {
|
| 1926 |
+
background: rgba(244, 67, 54, 0.2);
|
| 1927 |
+
color: #e57373;
|
| 1928 |
+
border: 1px solid rgba(244, 67, 54, 0.4);
|
| 1929 |
+
}
|
| 1930 |
+
|
| 1931 |
+
.plan-file-path {
|
| 1932 |
+
font-size: 11px;
|
| 1933 |
+
color: #c3c5dd;
|
| 1934 |
+
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
|
| 1935 |
+
background: #0a0b0f;
|
| 1936 |
+
padding: 2px 6px;
|
| 1937 |
+
border-radius: 4px;
|
| 1938 |
+
}
|
| 1939 |
+
|
| 1940 |
+
.plan-step-risks {
|
| 1941 |
+
margin-top: 8px;
|
| 1942 |
+
padding: 6px 8px;
|
| 1943 |
+
background: rgba(255, 152, 0, 0.1);
|
| 1944 |
+
border-left: 2px solid #ff9800;
|
| 1945 |
+
border-radius: 4px;
|
| 1946 |
+
font-size: 11px;
|
| 1947 |
+
color: #ffb74d;
|
| 1948 |
+
}
|
| 1949 |
+
|
| 1950 |
+
.plan-risk-label {
|
| 1951 |
+
font-weight: 600;
|
| 1952 |
+
}
|
| 1953 |
+
|
| 1954 |
+
/* Execution Log */
|
| 1955 |
+
.execution-steps {
|
| 1956 |
+
list-style: none;
|
| 1957 |
+
padding: 0;
|
| 1958 |
+
margin: 0;
|
| 1959 |
+
}
|
| 1960 |
+
|
| 1961 |
+
.execution-step {
|
| 1962 |
+
padding: 8px;
|
| 1963 |
+
margin-bottom: 6px;
|
| 1964 |
+
background: #0a0b0f;
|
| 1965 |
+
border-radius: 6px;
|
| 1966 |
+
font-size: 11px;
|
| 1967 |
+
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
|
| 1968 |
+
white-space: pre-wrap;
|
| 1969 |
+
}
|
| 1970 |
+
|
| 1971 |
+
.execution-step-number {
|
| 1972 |
+
color: #ff7a3c;
|
| 1973 |
+
font-weight: 600;
|
| 1974 |
+
margin-right: 8px;
|
| 1975 |
+
}
|
| 1976 |
+
|
| 1977 |
+
.execution-step-summary {
|
| 1978 |
+
color: #c3c5dd;
|
| 1979 |
+
}
|
| 1980 |
+
|
| 1981 |
+
/* Project Context Panel - Properly constrained */
|
| 1982 |
+
.gp-context {
|
| 1983 |
+
padding: 12px;
|
| 1984 |
+
height: 100%;
|
| 1985 |
+
overflow: hidden;
|
| 1986 |
+
display: flex;
|
| 1987 |
+
flex-direction: column;
|
| 1988 |
+
}
|
| 1989 |
+
|
| 1990 |
+
.gp-context-column {
|
| 1991 |
+
background: #0a0b0f;
|
| 1992 |
+
border-right: 1px solid #272832;
|
| 1993 |
+
overflow: hidden;
|
| 1994 |
+
display: flex;
|
| 1995 |
+
flex-direction: column;
|
| 1996 |
+
}
|
| 1997 |
+
|
| 1998 |
+
.gp-chat-column {
|
| 1999 |
+
display: flex;
|
| 2000 |
+
flex-direction: column;
|
| 2001 |
+
background: #050608;
|
| 2002 |
+
height: 100%;
|
| 2003 |
+
min-width: 0;
|
| 2004 |
+
overflow: hidden;
|
| 2005 |
+
}
|
| 2006 |
+
|
| 2007 |
+
.gp-card {
|
| 2008 |
+
background: #101117;
|
| 2009 |
+
border-radius: 12px;
|
| 2010 |
+
border: 1px solid #272832;
|
| 2011 |
+
overflow: hidden;
|
| 2012 |
+
display: flex;
|
| 2013 |
+
flex-direction: column;
|
| 2014 |
+
height: 100%;
|
| 2015 |
+
min-height: 0;
|
| 2016 |
+
}
|
| 2017 |
+
|
| 2018 |
+
.gp-card-header {
|
| 2019 |
+
padding: 10px 12px;
|
| 2020 |
+
background: #151622;
|
| 2021 |
+
border-bottom: 1px solid #272832;
|
| 2022 |
+
display: flex;
|
| 2023 |
+
align-items: center;
|
| 2024 |
+
justify-content: space-between;
|
| 2025 |
+
flex-shrink: 0;
|
| 2026 |
+
}
|
| 2027 |
+
|
| 2028 |
+
.gp-card-header h2 {
|
| 2029 |
+
margin: 0;
|
| 2030 |
+
font-size: 14px;
|
| 2031 |
+
font-weight: 600;
|
| 2032 |
+
color: #f5f5f7;
|
| 2033 |
+
}
|
| 2034 |
+
|
| 2035 |
+
.gp-badge {
|
| 2036 |
+
padding: 3px 8px;
|
| 2037 |
+
border-radius: 999px;
|
| 2038 |
+
background: #2a2b3c;
|
| 2039 |
+
border: 1px solid #3a3b4d;
|
| 2040 |
+
font-size: 11px;
|
| 2041 |
+
color: #c3c5dd;
|
| 2042 |
+
font-weight: 500;
|
| 2043 |
+
transition: all 0.2s ease;
|
| 2044 |
+
}
|
| 2045 |
+
|
| 2046 |
+
.gp-badge:hover {
|
| 2047 |
+
border-color: #ff7a3c;
|
| 2048 |
+
}
|
| 2049 |
+
|
| 2050 |
+
.gp-context-meta {
|
| 2051 |
+
padding: 12px;
|
| 2052 |
+
display: flex;
|
| 2053 |
+
flex-direction: column;
|
| 2054 |
+
gap: 6px;
|
| 2055 |
+
border-bottom: 1px solid #272832;
|
| 2056 |
+
flex-shrink: 0;
|
| 2057 |
+
background: #0a0b0f;
|
| 2058 |
+
}
|
| 2059 |
+
|
| 2060 |
+
.gp-context-meta-item {
|
| 2061 |
+
display: flex;
|
| 2062 |
+
align-items: center;
|
| 2063 |
+
gap: 6px;
|
| 2064 |
+
font-size: 12px;
|
| 2065 |
+
}
|
| 2066 |
+
|
| 2067 |
+
.gp-context-meta-label {
|
| 2068 |
+
color: #9a9bb0;
|
| 2069 |
+
min-width: 60px;
|
| 2070 |
+
}
|
| 2071 |
+
|
| 2072 |
+
.gp-context-meta-item strong {
|
| 2073 |
+
color: #f5f5f7;
|
| 2074 |
+
font-weight: 500;
|
| 2075 |
+
}
|
| 2076 |
+
|
| 2077 |
+
/* File tree - Properly scrollable */
|
| 2078 |
+
.gp-context-tree {
|
| 2079 |
+
flex: 1;
|
| 2080 |
+
overflow-y: auto;
|
| 2081 |
+
overflow-x: hidden;
|
| 2082 |
+
min-height: 0;
|
| 2083 |
+
padding: 4px;
|
| 2084 |
+
}
|
| 2085 |
+
|
| 2086 |
+
.gp-context-empty {
|
| 2087 |
+
padding: 20px 12px;
|
| 2088 |
+
text-align: center;
|
| 2089 |
+
color: #9a9bb0;
|
| 2090 |
+
font-size: 12px;
|
| 2091 |
+
}
|
| 2092 |
+
|
| 2093 |
+
/* Footer - Fixed at bottom */
|
| 2094 |
+
.gp-footer {
|
| 2095 |
+
position: fixed;
|
| 2096 |
+
bottom: 0;
|
| 2097 |
+
left: 0;
|
| 2098 |
+
right: 0;
|
| 2099 |
+
border-top: 1px solid #272832;
|
| 2100 |
+
padding: 8px 20px;
|
| 2101 |
+
display: flex;
|
| 2102 |
+
justify-content: space-between;
|
| 2103 |
+
align-items: center;
|
| 2104 |
+
font-size: 11px;
|
| 2105 |
+
color: #9a9bb0;
|
| 2106 |
+
background: #0a0b0f;
|
| 2107 |
+
backdrop-filter: blur(10px);
|
| 2108 |
+
z-index: 10;
|
| 2109 |
+
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
|
| 2110 |
+
}
|
| 2111 |
+
|
| 2112 |
+
.gp-footer-left {
|
| 2113 |
+
display: flex;
|
| 2114 |
+
align-items: center;
|
| 2115 |
+
gap: 6px;
|
| 2116 |
+
font-weight: 500;
|
| 2117 |
+
color: #c3c5dd;
|
| 2118 |
+
}
|
| 2119 |
+
|
| 2120 |
+
.gp-footer-right {
|
| 2121 |
+
display: flex;
|
| 2122 |
+
align-items: center;
|
| 2123 |
+
gap: 12px;
|
| 2124 |
+
}
|
| 2125 |
+
|
| 2126 |
+
.gp-footer-right a {
|
| 2127 |
+
color: #9a9bb0;
|
| 2128 |
+
text-decoration: none;
|
| 2129 |
+
transition: all 0.2s ease;
|
| 2130 |
+
}
|
| 2131 |
+
|
| 2132 |
+
.gp-footer-right a:hover {
|
| 2133 |
+
color: #ff7a3c;
|
| 2134 |
+
transform: translateY(-1px);
|
| 2135 |
+
}
|
| 2136 |
+
|
| 2137 |
+
/* Adjust app-root to account for fixed footer */
|
| 2138 |
+
.app-root > .main-wrapper {
|
| 2139 |
+
padding-bottom: 32px; /* Space for fixed footer */
|
| 2140 |
+
}
|
| 2141 |
+
|
| 2142 |
+
/* ============================================================================
|
| 2143 |
+
LOGIN PAGE - Enterprise GitHub Authentication
|
| 2144 |
+
============================================================================ */
|
| 2145 |
+
|
| 2146 |
+
.login-page {
|
| 2147 |
+
min-height: 100vh;
|
| 2148 |
+
display: flex;
|
| 2149 |
+
align-items: center;
|
| 2150 |
+
justify-content: center;
|
| 2151 |
+
background: radial-gradient(circle at center, #171823 0%, #050608 70%);
|
| 2152 |
+
padding: 20px;
|
| 2153 |
+
animation: fadeIn 0.4s ease;
|
| 2154 |
+
}
|
| 2155 |
+
|
| 2156 |
+
.login-container {
|
| 2157 |
+
width: 100%;
|
| 2158 |
+
max-width: 480px;
|
| 2159 |
+
background: #101117;
|
| 2160 |
+
border: 1px solid #272832;
|
| 2161 |
+
border-radius: 24px;
|
| 2162 |
+
padding: 40px 36px;
|
| 2163 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
| 2164 |
+
animation: slideUp 0.5s ease;
|
| 2165 |
+
}
|
| 2166 |
+
|
| 2167 |
+
@keyframes slideUp {
|
| 2168 |
+
from {
|
| 2169 |
+
opacity: 0;
|
| 2170 |
+
transform: translateY(20px);
|
| 2171 |
+
}
|
| 2172 |
+
to {
|
| 2173 |
+
opacity: 1;
|
| 2174 |
+
transform: translateY(0);
|
| 2175 |
+
}
|
| 2176 |
+
}
|
| 2177 |
+
|
| 2178 |
+
/* Header */
|
| 2179 |
+
.login-header {
|
| 2180 |
+
text-align: center;
|
| 2181 |
+
margin-bottom: 32px;
|
| 2182 |
+
}
|
| 2183 |
+
|
| 2184 |
+
.login-logo {
|
| 2185 |
+
display: flex;
|
| 2186 |
+
justify-content: center;
|
| 2187 |
+
margin-bottom: 16px;
|
| 2188 |
+
}
|
| 2189 |
+
|
| 2190 |
+
.logo-icon {
|
| 2191 |
+
width: 64px;
|
| 2192 |
+
height: 64px;
|
| 2193 |
+
border-radius: 16px;
|
| 2194 |
+
background: linear-gradient(135deg, #ff7a3c 0%, #ff6b2b 100%);
|
| 2195 |
+
display: flex;
|
| 2196 |
+
align-items: center;
|
| 2197 |
+
justify-content: center;
|
| 2198 |
+
font-weight: 700;
|
| 2199 |
+
font-size: 28px;
|
| 2200 |
+
color: #050608;
|
| 2201 |
+
box-shadow: 0 8px 24px rgba(255, 122, 60, 0.3);
|
| 2202 |
+
transition: transform 0.3s ease;
|
| 2203 |
+
}
|
| 2204 |
+
|
| 2205 |
+
.logo-icon:hover {
|
| 2206 |
+
transform: scale(1.05) rotate(3deg);
|
| 2207 |
+
}
|
| 2208 |
+
|
| 2209 |
+
.login-title {
|
| 2210 |
+
margin: 0;
|
| 2211 |
+
font-size: 28px;
|
| 2212 |
+
font-weight: 700;
|
| 2213 |
+
color: #f5f5f7;
|
| 2214 |
+
margin-bottom: 8px;
|
| 2215 |
+
letter-spacing: -0.5px;
|
| 2216 |
+
}
|
| 2217 |
+
|
| 2218 |
+
.login-subtitle {
|
| 2219 |
+
margin: 0;
|
| 2220 |
+
font-size: 14px;
|
| 2221 |
+
color: #9a9bb0;
|
| 2222 |
+
font-weight: 500;
|
| 2223 |
+
}
|
| 2224 |
+
|
| 2225 |
+
/* Welcome Section */
|
| 2226 |
+
.login-welcome {
|
| 2227 |
+
margin-bottom: 28px;
|
| 2228 |
+
padding-bottom: 28px;
|
| 2229 |
+
border-bottom: 1px solid #272832;
|
| 2230 |
+
}
|
| 2231 |
+
|
| 2232 |
+
.login-welcome h2 {
|
| 2233 |
+
margin: 0 0 12px 0;
|
| 2234 |
+
font-size: 20px;
|
| 2235 |
+
font-weight: 600;
|
| 2236 |
+
color: #f5f5f7;
|
| 2237 |
+
}
|
| 2238 |
+
|
| 2239 |
+
.login-welcome p {
|
| 2240 |
+
margin: 0;
|
| 2241 |
+
font-size: 14px;
|
| 2242 |
+
line-height: 1.6;
|
| 2243 |
+
color: #c3c5dd;
|
| 2244 |
+
}
|
| 2245 |
+
|
| 2246 |
+
/* Error Message */
|
| 2247 |
+
.login-error {
|
| 2248 |
+
display: flex;
|
| 2249 |
+
align-items: center;
|
| 2250 |
+
gap: 10px;
|
| 2251 |
+
padding: 12px 14px;
|
| 2252 |
+
background: rgba(255, 82, 82, 0.1);
|
| 2253 |
+
border: 1px solid rgba(255, 82, 82, 0.3);
|
| 2254 |
+
border-radius: 10px;
|
| 2255 |
+
color: #ff8a8a;
|
| 2256 |
+
font-size: 13px;
|
| 2257 |
+
margin-bottom: 20px;
|
| 2258 |
+
animation: shake 0.4s ease;
|
| 2259 |
+
}
|
| 2260 |
+
|
| 2261 |
+
@keyframes shake {
|
| 2262 |
+
0%, 100% { transform: translateX(0); }
|
| 2263 |
+
25% { transform: translateX(-5px); }
|
| 2264 |
+
75% { transform: translateX(5px); }
|
| 2265 |
+
}
|
| 2266 |
+
|
| 2267 |
+
.login-error svg {
|
| 2268 |
+
flex-shrink: 0;
|
| 2269 |
+
}
|
| 2270 |
+
|
| 2271 |
+
/* Login Actions */
|
| 2272 |
+
.login-actions {
|
| 2273 |
+
display: flex;
|
| 2274 |
+
flex-direction: column;
|
| 2275 |
+
gap: 14px;
|
| 2276 |
+
margin-bottom: 28px;
|
| 2277 |
+
}
|
| 2278 |
+
|
| 2279 |
+
/* Buttons */
|
| 2280 |
+
.btn-primary,
|
| 2281 |
+
.btn-secondary,
|
| 2282 |
+
.btn-text {
|
| 2283 |
+
border: none;
|
| 2284 |
+
outline: none;
|
| 2285 |
+
cursor: pointer;
|
| 2286 |
+
font-family: inherit;
|
| 2287 |
+
font-weight: 600;
|
| 2288 |
+
transition: all 0.2s ease;
|
| 2289 |
+
display: flex;
|
| 2290 |
+
align-items: center;
|
| 2291 |
+
justify-content: center;
|
| 2292 |
+
gap: 10px;
|
| 2293 |
+
}
|
| 2294 |
+
|
| 2295 |
+
.btn-large {
|
| 2296 |
+
padding: 14px 24px;
|
| 2297 |
+
font-size: 15px;
|
| 2298 |
+
border-radius: 12px;
|
| 2299 |
+
}
|
| 2300 |
+
|
| 2301 |
+
.btn-primary {
|
| 2302 |
+
background: linear-gradient(135deg, #ff7a3c 0%, #ff6b2b 100%);
|
| 2303 |
+
color: #fff;
|
| 2304 |
+
box-shadow: 0 4px 12px rgba(255, 122, 60, 0.25);
|
| 2305 |
+
}
|
| 2306 |
+
|
| 2307 |
+
.btn-primary:hover:not(:disabled) {
|
| 2308 |
+
transform: translateY(-2px);
|
| 2309 |
+
box-shadow: 0 8px 20px rgba(255, 122, 60, 0.35);
|
| 2310 |
+
}
|
| 2311 |
+
|
| 2312 |
+
.btn-primary:active:not(:disabled) {
|
| 2313 |
+
transform: translateY(0);
|
| 2314 |
+
}
|
| 2315 |
+
|
| 2316 |
+
.btn-primary:disabled {
|
| 2317 |
+
opacity: 0.6;
|
| 2318 |
+
cursor: not-allowed;
|
| 2319 |
+
}
|
| 2320 |
+
|
| 2321 |
+
.btn-secondary {
|
| 2322 |
+
background: #1a1b26;
|
| 2323 |
+
color: #f5f5f7;
|
| 2324 |
+
border: 1px solid #3a3b4d;
|
| 2325 |
+
}
|
| 2326 |
+
|
| 2327 |
+
.btn-secondary:hover {
|
| 2328 |
+
background: #2a2b3c;
|
| 2329 |
+
border-color: #4a4b5d;
|
| 2330 |
+
transform: translateY(-1px);
|
| 2331 |
+
}
|
| 2332 |
+
|
| 2333 |
+
.btn-text {
|
| 2334 |
+
background: transparent;
|
| 2335 |
+
color: #9a9bb0;
|
| 2336 |
+
padding: 10px;
|
| 2337 |
+
font-size: 14px;
|
| 2338 |
+
font-weight: 500;
|
| 2339 |
+
}
|
| 2340 |
+
|
| 2341 |
+
.btn-text:hover {
|
| 2342 |
+
color: #ff7a3c;
|
| 2343 |
+
}
|
| 2344 |
+
|
| 2345 |
+
/* Button Spinner */
|
| 2346 |
+
.btn-spinner {
|
| 2347 |
+
width: 16px;
|
| 2348 |
+
height: 16px;
|
| 2349 |
+
border: 2px solid rgba(255, 255, 255, 0.3);
|
| 2350 |
+
border-top-color: #fff;
|
| 2351 |
+
border-radius: 50%;
|
| 2352 |
+
animation: spin 0.6s linear infinite;
|
| 2353 |
+
}
|
| 2354 |
+
|
| 2355 |
+
@keyframes spin {
|
| 2356 |
+
to { transform: rotate(360deg); }
|
| 2357 |
+
}
|
| 2358 |
+
|
| 2359 |
+
/* Loading Spinner (Page) */
|
| 2360 |
+
.loading-spinner {
|
| 2361 |
+
width: 48px;
|
| 2362 |
+
height: 48px;
|
| 2363 |
+
border: 4px solid #272832;
|
| 2364 |
+
border-top-color: #ff7a3c;
|
| 2365 |
+
border-radius: 50%;
|
| 2366 |
+
animation: spin 0.8s linear infinite;
|
| 2367 |
+
margin: 0 auto;
|
| 2368 |
+
}
|
| 2369 |
+
|
| 2370 |
+
/* Divider */
|
| 2371 |
+
.login-divider {
|
| 2372 |
+
position: relative;
|
| 2373 |
+
text-align: center;
|
| 2374 |
+
margin: 8px 0;
|
| 2375 |
+
}
|
| 2376 |
+
|
| 2377 |
+
.login-divider::before {
|
| 2378 |
+
content: '';
|
| 2379 |
+
position: absolute;
|
| 2380 |
+
top: 50%;
|
| 2381 |
+
left: 0;
|
| 2382 |
+
right: 0;
|
| 2383 |
+
height: 1px;
|
| 2384 |
+
background: #272832;
|
| 2385 |
+
}
|
| 2386 |
+
|
| 2387 |
+
.login-divider span {
|
| 2388 |
+
position: relative;
|
| 2389 |
+
display: inline-block;
|
| 2390 |
+
padding: 0 16px;
|
| 2391 |
+
background: #101117;
|
| 2392 |
+
color: #9a9bb0;
|
| 2393 |
+
font-size: 12px;
|
| 2394 |
+
font-weight: 500;
|
| 2395 |
+
}
|
| 2396 |
+
|
| 2397 |
+
/* Form */
|
| 2398 |
+
.login-form {
|
| 2399 |
+
display: flex;
|
| 2400 |
+
flex-direction: column;
|
| 2401 |
+
gap: 18px;
|
| 2402 |
+
margin-bottom: 28px;
|
| 2403 |
+
}
|
| 2404 |
+
|
| 2405 |
+
.form-group {
|
| 2406 |
+
display: flex;
|
| 2407 |
+
flex-direction: column;
|
| 2408 |
+
gap: 8px;
|
| 2409 |
+
}
|
| 2410 |
+
|
| 2411 |
+
.form-group label {
|
| 2412 |
+
font-size: 13px;
|
| 2413 |
+
font-weight: 600;
|
| 2414 |
+
color: #f5f5f7;
|
| 2415 |
+
}
|
| 2416 |
+
|
| 2417 |
+
.form-input {
|
| 2418 |
+
background: #0a0b0f;
|
| 2419 |
+
border: 1px solid #272832;
|
| 2420 |
+
border-radius: 10px;
|
| 2421 |
+
padding: 12px 14px;
|
| 2422 |
+
color: #f5f5f7;
|
| 2423 |
+
font-size: 14px;
|
| 2424 |
+
font-family: "SF Mono", Monaco, monospace;
|
| 2425 |
+
transition: all 0.2s ease;
|
| 2426 |
+
}
|
| 2427 |
+
|
| 2428 |
+
.form-input:focus {
|
| 2429 |
+
outline: none;
|
| 2430 |
+
border-color: #ff7a3c;
|
| 2431 |
+
box-shadow: 0 0 0 4px rgba(255, 122, 60, 0.1);
|
| 2432 |
+
}
|
| 2433 |
+
|
| 2434 |
+
.form-input:disabled {
|
| 2435 |
+
opacity: 0.5;
|
| 2436 |
+
cursor: not-allowed;
|
| 2437 |
+
}
|
| 2438 |
+
|
| 2439 |
+
.form-input::placeholder {
|
| 2440 |
+
color: #676883;
|
| 2441 |
+
}
|
| 2442 |
+
|
| 2443 |
+
.form-hint {
|
| 2444 |
+
font-size: 12px;
|
| 2445 |
+
color: #9a9bb0;
|
| 2446 |
+
line-height: 1.5;
|
| 2447 |
+
margin: 0;
|
| 2448 |
+
}
|
| 2449 |
+
|
| 2450 |
+
.form-link {
|
| 2451 |
+
color: #ff7a3c;
|
| 2452 |
+
text-decoration: none;
|
| 2453 |
+
font-weight: 500;
|
| 2454 |
+
transition: color 0.2s ease;
|
| 2455 |
+
}
|
| 2456 |
+
|
| 2457 |
+
.form-link:hover {
|
| 2458 |
+
color: #ff8b52;
|
| 2459 |
+
text-decoration: underline;
|
| 2460 |
+
}
|
| 2461 |
+
|
| 2462 |
+
.form-hint code {
|
| 2463 |
+
background: #1a1b26;
|
| 2464 |
+
padding: 2px 6px;
|
| 2465 |
+
border-radius: 4px;
|
| 2466 |
+
font-family: "SF Mono", Monaco, monospace;
|
| 2467 |
+
font-size: 11px;
|
| 2468 |
+
color: #ff7a3c;
|
| 2469 |
+
}
|
| 2470 |
+
|
| 2471 |
+
/* Notice (for no auth configured) */
|
| 2472 |
+
.login-notice {
|
| 2473 |
+
padding: 20px;
|
| 2474 |
+
background: rgba(255, 152, 0, 0.1);
|
| 2475 |
+
border: 1px solid rgba(255, 152, 0, 0.3);
|
| 2476 |
+
border-radius: 12px;
|
| 2477 |
+
margin-bottom: 28px;
|
| 2478 |
+
}
|
| 2479 |
+
|
| 2480 |
+
.login-notice h3 {
|
| 2481 |
+
margin: 0 0 12px 0;
|
| 2482 |
+
font-size: 16px;
|
| 2483 |
+
color: #ffb74d;
|
| 2484 |
+
}
|
| 2485 |
+
|
| 2486 |
+
.login-notice p {
|
| 2487 |
+
margin: 0 0 12px 0;
|
| 2488 |
+
font-size: 13px;
|
| 2489 |
+
color: #c3c5dd;
|
| 2490 |
+
line-height: 1.6;
|
| 2491 |
+
}
|
| 2492 |
+
|
| 2493 |
+
.login-notice ul {
|
| 2494 |
+
margin: 0;
|
| 2495 |
+
padding-left: 20px;
|
| 2496 |
+
font-size: 13px;
|
| 2497 |
+
color: #c3c5dd;
|
| 2498 |
+
line-height: 1.8;
|
| 2499 |
+
}
|
| 2500 |
+
|
| 2501 |
+
.login-notice code {
|
| 2502 |
+
background: #1a1b26;
|
| 2503 |
+
padding: 2px 6px;
|
| 2504 |
+
border-radius: 4px;
|
| 2505 |
+
font-family: "SF Mono", Monaco, monospace;
|
| 2506 |
+
font-size: 12px;
|
| 2507 |
+
color: #ff7a3c;
|
| 2508 |
+
}
|
| 2509 |
+
|
| 2510 |
+
/* Features List */
|
| 2511 |
+
.login-features {
|
| 2512 |
+
display: flex;
|
| 2513 |
+
flex-direction: column;
|
| 2514 |
+
gap: 12px;
|
| 2515 |
+
padding: 20px 0;
|
| 2516 |
+
border-top: 1px solid #272832;
|
| 2517 |
+
border-bottom: 1px solid #272832;
|
| 2518 |
+
margin-bottom: 20px;
|
| 2519 |
+
}
|
| 2520 |
+
|
| 2521 |
+
.feature-item {
|
| 2522 |
+
display: flex;
|
| 2523 |
+
align-items: flex-start;
|
| 2524 |
+
gap: 12px;
|
| 2525 |
+
}
|
| 2526 |
+
|
| 2527 |
+
.feature-icon {
|
| 2528 |
+
flex-shrink: 0;
|
| 2529 |
+
width: 20px;
|
| 2530 |
+
height: 20px;
|
| 2531 |
+
display: flex;
|
| 2532 |
+
align-items: center;
|
| 2533 |
+
justify-content: center;
|
| 2534 |
+
color: #7cffb3;
|
| 2535 |
+
}
|
| 2536 |
+
|
| 2537 |
+
.feature-text {
|
| 2538 |
+
display: flex;
|
| 2539 |
+
flex-direction: column;
|
| 2540 |
+
gap: 2px;
|
| 2541 |
+
}
|
| 2542 |
+
|
| 2543 |
+
.feature-text strong {
|
| 2544 |
+
font-size: 13px;
|
| 2545 |
+
font-weight: 600;
|
| 2546 |
+
color: #f5f5f7;
|
| 2547 |
+
}
|
| 2548 |
+
|
| 2549 |
+
.feature-text span {
|
| 2550 |
+
font-size: 12px;
|
| 2551 |
+
color: #9a9bb0;
|
| 2552 |
+
}
|
| 2553 |
+
|
| 2554 |
+
/* Footer */
|
| 2555 |
+
.login-footer {
|
| 2556 |
+
text-align: center;
|
| 2557 |
+
}
|
| 2558 |
+
|
| 2559 |
+
.login-footer p {
|
| 2560 |
+
margin: 0;
|
| 2561 |
+
font-size: 11px;
|
| 2562 |
+
color: #7a7b8e;
|
| 2563 |
+
line-height: 1.6;
|
| 2564 |
+
}/* ============================================================================
|
| 2565 |
+
INSTALLATION MODAL - Claude Code Style
|
| 2566 |
+
============================================================================ */
|
| 2567 |
+
|
| 2568 |
+
.install-modal-backdrop {
|
| 2569 |
+
position: fixed;
|
| 2570 |
+
inset: 0;
|
| 2571 |
+
display: flex;
|
| 2572 |
+
align-items: center;
|
| 2573 |
+
justify-content: center;
|
| 2574 |
+
background: rgba(0, 0, 0, 0.7);
|
| 2575 |
+
backdrop-filter: blur(8px);
|
| 2576 |
+
z-index: 9999;
|
| 2577 |
+
animation: fadeIn 0.2s ease;
|
| 2578 |
+
}
|
| 2579 |
+
|
| 2580 |
+
.install-modal {
|
| 2581 |
+
width: 480px;
|
| 2582 |
+
max-width: 90vw;
|
| 2583 |
+
background: #101117;
|
| 2584 |
+
border: 1px solid #272832;
|
| 2585 |
+
border-radius: 16px;
|
| 2586 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
| 2587 |
+
animation: modalSlideIn 0.3s ease;
|
| 2588 |
+
overflow: hidden;
|
| 2589 |
+
}
|
| 2590 |
+
|
| 2591 |
+
@keyframes modalSlideIn {
|
| 2592 |
+
from {
|
| 2593 |
+
opacity: 0;
|
| 2594 |
+
transform: translateY(-20px) scale(0.95);
|
| 2595 |
+
}
|
| 2596 |
+
to {
|
| 2597 |
+
opacity: 1;
|
| 2598 |
+
transform: translateY(0) scale(1);
|
| 2599 |
+
}
|
| 2600 |
+
}
|
| 2601 |
+
|
| 2602 |
+
/* Modal Header */
|
| 2603 |
+
.install-modal-header {
|
| 2604 |
+
padding: 32px 32px 24px;
|
| 2605 |
+
text-align: center;
|
| 2606 |
+
border-bottom: 1px solid #272832;
|
| 2607 |
+
}
|
| 2608 |
+
|
| 2609 |
+
.install-modal-logo {
|
| 2610 |
+
display: flex;
|
| 2611 |
+
justify-content: center;
|
| 2612 |
+
margin-bottom: 16px;
|
| 2613 |
+
}
|
| 2614 |
+
|
| 2615 |
+
.logo-icon-large {
|
| 2616 |
+
width: 56px;
|
| 2617 |
+
height: 56px;
|
| 2618 |
+
border-radius: 12px;
|
| 2619 |
+
background: linear-gradient(135deg, #ff7a3c 0%, #ff6b2b 100%);
|
| 2620 |
+
display: flex;
|
| 2621 |
+
align-items: center;
|
| 2622 |
+
justify-content: center;
|
| 2623 |
+
font-weight: 700;
|
| 2624 |
+
font-size: 24px;
|
| 2625 |
+
color: #050608;
|
| 2626 |
+
box-shadow: 0 4px 16px rgba(255, 122, 60, 0.3);
|
| 2627 |
+
}
|
| 2628 |
+
|
| 2629 |
+
.install-modal-title {
|
| 2630 |
+
margin: 0 0 8px 0;
|
| 2631 |
+
font-size: 20px;
|
| 2632 |
+
font-weight: 600;
|
| 2633 |
+
color: #f5f5f7;
|
| 2634 |
+
}
|
| 2635 |
+
|
| 2636 |
+
.install-modal-subtitle {
|
| 2637 |
+
margin: 0;
|
| 2638 |
+
font-size: 13px;
|
| 2639 |
+
color: #9a9bb0;
|
| 2640 |
+
line-height: 1.5;
|
| 2641 |
+
}
|
| 2642 |
+
|
| 2643 |
+
/* Status Indicator */
|
| 2644 |
+
.install-status {
|
| 2645 |
+
display: flex;
|
| 2646 |
+
align-items: center;
|
| 2647 |
+
gap: 10px;
|
| 2648 |
+
padding: 12px 16px;
|
| 2649 |
+
margin: 16px 24px;
|
| 2650 |
+
border-radius: 8px;
|
| 2651 |
+
font-size: 13px;
|
| 2652 |
+
transition: all 0.2s ease;
|
| 2653 |
+
}
|
| 2654 |
+
|
| 2655 |
+
.install-status-error {
|
| 2656 |
+
background: rgba(255, 82, 82, 0.1);
|
| 2657 |
+
border: 1px solid rgba(255, 82, 82, 0.3);
|
| 2658 |
+
color: #ff8a8a;
|
| 2659 |
+
}
|
| 2660 |
+
|
| 2661 |
+
.install-status-pending {
|
| 2662 |
+
background: rgba(255, 152, 0, 0.1);
|
| 2663 |
+
border: 1px solid rgba(255, 152, 0, 0.3);
|
| 2664 |
+
color: #ffb74d;
|
| 2665 |
+
}
|
| 2666 |
+
|
| 2667 |
+
.status-icon {
|
| 2668 |
+
flex-shrink: 0;
|
| 2669 |
+
}
|
| 2670 |
+
|
| 2671 |
+
.status-spinner {
|
| 2672 |
+
width: 16px;
|
| 2673 |
+
height: 16px;
|
| 2674 |
+
border: 2px solid rgba(255, 180, 77, 0.3);
|
| 2675 |
+
border-top-color: #ffb74d;
|
| 2676 |
+
border-radius: 50%;
|
| 2677 |
+
animation: spin 0.6s linear infinite;
|
| 2678 |
+
}
|
| 2679 |
+
|
| 2680 |
+
/* Installation Steps */
|
| 2681 |
+
.install-steps {
|
| 2682 |
+
padding: 24px 32px;
|
| 2683 |
+
display: flex;
|
| 2684 |
+
flex-direction: column;
|
| 2685 |
+
gap: 16px;
|
| 2686 |
+
}
|
| 2687 |
+
|
| 2688 |
+
.install-step {
|
| 2689 |
+
display: flex;
|
| 2690 |
+
align-items: flex-start;
|
| 2691 |
+
gap: 12px;
|
| 2692 |
+
}
|
| 2693 |
+
|
| 2694 |
+
.step-number {
|
| 2695 |
+
flex-shrink: 0;
|
| 2696 |
+
width: 28px;
|
| 2697 |
+
height: 28px;
|
| 2698 |
+
border-radius: 8px;
|
| 2699 |
+
background: #1a1b26;
|
| 2700 |
+
border: 1px solid #3a3b4d;
|
| 2701 |
+
display: flex;
|
| 2702 |
+
align-items: center;
|
| 2703 |
+
justify-content: center;
|
| 2704 |
+
font-size: 13px;
|
| 2705 |
+
font-weight: 600;
|
| 2706 |
+
color: #ff7a3c;
|
| 2707 |
+
}
|
| 2708 |
+
|
| 2709 |
+
.step-content h3 {
|
| 2710 |
+
margin: 0 0 4px 0;
|
| 2711 |
+
font-size: 14px;
|
| 2712 |
+
font-weight: 600;
|
| 2713 |
+
color: #f5f5f7;
|
| 2714 |
+
}
|
| 2715 |
+
|
| 2716 |
+
.step-content p {
|
| 2717 |
+
margin: 0;
|
| 2718 |
+
font-size: 12px;
|
| 2719 |
+
color: #9a9bb0;
|
| 2720 |
+
line-height: 1.5;
|
| 2721 |
+
}
|
| 2722 |
+
|
| 2723 |
+
/* Action Buttons */
|
| 2724 |
+
.install-modal-actions {
|
| 2725 |
+
display: flex;
|
| 2726 |
+
align-items: center;
|
| 2727 |
+
justify-content: flex-end;
|
| 2728 |
+
gap: 10px;
|
| 2729 |
+
padding: 16px 24px;
|
| 2730 |
+
border-top: 1px solid #272832;
|
| 2731 |
+
background: #0a0b0f;
|
| 2732 |
+
}
|
| 2733 |
+
|
| 2734 |
+
.btn-install-primary {
|
| 2735 |
+
border: none;
|
| 2736 |
+
outline: none;
|
| 2737 |
+
background: #000;
|
| 2738 |
+
color: #fff;
|
| 2739 |
+
padding: 10px 18px;
|
| 2740 |
+
border-radius: 8px;
|
| 2741 |
+
font-size: 13px;
|
| 2742 |
+
font-weight: 600;
|
| 2743 |
+
cursor: pointer;
|
| 2744 |
+
display: flex;
|
| 2745 |
+
align-items: center;
|
| 2746 |
+
gap: 8px;
|
| 2747 |
+
transition: all 0.2s ease;
|
| 2748 |
+
}
|
| 2749 |
+
|
| 2750 |
+
.btn-install-primary:hover:not(:disabled) {
|
| 2751 |
+
background: #1a1a1a;
|
| 2752 |
+
transform: translateY(-1px);
|
| 2753 |
+
}
|
| 2754 |
+
|
| 2755 |
+
.btn-install-primary:active:not(:disabled) {
|
| 2756 |
+
transform: translateY(0);
|
| 2757 |
+
}
|
| 2758 |
+
|
| 2759 |
+
.btn-install-primary:disabled {
|
| 2760 |
+
opacity: 0.5;
|
| 2761 |
+
cursor: not-allowed;
|
| 2762 |
+
}
|
| 2763 |
+
|
| 2764 |
+
.btn-check-status {
|
| 2765 |
+
border: 1px solid #3a3b4d;
|
| 2766 |
+
outline: none;
|
| 2767 |
+
background: #1a1b26;
|
| 2768 |
+
color: #f5f5f7;
|
| 2769 |
+
padding: 10px 18px;
|
| 2770 |
+
border-radius: 8px;
|
| 2771 |
+
font-size: 13px;
|
| 2772 |
+
font-weight: 500;
|
| 2773 |
+
cursor: pointer;
|
| 2774 |
+
display: flex;
|
| 2775 |
+
align-items: center;
|
| 2776 |
+
gap: 8px;
|
| 2777 |
+
transition: all 0.2s ease;
|
| 2778 |
+
}
|
| 2779 |
+
|
| 2780 |
+
.btn-check-status:hover:not(:disabled) {
|
| 2781 |
+
background: #2a2b3c;
|
| 2782 |
+
border-color: #4a4b5d;
|
| 2783 |
+
}
|
| 2784 |
+
|
| 2785 |
+
.btn-check-status:disabled {
|
| 2786 |
+
opacity: 0.5;
|
| 2787 |
+
cursor: not-allowed;
|
| 2788 |
+
}
|
| 2789 |
+
|
| 2790 |
+
.btn-install-secondary {
|
| 2791 |
+
border: 1px solid #3a3b4d;
|
| 2792 |
+
outline: none;
|
| 2793 |
+
background: transparent;
|
| 2794 |
+
color: #c3c5dd;
|
| 2795 |
+
padding: 10px 18px;
|
| 2796 |
+
border-radius: 8px;
|
| 2797 |
+
font-size: 13px;
|
| 2798 |
+
font-weight: 500;
|
| 2799 |
+
cursor: pointer;
|
| 2800 |
+
display: flex;
|
| 2801 |
+
align-items: center;
|
| 2802 |
+
gap: 8px;
|
| 2803 |
+
transition: all 0.2s ease;
|
| 2804 |
+
}
|
| 2805 |
+
|
| 2806 |
+
.btn-install-secondary:hover:not(:disabled) {
|
| 2807 |
+
background: #1a1b26;
|
| 2808 |
+
border-color: #4a4b5d;
|
| 2809 |
+
}
|
| 2810 |
+
|
| 2811 |
+
.btn-install-secondary:disabled {
|
| 2812 |
+
opacity: 0.5;
|
| 2813 |
+
cursor: not-allowed;
|
| 2814 |
+
}
|
| 2815 |
+
|
| 2816 |
+
/* Footer */
|
| 2817 |
+
.install-modal-footer {
|
| 2818 |
+
padding: 16px 32px 24px;
|
| 2819 |
+
text-align: center;
|
| 2820 |
+
}
|
| 2821 |
+
|
| 2822 |
+
.install-modal-footer p {
|
| 2823 |
+
margin: 0;
|
| 2824 |
+
font-size: 12px;
|
| 2825 |
+
color: #7a7b8e;
|
| 2826 |
+
line-height: 1.6;
|
| 2827 |
+
}
|
| 2828 |
+
|
| 2829 |
+
.install-modal-footer strong {
|
| 2830 |
+
color: #c3c5dd;
|
| 2831 |
+
font-weight: 600;
|
| 2832 |
+
}
|
| 2833 |
+
|
| 2834 |
+
/* Button spinner */
|
| 2835 |
+
.btn-spinner {
|
| 2836 |
+
width: 14px;
|
| 2837 |
+
height: 14px;
|
| 2838 |
+
border: 2px solid rgba(255, 255, 255, 0.3);
|
| 2839 |
+
border-top-color: #fff;
|
| 2840 |
+
border-radius: 50%;
|
| 2841 |
+
animation: spin 0.6s linear infinite;
|
| 2842 |
+
}
|
| 2843 |
+
|
| 2844 |
+
@keyframes spin {
|
| 2845 |
+
to { transform: rotate(360deg); }
|
| 2846 |
+
}
|
| 2847 |
+
|
| 2848 |
+
/* Secondary primary-style button for "Load available models" */
|
| 2849 |
+
.settings-load-btn {
|
| 2850 |
+
margin-top: 8px;
|
| 2851 |
+
|
| 2852 |
+
/* Make it hug the text, not full width */
|
| 2853 |
+
display: inline-flex;
|
| 2854 |
+
align-items: center;
|
| 2855 |
+
justify-content: center;
|
| 2856 |
+
width: auto !important;
|
| 2857 |
+
min-width: 0;
|
| 2858 |
+
align-self: flex-start;
|
| 2859 |
+
|
| 2860 |
+
/* Size: slightly smaller than Save but same family */
|
| 2861 |
+
padding: 7px 14px;
|
| 2862 |
+
border-radius: 999px;
|
| 2863 |
+
|
| 2864 |
+
font-size: 12px;
|
| 2865 |
+
font-weight: 600;
|
| 2866 |
+
letter-spacing: 0.01em;
|
| 2867 |
+
|
| 2868 |
+
border: none;
|
| 2869 |
+
outline: none;
|
| 2870 |
+
cursor: pointer;
|
| 2871 |
+
|
| 2872 |
+
/* Match Save button color palette */
|
| 2873 |
+
background: #ff7a3c;
|
| 2874 |
+
color: #050608;
|
| 2875 |
+
|
| 2876 |
+
transition:
|
| 2877 |
+
background 0.2s ease,
|
| 2878 |
+
box-shadow 0.2s ease,
|
| 2879 |
+
transform 0.15s ease,
|
| 2880 |
+
opacity 0.2s ease;
|
| 2881 |
+
}
|
| 2882 |
+
|
| 2883 |
+
.settings-load-btn:hover {
|
| 2884 |
+
background: #ff8b52;
|
| 2885 |
+
transform: translateY(-1px);
|
| 2886 |
+
box-shadow: 0 3px 10px rgba(255, 122, 60, 0.28);
|
| 2887 |
+
}
|
| 2888 |
+
|
| 2889 |
+
.settings-load-btn:active {
|
| 2890 |
+
transform: translateY(0);
|
| 2891 |
+
box-shadow: 0 1px 4px rgba(255, 122, 60, 0.25);
|
| 2892 |
+
}
|
| 2893 |
+
|
| 2894 |
+
.settings-load-btn:disabled {
|
| 2895 |
+
opacity: 0.55;
|
| 2896 |
+
cursor: not-allowed;
|
| 2897 |
+
transform: none;
|
| 2898 |
+
box-shadow: none;
|
| 2899 |
+
}
|
| 2900 |
+
|
| 2901 |
+
/* ------------------------------
|
| 2902 |
+
LLM Settings Loading Experience
|
| 2903 |
+
------------------------------ */
|
| 2904 |
+
|
| 2905 |
+
.settings-loading-shell {
|
| 2906 |
+
min-height: calc(100vh - 32px);
|
| 2907 |
+
display: flex;
|
| 2908 |
+
align-items: center;
|
| 2909 |
+
justify-content: center;
|
| 2910 |
+
padding: 32px 24px;
|
| 2911 |
+
}
|
| 2912 |
+
|
| 2913 |
+
.settings-loading-card {
|
| 2914 |
+
width: 100%;
|
| 2915 |
+
max-width: 520px;
|
| 2916 |
+
background: #101117;
|
| 2917 |
+
border: 1px solid #272832;
|
| 2918 |
+
border-radius: 20px;
|
| 2919 |
+
padding: 40px 28px;
|
| 2920 |
+
text-align: center;
|
| 2921 |
+
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
|
| 2922 |
+
}
|
| 2923 |
+
|
| 2924 |
+
.settings-loading-card h1 {
|
| 2925 |
+
margin: 0 0 6px 0;
|
| 2926 |
+
font-size: 28px;
|
| 2927 |
+
line-height: 1.1;
|
| 2928 |
+
}
|
| 2929 |
+
|
| 2930 |
+
.settings-loading-subtitle {
|
| 2931 |
+
font-size: 13px;
|
| 2932 |
+
color: #9a9bb0;
|
| 2933 |
+
margin-bottom: 18px;
|
| 2934 |
+
}
|
| 2935 |
+
|
| 2936 |
+
.settings-loading-text {
|
| 2937 |
+
font-size: 14px;
|
| 2938 |
+
color: #d4d7e1;
|
| 2939 |
+
line-height: 1.6;
|
| 2940 |
+
margin: 0;
|
| 2941 |
+
}
|
| 2942 |
+
|
| 2943 |
+
.settings-loading-spinner {
|
| 2944 |
+
width: 56px;
|
| 2945 |
+
height: 56px;
|
| 2946 |
+
border: 4px solid #272832;
|
| 2947 |
+
border-top-color: #ff7a3c;
|
| 2948 |
+
border-right-color: rgba(255, 122, 60, 0.7);
|
| 2949 |
+
border-radius: 50%;
|
| 2950 |
+
animation: spin 0.8s linear infinite;
|
| 2951 |
+
margin: 0 auto 18px;
|
| 2952 |
+
}
|
| 2953 |
+
|
| 2954 |
+
.settings-loading-slow {
|
| 2955 |
+
margin-top: 18px;
|
| 2956 |
+
padding: 14px 16px;
|
| 2957 |
+
background: #0b0c11;
|
| 2958 |
+
border: 1px solid #272832;
|
| 2959 |
+
border-radius: 12px;
|
| 2960 |
+
}
|
| 2961 |
+
|
| 2962 |
+
.settings-loading-slow p {
|
| 2963 |
+
margin: 0 0 12px 0;
|
| 2964 |
+
color: #9a9bb0;
|
| 2965 |
+
font-size: 13px;
|
| 2966 |
+
line-height: 1.5;
|
| 2967 |
+
}
|
| 2968 |
+
|
| 2969 |
+
.settings-inline-error-card {
|
| 2970 |
+
width: 100%;
|
| 2971 |
+
max-width: 620px;
|
| 2972 |
+
margin: 60px auto 0;
|
| 2973 |
+
background: #101117;
|
| 2974 |
+
border: 1px solid #272832;
|
| 2975 |
+
border-radius: 16px;
|
| 2976 |
+
padding: 28px 24px;
|
| 2977 |
+
}
|
| 2978 |
+
|
| 2979 |
+
.settings-error-banner,
|
| 2980 |
+
.settings-success-banner {
|
| 2981 |
+
border-radius: 12px;
|
| 2982 |
+
padding: 12px 14px;
|
| 2983 |
+
margin-bottom: 14px;
|
| 2984 |
+
font-size: 13px;
|
| 2985 |
+
line-height: 1.5;
|
| 2986 |
+
}
|
| 2987 |
+
|
| 2988 |
+
.settings-error-banner {
|
| 2989 |
+
background: rgba(255, 87, 87, 0.08);
|
| 2990 |
+
border: 1px solid rgba(255, 87, 87, 0.24);
|
| 2991 |
+
color: #ffb0b0;
|
| 2992 |
+
}
|
| 2993 |
+
|
| 2994 |
+
.settings-success-banner {
|
| 2995 |
+
background: rgba(67, 181, 129, 0.08);
|
| 2996 |
+
border: 1px solid rgba(67, 181, 129, 0.24);
|
| 2997 |
+
color: #9ce7c2;
|
| 2998 |
+
}
|
| 2999 |
+
|
| 3000 |
+
.settings-error-text {
|
| 3001 |
+
color: #ffb0b0;
|
| 3002 |
+
font-size: 14px;
|
| 3003 |
+
line-height: 1.6;
|
| 3004 |
+
margin: 12px 0 18px;
|
| 3005 |
+
}
|
| 3006 |
+
|
| 3007 |
+
.settings-secondary-btn {
|
| 3008 |
+
background: transparent;
|
| 3009 |
+
border: 1px solid #313244;
|
| 3010 |
+
color: #f5f5f7;
|
| 3011 |
+
border-radius: 999px;
|
| 3012 |
+
padding: 9px 16px;
|
| 3013 |
+
font-size: 13px;
|
| 3014 |
+
cursor: pointer;
|
| 3015 |
+
transition: all 0.2s ease;
|
| 3016 |
+
}
|
| 3017 |
+
|
| 3018 |
+
.settings-secondary-btn:hover {
|
| 3019 |
+
border-color: #ff7a3c;
|
| 3020 |
+
color: #fff;
|
| 3021 |
+
background: rgba(255, 122, 60, 0.08);
|
| 3022 |
+
}
|
| 3023 |
+
|
| 3024 |
+
.settings-inline-row {
|
| 3025 |
+
display: flex;
|
| 3026 |
+
gap: 10px;
|
| 3027 |
+
align-items: center;
|
| 3028 |
+
}
|
| 3029 |
+
|
| 3030 |
+
.settings-inline-row .settings-input {
|
| 3031 |
+
flex: 1;
|
| 3032 |
+
}
|
| 3033 |
+
|
| 3034 |
+
.settings-model-list {
|
| 3035 |
+
display: flex;
|
| 3036 |
+
flex-wrap: wrap;
|
| 3037 |
+
gap: 8px;
|
| 3038 |
+
}
|
| 3039 |
+
|
| 3040 |
+
.settings-model-chip {
|
| 3041 |
+
background: #090a0e;
|
| 3042 |
+
border: 1px solid #2b2c36;
|
| 3043 |
+
border-radius: 999px;
|
| 3044 |
+
color: #f5f5f7;
|
| 3045 |
+
padding: 8px 12px;
|
| 3046 |
+
font-size: 12px;
|
| 3047 |
+
cursor: pointer;
|
| 3048 |
+
transition: all 0.18s ease;
|
| 3049 |
+
}
|
| 3050 |
+
|
| 3051 |
+
.settings-model-chip:hover {
|
| 3052 |
+
border-color: #ff7a3c;
|
| 3053 |
+
background: rgba(255, 122, 60, 0.08);
|
| 3054 |
+
}
|
| 3055 |
+
|
| 3056 |
+
/* =========================================================
|
| 3057 |
+
Startup Screen β Enterprise Loader
|
| 3058 |
+
========================================================= */
|
| 3059 |
+
|
| 3060 |
+
.startup-screen {
|
| 3061 |
+
min-height: 100vh;
|
| 3062 |
+
width: 100%;
|
| 3063 |
+
display: flex;
|
| 3064 |
+
align-items: center;
|
| 3065 |
+
justify-content: center;
|
| 3066 |
+
padding: 32px;
|
| 3067 |
+
box-sizing: border-box;
|
| 3068 |
+
background:
|
| 3069 |
+
radial-gradient(circle at top center, rgba(255, 122, 60, 0.10), transparent 18%),
|
| 3070 |
+
linear-gradient(180deg, #050814 0%, #03060f 100%);
|
| 3071 |
+
}
|
| 3072 |
+
|
| 3073 |
+
.startup-card {
|
| 3074 |
+
width: min(100%, 520px);
|
| 3075 |
+
display: flex;
|
| 3076 |
+
flex-direction: column;
|
| 3077 |
+
gap: 20px;
|
| 3078 |
+
padding: 28px 28px 24px;
|
| 3079 |
+
border-radius: 20px;
|
| 3080 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 3081 |
+
background:
|
| 3082 |
+
linear-gradient(180deg, rgba(18, 24, 42, 0.94) 0%, rgba(10, 15, 28, 0.96) 100%);
|
| 3083 |
+
box-shadow:
|
| 3084 |
+
0 10px 40px rgba(0, 0, 0, 0.45),
|
| 3085 |
+
0 0 0 1px rgba(255, 255, 255, 0.02) inset;
|
| 3086 |
+
backdrop-filter: blur(12px);
|
| 3087 |
+
}
|
| 3088 |
+
|
| 3089 |
+
.startup-brand-row {
|
| 3090 |
+
display: flex;
|
| 3091 |
+
align-items: center;
|
| 3092 |
+
gap: 16px;
|
| 3093 |
+
}
|
| 3094 |
+
|
| 3095 |
+
.startup-brand-mark {
|
| 3096 |
+
position: relative;
|
| 3097 |
+
width: 52px;
|
| 3098 |
+
height: 52px;
|
| 3099 |
+
flex: 0 0 52px;
|
| 3100 |
+
}
|
| 3101 |
+
|
| 3102 |
+
.startup-brand-ring {
|
| 3103 |
+
position: absolute;
|
| 3104 |
+
inset: 0;
|
| 3105 |
+
border-radius: 50%;
|
| 3106 |
+
border: 3px solid rgba(255, 122, 60, 0.22);
|
| 3107 |
+
border-top-color: #ff7a3c;
|
| 3108 |
+
animation: startup-spin 1.1s linear infinite;
|
| 3109 |
+
}
|
| 3110 |
+
|
| 3111 |
+
.startup-brand-core {
|
| 3112 |
+
position: absolute;
|
| 3113 |
+
inset: 11px;
|
| 3114 |
+
border-radius: 50%;
|
| 3115 |
+
background: radial-gradient(circle, rgba(255, 122, 60, 0.95) 0%, rgba(255, 122, 60, 0.25) 72%, transparent 100%);
|
| 3116 |
+
box-shadow: 0 0 24px rgba(255, 122, 60, 0.28);
|
| 3117 |
+
}
|
| 3118 |
+
|
| 3119 |
+
.startup-brand-copy {
|
| 3120 |
+
min-width: 0;
|
| 3121 |
+
}
|
| 3122 |
+
|
| 3123 |
+
.startup-title {
|
| 3124 |
+
font-size: 26px;
|
| 3125 |
+
line-height: 1.1;
|
| 3126 |
+
font-weight: 700;
|
| 3127 |
+
color: #f8fafc;
|
| 3128 |
+
letter-spacing: 0.01em;
|
| 3129 |
+
}
|
| 3130 |
+
|
| 3131 |
+
.startup-subtitle {
|
| 3132 |
+
margin-top: 4px;
|
| 3133 |
+
font-size: 13px;
|
| 3134 |
+
line-height: 1.5;
|
| 3135 |
+
color: #94a3b8;
|
| 3136 |
+
}
|
| 3137 |
+
|
| 3138 |
+
.startup-loader-wrap {
|
| 3139 |
+
display: flex;
|
| 3140 |
+
align-items: center;
|
| 3141 |
+
justify-content: center;
|
| 3142 |
+
padding-top: 4px;
|
| 3143 |
+
}
|
| 3144 |
+
|
| 3145 |
+
.startup-loader {
|
| 3146 |
+
position: relative;
|
| 3147 |
+
width: 72px;
|
| 3148 |
+
height: 72px;
|
| 3149 |
+
}
|
| 3150 |
+
|
| 3151 |
+
.startup-loader-ring {
|
| 3152 |
+
position: absolute;
|
| 3153 |
+
inset: 0;
|
| 3154 |
+
border-radius: 50%;
|
| 3155 |
+
}
|
| 3156 |
+
|
| 3157 |
+
.startup-loader-ring-outer {
|
| 3158 |
+
border: 4px solid rgba(255, 255, 255, 0.08);
|
| 3159 |
+
border-top-color: #ff7a3c;
|
| 3160 |
+
animation: startup-spin 1s linear infinite;
|
| 3161 |
+
}
|
| 3162 |
+
|
| 3163 |
+
.startup-loader-ring-inner {
|
| 3164 |
+
inset: 10px;
|
| 3165 |
+
border: 3px solid rgba(255, 122, 60, 0.14);
|
| 3166 |
+
border-bottom-color: rgba(255, 122, 60, 0.9);
|
| 3167 |
+
animation: startup-spin-reverse 1.4s linear infinite;
|
| 3168 |
+
}
|
| 3169 |
+
|
| 3170 |
+
.startup-status-block {
|
| 3171 |
+
text-align: center;
|
| 3172 |
+
}
|
| 3173 |
+
|
| 3174 |
+
.startup-status {
|
| 3175 |
+
font-size: 18px;
|
| 3176 |
+
font-weight: 600;
|
| 3177 |
+
color: #f8fafc;
|
| 3178 |
+
letter-spacing: 0.01em;
|
| 3179 |
+
}
|
| 3180 |
+
|
| 3181 |
+
.startup-detail {
|
| 3182 |
+
margin-top: 8px;
|
| 3183 |
+
font-size: 13px;
|
| 3184 |
+
line-height: 1.6;
|
| 3185 |
+
color: #94a3b8;
|
| 3186 |
+
}
|
| 3187 |
+
|
| 3188 |
+
.startup-phase-row {
|
| 3189 |
+
display: flex;
|
| 3190 |
+
justify-content: center;
|
| 3191 |
+
}
|
| 3192 |
+
|
| 3193 |
+
.startup-phase-badge {
|
| 3194 |
+
display: inline-flex;
|
| 3195 |
+
align-items: center;
|
| 3196 |
+
justify-content: center;
|
| 3197 |
+
min-height: 28px;
|
| 3198 |
+
padding: 0 12px;
|
| 3199 |
+
border-radius: 999px;
|
| 3200 |
+
background: rgba(255, 122, 60, 0.12);
|
| 3201 |
+
border: 1px solid rgba(255, 122, 60, 0.24);
|
| 3202 |
+
color: #ffb089;
|
| 3203 |
+
font-size: 12px;
|
| 3204 |
+
font-weight: 600;
|
| 3205 |
+
letter-spacing: 0.04em;
|
| 3206 |
+
text-transform: uppercase;
|
| 3207 |
+
}
|
| 3208 |
+
|
| 3209 |
+
.startup-meta-grid {
|
| 3210 |
+
display: grid;
|
| 3211 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 3212 |
+
gap: 12px;
|
| 3213 |
+
}
|
| 3214 |
+
|
| 3215 |
+
.startup-meta-item {
|
| 3216 |
+
padding: 12px 14px;
|
| 3217 |
+
border-radius: 14px;
|
| 3218 |
+
background: rgba(255, 255, 255, 0.035);
|
| 3219 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 3220 |
+
}
|
| 3221 |
+
|
| 3222 |
+
.startup-meta-item-wide {
|
| 3223 |
+
grid-column: 1 / -1;
|
| 3224 |
+
}
|
| 3225 |
+
|
| 3226 |
+
.startup-meta-label {
|
| 3227 |
+
font-size: 11px;
|
| 3228 |
+
font-weight: 600;
|
| 3229 |
+
letter-spacing: 0.05em;
|
| 3230 |
+
text-transform: uppercase;
|
| 3231 |
+
color: #64748b;
|
| 3232 |
+
}
|
| 3233 |
+
|
| 3234 |
+
.startup-meta-value {
|
| 3235 |
+
margin-top: 6px;
|
| 3236 |
+
font-size: 14px;
|
| 3237 |
+
font-weight: 600;
|
| 3238 |
+
color: #e2e8f0;
|
| 3239 |
+
word-break: break-word;
|
| 3240 |
+
}
|
| 3241 |
+
|
| 3242 |
+
.startup-footer {
|
| 3243 |
+
font-size: 12px;
|
| 3244 |
+
line-height: 1.6;
|
| 3245 |
+
color: #64748b;
|
| 3246 |
+
text-align: center;
|
| 3247 |
+
}
|
| 3248 |
+
|
| 3249 |
+
@keyframes startup-spin {
|
| 3250 |
+
from {
|
| 3251 |
+
transform: rotate(0deg);
|
| 3252 |
+
}
|
| 3253 |
+
to {
|
| 3254 |
+
transform: rotate(360deg);
|
| 3255 |
+
}
|
| 3256 |
+
}
|
| 3257 |
+
|
| 3258 |
+
@keyframes startup-spin-reverse {
|
| 3259 |
+
from {
|
| 3260 |
+
transform: rotate(360deg);
|
| 3261 |
+
}
|
| 3262 |
+
to {
|
| 3263 |
+
transform: rotate(0deg);
|
| 3264 |
+
}
|
| 3265 |
+
}
|
| 3266 |
+
|
| 3267 |
+
@media (max-width: 640px) {
|
| 3268 |
+
.startup-screen {
|
| 3269 |
+
padding: 20px;
|
| 3270 |
+
}
|
| 3271 |
+
|
| 3272 |
+
.startup-card {
|
| 3273 |
+
padding: 22px 20px 20px;
|
| 3274 |
+
border-radius: 18px;
|
| 3275 |
+
}
|
| 3276 |
+
|
| 3277 |
+
.startup-title {
|
| 3278 |
+
font-size: 22px;
|
| 3279 |
+
}
|
| 3280 |
+
|
| 3281 |
+
.startup-meta-grid {
|
| 3282 |
+
grid-template-columns: 1fr;
|
| 3283 |
+
}
|
| 3284 |
+
|
| 3285 |
+
.startup-meta-item-wide {
|
| 3286 |
+
grid-column: auto;
|
| 3287 |
+
}
|
| 3288 |
+
}
|
frontend/utils/api.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* API utilities for authenticated requests
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Get backend URL from environment or use relative path (for local dev)
|
| 7 |
+
* - Production (Vercel): Uses VITE_BACKEND_URL env var (e.g., https://gitpilot-backend.onrender.com)
|
| 8 |
+
* - Development (local): Uses relative paths (proxied by Vite to localhost:8000)
|
| 9 |
+
*/
|
| 10 |
+
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || '';
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* Check if backend URL is configured
|
| 14 |
+
* @returns {boolean} True if backend URL is set
|
| 15 |
+
*/
|
| 16 |
+
export function isBackendConfigured() {
|
| 17 |
+
return BACKEND_URL !== '' && BACKEND_URL !== undefined;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Get the configured backend URL
|
| 22 |
+
* @returns {string} Backend URL or empty string
|
| 23 |
+
*/
|
| 24 |
+
export function getBackendUrl() {
|
| 25 |
+
return BACKEND_URL;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Construct full API URL
|
| 30 |
+
* @param {string} path - API endpoint path (e.g., '/api/chat/plan')
|
| 31 |
+
* @returns {string} Full URL to API endpoint
|
| 32 |
+
*/
|
| 33 |
+
export function apiUrl(path) {
|
| 34 |
+
// Ensure path starts with /
|
| 35 |
+
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
| 36 |
+
return `${BACKEND_URL}${cleanPath}`;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Enhanced fetch with better error handling for JSON parsing
|
| 41 |
+
* @param {string} url - URL to fetch
|
| 42 |
+
* @param {Object} options - Fetch options
|
| 43 |
+
* @returns {Promise<any>} Parsed JSON response
|
| 44 |
+
*/
|
| 45 |
+
export async function safeFetchJSON(url, options = {}) {
|
| 46 |
+
try {
|
| 47 |
+
// Add timeout to prevent hanging when backend is starting up.
|
| 48 |
+
// Default raised to 15s to tolerate first-load GitHub API checks.
|
| 49 |
+
const timeout = options.timeout || 15000;
|
| 50 |
+
const controller = new AbortController();
|
| 51 |
+
const timer = setTimeout(() => controller.abort(), timeout);
|
| 52 |
+
const fetchOptions = { ...options, signal: options.signal || controller.signal };
|
| 53 |
+
delete fetchOptions.timeout;
|
| 54 |
+
|
| 55 |
+
let response;
|
| 56 |
+
try {
|
| 57 |
+
response = await fetch(url, fetchOptions);
|
| 58 |
+
} finally {
|
| 59 |
+
clearTimeout(timer);
|
| 60 |
+
}
|
| 61 |
+
const contentType = response.headers.get('content-type');
|
| 62 |
+
|
| 63 |
+
// Check if response is actually JSON
|
| 64 |
+
if (!contentType || !contentType.includes('application/json')) {
|
| 65 |
+
// If not JSON, it might be an HTML error page
|
| 66 |
+
const text = await response.text();
|
| 67 |
+
|
| 68 |
+
// Check if it looks like HTML (starts with <!doctype or <html)
|
| 69 |
+
if (text.trim().toLowerCase().startsWith('<!doctype') ||
|
| 70 |
+
text.trim().toLowerCase().startsWith('<html')) {
|
| 71 |
+
throw new Error(
|
| 72 |
+
`Backend not reachable. Received HTML instead of JSON. ` +
|
| 73 |
+
`${!isBackendConfigured() ? 'VITE_BACKEND_URL environment variable is not configured. ' : ''}` +
|
| 74 |
+
`Please check your backend configuration.`
|
| 75 |
+
);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// Try to return the text as-is if not HTML
|
| 79 |
+
throw new Error(`Unexpected response type: ${contentType || 'unknown'}. Response: ${text.substring(0, 100)}`);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
const data = await response.json();
|
| 83 |
+
|
| 84 |
+
if (!response.ok) {
|
| 85 |
+
throw new Error(data.detail || data.error || data.message || `Request failed with status ${response.status}`);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
return data;
|
| 89 |
+
} catch (error) {
|
| 90 |
+
// Re-throw with better error message
|
| 91 |
+
if (error.name === 'AbortError') {
|
| 92 |
+
throw new Error(
|
| 93 |
+
`Backend server did not respond in time. ` +
|
| 94 |
+
`Please check that the backend is running at ${BACKEND_URL || 'localhost:8000'}.`
|
| 95 |
+
);
|
| 96 |
+
}
|
| 97 |
+
if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
|
| 98 |
+
throw new Error(
|
| 99 |
+
`Cannot connect to backend server. ` +
|
| 100 |
+
`${!isBackendConfigured() ? 'VITE_BACKEND_URL environment variable is not configured. ' : ''}` +
|
| 101 |
+
`Please check that the backend is running and accessible.`
|
| 102 |
+
);
|
| 103 |
+
}
|
| 104 |
+
throw error;
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/**
|
| 109 |
+
* Get authorization headers with GitHub token
|
| 110 |
+
* @returns {Object} Headers object with Authorization if token exists
|
| 111 |
+
*/
|
| 112 |
+
export function getAuthHeaders() {
|
| 113 |
+
const token = localStorage.getItem('github_token');
|
| 114 |
+
|
| 115 |
+
if (!token) {
|
| 116 |
+
return {};
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
return {
|
| 120 |
+
'Authorization': `Bearer ${token}`,
|
| 121 |
+
};
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/**
|
| 125 |
+
* Make an authenticated fetch request
|
| 126 |
+
* @param {string} url - API endpoint URL
|
| 127 |
+
* @param {Object} options - Fetch options
|
| 128 |
+
* @returns {Promise<Response>} Fetch response
|
| 129 |
+
*/
|
| 130 |
+
export async function authFetch(url, options = {}) {
|
| 131 |
+
const headers = {
|
| 132 |
+
...getAuthHeaders(),
|
| 133 |
+
...options.headers,
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
return fetch(url, {
|
| 137 |
+
...options,
|
| 138 |
+
headers,
|
| 139 |
+
});
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
/**
|
| 143 |
+
* Make an authenticated JSON request
|
| 144 |
+
* @param {string} url - API endpoint URL
|
| 145 |
+
* @param {Object} options - Fetch options
|
| 146 |
+
* @returns {Promise<any>} Parsed JSON response
|
| 147 |
+
*/
|
| 148 |
+
export async function authFetchJSON(url, options = {}) {
|
| 149 |
+
const headers = {
|
| 150 |
+
'Content-Type': 'application/json',
|
| 151 |
+
...getAuthHeaders(),
|
| 152 |
+
...options.headers,
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
const response = await fetch(url, {
|
| 156 |
+
...options,
|
| 157 |
+
headers,
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
if (!response.ok) {
|
| 161 |
+
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
|
| 162 |
+
throw new Error(error.detail || error.message || 'Request failed');
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
return response.json();
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// βββ Redesigned API Endpoints ββββββββββββββββββββββββββββ
|
| 169 |
+
|
| 170 |
+
/**
|
| 171 |
+
* Get normalized server status
|
| 172 |
+
*/
|
| 173 |
+
export async function fetchStatus() {
|
| 174 |
+
return safeFetchJSON(apiUrl("/api/status"));
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/**
|
| 178 |
+
* Get server status with retry (for startup when backend may still be booting).
|
| 179 |
+
* Retries up to `maxRetries` times with `delayMs` between attempts.
|
| 180 |
+
* @param {number} maxRetries - Maximum retry attempts (default: 8)
|
| 181 |
+
* @param {number} delayMs - Delay between retries in ms (default: 2000)
|
| 182 |
+
* @returns {Promise<any>} Parsed status response or null
|
| 183 |
+
*/
|
| 184 |
+
export async function fetchStatusWithRetry(maxRetries = 8, delayMs = 2000) {
|
| 185 |
+
for (let i = 0; i < maxRetries; i++) {
|
| 186 |
+
try {
|
| 187 |
+
return await safeFetchJSON(apiUrl("/api/status"), { timeout: 5000 });
|
| 188 |
+
} catch {
|
| 189 |
+
if (i < maxRetries - 1) {
|
| 190 |
+
await new Promise((r) => setTimeout(r, delayMs));
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
return null;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/**
|
| 198 |
+
* Get detailed provider status
|
| 199 |
+
*/
|
| 200 |
+
export async function fetchProviderStatus() {
|
| 201 |
+
return safeFetchJSON(apiUrl("/api/providers/status"));
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
/**
|
| 205 |
+
* Test a provider configuration
|
| 206 |
+
*/
|
| 207 |
+
export async function testProvider(providerConfig) {
|
| 208 |
+
return safeFetchJSON(apiUrl("/api/providers/test"), {
|
| 209 |
+
method: "POST",
|
| 210 |
+
headers: { "Content-Type": "application/json" },
|
| 211 |
+
body: JSON.stringify(providerConfig),
|
| 212 |
+
});
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/**
|
| 216 |
+
* Start a session by mode
|
| 217 |
+
*/
|
| 218 |
+
export async function startSession(sessionConfig) {
|
| 219 |
+
return safeFetchJSON(apiUrl("/api/session/start"), {
|
| 220 |
+
method: "POST",
|
| 221 |
+
headers: { "Content-Type": "application/json" },
|
| 222 |
+
body: JSON.stringify(sessionConfig),
|
| 223 |
+
});
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
/**
|
| 227 |
+
* Send a chat message (redesigned endpoint)
|
| 228 |
+
*/
|
| 229 |
+
export async function sendChatMessage(messageConfig) {
|
| 230 |
+
return safeFetchJSON(apiUrl("/api/chat/send"), {
|
| 231 |
+
method: "POST",
|
| 232 |
+
headers: { "Content-Type": "application/json" },
|
| 233 |
+
body: JSON.stringify(messageConfig),
|
| 234 |
+
});
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/**
|
| 238 |
+
* Get workspace summary
|
| 239 |
+
*/
|
| 240 |
+
export async function fetchWorkspaceSummary(folderPath) {
|
| 241 |
+
const query = folderPath ? `?folder_path=${encodeURIComponent(folderPath)}` : "";
|
| 242 |
+
return safeFetchJSON(apiUrl(`/api/workspace/summary${query}`));
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
/**
|
| 246 |
+
* Run security scan on workspace
|
| 247 |
+
*/
|
| 248 |
+
export async function scanWorkspace(path) {
|
| 249 |
+
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
| 250 |
+
return safeFetchJSON(apiUrl(`/api/security/scan-workspace${query}`));
|
| 251 |
+
}
|
frontend/utils/appInit.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* GitPilot App Initialization β Single Source of Truth.
|
| 3 |
+
*
|
| 4 |
+
* Best-practice bootstrap pattern:
|
| 5 |
+
* - Runs EXACTLY ONCE per page load (even with React StrictMode)
|
| 6 |
+
* - Two-phase strategy: fast ping β then parallel status fetch
|
| 7 |
+
* - Long retry budget for slow backends (WSL, HF Spaces cold start)
|
| 8 |
+
* - Shared result between App.jsx and LoginPage.jsx
|
| 9 |
+
* - No duplicate polling, no race conditions
|
| 10 |
+
*
|
| 11 |
+
* Phase 1 β Readiness probe (/api/ping):
|
| 12 |
+
* /api/ping is a zero-dependency endpoint that responds instantly
|
| 13 |
+
* once uvicorn is listening. We poll it with short timeouts and many
|
| 14 |
+
* retries to detect backend readiness WITHOUT wasting time on the
|
| 15 |
+
* heavy /api/status endpoint (which does GitHub API checks).
|
| 16 |
+
*
|
| 17 |
+
* Phase 2 β Data fetch (/api/status + /api/auth/status):
|
| 18 |
+
* Only after ping succeeds, we fetch the real status data in parallel.
|
| 19 |
+
* These can still be slow (GitHub API, LLM provider probes) but the
|
| 20 |
+
* user already sees the login page.
|
| 21 |
+
*
|
| 22 |
+
* API call budget per page load:
|
| 23 |
+
* - Best case: 2-3 Γ /api/ping + 1 Γ /api/status + 1 Γ /api/auth/status
|
| 24 |
+
* - Worst case: up to 30 Γ /api/ping + 1 + 1 (60s timeout budget)
|
| 25 |
+
*/
|
| 26 |
+
import { safeFetchJSON, apiUrl } from './api.js';
|
| 27 |
+
|
| 28 |
+
// Module-level singleton β survives React StrictMode double-mount
|
| 29 |
+
let _initPromise = null;
|
| 30 |
+
let _initResult = null;
|
| 31 |
+
|
| 32 |
+
const PING_MAX_ATTEMPTS = 30; // up to ~60s of readiness polling
|
| 33 |
+
const PING_INTERVAL_MS = 2000; // 2s between pings
|
| 34 |
+
const PING_TIMEOUT_MS = 4000; // each ping gives up after 4s
|
| 35 |
+
const STATUS_TIMEOUT_MS = 15000; // once ready, status fetch has 15s
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Wait for the backend to become reachable by polling /api/ping.
|
| 39 |
+
* This is a zero-dependency endpoint that responds instantly once
|
| 40 |
+
* uvicorn is listening β much faster than /api/status which does
|
| 41 |
+
* GitHub API checks.
|
| 42 |
+
*
|
| 43 |
+
* @returns {Promise<boolean>} true if backend became reachable, false otherwise
|
| 44 |
+
*/
|
| 45 |
+
async function waitForBackend() {
|
| 46 |
+
for (let i = 0; i < PING_MAX_ATTEMPTS; i++) {
|
| 47 |
+
try {
|
| 48 |
+
const result = await safeFetchJSON(
|
| 49 |
+
apiUrl('/api/ping'),
|
| 50 |
+
{ timeout: PING_TIMEOUT_MS }
|
| 51 |
+
);
|
| 52 |
+
if (result && (result.ok === true || result.service)) {
|
| 53 |
+
console.log(
|
| 54 |
+
`[initApp] β
Backend reachable after ${i + 1} ping attempt(s) ` +
|
| 55 |
+
`(${(i * PING_INTERVAL_MS) / 1000}s elapsed)`
|
| 56 |
+
);
|
| 57 |
+
return true;
|
| 58 |
+
}
|
| 59 |
+
} catch (err) {
|
| 60 |
+
// Silent β we expect failures during cold start
|
| 61 |
+
if (i === 0 || i % 5 === 0) {
|
| 62 |
+
console.log(
|
| 63 |
+
`[initApp] Waiting for backend... ` +
|
| 64 |
+
`attempt ${i + 1}/${PING_MAX_ATTEMPTS}`
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
// Wait before next ping (except after last attempt)
|
| 69 |
+
if (i < PING_MAX_ATTEMPTS - 1) {
|
| 70 |
+
await new Promise((r) => setTimeout(r, PING_INTERVAL_MS));
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
return false;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* Initialize the app.
|
| 78 |
+
* Phase 1: poll /api/ping until backend is reachable
|
| 79 |
+
* Phase 2: fetch /api/status and /api/auth/status in parallel
|
| 80 |
+
*
|
| 81 |
+
* @returns {Promise<{status: object|null, authMode: string, ready: boolean, error: string|null}>}
|
| 82 |
+
*/
|
| 83 |
+
export function initApp() {
|
| 84 |
+
if (_initPromise) {
|
| 85 |
+
return _initPromise;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
_initPromise = (async () => {
|
| 89 |
+
// ββ Phase 1: wait for backend to be reachable ββ
|
| 90 |
+
const reachable = await waitForBackend();
|
| 91 |
+
|
| 92 |
+
if (!reachable) {
|
| 93 |
+
console.error(
|
| 94 |
+
`[initApp] β Backend did not respond after ${PING_MAX_ATTEMPTS} ping attempts ` +
|
| 95 |
+
`(${(PING_MAX_ATTEMPTS * PING_INTERVAL_MS) / 1000}s). Giving up.`
|
| 96 |
+
);
|
| 97 |
+
_initResult = {
|
| 98 |
+
status: null,
|
| 99 |
+
authMode: 'device',
|
| 100 |
+
ready: false,
|
| 101 |
+
error: 'Backend did not become reachable. Please check that the server is running.',
|
| 102 |
+
};
|
| 103 |
+
return _initResult;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// ββ Phase 2: fetch real data in parallel ββ
|
| 107 |
+
try {
|
| 108 |
+
console.log('[initApp] Fetching /api/status + /api/auth/status in parallel...');
|
| 109 |
+
const [status, authStatus] = await Promise.all([
|
| 110 |
+
safeFetchJSON(apiUrl('/api/status'), { timeout: STATUS_TIMEOUT_MS }),
|
| 111 |
+
safeFetchJSON(apiUrl('/api/auth/status'), { timeout: STATUS_TIMEOUT_MS })
|
| 112 |
+
.catch(() => null),
|
| 113 |
+
]);
|
| 114 |
+
|
| 115 |
+
console.log('[initApp] β
Init complete');
|
| 116 |
+
_initResult = {
|
| 117 |
+
status,
|
| 118 |
+
authMode: (authStatus && authStatus.mode) || 'device',
|
| 119 |
+
ready: true,
|
| 120 |
+
error: null,
|
| 121 |
+
};
|
| 122 |
+
return _initResult;
|
| 123 |
+
} catch (err) {
|
| 124 |
+
// Backend was reachable via ping but status fetch failed
|
| 125 |
+
// Still return ready:true so UI can proceed with limited state
|
| 126 |
+
console.warn(
|
| 127 |
+
`[initApp] Status fetch failed after ping succeeded: ${err.message || err}. ` +
|
| 128 |
+
`Proceeding with limited state.`
|
| 129 |
+
);
|
| 130 |
+
_initResult = {
|
| 131 |
+
status: null,
|
| 132 |
+
authMode: 'device',
|
| 133 |
+
ready: true, // backend is up, just slow
|
| 134 |
+
error: null,
|
| 135 |
+
};
|
| 136 |
+
return _initResult;
|
| 137 |
+
}
|
| 138 |
+
})();
|
| 139 |
+
|
| 140 |
+
return _initPromise;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/**
|
| 144 |
+
* Get the cached init result (null if init hasn't completed yet).
|
| 145 |
+
*/
|
| 146 |
+
export function getInitResult() {
|
| 147 |
+
return _initResult;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/**
|
| 151 |
+
* Reset the init singleton. Call this only when you need to force
|
| 152 |
+
* a re-initialization (e.g., after the user manually clicks "Retry").
|
| 153 |
+
*/
|
| 154 |
+
export function resetInit() {
|
| 155 |
+
_initPromise = null;
|
| 156 |
+
_initResult = null;
|
| 157 |
+
}
|