github-actions[bot] commited on
Commit
6a84b44
·
0 Parent(s):

Deploy from 7af131fd

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +76 -0
  2. README.md +79 -0
  3. REPO_README.md +1241 -0
  4. deploy/huggingface/start.sh +54 -0
  5. frontend/.dockerignore +39 -0
  6. frontend/.env.example +7 -0
  7. frontend/.env.production.example +5 -0
  8. frontend/App.jsx +909 -0
  9. frontend/components/AddRepoModal.jsx +256 -0
  10. frontend/components/AssistantMessage.jsx +116 -0
  11. frontend/components/BranchPicker.jsx +398 -0
  12. frontend/components/ChatPanel.jsx +686 -0
  13. frontend/components/ContextBar.jsx +156 -0
  14. frontend/components/CreatePRButton.jsx +159 -0
  15. frontend/components/DiffStats.jsx +59 -0
  16. frontend/components/DiffViewer.jsx +263 -0
  17. frontend/components/EnvironmentEditor.jsx +278 -0
  18. frontend/components/EnvironmentSelector.jsx +199 -0
  19. frontend/components/FileTree.jsx +307 -0
  20. frontend/components/FlowViewer.jsx +659 -0
  21. frontend/components/Footer.jsx +48 -0
  22. frontend/components/LlmSettings.jsx +775 -0
  23. frontend/components/LoginPage.jsx +535 -0
  24. frontend/components/PlanView.jsx +231 -0
  25. frontend/components/ProjectContextPanel.jsx +572 -0
  26. frontend/components/ProjectSettings/ContextTab.jsx +352 -0
  27. frontend/components/ProjectSettings/ConventionsTab.jsx +151 -0
  28. frontend/components/ProjectSettings/UseCaseTab.jsx +637 -0
  29. frontend/components/ProjectSettingsModal.jsx +230 -0
  30. frontend/components/RepoSelector.jsx +269 -0
  31. frontend/components/SessionItem.jsx +183 -0
  32. frontend/components/SessionSidebar.jsx +181 -0
  33. frontend/components/SettingsModal.jsx +270 -0
  34. frontend/components/StreamingMessage.jsx +182 -0
  35. frontend/index.html +12 -0
  36. frontend/main.jsx +11 -0
  37. frontend/nginx.conf +58 -0
  38. frontend/ollabridge.css +222 -0
  39. frontend/package-lock.json +0 -0
  40. frontend/package.json +21 -0
  41. frontend/styles.css +2825 -0
  42. frontend/utils/api.js +212 -0
  43. frontend/utils/ws.js +157 -0
  44. frontend/vite.config.js +22 -0
  45. gitpilot/__init__.py +5 -0
  46. gitpilot/__main__.py +5 -0
  47. gitpilot/_api_core.py +2382 -0
  48. gitpilot/a2a_adapter.py +560 -0
  49. gitpilot/agent_router.py +284 -0
  50. 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
+ [![PyPI version](https://badge.fury.io/py/gitcopilot.svg)](https://pypi.org/project/gitcopilot/)
8
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
10
+ [![GitHub stars](https://img.shields.io/github/stars/ruslanmv/gitpilot.svg?style=social&label=Star)](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
+ ![](assets/2025-11-15-01-18-56.png)
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
+ ![](assets/2025-11-16-00-25-49.png)
961
+
962
+ ### Example: Content Generation
963
+ ![](assets/2025-11-16-00-29-47.png)
964
+
965
+ ### Example: File Creation
966
+ ![](assets/2025-11-16-01-01-40.png)
967
+
968
+ ### Example multiple operations
969
+ ![](assets/2025-11-27-00-25-53.png)
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
+ &times;
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
+ &#10003;
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 &rarr;
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 &rarr;
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
+ &times;
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
+ &times;
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
+ &times;
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
+ &copy; {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 &quot;Edit&quot; 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
+ &times;
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()