github-actions[bot] commited on
Commit ·
6a84b44
0
Parent(s):
Deploy from 7af131fd
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +76 -0
- README.md +79 -0
- REPO_README.md +1241 -0
- deploy/huggingface/start.sh +54 -0
- frontend/.dockerignore +39 -0
- frontend/.env.example +7 -0
- frontend/.env.production.example +5 -0
- frontend/App.jsx +909 -0
- frontend/components/AddRepoModal.jsx +256 -0
- frontend/components/AssistantMessage.jsx +116 -0
- frontend/components/BranchPicker.jsx +398 -0
- frontend/components/ChatPanel.jsx +686 -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 +775 -0
- frontend/components/LoginPage.jsx +535 -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 +270 -0
- frontend/components/StreamingMessage.jsx +182 -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 +2825 -0
- frontend/utils/api.js +212 -0
- frontend/utils/ws.js +157 -0
- frontend/vite.config.js +22 -0
- gitpilot/__init__.py +5 -0
- gitpilot/__main__.py +5 -0
- gitpilot/_api_core.py +2382 -0
- gitpilot/a2a_adapter.py +560 -0
- gitpilot/agent_router.py +284 -0
- gitpilot/agent_teams.py +263 -0
Dockerfile
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# GitPilot - Hugging Face Spaces Dockerfile
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# Deploys GitPilot (FastAPI backend + React frontend) as a single container
|
| 5 |
+
# with OllaBridge Cloud integration for LLM inference.
|
| 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 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 26 |
+
git curl ca-certificates \
|
| 27 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 28 |
+
|
| 29 |
+
RUN useradd -m -u 1000 appuser && \
|
| 30 |
+
mkdir -p /app /tmp/gitpilot && \
|
| 31 |
+
chown -R appuser:appuser /app /tmp/gitpilot
|
| 32 |
+
|
| 33 |
+
WORKDIR /app
|
| 34 |
+
|
| 35 |
+
COPY pyproject.toml README.md ./
|
| 36 |
+
COPY gitpilot ./gitpilot
|
| 37 |
+
|
| 38 |
+
# Copy built frontend into gitpilot/web/
|
| 39 |
+
COPY --from=frontend-builder /build/dist/ ./gitpilot/web/
|
| 40 |
+
|
| 41 |
+
# Install Python dependencies (pip-only for reliability on HF Spaces)
|
| 42 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 43 |
+
pip install --no-cache-dir \
|
| 44 |
+
"fastapi>=0.111.0" \
|
| 45 |
+
"uvicorn[standard]>=0.30.0" \
|
| 46 |
+
"httpx>=0.27.0" \
|
| 47 |
+
"python-dotenv>=1.1.0" \
|
| 48 |
+
"typer>=0.12.0" \
|
| 49 |
+
"pydantic>=2.7.0" \
|
| 50 |
+
"rich>=13.0.0" \
|
| 51 |
+
"pyjwt[crypto]>=2.8.0" \
|
| 52 |
+
"litellm" \
|
| 53 |
+
"crewai>=0.76.9" \
|
| 54 |
+
"crewai-tools>=0.13.4" \
|
| 55 |
+
"anthropic>=0.39.0" && \
|
| 56 |
+
pip install --no-cache-dir -e .
|
| 57 |
+
|
| 58 |
+
COPY deploy/huggingface/start.sh /app/start.sh
|
| 59 |
+
RUN chmod +x /app/start.sh
|
| 60 |
+
|
| 61 |
+
ENV PORT=7860 \
|
| 62 |
+
HOST=0.0.0.0 \
|
| 63 |
+
HOME=/tmp \
|
| 64 |
+
GITPILOT_PROVIDER=ollabridge \
|
| 65 |
+
OLLABRIDGE_BASE_URL=https://ruslanmv-ollabridge.hf.space \
|
| 66 |
+
GITPILOT_OLLABRIDGE_MODEL=qwen2.5:1.5b \
|
| 67 |
+
CORS_ORIGINS="*" \
|
| 68 |
+
GITPILOT_CONFIG_DIR=/tmp/gitpilot
|
| 69 |
+
|
| 70 |
+
USER appuser
|
| 71 |
+
EXPOSE 7860
|
| 72 |
+
|
| 73 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
| 74 |
+
CMD curl -f http://localhost:7860/api/health || exit 1
|
| 75 |
+
|
| 76 |
+
CMD ["/app/start.sh"]
|
README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: GitPilot
|
| 3 |
+
emoji: "\U0001F916"
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: true
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Enterprise AI Coding Assistant for GitHub Repositories
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# GitPilot — Hugging Face Spaces
|
| 14 |
+
|
| 15 |
+
**Enterprise-grade AI coding assistant** for GitHub repositories with multi-LLM support, visual workflow insights, and intelligent code analysis.
|
| 16 |
+
|
| 17 |
+
## What This Does
|
| 18 |
+
|
| 19 |
+
This Space runs the full GitPilot stack:
|
| 20 |
+
1. **React Frontend** — Professional dark-theme UI with chat, file browser, and workflow visualization
|
| 21 |
+
2. **FastAPI Backend** — 80+ API endpoints for repository management, AI chat, planning, and execution
|
| 22 |
+
3. **Multi-Agent AI** — CrewAI orchestration with 7 switchable agent topologies
|
| 23 |
+
|
| 24 |
+
## LLM Providers
|
| 25 |
+
|
| 26 |
+
GitPilot connects to your favorite LLM provider. Configure in **Admin / LLM Settings**:
|
| 27 |
+
|
| 28 |
+
| Provider | Default | API Key Required |
|
| 29 |
+
|---|---|---|
|
| 30 |
+
| **OllaBridge Cloud** (default) | `qwen2.5:1.5b` | No |
|
| 31 |
+
| OpenAI | `gpt-4o-mini` | Yes |
|
| 32 |
+
| Anthropic Claude | `claude-sonnet-4-5` | Yes |
|
| 33 |
+
| Ollama (local) | `llama3` | No |
|
| 34 |
+
| Custom endpoint | Any model | Optional |
|
| 35 |
+
|
| 36 |
+
## Quick Start
|
| 37 |
+
|
| 38 |
+
1. Open the Space UI
|
| 39 |
+
2. Enter your **GitHub Token** (Settings -> GitHub)
|
| 40 |
+
3. Select a repository from the sidebar
|
| 41 |
+
4. Start chatting with your AI coding assistant
|
| 42 |
+
|
| 43 |
+
## API Endpoints
|
| 44 |
+
|
| 45 |
+
| Endpoint | Description |
|
| 46 |
+
|---|---|
|
| 47 |
+
| `GET /api/health` | Health check |
|
| 48 |
+
| `POST /api/chat/message` | Chat with AI assistant |
|
| 49 |
+
| `POST /api/chat/plan` | Generate implementation plan |
|
| 50 |
+
| `GET /api/repos` | List repositories |
|
| 51 |
+
| `GET /api/settings` | View/update settings |
|
| 52 |
+
| `GET /docs` | Interactive API docs (Swagger) |
|
| 53 |
+
|
| 54 |
+
## Connect to OllaBridge Cloud
|
| 55 |
+
|
| 56 |
+
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.
|
| 57 |
+
|
| 58 |
+
To use your own OllaBridge instance:
|
| 59 |
+
1. Go to **Admin / LLM Settings**
|
| 60 |
+
2. Select **OllaBridge** provider
|
| 61 |
+
3. Enter your OllaBridge URL and model
|
| 62 |
+
|
| 63 |
+
## Environment Variables
|
| 64 |
+
|
| 65 |
+
Configure via HF Spaces secrets:
|
| 66 |
+
|
| 67 |
+
| Variable | Description | Default |
|
| 68 |
+
|---|---|---|
|
| 69 |
+
| `GITPILOT_PROVIDER` | LLM provider | `ollabridge` |
|
| 70 |
+
| `OLLABRIDGE_BASE_URL` | OllaBridge Cloud URL | `https://ruslanmv-ollabridge.hf.space` |
|
| 71 |
+
| `GITHUB_TOKEN` | GitHub personal access token | - |
|
| 72 |
+
| `OPENAI_API_KEY` | OpenAI API key (if using OpenAI) | - |
|
| 73 |
+
| `ANTHROPIC_API_KEY` | Anthropic API key (if using Claude) | - |
|
| 74 |
+
|
| 75 |
+
## Links
|
| 76 |
+
|
| 77 |
+
- [GitPilot Repository](https://github.com/ruslanmv/gitpilot)
|
| 78 |
+
- [OllaBridge Cloud](https://huggingface.co/spaces/ruslanmv/ollabridge)
|
| 79 |
+
- [Documentation](https://github.com/ruslanmv/gitpilot#readme)
|
REPO_README.md
ADDED
|
@@ -0,0 +1,1241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GitPilot
|
| 2 |
+
|
| 3 |
+
<div align="center">
|
| 4 |
+
|
| 5 |
+
**🚀 The AI Coding Companion That Understands Your GitHub Repositories**
|
| 6 |
+
|
| 7 |
+
[](https://pypi.org/project/gitcopilot/)
|
| 8 |
+
[](https://www.python.org/downloads/)
|
| 9 |
+
[](https://opensource.org/licenses/MIT)
|
| 10 |
+
[](https://github.com/ruslanmv/gitpilot)
|
| 11 |
+
|
| 12 |
+
[Installation](#-installation) • [Quick Start](#-quick-start) • [Example Usage](#-example-usage) • [Documentation](#-complete-workflow-guide) • [Contributing](#-contributing)
|
| 13 |
+
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## ⭐ Star Us on GitHub!
|
| 19 |
+
|
| 20 |
+
**If GitPilot saves you time or helps your projects, please give us a star!** ⭐
|
| 21 |
+
|
| 22 |
+
Your support helps us:
|
| 23 |
+
- 🚀 Build new features faster
|
| 24 |
+
- 🐛 Fix bugs and improve stability
|
| 25 |
+
- 📚 Create better documentation
|
| 26 |
+
- 🌍 Grow the community
|
| 27 |
+
|
| 28 |
+
**[⭐ Click here to star GitPilot on GitHub](https://github.com/ruslanmv/gitpilot)** — it takes just 2 seconds and means the world to us! 💙
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## 🌟 What is GitPilot?
|
| 33 |
+
|
| 34 |
+
GitPilot is a **production-ready agentic AI assistant** that acts as your intelligent coding companion for GitHub repositories. Unlike copy-paste coding assistants, GitPilot:
|
| 35 |
+
|
| 36 |
+
* **🧠 Understands your entire codebase** – Analyzes project structure, file relationships, and cross-repository dependencies
|
| 37 |
+
* **📋 Shows clear plans before executing** – Always presents an "Answer + Action Plan" with structured file operations (CREATE/MODIFY/DELETE/READ)
|
| 38 |
+
* **🔄 Manages multiple LLM providers** – Seamlessly switch between OpenAI, Claude, Watsonx, and Ollama with smart model routing
|
| 39 |
+
* **👁️ Visualizes agent workflows** – See exactly how the multi-agent system thinks and operates
|
| 40 |
+
* **🔗 Works locally and with GitHub** – Full local file editing, terminal execution, and GitHub integration
|
| 41 |
+
* **🔌 Extensible ecosystem** – MCP server connections, plugin marketplace, /command skills, and IDE extensions
|
| 42 |
+
* **🛡️ Built-in security scanning** – AI-powered vulnerability detection beyond traditional SAST tools
|
| 43 |
+
* **🤖 Self-improving agents** – Learns from outcomes and adapts strategies per project over time
|
| 44 |
+
|
| 45 |
+
**Built with CrewAI, FastAPI, and React** — GitPilot combines the power of multi-agent AI with a beautiful, modern web interface and the extensibility of a platform.
|
| 46 |
+
|
| 47 |
+

|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## ✨ Example Usage
|
| 52 |
+
|
| 53 |
+
### Installation
|
| 54 |
+
|
| 55 |
+
```bash
|
| 56 |
+
# Install from PyPI
|
| 57 |
+
pip install gitcopilot
|
| 58 |
+
|
| 59 |
+
# Set your GitHub token
|
| 60 |
+
export GITPILOT_GITHUB_TOKEN="ghp_your_token_here"
|
| 61 |
+
|
| 62 |
+
# Set your LLM API key (choose one)
|
| 63 |
+
export OPENAI_API_KEY="sk-..."
|
| 64 |
+
# or
|
| 65 |
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
| 66 |
+
# or
|
| 67 |
+
export WATSONX_API_KEY="your_api_key"
|
| 68 |
+
export WATSONX_PROJECT_ID="your_project_id"
|
| 69 |
+
|
| 70 |
+
# Launch GitPilot
|
| 71 |
+
gitpilot
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
### Basic Workflow
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
# 1. Start GitPilot (opens in browser automatically)
|
| 78 |
+
gitpilot
|
| 79 |
+
|
| 80 |
+
# 2. Select a repository from the sidebar
|
| 81 |
+
# → Search your GitHub repos
|
| 82 |
+
# → Click to connect
|
| 83 |
+
|
| 84 |
+
# 3. Ask GitPilot to make changes
|
| 85 |
+
# Example: "Add a new API endpoint for user login with JWT authentication"
|
| 86 |
+
|
| 87 |
+
# 4. Review the generated plan
|
| 88 |
+
# ✓ Answer: Explanation of what will be done
|
| 89 |
+
# ✓ Action Plan: Structured steps with file operations
|
| 90 |
+
# - CREATE: src/api/auth.py
|
| 91 |
+
# - MODIFY: src/routes/index.py
|
| 92 |
+
# - READ: README.md (for analysis)
|
| 93 |
+
# - DELETE: deprecated/old_auth.py
|
| 94 |
+
|
| 95 |
+
# 5. Approve & Execute
|
| 96 |
+
# → GitPilot applies changes to your repository
|
| 97 |
+
# → View execution log with detailed results
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
### Real-World Examples
|
| 101 |
+
|
| 102 |
+
**Add a new feature:**
|
| 103 |
+
```
|
| 104 |
+
User: Add a rate limiter to all API endpoints to prevent abuse
|
| 105 |
+
|
| 106 |
+
GitPilot Response:
|
| 107 |
+
Answer: I'll implement rate limiting using a middleware approach with Redis
|
| 108 |
+
for distributed rate limiting support.
|
| 109 |
+
|
| 110 |
+
Action Plan:
|
| 111 |
+
Step 1: Create rate limiter middleware
|
| 112 |
+
- CREATE: src/middleware/rate_limiter.py
|
| 113 |
+
Step 2: Add Redis client configuration
|
| 114 |
+
- MODIFY: src/config.py
|
| 115 |
+
Step 3: Apply middleware to all routes
|
| 116 |
+
- MODIFY: src/main.py
|
| 117 |
+
Step 4: Add rate limit tests
|
| 118 |
+
- CREATE: tests/test_rate_limiter.py
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
**Refactor existing code:**
|
| 122 |
+
```
|
| 123 |
+
User: Refactor the user authentication to use async/await instead of callbacks
|
| 124 |
+
|
| 125 |
+
GitPilot Response:
|
| 126 |
+
Answer: I'll convert all authentication functions to async/await pattern,
|
| 127 |
+
update the database queries, and ensure all tests pass.
|
| 128 |
+
|
| 129 |
+
Action Plan:
|
| 130 |
+
Step 1: Analyze current authentication implementation
|
| 131 |
+
- READ: src/services/auth_service.py
|
| 132 |
+
- READ: src/routes/auth.py
|
| 133 |
+
Step 2: Convert auth service to async
|
| 134 |
+
- MODIFY: src/services/auth_service.py (12 functions)
|
| 135 |
+
Step 3: Update route handlers
|
| 136 |
+
- MODIFY: src/routes/auth.py
|
| 137 |
+
Step 4: Migrate database calls
|
| 138 |
+
- MODIFY: src/db/user_repository.py
|
| 139 |
+
Step 5: Update unit tests
|
| 140 |
+
- MODIFY: tests/test_auth_service.py
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
**Fix a bug:**
|
| 144 |
+
```
|
| 145 |
+
User: Fix the bug where empty email validation returns a 500 error instead of 400
|
| 146 |
+
|
| 147 |
+
GitPilot Response:
|
| 148 |
+
Answer: I'll add proper input validation with Pydantic models and return
|
| 149 |
+
appropriate HTTP 400 errors with helpful messages.
|
| 150 |
+
|
| 151 |
+
Action Plan:
|
| 152 |
+
Step 1: Add Pydantic validation model
|
| 153 |
+
- CREATE: src/models/user_input.py
|
| 154 |
+
Step 2: Update login endpoint with validation
|
| 155 |
+
- MODIFY: src/routes/auth.py
|
| 156 |
+
Step 3: Add validation error handler
|
| 157 |
+
- MODIFY: src/main.py
|
| 158 |
+
Step 4: Add test cases for validation
|
| 159 |
+
- MODIFY: tests/test_validation.py
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
## 🎯 Key Features
|
| 165 |
+
|
| 166 |
+
### 1. **Answer + Action Plan UX**
|
| 167 |
+
Every AI response is structured into two clear sections:
|
| 168 |
+
- **Answer**: Natural language explanation of what will be done and why
|
| 169 |
+
- **Action Plan**: Structured list of steps with explicit file operations:
|
| 170 |
+
- 🟢 **CREATE** – New files to be added
|
| 171 |
+
- 🔵 **MODIFY** – Existing files to be changed
|
| 172 |
+
- 🔴 **DELETE** – Files to be removed
|
| 173 |
+
- 📖 **READ** – Files to analyze (no changes)
|
| 174 |
+
|
| 175 |
+
See exactly what will happen before approving execution!
|
| 176 |
+
|
| 177 |
+
### 2. **Full Multi-LLM Support with Smart Routing** ✨
|
| 178 |
+
All four LLM providers are fully operational and tested:
|
| 179 |
+
- ✅ **OpenAI** – GPT-4o, GPT-4o-mini, GPT-4-turbo
|
| 180 |
+
- ✅ **Claude (Anthropic)** – Claude 4.5 Sonnet, Claude 3 Opus
|
| 181 |
+
- ✅ **IBM Watsonx.ai** – Llama 3.3, Granite 3.x models
|
| 182 |
+
- ✅ **Ollama** – Local models (Llama3, Mistral, CodeLlama, Phi3)
|
| 183 |
+
|
| 184 |
+
Switch between providers seamlessly through the Admin UI without restart. The **smart model router** automatically selects the optimal model (fast/balanced/powerful) for each task based on complexity analysis, keeping costs low while maintaining quality where it matters.
|
| 185 |
+
|
| 186 |
+
### 3. **Local Workspace & Terminal Execution** 🆕
|
| 187 |
+
GitPilot now works directly on your local filesystem — just like Claude Code:
|
| 188 |
+
- **Local file editing** – Read, write, search, and delete files in a sandboxed workspace
|
| 189 |
+
- **Terminal execution** – Run shell commands (`npm test`, `make build`, `python -m pytest`) with timeout and output capping
|
| 190 |
+
- **Git operations** – Commit, push, diff, stash, merge — all from the workspace
|
| 191 |
+
- **Path traversal protection** – All file operations are sandboxed to prevent escaping the workspace directory
|
| 192 |
+
|
| 193 |
+
### 4. **Session Management & Checkpoints** 🆕
|
| 194 |
+
Persistent sessions that survive restarts and support time-travel:
|
| 195 |
+
- **Resume any session** – Pick up exactly where you left off
|
| 196 |
+
- **Fork sessions** – Branch a conversation to try different approaches
|
| 197 |
+
- **Checkpoint & rewind** – Snapshot workspace state at key moments and roll back if something goes wrong
|
| 198 |
+
- **CI/CD headless mode** – Run GitPilot non-interactively via `gitpilot run --headless` for automation pipelines
|
| 199 |
+
|
| 200 |
+
### 5. **Hook System & Permissions** 🆕
|
| 201 |
+
Fine-grained control over what agents can do:
|
| 202 |
+
- **Lifecycle hooks** – Register shell commands that fire on events like `pre_commit`, `post_edit`, or `pre_push` — blocking hooks can veto risky actions
|
| 203 |
+
- **Three permission modes** – `normal` (ask before risky ops), `plan` (read-only), `auto` (approve everything)
|
| 204 |
+
- **Path-based blocking** – Prevent agents from touching `.env`, `*.pem`, or any sensitive file pattern
|
| 205 |
+
|
| 206 |
+
### 6. **Project Context Memory (GITPILOT.md)** 🆕
|
| 207 |
+
The equivalent of Claude Code's `CLAUDE.md` — teach your agents project-specific conventions:
|
| 208 |
+
- Create `.gitpilot/GITPILOT.md` with your code style, testing, and commit message rules
|
| 209 |
+
- Add modular rules in `.gitpilot/rules/*.md`
|
| 210 |
+
- Agents automatically learn patterns from session outcomes and store them in `.gitpilot/memory.json`
|
| 211 |
+
|
| 212 |
+
### 7. **MCP Server Connections** 🆕
|
| 213 |
+
Connect GitPilot to any MCP (Model Context Protocol) server — databases, Slack, Figma, Sentry, and more:
|
| 214 |
+
- **stdio, HTTP, and SSE transports** – Connect to local subprocess servers or remote endpoints
|
| 215 |
+
- **JSON-RPC 2.0** – Full protocol compliance with tool discovery and invocation
|
| 216 |
+
- **Auto-wrap as CrewAI tools** – MCP tools are automatically available to all agents
|
| 217 |
+
- Configure servers in `.gitpilot/mcp.json` with environment variable expansion
|
| 218 |
+
|
| 219 |
+
### 8. **Plugin Marketplace & /Command Skills** 🆕
|
| 220 |
+
Extend GitPilot with community plugins and project-specific skills:
|
| 221 |
+
- **Install plugins** from git URLs or local paths — each plugin can provide skills, hooks, and MCP configs
|
| 222 |
+
- **Define skills** as markdown files in `.gitpilot/skills/*.md` with YAML front-matter and template variables
|
| 223 |
+
- **Invoke skills** via `/command` syntax in chat or the CLI: `gitpilot skill review`
|
| 224 |
+
- **Auto-trigger skills** based on context patterns (e.g., auto-run lint after edits)
|
| 225 |
+
|
| 226 |
+
### 9. **Vision & Image Analysis** 🆕
|
| 227 |
+
Multimodal capabilities powered by OpenAI, Anthropic, and Ollama vision models:
|
| 228 |
+
- **Analyse screenshots** – Describe UI bugs, extract text via OCR, review design mockups
|
| 229 |
+
- **Compare before/after** – Detect visual regressions between two screenshots
|
| 230 |
+
- **Base64 encoding** with format validation (PNG, JPG, GIF, WebP, BMP, SVG) and 20 MB size limit
|
| 231 |
+
|
| 232 |
+
### 10. **AI-Powered Security Scanner** 🆕
|
| 233 |
+
Go beyond traditional SAST tools with pattern-based and context-aware vulnerability detection:
|
| 234 |
+
- **Secret detection** – AWS keys, GitHub tokens, JWTs, Slack tokens, private keys, passwords in code
|
| 235 |
+
- **Code vulnerability patterns** – SQL injection, command injection, XSS, SSRF, path traversal, weak crypto, insecure CORS, disabled SSL verification
|
| 236 |
+
- **Diff scanning** – Scan only added lines in a git diff for CI/CD integration
|
| 237 |
+
- **CWE mapping** – Every finding links to its CWE identifier with actionable recommendations
|
| 238 |
+
- **CLI command** – `gitpilot scan /path/to/repo` with confidence thresholds and severity summaries
|
| 239 |
+
|
| 240 |
+
### 11. **Predictive Workflow Engine** 🆕
|
| 241 |
+
Proactive suggestions based on what you just did:
|
| 242 |
+
- After merging a PR → suggest updating the changelog
|
| 243 |
+
- After test failure → suggest debugging approach
|
| 244 |
+
- After dependency update → suggest running full test suite
|
| 245 |
+
- After editing security-sensitive code → suggest security review
|
| 246 |
+
- 8 built-in trigger rules with configurable cooldowns and relevance scoring
|
| 247 |
+
- Add custom prediction rules for your own workflows
|
| 248 |
+
|
| 249 |
+
### 12. **Parallel Multi-Agent Teams** 🆕
|
| 250 |
+
Split large tasks across multiple agents working simultaneously:
|
| 251 |
+
- **Task decomposition** – Automatically split complex tasks into independent subtasks
|
| 252 |
+
- **Parallel execution** – Agents work concurrently via `asyncio.gather`, each on its own git worktree
|
| 253 |
+
- **Conflict detection** – Merging detects file-level conflicts when multiple agents touch the same files
|
| 254 |
+
- **Custom or auto-generated plans** — provide your own subtask descriptions or let the engine split evenly
|
| 255 |
+
|
| 256 |
+
### 13. **Self-Improving Agents** 🆕
|
| 257 |
+
GitPilot learns from every interaction and becomes specialised to each project over time:
|
| 258 |
+
- **Outcome evaluation** – Checks signals like tests_passed, pr_approved, error_fixed
|
| 259 |
+
- **Pattern extraction** – Generates natural-language insights from successes and failures
|
| 260 |
+
- **Per-repo persistence** – Learned strategies are stored in JSON and loaded in future sessions
|
| 261 |
+
- **Preferred style tracking** – Record project-specific code style preferences that agents follow
|
| 262 |
+
|
| 263 |
+
### 14. **Cross-Repository Intelligence** 🆕
|
| 264 |
+
Understand dependencies and impact across your entire codebase:
|
| 265 |
+
- **Dependency graph construction** – Parses `package.json`, `requirements.txt`, `pyproject.toml`, and `go.mod`
|
| 266 |
+
- **Impact analysis** – BFS traversal to find all affected repos when updating a dependency, with risk assessment (low/medium/high/critical)
|
| 267 |
+
- **Shared convention detection** – Find common config patterns across multiple repos
|
| 268 |
+
- **Migration planning** – Generate step-by-step migration plans when replacing one package with another
|
| 269 |
+
|
| 270 |
+
### 15. **Natural Language Database Queries** 🆕
|
| 271 |
+
Query your project's databases using plain English through MCP connections:
|
| 272 |
+
- **NL-to-SQL translation** – Rule-based translation with schema-aware table and column matching
|
| 273 |
+
- **Safety validation** – Read-only mode blocks INSERT/UPDATE/DELETE; read-write mode blocks DROP/TRUNCATE
|
| 274 |
+
- **Query explanation** – Translate SQL back to human-readable descriptions
|
| 275 |
+
- **Tabular output** – Results formatted as plain-text tables for CLI or API consumption
|
| 276 |
+
|
| 277 |
+
### 16. **IDE Extensions** 🆕
|
| 278 |
+
Use GitPilot from your favourite editor:
|
| 279 |
+
- **VS Code extension** – Sidebar chat panel, inline actions, keybindings (Ctrl+Shift+G), skill invocation, and server configuration
|
| 280 |
+
- Connects to the GitPilot API server — all the same agents and tools available from within your editor
|
| 281 |
+
|
| 282 |
+
### 17. **Topology Registry — Switchable Agent Architectures** 🆕
|
| 283 |
+
Seven pre-wired agent topologies that control routing, execution, and visualization in one place:
|
| 284 |
+
- **System architectures** (Default fan-out, GitPilot Code ReAct loop) and **task pipelines** (Feature Builder, Bug Hunter, Code Inspector, Architect Mode, Quick Fix) — each with its own agent roster, execution style, and flow graph
|
| 285 |
+
- **Zero-latency classifier** selects the best topology from the user's message without an extra LLM call, while the Flow Viewer dropdown lets you switch or pin a topology at any time
|
| 286 |
+
|
| 287 |
+
### 18. **Agent Flow Viewer**
|
| 288 |
+
Interactive visual representation of the CrewAI multi-agent system using ReactFlow:
|
| 289 |
+
- **Repository Explorer** – Thoroughly explores codebase structure
|
| 290 |
+
- **Refactor Planner** – Creates safe, step-by-step plans with verified file operations
|
| 291 |
+
- **Code Writer** – Implements approved changes with AI-generated content
|
| 292 |
+
- **Code Reviewer** – Reviews for quality and safety
|
| 293 |
+
- **Local Editor & Terminal** – Direct file editing and shell execution agents
|
| 294 |
+
- **GitHub API Tools** – Manages file operations and commits
|
| 295 |
+
|
| 296 |
+
### 19. **Admin / Settings Console**
|
| 297 |
+
Full-featured LLM provider configuration with:
|
| 298 |
+
- **OpenAI** – API key, model selection, optional base URL
|
| 299 |
+
- **Claude** – API key, model selection (Claude 4.5 Sonnet recommended)
|
| 300 |
+
- **IBM Watsonx.ai** – API key, project ID, model selection, regional URLs
|
| 301 |
+
- **Ollama** – Base URL (local), model selection
|
| 302 |
+
|
| 303 |
+
Settings are persisted to `~/.gitpilot/settings.json` and survive restarts.
|
| 304 |
+
|
| 305 |
+
### 20. **MCP / A2A Agent Integration (ContextForge Compatible)**
|
| 306 |
+
GitPilot can optionally run as an **A2A agent server** that can be **imported by URL** into **MCP ContextForge (MCP Gateway)** and exposed as MCP tools. This makes GitPilot usable not only from the web UI, but also from:
|
| 307 |
+
- MCP-enabled IDEs and CLIs
|
| 308 |
+
- automation pipelines (CI/CD)
|
| 309 |
+
- other AI agents orchestrated by an MCP gateway
|
| 310 |
+
|
| 311 |
+
A2A mode is **feature-flagged** and does **not** affect the existing UI/API unless enabled.
|
| 312 |
+
|
| 313 |
+
---
|
| 314 |
+
|
| 315 |
+
## 🚀 Installation
|
| 316 |
+
|
| 317 |
+
### From PyPI (Recommended)
|
| 318 |
+
|
| 319 |
+
```bash
|
| 320 |
+
pip install gitcopilot
|
| 321 |
+
```
|
| 322 |
+
|
| 323 |
+
### From Source
|
| 324 |
+
|
| 325 |
+
```bash
|
| 326 |
+
# Clone the repository
|
| 327 |
+
git clone https://github.com/ruslanmv/gitpilot.git
|
| 328 |
+
cd gitpilot
|
| 329 |
+
|
| 330 |
+
# Install dependencies
|
| 331 |
+
make install
|
| 332 |
+
|
| 333 |
+
# Build frontend
|
| 334 |
+
make frontend-build
|
| 335 |
+
|
| 336 |
+
# Run GitPilot
|
| 337 |
+
gitpilot
|
| 338 |
+
```
|
| 339 |
+
|
| 340 |
+
### Using Docker (Coming Soon)
|
| 341 |
+
|
| 342 |
+
```bash
|
| 343 |
+
docker pull ruslanmv/gitpilot
|
| 344 |
+
docker run -p 8000:8000 -e GITHUB_TOKEN=your_token ruslanmv/gitpilot
|
| 345 |
+
```
|
| 346 |
+
|
| 347 |
+
---
|
| 348 |
+
|
| 349 |
+
## 🚀 Quick Start
|
| 350 |
+
|
| 351 |
+
### Prerequisites
|
| 352 |
+
|
| 353 |
+
- **Python 3.11+**
|
| 354 |
+
- **GitHub Personal Access Token** (with `repo` scope)
|
| 355 |
+
- **API key** for at least one LLM provider (OpenAI, Claude, Watsonx, or Ollama)
|
| 356 |
+
|
| 357 |
+
### 1. Configure GitHub Access
|
| 358 |
+
|
| 359 |
+
Create a **GitHub Personal Access Token** at https://github.com/settings/tokens with `repo` scope:
|
| 360 |
+
|
| 361 |
+
```bash
|
| 362 |
+
export GITPILOT_GITHUB_TOKEN="ghp_XXXXXXXXXXXXXXXXXXXX"
|
| 363 |
+
# or
|
| 364 |
+
export GITHUB_TOKEN="ghp_XXXXXXXXXXXXXXXXXXXX"
|
| 365 |
+
```
|
| 366 |
+
|
| 367 |
+
### 2. Configure LLM Provider
|
| 368 |
+
|
| 369 |
+
You can configure providers via the web UI's Admin/Settings page, or set environment variables:
|
| 370 |
+
|
| 371 |
+
#### OpenAI
|
| 372 |
+
```bash
|
| 373 |
+
export OPENAI_API_KEY="sk-..."
|
| 374 |
+
export GITPILOT_OPENAI_MODEL="gpt-4o-mini" # optional
|
| 375 |
+
```
|
| 376 |
+
|
| 377 |
+
#### Claude (Anthropic)
|
| 378 |
+
```bash
|
| 379 |
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
| 380 |
+
export GITPILOT_CLAUDE_MODEL="claude-3-5-sonnet-20241022" # optional
|
| 381 |
+
```
|
| 382 |
+
|
| 383 |
+
**Note:** Claude integration now includes automatic environment variable configuration for seamless CrewAI compatibility.
|
| 384 |
+
|
| 385 |
+
#### IBM Watsonx.ai
|
| 386 |
+
```bash
|
| 387 |
+
export WATSONX_API_KEY="your-watsonx-api-key"
|
| 388 |
+
export WATSONX_PROJECT_ID="your-project-id" # Required!
|
| 389 |
+
export WATSONX_BASE_URL="https://us-south.ml.cloud.ibm.com" # optional, region-specific
|
| 390 |
+
export GITPILOT_WATSONX_MODEL="ibm/granite-3-8b-instruct" # optional
|
| 391 |
+
```
|
| 392 |
+
|
| 393 |
+
**Note:** Watsonx integration requires both API key and Project ID for proper authentication.
|
| 394 |
+
|
| 395 |
+
#### Ollama (Local Models)
|
| 396 |
+
```bash
|
| 397 |
+
export OLLAMA_BASE_URL="http://localhost:11434"
|
| 398 |
+
export GITPILOT_OLLAMA_MODEL="llama3" # optional
|
| 399 |
+
```
|
| 400 |
+
|
| 401 |
+
### 3. Run GitPilot
|
| 402 |
+
|
| 403 |
+
```bash
|
| 404 |
+
gitpilot
|
| 405 |
+
```
|
| 406 |
+
|
| 407 |
+
This will:
|
| 408 |
+
1. Start the FastAPI backend on `http://127.0.0.1:8000`
|
| 409 |
+
2. Serve the web UI at the root URL
|
| 410 |
+
3. Open your default browser automatically
|
| 411 |
+
|
| 412 |
+
Alternative commands:
|
| 413 |
+
```bash
|
| 414 |
+
# Custom host and port
|
| 415 |
+
gitpilot serve --host 0.0.0.0 --port 8000
|
| 416 |
+
|
| 417 |
+
# API only (no browser auto-open)
|
| 418 |
+
gitpilot-api
|
| 419 |
+
|
| 420 |
+
# Headless mode for CI/CD
|
| 421 |
+
gitpilot run --repo owner/repo --message "fix the login bug" --headless
|
| 422 |
+
|
| 423 |
+
# Initialize project conventions
|
| 424 |
+
gitpilot init
|
| 425 |
+
|
| 426 |
+
# Security scan
|
| 427 |
+
gitpilot scan /path/to/repo
|
| 428 |
+
|
| 429 |
+
# Get proactive suggestions
|
| 430 |
+
gitpilot predict "Tests failed in auth module"
|
| 431 |
+
|
| 432 |
+
# Manage plugins
|
| 433 |
+
gitpilot plugin install https://github.com/example/my-plugin.git
|
| 434 |
+
gitpilot plugin list
|
| 435 |
+
|
| 436 |
+
# List and invoke skills
|
| 437 |
+
gitpilot skill list
|
| 438 |
+
gitpilot skill review
|
| 439 |
+
|
| 440 |
+
# List available models
|
| 441 |
+
gitpilot list-models --provider openai
|
| 442 |
+
|
| 443 |
+
# Using make (for development)
|
| 444 |
+
make run
|
| 445 |
+
```
|
| 446 |
+
|
| 447 |
+
---
|
| 448 |
+
|
| 449 |
+
## 🔌 MCP / A2A Integration 🆕
|
| 450 |
+
|
| 451 |
+
GitPilot can run as a **self-contained MCP server** with A2A endpoints. You can use it standalone or optionally integrate with **MCP ContextForge gateway** for advanced multi-agent workflows.
|
| 452 |
+
|
| 453 |
+
### Two deployment modes
|
| 454 |
+
1. **Simple MCP Server** (recommended for most users)
|
| 455 |
+
- Just GitPilot with A2A endpoints enabled
|
| 456 |
+
- Direct MCP client connections
|
| 457 |
+
- Use `make mcp` to deploy
|
| 458 |
+
|
| 459 |
+
2. **Full MCP Gateway** (optional - with ContextForge)
|
| 460 |
+
- Complete MCP ContextForge infrastructure
|
| 461 |
+
- Advanced gateway features and orchestration
|
| 462 |
+
- Use `make gateway` to deploy
|
| 463 |
+
|
| 464 |
+
### Why this matters
|
| 465 |
+
- **Direct MCP access**: Use GitPilot from MCP-enabled IDEs/CLIs without additional infrastructure
|
| 466 |
+
- **No UI required**: Call GitPilot programmatically from automation pipelines
|
| 467 |
+
- **Composable**: GitPilot can act as the "repo editor agent" inside larger multi-agent workflows
|
| 468 |
+
- **Gateway optional**: Full ContextForge gateway only needed for advanced orchestration scenarios
|
| 469 |
+
|
| 470 |
+
### Enable A2A mode (does not change existing behavior)
|
| 471 |
+
A2A endpoints are disabled by default. Enable them using environment variables:
|
| 472 |
+
|
| 473 |
+
```bash
|
| 474 |
+
export GITPILOT_ENABLE_A2A=true
|
| 475 |
+
|
| 476 |
+
# Recommended: protect the A2A endpoint (gateway will inject this header)
|
| 477 |
+
export GITPILOT_A2A_REQUIRE_AUTH=true
|
| 478 |
+
export GITPILOT_A2A_SHARED_SECRET="REPLACE_WITH_LONG_RANDOM_SECRET"
|
| 479 |
+
```
|
| 480 |
+
|
| 481 |
+
Then start GitPilot as usual:
|
| 482 |
+
|
| 483 |
+
```bash
|
| 484 |
+
gitpilot serve --host 0.0.0.0 --port 8000
|
| 485 |
+
```
|
| 486 |
+
|
| 487 |
+
### A2A endpoints
|
| 488 |
+
|
| 489 |
+
When enabled, GitPilot exposes:
|
| 490 |
+
|
| 491 |
+
* `POST /a2a/invoke` – A2A invoke endpoint (JSON-RPC + envelope fallback)
|
| 492 |
+
* `POST /a2a/v1/invoke` – Versioned alias (recommended for gateways)
|
| 493 |
+
* `GET /a2a/health` – Health check
|
| 494 |
+
* `GET /a2a/manifest` – Capability discovery (methods + auth hints)
|
| 495 |
+
|
| 496 |
+
### Auth model (gateway-friendly)
|
| 497 |
+
|
| 498 |
+
GitPilot supports a gateway-friendly model:
|
| 499 |
+
|
| 500 |
+
* **Gateway → GitPilot authentication**:
|
| 501 |
+
* `X-A2A-Secret: <shared_secret>` *(recommended)*
|
| 502 |
+
or
|
| 503 |
+
* `Authorization: Bearer <shared_secret>`
|
| 504 |
+
|
| 505 |
+
* **GitHub auth (optional)**:
|
| 506 |
+
* `X-Github-Token: <token>`
|
| 507 |
+
*(recommended when not using a GitHub App internally)*
|
| 508 |
+
|
| 509 |
+
> Tip: Avoid sending GitHub tokens in request bodies. Prefer headers to reduce accidental logging exposure.
|
| 510 |
+
|
| 511 |
+
### Register GitPilot in MCP ContextForge (Optional - Gateway Only)
|
| 512 |
+
|
| 513 |
+
**Note:** This section is only needed if you're using the **full MCP ContextForge gateway** (`make gateway`). If you're using the simple MCP server (`make mcp`), you can connect MCP clients directly to GitPilot's A2A endpoints.
|
| 514 |
+
|
| 515 |
+
Once the full gateway stack is deployed, register GitPilot as an A2A agent in ContextForge by providing the endpoint URL (note trailing `/` is recommended for JSON-RPC mode):
|
| 516 |
+
|
| 517 |
+
* Endpoint URL:
|
| 518 |
+
* `https://YOUR_GITPILOT_DOMAIN/a2a/v1/invoke/`
|
| 519 |
+
* Agent type:
|
| 520 |
+
* `jsonrpc`
|
| 521 |
+
* Inject auth header:
|
| 522 |
+
* `X-A2A-Secret: <shared_secret>`
|
| 523 |
+
|
| 524 |
+
After registration, MCP clients connected to the gateway will see GitPilot as an MCP tool (name depends on the gateway configuration).
|
| 525 |
+
|
| 526 |
+
### Supported A2A methods (stable contract)
|
| 527 |
+
|
| 528 |
+
GitPilot exposes a small, composable set of methods:
|
| 529 |
+
|
| 530 |
+
* `repo.connect` – validate access and return repo metadata
|
| 531 |
+
* `repo.tree` – list repository tree / files
|
| 532 |
+
* `repo.read` – read a file
|
| 533 |
+
* `repo.write` – create/update a file (commit)
|
| 534 |
+
* `plan.generate` – generate an action plan for a goal
|
| 535 |
+
* `plan.execute` – execute an approved plan
|
| 536 |
+
* `repo.search` *(optional)* – search repositories
|
| 537 |
+
|
| 538 |
+
These methods are designed to remain stable even if internal implementation changes.
|
| 539 |
+
|
| 540 |
+
### Quick Start Deployment
|
| 541 |
+
|
| 542 |
+
#### Option 1: Simple MCP Server (Recommended)
|
| 543 |
+
```bash
|
| 544 |
+
# Configure MCP server
|
| 545 |
+
cp .env.a2a.example .env.a2a
|
| 546 |
+
# Edit .env.a2a and set GITPILOT_A2A_SHARED_SECRET
|
| 547 |
+
|
| 548 |
+
# Start GitPilot MCP server
|
| 549 |
+
make mcp
|
| 550 |
+
```
|
| 551 |
+
|
| 552 |
+
This starts GitPilot with A2A endpoints only - perfect for most use cases.
|
| 553 |
+
|
| 554 |
+
#### Option 2: Full MCP Gateway (Optional - with ContextForge)
|
| 555 |
+
Only needed if you want the complete MCP ContextForge gateway infrastructure:
|
| 556 |
+
|
| 557 |
+
```bash
|
| 558 |
+
# 1. Download ContextForge and place at: deploy/a2a-mcp/mcp-context-forge
|
| 559 |
+
# 2. Configure environment
|
| 560 |
+
cd deploy/a2a-mcp
|
| 561 |
+
cp .env.stack.example .env.stack
|
| 562 |
+
# Edit .env.stack and set secrets
|
| 563 |
+
|
| 564 |
+
# 3. Start full gateway stack
|
| 565 |
+
cd ../..
|
| 566 |
+
make gateway
|
| 567 |
+
|
| 568 |
+
# 4. Register GitPilot agent in ContextForge
|
| 569 |
+
export CF_ADMIN_BEARER="<jwt-token>"
|
| 570 |
+
export GITPILOT_A2A_SECRET="<same-as-env-stack>"
|
| 571 |
+
make gateway-register
|
| 572 |
+
```
|
| 573 |
+
|
| 574 |
+
**Note:** Most users only need `make mcp`. The full gateway is optional for advanced setups.
|
| 575 |
+
|
| 576 |
+
See `deploy/a2a-mcp/README.md` for detailed deployment instructions.
|
| 577 |
+
|
| 578 |
+
### Cloud deployment note
|
| 579 |
+
Because the A2A adapter is stateless, GitPilot can be deployed with multiple replicas behind a load balancer. For long-running executions, consider adding async job execution (Redis/Postgres) in a future release.
|
| 580 |
+
|
| 581 |
+
---
|
| 582 |
+
|
| 583 |
+
## 📖 Complete Workflow Guide
|
| 584 |
+
|
| 585 |
+
### Initial Setup
|
| 586 |
+
|
| 587 |
+
**Step 1: Launch GitPilot**
|
| 588 |
+
```bash
|
| 589 |
+
gitpilot
|
| 590 |
+
```
|
| 591 |
+
Your browser opens to `http://127.0.0.1:8000`
|
| 592 |
+
|
| 593 |
+
**Step 2: Configure LLM Provider**
|
| 594 |
+
1. Click **"⚙️ Admin / Settings"** in the sidebar
|
| 595 |
+
2. Select your preferred provider (e.g., OpenAI, Claude, Watsonx, or Ollama)
|
| 596 |
+
3. Enter your credentials:
|
| 597 |
+
- **OpenAI**: API key + model
|
| 598 |
+
- **Claude**: API key + model
|
| 599 |
+
- **Watsonx**: API key + Project ID + model + base URL
|
| 600 |
+
- **Ollama**: Base URL + model
|
| 601 |
+
4. Click **"Save settings"**
|
| 602 |
+
5. See the success message confirming your settings are saved
|
| 603 |
+
|
| 604 |
+
**Step 3: Connect to GitHub Repository**
|
| 605 |
+
1. Click **"📁 Workspace"** to return to the main interface
|
| 606 |
+
2. In the sidebar, use the search box to find your repository
|
| 607 |
+
3. Click **"Search my repos"** to list all accessible repositories
|
| 608 |
+
4. Click on any repository to connect
|
| 609 |
+
5. The **Project Context Panel** will show repository information
|
| 610 |
+
6. Use the **Refresh** button to update permissions and file counts
|
| 611 |
+
|
| 612 |
+
### Development Workflow
|
| 613 |
+
|
| 614 |
+
**Step 1: Browse Your Codebase**
|
| 615 |
+
- The **Project Context** panel shows repository metadata
|
| 616 |
+
- Browse the file tree to understand structure
|
| 617 |
+
- Click on files to preview their contents
|
| 618 |
+
- Use the **Refresh** button to update the file tree after changes
|
| 619 |
+
|
| 620 |
+
**Step 2: Describe Your Task**
|
| 621 |
+
In the chat panel, describe what you want in natural language:
|
| 622 |
+
|
| 623 |
+
**Example 1: Add a Feature**
|
| 624 |
+
```
|
| 625 |
+
Add a new API endpoint at /api/users/{id}/profile that returns
|
| 626 |
+
user profile information including name, email, and bio.
|
| 627 |
+
```
|
| 628 |
+
|
| 629 |
+
**Example 2: Refactor Code**
|
| 630 |
+
```
|
| 631 |
+
Refactor the authentication middleware to use JWT tokens
|
| 632 |
+
instead of session cookies. Update all related tests.
|
| 633 |
+
```
|
| 634 |
+
|
| 635 |
+
**Example 3: Analyze and Generate**
|
| 636 |
+
```
|
| 637 |
+
Analyze the README.md file and generate Python example code
|
| 638 |
+
that demonstrates the main features.
|
| 639 |
+
```
|
| 640 |
+
|
| 641 |
+
**Example 4: Fix a Bug**
|
| 642 |
+
```
|
| 643 |
+
The login endpoint is returning 500 errors when the email
|
| 644 |
+
field is empty. Add proper validation and return a 400
|
| 645 |
+
with a helpful error message.
|
| 646 |
+
```
|
| 647 |
+
|
| 648 |
+
**Step 3: Review the Answer + Action Plan**
|
| 649 |
+
GitPilot will show you:
|
| 650 |
+
|
| 651 |
+
**Answer Section:**
|
| 652 |
+
- Clear explanation of what will be done
|
| 653 |
+
- Why this approach was chosen
|
| 654 |
+
- Overall summary of changes
|
| 655 |
+
|
| 656 |
+
**Action Plan Section:**
|
| 657 |
+
- Numbered steps with descriptions
|
| 658 |
+
- File operations with colored pills:
|
| 659 |
+
- 🟢 CREATE – Files to be created
|
| 660 |
+
- 🔵 MODIFY – Files to be modified
|
| 661 |
+
- 🔴 DELETE – Files to be removed
|
| 662 |
+
- 📖 READ – Files to analyze (no changes)
|
| 663 |
+
- Summary totals (e.g., "2 files to create, 3 files to modify, 1 file to read")
|
| 664 |
+
- Risk warnings when applicable
|
| 665 |
+
|
| 666 |
+
**Step 4: Execute or Refine**
|
| 667 |
+
- If the plan looks good: Click **"Approve & Execute"**
|
| 668 |
+
- If you want changes: Provide feedback in the chat
|
| 669 |
+
```
|
| 670 |
+
The plan looks good, but please also add rate limiting
|
| 671 |
+
to the new endpoint to prevent abuse.
|
| 672 |
+
```
|
| 673 |
+
- GitPilot will update the plan based on your feedback
|
| 674 |
+
|
| 675 |
+
**Step 5: View Execution Results**
|
| 676 |
+
After execution, see a detailed log:
|
| 677 |
+
```
|
| 678 |
+
Step 1: Create authentication endpoint
|
| 679 |
+
✓ Created src/api/auth.py
|
| 680 |
+
✓ Modified src/routes/index.py
|
| 681 |
+
|
| 682 |
+
Step 2: Add authentication tests
|
| 683 |
+
✓ Created tests/test_auth.py
|
| 684 |
+
ℹ️ READ-only: inspected README.md
|
| 685 |
+
```
|
| 686 |
+
|
| 687 |
+
**Step 6: Refresh File Tree**
|
| 688 |
+
After agent operations:
|
| 689 |
+
- Click the **Refresh** button in the file tree header
|
| 690 |
+
- See newly created/modified files appear
|
| 691 |
+
- Verify changes were applied correctly
|
| 692 |
+
|
| 693 |
+
**Step 7: View Agent Workflow (Optional)**
|
| 694 |
+
Click **"🔄 Agent Flow"** to see:
|
| 695 |
+
- How agents collaborate (Explorer → Planner → Code Writer → Reviewer)
|
| 696 |
+
- Data flow between components
|
| 697 |
+
- The complete multi-agent system architecture
|
| 698 |
+
|
| 699 |
+
---
|
| 700 |
+
|
| 701 |
+
## 🏗️ Architecture
|
| 702 |
+
|
| 703 |
+
### Frontend Structure
|
| 704 |
+
|
| 705 |
+
```
|
| 706 |
+
frontend/
|
| 707 |
+
├── App.jsx # Main application with navigation
|
| 708 |
+
├── components/
|
| 709 |
+
│ ├── AssistantMessage.jsx # Answer + Action Plan display
|
| 710 |
+
│ ├── ChatPanel.jsx # AI chat interface
|
| 711 |
+
│ ├── FileTree.jsx # Repository file browser with refresh
|
| 712 |
+
│ ├── FlowViewer.jsx # Agent workflow visualization
|
| 713 |
+
│ ├── Footer.jsx # Footer with GitHub star CTA
|
| 714 |
+
│ ├── LlmSettings.jsx # Provider configuration UI
|
| 715 |
+
│ ├── PlanView.jsx # Enhanced plan rendering with READ support
|
| 716 |
+
│ ├── ProjectContextPanel.jsx # Repository context with refresh
|
| 717 |
+
│ └── RepoSelector.jsx # Repository search/selection
|
| 718 |
+
├── styles.css # Global styles with dark theme
|
| 719 |
+
├── index.html # Entry point
|
| 720 |
+
└── package.json # Dependencies (React, ReactFlow)
|
| 721 |
+
```
|
| 722 |
+
|
| 723 |
+
### Backend Structure
|
| 724 |
+
|
| 725 |
+
```
|
| 726 |
+
gitpilot/
|
| 727 |
+
├── __init__.py
|
| 728 |
+
├── api.py # FastAPI routes (80+ endpoints)
|
| 729 |
+
├── agentic.py # CrewAI multi-agent orchestration
|
| 730 |
+
├── agent_router.py # NLP-based request routing
|
| 731 |
+
├── agent_tools.py # GitHub API exploration tools
|
| 732 |
+
├── cli.py # CLI (serve, run, scan, predict, plugin, skill)
|
| 733 |
+
├── github_api.py # GitHub REST API client
|
| 734 |
+
├── github_app.py # GitHub App installation management
|
| 735 |
+
├── github_issues.py # Issue CRUD operations
|
| 736 |
+
├── github_pulls.py # Pull request operations
|
| 737 |
+
├── github_search.py # Code, issue, repo, user search
|
| 738 |
+
├── github_oauth.py # OAuth web + device flow
|
| 739 |
+
├── llm_provider.py # Multi-provider LLM factory
|
| 740 |
+
├── settings.py # Configuration management
|
| 741 |
+
├── topology_registry.py # Switchable agent topologies (7 built-in)
|
| 742 |
+
│
|
| 743 |
+
│ # --- Phase 1: Feature Parity ---
|
| 744 |
+
├── workspace.py # Local git clone & file operations
|
| 745 |
+
├── local_tools.py # CrewAI tools for local editing
|
| 746 |
+
├── terminal.py # Sandboxed shell command execution
|
| 747 |
+
├── session.py # Session persistence & checkpoints
|
| 748 |
+
├── hooks.py # Lifecycle event hooks
|
| 749 |
+
├── memory.py # GITPILOT.md context memory
|
| 750 |
+
├── permissions.py # Fine-grained permission policies
|
| 751 |
+
├── headless.py # CI/CD headless execution mode
|
| 752 |
+
│
|
| 753 |
+
│ # --- Phase 2: Ecosystem Superiority ---
|
| 754 |
+
├── mcp_client.py # MCP server connector (stdio/HTTP/SSE)
|
| 755 |
+
├── plugins.py # Plugin marketplace & management
|
| 756 |
+
├── skills.py # /command skill system
|
| 757 |
+
├── vision.py # Multimodal image analysis
|
| 758 |
+
├── smart_model_router.py # Auto-route tasks to optimal model
|
| 759 |
+
│
|
| 760 |
+
│ # --- Phase 3: Intelligence Superiority ---
|
| 761 |
+
├── agent_teams.py # Parallel multi-agent on git worktrees
|
| 762 |
+
├── learning.py # Self-improving agents (per-repo)
|
| 763 |
+
├── cross_repo.py # Dependency graphs & impact analysis
|
| 764 |
+
├── predictions.py # Predictive workflow suggestions
|
| 765 |
+
├── security.py # AI-powered security scanner
|
| 766 |
+
├── nl_database.py # Natural language SQL via MCP
|
| 767 |
+
│
|
| 768 |
+
├── a2a_adapter.py # Optional A2A/MCP gateway adapter
|
| 769 |
+
└── web/ # Production frontend build
|
| 770 |
+
├── index.html
|
| 771 |
+
└── assets/
|
| 772 |
+
```
|
| 773 |
+
|
| 774 |
+
### API Endpoints (80+)
|
| 775 |
+
|
| 776 |
+
#### Repository Management
|
| 777 |
+
- `GET /api/repos` – List user repositories (paginated + search)
|
| 778 |
+
- `GET /api/repos/{owner}/{repo}/tree` – Get repository file tree
|
| 779 |
+
- `GET /api/repos/{owner}/{repo}/file` – Get file contents
|
| 780 |
+
- `POST /api/repos/{owner}/{repo}/file` – Update/commit file
|
| 781 |
+
|
| 782 |
+
#### Issues & Pull Requests
|
| 783 |
+
- `GET/POST/PATCH /api/repos/{owner}/{repo}/issues` – Full issue CRUD
|
| 784 |
+
- `GET/POST /api/repos/{owner}/{repo}/pulls` – Pull request management
|
| 785 |
+
- `PUT /api/repos/{owner}/{repo}/pulls/{n}/merge` – Merge pull request
|
| 786 |
+
|
| 787 |
+
#### Search
|
| 788 |
+
- `GET /api/search/code` – Search code across GitHub
|
| 789 |
+
- `GET /api/search/issues` – Search issues and pull requests
|
| 790 |
+
- `GET /api/search/repositories` – Search repositories
|
| 791 |
+
- `GET /api/search/users` – Search users and organisations
|
| 792 |
+
|
| 793 |
+
#### Chat & Planning
|
| 794 |
+
- `POST /api/chat/message` – Conversational dispatcher (auto-routes to agents)
|
| 795 |
+
- `POST /api/chat/plan` – Generate execution plan
|
| 796 |
+
- `POST /api/chat/execute` – Execute approved plan
|
| 797 |
+
- `POST /api/chat/execute-with-pr` – Execute and auto-create pull request
|
| 798 |
+
- `POST /api/chat/route` – Preview routing without execution
|
| 799 |
+
|
| 800 |
+
#### Sessions & Hooks (Phase 1)
|
| 801 |
+
- `GET/POST/DELETE /api/sessions` – Session CRUD
|
| 802 |
+
- `POST /api/sessions/{id}/checkpoint` – Create checkpoint
|
| 803 |
+
- `GET/POST/DELETE /api/hooks` – Hook registration
|
| 804 |
+
- `GET/PUT /api/permissions` – Permission mode management
|
| 805 |
+
- `GET/POST /api/repos/{owner}/{repo}/context` – Project memory
|
| 806 |
+
|
| 807 |
+
#### MCP, Plugins & Skills (Phase 2)
|
| 808 |
+
- `GET /api/mcp/servers` – List configured MCP servers
|
| 809 |
+
- `POST /api/mcp/connect/{name}` – Connect to MCP server
|
| 810 |
+
- `POST /api/mcp/call` – Call a tool on a connected server
|
| 811 |
+
- `GET/POST/DELETE /api/plugins` – Plugin management
|
| 812 |
+
- `GET/POST /api/skills` – Skill listing and invocation
|
| 813 |
+
- `POST /api/vision/analyze` – Analyse an image with a text prompt
|
| 814 |
+
- `POST /api/model-router/select` – Preview model selection for a task
|
| 815 |
+
|
| 816 |
+
#### Intelligence (Phase 3)
|
| 817 |
+
- `POST /api/agent-teams/plan` – Split task into parallel subtasks
|
| 818 |
+
- `POST /api/agent-teams/execute` – Execute subtasks in parallel
|
| 819 |
+
- `POST /api/learning/evaluate` – Evaluate action outcome for learning
|
| 820 |
+
- `GET /api/learning/insights/{owner}/{repo}` – Get learned insights
|
| 821 |
+
- `POST /api/cross-repo/dependencies` – Build dependency graph
|
| 822 |
+
- `POST /api/cross-repo/impact` – Impact analysis for package update
|
| 823 |
+
- `POST /api/predictions/suggest` – Get proactive suggestions
|
| 824 |
+
- `POST /api/security/scan-file` – Scan file for vulnerabilities
|
| 825 |
+
- `POST /api/security/scan-directory` – Recursive directory scan
|
| 826 |
+
- `POST /api/security/scan-diff` – Scan git diff for issues
|
| 827 |
+
- `POST /api/nl-database/translate` – Natural language to SQL
|
| 828 |
+
- `POST /api/nl-database/explain` – Explain SQL in plain English
|
| 829 |
+
|
| 830 |
+
#### Workflow Visualization & Topologies
|
| 831 |
+
- `GET /api/flow/current` – Get agent workflow graph (supports `?topology=` param)
|
| 832 |
+
- `GET /api/flow/topologies` – List available agent topologies
|
| 833 |
+
- `GET /api/flow/topology/{id}` – Get graph for a specific topology
|
| 834 |
+
- `POST /api/flow/classify` – Auto-detect best topology for a message
|
| 835 |
+
- `GET /api/settings/topology` – Read saved topology preference
|
| 836 |
+
- `POST /api/settings/topology` – Save topology preference
|
| 837 |
+
|
| 838 |
+
#### A2A / MCP Integration (Optional)
|
| 839 |
+
Enabled only when `GITPILOT_ENABLE_A2A=true`:
|
| 840 |
+
|
| 841 |
+
- `POST /a2a/invoke` – A2A invoke endpoint (JSON-RPC + envelope)
|
| 842 |
+
- `POST /a2a/v1/invoke` – Versioned A2A endpoint (recommended)
|
| 843 |
+
- `GET /a2a/health` – A2A health check
|
| 844 |
+
- `GET /a2a/manifest` – A2A capability discovery (methods + schemas)
|
| 845 |
+
|
| 846 |
+
---
|
| 847 |
+
|
| 848 |
+
## 🛠️ Development
|
| 849 |
+
|
| 850 |
+
### Build Commands (Makefile)
|
| 851 |
+
|
| 852 |
+
```bash
|
| 853 |
+
# Install all dependencies
|
| 854 |
+
make install
|
| 855 |
+
|
| 856 |
+
# Install frontend dependencies only
|
| 857 |
+
make frontend-install
|
| 858 |
+
|
| 859 |
+
# Build frontend for production
|
| 860 |
+
make frontend-build
|
| 861 |
+
|
| 862 |
+
# Run development server
|
| 863 |
+
make run
|
| 864 |
+
|
| 865 |
+
# Run tests (846 tests across 28 test files)
|
| 866 |
+
make test
|
| 867 |
+
|
| 868 |
+
# Lint code
|
| 869 |
+
make lint
|
| 870 |
+
|
| 871 |
+
# Format code
|
| 872 |
+
make fmt
|
| 873 |
+
|
| 874 |
+
# Build Python package
|
| 875 |
+
make build
|
| 876 |
+
|
| 877 |
+
# Clean build artifacts
|
| 878 |
+
make clean
|
| 879 |
+
|
| 880 |
+
# MCP Server Deployment (Simple - Recommended)
|
| 881 |
+
make mcp # Start GitPilot MCP server (A2A endpoints)
|
| 882 |
+
make mcp-down # Stop GitPilot MCP server
|
| 883 |
+
make mcp-logs # View MCP server logs
|
| 884 |
+
|
| 885 |
+
# MCP Gateway Deployment (Optional - Full ContextForge Stack)
|
| 886 |
+
make gateway # Start GitPilot + MCP ContextForge gateway
|
| 887 |
+
make gateway-down # Stop MCP ContextForge gateway
|
| 888 |
+
make gateway-logs # View gateway logs
|
| 889 |
+
make gateway-register # Register agent in ContextForge
|
| 890 |
+
```
|
| 891 |
+
|
| 892 |
+
### CLI Commands
|
| 893 |
+
|
| 894 |
+
```bash
|
| 895 |
+
gitpilot # Start server with web UI (default)
|
| 896 |
+
gitpilot serve --host 0.0.0.0 # Custom host/port
|
| 897 |
+
gitpilot config # Show current configuration
|
| 898 |
+
gitpilot version # Show version
|
| 899 |
+
gitpilot run -r owner/repo -m "task" # Headless execution for CI/CD
|
| 900 |
+
gitpilot init # Initialize .gitpilot/ with template
|
| 901 |
+
gitpilot scan /path # Security scan a directory or file
|
| 902 |
+
gitpilot predict "context text" # Get proactive suggestions
|
| 903 |
+
gitpilot plugin install <source> # Install a plugin
|
| 904 |
+
gitpilot plugin list # List installed plugins
|
| 905 |
+
gitpilot skill list # List available skills
|
| 906 |
+
gitpilot skill <name> # Invoke a skill
|
| 907 |
+
gitpilot list-models # List LLM models for active provider
|
| 908 |
+
```
|
| 909 |
+
|
| 910 |
+
### Frontend Development
|
| 911 |
+
|
| 912 |
+
```bash
|
| 913 |
+
cd frontend
|
| 914 |
+
|
| 915 |
+
# Install dependencies
|
| 916 |
+
npm install
|
| 917 |
+
|
| 918 |
+
# Development mode with hot reload
|
| 919 |
+
npm run dev
|
| 920 |
+
|
| 921 |
+
# Build for production
|
| 922 |
+
npm run build
|
| 923 |
+
```
|
| 924 |
+
|
| 925 |
+
---
|
| 926 |
+
|
| 927 |
+
## 📦 Publishing to PyPI
|
| 928 |
+
|
| 929 |
+
GitPilot uses automated publishing via GitHub Actions with OIDC-based trusted publishing.
|
| 930 |
+
|
| 931 |
+
### Automated Release Workflow
|
| 932 |
+
|
| 933 |
+
1. **Update version** in `gitpilot/version.py`
|
| 934 |
+
2. **Create and publish a GitHub release** (tag format: `vX.Y.Z`)
|
| 935 |
+
3. **GitHub Actions automatically**:
|
| 936 |
+
- Builds source distribution and wheel
|
| 937 |
+
- Uploads artifacts to the release
|
| 938 |
+
- Publishes to PyPI via trusted publishing
|
| 939 |
+
|
| 940 |
+
See [.github/workflows/release.yml](.github/workflows/release.yml) for details.
|
| 941 |
+
|
| 942 |
+
### Manual Publishing (Alternative)
|
| 943 |
+
|
| 944 |
+
```bash
|
| 945 |
+
# Build distributions
|
| 946 |
+
make build
|
| 947 |
+
|
| 948 |
+
# Publish to TestPyPI
|
| 949 |
+
make publish-test
|
| 950 |
+
|
| 951 |
+
# Publish to PyPI
|
| 952 |
+
make publish
|
| 953 |
+
```
|
| 954 |
+
|
| 955 |
+
---
|
| 956 |
+
|
| 957 |
+
## 📸 Screenshots
|
| 958 |
+
|
| 959 |
+
### Example: File Deletion
|
| 960 |
+

|
| 961 |
+
|
| 962 |
+
### Example: Content Generation
|
| 963 |
+

|
| 964 |
+
|
| 965 |
+
### Example: File Creation
|
| 966 |
+

|
| 967 |
+
|
| 968 |
+
### Example multiple operations
|
| 969 |
+

|
| 970 |
+
|
| 971 |
+
---
|
| 972 |
+
|
| 973 |
+
## 🤝 Contributing
|
| 974 |
+
|
| 975 |
+
**We love contributions!** Whether it's bug fixes, new features, or documentation improvements.
|
| 976 |
+
|
| 977 |
+
### How to Contribute
|
| 978 |
+
|
| 979 |
+
1. ⭐ **Star the repository** (if you haven't already!)
|
| 980 |
+
2. 🍴 Fork the repository
|
| 981 |
+
3. 🌿 Create a feature branch (`git checkout -b feature/amazing-feature`)
|
| 982 |
+
4. ✍️ Make your changes
|
| 983 |
+
5. ✅ Run tests (`make test`)
|
| 984 |
+
6. 🎨 Run linter (`make lint`)
|
| 985 |
+
7. 📝 Commit your changes (`git commit -m 'Add amazing feature'`)
|
| 986 |
+
8. 🚀 Push to the branch (`git push origin feature/amazing-feature`)
|
| 987 |
+
9. 🎯 Open a Pull Request
|
| 988 |
+
|
| 989 |
+
### Development Setup
|
| 990 |
+
|
| 991 |
+
```bash
|
| 992 |
+
# Clone your fork
|
| 993 |
+
git clone https://github.com/YOUR_USERNAME/gitpilot.git
|
| 994 |
+
cd gitpilot
|
| 995 |
+
|
| 996 |
+
# Install dependencies
|
| 997 |
+
make install
|
| 998 |
+
|
| 999 |
+
# Create a branch
|
| 1000 |
+
git checkout -b feature/my-feature
|
| 1001 |
+
|
| 1002 |
+
# Make changes and test
|
| 1003 |
+
make run
|
| 1004 |
+
make test
|
| 1005 |
+
```
|
| 1006 |
+
|
| 1007 |
+
---
|
| 1008 |
+
|
| 1009 |
+
## 📄 License
|
| 1010 |
+
|
| 1011 |
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
| 1012 |
+
|
| 1013 |
+
---
|
| 1014 |
+
|
| 1015 |
+
## 👨💻 Author
|
| 1016 |
+
|
| 1017 |
+
**Ruslan Magana Vsevolodovna**
|
| 1018 |
+
|
| 1019 |
+
- GitHub: [@ruslanmv](https://github.com/ruslanmv)
|
| 1020 |
+
- Website: [ruslanmv.com](https://ruslanmv.com)
|
| 1021 |
+
|
| 1022 |
+
---
|
| 1023 |
+
|
| 1024 |
+
## 🙏 Acknowledgments
|
| 1025 |
+
|
| 1026 |
+
- **CrewAI** – Multi-agent orchestration framework
|
| 1027 |
+
- **FastAPI** – Modern, fast web framework
|
| 1028 |
+
- **React** – UI library
|
| 1029 |
+
- **ReactFlow** – Interactive node-based diagrams
|
| 1030 |
+
- **Vite** – Fast build tool
|
| 1031 |
+
- **MCP (Model Context Protocol)** – By Anthropic, for tool interoperability
|
| 1032 |
+
- **OWASP Top 10** – Security vulnerability categorisation
|
| 1033 |
+
- **BIRD benchmark** and **DIN-SQL** research – Text-to-SQL approaches
|
| 1034 |
+
- **Horvitz (1999)** – Proactive assistance in HCI research
|
| 1035 |
+
- **All our contributors and stargazers!** ⭐
|
| 1036 |
+
|
| 1037 |
+
---
|
| 1038 |
+
|
| 1039 |
+
## 📞 Support
|
| 1040 |
+
|
| 1041 |
+
- **Issues**: https://github.com/ruslanmv/gitpilot/issues
|
| 1042 |
+
- **Discussions**: https://github.com/ruslanmv/gitpilot/discussions
|
| 1043 |
+
- **Documentation**: [Full Documentation](https://github.com/ruslanmv/gitpilot#readme)
|
| 1044 |
+
|
| 1045 |
+
---
|
| 1046 |
+
|
| 1047 |
+
## 🗺️ Roadmap
|
| 1048 |
+
|
| 1049 |
+
### Recently Released (v0.2.0) 🆕
|
| 1050 |
+
|
| 1051 |
+
**Phase 1 — Feature Parity with Claude Code:**
|
| 1052 |
+
- ✅ **Local Workspace Manager** – Clone repos, read/write/search files locally
|
| 1053 |
+
- ✅ **Terminal Execution** – Sandboxed shell commands with timeout and output capping
|
| 1054 |
+
- ✅ **Session Persistence** – Resume, fork, checkpoint, and rewind sessions
|
| 1055 |
+
- ✅ **Hook System** – Lifecycle events (pre_commit, post_edit) with blocking support
|
| 1056 |
+
- ✅ **Permission Policies** – Three modes (normal/plan/auto) with path-based blocking
|
| 1057 |
+
- ✅ **Context Memory (GITPILOT.md)** – Project conventions injected into agent prompts
|
| 1058 |
+
- ✅ **Headless CI/CD Mode** – `gitpilot run --headless` for automation pipelines
|
| 1059 |
+
|
| 1060 |
+
**Phase 2 — Ecosystem Superiority:**
|
| 1061 |
+
- ✅ **MCP Client** – Connect to any MCP server (databases, Slack, Figma, Sentry)
|
| 1062 |
+
- ✅ **Plugin Marketplace** – Install/uninstall plugins from git or local paths
|
| 1063 |
+
- ✅ **Skill System** – /command skills defined as markdown with template variables
|
| 1064 |
+
- ✅ **Vision Analysis** – Multimodal image analysis via OpenAI, Anthropic, or Ollama
|
| 1065 |
+
- ✅ **Smart Model Router** – Auto-route tasks to optimal model by complexity
|
| 1066 |
+
- ✅ **VS Code Extension** – Sidebar chat, inline actions, and keybindings
|
| 1067 |
+
|
| 1068 |
+
**Phase 3 — Intelligence Superiority (no competitor has this):**
|
| 1069 |
+
- ✅ **Agent Teams** – Parallel multi-agent execution on git worktrees
|
| 1070 |
+
- ✅ **Self-Improving Agents** – Learn from outcomes and adapt per-project
|
| 1071 |
+
- ✅ **Cross-Repo Intelligence** – Dependency graphs and impact analysis across repos
|
| 1072 |
+
- ✅ **Predictive Workflows** – Proactive suggestions based on context patterns
|
| 1073 |
+
- ✅ **AI Security Scanner** – Secret detection, injection analysis, CWE mapping
|
| 1074 |
+
- ✅ **NL Database Queries** – Natural language to SQL translation via MCP
|
| 1075 |
+
- ✅ **Topology Registry** – Seven switchable agent architectures with zero-latency message classification and visual topology selector
|
| 1076 |
+
|
| 1077 |
+
### Previous Features (v0.1.2)
|
| 1078 |
+
- ✅ Full Multi-LLM Support – All 4 providers fully tested
|
| 1079 |
+
- ✅ Answer + Action Plan UX with structured file operations
|
| 1080 |
+
- ✅ Real Execution Engine with GitHub operations
|
| 1081 |
+
- ✅ Agent Flow Viewer with ReactFlow
|
| 1082 |
+
- ✅ MCP / A2A Integration (ContextForge compatible)
|
| 1083 |
+
- ✅ Issue and Pull Request management APIs
|
| 1084 |
+
- ✅ Code, issue, repository, and user search
|
| 1085 |
+
- ✅ OAuth web + device flow authentication
|
| 1086 |
+
|
| 1087 |
+
### Planned Features (v0.2.1+)
|
| 1088 |
+
- 🔄 Frontend components for terminal, sessions, checkpoints, and security dashboard
|
| 1089 |
+
- 🔄 JetBrains IDE plugin
|
| 1090 |
+
- 🔄 Real-time collaboration (shared sessions, audit log)
|
| 1091 |
+
- 🔄 Automated test generation from code changes
|
| 1092 |
+
- 🔄 Slack/Discord notification hooks
|
| 1093 |
+
- 🔄 LLM-powered semantic diff review
|
| 1094 |
+
- 🔄 OSV dependency vulnerability scanning
|
| 1095 |
+
- 🔄 Custom agent templates and agent marketplace
|
| 1096 |
+
|
| 1097 |
+
---
|
| 1098 |
+
|
| 1099 |
+
## ⚠️ Important Notes
|
| 1100 |
+
|
| 1101 |
+
### Security Best Practices
|
| 1102 |
+
|
| 1103 |
+
1. **Never commit API keys** to version control
|
| 1104 |
+
2. **Use environment variables** or the Admin UI for credentials
|
| 1105 |
+
3. **Rotate tokens regularly**
|
| 1106 |
+
4. **Limit GitHub token scopes** to only what's needed
|
| 1107 |
+
5. **Review all plans** before approving execution
|
| 1108 |
+
6. **Verify GitHub App installations** before granting write access
|
| 1109 |
+
7. **Run `gitpilot scan`** before releases to catch secrets, injection risks, and insecure configurations
|
| 1110 |
+
8. **Use permission modes** – run agents in `plan` mode (read-only) when exploring unfamiliar codebases
|
| 1111 |
+
|
| 1112 |
+
### LLM Provider Configuration
|
| 1113 |
+
|
| 1114 |
+
**All providers now fully supported!** ✨
|
| 1115 |
+
|
| 1116 |
+
Each provider has specific requirements:
|
| 1117 |
+
|
| 1118 |
+
**OpenAI**
|
| 1119 |
+
- Requires: `OPENAI_API_KEY`
|
| 1120 |
+
- Optional: `GITPILOT_OPENAI_MODEL`, `OPENAI_BASE_URL`
|
| 1121 |
+
|
| 1122 |
+
**Claude (Anthropic)**
|
| 1123 |
+
- Requires: `ANTHROPIC_API_KEY`
|
| 1124 |
+
- Optional: `GITPILOT_CLAUDE_MODEL`, `ANTHROPIC_BASE_URL`
|
| 1125 |
+
- Note: Environment variables are automatically configured by GitPilot
|
| 1126 |
+
|
| 1127 |
+
**IBM Watsonx.ai**
|
| 1128 |
+
- Requires: `WATSONX_API_KEY`, `WATSONX_PROJECT_ID`
|
| 1129 |
+
- Optional: `WATSONX_BASE_URL`, `GITPILOT_WATSONX_MODEL`
|
| 1130 |
+
- Note: Project ID is essential for proper authentication
|
| 1131 |
+
|
| 1132 |
+
**Ollama**
|
| 1133 |
+
- Requires: `OLLAMA_BASE_URL`
|
| 1134 |
+
- Optional: `GITPILOT_OLLAMA_MODEL`
|
| 1135 |
+
- Note: Runs locally, no API key needed
|
| 1136 |
+
|
| 1137 |
+
### File Action Types
|
| 1138 |
+
|
| 1139 |
+
GitPilot supports four file operation types in plans:
|
| 1140 |
+
|
| 1141 |
+
- **CREATE** (🟢) – Add new files with AI-generated content
|
| 1142 |
+
- **MODIFY** (🔵) – Update existing files intelligently
|
| 1143 |
+
- **DELETE** (🔴) – Remove files safely
|
| 1144 |
+
- **READ** (📖) – Analyze files without making changes (new!)
|
| 1145 |
+
|
| 1146 |
+
READ operations allow agents to gather context and information without modifying your repository, enabling better-informed plans.
|
| 1147 |
+
|
| 1148 |
+
---
|
| 1149 |
+
|
| 1150 |
+
## 🎓 Learn More
|
| 1151 |
+
|
| 1152 |
+
### Understanding the Agent System
|
| 1153 |
+
|
| 1154 |
+
GitPilot uses a multi-agent architecture where each request is routed to the right set of agents by the **agent router**, which analyses your message using NLP patterns and dispatches to one of 12+ specialised agent types:
|
| 1155 |
+
|
| 1156 |
+
**Core Agents:**
|
| 1157 |
+
- **Repository Explorer** – Scans codebase structure and gathers context
|
| 1158 |
+
- **Refactor Planner** – Creates structured step-by-step plans with file operations
|
| 1159 |
+
- **Code Writer** – Generates AI-powered content for new and modified files
|
| 1160 |
+
- **Code Reviewer** – Reviews changes for quality, safety, and adherence to conventions
|
| 1161 |
+
|
| 1162 |
+
**Local Agents (Phase 1):**
|
| 1163 |
+
- **Local Editor** – Reads, writes, and searches files directly on disk
|
| 1164 |
+
- **Terminal Agent** – Executes shell commands (`npm test`, `make build`, `pytest`)
|
| 1165 |
+
|
| 1166 |
+
**Specialised Agents (v2):**
|
| 1167 |
+
- **Issue Agent** – Creates, updates, labels, and comments on GitHub issues
|
| 1168 |
+
- **PR Agent** – Creates pull requests, lists files, manages merges
|
| 1169 |
+
- **Search Agent** – Searches code, issues, repos, and users across GitHub
|
| 1170 |
+
- **Learning Agent** – Evaluates outcomes and improves strategies over time
|
| 1171 |
+
|
| 1172 |
+
**Intelligence Layer (Phase 3):**
|
| 1173 |
+
- **Agent Teams** – Multiple agents work in parallel on subtasks with conflict detection
|
| 1174 |
+
- **Predictive Engine** – Suggests next actions before you ask
|
| 1175 |
+
- **Security Scanner** – Detects secrets, injection risks, and insecure configurations
|
| 1176 |
+
- **Cross-Repo Analyser** – Maps dependencies and assesses impact across repositories
|
| 1177 |
+
|
| 1178 |
+
The **smart model router** selects the optimal LLM for each task — simple queries go to fast/cheap models while complex reasoning gets the most powerful model available.
|
| 1179 |
+
|
| 1180 |
+
### Choosing the Right LLM Provider
|
| 1181 |
+
|
| 1182 |
+
**OpenAI (GPT-4o, GPT-4o-mini)**
|
| 1183 |
+
- ✅ Best for: General-purpose coding, fast responses
|
| 1184 |
+
- ✅ Strengths: Excellent code quality, great at following instructions
|
| 1185 |
+
- ✅ Status: Fully tested and working
|
| 1186 |
+
- ⚠️ Costs: Moderate to high
|
| 1187 |
+
|
| 1188 |
+
**Claude (Claude 4.5 Sonnet)**
|
| 1189 |
+
- ✅ Best for: Complex refactoring, detailed analysis
|
| 1190 |
+
- ✅ Strengths: Deep reasoning, excellent at planning
|
| 1191 |
+
- ✅ Status: Fully tested and working (latest integration fixes applied)
|
| 1192 |
+
- ⚠️ Costs: Moderate to high
|
| 1193 |
+
|
| 1194 |
+
**Watsonx (Llama 3.3, Granite 3.x)**
|
| 1195 |
+
- ✅ Best for: Enterprise deployments, privacy-focused
|
| 1196 |
+
- ✅ Strengths: On-premise option, compliance-friendly
|
| 1197 |
+
- ✅ Status: Fully tested and working (project_id integration fixed)
|
| 1198 |
+
- ⚠️ Costs: Subscription-based
|
| 1199 |
+
|
| 1200 |
+
**Ollama (Local Models)**
|
| 1201 |
+
- ✅ Best for: Cost-free operation, offline work
|
| 1202 |
+
- ✅ Strengths: Zero API costs, complete privacy
|
| 1203 |
+
- ✅ Status: Fully tested and working
|
| 1204 |
+
- ⚠️ Performance: Depends on hardware, may be slower
|
| 1205 |
+
|
| 1206 |
+
---
|
| 1207 |
+
|
| 1208 |
+
## 🐛 Troubleshooting
|
| 1209 |
+
|
| 1210 |
+
### Common Issues and Solutions
|
| 1211 |
+
|
| 1212 |
+
**Issue: "ANTHROPIC_API_KEY is required" error with Claude**
|
| 1213 |
+
- **Solution**: This is now automatically handled. Update to latest version or ensure environment variables are set via Admin UI.
|
| 1214 |
+
|
| 1215 |
+
**Issue: "Fallback to LiteLLM is not available" with Watsonx**
|
| 1216 |
+
- **Solution**: Ensure you've set both `WATSONX_API_KEY` and `WATSONX_PROJECT_ID`. Install `litellm` if needed: `pip install litellm`
|
| 1217 |
+
|
| 1218 |
+
**Issue: Plan generation fails with validation error**
|
| 1219 |
+
- **Solution**: Update to latest version which includes READ action support in schema validation.
|
| 1220 |
+
|
| 1221 |
+
**Issue: "Read Only" status despite having write access**
|
| 1222 |
+
- **Solution**: Install the GitPilot GitHub App on your repository. Click the install link in the UI or refresh permissions.
|
| 1223 |
+
|
| 1224 |
+
**Issue: File tree not updating after agent operations**
|
| 1225 |
+
- **Solution**: Click the Refresh button in the file tree header to see newly created/modified files.
|
| 1226 |
+
|
| 1227 |
+
For more issues, visit our [GitHub Issues](https://github.com/ruslanmv/gitpilot/issues) page.
|
| 1228 |
+
|
| 1229 |
+
---
|
| 1230 |
+
|
| 1231 |
+
<div align="center">
|
| 1232 |
+
|
| 1233 |
+
**⭐ Don't forget to star GitPilot if you find it useful! ⭐**
|
| 1234 |
+
|
| 1235 |
+
[⭐ Star on GitHub](https://github.com/ruslanmv/gitpilot) • [📖 Documentation](https://github.com/ruslanmv/gitpilot#readme) • [🐛 Report Bug](https://github.com/ruslanmv/gitpilot/issues) • [💡 Request Feature](https://github.com/ruslanmv/gitpilot/issues)
|
| 1236 |
+
|
| 1237 |
+
**GitPilot** – Your AI Coding Companion for GitHub 🚀
|
| 1238 |
+
|
| 1239 |
+
Made with ❤️ by [Ruslan Magana Vsevolodovna](https://github.com/ruslanmv)
|
| 1240 |
+
|
| 1241 |
+
</div>
|
deploy/huggingface/start.sh
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# =============================================================================
|
| 3 |
+
# GitPilot — HF Spaces Startup Script
|
| 4 |
+
# =============================================================================
|
| 5 |
+
# Starts GitPilot FastAPI server with React frontend on HuggingFace Spaces.
|
| 6 |
+
# Pre-configured to use OllaBridge Cloud as the default LLM provider.
|
| 7 |
+
# =============================================================================
|
| 8 |
+
|
| 9 |
+
set -e
|
| 10 |
+
|
| 11 |
+
echo "=============================================="
|
| 12 |
+
echo " GitPilot — Hugging Face Spaces"
|
| 13 |
+
echo "=============================================="
|
| 14 |
+
echo ""
|
| 15 |
+
|
| 16 |
+
# -- Ensure writable directories exist ----------------------------------------
|
| 17 |
+
mkdir -p /tmp/gitpilot /tmp/gitpilot/workspaces /tmp/gitpilot/sessions
|
| 18 |
+
export HOME=/tmp
|
| 19 |
+
export GITPILOT_CONFIG_DIR=/tmp/gitpilot
|
| 20 |
+
|
| 21 |
+
# -- Display configuration ---------------------------------------------------
|
| 22 |
+
echo "[1/2] Configuration:"
|
| 23 |
+
echo " Provider: ${GITPILOT_PROVIDER:-ollabridge}"
|
| 24 |
+
echo " OllaBridge URL: ${OLLABRIDGE_BASE_URL:-https://ruslanmv-ollabridge.hf.space}"
|
| 25 |
+
echo " Model: ${GITPILOT_OLLABRIDGE_MODEL:-qwen2.5:1.5b}"
|
| 26 |
+
echo ""
|
| 27 |
+
|
| 28 |
+
# -- Check OllaBridge Cloud connectivity (non-blocking) ----------------------
|
| 29 |
+
echo "[2/2] Checking LLM provider..."
|
| 30 |
+
if curl -sf "${OLLABRIDGE_BASE_URL:-https://ruslanmv-ollabridge.hf.space}/health" > /dev/null 2>&1; then
|
| 31 |
+
echo " OllaBridge Cloud is reachable"
|
| 32 |
+
else
|
| 33 |
+
echo " OllaBridge Cloud not reachable (will retry on first request)"
|
| 34 |
+
echo " You can configure a different provider in Admin / LLM Settings"
|
| 35 |
+
fi
|
| 36 |
+
echo ""
|
| 37 |
+
|
| 38 |
+
echo "=============================================="
|
| 39 |
+
echo " Ready! Endpoints:"
|
| 40 |
+
echo " - UI: / (React frontend)"
|
| 41 |
+
echo " - API: /api/health"
|
| 42 |
+
echo " - API Docs: /docs"
|
| 43 |
+
echo " - Chat: /api/chat/message"
|
| 44 |
+
echo " - Settings: /api/settings"
|
| 45 |
+
echo "=============================================="
|
| 46 |
+
echo ""
|
| 47 |
+
|
| 48 |
+
# -- Start GitPilot (foreground) ----------------------------------------------
|
| 49 |
+
exec python -m uvicorn gitpilot.api:app \
|
| 50 |
+
--host "${HOST:-0.0.0.0}" \
|
| 51 |
+
--port "${PORT:-7860}" \
|
| 52 |
+
--workers 1 \
|
| 53 |
+
--timeout-keep-alive 120 \
|
| 54 |
+
--no-access-log
|
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/.env.example
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Frontend Environment Variables
|
| 2 |
+
|
| 3 |
+
# Backend API URL
|
| 4 |
+
# Leave empty for local development (uses Vite proxy to localhost:8000)
|
| 5 |
+
# Set to your Render backend URL for production deployment on Vercel
|
| 6 |
+
# Example: VITE_BACKEND_URL=https://gitpilot-backend.onrender.com
|
| 7 |
+
VITE_BACKEND_URL=
|
frontend/.env.production.example
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Production Environment Variables (Vercel)
|
| 2 |
+
|
| 3 |
+
# Backend API URL - REQUIRED for production
|
| 4 |
+
# Point this to your Render backend URL
|
| 5 |
+
VITE_BACKEND_URL=https://gitpilot-backend.onrender.com
|
frontend/App.jsx
ADDED
|
@@ -0,0 +1,909 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/App.jsx
|
| 2 |
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
| 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 { apiUrl, safeFetchJSON, fetchStatus } from "./utils/api.js";
|
| 15 |
+
|
| 16 |
+
function makeRepoKey(repo) {
|
| 17 |
+
if (!repo) return null;
|
| 18 |
+
return repo.full_name || `${repo.owner}/${repo.name}`;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function uniq(arr) {
|
| 22 |
+
return Array.from(new Set((arr || []).filter(Boolean)));
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export default function App() {
|
| 26 |
+
// ---- Multi-repo context state ----
|
| 27 |
+
const [contextRepos, setContextRepos] = useState([]);
|
| 28 |
+
// Each entry: { repoKey: "owner/repo", repo: {...}, branch: "main" }
|
| 29 |
+
const [activeRepoKey, setActiveRepoKey] = useState(null);
|
| 30 |
+
const [addRepoOpen, setAddRepoOpen] = useState(false);
|
| 31 |
+
|
| 32 |
+
const [activePage, setActivePage] = useState("workspace");
|
| 33 |
+
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
| 34 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 35 |
+
const [userInfo, setUserInfo] = useState(null);
|
| 36 |
+
|
| 37 |
+
// Repo + Session State Machine
|
| 38 |
+
const [repoStateByKey, setRepoStateByKey] = useState({});
|
| 39 |
+
const [toast, setToast] = useState(null);
|
| 40 |
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 41 |
+
const [adminTab, setAdminTab] = useState("overview");
|
| 42 |
+
const [adminStatus, setAdminStatus] = useState(null);
|
| 43 |
+
|
| 44 |
+
// Fetch admin status when overview tab is active
|
| 45 |
+
useEffect(() => {
|
| 46 |
+
if (activePage === "admin" && adminTab === "overview") {
|
| 47 |
+
fetchStatus()
|
| 48 |
+
.then(data => setAdminStatus(data))
|
| 49 |
+
.catch(() => setAdminStatus(null));
|
| 50 |
+
}
|
| 51 |
+
}, [activePage, adminTab]);
|
| 52 |
+
|
| 53 |
+
// Claude-Code-on-Web: Session sidebar + Environment state
|
| 54 |
+
const [activeSessionId, setActiveSessionId] = useState(null);
|
| 55 |
+
const [activeEnvId, setActiveEnvId] = useState("default");
|
| 56 |
+
const [sessionRefreshNonce, setSessionRefreshNonce] = useState(0);
|
| 57 |
+
|
| 58 |
+
// ---- Derived `repo` — keeps all downstream consumers unchanged ----
|
| 59 |
+
const repo = useMemo(() => {
|
| 60 |
+
const entry = contextRepos.find((r) => r.repoKey === activeRepoKey);
|
| 61 |
+
return entry?.repo || null;
|
| 62 |
+
}, [contextRepos, activeRepoKey]);
|
| 63 |
+
|
| 64 |
+
const repoKey = activeRepoKey;
|
| 65 |
+
|
| 66 |
+
// Convenient selectors
|
| 67 |
+
const currentRepoState = repoKey ? repoStateByKey[repoKey] : null;
|
| 68 |
+
|
| 69 |
+
const defaultBranch = currentRepoState?.defaultBranch || repo?.default_branch || "main";
|
| 70 |
+
const currentBranch = currentRepoState?.currentBranch || defaultBranch;
|
| 71 |
+
const sessionBranches = currentRepoState?.sessionBranches || [];
|
| 72 |
+
const lastExecution = currentRepoState?.lastExecution || null;
|
| 73 |
+
const pulseNonce = currentRepoState?.pulseNonce || 0;
|
| 74 |
+
const chatByBranch = currentRepoState?.chatByBranch || {};
|
| 75 |
+
|
| 76 |
+
// ---------------------------------------------------------------------------
|
| 77 |
+
// Multi-repo context management
|
| 78 |
+
// ---------------------------------------------------------------------------
|
| 79 |
+
const addRepoToContext = useCallback((r) => {
|
| 80 |
+
const key = makeRepoKey(r);
|
| 81 |
+
if (!key) return;
|
| 82 |
+
|
| 83 |
+
setContextRepos((prev) => {
|
| 84 |
+
// Don't add duplicates
|
| 85 |
+
if (prev.some((e) => e.repoKey === key)) {
|
| 86 |
+
// Already in context — just activate it
|
| 87 |
+
setActiveRepoKey(key);
|
| 88 |
+
return prev;
|
| 89 |
+
}
|
| 90 |
+
const entry = { repoKey: key, repo: r, branch: r.default_branch || "main" };
|
| 91 |
+
const next = [...prev, entry];
|
| 92 |
+
return next;
|
| 93 |
+
});
|
| 94 |
+
setActiveRepoKey(key);
|
| 95 |
+
setAddRepoOpen(false);
|
| 96 |
+
}, []);
|
| 97 |
+
|
| 98 |
+
const removeRepoFromContext = useCallback((key) => {
|
| 99 |
+
setContextRepos((prev) => {
|
| 100 |
+
const next = prev.filter((e) => e.repoKey !== key);
|
| 101 |
+
// Reassign active if we removed the active one
|
| 102 |
+
setActiveRepoKey((curActive) => {
|
| 103 |
+
if (curActive === key) {
|
| 104 |
+
return next.length > 0 ? next[0].repoKey : null;
|
| 105 |
+
}
|
| 106 |
+
return curActive;
|
| 107 |
+
});
|
| 108 |
+
return next;
|
| 109 |
+
});
|
| 110 |
+
}, []);
|
| 111 |
+
|
| 112 |
+
const clearAllContext = useCallback(() => {
|
| 113 |
+
setContextRepos([]);
|
| 114 |
+
setActiveRepoKey(null);
|
| 115 |
+
}, []);
|
| 116 |
+
|
| 117 |
+
const handleContextBranchChange = useCallback((targetRepoKey, newBranch) => {
|
| 118 |
+
// Update branch in contextRepos
|
| 119 |
+
setContextRepos((prev) =>
|
| 120 |
+
prev.map((e) =>
|
| 121 |
+
e.repoKey === targetRepoKey ? { ...e, branch: newBranch } : e
|
| 122 |
+
)
|
| 123 |
+
);
|
| 124 |
+
// Update branch in repoStateByKey
|
| 125 |
+
setRepoStateByKey((prev) => {
|
| 126 |
+
const cur = prev[targetRepoKey];
|
| 127 |
+
if (!cur) return prev;
|
| 128 |
+
return {
|
| 129 |
+
...prev,
|
| 130 |
+
[targetRepoKey]: { ...cur, currentBranch: newBranch },
|
| 131 |
+
};
|
| 132 |
+
});
|
| 133 |
+
}, []);
|
| 134 |
+
|
| 135 |
+
// Init / reconcile repo state when active repo changes
|
| 136 |
+
useEffect(() => {
|
| 137 |
+
if (!repoKey || !repo) return;
|
| 138 |
+
|
| 139 |
+
setRepoStateByKey((prev) => {
|
| 140 |
+
const existing = prev[repoKey];
|
| 141 |
+
const d = repo.default_branch || "main";
|
| 142 |
+
|
| 143 |
+
if (!existing) {
|
| 144 |
+
return {
|
| 145 |
+
...prev,
|
| 146 |
+
[repoKey]: {
|
| 147 |
+
defaultBranch: d,
|
| 148 |
+
currentBranch: d,
|
| 149 |
+
sessionBranches: [],
|
| 150 |
+
lastExecution: null,
|
| 151 |
+
pulseNonce: 0,
|
| 152 |
+
chatByBranch: {
|
| 153 |
+
[d]: { messages: [], plan: null },
|
| 154 |
+
},
|
| 155 |
+
},
|
| 156 |
+
};
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
const next = { ...existing };
|
| 160 |
+
next.defaultBranch = d;
|
| 161 |
+
|
| 162 |
+
if (!next.chatByBranch?.[d]) {
|
| 163 |
+
next.chatByBranch = {
|
| 164 |
+
...(next.chatByBranch || {}),
|
| 165 |
+
[d]: { messages: [], plan: null },
|
| 166 |
+
};
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
if (!next.currentBranch) next.currentBranch = d;
|
| 170 |
+
|
| 171 |
+
return { ...prev, [repoKey]: next };
|
| 172 |
+
});
|
| 173 |
+
}, [repoKey, repo?.id, repo?.default_branch]);
|
| 174 |
+
|
| 175 |
+
const showToast = (title, message) => {
|
| 176 |
+
setToast({ title, message });
|
| 177 |
+
window.setTimeout(() => setToast(null), 5000);
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
// ---------------------------------------------------------------------------
|
| 181 |
+
// Session management — every chat is backed by a Session (Claude Code parity)
|
| 182 |
+
// ---------------------------------------------------------------------------
|
| 183 |
+
|
| 184 |
+
// Guard against double-creation during concurrent send() calls
|
| 185 |
+
const _creatingSessionRef = useRef(false);
|
| 186 |
+
|
| 187 |
+
/**
|
| 188 |
+
* ensureSession — Create a session on-demand (implicit).
|
| 189 |
+
*
|
| 190 |
+
* Called by ChatPanel before the first message is sent. If a session
|
| 191 |
+
* already exists it returns the current ID immediately. Otherwise it
|
| 192 |
+
* creates one, seeds the initial messages into chatBySession so the
|
| 193 |
+
* useEffect reset doesn't wipe them, and returns the new ID.
|
| 194 |
+
*
|
| 195 |
+
* @param {string} [sessionName] — optional title (first user prompt, truncated)
|
| 196 |
+
* @param {Array} [seedMessages] — messages to pre-populate into the new session
|
| 197 |
+
* @returns {Promise<string|null>} the session ID
|
| 198 |
+
*/
|
| 199 |
+
const ensureSession = useCallback(async (sessionName, seedMessages) => {
|
| 200 |
+
if (activeSessionId) return activeSessionId;
|
| 201 |
+
if (!repo) return null;
|
| 202 |
+
if (_creatingSessionRef.current) return null; // already in flight
|
| 203 |
+
_creatingSessionRef.current = true;
|
| 204 |
+
|
| 205 |
+
try {
|
| 206 |
+
const token = localStorage.getItem("github_token");
|
| 207 |
+
const headers = {
|
| 208 |
+
"Content-Type": "application/json",
|
| 209 |
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
| 210 |
+
};
|
| 211 |
+
const res = await fetch("/api/sessions", {
|
| 212 |
+
method: "POST",
|
| 213 |
+
headers,
|
| 214 |
+
body: JSON.stringify({
|
| 215 |
+
repo_full_name: repoKey,
|
| 216 |
+
branch: currentBranch,
|
| 217 |
+
name: sessionName || undefined,
|
| 218 |
+
repos: contextRepos.map((e) => ({
|
| 219 |
+
full_name: e.repoKey,
|
| 220 |
+
branch: e.branch,
|
| 221 |
+
mode: e.repoKey === activeRepoKey ? "write" : "read",
|
| 222 |
+
})),
|
| 223 |
+
active_repo: activeRepoKey,
|
| 224 |
+
}),
|
| 225 |
+
});
|
| 226 |
+
if (!res.ok) return null;
|
| 227 |
+
const data = await res.json();
|
| 228 |
+
const newId = data.session_id;
|
| 229 |
+
|
| 230 |
+
// Seed the session's chat state BEFORE setting activeSessionId so
|
| 231 |
+
// the ChatPanel useEffect sync picks up the messages instead of []
|
| 232 |
+
if (seedMessages && seedMessages.length > 0) {
|
| 233 |
+
setChatBySession((prev) => ({
|
| 234 |
+
...prev,
|
| 235 |
+
[newId]: { messages: seedMessages, plan: null },
|
| 236 |
+
}));
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
setActiveSessionId(newId);
|
| 240 |
+
setSessionRefreshNonce((n) => n + 1);
|
| 241 |
+
return newId;
|
| 242 |
+
} catch (err) {
|
| 243 |
+
console.warn("Failed to create session:", err);
|
| 244 |
+
return null;
|
| 245 |
+
} finally {
|
| 246 |
+
_creatingSessionRef.current = false;
|
| 247 |
+
}
|
| 248 |
+
}, [activeSessionId, repo, repoKey, currentBranch, contextRepos, activeRepoKey]);
|
| 249 |
+
|
| 250 |
+
// Explicit "New Session" button — clears chat and starts fresh
|
| 251 |
+
const handleNewSession = async () => {
|
| 252 |
+
// Clear the current session so ensureSession creates a new one
|
| 253 |
+
setActiveSessionId(null);
|
| 254 |
+
try {
|
| 255 |
+
const token = localStorage.getItem("github_token");
|
| 256 |
+
const headers = {
|
| 257 |
+
"Content-Type": "application/json",
|
| 258 |
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
| 259 |
+
};
|
| 260 |
+
const res = await fetch("/api/sessions", {
|
| 261 |
+
method: "POST",
|
| 262 |
+
headers,
|
| 263 |
+
body: JSON.stringify({
|
| 264 |
+
repo_full_name: repoKey,
|
| 265 |
+
branch: currentBranch,
|
| 266 |
+
repos: contextRepos.map((e) => ({
|
| 267 |
+
full_name: e.repoKey,
|
| 268 |
+
branch: e.branch,
|
| 269 |
+
mode: e.repoKey === activeRepoKey ? "write" : "read",
|
| 270 |
+
})),
|
| 271 |
+
active_repo: activeRepoKey,
|
| 272 |
+
}),
|
| 273 |
+
});
|
| 274 |
+
if (!res.ok) return;
|
| 275 |
+
const data = await res.json();
|
| 276 |
+
setActiveSessionId(data.session_id);
|
| 277 |
+
setSessionRefreshNonce((n) => n + 1);
|
| 278 |
+
showToast("Session Created", `New session started.`);
|
| 279 |
+
} catch (err) {
|
| 280 |
+
console.warn("Failed to create session:", err);
|
| 281 |
+
}
|
| 282 |
+
};
|
| 283 |
+
|
| 284 |
+
const handleSelectSession = (session) => {
|
| 285 |
+
setActiveSessionId(session.id);
|
| 286 |
+
if (session.branch && session.branch !== currentBranch) {
|
| 287 |
+
handleBranchChange(session.branch);
|
| 288 |
+
}
|
| 289 |
+
};
|
| 290 |
+
|
| 291 |
+
// When a session is deleted: if it was the active session, clear the
|
| 292 |
+
// chat so the user returns to a fresh "new conversation" state.
|
| 293 |
+
// Non-active session deletions only affect the sidebar (handled there).
|
| 294 |
+
const handleDeleteSession = useCallback((deletedId) => {
|
| 295 |
+
if (deletedId === activeSessionId) {
|
| 296 |
+
setActiveSessionId(null);
|
| 297 |
+
// Clean up the in-memory chat state for the deleted session
|
| 298 |
+
setChatBySession((prev) => {
|
| 299 |
+
const next = { ...prev };
|
| 300 |
+
delete next[deletedId];
|
| 301 |
+
return next;
|
| 302 |
+
});
|
| 303 |
+
// Also clear the branch-keyed chat (the persistence effect may have
|
| 304 |
+
// written the first user message there before the session was created)
|
| 305 |
+
if (repoKey) {
|
| 306 |
+
setRepoStateByKey((prev) => {
|
| 307 |
+
const cur = prev[repoKey];
|
| 308 |
+
if (!cur) return prev;
|
| 309 |
+
const branchKey = cur.currentBranch || cur.defaultBranch || defaultBranch;
|
| 310 |
+
return {
|
| 311 |
+
...prev,
|
| 312 |
+
[repoKey]: {
|
| 313 |
+
...cur,
|
| 314 |
+
chatByBranch: {
|
| 315 |
+
...(cur.chatByBranch || {}),
|
| 316 |
+
[branchKey]: { messages: [], plan: null },
|
| 317 |
+
},
|
| 318 |
+
},
|
| 319 |
+
};
|
| 320 |
+
});
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
}, [activeSessionId, repoKey, defaultBranch]);
|
| 324 |
+
|
| 325 |
+
// ---------------------------------------------------------------------------
|
| 326 |
+
// Chat persistence helpers
|
| 327 |
+
// ---------------------------------------------------------------------------
|
| 328 |
+
const updateChatForCurrentBranch = (patch) => {
|
| 329 |
+
if (!repoKey) return;
|
| 330 |
+
|
| 331 |
+
setRepoStateByKey((prev) => {
|
| 332 |
+
const cur = prev[repoKey];
|
| 333 |
+
if (!cur) return prev;
|
| 334 |
+
|
| 335 |
+
const branchKey = cur.currentBranch || cur.defaultBranch || defaultBranch;
|
| 336 |
+
|
| 337 |
+
const existing = cur.chatByBranch?.[branchKey] || {
|
| 338 |
+
messages: [],
|
| 339 |
+
plan: null,
|
| 340 |
+
};
|
| 341 |
+
|
| 342 |
+
return {
|
| 343 |
+
...prev,
|
| 344 |
+
[repoKey]: {
|
| 345 |
+
...cur,
|
| 346 |
+
chatByBranch: {
|
| 347 |
+
...(cur.chatByBranch || {}),
|
| 348 |
+
[branchKey]: { ...existing, ...patch },
|
| 349 |
+
},
|
| 350 |
+
},
|
| 351 |
+
};
|
| 352 |
+
});
|
| 353 |
+
};
|
| 354 |
+
|
| 355 |
+
const currentChatState = useMemo(() => {
|
| 356 |
+
const b = currentBranch || defaultBranch;
|
| 357 |
+
return chatByBranch[b] || { messages: [], plan: null };
|
| 358 |
+
}, [chatByBranch, currentBranch, defaultBranch]);
|
| 359 |
+
|
| 360 |
+
// ---------------------------------------------------------------------------
|
| 361 |
+
// Session-scoped chat state: isolate messages per (session + branch) instead
|
| 362 |
+
// of per-branch alone. This prevents session A's messages from leaking into
|
| 363 |
+
// session B when both sessions share the same branch.
|
| 364 |
+
// ---------------------------------------------------------------------------
|
| 365 |
+
const [chatBySession, setChatBySession] = useState({});
|
| 366 |
+
|
| 367 |
+
const sessionChatState = useMemo(() => {
|
| 368 |
+
if (!activeSessionId) {
|
| 369 |
+
// No session — fall back to legacy branch-keyed chat
|
| 370 |
+
return currentChatState;
|
| 371 |
+
}
|
| 372 |
+
return chatBySession[activeSessionId] || { messages: [], plan: null };
|
| 373 |
+
}, [activeSessionId, chatBySession, currentChatState]);
|
| 374 |
+
|
| 375 |
+
const updateSessionChat = (patch) => {
|
| 376 |
+
if (activeSessionId) {
|
| 377 |
+
setChatBySession((prev) => ({
|
| 378 |
+
...prev,
|
| 379 |
+
[activeSessionId]: {
|
| 380 |
+
...(prev[activeSessionId] || { messages: [], plan: null }),
|
| 381 |
+
...patch,
|
| 382 |
+
},
|
| 383 |
+
}));
|
| 384 |
+
} else {
|
| 385 |
+
// No active session — use legacy branch-keyed persistence
|
| 386 |
+
updateChatForCurrentBranch(patch);
|
| 387 |
+
}
|
| 388 |
+
};
|
| 389 |
+
|
| 390 |
+
// ---------------------------------------------------------------------------
|
| 391 |
+
// Branch change (manual — for active repo)
|
| 392 |
+
// ---------------------------------------------------------------------------
|
| 393 |
+
const handleBranchChange = (nextBranch) => {
|
| 394 |
+
if (!repoKey) return;
|
| 395 |
+
if (!nextBranch || nextBranch === currentBranch) return;
|
| 396 |
+
|
| 397 |
+
setRepoStateByKey((prev) => {
|
| 398 |
+
const cur = prev[repoKey];
|
| 399 |
+
if (!cur) return prev;
|
| 400 |
+
|
| 401 |
+
const nextState = { ...cur, currentBranch: nextBranch };
|
| 402 |
+
|
| 403 |
+
// If switching BACK to main/default -> clear main chat (new task start)
|
| 404 |
+
if (nextBranch === cur.defaultBranch) {
|
| 405 |
+
nextState.chatByBranch = {
|
| 406 |
+
...nextState.chatByBranch,
|
| 407 |
+
[nextBranch]: { messages: [], plan: null },
|
| 408 |
+
};
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
return { ...prev, [repoKey]: nextState };
|
| 412 |
+
});
|
| 413 |
+
|
| 414 |
+
// Also update contextRepos branch tracking
|
| 415 |
+
setContextRepos((prev) =>
|
| 416 |
+
prev.map((e) =>
|
| 417 |
+
e.repoKey === repoKey ? { ...e, branch: nextBranch } : e
|
| 418 |
+
)
|
| 419 |
+
);
|
| 420 |
+
|
| 421 |
+
if (nextBranch === defaultBranch) {
|
| 422 |
+
showToast("New Session", `Switched to ${defaultBranch}. Chat cleared.`);
|
| 423 |
+
} else {
|
| 424 |
+
showToast("Context Switched", `Now viewing ${nextBranch}.`);
|
| 425 |
+
}
|
| 426 |
+
};
|
| 427 |
+
|
| 428 |
+
// ---------------------------------------------------------------------------
|
| 429 |
+
// Execution complete
|
| 430 |
+
// ---------------------------------------------------------------------------
|
| 431 |
+
const handleExecutionComplete = ({
|
| 432 |
+
branch,
|
| 433 |
+
mode,
|
| 434 |
+
commit_url,
|
| 435 |
+
message,
|
| 436 |
+
completionMsg,
|
| 437 |
+
sourceBranch,
|
| 438 |
+
}) => {
|
| 439 |
+
if (!repoKey || !branch) return;
|
| 440 |
+
|
| 441 |
+
setRepoStateByKey((prev) => {
|
| 442 |
+
const cur =
|
| 443 |
+
prev[repoKey] || {
|
| 444 |
+
defaultBranch,
|
| 445 |
+
currentBranch: defaultBranch,
|
| 446 |
+
sessionBranches: [],
|
| 447 |
+
lastExecution: null,
|
| 448 |
+
pulseNonce: 0,
|
| 449 |
+
chatByBranch: { [defaultBranch]: { messages: [], plan: null } },
|
| 450 |
+
};
|
| 451 |
+
|
| 452 |
+
const next = { ...cur };
|
| 453 |
+
next.lastExecution = { mode, branch, ts: Date.now() };
|
| 454 |
+
|
| 455 |
+
if (!next.chatByBranch) next.chatByBranch = {};
|
| 456 |
+
|
| 457 |
+
const prevBranchKey =
|
| 458 |
+
sourceBranch || cur.currentBranch || cur.defaultBranch || defaultBranch;
|
| 459 |
+
|
| 460 |
+
const successSystemMsg = {
|
| 461 |
+
role: "system",
|
| 462 |
+
isSuccess: true,
|
| 463 |
+
link: commit_url,
|
| 464 |
+
content:
|
| 465 |
+
mode === "hard-switch"
|
| 466 |
+
? `🌱 **Session Started:** Created branch \`${branch}\`.`
|
| 467 |
+
: `✅ **Update Published:** Commits pushed to \`${branch}\`.`,
|
| 468 |
+
};
|
| 469 |
+
|
| 470 |
+
const normalizedCompletion =
|
| 471 |
+
completionMsg && (completionMsg.answer || completionMsg.content || completionMsg.executionLog)
|
| 472 |
+
? {
|
| 473 |
+
from: completionMsg.from || "ai",
|
| 474 |
+
role: completionMsg.role || "assistant",
|
| 475 |
+
answer: completionMsg.answer,
|
| 476 |
+
content: completionMsg.content,
|
| 477 |
+
executionLog: completionMsg.executionLog,
|
| 478 |
+
}
|
| 479 |
+
: null;
|
| 480 |
+
|
| 481 |
+
if (mode === "hard-switch") {
|
| 482 |
+
next.sessionBranches = uniq([...(next.sessionBranches || []), branch]);
|
| 483 |
+
next.currentBranch = branch;
|
| 484 |
+
next.pulseNonce = (next.pulseNonce || 0) + 1;
|
| 485 |
+
|
| 486 |
+
const existingTargetChat = next.chatByBranch[branch];
|
| 487 |
+
const isExistingSession =
|
| 488 |
+
existingTargetChat && (existingTargetChat.messages || []).length > 0;
|
| 489 |
+
|
| 490 |
+
if (isExistingSession) {
|
| 491 |
+
const appended = [
|
| 492 |
+
...(existingTargetChat.messages || []),
|
| 493 |
+
...(normalizedCompletion ? [normalizedCompletion] : []),
|
| 494 |
+
successSystemMsg,
|
| 495 |
+
];
|
| 496 |
+
|
| 497 |
+
next.chatByBranch[branch] = {
|
| 498 |
+
...existingTargetChat,
|
| 499 |
+
messages: appended,
|
| 500 |
+
plan: null,
|
| 501 |
+
};
|
| 502 |
+
} else {
|
| 503 |
+
const prevChat =
|
| 504 |
+
(cur.chatByBranch && cur.chatByBranch[prevBranchKey]) || { messages: [], plan: null };
|
| 505 |
+
|
| 506 |
+
next.chatByBranch[branch] = {
|
| 507 |
+
messages: [
|
| 508 |
+
...(prevChat.messages || []),
|
| 509 |
+
...(normalizedCompletion ? [normalizedCompletion] : []),
|
| 510 |
+
successSystemMsg,
|
| 511 |
+
],
|
| 512 |
+
plan: null,
|
| 513 |
+
};
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
if (!next.chatByBranch[next.defaultBranch]) {
|
| 517 |
+
next.chatByBranch[next.defaultBranch] = { messages: [], plan: null };
|
| 518 |
+
}
|
| 519 |
+
} else if (mode === "sticky") {
|
| 520 |
+
next.currentBranch = cur.currentBranch || branch;
|
| 521 |
+
|
| 522 |
+
const targetChat = next.chatByBranch[branch] || { messages: [], plan: null };
|
| 523 |
+
|
| 524 |
+
next.chatByBranch[branch] = {
|
| 525 |
+
messages: [
|
| 526 |
+
...(targetChat.messages || []),
|
| 527 |
+
...(normalizedCompletion ? [normalizedCompletion] : []),
|
| 528 |
+
successSystemMsg,
|
| 529 |
+
],
|
| 530 |
+
plan: null,
|
| 531 |
+
};
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
return { ...prev, [repoKey]: next };
|
| 535 |
+
});
|
| 536 |
+
|
| 537 |
+
if (mode === "hard-switch") {
|
| 538 |
+
showToast("Context Switched", `Active on ${branch}.`);
|
| 539 |
+
} else {
|
| 540 |
+
showToast("Changes Committed", `Updated ${branch}.`);
|
| 541 |
+
}
|
| 542 |
+
};
|
| 543 |
+
|
| 544 |
+
// ---------------------------------------------------------------------------
|
| 545 |
+
// Auth & Render
|
| 546 |
+
// ---------------------------------------------------------------------------
|
| 547 |
+
useEffect(() => {
|
| 548 |
+
checkAuthentication();
|
| 549 |
+
}, []);
|
| 550 |
+
|
| 551 |
+
const checkAuthentication = async () => {
|
| 552 |
+
const token = localStorage.getItem("github_token");
|
| 553 |
+
const user = localStorage.getItem("github_user");
|
| 554 |
+
if (token && user) {
|
| 555 |
+
try {
|
| 556 |
+
const data = await safeFetchJSON(apiUrl("/api/auth/validate"), {
|
| 557 |
+
method: "POST",
|
| 558 |
+
headers: { "Content-Type": "application/json" },
|
| 559 |
+
body: JSON.stringify({ access_token: token }),
|
| 560 |
+
});
|
| 561 |
+
if (data.authenticated) {
|
| 562 |
+
setIsAuthenticated(true);
|
| 563 |
+
setUserInfo(JSON.parse(user));
|
| 564 |
+
setIsLoading(false);
|
| 565 |
+
return;
|
| 566 |
+
}
|
| 567 |
+
} catch (err) {
|
| 568 |
+
console.error(err);
|
| 569 |
+
}
|
| 570 |
+
localStorage.removeItem("github_token");
|
| 571 |
+
localStorage.removeItem("github_user");
|
| 572 |
+
}
|
| 573 |
+
setIsAuthenticated(false);
|
| 574 |
+
setIsLoading(false);
|
| 575 |
+
};
|
| 576 |
+
|
| 577 |
+
const handleAuthenticated = (session) => {
|
| 578 |
+
setIsAuthenticated(true);
|
| 579 |
+
setUserInfo(session.user);
|
| 580 |
+
};
|
| 581 |
+
|
| 582 |
+
const handleLogout = () => {
|
| 583 |
+
localStorage.removeItem("github_token");
|
| 584 |
+
localStorage.removeItem("github_user");
|
| 585 |
+
setIsAuthenticated(false);
|
| 586 |
+
setUserInfo(null);
|
| 587 |
+
clearAllContext();
|
| 588 |
+
};
|
| 589 |
+
|
| 590 |
+
if (isLoading)
|
| 591 |
+
return (
|
| 592 |
+
<div className="app-root">
|
| 593 |
+
<div className="loading-spinner"></div>
|
| 594 |
+
</div>
|
| 595 |
+
);
|
| 596 |
+
|
| 597 |
+
if (!isAuthenticated) return <LoginPage onAuthenticated={handleAuthenticated} />;
|
| 598 |
+
|
| 599 |
+
const hasContext = contextRepos.length > 0;
|
| 600 |
+
|
| 601 |
+
return (
|
| 602 |
+
<div className="app-root">
|
| 603 |
+
<div className="main-wrapper">
|
| 604 |
+
<aside className="sidebar">
|
| 605 |
+
{/* ---- Brand ---- */}
|
| 606 |
+
<div className="logo-row">
|
| 607 |
+
<div className="logo-square">GP</div>
|
| 608 |
+
<div>
|
| 609 |
+
<div className="logo-title">GitPilot</div>
|
| 610 |
+
<div className="logo-subtitle">Agentic GitHub Copilot</div>
|
| 611 |
+
</div>
|
| 612 |
+
</div>
|
| 613 |
+
|
| 614 |
+
{/* ---- Navigation ---- */}
|
| 615 |
+
<div className="main-nav">
|
| 616 |
+
<button
|
| 617 |
+
className={"nav-btn" + (activePage === "workspace" ? " nav-btn-active" : "")}
|
| 618 |
+
onClick={() => setActivePage("workspace")}
|
| 619 |
+
>
|
| 620 |
+
Workspace
|
| 621 |
+
</button>
|
| 622 |
+
<button
|
| 623 |
+
className={"nav-btn" + (activePage === "flow" ? " nav-btn-active" : "")}
|
| 624 |
+
onClick={() => setActivePage("flow")}
|
| 625 |
+
>
|
| 626 |
+
Agent Workflow
|
| 627 |
+
</button>
|
| 628 |
+
<button
|
| 629 |
+
className={"nav-btn" + (activePage === "admin" ? " nav-btn-active" : "")}
|
| 630 |
+
onClick={() => setActivePage("admin")}
|
| 631 |
+
>
|
| 632 |
+
Admin
|
| 633 |
+
</button>
|
| 634 |
+
</div>
|
| 635 |
+
|
| 636 |
+
{/* ---- Repository Switcher (shown when no context) ---- */}
|
| 637 |
+
{!hasContext && (
|
| 638 |
+
<RepoSelector onSelect={(r) => addRepoToContext(r)} />
|
| 639 |
+
)}
|
| 640 |
+
|
| 641 |
+
{/* ---- Sessions ---- */}
|
| 642 |
+
{repo && (
|
| 643 |
+
<SessionSidebar
|
| 644 |
+
repo={repo}
|
| 645 |
+
activeSessionId={activeSessionId}
|
| 646 |
+
onSelectSession={handleSelectSession}
|
| 647 |
+
onNewSession={handleNewSession}
|
| 648 |
+
onDeleteSession={handleDeleteSession}
|
| 649 |
+
refreshNonce={sessionRefreshNonce}
|
| 650 |
+
/>
|
| 651 |
+
)}
|
| 652 |
+
|
| 653 |
+
{/* ---- User ---- */}
|
| 654 |
+
{userInfo && (
|
| 655 |
+
<div className="user-profile">
|
| 656 |
+
<div className="user-profile-header">
|
| 657 |
+
<img src={userInfo.avatar_url} alt={userInfo.login} className="user-avatar" />
|
| 658 |
+
<div className="user-info">
|
| 659 |
+
<div className="user-name">{userInfo.name || userInfo.login}</div>
|
| 660 |
+
<div className="user-login">@{userInfo.login}</div>
|
| 661 |
+
</div>
|
| 662 |
+
</div>
|
| 663 |
+
<button className="btn-logout" onClick={handleLogout}>
|
| 664 |
+
Logout
|
| 665 |
+
</button>
|
| 666 |
+
</div>
|
| 667 |
+
)}
|
| 668 |
+
</aside>
|
| 669 |
+
|
| 670 |
+
<main className="workspace">
|
| 671 |
+
{activePage === "admin" && (
|
| 672 |
+
<div style={{ padding: "24px", maxWidth: "960px", margin: "0 auto" }}>
|
| 673 |
+
{/* Admin Navigation */}
|
| 674 |
+
<div style={{ display: "flex", gap: "8px", marginBottom: "24px", flexWrap: "wrap" }}>
|
| 675 |
+
{["overview", "providers", "workspace-modes", "integrations", "sessions", "skills", "security", "advanced"].map(tab => (
|
| 676 |
+
<button
|
| 677 |
+
key={tab}
|
| 678 |
+
onClick={() => setAdminTab(tab)}
|
| 679 |
+
style={{
|
| 680 |
+
padding: "8px 16px",
|
| 681 |
+
borderRadius: "6px",
|
| 682 |
+
border: adminTab === tab ? "1px solid #3B82F6" : "1px solid #333",
|
| 683 |
+
background: adminTab === tab ? "#1e3a5f" : "#1a1b26",
|
| 684 |
+
color: adminTab === tab ? "#93c5fd" : "#a0a0b0",
|
| 685 |
+
cursor: "pointer",
|
| 686 |
+
fontSize: "13px",
|
| 687 |
+
textTransform: "capitalize",
|
| 688 |
+
}}
|
| 689 |
+
>
|
| 690 |
+
{tab.replace("-", " ")}
|
| 691 |
+
</button>
|
| 692 |
+
))}
|
| 693 |
+
</div>
|
| 694 |
+
|
| 695 |
+
{/* Overview */}
|
| 696 |
+
{adminTab === "overview" && (
|
| 697 |
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "16px" }}>
|
| 698 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 699 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Server</div>
|
| 700 |
+
<div style={{ fontSize: "16px", fontWeight: 600 }}>{adminStatus?.server_ready ? "Connected" : "Checking..."}</div>
|
| 701 |
+
<div style={{ fontSize: "12px", opacity: 0.5 }}>127.0.0.1:8000</div>
|
| 702 |
+
</div>
|
| 703 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 704 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Provider</div>
|
| 705 |
+
<div style={{ fontSize: "16px", fontWeight: 600 }}>{adminStatus?.provider?.name || "Loading..."}</div>
|
| 706 |
+
<div style={{ fontSize: "12px", opacity: 0.5 }}>{adminStatus?.provider?.configured ? `${adminStatus.provider.model || "Ready"}` : "Not configured"}</div>
|
| 707 |
+
</div>
|
| 708 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 709 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Workspace Modes</div>
|
| 710 |
+
<div style={{ fontSize: "12px" }}>Folder: {adminStatus?.workspace?.folder_mode_available ? "Yes" : "—"}</div>
|
| 711 |
+
<div style={{ fontSize: "12px" }}>Local Git: {adminStatus?.workspace?.local_git_available ? "Yes" : "—"}</div>
|
| 712 |
+
<div style={{ fontSize: "12px" }}>GitHub: {adminStatus?.workspace?.github_mode_available ? "Yes" : "Optional"}</div>
|
| 713 |
+
</div>
|
| 714 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 715 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>GitHub</div>
|
| 716 |
+
<div style={{ fontSize: "14px" }}>{adminStatus?.github?.connected ? "Connected" : "Optional"}</div>
|
| 717 |
+
<div style={{ fontSize: "12px", opacity: 0.5 }}>{adminStatus?.github?.username || "Not linked"}</div>
|
| 718 |
+
</div>
|
| 719 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 720 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Sessions</div>
|
| 721 |
+
<div style={{ fontSize: "14px" }}>—</div>
|
| 722 |
+
</div>
|
| 723 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
|
| 724 |
+
<div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Get Started</div>
|
| 725 |
+
<button onClick={() => setAdminTab("providers")} style={{ padding: "6px 12px", background: "#3B82F6", color: "#fff", border: "none", borderRadius: "4px", cursor: "pointer", fontSize: "12px", marginRight: "4px" }}>Configure Provider</button>
|
| 726 |
+
</div>
|
| 727 |
+
</div>
|
| 728 |
+
)}
|
| 729 |
+
|
| 730 |
+
{/* Providers */}
|
| 731 |
+
{adminTab === "providers" && (
|
| 732 |
+
<div>
|
| 733 |
+
<h3 style={{ marginBottom: "16px" }}>AI Providers</h3>
|
| 734 |
+
<LlmSettings />
|
| 735 |
+
</div>
|
| 736 |
+
)}
|
| 737 |
+
|
| 738 |
+
{/* Workspace Modes */}
|
| 739 |
+
{adminTab === "workspace-modes" && (
|
| 740 |
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "16px" }}>
|
| 741 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "20px", border: "1px solid #2a2b36" }}>
|
| 742 |
+
<h4 style={{ marginBottom: "8px" }}>Folder Mode</h4>
|
| 743 |
+
<p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "12px" }}>Work with any local folder. No Git required.</p>
|
| 744 |
+
<div style={{ fontSize: "12px" }}>Requires: Open folder</div>
|
| 745 |
+
<div style={{ fontSize: "12px" }}>Enables: Chat, explain, review</div>
|
| 746 |
+
</div>
|
| 747 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "20px", border: "1px solid #2a2b36" }}>
|
| 748 |
+
<h4 style={{ marginBottom: "8px" }}>Local Git Mode</h4>
|
| 749 |
+
<p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "12px" }}>Full repo + branch context for AI assistance.</p>
|
| 750 |
+
<div style={{ fontSize: "12px" }}>Requires: Git repository</div>
|
| 751 |
+
<div style={{ fontSize: "12px" }}>Enables: All local features</div>
|
| 752 |
+
</div>
|
| 753 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "20px", border: "1px solid #2a2b36" }}>
|
| 754 |
+
<h4 style={{ marginBottom: "8px" }}>GitHub Mode</h4>
|
| 755 |
+
<p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "12px" }}>PRs, issues, remote workflows via GitHub API.</p>
|
| 756 |
+
<div style={{ fontSize: "12px" }}>Requires: GitHub token</div>
|
| 757 |
+
<div style={{ fontSize: "12px" }}>Enables: Full platform features</div>
|
| 758 |
+
</div>
|
| 759 |
+
</div>
|
| 760 |
+
)}
|
| 761 |
+
|
| 762 |
+
{/* Integrations */}
|
| 763 |
+
{adminTab === "integrations" && (
|
| 764 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "20px", border: "1px solid #2a2b36" }}>
|
| 765 |
+
<h4 style={{ marginBottom: "8px" }}>GitHub Integration</h4>
|
| 766 |
+
<p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "12px" }}>GitHub is optional. Connect to enable PRs, issues, and remote workflows.</p>
|
| 767 |
+
<button style={{ padding: "8px 16px", background: "#3B82F6", color: "#fff", border: "none", borderRadius: "4px", cursor: "pointer" }}>Connect GitHub</button>
|
| 768 |
+
</div>
|
| 769 |
+
)}
|
| 770 |
+
|
| 771 |
+
{/* Security */}
|
| 772 |
+
{adminTab === "security" && (
|
| 773 |
+
<div style={{ background: "#1a1b26", borderRadius: "8px", padding: "20px", border: "1px solid #2a2b36" }}>
|
| 774 |
+
<h4 style={{ marginBottom: "8px" }}>Security Scanning</h4>
|
| 775 |
+
<p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "12px" }}>Run security scans on your workspace to detect vulnerabilities, secrets, and code issues.</p>
|
| 776 |
+
<button style={{ padding: "8px 16px", background: "#3B82F6", color: "#fff", border: "none", borderRadius: "4px", cursor: "pointer" }}>Scan Workspace</button>
|
| 777 |
+
</div>
|
| 778 |
+
)}
|
| 779 |
+
|
| 780 |
+
{/* Sessions */}
|
| 781 |
+
{adminTab === "sessions" && (
|
| 782 |
+
<div>
|
| 783 |
+
<h3 style={{ marginBottom: "16px" }}>Sessions</h3>
|
| 784 |
+
<p style={{ fontSize: "12px", opacity: 0.7 }}>Session management is available in the main workspace view.</p>
|
| 785 |
+
</div>
|
| 786 |
+
)}
|
| 787 |
+
|
| 788 |
+
{/* Skills & Plugins */}
|
| 789 |
+
{adminTab === "skills" && (
|
| 790 |
+
<div>
|
| 791 |
+
<h3 style={{ marginBottom: "16px" }}>Skills & Plugins</h3>
|
| 792 |
+
<p style={{ fontSize: "12px", opacity: 0.7 }}>Skills and plugins extend GitPilot capabilities. View and manage them from the main workspace.</p>
|
| 793 |
+
</div>
|
| 794 |
+
)}
|
| 795 |
+
|
| 796 |
+
{/* Advanced */}
|
| 797 |
+
{adminTab === "advanced" && (
|
| 798 |
+
<div>
|
| 799 |
+
<h3 style={{ marginBottom: "16px" }}>Advanced Settings</h3>
|
| 800 |
+
<p style={{ fontSize: "12px", opacity: 0.7 }}>Advanced configuration options are available in the Settings modal.</p>
|
| 801 |
+
<button onClick={() => setSettingsOpen(true)} style={{ padding: "8px 16px", background: "#3B82F6", color: "#fff", border: "none", borderRadius: "4px", cursor: "pointer", marginTop: "12px" }}>Open Settings</button>
|
| 802 |
+
</div>
|
| 803 |
+
)}
|
| 804 |
+
</div>
|
| 805 |
+
)}
|
| 806 |
+
{activePage === "flow" && <FlowViewer />}
|
| 807 |
+
{activePage === "workspace" &&
|
| 808 |
+
(repo ? (
|
| 809 |
+
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
| 810 |
+
{/* ---- Context Bar (single source of truth for repo selection) ---- */}
|
| 811 |
+
<ContextBar
|
| 812 |
+
contextRepos={contextRepos}
|
| 813 |
+
activeRepoKey={activeRepoKey}
|
| 814 |
+
repoStateByKey={repoStateByKey}
|
| 815 |
+
onActivate={setActiveRepoKey}
|
| 816 |
+
onRemove={removeRepoFromContext}
|
| 817 |
+
onAdd={() => setAddRepoOpen(true)}
|
| 818 |
+
onBranchChange={handleContextBranchChange}
|
| 819 |
+
/>
|
| 820 |
+
|
| 821 |
+
<div className="workspace-grid" style={{ flex: 1 }}>
|
| 822 |
+
<aside className="gp-context-column">
|
| 823 |
+
<ProjectContextPanel
|
| 824 |
+
repo={repo}
|
| 825 |
+
defaultBranch={defaultBranch}
|
| 826 |
+
currentBranch={currentBranch}
|
| 827 |
+
sessionBranches={sessionBranches}
|
| 828 |
+
onBranchChange={handleBranchChange}
|
| 829 |
+
pulseNonce={pulseNonce}
|
| 830 |
+
lastExecution={lastExecution}
|
| 831 |
+
onSettingsClick={() => setSettingsOpen(true)}
|
| 832 |
+
/>
|
| 833 |
+
</aside>
|
| 834 |
+
|
| 835 |
+
<main className="gp-chat-column">
|
| 836 |
+
<div className="panel-header">
|
| 837 |
+
<span>GitPilot chat</span>
|
| 838 |
+
</div>
|
| 839 |
+
|
| 840 |
+
<ChatPanel
|
| 841 |
+
repo={repo}
|
| 842 |
+
defaultBranch={defaultBranch}
|
| 843 |
+
currentBranch={currentBranch}
|
| 844 |
+
onExecutionComplete={handleExecutionComplete}
|
| 845 |
+
sessionChatState={sessionChatState}
|
| 846 |
+
onSessionChatStateChange={updateSessionChat}
|
| 847 |
+
sessionId={activeSessionId}
|
| 848 |
+
onEnsureSession={ensureSession}
|
| 849 |
+
/>
|
| 850 |
+
</main>
|
| 851 |
+
</div>
|
| 852 |
+
</div>
|
| 853 |
+
) : (
|
| 854 |
+
<div className="empty-state">
|
| 855 |
+
<div className="empty-bot">🤖</div>
|
| 856 |
+
<h1>Select a repository</h1>
|
| 857 |
+
<p>Select a repo to begin agentic workflow.</p>
|
| 858 |
+
</div>
|
| 859 |
+
))}
|
| 860 |
+
</main>
|
| 861 |
+
</div>
|
| 862 |
+
|
| 863 |
+
<Footer />
|
| 864 |
+
|
| 865 |
+
{repo && (
|
| 866 |
+
<ProjectSettingsModal
|
| 867 |
+
owner={repo.full_name?.split("/")[0] || repo.owner}
|
| 868 |
+
repo={repo.full_name?.split("/")[1] || repo.name}
|
| 869 |
+
isOpen={settingsOpen}
|
| 870 |
+
onClose={() => setSettingsOpen(false)}
|
| 871 |
+
activeEnvId={activeEnvId}
|
| 872 |
+
onEnvChange={setActiveEnvId}
|
| 873 |
+
/>
|
| 874 |
+
)}
|
| 875 |
+
|
| 876 |
+
{/* Add Repo Modal */}
|
| 877 |
+
<AddRepoModal
|
| 878 |
+
isOpen={addRepoOpen}
|
| 879 |
+
onSelect={addRepoToContext}
|
| 880 |
+
onClose={() => setAddRepoOpen(false)}
|
| 881 |
+
excludeKeys={contextRepos.map((e) => e.repoKey)}
|
| 882 |
+
/>
|
| 883 |
+
|
| 884 |
+
{toast && (
|
| 885 |
+
<div className="toast-notification">
|
| 886 |
+
<div style={{ fontSize: 12, fontWeight: 700 }}>{toast.title}</div>
|
| 887 |
+
<div style={{ fontSize: 12, opacity: 0.82 }}>{toast.message}</div>
|
| 888 |
+
</div>
|
| 889 |
+
)}
|
| 890 |
+
|
| 891 |
+
<style>{`
|
| 892 |
+
.toast-notification {
|
| 893 |
+
position: fixed;
|
| 894 |
+
top: 72px;
|
| 895 |
+
right: 18px;
|
| 896 |
+
z-index: 9999;
|
| 897 |
+
background: #0b0b0d;
|
| 898 |
+
color: #EDEDED;
|
| 899 |
+
border: 1px solid rgba(255,255,255,0.12);
|
| 900 |
+
border-left: 3px solid #3B82F6;
|
| 901 |
+
border-radius: 10px;
|
| 902 |
+
padding: 12px 14px;
|
| 903 |
+
min-width: 320px;
|
| 904 |
+
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
| 905 |
+
}
|
| 906 |
+
`}</style>
|
| 907 |
+
</div>
|
| 908 |
+
);
|
| 909 |
+
}
|
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/AssistantMessage.jsx
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
return (
|
| 73 |
+
<div className="chat-message-ai" style={styles.container}>
|
| 74 |
+
{/* Answer section */}
|
| 75 |
+
<section style={styles.section}>
|
| 76 |
+
<header style={styles.header}>
|
| 77 |
+
<h3 style={styles.title}>Answer</h3>
|
| 78 |
+
</header>
|
| 79 |
+
<div style={styles.content}>
|
| 80 |
+
<p style={{ margin: 0 }}>{answer}</p>
|
| 81 |
+
</div>
|
| 82 |
+
</section>
|
| 83 |
+
|
| 84 |
+
{/* Action Plan section */}
|
| 85 |
+
{plan && (
|
| 86 |
+
<section style={styles.section}>
|
| 87 |
+
<header style={styles.header}>
|
| 88 |
+
<h3 style={{ ...styles.title, color: "#D95C3D" }}>Action Plan</h3>
|
| 89 |
+
</header>
|
| 90 |
+
<div>
|
| 91 |
+
<PlanView plan={plan} />
|
| 92 |
+
</div>
|
| 93 |
+
</section>
|
| 94 |
+
)}
|
| 95 |
+
|
| 96 |
+
{/* Execution Log section (shown after execution) */}
|
| 97 |
+
{executionLog && (
|
| 98 |
+
<section style={styles.lastSection}>
|
| 99 |
+
<header style={styles.header}>
|
| 100 |
+
<h3 style={{ ...styles.title, color: "#10B981" }}>Execution Log</h3>
|
| 101 |
+
</header>
|
| 102 |
+
<div>
|
| 103 |
+
<ul style={styles.executionList}>
|
| 104 |
+
{executionLog.steps.map((s) => (
|
| 105 |
+
<li key={s.step_number} style={styles.executionStep}>
|
| 106 |
+
<span style={styles.stepNumber}>Step {s.step_number}</span>
|
| 107 |
+
<span style={styles.stepSummary}>{s.summary}</span>
|
| 108 |
+
</li>
|
| 109 |
+
))}
|
| 110 |
+
</ul>
|
| 111 |
+
</div>
|
| 112 |
+
</section>
|
| 113 |
+
)}
|
| 114 |
+
</div>
|
| 115 |
+
);
|
| 116 |
+
}
|
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,686 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
// Optimistic update (user bubble appears immediately)
|
| 205 |
+
const userMsg = { from: "user", role: "user", text, content: text };
|
| 206 |
+
setMessages((prev) => [...prev, userMsg]);
|
| 207 |
+
|
| 208 |
+
setLoadingPlan(true);
|
| 209 |
+
setStatus("");
|
| 210 |
+
setPlan(null);
|
| 211 |
+
setStreamingEvents([]);
|
| 212 |
+
|
| 213 |
+
// ------- Implicit session creation (Claude Code parity) -------
|
| 214 |
+
// Every chat must be backed by a session. If none exists yet,
|
| 215 |
+
// create one on-demand before sending the plan request.
|
| 216 |
+
let sid = sessionId;
|
| 217 |
+
if (!sid && typeof onEnsureSession === "function") {
|
| 218 |
+
// Derive a short title from the first message
|
| 219 |
+
const sessionName = text.length > 60 ? text.slice(0, 57) + "..." : text;
|
| 220 |
+
|
| 221 |
+
// Tell the sync useEffect to skip the reset that would otherwise
|
| 222 |
+
// wipe the optimistic user message when activeSessionId changes.
|
| 223 |
+
skipNextSyncRef.current = true;
|
| 224 |
+
|
| 225 |
+
sid = await onEnsureSession(sessionName, [userMsg]);
|
| 226 |
+
if (!sid) {
|
| 227 |
+
// Session creation failed — continue without session
|
| 228 |
+
skipNextSyncRef.current = false;
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
// Persist user message to backend session
|
| 233 |
+
persistMessage(sid, "user", text);
|
| 234 |
+
|
| 235 |
+
// Always use HTTP for plan generation (the original reliable flow).
|
| 236 |
+
// WebSocket is only used for real-time streaming feedback display.
|
| 237 |
+
const effectiveBranch = currentBranch || defaultBranch || "HEAD";
|
| 238 |
+
|
| 239 |
+
try {
|
| 240 |
+
const res = await fetch("/api/chat/plan", {
|
| 241 |
+
method: "POST",
|
| 242 |
+
headers: getHeaders(),
|
| 243 |
+
body: JSON.stringify({
|
| 244 |
+
repo_owner: repo.owner,
|
| 245 |
+
repo_name: repo.name,
|
| 246 |
+
goal: text,
|
| 247 |
+
branch_name: effectiveBranch,
|
| 248 |
+
}),
|
| 249 |
+
});
|
| 250 |
+
|
| 251 |
+
const data = await res.json();
|
| 252 |
+
if (!res.ok) throw new Error(data.detail || "Failed to generate plan");
|
| 253 |
+
|
| 254 |
+
setPlan(data);
|
| 255 |
+
|
| 256 |
+
// Extract summary from nested plan structure or top-level
|
| 257 |
+
const summary =
|
| 258 |
+
data.plan?.summary || data.summary || data.message ||
|
| 259 |
+
"Here is the proposed plan for your request.";
|
| 260 |
+
|
| 261 |
+
// Assistant response (Answer + Action Plan)
|
| 262 |
+
setMessages((prev) => [
|
| 263 |
+
...prev,
|
| 264 |
+
{
|
| 265 |
+
from: "ai",
|
| 266 |
+
role: "assistant",
|
| 267 |
+
answer: summary,
|
| 268 |
+
content: summary,
|
| 269 |
+
plan: data,
|
| 270 |
+
},
|
| 271 |
+
]);
|
| 272 |
+
|
| 273 |
+
// Persist assistant response to backend session
|
| 274 |
+
persistMessage(sid, "assistant", summary);
|
| 275 |
+
|
| 276 |
+
// Clear input only after success
|
| 277 |
+
setGoal("");
|
| 278 |
+
} catch (err) {
|
| 279 |
+
const msg = String(err?.message || err);
|
| 280 |
+
console.error(err);
|
| 281 |
+
setStatus(msg);
|
| 282 |
+
setMessages((prev) => [
|
| 283 |
+
...prev,
|
| 284 |
+
{ from: "ai", role: "system", content: `Error: ${msg}` },
|
| 285 |
+
]);
|
| 286 |
+
} finally {
|
| 287 |
+
setLoadingPlan(false);
|
| 288 |
+
}
|
| 289 |
+
};
|
| 290 |
+
|
| 291 |
+
const execute = async () => {
|
| 292 |
+
if (!repo || !plan) return;
|
| 293 |
+
|
| 294 |
+
setExecuting(true);
|
| 295 |
+
setStatus("");
|
| 296 |
+
|
| 297 |
+
try {
|
| 298 |
+
// Guard: currentBranch might be missing if parent didn't pass it yet
|
| 299 |
+
const safeCurrent = currentBranch || defaultBranch || "HEAD";
|
| 300 |
+
const safeDefault = defaultBranch || "main";
|
| 301 |
+
|
| 302 |
+
// Sticky vs Hard Switch:
|
| 303 |
+
// - If on default branch -> undefined (backend creates new branch)
|
| 304 |
+
// - If already on AI branch -> currentBranch (backend updates existing)
|
| 305 |
+
const branch_name = safeCurrent === safeDefault ? undefined : safeCurrent;
|
| 306 |
+
|
| 307 |
+
const res = await fetch("/api/chat/execute", {
|
| 308 |
+
method: "POST",
|
| 309 |
+
headers: getHeaders(),
|
| 310 |
+
body: JSON.stringify({
|
| 311 |
+
repo_owner: repo.owner,
|
| 312 |
+
repo_name: repo.name,
|
| 313 |
+
plan,
|
| 314 |
+
branch_name,
|
| 315 |
+
}),
|
| 316 |
+
});
|
| 317 |
+
|
| 318 |
+
const data = await res.json();
|
| 319 |
+
if (!res.ok) throw new Error(data.detail || "Execution failed");
|
| 320 |
+
|
| 321 |
+
setStatus(data.message || "Execution completed.");
|
| 322 |
+
|
| 323 |
+
const completionMsg = {
|
| 324 |
+
from: "ai",
|
| 325 |
+
role: "assistant",
|
| 326 |
+
answer: data.message || "Execution completed.",
|
| 327 |
+
content: data.message || "Execution completed.",
|
| 328 |
+
executionLog: data.executionLog,
|
| 329 |
+
};
|
| 330 |
+
|
| 331 |
+
// Show completion immediately (keeps old "Execution Log" section)
|
| 332 |
+
setMessages((prev) => [...prev, completionMsg]);
|
| 333 |
+
|
| 334 |
+
// Clear active plan UI
|
| 335 |
+
setPlan(null);
|
| 336 |
+
|
| 337 |
+
// Pass completionMsg upward for seeding branch history
|
| 338 |
+
if (typeof onExecutionComplete === "function") {
|
| 339 |
+
onExecutionComplete({
|
| 340 |
+
branch: data.branch || data.branch_name,
|
| 341 |
+
mode: data.mode,
|
| 342 |
+
commit_url: data.commit_url || data.html_url,
|
| 343 |
+
message: data.message,
|
| 344 |
+
completionMsg,
|
| 345 |
+
sourceBranch: safeCurrent,
|
| 346 |
+
});
|
| 347 |
+
}
|
| 348 |
+
} catch (err) {
|
| 349 |
+
console.error(err);
|
| 350 |
+
setStatus(String(err?.message || err));
|
| 351 |
+
} finally {
|
| 352 |
+
setExecuting(false);
|
| 353 |
+
}
|
| 354 |
+
};
|
| 355 |
+
|
| 356 |
+
// ---------------------------------------------------------------------------
|
| 357 |
+
// RENDER
|
| 358 |
+
// ---------------------------------------------------------------------------
|
| 359 |
+
const isOnSessionBranch = currentBranch && currentBranch !== defaultBranch;
|
| 360 |
+
|
| 361 |
+
return (
|
| 362 |
+
<div className="chat-container">
|
| 363 |
+
<style>{`
|
| 364 |
+
.chat-container { display: flex; flex-direction: column; height: 100%; }
|
| 365 |
+
|
| 366 |
+
.chat-messages {
|
| 367 |
+
flex: 1; overflow-y: auto;
|
| 368 |
+
padding: 20px;
|
| 369 |
+
display: flex; flex-direction: column; gap: 16px;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
.chat-message-user {
|
| 373 |
+
align-self: flex-end;
|
| 374 |
+
background: #27272A;
|
| 375 |
+
color: #fff;
|
| 376 |
+
padding: 12px 16px;
|
| 377 |
+
border-radius: 10px;
|
| 378 |
+
max-width: 85%;
|
| 379 |
+
font-size: 14px;
|
| 380 |
+
line-height: 1.5;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
/* Success System Message Styling */
|
| 384 |
+
.chat-msg-success {
|
| 385 |
+
align-self: flex-start;
|
| 386 |
+
width: 100%;
|
| 387 |
+
background: rgba(16, 185, 129, 0.10);
|
| 388 |
+
border: 1px solid rgba(16, 185, 129, 0.20);
|
| 389 |
+
color: #D1FAE5;
|
| 390 |
+
padding: 12px 16px;
|
| 391 |
+
border-radius: 10px;
|
| 392 |
+
display: flex;
|
| 393 |
+
gap: 12px;
|
| 394 |
+
font-size: 14px;
|
| 395 |
+
}
|
| 396 |
+
.success-icon { font-size: 18px; }
|
| 397 |
+
.success-link {
|
| 398 |
+
display: inline-block;
|
| 399 |
+
margin-top: 6px;
|
| 400 |
+
font-weight: 600;
|
| 401 |
+
color: #34D399;
|
| 402 |
+
text-decoration: none;
|
| 403 |
+
}
|
| 404 |
+
.success-link:hover { text-decoration: underline; }
|
| 405 |
+
|
| 406 |
+
.chat-input-box {
|
| 407 |
+
padding: 16px;
|
| 408 |
+
border-top: 1px solid #27272A;
|
| 409 |
+
background: #131316;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.chat-input-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
| 413 |
+
|
| 414 |
+
.chat-input {
|
| 415 |
+
flex: 1;
|
| 416 |
+
min-width: 200px;
|
| 417 |
+
background: #18181B;
|
| 418 |
+
border: 1px solid #27272A;
|
| 419 |
+
color: white;
|
| 420 |
+
padding: 10px 12px;
|
| 421 |
+
border-radius: 8px;
|
| 422 |
+
outline: none;
|
| 423 |
+
font-size: 14px;
|
| 424 |
+
font-family: inherit;
|
| 425 |
+
resize: none;
|
| 426 |
+
min-height: 40px;
|
| 427 |
+
max-height: 160px;
|
| 428 |
+
line-height: 1.4;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
/* Enterprise controls (restored) */
|
| 432 |
+
.chat-btn {
|
| 433 |
+
height: 38px;
|
| 434 |
+
padding: 0 14px;
|
| 435 |
+
border-radius: 8px;
|
| 436 |
+
font-weight: 700;
|
| 437 |
+
cursor: pointer;
|
| 438 |
+
border: 1px solid transparent;
|
| 439 |
+
font-size: 13px;
|
| 440 |
+
white-space: nowrap;
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
/* Orange primary (old style) */
|
| 444 |
+
.chat-btn.primary { background: #D95C3D; color: #fff; }
|
| 445 |
+
.chat-btn.primary:hover { filter: brightness(0.98); }
|
| 446 |
+
.chat-btn.primary:disabled { opacity: 0.55; cursor: not-allowed; }
|
| 447 |
+
|
| 448 |
+
/* Secondary outline */
|
| 449 |
+
.chat-btn.secondary {
|
| 450 |
+
background: transparent;
|
| 451 |
+
border: 1px solid #3F3F46;
|
| 452 |
+
color: #A1A1AA;
|
| 453 |
+
}
|
| 454 |
+
.chat-btn.secondary:hover { background: rgba(255,255,255,0.04); }
|
| 455 |
+
.chat-btn.secondary:disabled { opacity: 0.55; cursor: not-allowed; }
|
| 456 |
+
|
| 457 |
+
.chat-empty-state {
|
| 458 |
+
text-align: center;
|
| 459 |
+
color: #52525B;
|
| 460 |
+
margin-top: 40px;
|
| 461 |
+
font-size: 14px;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
/* WebSocket connection indicator */
|
| 465 |
+
.ws-indicator {
|
| 466 |
+
display: inline-flex;
|
| 467 |
+
align-items: center;
|
| 468 |
+
gap: 4px;
|
| 469 |
+
font-size: 10px;
|
| 470 |
+
color: #71717A;
|
| 471 |
+
padding: 2px 6px;
|
| 472 |
+
border-radius: 4px;
|
| 473 |
+
background: rgba(24, 24, 27, 0.6);
|
| 474 |
+
}
|
| 475 |
+
.ws-dot {
|
| 476 |
+
width: 6px;
|
| 477 |
+
height: 6px;
|
| 478 |
+
border-radius: 50%;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
@keyframes blink {
|
| 482 |
+
0%, 100% { opacity: 1; }
|
| 483 |
+
50% { opacity: 0; }
|
| 484 |
+
}
|
| 485 |
+
`}</style>
|
| 486 |
+
|
| 487 |
+
<div className="chat-messages">
|
| 488 |
+
{messages.map((m, idx) => {
|
| 489 |
+
// Success message (App.jsx injected)
|
| 490 |
+
if (m.isSuccess) {
|
| 491 |
+
return (
|
| 492 |
+
<div key={idx} className="chat-msg-success">
|
| 493 |
+
<div className="success-icon">🚀</div>
|
| 494 |
+
<div>
|
| 495 |
+
<div style={{ whiteSpace: "pre-wrap" }}>{m.content}</div>
|
| 496 |
+
{m.link && (
|
| 497 |
+
<a href={m.link} target="_blank" rel="noreferrer" className="success-link">
|
| 498 |
+
View Changes on GitHub →
|
| 499 |
+
</a>
|
| 500 |
+
)}
|
| 501 |
+
</div>
|
| 502 |
+
</div>
|
| 503 |
+
);
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
// User message
|
| 507 |
+
if (m.from === "user" || m.role === "user") {
|
| 508 |
+
return (
|
| 509 |
+
<div key={idx} className="chat-message-user">
|
| 510 |
+
<span>{m.text || m.content}</span>
|
| 511 |
+
</div>
|
| 512 |
+
);
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
// Assistant message (Answer / Plan / Execution Log)
|
| 516 |
+
return (
|
| 517 |
+
<div key={idx}>
|
| 518 |
+
<AssistantMessage
|
| 519 |
+
answer={m.answer || m.content}
|
| 520 |
+
plan={m.plan}
|
| 521 |
+
executionLog={m.executionLog}
|
| 522 |
+
/>
|
| 523 |
+
{/* Diff stats indicator (Claude-Code-on-Web parity) */}
|
| 524 |
+
{m.diff && (
|
| 525 |
+
<DiffStats diff={m.diff} onClick={() => {
|
| 526 |
+
setDiffData(m.diff);
|
| 527 |
+
setShowDiffViewer(true);
|
| 528 |
+
}} />
|
| 529 |
+
)}
|
| 530 |
+
</div>
|
| 531 |
+
);
|
| 532 |
+
})}
|
| 533 |
+
|
| 534 |
+
{/* Streaming events (real-time agent output) */}
|
| 535 |
+
{streamingEvents.length > 0 && (
|
| 536 |
+
<div>
|
| 537 |
+
<StreamingMessage events={streamingEvents} />
|
| 538 |
+
</div>
|
| 539 |
+
)}
|
| 540 |
+
|
| 541 |
+
{loadingPlan && streamingEvents.length === 0 && (
|
| 542 |
+
<div className="chat-message-ai" style={{ color: "#A1A1AA", fontStyle: "italic", padding: "10px" }}>
|
| 543 |
+
Thinking...
|
| 544 |
+
</div>
|
| 545 |
+
)}
|
| 546 |
+
|
| 547 |
+
{!messages.length && !plan && !loadingPlan && streamingEvents.length === 0 && (
|
| 548 |
+
<div className="chat-empty-state">
|
| 549 |
+
<div className="chat-empty-icon">💬</div>
|
| 550 |
+
<p>Tell GitPilot what you want to do with this repository.</p>
|
| 551 |
+
<p style={{ fontSize: 12, color: "#676883", marginTop: 4 }}>
|
| 552 |
+
It will propose a safe step-by-step plan before any execution.
|
| 553 |
+
</p>
|
| 554 |
+
</div>
|
| 555 |
+
)}
|
| 556 |
+
|
| 557 |
+
<div ref={messagesEndRef} />
|
| 558 |
+
</div>
|
| 559 |
+
|
| 560 |
+
{/* Diff stats bar (when agent has made changes) */}
|
| 561 |
+
{diffData && (
|
| 562 |
+
<div style={{
|
| 563 |
+
padding: "8px 16px",
|
| 564 |
+
borderTop: "1px solid #27272A",
|
| 565 |
+
background: "#18181B",
|
| 566 |
+
}}>
|
| 567 |
+
<DiffStats diff={diffData} onClick={() => setShowDiffViewer(true)} />
|
| 568 |
+
</div>
|
| 569 |
+
)}
|
| 570 |
+
|
| 571 |
+
<div className="chat-input-box">
|
| 572 |
+
{/* Readiness blocker banner */}
|
| 573 |
+
{!canChat && chatBlocker && (
|
| 574 |
+
<div style={{
|
| 575 |
+
fontSize: 12,
|
| 576 |
+
color: "#F59E0B",
|
| 577 |
+
background: "rgba(245, 158, 11, 0.08)",
|
| 578 |
+
border: "1px solid rgba(245, 158, 11, 0.2)",
|
| 579 |
+
borderRadius: 6,
|
| 580 |
+
padding: "8px 12px",
|
| 581 |
+
marginBottom: 8,
|
| 582 |
+
display: "flex",
|
| 583 |
+
alignItems: "center",
|
| 584 |
+
justifyContent: "space-between",
|
| 585 |
+
}}>
|
| 586 |
+
<span>{chatBlocker.message || "Chat is not ready yet."}</span>
|
| 587 |
+
{chatBlocker.cta && chatBlocker.onCta && (
|
| 588 |
+
<button
|
| 589 |
+
type="button"
|
| 590 |
+
onClick={chatBlocker.onCta}
|
| 591 |
+
style={{
|
| 592 |
+
fontSize: 11,
|
| 593 |
+
fontWeight: 600,
|
| 594 |
+
color: "#F59E0B",
|
| 595 |
+
background: "transparent",
|
| 596 |
+
border: "1px solid rgba(245, 158, 11, 0.3)",
|
| 597 |
+
borderRadius: 4,
|
| 598 |
+
padding: "2px 8px",
|
| 599 |
+
cursor: "pointer",
|
| 600 |
+
}}
|
| 601 |
+
>
|
| 602 |
+
{chatBlocker.cta}
|
| 603 |
+
</button>
|
| 604 |
+
)}
|
| 605 |
+
</div>
|
| 606 |
+
)}
|
| 607 |
+
{status && (
|
| 608 |
+
<div style={{ fontSize: 11, color: "#ffb3b7", marginBottom: 8 }}>
|
| 609 |
+
{status}
|
| 610 |
+
</div>
|
| 611 |
+
)}
|
| 612 |
+
|
| 613 |
+
<div className="chat-input-row">
|
| 614 |
+
<textarea
|
| 615 |
+
className="chat-input"
|
| 616 |
+
placeholder={wsConnected ? "Send feedback or instructions..." : "Describe the change you want to make..."}
|
| 617 |
+
value={goal}
|
| 618 |
+
rows={1}
|
| 619 |
+
onChange={(e) => {
|
| 620 |
+
setGoal(e.target.value);
|
| 621 |
+
e.target.style.height = "40px";
|
| 622 |
+
e.target.style.height = Math.min(e.target.scrollHeight, 160) + "px";
|
| 623 |
+
}}
|
| 624 |
+
onKeyDown={(e) => {
|
| 625 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 626 |
+
e.preventDefault();
|
| 627 |
+
if (!loadingPlan && !executing) send();
|
| 628 |
+
}
|
| 629 |
+
}}
|
| 630 |
+
disabled={!canChat || loadingPlan || executing}
|
| 631 |
+
/>
|
| 632 |
+
|
| 633 |
+
{/* Always show both buttons (old UX) */}
|
| 634 |
+
<button
|
| 635 |
+
className="chat-btn primary"
|
| 636 |
+
type="button"
|
| 637 |
+
onClick={send}
|
| 638 |
+
disabled={!canChat || loadingPlan || executing || !goal.trim()}
|
| 639 |
+
>
|
| 640 |
+
{loadingPlan ? "Planning..." : wsConnected ? "Send" : "Generate plan"}
|
| 641 |
+
</button>
|
| 642 |
+
|
| 643 |
+
<button
|
| 644 |
+
className="chat-btn secondary"
|
| 645 |
+
type="button"
|
| 646 |
+
onClick={execute}
|
| 647 |
+
disabled={!plan || executing || loadingPlan}
|
| 648 |
+
>
|
| 649 |
+
{executing ? "Executing..." : "Approve & execute"}
|
| 650 |
+
</button>
|
| 651 |
+
|
| 652 |
+
{/* Create PR button (Claude-Code-on-Web parity) */}
|
| 653 |
+
{isOnSessionBranch && (
|
| 654 |
+
<CreatePRButton
|
| 655 |
+
repo={repo}
|
| 656 |
+
sessionId={sessionId}
|
| 657 |
+
branch={currentBranch}
|
| 658 |
+
defaultBranch={defaultBranch}
|
| 659 |
+
disabled={executing || loadingPlan}
|
| 660 |
+
/>
|
| 661 |
+
)}
|
| 662 |
+
</div>
|
| 663 |
+
|
| 664 |
+
{/* WebSocket connection indicator */}
|
| 665 |
+
{sessionId && (
|
| 666 |
+
<div style={{ marginTop: 6, display: "flex", alignItems: "center", gap: 8 }}>
|
| 667 |
+
<span className="ws-indicator">
|
| 668 |
+
<span className="ws-dot" style={{
|
| 669 |
+
backgroundColor: wsConnected ? "#10B981" : "#EF4444",
|
| 670 |
+
}} />
|
| 671 |
+
{wsConnected ? "Live" : "Connecting..."}
|
| 672 |
+
</span>
|
| 673 |
+
</div>
|
| 674 |
+
)}
|
| 675 |
+
</div>
|
| 676 |
+
|
| 677 |
+
{/* Diff Viewer overlay */}
|
| 678 |
+
{showDiffViewer && (
|
| 679 |
+
<DiffViewer
|
| 680 |
+
diff={diffData}
|
| 681 |
+
onClose={() => setShowDiffViewer(false)}
|
| 682 |
+
/>
|
| 683 |
+
)}
|
| 684 |
+
</div>
|
| 685 |
+
);
|
| 686 |
+
}
|
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,775 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, 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: "\uD83D\uDCF1" },
|
| 16 |
+
{ id: "apikey", label: "API Key", icon: "\uD83D\uDD11" },
|
| 17 |
+
{ id: "local", label: "Local Trust", icon: "\uD83C\uDFE0" },
|
| 18 |
+
];
|
| 19 |
+
|
| 20 |
+
export default function LlmSettings() {
|
| 21 |
+
const [settings, setSettings] = useState(null);
|
| 22 |
+
const [saving, setSaving] = useState(false);
|
| 23 |
+
const [error, setError] = useState("");
|
| 24 |
+
const [savedMsg, setSavedMsg] = useState("");
|
| 25 |
+
|
| 26 |
+
const [modelsByProvider, setModelsByProvider] = useState({});
|
| 27 |
+
const [modelsError, setModelsError] = useState("");
|
| 28 |
+
const [loadingModelsFor, setLoadingModelsFor] = useState("");
|
| 29 |
+
|
| 30 |
+
const [testResult, setTestResult] = useState(null);
|
| 31 |
+
const [testing, setTesting] = useState(false);
|
| 32 |
+
|
| 33 |
+
// OllaBridge pairing state
|
| 34 |
+
const [authMode, setAuthMode] = useState("local");
|
| 35 |
+
const [pairCode, setPairCode] = useState("");
|
| 36 |
+
const [pairing, setPairing] = useState(false);
|
| 37 |
+
const [pairResult, setPairResult] = useState(null);
|
| 38 |
+
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
const load = async () => {
|
| 41 |
+
try {
|
| 42 |
+
const res = await fetch("/api/settings");
|
| 43 |
+
const data = await res.json();
|
| 44 |
+
if (!res.ok) throw new Error(data.error || "Failed to load settings");
|
| 45 |
+
setSettings(data);
|
| 46 |
+
} catch (e) {
|
| 47 |
+
console.error(e);
|
| 48 |
+
setError(e.message);
|
| 49 |
+
}
|
| 50 |
+
};
|
| 51 |
+
load();
|
| 52 |
+
}, []);
|
| 53 |
+
|
| 54 |
+
const updateField = (section, field, value) => {
|
| 55 |
+
setSettings((prev) => ({
|
| 56 |
+
...prev,
|
| 57 |
+
[section]: {
|
| 58 |
+
...prev[section],
|
| 59 |
+
[field]: value,
|
| 60 |
+
},
|
| 61 |
+
}));
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
const handleSave = async () => {
|
| 65 |
+
setSaving(true);
|
| 66 |
+
setError("");
|
| 67 |
+
setSavedMsg("");
|
| 68 |
+
try {
|
| 69 |
+
const res = await fetch("/api/settings/llm", {
|
| 70 |
+
method: "PUT",
|
| 71 |
+
headers: { "Content-Type": "application/json" },
|
| 72 |
+
body: JSON.stringify(settings),
|
| 73 |
+
});
|
| 74 |
+
const data = await res.json();
|
| 75 |
+
if (!res.ok) throw new Error(data.error || "Failed to save settings");
|
| 76 |
+
setSettings(data);
|
| 77 |
+
setSavedMsg("Settings saved successfully!");
|
| 78 |
+
setTimeout(() => setSavedMsg(""), 3000);
|
| 79 |
+
} catch (e) {
|
| 80 |
+
console.error(e);
|
| 81 |
+
setError(e.message);
|
| 82 |
+
} finally {
|
| 83 |
+
setSaving(false);
|
| 84 |
+
}
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
const loadModelsForProvider = async (provider) => {
|
| 88 |
+
setModelsError("");
|
| 89 |
+
setLoadingModelsFor(provider);
|
| 90 |
+
try {
|
| 91 |
+
const res = await fetch(`/api/settings/models?provider=${provider}`);
|
| 92 |
+
const data = await res.json();
|
| 93 |
+
if (!res.ok || data.error) {
|
| 94 |
+
throw new Error(data.error || "Failed to load models");
|
| 95 |
+
}
|
| 96 |
+
setModelsByProvider((prev) => ({
|
| 97 |
+
...prev,
|
| 98 |
+
[provider]: data.models || [],
|
| 99 |
+
}));
|
| 100 |
+
} catch (e) {
|
| 101 |
+
console.error(e);
|
| 102 |
+
setModelsError(e.message);
|
| 103 |
+
} finally {
|
| 104 |
+
setLoadingModelsFor("");
|
| 105 |
+
}
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
// OllaBridge device pairing
|
| 109 |
+
const handlePair = async () => {
|
| 110 |
+
if (!pairCode.trim()) return;
|
| 111 |
+
setPairing(true);
|
| 112 |
+
setPairResult(null);
|
| 113 |
+
try {
|
| 114 |
+
const baseUrl = settings?.ollabridge?.base_url || "https://ruslanmv-ollabridge.hf.space";
|
| 115 |
+
const res = await fetch("/api/ollabridge/pair", {
|
| 116 |
+
method: "POST",
|
| 117 |
+
headers: { "Content-Type": "application/json" },
|
| 118 |
+
body: JSON.stringify({ base_url: baseUrl, code: pairCode.trim() }),
|
| 119 |
+
});
|
| 120 |
+
const data = await res.json();
|
| 121 |
+
if (data.success) {
|
| 122 |
+
setPairResult({ ok: true, message: "Paired successfully!" });
|
| 123 |
+
if (data.token) {
|
| 124 |
+
updateField("ollabridge", "api_key", data.token);
|
| 125 |
+
}
|
| 126 |
+
} else {
|
| 127 |
+
setPairResult({ ok: false, message: data.error || "Pairing failed" });
|
| 128 |
+
}
|
| 129 |
+
} catch (e) {
|
| 130 |
+
setPairResult({ ok: false, message: e.message });
|
| 131 |
+
} finally {
|
| 132 |
+
setPairing(false);
|
| 133 |
+
}
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
const handleTestConnection = async () => {
|
| 137 |
+
setTesting(true);
|
| 138 |
+
setTestResult(null);
|
| 139 |
+
try {
|
| 140 |
+
const activeProvider = settings?.provider || "ollama";
|
| 141 |
+
const config = { provider: activeProvider };
|
| 142 |
+
|
| 143 |
+
// Add provider-specific config
|
| 144 |
+
if (activeProvider === "openai" && settings?.openai) {
|
| 145 |
+
config.openai = {
|
| 146 |
+
api_key: settings.openai.api_key,
|
| 147 |
+
base_url: settings.openai.base_url,
|
| 148 |
+
model: settings.openai.model,
|
| 149 |
+
};
|
| 150 |
+
} else if (activeProvider === "claude" && settings?.claude) {
|
| 151 |
+
config.claude = {
|
| 152 |
+
api_key: settings.claude.api_key,
|
| 153 |
+
base_url: settings.claude.base_url,
|
| 154 |
+
model: settings.claude.model,
|
| 155 |
+
};
|
| 156 |
+
} else if (activeProvider === "watsonx" && settings?.watsonx) {
|
| 157 |
+
config.watsonx = {
|
| 158 |
+
api_key: settings.watsonx.api_key,
|
| 159 |
+
project_id: settings.watsonx.project_id,
|
| 160 |
+
base_url: settings.watsonx.base_url,
|
| 161 |
+
model_id: settings.watsonx.model_id,
|
| 162 |
+
};
|
| 163 |
+
} else if (activeProvider === "ollama" && settings?.ollama) {
|
| 164 |
+
config.ollama = {
|
| 165 |
+
base_url: settings.ollama.base_url,
|
| 166 |
+
model: settings.ollama.model,
|
| 167 |
+
};
|
| 168 |
+
} else if (activeProvider === "ollabridge" && settings?.ollabridge) {
|
| 169 |
+
config.ollabridge = {
|
| 170 |
+
base_url: settings.ollabridge.base_url,
|
| 171 |
+
model: settings.ollabridge.model,
|
| 172 |
+
api_key: settings.ollabridge.api_key,
|
| 173 |
+
};
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const result = await testProvider(config);
|
| 177 |
+
setTestResult(result);
|
| 178 |
+
} catch (err) {
|
| 179 |
+
setTestResult({ health: "error", warning: err.message || "Test failed" });
|
| 180 |
+
} finally {
|
| 181 |
+
setTesting(false);
|
| 182 |
+
}
|
| 183 |
+
};
|
| 184 |
+
|
| 185 |
+
if (!settings) {
|
| 186 |
+
return (
|
| 187 |
+
<div className="settings-root">
|
| 188 |
+
<h1>Admin / LLM Settings</h1>
|
| 189 |
+
<p className="settings-muted">Loading current configuration\u2026</p>
|
| 190 |
+
</div>
|
| 191 |
+
);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
const { provider } = settings;
|
| 195 |
+
const availableModels = modelsByProvider[provider] || [];
|
| 196 |
+
|
| 197 |
+
return (
|
| 198 |
+
<div className="settings-root">
|
| 199 |
+
<h1>Admin / LLM Settings</h1>
|
| 200 |
+
<p className="settings-muted">
|
| 201 |
+
Choose which LLM provider GitPilot should use for planning and agent
|
| 202 |
+
workflows. Provider settings are stored on the server.
|
| 203 |
+
</p>
|
| 204 |
+
|
| 205 |
+
{/* ACTIVE PROVIDER */}
|
| 206 |
+
<div className="settings-card">
|
| 207 |
+
<label className="settings-label">Active provider</label>
|
| 208 |
+
<div className="settings-provider-tabs">
|
| 209 |
+
{PROVIDERS.map((p) => (
|
| 210 |
+
<button
|
| 211 |
+
key={p}
|
| 212 |
+
type="button"
|
| 213 |
+
className={
|
| 214 |
+
"settings-provider-tab" +
|
| 215 |
+
(provider === p ? " settings-provider-tab-active" : "")
|
| 216 |
+
}
|
| 217 |
+
onClick={() =>
|
| 218 |
+
setSettings((prev) => ({ ...prev, provider: p }))
|
| 219 |
+
}
|
| 220 |
+
>
|
| 221 |
+
{PROVIDER_LABELS[p] || p}
|
| 222 |
+
</button>
|
| 223 |
+
))}
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
{/* ============================================================ */}
|
| 228 |
+
{/* OLLABRIDGE CLOUD */}
|
| 229 |
+
{/* ============================================================ */}
|
| 230 |
+
{provider === "ollabridge" && (
|
| 231 |
+
<div className="settings-card">
|
| 232 |
+
<div className="settings-title">OllaBridge Cloud Configuration</div>
|
| 233 |
+
<div className="settings-hint" style={{ marginBottom: 12 }}>
|
| 234 |
+
Connect to OllaBridge Cloud or any OllaBridge instance for LLM
|
| 235 |
+
inference. No API key required for public endpoints.
|
| 236 |
+
</div>
|
| 237 |
+
|
| 238 |
+
{/* AUTH MODE TABS */}
|
| 239 |
+
<label className="settings-label">Authentication Mode</label>
|
| 240 |
+
<div className="ob-auth-tabs">
|
| 241 |
+
{AUTH_MODES.map((m) => (
|
| 242 |
+
<button
|
| 243 |
+
key={m.id}
|
| 244 |
+
type="button"
|
| 245 |
+
className={
|
| 246 |
+
"ob-auth-tab" +
|
| 247 |
+
(authMode === m.id ? " ob-auth-tab-active" : "")
|
| 248 |
+
}
|
| 249 |
+
onClick={() => setAuthMode(m.id)}
|
| 250 |
+
>
|
| 251 |
+
<span className="ob-auth-tab-icon">{m.icon}</span>
|
| 252 |
+
<span>{m.label}</span>
|
| 253 |
+
</button>
|
| 254 |
+
))}
|
| 255 |
+
</div>
|
| 256 |
+
|
| 257 |
+
{/* DEVICE PAIRING MODE */}
|
| 258 |
+
{authMode === "device" && (
|
| 259 |
+
<div className="ob-auth-panel">
|
| 260 |
+
<div className="ob-auth-desc">
|
| 261 |
+
Enter the pairing code from your OllaBridge console and
|
| 262 |
+
click Pair.
|
| 263 |
+
</div>
|
| 264 |
+
<div className="ob-pair-row">
|
| 265 |
+
<input
|
| 266 |
+
className="settings-input ob-pair-input"
|
| 267 |
+
type="text"
|
| 268 |
+
maxLength={9}
|
| 269 |
+
placeholder="ABCD-1234"
|
| 270 |
+
value={pairCode}
|
| 271 |
+
onChange={(e) => setPairCode(e.target.value.toUpperCase())}
|
| 272 |
+
onKeyDown={(e) => e.key === "Enter" && handlePair()}
|
| 273 |
+
/>
|
| 274 |
+
<button
|
| 275 |
+
type="button"
|
| 276 |
+
className="ob-pair-btn"
|
| 277 |
+
onClick={handlePair}
|
| 278 |
+
disabled={pairing || !pairCode.trim()}
|
| 279 |
+
>
|
| 280 |
+
{pairing ? (
|
| 281 |
+
<span className="ob-pair-spinner" />
|
| 282 |
+
) : (
|
| 283 |
+
"\uD83D\uDD17"
|
| 284 |
+
)}{" "}
|
| 285 |
+
Pair
|
| 286 |
+
</button>
|
| 287 |
+
</div>
|
| 288 |
+
{pairResult && (
|
| 289 |
+
<div
|
| 290 |
+
className={
|
| 291 |
+
"ob-pair-result " +
|
| 292 |
+
(pairResult.ok
|
| 293 |
+
? "ob-pair-result-ok"
|
| 294 |
+
: "ob-pair-result-err")
|
| 295 |
+
}
|
| 296 |
+
>
|
| 297 |
+
{pairResult.message}
|
| 298 |
+
</div>
|
| 299 |
+
)}
|
| 300 |
+
</div>
|
| 301 |
+
)}
|
| 302 |
+
|
| 303 |
+
{/* API KEY MODE */}
|
| 304 |
+
{authMode === "apikey" && (
|
| 305 |
+
<div className="ob-auth-panel">
|
| 306 |
+
<div className="ob-auth-desc">
|
| 307 |
+
Enter your OllaBridge API key or device token for authenticated
|
| 308 |
+
access.
|
| 309 |
+
</div>
|
| 310 |
+
<label className="settings-label">API Key / Device Token</label>
|
| 311 |
+
<input
|
| 312 |
+
className="settings-input"
|
| 313 |
+
type="password"
|
| 314 |
+
placeholder="Enter API key or device token"
|
| 315 |
+
value={settings.ollabridge?.api_key || ""}
|
| 316 |
+
onChange={(e) =>
|
| 317 |
+
updateField("ollabridge", "api_key", e.target.value)
|
| 318 |
+
}
|
| 319 |
+
/>
|
| 320 |
+
</div>
|
| 321 |
+
)}
|
| 322 |
+
|
| 323 |
+
{/* LOCAL TRUST MODE */}
|
| 324 |
+
{authMode === "local" && (
|
| 325 |
+
<div className="ob-auth-panel">
|
| 326 |
+
<div className="ob-auth-desc">
|
| 327 |
+
Connect to a local or trusted OllaBridge instance without
|
| 328 |
+
authentication. Ideal for local development or pre-configured
|
| 329 |
+
cloud endpoints.
|
| 330 |
+
</div>
|
| 331 |
+
</div>
|
| 332 |
+
)}
|
| 333 |
+
|
| 334 |
+
{/* BASE URL */}
|
| 335 |
+
<label className="settings-label" style={{ marginTop: 12 }}>
|
| 336 |
+
Base URL
|
| 337 |
+
</label>
|
| 338 |
+
<input
|
| 339 |
+
className="settings-input"
|
| 340 |
+
type="text"
|
| 341 |
+
placeholder="https://ruslanmv-ollabridge.hf.space"
|
| 342 |
+
value={settings.ollabridge?.base_url || ""}
|
| 343 |
+
onChange={(e) =>
|
| 344 |
+
updateField("ollabridge", "base_url", e.target.value)
|
| 345 |
+
}
|
| 346 |
+
/>
|
| 347 |
+
<div className="settings-hint">
|
| 348 |
+
Default: https://ruslanmv-ollabridge.hf.space (free, no key
|
| 349 |
+
needed)
|
| 350 |
+
</div>
|
| 351 |
+
|
| 352 |
+
{/* MODEL */}
|
| 353 |
+
<label className="settings-label" style={{ marginTop: 12 }}>
|
| 354 |
+
Model
|
| 355 |
+
</label>
|
| 356 |
+
<div className="ob-model-row">
|
| 357 |
+
<input
|
| 358 |
+
className="settings-input"
|
| 359 |
+
type="text"
|
| 360 |
+
placeholder="qwen2.5:1.5b"
|
| 361 |
+
value={settings.ollabridge?.model || ""}
|
| 362 |
+
onChange={(e) =>
|
| 363 |
+
updateField("ollabridge", "model", e.target.value)
|
| 364 |
+
}
|
| 365 |
+
style={{ flex: 1 }}
|
| 366 |
+
/>
|
| 367 |
+
<button
|
| 368 |
+
type="button"
|
| 369 |
+
className="ob-fetch-btn"
|
| 370 |
+
onClick={() => loadModelsForProvider("ollabridge")}
|
| 371 |
+
disabled={loadingModelsFor === "ollabridge"}
|
| 372 |
+
>
|
| 373 |
+
{loadingModelsFor === "ollabridge" ? (
|
| 374 |
+
<span className="ob-pair-spinner" />
|
| 375 |
+
) : (
|
| 376 |
+
"\uD83D\uDD04"
|
| 377 |
+
)}{" "}
|
| 378 |
+
Fetch Models
|
| 379 |
+
</button>
|
| 380 |
+
</div>
|
| 381 |
+
|
| 382 |
+
{availableModels.length > 0 && (
|
| 383 |
+
<>
|
| 384 |
+
<label className="settings-label" style={{ marginTop: 8 }}>
|
| 385 |
+
Available models
|
| 386 |
+
</label>
|
| 387 |
+
<select
|
| 388 |
+
className="settings-select"
|
| 389 |
+
value={settings.ollabridge?.model || ""}
|
| 390 |
+
onChange={(e) =>
|
| 391 |
+
updateField("ollabridge", "model", e.target.value)
|
| 392 |
+
}
|
| 393 |
+
>
|
| 394 |
+
<option value="">-- select a model --</option>
|
| 395 |
+
{availableModels.map((m) => (
|
| 396 |
+
<option key={m} value={m}>
|
| 397 |
+
{m}
|
| 398 |
+
</option>
|
| 399 |
+
))}
|
| 400 |
+
</select>
|
| 401 |
+
</>
|
| 402 |
+
)}
|
| 403 |
+
|
| 404 |
+
<div className="settings-hint" style={{ marginTop: 4 }}>
|
| 405 |
+
Examples: qwen2.5:1.5b, llama3, mistral, codellama,
|
| 406 |
+
deepseek-coder
|
| 407 |
+
</div>
|
| 408 |
+
</div>
|
| 409 |
+
)}
|
| 410 |
+
|
| 411 |
+
{/* OPENAI */}
|
| 412 |
+
{provider === "openai" && (
|
| 413 |
+
<div className="settings-card">
|
| 414 |
+
<div className="settings-title">OpenAI Configuration</div>
|
| 415 |
+
|
| 416 |
+
<label className="settings-label">API Key</label>
|
| 417 |
+
<input
|
| 418 |
+
className="settings-input"
|
| 419 |
+
type="password"
|
| 420 |
+
placeholder="sk-..."
|
| 421 |
+
value={settings.openai?.api_key || ""}
|
| 422 |
+
onChange={(e) => updateField("openai", "api_key", e.target.value)}
|
| 423 |
+
/>
|
| 424 |
+
|
| 425 |
+
<label className="settings-label" style={{ marginTop: 12 }}>
|
| 426 |
+
Model
|
| 427 |
+
</label>
|
| 428 |
+
<input
|
| 429 |
+
className="settings-input"
|
| 430 |
+
type="text"
|
| 431 |
+
placeholder="gpt-4o-mini"
|
| 432 |
+
value={settings.openai?.model || ""}
|
| 433 |
+
onChange={(e) => updateField("openai", "model", e.target.value)}
|
| 434 |
+
/>
|
| 435 |
+
|
| 436 |
+
<button
|
| 437 |
+
type="button"
|
| 438 |
+
className="settings-load-btn"
|
| 439 |
+
onClick={() => loadModelsForProvider("openai")}
|
| 440 |
+
disabled={loadingModelsFor === "openai"}
|
| 441 |
+
>
|
| 442 |
+
{loadingModelsFor === "openai"
|
| 443 |
+
? "Loading models\u2026"
|
| 444 |
+
: "Load available models"}
|
| 445 |
+
</button>
|
| 446 |
+
|
| 447 |
+
{availableModels.length > 0 && (
|
| 448 |
+
<>
|
| 449 |
+
<label className="settings-label" style={{ marginTop: 12 }}>
|
| 450 |
+
Choose from discovered models
|
| 451 |
+
</label>
|
| 452 |
+
<select
|
| 453 |
+
className="settings-select"
|
| 454 |
+
value={settings.openai?.model || ""}
|
| 455 |
+
onChange={(e) =>
|
| 456 |
+
updateField("openai", "model", e.target.value)
|
| 457 |
+
}
|
| 458 |
+
>
|
| 459 |
+
<option value="">-- select a model --</option>
|
| 460 |
+
{availableModels.map((m) => (
|
| 461 |
+
<option key={m} value={m}>
|
| 462 |
+
{m}
|
| 463 |
+
</option>
|
| 464 |
+
))}
|
| 465 |
+
</select>
|
| 466 |
+
</>
|
| 467 |
+
)}
|
| 468 |
+
|
| 469 |
+
<label className="settings-label" style={{ marginTop: 12 }}>
|
| 470 |
+
Base URL (optional)
|
| 471 |
+
</label>
|
| 472 |
+
<input
|
| 473 |
+
className="settings-input"
|
| 474 |
+
type="text"
|
| 475 |
+
placeholder="Leave empty for default, or use Azure OpenAI endpoint"
|
| 476 |
+
value={settings.openai?.base_url || ""}
|
| 477 |
+
onChange={(e) => updateField("openai", "base_url", e.target.value)}
|
| 478 |
+
/>
|
| 479 |
+
<div className="settings-hint">
|
| 480 |
+
Examples: gpt-4o, gpt-4o-mini, gpt-4.1, gpt-4.1-mini
|
| 481 |
+
</div>
|
| 482 |
+
</div>
|
| 483 |
+
)}
|
| 484 |
+
|
| 485 |
+
{/* CLAUDE */}
|
| 486 |
+
{provider === "claude" && (
|
| 487 |
+
<div className="settings-card">
|
| 488 |
+
<div className="settings-title">Claude Configuration</div>
|
| 489 |
+
|
| 490 |
+
<label className="settings-label">API Key</label>
|
| 491 |
+
<input
|
| 492 |
+
className="settings-input"
|
| 493 |
+
type="password"
|
| 494 |
+
placeholder="sk-ant-..."
|
| 495 |
+
value={settings.claude?.api_key || ""}
|
| 496 |
+
onChange={(e) => updateField("claude", "api_key", e.target.value)}
|
| 497 |
+
/>
|
| 498 |
+
|
| 499 |
+
<label className="settings-label" style={{ marginTop: 12 }}>
|
| 500 |
+
Model
|
| 501 |
+
</label>
|
| 502 |
+
<input
|
| 503 |
+
className="settings-input"
|
| 504 |
+
type="text"
|
| 505 |
+
placeholder="claude-sonnet-4-5"
|
| 506 |
+
value={settings.claude?.model || ""}
|
| 507 |
+
onChange={(e) => updateField("claude", "model", e.target.value)}
|
| 508 |
+
/>
|
| 509 |
+
|
| 510 |
+
<button
|
| 511 |
+
type="button"
|
| 512 |
+
className="settings-load-btn"
|
| 513 |
+
onClick={() => loadModelsForProvider("claude")}
|
| 514 |
+
disabled={loadingModelsFor === "claude"}
|
| 515 |
+
>
|
| 516 |
+
{loadingModelsFor === "claude"
|
| 517 |
+
? "Loading models\u2026"
|
| 518 |
+
: "Load available models"}
|
| 519 |
+
</button>
|
| 520 |
+
|
| 521 |
+
{availableModels.length > 0 && (
|
| 522 |
+
<>
|
| 523 |
+
<label className="settings-label" style={{ marginTop: 12 }}>
|
| 524 |
+
Choose from discovered models
|
| 525 |
+
</label>
|
| 526 |
+
<select
|
| 527 |
+
className="settings-select"
|
| 528 |
+
value={settings.claude?.model || ""}
|
| 529 |
+
onChange={(e) =>
|
| 530 |
+
updateField("claude", "model", e.target.value)
|
| 531 |
+
}
|
| 532 |
+
>
|
| 533 |
+
<option value="">-- select a model --</option>
|
| 534 |
+
{availableModels.map((m) => (
|
| 535 |
+
<option key={m} value={m}>
|
| 536 |
+
{m}
|
| 537 |
+
</option>
|
| 538 |
+
))}
|
| 539 |
+
</select>
|
| 540 |
+
</>
|
| 541 |
+
)}
|
| 542 |
+
|
| 543 |
+
<label className="settings-label" style={{ marginTop: 12 }}>
|
| 544 |
+
Base URL (optional)
|
| 545 |
+
</label>
|
| 546 |
+
<input
|
| 547 |
+
className="settings-input"
|
| 548 |
+
type="text"
|
| 549 |
+
placeholder="Leave empty for default Anthropic endpoint"
|
| 550 |
+
value={settings.claude?.base_url || ""}
|
| 551 |
+
onChange={(e) => updateField("claude", "base_url", e.target.value)}
|
| 552 |
+
/>
|
| 553 |
+
<div className="settings-hint">
|
| 554 |
+
Examples: claude-sonnet-4-5, claude-3.7-sonnet, claude-3-opus-20240229
|
| 555 |
+
</div>
|
| 556 |
+
</div>
|
| 557 |
+
)}
|
| 558 |
+
|
| 559 |
+
{/* WATSONX */}
|
| 560 |
+
{provider === "watsonx" && (
|
| 561 |
+
<div className="settings-card">
|
| 562 |
+
<div className="settings-title">IBM watsonx.ai Configuration</div>
|
| 563 |
+
|
| 564 |
+
<label className="settings-label">API Key</label>
|
| 565 |
+
<input
|
| 566 |
+
className="settings-input"
|
| 567 |
+
type="password"
|
| 568 |
+
placeholder="Your watsonx API key"
|
| 569 |
+
value={settings.watsonx?.api_key || ""}
|
| 570 |
+
onChange={(e) => updateField("watsonx", "api_key", e.target.value)}
|
| 571 |
+
/>
|
| 572 |
+
|
| 573 |
+
<label className="settings-label" style={{ marginTop: 12 }}>
|
| 574 |
+
Project ID
|
| 575 |
+
</label>
|
| 576 |
+
<input
|
| 577 |
+
className="settings-input"
|
| 578 |
+
type="text"
|
| 579 |
+
placeholder="Your watsonx project ID"
|
| 580 |
+
value={settings.watsonx?.project_id || ""}
|
| 581 |
+
onChange={(e) =>
|
| 582 |
+
updateField("watsonx", "project_id", e.target.value)
|
| 583 |
+
}
|
| 584 |
+
/>
|
| 585 |
+
|
| 586 |
+
<label className="settings-label" style={{ marginTop: 12 }}>
|
| 587 |
+
Model ID
|
| 588 |
+
</label>
|
| 589 |
+
<input
|
| 590 |
+
className="settings-input"
|
| 591 |
+
type="text"
|
| 592 |
+
placeholder="meta-llama/llama-3-3-70b-instruct"
|
| 593 |
+
value={settings.watsonx?.model_id || ""}
|
| 594 |
+
onChange={(e) =>
|
| 595 |
+
updateField("watsonx", "model_id", e.target.value)
|
| 596 |
+
}
|
| 597 |
+
/>
|
| 598 |
+
|
| 599 |
+
<button
|
| 600 |
+
type="button"
|
| 601 |
+
className="settings-load-btn"
|
| 602 |
+
onClick={() => loadModelsForProvider("watsonx")}
|
| 603 |
+
disabled={loadingModelsFor === "watsonx"}
|
| 604 |
+
>
|
| 605 |
+
{loadingModelsFor === "watsonx"
|
| 606 |
+
? "Loading models\u2026"
|
| 607 |
+
: "Load available models"}
|
| 608 |
+
</button>
|
| 609 |
+
|
| 610 |
+
{availableModels.length > 0 && (
|
| 611 |
+
<>
|
| 612 |
+
<label className="settings-label" style={{ marginTop: 12 }}>
|
| 613 |
+
Choose from discovered models
|
| 614 |
+
</label>
|
| 615 |
+
<select
|
| 616 |
+
className="settings-select"
|
| 617 |
+
value={settings.watsonx?.model_id || ""}
|
| 618 |
+
onChange={(e) =>
|
| 619 |
+
updateField("watsonx", "model_id", e.target.value)
|
| 620 |
+
}
|
| 621 |
+
>
|
| 622 |
+
<option value="">-- select a model --</option>
|
| 623 |
+
{availableModels.map((m) => (
|
| 624 |
+
<option key={m} value={m}>
|
| 625 |
+
{m}
|
| 626 |
+
</option>
|
| 627 |
+
))}
|
| 628 |
+
</select>
|
| 629 |
+
</>
|
| 630 |
+
)}
|
| 631 |
+
|
| 632 |
+
<label className="settings-label" style={{ marginTop: 12 }}>
|
| 633 |
+
Base URL
|
| 634 |
+
</label>
|
| 635 |
+
<input
|
| 636 |
+
className="settings-input"
|
| 637 |
+
type="text"
|
| 638 |
+
placeholder="https://api.watsonx.ai/v1"
|
| 639 |
+
value={settings.watsonx?.base_url || ""}
|
| 640 |
+
onChange={(e) =>
|
| 641 |
+
updateField("watsonx", "base_url", e.target.value)
|
| 642 |
+
}
|
| 643 |
+
/>
|
| 644 |
+
<div className="settings-hint">
|
| 645 |
+
Examples: meta-llama/llama-3-3-70b-instruct, ibm/granite-13b-chat-v2
|
| 646 |
+
</div>
|
| 647 |
+
</div>
|
| 648 |
+
)}
|
| 649 |
+
|
| 650 |
+
{/* OLLAMA */}
|
| 651 |
+
{provider === "ollama" && (
|
| 652 |
+
<div className="settings-card">
|
| 653 |
+
<div className="settings-title">Ollama Configuration</div>
|
| 654 |
+
|
| 655 |
+
<label className="settings-label">Base URL</label>
|
| 656 |
+
<input
|
| 657 |
+
className="settings-input"
|
| 658 |
+
type="text"
|
| 659 |
+
placeholder="http://localhost:11434"
|
| 660 |
+
value={settings.ollama?.base_url || ""}
|
| 661 |
+
onChange={(e) => updateField("ollama", "base_url", e.target.value)}
|
| 662 |
+
/>
|
| 663 |
+
|
| 664 |
+
<label className="settings-label" style={{ marginTop: 12 }}>
|
| 665 |
+
Model
|
| 666 |
+
</label>
|
| 667 |
+
<input
|
| 668 |
+
className="settings-input"
|
| 669 |
+
type="text"
|
| 670 |
+
placeholder="llama3"
|
| 671 |
+
value={settings.ollama?.model || ""}
|
| 672 |
+
onChange={(e) => updateField("ollama", "model", e.target.value)}
|
| 673 |
+
/>
|
| 674 |
+
|
| 675 |
+
<button
|
| 676 |
+
type="button"
|
| 677 |
+
className="settings-load-btn"
|
| 678 |
+
onClick={() => loadModelsForProvider("ollama")}
|
| 679 |
+
disabled={loadingModelsFor === "ollama"}
|
| 680 |
+
>
|
| 681 |
+
{loadingModelsFor === "ollama"
|
| 682 |
+
? "Loading models\u2026"
|
| 683 |
+
: "Load available models"}
|
| 684 |
+
</button>
|
| 685 |
+
|
| 686 |
+
{availableModels.length > 0 && (
|
| 687 |
+
<>
|
| 688 |
+
<label className="settings-label" style={{ marginTop: 12 }}>
|
| 689 |
+
Choose from discovered models
|
| 690 |
+
</label>
|
| 691 |
+
<select
|
| 692 |
+
className="settings-select"
|
| 693 |
+
value={settings.ollama?.model || ""}
|
| 694 |
+
onChange={(e) =>
|
| 695 |
+
updateField("ollama", "model", e.target.value)
|
| 696 |
+
}
|
| 697 |
+
>
|
| 698 |
+
<option value="">-- select a model --</option>
|
| 699 |
+
{availableModels.map((m) => (
|
| 700 |
+
<option key={m} value={m}>
|
| 701 |
+
{m}
|
| 702 |
+
</option>
|
| 703 |
+
))}
|
| 704 |
+
</select>
|
| 705 |
+
</>
|
| 706 |
+
)}
|
| 707 |
+
|
| 708 |
+
<div className="settings-hint">
|
| 709 |
+
Examples: llama3, mistral, codellama, phi3
|
| 710 |
+
</div>
|
| 711 |
+
</div>
|
| 712 |
+
)}
|
| 713 |
+
|
| 714 |
+
{modelsError && (
|
| 715 |
+
<div className="settings-error" style={{ marginTop: 8 }}>
|
| 716 |
+
{modelsError}
|
| 717 |
+
</div>
|
| 718 |
+
)}
|
| 719 |
+
|
| 720 |
+
<div className="settings-actions">
|
| 721 |
+
<button
|
| 722 |
+
onClick={handleTestConnection}
|
| 723 |
+
disabled={testing}
|
| 724 |
+
style={{
|
| 725 |
+
padding: "8px 16px",
|
| 726 |
+
background: "#2563EB",
|
| 727 |
+
color: "#fff",
|
| 728 |
+
border: "none",
|
| 729 |
+
borderRadius: "6px",
|
| 730 |
+
cursor: testing ? "not-allowed" : "pointer",
|
| 731 |
+
opacity: testing ? 0.6 : 1,
|
| 732 |
+
marginRight: "8px",
|
| 733 |
+
}}
|
| 734 |
+
>
|
| 735 |
+
{testing ? "Testing..." : "Test Connection"}
|
| 736 |
+
</button>
|
| 737 |
+
<button
|
| 738 |
+
className="settings-save-btn"
|
| 739 |
+
type="button"
|
| 740 |
+
onClick={handleSave}
|
| 741 |
+
disabled={saving}
|
| 742 |
+
>
|
| 743 |
+
{saving ? "Saving\u2026" : "Save settings"}
|
| 744 |
+
</button>
|
| 745 |
+
{savedMsg && <span className="settings-success">{savedMsg}</span>}
|
| 746 |
+
{error && <span className="settings-error">{error}</span>}
|
| 747 |
+
{testResult && (
|
| 748 |
+
<div style={{
|
| 749 |
+
marginTop: "12px",
|
| 750 |
+
padding: "10px 14px",
|
| 751 |
+
borderRadius: "6px",
|
| 752 |
+
background: testResult.health === "ok" ? "#0d3320" : "#3d1111",
|
| 753 |
+
border: `1px solid ${testResult.health === "ok" ? "#166534" : "#7f1d1d"}`,
|
| 754 |
+
fontSize: "13px",
|
| 755 |
+
width: "100%",
|
| 756 |
+
}}>
|
| 757 |
+
<span style={{ fontWeight: 600 }}>
|
| 758 |
+
{testResult.health === "ok" ? "\u2713 Connection successful" : "\u2717 Connection failed"}
|
| 759 |
+
</span>
|
| 760 |
+
{testResult.warning && (
|
| 761 |
+
<div style={{ marginTop: "4px", opacity: 0.8, fontSize: "12px" }}>
|
| 762 |
+
{testResult.warning}
|
| 763 |
+
</div>
|
| 764 |
+
)}
|
| 765 |
+
{testResult.model && (
|
| 766 |
+
<div style={{ marginTop: "4px", opacity: 0.7, fontSize: "12px" }}>
|
| 767 |
+
Model: {testResult.model}
|
| 768 |
+
</div>
|
| 769 |
+
)}
|
| 770 |
+
</div>
|
| 771 |
+
)}
|
| 772 |
+
</div>
|
| 773 |
+
</div>
|
| 774 |
+
);
|
| 775 |
+
}
|
frontend/components/LoginPage.jsx
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/components/LoginPage.jsx
|
| 2 |
+
import React, { useState, useEffect, useRef } from "react";
|
| 3 |
+
import { apiUrl, safeFetchJSON } from "../utils/api.js";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* GitPilot – Enterprise Agentic Login
|
| 7 |
+
* Theme: "Claude Code" / Anthropic Enterprise (Dark + Warm Orange)
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
export default function LoginPage({ onAuthenticated }) {
|
| 11 |
+
// Auth State
|
| 12 |
+
const [authProcessing, setAuthProcessing] = useState(false);
|
| 13 |
+
const [error, setError] = useState("");
|
| 14 |
+
|
| 15 |
+
// Mode State: 'loading' | 'web' (Has Secret) | 'device' (No Secret)
|
| 16 |
+
const [mode, setMode] = useState("loading");
|
| 17 |
+
|
| 18 |
+
// Device Flow State
|
| 19 |
+
const [deviceData, setDeviceData] = useState(null);
|
| 20 |
+
const pollTimer = useRef(null);
|
| 21 |
+
const stopPolling = useRef(false); // Flag to safely stop async polling
|
| 22 |
+
|
| 23 |
+
// Web Flow State
|
| 24 |
+
const [missingClientId, setMissingClientId] = useState(false);
|
| 25 |
+
|
| 26 |
+
// REF FIX: Prevents React StrictMode from running the auth exchange twice
|
| 27 |
+
const processingRef = useRef(false);
|
| 28 |
+
|
| 29 |
+
// 1. Initialization Effect
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
const params = new URLSearchParams(window.location.search);
|
| 32 |
+
const code = params.get("code");
|
| 33 |
+
const state = params.get("state");
|
| 34 |
+
|
| 35 |
+
// A. If returning from GitHub (Web Flow Callback)
|
| 36 |
+
if (code) {
|
| 37 |
+
if (!processingRef.current) {
|
| 38 |
+
processingRef.current = true;
|
| 39 |
+
setMode("web"); // Implicitly web mode if we have a code
|
| 40 |
+
consumeOAuthCallback(code, state);
|
| 41 |
+
}
|
| 42 |
+
return;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// B. Otherwise, check Server Capabilities to decide UI Mode
|
| 46 |
+
safeFetchJSON(apiUrl("/api/auth/status"))
|
| 47 |
+
.then((data) => {
|
| 48 |
+
setMode(data.mode === "web" ? "web" : "device");
|
| 49 |
+
})
|
| 50 |
+
.catch((err) => {
|
| 51 |
+
console.warn("Auth status check failed, defaulting to device flow:", err);
|
| 52 |
+
setMode("device");
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
// Cleanup polling on unmount
|
| 56 |
+
return () => {
|
| 57 |
+
stopPolling.current = true;
|
| 58 |
+
if (pollTimer.current) clearTimeout(pollTimer.current);
|
| 59 |
+
};
|
| 60 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 61 |
+
}, []);
|
| 62 |
+
|
| 63 |
+
// ===========================================================================
|
| 64 |
+
// WEB FLOW LOGIC (Standard OAuth2)
|
| 65 |
+
// ===========================================================================
|
| 66 |
+
|
| 67 |
+
async function consumeOAuthCallback(code, state) {
|
| 68 |
+
const expectedState = sessionStorage.getItem("gitpilot_oauth_state");
|
| 69 |
+
if (state && expectedState && expectedState !== state) {
|
| 70 |
+
console.warn("OAuth state mismatch - proceeding with caution.");
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
setAuthProcessing(true);
|
| 74 |
+
setError("");
|
| 75 |
+
window.history.replaceState({}, document.title, window.location.pathname);
|
| 76 |
+
|
| 77 |
+
try {
|
| 78 |
+
const data = await safeFetchJSON(apiUrl("/api/auth/callback"), {
|
| 79 |
+
method: "POST",
|
| 80 |
+
headers: { "Content-Type": "application/json" },
|
| 81 |
+
body: JSON.stringify({ code, state: state || "" }),
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
handleSuccess(data);
|
| 85 |
+
} catch (err) {
|
| 86 |
+
console.error("Login Error:", err);
|
| 87 |
+
setError(err instanceof Error ? err.message : "Login failed.");
|
| 88 |
+
setAuthProcessing(false);
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
async function handleSignInWithGitHub() {
|
| 93 |
+
setError("");
|
| 94 |
+
setMissingClientId(false);
|
| 95 |
+
setAuthProcessing(true);
|
| 96 |
+
|
| 97 |
+
try {
|
| 98 |
+
const data = await safeFetchJSON(apiUrl("/api/auth/url"));
|
| 99 |
+
|
| 100 |
+
if (data.state) {
|
| 101 |
+
sessionStorage.setItem("gitpilot_oauth_state", data.state);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
window.location.href = data.authorization_url;
|
| 105 |
+
} catch (err) {
|
| 106 |
+
console.error("Auth Start Error:", err);
|
| 107 |
+
// Check for missing client ID (404/500 errors)
|
| 108 |
+
if (err.message && (err.message.includes('404') || err.message.includes('500'))) {
|
| 109 |
+
setMissingClientId(true);
|
| 110 |
+
} else {
|
| 111 |
+
setError(err instanceof Error ? err.message : "Could not start sign-in.");
|
| 112 |
+
}
|
| 113 |
+
setAuthProcessing(false);
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// ===========================================================================
|
| 118 |
+
// DEVICE FLOW LOGIC (No Client Secret Required)
|
| 119 |
+
// ===========================================================================
|
| 120 |
+
|
| 121 |
+
const startDeviceFlow = async () => {
|
| 122 |
+
setError("");
|
| 123 |
+
setAuthProcessing(true);
|
| 124 |
+
stopPolling.current = false; // Reset stop flag
|
| 125 |
+
|
| 126 |
+
try {
|
| 127 |
+
const data = await safeFetchJSON(apiUrl("/api/auth/device/code"), { method: "POST" });
|
| 128 |
+
|
| 129 |
+
// Handle Errors
|
| 130 |
+
if (data.error) {
|
| 131 |
+
if (data.error.includes("400") || data.error.includes("Bad Request")) {
|
| 132 |
+
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'.");
|
| 133 |
+
}
|
| 134 |
+
throw new Error(data.error);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
if (!data.device_code) throw new Error("Invalid device code response");
|
| 138 |
+
|
| 139 |
+
setDeviceData(data);
|
| 140 |
+
setAuthProcessing(false);
|
| 141 |
+
|
| 142 |
+
// Start Polling (Recursive Timeout Pattern)
|
| 143 |
+
pollDeviceToken(data.device_code, data.interval || 5);
|
| 144 |
+
|
| 145 |
+
} catch (err) {
|
| 146 |
+
setError(err.message);
|
| 147 |
+
setAuthProcessing(false);
|
| 148 |
+
}
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
const pollDeviceToken = async (deviceCode, interval) => {
|
| 152 |
+
if (stopPolling.current) return;
|
| 153 |
+
|
| 154 |
+
try {
|
| 155 |
+
const response = await fetch(apiUrl("/api/auth/device/poll"), {
|
| 156 |
+
method: "POST",
|
| 157 |
+
headers: { "Content-Type": "application/json" },
|
| 158 |
+
body: JSON.stringify({ device_code: deviceCode })
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
// 1. Success (200)
|
| 162 |
+
if (response.status === 200) {
|
| 163 |
+
const data = await response.json();
|
| 164 |
+
handleSuccess(data);
|
| 165 |
+
return;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// 2. Pending (202) -> Continue Polling
|
| 169 |
+
if (response.status === 202) {
|
| 170 |
+
// Schedule next poll
|
| 171 |
+
pollTimer.current = setTimeout(
|
| 172 |
+
() => pollDeviceToken(deviceCode, interval),
|
| 173 |
+
interval * 1000
|
| 174 |
+
);
|
| 175 |
+
return;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// 3. Error (4xx/5xx) -> Stop Polling & Show Error
|
| 179 |
+
const errData = await response.json().catch(() => ({ error: "Unknown polling error" }));
|
| 180 |
+
|
| 181 |
+
// Special case: If it's just a 'slow_down' warning (sometimes 400), we just wait longer
|
| 182 |
+
if (errData.error === "slow_down") {
|
| 183 |
+
pollTimer.current = setTimeout(
|
| 184 |
+
() => pollDeviceToken(deviceCode, interval + 5),
|
| 185 |
+
(interval + 5) * 1000
|
| 186 |
+
);
|
| 187 |
+
return;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// Terminal errors
|
| 191 |
+
throw new Error(errData.error || `Polling failed: ${response.status}`);
|
| 192 |
+
|
| 193 |
+
} catch (e) {
|
| 194 |
+
console.error("Poll error:", e);
|
| 195 |
+
if (!stopPolling.current) {
|
| 196 |
+
setError(e.message || "Failed to connect to authentication server.");
|
| 197 |
+
setDeviceData(null); // Return to initial state
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
};
|
| 201 |
+
|
| 202 |
+
const handleManualCheck = async () => {
|
| 203 |
+
if (!deviceData?.device_code) return;
|
| 204 |
+
|
| 205 |
+
try {
|
| 206 |
+
const response = await fetch(apiUrl("/api/auth/device/poll"), {
|
| 207 |
+
method: "POST",
|
| 208 |
+
headers: { "Content-Type": "application/json" },
|
| 209 |
+
body: JSON.stringify({ device_code: deviceData.device_code })
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
if (response.status === 200) {
|
| 213 |
+
const data = await response.json();
|
| 214 |
+
handleSuccess(data);
|
| 215 |
+
} else if (response.status === 202) {
|
| 216 |
+
// Visual feedback for pending state
|
| 217 |
+
const btn = document.getElementById("manual-check-btn");
|
| 218 |
+
if (btn) {
|
| 219 |
+
const originalText = btn.innerText;
|
| 220 |
+
btn.innerText = "Still Pending...";
|
| 221 |
+
btn.disabled = true;
|
| 222 |
+
setTimeout(() => {
|
| 223 |
+
btn.innerText = originalText;
|
| 224 |
+
btn.disabled = false;
|
| 225 |
+
}, 2000);
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
} catch (e) {
|
| 229 |
+
console.error("Manual check failed", e);
|
| 230 |
+
}
|
| 231 |
+
};
|
| 232 |
+
|
| 233 |
+
const handleCancelDeviceFlow = () => {
|
| 234 |
+
stopPolling.current = true;
|
| 235 |
+
if (pollTimer.current) clearTimeout(pollTimer.current);
|
| 236 |
+
setDeviceData(null);
|
| 237 |
+
setError("");
|
| 238 |
+
};
|
| 239 |
+
|
| 240 |
+
// ===========================================================================
|
| 241 |
+
// SHARED HELPERS
|
| 242 |
+
// ===========================================================================
|
| 243 |
+
|
| 244 |
+
function handleSuccess(data) {
|
| 245 |
+
stopPolling.current = true; // Ensure polling stops
|
| 246 |
+
if (pollTimer.current) clearTimeout(pollTimer.current);
|
| 247 |
+
|
| 248 |
+
if (!data.access_token || !data.user) {
|
| 249 |
+
setError("Server returned incomplete session data.");
|
| 250 |
+
return;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
try {
|
| 254 |
+
localStorage.setItem("github_token", data.access_token);
|
| 255 |
+
localStorage.setItem("github_user", JSON.stringify(data.user));
|
| 256 |
+
} catch (e) {
|
| 257 |
+
console.warn("LocalStorage access denied:", e);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
if (typeof onAuthenticated === "function") {
|
| 261 |
+
onAuthenticated({
|
| 262 |
+
access_token: data.access_token,
|
| 263 |
+
user: data.user,
|
| 264 |
+
});
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// --- Design Token System ---
|
| 269 |
+
const theme = {
|
| 270 |
+
bg: "#131316",
|
| 271 |
+
cardBg: "#1C1C1F",
|
| 272 |
+
border: "#27272A",
|
| 273 |
+
accent: "#D95C3D",
|
| 274 |
+
accentHover: "#C44F32",
|
| 275 |
+
textPrimary: "#EDEDED",
|
| 276 |
+
textSecondary: "#A1A1AA",
|
| 277 |
+
font: '"Söhne", "Inter", -apple-system, sans-serif',
|
| 278 |
+
};
|
| 279 |
+
|
| 280 |
+
const styles = {
|
| 281 |
+
container: {
|
| 282 |
+
minHeight: "100vh",
|
| 283 |
+
display: "flex",
|
| 284 |
+
alignItems: "center",
|
| 285 |
+
justifyContent: "center",
|
| 286 |
+
backgroundColor: theme.bg,
|
| 287 |
+
fontFamily: theme.font,
|
| 288 |
+
color: theme.textPrimary,
|
| 289 |
+
letterSpacing: "-0.01em",
|
| 290 |
+
},
|
| 291 |
+
card: {
|
| 292 |
+
backgroundColor: theme.cardBg,
|
| 293 |
+
width: "100%",
|
| 294 |
+
maxWidth: "440px",
|
| 295 |
+
borderRadius: "12px",
|
| 296 |
+
border: `1px solid ${theme.border}`,
|
| 297 |
+
boxShadow: "0 24px 48px -12px rgba(0, 0, 0, 0.6)",
|
| 298 |
+
padding: "48px 40px",
|
| 299 |
+
textAlign: "center",
|
| 300 |
+
position: "relative",
|
| 301 |
+
},
|
| 302 |
+
logoBadge: {
|
| 303 |
+
width: "48px",
|
| 304 |
+
height: "48px",
|
| 305 |
+
backgroundColor: "rgba(217, 92, 61, 0.15)",
|
| 306 |
+
color: theme.accent,
|
| 307 |
+
borderRadius: "10px",
|
| 308 |
+
display: "flex",
|
| 309 |
+
alignItems: "center",
|
| 310 |
+
justifyContent: "center",
|
| 311 |
+
fontSize: "22px",
|
| 312 |
+
fontWeight: "700",
|
| 313 |
+
margin: "0 auto 32px auto",
|
| 314 |
+
border: "1px solid rgba(217, 92, 61, 0.2)",
|
| 315 |
+
},
|
| 316 |
+
h1: {
|
| 317 |
+
fontSize: "24px",
|
| 318 |
+
fontWeight: "600",
|
| 319 |
+
marginBottom: "12px",
|
| 320 |
+
color: theme.textPrimary,
|
| 321 |
+
},
|
| 322 |
+
p: {
|
| 323 |
+
fontSize: "14px",
|
| 324 |
+
color: theme.textSecondary,
|
| 325 |
+
lineHeight: "1.6",
|
| 326 |
+
marginBottom: "40px",
|
| 327 |
+
},
|
| 328 |
+
button: {
|
| 329 |
+
width: "100%",
|
| 330 |
+
height: "48px",
|
| 331 |
+
backgroundColor: theme.accent,
|
| 332 |
+
color: "#FFFFFF",
|
| 333 |
+
border: "none",
|
| 334 |
+
borderRadius: "8px",
|
| 335 |
+
fontSize: "14px",
|
| 336 |
+
fontWeight: "500",
|
| 337 |
+
cursor: (authProcessing || (mode === 'loading')) ? "not-allowed" : "pointer",
|
| 338 |
+
opacity: (authProcessing || (mode === 'loading')) ? 0.7 : 1,
|
| 339 |
+
transition: "background-color 0.2s ease",
|
| 340 |
+
display: "flex",
|
| 341 |
+
alignItems: "center",
|
| 342 |
+
justifyContent: "center",
|
| 343 |
+
gap: "10px",
|
| 344 |
+
boxShadow: "0 4px 12px rgba(217, 92, 61, 0.25)",
|
| 345 |
+
},
|
| 346 |
+
secondaryButton: {
|
| 347 |
+
backgroundColor: "transparent",
|
| 348 |
+
color: "#A1A1AA",
|
| 349 |
+
border: "1px solid #3F3F46",
|
| 350 |
+
padding: "8px 16px",
|
| 351 |
+
borderRadius: "6px",
|
| 352 |
+
fontSize: "12px",
|
| 353 |
+
cursor: "pointer",
|
| 354 |
+
marginTop: "16px",
|
| 355 |
+
minWidth: "100px"
|
| 356 |
+
},
|
| 357 |
+
errorBox: {
|
| 358 |
+
backgroundColor: "rgba(185, 28, 28, 0.15)",
|
| 359 |
+
border: "1px solid rgba(185, 28, 28, 0.3)",
|
| 360 |
+
color: "#FCA5A5",
|
| 361 |
+
padding: "12px",
|
| 362 |
+
borderRadius: "8px",
|
| 363 |
+
fontSize: "13px",
|
| 364 |
+
marginBottom: "24px",
|
| 365 |
+
textAlign: "left",
|
| 366 |
+
},
|
| 367 |
+
configCard: {
|
| 368 |
+
textAlign: "left",
|
| 369 |
+
backgroundColor: "#111",
|
| 370 |
+
border: "1px solid #333",
|
| 371 |
+
padding: "24px",
|
| 372 |
+
borderRadius: "8px",
|
| 373 |
+
marginBottom: "24px",
|
| 374 |
+
},
|
| 375 |
+
codeDisplay: {
|
| 376 |
+
backgroundColor: "#27272A",
|
| 377 |
+
color: theme.accent,
|
| 378 |
+
fontSize: "20px",
|
| 379 |
+
fontWeight: "700",
|
| 380 |
+
padding: "12px",
|
| 381 |
+
borderRadius: "6px",
|
| 382 |
+
textAlign: "center",
|
| 383 |
+
letterSpacing: "2px",
|
| 384 |
+
margin: "12px 0",
|
| 385 |
+
border: `1px dashed ${theme.accent}`,
|
| 386 |
+
cursor: "pointer",
|
| 387 |
+
},
|
| 388 |
+
footer: {
|
| 389 |
+
marginTop: "48px",
|
| 390 |
+
fontSize: "12px",
|
| 391 |
+
color: "#52525B",
|
| 392 |
+
}
|
| 393 |
+
};
|
| 394 |
+
|
| 395 |
+
// --- RENDER: Device Flow UI ---
|
| 396 |
+
const renderDeviceFlow = () => {
|
| 397 |
+
if (!deviceData) {
|
| 398 |
+
return (
|
| 399 |
+
<button
|
| 400 |
+
onClick={startDeviceFlow}
|
| 401 |
+
disabled={authProcessing}
|
| 402 |
+
style={styles.button}
|
| 403 |
+
onMouseOver={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accentHover)}
|
| 404 |
+
onMouseOut={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accent)}
|
| 405 |
+
>
|
| 406 |
+
{authProcessing ? "Connecting..." : "Sign in with GitHub"}
|
| 407 |
+
</button>
|
| 408 |
+
);
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
return (
|
| 412 |
+
<div style={styles.configCard}>
|
| 413 |
+
<h3 style={{marginTop:0, color: '#FFF', fontSize: '16px'}}>Authorize Device</h3>
|
| 414 |
+
<p style={{color: '#AAA', fontSize: '13px', marginBottom:'16px'}}>
|
| 415 |
+
GitPilot needs authorization to access your repositories.
|
| 416 |
+
</p>
|
| 417 |
+
|
| 418 |
+
<div style={{marginBottom: '16px'}}>
|
| 419 |
+
<div style={{color: '#AAA', fontSize: '12px', marginBottom: '4px'}}>1. Copy code:</div>
|
| 420 |
+
<div
|
| 421 |
+
style={styles.codeDisplay}
|
| 422 |
+
onClick={() => {
|
| 423 |
+
navigator.clipboard.writeText(deviceData.user_code);
|
| 424 |
+
}}
|
| 425 |
+
title="Click to copy"
|
| 426 |
+
>
|
| 427 |
+
{deviceData.user_code}
|
| 428 |
+
</div>
|
| 429 |
+
</div>
|
| 430 |
+
|
| 431 |
+
<div>
|
| 432 |
+
<div style={{color: '#AAA', fontSize: '12px', marginBottom: '4px'}}>2. Paste at GitHub:</div>
|
| 433 |
+
<a
|
| 434 |
+
href={deviceData.verification_uri}
|
| 435 |
+
target="_blank"
|
| 436 |
+
rel="noreferrer"
|
| 437 |
+
style={{
|
| 438 |
+
display: 'block',
|
| 439 |
+
backgroundColor: '#FFF',
|
| 440 |
+
color: '#000',
|
| 441 |
+
textDecoration: 'none',
|
| 442 |
+
padding: '10px',
|
| 443 |
+
borderRadius: '6px',
|
| 444 |
+
textAlign: 'center',
|
| 445 |
+
fontWeight: '600',
|
| 446 |
+
fontSize: '14px'
|
| 447 |
+
}}
|
| 448 |
+
>
|
| 449 |
+
Open Activation Page ↗
|
| 450 |
+
</a>
|
| 451 |
+
</div>
|
| 452 |
+
|
| 453 |
+
<div style={{marginTop: '20px', fontSize: '12px', color: '#666', textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px'}}>
|
| 454 |
+
<span style={{animation: 'spin 1s linear infinite', display: 'inline-block'}}>↻</span>
|
| 455 |
+
Waiting for authorization...
|
| 456 |
+
<style>{`@keyframes spin { 100% { transform: rotate(360deg); } }`}</style>
|
| 457 |
+
</div>
|
| 458 |
+
|
| 459 |
+
<div style={{textAlign: 'center', display: 'flex', gap: '10px', justifyContent: 'center'}}>
|
| 460 |
+
<button
|
| 461 |
+
id="manual-check-btn"
|
| 462 |
+
onClick={handleManualCheck}
|
| 463 |
+
style={styles.secondaryButton}
|
| 464 |
+
>
|
| 465 |
+
Check Status
|
| 466 |
+
</button>
|
| 467 |
+
<button
|
| 468 |
+
onClick={handleCancelDeviceFlow}
|
| 469 |
+
style={styles.secondaryButton}
|
| 470 |
+
>
|
| 471 |
+
Cancel
|
| 472 |
+
</button>
|
| 473 |
+
</div>
|
| 474 |
+
</div>
|
| 475 |
+
);
|
| 476 |
+
};
|
| 477 |
+
|
| 478 |
+
// --- RENDER: Config Error ---
|
| 479 |
+
if (missingClientId) {
|
| 480 |
+
return (
|
| 481 |
+
<div style={styles.container}>
|
| 482 |
+
<div style={styles.card}>
|
| 483 |
+
<div style={{...styles.logoBadge, color: "#F59E0B", backgroundColor: "rgba(245, 158, 11, 0.1)", borderColor: "rgba(245, 158, 11, 0.2)"}}>⚠️</div>
|
| 484 |
+
<h1 style={styles.h1}>Configuration Error</h1>
|
| 485 |
+
<p style={styles.p}>Could not connect to GitHub Authentication services.</p>
|
| 486 |
+
<button onClick={() => setMissingClientId(false)} style={{...styles.button, backgroundColor: "#3F3F46"}}>Retry</button>
|
| 487 |
+
</div>
|
| 488 |
+
</div>
|
| 489 |
+
);
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
// --- RENDER: Main ---
|
| 493 |
+
return (
|
| 494 |
+
<div style={styles.container}>
|
| 495 |
+
<div style={styles.card}>
|
| 496 |
+
<div style={styles.logoBadge}>GP</div>
|
| 497 |
+
|
| 498 |
+
<h1 style={styles.h1}>GitPilot Enterprise</h1>
|
| 499 |
+
<p style={styles.p}>
|
| 500 |
+
Agentic AI workflow for your repositories.<br/>
|
| 501 |
+
Secure. Context-aware. Automated.
|
| 502 |
+
</p>
|
| 503 |
+
|
| 504 |
+
{error && <div style={styles.errorBox}>{error}</div>}
|
| 505 |
+
|
| 506 |
+
{mode === "loading" && (
|
| 507 |
+
<div style={{color: '#666', fontSize: '14px'}}>Initializing...</div>
|
| 508 |
+
)}
|
| 509 |
+
|
| 510 |
+
{mode === "web" && (
|
| 511 |
+
<button
|
| 512 |
+
onClick={handleSignInWithGitHub}
|
| 513 |
+
disabled={authProcessing}
|
| 514 |
+
style={styles.button}
|
| 515 |
+
onMouseOver={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accentHover)}
|
| 516 |
+
onMouseOut={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accent)}
|
| 517 |
+
>
|
| 518 |
+
{authProcessing ? "Connecting..." : (
|
| 519 |
+
<>
|
| 520 |
+
<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>
|
| 521 |
+
Sign in with GitHub
|
| 522 |
+
</>
|
| 523 |
+
)}
|
| 524 |
+
</button>
|
| 525 |
+
)}
|
| 526 |
+
|
| 527 |
+
{mode === "device" && renderDeviceFlow()}
|
| 528 |
+
|
| 529 |
+
<div style={styles.footer}>
|
| 530 |
+
© {new Date().getFullYear()} GitPilot Inc.
|
| 531 |
+
</div>
|
| 532 |
+
</div>
|
| 533 |
+
</div>
|
| 534 |
+
);
|
| 535 |
+
}
|
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,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
if (!settings) return null;
|
| 133 |
+
|
| 134 |
+
const activeModel = currentModelForActiveProvider();
|
| 135 |
+
|
| 136 |
+
return (
|
| 137 |
+
<div className="modal-backdrop" onClick={onClose}>
|
| 138 |
+
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
| 139 |
+
<div className="modal-header">
|
| 140 |
+
<div className="modal-title">Settings</div>
|
| 141 |
+
<button className="modal-close" type="button" onClick={onClose}>
|
| 142 |
+
✕
|
| 143 |
+
</button>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<div style={{ fontSize: 13, color: "#c3c5dd" }}>
|
| 147 |
+
Select which LLM provider GitPilot should use for planning and chat.
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<div className="provider-list">
|
| 151 |
+
{settings.providers.map((p) => (
|
| 152 |
+
<div
|
| 153 |
+
key={p}
|
| 154 |
+
className={
|
| 155 |
+
"provider-item" + (settings.provider === p ? " active" : "")
|
| 156 |
+
}
|
| 157 |
+
>
|
| 158 |
+
<div className="provider-name">{p}</div>
|
| 159 |
+
<button
|
| 160 |
+
type="button"
|
| 161 |
+
className="chat-btn secondary"
|
| 162 |
+
style={{ padding: "4px 8px", fontSize: 11 }}
|
| 163 |
+
onClick={() => changeProvider(p)}
|
| 164 |
+
disabled={settings.provider === p}
|
| 165 |
+
>
|
| 166 |
+
{settings.provider === p ? "Active" : "Use"}
|
| 167 |
+
</button>
|
| 168 |
+
</div>
|
| 169 |
+
))}
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
{/* Models section */}
|
| 173 |
+
<div
|
| 174 |
+
style={{
|
| 175 |
+
marginTop: 16,
|
| 176 |
+
paddingTop: 12,
|
| 177 |
+
borderTop: "1px solid #2c2d46",
|
| 178 |
+
fontSize: 13,
|
| 179 |
+
}}
|
| 180 |
+
>
|
| 181 |
+
<div style={{ marginBottom: 6, color: "#c3c5dd" }}>
|
| 182 |
+
Active provider: <strong>{settings.provider}</strong>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
| 186 |
+
<button
|
| 187 |
+
type="button"
|
| 188 |
+
className="chat-btn secondary"
|
| 189 |
+
style={{ padding: "4px 8px", fontSize: 11 }}
|
| 190 |
+
onClick={testConnection}
|
| 191 |
+
disabled={testing}
|
| 192 |
+
>
|
| 193 |
+
{testing ? "Testing…" : "Test Connection"}
|
| 194 |
+
</button>
|
| 195 |
+
<button
|
| 196 |
+
type="button"
|
| 197 |
+
className="chat-btn secondary"
|
| 198 |
+
style={{ padding: "4px 8px", fontSize: 11 }}
|
| 199 |
+
onClick={loadModels}
|
| 200 |
+
disabled={loadingModels}
|
| 201 |
+
>
|
| 202 |
+
{loadingModels ? "Loading…" : "Display models"}
|
| 203 |
+
</button>
|
| 204 |
+
|
| 205 |
+
{activeModel && (
|
| 206 |
+
<span style={{ fontSize: 12, color: "#9092b5" }}>
|
| 207 |
+
Current model: <code>{activeModel}</code>
|
| 208 |
+
</span>
|
| 209 |
+
)}
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
{modelsError && (
|
| 213 |
+
<div style={{ marginTop: 8, color: "#ff8080", fontSize: 12 }}>
|
| 214 |
+
{modelsError}
|
| 215 |
+
</div>
|
| 216 |
+
)}
|
| 217 |
+
|
| 218 |
+
{testResult && (
|
| 219 |
+
<div style={{
|
| 220 |
+
marginTop: 8,
|
| 221 |
+
padding: "6px 10px",
|
| 222 |
+
borderRadius: 6,
|
| 223 |
+
background: testResult.ok ? "#0d3320" : "#3d1111",
|
| 224 |
+
border: `1px solid ${testResult.ok ? "#166534" : "#7f1d1d"}`,
|
| 225 |
+
color: testResult.ok ? "#86efac" : "#fca5a5",
|
| 226 |
+
fontSize: 12,
|
| 227 |
+
}}>
|
| 228 |
+
{testResult.ok ? "✓ " : "✗ "}{testResult.message}
|
| 229 |
+
</div>
|
| 230 |
+
)}
|
| 231 |
+
|
| 232 |
+
{models.length > 0 && (
|
| 233 |
+
<div style={{ marginTop: 10 }}>
|
| 234 |
+
<label
|
| 235 |
+
style={{
|
| 236 |
+
display: "block",
|
| 237 |
+
marginBottom: 4,
|
| 238 |
+
fontSize: 12,
|
| 239 |
+
color: "#c3c5dd",
|
| 240 |
+
}}
|
| 241 |
+
>
|
| 242 |
+
Select model for {settings.provider}:
|
| 243 |
+
</label>
|
| 244 |
+
<select
|
| 245 |
+
style={{
|
| 246 |
+
width: "100%",
|
| 247 |
+
fontSize: 12,
|
| 248 |
+
padding: "4px 6px",
|
| 249 |
+
background: "#14152a",
|
| 250 |
+
color: "#e6e8ff",
|
| 251 |
+
border: "1px solid #2c2d46",
|
| 252 |
+
borderRadius: 4,
|
| 253 |
+
}}
|
| 254 |
+
value={activeModel}
|
| 255 |
+
onChange={(e) => changeModel(e.target.value)}
|
| 256 |
+
>
|
| 257 |
+
<option value="">-- select a model --</option>
|
| 258 |
+
{models.map((m) => (
|
| 259 |
+
<option key={m} value={m}>
|
| 260 |
+
{m}
|
| 261 |
+
</option>
|
| 262 |
+
))}
|
| 263 |
+
</select>
|
| 264 |
+
</div>
|
| 265 |
+
)}
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
);
|
| 270 |
+
}
|
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/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.3",
|
| 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,2825 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
padding: 16px 14px;
|
| 62 |
+
border-right: 1px solid #272832;
|
| 63 |
+
background: linear-gradient(180deg, #101117 0, #050608 100%);
|
| 64 |
+
display: flex;
|
| 65 |
+
flex-direction: column;
|
| 66 |
+
gap: 16px;
|
| 67 |
+
overflow-y: auto;
|
| 68 |
+
overflow-x: hidden;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/* User Profile Section */
|
| 72 |
+
.user-profile {
|
| 73 |
+
margin-top: auto;
|
| 74 |
+
padding-top: 16px;
|
| 75 |
+
border-top: 1px solid #272832;
|
| 76 |
+
display: flex;
|
| 77 |
+
flex-direction: column;
|
| 78 |
+
gap: 12px;
|
| 79 |
+
animation: fadeIn 0.3s ease;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.user-profile-header {
|
| 83 |
+
display: flex;
|
| 84 |
+
align-items: center;
|
| 85 |
+
gap: 10px;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.user-avatar {
|
| 89 |
+
width: 40px;
|
| 90 |
+
height: 40px;
|
| 91 |
+
border-radius: 10px;
|
| 92 |
+
border: 2px solid #272832;
|
| 93 |
+
transition: all 0.2s ease;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.user-avatar:hover {
|
| 97 |
+
border-color: #ff7a3c;
|
| 98 |
+
transform: scale(1.05);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.user-info {
|
| 102 |
+
flex: 1;
|
| 103 |
+
min-width: 0;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.user-name {
|
| 107 |
+
font-size: 13px;
|
| 108 |
+
font-weight: 600;
|
| 109 |
+
color: #f5f5f7;
|
| 110 |
+
white-space: nowrap;
|
| 111 |
+
overflow: hidden;
|
| 112 |
+
text-overflow: ellipsis;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.user-login {
|
| 116 |
+
font-size: 11px;
|
| 117 |
+
color: #9a9bb0;
|
| 118 |
+
white-space: nowrap;
|
| 119 |
+
overflow: hidden;
|
| 120 |
+
text-overflow: ellipsis;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.btn-logout {
|
| 124 |
+
border: none;
|
| 125 |
+
outline: none;
|
| 126 |
+
background: #1a1b26;
|
| 127 |
+
color: #c3c5dd;
|
| 128 |
+
border-radius: 8px;
|
| 129 |
+
padding: 8px 12px;
|
| 130 |
+
font-size: 12px;
|
| 131 |
+
font-weight: 500;
|
| 132 |
+
cursor: pointer;
|
| 133 |
+
transition: all 0.2s ease;
|
| 134 |
+
border: 1px solid #272832;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.btn-logout:hover {
|
| 138 |
+
background: #2a2b3c;
|
| 139 |
+
border-color: #ff7a3c;
|
| 140 |
+
color: #ff7a3c;
|
| 141 |
+
transform: translateY(-1px);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.logo-row {
|
| 145 |
+
display: flex;
|
| 146 |
+
align-items: center;
|
| 147 |
+
gap: 10px;
|
| 148 |
+
animation: fadeIn 0.3s ease;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
@keyframes fadeIn {
|
| 152 |
+
from {
|
| 153 |
+
opacity: 0;
|
| 154 |
+
transform: translateY(-10px);
|
| 155 |
+
}
|
| 156 |
+
to {
|
| 157 |
+
opacity: 1;
|
| 158 |
+
transform: translateY(0);
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.logo-square {
|
| 163 |
+
width: 32px;
|
| 164 |
+
height: 32px;
|
| 165 |
+
border-radius: 8px;
|
| 166 |
+
background: #ff7a3c;
|
| 167 |
+
display: flex;
|
| 168 |
+
align-items: center;
|
| 169 |
+
justify-content: center;
|
| 170 |
+
font-weight: 700;
|
| 171 |
+
color: #050608;
|
| 172 |
+
transition: transform 0.2s ease;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.logo-square:hover {
|
| 176 |
+
transform: scale(1.05);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.logo-title {
|
| 180 |
+
font-size: 16px;
|
| 181 |
+
font-weight: 600;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.logo-subtitle {
|
| 185 |
+
font-size: 12px;
|
| 186 |
+
color: #a1a2b3;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
/* Active context card */
|
| 190 |
+
.sidebar-context-card {
|
| 191 |
+
padding: 10px 12px;
|
| 192 |
+
border-radius: 10px;
|
| 193 |
+
background: #151622;
|
| 194 |
+
border: 1px solid #272832;
|
| 195 |
+
display: flex;
|
| 196 |
+
flex-direction: column;
|
| 197 |
+
gap: 6px;
|
| 198 |
+
animation: slideIn 0.3s ease;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.sidebar-context-header {
|
| 202 |
+
display: flex;
|
| 203 |
+
align-items: center;
|
| 204 |
+
justify-content: space-between;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.sidebar-context-close {
|
| 208 |
+
width: 22px;
|
| 209 |
+
height: 22px;
|
| 210 |
+
border-radius: 4px;
|
| 211 |
+
border: none;
|
| 212 |
+
background: transparent;
|
| 213 |
+
color: #71717a;
|
| 214 |
+
cursor: pointer;
|
| 215 |
+
display: flex;
|
| 216 |
+
align-items: center;
|
| 217 |
+
justify-content: center;
|
| 218 |
+
padding: 0;
|
| 219 |
+
transition: all 0.15s ease;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.sidebar-context-close:hover {
|
| 223 |
+
background: #272832;
|
| 224 |
+
color: #f5f5f7;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.sidebar-section-label {
|
| 228 |
+
font-size: 10px;
|
| 229 |
+
font-weight: 700;
|
| 230 |
+
letter-spacing: 0.08em;
|
| 231 |
+
color: #71717a;
|
| 232 |
+
text-transform: uppercase;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.sidebar-context-body {
|
| 236 |
+
display: flex;
|
| 237 |
+
flex-direction: column;
|
| 238 |
+
gap: 2px;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.sidebar-context-repo {
|
| 242 |
+
font-size: 13px;
|
| 243 |
+
font-weight: 600;
|
| 244 |
+
color: #f5f5f7;
|
| 245 |
+
white-space: nowrap;
|
| 246 |
+
overflow: hidden;
|
| 247 |
+
text-overflow: ellipsis;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.sidebar-context-meta {
|
| 251 |
+
font-size: 11px;
|
| 252 |
+
color: #9a9bb0;
|
| 253 |
+
display: flex;
|
| 254 |
+
align-items: center;
|
| 255 |
+
gap: 6px;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.sidebar-context-dot {
|
| 259 |
+
width: 3px;
|
| 260 |
+
height: 3px;
|
| 261 |
+
border-radius: 50%;
|
| 262 |
+
background: #4a4b5e;
|
| 263 |
+
display: inline-block;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.sidebar-context-actions {
|
| 267 |
+
display: flex;
|
| 268 |
+
gap: 6px;
|
| 269 |
+
margin-top: 2px;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.sidebar-context-btn {
|
| 273 |
+
border: none;
|
| 274 |
+
outline: none;
|
| 275 |
+
background: #1a1b26;
|
| 276 |
+
color: #9a9bb0;
|
| 277 |
+
border-radius: 6px;
|
| 278 |
+
padding: 4px 10px;
|
| 279 |
+
font-size: 11px;
|
| 280 |
+
font-weight: 500;
|
| 281 |
+
cursor: pointer;
|
| 282 |
+
transition: all 0.15s ease;
|
| 283 |
+
border: 1px solid #272832;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.sidebar-context-btn:hover {
|
| 287 |
+
background: #222335;
|
| 288 |
+
color: #c3c5dd;
|
| 289 |
+
border-color: #3a3b4d;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
/* Per-repo chip list in sidebar context card */
|
| 293 |
+
.sidebar-repo-chips {
|
| 294 |
+
display: flex;
|
| 295 |
+
flex-direction: column;
|
| 296 |
+
gap: 3px;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.sidebar-repo-chip {
|
| 300 |
+
display: flex;
|
| 301 |
+
align-items: center;
|
| 302 |
+
gap: 5px;
|
| 303 |
+
padding: 5px 6px 5px 8px;
|
| 304 |
+
border-radius: 6px;
|
| 305 |
+
border: 1px solid #272832;
|
| 306 |
+
background: #111220;
|
| 307 |
+
cursor: pointer;
|
| 308 |
+
white-space: nowrap;
|
| 309 |
+
overflow: hidden;
|
| 310 |
+
transition: border-color 0.15s, background-color 0.15s;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.sidebar-repo-chip:hover {
|
| 314 |
+
border-color: #3a3b4d;
|
| 315 |
+
background: #1a1b2e;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.sidebar-repo-chip-active {
|
| 319 |
+
border-color: #3B82F6;
|
| 320 |
+
background: rgba(59, 130, 246, 0.06);
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.sidebar-chip-name {
|
| 324 |
+
font-size: 12px;
|
| 325 |
+
font-weight: 600;
|
| 326 |
+
color: #c3c5dd;
|
| 327 |
+
font-family: monospace;
|
| 328 |
+
overflow: hidden;
|
| 329 |
+
text-overflow: ellipsis;
|
| 330 |
+
flex: 1;
|
| 331 |
+
min-width: 0;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.sidebar-repo-chip-active .sidebar-chip-name {
|
| 335 |
+
color: #f5f5f7;
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
.sidebar-chip-dot {
|
| 339 |
+
width: 2px;
|
| 340 |
+
height: 2px;
|
| 341 |
+
border-radius: 50%;
|
| 342 |
+
background: #4a4b5e;
|
| 343 |
+
flex-shrink: 0;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.sidebar-chip-branch {
|
| 347 |
+
font-size: 10px;
|
| 348 |
+
color: #71717a;
|
| 349 |
+
font-family: monospace;
|
| 350 |
+
flex-shrink: 0;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.sidebar-repo-chip-active .sidebar-chip-branch {
|
| 354 |
+
color: #60a5fa;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
.sidebar-chip-write-badge {
|
| 358 |
+
font-size: 8px;
|
| 359 |
+
font-weight: 700;
|
| 360 |
+
text-transform: uppercase;
|
| 361 |
+
letter-spacing: 0.06em;
|
| 362 |
+
color: #4caf88;
|
| 363 |
+
padding: 0 4px;
|
| 364 |
+
border-radius: 3px;
|
| 365 |
+
border: 1px solid rgba(76, 175, 136, 0.25);
|
| 366 |
+
flex-shrink: 0;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
/* Per-chip remove button: subtle by default, visible on hover */
|
| 370 |
+
.sidebar-chip-remove {
|
| 371 |
+
display: flex;
|
| 372 |
+
align-items: center;
|
| 373 |
+
justify-content: center;
|
| 374 |
+
width: 16px;
|
| 375 |
+
height: 16px;
|
| 376 |
+
border-radius: 3px;
|
| 377 |
+
border: none;
|
| 378 |
+
background: transparent;
|
| 379 |
+
color: #52525B;
|
| 380 |
+
cursor: pointer;
|
| 381 |
+
flex-shrink: 0;
|
| 382 |
+
padding: 0;
|
| 383 |
+
opacity: 0;
|
| 384 |
+
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.sidebar-repo-chip:hover .sidebar-chip-remove {
|
| 388 |
+
opacity: 1;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.sidebar-chip-remove:hover {
|
| 392 |
+
color: #f87171;
|
| 393 |
+
background: rgba(248, 113, 113, 0.1);
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
/* "clear all" link-style button */
|
| 397 |
+
.sidebar-clear-all {
|
| 398 |
+
font-size: 9px;
|
| 399 |
+
color: #52525B;
|
| 400 |
+
width: auto;
|
| 401 |
+
height: auto;
|
| 402 |
+
padding: 2px 6px;
|
| 403 |
+
font-weight: 600;
|
| 404 |
+
text-transform: uppercase;
|
| 405 |
+
letter-spacing: 0.04em;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.sidebar-clear-all:hover {
|
| 409 |
+
color: #f87171;
|
| 410 |
+
background: rgba(248, 113, 113, 0.08);
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
@keyframes slideIn {
|
| 414 |
+
from {
|
| 415 |
+
opacity: 0;
|
| 416 |
+
transform: translateX(-10px);
|
| 417 |
+
}
|
| 418 |
+
to {
|
| 419 |
+
opacity: 1;
|
| 420 |
+
transform: translateX(0);
|
| 421 |
+
}
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
/* ContextBar — horizontal chip bar above workspace */
|
| 425 |
+
.ctxbar {
|
| 426 |
+
display: flex;
|
| 427 |
+
align-items: center;
|
| 428 |
+
gap: 8px;
|
| 429 |
+
padding: 6px 12px;
|
| 430 |
+
border-bottom: 1px solid #1E1F23;
|
| 431 |
+
background-color: #0D0D10;
|
| 432 |
+
min-height: 40px;
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
.ctxbar-scroll {
|
| 436 |
+
display: flex;
|
| 437 |
+
align-items: center;
|
| 438 |
+
gap: 6px;
|
| 439 |
+
flex: 1;
|
| 440 |
+
overflow-x: auto;
|
| 441 |
+
scrollbar-width: none;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.ctxbar-scroll::-webkit-scrollbar {
|
| 445 |
+
display: none;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
.ctxbar-chip {
|
| 449 |
+
display: flex;
|
| 450 |
+
align-items: center;
|
| 451 |
+
gap: 5px;
|
| 452 |
+
padding: 4px 6px 4px 8px;
|
| 453 |
+
border-radius: 6px;
|
| 454 |
+
border: 1px solid #27272A;
|
| 455 |
+
background: #18181B;
|
| 456 |
+
cursor: pointer;
|
| 457 |
+
white-space: nowrap;
|
| 458 |
+
position: relative;
|
| 459 |
+
flex-shrink: 0;
|
| 460 |
+
transition: border-color 0.15s, background-color 0.15s;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.ctxbar-chip:hover {
|
| 464 |
+
border-color: #3a3b4d;
|
| 465 |
+
background: #1e1f30;
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
.ctxbar-chip-active {
|
| 469 |
+
border-color: #3B82F6;
|
| 470 |
+
background: rgba(59, 130, 246, 0.08);
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
.ctxbar-chip-indicator {
|
| 474 |
+
position: absolute;
|
| 475 |
+
left: 0;
|
| 476 |
+
top: 25%;
|
| 477 |
+
bottom: 25%;
|
| 478 |
+
width: 2px;
|
| 479 |
+
border-radius: 1px;
|
| 480 |
+
background-color: #3B82F6;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
.ctxbar-chip-name {
|
| 484 |
+
font-size: 12px;
|
| 485 |
+
font-weight: 600;
|
| 486 |
+
font-family: monospace;
|
| 487 |
+
color: #A1A1AA;
|
| 488 |
+
max-width: 120px;
|
| 489 |
+
overflow: hidden;
|
| 490 |
+
text-overflow: ellipsis;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
.ctxbar-chip-active .ctxbar-chip-name {
|
| 494 |
+
color: #E4E4E7;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.ctxbar-chip-dot {
|
| 498 |
+
width: 2px;
|
| 499 |
+
height: 2px;
|
| 500 |
+
border-radius: 50%;
|
| 501 |
+
background: #4a4b5e;
|
| 502 |
+
flex-shrink: 0;
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
.ctxbar-chip-branch {
|
| 506 |
+
font-size: 10px;
|
| 507 |
+
font-family: monospace;
|
| 508 |
+
background: none;
|
| 509 |
+
border: 1px solid transparent;
|
| 510 |
+
border-radius: 3px;
|
| 511 |
+
padding: 1px 4px;
|
| 512 |
+
cursor: pointer;
|
| 513 |
+
color: #71717A;
|
| 514 |
+
transition: border-color 0.15s, color 0.15s;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.ctxbar-chip-branch:hover {
|
| 518 |
+
border-color: #3a3b4d;
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
.ctxbar-chip-branch-active {
|
| 522 |
+
color: #60a5fa;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
.ctxbar-chip-write {
|
| 526 |
+
font-size: 8px;
|
| 527 |
+
font-weight: 700;
|
| 528 |
+
text-transform: uppercase;
|
| 529 |
+
letter-spacing: 0.06em;
|
| 530 |
+
color: #4caf88;
|
| 531 |
+
padding: 0 4px;
|
| 532 |
+
border-radius: 3px;
|
| 533 |
+
border: 1px solid rgba(76, 175, 136, 0.25);
|
| 534 |
+
flex-shrink: 0;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
/* Hover-reveal remove button (Claude-style: hidden → visible on chip hover → red on X hover) */
|
| 538 |
+
.ctxbar-chip-remove {
|
| 539 |
+
display: flex;
|
| 540 |
+
align-items: center;
|
| 541 |
+
justify-content: center;
|
| 542 |
+
width: 16px;
|
| 543 |
+
height: 16px;
|
| 544 |
+
border-radius: 3px;
|
| 545 |
+
border: none;
|
| 546 |
+
background: transparent;
|
| 547 |
+
color: #52525B;
|
| 548 |
+
cursor: pointer;
|
| 549 |
+
flex-shrink: 0;
|
| 550 |
+
padding: 0;
|
| 551 |
+
opacity: 0;
|
| 552 |
+
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
.ctxbar-chip-remove-visible,
|
| 556 |
+
.ctxbar-chip:hover .ctxbar-chip-remove {
|
| 557 |
+
opacity: 1;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
.ctxbar-chip-remove:hover {
|
| 561 |
+
color: #f87171;
|
| 562 |
+
background: rgba(248, 113, 113, 0.1);
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
.ctxbar-add {
|
| 566 |
+
display: flex;
|
| 567 |
+
align-items: center;
|
| 568 |
+
justify-content: center;
|
| 569 |
+
width: 28px;
|
| 570 |
+
height: 28px;
|
| 571 |
+
border-radius: 6px;
|
| 572 |
+
border: 1px dashed #3F3F46;
|
| 573 |
+
background: transparent;
|
| 574 |
+
color: #71717A;
|
| 575 |
+
cursor: pointer;
|
| 576 |
+
flex-shrink: 0;
|
| 577 |
+
transition: border-color 0.15s, color 0.15s;
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
.ctxbar-add:hover {
|
| 581 |
+
border-color: #60a5fa;
|
| 582 |
+
color: #60a5fa;
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
.ctxbar-meta {
|
| 586 |
+
font-size: 10px;
|
| 587 |
+
color: #52525B;
|
| 588 |
+
white-space: nowrap;
|
| 589 |
+
flex-shrink: 0;
|
| 590 |
+
font-weight: 600;
|
| 591 |
+
text-transform: uppercase;
|
| 592 |
+
letter-spacing: 0.04em;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
.ctxbar-branch-picker {
|
| 596 |
+
position: absolute;
|
| 597 |
+
top: 100%;
|
| 598 |
+
left: 0;
|
| 599 |
+
z-index: 100;
|
| 600 |
+
margin-top: 4px;
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
/* Legacy compat — kept for other uses */
|
| 604 |
+
.sidebar-repo-info {
|
| 605 |
+
padding: 10px 12px;
|
| 606 |
+
border-radius: 10px;
|
| 607 |
+
background: #151622;
|
| 608 |
+
border: 1px solid #272832;
|
| 609 |
+
animation: slideIn 0.3s ease;
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
.sidebar-repo-name {
|
| 613 |
+
font-size: 13px;
|
| 614 |
+
font-weight: 500;
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
.sidebar-repo-meta {
|
| 618 |
+
font-size: 11px;
|
| 619 |
+
color: #9a9bb0;
|
| 620 |
+
margin-top: 2px;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
.settings-button {
|
| 624 |
+
border: none;
|
| 625 |
+
outline: none;
|
| 626 |
+
background: #1a1b26;
|
| 627 |
+
color: #f5f5f7;
|
| 628 |
+
border-radius: 8px;
|
| 629 |
+
padding: 8px 10px;
|
| 630 |
+
cursor: pointer;
|
| 631 |
+
font-size: 13px;
|
| 632 |
+
transition: all 0.2s ease;
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
.settings-button:hover {
|
| 636 |
+
background: #222335;
|
| 637 |
+
transform: translateY(-1px);
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
/* Repo search */
|
| 641 |
+
.repo-search-box {
|
| 642 |
+
border-radius: 12px;
|
| 643 |
+
background: #101117;
|
| 644 |
+
border: 1px solid #272832;
|
| 645 |
+
padding: 8px;
|
| 646 |
+
display: flex;
|
| 647 |
+
flex-direction: column;
|
| 648 |
+
gap: 8px;
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
/* Search header wrapper */
|
| 652 |
+
.repo-search-header {
|
| 653 |
+
display: flex;
|
| 654 |
+
flex-direction: column;
|
| 655 |
+
gap: 8px;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
/* Search row with input and button */
|
| 659 |
+
.repo-search-row {
|
| 660 |
+
display: flex;
|
| 661 |
+
gap: 6px;
|
| 662 |
+
align-items: center;
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
/* Search input */
|
| 666 |
+
.repo-search-input {
|
| 667 |
+
flex: 1;
|
| 668 |
+
border-radius: 7px;
|
| 669 |
+
padding: 8px 10px;
|
| 670 |
+
border: 1px solid #272832;
|
| 671 |
+
background: #050608;
|
| 672 |
+
color: #f5f5f7;
|
| 673 |
+
font-size: 13px;
|
| 674 |
+
transition: all 0.2s ease;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
.repo-search-input:focus {
|
| 678 |
+
outline: none;
|
| 679 |
+
border-color: #ff7a3c;
|
| 680 |
+
background: #0a0b0f;
|
| 681 |
+
box-shadow: 0 0 0 3px rgba(255, 122, 60, 0.08);
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
.repo-search-input::placeholder {
|
| 685 |
+
color: #676883;
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
.repo-search-input:disabled {
|
| 689 |
+
opacity: 0.5;
|
| 690 |
+
cursor: not-allowed;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
/* Search button */
|
| 694 |
+
.repo-search-btn {
|
| 695 |
+
border-radius: 7px;
|
| 696 |
+
border: none;
|
| 697 |
+
outline: none;
|
| 698 |
+
padding: 8px 14px;
|
| 699 |
+
background: #1a1b26;
|
| 700 |
+
color: #f5f5f7;
|
| 701 |
+
cursor: pointer;
|
| 702 |
+
font-size: 13px;
|
| 703 |
+
font-weight: 500;
|
| 704 |
+
transition: all 0.2s ease;
|
| 705 |
+
white-space: nowrap;
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
.repo-search-btn:hover:not(:disabled) {
|
| 709 |
+
background: #222335;
|
| 710 |
+
transform: translateY(-1px);
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
.repo-search-btn:active:not(:disabled) {
|
| 714 |
+
transform: translateY(0);
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
.repo-search-btn:disabled {
|
| 718 |
+
opacity: 0.5;
|
| 719 |
+
cursor: not-allowed;
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
/* Info bar (shows count and clear button) */
|
| 723 |
+
.repo-info-bar {
|
| 724 |
+
display: flex;
|
| 725 |
+
justify-content: space-between;
|
| 726 |
+
align-items: center;
|
| 727 |
+
padding: 6px 10px;
|
| 728 |
+
background: #0a0b0f;
|
| 729 |
+
border: 1px solid #272832;
|
| 730 |
+
border-radius: 7px;
|
| 731 |
+
font-size: 11px;
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
.repo-count {
|
| 735 |
+
color: #9a9bb0;
|
| 736 |
+
font-weight: 500;
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
.repo-clear-btn {
|
| 740 |
+
padding: 3px 10px;
|
| 741 |
+
background: transparent;
|
| 742 |
+
border: 1px solid #272832;
|
| 743 |
+
border-radius: 5px;
|
| 744 |
+
color: #9a9bb0;
|
| 745 |
+
font-size: 11px;
|
| 746 |
+
font-weight: 500;
|
| 747 |
+
cursor: pointer;
|
| 748 |
+
transition: all 0.2s ease;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
.repo-clear-btn:hover:not(:disabled) {
|
| 752 |
+
background: #1a1b26;
|
| 753 |
+
color: #c3c5dd;
|
| 754 |
+
border-color: #3a3b4d;
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
.repo-clear-btn:disabled {
|
| 758 |
+
opacity: 0.5;
|
| 759 |
+
cursor: not-allowed;
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
/* Status message */
|
| 763 |
+
.repo-status {
|
| 764 |
+
padding: 8px 10px;
|
| 765 |
+
background: #1a1b26;
|
| 766 |
+
border: 1px solid #272832;
|
| 767 |
+
border-radius: 7px;
|
| 768 |
+
color: #9a9bb0;
|
| 769 |
+
font-size: 11px;
|
| 770 |
+
text-align: center;
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
/* Repository list */
|
| 774 |
+
.repo-list {
|
| 775 |
+
max-height: 220px;
|
| 776 |
+
overflow-y: auto;
|
| 777 |
+
overflow-x: hidden;
|
| 778 |
+
padding-right: 2px;
|
| 779 |
+
display: flex;
|
| 780 |
+
flex-direction: column;
|
| 781 |
+
gap: 4px;
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
/* Custom scrollbar for repo list */
|
| 785 |
+
.repo-list::-webkit-scrollbar {
|
| 786 |
+
width: 6px;
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
.repo-list::-webkit-scrollbar-track {
|
| 790 |
+
background: transparent;
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
.repo-list::-webkit-scrollbar-thumb {
|
| 794 |
+
background: #272832;
|
| 795 |
+
border-radius: 3px;
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
.repo-list::-webkit-scrollbar-thumb:hover {
|
| 799 |
+
background: #3a3b4d;
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
/* Repository item */
|
| 803 |
+
.repo-item {
|
| 804 |
+
width: 100%;
|
| 805 |
+
text-align: left;
|
| 806 |
+
border: none;
|
| 807 |
+
outline: none;
|
| 808 |
+
background: transparent;
|
| 809 |
+
color: #f5f5f7;
|
| 810 |
+
padding: 8px 8px;
|
| 811 |
+
border-radius: 7px;
|
| 812 |
+
cursor: pointer;
|
| 813 |
+
display: flex;
|
| 814 |
+
align-items: center;
|
| 815 |
+
justify-content: space-between;
|
| 816 |
+
gap: 8px;
|
| 817 |
+
transition: all 0.15s ease;
|
| 818 |
+
border: 1px solid transparent;
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
.repo-item:hover {
|
| 822 |
+
background: #1a1b26;
|
| 823 |
+
border-color: #272832;
|
| 824 |
+
transform: translateX(2px);
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
.repo-item-content {
|
| 828 |
+
display: flex;
|
| 829 |
+
flex-direction: column;
|
| 830 |
+
gap: 2px;
|
| 831 |
+
flex: 1;
|
| 832 |
+
min-width: 0;
|
| 833 |
+
}
|
| 834 |
+
|
| 835 |
+
.repo-name {
|
| 836 |
+
font-size: 13px;
|
| 837 |
+
font-weight: 500;
|
| 838 |
+
overflow: hidden;
|
| 839 |
+
text-overflow: ellipsis;
|
| 840 |
+
white-space: nowrap;
|
| 841 |
+
}
|
| 842 |
+
|
| 843 |
+
.repo-owner {
|
| 844 |
+
font-size: 11px;
|
| 845 |
+
color: #8e8fac;
|
| 846 |
+
overflow: hidden;
|
| 847 |
+
text-overflow: ellipsis;
|
| 848 |
+
white-space: nowrap;
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
/* Private badge */
|
| 852 |
+
.repo-badge-private {
|
| 853 |
+
padding: 2px 6px;
|
| 854 |
+
background: #1a1b26;
|
| 855 |
+
border: 1px solid #3a3b4d;
|
| 856 |
+
border-radius: 4px;
|
| 857 |
+
color: #9a9bb0;
|
| 858 |
+
font-size: 9px;
|
| 859 |
+
font-weight: 600;
|
| 860 |
+
text-transform: uppercase;
|
| 861 |
+
letter-spacing: 0.3px;
|
| 862 |
+
white-space: nowrap;
|
| 863 |
+
flex-shrink: 0;
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
/* Loading states */
|
| 867 |
+
.repo-loading {
|
| 868 |
+
display: flex;
|
| 869 |
+
flex-direction: column;
|
| 870 |
+
align-items: center;
|
| 871 |
+
gap: 10px;
|
| 872 |
+
padding: 30px 20px;
|
| 873 |
+
color: #9a9bb0;
|
| 874 |
+
font-size: 12px;
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
.repo-loading-spinner {
|
| 878 |
+
width: 24px;
|
| 879 |
+
height: 24px;
|
| 880 |
+
border: 2px solid #272832;
|
| 881 |
+
border-top-color: #ff7a3c;
|
| 882 |
+
border-radius: 50%;
|
| 883 |
+
animation: repo-spin 0.8s linear infinite;
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
.repo-loading-spinner-small {
|
| 887 |
+
width: 14px;
|
| 888 |
+
height: 14px;
|
| 889 |
+
border: 2px solid rgba(255, 122, 60, 0.3);
|
| 890 |
+
border-top-color: #ff7a3c;
|
| 891 |
+
border-radius: 50%;
|
| 892 |
+
animation: repo-spin 0.8s linear infinite;
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
@keyframes repo-spin {
|
| 896 |
+
to {
|
| 897 |
+
transform: rotate(360deg);
|
| 898 |
+
}
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
/* Load more button */
|
| 902 |
+
.repo-load-more {
|
| 903 |
+
display: flex;
|
| 904 |
+
align-items: center;
|
| 905 |
+
justify-content: center;
|
| 906 |
+
gap: 8px;
|
| 907 |
+
width: 100%;
|
| 908 |
+
padding: 10px 12px;
|
| 909 |
+
margin: 4px 0;
|
| 910 |
+
background: #0a0b0f;
|
| 911 |
+
border: 1px solid #272832;
|
| 912 |
+
border-radius: 7px;
|
| 913 |
+
color: #c3c5dd;
|
| 914 |
+
font-size: 12px;
|
| 915 |
+
font-weight: 500;
|
| 916 |
+
cursor: pointer;
|
| 917 |
+
transition: all 0.2s ease;
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
.repo-load-more:hover:not(:disabled) {
|
| 921 |
+
background: #1a1b26;
|
| 922 |
+
border-color: #3a3b4d;
|
| 923 |
+
transform: translateY(-1px);
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
.repo-load-more:active:not(:disabled) {
|
| 927 |
+
transform: translateY(0);
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
.repo-load-more:disabled {
|
| 931 |
+
opacity: 0.6;
|
| 932 |
+
cursor: not-allowed;
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
.repo-load-more-count {
|
| 936 |
+
color: #7779a0;
|
| 937 |
+
font-weight: 400;
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
/* All loaded message */
|
| 941 |
+
.repo-all-loaded {
|
| 942 |
+
padding: 10px 12px;
|
| 943 |
+
margin: 4px 0;
|
| 944 |
+
background: rgba(124, 255, 179, 0.08);
|
| 945 |
+
border: 1px solid rgba(124, 255, 179, 0.2);
|
| 946 |
+
border-radius: 7px;
|
| 947 |
+
color: #7cffb3;
|
| 948 |
+
font-size: 11px;
|
| 949 |
+
text-align: center;
|
| 950 |
+
font-weight: 500;
|
| 951 |
+
}
|
| 952 |
+
|
| 953 |
+
/* GitHub App installation notice */
|
| 954 |
+
.repo-github-notice {
|
| 955 |
+
display: flex;
|
| 956 |
+
align-items: flex-start;
|
| 957 |
+
gap: 10px;
|
| 958 |
+
padding: 10px 12px;
|
| 959 |
+
background: #0a0b0f;
|
| 960 |
+
border: 1px solid #272832;
|
| 961 |
+
border-radius: 7px;
|
| 962 |
+
font-size: 11px;
|
| 963 |
+
line-height: 1.5;
|
| 964 |
+
margin-top: 4px;
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
.repo-github-icon {
|
| 968 |
+
flex-shrink: 0;
|
| 969 |
+
margin-top: 1px;
|
| 970 |
+
opacity: 0.6;
|
| 971 |
+
color: #9a9bb0;
|
| 972 |
+
width: 16px;
|
| 973 |
+
height: 16px;
|
| 974 |
+
}
|
| 975 |
+
|
| 976 |
+
.repo-github-notice-content {
|
| 977 |
+
flex: 1;
|
| 978 |
+
display: flex;
|
| 979 |
+
flex-direction: column;
|
| 980 |
+
gap: 3px;
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
.repo-github-notice-title {
|
| 984 |
+
color: #c3c5dd;
|
| 985 |
+
font-weight: 600;
|
| 986 |
+
font-size: 11px;
|
| 987 |
+
}
|
| 988 |
+
|
| 989 |
+
.repo-github-notice-text {
|
| 990 |
+
color: #9a9bb0;
|
| 991 |
+
}
|
| 992 |
+
|
| 993 |
+
.repo-github-link {
|
| 994 |
+
color: #ff7a3c;
|
| 995 |
+
text-decoration: none;
|
| 996 |
+
font-weight: 500;
|
| 997 |
+
transition: color 0.2s ease;
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
.repo-github-link:hover {
|
| 1001 |
+
color: #ff8b52;
|
| 1002 |
+
text-decoration: underline;
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
/* Focus visible for accessibility */
|
| 1006 |
+
.repo-item:focus-visible,
|
| 1007 |
+
.repo-search-btn:focus-visible,
|
| 1008 |
+
.repo-load-more:focus-visible,
|
| 1009 |
+
.repo-clear-btn:focus-visible {
|
| 1010 |
+
outline: 2px solid #ff7a3c;
|
| 1011 |
+
outline-offset: 2px;
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
+
/* Reduced motion support */
|
| 1015 |
+
@media (prefers-reduced-motion: reduce) {
|
| 1016 |
+
.repo-item,
|
| 1017 |
+
.repo-search-btn,
|
| 1018 |
+
.repo-load-more,
|
| 1019 |
+
.repo-clear-btn {
|
| 1020 |
+
transition: none;
|
| 1021 |
+
}
|
| 1022 |
+
|
| 1023 |
+
.repo-loading-spinner,
|
| 1024 |
+
.repo-loading-spinner-small {
|
| 1025 |
+
animation: none;
|
| 1026 |
+
}
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
/* Mobile responsive adjustments */
|
| 1030 |
+
@media (max-width: 768px) {
|
| 1031 |
+
.repo-search-input {
|
| 1032 |
+
font-size: 16px; /* Prevents zoom on iOS */
|
| 1033 |
+
}
|
| 1034 |
+
|
| 1035 |
+
.repo-item {
|
| 1036 |
+
padding: 7px 7px;
|
| 1037 |
+
}
|
| 1038 |
+
|
| 1039 |
+
.repo-name {
|
| 1040 |
+
font-size: 12px;
|
| 1041 |
+
}
|
| 1042 |
+
|
| 1043 |
+
.repo-owner {
|
| 1044 |
+
font-size: 10px;
|
| 1045 |
+
}
|
| 1046 |
+
}
|
| 1047 |
+
|
| 1048 |
+
/* Workspace */
|
| 1049 |
+
.workspace {
|
| 1050 |
+
flex: 1;
|
| 1051 |
+
display: flex;
|
| 1052 |
+
flex-direction: column;
|
| 1053 |
+
position: relative;
|
| 1054 |
+
overflow: hidden;
|
| 1055 |
+
min-height: 0;
|
| 1056 |
+
}
|
| 1057 |
+
|
| 1058 |
+
.empty-state {
|
| 1059 |
+
margin: auto;
|
| 1060 |
+
max-width: 420px;
|
| 1061 |
+
text-align: center;
|
| 1062 |
+
color: #c3c5dd;
|
| 1063 |
+
animation: fadeIn 0.5s ease;
|
| 1064 |
+
}
|
| 1065 |
+
|
| 1066 |
+
.empty-bot {
|
| 1067 |
+
font-size: 36px;
|
| 1068 |
+
margin-bottom: 12px;
|
| 1069 |
+
animation: bounce 2s ease infinite;
|
| 1070 |
+
}
|
| 1071 |
+
|
| 1072 |
+
@keyframes bounce {
|
| 1073 |
+
0%, 100% {
|
| 1074 |
+
transform: translateY(0);
|
| 1075 |
+
}
|
| 1076 |
+
50% {
|
| 1077 |
+
transform: translateY(-10px);
|
| 1078 |
+
}
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
.empty-state h1 {
|
| 1082 |
+
font-size: 24px;
|
| 1083 |
+
margin-bottom: 6px;
|
| 1084 |
+
}
|
| 1085 |
+
|
| 1086 |
+
.empty-state p {
|
| 1087 |
+
font-size: 14px;
|
| 1088 |
+
color: #9a9bb0;
|
| 1089 |
+
}
|
| 1090 |
+
|
| 1091 |
+
/* Workspace grid - Properly constrained */
|
| 1092 |
+
.workspace-grid {
|
| 1093 |
+
display: grid;
|
| 1094 |
+
grid-template-columns: 320px minmax(340px, 1fr);
|
| 1095 |
+
height: 100%;
|
| 1096 |
+
overflow: hidden;
|
| 1097 |
+
flex: 1;
|
| 1098 |
+
min-height: 0;
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
/* Panels */
|
| 1102 |
+
.panel-header {
|
| 1103 |
+
height: 40px;
|
| 1104 |
+
padding: 0 16px;
|
| 1105 |
+
border-bottom: 1px solid #272832;
|
| 1106 |
+
display: flex;
|
| 1107 |
+
align-items: center;
|
| 1108 |
+
justify-content: space-between;
|
| 1109 |
+
font-size: 13px;
|
| 1110 |
+
font-weight: 500;
|
| 1111 |
+
color: #c3c5dd;
|
| 1112 |
+
background: #0a0b0f;
|
| 1113 |
+
flex-shrink: 0;
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
.badge {
|
| 1117 |
+
padding: 2px 6px;
|
| 1118 |
+
border-radius: 999px;
|
| 1119 |
+
border: 1px solid #3a3b4d;
|
| 1120 |
+
font-size: 10px;
|
| 1121 |
+
}
|
| 1122 |
+
|
| 1123 |
+
/* Files */
|
| 1124 |
+
.files-panel {
|
| 1125 |
+
border-right: 1px solid #272832;
|
| 1126 |
+
background: #101117;
|
| 1127 |
+
display: flex;
|
| 1128 |
+
flex-direction: column;
|
| 1129 |
+
overflow: hidden;
|
| 1130 |
+
}
|
| 1131 |
+
|
| 1132 |
+
.files-list {
|
| 1133 |
+
flex: 1;
|
| 1134 |
+
overflow-y: auto;
|
| 1135 |
+
overflow-x: hidden;
|
| 1136 |
+
padding: 6px 4px;
|
| 1137 |
+
min-height: 0;
|
| 1138 |
+
}
|
| 1139 |
+
|
| 1140 |
+
.files-item {
|
| 1141 |
+
border: none;
|
| 1142 |
+
outline: none;
|
| 1143 |
+
width: 100%;
|
| 1144 |
+
background: transparent;
|
| 1145 |
+
color: #f5f5f7;
|
| 1146 |
+
display: flex;
|
| 1147 |
+
align-items: center;
|
| 1148 |
+
gap: 8px;
|
| 1149 |
+
padding: 4px 8px;
|
| 1150 |
+
border-radius: 6px;
|
| 1151 |
+
cursor: pointer;
|
| 1152 |
+
font-size: 12px;
|
| 1153 |
+
transition: all 0.15s ease;
|
| 1154 |
+
}
|
| 1155 |
+
|
| 1156 |
+
.files-item:hover {
|
| 1157 |
+
background: #1a1b26;
|
| 1158 |
+
transform: translateX(2px);
|
| 1159 |
+
}
|
| 1160 |
+
|
| 1161 |
+
.files-item-active {
|
| 1162 |
+
background: #2a2b3c;
|
| 1163 |
+
}
|
| 1164 |
+
|
| 1165 |
+
.file-icon {
|
| 1166 |
+
width: 16px;
|
| 1167 |
+
flex-shrink: 0;
|
| 1168 |
+
}
|
| 1169 |
+
|
| 1170 |
+
.file-path {
|
| 1171 |
+
white-space: nowrap;
|
| 1172 |
+
overflow: hidden;
|
| 1173 |
+
text-overflow: ellipsis;
|
| 1174 |
+
}
|
| 1175 |
+
|
| 1176 |
+
.files-empty {
|
| 1177 |
+
padding: 10px 12px;
|
| 1178 |
+
font-size: 12px;
|
| 1179 |
+
color: #9a9bb0;
|
| 1180 |
+
}
|
| 1181 |
+
|
| 1182 |
+
/* Chat panel */
|
| 1183 |
+
.editor-panel {
|
| 1184 |
+
display: flex;
|
| 1185 |
+
flex-direction: column;
|
| 1186 |
+
background: #050608;
|
| 1187 |
+
}
|
| 1188 |
+
|
| 1189 |
+
.chat-container {
|
| 1190 |
+
display: flex;
|
| 1191 |
+
flex-direction: column;
|
| 1192 |
+
flex: 1;
|
| 1193 |
+
min-height: 0;
|
| 1194 |
+
overflow: hidden;
|
| 1195 |
+
}
|
| 1196 |
+
|
| 1197 |
+
.chat-messages {
|
| 1198 |
+
flex: 1;
|
| 1199 |
+
padding: 12px 16px;
|
| 1200 |
+
overflow-y: auto;
|
| 1201 |
+
overflow-x: hidden;
|
| 1202 |
+
font-size: 13px;
|
| 1203 |
+
min-height: 0;
|
| 1204 |
+
scroll-behavior: smooth;
|
| 1205 |
+
}
|
| 1206 |
+
|
| 1207 |
+
.chat-message-user {
|
| 1208 |
+
margin-bottom: 16px;
|
| 1209 |
+
animation: slideInRight 0.3s ease;
|
| 1210 |
+
}
|
| 1211 |
+
|
| 1212 |
+
@keyframes slideInRight {
|
| 1213 |
+
from {
|
| 1214 |
+
opacity: 0;
|
| 1215 |
+
transform: translateX(20px);
|
| 1216 |
+
}
|
| 1217 |
+
to {
|
| 1218 |
+
opacity: 1;
|
| 1219 |
+
transform: translateX(0);
|
| 1220 |
+
}
|
| 1221 |
+
}
|
| 1222 |
+
|
| 1223 |
+
.chat-message-ai {
|
| 1224 |
+
margin-bottom: 16px;
|
| 1225 |
+
animation: slideInLeft 0.3s ease;
|
| 1226 |
+
}
|
| 1227 |
+
|
| 1228 |
+
@keyframes slideInLeft {
|
| 1229 |
+
from {
|
| 1230 |
+
opacity: 0;
|
| 1231 |
+
transform: translateX(-20px);
|
| 1232 |
+
}
|
| 1233 |
+
to {
|
| 1234 |
+
opacity: 1;
|
| 1235 |
+
transform: translateX(0);
|
| 1236 |
+
}
|
| 1237 |
+
}
|
| 1238 |
+
|
| 1239 |
+
.chat-message-ai span {
|
| 1240 |
+
display: inline-block;
|
| 1241 |
+
padding: 10px 14px;
|
| 1242 |
+
border-radius: 12px;
|
| 1243 |
+
max-width: 80%;
|
| 1244 |
+
line-height: 1.5;
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
.chat-message-user span {
|
| 1248 |
+
display: inline;
|
| 1249 |
+
padding: 0;
|
| 1250 |
+
border-radius: 0;
|
| 1251 |
+
background: transparent;
|
| 1252 |
+
border: none;
|
| 1253 |
+
max-width: none;
|
| 1254 |
+
line-height: inherit;
|
| 1255 |
+
}
|
| 1256 |
+
|
| 1257 |
+
.chat-message-ai span {
|
| 1258 |
+
background: #151622;
|
| 1259 |
+
border: 1px solid #272832;
|
| 1260 |
+
}
|
| 1261 |
+
|
| 1262 |
+
.chat-empty-state {
|
| 1263 |
+
display: flex;
|
| 1264 |
+
flex-direction: column;
|
| 1265 |
+
align-items: center;
|
| 1266 |
+
justify-content: center;
|
| 1267 |
+
min-height: 300px;
|
| 1268 |
+
padding: 40px 20px;
|
| 1269 |
+
text-align: center;
|
| 1270 |
+
}
|
| 1271 |
+
|
| 1272 |
+
.chat-empty-icon {
|
| 1273 |
+
font-size: 48px;
|
| 1274 |
+
margin-bottom: 16px;
|
| 1275 |
+
opacity: 0.6;
|
| 1276 |
+
animation: pulse 2s ease infinite;
|
| 1277 |
+
}
|
| 1278 |
+
|
| 1279 |
+
@keyframes pulse {
|
| 1280 |
+
0%, 100% {
|
| 1281 |
+
opacity: 0.6;
|
| 1282 |
+
}
|
| 1283 |
+
50% {
|
| 1284 |
+
opacity: 0.8;
|
| 1285 |
+
}
|
| 1286 |
+
}
|
| 1287 |
+
|
| 1288 |
+
.chat-empty-state p {
|
| 1289 |
+
margin: 0;
|
| 1290 |
+
font-size: 13px;
|
| 1291 |
+
color: #9a9bb0;
|
| 1292 |
+
max-width: 400px;
|
| 1293 |
+
}
|
| 1294 |
+
|
| 1295 |
+
.chat-input-box {
|
| 1296 |
+
padding: 12px 16px;
|
| 1297 |
+
border-top: 1px solid #272832;
|
| 1298 |
+
display: flex;
|
| 1299 |
+
flex-direction: column;
|
| 1300 |
+
gap: 10px;
|
| 1301 |
+
background: #050608;
|
| 1302 |
+
flex-shrink: 0;
|
| 1303 |
+
min-height: fit-content;
|
| 1304 |
+
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.3);
|
| 1305 |
+
}
|
| 1306 |
+
|
| 1307 |
+
.chat-input-row {
|
| 1308 |
+
display: flex;
|
| 1309 |
+
gap: 10px;
|
| 1310 |
+
align-items: center;
|
| 1311 |
+
flex-wrap: wrap;
|
| 1312 |
+
}
|
| 1313 |
+
|
| 1314 |
+
.chat-input {
|
| 1315 |
+
flex: 1;
|
| 1316 |
+
min-width: 200px;
|
| 1317 |
+
border-radius: 8px;
|
| 1318 |
+
padding: 10px 12px;
|
| 1319 |
+
border: 1px solid #272832;
|
| 1320 |
+
background: #0a0b0f;
|
| 1321 |
+
color: #f5f5f7;
|
| 1322 |
+
font-size: 13px;
|
| 1323 |
+
line-height: 1.5;
|
| 1324 |
+
transition: all 0.2s ease;
|
| 1325 |
+
}
|
| 1326 |
+
|
| 1327 |
+
.chat-input:focus {
|
| 1328 |
+
outline: none;
|
| 1329 |
+
border-color: #ff7a3c;
|
| 1330 |
+
background: #101117;
|
| 1331 |
+
box-shadow: 0 0 0 3px rgba(255, 122, 60, 0.1);
|
| 1332 |
+
}
|
| 1333 |
+
|
| 1334 |
+
.chat-input::placeholder {
|
| 1335 |
+
color: #676883;
|
| 1336 |
+
}
|
| 1337 |
+
|
| 1338 |
+
.chat-btn {
|
| 1339 |
+
border-radius: 8px;
|
| 1340 |
+
border: none;
|
| 1341 |
+
outline: none;
|
| 1342 |
+
padding: 10px 16px;
|
| 1343 |
+
background: #ff7a3c;
|
| 1344 |
+
color: #050608;
|
| 1345 |
+
cursor: pointer;
|
| 1346 |
+
font-size: 13px;
|
| 1347 |
+
font-weight: 600;
|
| 1348 |
+
transition: all 0.2s ease;
|
| 1349 |
+
white-space: nowrap;
|
| 1350 |
+
min-height: 40px;
|
| 1351 |
+
}
|
| 1352 |
+
|
| 1353 |
+
.chat-btn:hover:not(:disabled) {
|
| 1354 |
+
background: #ff8c52;
|
| 1355 |
+
transform: translateY(-1px);
|
| 1356 |
+
box-shadow: 0 4px 12px rgba(255, 122, 60, 0.3);
|
| 1357 |
+
}
|
| 1358 |
+
|
| 1359 |
+
.chat-btn:active:not(:disabled) {
|
| 1360 |
+
transform: translateY(0);
|
| 1361 |
+
}
|
| 1362 |
+
|
| 1363 |
+
.chat-btn.secondary {
|
| 1364 |
+
background: #1a1b26;
|
| 1365 |
+
color: #f5f5f7;
|
| 1366 |
+
border: 1px solid #272832;
|
| 1367 |
+
}
|
| 1368 |
+
|
| 1369 |
+
.chat-btn.secondary:hover:not(:disabled) {
|
| 1370 |
+
background: #222335;
|
| 1371 |
+
border-color: #3a3b4d;
|
| 1372 |
+
}
|
| 1373 |
+
|
| 1374 |
+
.chat-btn:disabled {
|
| 1375 |
+
opacity: 0.5;
|
| 1376 |
+
cursor: not-allowed;
|
| 1377 |
+
}
|
| 1378 |
+
|
| 1379 |
+
/* Plan rendering */
|
| 1380 |
+
.plan-card {
|
| 1381 |
+
border-radius: 12px;
|
| 1382 |
+
background: #101117;
|
| 1383 |
+
border: 1px solid #272832;
|
| 1384 |
+
padding: 10px 12px;
|
| 1385 |
+
margin-top: 6px;
|
| 1386 |
+
animation: fadeIn 0.3s ease;
|
| 1387 |
+
}
|
| 1388 |
+
|
| 1389 |
+
.plan-steps {
|
| 1390 |
+
margin: 6px 0 0;
|
| 1391 |
+
padding-left: 18px;
|
| 1392 |
+
font-size: 12px;
|
| 1393 |
+
}
|
| 1394 |
+
|
| 1395 |
+
.plan-steps li {
|
| 1396 |
+
margin-bottom: 4px;
|
| 1397 |
+
}
|
| 1398 |
+
|
| 1399 |
+
/* Modal */
|
| 1400 |
+
.modal-backdrop {
|
| 1401 |
+
position: fixed;
|
| 1402 |
+
inset: 0;
|
| 1403 |
+
background: rgba(0, 0, 0, 0.55);
|
| 1404 |
+
display: flex;
|
| 1405 |
+
align-items: center;
|
| 1406 |
+
justify-content: center;
|
| 1407 |
+
z-index: 20;
|
| 1408 |
+
animation: fadeIn 0.2s ease;
|
| 1409 |
+
}
|
| 1410 |
+
|
| 1411 |
+
.modal {
|
| 1412 |
+
background: #101117;
|
| 1413 |
+
border-radius: 16px;
|
| 1414 |
+
border: 1px solid #272832;
|
| 1415 |
+
padding: 16px 18px;
|
| 1416 |
+
width: 360px;
|
| 1417 |
+
animation: scaleIn 0.3s ease;
|
| 1418 |
+
}
|
| 1419 |
+
|
| 1420 |
+
@keyframes scaleIn {
|
| 1421 |
+
from {
|
| 1422 |
+
opacity: 0;
|
| 1423 |
+
transform: scale(0.9);
|
| 1424 |
+
}
|
| 1425 |
+
to {
|
| 1426 |
+
opacity: 1;
|
| 1427 |
+
transform: scale(1);
|
| 1428 |
+
}
|
| 1429 |
+
}
|
| 1430 |
+
|
| 1431 |
+
.modal-header {
|
| 1432 |
+
display: flex;
|
| 1433 |
+
justify-content: space-between;
|
| 1434 |
+
align-items: center;
|
| 1435 |
+
margin-bottom: 10px;
|
| 1436 |
+
}
|
| 1437 |
+
|
| 1438 |
+
.modal-title {
|
| 1439 |
+
font-size: 15px;
|
| 1440 |
+
font-weight: 600;
|
| 1441 |
+
}
|
| 1442 |
+
|
| 1443 |
+
.modal-close {
|
| 1444 |
+
border: none;
|
| 1445 |
+
outline: none;
|
| 1446 |
+
background: transparent;
|
| 1447 |
+
color: #9a9bb0;
|
| 1448 |
+
cursor: pointer;
|
| 1449 |
+
transition: color 0.2s ease;
|
| 1450 |
+
}
|
| 1451 |
+
|
| 1452 |
+
.modal-close:hover {
|
| 1453 |
+
color: #ff7a3c;
|
| 1454 |
+
}
|
| 1455 |
+
|
| 1456 |
+
.provider-list {
|
| 1457 |
+
display: flex;
|
| 1458 |
+
flex-direction: column;
|
| 1459 |
+
gap: 6px;
|
| 1460 |
+
margin-top: 8px;
|
| 1461 |
+
}
|
| 1462 |
+
|
| 1463 |
+
.provider-item {
|
| 1464 |
+
display: flex;
|
| 1465 |
+
align-items: center;
|
| 1466 |
+
justify-content: space-between;
|
| 1467 |
+
padding: 6px 8px;
|
| 1468 |
+
border-radius: 8px;
|
| 1469 |
+
background: #151622;
|
| 1470 |
+
border: 1px solid #272832;
|
| 1471 |
+
font-size: 13px;
|
| 1472 |
+
transition: all 0.2s ease;
|
| 1473 |
+
}
|
| 1474 |
+
|
| 1475 |
+
.provider-item:hover {
|
| 1476 |
+
border-color: #3a3b4d;
|
| 1477 |
+
}
|
| 1478 |
+
|
| 1479 |
+
.provider-item.active {
|
| 1480 |
+
border-color: #ff7a3c;
|
| 1481 |
+
background: rgba(255, 122, 60, 0.1);
|
| 1482 |
+
}
|
| 1483 |
+
|
| 1484 |
+
.provider-name {
|
| 1485 |
+
font-weight: 500;
|
| 1486 |
+
}
|
| 1487 |
+
|
| 1488 |
+
.provider-badge {
|
| 1489 |
+
font-size: 11px;
|
| 1490 |
+
color: #9a9bb0;
|
| 1491 |
+
}
|
| 1492 |
+
|
| 1493 |
+
/* Navigation */
|
| 1494 |
+
.main-nav {
|
| 1495 |
+
display: flex;
|
| 1496 |
+
flex-direction: column;
|
| 1497 |
+
gap: 2px;
|
| 1498 |
+
margin-top: 10px;
|
| 1499 |
+
margin-bottom: 10px;
|
| 1500 |
+
}
|
| 1501 |
+
|
| 1502 |
+
.nav-btn {
|
| 1503 |
+
border: none;
|
| 1504 |
+
outline: none;
|
| 1505 |
+
background: transparent;
|
| 1506 |
+
color: #9a9bb0;
|
| 1507 |
+
border-radius: 8px;
|
| 1508 |
+
font-size: 13px;
|
| 1509 |
+
font-weight: 500;
|
| 1510 |
+
padding: 8px 12px;
|
| 1511 |
+
text-align: left;
|
| 1512 |
+
cursor: pointer;
|
| 1513 |
+
transition: all 0.15s ease;
|
| 1514 |
+
}
|
| 1515 |
+
|
| 1516 |
+
.nav-btn:hover {
|
| 1517 |
+
background: #1a1b26;
|
| 1518 |
+
color: #c3c5dd;
|
| 1519 |
+
}
|
| 1520 |
+
|
| 1521 |
+
.nav-btn-active {
|
| 1522 |
+
background: #1a1b26;
|
| 1523 |
+
color: #f5f5f7;
|
| 1524 |
+
font-weight: 600;
|
| 1525 |
+
border-left: 2px solid #ff7a3c;
|
| 1526 |
+
padding-left: 10px;
|
| 1527 |
+
}
|
| 1528 |
+
|
| 1529 |
+
/* Settings page */
|
| 1530 |
+
.settings-root {
|
| 1531 |
+
padding: 20px 24px;
|
| 1532 |
+
overflow-y: auto;
|
| 1533 |
+
max-width: 800px;
|
| 1534 |
+
}
|
| 1535 |
+
|
| 1536 |
+
.settings-root h1 {
|
| 1537 |
+
margin-top: 0;
|
| 1538 |
+
font-size: 24px;
|
| 1539 |
+
margin-bottom: 8px;
|
| 1540 |
+
}
|
| 1541 |
+
|
| 1542 |
+
.settings-muted {
|
| 1543 |
+
font-size: 13px;
|
| 1544 |
+
color: #9a9bb0;
|
| 1545 |
+
margin-bottom: 20px;
|
| 1546 |
+
line-height: 1.5;
|
| 1547 |
+
}
|
| 1548 |
+
|
| 1549 |
+
.settings-card {
|
| 1550 |
+
background: #101117;
|
| 1551 |
+
border-radius: 12px;
|
| 1552 |
+
border: 1px solid #272832;
|
| 1553 |
+
padding: 14px 16px;
|
| 1554 |
+
margin-bottom: 14px;
|
| 1555 |
+
display: flex;
|
| 1556 |
+
flex-direction: column;
|
| 1557 |
+
gap: 8px;
|
| 1558 |
+
transition: all 0.2s ease;
|
| 1559 |
+
}
|
| 1560 |
+
|
| 1561 |
+
.settings-card:hover {
|
| 1562 |
+
border-color: #3a3b4d;
|
| 1563 |
+
}
|
| 1564 |
+
|
| 1565 |
+
.settings-title {
|
| 1566 |
+
font-size: 15px;
|
| 1567 |
+
font-weight: 600;
|
| 1568 |
+
margin-bottom: 4px;
|
| 1569 |
+
}
|
| 1570 |
+
|
| 1571 |
+
.settings-label {
|
| 1572 |
+
font-size: 12px;
|
| 1573 |
+
color: #9a9bb0;
|
| 1574 |
+
font-weight: 500;
|
| 1575 |
+
margin-top: 4px;
|
| 1576 |
+
}
|
| 1577 |
+
|
| 1578 |
+
.settings-input,
|
| 1579 |
+
.settings-select {
|
| 1580 |
+
background: #050608;
|
| 1581 |
+
border-radius: 8px;
|
| 1582 |
+
border: 1px solid #272832;
|
| 1583 |
+
padding: 8px 10px;
|
| 1584 |
+
color: #f5f5f7;
|
| 1585 |
+
font-size: 13px;
|
| 1586 |
+
font-family: inherit;
|
| 1587 |
+
transition: all 0.2s ease;
|
| 1588 |
+
}
|
| 1589 |
+
|
| 1590 |
+
.settings-input:focus,
|
| 1591 |
+
.settings-select:focus {
|
| 1592 |
+
outline: none;
|
| 1593 |
+
border-color: #ff7a3c;
|
| 1594 |
+
box-shadow: 0 0 0 3px rgba(255, 122, 60, 0.1);
|
| 1595 |
+
}
|
| 1596 |
+
|
| 1597 |
+
.settings-input::placeholder {
|
| 1598 |
+
color: #676883;
|
| 1599 |
+
}
|
| 1600 |
+
|
| 1601 |
+
.settings-hint {
|
| 1602 |
+
font-size: 11px;
|
| 1603 |
+
color: #7a7b8e;
|
| 1604 |
+
margin-top: -2px;
|
| 1605 |
+
}
|
| 1606 |
+
|
| 1607 |
+
.settings-actions {
|
| 1608 |
+
margin-top: 12px;
|
| 1609 |
+
display: flex;
|
| 1610 |
+
align-items: center;
|
| 1611 |
+
gap: 12px;
|
| 1612 |
+
}
|
| 1613 |
+
|
| 1614 |
+
.settings-save-btn {
|
| 1615 |
+
background: #ff7a3c;
|
| 1616 |
+
border-radius: 999px;
|
| 1617 |
+
border: none;
|
| 1618 |
+
outline: none;
|
| 1619 |
+
padding: 9px 18px;
|
| 1620 |
+
font-size: 13px;
|
| 1621 |
+
cursor: pointer;
|
| 1622 |
+
color: #050608;
|
| 1623 |
+
font-weight: 600;
|
| 1624 |
+
transition: all 0.2s ease;
|
| 1625 |
+
}
|
| 1626 |
+
|
| 1627 |
+
.settings-save-btn:hover {
|
| 1628 |
+
background: #ff8b52;
|
| 1629 |
+
transform: translateY(-1px);
|
| 1630 |
+
box-shadow: 0 4px 12px rgba(255, 122, 60, 0.3);
|
| 1631 |
+
}
|
| 1632 |
+
|
| 1633 |
+
.settings-save-btn:disabled {
|
| 1634 |
+
opacity: 0.5;
|
| 1635 |
+
cursor: not-allowed;
|
| 1636 |
+
transform: none;
|
| 1637 |
+
}
|
| 1638 |
+
|
| 1639 |
+
.settings-success {
|
| 1640 |
+
font-size: 12px;
|
| 1641 |
+
color: #7cffb3;
|
| 1642 |
+
font-weight: 500;
|
| 1643 |
+
}
|
| 1644 |
+
|
| 1645 |
+
.settings-error {
|
| 1646 |
+
font-size: 12px;
|
| 1647 |
+
color: #ff8a8a;
|
| 1648 |
+
font-weight: 500;
|
| 1649 |
+
}
|
| 1650 |
+
|
| 1651 |
+
/* Flow viewer */
|
| 1652 |
+
.flow-root {
|
| 1653 |
+
display: flex;
|
| 1654 |
+
flex-direction: column;
|
| 1655 |
+
height: 100%;
|
| 1656 |
+
overflow: hidden;
|
| 1657 |
+
}
|
| 1658 |
+
|
| 1659 |
+
.flow-header {
|
| 1660 |
+
padding: 16px 20px;
|
| 1661 |
+
border-bottom: 1px solid #272832;
|
| 1662 |
+
display: flex;
|
| 1663 |
+
align-items: center;
|
| 1664 |
+
justify-content: space-between;
|
| 1665 |
+
}
|
| 1666 |
+
|
| 1667 |
+
.flow-header h1 {
|
| 1668 |
+
margin: 0;
|
| 1669 |
+
font-size: 22px;
|
| 1670 |
+
margin-bottom: 4px;
|
| 1671 |
+
}
|
| 1672 |
+
|
| 1673 |
+
.flow-header p {
|
| 1674 |
+
margin: 0;
|
| 1675 |
+
font-size: 12px;
|
| 1676 |
+
color: #9a9bb0;
|
| 1677 |
+
max-width: 600px;
|
| 1678 |
+
line-height: 1.5;
|
| 1679 |
+
}
|
| 1680 |
+
|
| 1681 |
+
.flow-canvas {
|
| 1682 |
+
flex: 1;
|
| 1683 |
+
background: #050608;
|
| 1684 |
+
position: relative;
|
| 1685 |
+
}
|
| 1686 |
+
|
| 1687 |
+
.flow-error {
|
| 1688 |
+
position: absolute;
|
| 1689 |
+
inset: 0;
|
| 1690 |
+
display: flex;
|
| 1691 |
+
flex-direction: column;
|
| 1692 |
+
align-items: center;
|
| 1693 |
+
justify-content: center;
|
| 1694 |
+
gap: 12px;
|
| 1695 |
+
}
|
| 1696 |
+
|
| 1697 |
+
.error-icon {
|
| 1698 |
+
font-size: 48px;
|
| 1699 |
+
}
|
| 1700 |
+
|
| 1701 |
+
.error-text {
|
| 1702 |
+
font-size: 14px;
|
| 1703 |
+
color: #ff8a8a;
|
| 1704 |
+
}
|
| 1705 |
+
|
| 1706 |
+
/* Assistant Message Sections */
|
| 1707 |
+
.gp-section {
|
| 1708 |
+
margin-bottom: 16px;
|
| 1709 |
+
border-radius: 12px;
|
| 1710 |
+
background: #101117;
|
| 1711 |
+
border: 1px solid #272832;
|
| 1712 |
+
overflow: hidden;
|
| 1713 |
+
animation: fadeIn 0.3s ease;
|
| 1714 |
+
}
|
| 1715 |
+
|
| 1716 |
+
.gp-section-header {
|
| 1717 |
+
padding: 8px 12px;
|
| 1718 |
+
background: #151622;
|
| 1719 |
+
border-bottom: 1px solid #272832;
|
| 1720 |
+
}
|
| 1721 |
+
|
| 1722 |
+
.gp-section-header h3 {
|
| 1723 |
+
margin: 0;
|
| 1724 |
+
font-size: 13px;
|
| 1725 |
+
font-weight: 600;
|
| 1726 |
+
color: #c3c5dd;
|
| 1727 |
+
}
|
| 1728 |
+
|
| 1729 |
+
.gp-section-content {
|
| 1730 |
+
padding: 12px;
|
| 1731 |
+
}
|
| 1732 |
+
|
| 1733 |
+
.gp-section-answer .gp-section-content p {
|
| 1734 |
+
margin: 0;
|
| 1735 |
+
font-size: 13px;
|
| 1736 |
+
line-height: 1.6;
|
| 1737 |
+
color: #f5f5f7;
|
| 1738 |
+
}
|
| 1739 |
+
|
| 1740 |
+
.gp-section-plan {
|
| 1741 |
+
background: #0a0b0f;
|
| 1742 |
+
}
|
| 1743 |
+
|
| 1744 |
+
/* Plan View Enhanced */
|
| 1745 |
+
.plan-header {
|
| 1746 |
+
margin-bottom: 12px;
|
| 1747 |
+
}
|
| 1748 |
+
|
| 1749 |
+
.plan-goal {
|
| 1750 |
+
font-size: 13px;
|
| 1751 |
+
font-weight: 600;
|
| 1752 |
+
margin-bottom: 4px;
|
| 1753 |
+
color: #f5f5f7;
|
| 1754 |
+
}
|
| 1755 |
+
|
| 1756 |
+
.plan-summary {
|
| 1757 |
+
font-size: 12px;
|
| 1758 |
+
color: #c3c5dd;
|
| 1759 |
+
line-height: 1.5;
|
| 1760 |
+
}
|
| 1761 |
+
|
| 1762 |
+
.plan-totals {
|
| 1763 |
+
display: flex;
|
| 1764 |
+
gap: 8px;
|
| 1765 |
+
margin-bottom: 12px;
|
| 1766 |
+
flex-wrap: wrap;
|
| 1767 |
+
}
|
| 1768 |
+
|
| 1769 |
+
.plan-total {
|
| 1770 |
+
padding: 4px 8px;
|
| 1771 |
+
border-radius: 6px;
|
| 1772 |
+
font-size: 11px;
|
| 1773 |
+
font-weight: 500;
|
| 1774 |
+
animation: fadeIn 0.3s ease;
|
| 1775 |
+
}
|
| 1776 |
+
|
| 1777 |
+
.plan-total-create {
|
| 1778 |
+
background: rgba(76, 175, 80, 0.15);
|
| 1779 |
+
color: #81c784;
|
| 1780 |
+
border: 1px solid rgba(76, 175, 80, 0.3);
|
| 1781 |
+
}
|
| 1782 |
+
|
| 1783 |
+
.plan-total-modify {
|
| 1784 |
+
background: rgba(33, 150, 243, 0.15);
|
| 1785 |
+
color: #64b5f6;
|
| 1786 |
+
border: 1px solid rgba(33, 150, 243, 0.3);
|
| 1787 |
+
}
|
| 1788 |
+
|
| 1789 |
+
.plan-total-delete {
|
| 1790 |
+
background: rgba(244, 67, 54, 0.15);
|
| 1791 |
+
color: #e57373;
|
| 1792 |
+
border: 1px solid rgba(244, 67, 54, 0.3);
|
| 1793 |
+
}
|
| 1794 |
+
|
| 1795 |
+
.plan-step {
|
| 1796 |
+
margin-bottom: 12px;
|
| 1797 |
+
padding-bottom: 12px;
|
| 1798 |
+
border-bottom: 1px solid #1a1b26;
|
| 1799 |
+
}
|
| 1800 |
+
|
| 1801 |
+
.plan-step:last-child {
|
| 1802 |
+
border-bottom: none;
|
| 1803 |
+
padding-bottom: 0;
|
| 1804 |
+
margin-bottom: 0;
|
| 1805 |
+
}
|
| 1806 |
+
|
| 1807 |
+
.plan-step-header {
|
| 1808 |
+
margin-bottom: 6px;
|
| 1809 |
+
}
|
| 1810 |
+
|
| 1811 |
+
.plan-step-description {
|
| 1812 |
+
font-size: 12px;
|
| 1813 |
+
color: #9a9bb0;
|
| 1814 |
+
margin-bottom: 8px;
|
| 1815 |
+
}
|
| 1816 |
+
|
| 1817 |
+
.plan-files {
|
| 1818 |
+
list-style: none;
|
| 1819 |
+
padding: 0;
|
| 1820 |
+
margin: 8px 0;
|
| 1821 |
+
}
|
| 1822 |
+
|
| 1823 |
+
.plan-file {
|
| 1824 |
+
display: flex;
|
| 1825 |
+
align-items: center;
|
| 1826 |
+
gap: 8px;
|
| 1827 |
+
padding: 4px 0;
|
| 1828 |
+
}
|
| 1829 |
+
|
| 1830 |
+
.gp-pill {
|
| 1831 |
+
padding: 2px 6px;
|
| 1832 |
+
border-radius: 4px;
|
| 1833 |
+
font-size: 10px;
|
| 1834 |
+
font-weight: 600;
|
| 1835 |
+
text-transform: uppercase;
|
| 1836 |
+
letter-spacing: 0.3px;
|
| 1837 |
+
}
|
| 1838 |
+
|
| 1839 |
+
.gp-pill-create {
|
| 1840 |
+
background: rgba(76, 175, 80, 0.2);
|
| 1841 |
+
color: #81c784;
|
| 1842 |
+
border: 1px solid rgba(76, 175, 80, 0.4);
|
| 1843 |
+
}
|
| 1844 |
+
|
| 1845 |
+
.gp-pill-modify {
|
| 1846 |
+
background: rgba(33, 150, 243, 0.2);
|
| 1847 |
+
color: #64b5f6;
|
| 1848 |
+
border: 1px solid rgba(33, 150, 243, 0.4);
|
| 1849 |
+
}
|
| 1850 |
+
|
| 1851 |
+
.gp-pill-delete {
|
| 1852 |
+
background: rgba(244, 67, 54, 0.2);
|
| 1853 |
+
color: #e57373;
|
| 1854 |
+
border: 1px solid rgba(244, 67, 54, 0.4);
|
| 1855 |
+
}
|
| 1856 |
+
|
| 1857 |
+
.plan-file-path {
|
| 1858 |
+
font-size: 11px;
|
| 1859 |
+
color: #c3c5dd;
|
| 1860 |
+
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
|
| 1861 |
+
background: #0a0b0f;
|
| 1862 |
+
padding: 2px 6px;
|
| 1863 |
+
border-radius: 4px;
|
| 1864 |
+
}
|
| 1865 |
+
|
| 1866 |
+
.plan-step-risks {
|
| 1867 |
+
margin-top: 8px;
|
| 1868 |
+
padding: 6px 8px;
|
| 1869 |
+
background: rgba(255, 152, 0, 0.1);
|
| 1870 |
+
border-left: 2px solid #ff9800;
|
| 1871 |
+
border-radius: 4px;
|
| 1872 |
+
font-size: 11px;
|
| 1873 |
+
color: #ffb74d;
|
| 1874 |
+
}
|
| 1875 |
+
|
| 1876 |
+
.plan-risk-label {
|
| 1877 |
+
font-weight: 600;
|
| 1878 |
+
}
|
| 1879 |
+
|
| 1880 |
+
/* Execution Log */
|
| 1881 |
+
.execution-steps {
|
| 1882 |
+
list-style: none;
|
| 1883 |
+
padding: 0;
|
| 1884 |
+
margin: 0;
|
| 1885 |
+
}
|
| 1886 |
+
|
| 1887 |
+
.execution-step {
|
| 1888 |
+
padding: 8px;
|
| 1889 |
+
margin-bottom: 6px;
|
| 1890 |
+
background: #0a0b0f;
|
| 1891 |
+
border-radius: 6px;
|
| 1892 |
+
font-size: 11px;
|
| 1893 |
+
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
|
| 1894 |
+
white-space: pre-wrap;
|
| 1895 |
+
}
|
| 1896 |
+
|
| 1897 |
+
.execution-step-number {
|
| 1898 |
+
color: #ff7a3c;
|
| 1899 |
+
font-weight: 600;
|
| 1900 |
+
margin-right: 8px;
|
| 1901 |
+
}
|
| 1902 |
+
|
| 1903 |
+
.execution-step-summary {
|
| 1904 |
+
color: #c3c5dd;
|
| 1905 |
+
}
|
| 1906 |
+
|
| 1907 |
+
/* Project Context Panel - Properly constrained */
|
| 1908 |
+
.gp-context {
|
| 1909 |
+
padding: 12px;
|
| 1910 |
+
height: 100%;
|
| 1911 |
+
overflow: hidden;
|
| 1912 |
+
display: flex;
|
| 1913 |
+
flex-direction: column;
|
| 1914 |
+
}
|
| 1915 |
+
|
| 1916 |
+
.gp-context-column {
|
| 1917 |
+
background: #0a0b0f;
|
| 1918 |
+
border-right: 1px solid #272832;
|
| 1919 |
+
overflow: hidden;
|
| 1920 |
+
display: flex;
|
| 1921 |
+
flex-direction: column;
|
| 1922 |
+
}
|
| 1923 |
+
|
| 1924 |
+
.gp-chat-column {
|
| 1925 |
+
display: flex;
|
| 1926 |
+
flex-direction: column;
|
| 1927 |
+
background: #050608;
|
| 1928 |
+
height: 100%;
|
| 1929 |
+
min-width: 0;
|
| 1930 |
+
overflow: hidden;
|
| 1931 |
+
}
|
| 1932 |
+
|
| 1933 |
+
.gp-card {
|
| 1934 |
+
background: #101117;
|
| 1935 |
+
border-radius: 12px;
|
| 1936 |
+
border: 1px solid #272832;
|
| 1937 |
+
overflow: hidden;
|
| 1938 |
+
display: flex;
|
| 1939 |
+
flex-direction: column;
|
| 1940 |
+
height: 100%;
|
| 1941 |
+
min-height: 0;
|
| 1942 |
+
}
|
| 1943 |
+
|
| 1944 |
+
.gp-card-header {
|
| 1945 |
+
padding: 10px 12px;
|
| 1946 |
+
background: #151622;
|
| 1947 |
+
border-bottom: 1px solid #272832;
|
| 1948 |
+
display: flex;
|
| 1949 |
+
align-items: center;
|
| 1950 |
+
justify-content: space-between;
|
| 1951 |
+
flex-shrink: 0;
|
| 1952 |
+
}
|
| 1953 |
+
|
| 1954 |
+
.gp-card-header h2 {
|
| 1955 |
+
margin: 0;
|
| 1956 |
+
font-size: 14px;
|
| 1957 |
+
font-weight: 600;
|
| 1958 |
+
color: #f5f5f7;
|
| 1959 |
+
}
|
| 1960 |
+
|
| 1961 |
+
.gp-badge {
|
| 1962 |
+
padding: 3px 8px;
|
| 1963 |
+
border-radius: 999px;
|
| 1964 |
+
background: #2a2b3c;
|
| 1965 |
+
border: 1px solid #3a3b4d;
|
| 1966 |
+
font-size: 11px;
|
| 1967 |
+
color: #c3c5dd;
|
| 1968 |
+
font-weight: 500;
|
| 1969 |
+
transition: all 0.2s ease;
|
| 1970 |
+
}
|
| 1971 |
+
|
| 1972 |
+
.gp-badge:hover {
|
| 1973 |
+
border-color: #ff7a3c;
|
| 1974 |
+
}
|
| 1975 |
+
|
| 1976 |
+
.gp-context-meta {
|
| 1977 |
+
padding: 12px;
|
| 1978 |
+
display: flex;
|
| 1979 |
+
flex-direction: column;
|
| 1980 |
+
gap: 6px;
|
| 1981 |
+
border-bottom: 1px solid #272832;
|
| 1982 |
+
flex-shrink: 0;
|
| 1983 |
+
background: #0a0b0f;
|
| 1984 |
+
}
|
| 1985 |
+
|
| 1986 |
+
.gp-context-meta-item {
|
| 1987 |
+
display: flex;
|
| 1988 |
+
align-items: center;
|
| 1989 |
+
gap: 6px;
|
| 1990 |
+
font-size: 12px;
|
| 1991 |
+
}
|
| 1992 |
+
|
| 1993 |
+
.gp-context-meta-label {
|
| 1994 |
+
color: #9a9bb0;
|
| 1995 |
+
min-width: 60px;
|
| 1996 |
+
}
|
| 1997 |
+
|
| 1998 |
+
.gp-context-meta-item strong {
|
| 1999 |
+
color: #f5f5f7;
|
| 2000 |
+
font-weight: 500;
|
| 2001 |
+
}
|
| 2002 |
+
|
| 2003 |
+
/* File tree - Properly scrollable */
|
| 2004 |
+
.gp-context-tree {
|
| 2005 |
+
flex: 1;
|
| 2006 |
+
overflow-y: auto;
|
| 2007 |
+
overflow-x: hidden;
|
| 2008 |
+
min-height: 0;
|
| 2009 |
+
padding: 4px;
|
| 2010 |
+
}
|
| 2011 |
+
|
| 2012 |
+
.gp-context-empty {
|
| 2013 |
+
padding: 20px 12px;
|
| 2014 |
+
text-align: center;
|
| 2015 |
+
color: #9a9bb0;
|
| 2016 |
+
font-size: 12px;
|
| 2017 |
+
}
|
| 2018 |
+
|
| 2019 |
+
/* Footer - Fixed at bottom */
|
| 2020 |
+
.gp-footer {
|
| 2021 |
+
position: fixed;
|
| 2022 |
+
bottom: 0;
|
| 2023 |
+
left: 0;
|
| 2024 |
+
right: 0;
|
| 2025 |
+
border-top: 1px solid #272832;
|
| 2026 |
+
padding: 8px 20px;
|
| 2027 |
+
display: flex;
|
| 2028 |
+
justify-content: space-between;
|
| 2029 |
+
align-items: center;
|
| 2030 |
+
font-size: 11px;
|
| 2031 |
+
color: #9a9bb0;
|
| 2032 |
+
background: #0a0b0f;
|
| 2033 |
+
backdrop-filter: blur(10px);
|
| 2034 |
+
z-index: 10;
|
| 2035 |
+
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
|
| 2036 |
+
}
|
| 2037 |
+
|
| 2038 |
+
.gp-footer-left {
|
| 2039 |
+
display: flex;
|
| 2040 |
+
align-items: center;
|
| 2041 |
+
gap: 6px;
|
| 2042 |
+
font-weight: 500;
|
| 2043 |
+
color: #c3c5dd;
|
| 2044 |
+
}
|
| 2045 |
+
|
| 2046 |
+
.gp-footer-right {
|
| 2047 |
+
display: flex;
|
| 2048 |
+
align-items: center;
|
| 2049 |
+
gap: 12px;
|
| 2050 |
+
}
|
| 2051 |
+
|
| 2052 |
+
.gp-footer-right a {
|
| 2053 |
+
color: #9a9bb0;
|
| 2054 |
+
text-decoration: none;
|
| 2055 |
+
transition: all 0.2s ease;
|
| 2056 |
+
}
|
| 2057 |
+
|
| 2058 |
+
.gp-footer-right a:hover {
|
| 2059 |
+
color: #ff7a3c;
|
| 2060 |
+
transform: translateY(-1px);
|
| 2061 |
+
}
|
| 2062 |
+
|
| 2063 |
+
/* Adjust app-root to account for fixed footer */
|
| 2064 |
+
.app-root > .main-wrapper {
|
| 2065 |
+
padding-bottom: 32px; /* Space for fixed footer */
|
| 2066 |
+
}
|
| 2067 |
+
|
| 2068 |
+
/* ============================================================================
|
| 2069 |
+
LOGIN PAGE - Enterprise GitHub Authentication
|
| 2070 |
+
============================================================================ */
|
| 2071 |
+
|
| 2072 |
+
.login-page {
|
| 2073 |
+
min-height: 100vh;
|
| 2074 |
+
display: flex;
|
| 2075 |
+
align-items: center;
|
| 2076 |
+
justify-content: center;
|
| 2077 |
+
background: radial-gradient(circle at center, #171823 0%, #050608 70%);
|
| 2078 |
+
padding: 20px;
|
| 2079 |
+
animation: fadeIn 0.4s ease;
|
| 2080 |
+
}
|
| 2081 |
+
|
| 2082 |
+
.login-container {
|
| 2083 |
+
width: 100%;
|
| 2084 |
+
max-width: 480px;
|
| 2085 |
+
background: #101117;
|
| 2086 |
+
border: 1px solid #272832;
|
| 2087 |
+
border-radius: 24px;
|
| 2088 |
+
padding: 40px 36px;
|
| 2089 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
| 2090 |
+
animation: slideUp 0.5s ease;
|
| 2091 |
+
}
|
| 2092 |
+
|
| 2093 |
+
@keyframes slideUp {
|
| 2094 |
+
from {
|
| 2095 |
+
opacity: 0;
|
| 2096 |
+
transform: translateY(20px);
|
| 2097 |
+
}
|
| 2098 |
+
to {
|
| 2099 |
+
opacity: 1;
|
| 2100 |
+
transform: translateY(0);
|
| 2101 |
+
}
|
| 2102 |
+
}
|
| 2103 |
+
|
| 2104 |
+
/* Header */
|
| 2105 |
+
.login-header {
|
| 2106 |
+
text-align: center;
|
| 2107 |
+
margin-bottom: 32px;
|
| 2108 |
+
}
|
| 2109 |
+
|
| 2110 |
+
.login-logo {
|
| 2111 |
+
display: flex;
|
| 2112 |
+
justify-content: center;
|
| 2113 |
+
margin-bottom: 16px;
|
| 2114 |
+
}
|
| 2115 |
+
|
| 2116 |
+
.logo-icon {
|
| 2117 |
+
width: 64px;
|
| 2118 |
+
height: 64px;
|
| 2119 |
+
border-radius: 16px;
|
| 2120 |
+
background: linear-gradient(135deg, #ff7a3c 0%, #ff6b2b 100%);
|
| 2121 |
+
display: flex;
|
| 2122 |
+
align-items: center;
|
| 2123 |
+
justify-content: center;
|
| 2124 |
+
font-weight: 700;
|
| 2125 |
+
font-size: 28px;
|
| 2126 |
+
color: #050608;
|
| 2127 |
+
box-shadow: 0 8px 24px rgba(255, 122, 60, 0.3);
|
| 2128 |
+
transition: transform 0.3s ease;
|
| 2129 |
+
}
|
| 2130 |
+
|
| 2131 |
+
.logo-icon:hover {
|
| 2132 |
+
transform: scale(1.05) rotate(3deg);
|
| 2133 |
+
}
|
| 2134 |
+
|
| 2135 |
+
.login-title {
|
| 2136 |
+
margin: 0;
|
| 2137 |
+
font-size: 28px;
|
| 2138 |
+
font-weight: 700;
|
| 2139 |
+
color: #f5f5f7;
|
| 2140 |
+
margin-bottom: 8px;
|
| 2141 |
+
letter-spacing: -0.5px;
|
| 2142 |
+
}
|
| 2143 |
+
|
| 2144 |
+
.login-subtitle {
|
| 2145 |
+
margin: 0;
|
| 2146 |
+
font-size: 14px;
|
| 2147 |
+
color: #9a9bb0;
|
| 2148 |
+
font-weight: 500;
|
| 2149 |
+
}
|
| 2150 |
+
|
| 2151 |
+
/* Welcome Section */
|
| 2152 |
+
.login-welcome {
|
| 2153 |
+
margin-bottom: 28px;
|
| 2154 |
+
padding-bottom: 28px;
|
| 2155 |
+
border-bottom: 1px solid #272832;
|
| 2156 |
+
}
|
| 2157 |
+
|
| 2158 |
+
.login-welcome h2 {
|
| 2159 |
+
margin: 0 0 12px 0;
|
| 2160 |
+
font-size: 20px;
|
| 2161 |
+
font-weight: 600;
|
| 2162 |
+
color: #f5f5f7;
|
| 2163 |
+
}
|
| 2164 |
+
|
| 2165 |
+
.login-welcome p {
|
| 2166 |
+
margin: 0;
|
| 2167 |
+
font-size: 14px;
|
| 2168 |
+
line-height: 1.6;
|
| 2169 |
+
color: #c3c5dd;
|
| 2170 |
+
}
|
| 2171 |
+
|
| 2172 |
+
/* Error Message */
|
| 2173 |
+
.login-error {
|
| 2174 |
+
display: flex;
|
| 2175 |
+
align-items: center;
|
| 2176 |
+
gap: 10px;
|
| 2177 |
+
padding: 12px 14px;
|
| 2178 |
+
background: rgba(255, 82, 82, 0.1);
|
| 2179 |
+
border: 1px solid rgba(255, 82, 82, 0.3);
|
| 2180 |
+
border-radius: 10px;
|
| 2181 |
+
color: #ff8a8a;
|
| 2182 |
+
font-size: 13px;
|
| 2183 |
+
margin-bottom: 20px;
|
| 2184 |
+
animation: shake 0.4s ease;
|
| 2185 |
+
}
|
| 2186 |
+
|
| 2187 |
+
@keyframes shake {
|
| 2188 |
+
0%, 100% { transform: translateX(0); }
|
| 2189 |
+
25% { transform: translateX(-5px); }
|
| 2190 |
+
75% { transform: translateX(5px); }
|
| 2191 |
+
}
|
| 2192 |
+
|
| 2193 |
+
.login-error svg {
|
| 2194 |
+
flex-shrink: 0;
|
| 2195 |
+
}
|
| 2196 |
+
|
| 2197 |
+
/* Login Actions */
|
| 2198 |
+
.login-actions {
|
| 2199 |
+
display: flex;
|
| 2200 |
+
flex-direction: column;
|
| 2201 |
+
gap: 14px;
|
| 2202 |
+
margin-bottom: 28px;
|
| 2203 |
+
}
|
| 2204 |
+
|
| 2205 |
+
/* Buttons */
|
| 2206 |
+
.btn-primary,
|
| 2207 |
+
.btn-secondary,
|
| 2208 |
+
.btn-text {
|
| 2209 |
+
border: none;
|
| 2210 |
+
outline: none;
|
| 2211 |
+
cursor: pointer;
|
| 2212 |
+
font-family: inherit;
|
| 2213 |
+
font-weight: 600;
|
| 2214 |
+
transition: all 0.2s ease;
|
| 2215 |
+
display: flex;
|
| 2216 |
+
align-items: center;
|
| 2217 |
+
justify-content: center;
|
| 2218 |
+
gap: 10px;
|
| 2219 |
+
}
|
| 2220 |
+
|
| 2221 |
+
.btn-large {
|
| 2222 |
+
padding: 14px 24px;
|
| 2223 |
+
font-size: 15px;
|
| 2224 |
+
border-radius: 12px;
|
| 2225 |
+
}
|
| 2226 |
+
|
| 2227 |
+
.btn-primary {
|
| 2228 |
+
background: linear-gradient(135deg, #ff7a3c 0%, #ff6b2b 100%);
|
| 2229 |
+
color: #fff;
|
| 2230 |
+
box-shadow: 0 4px 12px rgba(255, 122, 60, 0.25);
|
| 2231 |
+
}
|
| 2232 |
+
|
| 2233 |
+
.btn-primary:hover:not(:disabled) {
|
| 2234 |
+
transform: translateY(-2px);
|
| 2235 |
+
box-shadow: 0 8px 20px rgba(255, 122, 60, 0.35);
|
| 2236 |
+
}
|
| 2237 |
+
|
| 2238 |
+
.btn-primary:active:not(:disabled) {
|
| 2239 |
+
transform: translateY(0);
|
| 2240 |
+
}
|
| 2241 |
+
|
| 2242 |
+
.btn-primary:disabled {
|
| 2243 |
+
opacity: 0.6;
|
| 2244 |
+
cursor: not-allowed;
|
| 2245 |
+
}
|
| 2246 |
+
|
| 2247 |
+
.btn-secondary {
|
| 2248 |
+
background: #1a1b26;
|
| 2249 |
+
color: #f5f5f7;
|
| 2250 |
+
border: 1px solid #3a3b4d;
|
| 2251 |
+
}
|
| 2252 |
+
|
| 2253 |
+
.btn-secondary:hover {
|
| 2254 |
+
background: #2a2b3c;
|
| 2255 |
+
border-color: #4a4b5d;
|
| 2256 |
+
transform: translateY(-1px);
|
| 2257 |
+
}
|
| 2258 |
+
|
| 2259 |
+
.btn-text {
|
| 2260 |
+
background: transparent;
|
| 2261 |
+
color: #9a9bb0;
|
| 2262 |
+
padding: 10px;
|
| 2263 |
+
font-size: 14px;
|
| 2264 |
+
font-weight: 500;
|
| 2265 |
+
}
|
| 2266 |
+
|
| 2267 |
+
.btn-text:hover {
|
| 2268 |
+
color: #ff7a3c;
|
| 2269 |
+
}
|
| 2270 |
+
|
| 2271 |
+
/* Button Spinner */
|
| 2272 |
+
.btn-spinner {
|
| 2273 |
+
width: 16px;
|
| 2274 |
+
height: 16px;
|
| 2275 |
+
border: 2px solid rgba(255, 255, 255, 0.3);
|
| 2276 |
+
border-top-color: #fff;
|
| 2277 |
+
border-radius: 50%;
|
| 2278 |
+
animation: spin 0.6s linear infinite;
|
| 2279 |
+
}
|
| 2280 |
+
|
| 2281 |
+
@keyframes spin {
|
| 2282 |
+
to { transform: rotate(360deg); }
|
| 2283 |
+
}
|
| 2284 |
+
|
| 2285 |
+
/* Loading Spinner (Page) */
|
| 2286 |
+
.loading-spinner {
|
| 2287 |
+
width: 48px;
|
| 2288 |
+
height: 48px;
|
| 2289 |
+
border: 4px solid #272832;
|
| 2290 |
+
border-top-color: #ff7a3c;
|
| 2291 |
+
border-radius: 50%;
|
| 2292 |
+
animation: spin 0.8s linear infinite;
|
| 2293 |
+
margin: 0 auto;
|
| 2294 |
+
}
|
| 2295 |
+
|
| 2296 |
+
/* Divider */
|
| 2297 |
+
.login-divider {
|
| 2298 |
+
position: relative;
|
| 2299 |
+
text-align: center;
|
| 2300 |
+
margin: 8px 0;
|
| 2301 |
+
}
|
| 2302 |
+
|
| 2303 |
+
.login-divider::before {
|
| 2304 |
+
content: '';
|
| 2305 |
+
position: absolute;
|
| 2306 |
+
top: 50%;
|
| 2307 |
+
left: 0;
|
| 2308 |
+
right: 0;
|
| 2309 |
+
height: 1px;
|
| 2310 |
+
background: #272832;
|
| 2311 |
+
}
|
| 2312 |
+
|
| 2313 |
+
.login-divider span {
|
| 2314 |
+
position: relative;
|
| 2315 |
+
display: inline-block;
|
| 2316 |
+
padding: 0 16px;
|
| 2317 |
+
background: #101117;
|
| 2318 |
+
color: #9a9bb0;
|
| 2319 |
+
font-size: 12px;
|
| 2320 |
+
font-weight: 500;
|
| 2321 |
+
}
|
| 2322 |
+
|
| 2323 |
+
/* Form */
|
| 2324 |
+
.login-form {
|
| 2325 |
+
display: flex;
|
| 2326 |
+
flex-direction: column;
|
| 2327 |
+
gap: 18px;
|
| 2328 |
+
margin-bottom: 28px;
|
| 2329 |
+
}
|
| 2330 |
+
|
| 2331 |
+
.form-group {
|
| 2332 |
+
display: flex;
|
| 2333 |
+
flex-direction: column;
|
| 2334 |
+
gap: 8px;
|
| 2335 |
+
}
|
| 2336 |
+
|
| 2337 |
+
.form-group label {
|
| 2338 |
+
font-size: 13px;
|
| 2339 |
+
font-weight: 600;
|
| 2340 |
+
color: #f5f5f7;
|
| 2341 |
+
}
|
| 2342 |
+
|
| 2343 |
+
.form-input {
|
| 2344 |
+
background: #0a0b0f;
|
| 2345 |
+
border: 1px solid #272832;
|
| 2346 |
+
border-radius: 10px;
|
| 2347 |
+
padding: 12px 14px;
|
| 2348 |
+
color: #f5f5f7;
|
| 2349 |
+
font-size: 14px;
|
| 2350 |
+
font-family: "SF Mono", Monaco, monospace;
|
| 2351 |
+
transition: all 0.2s ease;
|
| 2352 |
+
}
|
| 2353 |
+
|
| 2354 |
+
.form-input:focus {
|
| 2355 |
+
outline: none;
|
| 2356 |
+
border-color: #ff7a3c;
|
| 2357 |
+
box-shadow: 0 0 0 4px rgba(255, 122, 60, 0.1);
|
| 2358 |
+
}
|
| 2359 |
+
|
| 2360 |
+
.form-input:disabled {
|
| 2361 |
+
opacity: 0.5;
|
| 2362 |
+
cursor: not-allowed;
|
| 2363 |
+
}
|
| 2364 |
+
|
| 2365 |
+
.form-input::placeholder {
|
| 2366 |
+
color: #676883;
|
| 2367 |
+
}
|
| 2368 |
+
|
| 2369 |
+
.form-hint {
|
| 2370 |
+
font-size: 12px;
|
| 2371 |
+
color: #9a9bb0;
|
| 2372 |
+
line-height: 1.5;
|
| 2373 |
+
margin: 0;
|
| 2374 |
+
}
|
| 2375 |
+
|
| 2376 |
+
.form-link {
|
| 2377 |
+
color: #ff7a3c;
|
| 2378 |
+
text-decoration: none;
|
| 2379 |
+
font-weight: 500;
|
| 2380 |
+
transition: color 0.2s ease;
|
| 2381 |
+
}
|
| 2382 |
+
|
| 2383 |
+
.form-link:hover {
|
| 2384 |
+
color: #ff8b52;
|
| 2385 |
+
text-decoration: underline;
|
| 2386 |
+
}
|
| 2387 |
+
|
| 2388 |
+
.form-hint code {
|
| 2389 |
+
background: #1a1b26;
|
| 2390 |
+
padding: 2px 6px;
|
| 2391 |
+
border-radius: 4px;
|
| 2392 |
+
font-family: "SF Mono", Monaco, monospace;
|
| 2393 |
+
font-size: 11px;
|
| 2394 |
+
color: #ff7a3c;
|
| 2395 |
+
}
|
| 2396 |
+
|
| 2397 |
+
/* Notice (for no auth configured) */
|
| 2398 |
+
.login-notice {
|
| 2399 |
+
padding: 20px;
|
| 2400 |
+
background: rgba(255, 152, 0, 0.1);
|
| 2401 |
+
border: 1px solid rgba(255, 152, 0, 0.3);
|
| 2402 |
+
border-radius: 12px;
|
| 2403 |
+
margin-bottom: 28px;
|
| 2404 |
+
}
|
| 2405 |
+
|
| 2406 |
+
.login-notice h3 {
|
| 2407 |
+
margin: 0 0 12px 0;
|
| 2408 |
+
font-size: 16px;
|
| 2409 |
+
color: #ffb74d;
|
| 2410 |
+
}
|
| 2411 |
+
|
| 2412 |
+
.login-notice p {
|
| 2413 |
+
margin: 0 0 12px 0;
|
| 2414 |
+
font-size: 13px;
|
| 2415 |
+
color: #c3c5dd;
|
| 2416 |
+
line-height: 1.6;
|
| 2417 |
+
}
|
| 2418 |
+
|
| 2419 |
+
.login-notice ul {
|
| 2420 |
+
margin: 0;
|
| 2421 |
+
padding-left: 20px;
|
| 2422 |
+
font-size: 13px;
|
| 2423 |
+
color: #c3c5dd;
|
| 2424 |
+
line-height: 1.8;
|
| 2425 |
+
}
|
| 2426 |
+
|
| 2427 |
+
.login-notice code {
|
| 2428 |
+
background: #1a1b26;
|
| 2429 |
+
padding: 2px 6px;
|
| 2430 |
+
border-radius: 4px;
|
| 2431 |
+
font-family: "SF Mono", Monaco, monospace;
|
| 2432 |
+
font-size: 12px;
|
| 2433 |
+
color: #ff7a3c;
|
| 2434 |
+
}
|
| 2435 |
+
|
| 2436 |
+
/* Features List */
|
| 2437 |
+
.login-features {
|
| 2438 |
+
display: flex;
|
| 2439 |
+
flex-direction: column;
|
| 2440 |
+
gap: 12px;
|
| 2441 |
+
padding: 20px 0;
|
| 2442 |
+
border-top: 1px solid #272832;
|
| 2443 |
+
border-bottom: 1px solid #272832;
|
| 2444 |
+
margin-bottom: 20px;
|
| 2445 |
+
}
|
| 2446 |
+
|
| 2447 |
+
.feature-item {
|
| 2448 |
+
display: flex;
|
| 2449 |
+
align-items: flex-start;
|
| 2450 |
+
gap: 12px;
|
| 2451 |
+
}
|
| 2452 |
+
|
| 2453 |
+
.feature-icon {
|
| 2454 |
+
flex-shrink: 0;
|
| 2455 |
+
width: 20px;
|
| 2456 |
+
height: 20px;
|
| 2457 |
+
display: flex;
|
| 2458 |
+
align-items: center;
|
| 2459 |
+
justify-content: center;
|
| 2460 |
+
color: #7cffb3;
|
| 2461 |
+
}
|
| 2462 |
+
|
| 2463 |
+
.feature-text {
|
| 2464 |
+
display: flex;
|
| 2465 |
+
flex-direction: column;
|
| 2466 |
+
gap: 2px;
|
| 2467 |
+
}
|
| 2468 |
+
|
| 2469 |
+
.feature-text strong {
|
| 2470 |
+
font-size: 13px;
|
| 2471 |
+
font-weight: 600;
|
| 2472 |
+
color: #f5f5f7;
|
| 2473 |
+
}
|
| 2474 |
+
|
| 2475 |
+
.feature-text span {
|
| 2476 |
+
font-size: 12px;
|
| 2477 |
+
color: #9a9bb0;
|
| 2478 |
+
}
|
| 2479 |
+
|
| 2480 |
+
/* Footer */
|
| 2481 |
+
.login-footer {
|
| 2482 |
+
text-align: center;
|
| 2483 |
+
}
|
| 2484 |
+
|
| 2485 |
+
.login-footer p {
|
| 2486 |
+
margin: 0;
|
| 2487 |
+
font-size: 11px;
|
| 2488 |
+
color: #7a7b8e;
|
| 2489 |
+
line-height: 1.6;
|
| 2490 |
+
}/* ============================================================================
|
| 2491 |
+
INSTALLATION MODAL - Claude Code Style
|
| 2492 |
+
============================================================================ */
|
| 2493 |
+
|
| 2494 |
+
.install-modal-backdrop {
|
| 2495 |
+
position: fixed;
|
| 2496 |
+
inset: 0;
|
| 2497 |
+
display: flex;
|
| 2498 |
+
align-items: center;
|
| 2499 |
+
justify-content: center;
|
| 2500 |
+
background: rgba(0, 0, 0, 0.7);
|
| 2501 |
+
backdrop-filter: blur(8px);
|
| 2502 |
+
z-index: 9999;
|
| 2503 |
+
animation: fadeIn 0.2s ease;
|
| 2504 |
+
}
|
| 2505 |
+
|
| 2506 |
+
.install-modal {
|
| 2507 |
+
width: 480px;
|
| 2508 |
+
max-width: 90vw;
|
| 2509 |
+
background: #101117;
|
| 2510 |
+
border: 1px solid #272832;
|
| 2511 |
+
border-radius: 16px;
|
| 2512 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
| 2513 |
+
animation: modalSlideIn 0.3s ease;
|
| 2514 |
+
overflow: hidden;
|
| 2515 |
+
}
|
| 2516 |
+
|
| 2517 |
+
@keyframes modalSlideIn {
|
| 2518 |
+
from {
|
| 2519 |
+
opacity: 0;
|
| 2520 |
+
transform: translateY(-20px) scale(0.95);
|
| 2521 |
+
}
|
| 2522 |
+
to {
|
| 2523 |
+
opacity: 1;
|
| 2524 |
+
transform: translateY(0) scale(1);
|
| 2525 |
+
}
|
| 2526 |
+
}
|
| 2527 |
+
|
| 2528 |
+
/* Modal Header */
|
| 2529 |
+
.install-modal-header {
|
| 2530 |
+
padding: 32px 32px 24px;
|
| 2531 |
+
text-align: center;
|
| 2532 |
+
border-bottom: 1px solid #272832;
|
| 2533 |
+
}
|
| 2534 |
+
|
| 2535 |
+
.install-modal-logo {
|
| 2536 |
+
display: flex;
|
| 2537 |
+
justify-content: center;
|
| 2538 |
+
margin-bottom: 16px;
|
| 2539 |
+
}
|
| 2540 |
+
|
| 2541 |
+
.logo-icon-large {
|
| 2542 |
+
width: 56px;
|
| 2543 |
+
height: 56px;
|
| 2544 |
+
border-radius: 12px;
|
| 2545 |
+
background: linear-gradient(135deg, #ff7a3c 0%, #ff6b2b 100%);
|
| 2546 |
+
display: flex;
|
| 2547 |
+
align-items: center;
|
| 2548 |
+
justify-content: center;
|
| 2549 |
+
font-weight: 700;
|
| 2550 |
+
font-size: 24px;
|
| 2551 |
+
color: #050608;
|
| 2552 |
+
box-shadow: 0 4px 16px rgba(255, 122, 60, 0.3);
|
| 2553 |
+
}
|
| 2554 |
+
|
| 2555 |
+
.install-modal-title {
|
| 2556 |
+
margin: 0 0 8px 0;
|
| 2557 |
+
font-size: 20px;
|
| 2558 |
+
font-weight: 600;
|
| 2559 |
+
color: #f5f5f7;
|
| 2560 |
+
}
|
| 2561 |
+
|
| 2562 |
+
.install-modal-subtitle {
|
| 2563 |
+
margin: 0;
|
| 2564 |
+
font-size: 13px;
|
| 2565 |
+
color: #9a9bb0;
|
| 2566 |
+
line-height: 1.5;
|
| 2567 |
+
}
|
| 2568 |
+
|
| 2569 |
+
/* Status Indicator */
|
| 2570 |
+
.install-status {
|
| 2571 |
+
display: flex;
|
| 2572 |
+
align-items: center;
|
| 2573 |
+
gap: 10px;
|
| 2574 |
+
padding: 12px 16px;
|
| 2575 |
+
margin: 16px 24px;
|
| 2576 |
+
border-radius: 8px;
|
| 2577 |
+
font-size: 13px;
|
| 2578 |
+
transition: all 0.2s ease;
|
| 2579 |
+
}
|
| 2580 |
+
|
| 2581 |
+
.install-status-error {
|
| 2582 |
+
background: rgba(255, 82, 82, 0.1);
|
| 2583 |
+
border: 1px solid rgba(255, 82, 82, 0.3);
|
| 2584 |
+
color: #ff8a8a;
|
| 2585 |
+
}
|
| 2586 |
+
|
| 2587 |
+
.install-status-pending {
|
| 2588 |
+
background: rgba(255, 152, 0, 0.1);
|
| 2589 |
+
border: 1px solid rgba(255, 152, 0, 0.3);
|
| 2590 |
+
color: #ffb74d;
|
| 2591 |
+
}
|
| 2592 |
+
|
| 2593 |
+
.status-icon {
|
| 2594 |
+
flex-shrink: 0;
|
| 2595 |
+
}
|
| 2596 |
+
|
| 2597 |
+
.status-spinner {
|
| 2598 |
+
width: 16px;
|
| 2599 |
+
height: 16px;
|
| 2600 |
+
border: 2px solid rgba(255, 180, 77, 0.3);
|
| 2601 |
+
border-top-color: #ffb74d;
|
| 2602 |
+
border-radius: 50%;
|
| 2603 |
+
animation: spin 0.6s linear infinite;
|
| 2604 |
+
}
|
| 2605 |
+
|
| 2606 |
+
/* Installation Steps */
|
| 2607 |
+
.install-steps {
|
| 2608 |
+
padding: 24px 32px;
|
| 2609 |
+
display: flex;
|
| 2610 |
+
flex-direction: column;
|
| 2611 |
+
gap: 16px;
|
| 2612 |
+
}
|
| 2613 |
+
|
| 2614 |
+
.install-step {
|
| 2615 |
+
display: flex;
|
| 2616 |
+
align-items: flex-start;
|
| 2617 |
+
gap: 12px;
|
| 2618 |
+
}
|
| 2619 |
+
|
| 2620 |
+
.step-number {
|
| 2621 |
+
flex-shrink: 0;
|
| 2622 |
+
width: 28px;
|
| 2623 |
+
height: 28px;
|
| 2624 |
+
border-radius: 8px;
|
| 2625 |
+
background: #1a1b26;
|
| 2626 |
+
border: 1px solid #3a3b4d;
|
| 2627 |
+
display: flex;
|
| 2628 |
+
align-items: center;
|
| 2629 |
+
justify-content: center;
|
| 2630 |
+
font-size: 13px;
|
| 2631 |
+
font-weight: 600;
|
| 2632 |
+
color: #ff7a3c;
|
| 2633 |
+
}
|
| 2634 |
+
|
| 2635 |
+
.step-content h3 {
|
| 2636 |
+
margin: 0 0 4px 0;
|
| 2637 |
+
font-size: 14px;
|
| 2638 |
+
font-weight: 600;
|
| 2639 |
+
color: #f5f5f7;
|
| 2640 |
+
}
|
| 2641 |
+
|
| 2642 |
+
.step-content p {
|
| 2643 |
+
margin: 0;
|
| 2644 |
+
font-size: 12px;
|
| 2645 |
+
color: #9a9bb0;
|
| 2646 |
+
line-height: 1.5;
|
| 2647 |
+
}
|
| 2648 |
+
|
| 2649 |
+
/* Action Buttons */
|
| 2650 |
+
.install-modal-actions {
|
| 2651 |
+
display: flex;
|
| 2652 |
+
align-items: center;
|
| 2653 |
+
justify-content: flex-end;
|
| 2654 |
+
gap: 10px;
|
| 2655 |
+
padding: 16px 24px;
|
| 2656 |
+
border-top: 1px solid #272832;
|
| 2657 |
+
background: #0a0b0f;
|
| 2658 |
+
}
|
| 2659 |
+
|
| 2660 |
+
.btn-install-primary {
|
| 2661 |
+
border: none;
|
| 2662 |
+
outline: none;
|
| 2663 |
+
background: #000;
|
| 2664 |
+
color: #fff;
|
| 2665 |
+
padding: 10px 18px;
|
| 2666 |
+
border-radius: 8px;
|
| 2667 |
+
font-size: 13px;
|
| 2668 |
+
font-weight: 600;
|
| 2669 |
+
cursor: pointer;
|
| 2670 |
+
display: flex;
|
| 2671 |
+
align-items: center;
|
| 2672 |
+
gap: 8px;
|
| 2673 |
+
transition: all 0.2s ease;
|
| 2674 |
+
}
|
| 2675 |
+
|
| 2676 |
+
.btn-install-primary:hover:not(:disabled) {
|
| 2677 |
+
background: #1a1a1a;
|
| 2678 |
+
transform: translateY(-1px);
|
| 2679 |
+
}
|
| 2680 |
+
|
| 2681 |
+
.btn-install-primary:active:not(:disabled) {
|
| 2682 |
+
transform: translateY(0);
|
| 2683 |
+
}
|
| 2684 |
+
|
| 2685 |
+
.btn-install-primary:disabled {
|
| 2686 |
+
opacity: 0.5;
|
| 2687 |
+
cursor: not-allowed;
|
| 2688 |
+
}
|
| 2689 |
+
|
| 2690 |
+
.btn-check-status {
|
| 2691 |
+
border: 1px solid #3a3b4d;
|
| 2692 |
+
outline: none;
|
| 2693 |
+
background: #1a1b26;
|
| 2694 |
+
color: #f5f5f7;
|
| 2695 |
+
padding: 10px 18px;
|
| 2696 |
+
border-radius: 8px;
|
| 2697 |
+
font-size: 13px;
|
| 2698 |
+
font-weight: 500;
|
| 2699 |
+
cursor: pointer;
|
| 2700 |
+
display: flex;
|
| 2701 |
+
align-items: center;
|
| 2702 |
+
gap: 8px;
|
| 2703 |
+
transition: all 0.2s ease;
|
| 2704 |
+
}
|
| 2705 |
+
|
| 2706 |
+
.btn-check-status:hover:not(:disabled) {
|
| 2707 |
+
background: #2a2b3c;
|
| 2708 |
+
border-color: #4a4b5d;
|
| 2709 |
+
}
|
| 2710 |
+
|
| 2711 |
+
.btn-check-status:disabled {
|
| 2712 |
+
opacity: 0.5;
|
| 2713 |
+
cursor: not-allowed;
|
| 2714 |
+
}
|
| 2715 |
+
|
| 2716 |
+
.btn-install-secondary {
|
| 2717 |
+
border: 1px solid #3a3b4d;
|
| 2718 |
+
outline: none;
|
| 2719 |
+
background: transparent;
|
| 2720 |
+
color: #c3c5dd;
|
| 2721 |
+
padding: 10px 18px;
|
| 2722 |
+
border-radius: 8px;
|
| 2723 |
+
font-size: 13px;
|
| 2724 |
+
font-weight: 500;
|
| 2725 |
+
cursor: pointer;
|
| 2726 |
+
display: flex;
|
| 2727 |
+
align-items: center;
|
| 2728 |
+
gap: 8px;
|
| 2729 |
+
transition: all 0.2s ease;
|
| 2730 |
+
}
|
| 2731 |
+
|
| 2732 |
+
.btn-install-secondary:hover:not(:disabled) {
|
| 2733 |
+
background: #1a1b26;
|
| 2734 |
+
border-color: #4a4b5d;
|
| 2735 |
+
}
|
| 2736 |
+
|
| 2737 |
+
.btn-install-secondary:disabled {
|
| 2738 |
+
opacity: 0.5;
|
| 2739 |
+
cursor: not-allowed;
|
| 2740 |
+
}
|
| 2741 |
+
|
| 2742 |
+
/* Footer */
|
| 2743 |
+
.install-modal-footer {
|
| 2744 |
+
padding: 16px 32px 24px;
|
| 2745 |
+
text-align: center;
|
| 2746 |
+
}
|
| 2747 |
+
|
| 2748 |
+
.install-modal-footer p {
|
| 2749 |
+
margin: 0;
|
| 2750 |
+
font-size: 12px;
|
| 2751 |
+
color: #7a7b8e;
|
| 2752 |
+
line-height: 1.6;
|
| 2753 |
+
}
|
| 2754 |
+
|
| 2755 |
+
.install-modal-footer strong {
|
| 2756 |
+
color: #c3c5dd;
|
| 2757 |
+
font-weight: 600;
|
| 2758 |
+
}
|
| 2759 |
+
|
| 2760 |
+
/* Button spinner */
|
| 2761 |
+
.btn-spinner {
|
| 2762 |
+
width: 14px;
|
| 2763 |
+
height: 14px;
|
| 2764 |
+
border: 2px solid rgba(255, 255, 255, 0.3);
|
| 2765 |
+
border-top-color: #fff;
|
| 2766 |
+
border-radius: 50%;
|
| 2767 |
+
animation: spin 0.6s linear infinite;
|
| 2768 |
+
}
|
| 2769 |
+
|
| 2770 |
+
@keyframes spin {
|
| 2771 |
+
to { transform: rotate(360deg); }
|
| 2772 |
+
}
|
| 2773 |
+
|
| 2774 |
+
/* Secondary primary-style button for "Load available models" */
|
| 2775 |
+
.settings-load-btn {
|
| 2776 |
+
margin-top: 8px;
|
| 2777 |
+
|
| 2778 |
+
/* Make it hug the text, not full width */
|
| 2779 |
+
display: inline-flex;
|
| 2780 |
+
align-items: center;
|
| 2781 |
+
justify-content: center;
|
| 2782 |
+
width: auto !important;
|
| 2783 |
+
min-width: 0;
|
| 2784 |
+
align-self: flex-start;
|
| 2785 |
+
|
| 2786 |
+
/* Size: slightly smaller than Save but same family */
|
| 2787 |
+
padding: 7px 14px;
|
| 2788 |
+
border-radius: 999px;
|
| 2789 |
+
|
| 2790 |
+
font-size: 12px;
|
| 2791 |
+
font-weight: 600;
|
| 2792 |
+
letter-spacing: 0.01em;
|
| 2793 |
+
|
| 2794 |
+
border: none;
|
| 2795 |
+
outline: none;
|
| 2796 |
+
cursor: pointer;
|
| 2797 |
+
|
| 2798 |
+
/* Match Save button color palette */
|
| 2799 |
+
background: #ff7a3c;
|
| 2800 |
+
color: #050608;
|
| 2801 |
+
|
| 2802 |
+
transition:
|
| 2803 |
+
background 0.2s ease,
|
| 2804 |
+
box-shadow 0.2s ease,
|
| 2805 |
+
transform 0.15s ease,
|
| 2806 |
+
opacity 0.2s ease;
|
| 2807 |
+
}
|
| 2808 |
+
|
| 2809 |
+
.settings-load-btn:hover {
|
| 2810 |
+
background: #ff8b52;
|
| 2811 |
+
transform: translateY(-1px);
|
| 2812 |
+
box-shadow: 0 3px 10px rgba(255, 122, 60, 0.28);
|
| 2813 |
+
}
|
| 2814 |
+
|
| 2815 |
+
.settings-load-btn:active {
|
| 2816 |
+
transform: translateY(0);
|
| 2817 |
+
box-shadow: 0 1px 4px rgba(255, 122, 60, 0.25);
|
| 2818 |
+
}
|
| 2819 |
+
|
| 2820 |
+
.settings-load-btn:disabled {
|
| 2821 |
+
opacity: 0.55;
|
| 2822 |
+
cursor: not-allowed;
|
| 2823 |
+
transform: none;
|
| 2824 |
+
box-shadow: none;
|
| 2825 |
+
}
|
frontend/utils/api.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
const response = await fetch(url, options);
|
| 48 |
+
const contentType = response.headers.get('content-type');
|
| 49 |
+
|
| 50 |
+
// Check if response is actually JSON
|
| 51 |
+
if (!contentType || !contentType.includes('application/json')) {
|
| 52 |
+
// If not JSON, it might be an HTML error page
|
| 53 |
+
const text = await response.text();
|
| 54 |
+
|
| 55 |
+
// Check if it looks like HTML (starts with <!doctype or <html)
|
| 56 |
+
if (text.trim().toLowerCase().startsWith('<!doctype') ||
|
| 57 |
+
text.trim().toLowerCase().startsWith('<html')) {
|
| 58 |
+
throw new Error(
|
| 59 |
+
`Backend not reachable. Received HTML instead of JSON. ` +
|
| 60 |
+
`${!isBackendConfigured() ? 'VITE_BACKEND_URL environment variable is not configured. ' : ''}` +
|
| 61 |
+
`Please check your backend configuration.`
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// Try to return the text as-is if not HTML
|
| 66 |
+
throw new Error(`Unexpected response type: ${contentType || 'unknown'}. Response: ${text.substring(0, 100)}`);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const data = await response.json();
|
| 70 |
+
|
| 71 |
+
if (!response.ok) {
|
| 72 |
+
throw new Error(data.detail || data.error || data.message || `Request failed with status ${response.status}`);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
return data;
|
| 76 |
+
} catch (error) {
|
| 77 |
+
// Re-throw with better error message
|
| 78 |
+
if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
|
| 79 |
+
throw new Error(
|
| 80 |
+
`Cannot connect to backend server. ` +
|
| 81 |
+
`${!isBackendConfigured() ? 'VITE_BACKEND_URL environment variable is not configured. ' : ''}` +
|
| 82 |
+
`Please check that the backend is running and accessible.`
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
+
throw error;
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* Get authorization headers with GitHub token
|
| 91 |
+
* @returns {Object} Headers object with Authorization if token exists
|
| 92 |
+
*/
|
| 93 |
+
export function getAuthHeaders() {
|
| 94 |
+
const token = localStorage.getItem('github_token');
|
| 95 |
+
|
| 96 |
+
if (!token) {
|
| 97 |
+
return {};
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return {
|
| 101 |
+
'Authorization': `Bearer ${token}`,
|
| 102 |
+
};
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/**
|
| 106 |
+
* Make an authenticated fetch request
|
| 107 |
+
* @param {string} url - API endpoint URL
|
| 108 |
+
* @param {Object} options - Fetch options
|
| 109 |
+
* @returns {Promise<Response>} Fetch response
|
| 110 |
+
*/
|
| 111 |
+
export async function authFetch(url, options = {}) {
|
| 112 |
+
const headers = {
|
| 113 |
+
...getAuthHeaders(),
|
| 114 |
+
...options.headers,
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
return fetch(url, {
|
| 118 |
+
...options,
|
| 119 |
+
headers,
|
| 120 |
+
});
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* Make an authenticated JSON request
|
| 125 |
+
* @param {string} url - API endpoint URL
|
| 126 |
+
* @param {Object} options - Fetch options
|
| 127 |
+
* @returns {Promise<any>} Parsed JSON response
|
| 128 |
+
*/
|
| 129 |
+
export async function authFetchJSON(url, options = {}) {
|
| 130 |
+
const headers = {
|
| 131 |
+
'Content-Type': 'application/json',
|
| 132 |
+
...getAuthHeaders(),
|
| 133 |
+
...options.headers,
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
const response = await fetch(url, {
|
| 137 |
+
...options,
|
| 138 |
+
headers,
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
if (!response.ok) {
|
| 142 |
+
const error = await response.json().catch(() => ({ detail: 'Request failed' }));
|
| 143 |
+
throw new Error(error.detail || error.message || 'Request failed');
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
return response.json();
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// ─── Redesigned API Endpoints ────────────────────────────
|
| 150 |
+
|
| 151 |
+
/**
|
| 152 |
+
* Get normalized server status
|
| 153 |
+
*/
|
| 154 |
+
export async function fetchStatus() {
|
| 155 |
+
return safeFetchJSON(apiUrl("/api/status"));
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/**
|
| 159 |
+
* Get detailed provider status
|
| 160 |
+
*/
|
| 161 |
+
export async function fetchProviderStatus() {
|
| 162 |
+
return safeFetchJSON(apiUrl("/api/providers/status"));
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/**
|
| 166 |
+
* Test a provider configuration
|
| 167 |
+
*/
|
| 168 |
+
export async function testProvider(providerConfig) {
|
| 169 |
+
return safeFetchJSON(apiUrl("/api/providers/test"), {
|
| 170 |
+
method: "POST",
|
| 171 |
+
headers: { "Content-Type": "application/json" },
|
| 172 |
+
body: JSON.stringify(providerConfig),
|
| 173 |
+
});
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
/**
|
| 177 |
+
* Start a session by mode
|
| 178 |
+
*/
|
| 179 |
+
export async function startSession(sessionConfig) {
|
| 180 |
+
return safeFetchJSON(apiUrl("/api/session/start"), {
|
| 181 |
+
method: "POST",
|
| 182 |
+
headers: { "Content-Type": "application/json" },
|
| 183 |
+
body: JSON.stringify(sessionConfig),
|
| 184 |
+
});
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
/**
|
| 188 |
+
* Send a chat message (redesigned endpoint)
|
| 189 |
+
*/
|
| 190 |
+
export async function sendChatMessage(messageConfig) {
|
| 191 |
+
return safeFetchJSON(apiUrl("/api/chat/send"), {
|
| 192 |
+
method: "POST",
|
| 193 |
+
headers: { "Content-Type": "application/json" },
|
| 194 |
+
body: JSON.stringify(messageConfig),
|
| 195 |
+
});
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/**
|
| 199 |
+
* Get workspace summary
|
| 200 |
+
*/
|
| 201 |
+
export async function fetchWorkspaceSummary(folderPath) {
|
| 202 |
+
const query = folderPath ? `?folder_path=${encodeURIComponent(folderPath)}` : "";
|
| 203 |
+
return safeFetchJSON(apiUrl(`/api/workspace/summary${query}`));
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/**
|
| 207 |
+
* Run security scan on workspace
|
| 208 |
+
*/
|
| 209 |
+
export async function scanWorkspace(path) {
|
| 210 |
+
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
| 211 |
+
return safeFetchJSON(apiUrl(`/api/security/scan-workspace${query}`));
|
| 212 |
+
}
|
frontend/utils/ws.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* WebSocket client for real-time session streaming.
|
| 3 |
+
*
|
| 4 |
+
* Provides auto-reconnection, heartbeat, and event dispatching.
|
| 5 |
+
* Falls back gracefully — callers should always have an HTTP fallback.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
const WS_RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
|
| 9 |
+
const HEARTBEAT_INTERVAL = 30000;
|
| 10 |
+
const MAX_RECONNECT_ATTEMPTS = 5;
|
| 11 |
+
// If a connection dies within this window it counts as unstable
|
| 12 |
+
const MIN_STABLE_DURATION_MS = 3000;
|
| 13 |
+
|
| 14 |
+
export class SessionWebSocket {
|
| 15 |
+
constructor(sessionId, { onMessage, onStatusChange, onError, onConnect, onDisconnect } = {}) {
|
| 16 |
+
this._sessionId = sessionId;
|
| 17 |
+
this._handlers = { onMessage, onStatusChange, onError, onConnect, onDisconnect };
|
| 18 |
+
this._ws = null;
|
| 19 |
+
this._reconnectAttempt = 0;
|
| 20 |
+
this._heartbeatTimer = null;
|
| 21 |
+
this._closed = false;
|
| 22 |
+
this._connectTime = 0;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
connect() {
|
| 26 |
+
if (this._closed) return;
|
| 27 |
+
|
| 28 |
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 29 |
+
const backendUrl = import.meta.env.VITE_BACKEND_URL || '';
|
| 30 |
+
let wsUrl;
|
| 31 |
+
|
| 32 |
+
if (backendUrl) {
|
| 33 |
+
// Production: replace http(s) with ws(s)
|
| 34 |
+
wsUrl = backendUrl.replace(/^http/, 'ws') + `/ws/sessions/${this._sessionId}`;
|
| 35 |
+
} else {
|
| 36 |
+
// Dev: same host
|
| 37 |
+
wsUrl = `${protocol}//${window.location.host}/ws/sessions/${this._sessionId}`;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
this._ws = new WebSocket(wsUrl);
|
| 41 |
+
|
| 42 |
+
this._ws.onopen = () => {
|
| 43 |
+
this._connectTime = Date.now();
|
| 44 |
+
this._reconnectAttempt = 0;
|
| 45 |
+
this._startHeartbeat();
|
| 46 |
+
this._handlers.onConnect?.();
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
this._ws.onmessage = (event) => {
|
| 50 |
+
try {
|
| 51 |
+
const data = JSON.parse(event.data);
|
| 52 |
+
this._dispatch(data);
|
| 53 |
+
} catch (e) {
|
| 54 |
+
console.warn('[ws] Failed to parse message:', e);
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
this._ws.onclose = (event) => {
|
| 59 |
+
this._stopHeartbeat();
|
| 60 |
+
this._handlers.onDisconnect?.(event);
|
| 61 |
+
|
| 62 |
+
if (!this._closed) {
|
| 63 |
+
// If connection died very quickly, count it as unstable
|
| 64 |
+
const lived = Date.now() - (this._connectTime || 0);
|
| 65 |
+
if (lived < MIN_STABLE_DURATION_MS) {
|
| 66 |
+
this._reconnectAttempt++;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if (this._reconnectAttempt < MAX_RECONNECT_ATTEMPTS) {
|
| 70 |
+
this._scheduleReconnect();
|
| 71 |
+
} else {
|
| 72 |
+
console.warn('[ws] Max reconnect attempts reached, giving up.');
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
this._ws.onerror = (error) => {
|
| 78 |
+
this._handlers.onError?.(error);
|
| 79 |
+
};
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
send(data) {
|
| 83 |
+
if (this._ws?.readyState === WebSocket.OPEN) {
|
| 84 |
+
this._ws.send(JSON.stringify(data));
|
| 85 |
+
return true;
|
| 86 |
+
}
|
| 87 |
+
return false;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
sendMessage(content) {
|
| 91 |
+
return this.send({ type: 'user_message', content });
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
cancel() {
|
| 95 |
+
return this.send({ type: 'cancel' });
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
close() {
|
| 99 |
+
this._closed = true;
|
| 100 |
+
this._stopHeartbeat();
|
| 101 |
+
if (this._ws) {
|
| 102 |
+
this._ws.close();
|
| 103 |
+
this._ws = null;
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
get connected() {
|
| 108 |
+
return this._ws?.readyState === WebSocket.OPEN;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
_dispatch(data) {
|
| 112 |
+
const { type } = data;
|
| 113 |
+
|
| 114 |
+
switch (type) {
|
| 115 |
+
case 'agent_message':
|
| 116 |
+
case 'tool_use':
|
| 117 |
+
case 'tool_result':
|
| 118 |
+
case 'diff_update':
|
| 119 |
+
case 'session_restored':
|
| 120 |
+
case 'message_received':
|
| 121 |
+
this._handlers.onMessage?.(data);
|
| 122 |
+
break;
|
| 123 |
+
case 'status_change':
|
| 124 |
+
this._handlers.onStatusChange?.(data.status);
|
| 125 |
+
break;
|
| 126 |
+
case 'error':
|
| 127 |
+
this._handlers.onError?.(new Error(data.message));
|
| 128 |
+
break;
|
| 129 |
+
case 'pong':
|
| 130 |
+
break;
|
| 131 |
+
default:
|
| 132 |
+
this._handlers.onMessage?.(data);
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
_startHeartbeat() {
|
| 137 |
+
this._stopHeartbeat();
|
| 138 |
+
this._heartbeatTimer = setInterval(() => {
|
| 139 |
+
this.send({ type: 'ping' });
|
| 140 |
+
}, HEARTBEAT_INTERVAL);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
_stopHeartbeat() {
|
| 144 |
+
if (this._heartbeatTimer) {
|
| 145 |
+
clearInterval(this._heartbeatTimer);
|
| 146 |
+
this._heartbeatTimer = null;
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
_scheduleReconnect() {
|
| 151 |
+
const delay = WS_RECONNECT_DELAYS[
|
| 152 |
+
Math.min(this._reconnectAttempt, WS_RECONNECT_DELAYS.length - 1)
|
| 153 |
+
];
|
| 154 |
+
this._reconnectAttempt++;
|
| 155 |
+
setTimeout(() => this.connect(), delay);
|
| 156 |
+
}
|
| 157 |
+
}
|
frontend/vite.config.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// frontend/vite.config.js (Fixed)
|
| 2 |
+
import { defineConfig } from "vite";
|
| 3 |
+
import react from "@vitejs/plugin-react";
|
| 4 |
+
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
server: {
|
| 8 |
+
// 💡 FIX: This tells Vite to bind to 0.0.0.0,
|
| 9 |
+
// making it reachable from the Windows host in WSL.
|
| 10 |
+
port: 5173,
|
| 11 |
+
host: true, // <--- Add this line
|
| 12 |
+
// Only proxy API requests when NOT running in Vercel dev
|
| 13 |
+
// (Vercel dev handles API routing to serverless functions)
|
| 14 |
+
proxy: process.env.VERCEL ? undefined : {
|
| 15 |
+
"/api": "http://localhost:8000",
|
| 16 |
+
"/ws": {
|
| 17 |
+
target: "ws://localhost:8000",
|
| 18 |
+
ws: true
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
});
|
gitpilot/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GitPilot package."""
|
| 2 |
+
|
| 3 |
+
from .version import __version__
|
| 4 |
+
|
| 5 |
+
__all__ = ["__version__"]
|
gitpilot/__main__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Allow running gitpilot as a module: python -m gitpilot"""
|
| 2 |
+
from .cli import main
|
| 3 |
+
|
| 4 |
+
if __name__ == "__main__":
|
| 5 |
+
main()
|
gitpilot/_api_core.py
ADDED
|
@@ -0,0 +1,2382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# gitpilot/_api_core.py -- Original API module (re-exported by api.py)
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI, Query, Path as FPath, Header, HTTPException, UploadFile, File
|
| 8 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 9 |
+
from fastapi.staticfiles import StaticFiles
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from pydantic import BaseModel, Field
|
| 12 |
+
|
| 13 |
+
from .version import __version__
|
| 14 |
+
from .github_api import (
|
| 15 |
+
list_user_repos,
|
| 16 |
+
list_user_repos_paginated, # Pagination support
|
| 17 |
+
search_user_repos, # Search across all repos
|
| 18 |
+
get_repo_tree,
|
| 19 |
+
get_file,
|
| 20 |
+
put_file,
|
| 21 |
+
execution_context,
|
| 22 |
+
github_request,
|
| 23 |
+
)
|
| 24 |
+
from .github_app import check_repo_write_access
|
| 25 |
+
from .settings import AppSettings, get_settings, set_provider, update_settings, LLMProvider
|
| 26 |
+
from .agentic import (
|
| 27 |
+
generate_plan,
|
| 28 |
+
execute_plan,
|
| 29 |
+
PlanResult,
|
| 30 |
+
get_flow_definition,
|
| 31 |
+
dispatch_request,
|
| 32 |
+
create_pr_after_execution,
|
| 33 |
+
)
|
| 34 |
+
from .agent_router import route as route_request
|
| 35 |
+
from . import github_issues
|
| 36 |
+
from . import github_pulls
|
| 37 |
+
from . import github_search
|
| 38 |
+
from .session import SessionManager, Session
|
| 39 |
+
from .hooks import HookManager, HookEvent
|
| 40 |
+
from .permissions import PermissionManager, PermissionMode
|
| 41 |
+
from .memory import MemoryManager
|
| 42 |
+
from .context_vault import ContextVault
|
| 43 |
+
from .use_case import UseCaseManager
|
| 44 |
+
from .mcp_client import MCPClient
|
| 45 |
+
from .plugins import PluginManager
|
| 46 |
+
from .skills import SkillManager
|
| 47 |
+
from .smart_model_router import ModelRouter, ModelRouterConfig
|
| 48 |
+
from .topology_registry import (
|
| 49 |
+
list_topologies as _list_topologies,
|
| 50 |
+
get_topology_graph as _get_topology_graph,
|
| 51 |
+
classify_message as _classify_message,
|
| 52 |
+
get_saved_topology_preference,
|
| 53 |
+
save_topology_preference,
|
| 54 |
+
)
|
| 55 |
+
from .agent_teams import AgentTeam
|
| 56 |
+
from .learning import LearningEngine
|
| 57 |
+
from .cross_repo import CrossRepoAnalyzer
|
| 58 |
+
from .predictions import PredictiveEngine
|
| 59 |
+
from .security import SecurityScanner
|
| 60 |
+
from .nl_database import NLQueryEngine, QueryDialect, SafetyLevel, TableSchema
|
| 61 |
+
from .github_oauth import (
|
| 62 |
+
generate_authorization_url,
|
| 63 |
+
exchange_code_for_token,
|
| 64 |
+
validate_token,
|
| 65 |
+
initiate_device_flow,
|
| 66 |
+
poll_device_token,
|
| 67 |
+
AuthSession,
|
| 68 |
+
GitHubUser,
|
| 69 |
+
)
|
| 70 |
+
import os
|
| 71 |
+
import logging
|
| 72 |
+
from .model_catalog import list_models_for_provider
|
| 73 |
+
|
| 74 |
+
# Optional A2A adapter (MCP ContextForge)
|
| 75 |
+
from .a2a_adapter import router as a2a_router
|
| 76 |
+
|
| 77 |
+
logger = logging.getLogger(__name__)
|
| 78 |
+
|
| 79 |
+
# --- Phase 1 singletons ---
|
| 80 |
+
_session_mgr = SessionManager()
|
| 81 |
+
_hook_mgr = HookManager()
|
| 82 |
+
_perm_mgr = PermissionManager()
|
| 83 |
+
|
| 84 |
+
# --- Phase 2 singletons ---
|
| 85 |
+
_mcp_client = MCPClient()
|
| 86 |
+
_plugin_mgr = PluginManager()
|
| 87 |
+
_skill_mgr = SkillManager()
|
| 88 |
+
_model_router = ModelRouter()
|
| 89 |
+
|
| 90 |
+
# --- Phase 3 singletons ---
|
| 91 |
+
_agent_team = AgentTeam()
|
| 92 |
+
_learning_engine = LearningEngine()
|
| 93 |
+
_cross_repo = CrossRepoAnalyzer()
|
| 94 |
+
_predictive_engine = PredictiveEngine()
|
| 95 |
+
_security_scanner = SecurityScanner()
|
| 96 |
+
_nl_engine = NLQueryEngine()
|
| 97 |
+
|
| 98 |
+
app = FastAPI(
|
| 99 |
+
title="GitPilot API",
|
| 100 |
+
version=__version__,
|
| 101 |
+
description="Agentic AI assistant for GitHub repositories.",
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# ==========================================================================
|
| 105 |
+
# Optional A2A Adapter (MCP ContextForge)
|
| 106 |
+
# ==========================================================================
|
| 107 |
+
# This is feature-flagged and does not affect the existing UI/REST API unless
|
| 108 |
+
# explicitly enabled.
|
| 109 |
+
def _env_bool(name: str, default: bool) -> bool:
|
| 110 |
+
raw = os.getenv(name)
|
| 111 |
+
if raw is None:
|
| 112 |
+
return default
|
| 113 |
+
return raw.strip().lower() in {"1", "true", "yes", "y", "on"}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
if _env_bool("GITPILOT_ENABLE_A2A", False):
|
| 117 |
+
logger.info("A2A adapter enabled (mounting /a2a/* endpoints)")
|
| 118 |
+
app.include_router(a2a_router)
|
| 119 |
+
else:
|
| 120 |
+
logger.info("A2A adapter disabled (set GITPILOT_ENABLE_A2A=true to enable)")
|
| 121 |
+
|
| 122 |
+
# ============================================================================
|
| 123 |
+
# CORS Configuration
|
| 124 |
+
# ============================================================================
|
| 125 |
+
# Enable CORS to allow frontend (local dev or Vercel) to connect to backend
|
| 126 |
+
allowed_origins_str = os.getenv("CORS_ORIGINS", "http://localhost:5173")
|
| 127 |
+
allowed_origins = [origin.strip() for origin in allowed_origins_str.split(",")]
|
| 128 |
+
|
| 129 |
+
logger.info(f"CORS enabled for origins: {allowed_origins}")
|
| 130 |
+
|
| 131 |
+
app.add_middleware(
|
| 132 |
+
CORSMiddleware,
|
| 133 |
+
allow_origins=allowed_origins,
|
| 134 |
+
allow_credentials=True,
|
| 135 |
+
allow_methods=["*"],
|
| 136 |
+
allow_headers=["*"],
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def get_github_token(authorization: Optional[str] = Header(None)) -> Optional[str]:
|
| 141 |
+
"""
|
| 142 |
+
Extract GitHub token from Authorization header.
|
| 143 |
+
|
| 144 |
+
Supports formats:
|
| 145 |
+
- Bearer <token>
|
| 146 |
+
- token <token>
|
| 147 |
+
- <token>
|
| 148 |
+
"""
|
| 149 |
+
if not authorization:
|
| 150 |
+
return None
|
| 151 |
+
|
| 152 |
+
if authorization.startswith("Bearer "):
|
| 153 |
+
return authorization[7:]
|
| 154 |
+
elif authorization.startswith("token "):
|
| 155 |
+
return authorization[6:]
|
| 156 |
+
else:
|
| 157 |
+
return authorization
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# --- FIXED: Added default_branch to model ---
|
| 161 |
+
class RepoSummary(BaseModel):
|
| 162 |
+
id: int
|
| 163 |
+
name: str
|
| 164 |
+
full_name: str
|
| 165 |
+
private: bool
|
| 166 |
+
owner: str
|
| 167 |
+
default_branch: str = "main" # <--- CRITICAL FIX: Defaults to main, but can be master/dev
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class PaginatedReposResponse(BaseModel):
|
| 171 |
+
"""Response model for paginated repository listing."""
|
| 172 |
+
repositories: List[RepoSummary]
|
| 173 |
+
page: int
|
| 174 |
+
per_page: int
|
| 175 |
+
total_count: Optional[int] = None
|
| 176 |
+
has_more: bool
|
| 177 |
+
query: Optional[str] = None
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
class FileEntry(BaseModel):
|
| 181 |
+
path: str
|
| 182 |
+
type: str
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class FileTreeResponse(BaseModel):
|
| 186 |
+
files: List[FileEntry] = Field(default_factory=list)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
class FileContent(BaseModel):
|
| 190 |
+
path: str
|
| 191 |
+
encoding: str = "utf-8"
|
| 192 |
+
content: str
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class CommitRequest(BaseModel):
|
| 196 |
+
path: str
|
| 197 |
+
content: str
|
| 198 |
+
message: str
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
class CommitResponse(BaseModel):
|
| 202 |
+
path: str
|
| 203 |
+
commit_sha: str
|
| 204 |
+
commit_url: Optional[str] = None
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
class SettingsResponse(BaseModel):
|
| 208 |
+
provider: LLMProvider
|
| 209 |
+
providers: List[LLMProvider]
|
| 210 |
+
openai: dict
|
| 211 |
+
claude: dict
|
| 212 |
+
watsonx: dict
|
| 213 |
+
ollama: dict
|
| 214 |
+
langflow_url: str
|
| 215 |
+
has_langflow_plan_flow: bool
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
class ProviderModelsResponse(BaseModel):
|
| 219 |
+
provider: LLMProvider
|
| 220 |
+
models: List[str] = Field(default_factory=list)
|
| 221 |
+
error: Optional[str] = None
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
class ProviderUpdate(BaseModel):
|
| 225 |
+
provider: LLMProvider
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
class ChatPlanRequest(BaseModel):
|
| 229 |
+
repo_owner: str
|
| 230 |
+
repo_name: str
|
| 231 |
+
goal: str
|
| 232 |
+
branch_name: Optional[str] = None
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class ExecutePlanRequest(BaseModel):
|
| 236 |
+
repo_owner: str
|
| 237 |
+
repo_name: str
|
| 238 |
+
plan: PlanResult
|
| 239 |
+
branch_name: Optional[str] = None
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
class AuthUrlResponse(BaseModel):
|
| 243 |
+
authorization_url: str
|
| 244 |
+
state: str
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
class AuthCallbackRequest(BaseModel):
|
| 248 |
+
code: str
|
| 249 |
+
state: str
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
class TokenValidationRequest(BaseModel):
|
| 253 |
+
access_token: str
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
class UserInfoResponse(BaseModel):
|
| 257 |
+
user: GitHubUser
|
| 258 |
+
authenticated: bool
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
class RepoAccessResponse(BaseModel):
|
| 262 |
+
can_write: bool
|
| 263 |
+
app_installed: bool
|
| 264 |
+
auth_type: str
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# --- v2 Request/Response models ---
|
| 268 |
+
|
| 269 |
+
class ChatRequest(BaseModel):
|
| 270 |
+
"""Unified chat request for the conversational dispatcher."""
|
| 271 |
+
repo_owner: str
|
| 272 |
+
repo_name: str
|
| 273 |
+
message: str
|
| 274 |
+
branch_name: Optional[str] = None
|
| 275 |
+
auto_pr: bool = False
|
| 276 |
+
topology_id: Optional[str] = None # Override topology for this request
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
class IssueCreateRequest(BaseModel):
|
| 280 |
+
title: str
|
| 281 |
+
body: Optional[str] = None
|
| 282 |
+
labels: Optional[List[str]] = None
|
| 283 |
+
assignees: Optional[List[str]] = None
|
| 284 |
+
milestone: Optional[int] = None
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
class IssueUpdateRequest(BaseModel):
|
| 288 |
+
title: Optional[str] = None
|
| 289 |
+
body: Optional[str] = None
|
| 290 |
+
state: Optional[str] = None
|
| 291 |
+
labels: Optional[List[str]] = None
|
| 292 |
+
assignees: Optional[List[str]] = None
|
| 293 |
+
milestone: Optional[int] = None
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
class IssueCommentRequest(BaseModel):
|
| 297 |
+
body: str
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
class PRCreateRequest(BaseModel):
|
| 301 |
+
title: str
|
| 302 |
+
head: str
|
| 303 |
+
base: str
|
| 304 |
+
body: Optional[str] = None
|
| 305 |
+
draft: bool = False
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
class PRMergeRequest(BaseModel):
|
| 309 |
+
merge_method: str = "merge"
|
| 310 |
+
commit_title: Optional[str] = None
|
| 311 |
+
commit_message: Optional[str] = None
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
class SearchRequest(BaseModel):
|
| 315 |
+
query: str
|
| 316 |
+
per_page: int = 30
|
| 317 |
+
page: int = 1
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
# ============================================================================
|
| 321 |
+
# Repository Endpoints - Enterprise Grade with Pagination & Search
|
| 322 |
+
# ============================================================================
|
| 323 |
+
|
| 324 |
+
@app.get("/api/repos", response_model=PaginatedReposResponse)
|
| 325 |
+
async def api_list_repos(
|
| 326 |
+
query: Optional[str] = Query(None, description="Search query (searches across ALL repositories)"),
|
| 327 |
+
page: int = Query(1, ge=1, description="Page number (starts at 1)"),
|
| 328 |
+
per_page: int = Query(100, ge=1, le=100, description="Results per page (max 100)"),
|
| 329 |
+
authorization: Optional[str] = Header(None),
|
| 330 |
+
):
|
| 331 |
+
"""
|
| 332 |
+
List user repositories with enterprise-grade pagination and search.
|
| 333 |
+
Includes default_branch information for correct frontend routing.
|
| 334 |
+
"""
|
| 335 |
+
token = get_github_token(authorization)
|
| 336 |
+
|
| 337 |
+
try:
|
| 338 |
+
if query:
|
| 339 |
+
# SEARCH MODE: Search across ALL repositories
|
| 340 |
+
result = await search_user_repos(
|
| 341 |
+
query=query,
|
| 342 |
+
page=page,
|
| 343 |
+
per_page=per_page,
|
| 344 |
+
token=token
|
| 345 |
+
)
|
| 346 |
+
else:
|
| 347 |
+
# PAGINATION MODE: Return repos page by page
|
| 348 |
+
result = await list_user_repos_paginated(
|
| 349 |
+
page=page,
|
| 350 |
+
per_page=per_page,
|
| 351 |
+
token=token
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
# --- FIXED: Mapping default_branch ---
|
| 355 |
+
repos = [
|
| 356 |
+
RepoSummary(
|
| 357 |
+
id=r["id"],
|
| 358 |
+
name=r["name"],
|
| 359 |
+
full_name=r["full_name"],
|
| 360 |
+
private=r["private"],
|
| 361 |
+
owner=r["owner"],
|
| 362 |
+
default_branch=r.get("default_branch", "main"), # <--- CRITICAL FIX
|
| 363 |
+
)
|
| 364 |
+
for r in result["repositories"]
|
| 365 |
+
]
|
| 366 |
+
|
| 367 |
+
return PaginatedReposResponse(
|
| 368 |
+
repositories=repos,
|
| 369 |
+
page=result["page"],
|
| 370 |
+
per_page=result["per_page"],
|
| 371 |
+
total_count=result.get("total_count"),
|
| 372 |
+
has_more=result["has_more"],
|
| 373 |
+
query=query,
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
except Exception as e:
|
| 377 |
+
logging.exception("Error fetching repositories")
|
| 378 |
+
return JSONResponse(
|
| 379 |
+
content={
|
| 380 |
+
"error": f"Failed to fetch repositories: {str(e)}",
|
| 381 |
+
"repositories": [],
|
| 382 |
+
"page": page,
|
| 383 |
+
"per_page": per_page,
|
| 384 |
+
"has_more": False,
|
| 385 |
+
},
|
| 386 |
+
status_code=500
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
@app.get("/api/repos/all")
|
| 391 |
+
async def api_list_all_repos(
|
| 392 |
+
query: Optional[str] = Query(None, description="Search query"),
|
| 393 |
+
authorization: Optional[str] = Header(None),
|
| 394 |
+
):
|
| 395 |
+
"""
|
| 396 |
+
Fetch ALL user repositories at once (no pagination).
|
| 397 |
+
Useful for quick searches, but paginated endpoint is preferred.
|
| 398 |
+
"""
|
| 399 |
+
token = get_github_token(authorization)
|
| 400 |
+
|
| 401 |
+
try:
|
| 402 |
+
# Fetch all repositories (this will make multiple API calls)
|
| 403 |
+
all_repos = []
|
| 404 |
+
page = 1
|
| 405 |
+
max_pages = 15 # Safety limit: 1500 repos max (15 * 100)
|
| 406 |
+
|
| 407 |
+
while page <= max_pages:
|
| 408 |
+
result = await list_user_repos_paginated(
|
| 409 |
+
page=page,
|
| 410 |
+
per_page=100,
|
| 411 |
+
token=token
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
all_repos.extend(result["repositories"])
|
| 415 |
+
|
| 416 |
+
if not result["has_more"]:
|
| 417 |
+
break
|
| 418 |
+
|
| 419 |
+
page += 1
|
| 420 |
+
|
| 421 |
+
# Filter by query if provided
|
| 422 |
+
if query:
|
| 423 |
+
query_lower = query.lower()
|
| 424 |
+
all_repos = [
|
| 425 |
+
r for r in all_repos
|
| 426 |
+
if query_lower in r["name"].lower() or query_lower in r["full_name"].lower()
|
| 427 |
+
]
|
| 428 |
+
|
| 429 |
+
# --- FIXED: Mapping default_branch ---
|
| 430 |
+
repos = [
|
| 431 |
+
RepoSummary(
|
| 432 |
+
id=r["id"],
|
| 433 |
+
name=r["name"],
|
| 434 |
+
full_name=r["full_name"],
|
| 435 |
+
private=r["private"],
|
| 436 |
+
owner=r["owner"],
|
| 437 |
+
default_branch=r.get("default_branch", "main"), # <--- CRITICAL FIX
|
| 438 |
+
)
|
| 439 |
+
for r in all_repos
|
| 440 |
+
]
|
| 441 |
+
|
| 442 |
+
return {
|
| 443 |
+
"repositories": repos,
|
| 444 |
+
"total_count": len(repos),
|
| 445 |
+
"query": query,
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
except Exception as e:
|
| 449 |
+
logging.exception("Error fetching all repositories")
|
| 450 |
+
return JSONResponse(
|
| 451 |
+
content={"error": f"Failed to fetch repositories: {str(e)}"},
|
| 452 |
+
status_code=500
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
@app.get("/api/repos/{owner}/{repo}/tree", response_model=FileTreeResponse)
|
| 457 |
+
async def api_repo_tree(
|
| 458 |
+
owner: str = FPath(...),
|
| 459 |
+
repo: str = FPath(...),
|
| 460 |
+
ref: Optional[str] = Query(
|
| 461 |
+
None,
|
| 462 |
+
description="Git reference (branch, tag, or commit SHA). If omitted, defaults to HEAD.",
|
| 463 |
+
),
|
| 464 |
+
authorization: Optional[str] = Header(None),
|
| 465 |
+
):
|
| 466 |
+
"""
|
| 467 |
+
Get the file tree for a repository.
|
| 468 |
+
Handles 'main' vs 'master' discrepancies and empty repositories gracefully.
|
| 469 |
+
"""
|
| 470 |
+
token = get_github_token(authorization)
|
| 471 |
+
|
| 472 |
+
# Keep legacy behavior: missing/empty ref behaves like HEAD.
|
| 473 |
+
ref_value = (ref or "").strip() or "HEAD"
|
| 474 |
+
|
| 475 |
+
try:
|
| 476 |
+
tree = await get_repo_tree(owner, repo, token=token, ref=ref_value)
|
| 477 |
+
return FileTreeResponse(files=[FileEntry(**f) for f in tree])
|
| 478 |
+
|
| 479 |
+
except HTTPException as e:
|
| 480 |
+
if e.status_code == 409:
|
| 481 |
+
return FileTreeResponse(files=[])
|
| 482 |
+
|
| 483 |
+
if e.status_code == 404:
|
| 484 |
+
return JSONResponse(
|
| 485 |
+
status_code=404,
|
| 486 |
+
content={
|
| 487 |
+
"detail": f"Ref '{ref_value}' not found. The repository might be using a different default branch (e.g., 'master')."
|
| 488 |
+
}
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
+
raise e
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
@app.get("/api/repos/{owner}/{repo}/file", response_model=FileContent)
|
| 495 |
+
async def api_get_file(
|
| 496 |
+
owner: str = FPath(...),
|
| 497 |
+
repo: str = FPath(...),
|
| 498 |
+
path: str = Query(...),
|
| 499 |
+
authorization: Optional[str] = Header(None),
|
| 500 |
+
):
|
| 501 |
+
token = get_github_token(authorization)
|
| 502 |
+
content = await get_file(owner, repo, path, token=token)
|
| 503 |
+
return FileContent(path=path, content=content)
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
@app.post("/api/repos/{owner}/{repo}/file", response_model=CommitResponse)
|
| 507 |
+
async def api_put_file(
|
| 508 |
+
owner: str = FPath(...),
|
| 509 |
+
repo: str = FPath(...),
|
| 510 |
+
payload: CommitRequest = ...,
|
| 511 |
+
authorization: Optional[str] = Header(None),
|
| 512 |
+
):
|
| 513 |
+
token = get_github_token(authorization)
|
| 514 |
+
result = await put_file(
|
| 515 |
+
owner, repo, payload.path, payload.content, payload.message, token=token
|
| 516 |
+
)
|
| 517 |
+
return CommitResponse(**result)
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
# ============================================================================
|
| 521 |
+
# Settings Endpoints
|
| 522 |
+
# ============================================================================
|
| 523 |
+
|
| 524 |
+
@app.get("/api/settings", response_model=SettingsResponse)
|
| 525 |
+
async def api_get_settings():
|
| 526 |
+
s: AppSettings = get_settings()
|
| 527 |
+
return SettingsResponse(
|
| 528 |
+
provider=s.provider,
|
| 529 |
+
providers=[LLMProvider.openai, LLMProvider.claude, LLMProvider.watsonx, LLMProvider.ollama],
|
| 530 |
+
openai=s.openai.model_dump(),
|
| 531 |
+
claude=s.claude.model_dump(),
|
| 532 |
+
watsonx=s.watsonx.model_dump(),
|
| 533 |
+
ollama=s.ollama.model_dump(),
|
| 534 |
+
langflow_url=s.langflow_url,
|
| 535 |
+
has_langflow_plan_flow=bool(s.langflow_plan_flow_id),
|
| 536 |
+
)
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
@app.get("/api/settings/models", response_model=ProviderModelsResponse)
|
| 540 |
+
async def api_list_models(provider: Optional[LLMProvider] = Query(None)):
|
| 541 |
+
"""
|
| 542 |
+
Return the list of LLM models available for a provider.
|
| 543 |
+
|
| 544 |
+
If 'provider' is not given, use the currently active provider from settings.
|
| 545 |
+
"""
|
| 546 |
+
s: AppSettings = get_settings()
|
| 547 |
+
effective_provider = provider or s.provider
|
| 548 |
+
|
| 549 |
+
models, error = list_models_for_provider(effective_provider, s)
|
| 550 |
+
|
| 551 |
+
return ProviderModelsResponse(
|
| 552 |
+
provider=effective_provider,
|
| 553 |
+
models=models,
|
| 554 |
+
error=error,
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
@app.post("/api/settings/provider", response_model=SettingsResponse)
|
| 559 |
+
async def api_set_provider(update: ProviderUpdate):
|
| 560 |
+
s = set_provider(update.provider)
|
| 561 |
+
return SettingsResponse(
|
| 562 |
+
provider=s.provider,
|
| 563 |
+
providers=[LLMProvider.openai, LLMProvider.claude, LLMProvider.watsonx, LLMProvider.ollama],
|
| 564 |
+
openai=s.openai.model_dump(),
|
| 565 |
+
claude=s.claude.model_dump(),
|
| 566 |
+
watsonx=s.watsonx.model_dump(),
|
| 567 |
+
ollama=s.ollama.model_dump(),
|
| 568 |
+
langflow_url=s.langflow_url,
|
| 569 |
+
has_langflow_plan_flow=bool(s.langflow_plan_flow_id),
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
@app.put("/api/settings/llm", response_model=SettingsResponse)
|
| 574 |
+
async def api_update_llm_settings(updates: dict):
|
| 575 |
+
"""Update full LLM settings including provider-specific configs."""
|
| 576 |
+
s = update_settings(updates)
|
| 577 |
+
return SettingsResponse(
|
| 578 |
+
provider=s.provider,
|
| 579 |
+
providers=[LLMProvider.openai, LLMProvider.claude, LLMProvider.watsonx, LLMProvider.ollama],
|
| 580 |
+
openai=s.openai.model_dump(),
|
| 581 |
+
claude=s.claude.model_dump(),
|
| 582 |
+
watsonx=s.watsonx.model_dump(),
|
| 583 |
+
ollama=s.ollama.model_dump(),
|
| 584 |
+
langflow_url=s.langflow_url,
|
| 585 |
+
has_langflow_plan_flow=bool(s.langflow_plan_flow_id),
|
| 586 |
+
)
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
# ============================================================================
|
| 590 |
+
# Chat Endpoints
|
| 591 |
+
# ============================================================================
|
| 592 |
+
|
| 593 |
+
@app.post("/api/chat/plan", response_model=PlanResult)
|
| 594 |
+
async def api_chat_plan(req: ChatPlanRequest, authorization: Optional[str] = Header(None)):
|
| 595 |
+
token = get_github_token(authorization)
|
| 596 |
+
|
| 597 |
+
# ✅ Added logging for branch_name received
|
| 598 |
+
logger.info(
|
| 599 |
+
"PLAN REQUEST: %s/%s | branch_name=%r",
|
| 600 |
+
req.repo_owner,
|
| 601 |
+
req.repo_name,
|
| 602 |
+
req.branch_name,
|
| 603 |
+
)
|
| 604 |
+
|
| 605 |
+
with execution_context(token, ref=req.branch_name): # ✅ set ref context
|
| 606 |
+
full_name = f"{req.repo_owner}/{req.repo_name}"
|
| 607 |
+
plan = await generate_plan(req.goal, full_name, token=token, branch_name=req.branch_name)
|
| 608 |
+
return plan
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
@app.post("/api/chat/execute")
|
| 612 |
+
async def api_chat_execute(
|
| 613 |
+
req: ExecutePlanRequest,
|
| 614 |
+
authorization: Optional[str] = Header(None)
|
| 615 |
+
):
|
| 616 |
+
token = get_github_token(authorization)
|
| 617 |
+
|
| 618 |
+
# ✅ FIX: use execution_context(token, ref=req.branch_name) so tool calls that rely on context
|
| 619 |
+
# never accidentally run on HEAD/default when branch_name is provided.
|
| 620 |
+
with execution_context(token, ref=req.branch_name):
|
| 621 |
+
full_name = f"{req.repo_owner}/{req.repo_name}"
|
| 622 |
+
result = await execute_plan(
|
| 623 |
+
req.plan, full_name, token=token, branch_name=req.branch_name
|
| 624 |
+
)
|
| 625 |
+
if isinstance(result, dict):
|
| 626 |
+
result.setdefault(
|
| 627 |
+
"mode",
|
| 628 |
+
"sticky" if req.branch_name else "hard-switch",
|
| 629 |
+
)
|
| 630 |
+
return result
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
@app.get("/api/flow/current")
|
| 634 |
+
async def api_get_flow(topology: Optional[str] = Query(None)):
|
| 635 |
+
"""Return the agent flow definition as a graph.
|
| 636 |
+
|
| 637 |
+
If ``topology`` query param is provided, returns the graph for that
|
| 638 |
+
topology. Otherwise falls back to the user's saved preference, and
|
| 639 |
+
finally to the legacy ``get_flow_definition()`` output for full
|
| 640 |
+
backward compatibility.
|
| 641 |
+
"""
|
| 642 |
+
tid = topology or get_saved_topology_preference()
|
| 643 |
+
if tid:
|
| 644 |
+
return _get_topology_graph(tid)
|
| 645 |
+
# Legacy path — returns the original hardcoded graph
|
| 646 |
+
flow = await get_flow_definition()
|
| 647 |
+
return flow
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
# ============================================================================
|
| 651 |
+
# Topology Registry Endpoints (additive — no existing behaviour changed)
|
| 652 |
+
# ============================================================================
|
| 653 |
+
|
| 654 |
+
@app.get("/api/flow/topologies")
|
| 655 |
+
async def api_list_topologies():
|
| 656 |
+
"""Return lightweight summaries of all available topology presets."""
|
| 657 |
+
return _list_topologies()
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
@app.get("/api/flow/topology/{topology_id}")
|
| 661 |
+
async def api_get_topology(topology_id: str):
|
| 662 |
+
"""Return the full flow graph for a specific topology."""
|
| 663 |
+
return _get_topology_graph(topology_id)
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
class ClassifyRequest(BaseModel):
|
| 667 |
+
message: str
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
@app.post("/api/flow/classify")
|
| 671 |
+
async def api_classify_message(req: ClassifyRequest):
|
| 672 |
+
"""Auto-detect the best topology for a given user message.
|
| 673 |
+
|
| 674 |
+
Returns the recommended topology, confidence score, and up to 4
|
| 675 |
+
alternatives ranked by relevance.
|
| 676 |
+
"""
|
| 677 |
+
result = _classify_message(req.message)
|
| 678 |
+
return result.to_dict()
|
| 679 |
+
|
| 680 |
+
|
| 681 |
+
class TopologyPrefRequest(BaseModel):
|
| 682 |
+
topology: str
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
@app.get("/api/settings/topology")
|
| 686 |
+
async def api_get_topology_pref():
|
| 687 |
+
"""Return the user's saved topology preference (or null)."""
|
| 688 |
+
pref = get_saved_topology_preference()
|
| 689 |
+
return {"topology": pref}
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
@app.post("/api/settings/topology")
|
| 693 |
+
async def api_set_topology_pref(req: TopologyPrefRequest):
|
| 694 |
+
"""Save the user's preferred topology."""
|
| 695 |
+
save_topology_preference(req.topology)
|
| 696 |
+
return {"status": "ok", "topology": req.topology}
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
# ============================================================================
|
| 700 |
+
# Conversational Chat Endpoint (v2 upgrade)
|
| 701 |
+
# ============================================================================
|
| 702 |
+
|
| 703 |
+
@app.post("/api/chat/message")
|
| 704 |
+
async def api_chat_message(req: ChatRequest, authorization: Optional[str] = Header(None)):
|
| 705 |
+
"""
|
| 706 |
+
Unified conversational endpoint. The router analyses the message and
|
| 707 |
+
dispatches to the appropriate agent (issue, PR, search, review, learning,
|
| 708 |
+
or the existing plan+execute pipeline).
|
| 709 |
+
"""
|
| 710 |
+
token = get_github_token(authorization)
|
| 711 |
+
|
| 712 |
+
logger.info(
|
| 713 |
+
"CHAT MESSAGE: %s/%s | message=%r | branch=%r",
|
| 714 |
+
req.repo_owner,
|
| 715 |
+
req.repo_name,
|
| 716 |
+
req.message[:80],
|
| 717 |
+
req.branch_name,
|
| 718 |
+
)
|
| 719 |
+
|
| 720 |
+
with execution_context(token, ref=req.branch_name):
|
| 721 |
+
full_name = f"{req.repo_owner}/{req.repo_name}"
|
| 722 |
+
result = await dispatch_request(
|
| 723 |
+
req.message, full_name, token=token, branch_name=req.branch_name,
|
| 724 |
+
topology_id=req.topology_id,
|
| 725 |
+
)
|
| 726 |
+
|
| 727 |
+
# If auto_pr is requested and execution completed, create PR
|
| 728 |
+
if (
|
| 729 |
+
req.auto_pr
|
| 730 |
+
and isinstance(result, dict)
|
| 731 |
+
and result.get("category") == "plan_execute"
|
| 732 |
+
and result.get("plan")
|
| 733 |
+
):
|
| 734 |
+
result["auto_pr_hint"] = (
|
| 735 |
+
"Plan generated. Execute it first, then auto-PR will be created."
|
| 736 |
+
)
|
| 737 |
+
|
| 738 |
+
return result
|
| 739 |
+
|
| 740 |
+
|
| 741 |
+
@app.post("/api/chat/execute-with-pr")
|
| 742 |
+
async def api_chat_execute_with_pr(
|
| 743 |
+
req: ExecutePlanRequest,
|
| 744 |
+
authorization: Optional[str] = Header(None),
|
| 745 |
+
):
|
| 746 |
+
"""Execute a plan AND automatically create a pull request afterwards."""
|
| 747 |
+
token = get_github_token(authorization)
|
| 748 |
+
|
| 749 |
+
with execution_context(token, ref=req.branch_name):
|
| 750 |
+
full_name = f"{req.repo_owner}/{req.repo_name}"
|
| 751 |
+
result = await execute_plan(
|
| 752 |
+
req.plan, full_name, token=token, branch_name=req.branch_name,
|
| 753 |
+
)
|
| 754 |
+
|
| 755 |
+
if isinstance(result, dict) and result.get("status") == "completed":
|
| 756 |
+
branch = result.get("branch", req.branch_name)
|
| 757 |
+
if branch:
|
| 758 |
+
pr = await create_pr_after_execution(
|
| 759 |
+
full_name,
|
| 760 |
+
branch,
|
| 761 |
+
req.plan.goal,
|
| 762 |
+
result.get("executionLog", {}),
|
| 763 |
+
token=token,
|
| 764 |
+
)
|
| 765 |
+
if pr:
|
| 766 |
+
result["pull_request"] = {
|
| 767 |
+
"number": pr.get("number"),
|
| 768 |
+
"url": pr.get("html_url"),
|
| 769 |
+
"title": pr.get("title"),
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
result.setdefault(
|
| 773 |
+
"mode",
|
| 774 |
+
"sticky" if req.branch_name else "hard-switch",
|
| 775 |
+
)
|
| 776 |
+
|
| 777 |
+
return result
|
| 778 |
+
|
| 779 |
+
|
| 780 |
+
# ============================================================================
|
| 781 |
+
# Issue Endpoints (v2 upgrade)
|
| 782 |
+
# ============================================================================
|
| 783 |
+
|
| 784 |
+
@app.get("/api/repos/{owner}/{repo}/issues")
|
| 785 |
+
async def api_list_issues(
|
| 786 |
+
owner: str = FPath(...),
|
| 787 |
+
repo: str = FPath(...),
|
| 788 |
+
state: str = Query("open"),
|
| 789 |
+
labels: Optional[str] = Query(None),
|
| 790 |
+
per_page: int = Query(30, ge=1, le=100),
|
| 791 |
+
page: int = Query(1, ge=1),
|
| 792 |
+
authorization: Optional[str] = Header(None),
|
| 793 |
+
):
|
| 794 |
+
"""List issues for a repository."""
|
| 795 |
+
token = get_github_token(authorization)
|
| 796 |
+
issues = await github_issues.list_issues(
|
| 797 |
+
owner, repo, state=state, labels=labels,
|
| 798 |
+
per_page=per_page, page=page, token=token,
|
| 799 |
+
)
|
| 800 |
+
return {"issues": issues, "page": page, "per_page": per_page}
|
| 801 |
+
|
| 802 |
+
|
| 803 |
+
@app.get("/api/repos/{owner}/{repo}/issues/{issue_number}")
|
| 804 |
+
async def api_get_issue(
|
| 805 |
+
owner: str = FPath(...),
|
| 806 |
+
repo: str = FPath(...),
|
| 807 |
+
issue_number: int = FPath(...),
|
| 808 |
+
authorization: Optional[str] = Header(None),
|
| 809 |
+
):
|
| 810 |
+
"""Get a single issue."""
|
| 811 |
+
token = get_github_token(authorization)
|
| 812 |
+
return await github_issues.get_issue(owner, repo, issue_number, token=token)
|
| 813 |
+
|
| 814 |
+
|
| 815 |
+
@app.post("/api/repos/{owner}/{repo}/issues")
|
| 816 |
+
async def api_create_issue(
|
| 817 |
+
owner: str = FPath(...),
|
| 818 |
+
repo: str = FPath(...),
|
| 819 |
+
payload: IssueCreateRequest = ...,
|
| 820 |
+
authorization: Optional[str] = Header(None),
|
| 821 |
+
):
|
| 822 |
+
"""Create a new issue."""
|
| 823 |
+
token = get_github_token(authorization)
|
| 824 |
+
return await github_issues.create_issue(
|
| 825 |
+
owner, repo, payload.title,
|
| 826 |
+
body=payload.body, labels=payload.labels,
|
| 827 |
+
assignees=payload.assignees, milestone=payload.milestone,
|
| 828 |
+
token=token,
|
| 829 |
+
)
|
| 830 |
+
|
| 831 |
+
|
| 832 |
+
@app.patch("/api/repos/{owner}/{repo}/issues/{issue_number}")
|
| 833 |
+
async def api_update_issue(
|
| 834 |
+
owner: str = FPath(...),
|
| 835 |
+
repo: str = FPath(...),
|
| 836 |
+
issue_number: int = FPath(...),
|
| 837 |
+
payload: IssueUpdateRequest = ...,
|
| 838 |
+
authorization: Optional[str] = Header(None),
|
| 839 |
+
):
|
| 840 |
+
"""Update an existing issue."""
|
| 841 |
+
token = get_github_token(authorization)
|
| 842 |
+
return await github_issues.update_issue(
|
| 843 |
+
owner, repo, issue_number,
|
| 844 |
+
title=payload.title, body=payload.body, state=payload.state,
|
| 845 |
+
labels=payload.labels, assignees=payload.assignees,
|
| 846 |
+
milestone=payload.milestone, token=token,
|
| 847 |
+
)
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
@app.get("/api/repos/{owner}/{repo}/issues/{issue_number}/comments")
|
| 851 |
+
async def api_list_issue_comments(
|
| 852 |
+
owner: str = FPath(...),
|
| 853 |
+
repo: str = FPath(...),
|
| 854 |
+
issue_number: int = FPath(...),
|
| 855 |
+
authorization: Optional[str] = Header(None),
|
| 856 |
+
):
|
| 857 |
+
"""List comments on an issue."""
|
| 858 |
+
token = get_github_token(authorization)
|
| 859 |
+
return await github_issues.list_issue_comments(owner, repo, issue_number, token=token)
|
| 860 |
+
|
| 861 |
+
|
| 862 |
+
@app.post("/api/repos/{owner}/{repo}/issues/{issue_number}/comments")
|
| 863 |
+
async def api_add_issue_comment(
|
| 864 |
+
owner: str = FPath(...),
|
| 865 |
+
repo: str = FPath(...),
|
| 866 |
+
issue_number: int = FPath(...),
|
| 867 |
+
payload: IssueCommentRequest = ...,
|
| 868 |
+
authorization: Optional[str] = Header(None),
|
| 869 |
+
):
|
| 870 |
+
"""Add a comment to an issue."""
|
| 871 |
+
token = get_github_token(authorization)
|
| 872 |
+
return await github_issues.add_issue_comment(
|
| 873 |
+
owner, repo, issue_number, payload.body, token=token,
|
| 874 |
+
)
|
| 875 |
+
|
| 876 |
+
|
| 877 |
+
# ============================================================================
|
| 878 |
+
# Pull Request Endpoints (v2 upgrade)
|
| 879 |
+
# ============================================================================
|
| 880 |
+
|
| 881 |
+
@app.get("/api/repos/{owner}/{repo}/pulls")
|
| 882 |
+
async def api_list_pulls(
|
| 883 |
+
owner: str = FPath(...),
|
| 884 |
+
repo: str = FPath(...),
|
| 885 |
+
state: str = Query("open"),
|
| 886 |
+
per_page: int = Query(30, ge=1, le=100),
|
| 887 |
+
page: int = Query(1, ge=1),
|
| 888 |
+
authorization: Optional[str] = Header(None),
|
| 889 |
+
):
|
| 890 |
+
"""List pull requests."""
|
| 891 |
+
token = get_github_token(authorization)
|
| 892 |
+
prs = await github_pulls.list_pull_requests(
|
| 893 |
+
owner, repo, state=state, per_page=per_page, page=page, token=token,
|
| 894 |
+
)
|
| 895 |
+
return {"pull_requests": prs, "page": page, "per_page": per_page}
|
| 896 |
+
|
| 897 |
+
|
| 898 |
+
@app.get("/api/repos/{owner}/{repo}/pulls/{pull_number}")
|
| 899 |
+
async def api_get_pull(
|
| 900 |
+
owner: str = FPath(...),
|
| 901 |
+
repo: str = FPath(...),
|
| 902 |
+
pull_number: int = FPath(...),
|
| 903 |
+
authorization: Optional[str] = Header(None),
|
| 904 |
+
):
|
| 905 |
+
"""Get a single pull request."""
|
| 906 |
+
token = get_github_token(authorization)
|
| 907 |
+
return await github_pulls.get_pull_request(owner, repo, pull_number, token=token)
|
| 908 |
+
|
| 909 |
+
|
| 910 |
+
@app.post("/api/repos/{owner}/{repo}/pulls")
|
| 911 |
+
async def api_create_pull(
|
| 912 |
+
owner: str = FPath(...),
|
| 913 |
+
repo: str = FPath(...),
|
| 914 |
+
payload: PRCreateRequest = ...,
|
| 915 |
+
authorization: Optional[str] = Header(None),
|
| 916 |
+
):
|
| 917 |
+
"""Create a new pull request."""
|
| 918 |
+
token = get_github_token(authorization)
|
| 919 |
+
return await github_pulls.create_pull_request(
|
| 920 |
+
owner, repo, title=payload.title, head=payload.head,
|
| 921 |
+
base=payload.base, body=payload.body, draft=payload.draft,
|
| 922 |
+
token=token,
|
| 923 |
+
)
|
| 924 |
+
|
| 925 |
+
|
| 926 |
+
@app.put("/api/repos/{owner}/{repo}/pulls/{pull_number}/merge")
|
| 927 |
+
async def api_merge_pull(
|
| 928 |
+
owner: str = FPath(...),
|
| 929 |
+
repo: str = FPath(...),
|
| 930 |
+
pull_number: int = FPath(...),
|
| 931 |
+
payload: PRMergeRequest = ...,
|
| 932 |
+
authorization: Optional[str] = Header(None),
|
| 933 |
+
):
|
| 934 |
+
"""Merge a pull request."""
|
| 935 |
+
token = get_github_token(authorization)
|
| 936 |
+
return await github_pulls.merge_pull_request(
|
| 937 |
+
owner, repo, pull_number,
|
| 938 |
+
merge_method=payload.merge_method,
|
| 939 |
+
commit_title=payload.commit_title,
|
| 940 |
+
commit_message=payload.commit_message,
|
| 941 |
+
token=token,
|
| 942 |
+
)
|
| 943 |
+
|
| 944 |
+
|
| 945 |
+
@app.get("/api/repos/{owner}/{repo}/pulls/{pull_number}/files")
|
| 946 |
+
async def api_list_pr_files(
|
| 947 |
+
owner: str = FPath(...),
|
| 948 |
+
repo: str = FPath(...),
|
| 949 |
+
pull_number: int = FPath(...),
|
| 950 |
+
authorization: Optional[str] = Header(None),
|
| 951 |
+
):
|
| 952 |
+
"""List files changed in a pull request."""
|
| 953 |
+
token = get_github_token(authorization)
|
| 954 |
+
return await github_pulls.list_pr_files(owner, repo, pull_number, token=token)
|
| 955 |
+
|
| 956 |
+
|
| 957 |
+
# ============================================================================
|
| 958 |
+
# Search Endpoints (v2 upgrade)
|
| 959 |
+
# ============================================================================
|
| 960 |
+
|
| 961 |
+
@app.get("/api/search/code")
|
| 962 |
+
async def api_search_code(
|
| 963 |
+
q: str = Query(..., description="Search query"),
|
| 964 |
+
owner: Optional[str] = Query(None),
|
| 965 |
+
repo: Optional[str] = Query(None),
|
| 966 |
+
language: Optional[str] = Query(None),
|
| 967 |
+
per_page: int = Query(30, ge=1, le=100),
|
| 968 |
+
page: int = Query(1, ge=1),
|
| 969 |
+
authorization: Optional[str] = Header(None),
|
| 970 |
+
):
|
| 971 |
+
"""Search for code across GitHub."""
|
| 972 |
+
token = get_github_token(authorization)
|
| 973 |
+
return await github_search.search_code(
|
| 974 |
+
q, owner=owner, repo=repo, language=language,
|
| 975 |
+
per_page=per_page, page=page, token=token,
|
| 976 |
+
)
|
| 977 |
+
|
| 978 |
+
|
| 979 |
+
@app.get("/api/search/issues")
|
| 980 |
+
async def api_search_issues(
|
| 981 |
+
q: str = Query(..., description="Search query"),
|
| 982 |
+
owner: Optional[str] = Query(None),
|
| 983 |
+
repo: Optional[str] = Query(None),
|
| 984 |
+
state: Optional[str] = Query(None),
|
| 985 |
+
label: Optional[str] = Query(None),
|
| 986 |
+
per_page: int = Query(30, ge=1, le=100),
|
| 987 |
+
page: int = Query(1, ge=1),
|
| 988 |
+
authorization: Optional[str] = Header(None),
|
| 989 |
+
):
|
| 990 |
+
"""Search issues and pull requests."""
|
| 991 |
+
token = get_github_token(authorization)
|
| 992 |
+
return await github_search.search_issues(
|
| 993 |
+
q, owner=owner, repo=repo, state=state, label=label,
|
| 994 |
+
per_page=per_page, page=page, token=token,
|
| 995 |
+
)
|
| 996 |
+
|
| 997 |
+
|
| 998 |
+
@app.get("/api/search/repositories")
|
| 999 |
+
async def api_search_repositories(
|
| 1000 |
+
q: str = Query(..., description="Search query"),
|
| 1001 |
+
language: Optional[str] = Query(None),
|
| 1002 |
+
sort: Optional[str] = Query(None),
|
| 1003 |
+
per_page: int = Query(30, ge=1, le=100),
|
| 1004 |
+
page: int = Query(1, ge=1),
|
| 1005 |
+
authorization: Optional[str] = Header(None),
|
| 1006 |
+
):
|
| 1007 |
+
"""Search for repositories."""
|
| 1008 |
+
token = get_github_token(authorization)
|
| 1009 |
+
return await github_search.search_repositories(
|
| 1010 |
+
q, language=language, sort=sort,
|
| 1011 |
+
per_page=per_page, page=page, token=token,
|
| 1012 |
+
)
|
| 1013 |
+
|
| 1014 |
+
|
| 1015 |
+
@app.get("/api/search/users")
|
| 1016 |
+
async def api_search_users(
|
| 1017 |
+
q: str = Query(..., description="Search query"),
|
| 1018 |
+
type_filter: Optional[str] = Query(None, alias="type"),
|
| 1019 |
+
location: Optional[str] = Query(None),
|
| 1020 |
+
language: Optional[str] = Query(None),
|
| 1021 |
+
per_page: int = Query(30, ge=1, le=100),
|
| 1022 |
+
page: int = Query(1, ge=1),
|
| 1023 |
+
authorization: Optional[str] = Header(None),
|
| 1024 |
+
):
|
| 1025 |
+
"""Search for GitHub users and organizations."""
|
| 1026 |
+
token = get_github_token(authorization)
|
| 1027 |
+
return await github_search.search_users(
|
| 1028 |
+
q, type_filter=type_filter, location=location, language=language,
|
| 1029 |
+
per_page=per_page, page=page, token=token,
|
| 1030 |
+
)
|
| 1031 |
+
|
| 1032 |
+
|
| 1033 |
+
# ============================================================================
|
| 1034 |
+
# Route Analysis Endpoint (v2 upgrade)
|
| 1035 |
+
# ============================================================================
|
| 1036 |
+
|
| 1037 |
+
@app.post("/api/chat/route")
|
| 1038 |
+
async def api_chat_route(payload: dict):
|
| 1039 |
+
"""Preview how a message would be routed without executing it.
|
| 1040 |
+
|
| 1041 |
+
Useful for the frontend to display which agent(s) will handle the request.
|
| 1042 |
+
"""
|
| 1043 |
+
message = payload.get("message", "")
|
| 1044 |
+
if not message:
|
| 1045 |
+
return JSONResponse({"error": "message is required"}, status_code=400)
|
| 1046 |
+
|
| 1047 |
+
workflow = route_request(message)
|
| 1048 |
+
return {
|
| 1049 |
+
"category": workflow.category.value,
|
| 1050 |
+
"agents": [a.value for a in workflow.agents],
|
| 1051 |
+
"description": workflow.description,
|
| 1052 |
+
"requires_repo_context": workflow.requires_repo_context,
|
| 1053 |
+
"entity_number": workflow.entity_number,
|
| 1054 |
+
"metadata": workflow.metadata,
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
|
| 1058 |
+
# ============================================================================
|
| 1059 |
+
# Authentication Endpoints (Web Flow + Device Flow)
|
| 1060 |
+
# ============================================================================
|
| 1061 |
+
|
| 1062 |
+
@app.get("/api/auth/url", response_model=AuthUrlResponse)
|
| 1063 |
+
async def api_get_auth_url():
|
| 1064 |
+
"""
|
| 1065 |
+
Generate GitHub OAuth authorization URL (Web Flow).
|
| 1066 |
+
Requires Client Secret to be configured.
|
| 1067 |
+
"""
|
| 1068 |
+
auth_url, state = generate_authorization_url()
|
| 1069 |
+
return AuthUrlResponse(authorization_url=auth_url, state=state)
|
| 1070 |
+
|
| 1071 |
+
|
| 1072 |
+
@app.post("/api/auth/callback", response_model=AuthSession)
|
| 1073 |
+
async def api_auth_callback(request: AuthCallbackRequest):
|
| 1074 |
+
"""
|
| 1075 |
+
Handle GitHub OAuth callback (Web Flow).
|
| 1076 |
+
Exchange the authorization code for an access token.
|
| 1077 |
+
"""
|
| 1078 |
+
try:
|
| 1079 |
+
session = await exchange_code_for_token(request.code, request.state)
|
| 1080 |
+
return session
|
| 1081 |
+
except ValueError as e:
|
| 1082 |
+
return JSONResponse(
|
| 1083 |
+
{"error": str(e)},
|
| 1084 |
+
status_code=400,
|
| 1085 |
+
)
|
| 1086 |
+
|
| 1087 |
+
|
| 1088 |
+
@app.post("/api/auth/validate", response_model=UserInfoResponse)
|
| 1089 |
+
async def api_validate_token(request: TokenValidationRequest):
|
| 1090 |
+
"""
|
| 1091 |
+
Validate a GitHub access token and return user information.
|
| 1092 |
+
"""
|
| 1093 |
+
user = await validate_token(request.access_token)
|
| 1094 |
+
if user:
|
| 1095 |
+
return UserInfoResponse(user=user, authenticated=True)
|
| 1096 |
+
return UserInfoResponse(
|
| 1097 |
+
user=GitHubUser(login="", id=0, avatar_url=""),
|
| 1098 |
+
authenticated=False,
|
| 1099 |
+
)
|
| 1100 |
+
|
| 1101 |
+
|
| 1102 |
+
@app.post("/api/auth/device/code")
|
| 1103 |
+
async def api_device_code():
|
| 1104 |
+
"""
|
| 1105 |
+
Start the device login flow (Step 1).
|
| 1106 |
+
Does NOT require a client secret.
|
| 1107 |
+
"""
|
| 1108 |
+
try:
|
| 1109 |
+
data = await initiate_device_flow()
|
| 1110 |
+
return data
|
| 1111 |
+
except Exception as e:
|
| 1112 |
+
return JSONResponse({"error": str(e)}, status_code=500)
|
| 1113 |
+
|
| 1114 |
+
|
| 1115 |
+
@app.post("/api/auth/device/poll")
|
| 1116 |
+
async def api_device_poll(payload: dict):
|
| 1117 |
+
"""
|
| 1118 |
+
Poll GitHub to check if user authorized the device (Step 2).
|
| 1119 |
+
"""
|
| 1120 |
+
device_code = payload.get("device_code")
|
| 1121 |
+
if not device_code:
|
| 1122 |
+
return JSONResponse({"error": "Missing device_code"}, status_code=400)
|
| 1123 |
+
|
| 1124 |
+
try:
|
| 1125 |
+
session = await poll_device_token(device_code)
|
| 1126 |
+
if session:
|
| 1127 |
+
return session
|
| 1128 |
+
|
| 1129 |
+
return JSONResponse({"status": "pending"}, status_code=202)
|
| 1130 |
+
except ValueError as e:
|
| 1131 |
+
return JSONResponse({"error": str(e)}, status_code=400)
|
| 1132 |
+
|
| 1133 |
+
|
| 1134 |
+
@app.get("/api/auth/status")
|
| 1135 |
+
async def api_auth_status():
|
| 1136 |
+
"""
|
| 1137 |
+
Smart check: Do we have a secret (Web Flow) or just ID (Device Flow)?
|
| 1138 |
+
This tells the frontend which UI to render.
|
| 1139 |
+
"""
|
| 1140 |
+
has_secret = bool(os.getenv("GITHUB_CLIENT_SECRET"))
|
| 1141 |
+
has_id = bool(os.getenv("GITHUB_CLIENT_ID", "Iv23litmRp80Z6wmlyRn"))
|
| 1142 |
+
|
| 1143 |
+
return {
|
| 1144 |
+
"mode": "web" if has_secret else "device",
|
| 1145 |
+
"configured": has_id,
|
| 1146 |
+
"oauth_configured": has_secret,
|
| 1147 |
+
"pat_configured": bool(os.getenv("GITPILOT_GITHUB_TOKEN") or os.getenv("GITHUB_TOKEN")),
|
| 1148 |
+
}
|
| 1149 |
+
|
| 1150 |
+
|
| 1151 |
+
@app.get("/api/auth/app-url")
|
| 1152 |
+
async def api_get_app_url():
|
| 1153 |
+
"""Get GitHub App installation URL."""
|
| 1154 |
+
app_slug = os.getenv("GITHUB_APP_SLUG", "gitpilota")
|
| 1155 |
+
app_url = f"https://github.com/apps/{app_slug}"
|
| 1156 |
+
return {
|
| 1157 |
+
"app_url": app_url,
|
| 1158 |
+
"app_slug": app_slug,
|
| 1159 |
+
}
|
| 1160 |
+
|
| 1161 |
+
|
| 1162 |
+
@app.get("/api/auth/installation-status")
|
| 1163 |
+
async def api_check_installation_status():
|
| 1164 |
+
"""Check if GitHub App is installed for the current user."""
|
| 1165 |
+
pat_token = os.getenv("GITPILOT_GITHUB_TOKEN") or os.getenv("GITHUB_TOKEN")
|
| 1166 |
+
|
| 1167 |
+
if pat_token:
|
| 1168 |
+
user = await validate_token(pat_token)
|
| 1169 |
+
if user:
|
| 1170 |
+
return {
|
| 1171 |
+
"installed": True,
|
| 1172 |
+
"access_token": pat_token,
|
| 1173 |
+
"user": user,
|
| 1174 |
+
"auth_type": "pat",
|
| 1175 |
+
}
|
| 1176 |
+
|
| 1177 |
+
github_app_id = os.getenv("GITHUB_APP_ID", "2313985")
|
| 1178 |
+
if not github_app_id:
|
| 1179 |
+
return {
|
| 1180 |
+
"installed": False,
|
| 1181 |
+
"message": "GitHub authentication not configured.",
|
| 1182 |
+
"auth_type": "none",
|
| 1183 |
+
}
|
| 1184 |
+
|
| 1185 |
+
return {
|
| 1186 |
+
"installed": False,
|
| 1187 |
+
"message": "GitHub App not installed.",
|
| 1188 |
+
"auth_type": "github_app",
|
| 1189 |
+
}
|
| 1190 |
+
|
| 1191 |
+
|
| 1192 |
+
@app.get("/api/auth/repo-access", response_model=RepoAccessResponse)
|
| 1193 |
+
async def api_check_repo_access(
|
| 1194 |
+
owner: str = Query(...),
|
| 1195 |
+
repo: str = Query(...),
|
| 1196 |
+
authorization: Optional[str] = Header(None),
|
| 1197 |
+
):
|
| 1198 |
+
"""
|
| 1199 |
+
Check if we have write access to a repository via User token or GitHub App.
|
| 1200 |
+
|
| 1201 |
+
This endpoint helps the frontend determine if it should show
|
| 1202 |
+
installation prompts or if the user already has sufficient permissions.
|
| 1203 |
+
"""
|
| 1204 |
+
token = get_github_token(authorization)
|
| 1205 |
+
access_info = await check_repo_write_access(owner, repo, user_token=token)
|
| 1206 |
+
|
| 1207 |
+
return RepoAccessResponse(
|
| 1208 |
+
can_write=access_info["can_write"],
|
| 1209 |
+
app_installed=access_info["app_installed"],
|
| 1210 |
+
auth_type=access_info["auth_type"],
|
| 1211 |
+
)
|
| 1212 |
+
|
| 1213 |
+
|
| 1214 |
+
# ============================================================================
|
| 1215 |
+
# Session Endpoints (Phase 1)
|
| 1216 |
+
# ============================================================================
|
| 1217 |
+
|
| 1218 |
+
@app.get("/api/sessions")
|
| 1219 |
+
async def api_list_sessions():
|
| 1220 |
+
"""List all saved sessions."""
|
| 1221 |
+
return {"sessions": _session_mgr.list_sessions()}
|
| 1222 |
+
|
| 1223 |
+
|
| 1224 |
+
@app.post("/api/sessions")
|
| 1225 |
+
async def api_create_session(payload: dict):
|
| 1226 |
+
"""Create a new session.
|
| 1227 |
+
|
| 1228 |
+
Accepts either legacy single-repo or multi-repo format:
|
| 1229 |
+
Legacy: {"repo_full_name": "owner/repo", "branch": "main"}
|
| 1230 |
+
Multi: {"repos": [{full_name, branch, mode}], "active_repo": "owner/repo"}
|
| 1231 |
+
"""
|
| 1232 |
+
repo = payload.get("repo_full_name", "")
|
| 1233 |
+
branch = payload.get("branch")
|
| 1234 |
+
name = payload.get("name") # optional — derived from first user prompt
|
| 1235 |
+
session = _session_mgr.create(repo_full_name=repo, branch=branch, name=name)
|
| 1236 |
+
|
| 1237 |
+
# Multi-repo context support
|
| 1238 |
+
if payload.get("repos"):
|
| 1239 |
+
session.repos = payload["repos"]
|
| 1240 |
+
session.active_repo = payload.get("active_repo", repo)
|
| 1241 |
+
elif repo:
|
| 1242 |
+
session.repos = [{"full_name": repo, "branch": branch or "main", "mode": "write"}]
|
| 1243 |
+
session.active_repo = repo
|
| 1244 |
+
|
| 1245 |
+
_session_mgr.save(session)
|
| 1246 |
+
return {"session_id": session.id, "status": session.status}
|
| 1247 |
+
|
| 1248 |
+
|
| 1249 |
+
@app.get("/api/sessions/{session_id}")
|
| 1250 |
+
async def api_get_session(session_id: str):
|
| 1251 |
+
"""Get session details."""
|
| 1252 |
+
session = _session_mgr.load(session_id)
|
| 1253 |
+
if not session:
|
| 1254 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 1255 |
+
return {
|
| 1256 |
+
"id": session.id,
|
| 1257 |
+
"status": session.status,
|
| 1258 |
+
"repo_full_name": session.repo_full_name,
|
| 1259 |
+
"branch": session.branch,
|
| 1260 |
+
"created_at": session.created_at,
|
| 1261 |
+
"message_count": len(session.messages),
|
| 1262 |
+
"checkpoint_count": len(session.checkpoints),
|
| 1263 |
+
"repos": session.repos,
|
| 1264 |
+
"active_repo": session.active_repo,
|
| 1265 |
+
}
|
| 1266 |
+
|
| 1267 |
+
|
| 1268 |
+
@app.delete("/api/sessions/{session_id}")
|
| 1269 |
+
async def api_delete_session(session_id: str):
|
| 1270 |
+
"""Delete a session."""
|
| 1271 |
+
deleted = _session_mgr.delete(session_id)
|
| 1272 |
+
if not deleted:
|
| 1273 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 1274 |
+
return {"deleted": True}
|
| 1275 |
+
|
| 1276 |
+
|
| 1277 |
+
@app.patch("/api/sessions/{session_id}/context")
|
| 1278 |
+
async def api_update_session_context(session_id: str, payload: dict):
|
| 1279 |
+
"""Add, remove, or activate repos in a session's multi-repo context.
|
| 1280 |
+
|
| 1281 |
+
Actions:
|
| 1282 |
+
{"action": "add", "repo_full_name": "owner/repo", "branch": "main"}
|
| 1283 |
+
{"action": "remove", "repo_full_name": "owner/repo"}
|
| 1284 |
+
{"action": "set_active", "repo_full_name": "owner/repo"}
|
| 1285 |
+
"""
|
| 1286 |
+
session = _session_mgr.load(session_id)
|
| 1287 |
+
if not session:
|
| 1288 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 1289 |
+
|
| 1290 |
+
action = payload.get("action")
|
| 1291 |
+
repo_name = payload.get("repo_full_name")
|
| 1292 |
+
if not action or not repo_name:
|
| 1293 |
+
raise HTTPException(status_code=400, detail="action and repo_full_name required")
|
| 1294 |
+
|
| 1295 |
+
if action == "add":
|
| 1296 |
+
branch = payload.get("branch", "main")
|
| 1297 |
+
if not any(r.get("full_name") == repo_name for r in session.repos):
|
| 1298 |
+
session.repos.append({
|
| 1299 |
+
"full_name": repo_name,
|
| 1300 |
+
"branch": branch,
|
| 1301 |
+
"mode": "read",
|
| 1302 |
+
})
|
| 1303 |
+
if not session.active_repo:
|
| 1304 |
+
session.active_repo = repo_name
|
| 1305 |
+
elif action == "remove":
|
| 1306 |
+
session.repos = [r for r in session.repos if r.get("full_name") != repo_name]
|
| 1307 |
+
if session.active_repo == repo_name:
|
| 1308 |
+
session.active_repo = session.repos[0]["full_name"] if session.repos else None
|
| 1309 |
+
elif action == "set_active":
|
| 1310 |
+
if any(r.get("full_name") == repo_name for r in session.repos):
|
| 1311 |
+
# Update mode flags
|
| 1312 |
+
for r in session.repos:
|
| 1313 |
+
r["mode"] = "write" if r.get("full_name") == repo_name else "read"
|
| 1314 |
+
session.active_repo = repo_name
|
| 1315 |
+
else:
|
| 1316 |
+
raise HTTPException(status_code=400, detail="Repo not in session context")
|
| 1317 |
+
else:
|
| 1318 |
+
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
|
| 1319 |
+
|
| 1320 |
+
_session_mgr.save(session)
|
| 1321 |
+
return {
|
| 1322 |
+
"repos": session.repos,
|
| 1323 |
+
"active_repo": session.active_repo,
|
| 1324 |
+
}
|
| 1325 |
+
|
| 1326 |
+
|
| 1327 |
+
@app.post("/api/sessions/{session_id}/checkpoint")
|
| 1328 |
+
async def api_create_checkpoint(session_id: str, payload: dict):
|
| 1329 |
+
"""Create a checkpoint for a session."""
|
| 1330 |
+
session = _session_mgr.load(session_id)
|
| 1331 |
+
if not session:
|
| 1332 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 1333 |
+
label = payload.get("label", "checkpoint")
|
| 1334 |
+
cp = _session_mgr.create_checkpoint(session, label=label)
|
| 1335 |
+
return {"checkpoint_id": cp.id, "label": cp.label, "created_at": cp.created_at}
|
| 1336 |
+
|
| 1337 |
+
|
| 1338 |
+
# ============================================================================
|
| 1339 |
+
# Hooks Endpoints (Phase 1)
|
| 1340 |
+
# ============================================================================
|
| 1341 |
+
|
| 1342 |
+
@app.get("/api/hooks")
|
| 1343 |
+
async def api_list_hooks():
|
| 1344 |
+
"""List registered hooks."""
|
| 1345 |
+
return {"hooks": _hook_mgr.list_hooks()}
|
| 1346 |
+
|
| 1347 |
+
|
| 1348 |
+
@app.post("/api/hooks")
|
| 1349 |
+
async def api_register_hook(payload: dict):
|
| 1350 |
+
"""Register a new hook."""
|
| 1351 |
+
from .hooks import HookDefinition
|
| 1352 |
+
try:
|
| 1353 |
+
hook = HookDefinition(
|
| 1354 |
+
event=HookEvent(payload["event"]),
|
| 1355 |
+
name=payload["name"],
|
| 1356 |
+
command=payload.get("command"),
|
| 1357 |
+
blocking=payload.get("blocking", False),
|
| 1358 |
+
timeout=payload.get("timeout", 30),
|
| 1359 |
+
)
|
| 1360 |
+
_hook_mgr.register(hook)
|
| 1361 |
+
return {"registered": True, "name": hook.name, "event": hook.event.value}
|
| 1362 |
+
except (KeyError, ValueError) as e:
|
| 1363 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 1364 |
+
|
| 1365 |
+
|
| 1366 |
+
@app.delete("/api/hooks/{event}/{name}")
|
| 1367 |
+
async def api_unregister_hook(event: str, name: str):
|
| 1368 |
+
"""Unregister a hook by event and name."""
|
| 1369 |
+
try:
|
| 1370 |
+
_hook_mgr.unregister(HookEvent(event), name)
|
| 1371 |
+
return {"unregistered": True}
|
| 1372 |
+
except ValueError as e:
|
| 1373 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 1374 |
+
|
| 1375 |
+
|
| 1376 |
+
# ============================================================================
|
| 1377 |
+
# Permissions Endpoints (Phase 1)
|
| 1378 |
+
# ============================================================================
|
| 1379 |
+
|
| 1380 |
+
@app.get("/api/permissions")
|
| 1381 |
+
async def api_get_permissions():
|
| 1382 |
+
"""Get current permission policy."""
|
| 1383 |
+
return _perm_mgr.to_dict()
|
| 1384 |
+
|
| 1385 |
+
|
| 1386 |
+
@app.put("/api/permissions/mode")
|
| 1387 |
+
async def api_set_permission_mode(payload: dict):
|
| 1388 |
+
"""Set the permission mode (normal, plan, auto)."""
|
| 1389 |
+
mode_str = payload.get("mode", "normal")
|
| 1390 |
+
try:
|
| 1391 |
+
_perm_mgr.policy.mode = PermissionMode(mode_str)
|
| 1392 |
+
return {"mode": _perm_mgr.policy.mode.value}
|
| 1393 |
+
except ValueError:
|
| 1394 |
+
raise HTTPException(status_code=400, detail=f"Invalid mode: {mode_str}")
|
| 1395 |
+
|
| 1396 |
+
|
| 1397 |
+
# ============================================================================
|
| 1398 |
+
# Project Context / Memory Endpoints (Phase 1)
|
| 1399 |
+
# ============================================================================
|
| 1400 |
+
|
| 1401 |
+
@app.get("/api/repos/{owner}/{repo}/context")
|
| 1402 |
+
async def api_get_project_context(
|
| 1403 |
+
owner: str = FPath(...),
|
| 1404 |
+
repo: str = FPath(...),
|
| 1405 |
+
):
|
| 1406 |
+
"""Get project conventions and memory for a repository workspace."""
|
| 1407 |
+
from pathlib import Path as StdPath
|
| 1408 |
+
workspace_path = StdPath.home() / ".gitpilot" / "workspaces" / owner / repo
|
| 1409 |
+
if not workspace_path.exists():
|
| 1410 |
+
return {"conventions": "", "rules": [], "auto_memory": {}, "system_prompt": ""}
|
| 1411 |
+
mgr = MemoryManager(workspace_path)
|
| 1412 |
+
ctx = mgr.load_context()
|
| 1413 |
+
return {
|
| 1414 |
+
"conventions": ctx.conventions,
|
| 1415 |
+
"rules": ctx.rules,
|
| 1416 |
+
"auto_memory": ctx.auto_memory,
|
| 1417 |
+
"system_prompt": ctx.to_system_prompt(),
|
| 1418 |
+
}
|
| 1419 |
+
|
| 1420 |
+
|
| 1421 |
+
@app.post("/api/repos/{owner}/{repo}/context/init")
|
| 1422 |
+
async def api_init_project_context(
|
| 1423 |
+
owner: str = FPath(...),
|
| 1424 |
+
repo: str = FPath(...),
|
| 1425 |
+
):
|
| 1426 |
+
"""Initialize .gitpilot/ directory with template GITPILOT.md."""
|
| 1427 |
+
from pathlib import Path as StdPath
|
| 1428 |
+
workspace_path = StdPath.home() / ".gitpilot" / "workspaces" / owner / repo
|
| 1429 |
+
workspace_path.mkdir(parents=True, exist_ok=True)
|
| 1430 |
+
mgr = MemoryManager(workspace_path)
|
| 1431 |
+
md_path = mgr.init_project()
|
| 1432 |
+
return {"initialized": True, "path": str(md_path)}
|
| 1433 |
+
|
| 1434 |
+
|
| 1435 |
+
@app.post("/api/repos/{owner}/{repo}/context/pattern")
|
| 1436 |
+
async def api_add_learned_pattern(
|
| 1437 |
+
owner: str = FPath(...),
|
| 1438 |
+
repo: str = FPath(...),
|
| 1439 |
+
payload: dict = ...,
|
| 1440 |
+
):
|
| 1441 |
+
"""Add a learned pattern to auto-memory."""
|
| 1442 |
+
from pathlib import Path as StdPath
|
| 1443 |
+
pattern = payload.get("pattern", "")
|
| 1444 |
+
if not pattern:
|
| 1445 |
+
raise HTTPException(status_code=400, detail="pattern is required")
|
| 1446 |
+
workspace_path = StdPath.home() / ".gitpilot" / "workspaces" / owner / repo
|
| 1447 |
+
workspace_path.mkdir(parents=True, exist_ok=True)
|
| 1448 |
+
mgr = MemoryManager(workspace_path)
|
| 1449 |
+
mgr.add_learned_pattern(pattern)
|
| 1450 |
+
return {"added": True, "pattern": pattern}
|
| 1451 |
+
|
| 1452 |
+
|
| 1453 |
+
# ============================================================================
|
| 1454 |
+
# Context Vault Endpoints (additive — Context + Use Case system)
|
| 1455 |
+
# ============================================================================
|
| 1456 |
+
|
| 1457 |
+
def _workspace_path(owner: str, repo: str) -> Path:
|
| 1458 |
+
"""Resolve the local workspace path for a repo."""
|
| 1459 |
+
return Path.home() / ".gitpilot" / "workspaces" / owner / repo
|
| 1460 |
+
|
| 1461 |
+
|
| 1462 |
+
@app.get("/api/repos/{owner}/{repo}/context/assets")
|
| 1463 |
+
async def api_list_context_assets(
|
| 1464 |
+
owner: str = FPath(...),
|
| 1465 |
+
repo: str = FPath(...),
|
| 1466 |
+
):
|
| 1467 |
+
"""List all uploaded context assets for a repository."""
|
| 1468 |
+
vault = ContextVault(_workspace_path(owner, repo))
|
| 1469 |
+
assets = vault.list_assets()
|
| 1470 |
+
return {"assets": [a.to_dict() for a in assets]}
|
| 1471 |
+
|
| 1472 |
+
|
| 1473 |
+
@app.post("/api/repos/{owner}/{repo}/context/assets/upload")
|
| 1474 |
+
async def api_upload_context_asset(
|
| 1475 |
+
owner: str = FPath(...),
|
| 1476 |
+
repo: str = FPath(...),
|
| 1477 |
+
file: UploadFile = File(...),
|
| 1478 |
+
):
|
| 1479 |
+
"""Upload a file to the project context vault."""
|
| 1480 |
+
vault = ContextVault(_workspace_path(owner, repo))
|
| 1481 |
+
content = await file.read()
|
| 1482 |
+
mime = file.content_type or ""
|
| 1483 |
+
filename = file.filename or "upload"
|
| 1484 |
+
|
| 1485 |
+
try:
|
| 1486 |
+
meta = vault.upload_asset(filename, content, mime=mime)
|
| 1487 |
+
return {"asset": meta.to_dict()}
|
| 1488 |
+
except ValueError as e:
|
| 1489 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 1490 |
+
|
| 1491 |
+
|
| 1492 |
+
@app.delete("/api/repos/{owner}/{repo}/context/assets/{asset_id}")
|
| 1493 |
+
async def api_delete_context_asset(
|
| 1494 |
+
owner: str = FPath(...),
|
| 1495 |
+
repo: str = FPath(...),
|
| 1496 |
+
asset_id: str = FPath(...),
|
| 1497 |
+
):
|
| 1498 |
+
"""Delete a context asset."""
|
| 1499 |
+
vault = ContextVault(_workspace_path(owner, repo))
|
| 1500 |
+
vault.delete_asset(asset_id)
|
| 1501 |
+
return {"deleted": True, "asset_id": asset_id}
|
| 1502 |
+
|
| 1503 |
+
|
| 1504 |
+
@app.get("/api/repos/{owner}/{repo}/context/assets/{asset_id}/download")
|
| 1505 |
+
async def api_download_context_asset(
|
| 1506 |
+
owner: str = FPath(...),
|
| 1507 |
+
repo: str = FPath(...),
|
| 1508 |
+
asset_id: str = FPath(...),
|
| 1509 |
+
):
|
| 1510 |
+
"""Download a raw context asset file."""
|
| 1511 |
+
vault = ContextVault(_workspace_path(owner, repo))
|
| 1512 |
+
asset_path = vault.get_asset_path(asset_id)
|
| 1513 |
+
if not asset_path:
|
| 1514 |
+
raise HTTPException(status_code=404, detail="Asset not found")
|
| 1515 |
+
filename = vault.get_asset_filename(asset_id)
|
| 1516 |
+
return FileResponse(asset_path, filename=filename)
|
| 1517 |
+
|
| 1518 |
+
|
| 1519 |
+
# ============================================================================
|
| 1520 |
+
# Use Case Endpoints (additive — guided requirement clarification)
|
| 1521 |
+
# ============================================================================
|
| 1522 |
+
|
| 1523 |
+
@app.get("/api/repos/{owner}/{repo}/use-cases")
|
| 1524 |
+
async def api_list_use_cases(
|
| 1525 |
+
owner: str = FPath(...),
|
| 1526 |
+
repo: str = FPath(...),
|
| 1527 |
+
):
|
| 1528 |
+
"""List all use cases for a repository."""
|
| 1529 |
+
mgr = UseCaseManager(_workspace_path(owner, repo))
|
| 1530 |
+
return {"use_cases": mgr.list_use_cases()}
|
| 1531 |
+
|
| 1532 |
+
|
| 1533 |
+
@app.post("/api/repos/{owner}/{repo}/use-cases")
|
| 1534 |
+
async def api_create_use_case(
|
| 1535 |
+
owner: str = FPath(...),
|
| 1536 |
+
repo: str = FPath(...),
|
| 1537 |
+
payload: dict = ...,
|
| 1538 |
+
):
|
| 1539 |
+
"""Create a new use case."""
|
| 1540 |
+
title = payload.get("title", "New Use Case")
|
| 1541 |
+
initial_notes = payload.get("initial_notes", "")
|
| 1542 |
+
mgr = UseCaseManager(_workspace_path(owner, repo))
|
| 1543 |
+
uc = mgr.create_use_case(title=title, initial_notes=initial_notes)
|
| 1544 |
+
return {"use_case": uc.to_dict()}
|
| 1545 |
+
|
| 1546 |
+
|
| 1547 |
+
@app.get("/api/repos/{owner}/{repo}/use-cases/{use_case_id}")
|
| 1548 |
+
async def api_get_use_case(
|
| 1549 |
+
owner: str = FPath(...),
|
| 1550 |
+
repo: str = FPath(...),
|
| 1551 |
+
use_case_id: str = FPath(...),
|
| 1552 |
+
):
|
| 1553 |
+
"""Get a single use case with messages and spec."""
|
| 1554 |
+
mgr = UseCaseManager(_workspace_path(owner, repo))
|
| 1555 |
+
uc = mgr.get_use_case(use_case_id)
|
| 1556 |
+
if not uc:
|
| 1557 |
+
raise HTTPException(status_code=404, detail="Use case not found")
|
| 1558 |
+
return {"use_case": uc.to_dict()}
|
| 1559 |
+
|
| 1560 |
+
|
| 1561 |
+
@app.post("/api/repos/{owner}/{repo}/use-cases/{use_case_id}/chat")
|
| 1562 |
+
async def api_use_case_chat(
|
| 1563 |
+
owner: str = FPath(...),
|
| 1564 |
+
repo: str = FPath(...),
|
| 1565 |
+
use_case_id: str = FPath(...),
|
| 1566 |
+
payload: dict = ...,
|
| 1567 |
+
):
|
| 1568 |
+
"""Send a guided chat message and get assistant response + updated spec."""
|
| 1569 |
+
message = payload.get("message", "")
|
| 1570 |
+
if not message:
|
| 1571 |
+
raise HTTPException(status_code=400, detail="message is required")
|
| 1572 |
+
mgr = UseCaseManager(_workspace_path(owner, repo))
|
| 1573 |
+
uc = mgr.chat(use_case_id, message)
|
| 1574 |
+
if not uc:
|
| 1575 |
+
raise HTTPException(status_code=404, detail="Use case not found")
|
| 1576 |
+
return {"use_case": uc.to_dict()}
|
| 1577 |
+
|
| 1578 |
+
|
| 1579 |
+
@app.post("/api/repos/{owner}/{repo}/use-cases/{use_case_id}/finalize")
|
| 1580 |
+
async def api_finalize_use_case(
|
| 1581 |
+
owner: str = FPath(...),
|
| 1582 |
+
repo: str = FPath(...),
|
| 1583 |
+
use_case_id: str = FPath(...),
|
| 1584 |
+
):
|
| 1585 |
+
"""Finalize a use case: mark active, export markdown spec."""
|
| 1586 |
+
mgr = UseCaseManager(_workspace_path(owner, repo))
|
| 1587 |
+
uc = mgr.finalize(use_case_id)
|
| 1588 |
+
if not uc:
|
| 1589 |
+
raise HTTPException(status_code=404, detail="Use case not found")
|
| 1590 |
+
return {"use_case": uc.to_dict()}
|
| 1591 |
+
|
| 1592 |
+
|
| 1593 |
+
# ============================================================================
|
| 1594 |
+
# MCP Endpoints (Phase 2)
|
| 1595 |
+
# ============================================================================
|
| 1596 |
+
|
| 1597 |
+
@app.get("/api/mcp/servers")
|
| 1598 |
+
async def api_mcp_list_servers():
|
| 1599 |
+
"""List configured MCP servers and their connection status."""
|
| 1600 |
+
return _mcp_client.to_dict()
|
| 1601 |
+
|
| 1602 |
+
|
| 1603 |
+
@app.post("/api/mcp/connect/{server_name}")
|
| 1604 |
+
async def api_mcp_connect(server_name: str):
|
| 1605 |
+
"""Connect to a named MCP server."""
|
| 1606 |
+
try:
|
| 1607 |
+
conn = await _mcp_client.connect(server_name)
|
| 1608 |
+
return {
|
| 1609 |
+
"connected": True,
|
| 1610 |
+
"server": server_name,
|
| 1611 |
+
"tools": [{"name": t.name, "description": t.description} for t in conn.tools],
|
| 1612 |
+
}
|
| 1613 |
+
except Exception as e:
|
| 1614 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 1615 |
+
|
| 1616 |
+
|
| 1617 |
+
@app.post("/api/mcp/disconnect/{server_name}")
|
| 1618 |
+
async def api_mcp_disconnect(server_name: str):
|
| 1619 |
+
"""Disconnect from a named MCP server."""
|
| 1620 |
+
await _mcp_client.disconnect(server_name)
|
| 1621 |
+
return {"disconnected": True, "server": server_name}
|
| 1622 |
+
|
| 1623 |
+
|
| 1624 |
+
@app.post("/api/mcp/call")
|
| 1625 |
+
async def api_mcp_call_tool(payload: dict):
|
| 1626 |
+
"""Call a tool on a connected MCP server."""
|
| 1627 |
+
server = payload.get("server", "")
|
| 1628 |
+
tool_name = payload.get("tool", "")
|
| 1629 |
+
params = payload.get("params", {})
|
| 1630 |
+
if not server or not tool_name:
|
| 1631 |
+
raise HTTPException(status_code=400, detail="server and tool are required")
|
| 1632 |
+
conn = _mcp_client._connections.get(server)
|
| 1633 |
+
if not conn:
|
| 1634 |
+
raise HTTPException(status_code=404, detail=f"Not connected to server: {server}")
|
| 1635 |
+
try:
|
| 1636 |
+
result = await _mcp_client.call_tool(conn, tool_name, params)
|
| 1637 |
+
return {"result": result}
|
| 1638 |
+
except Exception as e:
|
| 1639 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 1640 |
+
|
| 1641 |
+
|
| 1642 |
+
# ============================================================================
|
| 1643 |
+
# Plugin Endpoints (Phase 2)
|
| 1644 |
+
# ============================================================================
|
| 1645 |
+
|
| 1646 |
+
@app.get("/api/plugins")
|
| 1647 |
+
async def api_list_plugins():
|
| 1648 |
+
"""List installed plugins."""
|
| 1649 |
+
plugins = _plugin_mgr.list_installed()
|
| 1650 |
+
return {"plugins": [p.to_dict() for p in plugins]}
|
| 1651 |
+
|
| 1652 |
+
|
| 1653 |
+
@app.post("/api/plugins/install")
|
| 1654 |
+
async def api_install_plugin(payload: dict):
|
| 1655 |
+
"""Install a plugin from a git URL or local path."""
|
| 1656 |
+
source = payload.get("source", "")
|
| 1657 |
+
if not source:
|
| 1658 |
+
raise HTTPException(status_code=400, detail="source is required")
|
| 1659 |
+
try:
|
| 1660 |
+
info = _plugin_mgr.install(source)
|
| 1661 |
+
return {"installed": True, "plugin": info.to_dict()}
|
| 1662 |
+
except Exception as e:
|
| 1663 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 1664 |
+
|
| 1665 |
+
|
| 1666 |
+
@app.delete("/api/plugins/{name}")
|
| 1667 |
+
async def api_uninstall_plugin(name: str):
|
| 1668 |
+
"""Uninstall a plugin by name."""
|
| 1669 |
+
removed = _plugin_mgr.uninstall(name)
|
| 1670 |
+
if not removed:
|
| 1671 |
+
raise HTTPException(status_code=404, detail=f"Plugin not found: {name}")
|
| 1672 |
+
return {"uninstalled": True, "name": name}
|
| 1673 |
+
|
| 1674 |
+
|
| 1675 |
+
# ============================================================================
|
| 1676 |
+
# Skills Endpoints (Phase 2)
|
| 1677 |
+
# ============================================================================
|
| 1678 |
+
|
| 1679 |
+
@app.get("/api/skills")
|
| 1680 |
+
async def api_list_skills():
|
| 1681 |
+
"""List all available skills."""
|
| 1682 |
+
return {"skills": _skill_mgr.list_skills()}
|
| 1683 |
+
|
| 1684 |
+
|
| 1685 |
+
@app.post("/api/skills/invoke")
|
| 1686 |
+
async def api_invoke_skill(payload: dict):
|
| 1687 |
+
"""Invoke a skill by name."""
|
| 1688 |
+
name = payload.get("name", "")
|
| 1689 |
+
context = payload.get("context", {})
|
| 1690 |
+
if not name:
|
| 1691 |
+
raise HTTPException(status_code=400, detail="name is required")
|
| 1692 |
+
prompt = _skill_mgr.invoke(name, context)
|
| 1693 |
+
if prompt is None:
|
| 1694 |
+
raise HTTPException(status_code=404, detail=f"Skill not found: {name}")
|
| 1695 |
+
return {"skill": name, "rendered_prompt": prompt}
|
| 1696 |
+
|
| 1697 |
+
|
| 1698 |
+
@app.post("/api/skills/reload")
|
| 1699 |
+
async def api_reload_skills():
|
| 1700 |
+
"""Reload skills from all sources."""
|
| 1701 |
+
count = _skill_mgr.load_all()
|
| 1702 |
+
return {"reloaded": True, "count": count}
|
| 1703 |
+
|
| 1704 |
+
|
| 1705 |
+
# ============================================================================
|
| 1706 |
+
# Vision Endpoints (Phase 2)
|
| 1707 |
+
# ============================================================================
|
| 1708 |
+
|
| 1709 |
+
@app.post("/api/vision/analyze")
|
| 1710 |
+
async def api_vision_analyze(payload: dict):
|
| 1711 |
+
"""Analyze an image with a text prompt."""
|
| 1712 |
+
from .vision import VisionAnalyzer
|
| 1713 |
+
image_path = payload.get("image_path", "")
|
| 1714 |
+
prompt = payload.get("prompt", "Describe this image.")
|
| 1715 |
+
provider = payload.get("provider", "openai")
|
| 1716 |
+
if not image_path:
|
| 1717 |
+
raise HTTPException(status_code=400, detail="image_path is required")
|
| 1718 |
+
try:
|
| 1719 |
+
analyzer = VisionAnalyzer(provider=provider)
|
| 1720 |
+
result = await analyzer.analyze_image(Path(image_path), prompt)
|
| 1721 |
+
return result.to_dict()
|
| 1722 |
+
except Exception as e:
|
| 1723 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 1724 |
+
|
| 1725 |
+
|
| 1726 |
+
# ============================================================================
|
| 1727 |
+
# Model Router Endpoints (Phase 2)
|
| 1728 |
+
# ============================================================================
|
| 1729 |
+
|
| 1730 |
+
@app.post("/api/model-router/select")
|
| 1731 |
+
async def api_model_select(payload: dict):
|
| 1732 |
+
"""Preview which model would be selected for a request."""
|
| 1733 |
+
request = payload.get("request", "")
|
| 1734 |
+
category = payload.get("category")
|
| 1735 |
+
if not request:
|
| 1736 |
+
raise HTTPException(status_code=400, detail="request is required")
|
| 1737 |
+
selection = _model_router.select(request, category)
|
| 1738 |
+
return {
|
| 1739 |
+
"model": selection.model,
|
| 1740 |
+
"tier": selection.tier.value,
|
| 1741 |
+
"complexity": selection.complexity.value,
|
| 1742 |
+
"provider": selection.provider,
|
| 1743 |
+
"reason": selection.reason,
|
| 1744 |
+
}
|
| 1745 |
+
|
| 1746 |
+
|
| 1747 |
+
@app.get("/api/model-router/usage")
|
| 1748 |
+
async def api_model_usage():
|
| 1749 |
+
"""Get model usage summary and budget status."""
|
| 1750 |
+
return _model_router.get_usage_summary()
|
| 1751 |
+
|
| 1752 |
+
|
| 1753 |
+
# ============================================================================
|
| 1754 |
+
# Agent Teams Endpoints (Phase 3)
|
| 1755 |
+
# ============================================================================
|
| 1756 |
+
|
| 1757 |
+
@app.post("/api/agent-teams/plan")
|
| 1758 |
+
async def api_team_plan(payload: dict):
|
| 1759 |
+
"""Split a complex task into parallel subtasks."""
|
| 1760 |
+
task = payload.get("task", "")
|
| 1761 |
+
if not task:
|
| 1762 |
+
raise HTTPException(status_code=400, detail="task is required")
|
| 1763 |
+
subtasks = _agent_team.plan_and_split(task)
|
| 1764 |
+
return {"subtasks": [{"id": s.id, "title": s.title, "description": s.description} for s in subtasks]}
|
| 1765 |
+
|
| 1766 |
+
|
| 1767 |
+
@app.post("/api/agent-teams/execute")
|
| 1768 |
+
async def api_team_execute(payload: dict):
|
| 1769 |
+
"""Execute subtasks in parallel and merge results."""
|
| 1770 |
+
task = payload.get("task", "")
|
| 1771 |
+
if not task:
|
| 1772 |
+
raise HTTPException(status_code=400, detail="task is required")
|
| 1773 |
+
subtasks = _agent_team.plan_and_split(task)
|
| 1774 |
+
result = await _agent_team.execute_parallel(subtasks)
|
| 1775 |
+
return result.to_dict()
|
| 1776 |
+
|
| 1777 |
+
|
| 1778 |
+
# ============================================================================
|
| 1779 |
+
# Learning Engine Endpoints (Phase 3)
|
| 1780 |
+
# ============================================================================
|
| 1781 |
+
|
| 1782 |
+
@app.post("/api/learning/evaluate")
|
| 1783 |
+
async def api_learning_evaluate(payload: dict):
|
| 1784 |
+
"""Evaluate an action outcome for learning."""
|
| 1785 |
+
action = payload.get("action", "")
|
| 1786 |
+
outcome = payload.get("outcome", {})
|
| 1787 |
+
repo = payload.get("repo", "")
|
| 1788 |
+
if not action:
|
| 1789 |
+
raise HTTPException(status_code=400, detail="action is required")
|
| 1790 |
+
evaluation = _learning_engine.evaluate_outcome(action, outcome, repo=repo)
|
| 1791 |
+
return {
|
| 1792 |
+
"action": evaluation.action,
|
| 1793 |
+
"success": evaluation.success,
|
| 1794 |
+
"score": evaluation.score,
|
| 1795 |
+
"feedback": evaluation.feedback,
|
| 1796 |
+
}
|
| 1797 |
+
|
| 1798 |
+
|
| 1799 |
+
@app.get("/api/learning/insights/{owner}/{repo}")
|
| 1800 |
+
async def api_learning_insights(owner: str = FPath(...), repo: str = FPath(...)):
|
| 1801 |
+
"""Get learned insights for a repository."""
|
| 1802 |
+
repo_name = f"{owner}/{repo}"
|
| 1803 |
+
insights = _learning_engine.get_repo_insights(repo_name)
|
| 1804 |
+
return {
|
| 1805 |
+
"repo": repo_name,
|
| 1806 |
+
"patterns": insights.patterns,
|
| 1807 |
+
"preferred_style": insights.preferred_style,
|
| 1808 |
+
"success_rate": insights.success_rate,
|
| 1809 |
+
"total_evaluations": insights.total_evaluations,
|
| 1810 |
+
}
|
| 1811 |
+
|
| 1812 |
+
|
| 1813 |
+
@app.post("/api/learning/style")
|
| 1814 |
+
async def api_learning_set_style(payload: dict):
|
| 1815 |
+
"""Set preferred coding style for a repository."""
|
| 1816 |
+
repo = payload.get("repo", "")
|
| 1817 |
+
style = payload.get("style", {})
|
| 1818 |
+
if not repo:
|
| 1819 |
+
raise HTTPException(status_code=400, detail="repo is required")
|
| 1820 |
+
_learning_engine.set_preferred_style(repo, style)
|
| 1821 |
+
return {"repo": repo, "style": style}
|
| 1822 |
+
|
| 1823 |
+
|
| 1824 |
+
# ============================================================================
|
| 1825 |
+
# Cross-Repo Intelligence Endpoints (Phase 3)
|
| 1826 |
+
# ============================================================================
|
| 1827 |
+
|
| 1828 |
+
@app.post("/api/cross-repo/dependencies")
|
| 1829 |
+
async def api_cross_repo_dependencies(payload: dict):
|
| 1830 |
+
"""Analyze dependencies from provided file contents."""
|
| 1831 |
+
files = payload.get("files", {})
|
| 1832 |
+
if not files:
|
| 1833 |
+
raise HTTPException(status_code=400, detail="files dict is required (filename -> content)")
|
| 1834 |
+
graph = _cross_repo.analyze_dependencies_from_files(files)
|
| 1835 |
+
return graph.to_dict()
|
| 1836 |
+
|
| 1837 |
+
|
| 1838 |
+
@app.post("/api/cross-repo/impact")
|
| 1839 |
+
async def api_cross_repo_impact(payload: dict):
|
| 1840 |
+
"""Analyze impact of updating a package."""
|
| 1841 |
+
files = payload.get("files", {})
|
| 1842 |
+
package_name = payload.get("package", "")
|
| 1843 |
+
new_version = payload.get("new_version")
|
| 1844 |
+
if not package_name:
|
| 1845 |
+
raise HTTPException(status_code=400, detail="package is required")
|
| 1846 |
+
graph = _cross_repo.analyze_dependencies_from_files(files)
|
| 1847 |
+
report = _cross_repo.impact_analysis(graph, package_name, new_version)
|
| 1848 |
+
return report.to_dict()
|
| 1849 |
+
|
| 1850 |
+
|
| 1851 |
+
# ============================================================================
|
| 1852 |
+
# Predictions Endpoints (Phase 3)
|
| 1853 |
+
# ============================================================================
|
| 1854 |
+
|
| 1855 |
+
@app.post("/api/predictions/suggest")
|
| 1856 |
+
async def api_predictions_suggest(payload: dict):
|
| 1857 |
+
"""Get proactive suggestions based on context."""
|
| 1858 |
+
context = payload.get("context", "")
|
| 1859 |
+
if not context:
|
| 1860 |
+
raise HTTPException(status_code=400, detail="context is required")
|
| 1861 |
+
suggestions = _predictive_engine.predict(context)
|
| 1862 |
+
return {"suggestions": [s.to_dict() for s in suggestions]}
|
| 1863 |
+
|
| 1864 |
+
|
| 1865 |
+
@app.get("/api/predictions/rules")
|
| 1866 |
+
async def api_predictions_rules():
|
| 1867 |
+
"""List all prediction rules."""
|
| 1868 |
+
return {"rules": _predictive_engine.list_rules()}
|
| 1869 |
+
|
| 1870 |
+
|
| 1871 |
+
# ============================================================================
|
| 1872 |
+
# Security Scanner Endpoints (Phase 3)
|
| 1873 |
+
# ============================================================================
|
| 1874 |
+
|
| 1875 |
+
@app.post("/api/security/scan-file")
|
| 1876 |
+
async def api_security_scan_file(payload: dict):
|
| 1877 |
+
"""Scan a single file for security issues."""
|
| 1878 |
+
file_path = payload.get("file_path", "")
|
| 1879 |
+
if not file_path:
|
| 1880 |
+
raise HTTPException(status_code=400, detail="file_path is required")
|
| 1881 |
+
findings = _security_scanner.scan_file(file_path)
|
| 1882 |
+
return {"findings": [f.to_dict() for f in findings], "count": len(findings)}
|
| 1883 |
+
|
| 1884 |
+
|
| 1885 |
+
@app.post("/api/security/scan-directory")
|
| 1886 |
+
async def api_security_scan_directory(payload: dict):
|
| 1887 |
+
"""Recursively scan a directory for security issues."""
|
| 1888 |
+
directory = payload.get("directory", "")
|
| 1889 |
+
if not directory:
|
| 1890 |
+
raise HTTPException(status_code=400, detail="directory is required")
|
| 1891 |
+
result = _security_scanner.scan_directory(directory)
|
| 1892 |
+
return result.to_dict()
|
| 1893 |
+
|
| 1894 |
+
|
| 1895 |
+
@app.post("/api/security/scan-diff")
|
| 1896 |
+
async def api_security_scan_diff(payload: dict):
|
| 1897 |
+
"""Scan a git diff for security issues in added lines."""
|
| 1898 |
+
diff_text = payload.get("diff", "")
|
| 1899 |
+
if not diff_text:
|
| 1900 |
+
raise HTTPException(status_code=400, detail="diff is required")
|
| 1901 |
+
findings = _security_scanner.scan_diff(diff_text)
|
| 1902 |
+
return {"findings": [f.to_dict() for f in findings], "count": len(findings)}
|
| 1903 |
+
|
| 1904 |
+
|
| 1905 |
+
# ============================================================================
|
| 1906 |
+
# Natural Language Database Endpoints (Phase 3)
|
| 1907 |
+
# ============================================================================
|
| 1908 |
+
|
| 1909 |
+
@app.post("/api/nl-database/translate")
|
| 1910 |
+
async def api_nl_translate(payload: dict):
|
| 1911 |
+
"""Translate natural language to SQL."""
|
| 1912 |
+
question = payload.get("question", "")
|
| 1913 |
+
dialect = payload.get("dialect", "postgresql")
|
| 1914 |
+
tables = payload.get("tables", [])
|
| 1915 |
+
if not question:
|
| 1916 |
+
raise HTTPException(status_code=400, detail="question is required")
|
| 1917 |
+
engine = NLQueryEngine(dialect=QueryDialect(dialect))
|
| 1918 |
+
for t in tables:
|
| 1919 |
+
engine.add_table(TableSchema(
|
| 1920 |
+
name=t["name"],
|
| 1921 |
+
columns=t.get("columns", []),
|
| 1922 |
+
primary_key=t.get("primary_key"),
|
| 1923 |
+
))
|
| 1924 |
+
sql = engine.translate(question)
|
| 1925 |
+
error = engine.validate_query(sql)
|
| 1926 |
+
return {"question": question, "sql": sql, "valid": error is None, "error": error}
|
| 1927 |
+
|
| 1928 |
+
|
| 1929 |
+
@app.post("/api/nl-database/explain")
|
| 1930 |
+
async def api_nl_explain(payload: dict):
|
| 1931 |
+
"""Explain what a SQL query does in plain English."""
|
| 1932 |
+
sql = payload.get("sql", "")
|
| 1933 |
+
if not sql:
|
| 1934 |
+
raise HTTPException(status_code=400, detail="sql is required")
|
| 1935 |
+
explanation = _nl_engine.explain(sql)
|
| 1936 |
+
return {"sql": sql, "explanation": explanation}
|
| 1937 |
+
|
| 1938 |
+
|
| 1939 |
+
# ============================================================================
|
| 1940 |
+
# Branch Listing Endpoint (Claude-Code-on-Web Parity)
|
| 1941 |
+
# ============================================================================
|
| 1942 |
+
|
| 1943 |
+
class BranchInfo(BaseModel):
|
| 1944 |
+
name: str
|
| 1945 |
+
is_default: bool = False
|
| 1946 |
+
protected: bool = False
|
| 1947 |
+
commit_sha: Optional[str] = None
|
| 1948 |
+
|
| 1949 |
+
|
| 1950 |
+
class BranchListResponse(BaseModel):
|
| 1951 |
+
repository: str
|
| 1952 |
+
default_branch: str
|
| 1953 |
+
page: int
|
| 1954 |
+
per_page: int
|
| 1955 |
+
has_more: bool
|
| 1956 |
+
branches: List[BranchInfo]
|
| 1957 |
+
|
| 1958 |
+
|
| 1959 |
+
@app.get("/api/repos/{owner}/{repo}/branches", response_model=BranchListResponse)
|
| 1960 |
+
async def api_list_branches(
|
| 1961 |
+
owner: str = FPath(...),
|
| 1962 |
+
repo: str = FPath(...),
|
| 1963 |
+
page: int = Query(1, ge=1),
|
| 1964 |
+
per_page: int = Query(100, ge=1, le=100),
|
| 1965 |
+
query: Optional[str] = Query(None, description="Substring filter"),
|
| 1966 |
+
authorization: Optional[str] = Header(None),
|
| 1967 |
+
):
|
| 1968 |
+
"""List branches for a repository with optional search filtering."""
|
| 1969 |
+
import httpx as _httpx
|
| 1970 |
+
|
| 1971 |
+
token = get_github_token(authorization)
|
| 1972 |
+
if not token:
|
| 1973 |
+
raise HTTPException(status_code=401, detail="GitHub token required")
|
| 1974 |
+
|
| 1975 |
+
headers = {
|
| 1976 |
+
"Authorization": f"Bearer {token}",
|
| 1977 |
+
"Accept": "application/vnd.github+json",
|
| 1978 |
+
"User-Agent": "gitpilot",
|
| 1979 |
+
}
|
| 1980 |
+
timeout = _httpx.Timeout(connect=10.0, read=30.0, write=30.0, pool=10.0)
|
| 1981 |
+
|
| 1982 |
+
async with _httpx.AsyncClient(
|
| 1983 |
+
base_url="https://api.github.com", headers=headers, timeout=timeout
|
| 1984 |
+
) as client:
|
| 1985 |
+
# Fetch repo info for default_branch
|
| 1986 |
+
repo_resp = await client.get(f"/repos/{owner}/{repo}")
|
| 1987 |
+
if repo_resp.status_code >= 400:
|
| 1988 |
+
logging.warning(
|
| 1989 |
+
"branches: repo lookup failed %s/%s → %s %s",
|
| 1990 |
+
owner, repo, repo_resp.status_code, repo_resp.text[:200],
|
| 1991 |
+
)
|
| 1992 |
+
raise HTTPException(
|
| 1993 |
+
status_code=repo_resp.status_code,
|
| 1994 |
+
detail=f"Cannot access repository: {repo_resp.status_code}",
|
| 1995 |
+
)
|
| 1996 |
+
|
| 1997 |
+
repo_data = repo_resp.json()
|
| 1998 |
+
default_branch_name = repo_data.get("default_branch", "main")
|
| 1999 |
+
|
| 2000 |
+
# Fetch ALL branch pages (GitHub caps at 100 per page)
|
| 2001 |
+
all_raw = []
|
| 2002 |
+
current_page = page
|
| 2003 |
+
while True:
|
| 2004 |
+
branch_resp = await client.get(
|
| 2005 |
+
f"/repos/{owner}/{repo}/branches",
|
| 2006 |
+
params={"page": current_page, "per_page": per_page},
|
| 2007 |
+
)
|
| 2008 |
+
if branch_resp.status_code >= 400:
|
| 2009 |
+
logging.warning(
|
| 2010 |
+
"branches: list failed %s/%s page=%s → %s %s",
|
| 2011 |
+
owner, repo, current_page, branch_resp.status_code, branch_resp.text[:200],
|
| 2012 |
+
)
|
| 2013 |
+
raise HTTPException(
|
| 2014 |
+
status_code=branch_resp.status_code,
|
| 2015 |
+
detail=f"Failed to list branches: {branch_resp.status_code}",
|
| 2016 |
+
)
|
| 2017 |
+
|
| 2018 |
+
page_data = branch_resp.json() if isinstance(branch_resp.json(), list) else []
|
| 2019 |
+
all_raw.extend(page_data)
|
| 2020 |
+
|
| 2021 |
+
# Check if there are more pages
|
| 2022 |
+
link_header = branch_resp.headers.get("Link", "") or ""
|
| 2023 |
+
if 'rel="next"' not in link_header or len(page_data) < per_page:
|
| 2024 |
+
break
|
| 2025 |
+
current_page += 1
|
| 2026 |
+
# Safety: cap at 10 pages (1000 branches)
|
| 2027 |
+
if current_page - page >= 10:
|
| 2028 |
+
break
|
| 2029 |
+
|
| 2030 |
+
q = (query or "").strip().lower()
|
| 2031 |
+
|
| 2032 |
+
branches = []
|
| 2033 |
+
for b in all_raw:
|
| 2034 |
+
name = (b.get("name") or "").strip()
|
| 2035 |
+
if not name:
|
| 2036 |
+
continue
|
| 2037 |
+
if q and q not in name.lower():
|
| 2038 |
+
continue
|
| 2039 |
+
branches.append(BranchInfo(
|
| 2040 |
+
name=name,
|
| 2041 |
+
is_default=(name == default_branch_name),
|
| 2042 |
+
protected=bool(b.get("protected", False)),
|
| 2043 |
+
commit_sha=(b.get("commit") or {}).get("sha"),
|
| 2044 |
+
))
|
| 2045 |
+
|
| 2046 |
+
# Sort: default branch first, then alphabetical
|
| 2047 |
+
branches.sort(key=lambda x: (0 if x.is_default else 1, x.name.lower()))
|
| 2048 |
+
|
| 2049 |
+
return BranchListResponse(
|
| 2050 |
+
repository=f"{owner}/{repo}",
|
| 2051 |
+
default_branch=default_branch_name,
|
| 2052 |
+
page=page,
|
| 2053 |
+
per_page=per_page,
|
| 2054 |
+
has_more=False,
|
| 2055 |
+
branches=branches,
|
| 2056 |
+
)
|
| 2057 |
+
|
| 2058 |
+
|
| 2059 |
+
# ============================================================================
|
| 2060 |
+
# Environment Configuration Endpoints (Claude-Code-on-Web Parity)
|
| 2061 |
+
# ============================================================================
|
| 2062 |
+
|
| 2063 |
+
import json as _json
|
| 2064 |
+
_ENV_ROOT = Path.home() / ".gitpilot" / "environments"
|
| 2065 |
+
|
| 2066 |
+
|
| 2067 |
+
class EnvironmentConfig(BaseModel):
|
| 2068 |
+
id: Optional[str] = None
|
| 2069 |
+
name: str = "Default"
|
| 2070 |
+
network_access: str = Field("limited", description="limited | full | none")
|
| 2071 |
+
env_vars: dict = Field(default_factory=dict)
|
| 2072 |
+
|
| 2073 |
+
|
| 2074 |
+
class EnvironmentListResponse(BaseModel):
|
| 2075 |
+
environments: List[EnvironmentConfig]
|
| 2076 |
+
|
| 2077 |
+
|
| 2078 |
+
@app.get("/api/environments", response_model=EnvironmentListResponse)
|
| 2079 |
+
async def api_list_environments():
|
| 2080 |
+
"""List all environment configurations."""
|
| 2081 |
+
_ENV_ROOT.mkdir(parents=True, exist_ok=True)
|
| 2082 |
+
envs = []
|
| 2083 |
+
for path in sorted(_ENV_ROOT.glob("*.json")):
|
| 2084 |
+
try:
|
| 2085 |
+
data = _json.loads(path.read_text())
|
| 2086 |
+
envs.append(EnvironmentConfig(**data))
|
| 2087 |
+
except Exception:
|
| 2088 |
+
continue
|
| 2089 |
+
if not envs:
|
| 2090 |
+
envs.append(EnvironmentConfig(id="default", name="Default", network_access="limited"))
|
| 2091 |
+
return EnvironmentListResponse(environments=envs)
|
| 2092 |
+
|
| 2093 |
+
|
| 2094 |
+
@app.post("/api/environments")
|
| 2095 |
+
async def api_create_environment(config: EnvironmentConfig):
|
| 2096 |
+
"""Create a new environment configuration."""
|
| 2097 |
+
import uuid
|
| 2098 |
+
_ENV_ROOT.mkdir(parents=True, exist_ok=True)
|
| 2099 |
+
config.id = config.id or uuid.uuid4().hex[:12]
|
| 2100 |
+
path = _ENV_ROOT / f"{config.id}.json"
|
| 2101 |
+
path.write_text(_json.dumps(config.model_dump(), indent=2))
|
| 2102 |
+
return config.model_dump()
|
| 2103 |
+
|
| 2104 |
+
|
| 2105 |
+
@app.put("/api/environments/{env_id}")
|
| 2106 |
+
async def api_update_environment(env_id: str, config: EnvironmentConfig):
|
| 2107 |
+
"""Update an environment configuration."""
|
| 2108 |
+
_ENV_ROOT.mkdir(parents=True, exist_ok=True)
|
| 2109 |
+
path = _ENV_ROOT / f"{env_id}.json"
|
| 2110 |
+
config.id = env_id
|
| 2111 |
+
path.write_text(_json.dumps(config.model_dump(), indent=2))
|
| 2112 |
+
return config.model_dump()
|
| 2113 |
+
|
| 2114 |
+
|
| 2115 |
+
@app.delete("/api/environments/{env_id}")
|
| 2116 |
+
async def api_delete_environment(env_id: str):
|
| 2117 |
+
"""Delete an environment configuration."""
|
| 2118 |
+
path = _ENV_ROOT / f"{env_id}.json"
|
| 2119 |
+
if path.exists():
|
| 2120 |
+
path.unlink()
|
| 2121 |
+
return {"deleted": True}
|
| 2122 |
+
raise HTTPException(status_code=404, detail="Environment not found")
|
| 2123 |
+
|
| 2124 |
+
|
| 2125 |
+
# ============================================================================
|
| 2126 |
+
# Session Messages + Diff Endpoints (Claude-Code-on-Web Parity)
|
| 2127 |
+
# ============================================================================
|
| 2128 |
+
|
| 2129 |
+
@app.post("/api/sessions/{session_id}/message")
|
| 2130 |
+
async def api_add_session_message(session_id: str, payload: dict):
|
| 2131 |
+
"""Add a message to a session's conversation history."""
|
| 2132 |
+
try:
|
| 2133 |
+
session = _session_mgr.load(session_id)
|
| 2134 |
+
except FileNotFoundError:
|
| 2135 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 2136 |
+
role = payload.get("role", "user")
|
| 2137 |
+
content = payload.get("content", "")
|
| 2138 |
+
session.add_message(role, content, **payload.get("metadata", {}))
|
| 2139 |
+
_session_mgr.save(session)
|
| 2140 |
+
return {"message_count": len(session.messages)}
|
| 2141 |
+
|
| 2142 |
+
|
| 2143 |
+
@app.get("/api/sessions/{session_id}/messages")
|
| 2144 |
+
async def api_get_session_messages(session_id: str):
|
| 2145 |
+
"""Get all messages for a session."""
|
| 2146 |
+
try:
|
| 2147 |
+
session = _session_mgr.load(session_id)
|
| 2148 |
+
except FileNotFoundError:
|
| 2149 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 2150 |
+
return {
|
| 2151 |
+
"session_id": session.id,
|
| 2152 |
+
"messages": [
|
| 2153 |
+
{
|
| 2154 |
+
"role": m.role,
|
| 2155 |
+
"content": m.content,
|
| 2156 |
+
"timestamp": m.timestamp,
|
| 2157 |
+
"metadata": m.metadata,
|
| 2158 |
+
}
|
| 2159 |
+
for m in session.messages
|
| 2160 |
+
],
|
| 2161 |
+
}
|
| 2162 |
+
|
| 2163 |
+
|
| 2164 |
+
@app.get("/api/sessions/{session_id}/diff")
|
| 2165 |
+
async def api_get_session_diff(session_id: str):
|
| 2166 |
+
"""Get diff stats for a session (placeholder for sandbox integration)."""
|
| 2167 |
+
try:
|
| 2168 |
+
session = _session_mgr.load(session_id)
|
| 2169 |
+
except FileNotFoundError:
|
| 2170 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 2171 |
+
diff = session.metadata.get("diff", {
|
| 2172 |
+
"files_changed": 0,
|
| 2173 |
+
"additions": 0,
|
| 2174 |
+
"deletions": 0,
|
| 2175 |
+
"files": [],
|
| 2176 |
+
})
|
| 2177 |
+
return {"session_id": session.id, "diff": diff}
|
| 2178 |
+
|
| 2179 |
+
|
| 2180 |
+
@app.post("/api/sessions/{session_id}/status")
|
| 2181 |
+
async def api_update_session_status(session_id: str, payload: dict):
|
| 2182 |
+
"""Update session status (active, completed, failed, waiting)."""
|
| 2183 |
+
try:
|
| 2184 |
+
session = _session_mgr.load(session_id)
|
| 2185 |
+
except FileNotFoundError:
|
| 2186 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 2187 |
+
new_status = payload.get("status", "active")
|
| 2188 |
+
if new_status not in ("active", "paused", "completed", "failed", "waiting"):
|
| 2189 |
+
raise HTTPException(status_code=400, detail="Invalid status")
|
| 2190 |
+
session.status = new_status
|
| 2191 |
+
_session_mgr.save(session)
|
| 2192 |
+
return {"session_id": session.id, "status": session.status}
|
| 2193 |
+
|
| 2194 |
+
|
| 2195 |
+
# ============================================================================
|
| 2196 |
+
# WebSocket Streaming Endpoint (Claude-Code-on-Web Parity)
|
| 2197 |
+
# ============================================================================
|
| 2198 |
+
|
| 2199 |
+
from fastapi import WebSocket, WebSocketDisconnect
|
| 2200 |
+
|
| 2201 |
+
|
| 2202 |
+
@app.websocket("/ws/sessions/{session_id}")
|
| 2203 |
+
async def session_websocket(websocket: WebSocket, session_id: str):
|
| 2204 |
+
"""
|
| 2205 |
+
Real-time bidirectional communication for a coding session.
|
| 2206 |
+
|
| 2207 |
+
Server events:
|
| 2208 |
+
{ type: "agent_message", content: "..." }
|
| 2209 |
+
{ type: "tool_use", tool: "bash", input: "npm test" }
|
| 2210 |
+
{ type: "tool_result", tool: "bash", output: "All tests passed" }
|
| 2211 |
+
{ type: "diff_update", stats: { additions: N, deletions: N, files: N } }
|
| 2212 |
+
{ type: "status_change", status: "completed" }
|
| 2213 |
+
{ type: "error", message: "..." }
|
| 2214 |
+
|
| 2215 |
+
Client events:
|
| 2216 |
+
{ type: "user_message", content: "..." }
|
| 2217 |
+
{ type: "cancel" }
|
| 2218 |
+
"""
|
| 2219 |
+
await websocket.accept()
|
| 2220 |
+
|
| 2221 |
+
# Verify session exists
|
| 2222 |
+
try:
|
| 2223 |
+
session = _session_mgr.load(session_id)
|
| 2224 |
+
except FileNotFoundError:
|
| 2225 |
+
await websocket.send_json({"type": "error", "message": "Session not found"})
|
| 2226 |
+
await websocket.close()
|
| 2227 |
+
return
|
| 2228 |
+
|
| 2229 |
+
# Send session history on connect
|
| 2230 |
+
await websocket.send_json({
|
| 2231 |
+
"type": "session_restored",
|
| 2232 |
+
"session_id": session.id,
|
| 2233 |
+
"status": session.status,
|
| 2234 |
+
"message_count": len(session.messages),
|
| 2235 |
+
})
|
| 2236 |
+
|
| 2237 |
+
try:
|
| 2238 |
+
while True:
|
| 2239 |
+
data = await websocket.receive_json()
|
| 2240 |
+
event_type = data.get("type", "")
|
| 2241 |
+
|
| 2242 |
+
if event_type == "user_message":
|
| 2243 |
+
content = data.get("content", "")
|
| 2244 |
+
session.add_message("user", content)
|
| 2245 |
+
_session_mgr.save(session)
|
| 2246 |
+
|
| 2247 |
+
# Acknowledge receipt
|
| 2248 |
+
await websocket.send_json({
|
| 2249 |
+
"type": "message_received",
|
| 2250 |
+
"message_index": len(session.messages) - 1,
|
| 2251 |
+
})
|
| 2252 |
+
|
| 2253 |
+
# Stream agent response (integration point for agentic.py)
|
| 2254 |
+
await websocket.send_json({
|
| 2255 |
+
"type": "status_change",
|
| 2256 |
+
"status": "active",
|
| 2257 |
+
})
|
| 2258 |
+
|
| 2259 |
+
# Agent processing hook — when the agent orchestrator is wired,
|
| 2260 |
+
# replace this with actual streaming from agentic.py
|
| 2261 |
+
try:
|
| 2262 |
+
repo_full = session.repo_full_name or ""
|
| 2263 |
+
parts = repo_full.split("/", 1)
|
| 2264 |
+
if len(parts) == 2 and content.strip():
|
| 2265 |
+
# Use canonical dispatcher signature
|
| 2266 |
+
result = await dispatch_request(
|
| 2267 |
+
user_request=content,
|
| 2268 |
+
repo_full_name=f"{parts[0]}/{parts[1]}",
|
| 2269 |
+
branch_name=session.branch,
|
| 2270 |
+
)
|
| 2271 |
+
answer = ""
|
| 2272 |
+
if isinstance(result, dict):
|
| 2273 |
+
answer = (
|
| 2274 |
+
result.get("result")
|
| 2275 |
+
or result.get("answer")
|
| 2276 |
+
or result.get("message")
|
| 2277 |
+
or result.get("summary")
|
| 2278 |
+
or (result.get("plan", {}) or {}).get("summary")
|
| 2279 |
+
or str(result)
|
| 2280 |
+
)
|
| 2281 |
+
else:
|
| 2282 |
+
answer = str(result)
|
| 2283 |
+
|
| 2284 |
+
# Stream the response
|
| 2285 |
+
await websocket.send_json({
|
| 2286 |
+
"type": "agent_message",
|
| 2287 |
+
"content": answer,
|
| 2288 |
+
})
|
| 2289 |
+
|
| 2290 |
+
session.add_message("assistant", answer)
|
| 2291 |
+
_session_mgr.save(session)
|
| 2292 |
+
else:
|
| 2293 |
+
await websocket.send_json({
|
| 2294 |
+
"type": "agent_message",
|
| 2295 |
+
"content": "Session is not connected to a repository.",
|
| 2296 |
+
})
|
| 2297 |
+
except Exception as agent_err:
|
| 2298 |
+
logger.error(f"Agent error in WS session {session_id}: {agent_err}")
|
| 2299 |
+
await websocket.send_json({
|
| 2300 |
+
"type": "error",
|
| 2301 |
+
"message": str(agent_err),
|
| 2302 |
+
})
|
| 2303 |
+
|
| 2304 |
+
await websocket.send_json({
|
| 2305 |
+
"type": "status_change",
|
| 2306 |
+
"status": "waiting",
|
| 2307 |
+
})
|
| 2308 |
+
|
| 2309 |
+
elif event_type == "cancel":
|
| 2310 |
+
await websocket.send_json({
|
| 2311 |
+
"type": "status_change",
|
| 2312 |
+
"status": "waiting",
|
| 2313 |
+
})
|
| 2314 |
+
|
| 2315 |
+
elif event_type == "ping":
|
| 2316 |
+
await websocket.send_json({"type": "pong"})
|
| 2317 |
+
|
| 2318 |
+
except WebSocketDisconnect:
|
| 2319 |
+
logger.info(f"WebSocket disconnected for session {session_id}")
|
| 2320 |
+
except Exception as e:
|
| 2321 |
+
logger.error(f"WebSocket error for session {session_id}: {e}")
|
| 2322 |
+
try:
|
| 2323 |
+
await websocket.send_json({"type": "error", "message": str(e)})
|
| 2324 |
+
except Exception:
|
| 2325 |
+
pass
|
| 2326 |
+
|
| 2327 |
+
|
| 2328 |
+
# ============================================================================
|
| 2329 |
+
# Static Files & Frontend Serving (SPA Support)
|
| 2330 |
+
# ============================================================================
|
| 2331 |
+
|
| 2332 |
+
STATIC_DIR = Path(__file__).resolve().parent / "web"
|
| 2333 |
+
ASSETS_DIR = STATIC_DIR / "assets"
|
| 2334 |
+
|
| 2335 |
+
if ASSETS_DIR.exists():
|
| 2336 |
+
app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets")
|
| 2337 |
+
|
| 2338 |
+
if STATIC_DIR.exists():
|
| 2339 |
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
| 2340 |
+
|
| 2341 |
+
|
| 2342 |
+
@app.get("/api/health")
|
| 2343 |
+
async def health_check():
|
| 2344 |
+
"""Health check endpoint for monitoring and diagnostics."""
|
| 2345 |
+
return {"status": "healthy", "service": "gitpilot-backend"}
|
| 2346 |
+
|
| 2347 |
+
|
| 2348 |
+
@app.get("/healthz")
|
| 2349 |
+
async def healthz():
|
| 2350 |
+
"""Health check endpoint (Render/Kubernetes standard)."""
|
| 2351 |
+
return {"status": "healthy", "service": "gitpilot-backend"}
|
| 2352 |
+
|
| 2353 |
+
|
| 2354 |
+
@app.get("/", include_in_schema=False)
|
| 2355 |
+
async def index():
|
| 2356 |
+
"""Serve the React App entry point."""
|
| 2357 |
+
index_file = STATIC_DIR / "index.html"
|
| 2358 |
+
if index_file.exists():
|
| 2359 |
+
return FileResponse(index_file)
|
| 2360 |
+
return JSONResponse(
|
| 2361 |
+
{"message": "GitPilot UI not built. The static files directory is missing."},
|
| 2362 |
+
status_code=500,
|
| 2363 |
+
)
|
| 2364 |
+
|
| 2365 |
+
|
| 2366 |
+
@app.get("/{full_path:path}", include_in_schema=False)
|
| 2367 |
+
async def catch_all_spa_routes(full_path: str):
|
| 2368 |
+
"""
|
| 2369 |
+
Catch-all route to serve index.html for frontend routing.
|
| 2370 |
+
Excludes '/api' paths to ensure genuine API 404s are returned as JSON.
|
| 2371 |
+
"""
|
| 2372 |
+
if full_path.startswith("api/"):
|
| 2373 |
+
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
| 2374 |
+
|
| 2375 |
+
index_file = STATIC_DIR / "index.html"
|
| 2376 |
+
if index_file.exists():
|
| 2377 |
+
return FileResponse(index_file)
|
| 2378 |
+
|
| 2379 |
+
return JSONResponse(
|
| 2380 |
+
{"message": "GitPilot UI not built. The static files directory is missing."},
|
| 2381 |
+
status_code=500,
|
| 2382 |
+
)
|
gitpilot/a2a_adapter.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Optional A2A adapter for GitPilot (MCP ContextForge compatible).
|
| 2 |
+
|
| 3 |
+
This module is feature-flagged. Nothing changes in GitPilot unless the main app
|
| 4 |
+
mounts this router when GITPILOT_ENABLE_A2A=true.
|
| 5 |
+
|
| 6 |
+
Supported protocols
|
| 7 |
+
- JSON-RPC 2.0 (preferred)
|
| 8 |
+
- ContextForge custom A2A envelope (fallback)
|
| 9 |
+
|
| 10 |
+
Security model (recommended)
|
| 11 |
+
- Gateway injects a shared secret:
|
| 12 |
+
X-A2A-Secret: <secret>
|
| 13 |
+
or
|
| 14 |
+
Authorization: Bearer <secret>
|
| 15 |
+
|
| 16 |
+
- GitHub token (if needed) should be provided via:
|
| 17 |
+
X-Github-Token: <token>
|
| 18 |
+
(avoid passing tokens in JSON bodies to reduce leak risk in logs)
|
| 19 |
+
|
| 20 |
+
Environment
|
| 21 |
+
- GITPILOT_A2A_REQUIRE_AUTH=true
|
| 22 |
+
- GITPILOT_A2A_SHARED_SECRET=<long random>
|
| 23 |
+
- GITPILOT_A2A_MAX_BODY_MB=2
|
| 24 |
+
- GITPILOT_A2A_ALLOW_GITHUB_TOKEN_IN_PARAMS=false
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import os
|
| 30 |
+
import time
|
| 31 |
+
import uuid
|
| 32 |
+
from typing import Any, Dict, Optional, Tuple
|
| 33 |
+
|
| 34 |
+
from fastapi import APIRouter, Header, HTTPException, Request
|
| 35 |
+
from fastapi.responses import JSONResponse
|
| 36 |
+
|
| 37 |
+
from .agentic import PlanResult, execute_plan, generate_plan, dispatch_request
|
| 38 |
+
from .github_api import get_file, get_repo_tree, github_request, put_file
|
| 39 |
+
from . import github_issues
|
| 40 |
+
from . import github_pulls
|
| 41 |
+
from . import github_search
|
| 42 |
+
|
| 43 |
+
router = APIRouter(tags=["a2a"])
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _env_bool(name: str, default: bool) -> bool:
|
| 47 |
+
raw = os.getenv(name)
|
| 48 |
+
if raw is None:
|
| 49 |
+
return default
|
| 50 |
+
return raw.strip().lower() in {"1", "true", "yes", "y", "on"}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _env_int(name: str, default: int) -> int:
|
| 54 |
+
raw = os.getenv(name)
|
| 55 |
+
if raw is None:
|
| 56 |
+
return default
|
| 57 |
+
try:
|
| 58 |
+
return int(raw.strip())
|
| 59 |
+
except Exception:
|
| 60 |
+
return default
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _extract_bearer(value: Optional[str]) -> Optional[str]:
|
| 64 |
+
if not value:
|
| 65 |
+
return None
|
| 66 |
+
if value.startswith("Bearer "):
|
| 67 |
+
return value[7:]
|
| 68 |
+
if value.startswith("token "):
|
| 69 |
+
return value[6:]
|
| 70 |
+
return value
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _get_trace_id(x_request_id: Optional[str]) -> str:
|
| 74 |
+
return (x_request_id or "").strip() or str(uuid.uuid4())
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _require_gateway_secret(authorization: Optional[str], x_a2a_secret: Optional[str]) -> None:
|
| 78 |
+
require_auth = _env_bool("GITPILOT_A2A_REQUIRE_AUTH", True)
|
| 79 |
+
if not require_auth:
|
| 80 |
+
return
|
| 81 |
+
|
| 82 |
+
expected = os.getenv("GITPILOT_A2A_SHARED_SECRET", "").strip()
|
| 83 |
+
if not expected:
|
| 84 |
+
raise HTTPException(
|
| 85 |
+
status_code=500,
|
| 86 |
+
detail="A2A is enabled but GITPILOT_A2A_SHARED_SECRET is not set",
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
candidate = _extract_bearer(authorization) or (x_a2a_secret or "").strip()
|
| 90 |
+
if not candidate or candidate != expected:
|
| 91 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _split_full_name(repo_full_name: str) -> Tuple[str, str]:
|
| 95 |
+
if not repo_full_name or "/" not in repo_full_name:
|
| 96 |
+
raise HTTPException(status_code=400, detail="repo_full_name must be 'owner/repo'")
|
| 97 |
+
owner, repo = repo_full_name.split("/", 1)
|
| 98 |
+
owner, repo = owner.strip(), repo.strip()
|
| 99 |
+
if not owner or not repo:
|
| 100 |
+
raise HTTPException(status_code=400, detail="repo_full_name must be 'owner/repo'")
|
| 101 |
+
return owner, repo
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _jsonrpc_error(id_value: Any, code: int, message: str, data: Any = None) -> Dict[str, Any]:
|
| 105 |
+
err: Dict[str, Any] = {"code": code, "message": message}
|
| 106 |
+
if data is not None:
|
| 107 |
+
err["data"] = data
|
| 108 |
+
return {"jsonrpc": "2.0", "error": err, "id": id_value}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _jsonrpc_result(id_value: Any, result: Any) -> Dict[str, Any]:
|
| 112 |
+
return {"jsonrpc": "2.0", "result": result, "id": id_value}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
async def _dispatch(method: str, params: Dict[str, Any], github_token: Optional[str]) -> Any:
|
| 116 |
+
if method == "repo.connect":
|
| 117 |
+
repo_full_name = params.get("repo_full_name")
|
| 118 |
+
owner, repo = _split_full_name(str(repo_full_name))
|
| 119 |
+
info = await github_request(f"/repos/{owner}/{repo}", token=github_token)
|
| 120 |
+
return {
|
| 121 |
+
"repo": {
|
| 122 |
+
"id": info.get("id"),
|
| 123 |
+
"full_name": info.get("full_name"),
|
| 124 |
+
"private": info.get("private"),
|
| 125 |
+
"html_url": info.get("html_url"),
|
| 126 |
+
},
|
| 127 |
+
"default_branch": info.get("default_branch"),
|
| 128 |
+
"permissions": info.get("permissions"),
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
if method == "repo.tree":
|
| 132 |
+
repo_full_name = params.get("repo_full_name")
|
| 133 |
+
ref = (params.get("ref") or "").strip() or "HEAD"
|
| 134 |
+
owner, repo = _split_full_name(str(repo_full_name))
|
| 135 |
+
tree = await get_repo_tree(owner, repo, token=github_token, ref=ref)
|
| 136 |
+
return {"entries": tree, "ref": ref}
|
| 137 |
+
|
| 138 |
+
if method == "repo.read":
|
| 139 |
+
repo_full_name = params.get("repo_full_name")
|
| 140 |
+
path = params.get("path")
|
| 141 |
+
if not path:
|
| 142 |
+
raise HTTPException(status_code=400, detail="Missing required param: path")
|
| 143 |
+
owner, repo = _split_full_name(str(repo_full_name))
|
| 144 |
+
# NOTE: current get_file() reads from default branch/ref in this repo.
|
| 145 |
+
# You can extend github_api.get_file to accept ref and pass it here later.
|
| 146 |
+
content = await get_file(owner, repo, str(path), token=github_token)
|
| 147 |
+
return {"path": str(path), "content": content, "encoding": "utf-8"}
|
| 148 |
+
|
| 149 |
+
if method == "repo.write":
|
| 150 |
+
repo_full_name = params.get("repo_full_name")
|
| 151 |
+
path = params.get("path")
|
| 152 |
+
content = params.get("content")
|
| 153 |
+
message = params.get("message") or "Update via GitPilot A2A"
|
| 154 |
+
branch = params.get("branch") or params.get("branch_name")
|
| 155 |
+
if not path:
|
| 156 |
+
raise HTTPException(status_code=400, detail="Missing required param: path")
|
| 157 |
+
if content is None:
|
| 158 |
+
raise HTTPException(status_code=400, detail="Missing required param: content")
|
| 159 |
+
owner, repo = _split_full_name(str(repo_full_name))
|
| 160 |
+
result = await put_file(
|
| 161 |
+
owner,
|
| 162 |
+
repo,
|
| 163 |
+
str(path),
|
| 164 |
+
str(content),
|
| 165 |
+
str(message),
|
| 166 |
+
token=github_token,
|
| 167 |
+
branch=branch,
|
| 168 |
+
)
|
| 169 |
+
return result
|
| 170 |
+
|
| 171 |
+
if method == "plan.generate":
|
| 172 |
+
repo_full_name = params.get("repo_full_name")
|
| 173 |
+
goal = params.get("goal")
|
| 174 |
+
branch_name = params.get("branch") or params.get("branch_name")
|
| 175 |
+
if not goal:
|
| 176 |
+
raise HTTPException(status_code=400, detail="Missing required param: goal")
|
| 177 |
+
if not repo_full_name:
|
| 178 |
+
raise HTTPException(status_code=400, detail="Missing required param: repo_full_name")
|
| 179 |
+
plan = await generate_plan(str(goal), str(repo_full_name), token=github_token, branch_name=branch_name)
|
| 180 |
+
return plan.model_dump() if hasattr(plan, "model_dump") else plan
|
| 181 |
+
|
| 182 |
+
if method == "plan.execute":
|
| 183 |
+
repo_full_name = params.get("repo_full_name")
|
| 184 |
+
branch_name = params.get("branch") or params.get("branch_name")
|
| 185 |
+
plan_raw = params.get("plan")
|
| 186 |
+
if not repo_full_name:
|
| 187 |
+
raise HTTPException(status_code=400, detail="Missing required param: repo_full_name")
|
| 188 |
+
if plan_raw is None:
|
| 189 |
+
raise HTTPException(status_code=400, detail="Missing required param: plan")
|
| 190 |
+
if isinstance(plan_raw, PlanResult):
|
| 191 |
+
plan_obj = plan_raw
|
| 192 |
+
else:
|
| 193 |
+
try:
|
| 194 |
+
plan_obj = PlanResult.model_validate(plan_raw) # pydantic v2
|
| 195 |
+
except Exception:
|
| 196 |
+
plan_obj = PlanResult.parse_obj(plan_raw) # pydantic v1
|
| 197 |
+
result = await execute_plan(plan_obj, str(repo_full_name), token=github_token, branch_name=branch_name)
|
| 198 |
+
return result
|
| 199 |
+
|
| 200 |
+
if method == "repo.search":
|
| 201 |
+
query = params.get("query")
|
| 202 |
+
if not query:
|
| 203 |
+
raise HTTPException(status_code=400, detail="Missing required param: query")
|
| 204 |
+
result = await github_request(
|
| 205 |
+
"/search/repositories",
|
| 206 |
+
params={"q": str(query), "per_page": 20},
|
| 207 |
+
token=github_token,
|
| 208 |
+
)
|
| 209 |
+
items = (result or {}).get("items", []) if isinstance(result, dict) else []
|
| 210 |
+
return {
|
| 211 |
+
"repos": [
|
| 212 |
+
{
|
| 213 |
+
"full_name": i.get("full_name"),
|
| 214 |
+
"private": i.get("private"),
|
| 215 |
+
"html_url": i.get("html_url"),
|
| 216 |
+
"description": i.get("description"),
|
| 217 |
+
"default_branch": i.get("default_branch"),
|
| 218 |
+
}
|
| 219 |
+
for i in items
|
| 220 |
+
]
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
# --- v2 methods: issues, pulls, search, chat --------------------------
|
| 224 |
+
|
| 225 |
+
if method == "issue.list":
|
| 226 |
+
repo_full_name = params.get("repo_full_name")
|
| 227 |
+
owner, repo = _split_full_name(str(repo_full_name))
|
| 228 |
+
issues = await github_issues.list_issues(
|
| 229 |
+
owner, repo, state=params.get("state", "open"),
|
| 230 |
+
labels=params.get("labels"), per_page=params.get("per_page", 30),
|
| 231 |
+
token=github_token,
|
| 232 |
+
)
|
| 233 |
+
return {"issues": issues}
|
| 234 |
+
|
| 235 |
+
if method == "issue.get":
|
| 236 |
+
repo_full_name = params.get("repo_full_name")
|
| 237 |
+
issue_number = params.get("issue_number")
|
| 238 |
+
if not issue_number:
|
| 239 |
+
raise HTTPException(status_code=400, detail="Missing required param: issue_number")
|
| 240 |
+
owner, repo = _split_full_name(str(repo_full_name))
|
| 241 |
+
return await github_issues.get_issue(owner, repo, int(issue_number), token=github_token)
|
| 242 |
+
|
| 243 |
+
if method == "issue.create":
|
| 244 |
+
repo_full_name = params.get("repo_full_name")
|
| 245 |
+
title = params.get("title")
|
| 246 |
+
if not title:
|
| 247 |
+
raise HTTPException(status_code=400, detail="Missing required param: title")
|
| 248 |
+
owner, repo = _split_full_name(str(repo_full_name))
|
| 249 |
+
return await github_issues.create_issue(
|
| 250 |
+
owner, repo, str(title),
|
| 251 |
+
body=params.get("body"), labels=params.get("labels"),
|
| 252 |
+
assignees=params.get("assignees"), token=github_token,
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
if method == "issue.update":
|
| 256 |
+
repo_full_name = params.get("repo_full_name")
|
| 257 |
+
issue_number = params.get("issue_number")
|
| 258 |
+
if not issue_number:
|
| 259 |
+
raise HTTPException(status_code=400, detail="Missing required param: issue_number")
|
| 260 |
+
owner, repo = _split_full_name(str(repo_full_name))
|
| 261 |
+
return await github_issues.update_issue(
|
| 262 |
+
owner, repo, int(issue_number),
|
| 263 |
+
title=params.get("title"), body=params.get("body"),
|
| 264 |
+
state=params.get("state"), labels=params.get("labels"),
|
| 265 |
+
assignees=params.get("assignees"), token=github_token,
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
if method == "issue.comment":
|
| 269 |
+
repo_full_name = params.get("repo_full_name")
|
| 270 |
+
issue_number = params.get("issue_number")
|
| 271 |
+
body = params.get("body")
|
| 272 |
+
if not issue_number:
|
| 273 |
+
raise HTTPException(status_code=400, detail="Missing required param: issue_number")
|
| 274 |
+
if not body:
|
| 275 |
+
raise HTTPException(status_code=400, detail="Missing required param: body")
|
| 276 |
+
owner, repo = _split_full_name(str(repo_full_name))
|
| 277 |
+
return await github_issues.add_issue_comment(
|
| 278 |
+
owner, repo, int(issue_number), str(body), token=github_token,
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
if method == "pr.list":
|
| 282 |
+
repo_full_name = params.get("repo_full_name")
|
| 283 |
+
owner, repo = _split_full_name(str(repo_full_name))
|
| 284 |
+
return await github_pulls.list_pull_requests(
|
| 285 |
+
owner, repo, state=params.get("state", "open"),
|
| 286 |
+
per_page=params.get("per_page", 30), token=github_token,
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
if method == "pr.create":
|
| 290 |
+
repo_full_name = params.get("repo_full_name")
|
| 291 |
+
title = params.get("title")
|
| 292 |
+
head = params.get("head")
|
| 293 |
+
base = params.get("base")
|
| 294 |
+
if not title or not head or not base:
|
| 295 |
+
raise HTTPException(status_code=400, detail="Missing required params: title, head, base")
|
| 296 |
+
owner, repo = _split_full_name(str(repo_full_name))
|
| 297 |
+
return await github_pulls.create_pull_request(
|
| 298 |
+
owner, repo, title=str(title), head=str(head), base=str(base),
|
| 299 |
+
body=params.get("body"), token=github_token,
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
if method == "pr.merge":
|
| 303 |
+
repo_full_name = params.get("repo_full_name")
|
| 304 |
+
pull_number = params.get("pull_number")
|
| 305 |
+
if not pull_number:
|
| 306 |
+
raise HTTPException(status_code=400, detail="Missing required param: pull_number")
|
| 307 |
+
owner, repo = _split_full_name(str(repo_full_name))
|
| 308 |
+
return await github_pulls.merge_pull_request(
|
| 309 |
+
owner, repo, int(pull_number),
|
| 310 |
+
merge_method=params.get("merge_method", "merge"),
|
| 311 |
+
token=github_token,
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
if method == "search.code":
|
| 315 |
+
query = params.get("query")
|
| 316 |
+
if not query:
|
| 317 |
+
raise HTTPException(status_code=400, detail="Missing required param: query")
|
| 318 |
+
return await github_search.search_code(
|
| 319 |
+
str(query), owner=params.get("owner"), repo=params.get("repo"),
|
| 320 |
+
language=params.get("language"), per_page=params.get("per_page", 20),
|
| 321 |
+
token=github_token,
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
if method == "search.issues":
|
| 325 |
+
query = params.get("query")
|
| 326 |
+
if not query:
|
| 327 |
+
raise HTTPException(status_code=400, detail="Missing required param: query")
|
| 328 |
+
return await github_search.search_issues(
|
| 329 |
+
str(query), owner=params.get("owner"), repo=params.get("repo"),
|
| 330 |
+
state=params.get("state"), label=params.get("label"),
|
| 331 |
+
per_page=params.get("per_page", 20), token=github_token,
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
if method == "search.users":
|
| 335 |
+
query = params.get("query")
|
| 336 |
+
if not query:
|
| 337 |
+
raise HTTPException(status_code=400, detail="Missing required param: query")
|
| 338 |
+
return await github_search.search_users(
|
| 339 |
+
str(query), type_filter=params.get("type"),
|
| 340 |
+
location=params.get("location"), language=params.get("language"),
|
| 341 |
+
per_page=params.get("per_page", 20), token=github_token,
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
if method == "chat.message":
|
| 345 |
+
repo_full_name = params.get("repo_full_name")
|
| 346 |
+
message = params.get("message")
|
| 347 |
+
if not message:
|
| 348 |
+
raise HTTPException(status_code=400, detail="Missing required param: message")
|
| 349 |
+
if not repo_full_name:
|
| 350 |
+
raise HTTPException(status_code=400, detail="Missing required param: repo_full_name")
|
| 351 |
+
return await dispatch_request(
|
| 352 |
+
str(message), str(repo_full_name),
|
| 353 |
+
token=github_token,
|
| 354 |
+
branch_name=params.get("branch") or params.get("branch_name"),
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
raise HTTPException(status_code=404, detail=f"Unknown method: {method}")
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
@router.get("/a2a/health")
|
| 361 |
+
async def a2a_health() -> Dict[str, Any]:
|
| 362 |
+
return {"status": "ok", "ts": int(time.time())}
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
@router.get("/a2a/manifest")
|
| 366 |
+
async def a2a_manifest() -> Dict[str, Any]:
|
| 367 |
+
# Best-effort schemas (kept intentionally simple and stable)
|
| 368 |
+
return {
|
| 369 |
+
"name": "gitpilot",
|
| 370 |
+
"a2a_version": "1.0",
|
| 371 |
+
"protocols": ["jsonrpc-2.0", "a2a-envelope-1.0"],
|
| 372 |
+
"auth": {"type": "shared_secret", "header": "X-A2A-Secret"},
|
| 373 |
+
"rate_limits": {"hint": "apply gateway rate limiting; server enforces body size"},
|
| 374 |
+
"methods": {
|
| 375 |
+
"repo.connect": {
|
| 376 |
+
"params": {"repo_full_name": "string"},
|
| 377 |
+
"result": {"repo": "object", "default_branch": "string", "permissions": "object?"},
|
| 378 |
+
},
|
| 379 |
+
"repo.tree": {
|
| 380 |
+
"params": {"repo_full_name": "string", "ref": "string?"},
|
| 381 |
+
"result": {"entries": "array", "ref": "string"},
|
| 382 |
+
},
|
| 383 |
+
"repo.read": {
|
| 384 |
+
"params": {"repo_full_name": "string", "path": "string"},
|
| 385 |
+
"result": {"path": "string", "content": "string"},
|
| 386 |
+
},
|
| 387 |
+
"repo.write": {
|
| 388 |
+
"params": {
|
| 389 |
+
"repo_full_name": "string",
|
| 390 |
+
"path": "string",
|
| 391 |
+
"content": "string",
|
| 392 |
+
"message": "string?",
|
| 393 |
+
"branch": "string?",
|
| 394 |
+
},
|
| 395 |
+
"result": "object",
|
| 396 |
+
},
|
| 397 |
+
"plan.generate": {
|
| 398 |
+
"params": {"repo_full_name": "string", "goal": "string", "branch": "string?"},
|
| 399 |
+
"result": "PlanResult",
|
| 400 |
+
},
|
| 401 |
+
"plan.execute": {
|
| 402 |
+
"params": {"repo_full_name": "string", "plan": "PlanResult", "branch": "string?"},
|
| 403 |
+
"result": "object",
|
| 404 |
+
},
|
| 405 |
+
"repo.search": {
|
| 406 |
+
"params": {"query": "string"},
|
| 407 |
+
"result": {"repos": "array"},
|
| 408 |
+
},
|
| 409 |
+
# v2 methods
|
| 410 |
+
"issue.list": {
|
| 411 |
+
"params": {"repo_full_name": "string", "state": "string?", "labels": "string?"},
|
| 412 |
+
"result": {"issues": "array"},
|
| 413 |
+
},
|
| 414 |
+
"issue.get": {
|
| 415 |
+
"params": {"repo_full_name": "string", "issue_number": "integer"},
|
| 416 |
+
"result": "object",
|
| 417 |
+
},
|
| 418 |
+
"issue.create": {
|
| 419 |
+
"params": {"repo_full_name": "string", "title": "string", "body": "string?", "labels": "array?", "assignees": "array?"},
|
| 420 |
+
"result": "object",
|
| 421 |
+
},
|
| 422 |
+
"issue.update": {
|
| 423 |
+
"params": {"repo_full_name": "string", "issue_number": "integer", "title": "string?", "body": "string?", "state": "string?"},
|
| 424 |
+
"result": "object",
|
| 425 |
+
},
|
| 426 |
+
"issue.comment": {
|
| 427 |
+
"params": {"repo_full_name": "string", "issue_number": "integer", "body": "string"},
|
| 428 |
+
"result": "object",
|
| 429 |
+
},
|
| 430 |
+
"pr.list": {
|
| 431 |
+
"params": {"repo_full_name": "string", "state": "string?"},
|
| 432 |
+
"result": "array",
|
| 433 |
+
},
|
| 434 |
+
"pr.create": {
|
| 435 |
+
"params": {"repo_full_name": "string", "title": "string", "head": "string", "base": "string", "body": "string?"},
|
| 436 |
+
"result": "object",
|
| 437 |
+
},
|
| 438 |
+
"pr.merge": {
|
| 439 |
+
"params": {"repo_full_name": "string", "pull_number": "integer", "merge_method": "string?"},
|
| 440 |
+
"result": "object",
|
| 441 |
+
},
|
| 442 |
+
"search.code": {
|
| 443 |
+
"params": {"query": "string", "owner": "string?", "repo": "string?", "language": "string?"},
|
| 444 |
+
"result": {"total_count": "integer", "items": "array"},
|
| 445 |
+
},
|
| 446 |
+
"search.issues": {
|
| 447 |
+
"params": {"query": "string", "owner": "string?", "repo": "string?", "state": "string?"},
|
| 448 |
+
"result": {"total_count": "integer", "items": "array"},
|
| 449 |
+
},
|
| 450 |
+
"search.users": {
|
| 451 |
+
"params": {"query": "string", "type": "string?", "location": "string?"},
|
| 452 |
+
"result": {"total_count": "integer", "items": "array"},
|
| 453 |
+
},
|
| 454 |
+
"chat.message": {
|
| 455 |
+
"params": {"repo_full_name": "string", "message": "string", "branch": "string?"},
|
| 456 |
+
"result": "object",
|
| 457 |
+
},
|
| 458 |
+
},
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
async def _handle_invoke(
|
| 463 |
+
request: Request,
|
| 464 |
+
authorization: Optional[str],
|
| 465 |
+
x_a2a_secret: Optional[str],
|
| 466 |
+
x_github_token: Optional[str],
|
| 467 |
+
x_request_id: Optional[str],
|
| 468 |
+
) -> JSONResponse:
|
| 469 |
+
trace_id = _get_trace_id(x_request_id)
|
| 470 |
+
_require_gateway_secret(authorization=authorization, x_a2a_secret=x_a2a_secret)
|
| 471 |
+
|
| 472 |
+
# Body size guard (helps protect from abuse)
|
| 473 |
+
max_mb = _env_int("GITPILOT_A2A_MAX_BODY_MB", 2)
|
| 474 |
+
cl = request.headers.get("content-length")
|
| 475 |
+
if cl:
|
| 476 |
+
try:
|
| 477 |
+
if int(cl) > max_mb * 1024 * 1024:
|
| 478 |
+
raise HTTPException(status_code=413, detail="Request entity too large")
|
| 479 |
+
except ValueError:
|
| 480 |
+
pass
|
| 481 |
+
|
| 482 |
+
started = time.time()
|
| 483 |
+
payload = await request.json()
|
| 484 |
+
|
| 485 |
+
github_token = _extract_bearer(x_github_token) or None
|
| 486 |
+
if not github_token:
|
| 487 |
+
github_token = _extract_bearer(authorization)
|
| 488 |
+
|
| 489 |
+
# JSON-RPC mode
|
| 490 |
+
if isinstance(payload, dict) and payload.get("jsonrpc") == "2.0" and "method" in payload:
|
| 491 |
+
rpc_id = payload.get("id")
|
| 492 |
+
method = payload.get("method")
|
| 493 |
+
params = payload.get("params") or {}
|
| 494 |
+
if not isinstance(params, dict):
|
| 495 |
+
return JSONResponse(_jsonrpc_error(rpc_id, -32602, "Invalid params"), status_code=400)
|
| 496 |
+
|
| 497 |
+
allow_in_params = _env_bool("GITPILOT_A2A_ALLOW_GITHUB_TOKEN_IN_PARAMS", False)
|
| 498 |
+
if allow_in_params and not github_token:
|
| 499 |
+
github_token = _extract_bearer(params.get("github_token"))
|
| 500 |
+
|
| 501 |
+
try:
|
| 502 |
+
result = await _dispatch(str(method), params, github_token)
|
| 503 |
+
resp = _jsonrpc_result(rpc_id, result)
|
| 504 |
+
return JSONResponse(resp, headers={"X-Trace-Id": trace_id})
|
| 505 |
+
except HTTPException as e:
|
| 506 |
+
resp = _jsonrpc_error(rpc_id, e.status_code, str(e.detail), {"trace_id": trace_id})
|
| 507 |
+
return JSONResponse(resp, status_code=200, headers={"X-Trace-Id": trace_id})
|
| 508 |
+
except Exception as e:
|
| 509 |
+
resp = _jsonrpc_error(rpc_id, -32000, "Server error", {"trace_id": trace_id, "error": str(e)})
|
| 510 |
+
return JSONResponse(resp, status_code=200, headers={"X-Trace-Id": trace_id})
|
| 511 |
+
finally:
|
| 512 |
+
_ = time.time() - started
|
| 513 |
+
|
| 514 |
+
# Custom envelope fallback
|
| 515 |
+
if isinstance(payload, dict) and payload.get("interaction_type"):
|
| 516 |
+
interaction_type = str(payload.get("interaction_type"))
|
| 517 |
+
parameters = payload.get("parameters") or {}
|
| 518 |
+
if not isinstance(parameters, dict):
|
| 519 |
+
raise HTTPException(status_code=400, detail="Invalid parameters")
|
| 520 |
+
|
| 521 |
+
if interaction_type == "query":
|
| 522 |
+
repo_full_name = parameters.get("repo_full_name")
|
| 523 |
+
goal = parameters.get("query") or parameters.get("goal")
|
| 524 |
+
params = {
|
| 525 |
+
"repo_full_name": repo_full_name,
|
| 526 |
+
"goal": goal,
|
| 527 |
+
"branch": parameters.get("branch") or parameters.get("branch_name"),
|
| 528 |
+
}
|
| 529 |
+
result = await _dispatch("plan.generate", params, github_token)
|
| 530 |
+
return JSONResponse(
|
| 531 |
+
{"response": result, "protocol_version": payload.get("protocol_version", "1.0")},
|
| 532 |
+
headers={"X-Trace-Id": trace_id},
|
| 533 |
+
)
|
| 534 |
+
|
| 535 |
+
raise HTTPException(status_code=404, detail=f"Unsupported interaction_type: {interaction_type}")
|
| 536 |
+
|
| 537 |
+
raise HTTPException(status_code=400, detail=f"Invalid A2A payload (trace_id={trace_id})")
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
@router.post("/a2a/invoke")
|
| 541 |
+
async def a2a_invoke(
|
| 542 |
+
request: Request,
|
| 543 |
+
authorization: Optional[str] = Header(None),
|
| 544 |
+
x_a2a_secret: Optional[str] = Header(None, alias="X-A2A-Secret"),
|
| 545 |
+
x_github_token: Optional[str] = Header(None, alias="X-Github-Token"),
|
| 546 |
+
x_request_id: Optional[str] = Header(None, alias="X-Request-Id"),
|
| 547 |
+
) -> JSONResponse:
|
| 548 |
+
return await _handle_invoke(request, authorization, x_a2a_secret, x_github_token, x_request_id)
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
@router.post("/a2a/v1/invoke")
|
| 552 |
+
async def a2a_v1_invoke(
|
| 553 |
+
request: Request,
|
| 554 |
+
authorization: Optional[str] = Header(None),
|
| 555 |
+
x_a2a_secret: Optional[str] = Header(None, alias="X-A2A-Secret"),
|
| 556 |
+
x_github_token: Optional[str] = Header(None, alias="X-Github-Token"),
|
| 557 |
+
x_request_id: Optional[str] = Header(None, alias="X-Request-Id"),
|
| 558 |
+
) -> JSONResponse:
|
| 559 |
+
# Alias for versioned clients. Keep behavior identical to /a2a/invoke.
|
| 560 |
+
return await _handle_invoke(request, authorization, x_a2a_secret, x_github_token, x_request_id)
|
gitpilot/agent_router.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# gitpilot/agent_router.py
|
| 2 |
+
"""Intelligent Agent Router for GitPilot.
|
| 3 |
+
|
| 4 |
+
Classifies user requests and delegates them to the appropriate specialised
|
| 5 |
+
agent (or a pipeline of agents). The router itself does **not** use an LLM;
|
| 6 |
+
it relies on lightweight keyword / pattern matching so that routing is
|
| 7 |
+
instantaneous and deterministic.
|
| 8 |
+
|
| 9 |
+
The router returns a *WorkflowPlan* describing which agents should run and
|
| 10 |
+
in what order. The actual agent execution is handled by the orchestrator
|
| 11 |
+
in ``agentic.py``.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import re
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from enum import Enum
|
| 18 |
+
from typing import List, Optional
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class AgentType(str, Enum):
|
| 22 |
+
"""Available specialised agents."""
|
| 23 |
+
|
| 24 |
+
EXPLORER = "explorer"
|
| 25 |
+
PLANNER = "planner"
|
| 26 |
+
CODE_WRITER = "code_writer"
|
| 27 |
+
CODE_REVIEWER = "code_reviewer"
|
| 28 |
+
ISSUE_MANAGER = "issue_manager"
|
| 29 |
+
PR_MANAGER = "pr_manager"
|
| 30 |
+
SEARCH = "search"
|
| 31 |
+
LEARNING = "learning"
|
| 32 |
+
LOCAL_EDITOR = "local_editor" # Phase 1: local file editing + shell
|
| 33 |
+
TERMINAL = "terminal" # Phase 1: dedicated terminal agent
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class RequestCategory(str, Enum):
|
| 37 |
+
"""High-level intent category inferred from the user request."""
|
| 38 |
+
|
| 39 |
+
PLAN_EXECUTE = "plan_execute" # Existing explore -> plan -> execute workflow
|
| 40 |
+
ISSUE_MANAGEMENT = "issue_management"
|
| 41 |
+
PR_MANAGEMENT = "pr_management"
|
| 42 |
+
CODE_SEARCH = "code_search"
|
| 43 |
+
CODE_REVIEW = "code_review"
|
| 44 |
+
LEARNING = "learning"
|
| 45 |
+
CONVERSATIONAL = "conversational" # Free-form chat / Q&A about the repo
|
| 46 |
+
LOCAL_EDIT = "local_edit" # Phase 1: direct file editing with verification
|
| 47 |
+
TERMINAL = "terminal" # Phase 1: shell command execution
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class WorkflowPlan:
|
| 52 |
+
"""Describes which agents to invoke and in what order."""
|
| 53 |
+
|
| 54 |
+
category: RequestCategory
|
| 55 |
+
agents: List[AgentType]
|
| 56 |
+
description: str
|
| 57 |
+
requires_repo_context: bool = True
|
| 58 |
+
# If the request mentions a specific issue/PR number, capture it.
|
| 59 |
+
entity_number: Optional[int] = None
|
| 60 |
+
# Additional metadata extracted from the request.
|
| 61 |
+
metadata: dict = field(default_factory=dict)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
# Pattern definitions (order matters -- first match wins)
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
|
| 68 |
+
_ISSUE_CREATE_RE = re.compile(
|
| 69 |
+
r"\b(create|open|new|file|add)\b.*\bissue\b", re.IGNORECASE
|
| 70 |
+
)
|
| 71 |
+
_ISSUE_UPDATE_RE = re.compile(
|
| 72 |
+
r"\b(update|modify|edit|change|close|reopen|label|assign|milestone)\b.*\bissue\b",
|
| 73 |
+
re.IGNORECASE,
|
| 74 |
+
)
|
| 75 |
+
_ISSUE_LIST_RE = re.compile(
|
| 76 |
+
r"\b(list|show|get|find|search)\b.*\bissues?\b", re.IGNORECASE
|
| 77 |
+
)
|
| 78 |
+
_ISSUE_COMMENT_RE = re.compile(
|
| 79 |
+
r"\b(comment|reply|respond)\b.*\bissue\b", re.IGNORECASE
|
| 80 |
+
)
|
| 81 |
+
_ISSUE_NUMBER_RE = re.compile(r"#(\d+)")
|
| 82 |
+
|
| 83 |
+
_PR_CREATE_RE = re.compile(
|
| 84 |
+
r"\b(create|open|new|make)\b.*\b(pull request|pr|pull)\b", re.IGNORECASE
|
| 85 |
+
)
|
| 86 |
+
_PR_MERGE_RE = re.compile(
|
| 87 |
+
r"\b(merge|squash|rebase)\b.*\b(pull request|pr|pull)\b", re.IGNORECASE
|
| 88 |
+
)
|
| 89 |
+
_PR_REVIEW_RE = re.compile(
|
| 90 |
+
r"\b(review|approve|request changes)\b.*\b(pull request|pr|pull)\b",
|
| 91 |
+
re.IGNORECASE,
|
| 92 |
+
)
|
| 93 |
+
_PR_LIST_RE = re.compile(
|
| 94 |
+
r"\b(list|show|get|find)\b.*\b(pull requests?|prs?|pulls?)\b", re.IGNORECASE
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
_SEARCH_CODE_RE = re.compile(
|
| 98 |
+
r"\b(search|find|locate|grep|look for)\b.*\b(code|function|class|symbol|pattern|file)\b",
|
| 99 |
+
re.IGNORECASE,
|
| 100 |
+
)
|
| 101 |
+
_SEARCH_USER_RE = re.compile(
|
| 102 |
+
r"\b(search|find|who)\b.*\b(user|developer|org|organization|contributor)\b",
|
| 103 |
+
re.IGNORECASE,
|
| 104 |
+
)
|
| 105 |
+
_SEARCH_REPO_RE = re.compile(
|
| 106 |
+
r"\b(search|find|discover)\b.*\b(repo|repository|project)\b", re.IGNORECASE
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
_TERMINAL_RE = re.compile(
|
| 110 |
+
r"\b(run|execute|launch)\b.*\b(command|test|tests|script|build|lint|npm|pip|make|docker|pytest|cargo|go)\b",
|
| 111 |
+
re.IGNORECASE,
|
| 112 |
+
)
|
| 113 |
+
_LOCAL_EDIT_RE = re.compile(
|
| 114 |
+
r"\b(edit|modify|change|update|fix|write|rewrite|patch)\b.*\b(file|code|function|class|method|module|line|lines)\b",
|
| 115 |
+
re.IGNORECASE,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
_REVIEW_RE = re.compile(
|
| 119 |
+
r"\b(review|analyze|audit|check|inspect)\b.*\b(code|quality|security|performance)\b",
|
| 120 |
+
re.IGNORECASE,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
_LEARNING_RE = re.compile(
|
| 124 |
+
r"\b(how (do|can|to)|explain|what is|guide|tutorial|best practice|help with)\b",
|
| 125 |
+
re.IGNORECASE,
|
| 126 |
+
)
|
| 127 |
+
_GITHUB_TOPICS_RE = re.compile(
|
| 128 |
+
r"\b(actions?|workflow|ci/?cd|pages?|packages?|discussions?|authentication|deploy|release)\b",
|
| 129 |
+
re.IGNORECASE,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _extract_issue_number(text: str) -> Optional[int]:
|
| 134 |
+
m = _ISSUE_NUMBER_RE.search(text)
|
| 135 |
+
if m:
|
| 136 |
+
return int(m.group(1))
|
| 137 |
+
# Also try "issue 42" / "issue number 42"
|
| 138 |
+
m2 = re.search(r"\bissue\s*(?:number\s*)?(\d+)\b", text, re.IGNORECASE)
|
| 139 |
+
return int(m2.group(1)) if m2 else None
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _extract_pr_number(text: str) -> Optional[int]:
|
| 143 |
+
m = re.search(r"\b(?:pr|pull request|pull)\s*#?(\d+)\b", text, re.IGNORECASE)
|
| 144 |
+
return int(m.group(1)) if m else None
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
# Public API
|
| 149 |
+
# ---------------------------------------------------------------------------
|
| 150 |
+
|
| 151 |
+
def route(user_request: str) -> WorkflowPlan:
|
| 152 |
+
"""Classify *user_request* and return a ``WorkflowPlan``."""
|
| 153 |
+
text = user_request.strip()
|
| 154 |
+
|
| 155 |
+
# --- Issue management ------------------------------------------------
|
| 156 |
+
if _ISSUE_CREATE_RE.search(text):
|
| 157 |
+
return WorkflowPlan(
|
| 158 |
+
category=RequestCategory.ISSUE_MANAGEMENT,
|
| 159 |
+
agents=[AgentType.ISSUE_MANAGER],
|
| 160 |
+
description="Create a new GitHub issue",
|
| 161 |
+
entity_number=_extract_issue_number(text),
|
| 162 |
+
metadata={"action": "create"},
|
| 163 |
+
)
|
| 164 |
+
if _ISSUE_COMMENT_RE.search(text):
|
| 165 |
+
return WorkflowPlan(
|
| 166 |
+
category=RequestCategory.ISSUE_MANAGEMENT,
|
| 167 |
+
agents=[AgentType.ISSUE_MANAGER],
|
| 168 |
+
description="Comment on an issue",
|
| 169 |
+
entity_number=_extract_issue_number(text),
|
| 170 |
+
metadata={"action": "comment"},
|
| 171 |
+
)
|
| 172 |
+
if _ISSUE_UPDATE_RE.search(text):
|
| 173 |
+
return WorkflowPlan(
|
| 174 |
+
category=RequestCategory.ISSUE_MANAGEMENT,
|
| 175 |
+
agents=[AgentType.ISSUE_MANAGER],
|
| 176 |
+
description="Update an existing issue",
|
| 177 |
+
entity_number=_extract_issue_number(text),
|
| 178 |
+
metadata={"action": "update"},
|
| 179 |
+
)
|
| 180 |
+
if _ISSUE_LIST_RE.search(text):
|
| 181 |
+
return WorkflowPlan(
|
| 182 |
+
category=RequestCategory.ISSUE_MANAGEMENT,
|
| 183 |
+
agents=[AgentType.ISSUE_MANAGER],
|
| 184 |
+
description="List or search issues",
|
| 185 |
+
metadata={"action": "list"},
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
# --- PR management ---------------------------------------------------
|
| 189 |
+
if _PR_CREATE_RE.search(text):
|
| 190 |
+
return WorkflowPlan(
|
| 191 |
+
category=RequestCategory.PR_MANAGEMENT,
|
| 192 |
+
agents=[AgentType.PR_MANAGER],
|
| 193 |
+
description="Create a pull request",
|
| 194 |
+
metadata={"action": "create"},
|
| 195 |
+
)
|
| 196 |
+
if _PR_MERGE_RE.search(text):
|
| 197 |
+
return WorkflowPlan(
|
| 198 |
+
category=RequestCategory.PR_MANAGEMENT,
|
| 199 |
+
agents=[AgentType.PR_MANAGER],
|
| 200 |
+
description="Merge a pull request",
|
| 201 |
+
entity_number=_extract_pr_number(text),
|
| 202 |
+
metadata={"action": "merge"},
|
| 203 |
+
)
|
| 204 |
+
if _PR_REVIEW_RE.search(text):
|
| 205 |
+
return WorkflowPlan(
|
| 206 |
+
category=RequestCategory.PR_MANAGEMENT,
|
| 207 |
+
agents=[AgentType.CODE_REVIEWER, AgentType.PR_MANAGER],
|
| 208 |
+
description="Review a pull request",
|
| 209 |
+
entity_number=_extract_pr_number(text),
|
| 210 |
+
metadata={"action": "review"},
|
| 211 |
+
)
|
| 212 |
+
if _PR_LIST_RE.search(text):
|
| 213 |
+
return WorkflowPlan(
|
| 214 |
+
category=RequestCategory.PR_MANAGEMENT,
|
| 215 |
+
agents=[AgentType.PR_MANAGER],
|
| 216 |
+
description="List pull requests",
|
| 217 |
+
metadata={"action": "list"},
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# --- Code search -----------------------------------------------------
|
| 221 |
+
if _SEARCH_USER_RE.search(text):
|
| 222 |
+
return WorkflowPlan(
|
| 223 |
+
category=RequestCategory.CODE_SEARCH,
|
| 224 |
+
agents=[AgentType.SEARCH],
|
| 225 |
+
description="Search for GitHub users or organisations",
|
| 226 |
+
requires_repo_context=False,
|
| 227 |
+
metadata={"search_type": "users"},
|
| 228 |
+
)
|
| 229 |
+
if _SEARCH_REPO_RE.search(text):
|
| 230 |
+
return WorkflowPlan(
|
| 231 |
+
category=RequestCategory.CODE_SEARCH,
|
| 232 |
+
agents=[AgentType.SEARCH],
|
| 233 |
+
description="Search for repositories",
|
| 234 |
+
requires_repo_context=False,
|
| 235 |
+
metadata={"search_type": "repositories"},
|
| 236 |
+
)
|
| 237 |
+
if _SEARCH_CODE_RE.search(text):
|
| 238 |
+
return WorkflowPlan(
|
| 239 |
+
category=RequestCategory.CODE_SEARCH,
|
| 240 |
+
agents=[AgentType.SEARCH],
|
| 241 |
+
description="Search for code in the repository",
|
| 242 |
+
metadata={"search_type": "code"},
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
# --- Terminal / shell commands ----------------------------------------
|
| 246 |
+
if _TERMINAL_RE.search(text):
|
| 247 |
+
return WorkflowPlan(
|
| 248 |
+
category=RequestCategory.TERMINAL,
|
| 249 |
+
agents=[AgentType.TERMINAL],
|
| 250 |
+
description="Run shell commands in the workspace",
|
| 251 |
+
metadata={"action": "execute"},
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
# --- Local file editing -----------------------------------------------
|
| 255 |
+
if _LOCAL_EDIT_RE.search(text):
|
| 256 |
+
return WorkflowPlan(
|
| 257 |
+
category=RequestCategory.LOCAL_EDIT,
|
| 258 |
+
agents=[AgentType.LOCAL_EDITOR],
|
| 259 |
+
description="Edit files directly in the local workspace",
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
# --- Code review -----------------------------------------------------
|
| 263 |
+
if _REVIEW_RE.search(text):
|
| 264 |
+
return WorkflowPlan(
|
| 265 |
+
category=RequestCategory.CODE_REVIEW,
|
| 266 |
+
agents=[AgentType.EXPLORER, AgentType.CODE_REVIEWER],
|
| 267 |
+
description="Analyse code quality and suggest improvements",
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
# --- Learning & guidance ---------------------------------------------
|
| 271 |
+
if _LEARNING_RE.search(text) or _GITHUB_TOPICS_RE.search(text):
|
| 272 |
+
return WorkflowPlan(
|
| 273 |
+
category=RequestCategory.LEARNING,
|
| 274 |
+
agents=[AgentType.LEARNING],
|
| 275 |
+
description="Provide guidance on GitHub features or best practices",
|
| 276 |
+
requires_repo_context=False,
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
# --- Default: existing plan+execute workflow -------------------------
|
| 280 |
+
return WorkflowPlan(
|
| 281 |
+
category=RequestCategory.PLAN_EXECUTE,
|
| 282 |
+
agents=[AgentType.EXPLORER, AgentType.PLANNER, AgentType.CODE_WRITER],
|
| 283 |
+
description="Explore repository, create plan, and execute changes",
|
| 284 |
+
)
|
gitpilot/agent_teams.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# gitpilot/agent_teams.py
|
| 2 |
+
"""Parallel multi-agent execution on git worktrees.
|
| 3 |
+
|
| 4 |
+
Coordinates multiple agents working on independent subtasks simultaneously.
|
| 5 |
+
Each agent operates on its own git worktree to avoid conflicts, and a lead
|
| 6 |
+
agent reviews and merges the results.
|
| 7 |
+
|
| 8 |
+
Architecture inspired by the MapReduce pattern and the *divide-and-conquer*
|
| 9 |
+
approach from distributed systems research (Dean & Ghemawat, 2004).
|
| 10 |
+
|
| 11 |
+
Workflow::
|
| 12 |
+
|
| 13 |
+
User: "Add authentication to the API"
|
| 14 |
+
Lead agent splits → 4 subtasks
|
| 15 |
+
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
|
| 16 |
+
│ Agent A: │ │ Agent B: │ │ Agent C: │ │ Agent D: │
|
| 17 |
+
│ User model │ │ Middleware │ │ Endpoints │ │ Tests │
|
| 18 |
+
│ worktree/a │ │ worktree/b │ │ worktree/c │ │ worktree/d │
|
| 19 |
+
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘
|
| 20 |
+
└───────────┴───────────┴───────────┘
|
| 21 |
+
│
|
| 22 |
+
Lead reviews & merges
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import asyncio
|
| 27 |
+
import logging
|
| 28 |
+
import uuid
|
| 29 |
+
from dataclasses import dataclass, field
|
| 30 |
+
from datetime import datetime, timezone
|
| 31 |
+
from enum import Enum
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
from typing import Any, Dict, List, Optional
|
| 34 |
+
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class SubTaskStatus(str, Enum):
|
| 39 |
+
PENDING = "pending"
|
| 40 |
+
RUNNING = "running"
|
| 41 |
+
COMPLETED = "completed"
|
| 42 |
+
FAILED = "failed"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class SubTask:
|
| 47 |
+
"""A single subtask to be executed by one agent."""
|
| 48 |
+
|
| 49 |
+
id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
|
| 50 |
+
title: str = ""
|
| 51 |
+
description: str = ""
|
| 52 |
+
assigned_agent: str = ""
|
| 53 |
+
files: List[str] = field(default_factory=list)
|
| 54 |
+
status: SubTaskStatus = SubTaskStatus.PENDING
|
| 55 |
+
result: str = ""
|
| 56 |
+
error: Optional[str] = None
|
| 57 |
+
worktree_path: Optional[Path] = None
|
| 58 |
+
started_at: Optional[str] = None
|
| 59 |
+
completed_at: Optional[str] = None
|
| 60 |
+
|
| 61 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 62 |
+
return {
|
| 63 |
+
"id": self.id,
|
| 64 |
+
"title": self.title,
|
| 65 |
+
"description": self.description,
|
| 66 |
+
"assigned_agent": self.assigned_agent,
|
| 67 |
+
"files": self.files,
|
| 68 |
+
"status": self.status.value,
|
| 69 |
+
"result": self.result,
|
| 70 |
+
"error": self.error,
|
| 71 |
+
"started_at": self.started_at,
|
| 72 |
+
"completed_at": self.completed_at,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@dataclass
|
| 77 |
+
class TeamResult:
|
| 78 |
+
"""Aggregated result from parallel agent execution."""
|
| 79 |
+
|
| 80 |
+
task: str
|
| 81 |
+
subtasks: List[SubTask] = field(default_factory=list)
|
| 82 |
+
merge_status: str = "pending" # pending | merged | conflict | failed
|
| 83 |
+
conflicts: List[str] = field(default_factory=list)
|
| 84 |
+
summary: str = ""
|
| 85 |
+
|
| 86 |
+
@property
|
| 87 |
+
def all_completed(self) -> bool:
|
| 88 |
+
return all(s.status == SubTaskStatus.COMPLETED for s in self.subtasks)
|
| 89 |
+
|
| 90 |
+
@property
|
| 91 |
+
def any_failed(self) -> bool:
|
| 92 |
+
return any(s.status == SubTaskStatus.FAILED for s in self.subtasks)
|
| 93 |
+
|
| 94 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 95 |
+
return {
|
| 96 |
+
"task": self.task,
|
| 97 |
+
"subtasks": [s.to_dict() for s in self.subtasks],
|
| 98 |
+
"merge_status": self.merge_status,
|
| 99 |
+
"conflicts": self.conflicts,
|
| 100 |
+
"summary": self.summary,
|
| 101 |
+
"all_completed": self.all_completed,
|
| 102 |
+
"any_failed": self.any_failed,
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class AgentTeam:
|
| 107 |
+
"""Coordinate multiple agents working in parallel.
|
| 108 |
+
|
| 109 |
+
Usage::
|
| 110 |
+
|
| 111 |
+
team = AgentTeam(workspace_path=Path("/repo"))
|
| 112 |
+
subtasks = team.plan_and_split("Add auth system", num_agents=4)
|
| 113 |
+
result = await team.execute_parallel(subtasks, executor_fn=my_agent_fn)
|
| 114 |
+
merge = await team.merge_results(result)
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
+
def __init__(self, workspace_path: Optional[Path] = None) -> None:
|
| 118 |
+
self.workspace_path = workspace_path
|
| 119 |
+
self._worktrees: List[Path] = []
|
| 120 |
+
|
| 121 |
+
def plan_and_split(
|
| 122 |
+
self,
|
| 123 |
+
task: str,
|
| 124 |
+
num_agents: int = 4,
|
| 125 |
+
subtask_descriptions: Optional[List[Dict[str, str]]] = None,
|
| 126 |
+
) -> List[SubTask]:
|
| 127 |
+
"""Split a task into independent subtasks.
|
| 128 |
+
|
| 129 |
+
If ``subtask_descriptions`` is provided, use those directly.
|
| 130 |
+
Otherwise, create generic subtasks from the task description.
|
| 131 |
+
"""
|
| 132 |
+
subtasks = []
|
| 133 |
+
|
| 134 |
+
if subtask_descriptions:
|
| 135 |
+
for i, desc in enumerate(subtask_descriptions):
|
| 136 |
+
subtasks.append(SubTask(
|
| 137 |
+
title=desc.get("title", f"Subtask {i + 1}"),
|
| 138 |
+
description=desc.get("description", ""),
|
| 139 |
+
assigned_agent=desc.get("agent", f"agent_{i}"),
|
| 140 |
+
files=desc.get("files", []),
|
| 141 |
+
))
|
| 142 |
+
else:
|
| 143 |
+
# Generic split — the LLM would normally do this
|
| 144 |
+
for i in range(min(num_agents, 8)):
|
| 145 |
+
subtasks.append(SubTask(
|
| 146 |
+
title=f"Part {i + 1} of {task}",
|
| 147 |
+
description=f"Handle part {i + 1} of the task: {task}",
|
| 148 |
+
assigned_agent=f"agent_{i}",
|
| 149 |
+
))
|
| 150 |
+
|
| 151 |
+
return subtasks
|
| 152 |
+
|
| 153 |
+
async def execute_parallel(
|
| 154 |
+
self,
|
| 155 |
+
subtasks: List[SubTask],
|
| 156 |
+
executor_fn: Optional[Any] = None,
|
| 157 |
+
) -> TeamResult:
|
| 158 |
+
"""Execute subtasks in parallel.
|
| 159 |
+
|
| 160 |
+
``executor_fn`` is an async callable(SubTask) -> str that runs the
|
| 161 |
+
agent logic for each subtask. If not provided, subtasks are marked
|
| 162 |
+
as completed with a placeholder result.
|
| 163 |
+
"""
|
| 164 |
+
result = TeamResult(task="parallel_execution", subtasks=subtasks)
|
| 165 |
+
|
| 166 |
+
async def _run_subtask(subtask: SubTask) -> None:
|
| 167 |
+
subtask.status = SubTaskStatus.RUNNING
|
| 168 |
+
subtask.started_at = datetime.now(timezone.utc).isoformat()
|
| 169 |
+
try:
|
| 170 |
+
if executor_fn:
|
| 171 |
+
subtask.result = await executor_fn(subtask)
|
| 172 |
+
else:
|
| 173 |
+
subtask.result = f"Completed: {subtask.title}"
|
| 174 |
+
subtask.status = SubTaskStatus.COMPLETED
|
| 175 |
+
except Exception as e:
|
| 176 |
+
subtask.status = SubTaskStatus.FAILED
|
| 177 |
+
subtask.error = str(e)
|
| 178 |
+
logger.error("Subtask %s failed: %s", subtask.id, e)
|
| 179 |
+
finally:
|
| 180 |
+
subtask.completed_at = datetime.now(timezone.utc).isoformat()
|
| 181 |
+
|
| 182 |
+
# Run all subtasks concurrently
|
| 183 |
+
await asyncio.gather(*[_run_subtask(st) for st in subtasks])
|
| 184 |
+
|
| 185 |
+
return result
|
| 186 |
+
|
| 187 |
+
async def merge_results(self, team_result: TeamResult) -> TeamResult:
|
| 188 |
+
"""Merge results from parallel execution.
|
| 189 |
+
|
| 190 |
+
In a full implementation, this would:
|
| 191 |
+
1. Check for file conflicts between subtask outputs
|
| 192 |
+
2. Use git merge-tree for conflict detection
|
| 193 |
+
3. Have a lead agent resolve conflicts
|
| 194 |
+
|
| 195 |
+
For now, it aggregates results and detects file overlaps.
|
| 196 |
+
"""
|
| 197 |
+
if team_result.any_failed:
|
| 198 |
+
team_result.merge_status = "failed"
|
| 199 |
+
failed = [s for s in team_result.subtasks if s.status == SubTaskStatus.FAILED]
|
| 200 |
+
team_result.summary = (
|
| 201 |
+
f"{len(failed)} subtask(s) failed: "
|
| 202 |
+
+ ", ".join(f"{s.title} ({s.error})" for s in failed)
|
| 203 |
+
)
|
| 204 |
+
return team_result
|
| 205 |
+
|
| 206 |
+
# Detect file conflicts (same file modified by multiple agents)
|
| 207 |
+
file_owners: Dict[str, List[str]] = {}
|
| 208 |
+
for st in team_result.subtasks:
|
| 209 |
+
for f in st.files:
|
| 210 |
+
file_owners.setdefault(f, []).append(st.assigned_agent)
|
| 211 |
+
|
| 212 |
+
conflicts = [f for f, owners in file_owners.items() if len(owners) > 1]
|
| 213 |
+
team_result.conflicts = conflicts
|
| 214 |
+
|
| 215 |
+
if conflicts:
|
| 216 |
+
team_result.merge_status = "conflict"
|
| 217 |
+
team_result.summary = (
|
| 218 |
+
f"File conflicts detected in: {', '.join(conflicts)}. "
|
| 219 |
+
"Manual review required."
|
| 220 |
+
)
|
| 221 |
+
else:
|
| 222 |
+
team_result.merge_status = "merged"
|
| 223 |
+
completed = [s for s in team_result.subtasks if s.status == SubTaskStatus.COMPLETED]
|
| 224 |
+
team_result.summary = (
|
| 225 |
+
f"All {len(completed)} subtasks completed successfully. "
|
| 226 |
+
"No file conflicts detected."
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
return team_result
|
| 230 |
+
|
| 231 |
+
async def setup_worktrees(self, subtasks: List[SubTask], base_branch: str = "main") -> None:
|
| 232 |
+
"""Create git worktrees for each subtask (requires workspace_path)."""
|
| 233 |
+
if not self.workspace_path:
|
| 234 |
+
return
|
| 235 |
+
for st in subtasks:
|
| 236 |
+
worktree_name = f"worktree-{st.id}"
|
| 237 |
+
worktree_path = self.workspace_path / ".worktrees" / worktree_name
|
| 238 |
+
branch_name = f"team/{st.id}"
|
| 239 |
+
|
| 240 |
+
proc = await asyncio.create_subprocess_exec(
|
| 241 |
+
"git", "worktree", "add", "-b", branch_name,
|
| 242 |
+
str(worktree_path), base_branch,
|
| 243 |
+
cwd=str(self.workspace_path),
|
| 244 |
+
stdout=asyncio.subprocess.PIPE,
|
| 245 |
+
stderr=asyncio.subprocess.PIPE,
|
| 246 |
+
)
|
| 247 |
+
await proc.communicate()
|
| 248 |
+
st.worktree_path = worktree_path
|
| 249 |
+
self._worktrees.append(worktree_path)
|
| 250 |
+
|
| 251 |
+
async def cleanup_worktrees(self) -> None:
|
| 252 |
+
"""Remove all worktrees created by this team."""
|
| 253 |
+
if not self.workspace_path:
|
| 254 |
+
return
|
| 255 |
+
for wt in self._worktrees:
|
| 256 |
+
proc = await asyncio.create_subprocess_exec(
|
| 257 |
+
"git", "worktree", "remove", "--force", str(wt),
|
| 258 |
+
cwd=str(self.workspace_path),
|
| 259 |
+
stdout=asyncio.subprocess.PIPE,
|
| 260 |
+
stderr=asyncio.subprocess.PIPE,
|
| 261 |
+
)
|
| 262 |
+
await proc.communicate()
|
| 263 |
+
self._worktrees.clear()
|