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

Deploy from c4b1af84

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 +92 -0
  2. README.md +80 -0
  3. REPO_README.md +418 -0
  4. frontend/.dockerignore +39 -0
  5. frontend/App.jsx +1229 -0
  6. frontend/components/AboutModal.jsx +488 -0
  7. frontend/components/AddRepoModal.jsx +256 -0
  8. frontend/components/AdminTabs/AdvancedTab.jsx +360 -0
  9. frontend/components/AdminTabs/IntegrationsTab.jsx +238 -0
  10. frontend/components/AdminTabs/MCPServersTab.jsx +337 -0
  11. frontend/components/AdminTabs/SecurityTab.jsx +341 -0
  12. frontend/components/AdminTabs/SessionsTab.jsx +362 -0
  13. frontend/components/AdminTabs/SkillsTab.jsx +266 -0
  14. frontend/components/AdminTabs/WorkspaceModesTab.jsx +254 -0
  15. frontend/components/AdminTabs/index.js +9 -0
  16. frontend/components/AdminTabs/mcp/CatalogList.jsx +101 -0
  17. frontend/components/AdminTabs/mcp/CustomInstallForm.jsx +134 -0
  18. frontend/components/AdminTabs/mcp/GatewayHeader.jsx +145 -0
  19. frontend/components/AdminTabs/mcp/ServerCard.jsx +334 -0
  20. frontend/components/AdminTabs/mcp/SyncReport.jsx +152 -0
  21. frontend/components/AdminTabs/mcp/ToolRow.jsx +149 -0
  22. frontend/components/AssistantMessage.jsx +167 -0
  23. frontend/components/BranchPicker.jsx +398 -0
  24. frontend/components/ChatPanel.jsx +940 -0
  25. frontend/components/ContextBar.jsx +156 -0
  26. frontend/components/ContextMeter.jsx +410 -0
  27. frontend/components/CreatePRButton.jsx +159 -0
  28. frontend/components/DiffStats.jsx +59 -0
  29. frontend/components/DiffViewer.jsx +263 -0
  30. frontend/components/EnvironmentEditor.jsx +278 -0
  31. frontend/components/EnvironmentSelector.jsx +199 -0
  32. frontend/components/FileTree.jsx +307 -0
  33. frontend/components/FlowViewer.jsx +659 -0
  34. frontend/components/Footer.jsx +48 -0
  35. frontend/components/LlmSettings.jsx +623 -0
  36. frontend/components/LoginPage.jsx +544 -0
  37. frontend/components/PlanView.jsx +231 -0
  38. frontend/components/ProjectContextPanel.jsx +572 -0
  39. frontend/components/ProjectSettings/ContextTab.jsx +352 -0
  40. frontend/components/ProjectSettings/ConventionsTab.jsx +151 -0
  41. frontend/components/ProjectSettings/UseCaseTab.jsx +637 -0
  42. frontend/components/ProjectSettingsModal.jsx +230 -0
  43. frontend/components/RepoSelector.jsx +269 -0
  44. frontend/components/SessionItem.jsx +183 -0
  45. frontend/components/SessionSidebar.jsx +181 -0
  46. frontend/components/SettingsModal.jsx +333 -0
  47. frontend/components/StartupScreen.jsx +92 -0
  48. frontend/components/StreamingMessage.jsx +182 -0
  49. frontend/components/ThinkingIndicator.jsx +151 -0
  50. frontend/components/UserMenu.jsx +424 -0
Dockerfile ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # GitPilot - Hugging Face Spaces Dockerfile
3
+ # =============================================================================
4
+ # Follows the official HF Docker Spaces pattern:
5
+ # https://huggingface.co/docs/hub/spaces-sdks-docker
6
+ #
7
+ # Architecture:
8
+ # React UI (Vite build) -> FastAPI backend -> OllaBridge Cloud / any LLM
9
+ # =============================================================================
10
+
11
+ # -- Stage 1: Build React frontend -------------------------------------------
12
+ FROM node:20-slim AS frontend-builder
13
+
14
+ WORKDIR /build
15
+
16
+ COPY frontend/package.json frontend/package-lock.json ./
17
+ RUN npm ci --production=false
18
+
19
+ COPY frontend/ ./
20
+ RUN npm run build
21
+
22
+ # -- Stage 2: Python runtime -------------------------------------------------
23
+ FROM python:3.12-slim
24
+
25
+ # System deps needed at runtime
26
+ RUN apt-get update && apt-get install -y --no-install-recommends \
27
+ git curl ca-certificates \
28
+ && rm -rf /var/lib/apt/lists/*
29
+
30
+ # HF Spaces runs containers as UID 1000 β€” create user early (official pattern)
31
+ RUN useradd -m -u 1000 user
32
+
33
+ USER user
34
+
35
+ ENV HOME=/home/user \
36
+ PATH=/home/user/.local/bin:$PATH \
37
+ PYTHONUNBUFFERED=1 \
38
+ GITPILOT_PROVIDER=ollabridge \
39
+ OLLABRIDGE_BASE_URL=https://ruslanmv-ollabridge.hf.space \
40
+ GITPILOT_OLLABRIDGE_MODEL=qwen2.5:1.5b \
41
+ CORS_ORIGINS="*" \
42
+ GITPILOT_CONFIG_DIR=/tmp/gitpilot
43
+
44
+ WORKDIR $HOME/app
45
+
46
+ # ── Install Python dependencies BEFORE copying source code ──────────
47
+ # This ensures pip install layers are cached even when code changes.
48
+ COPY --chown=user pyproject.toml README.md ./
49
+
50
+ # Step 1: lightweight deps (cached layer)
51
+ RUN pip install --no-cache-dir --upgrade pip && \
52
+ pip install --no-cache-dir \
53
+ "fastapi>=0.111.0" \
54
+ "uvicorn[standard]>=0.30.0" \
55
+ "httpx>=0.27.0" \
56
+ "python-dotenv>=1.1.0,<1.2.0" \
57
+ "typer>=0.12.0,<0.24.0" \
58
+ "pydantic>=2.7.0,<2.12.0" \
59
+ "rich>=13.0.0" \
60
+ "pyjwt[crypto]>=2.8.0"
61
+
62
+ # Step 2: heavy ML/agent deps (separate layer for better caching)
63
+ RUN pip install --no-cache-dir \
64
+ "litellm" \
65
+ "crewai[anthropic]>=0.76.9" \
66
+ "crewai-tools>=0.13.4" \
67
+ "anthropic>=0.39.0" \
68
+ "ibm-watsonx-ai>=1.1.0" \
69
+ "langchain-ibm>=0.3.0"
70
+
71
+ # ── Now copy source code (cache-busting only affects layers below) ──
72
+ COPY --chown=user gitpilot ./gitpilot
73
+
74
+ # Copy built frontend into gitpilot/web/
75
+ COPY --chown=user --from=frontend-builder /build/dist/ ./gitpilot/web/
76
+
77
+ # Step 3: editable install of gitpilot itself (deps already satisfied)
78
+ RUN pip install --no-cache-dir --no-deps -e .
79
+
80
+ EXPOSE 7860
81
+
82
+ # NOTE: Do NOT add a Docker HEALTHCHECK here.
83
+ # HF Spaces has its own HTTP probe on app_port (7860) and ignores the
84
+ # Docker HEALTHCHECK directive.
85
+
86
+ # Direct CMD β€” no shell script, fewer failure points.
87
+ CMD ["python", "-m", "uvicorn", "gitpilot.api:app", \
88
+ "--host", "0.0.0.0", \
89
+ "--port", "7860", \
90
+ "--workers", "2", \
91
+ "--limit-concurrency", "10", \
92
+ "--timeout-keep-alive", "120"]
README.md ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: GitPilot
3
+ emoji: "\U0001F916"
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ startup_duration_timeout: 5m
9
+ pinned: true
10
+ license: mit
11
+ short_description: Enterprise AI Coding Assistant for GitHub Repositories
12
+ ---
13
+
14
+ # GitPilot β€” Hugging Face Spaces
15
+
16
+ **Enterprise-grade AI coding assistant** for GitHub repositories with multi-LLM support, visual workflow insights, and intelligent code analysis.
17
+
18
+ ## What This Does
19
+
20
+ This Space runs the full GitPilot stack:
21
+ 1. **React Frontend** β€” Professional dark-theme UI with chat, file browser, and workflow visualization
22
+ 2. **FastAPI Backend** β€” 80+ API endpoints for repository management, AI chat, planning, and execution
23
+ 3. **Multi-Agent AI** β€” CrewAI orchestration with 7 switchable agent topologies
24
+
25
+ ## LLM Providers
26
+
27
+ GitPilot connects to your favorite LLM provider. Configure in **Admin / LLM Settings**:
28
+
29
+ | Provider | Default | API Key Required |
30
+ |---|---|---|
31
+ | **OllaBridge Cloud** (default) | `qwen2.5:1.5b` | No |
32
+ | OpenAI | `gpt-4o-mini` | Yes |
33
+ | Anthropic Claude | `claude-sonnet-4-5` | Yes |
34
+ | Ollama (local) | `llama3` | No |
35
+ | Custom endpoint | Any model | Optional |
36
+
37
+ ## Quick Start
38
+
39
+ 1. Open the Space UI
40
+ 2. Enter your **GitHub Token** (Settings -> GitHub)
41
+ 3. Select a repository from the sidebar
42
+ 4. Start chatting with your AI coding assistant
43
+
44
+ ## API Endpoints
45
+
46
+ | Endpoint | Description |
47
+ |---|---|
48
+ | `GET /api/health` | Health check |
49
+ | `POST /api/chat/message` | Chat with AI assistant |
50
+ | `POST /api/chat/plan` | Generate implementation plan |
51
+ | `GET /api/repos` | List repositories |
52
+ | `GET /api/settings` | View/update settings |
53
+ | `GET /docs` | Interactive API docs (Swagger) |
54
+
55
+ ## Connect to OllaBridge Cloud
56
+
57
+ By default, GitPilot connects to [OllaBridge Cloud](https://huggingface.co/spaces/ruslanmv/ollabridge) for LLM inference. This provides free access to open-source models without needing API keys.
58
+
59
+ To use your own OllaBridge instance:
60
+ 1. Go to **Admin / LLM Settings**
61
+ 2. Select **OllaBridge** provider
62
+ 3. Enter your OllaBridge URL and model
63
+
64
+ ## Environment Variables
65
+
66
+ Configure via HF Spaces secrets:
67
+
68
+ | Variable | Description | Default |
69
+ |---|---|---|
70
+ | `GITPILOT_PROVIDER` | LLM provider | `ollabridge` |
71
+ | `OLLABRIDGE_BASE_URL` | OllaBridge Cloud URL | `https://ruslanmv-ollabridge.hf.space` |
72
+ | `GITHUB_TOKEN` | GitHub personal access token | - |
73
+ | `OPENAI_API_KEY` | OpenAI API key (if using OpenAI) | - |
74
+ | `ANTHROPIC_API_KEY` | Anthropic API key (if using Claude) | - |
75
+
76
+ ## Links
77
+
78
+ - [GitPilot Repository](https://github.com/ruslanmv/gitpilot)
79
+ - [OllaBridge Cloud](https://huggingface.co/spaces/ruslanmv/ollabridge)
80
+ - [Documentation](https://github.com/ruslanmv/gitpilot#readme)
REPO_README.md ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+
3
+ <img src="docs/logo.svg" alt="GitPilot" width="140" />
4
+
5
+ # GitPilot
6
+
7
+ ### The first open-source multi-agent AI coding assistant.
8
+
9
+ Multiple specialized agents β€” including Explorer, Planner, Coder, and Reviewer β€” collaborate seamlessly on every task. By default, GitPilot requests confirmation before executing high-impact actions. Switch to Auto or Plan mode at any time.
10
+
11
+ [![PyPI](https://img.shields.io/pypi/v/gitcopilot?style=flat-square&color=D95C3D&labelColor=1C1C1F&label=pypi)](https://pypi.org/project/gitcopilot/)
12
+ [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12-D95C3D?style=flat-square&labelColor=1C1C1F)](https://www.python.org/)
13
+ [![License](https://img.shields.io/badge/license-Apache%202.0-D95C3D?style=flat-square&labelColor=1C1C1F)](LICENSE)
14
+ [![VS Code](https://img.shields.io/badge/VS%20Code-Extension-D95C3D?style=flat-square&labelColor=1C1C1F)](https://marketplace.visualstudio.com/)
15
+ [![Tests](https://img.shields.io/badge/tests-854%20passing-D95C3D?style=flat-square&labelColor=1C1C1F)](#contributing)
16
+
17
+ [**Get Started**](#get-started) &nbsp;Β·&nbsp; [VS Code](#vs-code-extension) &nbsp;Β·&nbsp; [Web App](#web-app) &nbsp;Β·&nbsp; [How It Works](#how-it-works) &nbsp;Β·&nbsp; [Providers](#supported-ai-providers)
18
+
19
+ </div>
20
+
21
+ ---
22
+
23
+ <p align="center">
24
+ <picture>
25
+ <source srcset="docs/assets/flow.svg" type="image/svg+xml" />
26
+ <img src="docs/assets/flow.png" alt="GitPilot loop: Ask, Plan, Code, Ship β€” you approve every change." width="900" />
27
+ </picture>
28
+ </p>
29
+
30
+ ## Why GitPilot?
31
+
32
+ Most AI coding tools are a **single model behind a chat box**. GitPilot is fundamentally different: it deploys a **team of four specialized AI agents** that collaborate on every task β€” just like a real engineering team.
33
+
34
+ | Agent | Role | What it does |
35
+ |---|---|---|
36
+ | **Explorer** | Context | Reads your full repo, git log, test suite, and dependencies so the plan starts with real knowledge β€” not guesses |
37
+ | **Planner** | Strategy | Drafts a safe, step-by-step plan with diffs and surfaces risks before any file is touched |
38
+ | **Coder** | Execution | Writes code, runs your tests, and self-corrects on failure β€” iterating until the suite passes |
39
+ | **Reviewer** | Quality | Validates the output, re-runs the suite, and drafts a commit message and PR summary |
40
+
41
+ **You control how the agent runs.** Three execution modes β€” selectable per session from the VS Code compose bar or backend API:
42
+
43
+ | Mode | Default? | Behavior |
44
+ |---|---|---|
45
+ | **Ask** | Yes | Prompts you before each dangerous action (write, edit, run, commit). You see the diff and click Allow / Deny. |
46
+ | **Auto** | | Executes all tools automatically. Fastest for experienced users who trust the plan. |
47
+ | **Plan** | | Read-only. Generates and displays the plan but blocks all file writes and commands. |
48
+
49
+ Diffs are shown before they're applied. Tests run before anything is committed. No surprises.
50
+
51
+ ### What else sets GitPilot apart
52
+
53
+ - 🧭 **Works where you work** β€” VS Code, web app, and CLI share one login, one history, and one set of approvals.
54
+ - 🧠 **Any LLM, zero lock-in** β€” OpenAI, Anthropic Claude, IBM Watsonx, Ollama (local & free) or OllaBridge. Switch in settings, no code change.
55
+ - πŸ” **Private by default** β€” run the entire stack locally with Ollama. No telemetry, no data leaves your machine.
56
+ - 🏒 **Enterprise-ready, Apache 2.0 open source** β€” 854 passing tests, Docker & Hugging Face deployment recipes, audit the code yourself.
57
+ - 🌍 **Runs anywhere** β€” laptop, private cloud, air-gapped environments, or managed hosting. Your repo, your rules.
58
+
59
+ ---
60
+
61
+ ## What is GitPilot?
62
+
63
+ GitPilot is an AI assistant that helps you ship better code, faster β€” without giving up control. It understands your project, plans changes you can read before they happen, writes the code, runs your tests, and drafts the commit message and pull request for you.
64
+
65
+ **Works with any language. Runs on any LLM.** Start free and local with Ollama, or bring your own OpenAI, Claude, or Watsonx key.
66
+
67
+ ```
68
+ You: "Add input validation to the login form"
69
+
70
+ GitPilot:
71
+ 1. Reading src/auth/login.ts...
72
+ 2. Planning 3 changes...
73
+ 3. Editing login.ts β†’ [Apply Patch] [Revert]
74
+ 4. Running npm test... 3 passed
75
+ 5. Done β€” files written to your workspace.
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Get Started
81
+
82
+ ### Option 1: VS Code Extension (recommended)
83
+
84
+ Install the extension, configure your LLM, and start chatting:
85
+
86
+ ```
87
+ 1. Open VS Code
88
+ 2. Install "GitPilot Workspace" from Extensions
89
+ 3. Click the GitPilot icon in the sidebar
90
+ 4. Choose your AI provider (OpenAI, Claude, Ollama...)
91
+ 5. Start asking questions about your code
92
+ ```
93
+
94
+ ### Option 2: Web App
95
+ ![assets/2026-04-07-14-22-32.png](assets/2026-04-07-14-22-32.png)
96
+ Run the full web interface with Docker:
97
+
98
+ ```bash
99
+ git clone https://github.com/ruslanmv/gitpilot.git
100
+ cd gitpilot
101
+ docker compose up
102
+ ```
103
+
104
+ Open [http://localhost:3000](http://localhost:3000) in your browser.
105
+
106
+ ### Live Demo on Hugging Face
107
+
108
+ Experience the application in action through our hosted demo environment:
109
+
110
+ [![Live Demo Preview](assets/2026-04-07-16-17-56.png)](https://huggingface.co/spaces/ruslanmv/gitpilot)
111
+
112
+ πŸ”— **Access the live demo:**
113
+ [https://huggingface.co/spaces/ruslanmv/gitpilot](https://huggingface.co/spaces/ruslanmv/gitpilot)
114
+
115
+ ### Option 3: Python CLI (fastest)
116
+
117
+ ```bash
118
+ pip install gitcopilot
119
+ gitpilot serve
120
+ ```
121
+
122
+ Open [http://localhost:8000](http://localhost:8000) and you're done.
123
+
124
+ > **Heads up:** the PyPI package is published as **`gitcopilot`** (the name `gitpilot` was already taken) but the command you run is `gitpilot`. Python **3.11** or **3.12** required.
125
+
126
+ ---
127
+
128
+ ## VS Code Extension
129
+ ![assets/2026-04-07-14-15-33.png](assets/2026-04-07-14-15-33.png)
130
+ The sidebar panel gives you everything in one place:
131
+
132
+ | Feature | What it does |
133
+ |---|---|
134
+ | **Chat** | Ask questions, request changes, review code |
135
+ | **Execution Modes** | Bottom bar: `Auto` / `Ask` / `Plan` β€” controls agent permissions per session |
136
+ | **Plan View** | See the step-by-step plan before changes are made |
137
+ | **Plan Approval** | "Approve & Execute" / "Dismiss" bar β€” execution waits for your OK |
138
+ | **Tool Approvals** | Per-action Allow / Allow for session / Deny cards (Ask mode) |
139
+ | **Diff Preview** | Review proposed edits in VS Code's native diff viewer |
140
+ | **Apply / Revert** | One click to apply changes, one click to undo |
141
+ | **Quick Actions** | Explain, Review, Fix, Generate Tests, Security Scan |
142
+ | **Smart Commit** | AI-generated commit messages |
143
+ | **Code Lens** | Inline "Explain / Review" hints on functions |
144
+ | **Settings Tab** | Branded settings page (General, Provider, Agent, Editor) |
145
+ | **New Chat** | One click to clear chat and start a fresh session |
146
+
147
+ ### Execution modes
148
+
149
+ The compose bar includes a mode selector that controls how the multi-agent pipeline runs:
150
+
151
+ ```
152
+ [ Auto | Ask | Plan ] [ Send ] [ New Chat ]
153
+ ```
154
+
155
+ | Mode | VS Code setting | Backend value | What happens |
156
+ |---|---|---|---|
157
+ | **Ask** (default) | `gitpilot.permissionMode: "normal"` | `"normal"` | Each dangerous tool (write, edit, run, commit) shows an approval card |
158
+ | **Auto** | `gitpilot.permissionMode: "auto"` | `"auto"` | Tools execute automatically β€” no approval prompts |
159
+ | **Plan** | `gitpilot.permissionMode: "plan"` | `"plan"` | Plan is generated and displayed, all writes/commands blocked |
160
+
161
+ Mode changes are persisted to VS Code settings and synced to the backend via `PUT /api/permissions/mode`.
162
+
163
+ ### How approvals work
164
+
165
+ ```
166
+ You send a request
167
+ β†’ Explorer reads repo context
168
+ β†’ Planner drafts step-by-step plan
169
+ β†’ Plan appears in sidebar (Approve & Execute / Dismiss)
170
+ β†’ You click Approve
171
+ β†’ Coder begins execution
172
+ β†’ Dangerous tool requested (e.g. write_file)
173
+ β†’ Ask mode: approval card shown (Allow / Allow for session / Deny)
174
+ β†’ Auto mode: executes immediately
175
+ β†’ Plan mode: blocked
176
+ β†’ Tests run, Reviewer validates
177
+ β†’ Done β€” Apply Patch or Revert
178
+ ```
179
+
180
+ > **Note:** Simple questions (e.g. "explain this code") may return a direct answer without generating a multi-step plan. This is expected β€” the planner activates for tasks that require file changes or multi-step execution.
181
+
182
+ ### Code generation and Apply Patch
183
+
184
+ When you ask GitPilot to create or edit files, the response includes structured `edits` β€” not just text. The **Apply Patch** button writes them directly to your workspace.
185
+
186
+ ```
187
+ You: "Create a Flask app with app.py, requirements.txt, and README.md"
188
+
189
+ GitPilot:
190
+ β†’ LLM generates 3 files with content
191
+ β†’ Backend extracts structured edits (path + content)
192
+ β†’ VS Code shows [Apply Patch] [Revert]
193
+ β†’ You click Apply Patch
194
+ β†’ 3 files written to disk
195
+ β†’ Project context refreshes automatically
196
+ β†’ First file opens in the editor
197
+ ```
198
+
199
+ How it works under the hood:
200
+ - The LLM is instructed to output code blocks with the filename on the fence line (` ```python hello.py`)
201
+ - The backend parses these blocks into `ProposedEdit` objects with file path, kind, and content
202
+ - All paths are sanitized (rejects `../` traversal, absolute paths, drive letters)
203
+ - The extension stores edits in `activeTask.edits` and shows Apply / Revert
204
+ - `PatchApplier` writes files via `vscode.workspace.fs.writeFile`
205
+ - After apply, project context refreshes and the first file opens
206
+
207
+ > **Note:** For folder-only sessions (no GitHub remote), code generation uses the LLM directly with structured output instructions. For GitHub-connected sessions, the full CrewAI multi-agent pipeline (Explorer β†’ Planner β†’ Coder β†’ Reviewer) handles planning and execution.
208
+
209
+ ### Supported AI Providers
210
+
211
+ | Provider | Setup | Free? |
212
+ |---|---|---|
213
+ | **Ollama** | Install Ollama, run `ollama pull llama3` | Yes |
214
+ | **OllaBridge** | Works out of the box (cloud Ollama) | Yes |
215
+ | **OpenAI** | Add your API key in settings | Paid |
216
+ | **Claude** | Add your Anthropic API key | Paid |
217
+ | **Watsonx** | Add IBM credentials | Paid |
218
+
219
+ ---
220
+
221
+ ## Web App
222
+
223
+ The web interface includes:
224
+
225
+ - Chat with real-time responses
226
+ - GitHub integration (connect your repos)
227
+ - File tree browser
228
+ - Diff viewer with line-by-line changes
229
+ - Pull request creation
230
+ - Session history with checkpoints
231
+ - Multi-repo support
232
+
233
+
234
+
235
+ ### Example: File Deletion
236
+ ![](assets/2025-11-16-00-25-49.png)
237
+
238
+ ### Example: Content Generation
239
+ ![](assets/2025-11-16-00-29-47.png)
240
+
241
+ ### Example: File Creation
242
+ ![](assets/2025-11-16-01-01-40.png)
243
+
244
+ ### Example multiple operations
245
+ ![](assets/2025-11-27-00-25-53.png)
246
+
247
+ ### Example of multiagent topologies
248
+ ![](assets/2026-04-07-16-11-47.png)
249
+
250
+ ---
251
+
252
+ ## How It Works
253
+
254
+ <p align="center">
255
+ <picture>
256
+ <source srcset="docs/assets/architecture.svg" type="image/svg+xml" />
257
+ <img src="docs/assets/architecture.png" alt="GitPilot architecture: Web, VS Code and CLI share one FastAPI backend that orchestrates a CrewAI multi-agent pipeline (Explorer, Planner, Executor, Reviewer) over any LLM provider." width="100%" />
258
+ </picture>
259
+ </p>
260
+
261
+ GitPilot uses a multi-agent system powered by CrewAI:
262
+
263
+ 1. **Explorer** reads your repo structure, git log, and key files
264
+ 2. **Planner** creates a safe step-by-step plan with diffs
265
+ 3. **Executor** writes code and runs tests, self-correcting on failure
266
+ 4. **Reviewer** validates the output and summarises what changed
267
+
268
+ In **Ask** mode (default), you approve every change before it's applied. In **Auto** mode, tools execute without prompts. In **Plan** mode, only the plan is generated β€” no files are touched.
269
+
270
+ ---
271
+
272
+ ## Project Structure
273
+
274
+ ```
275
+ gitpilot/
276
+ gitpilot/ Python backend (FastAPI)
277
+ frontend/ React web app
278
+ extensions/vscode/ VS Code extension
279
+ docs/ Documentation and assets
280
+ tests/ Test suite
281
+ ```
282
+
283
+ ---
284
+
285
+ ## Configuration
286
+
287
+ GitPilot works with environment variables or the settings UI.
288
+
289
+ **Minimal setup** (Ollama, free, local):
290
+
291
+ ```bash
292
+ # .env
293
+ GITPILOT_PROVIDER=ollama
294
+ OLLAMA_BASE_URL=http://localhost:11434
295
+ GITPILOT_OLLAMA_MODEL=llama3
296
+ ```
297
+
298
+ **Cloud setup** (OpenAI):
299
+
300
+ ```bash
301
+ # .env
302
+ GITPILOT_PROVIDER=openai
303
+ OPENAI_API_KEY=sk-...
304
+ GITPILOT_OPENAI_MODEL=gpt-4o-mini
305
+ ```
306
+
307
+ **Cloud setup** (Claude):
308
+
309
+ ```bash
310
+ # .env
311
+ GITPILOT_PROVIDER=claude
312
+ ANTHROPIC_API_KEY=sk-ant-...
313
+ GITPILOT_CLAUDE_MODEL=claude-sonnet-4-5
314
+ ```
315
+
316
+ All settings can also be changed from the VS Code extension or web UI without editing files.
317
+
318
+ ---
319
+
320
+ ## API
321
+
322
+ GitPilot exposes a REST + WebSocket API:
323
+
324
+ | Endpoint | What it does |
325
+ |---|---|
326
+ | `GET /api/status` | Server health check |
327
+ | `POST /api/chat/send` | Send a message, get a response |
328
+ | `POST /api/v2/chat/stream` | Stream agent events (SSE) β€” accepts `permission_mode` |
329
+ | `WS /ws/v2/sessions/{id}` | Real-time WebSocket streaming |
330
+ | `POST /api/chat/plan` | Generate an execution plan |
331
+ | `POST /api/chat/execute` | Execute a plan |
332
+ | `GET /api/repos` | List connected repositories |
333
+ | `GET /api/sessions` | List chat sessions |
334
+ | `GET /api/permissions` | Current permission policy |
335
+ | `PUT /api/permissions/mode` | Set execution mode: `normal` / `auto` / `plan` |
336
+ | `POST /api/v2/approval/respond` | Approve or deny a tool execution request |
337
+
338
+ Full API docs at `http://localhost:8000/docs` (Swagger UI).
339
+
340
+ ---
341
+
342
+ ## Deployment
343
+
344
+ ### Hugging Face Spaces
345
+
346
+ GitPilot runs on Hugging Face Spaces with OllaBridge (free):
347
+
348
+ ```
349
+ Runtime: Docker
350
+ Port: 7860
351
+ Provider: OllaBridge (cloud Ollama)
352
+ ```
353
+
354
+ ### Docker Compose
355
+
356
+ ```bash
357
+ docker compose up -d
358
+ # Backend: http://localhost:8000
359
+ # Frontend: http://localhost:3000
360
+ ```
361
+
362
+ ### Vercel
363
+
364
+ The frontend deploys to Vercel. Set `VITE_BACKEND_URL` to your backend.
365
+
366
+ ---
367
+
368
+ ## Contributing
369
+
370
+ ```bash
371
+ # Standard install: runtime backend + frontend + MCP stack
372
+ make install
373
+ # WSL note: the Makefile defaults uv to UV_LINK_MODE=copy to avoid
374
+ # hardlink fallback warnings on /mnt/c checkouts. For best install speed,
375
+ # clone the repo inside the native WSL filesystem (for example ~/workspace).
376
+
377
+ # Developer/test tooling
378
+ make install-dev
379
+ make test
380
+
381
+ # Frontend only
382
+ cd frontend
383
+ npm ci
384
+ npm run dev
385
+
386
+ # VS Code Extension
387
+ cd extensions/vscode
388
+ npm install
389
+ make compile
390
+ # Press F5 in VS Code to launch debug host
391
+ ```
392
+
393
+ ---
394
+
395
+ ## License
396
+
397
+ Apache License 2.0. See [LICENSE](LICENSE).
398
+
399
+ ---
400
+
401
+ <div align="center">
402
+
403
+ **GitPilot** is made by [Ruslan Magana Vsevolodovna](https://github.com/ruslanmv)
404
+
405
+ [Star on GitHub](https://github.com/ruslanmv/gitpilot) &#8226; [Report a Bug](https://github.com/ruslanmv/gitpilot/issues) &#8226; [Request a Feature](https://github.com/ruslanmv/gitpilot/issues)
406
+
407
+ </div>
408
+
409
+ ---
410
+ **MCP Context Forge integration** β€” GitPilot ships a default MCP stack (Forge + PostgreSQL / Milvus / Inspector servers) wired into the agents like Claude Code's built-ins; `make run` brings everything up. No Docker? Use `make run-bare` to start GitPilot core without MCP. See [docs/deploy/install-mcp.md](./docs/deploy/install-mcp.md) and [docs/deploy/production-mcp.md](./docs/deploy/production-mcp.md).
411
+
412
+ ---
413
+
414
+ ## What's New
415
+
416
+ > **Enterprise-ready foundation:** GitPilot now ships with safer defaults and production-grade controls, including thread-safe feature flags, strict typing, CI coverage enforcement, structured error handling, and a fast `gitpilot doctor` health check. All upgrades are additive, flag-gated, and disabled by default, so existing installations remain stable while teams can adopt new capabilities gradually.
417
+
418
+ > **Performance, onboarding, and release confidence:** GitPilot now improves runtime efficiency with prompt caching, lazy tool loading, context memoisation, SSE streaming, and safe model warmup. First-time setup is easier with `gitpilot init --wizard`, which creates configuration files atomically with rollback protection and no secret exposure. The platform also adds a stable public API, deprecation handling, MkDocs documentation, broken-link checks, SBOM generation, npm auditing, and Sigstore-based release signing.
frontend/.dockerignore ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Node
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-debug.log*
5
+ yarn-error.log*
6
+ .pnpm-debug.log*
7
+
8
+ # Build
9
+ dist/
10
+ build/
11
+
12
+ # Environment
13
+ .env
14
+ .env.local
15
+ .env.development
16
+ .env.test
17
+ .env.production.local
18
+
19
+ # IDE
20
+ .vscode/
21
+ .idea/
22
+ *.swp
23
+ *.swo
24
+ *~
25
+
26
+ # OS
27
+ .DS_Store
28
+ Thumbs.db
29
+
30
+ # Git
31
+ .git
32
+ .gitignore
33
+
34
+ # Testing
35
+ coverage/
36
+ .nyc_output/
37
+
38
+ # Misc
39
+ *.log
frontend/App.jsx ADDED
@@ -0,0 +1,1229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import StartupScreen from "./components/StartupScreen.jsx";
3
+ import LoginPage from "./components/LoginPage.jsx";
4
+ import RepoSelector from "./components/RepoSelector.jsx";
5
+ import ProjectContextPanel from "./components/ProjectContextPanel.jsx";
6
+ import ChatPanel from "./components/ChatPanel.jsx";
7
+ import LlmSettings from "./components/LlmSettings.jsx";
8
+ import FlowViewer from "./components/FlowViewer.jsx";
9
+ import Footer from "./components/Footer.jsx";
10
+ import ProjectSettingsModal from "./components/ProjectSettingsModal.jsx";
11
+ import SessionSidebar from "./components/SessionSidebar.jsx";
12
+ import ContextBar from "./components/ContextBar.jsx";
13
+ import AddRepoModal from "./components/AddRepoModal.jsx";
14
+ import UserMenu from "./components/UserMenu.jsx";
15
+ import AboutModal from "./components/AboutModal.jsx";
16
+ import {
17
+ WorkspaceModesTab,
18
+ SecurityTab,
19
+ IntegrationsTab,
20
+ MCPServersTab,
21
+ SkillsTab,
22
+ SessionsTab,
23
+ AdvancedTab,
24
+ } from "./components/AdminTabs";
25
+ import { apiUrl, safeFetchJSON, fetchStatus } from "./utils/api.js";
26
+ import { initApp } from "./utils/appInit.js";
27
+
28
+ function makeRepoKey(repo) {
29
+ if (!repo) return null;
30
+ return repo.full_name || `${repo.owner}/${repo.name}`;
31
+ }
32
+
33
+ function uniq(arr) {
34
+ return Array.from(new Set((arr || []).filter(Boolean)));
35
+ }
36
+
37
+ function getProviderLabel(status) {
38
+ if (!status) return "Checking...";
39
+ return (
40
+ status?.provider?.name ||
41
+ status?.provider_name ||
42
+ status?.provider?.provider ||
43
+ "Checking..."
44
+ );
45
+ }
46
+
47
+ function getBackendVersion(status) {
48
+ if (!status) return "Checking...";
49
+ return status?.version || status?.app_version || "Checking...";
50
+ }
51
+
52
+ export default function App() {
53
+ const frontendVersion = __APP_VERSION__ || "unknown";
54
+
55
+ // ---- Multi-repo context state ----
56
+ const [contextRepos, setContextRepos] = useState([]);
57
+ // Each entry: { repoKey: "owner/repo", repo: {...}, branch: "main" }
58
+ const [activeRepoKey, setActiveRepoKey] = useState(null);
59
+ const [addRepoOpen, setAddRepoOpen] = useState(false);
60
+
61
+ const [activePage, setActivePage] = useState("workspace");
62
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
63
+ const [isLoading, setIsLoading] = useState(true);
64
+ const [userInfo, setUserInfo] = useState(null);
65
+
66
+ // Startup / enterprise loader state
67
+ const [startupPhase, setStartupPhase] = useState("booting");
68
+ const [startupStatusMessage, setStartupStatusMessage] = useState("Starting application...");
69
+ const [startupDetailMessage, setStartupDetailMessage] = useState(
70
+ "Initializing authentication, provider, and workspace context."
71
+ );
72
+ const [startupStatusSnapshot, setStartupStatusSnapshot] = useState(null);
73
+
74
+ // Repo + Session State Machine
75
+ const [repoStateByKey, setRepoStateByKey] = useState({});
76
+ const [toast, setToast] = useState(null);
77
+ const [settingsOpen, setSettingsOpen] = useState(false);
78
+ const [aboutOpen, setAboutOpen] = useState(false);
79
+ const [adminTab, setAdminTab] = useState("overview");
80
+ const [adminStatus, setAdminStatus] = useState(null);
81
+
82
+ // Fetch admin status when overview tab is active
83
+ useEffect(() => {
84
+ if (activePage === "admin" && adminTab === "overview") {
85
+ fetchStatus()
86
+ .then((data) => setAdminStatus(data))
87
+ .catch(() => setAdminStatus(null));
88
+ }
89
+ }, [activePage, adminTab]);
90
+
91
+ // Claude-Code-on-Web: Session sidebar + Environment state
92
+ const [activeSessionId, setActiveSessionId] = useState(null);
93
+ const [activeEnvId, setActiveEnvId] = useState("default");
94
+ const [sessionRefreshNonce, setSessionRefreshNonce] = useState(0);
95
+
96
+ // Sidebar collapse state (persisted in localStorage)
97
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
98
+ try {
99
+ return localStorage.getItem("gitpilot_sidebar_collapsed") === "true";
100
+ } catch {
101
+ return false;
102
+ }
103
+ });
104
+
105
+ const toggleSidebar = useCallback(() => {
106
+ setSidebarCollapsed((prev) => {
107
+ const next = !prev;
108
+ try {
109
+ localStorage.setItem("gitpilot_sidebar_collapsed", String(next));
110
+ } catch {}
111
+ return next;
112
+ });
113
+ }, []);
114
+
115
+ // Keyboard shortcut: Cmd/Ctrl + B to toggle sidebar
116
+ useEffect(() => {
117
+ const handler = (e) => {
118
+ if ((e.metaKey || e.ctrlKey) && e.key === "b") {
119
+ e.preventDefault();
120
+ toggleSidebar();
121
+ }
122
+ };
123
+ window.addEventListener("keydown", handler);
124
+ return () => window.removeEventListener("keydown", handler);
125
+ }, [toggleSidebar]);
126
+
127
+ // ---- Derived `repo` β€” keeps all downstream consumers unchanged ----
128
+ const repo = useMemo(() => {
129
+ const entry = contextRepos.find((r) => r.repoKey === activeRepoKey);
130
+ return entry?.repo || null;
131
+ }, [contextRepos, activeRepoKey]);
132
+
133
+ const repoKey = activeRepoKey;
134
+
135
+ // Convenient selectors
136
+ const currentRepoState = repoKey ? repoStateByKey[repoKey] : null;
137
+
138
+ const defaultBranch = currentRepoState?.defaultBranch || repo?.default_branch || "main";
139
+ const currentBranch = currentRepoState?.currentBranch || defaultBranch;
140
+ const sessionBranches = currentRepoState?.sessionBranches || [];
141
+ const lastExecution = currentRepoState?.lastExecution || null;
142
+ const pulseNonce = currentRepoState?.pulseNonce || 0;
143
+ const chatByBranch = currentRepoState?.chatByBranch || {};
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Multi-repo context management
147
+ // ---------------------------------------------------------------------------
148
+ const addRepoToContext = useCallback((r) => {
149
+ const key = makeRepoKey(r);
150
+ if (!key) return;
151
+
152
+ setContextRepos((prev) => {
153
+ if (prev.some((e) => e.repoKey === key)) {
154
+ setActiveRepoKey(key);
155
+ return prev;
156
+ }
157
+ const entry = { repoKey: key, repo: r, branch: r.default_branch || "main" };
158
+ return [...prev, entry];
159
+ });
160
+
161
+ setActiveRepoKey(key);
162
+ setAddRepoOpen(false);
163
+ }, []);
164
+
165
+ const removeRepoFromContext = useCallback((key) => {
166
+ setContextRepos((prev) => {
167
+ const next = prev.filter((e) => e.repoKey !== key);
168
+ setActiveRepoKey((curActive) => {
169
+ if (curActive === key) {
170
+ return next.length > 0 ? next[0].repoKey : null;
171
+ }
172
+ return curActive;
173
+ });
174
+ return next;
175
+ });
176
+ }, []);
177
+
178
+ const clearAllContext = useCallback(() => {
179
+ setContextRepos([]);
180
+ setActiveRepoKey(null);
181
+ }, []);
182
+
183
+ const handleContextBranchChange = useCallback((targetRepoKey, newBranch) => {
184
+ setContextRepos((prev) =>
185
+ prev.map((e) =>
186
+ e.repoKey === targetRepoKey ? { ...e, branch: newBranch } : e
187
+ )
188
+ );
189
+
190
+ setRepoStateByKey((prev) => {
191
+ const cur = prev[targetRepoKey];
192
+ if (!cur) return prev;
193
+ return {
194
+ ...prev,
195
+ [targetRepoKey]: { ...cur, currentBranch: newBranch },
196
+ };
197
+ });
198
+ }, []);
199
+
200
+ // Init / reconcile repo state when active repo changes
201
+ useEffect(() => {
202
+ if (!repoKey || !repo) return;
203
+
204
+ setRepoStateByKey((prev) => {
205
+ const existing = prev[repoKey];
206
+ const d = repo.default_branch || "main";
207
+
208
+ if (!existing) {
209
+ return {
210
+ ...prev,
211
+ [repoKey]: {
212
+ defaultBranch: d,
213
+ currentBranch: d,
214
+ sessionBranches: [],
215
+ lastExecution: null,
216
+ pulseNonce: 0,
217
+ chatByBranch: {
218
+ [d]: { messages: [], plan: null },
219
+ },
220
+ },
221
+ };
222
+ }
223
+
224
+ const next = { ...existing };
225
+ next.defaultBranch = d;
226
+
227
+ if (!next.chatByBranch?.[d]) {
228
+ next.chatByBranch = {
229
+ ...(next.chatByBranch || {}),
230
+ [d]: { messages: [], plan: null },
231
+ };
232
+ }
233
+
234
+ if (!next.currentBranch) next.currentBranch = d;
235
+
236
+ return { ...prev, [repoKey]: next };
237
+ });
238
+ }, [repoKey, repo?.id, repo?.default_branch]);
239
+
240
+ const showToast = (title, message) => {
241
+ setToast({ title, message });
242
+ window.setTimeout(() => setToast(null), 5000);
243
+ };
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Session management β€” every chat is backed by a Session (Claude Code parity)
247
+ // ---------------------------------------------------------------------------
248
+
249
+ const _creatingSessionRef = useRef(false);
250
+
251
+ const [chatBySession, setChatBySession] = useState({});
252
+
253
+ const ensureSession = useCallback(
254
+ async (sessionName, seedMessages) => {
255
+ if (activeSessionId) return activeSessionId;
256
+ if (!repo) return null;
257
+ if (_creatingSessionRef.current) return null;
258
+ _creatingSessionRef.current = true;
259
+
260
+ try {
261
+ const token = localStorage.getItem("github_token");
262
+ const headers = {
263
+ "Content-Type": "application/json",
264
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
265
+ };
266
+
267
+ const res = await fetch("/api/sessions", {
268
+ method: "POST",
269
+ headers,
270
+ body: JSON.stringify({
271
+ repo_full_name: repoKey,
272
+ branch: currentBranch,
273
+ name: sessionName || undefined,
274
+ repos: contextRepos.map((e) => ({
275
+ full_name: e.repoKey,
276
+ branch: e.branch,
277
+ mode: e.repoKey === activeRepoKey ? "write" : "read",
278
+ })),
279
+ active_repo: activeRepoKey,
280
+ }),
281
+ });
282
+
283
+ if (!res.ok) return null;
284
+ const data = await res.json();
285
+ const newId = data.session_id;
286
+
287
+ if (seedMessages && seedMessages.length > 0) {
288
+ setChatBySession((prev) => ({
289
+ ...prev,
290
+ [newId]: { messages: seedMessages, plan: null },
291
+ }));
292
+ }
293
+
294
+ setActiveSessionId(newId);
295
+ setSessionRefreshNonce((n) => n + 1);
296
+ return newId;
297
+ } catch (err) {
298
+ console.warn("Failed to create session:", err);
299
+ return null;
300
+ } finally {
301
+ _creatingSessionRef.current = false;
302
+ }
303
+ },
304
+ [activeSessionId, repo, repoKey, currentBranch, contextRepos, activeRepoKey]
305
+ );
306
+
307
+ const handleNewSession = async () => {
308
+ setActiveSessionId(null);
309
+
310
+ try {
311
+ const token = localStorage.getItem("github_token");
312
+ const headers = {
313
+ "Content-Type": "application/json",
314
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
315
+ };
316
+
317
+ const res = await fetch("/api/sessions", {
318
+ method: "POST",
319
+ headers,
320
+ body: JSON.stringify({
321
+ repo_full_name: repoKey,
322
+ branch: currentBranch,
323
+ repos: contextRepos.map((e) => ({
324
+ full_name: e.repoKey,
325
+ branch: e.branch,
326
+ mode: e.repoKey === activeRepoKey ? "write" : "read",
327
+ })),
328
+ active_repo: activeRepoKey,
329
+ }),
330
+ });
331
+
332
+ if (!res.ok) return;
333
+ const data = await res.json();
334
+ setActiveSessionId(data.session_id);
335
+ setSessionRefreshNonce((n) => n + 1);
336
+ showToast("Session Created", "New session started.");
337
+ } catch (err) {
338
+ console.warn("Failed to create session:", err);
339
+ }
340
+ };
341
+
342
+ /**
343
+ * Convert a backend Message object to the frontend chat UI shape.
344
+ * Backend: { role: "user|assistant|system", content: "...", timestamp, metadata }
345
+ * Frontend: { from: "user|ai", role: "user|assistant|system", content, answer, ... }
346
+ */
347
+ const normalizeBackendMessage = (m) => {
348
+ const role = m.role || "assistant";
349
+ const content = m.content || "";
350
+ if (role === "user") {
351
+ return { from: "user", role: "user", content, text: content };
352
+ }
353
+ if (role === "system") {
354
+ return { from: "ai", role: "system", content };
355
+ }
356
+ // assistant
357
+ return {
358
+ from: "ai",
359
+ role: "assistant",
360
+ content,
361
+ answer: content,
362
+ // Preserve any structured metadata the backend stored (plan, diff, etc.)
363
+ ...(m.metadata && typeof m.metadata === "object" ? m.metadata : {}),
364
+ };
365
+ };
366
+
367
+ /**
368
+ * Fetch persisted messages for a session from the backend.
369
+ * Returns an array of normalized frontend messages (ready for ChatPanel),
370
+ * or an empty array on failure.
371
+ */
372
+ const fetchSessionMessages = useCallback(async (sessionId) => {
373
+ if (!sessionId) return [];
374
+ try {
375
+ const token = localStorage.getItem("github_token");
376
+ const headers = { "Content-Type": "application/json" };
377
+ if (token) headers["Authorization"] = `Bearer ${token}`;
378
+
379
+ const res = await fetch(apiUrl(`/api/sessions/${sessionId}/messages`), {
380
+ headers,
381
+ });
382
+ if (!res.ok) {
383
+ console.warn(`[fetchSessionMessages] ${res.status} for ${sessionId}`);
384
+ return [];
385
+ }
386
+ const data = await res.json();
387
+ const backendMessages = Array.isArray(data.messages) ? data.messages : [];
388
+ return backendMessages.map(normalizeBackendMessage);
389
+ } catch (err) {
390
+ console.warn(`[fetchSessionMessages] Failed to fetch ${sessionId}:`, err);
391
+ return [];
392
+ }
393
+ }, []);
394
+
395
+ /**
396
+ * Handle click on a session in the sidebar.
397
+ *
398
+ * Critical ordering: we must hydrate chatBySession BEFORE setting
399
+ * activeSessionId, because ChatPanel's session-sync useEffect reads
400
+ * sessionChatState only when sessionId changes (it does NOT depend on
401
+ * chatBySession to avoid prop/state loops). If we set activeSessionId
402
+ * first, ChatPanel would see an empty messages array, then our async
403
+ * hydration would complete but ChatPanel wouldn't re-sync.
404
+ */
405
+ const handleSelectSession = useCallback(async (session) => {
406
+ // 1. Fetch persisted messages first
407
+ const messages = await fetchSessionMessages(session.id);
408
+
409
+ // 2. Seed the chat cache (ChatPanel will read this via sessionChatState)
410
+ setChatBySession((prev) => ({
411
+ ...prev,
412
+ [session.id]: {
413
+ ...(prev[session.id] || { plan: null }),
414
+ messages,
415
+ },
416
+ }));
417
+
418
+ // 3. NOW activate the session β€” ChatPanel's sync effect will read
419
+ // the hydrated messages from chatBySession[session.id]
420
+ setActiveSessionId(session.id);
421
+ if (session.branch && session.branch !== currentBranch) {
422
+ handleBranchChange(session.branch);
423
+ }
424
+ // eslint-disable-next-line react-hooks/exhaustive-deps
425
+ }, [fetchSessionMessages, currentBranch]);
426
+
427
+ const handleDeleteSession = useCallback(
428
+ (deletedId) => {
429
+ if (deletedId === activeSessionId) {
430
+ setActiveSessionId(null);
431
+
432
+ setChatBySession((prev) => {
433
+ const next = { ...prev };
434
+ delete next[deletedId];
435
+ return next;
436
+ });
437
+
438
+ if (repoKey) {
439
+ setRepoStateByKey((prev) => {
440
+ const cur = prev[repoKey];
441
+ if (!cur) return prev;
442
+ const branchKey = cur.currentBranch || cur.defaultBranch || defaultBranch;
443
+ return {
444
+ ...prev,
445
+ [repoKey]: {
446
+ ...cur,
447
+ chatByBranch: {
448
+ ...(cur.chatByBranch || {}),
449
+ [branchKey]: { messages: [], plan: null },
450
+ },
451
+ },
452
+ };
453
+ });
454
+ }
455
+ }
456
+ },
457
+ [activeSessionId, repoKey, defaultBranch]
458
+ );
459
+
460
+ // ---------------------------------------------------------------------------
461
+ // Chat persistence helpers
462
+ // ---------------------------------------------------------------------------
463
+ const updateChatForCurrentBranch = (patch) => {
464
+ if (!repoKey) return;
465
+
466
+ setRepoStateByKey((prev) => {
467
+ const cur = prev[repoKey];
468
+ if (!cur) return prev;
469
+
470
+ const branchKey = cur.currentBranch || cur.defaultBranch || defaultBranch;
471
+
472
+ const existing = cur.chatByBranch?.[branchKey] || {
473
+ messages: [],
474
+ plan: null,
475
+ };
476
+
477
+ return {
478
+ ...prev,
479
+ [repoKey]: {
480
+ ...cur,
481
+ chatByBranch: {
482
+ ...(cur.chatByBranch || {}),
483
+ [branchKey]: { ...existing, ...patch },
484
+ },
485
+ },
486
+ };
487
+ });
488
+ };
489
+
490
+ const currentChatState = useMemo(() => {
491
+ const b = currentBranch || defaultBranch;
492
+ return chatByBranch[b] || { messages: [], plan: null };
493
+ }, [chatByBranch, currentBranch, defaultBranch]);
494
+
495
+ const sessionChatState = useMemo(() => {
496
+ if (!activeSessionId) {
497
+ return currentChatState;
498
+ }
499
+ return chatBySession[activeSessionId] || { messages: [], plan: null };
500
+ }, [activeSessionId, chatBySession, currentChatState]);
501
+
502
+ const updateSessionChat = (patch) => {
503
+ if (activeSessionId) {
504
+ setChatBySession((prev) => ({
505
+ ...prev,
506
+ [activeSessionId]: {
507
+ ...(prev[activeSessionId] || { messages: [], plan: null }),
508
+ ...patch,
509
+ },
510
+ }));
511
+ } else {
512
+ updateChatForCurrentBranch(patch);
513
+ }
514
+ };
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // Branch change (manual β€” for active repo)
518
+ // ---------------------------------------------------------------------------
519
+ const handleBranchChange = (nextBranch) => {
520
+ if (!repoKey) return;
521
+ if (!nextBranch || nextBranch === currentBranch) return;
522
+
523
+ setRepoStateByKey((prev) => {
524
+ const cur = prev[repoKey];
525
+ if (!cur) return prev;
526
+
527
+ const nextState = { ...cur, currentBranch: nextBranch };
528
+
529
+ if (nextBranch === cur.defaultBranch) {
530
+ nextState.chatByBranch = {
531
+ ...nextState.chatByBranch,
532
+ [nextBranch]: { messages: [], plan: null },
533
+ };
534
+ }
535
+
536
+ return { ...prev, [repoKey]: nextState };
537
+ });
538
+
539
+ setContextRepos((prev) =>
540
+ prev.map((e) =>
541
+ e.repoKey === repoKey ? { ...e, branch: nextBranch } : e
542
+ )
543
+ );
544
+
545
+ if (nextBranch === defaultBranch) {
546
+ showToast("New Session", `Switched to ${defaultBranch}. Chat cleared.`);
547
+ } else {
548
+ showToast("Context Switched", `Now viewing ${nextBranch}.`);
549
+ }
550
+ };
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // Execution complete
554
+ // ---------------------------------------------------------------------------
555
+ const handleExecutionComplete = ({
556
+ branch,
557
+ mode,
558
+ commit_url,
559
+ completionMsg,
560
+ sourceBranch,
561
+ }) => {
562
+ if (!repoKey || !branch) return;
563
+
564
+ // Clear the session-keyed chat cache's ``plan`` AND append the
565
+ // completion message synchronously, before any branch change can
566
+ // trigger ChatPanel's session-sync effect. Two bugs need to be
567
+ // fixed in the same write:
568
+ //
569
+ // 1. Stale plan: without clearing, the sync effect re-reads the
570
+ // old approved plan and restores the Approve & execute / Reject
571
+ // plan buttons, enabling accidental double-execution.
572
+ //
573
+ // 2. Wiped completion: in hard-switch mode the sync effect runs
574
+ // BEFORE the persistence effect (declared earlier in
575
+ // ChatPanel), so it overwrites local ``messages`` with
576
+ // ``sessionChatState.messages`` β€” which doesn't yet contain
577
+ // completionMsg. The user's "Answer / Execution Log" block
578
+ // then vanishes from the session view.
579
+ //
580
+ // By appending normalizedCompletion here, sessionChatState already
581
+ // carries the completion when the sync effect reads it. No
582
+ // duplicate is introduced: local ``messages`` already has the same
583
+ // entry, so the subsequent persistence pass is a no-op write.
584
+ if (activeSessionId) {
585
+ const normalizedCompletion =
586
+ completionMsg &&
587
+ (completionMsg.answer || completionMsg.content || completionMsg.executionLog)
588
+ ? {
589
+ from: completionMsg.from || "ai",
590
+ role: completionMsg.role || "assistant",
591
+ answer: completionMsg.answer,
592
+ content: completionMsg.content,
593
+ executionLog: completionMsg.executionLog,
594
+ diff: completionMsg.diff,
595
+ }
596
+ : null;
597
+ setChatBySession((prev) => {
598
+ const existing = prev[activeSessionId];
599
+ if (!existing) return prev;
600
+ const noPlanChange = existing.plan == null;
601
+ if (noPlanChange && !normalizedCompletion) return prev;
602
+ return {
603
+ ...prev,
604
+ [activeSessionId]: {
605
+ ...existing,
606
+ messages: normalizedCompletion
607
+ ? [...(existing.messages || []), normalizedCompletion]
608
+ : existing.messages,
609
+ plan: null,
610
+ },
611
+ };
612
+ });
613
+ }
614
+
615
+ setRepoStateByKey((prev) => {
616
+ const cur =
617
+ prev[repoKey] || {
618
+ defaultBranch,
619
+ currentBranch: defaultBranch,
620
+ sessionBranches: [],
621
+ lastExecution: null,
622
+ pulseNonce: 0,
623
+ chatByBranch: { [defaultBranch]: { messages: [], plan: null } },
624
+ };
625
+
626
+ const next = { ...cur };
627
+ next.lastExecution = { mode, branch, ts: Date.now() };
628
+
629
+ if (!next.chatByBranch) next.chatByBranch = {};
630
+
631
+ const prevBranchKey =
632
+ sourceBranch || cur.currentBranch || cur.defaultBranch || defaultBranch;
633
+
634
+ const successSystemMsg = {
635
+ role: "system",
636
+ isSuccess: true,
637
+ link: commit_url,
638
+ content:
639
+ mode === "hard-switch"
640
+ ? `🌱 **Session Started:** Created branch \`${branch}\`.`
641
+ : `βœ… **Update Published:** Commits pushed to \`${branch}\`.`,
642
+ };
643
+
644
+ const normalizedCompletion =
645
+ completionMsg &&
646
+ (completionMsg.answer || completionMsg.content || completionMsg.executionLog)
647
+ ? {
648
+ from: completionMsg.from || "ai",
649
+ role: completionMsg.role || "assistant",
650
+ answer: completionMsg.answer,
651
+ content: completionMsg.content,
652
+ executionLog: completionMsg.executionLog,
653
+ }
654
+ : null;
655
+
656
+ if (mode === "hard-switch") {
657
+ next.sessionBranches = uniq([...(next.sessionBranches || []), branch]);
658
+ next.currentBranch = branch;
659
+ next.pulseNonce = (next.pulseNonce || 0) + 1;
660
+
661
+ const existingTargetChat = next.chatByBranch[branch];
662
+ const isExistingSession =
663
+ existingTargetChat && (existingTargetChat.messages || []).length > 0;
664
+
665
+ if (isExistingSession) {
666
+ const appended = [
667
+ ...(existingTargetChat.messages || []),
668
+ ...(normalizedCompletion ? [normalizedCompletion] : []),
669
+ successSystemMsg,
670
+ ];
671
+
672
+ next.chatByBranch[branch] = {
673
+ ...existingTargetChat,
674
+ messages: appended,
675
+ plan: null,
676
+ };
677
+ } else {
678
+ const prevChat =
679
+ (cur.chatByBranch && cur.chatByBranch[prevBranchKey]) || {
680
+ messages: [],
681
+ plan: null,
682
+ };
683
+
684
+ next.chatByBranch[branch] = {
685
+ messages: [
686
+ ...(prevChat.messages || []),
687
+ ...(normalizedCompletion ? [normalizedCompletion] : []),
688
+ successSystemMsg,
689
+ ],
690
+ plan: null,
691
+ };
692
+ }
693
+
694
+ if (!next.chatByBranch[next.defaultBranch]) {
695
+ next.chatByBranch[next.defaultBranch] = { messages: [], plan: null };
696
+ }
697
+ } else if (mode === "sticky") {
698
+ next.currentBranch = cur.currentBranch || branch;
699
+
700
+ const targetChat = next.chatByBranch[branch] || { messages: [], plan: null };
701
+
702
+ next.chatByBranch[branch] = {
703
+ messages: [
704
+ ...(targetChat.messages || []),
705
+ ...(normalizedCompletion ? [normalizedCompletion] : []),
706
+ successSystemMsg,
707
+ ],
708
+ plan: null,
709
+ };
710
+ }
711
+
712
+ return { ...prev, [repoKey]: next };
713
+ });
714
+
715
+ if (mode === "hard-switch") {
716
+ showToast("Context Switched", `Active on ${branch}.`);
717
+ } else {
718
+ showToast("Changes Committed", `Updated ${branch}.`);
719
+ }
720
+ };
721
+
722
+ // ---------------------------------------------------------------------------
723
+ // Auth & startup render
724
+ // ---------------------------------------------------------------------------
725
+ useEffect(() => {
726
+ checkAuthentication();
727
+ }, []);
728
+
729
+ const checkAuthentication = async () => {
730
+ setStartupPhase("booting");
731
+ setStartupStatusMessage("Starting application...");
732
+ setStartupDetailMessage(
733
+ "Initializing authentication, provider, and workspace context."
734
+ );
735
+
736
+ try {
737
+ setStartupPhase("checking-backend");
738
+ setStartupStatusMessage("Connecting to backend...");
739
+ setStartupDetailMessage(
740
+ "Waiting for the server to be ready. This may take a few seconds on first start."
741
+ );
742
+
743
+ // Single-source-of-truth init: combines /api/status + /api/auth/status
744
+ // in one request. Runs exactly once per page load (StrictMode-safe).
745
+ const initResult = await initApp();
746
+ const status = initResult.status;
747
+ if (status) {
748
+ setStartupStatusSnapshot(status);
749
+ setAdminStatus(status);
750
+ }
751
+
752
+ const token = localStorage.getItem("github_token");
753
+ const user = localStorage.getItem("github_user");
754
+
755
+ if (token && user) {
756
+ setStartupPhase("validating-auth");
757
+ setStartupStatusMessage("Validating authentication...");
758
+ setStartupDetailMessage(
759
+ "Restoring your GitHub session and confirming access."
760
+ );
761
+
762
+ try {
763
+ const data = await safeFetchJSON(apiUrl("/api/auth/validate"), {
764
+ method: "POST",
765
+ headers: { "Content-Type": "application/json" },
766
+ body: JSON.stringify({ access_token: token }),
767
+ timeout: 20000, // 20s β€” first-load GitHub API validation can be slow
768
+ });
769
+
770
+ if (data.authenticated) {
771
+ setStartupPhase("restoring-session");
772
+ setStartupStatusMessage("Restoring workspace...");
773
+ setStartupDetailMessage(
774
+ "Loading user profile, reconnecting provider state, and preparing the workspace."
775
+ );
776
+
777
+ setIsAuthenticated(true);
778
+ setUserInfo(JSON.parse(user));
779
+ setIsLoading(false);
780
+ return;
781
+ }
782
+ } catch (err) {
783
+ console.error(err);
784
+ }
785
+
786
+ localStorage.removeItem("github_token");
787
+ localStorage.removeItem("github_user");
788
+ }
789
+
790
+ setStartupPhase("ready");
791
+ setStartupStatusMessage("Preparing sign-in...");
792
+ setStartupDetailMessage(
793
+ "GitPilot is ready. Please authenticate to continue."
794
+ );
795
+
796
+ setIsAuthenticated(false);
797
+ setIsLoading(false);
798
+ } catch (err) {
799
+ console.error(err);
800
+ setStartupPhase("fallback");
801
+ setStartupStatusMessage("Starting application...");
802
+ setStartupDetailMessage(
803
+ "Continuing with basic startup while backend status is still loading."
804
+ );
805
+ setIsAuthenticated(false);
806
+ setIsLoading(false);
807
+ }
808
+ };
809
+
810
+ const handleAuthenticated = (session) => {
811
+ setIsAuthenticated(true);
812
+ setUserInfo(session.user);
813
+ };
814
+
815
+ const handleLogout = () => {
816
+ localStorage.removeItem("github_token");
817
+ localStorage.removeItem("github_user");
818
+ setIsAuthenticated(false);
819
+ setUserInfo(null);
820
+ clearAllContext();
821
+ };
822
+
823
+ if (isLoading) {
824
+ return (
825
+ <StartupScreen
826
+ appName="GitPilot"
827
+ subtitle="Enterprise Workspace Copilot"
828
+ frontendVersion={frontendVersion}
829
+ backendVersion={getBackendVersion(startupStatusSnapshot)}
830
+ provider={getProviderLabel(startupStatusSnapshot)}
831
+ statusMessage={startupStatusMessage}
832
+ detailMessage={startupDetailMessage}
833
+ phase={startupPhase}
834
+ />
835
+ );
836
+ }
837
+
838
+ if (!isAuthenticated) {
839
+ return (
840
+ <LoginPage
841
+ onAuthenticated={handleAuthenticated}
842
+ backendReady={!!startupStatusSnapshot}
843
+ />
844
+ );
845
+ }
846
+
847
+ const hasContext = contextRepos.length > 0;
848
+
849
+ return (
850
+ <div className="app-root">
851
+ <div className="main-wrapper">
852
+ <aside className={`sidebar${sidebarCollapsed ? " sidebar--collapsed" : ""}`}>
853
+ <div
854
+ className="sidebar-top-row"
855
+ >
856
+ <div
857
+ className="logo-row"
858
+ onClick={sidebarCollapsed ? toggleSidebar : undefined}
859
+ style={sidebarCollapsed ? { cursor: "pointer" } : undefined}
860
+ >
861
+ <div className="logo-square">GP</div>
862
+ {!sidebarCollapsed && (
863
+ <div>
864
+ <div className="logo-title">GitPilot</div>
865
+ <div className="logo-subtitle">Agentic GitHub Copilot</div>
866
+ </div>
867
+ )}
868
+ </div>
869
+
870
+ {!sidebarCollapsed && (
871
+ <button
872
+ className="sidebar-toggle-btn"
873
+ onClick={toggleSidebar}
874
+ title="Collapse sidebar (Ctrl+B)"
875
+ >
876
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
877
+ <path
878
+ d="M10 3L5 8L10 13"
879
+ stroke="currentColor"
880
+ strokeWidth="1.5"
881
+ strokeLinecap="round"
882
+ strokeLinejoin="round"
883
+ />
884
+ </svg>
885
+ </button>
886
+ )}
887
+ </div>
888
+
889
+ <div className="main-nav">
890
+ <button
891
+ className={"nav-btn" + (activePage === "workspace" ? " nav-btn-active" : "")}
892
+ onClick={() => setActivePage("workspace")}
893
+ title="Workspace"
894
+ >
895
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
896
+ <rect x="2" y="2" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.3" />
897
+ <rect x="9" y="2" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.3" />
898
+ <rect x="2" y="9" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.3" />
899
+ <rect x="9" y="9" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.3" />
900
+ </svg>
901
+ {!sidebarCollapsed && <span>Workspace</span>}
902
+ </button>
903
+
904
+ <button
905
+ className={"nav-btn" + (activePage === "flow" ? " nav-btn-active" : "")}
906
+ onClick={() => setActivePage("flow")}
907
+ title="Agent Workflow"
908
+ >
909
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
910
+ <circle cx="4" cy="4" r="2" stroke="currentColor" strokeWidth="1.3" />
911
+ <circle cx="12" cy="4" r="2" stroke="currentColor" strokeWidth="1.3" />
912
+ <circle cx="8" cy="12" r="2" stroke="currentColor" strokeWidth="1.3" />
913
+ <path d="M5.5 5.5L7 10.5" stroke="currentColor" strokeWidth="1.3" />
914
+ <path d="M10.5 5.5L9 10.5" stroke="currentColor" strokeWidth="1.3" />
915
+ </svg>
916
+ {!sidebarCollapsed && <span>Agent Workflow</span>}
917
+ </button>
918
+
919
+ <button
920
+ className={"nav-btn" + (activePage === "admin" ? " nav-btn-active" : "")}
921
+ onClick={() => setActivePage("admin")}
922
+ title="Admin"
923
+ >
924
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
925
+ <path
926
+ d="M8 2C8 2 9.5 4 9.5 6C9.5 6.8 9.2 7.5 8.7 8L10 14H6L7.3 8C6.8 7.5 6.5 6.8 6.5 6C6.5 4 8 2 8 2Z"
927
+ stroke="currentColor"
928
+ strokeWidth="1.3"
929
+ strokeLinejoin="round"
930
+ />
931
+ <circle cx="8" cy="6" r="1.5" stroke="currentColor" strokeWidth="1.3" />
932
+ </svg>
933
+ {!sidebarCollapsed && <span>Admin</span>}
934
+ </button>
935
+ </div>
936
+
937
+ {!sidebarCollapsed && (
938
+ <>
939
+ {!hasContext && (
940
+ <RepoSelector onSelect={(r) => addRepoToContext(r)} />
941
+ )}
942
+
943
+ {repo && (
944
+ <SessionSidebar
945
+ repo={repo}
946
+ activeSessionId={activeSessionId}
947
+ onSelectSession={handleSelectSession}
948
+ onNewSession={handleNewSession}
949
+ onDeleteSession={handleDeleteSession}
950
+ refreshNonce={sessionRefreshNonce}
951
+ />
952
+ )}
953
+ </>
954
+ )}
955
+
956
+ {userInfo && (
957
+ <div className="user-profile">
958
+ <UserMenu
959
+ userInfo={userInfo}
960
+ sidebarCollapsed={sidebarCollapsed}
961
+ onOpenSettings={() => {
962
+ setActivePage("admin");
963
+ setAdminTab("advanced");
964
+ }}
965
+ onOpenAbout={() => setAboutOpen(true)}
966
+ onLogout={handleLogout}
967
+ />
968
+ </div>
969
+ )}
970
+ </aside>
971
+
972
+ <main className="workspace">
973
+ {activePage === "admin" && (
974
+ <div style={{ padding: "24px", maxWidth: "960px", margin: "0 auto" }}>
975
+ <div style={{ display: "flex", gap: "8px", marginBottom: "24px", flexWrap: "wrap" }}>
976
+ {["overview", "providers", "workspace-modes", "integrations", "mcp-servers", "sessions", "skills", "security", "advanced"].map((tab) => (
977
+ <button
978
+ key={tab}
979
+ onClick={() => setAdminTab(tab)}
980
+ style={{
981
+ padding: "8px 16px",
982
+ borderRadius: "6px",
983
+ border: adminTab === tab ? "1px solid #3B82F6" : "1px solid #333",
984
+ background: adminTab === tab ? "#1e3a5f" : "#1a1b26",
985
+ color: adminTab === tab ? "#93c5fd" : "#a0a0b0",
986
+ cursor: "pointer",
987
+ fontSize: "13px",
988
+ textTransform: "capitalize",
989
+ }}
990
+ >
991
+ {tab.replace("-", " ")}
992
+ </button>
993
+ ))}
994
+ </div>
995
+
996
+ {adminTab === "overview" && (
997
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "16px" }}>
998
+ <div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
999
+ <div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Server</div>
1000
+ <div style={{ fontSize: "16px", fontWeight: 600 }}>
1001
+ {adminStatus?.server_ready ? "Connected" : "Checking..."}
1002
+ </div>
1003
+ <div style={{ fontSize: "12px", opacity: 0.5 }}>127.0.0.1:8000</div>
1004
+ </div>
1005
+
1006
+ <div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
1007
+ <div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Provider</div>
1008
+ <div style={{ fontSize: "16px", fontWeight: 600 }}>
1009
+ {adminStatus?.provider?.name || "Loading..."}
1010
+ </div>
1011
+ <div style={{ fontSize: "12px", opacity: 0.5 }}>
1012
+ {adminStatus?.provider?.configured
1013
+ ? `${adminStatus.provider.model || "Ready"}`
1014
+ : "Not configured"}
1015
+ </div>
1016
+ </div>
1017
+
1018
+ <div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
1019
+ <div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Workspace Modes</div>
1020
+ <div style={{ fontSize: "12px" }}>
1021
+ Folder: {adminStatus?.workspace?.folder_mode_available ? "Yes" : "β€”"}
1022
+ </div>
1023
+ <div style={{ fontSize: "12px" }}>
1024
+ Local Git: {adminStatus?.workspace?.local_git_available ? "Yes" : "β€”"}
1025
+ </div>
1026
+ <div style={{ fontSize: "12px" }}>
1027
+ GitHub: {adminStatus?.workspace?.github_mode_available ? "Yes" : "Optional"}
1028
+ </div>
1029
+ </div>
1030
+
1031
+ <div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
1032
+ <div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>GitHub</div>
1033
+ <div style={{ fontSize: "14px" }}>
1034
+ {adminStatus?.github?.connected ? "Connected" : "Optional"}
1035
+ </div>
1036
+ <div style={{ fontSize: "12px", opacity: 0.5 }}>
1037
+ {adminStatus?.github?.username || "Not linked"}
1038
+ </div>
1039
+ </div>
1040
+
1041
+ <div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
1042
+ <div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Sessions</div>
1043
+ <div style={{ fontSize: "14px" }}>β€”</div>
1044
+ </div>
1045
+
1046
+ <div style={{ background: "#1a1b26", borderRadius: "8px", padding: "16px", border: "1px solid #2a2b36" }}>
1047
+ <div style={{ fontSize: "12px", opacity: 0.6, marginBottom: "8px" }}>Get Started</div>
1048
+ <button
1049
+ onClick={() => setAdminTab("providers")}
1050
+ style={{
1051
+ padding: "6px 12px",
1052
+ background: "#3B82F6",
1053
+ color: "#fff",
1054
+ border: "none",
1055
+ borderRadius: "4px",
1056
+ cursor: "pointer",
1057
+ fontSize: "12px",
1058
+ marginRight: "4px",
1059
+ }}
1060
+ >
1061
+ Configure Provider
1062
+ </button>
1063
+ </div>
1064
+ </div>
1065
+ )}
1066
+
1067
+ {adminTab === "providers" && (
1068
+ <div>
1069
+ <h3 style={{ marginBottom: "16px" }}>AI Providers</h3>
1070
+ <LlmSettings />
1071
+ </div>
1072
+ )}
1073
+
1074
+ {adminTab === "workspace-modes" && (
1075
+ <WorkspaceModesTab
1076
+ showToast={showToast}
1077
+ onSessionStarted={(result) => {
1078
+ setActiveSessionId(result.session_id);
1079
+ setSessionRefreshNonce((n) => n + 1);
1080
+ setActivePage("workspace");
1081
+ }}
1082
+ />
1083
+ )}
1084
+
1085
+ {adminTab === "integrations" && (
1086
+ <IntegrationsTab
1087
+ userInfo={userInfo}
1088
+ onDisconnect={handleLogout}
1089
+ showToast={showToast}
1090
+ />
1091
+ )}
1092
+
1093
+ {adminTab === "mcp-servers" && (
1094
+ <MCPServersTab showToast={showToast} />
1095
+ )}
1096
+
1097
+ {adminTab === "security" && (
1098
+ <SecurityTab showToast={showToast} />
1099
+ )}
1100
+
1101
+ {adminTab === "sessions" && (
1102
+ <SessionsTab
1103
+ showToast={showToast}
1104
+ onSelectSession={(s) => {
1105
+ handleSelectSession(s);
1106
+ setActivePage("workspace");
1107
+ }}
1108
+ />
1109
+ )}
1110
+
1111
+ {adminTab === "skills" && <SkillsTab showToast={showToast} />}
1112
+
1113
+ {adminTab === "advanced" && (
1114
+ <AdvancedTab
1115
+ showToast={showToast}
1116
+ onOpenFullSettings={() => setSettingsOpen(true)}
1117
+ />
1118
+ )}
1119
+ </div>
1120
+ )}
1121
+
1122
+ {activePage === "flow" && <FlowViewer />}
1123
+
1124
+ {activePage === "workspace" &&
1125
+ (repo ? (
1126
+ <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
1127
+ <ContextBar
1128
+ contextRepos={contextRepos}
1129
+ activeRepoKey={activeRepoKey}
1130
+ repoStateByKey={repoStateByKey}
1131
+ onActivate={setActiveRepoKey}
1132
+ onRemove={removeRepoFromContext}
1133
+ onAdd={() => setAddRepoOpen(true)}
1134
+ onBranchChange={handleContextBranchChange}
1135
+ />
1136
+
1137
+ <div className="workspace-grid" style={{ flex: 1 }}>
1138
+ <aside className="gp-context-column">
1139
+ <ProjectContextPanel
1140
+ repo={repo}
1141
+ defaultBranch={defaultBranch}
1142
+ currentBranch={currentBranch}
1143
+ sessionBranches={sessionBranches}
1144
+ onBranchChange={handleBranchChange}
1145
+ pulseNonce={pulseNonce}
1146
+ lastExecution={lastExecution}
1147
+ onSettingsClick={() => setSettingsOpen(true)}
1148
+ />
1149
+ </aside>
1150
+
1151
+ <main className="gp-chat-column">
1152
+ <div className="panel-header">
1153
+ <span>GitPilot chat</span>
1154
+ </div>
1155
+
1156
+ <ChatPanel
1157
+ repo={repo}
1158
+ defaultBranch={defaultBranch}
1159
+ currentBranch={currentBranch}
1160
+ onExecutionComplete={handleExecutionComplete}
1161
+ sessionChatState={sessionChatState}
1162
+ onSessionChatStateChange={updateSessionChat}
1163
+ sessionId={activeSessionId}
1164
+ onEnsureSession={ensureSession}
1165
+ />
1166
+ </main>
1167
+ </div>
1168
+ </div>
1169
+ ) : (
1170
+ <div className="empty-state">
1171
+ <div className="empty-bot">πŸ€–</div>
1172
+ <h1>Select a repository</h1>
1173
+ <p>Select a repo to begin agentic workflow.</p>
1174
+ </div>
1175
+ ))}
1176
+ </main>
1177
+ </div>
1178
+
1179
+ <Footer />
1180
+
1181
+ {repo && (
1182
+ <ProjectSettingsModal
1183
+ owner={repo.full_name?.split("/")[0] || repo.owner}
1184
+ repo={repo.full_name?.split("/")[1] || repo.name}
1185
+ isOpen={settingsOpen}
1186
+ onClose={() => setSettingsOpen(false)}
1187
+ activeEnvId={activeEnvId}
1188
+ onEnvChange={setActiveEnvId}
1189
+ />
1190
+ )}
1191
+
1192
+ <AddRepoModal
1193
+ isOpen={addRepoOpen}
1194
+ onSelect={addRepoToContext}
1195
+ onClose={() => setAddRepoOpen(false)}
1196
+ excludeKeys={contextRepos.map((e) => e.repoKey)}
1197
+ />
1198
+
1199
+ <AboutModal
1200
+ isOpen={aboutOpen}
1201
+ onClose={() => setAboutOpen(false)}
1202
+ />
1203
+
1204
+ {toast && (
1205
+ <div className="toast-notification">
1206
+ <div style={{ fontSize: 12, fontWeight: 700 }}>{toast.title}</div>
1207
+ <div style={{ fontSize: 12, opacity: 0.82 }}>{toast.message}</div>
1208
+ </div>
1209
+ )}
1210
+
1211
+ <style>{`
1212
+ .toast-notification {
1213
+ position: fixed;
1214
+ top: 72px;
1215
+ right: 18px;
1216
+ z-index: 9999;
1217
+ background: #0b0b0d;
1218
+ color: #EDEDED;
1219
+ border: 1px solid rgba(255,255,255,0.12);
1220
+ border-left: 3px solid #3B82F6;
1221
+ border-radius: 10px;
1222
+ padding: 12px 14px;
1223
+ min-width: 320px;
1224
+ box-shadow: 0 10px 30px rgba(0,0,0,0.4);
1225
+ }
1226
+ `}</style>
1227
+ </div>
1228
+ );
1229
+ }
frontend/components/AboutModal.jsx ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AboutModal.jsx
2
+ import React, { useEffect, useCallback, useState } from "react";
3
+ import { apiUrl, safeFetchJSON } from "../utils/api.js";
4
+
5
+ /**
6
+ * AboutModal β€” "About GitPilot" dialog shown from the user menu.
7
+ *
8
+ * Enterprise design goals:
9
+ * - Prominent brand mark matching docs/logo.svg (orange ring + GP monogram)
10
+ * - Clear identity: name, tagline, version (frontend + backend)
11
+ * - Credits the creator (Ruslan Magana Vsevolodovna) as a link to GitHub
12
+ * - Open-source positioning: Apache 2.0 license + GitHub repo link
13
+ * - Action row: View on GitHub, Report Issue, Documentation
14
+ * - Accessible: role="dialog", aria-modal, aria-labelledby, Escape to close,
15
+ * focus trap via initial focus on close button
16
+ * - Brand palette: #D95C3D accent, #1C1C1F card, #27272A border, #EDEDED text
17
+ */
18
+
19
+ const FRONTEND_VERSION =
20
+ typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "0.1.5";
21
+
22
+ export default function AboutModal({ isOpen, onClose }) {
23
+ const [backendVersion, setBackendVersion] = useState(null);
24
+
25
+ // Fetch backend version when opened
26
+ useEffect(() => {
27
+ if (!isOpen) return;
28
+ let cancelled = false;
29
+ (async () => {
30
+ try {
31
+ const data = await safeFetchJSON(apiUrl("/api/ping"), { timeout: 4000 });
32
+ if (!cancelled) {
33
+ setBackendVersion(data?.version || null);
34
+ }
35
+ } catch {
36
+ if (!cancelled) setBackendVersion(null);
37
+ }
38
+ })();
39
+ return () => {
40
+ cancelled = true;
41
+ };
42
+ }, [isOpen]);
43
+
44
+ // Escape to close
45
+ useEffect(() => {
46
+ if (!isOpen) return;
47
+ const handleKey = (e) => {
48
+ if (e.key === "Escape") onClose?.();
49
+ };
50
+ document.addEventListener("keydown", handleKey);
51
+ return () => document.removeEventListener("keydown", handleKey);
52
+ }, [isOpen, onClose]);
53
+
54
+ // Lock body scroll while open
55
+ useEffect(() => {
56
+ if (!isOpen) return;
57
+ const prev = document.body.style.overflow;
58
+ document.body.style.overflow = "hidden";
59
+ return () => {
60
+ document.body.style.overflow = prev;
61
+ };
62
+ }, [isOpen]);
63
+
64
+ const handleBackdropClick = useCallback(
65
+ (e) => {
66
+ if (e.target === e.currentTarget) onClose?.();
67
+ },
68
+ [onClose]
69
+ );
70
+
71
+ if (!isOpen) return null;
72
+
73
+ return (
74
+ <div
75
+ role="dialog"
76
+ aria-modal="true"
77
+ aria-labelledby="about-modal-title"
78
+ onClick={handleBackdropClick}
79
+ style={{
80
+ position: "fixed",
81
+ inset: 0,
82
+ zIndex: 2000,
83
+ display: "flex",
84
+ alignItems: "center",
85
+ justifyContent: "center",
86
+ padding: 20,
87
+ background: "rgba(0, 0, 0, 0.65)",
88
+ backdropFilter: "blur(4px)",
89
+ WebkitBackdropFilter: "blur(4px)",
90
+ animation: "aboutBackdropIn 180ms ease-out",
91
+ }}
92
+ >
93
+ <div
94
+ style={{
95
+ position: "relative",
96
+ width: "100%",
97
+ maxWidth: 520,
98
+ background: "#1C1C1F",
99
+ border: "1px solid #27272A",
100
+ borderRadius: 16,
101
+ boxShadow:
102
+ "0 32px 64px -16px rgba(0, 0, 0, 0.8), 0 8px 24px rgba(0, 0, 0, 0.4)",
103
+ color: "#EDEDED",
104
+ fontFamily:
105
+ '"SΓΆhne", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
106
+ letterSpacing: "-0.01em",
107
+ overflow: "hidden",
108
+ animation: "aboutCardIn 220ms cubic-bezier(0.16, 1, 0.3, 1)",
109
+ }}
110
+ >
111
+ {/* Close button */}
112
+ <button
113
+ type="button"
114
+ onClick={onClose}
115
+ aria-label="Close About dialog"
116
+ autoFocus
117
+ style={{
118
+ position: "absolute",
119
+ top: 14,
120
+ right: 14,
121
+ width: 32,
122
+ height: 32,
123
+ background: "transparent",
124
+ border: "1px solid transparent",
125
+ borderRadius: 8,
126
+ color: "#A1A1AA",
127
+ cursor: "pointer",
128
+ display: "inline-flex",
129
+ alignItems: "center",
130
+ justifyContent: "center",
131
+ transition: "all 120ms ease",
132
+ zIndex: 1,
133
+ }}
134
+ onMouseEnter={(e) => {
135
+ e.currentTarget.style.background = "#27272A";
136
+ e.currentTarget.style.color = "#EDEDED";
137
+ }}
138
+ onMouseLeave={(e) => {
139
+ e.currentTarget.style.background = "transparent";
140
+ e.currentTarget.style.color = "#A1A1AA";
141
+ }}
142
+ >
143
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
144
+ <path
145
+ d="M4 4l8 8M12 4l-8 8"
146
+ stroke="currentColor"
147
+ strokeWidth="1.5"
148
+ strokeLinecap="round"
149
+ />
150
+ </svg>
151
+ </button>
152
+
153
+ {/* Hero: brand mark + name */}
154
+ <div
155
+ style={{
156
+ padding: "40px 32px 24px",
157
+ textAlign: "center",
158
+ background:
159
+ "radial-gradient(circle at 50% 0%, rgba(217, 92, 61, 0.12), transparent 70%)",
160
+ }}
161
+ >
162
+ <BrandMark />
163
+
164
+ <h2
165
+ id="about-modal-title"
166
+ style={{
167
+ margin: "20px 0 6px",
168
+ fontSize: 24,
169
+ fontWeight: 700,
170
+ color: "#EDEDED",
171
+ letterSpacing: "-0.02em",
172
+ }}
173
+ >
174
+ GitPilot
175
+ </h2>
176
+ <div
177
+ style={{
178
+ fontSize: 13,
179
+ color: "#A1A1AA",
180
+ marginBottom: 10,
181
+ }}
182
+ >
183
+ Enterprise Workspace Copilot
184
+ </div>
185
+
186
+ <div
187
+ style={{
188
+ display: "inline-flex",
189
+ alignItems: "center",
190
+ gap: 8,
191
+ padding: "4px 12px",
192
+ background: "rgba(217, 92, 61, 0.12)",
193
+ border: "1px solid rgba(217, 92, 61, 0.25)",
194
+ borderRadius: 999,
195
+ fontSize: 11,
196
+ fontWeight: 600,
197
+ color: "#ff7a3c",
198
+ letterSpacing: "0.04em",
199
+ textTransform: "uppercase",
200
+ }}
201
+ >
202
+ <span
203
+ aria-hidden="true"
204
+ style={{
205
+ width: 6,
206
+ height: 6,
207
+ borderRadius: "50%",
208
+ background: "#ff7a3c",
209
+ boxShadow: "0 0 8px rgba(255, 122, 60, 0.8)",
210
+ }}
211
+ />
212
+ Open Source Β· Apache 2.0
213
+ </div>
214
+ </div>
215
+
216
+ {/* Body */}
217
+ <div style={{ padding: "8px 32px 0" }}>
218
+ <p
219
+ style={{
220
+ fontSize: 14,
221
+ lineHeight: 1.6,
222
+ color: "#A1A1AA",
223
+ textAlign: "center",
224
+ margin: "0 0 24px",
225
+ }}
226
+ >
227
+ An agentic AI coding companion for your repositories. Ask, plan,
228
+ code, and ship β€” with multi-LLM support, security scanning, and
229
+ VS Code integration.
230
+ </p>
231
+
232
+ {/* Meta table */}
233
+ <div
234
+ style={{
235
+ background: "#131316",
236
+ border: "1px solid #27272A",
237
+ borderRadius: 10,
238
+ padding: "4px 0",
239
+ marginBottom: 24,
240
+ }}
241
+ >
242
+ <MetaRow label="Version" value={`v${FRONTEND_VERSION}`} />
243
+ <MetaRow
244
+ label="Backend"
245
+ value={backendVersion ? `v${backendVersion}` : "β€”"}
246
+ />
247
+ <MetaRow label="License" value="Apache 2.0" />
248
+ <MetaRow
249
+ label="Created by"
250
+ value={
251
+ <a
252
+ href="https://github.com/ruslanmv"
253
+ target="_blank"
254
+ rel="noopener noreferrer"
255
+ style={{
256
+ color: "#ff7a3c",
257
+ textDecoration: "none",
258
+ fontWeight: 600,
259
+ }}
260
+ onMouseEnter={(e) =>
261
+ (e.currentTarget.style.textDecoration = "underline")
262
+ }
263
+ onMouseLeave={(e) =>
264
+ (e.currentTarget.style.textDecoration = "none")
265
+ }
266
+ >
267
+ Ruslan Magana Vsevolodovna
268
+ </a>
269
+ }
270
+ isLast
271
+ />
272
+ </div>
273
+ </div>
274
+
275
+ {/* Action row */}
276
+ <div
277
+ style={{
278
+ padding: "0 32px 32px",
279
+ display: "grid",
280
+ gridTemplateColumns: "1fr 1fr 1fr",
281
+ gap: 8,
282
+ }}
283
+ >
284
+ <ActionButton
285
+ href="https://github.com/ruslanmv/gitpilot"
286
+ icon={<GitHubIcon />}
287
+ label="GitHub"
288
+ />
289
+ <ActionButton
290
+ href="https://github.com/ruslanmv/gitpilot#readme"
291
+ icon={<DocsIcon />}
292
+ label="Docs"
293
+ />
294
+ <ActionButton
295
+ href="https://github.com/ruslanmv/gitpilot/issues"
296
+ icon={<BugIcon />}
297
+ label="Report"
298
+ />
299
+ </div>
300
+
301
+ {/* Footer */}
302
+ <div
303
+ style={{
304
+ padding: "16px 32px",
305
+ background: "#131316",
306
+ borderTop: "1px solid #27272A",
307
+ fontSize: 11,
308
+ color: "#71717a",
309
+ textAlign: "center",
310
+ lineHeight: 1.5,
311
+ }}
312
+ >
313
+ &copy; {new Date().getFullYear()} GitPilot Β· Made with care for
314
+ developers everywhere
315
+ </div>
316
+ </div>
317
+
318
+ <style>{`
319
+ @keyframes aboutBackdropIn {
320
+ from { opacity: 0; }
321
+ to { opacity: 1; }
322
+ }
323
+ @keyframes aboutCardIn {
324
+ from { opacity: 0; transform: translateY(12px) scale(0.97); }
325
+ to { opacity: 1; transform: translateY(0) scale(1); }
326
+ }
327
+ `}</style>
328
+ </div>
329
+ );
330
+ }
331
+
332
+ // ── Brand mark (mirrors docs/logo.svg) ──────────────────────────────
333
+ function BrandMark() {
334
+ return (
335
+ <div
336
+ aria-hidden="true"
337
+ style={{
338
+ position: "relative",
339
+ width: 88,
340
+ height: 88,
341
+ margin: "0 auto",
342
+ }}
343
+ >
344
+ {/* Outer subtle ring */}
345
+ <div
346
+ style={{
347
+ position: "absolute",
348
+ inset: 0,
349
+ borderRadius: "50%",
350
+ border: "2px solid rgba(255, 122, 60, 0.22)",
351
+ }}
352
+ />
353
+ {/* Active arc (top-right, uses conic gradient for smooth arc) */}
354
+ <div
355
+ style={{
356
+ position: "absolute",
357
+ inset: -2,
358
+ borderRadius: "50%",
359
+ background:
360
+ "conic-gradient(from -90deg, #ff7a3c 0deg, #D95C3D 90deg, transparent 91deg, transparent 360deg)",
361
+ mask: "radial-gradient(circle, transparent 40px, black 42px, black 44px, transparent 46px)",
362
+ WebkitMask:
363
+ "radial-gradient(circle, transparent 40px, black 42px, black 44px, transparent 46px)",
364
+ }}
365
+ />
366
+ {/* Soft core glow */}
367
+ <div
368
+ style={{
369
+ position: "absolute",
370
+ inset: 14,
371
+ borderRadius: "50%",
372
+ background:
373
+ "radial-gradient(circle, rgba(255, 122, 60, 0.22) 0%, rgba(255, 122, 60, 0.06) 60%, transparent 100%)",
374
+ }}
375
+ />
376
+ {/* GP monogram */}
377
+ <div
378
+ style={{
379
+ position: "absolute",
380
+ inset: 0,
381
+ display: "flex",
382
+ alignItems: "center",
383
+ justifyContent: "center",
384
+ fontSize: 28,
385
+ fontWeight: 700,
386
+ color: "#EDEDED",
387
+ letterSpacing: "-1px",
388
+ }}
389
+ >
390
+ GP
391
+ </div>
392
+ </div>
393
+ );
394
+ }
395
+
396
+ // ── Meta row ────────────────────────────────────────────────────────
397
+ function MetaRow({ label, value, isLast = false }) {
398
+ return (
399
+ <div
400
+ style={{
401
+ display: "flex",
402
+ justifyContent: "space-between",
403
+ alignItems: "center",
404
+ padding: "10px 16px",
405
+ borderBottom: isLast ? "none" : "1px solid #27272A",
406
+ fontSize: 13,
407
+ }}
408
+ >
409
+ <span style={{ color: "#71717a", fontWeight: 500 }}>{label}</span>
410
+ <span style={{ color: "#EDEDED", fontWeight: 600, textAlign: "right" }}>
411
+ {value}
412
+ </span>
413
+ </div>
414
+ );
415
+ }
416
+
417
+ // ── Action button ───────────────────────────────────────────────────
418
+ function ActionButton({ href, icon, label }) {
419
+ return (
420
+ <a
421
+ href={href}
422
+ target="_blank"
423
+ rel="noopener noreferrer"
424
+ style={{
425
+ display: "flex",
426
+ flexDirection: "column",
427
+ alignItems: "center",
428
+ justifyContent: "center",
429
+ gap: 6,
430
+ padding: "12px 8px",
431
+ background: "#131316",
432
+ border: "1px solid #27272A",
433
+ borderRadius: 10,
434
+ color: "#EDEDED",
435
+ fontSize: 12,
436
+ fontWeight: 600,
437
+ textDecoration: "none",
438
+ transition: "all 140ms ease",
439
+ }}
440
+ onMouseEnter={(e) => {
441
+ e.currentTarget.style.borderColor = "#D95C3D";
442
+ e.currentTarget.style.background = "rgba(217, 92, 61, 0.08)";
443
+ }}
444
+ onMouseLeave={(e) => {
445
+ e.currentTarget.style.borderColor = "#27272A";
446
+ e.currentTarget.style.background = "#131316";
447
+ }}
448
+ >
449
+ <span
450
+ aria-hidden="true"
451
+ style={{
452
+ color: "#ff7a3c",
453
+ display: "inline-flex",
454
+ }}
455
+ >
456
+ {icon}
457
+ </span>
458
+ <span>{label}</span>
459
+ </a>
460
+ );
461
+ }
462
+
463
+ // ── Icons ───────────────────────────────────────────────────────────
464
+ function GitHubIcon() {
465
+ return (
466
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
467
+ <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405 1.02 0 2.04.135 3 .405 2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
468
+ </svg>
469
+ );
470
+ }
471
+
472
+ function DocsIcon() {
473
+ return (
474
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
475
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
476
+ <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
477
+ </svg>
478
+ );
479
+ }
480
+
481
+ function BugIcon() {
482
+ return (
483
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
484
+ <rect x="8" y="6" width="8" height="14" rx="4" />
485
+ <path d="M19 7l-3 2M5 7l3 2M19 13h-3M5 13h3M19 19l-3-2M5 19l3-2M12 6V2" />
486
+ </svg>
487
+ );
488
+ }
frontend/components/AddRepoModal.jsx ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useEffect, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { authFetch } from "../utils/api.js";
4
+
5
+ /**
6
+ * AddRepoModal β€” lightweight portal modal for adding repos to context.
7
+ *
8
+ * Embeds a minimal repo search/list (not the full RepoSelector) to keep
9
+ * the modal focused. Filters out repos already in context.
10
+ */
11
+ export default function AddRepoModal({ isOpen, onSelect, onClose, excludeKeys = [] }) {
12
+ const [query, setQuery] = useState("");
13
+ const [repos, setRepos] = useState([]);
14
+ const [loading, setLoading] = useState(false);
15
+
16
+ const fetchRepos = useCallback(
17
+ async (searchQuery) => {
18
+ setLoading(true);
19
+ try {
20
+ const params = new URLSearchParams({ per_page: "50" });
21
+ if (searchQuery) params.set("query", searchQuery);
22
+ const res = await authFetch(`/api/repos?${params}`);
23
+ if (!res.ok) return;
24
+ const data = await res.json();
25
+ setRepos(data.repositories || []);
26
+ } catch (err) {
27
+ console.warn("AddRepoModal: fetch failed:", err);
28
+ } finally {
29
+ setLoading(false);
30
+ }
31
+ },
32
+ []
33
+ );
34
+
35
+ useEffect(() => {
36
+ if (isOpen) {
37
+ setQuery("");
38
+ fetchRepos("");
39
+ }
40
+ }, [isOpen, fetchRepos]);
41
+
42
+ // Debounced search
43
+ useEffect(() => {
44
+ if (!isOpen) return;
45
+ const t = setTimeout(() => fetchRepos(query), 300);
46
+ return () => clearTimeout(t);
47
+ }, [query, isOpen, fetchRepos]);
48
+
49
+ const excludeSet = new Set(excludeKeys);
50
+ const filtered = repos.filter((r) => {
51
+ const key = r.full_name || `${r.owner}/${r.name}`;
52
+ return !excludeSet.has(key);
53
+ });
54
+
55
+ if (!isOpen) return null;
56
+
57
+ return createPortal(
58
+ <div
59
+ style={styles.overlay}
60
+ onMouseDown={(e) => {
61
+ if (e.target === e.currentTarget) onClose();
62
+ }}
63
+ >
64
+ <div style={styles.modal} onMouseDown={(e) => e.stopPropagation()}>
65
+ <div style={styles.header}>
66
+ <span style={styles.headerTitle}>Add Repository</span>
67
+ <button type="button" style={styles.closeBtn} onClick={onClose}>
68
+ &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/AdminTabs/AdvancedTab.jsx ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AdminTabs/AdvancedTab.jsx
2
+ import React, { useEffect, useState, useCallback } from "react";
3
+ import { apiUrl, safeFetchJSON } from "../../utils/api.js";
4
+
5
+ /**
6
+ * Advanced tab β€” inline toggles for:
7
+ * - Lite Mode (via /api/settings/topology β€” sets topology to "lite_mode")
8
+ * - Permission Mode (normal | auto | plan via /api/permissions/mode)
9
+ * - Link to full Settings modal for power users
10
+ *
11
+ * Best practices applied:
12
+ * - Optimistic UI with rollback on error
13
+ * - Each setting has its own loading indicator (no global lock)
14
+ * - Descriptions explain what each mode does
15
+ * - ARIA-labeled toggle switches for accessibility
16
+ */
17
+
18
+ const PERMISSION_MODES = [
19
+ {
20
+ value: "normal",
21
+ label: "Normal",
22
+ description:
23
+ "Ask before writing files or running commands (recommended).",
24
+ },
25
+ {
26
+ value: "auto",
27
+ label: "Auto",
28
+ description:
29
+ "Approve all tool calls automatically. Use only when you trust the agent.",
30
+ },
31
+ {
32
+ value: "plan",
33
+ label: "Plan Only",
34
+ description:
35
+ "Read-only mode. Agent cannot write files or run commands.",
36
+ },
37
+ ];
38
+
39
+ function ToggleSwitch({ checked, onChange, disabled, ariaLabel }) {
40
+ return (
41
+ <button
42
+ type="button"
43
+ role="switch"
44
+ aria-checked={checked}
45
+ aria-label={ariaLabel}
46
+ onClick={() => !disabled && onChange(!checked)}
47
+ disabled={disabled}
48
+ style={{
49
+ position: "relative",
50
+ width: "44px",
51
+ height: "24px",
52
+ borderRadius: "12px",
53
+ background: checked ? "#3B82F6" : "#374151",
54
+ border: "none",
55
+ cursor: disabled ? "not-allowed" : "pointer",
56
+ transition: "background 150ms ease",
57
+ padding: 0,
58
+ opacity: disabled ? 0.5 : 1,
59
+ }}
60
+ >
61
+ <div
62
+ style={{
63
+ position: "absolute",
64
+ top: "2px",
65
+ left: checked ? "22px" : "2px",
66
+ width: "20px",
67
+ height: "20px",
68
+ borderRadius: "50%",
69
+ background: "#fff",
70
+ transition: "left 150ms ease",
71
+ boxShadow: "0 1px 3px rgba(0,0,0,0.3)",
72
+ }}
73
+ />
74
+ </button>
75
+ );
76
+ }
77
+
78
+ export default function AdvancedTab({ showToast, onOpenFullSettings }) {
79
+ const [liteMode, setLiteMode] = useState(false);
80
+ const [permissionMode, setPermissionMode] = useState("normal");
81
+ const [loading, setLoading] = useState(true);
82
+ const [updatingLite, setUpdatingLite] = useState(false);
83
+ const [updatingPerm, setUpdatingPerm] = useState(false);
84
+ const [error, setError] = useState(null);
85
+
86
+ // Initial fetch: topology preference + permission mode
87
+ useEffect(() => {
88
+ let cancelled = false;
89
+ (async () => {
90
+ try {
91
+ const [topo, perms] = await Promise.all([
92
+ safeFetchJSON(apiUrl("/api/settings/topology"), { timeout: 5000 })
93
+ .catch(() => ({ topology: null })),
94
+ safeFetchJSON(apiUrl("/api/permissions"), { timeout: 5000 })
95
+ .catch(() => ({ mode: "normal" })),
96
+ ]);
97
+ if (cancelled) return;
98
+ setLiteMode(topo?.topology === "lite_mode");
99
+ setPermissionMode(perms?.mode || perms?.policy?.mode || "normal");
100
+ } catch (err) {
101
+ if (!cancelled) setError(err?.message || "Failed to load settings");
102
+ } finally {
103
+ if (!cancelled) setLoading(false);
104
+ }
105
+ })();
106
+ return () => {
107
+ cancelled = true;
108
+ };
109
+ }, []);
110
+
111
+ const handleLiteToggle = useCallback(async (next) => {
112
+ setUpdatingLite(true);
113
+ setError(null);
114
+ const previous = liteMode;
115
+ setLiteMode(next); // optimistic
116
+ try {
117
+ await safeFetchJSON(apiUrl("/api/settings/topology"), {
118
+ method: "POST",
119
+ headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify({ topology: next ? "lite_mode" : null }),
121
+ timeout: 5000,
122
+ });
123
+ showToast?.(
124
+ "Lite Mode " + (next ? "enabled" : "disabled"),
125
+ next
126
+ ? "Single-agent path β€” better for small local models."
127
+ : "Multi-agent path β€” uses full CrewAI orchestration."
128
+ );
129
+ } catch (err) {
130
+ setLiteMode(previous); // rollback
131
+ setError(err?.message || "Failed to update lite mode");
132
+ } finally {
133
+ setUpdatingLite(false);
134
+ }
135
+ }, [liteMode, showToast]);
136
+
137
+ const handlePermissionChange = useCallback(async (next) => {
138
+ setUpdatingPerm(true);
139
+ setError(null);
140
+ const previous = permissionMode;
141
+ setPermissionMode(next); // optimistic
142
+ try {
143
+ const res = await fetch(apiUrl("/api/permissions/mode"), {
144
+ method: "PUT",
145
+ headers: { "Content-Type": "application/json" },
146
+ body: JSON.stringify({ mode: next }),
147
+ });
148
+ if (!res.ok) {
149
+ const body = await res.json().catch(() => ({}));
150
+ throw new Error(body.detail || `HTTP ${res.status}`);
151
+ }
152
+ showToast?.(
153
+ "Permission mode updated",
154
+ `Set to ${next}.`
155
+ );
156
+ } catch (err) {
157
+ setPermissionMode(previous); // rollback
158
+ setError(err?.message || "Failed to update permission mode");
159
+ } finally {
160
+ setUpdatingPerm(false);
161
+ }
162
+ }, [permissionMode, showToast]);
163
+
164
+ if (loading) {
165
+ return (
166
+ <div>
167
+ <h3 style={{ marginBottom: "16px" }}>Advanced</h3>
168
+ <div
169
+ style={{
170
+ background: "#1a1b26",
171
+ borderRadius: "8px",
172
+ padding: "40px 20px",
173
+ textAlign: "center",
174
+ border: "1px solid #2a2b36",
175
+ fontSize: "12px",
176
+ opacity: 0.6,
177
+ }}
178
+ >
179
+ Loading advanced settings...
180
+ </div>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ return (
186
+ <div>
187
+ <h3 style={{ marginBottom: "16px" }}>Advanced</h3>
188
+ <p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "16px" }}>
189
+ Fine-tune GitPilot's agent behavior and safety settings.
190
+ </p>
191
+
192
+ {error && (
193
+ <div
194
+ role="alert"
195
+ style={{
196
+ background: "#7f1d1d",
197
+ color: "#fecaca",
198
+ border: "1px solid #991b1b",
199
+ borderRadius: "8px",
200
+ padding: "12px",
201
+ fontSize: "12px",
202
+ marginBottom: "16px",
203
+ }}
204
+ >
205
+ {error}
206
+ </div>
207
+ )}
208
+
209
+ {/* Lite Mode toggle */}
210
+ <div
211
+ style={{
212
+ background: "#1a1b26",
213
+ borderRadius: "8px",
214
+ padding: "16px",
215
+ border: "1px solid #2a2b36",
216
+ marginBottom: "12px",
217
+ }}
218
+ >
219
+ <div
220
+ style={{
221
+ display: "flex",
222
+ justifyContent: "space-between",
223
+ alignItems: "flex-start",
224
+ gap: "16px",
225
+ }}
226
+ >
227
+ <div style={{ flex: 1 }}>
228
+ <h4 style={{ marginBottom: "4px", fontSize: "14px" }}>Lite Mode</h4>
229
+ <p style={{ fontSize: "12px", opacity: 0.7, lineHeight: 1.5 }}>
230
+ Use a simplified single-agent prompt instead of the multi-agent
231
+ CrewAI pipeline. Recommended for small local models
232
+ (qwen2.5:1.5b, deepseek-r1, phi3:mini) that struggle with the
233
+ ReAct format.
234
+ </p>
235
+ </div>
236
+ <ToggleSwitch
237
+ checked={liteMode}
238
+ onChange={handleLiteToggle}
239
+ disabled={updatingLite}
240
+ ariaLabel="Toggle Lite Mode"
241
+ />
242
+ </div>
243
+ </div>
244
+
245
+ {/* Permission Mode selector */}
246
+ <div
247
+ style={{
248
+ background: "#1a1b26",
249
+ borderRadius: "8px",
250
+ padding: "16px",
251
+ border: "1px solid #2a2b36",
252
+ marginBottom: "12px",
253
+ }}
254
+ >
255
+ <h4 style={{ marginBottom: "4px", fontSize: "14px" }}>Permission Mode</h4>
256
+ <p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "12px" }}>
257
+ Controls when the agent needs your approval before writing files or
258
+ running commands.
259
+ </p>
260
+
261
+ <div style={{ display: "grid", gap: "8px" }}>
262
+ {PERMISSION_MODES.map((mode) => {
263
+ const selected = permissionMode === mode.value;
264
+ return (
265
+ <label
266
+ key={mode.value}
267
+ style={{
268
+ display: "flex",
269
+ alignItems: "flex-start",
270
+ gap: "10px",
271
+ padding: "10px 12px",
272
+ background: selected ? "#1e3a5f" : "#0d0e15",
273
+ border: selected ? "1px solid #3B82F6" : "1px solid #2a2b36",
274
+ borderRadius: "6px",
275
+ cursor: updatingPerm ? "not-allowed" : "pointer",
276
+ opacity: updatingPerm && !selected ? 0.5 : 1,
277
+ }}
278
+ >
279
+ <input
280
+ type="radio"
281
+ name="permission-mode"
282
+ value={mode.value}
283
+ checked={selected}
284
+ onChange={() => handlePermissionChange(mode.value)}
285
+ disabled={updatingPerm}
286
+ style={{ marginTop: "2px", cursor: "inherit" }}
287
+ />
288
+ <div>
289
+ <div
290
+ style={{
291
+ fontSize: "13px",
292
+ fontWeight: 600,
293
+ color: selected ? "#93c5fd" : "#fff",
294
+ }}
295
+ >
296
+ {mode.label}
297
+ </div>
298
+ <div
299
+ style={{
300
+ fontSize: "11px",
301
+ opacity: 0.7,
302
+ marginTop: "2px",
303
+ }}
304
+ >
305
+ {mode.description}
306
+ </div>
307
+ </div>
308
+ </label>
309
+ );
310
+ })}
311
+ </div>
312
+ </div>
313
+
314
+ {/* Link to full settings modal */}
315
+ <div
316
+ style={{
317
+ background: "#1a1b26",
318
+ borderRadius: "8px",
319
+ padding: "16px",
320
+ border: "1px solid #2a2b36",
321
+ }}
322
+ >
323
+ <div
324
+ style={{
325
+ display: "flex",
326
+ justifyContent: "space-between",
327
+ alignItems: "center",
328
+ gap: "16px",
329
+ }}
330
+ >
331
+ <div>
332
+ <h4 style={{ marginBottom: "4px", fontSize: "14px" }}>
333
+ Full Settings
334
+ </h4>
335
+ <p style={{ fontSize: "12px", opacity: 0.7 }}>
336
+ Server URL, telemetry, debug logs, environment variables, and more.
337
+ </p>
338
+ </div>
339
+ <button
340
+ type="button"
341
+ onClick={onOpenFullSettings}
342
+ style={{
343
+ padding: "8px 16px",
344
+ background: "transparent",
345
+ color: "#93c5fd",
346
+ border: "1px solid #3B82F6",
347
+ borderRadius: "4px",
348
+ cursor: "pointer",
349
+ fontSize: "12px",
350
+ fontWeight: 600,
351
+ whiteSpace: "nowrap",
352
+ }}
353
+ >
354
+ Open Settings Modal
355
+ </button>
356
+ </div>
357
+ </div>
358
+ </div>
359
+ );
360
+ }
frontend/components/AdminTabs/IntegrationsTab.jsx ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AdminTabs/IntegrationsTab.jsx
2
+ import React, { useEffect, useState } from "react";
3
+ import { apiUrl, safeFetchJSON } from "../../utils/api.js";
4
+
5
+ /**
6
+ * Integrations tab β€” shows connection status for GitHub (and future
7
+ * third-party integrations) with Connect/Disconnect actions.
8
+ *
9
+ * Best practices applied:
10
+ * - Fetch current status on mount via /api/auth/status
11
+ * - Show connected user info if already authenticated
12
+ * - "Connect GitHub" button opens /api/auth/url in the same window
13
+ * (OAuth flow will redirect back with ?code=...)
14
+ * - Disconnect clears localStorage token and re-fetches status
15
+ * - Handles both Web OAuth and Device Flow modes
16
+ */
17
+
18
+ export default function IntegrationsTab({ userInfo, onDisconnect, showToast }) {
19
+ const [authStatus, setAuthStatus] = useState(null);
20
+ const [loading, setLoading] = useState(true);
21
+ const [connecting, setConnecting] = useState(false);
22
+ const [error, setError] = useState(null);
23
+
24
+ useEffect(() => {
25
+ let cancelled = false;
26
+ (async () => {
27
+ try {
28
+ const data = await safeFetchJSON(apiUrl("/api/auth/status"), { timeout: 5000 });
29
+ if (!cancelled) setAuthStatus(data);
30
+ } catch (err) {
31
+ if (!cancelled) setError(err?.message || "Failed to check auth status");
32
+ } finally {
33
+ if (!cancelled) setLoading(false);
34
+ }
35
+ })();
36
+ return () => {
37
+ cancelled = true;
38
+ };
39
+ }, []);
40
+
41
+ const handleConnect = async () => {
42
+ setConnecting(true);
43
+ setError(null);
44
+ try {
45
+ if (authStatus?.mode === "web") {
46
+ // Web OAuth flow β€” redirect to GitHub authorization URL
47
+ const { authorization_url, state } = await safeFetchJSON(
48
+ apiUrl("/api/auth/url"),
49
+ { timeout: 5000 }
50
+ );
51
+ if (state) {
52
+ sessionStorage.setItem("gitpilot_oauth_state", state);
53
+ }
54
+ // Full page redirect (OAuth providers don't support iframes)
55
+ window.location.href = authorization_url;
56
+ } else {
57
+ // Device flow β€” the LoginPage already handles this.
58
+ showToast?.(
59
+ "Device flow",
60
+ "GitHub device flow is configured. Sign out and sign in again to reconnect."
61
+ );
62
+ }
63
+ } catch (err) {
64
+ setError(err?.message || "Failed to start OAuth flow");
65
+ setConnecting(false);
66
+ }
67
+ };
68
+
69
+ const handleDisconnect = () => {
70
+ if (!window.confirm("Disconnect GitHub? You will be signed out.")) return;
71
+ localStorage.removeItem("github_token");
72
+ localStorage.removeItem("github_user");
73
+ onDisconnect?.();
74
+ showToast?.("Disconnected", "GitHub token removed.");
75
+ };
76
+
77
+ const isConnected = !!(userInfo && userInfo.login);
78
+
79
+ return (
80
+ <div>
81
+ <h3 style={{ marginBottom: "16px" }}>Integrations</h3>
82
+ <p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "16px" }}>
83
+ Connect third-party services to unlock additional GitPilot features.
84
+ </p>
85
+
86
+ {/* GitHub integration card */}
87
+ <div
88
+ style={{
89
+ background: "#1a1b26",
90
+ borderRadius: "8px",
91
+ padding: "20px",
92
+ border: "1px solid #2a2b36",
93
+ marginBottom: "16px",
94
+ }}
95
+ >
96
+ <div
97
+ style={{
98
+ display: "flex",
99
+ justifyContent: "space-between",
100
+ alignItems: "flex-start",
101
+ marginBottom: "12px",
102
+ }}
103
+ >
104
+ <div>
105
+ <h4 style={{ marginBottom: "4px" }}>GitHub</h4>
106
+ <p style={{ fontSize: "12px", opacity: 0.7 }}>
107
+ Pull requests, issues, and remote repository workflows.
108
+ </p>
109
+ </div>
110
+ <span
111
+ style={{
112
+ padding: "2px 10px",
113
+ borderRadius: "10px",
114
+ fontSize: "11px",
115
+ fontWeight: 600,
116
+ background: isConnected ? "#064e3b" : "#374151",
117
+ color: isConnected ? "#a7f3d0" : "#9ca3af",
118
+ border: `1px solid ${isConnected ? "#065f46" : "#4b5563"}`,
119
+ }}
120
+ >
121
+ {loading ? "CHECKING..." : isConnected ? "CONNECTED" : "NOT CONNECTED"}
122
+ </span>
123
+ </div>
124
+
125
+ {isConnected && userInfo && (
126
+ <div
127
+ style={{
128
+ display: "flex",
129
+ alignItems: "center",
130
+ gap: "12px",
131
+ padding: "12px",
132
+ background: "#0d0e15",
133
+ borderRadius: "6px",
134
+ marginBottom: "12px",
135
+ }}
136
+ >
137
+ {userInfo.avatar_url && (
138
+ <img
139
+ src={userInfo.avatar_url}
140
+ alt={userInfo.login}
141
+ style={{ width: "36px", height: "36px", borderRadius: "50%" }}
142
+ />
143
+ )}
144
+ <div>
145
+ <div style={{ fontSize: "13px", fontWeight: 600 }}>
146
+ {userInfo.name || userInfo.login}
147
+ </div>
148
+ <div style={{ fontSize: "11px", opacity: 0.6 }}>@{userInfo.login}</div>
149
+ </div>
150
+ </div>
151
+ )}
152
+
153
+ {error && (
154
+ <div
155
+ role="alert"
156
+ style={{
157
+ padding: "8px 12px",
158
+ background: "#7f1d1d",
159
+ color: "#fecaca",
160
+ border: "1px solid #991b1b",
161
+ borderRadius: "4px",
162
+ fontSize: "11px",
163
+ marginBottom: "12px",
164
+ }}
165
+ >
166
+ {error}
167
+ </div>
168
+ )}
169
+
170
+ <div style={{ display: "flex", gap: "8px" }}>
171
+ {isConnected ? (
172
+ <button
173
+ type="button"
174
+ onClick={handleDisconnect}
175
+ style={{
176
+ padding: "8px 16px",
177
+ background: "transparent",
178
+ color: "#f87171",
179
+ border: "1px solid #991b1b",
180
+ borderRadius: "4px",
181
+ cursor: "pointer",
182
+ fontSize: "12px",
183
+ fontWeight: 600,
184
+ }}
185
+ >
186
+ Disconnect
187
+ </button>
188
+ ) : (
189
+ <button
190
+ type="button"
191
+ onClick={handleConnect}
192
+ disabled={connecting || loading}
193
+ style={{
194
+ padding: "8px 16px",
195
+ background: connecting ? "#555" : "#3B82F6",
196
+ color: "#fff",
197
+ border: "none",
198
+ borderRadius: "4px",
199
+ cursor: connecting || loading ? "not-allowed" : "pointer",
200
+ fontSize: "12px",
201
+ fontWeight: 600,
202
+ display: "inline-flex",
203
+ alignItems: "center",
204
+ gap: "6px",
205
+ }}
206
+ >
207
+ {connecting ? "Connecting..." : "Connect GitHub"}
208
+ </button>
209
+ )}
210
+ </div>
211
+
212
+ {authStatus && !isConnected && (
213
+ <div style={{ fontSize: "10px", opacity: 0.5, marginTop: "10px" }}>
214
+ Auth mode: {authStatus.mode || "unknown"}
215
+ {authStatus.oauth_configured && " (Web OAuth)"}
216
+ {authStatus.pat_configured && " (Personal Access Token)"}
217
+ </div>
218
+ )}
219
+ </div>
220
+
221
+ {/* Placeholder for future integrations */}
222
+ <div
223
+ style={{
224
+ background: "#1a1b26",
225
+ borderRadius: "8px",
226
+ padding: "20px",
227
+ border: "1px dashed #2a2b36",
228
+ opacity: 0.5,
229
+ textAlign: "center",
230
+ }}
231
+ >
232
+ <p style={{ fontSize: "12px", margin: 0 }}>
233
+ More integrations coming soon (GitLab, Bitbucket, Jira, Slack)
234
+ </p>
235
+ </div>
236
+ </div>
237
+ );
238
+ }
frontend/components/AdminTabs/MCPServersTab.jsx ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AdminTabs/MCPServersTab.jsx
2
+ //
3
+ // Settings tab for managing MCP Context Forge servers.
4
+ //
5
+ // UX layout (mirrors industry-standard plugin/extension managers):
6
+ //
7
+ // β”Œβ”€ Header ─ gateway pill, totals, global "MCP enabled" toggle ─┐
8
+ // β”œβ”€ Sub-tabs: Installed Β· Catalog Β· Custom ─────────────────────
9
+ // β”œβ”€ ServerCard list (Installed) β”‚
10
+ // β”‚ β–Έ status / description / tags / tool count β”‚
11
+ // β”‚ β–Έ Test Β· Configure Β· Disable Β· Uninstall β”‚
12
+ // β”‚ β–Έ Expandable per-tool list with risk badges + toggles β”‚
13
+ // β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
14
+
15
+ import React, { useCallback, useEffect, useMemo, useState } from "react";
16
+ import { apiUrl, safeFetchJSON } from "../../utils/api.js";
17
+
18
+ import ServerCard from "./mcp/ServerCard.jsx";
19
+ import CatalogList from "./mcp/CatalogList.jsx";
20
+ import CustomInstallForm from "./mcp/CustomInstallForm.jsx";
21
+ import GatewayHeader from "./mcp/GatewayHeader.jsx";
22
+ import SyncReport from "./mcp/SyncReport.jsx";
23
+
24
+ const TAB_INSTALLED = "installed";
25
+ const TAB_CATALOG = "catalog";
26
+ const TAB_CUSTOM = "custom";
27
+
28
+ export default function MCPServersTab({ showToast }) {
29
+ const [activeSubTab, setActiveSubTab] = useState(TAB_INSTALLED);
30
+ const [status, setStatus] = useState(null);
31
+ const [servers, setServers] = useState([]);
32
+ const [catalog, setCatalog] = useState([]);
33
+ const [loading, setLoading] = useState(true);
34
+ const [error, setError] = useState(null);
35
+ const [syncing, setSyncing] = useState(false);
36
+ const [syncReport, setSyncReport] = useState(null);
37
+
38
+ const refresh = useCallback(async () => {
39
+ setLoading(true);
40
+ setError(null);
41
+ try {
42
+ const [statusData, serversData, catalogData] = await Promise.all([
43
+ safeFetchJSON(apiUrl("/api/mcp/status"), { timeout: 5000 }),
44
+ safeFetchJSON(apiUrl("/api/mcp/servers"), { timeout: 5000 }),
45
+ safeFetchJSON(apiUrl("/api/mcp/catalog"), { timeout: 5000 }),
46
+ ]);
47
+ setStatus(statusData);
48
+ setServers(serversData?.servers || []);
49
+ setCatalog(catalogData?.items || []);
50
+ } catch (err) {
51
+ setError(err?.message || "Failed to load MCP server state");
52
+ } finally {
53
+ setLoading(false);
54
+ }
55
+ }, []);
56
+
57
+ useEffect(() => {
58
+ refresh();
59
+ }, [refresh]);
60
+
61
+ const post = useCallback(
62
+ async (path, body) => {
63
+ try {
64
+ const res = await fetch(apiUrl(path), {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/json" },
67
+ body: body ? JSON.stringify(body) : undefined,
68
+ });
69
+ if (!res.ok) {
70
+ const detail = await res.json().catch(() => ({}));
71
+ throw new Error(detail?.detail || `HTTP ${res.status}`);
72
+ }
73
+ return await res.json();
74
+ } catch (err) {
75
+ showToast?.("MCP error", err?.message || String(err));
76
+ throw err;
77
+ }
78
+ },
79
+ [showToast]
80
+ );
81
+
82
+ // ---- Server actions -----------------------------------------------------
83
+ const onEnableServer = async (id) => {
84
+ await post(`/api/mcp/servers/${id}/enable`);
85
+ showToast?.("Server enabled", id);
86
+ await refresh();
87
+ };
88
+ const onDisableServer = async (id) => {
89
+ await post(`/api/mcp/servers/${id}/disable`);
90
+ showToast?.("Server disabled", id);
91
+ await refresh();
92
+ };
93
+ const onUninstallServer = async (id) => {
94
+ if (
95
+ !window.confirm(
96
+ `Uninstall ${id}? GitPilot will stop calling its tools immediately.`
97
+ )
98
+ )
99
+ return;
100
+ await post(`/api/mcp/servers/${id}/uninstall`);
101
+ showToast?.("Server uninstalled", id);
102
+ await refresh();
103
+ };
104
+ const onTestServer = async (id) => {
105
+ const result = await post(`/api/mcp/servers/${id}/test`);
106
+ if (result?.ok) {
107
+ showToast?.("Server healthy", id);
108
+ } else {
109
+ showToast?.(
110
+ "Server unreachable",
111
+ result?.reason || result?.error || "Unknown error"
112
+ );
113
+ }
114
+ return result;
115
+ };
116
+ const onSync = useCallback(async () => {
117
+ setSyncing(true);
118
+ setSyncReport(null);
119
+ try {
120
+ const report = await post("/api/mcp/sync", {});
121
+ setSyncReport(report);
122
+ const total =
123
+ (report.added?.length || 0) +
124
+ (report.kept?.length || 0) +
125
+ (report.orphaned?.length || 0);
126
+ showToast?.(
127
+ report.forge_unreachable ? "Sync failed" : "Sync complete",
128
+ report.forge_unreachable
129
+ ? report.error || "forge unreachable"
130
+ : `+${report.added?.length || 0} added Β· ${total} total`
131
+ );
132
+ await refresh();
133
+ } catch {
134
+ // post() already toasted; nothing more to do.
135
+ } finally {
136
+ setSyncing(false);
137
+ }
138
+ }, [post, refresh, showToast]);
139
+
140
+ const onForgetOrphan = async (id) => {
141
+ if (
142
+ !window.confirm(
143
+ `Forget ${id}? It will be removed from the local list.\n` +
144
+ "Re-attach it to MCP Context Forge then click Sync to bring it back."
145
+ )
146
+ )
147
+ return;
148
+ await post(`/api/mcp/servers/${id}/forget`);
149
+ showToast?.("Server forgotten", id);
150
+ await refresh();
151
+ };
152
+
153
+ const onToggleTool = async (serverId, toolName, enabled) => {
154
+ await post(
155
+ `/api/mcp/servers/${serverId}/tools/${encodeURIComponent(
156
+ toolName
157
+ )}/toggle`,
158
+ { enabled }
159
+ );
160
+ await refresh();
161
+ };
162
+
163
+ const onInstallFromCatalog = async (serverId) => {
164
+ await post("/api/mcp/servers/install", { server_id: serverId });
165
+ showToast?.("Installed", `${serverId} (disabled until you enable it)`);
166
+ await refresh();
167
+ setActiveSubTab(TAB_INSTALLED);
168
+ };
169
+
170
+ const onInstallCustom = async (registerJson) => {
171
+ await post("/api/mcp/servers/install-custom", {
172
+ register_json: registerJson,
173
+ });
174
+ showToast?.("Custom server added", registerJson.name);
175
+ await refresh();
176
+ setActiveSubTab(TAB_INSTALLED);
177
+ };
178
+
179
+ // ---- Derived totals -----------------------------------------------------
180
+ const installedCount = servers.filter((s) => s.installed).length;
181
+ const enabledCount = servers.filter((s) => s.installed && s.enabled).length;
182
+ const totalTools = useMemo(
183
+ () => servers.reduce((acc, s) => acc + (s.tool_count || 0), 0),
184
+ [servers]
185
+ );
186
+
187
+ return (
188
+ <div>
189
+ <GatewayHeader
190
+ status={status}
191
+ installedCount={installedCount}
192
+ enabledCount={enabledCount}
193
+ totalTools={totalTools}
194
+ onRefresh={refresh}
195
+ onSync={onSync}
196
+ syncing={syncing}
197
+ />
198
+
199
+ {syncReport && (
200
+ <SyncReport
201
+ report={syncReport}
202
+ onDismiss={() => setSyncReport(null)}
203
+ />
204
+ )}
205
+
206
+ {/* Sub-tab strip */}
207
+ <div
208
+ role="tablist"
209
+ style={{
210
+ display: "flex",
211
+ gap: "4px",
212
+ marginBottom: "16px",
213
+ borderBottom: "1px solid #2a2b36",
214
+ }}
215
+ >
216
+ {[
217
+ { id: TAB_INSTALLED, label: `Installed (${installedCount})` },
218
+ { id: TAB_CATALOG, label: `Catalog (${catalog.length})` },
219
+ { id: TAB_CUSTOM, label: "Custom" },
220
+ ].map((t) => (
221
+ <button
222
+ key={t.id}
223
+ role="tab"
224
+ aria-selected={activeSubTab === t.id}
225
+ onClick={() => setActiveSubTab(t.id)}
226
+ style={{
227
+ padding: "10px 16px",
228
+ border: "none",
229
+ background: "transparent",
230
+ color: activeSubTab === t.id ? "#93c5fd" : "#a0a0b0",
231
+ borderBottom:
232
+ activeSubTab === t.id
233
+ ? "2px solid #3B82F6"
234
+ : "2px solid transparent",
235
+ cursor: "pointer",
236
+ fontSize: "13px",
237
+ fontWeight: activeSubTab === t.id ? 600 : 400,
238
+ }}
239
+ >
240
+ {t.label}
241
+ </button>
242
+ ))}
243
+ </div>
244
+
245
+ {error && (
246
+ <div
247
+ role="alert"
248
+ style={{
249
+ padding: "12px",
250
+ background: "#5c1a1a",
251
+ border: "1px solid #8a2a2a",
252
+ borderRadius: "6px",
253
+ marginBottom: "16px",
254
+ color: "#fecaca",
255
+ fontSize: "13px",
256
+ }}
257
+ >
258
+ {error}
259
+ </div>
260
+ )}
261
+
262
+ {loading && !servers.length && (
263
+ <p style={{ color: "#a0a0b0", fontSize: "13px" }}>Loading…</p>
264
+ )}
265
+
266
+ {activeSubTab === TAB_INSTALLED && !loading && (
267
+ <div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
268
+ {servers.length === 0 && (
269
+ <EmptyState
270
+ title="No MCP servers installed"
271
+ hint="Browse the catalog or paste a register.json under Custom."
272
+ actionLabel="Browse catalog"
273
+ onAction={() => setActiveSubTab(TAB_CATALOG)}
274
+ />
275
+ )}
276
+ {servers.map((s) => (
277
+ <ServerCard
278
+ key={s.id}
279
+ server={s}
280
+ onEnable={() => onEnableServer(s.id)}
281
+ onDisable={() => onDisableServer(s.id)}
282
+ onUninstall={() => onUninstallServer(s.id)}
283
+ onTest={() => onTestServer(s.id)}
284
+ onForget={s.orphan ? () => onForgetOrphan(s.id) : undefined}
285
+ onToggleTool={(tool, enabled) =>
286
+ onToggleTool(s.id, tool, enabled)
287
+ }
288
+ />
289
+ ))}
290
+ </div>
291
+ )}
292
+
293
+ {activeSubTab === TAB_CATALOG && !loading && (
294
+ <CatalogList items={catalog} onInstall={onInstallFromCatalog} />
295
+ )}
296
+
297
+ {activeSubTab === TAB_CUSTOM && (
298
+ <CustomInstallForm onSubmit={onInstallCustom} />
299
+ )}
300
+ </div>
301
+ );
302
+ }
303
+
304
+ function EmptyState({ title, hint, actionLabel, onAction }) {
305
+ return (
306
+ <div
307
+ style={{
308
+ padding: "32px",
309
+ background: "#1a1b26",
310
+ border: "1px dashed #2a2b36",
311
+ borderRadius: "8px",
312
+ textAlign: "center",
313
+ }}
314
+ >
315
+ <h4 style={{ margin: "0 0 8px 0", color: "#e0e0e7" }}>{title}</h4>
316
+ <p style={{ margin: "0 0 16px 0", color: "#a0a0b0", fontSize: "13px" }}>
317
+ {hint}
318
+ </p>
319
+ {actionLabel && (
320
+ <button
321
+ onClick={onAction}
322
+ style={{
323
+ padding: "8px 16px",
324
+ background: "#3B82F6",
325
+ color: "#fff",
326
+ border: "none",
327
+ borderRadius: "6px",
328
+ cursor: "pointer",
329
+ fontSize: "13px",
330
+ }}
331
+ >
332
+ {actionLabel}
333
+ </button>
334
+ )}
335
+ </div>
336
+ );
337
+ }
frontend/components/AdminTabs/SecurityTab.jsx ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AdminTabs/SecurityTab.jsx
2
+ import React, { useState } from "react";
3
+ import { scanWorkspace } from "../../utils/api.js";
4
+
5
+ /**
6
+ * Security tab β€” runs a workspace scan via /api/security/scan-workspace
7
+ * and renders findings grouped by severity.
8
+ *
9
+ * Best practices applied:
10
+ * - Custom path input (defaults to ".")
11
+ * - Loading spinner while scanning
12
+ * - Error state with retry
13
+ * - Empty state ("No findings") with green checkmark
14
+ * - Findings grouped by severity (critical β†’ info)
15
+ * - Each finding shows file, line, CWE, recommendation
16
+ * - Color-coded severity badges
17
+ */
18
+
19
+ const SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"];
20
+
21
+ const SEVERITY_COLORS = {
22
+ critical: { bg: "#7f1d1d", text: "#fecaca", border: "#991b1b" },
23
+ high: { bg: "#9a3412", text: "#fed7aa", border: "#c2410c" },
24
+ medium: { bg: "#78350f", text: "#fde68a", border: "#a16207" },
25
+ low: { bg: "#164e63", text: "#a5f3fc", border: "#0e7490" },
26
+ info: { bg: "#1e3a5f", text: "#93c5fd", border: "#3B82F6" },
27
+ };
28
+
29
+ function SeverityBadge({ severity }) {
30
+ const c = SEVERITY_COLORS[severity] || SEVERITY_COLORS.info;
31
+ return (
32
+ <span
33
+ style={{
34
+ display: "inline-block",
35
+ padding: "2px 8px",
36
+ background: c.bg,
37
+ color: c.text,
38
+ border: `1px solid ${c.border}`,
39
+ borderRadius: "10px",
40
+ fontSize: "10px",
41
+ fontWeight: 700,
42
+ textTransform: "uppercase",
43
+ letterSpacing: "0.5px",
44
+ }}
45
+ >
46
+ {severity}
47
+ </span>
48
+ );
49
+ }
50
+
51
+ export default function SecurityTab({ showToast }) {
52
+ const [path, setPath] = useState(".");
53
+ const [scanning, setScanning] = useState(false);
54
+ const [result, setResult] = useState(null);
55
+ const [error, setError] = useState(null);
56
+
57
+ const handleScan = async () => {
58
+ setScanning(true);
59
+ setError(null);
60
+ setResult(null);
61
+ try {
62
+ const data = await scanWorkspace(path.trim() || ".");
63
+ setResult(data);
64
+ const findingsCount = data.findings?.length || 0;
65
+ showToast?.(
66
+ "Scan complete",
67
+ findingsCount === 0
68
+ ? "No security findings."
69
+ : `Found ${findingsCount} issue${findingsCount !== 1 ? "s" : ""}.`
70
+ );
71
+ } catch (err) {
72
+ setError(err?.message || "Scan failed");
73
+ } finally {
74
+ setScanning(false);
75
+ }
76
+ };
77
+
78
+ // Group findings by severity
79
+ const grouped = React.useMemo(() => {
80
+ const out = {};
81
+ if (result?.findings) {
82
+ for (const f of result.findings) {
83
+ const sev = f.severity || "info";
84
+ if (!out[sev]) out[sev] = [];
85
+ out[sev].push(f);
86
+ }
87
+ }
88
+ return out;
89
+ }, [result]);
90
+
91
+ const totalFindings = result?.findings?.length || 0;
92
+
93
+ return (
94
+ <div>
95
+ <h3 style={{ marginBottom: "16px" }}>Security Scanning</h3>
96
+ <p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "16px" }}>
97
+ Scan your workspace for vulnerabilities, secrets, and insecure patterns (OWASP Top 10).
98
+ </p>
99
+
100
+ {/* Scan controls */}
101
+ <div
102
+ style={{
103
+ background: "#1a1b26",
104
+ borderRadius: "8px",
105
+ padding: "16px",
106
+ border: "1px solid #2a2b36",
107
+ marginBottom: "16px",
108
+ display: "flex",
109
+ gap: "8px",
110
+ alignItems: "flex-end",
111
+ }}
112
+ >
113
+ <div style={{ flex: 1 }}>
114
+ <label
115
+ htmlFor="security-scan-path"
116
+ style={{
117
+ fontSize: "11px",
118
+ opacity: 0.7,
119
+ display: "block",
120
+ marginBottom: "4px",
121
+ }}
122
+ >
123
+ Path to scan (relative or absolute)
124
+ </label>
125
+ <input
126
+ id="security-scan-path"
127
+ type="text"
128
+ value={path}
129
+ onChange={(e) => setPath(e.target.value)}
130
+ disabled={scanning}
131
+ placeholder="."
132
+ style={{
133
+ width: "100%",
134
+ padding: "8px 10px",
135
+ background: "#0d0e15",
136
+ border: "1px solid #2a2b36",
137
+ borderRadius: "4px",
138
+ color: "#fff",
139
+ fontSize: "12px",
140
+ fontFamily: "monospace",
141
+ }}
142
+ />
143
+ </div>
144
+ <button
145
+ type="button"
146
+ onClick={handleScan}
147
+ disabled={scanning}
148
+ style={{
149
+ padding: "8px 16px",
150
+ background: scanning ? "#555" : "#3B82F6",
151
+ color: "#fff",
152
+ border: "none",
153
+ borderRadius: "4px",
154
+ cursor: scanning ? "not-allowed" : "pointer",
155
+ fontSize: "12px",
156
+ fontWeight: 600,
157
+ whiteSpace: "nowrap",
158
+ }}
159
+ >
160
+ {scanning ? "Scanning..." : "Scan Workspace"}
161
+ </button>
162
+ </div>
163
+
164
+ {/* Error state */}
165
+ {error && (
166
+ <div
167
+ role="alert"
168
+ style={{
169
+ background: "#7f1d1d",
170
+ color: "#fecaca",
171
+ border: "1px solid #991b1b",
172
+ borderRadius: "8px",
173
+ padding: "12px",
174
+ fontSize: "12px",
175
+ marginBottom: "16px",
176
+ }}
177
+ >
178
+ <strong>Scan failed: </strong>
179
+ {error}
180
+ </div>
181
+ )}
182
+
183
+ {/* Results summary */}
184
+ {result && (
185
+ <div
186
+ style={{
187
+ background: "#1a1b26",
188
+ borderRadius: "8px",
189
+ padding: "16px",
190
+ border: "1px solid #2a2b36",
191
+ marginBottom: "16px",
192
+ }}
193
+ >
194
+ <div style={{ display: "flex", gap: "24px", fontSize: "12px" }}>
195
+ <div>
196
+ <div style={{ opacity: 0.6 }}>Files Scanned</div>
197
+ <div style={{ fontSize: "18px", fontWeight: 600 }}>
198
+ {result.files_scanned ?? 0}
199
+ </div>
200
+ </div>
201
+ <div>
202
+ <div style={{ opacity: 0.6 }}>Total Findings</div>
203
+ <div
204
+ style={{
205
+ fontSize: "18px",
206
+ fontWeight: 600,
207
+ color: totalFindings === 0 ? "#4ade80" : "#fcd34d",
208
+ }}
209
+ >
210
+ {totalFindings}
211
+ </div>
212
+ </div>
213
+ <div>
214
+ <div style={{ opacity: 0.6 }}>Duration</div>
215
+ <div style={{ fontSize: "18px", fontWeight: 600 }}>
216
+ {result.scan_duration_ms ?? 0}ms
217
+ </div>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ )}
222
+
223
+ {/* Empty state β€” no findings */}
224
+ {result && totalFindings === 0 && (
225
+ <div
226
+ style={{
227
+ background: "#064e3b",
228
+ color: "#a7f3d0",
229
+ border: "1px solid #065f46",
230
+ borderRadius: "8px",
231
+ padding: "20px",
232
+ textAlign: "center",
233
+ }}
234
+ >
235
+ <div style={{ fontSize: "32px", marginBottom: "8px" }}>βœ“</div>
236
+ <div style={{ fontSize: "14px", fontWeight: 600 }}>
237
+ No security issues found
238
+ </div>
239
+ <div style={{ fontSize: "12px", opacity: 0.8, marginTop: "4px" }}>
240
+ Your workspace passed all {result.files_scanned ?? 0} file checks.
241
+ </div>
242
+ </div>
243
+ )}
244
+
245
+ {/* Findings grouped by severity */}
246
+ {totalFindings > 0 &&
247
+ SEVERITY_ORDER.filter((sev) => grouped[sev]?.length > 0).map((sev) => (
248
+ <div key={sev} style={{ marginBottom: "16px" }}>
249
+ <h4
250
+ style={{
251
+ fontSize: "13px",
252
+ marginBottom: "8px",
253
+ display: "flex",
254
+ alignItems: "center",
255
+ gap: "8px",
256
+ }}
257
+ >
258
+ <SeverityBadge severity={sev} />
259
+ <span>
260
+ {grouped[sev].length} {sev} issue{grouped[sev].length !== 1 ? "s" : ""}
261
+ </span>
262
+ </h4>
263
+ <div style={{ display: "grid", gap: "8px" }}>
264
+ {grouped[sev].map((f, idx) => (
265
+ <div
266
+ key={`${f.rule_id}-${f.file_path}-${f.line_number}-${idx}`}
267
+ style={{
268
+ background: "#1a1b26",
269
+ borderRadius: "8px",
270
+ padding: "12px",
271
+ border: `1px solid ${SEVERITY_COLORS[sev]?.border || "#2a2b36"}`,
272
+ }}
273
+ >
274
+ <div
275
+ style={{
276
+ display: "flex",
277
+ justifyContent: "space-between",
278
+ alignItems: "flex-start",
279
+ marginBottom: "6px",
280
+ }}
281
+ >
282
+ <div style={{ fontSize: "13px", fontWeight: 600 }}>{f.title}</div>
283
+ {f.cwe_id && (
284
+ <span
285
+ style={{
286
+ fontSize: "10px",
287
+ opacity: 0.6,
288
+ fontFamily: "monospace",
289
+ }}
290
+ >
291
+ {f.cwe_id}
292
+ </span>
293
+ )}
294
+ </div>
295
+ <div
296
+ style={{
297
+ fontSize: "11px",
298
+ fontFamily: "monospace",
299
+ opacity: 0.7,
300
+ marginBottom: "6px",
301
+ }}
302
+ >
303
+ {f.file_path}:{f.line_number}
304
+ </div>
305
+ {f.snippet && (
306
+ <pre
307
+ style={{
308
+ fontSize: "11px",
309
+ background: "#0d0e15",
310
+ padding: "8px",
311
+ borderRadius: "4px",
312
+ overflowX: "auto",
313
+ margin: "6px 0",
314
+ color: "#e0e7ff",
315
+ }}
316
+ >
317
+ {f.snippet}
318
+ </pre>
319
+ )}
320
+ {f.recommendation && (
321
+ <div
322
+ style={{
323
+ fontSize: "11px",
324
+ opacity: 0.8,
325
+ marginTop: "6px",
326
+ paddingTop: "6px",
327
+ borderTop: "1px solid #2a2b36",
328
+ }}
329
+ >
330
+ <strong>Fix: </strong>
331
+ {f.recommendation}
332
+ </div>
333
+ )}
334
+ </div>
335
+ ))}
336
+ </div>
337
+ </div>
338
+ ))}
339
+ </div>
340
+ );
341
+ }
frontend/components/AdminTabs/SessionsTab.jsx ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AdminTabs/SessionsTab.jsx
2
+ import React, { useEffect, useMemo, useState, useCallback } from "react";
3
+ import { apiUrl, safeFetchJSON } from "../../utils/api.js";
4
+
5
+ /**
6
+ * Sessions tab β€” admin-level table view of all saved sessions with
7
+ * search, sort, and delete actions.
8
+ *
9
+ * Best practices applied:
10
+ * - Fetch all sessions on mount
11
+ * - Client-side search (useMemo for filtered list)
12
+ * - Confirmation dialog before delete
13
+ * - Row hover effect
14
+ * - Empty / loading / error states
15
+ * - Relative timestamps ("2 hours ago")
16
+ * - Click row to open in workspace view
17
+ */
18
+
19
+ function formatRelativeTime(iso) {
20
+ if (!iso) return "β€”";
21
+ try {
22
+ const d = new Date(iso);
23
+ const diff = Date.now() - d.getTime();
24
+ if (diff < 60_000) return "just now";
25
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
26
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
27
+ if (diff < 2_592_000_000) return `${Math.floor(diff / 86_400_000)}d ago`;
28
+ return d.toLocaleDateString();
29
+ } catch {
30
+ return "β€”";
31
+ }
32
+ }
33
+
34
+ export default function SessionsTab({ onSelectSession, showToast }) {
35
+ const [sessions, setSessions] = useState([]);
36
+ const [loading, setLoading] = useState(true);
37
+ const [error, setError] = useState(null);
38
+ const [query, setQuery] = useState("");
39
+ const [deletingId, setDeletingId] = useState(null);
40
+
41
+ const fetchSessions = useCallback(async () => {
42
+ setError(null);
43
+ try {
44
+ const data = await safeFetchJSON(apiUrl("/api/sessions"), { timeout: 10000 });
45
+ setSessions(Array.isArray(data.sessions) ? data.sessions : []);
46
+ } catch (err) {
47
+ setError(err?.message || "Failed to load sessions");
48
+ } finally {
49
+ setLoading(false);
50
+ }
51
+ }, []);
52
+
53
+ useEffect(() => {
54
+ fetchSessions();
55
+ }, [fetchSessions]);
56
+
57
+ const handleDelete = async (session) => {
58
+ if (
59
+ !window.confirm(
60
+ `Delete session "${session.name || session.id?.slice(0, 8)}"? This cannot be undone.`
61
+ )
62
+ ) {
63
+ return;
64
+ }
65
+
66
+ setDeletingId(session.id);
67
+ try {
68
+ const res = await fetch(apiUrl(`/api/sessions/${session.id}`), {
69
+ method: "DELETE",
70
+ });
71
+ if (!res.ok) {
72
+ throw new Error(`Delete failed (${res.status})`);
73
+ }
74
+ showToast?.("Session deleted", session.name || session.id);
75
+ // Optimistic removal
76
+ setSessions((prev) => prev.filter((s) => s.id !== session.id));
77
+ } catch (err) {
78
+ setError(err?.message || "Failed to delete session");
79
+ } finally {
80
+ setDeletingId(null);
81
+ }
82
+ };
83
+
84
+ const filtered = useMemo(() => {
85
+ if (!query.trim()) return sessions;
86
+ const q = query.toLowerCase();
87
+ return sessions.filter((s) => {
88
+ return (
89
+ (s.name || "").toLowerCase().includes(q) ||
90
+ (s.repo || "").toLowerCase().includes(q) ||
91
+ (s.branch || "").toLowerCase().includes(q) ||
92
+ (s.id || "").toLowerCase().includes(q)
93
+ );
94
+ });
95
+ }, [sessions, query]);
96
+
97
+ return (
98
+ <div>
99
+ <div
100
+ style={{
101
+ display: "flex",
102
+ justifyContent: "space-between",
103
+ alignItems: "center",
104
+ marginBottom: "16px",
105
+ gap: "12px",
106
+ flexWrap: "wrap",
107
+ }}
108
+ >
109
+ <div>
110
+ <h3 style={{ marginBottom: "4px" }}>Sessions</h3>
111
+ <p style={{ fontSize: "12px", opacity: 0.7 }}>
112
+ All saved chat sessions ({sessions.length} total
113
+ {query ? `, ${filtered.length} matching` : ""}).
114
+ </p>
115
+ </div>
116
+ <div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
117
+ <input
118
+ type="text"
119
+ value={query}
120
+ onChange={(e) => setQuery(e.target.value)}
121
+ placeholder="Search sessions..."
122
+ style={{
123
+ padding: "6px 10px",
124
+ background: "#0d0e15",
125
+ border: "1px solid #2a2b36",
126
+ borderRadius: "4px",
127
+ color: "#fff",
128
+ fontSize: "12px",
129
+ width: "220px",
130
+ }}
131
+ />
132
+ <button
133
+ type="button"
134
+ onClick={fetchSessions}
135
+ disabled={loading}
136
+ style={{
137
+ padding: "6px 12px",
138
+ background: "transparent",
139
+ color: "#a0a0b0",
140
+ border: "1px solid #2a2b36",
141
+ borderRadius: "4px",
142
+ cursor: loading ? "not-allowed" : "pointer",
143
+ fontSize: "12px",
144
+ }}
145
+ >
146
+ Refresh
147
+ </button>
148
+ </div>
149
+ </div>
150
+
151
+ {/* Loading state */}
152
+ {loading && (
153
+ <div
154
+ style={{
155
+ background: "#1a1b26",
156
+ borderRadius: "8px",
157
+ padding: "40px 20px",
158
+ textAlign: "center",
159
+ border: "1px solid #2a2b36",
160
+ fontSize: "12px",
161
+ opacity: 0.6,
162
+ }}
163
+ >
164
+ Loading sessions...
165
+ </div>
166
+ )}
167
+
168
+ {/* Error state */}
169
+ {error && !loading && (
170
+ <div
171
+ role="alert"
172
+ style={{
173
+ background: "#7f1d1d",
174
+ color: "#fecaca",
175
+ border: "1px solid #991b1b",
176
+ borderRadius: "8px",
177
+ padding: "12px",
178
+ fontSize: "12px",
179
+ marginBottom: "12px",
180
+ }}
181
+ >
182
+ <strong>Error: </strong>
183
+ {error}
184
+ </div>
185
+ )}
186
+
187
+ {/* Empty state */}
188
+ {!loading && !error && sessions.length === 0 && (
189
+ <div
190
+ style={{
191
+ background: "#1a1b26",
192
+ borderRadius: "8px",
193
+ padding: "40px 20px",
194
+ textAlign: "center",
195
+ border: "1px dashed #2a2b36",
196
+ }}
197
+ >
198
+ <div style={{ fontSize: "32px", marginBottom: "8px" }}>πŸ’¬</div>
199
+ <div style={{ fontSize: "14px", fontWeight: 600, marginBottom: "4px" }}>
200
+ No sessions yet
201
+ </div>
202
+ <div style={{ fontSize: "12px", opacity: 0.6 }}>
203
+ Start chatting with GitPilot to create your first session.
204
+ </div>
205
+ </div>
206
+ )}
207
+
208
+ {/* Table */}
209
+ {!loading && filtered.length > 0 && (
210
+ <div
211
+ style={{
212
+ background: "#1a1b26",
213
+ borderRadius: "8px",
214
+ border: "1px solid #2a2b36",
215
+ overflow: "hidden",
216
+ }}
217
+ >
218
+ <table
219
+ style={{
220
+ width: "100%",
221
+ borderCollapse: "collapse",
222
+ fontSize: "12px",
223
+ }}
224
+ >
225
+ <thead>
226
+ <tr style={{ background: "#0d0e15" }}>
227
+ <th style={thStyle}>Name</th>
228
+ <th style={thStyle}>Repository</th>
229
+ <th style={thStyle}>Branch</th>
230
+ <th style={thStyle}>Messages</th>
231
+ <th style={thStyle}>Status</th>
232
+ <th style={thStyle}>Updated</th>
233
+ <th style={{ ...thStyle, textAlign: "right" }}>Actions</th>
234
+ </tr>
235
+ </thead>
236
+ <tbody>
237
+ {filtered.map((s) => (
238
+ <tr
239
+ key={s.id}
240
+ style={{
241
+ borderTop: "1px solid #2a2b36",
242
+ cursor: onSelectSession ? "pointer" : "default",
243
+ }}
244
+ onMouseEnter={(e) =>
245
+ (e.currentTarget.style.background = "#22232e")
246
+ }
247
+ onMouseLeave={(e) =>
248
+ (e.currentTarget.style.background = "transparent")
249
+ }
250
+ onClick={() => onSelectSession?.(s)}
251
+ >
252
+ <td style={tdStyle}>
253
+ <div style={{ fontWeight: 600 }}>
254
+ {s.name || <span style={{ opacity: 0.4 }}>(unnamed)</span>}
255
+ </div>
256
+ <div
257
+ style={{
258
+ fontSize: "10px",
259
+ opacity: 0.4,
260
+ fontFamily: "monospace",
261
+ }}
262
+ >
263
+ {s.id?.slice(0, 12)}
264
+ </div>
265
+ </td>
266
+ <td style={{ ...tdStyle, fontFamily: "monospace" }}>
267
+ {s.repo || <span style={{ opacity: 0.4 }}>β€”</span>}
268
+ </td>
269
+ <td style={{ ...tdStyle, fontFamily: "monospace" }}>
270
+ {s.branch || <span style={{ opacity: 0.4 }}>β€”</span>}
271
+ </td>
272
+ <td style={tdStyle}>{s.message_count ?? 0}</td>
273
+ <td style={tdStyle}>
274
+ <span
275
+ style={{
276
+ padding: "2px 8px",
277
+ background:
278
+ s.status === "active"
279
+ ? "#064e3b"
280
+ : s.status === "completed"
281
+ ? "#1e3a5f"
282
+ : "#374151",
283
+ color:
284
+ s.status === "active"
285
+ ? "#a7f3d0"
286
+ : s.status === "completed"
287
+ ? "#93c5fd"
288
+ : "#9ca3af",
289
+ borderRadius: "10px",
290
+ fontSize: "10px",
291
+ fontWeight: 600,
292
+ textTransform: "uppercase",
293
+ }}
294
+ >
295
+ {s.status || "unknown"}
296
+ </span>
297
+ </td>
298
+ <td style={{ ...tdStyle, opacity: 0.7 }}>
299
+ {formatRelativeTime(s.updated_at)}
300
+ </td>
301
+ <td style={{ ...tdStyle, textAlign: "right" }}>
302
+ <button
303
+ type="button"
304
+ onClick={(e) => {
305
+ e.stopPropagation();
306
+ handleDelete(s);
307
+ }}
308
+ disabled={deletingId === s.id}
309
+ style={{
310
+ padding: "4px 10px",
311
+ background: "transparent",
312
+ color: "#f87171",
313
+ border: "1px solid #991b1b",
314
+ borderRadius: "4px",
315
+ cursor: deletingId === s.id ? "not-allowed" : "pointer",
316
+ fontSize: "11px",
317
+ }}
318
+ >
319
+ {deletingId === s.id ? "..." : "Delete"}
320
+ </button>
321
+ </td>
322
+ </tr>
323
+ ))}
324
+ </tbody>
325
+ </table>
326
+ </div>
327
+ )}
328
+
329
+ {/* No matches for search */}
330
+ {!loading && sessions.length > 0 && filtered.length === 0 && (
331
+ <div
332
+ style={{
333
+ background: "#1a1b26",
334
+ borderRadius: "8px",
335
+ padding: "20px",
336
+ textAlign: "center",
337
+ border: "1px dashed #2a2b36",
338
+ fontSize: "12px",
339
+ opacity: 0.7,
340
+ }}
341
+ >
342
+ No sessions match "{query}"
343
+ </div>
344
+ )}
345
+ </div>
346
+ );
347
+ }
348
+
349
+ const thStyle = {
350
+ padding: "10px 12px",
351
+ textAlign: "left",
352
+ fontSize: "11px",
353
+ fontWeight: 600,
354
+ textTransform: "uppercase",
355
+ letterSpacing: "0.5px",
356
+ opacity: 0.7,
357
+ };
358
+
359
+ const tdStyle = {
360
+ padding: "10px 12px",
361
+ verticalAlign: "middle",
362
+ };
frontend/components/AdminTabs/SkillsTab.jsx ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AdminTabs/SkillsTab.jsx
2
+ import React, { useEffect, useState, useCallback } from "react";
3
+ import { apiUrl, safeFetchJSON } from "../../utils/api.js";
4
+
5
+ /**
6
+ * Skills tab β€” lists all loaded skills from /api/skills and allows
7
+ * reloading them from disk via /api/skills/reload.
8
+ *
9
+ * Best practices applied:
10
+ * - Fetch on mount
11
+ * - Explicit reload button (skills are loaded from .md files on disk)
12
+ * - Loading / empty / error states
13
+ * - Auto-trigger indicator badge
14
+ * - Required tools list per skill
15
+ * - Source file path for debugging
16
+ */
17
+
18
+ export default function SkillsTab({ showToast }) {
19
+ const [skills, setSkills] = useState([]);
20
+ const [loading, setLoading] = useState(true);
21
+ const [reloading, setReloading] = useState(false);
22
+ const [error, setError] = useState(null);
23
+
24
+ const fetchSkills = useCallback(async () => {
25
+ setError(null);
26
+ try {
27
+ const data = await safeFetchJSON(apiUrl("/api/skills"), { timeout: 10000 });
28
+ setSkills(Array.isArray(data.skills) ? data.skills : []);
29
+ } catch (err) {
30
+ setError(err?.message || "Failed to load skills");
31
+ } finally {
32
+ setLoading(false);
33
+ }
34
+ }, []);
35
+
36
+ useEffect(() => {
37
+ fetchSkills();
38
+ }, [fetchSkills]);
39
+
40
+ const handleReload = async () => {
41
+ setReloading(true);
42
+ setError(null);
43
+ try {
44
+ const data = await safeFetchJSON(apiUrl("/api/skills/reload"), {
45
+ method: "POST",
46
+ timeout: 10000,
47
+ });
48
+ showToast?.(
49
+ "Skills reloaded",
50
+ `${data.count ?? 0} skill${data.count !== 1 ? "s" : ""} loaded from disk.`
51
+ );
52
+ await fetchSkills();
53
+ } catch (err) {
54
+ setError(err?.message || "Failed to reload skills");
55
+ } finally {
56
+ setReloading(false);
57
+ }
58
+ };
59
+
60
+ return (
61
+ <div>
62
+ <div
63
+ style={{
64
+ display: "flex",
65
+ justifyContent: "space-between",
66
+ alignItems: "flex-start",
67
+ marginBottom: "16px",
68
+ }}
69
+ >
70
+ <div>
71
+ <h3 style={{ marginBottom: "4px" }}>Skills</h3>
72
+ <p style={{ fontSize: "12px", opacity: 0.7 }}>
73
+ Reusable prompt templates loaded from{" "}
74
+ <code style={{ fontSize: "11px" }}>.gitpilot/skills/*.md</code> files.
75
+ </p>
76
+ </div>
77
+ <button
78
+ type="button"
79
+ onClick={handleReload}
80
+ disabled={reloading || loading}
81
+ style={{
82
+ padding: "6px 12px",
83
+ background: reloading ? "#555" : "#3B82F6",
84
+ color: "#fff",
85
+ border: "none",
86
+ borderRadius: "4px",
87
+ cursor: reloading || loading ? "not-allowed" : "pointer",
88
+ fontSize: "12px",
89
+ fontWeight: 600,
90
+ }}
91
+ >
92
+ {reloading ? "Reloading..." : "Reload Skills"}
93
+ </button>
94
+ </div>
95
+
96
+ {/* Loading state */}
97
+ {loading && (
98
+ <div
99
+ style={{
100
+ background: "#1a1b26",
101
+ borderRadius: "8px",
102
+ padding: "40px 20px",
103
+ textAlign: "center",
104
+ border: "1px solid #2a2b36",
105
+ fontSize: "12px",
106
+ opacity: 0.6,
107
+ }}
108
+ >
109
+ Loading skills...
110
+ </div>
111
+ )}
112
+
113
+ {/* Error state */}
114
+ {error && !loading && (
115
+ <div
116
+ role="alert"
117
+ style={{
118
+ background: "#7f1d1d",
119
+ color: "#fecaca",
120
+ border: "1px solid #991b1b",
121
+ borderRadius: "8px",
122
+ padding: "12px",
123
+ fontSize: "12px",
124
+ }}
125
+ >
126
+ <strong>Error: </strong>
127
+ {error}
128
+ </div>
129
+ )}
130
+
131
+ {/* Empty state */}
132
+ {!loading && !error && skills.length === 0 && (
133
+ <div
134
+ style={{
135
+ background: "#1a1b26",
136
+ borderRadius: "8px",
137
+ padding: "40px 20px",
138
+ textAlign: "center",
139
+ border: "1px dashed #2a2b36",
140
+ }}
141
+ >
142
+ <div style={{ fontSize: "32px", marginBottom: "8px" }}>πŸ“š</div>
143
+ <div style={{ fontSize: "14px", fontWeight: 600, marginBottom: "4px" }}>
144
+ No skills loaded
145
+ </div>
146
+ <div style={{ fontSize: "12px", opacity: 0.6 }}>
147
+ Create a <code>.gitpilot/skills/my-skill.md</code> file with YAML
148
+ frontmatter to add custom skills.
149
+ </div>
150
+ </div>
151
+ )}
152
+
153
+ {/* Skills grid */}
154
+ {!loading && skills.length > 0 && (
155
+ <div
156
+ style={{
157
+ display: "grid",
158
+ gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
159
+ gap: "12px",
160
+ }}
161
+ >
162
+ {skills.map((skill) => (
163
+ <div
164
+ key={skill.name}
165
+ style={{
166
+ background: "#1a1b26",
167
+ borderRadius: "8px",
168
+ padding: "16px",
169
+ border: "1px solid #2a2b36",
170
+ display: "flex",
171
+ flexDirection: "column",
172
+ gap: "8px",
173
+ }}
174
+ >
175
+ <div
176
+ style={{
177
+ display: "flex",
178
+ justifyContent: "space-between",
179
+ alignItems: "flex-start",
180
+ gap: "8px",
181
+ }}
182
+ >
183
+ <h4
184
+ style={{
185
+ fontSize: "14px",
186
+ fontWeight: 600,
187
+ margin: 0,
188
+ color: "#fff",
189
+ }}
190
+ >
191
+ {skill.name}
192
+ </h4>
193
+ {skill.auto_trigger && (
194
+ <span
195
+ title="Auto-triggered by matching context"
196
+ style={{
197
+ padding: "2px 8px",
198
+ background: "#1e3a5f",
199
+ color: "#93c5fd",
200
+ border: "1px solid #3B82F6",
201
+ borderRadius: "10px",
202
+ fontSize: "9px",
203
+ fontWeight: 700,
204
+ textTransform: "uppercase",
205
+ whiteSpace: "nowrap",
206
+ }}
207
+ >
208
+ Auto
209
+ </span>
210
+ )}
211
+ </div>
212
+
213
+ <p
214
+ style={{
215
+ fontSize: "12px",
216
+ opacity: 0.7,
217
+ lineHeight: 1.5,
218
+ margin: 0,
219
+ minHeight: "36px",
220
+ }}
221
+ >
222
+ {skill.description || "No description"}
223
+ </p>
224
+
225
+ {Array.isArray(skill.required_tools) && skill.required_tools.length > 0 && (
226
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
227
+ {skill.required_tools.map((t) => (
228
+ <span
229
+ key={t}
230
+ style={{
231
+ padding: "2px 6px",
232
+ background: "#0d0e15",
233
+ border: "1px solid #2a2b36",
234
+ borderRadius: "4px",
235
+ fontSize: "10px",
236
+ fontFamily: "monospace",
237
+ opacity: 0.8,
238
+ }}
239
+ >
240
+ {t}
241
+ </span>
242
+ ))}
243
+ </div>
244
+ )}
245
+
246
+ {skill.source && (
247
+ <div
248
+ style={{
249
+ fontSize: "10px",
250
+ opacity: 0.4,
251
+ fontFamily: "monospace",
252
+ borderTop: "1px solid #2a2b36",
253
+ paddingTop: "8px",
254
+ wordBreak: "break-all",
255
+ }}
256
+ >
257
+ {skill.source}
258
+ </div>
259
+ )}
260
+ </div>
261
+ ))}
262
+ </div>
263
+ )}
264
+ </div>
265
+ );
266
+ }
frontend/components/AdminTabs/WorkspaceModesTab.jsx ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AdminTabs/WorkspaceModesTab.jsx
2
+ import React, { useState } from "react";
3
+ import { startSession } from "../../utils/api.js";
4
+
5
+ /**
6
+ * Workspace Modes tab β€” allows the user to start a session in one of
7
+ * three modes (folder, local_git, github). Calls POST /api/session/start.
8
+ *
9
+ * Best practices applied:
10
+ * - Loading state while the request is in flight
11
+ * - Per-mode error state (not a global error)
12
+ * - Disabled card during submission to prevent double-click
13
+ * - ARIA role="button" + aria-disabled for accessibility
14
+ * - Toast notification on success
15
+ * - Success callback so App.jsx can set activeSessionId and switch to workspace view
16
+ */
17
+
18
+ const MODES = [
19
+ {
20
+ id: "folder",
21
+ title: "Folder Mode",
22
+ description: "Work with any local folder. No Git required.",
23
+ requires: "A local folder path",
24
+ enables: "Chat, explain, review",
25
+ promptKey: "folder_path",
26
+ promptLabel: "Folder path (absolute)",
27
+ promptPlaceholder: "/home/you/myproject",
28
+ buildPayload: (value) => ({ mode: "folder", folder_path: value }),
29
+ },
30
+ {
31
+ id: "local_git",
32
+ title: "Local Git Mode",
33
+ description: "Full repo + branch context for AI assistance.",
34
+ requires: "A local Git repository",
35
+ enables: "All local features (branches, diff, commit)",
36
+ promptKey: "repo_root",
37
+ promptLabel: "Repository root (absolute path)",
38
+ promptPlaceholder: "/home/you/my-git-repo",
39
+ buildPayload: (value) => ({ mode: "local_git", repo_root: value }),
40
+ },
41
+ {
42
+ id: "github",
43
+ title: "GitHub Mode",
44
+ description: "PRs, issues, remote workflows via GitHub API.",
45
+ requires: "GitHub token (already signed in)",
46
+ enables: "Full platform features",
47
+ promptKey: "repo_full_name",
48
+ promptLabel: "Repository (owner/repo)",
49
+ promptPlaceholder: "octocat/hello-world",
50
+ buildPayload: (value) => ({ mode: "github", repo_full_name: value }),
51
+ },
52
+ ];
53
+
54
+ export default function WorkspaceModesTab({ onSessionStarted, showToast }) {
55
+ const [activeModeId, setActiveModeId] = useState(null);
56
+ const [inputValue, setInputValue] = useState("");
57
+ const [submittingId, setSubmittingId] = useState(null);
58
+ const [errorByMode, setErrorByMode] = useState({});
59
+
60
+ const handleCardClick = (mode) => {
61
+ if (submittingId) return;
62
+ setActiveModeId(mode.id);
63
+ setInputValue("");
64
+ setErrorByMode((prev) => ({ ...prev, [mode.id]: null }));
65
+ };
66
+
67
+ const handleStart = async (mode) => {
68
+ const trimmed = inputValue.trim();
69
+ if (!trimmed) {
70
+ setErrorByMode((prev) => ({
71
+ ...prev,
72
+ [mode.id]: `${mode.promptLabel} is required`,
73
+ }));
74
+ return;
75
+ }
76
+
77
+ setSubmittingId(mode.id);
78
+ setErrorByMode((prev) => ({ ...prev, [mode.id]: null }));
79
+
80
+ try {
81
+ const payload = mode.buildPayload(trimmed);
82
+ const result = await startSession(payload);
83
+
84
+ showToast?.(
85
+ `${mode.title} started`,
86
+ `Session ${result.session_id?.slice(0, 8) || ""} is now active.`
87
+ );
88
+
89
+ onSessionStarted?.(result);
90
+ setActiveModeId(null);
91
+ setInputValue("");
92
+ } catch (err) {
93
+ setErrorByMode((prev) => ({
94
+ ...prev,
95
+ [mode.id]: err?.message || "Failed to start session",
96
+ }));
97
+ } finally {
98
+ setSubmittingId(null);
99
+ }
100
+ };
101
+
102
+ const handleCancel = () => {
103
+ if (submittingId) return;
104
+ setActiveModeId(null);
105
+ setInputValue("");
106
+ };
107
+
108
+ return (
109
+ <div>
110
+ <h3 style={{ marginBottom: "16px" }}>Workspace Modes</h3>
111
+ <p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "16px" }}>
112
+ Choose how you want GitPilot to interact with your code. You can switch modes at any time.
113
+ </p>
114
+
115
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "16px" }}>
116
+ {MODES.map((mode) => {
117
+ const isActive = activeModeId === mode.id;
118
+ const isSubmitting = submittingId === mode.id;
119
+ const error = errorByMode[mode.id];
120
+
121
+ return (
122
+ <div
123
+ key={mode.id}
124
+ role="button"
125
+ tabIndex={isSubmitting ? -1 : 0}
126
+ aria-disabled={!!submittingId && !isSubmitting}
127
+ onClick={() => !isActive && handleCardClick(mode)}
128
+ onKeyDown={(e) => {
129
+ if ((e.key === "Enter" || e.key === " ") && !isActive) {
130
+ e.preventDefault();
131
+ handleCardClick(mode);
132
+ }
133
+ }}
134
+ style={{
135
+ background: isActive ? "#1e3a5f" : "#1a1b26",
136
+ borderRadius: "8px",
137
+ padding: "20px",
138
+ border: isActive ? "1px solid #3B82F6" : "1px solid #2a2b36",
139
+ cursor: submittingId && !isSubmitting ? "not-allowed" : "pointer",
140
+ opacity: submittingId && !isSubmitting ? 0.5 : 1,
141
+ transition: "all 150ms ease",
142
+ }}
143
+ >
144
+ <h4 style={{ marginBottom: "8px", color: isActive ? "#93c5fd" : "#fff" }}>
145
+ {mode.title}
146
+ </h4>
147
+ <p style={{ fontSize: "12px", opacity: 0.7, marginBottom: "12px" }}>
148
+ {mode.description}
149
+ </p>
150
+ <div style={{ fontSize: "12px", marginBottom: "4px" }}>
151
+ <span style={{ opacity: 0.6 }}>Requires: </span>
152
+ {mode.requires}
153
+ </div>
154
+ <div style={{ fontSize: "12px", marginBottom: "12px" }}>
155
+ <span style={{ opacity: 0.6 }}>Enables: </span>
156
+ {mode.enables}
157
+ </div>
158
+
159
+ {isActive && (
160
+ <div onClick={(e) => e.stopPropagation()} style={{ marginTop: "12px" }}>
161
+ <label
162
+ htmlFor={`mode-input-${mode.id}`}
163
+ style={{
164
+ fontSize: "11px",
165
+ opacity: 0.7,
166
+ display: "block",
167
+ marginBottom: "4px",
168
+ }}
169
+ >
170
+ {mode.promptLabel}
171
+ </label>
172
+ <input
173
+ id={`mode-input-${mode.id}`}
174
+ type="text"
175
+ value={inputValue}
176
+ onChange={(e) => setInputValue(e.target.value)}
177
+ onKeyDown={(e) => {
178
+ if (e.key === "Enter") {
179
+ e.preventDefault();
180
+ handleStart(mode);
181
+ } else if (e.key === "Escape") {
182
+ handleCancel();
183
+ }
184
+ }}
185
+ placeholder={mode.promptPlaceholder}
186
+ disabled={isSubmitting}
187
+ autoFocus
188
+ style={{
189
+ width: "100%",
190
+ padding: "6px 8px",
191
+ background: "#0d0e15",
192
+ border: "1px solid #2a2b36",
193
+ borderRadius: "4px",
194
+ color: "#fff",
195
+ fontSize: "12px",
196
+ fontFamily: "monospace",
197
+ }}
198
+ />
199
+ {error && (
200
+ <div
201
+ style={{
202
+ fontSize: "11px",
203
+ color: "#f87171",
204
+ marginTop: "6px",
205
+ }}
206
+ role="alert"
207
+ >
208
+ {error}
209
+ </div>
210
+ )}
211
+ <div style={{ display: "flex", gap: "6px", marginTop: "10px" }}>
212
+ <button
213
+ type="button"
214
+ onClick={() => handleStart(mode)}
215
+ disabled={isSubmitting || !inputValue.trim()}
216
+ style={{
217
+ padding: "6px 12px",
218
+ background: isSubmitting ? "#555" : "#3B82F6",
219
+ color: "#fff",
220
+ border: "none",
221
+ borderRadius: "4px",
222
+ cursor: isSubmitting || !inputValue.trim() ? "not-allowed" : "pointer",
223
+ fontSize: "12px",
224
+ fontWeight: 600,
225
+ }}
226
+ >
227
+ {isSubmitting ? "Starting..." : "Start Session"}
228
+ </button>
229
+ <button
230
+ type="button"
231
+ onClick={handleCancel}
232
+ disabled={isSubmitting}
233
+ style={{
234
+ padding: "6px 12px",
235
+ background: "transparent",
236
+ color: "#a0a0b0",
237
+ border: "1px solid #2a2b36",
238
+ borderRadius: "4px",
239
+ cursor: isSubmitting ? "not-allowed" : "pointer",
240
+ fontSize: "12px",
241
+ }}
242
+ >
243
+ Cancel
244
+ </button>
245
+ </div>
246
+ </div>
247
+ )}
248
+ </div>
249
+ );
250
+ })}
251
+ </div>
252
+ </div>
253
+ );
254
+ }
frontend/components/AdminTabs/index.js ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AdminTabs/index.js
2
+ // Barrel export β€” all admin tab components in one place
3
+ export { default as WorkspaceModesTab } from "./WorkspaceModesTab.jsx";
4
+ export { default as SecurityTab } from "./SecurityTab.jsx";
5
+ export { default as IntegrationsTab } from "./IntegrationsTab.jsx";
6
+ export { default as MCPServersTab } from "./MCPServersTab.jsx";
7
+ export { default as SkillsTab } from "./SkillsTab.jsx";
8
+ export { default as SessionsTab } from "./SessionsTab.jsx";
9
+ export { default as AdvancedTab } from "./AdvancedTab.jsx";
frontend/components/AdminTabs/mcp/CatalogList.jsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AdminTabs/mcp/CatalogList.jsx
2
+ // Browse curated MCP servers shipped with GitPilot. Each item has a
3
+ // one-click "Install" that lands the server in the Installed tab,
4
+ // disabled by default.
5
+
6
+ import React from "react";
7
+
8
+ export default function CatalogList({ items, onInstall }) {
9
+ if (!items?.length) {
10
+ return (
11
+ <p style={{ color: "#a0a0b0", fontSize: "13px" }}>
12
+ No catalog entries shipped with this build.
13
+ </p>
14
+ );
15
+ }
16
+
17
+ return (
18
+ <div style={{ display: "grid", gap: "12px", gridTemplateColumns: "1fr" }}>
19
+ {items.map((item) => (
20
+ <div
21
+ key={item.id}
22
+ style={{
23
+ background: "#1a1b26",
24
+ border: "1px solid #2a2b36",
25
+ borderRadius: "8px",
26
+ padding: "16px 20px",
27
+ display: "flex",
28
+ justifyContent: "space-between",
29
+ alignItems: "center",
30
+ gap: "16px",
31
+ }}
32
+ >
33
+ <div style={{ flex: "1 1 auto", minWidth: 0 }}>
34
+ <div
35
+ style={{
36
+ display: "flex",
37
+ gap: "8px",
38
+ alignItems: "center",
39
+ marginBottom: "4px",
40
+ }}
41
+ >
42
+ <strong style={{ fontSize: "14px", color: "#e0e0e7" }}>
43
+ {item.id}
44
+ </strong>
45
+ {item.installed && (
46
+ <span
47
+ style={{
48
+ padding: "2px 8px",
49
+ background: "#0f3a26",
50
+ color: "#86efac",
51
+ borderRadius: "4px",
52
+ fontSize: "10px",
53
+ fontWeight: 600,
54
+ }}
55
+ >
56
+ installed
57
+ </span>
58
+ )}
59
+ </div>
60
+ <div style={{ fontSize: "12px", color: "#a0a0b0", marginBottom: "6px" }}>
61
+ {item.description}
62
+ </div>
63
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
64
+ {(item.tags || []).map((t) => (
65
+ <span
66
+ key={t}
67
+ style={{
68
+ padding: "1px 6px",
69
+ background: "#252634",
70
+ border: "1px solid #2a2b36",
71
+ borderRadius: "10px",
72
+ fontSize: "10px",
73
+ color: "#cdd0d8",
74
+ }}
75
+ >
76
+ {t}
77
+ </span>
78
+ ))}
79
+ </div>
80
+ </div>
81
+ <button
82
+ onClick={() => onInstall(item.id)}
83
+ disabled={item.installed}
84
+ style={{
85
+ padding: "6px 14px",
86
+ background: item.installed ? "#252634" : "#3B82F6",
87
+ color: item.installed ? "#7a7d8a" : "#fff",
88
+ border: "none",
89
+ borderRadius: "4px",
90
+ cursor: item.installed ? "not-allowed" : "pointer",
91
+ fontSize: "12px",
92
+ fontWeight: 600,
93
+ }}
94
+ >
95
+ {item.installed ? "Installed" : "Install"}
96
+ </button>
97
+ </div>
98
+ ))}
99
+ </div>
100
+ );
101
+ }
frontend/components/AdminTabs/mcp/CustomInstallForm.jsx ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AdminTabs/mcp/CustomInstallForm.jsx
2
+ // Paste-a-register.json form for installing a custom MCP server.
3
+
4
+ import React, { useState } from "react";
5
+
6
+ const SAMPLE = `{
7
+ "name": "mcp-neo4j-server",
8
+ "endpoint": "http://mcp-neo4j-server:8083/mcp",
9
+ "description": "Neo4j MCP server for graph schema discovery",
10
+ "tags": ["graph", "neo4j"],
11
+ "auth": { "type": "bearer", "env": "MCP_NEO4J_SERVER_TOKEN" }
12
+ }`;
13
+
14
+ export default function CustomInstallForm({ onSubmit }) {
15
+ const [text, setText] = useState(SAMPLE);
16
+ const [err, setErr] = useState(null);
17
+ const [submitting, setSubmitting] = useState(false);
18
+
19
+ const handleSubmit = async () => {
20
+ setErr(null);
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(text);
24
+ } catch (e) {
25
+ setErr("Invalid JSON: " + (e?.message || ""));
26
+ return;
27
+ }
28
+ if (!parsed.name || !parsed.endpoint) {
29
+ setErr("register.json must include 'name' and 'endpoint'.");
30
+ return;
31
+ }
32
+ setSubmitting(true);
33
+ try {
34
+ await onSubmit(parsed);
35
+ setText(SAMPLE);
36
+ } catch (e) {
37
+ setErr(e?.message || "Install failed");
38
+ } finally {
39
+ setSubmitting(false);
40
+ }
41
+ };
42
+
43
+ return (
44
+ <div
45
+ style={{
46
+ background: "#1a1b26",
47
+ border: "1px solid #2a2b36",
48
+ borderRadius: "8px",
49
+ padding: "20px",
50
+ }}
51
+ >
52
+ <h4 style={{ margin: "0 0 6px 0", color: "#e0e0e7" }}>Install custom server</h4>
53
+ <p style={{ margin: "0 0 12px 0", fontSize: "12px", color: "#a0a0b0" }}>
54
+ Paste a Context Forge <code>register.json</code>. The server lands
55
+ disabled β€” turn it on from the Installed tab once you have set its
56
+ auth token.
57
+ </p>
58
+ <textarea
59
+ value={text}
60
+ onChange={(e) => setText(e.target.value)}
61
+ rows={12}
62
+ spellCheck={false}
63
+ aria-label="register.json"
64
+ style={{
65
+ width: "100%",
66
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
67
+ fontSize: "12px",
68
+ background: "#0f1018",
69
+ color: "#cdd0d8",
70
+ border: "1px solid #2a2b36",
71
+ borderRadius: "6px",
72
+ padding: "12px",
73
+ resize: "vertical",
74
+ boxSizing: "border-box",
75
+ }}
76
+ />
77
+ {err && (
78
+ <div
79
+ role="alert"
80
+ style={{
81
+ marginTop: "8px",
82
+ padding: "8px 12px",
83
+ background: "#3a0f0f",
84
+ border: "1px solid #5a1f1f",
85
+ borderRadius: "6px",
86
+ color: "#fca5a5",
87
+ fontSize: "12px",
88
+ }}
89
+ >
90
+ {err}
91
+ </div>
92
+ )}
93
+ <div
94
+ style={{
95
+ display: "flex",
96
+ justifyContent: "flex-end",
97
+ gap: "8px",
98
+ marginTop: "12px",
99
+ }}
100
+ >
101
+ <button
102
+ onClick={() => setText(SAMPLE)}
103
+ style={{
104
+ padding: "6px 12px",
105
+ background: "transparent",
106
+ color: "#cdd0d8",
107
+ border: "1px solid #3a3b4a",
108
+ borderRadius: "4px",
109
+ cursor: "pointer",
110
+ fontSize: "12px",
111
+ }}
112
+ >
113
+ Reset
114
+ </button>
115
+ <button
116
+ onClick={handleSubmit}
117
+ disabled={submitting}
118
+ style={{
119
+ padding: "6px 14px",
120
+ background: "#3B82F6",
121
+ color: "#fff",
122
+ border: "none",
123
+ borderRadius: "4px",
124
+ cursor: submitting ? "not-allowed" : "pointer",
125
+ fontSize: "12px",
126
+ fontWeight: 600,
127
+ }}
128
+ >
129
+ {submitting ? "Installing…" : "Install server"}
130
+ </button>
131
+ </div>
132
+ </div>
133
+ );
134
+ }
frontend/components/AdminTabs/mcp/GatewayHeader.jsx ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AdminTabs/mcp/GatewayHeader.jsx
2
+ // Top strip of the MCP Servers tab: gateway pill + roll-up counters.
3
+
4
+ import React from "react";
5
+
6
+ export default function GatewayHeader({
7
+ status,
8
+ installedCount,
9
+ enabledCount,
10
+ totalTools,
11
+ onRefresh,
12
+ onSync,
13
+ syncing,
14
+ }) {
15
+ const reachable = status?.gateway_reachable;
16
+ const dotColor = reachable ? "#10b981" : "#ef4444";
17
+ const dotLabel = reachable ? "Connected" : "Unreachable";
18
+ const gatewayUrl = status?.gateway_url || "β€”";
19
+
20
+ return (
21
+ <div
22
+ style={{
23
+ background: "#1a1b26",
24
+ border: "1px solid #2a2b36",
25
+ borderRadius: "8px",
26
+ padding: "16px 20px",
27
+ marginBottom: "16px",
28
+ display: "flex",
29
+ justifyContent: "space-between",
30
+ alignItems: "center",
31
+ flexWrap: "wrap",
32
+ gap: "12px",
33
+ }}
34
+ >
35
+ <div>
36
+ <div
37
+ style={{
38
+ display: "flex",
39
+ alignItems: "center",
40
+ gap: "8px",
41
+ marginBottom: "4px",
42
+ }}
43
+ >
44
+ <span
45
+ aria-hidden
46
+ style={{
47
+ width: "10px",
48
+ height: "10px",
49
+ background: dotColor,
50
+ borderRadius: "50%",
51
+ boxShadow: `0 0 8px ${dotColor}`,
52
+ }}
53
+ />
54
+ <strong style={{ fontSize: "14px", color: "#e0e0e7" }}>
55
+ MCP Context Forge β€” {dotLabel}
56
+ </strong>
57
+ {status?.plugin_enabled === false && (
58
+ <span
59
+ title="Set GITPILOT_MCP_ENABLED=true to let agents call MCP tools."
60
+ style={{
61
+ marginLeft: "8px",
62
+ padding: "2px 8px",
63
+ background: "#5c1a1a",
64
+ color: "#fecaca",
65
+ borderRadius: "4px",
66
+ fontSize: "11px",
67
+ fontWeight: 600,
68
+ }}
69
+ >
70
+ plugin disabled
71
+ </span>
72
+ )}
73
+ </div>
74
+ <div style={{ fontSize: "12px", color: "#a0a0b0" }}>
75
+ Gateway: <code>{gatewayUrl}</code>
76
+ </div>
77
+ </div>
78
+
79
+ <div
80
+ style={{
81
+ display: "flex",
82
+ gap: "20px",
83
+ alignItems: "center",
84
+ fontSize: "12px",
85
+ color: "#cdd0d8",
86
+ }}
87
+ >
88
+ <Counter label="Installed" value={installedCount} />
89
+ <Counter label="Enabled" value={enabledCount} />
90
+ <Counter label="Tools" value={totalTools} />
91
+ <button
92
+ onClick={onRefresh}
93
+ style={{
94
+ padding: "6px 12px",
95
+ background: "#252634",
96
+ color: "#e0e0e7",
97
+ border: "1px solid #3a3b4a",
98
+ borderRadius: "4px",
99
+ cursor: "pointer",
100
+ fontSize: "12px",
101
+ }}
102
+ >
103
+ Refresh
104
+ </button>
105
+ {onSync && (
106
+ <button
107
+ onClick={onSync}
108
+ disabled={!reachable || syncing}
109
+ title={
110
+ reachable
111
+ ? "Pull the server registry from MCP Context Forge"
112
+ : "Gateway unreachable β€” start MCP Context Forge first (make run)"
113
+ }
114
+ style={{
115
+ padding: "6px 12px",
116
+ background: reachable ? "#1e3a5f" : "#252634",
117
+ color: reachable ? "#93c5fd" : "#7a7d8a",
118
+ border: `1px solid ${reachable ? "#3B82F6" : "#3a3b4a"}`,
119
+ borderRadius: "4px",
120
+ cursor: reachable && !syncing ? "pointer" : "not-allowed",
121
+ fontSize: "12px",
122
+ fontWeight: 600,
123
+ opacity: !reachable || syncing ? 0.6 : 1,
124
+ }}
125
+ >
126
+ {syncing ? "Syncing…" : "Sync"}
127
+ </button>
128
+ )}
129
+ </div>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ function Counter({ label, value }) {
135
+ return (
136
+ <div style={{ textAlign: "center" }}>
137
+ <div style={{ fontSize: "18px", fontWeight: 700, color: "#e0e0e7" }}>
138
+ {value ?? "β€”"}
139
+ </div>
140
+ <div style={{ fontSize: "11px", color: "#7a7d8a", textTransform: "uppercase" }}>
141
+ {label}
142
+ </div>
143
+ </div>
144
+ );
145
+ }
frontend/components/AdminTabs/mcp/ServerCard.jsx ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AdminTabs/mcp/ServerCard.jsx
2
+ // One installed MCP server. Collapsed shows summary + actions; expanded
3
+ // reveals the per-tool list with risk badges and individual toggles.
4
+
5
+ import React, { useState } from "react";
6
+ import ToolRow from "./ToolRow.jsx";
7
+
8
+ const RISK_PALETTE = {
9
+ low: { bg: "#0f3a26", border: "#1f5a3e", text: "#86efac" },
10
+ medium: { bg: "#3a2e0f", border: "#5a4a1f", text: "#fcd34d" },
11
+ high: { bg: "#3a0f0f", border: "#5a1f1f", text: "#fca5a5" },
12
+ };
13
+
14
+ export default function ServerCard({
15
+ server,
16
+ onEnable,
17
+ onDisable,
18
+ onUninstall,
19
+ onTest,
20
+ onToggleTool,
21
+ onForget,
22
+ }) {
23
+ const [expanded, setExpanded] = useState(false);
24
+ const [testResult, setTestResult] = useState(null);
25
+ const [testing, setTesting] = useState(false);
26
+
27
+ const handleTest = async () => {
28
+ setTesting(true);
29
+ try {
30
+ const result = await onTest();
31
+ setTestResult(result);
32
+ } finally {
33
+ setTesting(false);
34
+ }
35
+ };
36
+
37
+ const statusDot = server.enabled ? "#10b981" : "#6b7280";
38
+ const statusLabel = server.enabled ? "Enabled" : "Disabled";
39
+
40
+ // Risk roll-up shown next to the tool count.
41
+ const riskCounts = server.tools?.reduce(
42
+ (acc, t) => ({ ...acc, [t.risk]: (acc[t.risk] || 0) + 1 }),
43
+ {}
44
+ ) || {};
45
+
46
+ return (
47
+ <div
48
+ style={{
49
+ background: "#1a1b26",
50
+ border: "1px solid #2a2b36",
51
+ borderRadius: "8px",
52
+ overflow: "hidden",
53
+ }}
54
+ >
55
+ <div style={{ padding: "16px 20px" }}>
56
+ <div
57
+ style={{
58
+ display: "flex",
59
+ justifyContent: "space-between",
60
+ alignItems: "flex-start",
61
+ gap: "16px",
62
+ flexWrap: "wrap",
63
+ }}
64
+ >
65
+ <div style={{ flex: "1 1 320px", minWidth: 0 }}>
66
+ <div
67
+ style={{
68
+ display: "flex",
69
+ alignItems: "center",
70
+ gap: "8px",
71
+ marginBottom: "6px",
72
+ }}
73
+ >
74
+ <span
75
+ aria-hidden
76
+ style={{
77
+ width: "8px",
78
+ height: "8px",
79
+ background: statusDot,
80
+ borderRadius: "50%",
81
+ }}
82
+ />
83
+ <strong
84
+ style={{
85
+ fontSize: "15px",
86
+ color: "#e0e0e7",
87
+ overflow: "hidden",
88
+ textOverflow: "ellipsis",
89
+ whiteSpace: "nowrap",
90
+ }}
91
+ >
92
+ {server.id}
93
+ </strong>
94
+ <span
95
+ style={{
96
+ padding: "2px 8px",
97
+ background: server.enabled ? "#0f3a26" : "#252634",
98
+ color: server.enabled ? "#86efac" : "#a0a0b0",
99
+ borderRadius: "4px",
100
+ fontSize: "11px",
101
+ fontWeight: 600,
102
+ }}
103
+ >
104
+ {statusLabel}
105
+ </span>
106
+ {!server.is_known && (
107
+ <span
108
+ title="Custom server (not part of the GitPilot catalog)"
109
+ style={{
110
+ padding: "2px 8px",
111
+ background: "#1e3a5f",
112
+ color: "#93c5fd",
113
+ borderRadius: "4px",
114
+ fontSize: "11px",
115
+ }}
116
+ >
117
+ custom
118
+ </span>
119
+ )}
120
+ {server.orphan && (
121
+ <span
122
+ title="No longer registered in MCP Context Forge. Still works locally; click Forget to drop it, or re-attach it in Forge."
123
+ style={{
124
+ padding: "2px 8px",
125
+ background: "#3a2e0f",
126
+ color: "#fcd34d",
127
+ borderRadius: "4px",
128
+ fontSize: "11px",
129
+ fontWeight: 600,
130
+ }}
131
+ >
132
+ orphan
133
+ </span>
134
+ )}
135
+ {server.source === "forge-sync" && !server.orphan && (
136
+ <span
137
+ title="Added by the most recent forge sync."
138
+ style={{
139
+ padding: "2px 8px",
140
+ background: "#1e3a5f",
141
+ color: "#93c5fd",
142
+ borderRadius: "4px",
143
+ fontSize: "11px",
144
+ }}
145
+ >
146
+ via sync
147
+ </span>
148
+ )}
149
+ </div>
150
+ <div
151
+ style={{
152
+ fontSize: "12px",
153
+ color: "#a0a0b0",
154
+ marginBottom: "8px",
155
+ }}
156
+ >
157
+ {server.description || "β€”"}
158
+ </div>
159
+ <div
160
+ style={{
161
+ fontSize: "11px",
162
+ color: "#7a7d8a",
163
+ marginBottom: "8px",
164
+ wordBreak: "break-all",
165
+ }}
166
+ >
167
+ <code>{server.endpoint || "β€”"}</code>
168
+ </div>
169
+ <div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
170
+ {(server.tags || []).map((t) => (
171
+ <span
172
+ key={t}
173
+ style={{
174
+ padding: "2px 8px",
175
+ background: "#252634",
176
+ border: "1px solid #2a2b36",
177
+ borderRadius: "10px",
178
+ fontSize: "11px",
179
+ color: "#cdd0d8",
180
+ }}
181
+ >
182
+ {t}
183
+ </span>
184
+ ))}
185
+ </div>
186
+ </div>
187
+
188
+ <div
189
+ style={{
190
+ display: "flex",
191
+ flexDirection: "column",
192
+ gap: "8px",
193
+ alignItems: "flex-end",
194
+ }}
195
+ >
196
+ <div style={{ display: "flex", gap: "6px" }}>
197
+ {server.enabled ? (
198
+ <Btn variant="ghost" onClick={onDisable}>
199
+ Disable
200
+ </Btn>
201
+ ) : (
202
+ <Btn variant="primary" onClick={onEnable}>
203
+ Enable
204
+ </Btn>
205
+ )}
206
+ <Btn onClick={handleTest} disabled={testing}>
207
+ {testing ? "Testing…" : "Test"}
208
+ </Btn>
209
+ {onForget ? (
210
+ <Btn variant="danger" onClick={onForget}>
211
+ Forget
212
+ </Btn>
213
+ ) : (
214
+ <Btn variant="danger" onClick={onUninstall}>
215
+ Uninstall
216
+ </Btn>
217
+ )}
218
+ </div>
219
+ <div style={{ fontSize: "11px", color: "#a0a0b0" }}>
220
+ {server.tool_count} tool{server.tool_count === 1 ? "" : "s"}
221
+ {Object.entries(riskCounts).map(([risk, count]) => (
222
+ <span
223
+ key={risk}
224
+ title={`${count} ${risk}-risk tools`}
225
+ style={{
226
+ marginLeft: "6px",
227
+ padding: "1px 6px",
228
+ borderRadius: "10px",
229
+ background: RISK_PALETTE[risk]?.bg,
230
+ color: RISK_PALETTE[risk]?.text,
231
+ border: `1px solid ${RISK_PALETTE[risk]?.border}`,
232
+ fontSize: "10px",
233
+ }}
234
+ >
235
+ {count} {risk}
236
+ </span>
237
+ ))}
238
+ </div>
239
+ <button
240
+ onClick={() => setExpanded((v) => !v)}
241
+ style={{
242
+ padding: "4px 8px",
243
+ background: "transparent",
244
+ color: "#93c5fd",
245
+ border: "none",
246
+ cursor: "pointer",
247
+ fontSize: "12px",
248
+ }}
249
+ >
250
+ {expanded ? "Hide tools β–΄" : "Show tools β–Ύ"}
251
+ </button>
252
+ </div>
253
+ </div>
254
+
255
+ {testResult && (
256
+ <div
257
+ role="status"
258
+ style={{
259
+ marginTop: "12px",
260
+ padding: "8px 12px",
261
+ fontSize: "12px",
262
+ borderRadius: "6px",
263
+ background: testResult.ok ? "#0f3a26" : "#3a0f0f",
264
+ border: `1px solid ${testResult.ok ? "#1f5a3e" : "#5a1f1f"}`,
265
+ color: testResult.ok ? "#86efac" : "#fca5a5",
266
+ }}
267
+ >
268
+ {testResult.ok
269
+ ? "Healthy. Inspector confirmed the server is reachable and advertises its expected tools."
270
+ : `Failed: ${testResult.reason || testResult.error || "unknown error"}`}
271
+ </div>
272
+ )}
273
+ </div>
274
+
275
+ {expanded && (
276
+ <div
277
+ style={{
278
+ borderTop: "1px solid #2a2b36",
279
+ background: "#16171f",
280
+ padding: "8px 0",
281
+ }}
282
+ >
283
+ {server.tools?.length ? (
284
+ server.tools.map((t) => (
285
+ <ToolRow
286
+ key={t.name}
287
+ tool={t}
288
+ disabled={!server.enabled}
289
+ onToggle={(enabled) => onToggleTool(t.name, enabled)}
290
+ />
291
+ ))
292
+ ) : (
293
+ <div
294
+ style={{
295
+ padding: "12px 20px",
296
+ fontSize: "12px",
297
+ color: "#a0a0b0",
298
+ }}
299
+ >
300
+ No tools advertised by this server.
301
+ </div>
302
+ )}
303
+ </div>
304
+ )}
305
+ </div>
306
+ );
307
+ }
308
+
309
+ function Btn({ children, variant = "default", ...props }) {
310
+ const palettes = {
311
+ default: { bg: "#252634", color: "#e0e0e7", border: "#3a3b4a" },
312
+ primary: { bg: "#3B82F6", color: "#fff", border: "#3B82F6" },
313
+ ghost: { bg: "transparent", color: "#cdd0d8", border: "#3a3b4a" },
314
+ danger: { bg: "transparent", color: "#fca5a5", border: "#5a1f1f" },
315
+ };
316
+ const p = palettes[variant];
317
+ return (
318
+ <button
319
+ {...props}
320
+ style={{
321
+ padding: "6px 12px",
322
+ background: p.bg,
323
+ color: p.color,
324
+ border: `1px solid ${p.border}`,
325
+ borderRadius: "4px",
326
+ cursor: props.disabled ? "not-allowed" : "pointer",
327
+ fontSize: "12px",
328
+ opacity: props.disabled ? 0.6 : 1,
329
+ }}
330
+ >
331
+ {children}
332
+ </button>
333
+ );
334
+ }
frontend/components/AdminTabs/mcp/SyncReport.jsx ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AdminTabs/mcp/SyncReport.jsx
2
+ // Renders the SyncReport returned by POST /api/mcp/sync as a compact
3
+ // dismissible banner. Best practices: structured + colour-coded counts,
4
+ // truncated id lists with a "View" toggle, single primary "Dismiss"
5
+ // action, ARIA "status" so a screen reader announces the result once.
6
+
7
+ import React, { useState } from "react";
8
+
9
+ export default function SyncReport({ report, onDismiss }) {
10
+ const [open, setOpen] = useState(false);
11
+ if (!report) return null;
12
+
13
+ const { added = [], kept = [], orphaned = [], forge_unreachable, error } = report;
14
+
15
+ if (forge_unreachable) {
16
+ return (
17
+ <Banner
18
+ kind="bad"
19
+ title="Sync failed: forge unreachable"
20
+ body={error || "Could not reach MCP Context Forge."}
21
+ onDismiss={onDismiss}
22
+ />
23
+ );
24
+ }
25
+
26
+ const noChanges = added.length === 0 && orphaned.length === 0;
27
+
28
+ return (
29
+ <Banner
30
+ kind={noChanges ? "info" : added.length ? "ok" : "warn"}
31
+ title={
32
+ noChanges
33
+ ? "Sync complete β€” no changes"
34
+ : `Sync complete Β· +${added.length} added Β· ${kept.length} refreshed Β· ${orphaned.length} orphan`
35
+ }
36
+ onDismiss={onDismiss}
37
+ footer={
38
+ <button
39
+ onClick={() => setOpen((v) => !v)}
40
+ style={{
41
+ background: "transparent",
42
+ border: "none",
43
+ color: "#93c5fd",
44
+ cursor: "pointer",
45
+ fontSize: 12,
46
+ padding: 0,
47
+ }}
48
+ >
49
+ {open ? "Hide details β–΄" : "View details β–Ύ"}
50
+ </button>
51
+ }
52
+ >
53
+ {open && (
54
+ <div
55
+ style={{ fontSize: 12, color: "#cdd0d8", marginTop: 6, lineHeight: 1.5 }}
56
+ >
57
+ {added.length > 0 && (
58
+ <Detail label="Added" items={added} colour="#86efac" />
59
+ )}
60
+ {kept.length > 0 && (
61
+ <Detail label="Refreshed" items={kept} colour="#93c5fd" />
62
+ )}
63
+ {orphaned.length > 0 && (
64
+ <Detail
65
+ label="Orphaned"
66
+ items={orphaned}
67
+ colour="#fcd34d"
68
+ hint="Still works locally β€” Forge no longer advertises them. Use 'Forget' to drop, or re-attach them in Forge."
69
+ />
70
+ )}
71
+ {report.correlation_id && (
72
+ <div style={{ marginTop: 6, color: "#7a7d8a" }}>
73
+ correlation_id: <code>{report.correlation_id}</code>
74
+ </div>
75
+ )}
76
+ </div>
77
+ )}
78
+ </Banner>
79
+ );
80
+ }
81
+
82
+ function Banner({ kind, title, body, footer, onDismiss, children }) {
83
+ const palette = {
84
+ ok: { bg: "#0f3a26", border: "#1f5a3e", text: "#86efac" },
85
+ info: { bg: "#1e3a5f", border: "#3B82F6", text: "#93c5fd" },
86
+ warn: { bg: "#3a2e0f", border: "#5a4a1f", text: "#fcd34d" },
87
+ bad: { bg: "#3a0f0f", border: "#5a1f1f", text: "#fca5a5" },
88
+ }[kind] || { bg: "#1a1b26", border: "#2a2b36", text: "#cdd0d8" };
89
+
90
+ return (
91
+ <div
92
+ role="status"
93
+ aria-live="polite"
94
+ style={{
95
+ background: palette.bg,
96
+ border: `1px solid ${palette.border}`,
97
+ borderRadius: 6,
98
+ padding: "10px 14px",
99
+ marginBottom: 12,
100
+ color: palette.text,
101
+ fontSize: 13,
102
+ display: "flex",
103
+ flexDirection: "column",
104
+ gap: 4,
105
+ }}
106
+ >
107
+ <div
108
+ style={{
109
+ display: "flex",
110
+ justifyContent: "space-between",
111
+ alignItems: "center",
112
+ gap: 8,
113
+ }}
114
+ >
115
+ <strong>{title}</strong>
116
+ <button
117
+ onClick={onDismiss}
118
+ aria-label="Dismiss"
119
+ style={{
120
+ background: "transparent",
121
+ border: "none",
122
+ color: palette.text,
123
+ cursor: "pointer",
124
+ fontSize: 16,
125
+ lineHeight: 1,
126
+ padding: 0,
127
+ }}
128
+ >
129
+ Γ—
130
+ </button>
131
+ </div>
132
+ {body && <div>{body}</div>}
133
+ {children}
134
+ {footer && <div>{footer}</div>}
135
+ </div>
136
+ );
137
+ }
138
+
139
+ function Detail({ label, items, colour, hint }) {
140
+ return (
141
+ <div style={{ marginTop: 4 }}>
142
+ <span style={{ color: colour, fontWeight: 600 }}>{label}:</span>{" "}
143
+ <span>
144
+ {items.slice(0, 6).join(", ")}
145
+ {items.length > 6 ? ` … (+${items.length - 6} more)` : ""}
146
+ </span>
147
+ {hint && (
148
+ <div style={{ marginTop: 2, fontSize: 11, color: "#7a7d8a" }}>{hint}</div>
149
+ )}
150
+ </div>
151
+ );
152
+ }
frontend/components/AdminTabs/mcp/ToolRow.jsx ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/AdminTabs/mcp/ToolRow.jsx
2
+ // One row inside an expanded ServerCard. Tool name, risk badge,
3
+ // "used by" agent chips, and a per-tool enable toggle.
4
+
5
+ import React from "react";
6
+
7
+ const RISK_PALETTE = {
8
+ low: { bg: "#0f3a26", text: "#86efac", label: "low" },
9
+ medium: { bg: "#3a2e0f", text: "#fcd34d", label: "med" },
10
+ high: { bg: "#3a0f0f", text: "#fca5a5", label: "high" },
11
+ };
12
+
13
+ export default function ToolRow({ tool, disabled, onToggle }) {
14
+ const risk = RISK_PALETTE[tool.risk] || RISK_PALETTE.low;
15
+
16
+ // Destructive tools cannot be toggled on by the UI; the backend's
17
+ // PolicyEngine will reject a toggle PUT anyway, but disabling the
18
+ // control surfaces the constraint up front.
19
+ const lockEnable = tool.destructive;
20
+
21
+ return (
22
+ <div
23
+ style={{
24
+ display: "flex",
25
+ alignItems: "center",
26
+ justifyContent: "space-between",
27
+ gap: "12px",
28
+ padding: "8px 20px",
29
+ borderBottom: "1px solid #1a1b26",
30
+ }}
31
+ >
32
+ <div style={{ flex: "1 1 auto", minWidth: 0 }}>
33
+ <div
34
+ style={{
35
+ display: "flex",
36
+ alignItems: "center",
37
+ gap: "8px",
38
+ marginBottom: "2px",
39
+ }}
40
+ >
41
+ <code
42
+ style={{
43
+ fontSize: "12px",
44
+ color: "#e0e0e7",
45
+ overflow: "hidden",
46
+ textOverflow: "ellipsis",
47
+ whiteSpace: "nowrap",
48
+ }}
49
+ >
50
+ {tool.name}
51
+ </code>
52
+ <span
53
+ title={`risk: ${tool.risk}`}
54
+ style={{
55
+ padding: "1px 6px",
56
+ borderRadius: "10px",
57
+ background: risk.bg,
58
+ color: risk.text,
59
+ fontSize: "10px",
60
+ fontWeight: 600,
61
+ textTransform: "uppercase",
62
+ }}
63
+ >
64
+ {risk.label}
65
+ </span>
66
+ </div>
67
+ <div
68
+ style={{
69
+ display: "flex",
70
+ gap: "4px",
71
+ flexWrap: "wrap",
72
+ fontSize: "10px",
73
+ color: "#7a7d8a",
74
+ }}
75
+ >
76
+ {(tool.used_by || []).length > 0 && <span>used by</span>}
77
+ {(tool.used_by || []).map((agent) => (
78
+ <span
79
+ key={agent}
80
+ style={{
81
+ padding: "1px 6px",
82
+ borderRadius: "10px",
83
+ background: "#1e3a5f",
84
+ color: "#93c5fd",
85
+ }}
86
+ >
87
+ {agent}
88
+ </span>
89
+ ))}
90
+ </div>
91
+ </div>
92
+
93
+ <Toggle
94
+ checked={tool.enabled}
95
+ disabled={disabled || lockEnable}
96
+ title={
97
+ lockEnable
98
+ ? "Destructive tool β€” blocked by policy"
99
+ : disabled
100
+ ? "Enable the server first"
101
+ : tool.enabled
102
+ ? "Disable this tool for GitPilot agents"
103
+ : "Allow GitPilot agents to call this tool"
104
+ }
105
+ onChange={(checked) => onToggle(checked)}
106
+ />
107
+ </div>
108
+ );
109
+ }
110
+
111
+ function Toggle({ checked, disabled, title, onChange }) {
112
+ return (
113
+ <label title={title} style={{ display: "inline-flex", alignItems: "center" }}>
114
+ <input
115
+ type="checkbox"
116
+ checked={!!checked}
117
+ disabled={disabled}
118
+ onChange={(e) => onChange(e.target.checked)}
119
+ style={{ position: "absolute", opacity: 0, pointerEvents: "none" }}
120
+ aria-label={title}
121
+ />
122
+ <span
123
+ style={{
124
+ width: "32px",
125
+ height: "18px",
126
+ borderRadius: "9px",
127
+ background: checked && !disabled ? "#3B82F6" : "#2a2b36",
128
+ position: "relative",
129
+ transition: "background 0.15s",
130
+ opacity: disabled ? 0.5 : 1,
131
+ cursor: disabled ? "not-allowed" : "pointer",
132
+ }}
133
+ >
134
+ <span
135
+ style={{
136
+ width: "14px",
137
+ height: "14px",
138
+ borderRadius: "50%",
139
+ background: "#fff",
140
+ position: "absolute",
141
+ top: "2px",
142
+ left: checked ? "16px" : "2px",
143
+ transition: "left 0.15s",
144
+ }}
145
+ />
146
+ </span>
147
+ </label>
148
+ );
149
+ }
frontend/components/AssistantMessage.jsx ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import PlanView from "./PlanView.jsx";
3
+
4
+ export default function AssistantMessage({ answer, plan, executionLog, planStatus }) {
5
+ // ``planStatus`` is optional metadata about the lifecycle of the plan
6
+ // attached to this message: "executed" | "rejected" | null. It drives
7
+ // the badge next to the Action Plan header so the user can tell at a
8
+ // glance, in chat history, whether a previous plan was approved or
9
+ // dismissed. Defaults to null (no badge) to keep the legacy render
10
+ // path untouched.
11
+ const styles = {
12
+ container: {
13
+ marginBottom: "20px",
14
+ padding: "20px",
15
+ backgroundColor: "#18181B", // Zinc-900
16
+ borderRadius: "12px",
17
+ border: "1px solid #27272A", // Zinc-800
18
+ color: "#F4F4F5", // Zinc-100
19
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
20
+ boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
21
+ },
22
+ section: {
23
+ marginBottom: "20px",
24
+ },
25
+ lastSection: {
26
+ marginBottom: "0",
27
+ },
28
+ header: {
29
+ display: "flex",
30
+ alignItems: "center",
31
+ marginBottom: "12px",
32
+ paddingBottom: "8px",
33
+ borderBottom: "1px solid #3F3F46", // Zinc-700
34
+ },
35
+ title: {
36
+ fontSize: "12px",
37
+ fontWeight: "600",
38
+ textTransform: "uppercase",
39
+ letterSpacing: "0.05em",
40
+ color: "#A1A1AA", // Zinc-400
41
+ margin: 0,
42
+ },
43
+ content: {
44
+ fontSize: "14px",
45
+ lineHeight: "1.6",
46
+ whiteSpace: "pre-wrap",
47
+ },
48
+ executionList: {
49
+ listStyle: "none",
50
+ padding: 0,
51
+ margin: 0,
52
+ display: "flex",
53
+ flexDirection: "column",
54
+ gap: "8px",
55
+ },
56
+ executionStep: {
57
+ display: "flex",
58
+ flexDirection: "column",
59
+ gap: "4px",
60
+ padding: "10px",
61
+ backgroundColor: "#09090B", // Zinc-950
62
+ borderRadius: "6px",
63
+ border: "1px solid #27272A",
64
+ fontSize: "13px",
65
+ },
66
+ stepNumber: {
67
+ fontSize: "11px",
68
+ fontWeight: "600",
69
+ color: "#10B981", // Emerald-500
70
+ textTransform: "uppercase",
71
+ },
72
+ stepSummary: {
73
+ color: "#D4D4D8", // Zinc-300
74
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
75
+ },
76
+ };
77
+
78
+ // Only show Action Plan section when there are actual file actions.
79
+ // For Lite Mode Q&A responses (all steps have 0 files), the plan
80
+ // just duplicates the answer β€” hiding it avoids showing the same text 3x.
81
+ const hasFileActions = plan?.steps?.some(s => s.files?.length > 0);
82
+
83
+ return (
84
+ <div className="chat-message-ai" style={styles.container}>
85
+ {/* Answer section */}
86
+ <section style={styles.section}>
87
+ <header style={styles.header}>
88
+ <h3 style={styles.title}>Answer</h3>
89
+ </header>
90
+ <div style={styles.content}>
91
+ <p style={{ margin: 0 }}>{answer}</p>
92
+ </div>
93
+ </section>
94
+
95
+ {/* Action Plan section β€” only when there are file changes */}
96
+ {plan && hasFileActions && (
97
+ <section style={styles.section}>
98
+ <header style={{ ...styles.header, display: "flex", alignItems: "center", gap: "10px" }}>
99
+ <h3 style={{ ...styles.title, color: "#D95C3D", margin: 0 }}>Action Plan</h3>
100
+ {planStatus === "executed" && (
101
+ <span
102
+ style={{
103
+ display: "inline-flex",
104
+ alignItems: "center",
105
+ gap: "4px",
106
+ fontSize: "11px",
107
+ fontWeight: 600,
108
+ color: "#10B981",
109
+ border: "1px solid rgba(16, 185, 129, 0.35)",
110
+ background: "rgba(16, 185, 129, 0.08)",
111
+ borderRadius: "6px",
112
+ padding: "2px 6px",
113
+ letterSpacing: "0.02em",
114
+ }}
115
+ title="This plan was approved and executed."
116
+ >
117
+ βœ“ Executed
118
+ </span>
119
+ )}
120
+ {planStatus === "rejected" && (
121
+ <span
122
+ style={{
123
+ display: "inline-flex",
124
+ alignItems: "center",
125
+ gap: "4px",
126
+ fontSize: "11px",
127
+ fontWeight: 600,
128
+ color: "#9CA3AF",
129
+ border: "1px solid rgba(156, 163, 175, 0.35)",
130
+ background: "rgba(156, 163, 175, 0.08)",
131
+ borderRadius: "6px",
132
+ padding: "2px 6px",
133
+ letterSpacing: "0.02em",
134
+ }}
135
+ title="This plan was rejected. No files were changed."
136
+ >
137
+ βœ• Rejected
138
+ </span>
139
+ )}
140
+ </header>
141
+ <div>
142
+ <PlanView plan={plan} />
143
+ </div>
144
+ </section>
145
+ )}
146
+
147
+ {/* Execution Log section (shown after execution) */}
148
+ {executionLog && (
149
+ <section style={styles.lastSection}>
150
+ <header style={styles.header}>
151
+ <h3 style={{ ...styles.title, color: "#10B981" }}>Execution Log</h3>
152
+ </header>
153
+ <div>
154
+ <ul style={styles.executionList}>
155
+ {executionLog.steps.map((s) => (
156
+ <li key={s.step_number} style={styles.executionStep}>
157
+ <span style={styles.stepNumber}>Step {s.step_number}</span>
158
+ <span style={styles.stepSummary}>{s.summary}</span>
159
+ </li>
160
+ ))}
161
+ </ul>
162
+ </div>
163
+ </section>
164
+ )}
165
+ </div>
166
+ );
167
+ }
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,940 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/ChatPanel.jsx
2
+ import React, { useEffect, useRef, useState } from "react";
3
+ import AssistantMessage from "./AssistantMessage.jsx";
4
+ import ThinkingIndicator from "./ThinkingIndicator.jsx";
5
+ import ContextMeter from "./ContextMeter.jsx";
6
+ import DiffStats from "./DiffStats.jsx";
7
+ import DiffViewer from "./DiffViewer.jsx";
8
+ import CreatePRButton from "./CreatePRButton.jsx";
9
+ import StreamingMessage from "./StreamingMessage.jsx";
10
+ import { SessionWebSocket } from "../utils/ws.js";
11
+
12
+ // Helper to get headers (inline safety if utility is missing)
13
+ const getHeaders = () => ({
14
+ "Content-Type": "application/json",
15
+ Authorization: `Bearer ${localStorage.getItem("github_token") || ""}`,
16
+ });
17
+
18
+ export default function ChatPanel({
19
+ repo,
20
+ defaultBranch = "main",
21
+ currentBranch, // do NOT default here; parent must pass the real one
22
+ onExecutionComplete,
23
+ sessionChatState,
24
+ onSessionChatStateChange,
25
+ sessionId,
26
+ onEnsureSession,
27
+ canChat = true, // readiness gate: false disables composer and shows blocker
28
+ chatBlocker = null, // { message: string, cta?: string, onCta?: () => void }
29
+ }) {
30
+ // Initialize state from props or defaults
31
+ const [messages, setMessages] = useState(sessionChatState?.messages || []);
32
+ const [goal, setGoal] = useState("");
33
+ const [plan, setPlan] = useState(sessionChatState?.plan || null);
34
+
35
+ const [loadingPlan, setLoadingPlan] = useState(false);
36
+ const [executing, setExecuting] = useState(false);
37
+ const [status, setStatus] = useState("");
38
+
39
+ // Claude-Code-on-Web: WebSocket streaming + diff + PR
40
+ const [wsConnected, setWsConnected] = useState(false);
41
+ const [streamingEvents, setStreamingEvents] = useState([]);
42
+ const [diffData, setDiffData] = useState(null);
43
+ const [showDiffViewer, setShowDiffViewer] = useState(false);
44
+ const wsRef = useRef(null);
45
+
46
+ // Ref mirrors streamingEvents so WS callbacks avoid stale closures
47
+ const streamingEventsRef = useRef([]);
48
+ useEffect(() => { streamingEventsRef.current = streamingEvents; }, [streamingEvents]);
49
+
50
+ // Skip the session-sync useEffect reset when we just created a session
51
+ // (the parent already seeded the messages into chatBySession)
52
+ const skipNextSyncRef = useRef(false);
53
+
54
+ const messagesEndRef = useRef(null);
55
+ const prevMsgCountRef = useRef((sessionChatState?.messages || []).length);
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // WebSocket connection management
59
+ // ---------------------------------------------------------------------------
60
+ useEffect(() => {
61
+ // Clean up previous connection
62
+ if (wsRef.current) {
63
+ wsRef.current.close();
64
+ wsRef.current = null;
65
+ setWsConnected(false);
66
+ }
67
+
68
+ if (!sessionId) return;
69
+
70
+ // Wait for backend to be reachable before opening WebSocket.
71
+ // Without this, the WS connects immediately on session creation
72
+ // and fails repeatedly with "closed before established" when the
73
+ // backend is still starting up (common on WSL cold start).
74
+ let cancelled = false;
75
+ const backendUrl = import.meta.env.VITE_BACKEND_URL || '';
76
+ const pingUrl = backendUrl ? `${backendUrl}/api/ping` : '/api/ping';
77
+ const waitForBackend = async () => {
78
+ for (let i = 0; i < 10 && !cancelled; i++) {
79
+ try {
80
+ const res = await fetch(pingUrl, { method: 'GET', signal: AbortSignal.timeout(2000) });
81
+ if (res.ok) return true;
82
+ } catch { /* retry */ }
83
+ await new Promise(r => setTimeout(r, 1500));
84
+ }
85
+ return false;
86
+ };
87
+
88
+ waitForBackend().then((ok) => {
89
+ if (cancelled || !ok) return;
90
+ connectWs();
91
+ });
92
+
93
+ function connectWs() {
94
+ const ws = new SessionWebSocket(sessionId, {
95
+ onConnect: () => setWsConnected(true),
96
+ onDisconnect: () => setWsConnected(false),
97
+ onMessage: (data) => {
98
+ if (data.type === "agent_message") {
99
+ setStreamingEvents((prev) => [...prev, data]);
100
+ } else if (data.type === "tool_use" || data.type === "tool_result") {
101
+ setStreamingEvents((prev) => [...prev, data]);
102
+ } else if (data.type === "diff_update") {
103
+ setDiffData(data.stats || data);
104
+ } else if (data.type === "session_restored") {
105
+ // Session loaded
106
+ }
107
+ },
108
+ onStatusChange: (newStatus) => {
109
+ if (newStatus === "waiting") {
110
+ // Always clear loading state when agent finishes
111
+ setLoadingPlan(false);
112
+
113
+ // Consolidate streaming events into a chat message (use ref to
114
+ // avoid stale closure β€” streamingEvents state would be stale here).
115
+ //
116
+ // We also commit the FINAL consolidated text to the backend session
117
+ // here. Previously this branch never called persistMessage, so the
118
+ // assistant turn looked correct in the live view but vanished on the
119
+ // next session reload β€” the canonical "streaming truncation" symptom.
120
+ const events = streamingEventsRef.current;
121
+ if (events.length > 0) {
122
+ const textParts = events
123
+ .filter((e) => e.type === "agent_message")
124
+ .map((e) => e.content);
125
+ if (textParts.length > 0) {
126
+ const consolidated = {
127
+ from: "ai",
128
+ role: "assistant",
129
+ answer: textParts.join(""),
130
+ content: textParts.join(""),
131
+ };
132
+ setMessages((prev) => [...prev, consolidated]);
133
+ persistMessage(sessionId, "assistant", consolidated.content);
134
+ }
135
+ setStreamingEvents([]);
136
+ }
137
+ }
138
+ },
139
+ onError: (err) => {
140
+ console.warn("[ws] Error:", err);
141
+ setLoadingPlan(false);
142
+ },
143
+ });
144
+
145
+ ws.connect();
146
+ wsRef.current = ws;
147
+ } // end connectWs
148
+
149
+ return () => {
150
+ cancelled = true;
151
+ if (wsRef.current) wsRef.current.close();
152
+ };
153
+ }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // 1) SESSION SYNC: Restore chat when branch, repo, OR session changes
157
+ // IMPORTANT: Do NOT depend on sessionChatState here (prevents prop/state loop)
158
+ // ---------------------------------------------------------------------------
159
+ useEffect(() => {
160
+ // When send() just created a session, the parent seeded the messages
161
+ // into chatBySession already. Skip the reset so we don't wipe
162
+ // the optimistic user message that was already rendered.
163
+ if (skipNextSyncRef.current) {
164
+ skipNextSyncRef.current = false;
165
+ return;
166
+ }
167
+
168
+ const nextMessages = sessionChatState?.messages || [];
169
+ const nextPlan = sessionChatState?.plan || null;
170
+
171
+ setMessages(nextMessages);
172
+ setPlan(nextPlan);
173
+
174
+ // Reset transient UI state on branch/repo/session switch
175
+ setGoal("");
176
+ setStatus("");
177
+ setLoadingPlan(false);
178
+ setExecuting(false);
179
+ setStreamingEvents([]);
180
+ setDiffData(null);
181
+
182
+ // Update msg count tracker so auto-scroll doesn't "jump" on switch
183
+ prevMsgCountRef.current = nextMessages.length;
184
+ // eslint-disable-next-line react-hooks/exhaustive-deps
185
+ }, [currentBranch, repo?.full_name, sessionId]);
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // 2) PERSISTENCE: Save chat to Parent (no loop now because sync only on branch)
189
+ // ---------------------------------------------------------------------------
190
+ useEffect(() => {
191
+ if (typeof onSessionChatStateChange === "function") {
192
+ // Avoid wiping parent state on mount
193
+ if (messages.length > 0 || plan) {
194
+ onSessionChatStateChange({ messages, plan });
195
+ }
196
+ }
197
+ // eslint-disable-next-line react-hooks/exhaustive-deps
198
+ }, [messages, plan]);
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // 3) AUTO-SCROLL: Only scroll when a message is appended (reduces flicker)
202
+ // ---------------------------------------------------------------------------
203
+ useEffect(() => {
204
+ const curCount = messages.length + streamingEvents.length;
205
+ const prevCount = prevMsgCountRef.current;
206
+
207
+ // Only scroll when new messages are added
208
+ if (curCount > prevCount) {
209
+ prevMsgCountRef.current = curCount;
210
+ requestAnimationFrame(() => {
211
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
212
+ });
213
+ } else {
214
+ prevMsgCountRef.current = curCount;
215
+ }
216
+ }, [messages.length, streamingEvents.length]);
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // HANDLERS
220
+ // ---------------------------------------------------------------------------
221
+ // ---------------------------------------------------------------------------
222
+ // Persist a message to the backend session (fire-and-forget).
223
+ //
224
+ // The fourth argument carries the *structured* payload of the assistant
225
+ // response β€” the Action Plan, the Execution Log, diff stats, etc. The
226
+ // backend stores it on Message.metadata; on session reload App.jsx
227
+ // spreads metadata back into the local message via normalizeBackendMessage,
228
+ // so the same AssistantMessage renderer can re-draw the Plan / Steps /
229
+ // Create buttons identically to the live view.
230
+ //
231
+ // Before this fix the structured payload was dropped at persist time β€”
232
+ // the session reloaded as raw text, and the UI degraded to a plain
233
+ // paragraph. This is the canonical "state loss during hydration" bug.
234
+ // ---------------------------------------------------------------------------
235
+ const persistMessage = (sid, role, content, metadata = null) => {
236
+ if (!sid) return;
237
+ const body = { role, content };
238
+ if (metadata && typeof metadata === "object" && Object.keys(metadata).length > 0) {
239
+ body.metadata = metadata;
240
+ }
241
+ fetch(`/api/sessions/${sid}/message`, {
242
+ method: "POST",
243
+ headers: getHeaders(),
244
+ body: JSON.stringify(body),
245
+ }).catch(() => {}); // best-effort
246
+ };
247
+
248
+ // Pick the structured fields a message can carry across a reload.
249
+ // Keep this in one place so every call-site stores the same shape and
250
+ // the renderer never has to guess.
251
+ const pickAssistantMetadata = (m) => {
252
+ if (!m || typeof m !== "object") return null;
253
+ const meta = {};
254
+ if (m.plan) meta.plan = m.plan;
255
+ if (m.executionLog) meta.executionLog = m.executionLog;
256
+ if (m.diff) meta.diff = m.diff;
257
+ if (m.actions) meta.actions = m.actions;
258
+ return Object.keys(meta).length > 0 ? meta : null;
259
+ };
260
+
261
+ const send = async () => {
262
+ if (!repo || !goal.trim()) return;
263
+
264
+ const text = goal.trim();
265
+
266
+ // Clear input immediately (Claude Code behavior)
267
+ setGoal("");
268
+ // Reset textarea height
269
+ const ta = document.querySelector(".chat-input");
270
+ if (ta) ta.style.height = "40px";
271
+
272
+ // Optimistic update (user bubble appears immediately)
273
+ const userMsg = { from: "user", role: "user", text, content: text };
274
+ setMessages((prev) => [...prev, userMsg]);
275
+
276
+ setLoadingPlan(true);
277
+ setStatus("");
278
+ setPlan(null);
279
+ setStreamingEvents([]);
280
+
281
+ // ------- Implicit session creation (Claude Code parity) -------
282
+ // Every chat must be backed by a session. If none exists yet,
283
+ // create one on-demand before sending the plan request.
284
+ let sid = sessionId;
285
+ if (!sid && typeof onEnsureSession === "function") {
286
+ // Derive a short title from the first message
287
+ const sessionName = text.length > 60 ? text.slice(0, 57) + "..." : text;
288
+
289
+ // Tell the sync useEffect to skip the reset that would otherwise
290
+ // wipe the optimistic user message when activeSessionId changes.
291
+ skipNextSyncRef.current = true;
292
+
293
+ sid = await onEnsureSession(sessionName, [userMsg]);
294
+ if (!sid) {
295
+ // Session creation failed β€” continue without session
296
+ skipNextSyncRef.current = false;
297
+ }
298
+ }
299
+
300
+ // Persist user message to backend session
301
+ persistMessage(sid, "user", text);
302
+
303
+ // Always use HTTP for plan generation (the original reliable flow).
304
+ // WebSocket is only used for real-time streaming feedback display.
305
+ const effectiveBranch = currentBranch || defaultBranch || "HEAD";
306
+
307
+ try {
308
+ // Timeout after 5 minutes (CrewAI agent can be slow with small models)
309
+ const planController = new AbortController();
310
+ const planTimer = setTimeout(() => planController.abort(), 300000);
311
+
312
+ let res;
313
+ try {
314
+ res = await fetch("/api/chat/plan", {
315
+ method: "POST",
316
+ headers: getHeaders(),
317
+ body: JSON.stringify({
318
+ repo_owner: repo.owner,
319
+ repo_name: repo.name,
320
+ goal: text,
321
+ branch_name: effectiveBranch,
322
+ }),
323
+ signal: planController.signal,
324
+ });
325
+ } catch (fetchErr) {
326
+ if (fetchErr.name === "AbortError") {
327
+ throw new Error("Request timed out after 5 minutes. The LLM may be too slow. Try a faster model.");
328
+ }
329
+ throw fetchErr;
330
+ } finally {
331
+ clearTimeout(planTimer);
332
+ }
333
+
334
+ let data;
335
+ try {
336
+ data = await res.json();
337
+ } catch {
338
+ throw new Error(`Server error (${res.status}). The LLM may have returned an invalid response. Try a different model or enable Lite Mode in Settings.`);
339
+ }
340
+ if (!res.ok) {
341
+ const detail = data?.detail || data?.error || data?.message || "";
342
+ // Friendly message for common LLM failures
343
+ if (detail.includes("None or empty") || detail.includes("Invalid response from LLM")) {
344
+ throw new Error(
345
+ "The LLM returned an empty response. This often happens with small models (deepseek, qwen 0.5b). " +
346
+ "Try a larger model (llama3, qwen2.5:7b) or enable Lite Mode in Settings."
347
+ );
348
+ }
349
+ throw new Error(detail || "Failed to generate plan");
350
+ }
351
+
352
+ // Guard: a plan with no executable file actions is not a plan we
353
+ // can approve. This happens when the planner/explorer agents
354
+ // refused (tool-loop hallucination or a real safety refusal) and
355
+ // CrewAI returned a schema-valid but empty payload. Without
356
+ // this guard the Approve & execute / Reject plan buttons would
357
+ // render against a payload that can't actually be executed.
358
+ const planSteps = Array.isArray(data?.steps)
359
+ ? data.steps
360
+ : Array.isArray(data?.plan?.steps)
361
+ ? data.plan.steps
362
+ : [];
363
+ const hasExecutableFiles = planSteps.some(
364
+ (s) =>
365
+ Array.isArray(s?.files) &&
366
+ s.files.some((f) => ["CREATE", "MODIFY", "DELETE"].includes(f?.action)),
367
+ );
368
+
369
+ // Extract summary from nested plan structure or top-level
370
+ const summary =
371
+ data.plan?.summary || data.summary || data.message ||
372
+ "Here is the proposed plan for your request.";
373
+
374
+ if (hasExecutableFiles) {
375
+ setPlan(data);
376
+ const assistantMsg = {
377
+ from: "ai",
378
+ role: "assistant",
379
+ answer: summary,
380
+ content: summary,
381
+ plan: data,
382
+ };
383
+ setMessages((prev) => [...prev, assistantMsg]);
384
+ persistMessage(sid, "assistant", summary, pickAssistantMetadata(assistantMsg));
385
+ } else {
386
+ // No executable steps β€” surface a clear failure to the user
387
+ // instead of half-rendering a plan card and dangling buttons.
388
+ // The most common cause is the explorer/planner agent loop
389
+ // (CrewAI same-input limiter blocks repeat tool calls, the
390
+ // agent panics and "refuses"). Encourage a retry rather than
391
+ // letting the user click Approve on nothing.
392
+ setPlan(null);
393
+ const failureText =
394
+ "I couldn't produce a plan for that request. The agent may have " +
395
+ "got stuck reading the same file twice. Try rephrasing, or " +
396
+ "switch to a stronger model in Settings β†’ Provider.";
397
+ const failureMsg = {
398
+ from: "ai",
399
+ role: "system",
400
+ content: failureText,
401
+ };
402
+ setMessages((prev) => [...prev, failureMsg]);
403
+ persistMessage(sid, "system", failureText);
404
+ setStatus("No executable plan produced.");
405
+ return;
406
+ }
407
+ } catch (err) {
408
+ const msg = String(err?.message || err);
409
+ console.error(err);
410
+ setStatus(msg);
411
+ setMessages((prev) => [
412
+ ...prev,
413
+ { from: "ai", role: "system", content: `Error: ${msg}` },
414
+ ]);
415
+ } finally {
416
+ setLoadingPlan(false);
417
+ }
418
+ };
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // Reject the active plan β€” minimal first cut.
422
+ //
423
+ // Industry rule we follow from the start: never write to disk on a path the
424
+ // user did not approve. Rejecting is the cheapest expression of that β€”
425
+ // discard the proposed plan locally, leave the workspace untouched, record
426
+ // the rejection in chat history so the user sees it after a session reload.
427
+ //
428
+ // No backend endpoint is needed yet because plans are not persisted as
429
+ // first-class objects today; they ride along on the assistant message's
430
+ // metadata. When we later add per-plan state tracking, this handler will
431
+ // also POST /api/chat/plan/{id}/reject β€” leaving that for a follow-up.
432
+ // ---------------------------------------------------------------------------
433
+ const rejectPlan = () => {
434
+ if (!plan || executing) return;
435
+ setPlan(null);
436
+ setStatus("Plan rejected. No files were changed.");
437
+
438
+ const rejectionMsg = {
439
+ from: "ai",
440
+ role: "system",
441
+ content: "Plan rejected. No files were changed.",
442
+ };
443
+ setMessages((prev) => [...prev, rejectionMsg]);
444
+
445
+ if (sessionId) {
446
+ persistMessage(sessionId, "system", rejectionMsg.content);
447
+ }
448
+ };
449
+
450
+ const execute = async () => {
451
+ if (!repo || !plan) return;
452
+
453
+ setExecuting(true);
454
+ setStatus("");
455
+
456
+ try {
457
+ // Guard: currentBranch might be missing if parent didn't pass it yet
458
+ const safeCurrent = currentBranch || defaultBranch || "HEAD";
459
+ const safeDefault = defaultBranch || "main";
460
+
461
+ // Sticky vs Hard Switch:
462
+ // - If on default branch -> undefined (backend creates new branch)
463
+ // - If already on AI branch -> currentBranch (backend updates existing)
464
+ const branch_name = safeCurrent === safeDefault ? undefined : safeCurrent;
465
+
466
+ const res = await fetch("/api/chat/execute", {
467
+ method: "POST",
468
+ headers: getHeaders(),
469
+ body: JSON.stringify({
470
+ repo_owner: repo.owner,
471
+ repo_name: repo.name,
472
+ plan,
473
+ branch_name,
474
+ }),
475
+ });
476
+
477
+ const data = await res.json();
478
+ if (!res.ok) throw new Error(data.detail || "Execution failed");
479
+
480
+ setStatus(data.message || "Execution completed.");
481
+
482
+ const completionMsg = {
483
+ from: "ai",
484
+ role: "assistant",
485
+ answer: data.message || "Execution completed.",
486
+ content: data.message || "Execution completed.",
487
+ executionLog: data.executionLog,
488
+ diff: data.diff,
489
+ };
490
+
491
+ // Show completion immediately (keeps old "Execution Log" section)
492
+ setMessages((prev) => [...prev, completionMsg]);
493
+
494
+ // Persist the execution log + diff alongside the message text so
495
+ // the History view re-renders the green "Execution Log" panel and
496
+ // the "View diff" affordance. Without this, reloading the session
497
+ // shows just the one-line "Execution completed." summary.
498
+ persistMessage(
499
+ sessionId,
500
+ "assistant",
501
+ completionMsg.content,
502
+ pickAssistantMetadata(completionMsg),
503
+ );
504
+
505
+ // Clear active plan UI
506
+ setPlan(null);
507
+
508
+ // Pass completionMsg upward for seeding branch history
509
+ if (typeof onExecutionComplete === "function") {
510
+ onExecutionComplete({
511
+ branch: data.branch || data.branch_name,
512
+ mode: data.mode,
513
+ commit_url: data.commit_url || data.html_url,
514
+ message: data.message,
515
+ completionMsg,
516
+ sourceBranch: safeCurrent,
517
+ });
518
+ }
519
+ } catch (err) {
520
+ console.error(err);
521
+ setStatus(String(err?.message || err));
522
+ } finally {
523
+ setExecuting(false);
524
+ }
525
+ };
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // RENDER
529
+ // ---------------------------------------------------------------------------
530
+ const isOnSessionBranch = currentBranch && currentBranch !== defaultBranch;
531
+
532
+ return (
533
+ <div className="chat-container">
534
+ <style>{`
535
+ .chat-container { display: flex; flex-direction: column; height: 100%; }
536
+
537
+ .chat-messages {
538
+ flex: 1; overflow-y: auto;
539
+ padding: 20px;
540
+ display: flex; flex-direction: column; gap: 16px;
541
+ }
542
+
543
+ .chat-message-user {
544
+ align-self: flex-end;
545
+ background: #27272A;
546
+ color: #fff;
547
+ padding: 12px 16px;
548
+ border-radius: 10px;
549
+ max-width: 85%;
550
+ font-size: 14px;
551
+ line-height: 1.5;
552
+ }
553
+
554
+ /* Success System Message Styling */
555
+ .chat-msg-success {
556
+ align-self: flex-start;
557
+ width: 100%;
558
+ background: rgba(16, 185, 129, 0.10);
559
+ border: 1px solid rgba(16, 185, 129, 0.20);
560
+ color: #D1FAE5;
561
+ padding: 12px 16px;
562
+ border-radius: 10px;
563
+ display: flex;
564
+ gap: 12px;
565
+ font-size: 14px;
566
+ }
567
+ .success-icon { font-size: 18px; }
568
+ .success-link {
569
+ display: inline-block;
570
+ margin-top: 6px;
571
+ font-weight: 600;
572
+ color: #34D399;
573
+ text-decoration: none;
574
+ }
575
+ .success-link:hover { text-decoration: underline; }
576
+
577
+ .chat-input-box {
578
+ padding: 16px;
579
+ border-top: 1px solid #27272A;
580
+ background: #131316;
581
+ }
582
+
583
+ .chat-input-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
584
+
585
+ .chat-input {
586
+ flex: 1;
587
+ min-width: 200px;
588
+ background: #18181B;
589
+ border: 1px solid #27272A;
590
+ color: white;
591
+ padding: 10px 12px;
592
+ border-radius: 8px;
593
+ outline: none;
594
+ font-size: 14px;
595
+ font-family: inherit;
596
+ resize: none;
597
+ min-height: 40px;
598
+ max-height: 160px;
599
+ line-height: 1.4;
600
+ }
601
+
602
+ /* Enterprise controls (restored) */
603
+ .chat-btn {
604
+ height: 38px;
605
+ padding: 0 14px;
606
+ border-radius: 8px;
607
+ font-weight: 700;
608
+ cursor: pointer;
609
+ border: 1px solid transparent;
610
+ font-size: 13px;
611
+ white-space: nowrap;
612
+ }
613
+
614
+ /* Orange primary (old style) */
615
+ .chat-btn.primary { background: #D95C3D; color: #fff; }
616
+ .chat-btn.primary:hover { filter: brightness(0.98); }
617
+ .chat-btn.primary:disabled { opacity: 0.55; cursor: not-allowed; }
618
+
619
+ /* Secondary outline */
620
+ .chat-btn.secondary {
621
+ background: transparent;
622
+ border: 1px solid #3F3F46;
623
+ color: #A1A1AA;
624
+ }
625
+ .chat-btn.secondary:hover { background: rgba(255,255,255,0.04); }
626
+ .chat-btn.secondary:disabled { opacity: 0.55; cursor: not-allowed; }
627
+
628
+ .chat-empty-state {
629
+ text-align: center;
630
+ color: #52525B;
631
+ margin-top: 40px;
632
+ font-size: 14px;
633
+ }
634
+
635
+ /* WebSocket connection indicator */
636
+ .ws-indicator {
637
+ display: inline-flex;
638
+ align-items: center;
639
+ gap: 4px;
640
+ font-size: 10px;
641
+ color: #71717A;
642
+ padding: 2px 6px;
643
+ border-radius: 4px;
644
+ background: rgba(24, 24, 27, 0.6);
645
+ }
646
+ .ws-dot {
647
+ width: 6px;
648
+ height: 6px;
649
+ border-radius: 50%;
650
+ }
651
+
652
+ @keyframes blink {
653
+ 0%, 100% { opacity: 1; }
654
+ 50% { opacity: 0; }
655
+ }
656
+ `}</style>
657
+
658
+ <div className="chat-messages">
659
+ {messages.map((m, idx) => {
660
+ // Success message (App.jsx injected)
661
+ if (m.isSuccess) {
662
+ return (
663
+ <div key={idx} className="chat-msg-success">
664
+ <div className="success-icon">πŸš€</div>
665
+ <div>
666
+ <div style={{ whiteSpace: "pre-wrap" }}>{m.content}</div>
667
+ {m.link && (
668
+ <a href={m.link} target="_blank" rel="noreferrer" className="success-link">
669
+ View Changes on GitHub &rarr;
670
+ </a>
671
+ )}
672
+ </div>
673
+ </div>
674
+ );
675
+ }
676
+
677
+ // User message
678
+ if (m.from === "user" || m.role === "user") {
679
+ return (
680
+ <div key={idx} className="chat-message-user">
681
+ <span>{m.text || m.content}</span>
682
+ </div>
683
+ );
684
+ }
685
+
686
+ // Assistant message (Answer / Plan / Execution Log).
687
+ //
688
+ // Lifecycle audit signal: if this message carries a plan, look
689
+ // ahead in the timeline for any subsequent message that
690
+ // records an execution log (=> the plan was approved+executed)
691
+ // or a system "Plan rejected" entry (=> the plan was
692
+ // rejected). The status is rendered as a small green/grey
693
+ // badge next to the Action Plan header so users can tell at a
694
+ // glance β€” in history β€” whether a previous plan was acted on.
695
+ let planStatus = null;
696
+ if (m.plan) {
697
+ const after = messages.slice(idx + 1);
698
+ if (after.some((later) => later.executionLog)) {
699
+ planStatus = "executed";
700
+ } else if (
701
+ after.some(
702
+ (later) =>
703
+ later.role === "system" &&
704
+ typeof later.content === "string" &&
705
+ later.content.includes("Plan rejected"),
706
+ )
707
+ ) {
708
+ planStatus = "rejected";
709
+ }
710
+ }
711
+
712
+ return (
713
+ <div key={idx}>
714
+ <AssistantMessage
715
+ answer={m.answer || m.content}
716
+ plan={m.plan}
717
+ executionLog={m.executionLog}
718
+ planStatus={planStatus}
719
+ />
720
+ {/* Diff stats indicator (Claude-Code-on-Web parity) */}
721
+ {m.diff && (
722
+ <DiffStats diff={m.diff} onClick={() => {
723
+ setDiffData(m.diff);
724
+ setShowDiffViewer(true);
725
+ }} />
726
+ )}
727
+ </div>
728
+ );
729
+ })}
730
+
731
+ {/* Streaming events (real-time agent output) */}
732
+ {streamingEvents.length > 0 && (
733
+ <div>
734
+ <StreamingMessage events={streamingEvents} />
735
+ </div>
736
+ )}
737
+
738
+ {/* Enterprise Pulse β€” agentic thinking state shown after the user
739
+ hits Send and before the first streamed/planned chunk arrives.
740
+ Falls back gracefully to nothing once streamingEvents start
741
+ flowing in (StreamingMessage takes over the live feedback). */}
742
+ {loadingPlan && streamingEvents.length === 0 && (
743
+ <ThinkingIndicator />
744
+ )}
745
+
746
+ {/* Live execution status β€” visible in the chat timeline while
747
+ ``executing`` is true, sits between the Action Plan card and
748
+ where the Execution Log (green panel in AssistantMessage)
749
+ will land once the backend returns. Removes the "did the
750
+ app freeze?" feeling caused by only the bottom button
751
+ saying "Executing…".
752
+
753
+ Reuses the ThinkingIndicator with execution-specific labels.
754
+ When the executor finishes, ``setExecuting(false)`` removes
755
+ this bubble and the completionMsg lands in the timeline as
756
+ a normal assistant message with its green Execution Log
757
+ block β€” already rendered by AssistantMessage today. */}
758
+ {executing && (
759
+ <ThinkingIndicator
760
+ labels={[
761
+ "Executing plan",
762
+ "Applying changes",
763
+ "Verifying result",
764
+ ]}
765
+ />
766
+ )}
767
+
768
+ {!messages.length && !plan && !loadingPlan && streamingEvents.length === 0 && (
769
+ <div className="chat-empty-state">
770
+ <div className="chat-empty-icon">πŸ’¬</div>
771
+ <p>Tell GitPilot what you want to do with this repository.</p>
772
+ <p style={{ fontSize: 12, color: "#676883", marginTop: 4 }}>
773
+ It will propose a safe step-by-step plan before any execution.
774
+ </p>
775
+ </div>
776
+ )}
777
+
778
+ <div ref={messagesEndRef} />
779
+ </div>
780
+
781
+ {/* Diff stats bar (when agent has made changes) */}
782
+ {diffData && (
783
+ <div style={{
784
+ padding: "8px 16px",
785
+ borderTop: "1px solid #27272A",
786
+ background: "#18181B",
787
+ }}>
788
+ <DiffStats diff={diffData} onClick={() => setShowDiffViewer(true)} />
789
+ </div>
790
+ )}
791
+
792
+ <div className="chat-input-box">
793
+ {/* Readiness blocker banner */}
794
+ {!canChat && chatBlocker && (
795
+ <div style={{
796
+ fontSize: 12,
797
+ color: "#F59E0B",
798
+ background: "rgba(245, 158, 11, 0.08)",
799
+ border: "1px solid rgba(245, 158, 11, 0.2)",
800
+ borderRadius: 6,
801
+ padding: "8px 12px",
802
+ marginBottom: 8,
803
+ display: "flex",
804
+ alignItems: "center",
805
+ justifyContent: "space-between",
806
+ }}>
807
+ <span>{chatBlocker.message || "Chat is not ready yet."}</span>
808
+ {chatBlocker.cta && chatBlocker.onCta && (
809
+ <button
810
+ type="button"
811
+ onClick={chatBlocker.onCta}
812
+ style={{
813
+ fontSize: 11,
814
+ fontWeight: 600,
815
+ color: "#F59E0B",
816
+ background: "transparent",
817
+ border: "1px solid rgba(245, 158, 11, 0.3)",
818
+ borderRadius: 4,
819
+ padding: "2px 8px",
820
+ cursor: "pointer",
821
+ }}
822
+ >
823
+ {chatBlocker.cta}
824
+ </button>
825
+ )}
826
+ </div>
827
+ )}
828
+ {status && (
829
+ <div style={{ fontSize: 11, color: "#ffb3b7", marginBottom: 8 }}>
830
+ {status}
831
+ </div>
832
+ )}
833
+
834
+ <div className="chat-input-row">
835
+ <textarea
836
+ className="chat-input"
837
+ placeholder={wsConnected ? "Send feedback or instructions..." : "Describe the change you want to make..."}
838
+ value={goal}
839
+ rows={1}
840
+ onChange={(e) => {
841
+ setGoal(e.target.value);
842
+ e.target.style.height = "40px";
843
+ e.target.style.height = Math.min(e.target.scrollHeight, 160) + "px";
844
+ }}
845
+ onKeyDown={(e) => {
846
+ if (e.key === "Enter" && !e.shiftKey) {
847
+ e.preventDefault();
848
+ if (!loadingPlan && !executing) send();
849
+ }
850
+ }}
851
+ disabled={!canChat || loadingPlan || executing}
852
+ />
853
+
854
+ {/* Always show both buttons (old UX) */}
855
+ <button
856
+ className="chat-btn primary"
857
+ type="button"
858
+ onClick={send}
859
+ disabled={!canChat || loadingPlan || executing || !goal.trim()}
860
+ >
861
+ {loadingPlan ? "Planning..." : wsConnected ? "Send" : "Generate plan"}
862
+ </button>
863
+
864
+ {/* Approve & execute β€” visible only while a plan is awaiting
865
+ approval, or while an execution is already in flight (so
866
+ the user sees the "Executing…" label, not a missing
867
+ button). Previously this was always rendered with
868
+ ``disabled={!plan}``, which meant after a successful
869
+ execute() the button stayed on screen as a dimmed ghost
870
+ and a second click could trigger a duplicate run β€”
871
+ causing the executor to re-write the same file with the
872
+ same content (~50 s of wasted LLM time per accidental
873
+ click). Hiding the button entirely once ``plan`` is
874
+ null makes the bug impossible. */}
875
+ {(plan || executing) && (
876
+ <button
877
+ className="chat-btn secondary"
878
+ type="button"
879
+ onClick={execute}
880
+ disabled={executing || loadingPlan}
881
+ >
882
+ {executing ? "Executing..." : "Approve & execute"}
883
+ </button>
884
+ )}
885
+
886
+ {/* Reject plan β€” same visibility window as Approve. */}
887
+ {plan && !executing && !loadingPlan && (
888
+ <button
889
+ className="chat-btn ghost"
890
+ type="button"
891
+ onClick={rejectPlan}
892
+ title="Discard this plan. No files will be changed."
893
+ style={{
894
+ color: "#F87171",
895
+ borderColor: "rgba(248, 113, 113, 0.35)",
896
+ background: "transparent",
897
+ }}
898
+ >
899
+ Reject plan
900
+ </button>
901
+ )}
902
+
903
+ {/* Create PR button (Claude-Code-on-Web parity) */}
904
+ {isOnSessionBranch && (
905
+ <CreatePRButton
906
+ repo={repo}
907
+ sessionId={sessionId}
908
+ branch={currentBranch}
909
+ defaultBranch={defaultBranch}
910
+ disabled={executing || loadingPlan}
911
+ />
912
+ )}
913
+ </div>
914
+
915
+ {/* WebSocket connection indicator + context-window meter */}
916
+ <div style={{ marginTop: 6, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
917
+ <span>
918
+ {sessionId && (
919
+ <span className="ws-indicator">
920
+ <span className="ws-dot" style={{
921
+ backgroundColor: wsConnected ? "#10B981" : "#EF4444",
922
+ }} />
923
+ {wsConnected ? "Live" : "Connecting..."}
924
+ </span>
925
+ )}
926
+ </span>
927
+ <ContextMeter sessionId={sessionId} />
928
+ </div>
929
+ </div>
930
+
931
+ {/* Diff Viewer overlay */}
932
+ {showDiffViewer && (
933
+ <DiffViewer
934
+ diff={diffData}
935
+ onClose={() => setShowDiffViewer(false)}
936
+ />
937
+ )}
938
+ </div>
939
+ );
940
+ }
frontend/components/ContextBar.jsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useRef, useState } from "react";
2
+ import BranchPicker from "./BranchPicker.jsx";
3
+
4
+ /**
5
+ * ContextBar β€” horizontal repo chip bar for multi-repo workspace context.
6
+ *
7
+ * Uses CSS classes for hover-reveal X (Claude-style: subtle by default,
8
+ * visible on chip hover, red on X hover). Each chip owns its own remove
9
+ * button β€” removing one repo never affects the others.
10
+ */
11
+ export default function ContextBar({
12
+ contextRepos,
13
+ activeRepoKey,
14
+ repoStateByKey,
15
+ onActivate,
16
+ onRemove,
17
+ onAdd,
18
+ onBranchChange,
19
+ mode, // workspace mode: "github", "local-git", "folder" (optional)
20
+ }) {
21
+ if (!contextRepos || contextRepos.length === 0) return null;
22
+
23
+ return (
24
+ <div className="ctxbar">
25
+ {/* Workspace mode indicator */}
26
+ {mode && (
27
+ <span className="ctxbar-mode" title={`Workspace mode: ${mode}`}>
28
+ {mode === "github" ? "GH" : mode === "local-git" ? "Git" : "Dir"}
29
+ </span>
30
+ )}
31
+ <div className="ctxbar-scroll">
32
+ {contextRepos.map((entry) => {
33
+ const isActive = entry.repoKey === activeRepoKey;
34
+ return (
35
+ <RepoChip
36
+ key={entry.repoKey}
37
+ entry={entry}
38
+ isActive={isActive}
39
+ repoState={repoStateByKey?.[entry.repoKey]}
40
+ onActivate={() => onActivate(entry.repoKey)}
41
+ onRemove={() => onRemove(entry.repoKey)}
42
+ onBranchChange={(newBranch) =>
43
+ onBranchChange(entry.repoKey, newBranch)
44
+ }
45
+ />
46
+ );
47
+ })}
48
+
49
+ <button
50
+ type="button"
51
+ className="ctxbar-add"
52
+ onClick={onAdd}
53
+ title="Add repository to context"
54
+ >
55
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
56
+ <line x1="12" y1="5" x2="12" y2="19" />
57
+ <line x1="5" y1="12" x2="19" y2="12" />
58
+ </svg>
59
+ </button>
60
+ </div>
61
+
62
+ <div className="ctxbar-meta">
63
+ {contextRepos.length} {contextRepos.length === 1 ? "repo" : "repos"}
64
+ </div>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ function RepoChip({ entry, isActive, repoState, onActivate, onRemove, onBranchChange }) {
70
+ const [branchOpen, setBranchOpen] = useState(false);
71
+ const [hovered, setHovered] = useState(false);
72
+ const branchBtnRef = useRef(null);
73
+ const repo = entry.repo;
74
+ const branch = repoState?.currentBranch || entry.branch || repo?.default_branch || "main";
75
+ const defaultBranch = repoState?.defaultBranch || repo?.default_branch || "main";
76
+ const sessionBranches = repoState?.sessionBranches || [];
77
+ const displayName = repo?.name || entry.repoKey?.split("/")[1] || entry.repoKey;
78
+
79
+ const handleChipClick = useCallback(
80
+ (e) => {
81
+ if (e.target.closest("[data-chip-action]")) return;
82
+ onActivate();
83
+ },
84
+ [onActivate]
85
+ );
86
+
87
+ return (
88
+ <div
89
+ className={"ctxbar-chip" + (isActive ? " ctxbar-chip-active" : "")}
90
+ onClick={handleChipClick}
91
+ onMouseEnter={() => setHovered(true)}
92
+ onMouseLeave={() => setHovered(false)}
93
+ title={isActive ? `Active (write): ${entry.repoKey}` : `Click to activate ${entry.repoKey}`}
94
+ >
95
+ {/* Active indicator bar */}
96
+ {isActive && <div className="ctxbar-chip-indicator" />}
97
+
98
+ {/* Repo name */}
99
+ <span className="ctxbar-chip-name">{displayName}</span>
100
+
101
+ {/* Separator dot */}
102
+ <span className="ctxbar-chip-dot" />
103
+
104
+ {/* Branch name β€” single click opens GitHub branch list */}
105
+ <button
106
+ ref={branchBtnRef}
107
+ type="button"
108
+ data-chip-action="branch"
109
+ className={"ctxbar-chip-branch" + (isActive ? " ctxbar-chip-branch-active" : "")}
110
+ onClick={(e) => {
111
+ e.stopPropagation();
112
+ setBranchOpen((v) => !v);
113
+ }}
114
+ >
115
+ {branch}
116
+ </button>
117
+
118
+ {/* Write badge for active repo */}
119
+ {isActive && <span className="ctxbar-chip-write">write</span>}
120
+
121
+ {/* Remove button: hidden by default, revealed on hover */}
122
+ <button
123
+ type="button"
124
+ data-chip-action="remove"
125
+ className={"ctxbar-chip-remove" + (hovered ? " ctxbar-chip-remove-visible" : "")}
126
+ onClick={(e) => {
127
+ e.stopPropagation();
128
+ onRemove();
129
+ }}
130
+ title={`Remove ${displayName} from context`}
131
+ >
132
+ <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
133
+ <line x1="18" y1="6" x2="6" y2="18" />
134
+ <line x1="6" y1="6" x2="18" y2="18" />
135
+ </svg>
136
+ </button>
137
+
138
+ {/* BranchPicker in external-anchor mode: dropdown opens immediately,
139
+ positioned from the branch button, fetches all branches from GitHub */}
140
+ {branchOpen && (
141
+ <BranchPicker
142
+ repo={repo}
143
+ currentBranch={branch}
144
+ defaultBranch={defaultBranch}
145
+ sessionBranches={sessionBranches}
146
+ externalAnchorRef={branchBtnRef}
147
+ onBranchChange={(newBranch) => {
148
+ onBranchChange(newBranch);
149
+ setBranchOpen(false);
150
+ }}
151
+ onClose={() => setBranchOpen(false)}
152
+ />
153
+ )}
154
+ </div>
155
+ );
156
+ }
frontend/components/ContextMeter.jsx ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/ContextMeter.jsx
2
+ //
3
+ // Small bottom-right control that shows the active LLM's context-window
4
+ // utilisation. Collapsed: a single β“˜ icon (no number β€” keeps the UI
5
+ // quiet during normal use). Expanded: a compact popover with the
6
+ // breakdown, topology line, and a manual refresh button.
7
+ //
8
+ // Refresh model: lazy β€” fetched only when the popover opens, plus the
9
+ // explicit ↻ button. Zero idle traffic.
10
+ //
11
+ // Token-count estimate flag: when the backend reports is_estimate=true
12
+ // (Ollama / OllaBridge β€” no real tokenizer available) every number is
13
+ // prefixed with β‰ˆ so the imprecision is visible.
14
+ //
15
+ // Colours: GitPilot orange #D95C3D for β‰₯60% (warning), red #B91C1C for
16
+ // β‰₯85% (saturated). No new dependencies; inline styles + a scoped
17
+ // <style> block for animations / focus rings.
18
+
19
+ import React, { useEffect, useRef, useState } from "react";
20
+
21
+ const GITPILOT_ORANGE = "#D95C3D";
22
+ const SATURATED_RED = "#B91C1C";
23
+ const DIM = "#9aa0b4";
24
+ const SLATE = "#6b7280";
25
+
26
+ const fmt = (n) => {
27
+ if (n == null) return "β€”";
28
+ return new Intl.NumberFormat("en-US").format(n);
29
+ };
30
+
31
+ const pct = (used, total) => {
32
+ if (!total) return 0;
33
+ return Math.max(0, Math.min(100, (100 * used) / total));
34
+ };
35
+
36
+ const colourFor = (percent) => {
37
+ if (percent >= 85) return SATURATED_RED;
38
+ if (percent >= 60) return GITPILOT_ORANGE;
39
+ return SLATE;
40
+ };
41
+
42
+ function Bar({ percent, colour }) {
43
+ // 16-segment monochrome bar, matching the ASCII design.
44
+ const filled = Math.round((percent / 100) * 16);
45
+ const segs = [];
46
+ for (let i = 0; i < 16; i++) {
47
+ segs.push(
48
+ <span
49
+ key={i}
50
+ aria-hidden="true"
51
+ style={{
52
+ display: "inline-block",
53
+ width: 6,
54
+ height: 8,
55
+ marginRight: 1,
56
+ background: i < filled ? colour : "rgba(255,255,255,0.08)",
57
+ borderRadius: 1,
58
+ }}
59
+ />,
60
+ );
61
+ }
62
+ return (
63
+ <span style={{ display: "inline-flex", alignItems: "center", lineHeight: 1 }}>
64
+ {segs}
65
+ </span>
66
+ );
67
+ }
68
+
69
+ function Row({ label, tokens, total, estimate, accent }) {
70
+ const p = pct(tokens, total);
71
+ const prefix = estimate ? "β‰ˆ " : "";
72
+ return (
73
+ <div
74
+ style={{
75
+ display: "grid",
76
+ gridTemplateColumns: "1fr auto auto",
77
+ gap: 12,
78
+ padding: "4px 0",
79
+ fontSize: 12,
80
+ color: accent ? "#e5e7eb" : DIM,
81
+ fontVariantNumeric: "tabular-nums",
82
+ }}
83
+ >
84
+ <span>{label}</span>
85
+ <span style={{ color: accent ? "#e5e7eb" : "#cbd1e3" }}>
86
+ {prefix}
87
+ {fmt(tokens)}
88
+ </span>
89
+ <span style={{ width: 48, textAlign: "right" }}>{p.toFixed(1)}%</span>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ export default function ContextMeter({ sessionId = null }) {
95
+ const [open, setOpen] = useState(false);
96
+ const [data, setData] = useState(null);
97
+ const [loading, setLoading] = useState(false);
98
+ const [error, setError] = useState(null);
99
+ const popoverRef = useRef(null);
100
+ const triggerRef = useRef(null);
101
+
102
+ const fetchUsage = async () => {
103
+ setLoading(true);
104
+ setError(null);
105
+ try {
106
+ const qs = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : "";
107
+ const r = await fetch(`/api/context/usage${qs}`);
108
+ if (!r.ok) {
109
+ // 404 means the feature flag is off β€” render nothing in that case.
110
+ if (r.status === 404) {
111
+ setError("disabled");
112
+ setData(null);
113
+ } else {
114
+ setError(`http ${r.status}`);
115
+ }
116
+ } else {
117
+ setData(await r.json());
118
+ }
119
+ } catch (e) {
120
+ setError(String(e?.message || e));
121
+ } finally {
122
+ setLoading(false);
123
+ }
124
+ };
125
+
126
+ // Refetch every time the popover opens so the user sees the *current*
127
+ // numbers after each plan/execute cycle β€” not a frozen snapshot from
128
+ // first open. The endpoint is cheap (single-digit-ms after the first
129
+ // provider probe), so re-fetch-on-open is the honest default.
130
+ useEffect(() => {
131
+ if (open) {
132
+ fetchUsage();
133
+ }
134
+ }, [open, sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
135
+
136
+ // Invalidate the displayed snapshot when the active session changes
137
+ // so we don't briefly show another session's numbers.
138
+ useEffect(() => {
139
+ setData(null);
140
+ }, [sessionId]);
141
+
142
+ // Click-outside + Esc to close.
143
+ useEffect(() => {
144
+ if (!open) return;
145
+ const onDocClick = (e) => {
146
+ if (
147
+ popoverRef.current &&
148
+ !popoverRef.current.contains(e.target) &&
149
+ triggerRef.current &&
150
+ !triggerRef.current.contains(e.target)
151
+ ) {
152
+ setOpen(false);
153
+ }
154
+ };
155
+ const onKey = (e) => {
156
+ if (e.key === "Escape") setOpen(false);
157
+ };
158
+ document.addEventListener("mousedown", onDocClick);
159
+ document.addEventListener("keydown", onKey);
160
+ return () => {
161
+ document.removeEventListener("mousedown", onDocClick);
162
+ document.removeEventListener("keydown", onKey);
163
+ };
164
+ }, [open]);
165
+
166
+ // Feature flag off β€” render nothing.
167
+ if (error === "disabled") return null;
168
+
169
+ const percent = data ? data.percent_used : 0;
170
+ const bar = colourFor(percent);
171
+ const estimate = data?.is_estimate;
172
+ const prefix = estimate ? "β‰ˆ " : "";
173
+
174
+ return (
175
+ <span
176
+ className="gitpilot-ctx-meter"
177
+ style={{ position: "relative", display: "inline-flex" }}
178
+ >
179
+ <style>{`
180
+ .gitpilot-ctx-meter .ctx-trigger {
181
+ background: transparent;
182
+ border: 1px solid rgba(255,255,255,0.12);
183
+ color: ${DIM};
184
+ width: 22px;
185
+ height: 22px;
186
+ border-radius: 11px;
187
+ display: inline-flex;
188
+ align-items: center;
189
+ justify-content: center;
190
+ font-size: 12px;
191
+ line-height: 1;
192
+ cursor: pointer;
193
+ padding: 0;
194
+ transition: color 120ms ease, border-color 120ms ease;
195
+ }
196
+ .gitpilot-ctx-meter .ctx-trigger:hover,
197
+ .gitpilot-ctx-meter .ctx-trigger:focus-visible {
198
+ color: #e5e7eb;
199
+ border-color: rgba(255,255,255,0.28);
200
+ outline: none;
201
+ }
202
+ .gitpilot-ctx-meter .ctx-trigger[data-warn="1"] { color: ${GITPILOT_ORANGE}; border-color: ${GITPILOT_ORANGE}55; }
203
+ .gitpilot-ctx-meter .ctx-trigger[data-sat="1"] { color: ${SATURATED_RED}; border-color: ${SATURATED_RED}55; }
204
+ .gitpilot-ctx-meter .ctx-popover {
205
+ position: absolute;
206
+ right: 0;
207
+ bottom: calc(100% + 8px);
208
+ width: 360px;
209
+ background: #1a1c25;
210
+ border: 1px solid rgba(255,255,255,0.10);
211
+ border-radius: 8px;
212
+ box-shadow: 0 8px 24px rgba(0,0,0,0.45);
213
+ padding: 14px 16px;
214
+ z-index: 50;
215
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
216
+ }
217
+ .gitpilot-ctx-meter .ctx-popover h4 {
218
+ margin: 0 0 10px 0;
219
+ font-size: 12px;
220
+ font-weight: 600;
221
+ letter-spacing: 0.04em;
222
+ text-transform: uppercase;
223
+ color: ${DIM};
224
+ }
225
+ .gitpilot-ctx-meter .ctx-meta {
226
+ display: grid;
227
+ grid-template-columns: 84px 1fr;
228
+ gap: 2px 12px;
229
+ font-size: 12px;
230
+ color: #cbd1e3;
231
+ margin-bottom: 12px;
232
+ font-variant-numeric: tabular-nums;
233
+ }
234
+ .gitpilot-ctx-meter .ctx-meta .k { color: ${DIM}; }
235
+ .gitpilot-ctx-meter .ctx-divider {
236
+ height: 1px;
237
+ background: rgba(255,255,255,0.08);
238
+ margin: 6px 0;
239
+ }
240
+ .gitpilot-ctx-meter .ctx-footer {
241
+ display: flex;
242
+ justify-content: space-between;
243
+ align-items: center;
244
+ margin-top: 10px;
245
+ font-size: 11px;
246
+ color: ${DIM};
247
+ }
248
+ .gitpilot-ctx-meter .ctx-refresh {
249
+ background: transparent;
250
+ border: 1px solid rgba(255,255,255,0.14);
251
+ color: #cbd1e3;
252
+ font-size: 11px;
253
+ padding: 2px 8px;
254
+ border-radius: 4px;
255
+ cursor: pointer;
256
+ }
257
+ .gitpilot-ctx-meter .ctx-refresh:hover { color: #fff; border-color: rgba(255,255,255,0.3); }
258
+ .gitpilot-ctx-meter .ctx-refresh:disabled { opacity: 0.5; cursor: default; }
259
+ .gitpilot-ctx-meter .ctx-warn {
260
+ margin-top: 10px;
261
+ padding: 8px 10px;
262
+ border: 1px solid ${GITPILOT_ORANGE}55;
263
+ background: ${GITPILOT_ORANGE}14;
264
+ color: ${GITPILOT_ORANGE};
265
+ border-radius: 4px;
266
+ font-size: 11px;
267
+ line-height: 1.5;
268
+ }
269
+ .gitpilot-ctx-meter .ctx-warn[data-sat="1"] {
270
+ border-color: ${SATURATED_RED}66;
271
+ background: ${SATURATED_RED}14;
272
+ color: ${SATURATED_RED};
273
+ }
274
+ .gitpilot-ctx-meter .ctx-warn ul { margin: 4px 0 0 18px; padding: 0; }
275
+ `}</style>
276
+
277
+ <button
278
+ ref={triggerRef}
279
+ type="button"
280
+ className="ctx-trigger"
281
+ aria-label="Context window usage"
282
+ aria-haspopup="dialog"
283
+ aria-expanded={open}
284
+ data-warn={data && percent >= 60 && percent < 85 ? "1" : "0"}
285
+ data-sat={data && percent >= 85 ? "1" : "0"}
286
+ onClick={() => setOpen((v) => !v)}
287
+ title="Context window usage"
288
+ >
289
+ {"β“˜"}
290
+ </button>
291
+
292
+ {open && (
293
+ <div
294
+ ref={popoverRef}
295
+ className="ctx-popover"
296
+ role="dialog"
297
+ aria-label="Context window usage details"
298
+ >
299
+ <h4>Context window</h4>
300
+
301
+ {loading && !data && (
302
+ <div style={{ color: DIM, fontSize: 12 }}>Loading…</div>
303
+ )}
304
+ {error && error !== "disabled" && (
305
+ <div style={{ color: "#ffb3b7", fontSize: 12 }}>
306
+ Couldn't load: {error}
307
+ </div>
308
+ )}
309
+
310
+ {data && (
311
+ <>
312
+ <div className="ctx-meta">
313
+ <span className="k">Provider</span>
314
+ <span>{data.provider}</span>
315
+ <span className="k">Model</span>
316
+ <span>{data.model || "β€”"}</span>
317
+ <span className="k">Topology</span>
318
+ <span>{data.topology}</span>
319
+ </div>
320
+
321
+ <div
322
+ style={{
323
+ display: "flex",
324
+ justifyContent: "space-between",
325
+ alignItems: "center",
326
+ fontSize: 12,
327
+ color: "#cbd1e3",
328
+ fontVariantNumeric: "tabular-nums",
329
+ marginBottom: 8,
330
+ }}
331
+ >
332
+ <Bar percent={percent} colour={bar} />
333
+ <span>
334
+ {prefix}
335
+ {fmt(data.used)} / {fmt(data.context_window)}{" "}
336
+ <span style={{ color: bar }}>({percent.toFixed(1)}%)</span>
337
+ </span>
338
+ </div>
339
+
340
+ <Row
341
+ label="Conversation messages"
342
+ tokens={data.breakdown?.messages || 0}
343
+ total={data.context_window}
344
+ estimate={estimate}
345
+ />
346
+ <Row
347
+ label="Planner system prompt"
348
+ tokens={data.breakdown?.system_prompt || 0}
349
+ total={data.context_window}
350
+ estimate={estimate}
351
+ />
352
+ <Row
353
+ label="Repo context summary"
354
+ tokens={data.breakdown?.repo_context || 0}
355
+ total={data.context_window}
356
+ estimate={estimate}
357
+ />
358
+ <Row
359
+ label={`Tool schemas (${data.tool_count || 0})`}
360
+ tokens={data.breakdown?.tool_schemas || 0}
361
+ total={data.context_window}
362
+ estimate={estimate}
363
+ />
364
+ <Row
365
+ label="Reserved for response"
366
+ tokens={data.reserved_response}
367
+ total={data.context_window}
368
+ estimate={false}
369
+ />
370
+
371
+ <div className="ctx-divider" />
372
+
373
+ <Row
374
+ label="Free space"
375
+ tokens={data.free}
376
+ total={data.context_window}
377
+ estimate={estimate}
378
+ accent
379
+ />
380
+
381
+ {percent >= 85 && (
382
+ <div className="ctx-warn" data-sat={percent >= 95 ? "1" : "0"}>
383
+ Context near saturation. Consider:
384
+ <ul>
385
+ <li>Resetting the conversation</li>
386
+ <li>Switching to a larger-context model</li>
387
+ <li>Reducing repository scope</li>
388
+ </ul>
389
+ </div>
390
+ )}
391
+
392
+ <div className="ctx-footer">
393
+ <span>{estimate ? "Token counts are estimated" : "Token counts via tiktoken"}</span>
394
+ <button
395
+ type="button"
396
+ className="ctx-refresh"
397
+ onClick={fetchUsage}
398
+ disabled={loading}
399
+ aria-label="Refresh context usage"
400
+ >
401
+ {loading ? "…" : "↻ refresh"}
402
+ </button>
403
+ </div>
404
+ </>
405
+ )}
406
+ </div>
407
+ )}
408
+ </span>
409
+ );
410
+ }
frontend/components/CreatePRButton.jsx ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from "react";
2
+
3
+ /**
4
+ * CreatePRButton β€” Claude-Code-on-Web parity PR creation action.
5
+ *
6
+ * When clicked, pushes session changes to a new branch and opens a PR.
7
+ * Shows loading state and links to the created PR on GitHub.
8
+ */
9
+ export default function CreatePRButton({
10
+ repo,
11
+ sessionId,
12
+ branch,
13
+ defaultBranch,
14
+ disabled,
15
+ onPRCreated,
16
+ }) {
17
+ const [creating, setCreating] = useState(false);
18
+ const [prUrl, setPrUrl] = useState(null);
19
+ const [error, setError] = useState(null);
20
+
21
+ const handleCreate = async () => {
22
+ if (!repo || !branch || branch === defaultBranch) return;
23
+
24
+ setCreating(true);
25
+ setError(null);
26
+
27
+ try {
28
+ const token = localStorage.getItem("github_token");
29
+ const headers = {
30
+ "Content-Type": "application/json",
31
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
32
+ };
33
+
34
+ const owner = repo.full_name?.split("/")[0] || repo.owner;
35
+ const name = repo.full_name?.split("/")[1] || repo.name;
36
+
37
+ const res = await fetch(`/api/repos/${owner}/${name}/pulls`, {
38
+ method: "POST",
39
+ headers,
40
+ body: JSON.stringify({
41
+ title: `[GitPilot] Changes from session ${sessionId ? sessionId.slice(0, 8) : branch}`,
42
+ head: branch,
43
+ base: defaultBranch || "main",
44
+ body: [
45
+ "## Summary",
46
+ "",
47
+ `Changes created by GitPilot AI assistant on branch \`${branch}\`.`,
48
+ "",
49
+ sessionId ? `Session ID: \`${sessionId}\`` : "",
50
+ "",
51
+ "---",
52
+ "*This PR was generated by [GitPilot](https://github.com/ruslanmv/gitpilot).*",
53
+ ]
54
+ .filter(Boolean)
55
+ .join("\n"),
56
+ }),
57
+ });
58
+
59
+ const data = await res.json();
60
+ if (!res.ok) throw new Error(data.detail || "Failed to create PR");
61
+
62
+ const url = data.html_url || data.url;
63
+ setPrUrl(url);
64
+ onPRCreated?.({ pr_url: url, pr_number: data.number, branch });
65
+ } catch (err) {
66
+ setError(err.message);
67
+ } finally {
68
+ setCreating(false);
69
+ }
70
+ };
71
+
72
+ if (prUrl) {
73
+ return (
74
+ <a
75
+ href={prUrl}
76
+ target="_blank"
77
+ rel="noreferrer"
78
+ style={styles.prLink}
79
+ >
80
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
81
+ <circle cx="18" cy="18" r="3" />
82
+ <circle cx="6" cy="6" r="3" />
83
+ <path d="M13 6h3a2 2 0 0 1 2 2v7" />
84
+ <line x1="6" y1="9" x2="6" y2="21" />
85
+ </svg>
86
+ View PR on GitHub &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,623 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useMemo, useState } from "react";
2
+ import { testProvider } from "../utils/api";
3
+
4
+ const PROVIDERS = ["ollabridge", "openai", "claude", "watsonx", "ollama"];
5
+
6
+ const PROVIDER_LABELS = {
7
+ ollabridge: "OllaBridge Cloud",
8
+ openai: "OpenAI",
9
+ claude: "Claude",
10
+ watsonx: "Watsonx",
11
+ ollama: "Ollama",
12
+ };
13
+
14
+ const AUTH_MODES = [
15
+ { id: "device", label: "Device Pairing", icon: "πŸ“±" },
16
+ { id: "apikey", label: "API Key", icon: "πŸ”‘" },
17
+ { id: "local", label: "Local Trust", icon: "🏠" },
18
+ ];
19
+
20
+ function LoadingState({ loadingMessage, loadingSlow, onRetry }) {
21
+ return (
22
+ <div className="settings-loading-shell">
23
+ <div className="settings-loading-card">
24
+ <div className="settings-loading-spinner" aria-hidden="true" />
25
+ <h1>AI Providers</h1>
26
+ <div className="settings-loading-subtitle">Admin / LLM Settings</div>
27
+ <p className="settings-loading-text">{loadingMessage}</p>
28
+
29
+ {loadingSlow && (
30
+ <div className="settings-loading-slow">
31
+ <p>
32
+ This is taking longer than expected. The backend may still be
33
+ starting or the settings endpoint may be slow.
34
+ </p>
35
+ <button
36
+ type="button"
37
+ className="settings-secondary-btn"
38
+ onClick={onRetry}
39
+ >
40
+ Retry
41
+ </button>
42
+ </div>
43
+ )}
44
+ </div>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ export default function LlmSettings() {
50
+ const [settings, setSettings] = useState(null);
51
+ const [initialLoading, setInitialLoading] = useState(true);
52
+ const [loadingSlow, setLoadingSlow] = useState(false);
53
+
54
+ const [saving, setSaving] = useState(false);
55
+ const [error, setError] = useState("");
56
+ const [savedMsg, setSavedMsg] = useState("");
57
+
58
+ const [modelsByProvider, setModelsByProvider] = useState({});
59
+ const [modelsError, setModelsError] = useState("");
60
+ const [loadingModelsFor, setLoadingModelsFor] = useState("");
61
+
62
+ const [testResult, setTestResult] = useState(null);
63
+ const [testing, setTesting] = useState(false);
64
+
65
+ const [authMode, setAuthMode] = useState("local");
66
+ const [pairCode, setPairCode] = useState("");
67
+ const [pairing, setPairing] = useState(false);
68
+ const [pairResult, setPairResult] = useState(null);
69
+
70
+ const loadingMessage = useMemo(() => {
71
+ if (loadingSlow) {
72
+ return "Still loading provider configuration…";
73
+ }
74
+ return "Loading current configuration…";
75
+ }, [loadingSlow]);
76
+
77
+ const loadSettings = async () => {
78
+ setInitialLoading(true);
79
+ setError("");
80
+ setLoadingSlow(false);
81
+
82
+ let slowTimer;
83
+ try {
84
+ slowTimer = window.setTimeout(() => {
85
+ setLoadingSlow(true);
86
+ }, 1500);
87
+
88
+ const res = await fetch("/api/settings");
89
+ const data = await res.json();
90
+
91
+ if (!res.ok) {
92
+ throw new Error(data.error || "Failed to load settings");
93
+ }
94
+
95
+ setSettings(data);
96
+ } catch (e) {
97
+ console.error(e);
98
+ setError(e.message || "Failed to load settings");
99
+ } finally {
100
+ window.clearTimeout(slowTimer);
101
+ setInitialLoading(false);
102
+ }
103
+ };
104
+
105
+ useEffect(() => {
106
+ loadSettings();
107
+ }, []);
108
+
109
+ const updateField = (section, field, value) => {
110
+ setSettings((prev) => ({
111
+ ...prev,
112
+ [section]: {
113
+ ...prev[section],
114
+ [field]: value,
115
+ },
116
+ }));
117
+ };
118
+
119
+ const handleSave = async () => {
120
+ setSaving(true);
121
+ setError("");
122
+ setSavedMsg("");
123
+
124
+ try {
125
+ const res = await fetch("/api/settings/llm", {
126
+ method: "PUT",
127
+ headers: { "Content-Type": "application/json" },
128
+ body: JSON.stringify(settings),
129
+ });
130
+
131
+ const data = await res.json();
132
+ if (!res.ok) throw new Error(data.error || "Failed to save settings");
133
+
134
+ setSettings(data);
135
+ setSavedMsg("Settings saved successfully!");
136
+ setTimeout(() => setSavedMsg(""), 3000);
137
+ } catch (e) {
138
+ console.error(e);
139
+ setError(e.message || "Failed to save settings");
140
+ } finally {
141
+ setSaving(false);
142
+ }
143
+ };
144
+
145
+ const loadModelsForProvider = async (provider) => {
146
+ setModelsError("");
147
+ setLoadingModelsFor(provider);
148
+
149
+ try {
150
+ const res = await fetch(`/api/settings/models?provider=${provider}`);
151
+ const data = await res.json();
152
+
153
+ if (!res.ok || data.error) {
154
+ throw new Error(data.error || "Failed to load models");
155
+ }
156
+
157
+ setModelsByProvider((prev) => ({
158
+ ...prev,
159
+ [provider]: data.models || [],
160
+ }));
161
+ } catch (e) {
162
+ console.error(e);
163
+ setModelsError(e.message || "Failed to load models");
164
+ } finally {
165
+ setLoadingModelsFor("");
166
+ }
167
+ };
168
+
169
+ const handlePair = async () => {
170
+ if (!pairCode.trim()) return;
171
+
172
+ setPairing(true);
173
+ setPairResult(null);
174
+
175
+ try {
176
+ const baseUrl =
177
+ settings?.ollabridge?.base_url || "https://ruslanmv-ollabridge.hf.space";
178
+
179
+ const res = await fetch("/api/ollabridge/pair", {
180
+ method: "POST",
181
+ headers: { "Content-Type": "application/json" },
182
+ body: JSON.stringify({ base_url: baseUrl, code: pairCode.trim() }),
183
+ });
184
+
185
+ const data = await res.json();
186
+
187
+ if (data.success) {
188
+ setPairResult({ ok: true, message: "Paired successfully!" });
189
+ if (data.token) {
190
+ updateField("ollabridge", "api_key", data.token);
191
+ }
192
+ } else {
193
+ setPairResult({
194
+ ok: false,
195
+ message: data.error || "Pairing failed",
196
+ });
197
+ }
198
+ } catch (e) {
199
+ setPairResult({ ok: false, message: e.message || "Pairing failed" });
200
+ } finally {
201
+ setPairing(false);
202
+ }
203
+ };
204
+
205
+ const handleTestConnection = async () => {
206
+ setTesting(true);
207
+ setTestResult(null);
208
+
209
+ try {
210
+ const activeProvider = settings?.provider || "ollama";
211
+ const config = { provider: activeProvider };
212
+
213
+ if (activeProvider === "openai" && settings?.openai) {
214
+ config.openai = {
215
+ api_key: settings.openai.api_key,
216
+ base_url: settings.openai.base_url,
217
+ model: settings.openai.model,
218
+ };
219
+ } else if (activeProvider === "claude" && settings?.claude) {
220
+ config.claude = {
221
+ api_key: settings.claude.api_key,
222
+ base_url: settings.claude.base_url,
223
+ model: settings.claude.model,
224
+ };
225
+ } else if (activeProvider === "watsonx" && settings?.watsonx) {
226
+ config.watsonx = {
227
+ api_key: settings.watsonx.api_key,
228
+ project_id: settings.watsonx.project_id,
229
+ base_url: settings.watsonx.base_url,
230
+ model_id: settings.watsonx.model_id,
231
+ };
232
+ } else if (activeProvider === "ollama" && settings?.ollama) {
233
+ config.ollama = {
234
+ base_url: settings.ollama.base_url,
235
+ model: settings.ollama.model,
236
+ };
237
+ } else if (activeProvider === "ollabridge" && settings?.ollabridge) {
238
+ config.ollabridge = {
239
+ base_url: settings.ollabridge.base_url,
240
+ model: settings.ollabridge.model,
241
+ api_key: settings.ollabridge.api_key,
242
+ };
243
+ }
244
+
245
+ const result = await testProvider(config);
246
+ setTestResult(result);
247
+ } catch (err) {
248
+ setTestResult({
249
+ health: "error",
250
+ warning: err.message || "Test failed",
251
+ });
252
+ } finally {
253
+ setTesting(false);
254
+ }
255
+ };
256
+
257
+ if (initialLoading) {
258
+ return (
259
+ <LoadingState
260
+ loadingMessage={loadingMessage}
261
+ loadingSlow={loadingSlow}
262
+ onRetry={loadSettings}
263
+ />
264
+ );
265
+ }
266
+
267
+ if (!settings) {
268
+ return (
269
+ <div className="settings-root">
270
+ <div className="settings-inline-error-card">
271
+ <h1>AI Providers</h1>
272
+ <div className="settings-loading-subtitle">Admin / LLM Settings</div>
273
+ <p className="settings-error-text">
274
+ {error || "Unable to load current configuration."}
275
+ </p>
276
+ <button
277
+ type="button"
278
+ className="settings-secondary-btn"
279
+ onClick={loadSettings}
280
+ >
281
+ Retry
282
+ </button>
283
+ </div>
284
+ </div>
285
+ );
286
+ }
287
+
288
+ const { provider } = settings;
289
+ const availableModels = modelsByProvider[provider] || [];
290
+
291
+ return (
292
+ <div className="settings-root">
293
+ <h1>AI Providers</h1>
294
+ <p className="settings-muted">
295
+ Choose which LLM provider GitPilot should use for planning and agent
296
+ workflows. Provider settings are stored on the server.
297
+ </p>
298
+
299
+ {error && <div className="settings-error-banner">{error}</div>}
300
+ {savedMsg && <div className="settings-success-banner">{savedMsg}</div>}
301
+
302
+ <div className="settings-card">
303
+ <label className="settings-label">Active provider</label>
304
+ <div className="settings-provider-tabs">
305
+ {PROVIDERS.map((p) => (
306
+ <button
307
+ key={p}
308
+ type="button"
309
+ className={
310
+ "settings-provider-tab" +
311
+ (provider === p ? " settings-provider-tab-active" : "")
312
+ }
313
+ onClick={() => setSettings((prev) => ({ ...prev, provider: p }))}
314
+ >
315
+ {PROVIDER_LABELS[p] || p}
316
+ </button>
317
+ ))}
318
+ </div>
319
+ </div>
320
+
321
+ {provider === "ollabridge" && (
322
+ <div className="settings-card">
323
+ <div className="settings-title">OllaBridge Cloud Configuration</div>
324
+ <div className="settings-hint" style={{ marginBottom: 12 }}>
325
+ Connect to OllaBridge Cloud or any OllaBridge instance for LLM
326
+ inference. No API key required for public endpoints.
327
+ </div>
328
+
329
+ <label className="settings-label">Authentication Mode</label>
330
+ <div className="ob-auth-tabs">
331
+ {AUTH_MODES.map((m) => (
332
+ <button
333
+ key={m.id}
334
+ type="button"
335
+ className={
336
+ "ob-auth-tab" +
337
+ (authMode === m.id ? " ob-auth-tab-active" : "")
338
+ }
339
+ onClick={() => setAuthMode(m.id)}
340
+ >
341
+ <span className="ob-auth-tab-icon">{m.icon}</span>
342
+ <span>{m.label}</span>
343
+ </button>
344
+ ))}
345
+ </div>
346
+
347
+ {authMode === "device" && (
348
+ <div className="ob-auth-panel">
349
+ <div className="ob-auth-desc">
350
+ Enter the pairing code from your OllaBridge console and click
351
+ Pair.
352
+ </div>
353
+ <div className="ob-pair-row">
354
+ <input
355
+ className="settings-input ob-pair-input"
356
+ type="text"
357
+ maxLength={9}
358
+ placeholder="ABCD-1234"
359
+ value={pairCode}
360
+ onChange={(e) => setPairCode(e.target.value.toUpperCase())}
361
+ onKeyDown={(e) => e.key === "Enter" && handlePair()}
362
+ />
363
+ <button
364
+ type="button"
365
+ className="ob-pair-btn"
366
+ onClick={handlePair}
367
+ disabled={pairing || !pairCode.trim()}
368
+ >
369
+ {pairing ? "Pairing…" : "Pair"}
370
+ </button>
371
+ </div>
372
+ {pairResult && (
373
+ <div
374
+ className={
375
+ pairResult.ok ? "settings-success-banner" : "settings-error-banner"
376
+ }
377
+ >
378
+ {pairResult.message}
379
+ </div>
380
+ )}
381
+ </div>
382
+ )}
383
+
384
+ <label className="settings-label">Base URL</label>
385
+ <input
386
+ className="settings-input"
387
+ value={settings.ollabridge?.base_url || ""}
388
+ onChange={(e) =>
389
+ updateField("ollabridge", "base_url", e.target.value)
390
+ }
391
+ placeholder="https://your-ollabridge-endpoint"
392
+ />
393
+
394
+ {(authMode === "apikey" || authMode === "local") && (
395
+ <>
396
+ <label className="settings-label">API Key</label>
397
+ <input
398
+ className="settings-input"
399
+ type="password"
400
+ value={settings.ollabridge?.api_key || ""}
401
+ onChange={(e) =>
402
+ updateField("ollabridge", "api_key", e.target.value)
403
+ }
404
+ placeholder="Optional API key"
405
+ />
406
+ </>
407
+ )}
408
+
409
+ <label className="settings-label">Model</label>
410
+ <div className="settings-inline-row">
411
+ <input
412
+ className="settings-input"
413
+ value={settings.ollabridge?.model || ""}
414
+ onChange={(e) =>
415
+ updateField("ollabridge", "model", e.target.value)
416
+ }
417
+ placeholder="qwen2.5:1.5b"
418
+ />
419
+ <button
420
+ type="button"
421
+ className="settings-secondary-btn"
422
+ onClick={() => loadModelsForProvider("ollabridge")}
423
+ disabled={loadingModelsFor === "ollabridge"}
424
+ >
425
+ {loadingModelsFor === "ollabridge" ? "Loading…" : "Load Models"}
426
+ </button>
427
+ </div>
428
+ </div>
429
+ )}
430
+
431
+ {provider === "openai" && (
432
+ <div className="settings-card">
433
+ <div className="settings-title">OpenAI Configuration</div>
434
+
435
+ <label className="settings-label">API Key</label>
436
+ <input
437
+ className="settings-input"
438
+ type="password"
439
+ value={settings.openai?.api_key || ""}
440
+ onChange={(e) => updateField("openai", "api_key", e.target.value)}
441
+ placeholder="sk-..."
442
+ />
443
+
444
+ <label className="settings-label">Base URL</label>
445
+ <input
446
+ className="settings-input"
447
+ value={settings.openai?.base_url || ""}
448
+ onChange={(e) => updateField("openai", "base_url", e.target.value)}
449
+ placeholder="Optional custom base URL"
450
+ />
451
+
452
+ <label className="settings-label">Model</label>
453
+ <input
454
+ className="settings-input"
455
+ value={settings.openai?.model || ""}
456
+ onChange={(e) => updateField("openai", "model", e.target.value)}
457
+ placeholder="gpt-4o-mini"
458
+ />
459
+ </div>
460
+ )}
461
+
462
+ {provider === "claude" && (
463
+ <div className="settings-card">
464
+ <div className="settings-title">Claude Configuration</div>
465
+
466
+ <label className="settings-label">API Key</label>
467
+ <input
468
+ className="settings-input"
469
+ type="password"
470
+ value={settings.claude?.api_key || ""}
471
+ onChange={(e) => updateField("claude", "api_key", e.target.value)}
472
+ placeholder="Anthropic API key"
473
+ />
474
+
475
+ <label className="settings-label">Base URL</label>
476
+ <input
477
+ className="settings-input"
478
+ value={settings.claude?.base_url || ""}
479
+ onChange={(e) => updateField("claude", "base_url", e.target.value)}
480
+ placeholder="Optional custom base URL"
481
+ />
482
+
483
+ <label className="settings-label">Model</label>
484
+ <input
485
+ className="settings-input"
486
+ value={settings.claude?.model || ""}
487
+ onChange={(e) => updateField("claude", "model", e.target.value)}
488
+ placeholder="claude-sonnet-4-5"
489
+ />
490
+ </div>
491
+ )}
492
+
493
+ {provider === "watsonx" && (
494
+ <div className="settings-card">
495
+ <div className="settings-title">Watsonx Configuration</div>
496
+
497
+ <label className="settings-label">API Key</label>
498
+ <input
499
+ className="settings-input"
500
+ type="password"
501
+ value={settings.watsonx?.api_key || ""}
502
+ onChange={(e) => updateField("watsonx", "api_key", e.target.value)}
503
+ placeholder="Watsonx API key"
504
+ />
505
+
506
+ <label className="settings-label">Project ID</label>
507
+ <input
508
+ className="settings-input"
509
+ value={settings.watsonx?.project_id || ""}
510
+ onChange={(e) =>
511
+ updateField("watsonx", "project_id", e.target.value)
512
+ }
513
+ placeholder="Watsonx project ID"
514
+ />
515
+
516
+ <label className="settings-label">Base URL</label>
517
+ <input
518
+ className="settings-input"
519
+ value={settings.watsonx?.base_url || ""}
520
+ onChange={(e) => updateField("watsonx", "base_url", e.target.value)}
521
+ placeholder="https://api.watsonx.ai/v1"
522
+ />
523
+
524
+ <label className="settings-label">Model</label>
525
+ <input
526
+ className="settings-input"
527
+ value={settings.watsonx?.model_id || ""}
528
+ onChange={(e) =>
529
+ updateField("watsonx", "model_id", e.target.value)
530
+ }
531
+ placeholder="meta-llama/llama-3-3-70b-instruct"
532
+ />
533
+ </div>
534
+ )}
535
+
536
+ {provider === "ollama" && (
537
+ <div className="settings-card">
538
+ <div className="settings-title">Ollama Configuration</div>
539
+
540
+ <label className="settings-label">Base URL</label>
541
+ <input
542
+ className="settings-input"
543
+ value={settings.ollama?.base_url || ""}
544
+ onChange={(e) => updateField("ollama", "base_url", e.target.value)}
545
+ placeholder="http://localhost:11434"
546
+ />
547
+
548
+ <label className="settings-label">Model</label>
549
+ <div className="settings-inline-row">
550
+ <input
551
+ className="settings-input"
552
+ value={settings.ollama?.model || ""}
553
+ onChange={(e) => updateField("ollama", "model", e.target.value)}
554
+ placeholder="llama3"
555
+ />
556
+ <button
557
+ type="button"
558
+ className="settings-secondary-btn"
559
+ onClick={() => loadModelsForProvider("ollama")}
560
+ disabled={loadingModelsFor === "ollama"}
561
+ >
562
+ {loadingModelsFor === "ollama" ? "Loading…" : "Load Models"}
563
+ </button>
564
+ </div>
565
+ </div>
566
+ )}
567
+
568
+ {availableModels.length > 0 && (
569
+ <div className="settings-card">
570
+ <div className="settings-title">Available Models</div>
571
+ <div className="settings-model-list">
572
+ {availableModels.map((model) => (
573
+ <button
574
+ key={model}
575
+ type="button"
576
+ className="settings-model-chip"
577
+ onClick={() => updateField(provider, "model", model)}
578
+ >
579
+ {model}
580
+ </button>
581
+ ))}
582
+ </div>
583
+ </div>
584
+ )}
585
+
586
+ {modelsError && <div className="settings-error-banner">{modelsError}</div>}
587
+
588
+ {testResult && (
589
+ <div
590
+ className={
591
+ testResult.health === "ok"
592
+ ? "settings-success-banner"
593
+ : "settings-error-banner"
594
+ }
595
+ >
596
+ {testResult.health === "ok"
597
+ ? testResult.details || "Provider connection successful."
598
+ : testResult.warning || "Provider connection failed."}
599
+ </div>
600
+ )}
601
+
602
+ <div className="settings-actions">
603
+ <button
604
+ type="button"
605
+ className="settings-save-btn"
606
+ onClick={handleSave}
607
+ disabled={saving}
608
+ >
609
+ {saving ? "Saving…" : "Save Settings"}
610
+ </button>
611
+
612
+ <button
613
+ type="button"
614
+ className="settings-secondary-btn"
615
+ onClick={handleTestConnection}
616
+ disabled={testing}
617
+ >
618
+ {testing ? "Testing…" : "Test Connection"}
619
+ </button>
620
+ </div>
621
+ </div>
622
+ );
623
+ }
frontend/components/LoginPage.jsx ADDED
@@ -0,0 +1,544 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/LoginPage.jsx
2
+ import React, { useState, useEffect, useRef } from "react";
3
+ import { apiUrl, safeFetchJSON } from "../utils/api.js";
4
+ import { initApp } from "../utils/appInit.js";
5
+
6
+ /**
7
+ * GitPilot – Enterprise Agentic Login
8
+ * Theme: "Claude Code" / Anthropic Enterprise (Dark + Warm Orange)
9
+ */
10
+
11
+ export default function LoginPage({ onAuthenticated, backendReady = false }) {
12
+ // Auth State
13
+ const [authProcessing, setAuthProcessing] = useState(false);
14
+ const [error, setError] = useState("");
15
+
16
+ // Mode State: 'loading' | 'web' (Has Secret) | 'device' (No Secret)
17
+ const [mode, setMode] = useState("loading");
18
+
19
+ // Device Flow State
20
+ const [deviceData, setDeviceData] = useState(null);
21
+ const pollTimer = useRef(null);
22
+ const stopPolling = useRef(false); // Flag to safely stop async polling
23
+
24
+ // Web Flow State
25
+ const [missingClientId, setMissingClientId] = useState(false);
26
+
27
+ // REF FIX: Prevents React StrictMode from running the auth exchange twice
28
+ const processingRef = useRef(false);
29
+ const authCheckDone = useRef(false);
30
+
31
+ // 1. Initialization Effect β€” runs once on mount AND when backendReady changes
32
+ useEffect(() => {
33
+ // Skip if already resolved
34
+ if (authCheckDone.current && mode !== "loading") return;
35
+
36
+ const params = new URLSearchParams(window.location.search);
37
+ const code = params.get("code");
38
+ const state = params.get("state");
39
+
40
+ // A. If returning from GitHub (Web Flow Callback)
41
+ if (code) {
42
+ if (!processingRef.current) {
43
+ processingRef.current = true;
44
+ setMode("web");
45
+ consumeOAuthCallback(code, state);
46
+ }
47
+ return;
48
+ }
49
+
50
+ // B. Use the shared singleton init β€” reuses App.jsx's result.
51
+ // No duplicate /api/auth/status calls, no separate retry loops.
52
+ initApp().then((result) => {
53
+ authCheckDone.current = true;
54
+ if (result.ready) {
55
+ setError("");
56
+ setMode(result.authMode === "web" ? "web" : "device");
57
+ } else {
58
+ // Backend unreachable β€” allow device flow as fallback
59
+ setError(result.error || "Backend unavailable");
60
+ setMode("device");
61
+ }
62
+ });
63
+
64
+ // Cleanup polling on unmount
65
+ return () => {
66
+ stopPolling.current = true;
67
+ if (pollTimer.current) clearTimeout(pollTimer.current);
68
+ };
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [backendReady]);
71
+
72
+ // ===========================================================================
73
+ // WEB FLOW LOGIC (Standard OAuth2)
74
+ // ===========================================================================
75
+
76
+ async function consumeOAuthCallback(code, state) {
77
+ const expectedState = sessionStorage.getItem("gitpilot_oauth_state");
78
+ if (state && expectedState && expectedState !== state) {
79
+ console.warn("OAuth state mismatch - proceeding with caution.");
80
+ }
81
+
82
+ setAuthProcessing(true);
83
+ setError("");
84
+ window.history.replaceState({}, document.title, window.location.pathname);
85
+
86
+ try {
87
+ const data = await safeFetchJSON(apiUrl("/api/auth/callback"), {
88
+ method: "POST",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify({ code, state: state || "" }),
91
+ });
92
+
93
+ handleSuccess(data);
94
+ } catch (err) {
95
+ console.error("Login Error:", err);
96
+ setError(err instanceof Error ? err.message : "Login failed.");
97
+ setAuthProcessing(false);
98
+ }
99
+ }
100
+
101
+ async function handleSignInWithGitHub() {
102
+ setError("");
103
+ setMissingClientId(false);
104
+ setAuthProcessing(true);
105
+
106
+ try {
107
+ const data = await safeFetchJSON(apiUrl("/api/auth/url"));
108
+
109
+ if (data.state) {
110
+ sessionStorage.setItem("gitpilot_oauth_state", data.state);
111
+ }
112
+
113
+ window.location.href = data.authorization_url;
114
+ } catch (err) {
115
+ console.error("Auth Start Error:", err);
116
+ // Check for missing client ID (404/500 errors)
117
+ if (err.message && (err.message.includes('404') || err.message.includes('500'))) {
118
+ setMissingClientId(true);
119
+ } else {
120
+ setError(err instanceof Error ? err.message : "Could not start sign-in.");
121
+ }
122
+ setAuthProcessing(false);
123
+ }
124
+ }
125
+
126
+ // ===========================================================================
127
+ // DEVICE FLOW LOGIC (No Client Secret Required)
128
+ // ===========================================================================
129
+
130
+ const startDeviceFlow = async () => {
131
+ setError("");
132
+ setAuthProcessing(true);
133
+ stopPolling.current = false; // Reset stop flag
134
+
135
+ try {
136
+ const data = await safeFetchJSON(apiUrl("/api/auth/device/code"), { method: "POST" });
137
+
138
+ // Handle Errors
139
+ if (data.error) {
140
+ if (data.error.includes("400") || data.error.includes("Bad Request")) {
141
+ throw new Error("Device Flow is disabled in GitHub. Please go to your GitHub App Settings > 'General' > 'Identifying and authorizing users' and check the box 'Enable Device Flow'.");
142
+ }
143
+ throw new Error(data.error);
144
+ }
145
+
146
+ if (!data.device_code) throw new Error("Invalid device code response");
147
+
148
+ setDeviceData(data);
149
+ setAuthProcessing(false);
150
+
151
+ // Start Polling (Recursive Timeout Pattern)
152
+ pollDeviceToken(data.device_code, data.interval || 5);
153
+
154
+ } catch (err) {
155
+ setError(err.message);
156
+ setAuthProcessing(false);
157
+ }
158
+ };
159
+
160
+ const pollDeviceToken = async (deviceCode, interval) => {
161
+ if (stopPolling.current) return;
162
+
163
+ try {
164
+ const response = await fetch(apiUrl("/api/auth/device/poll"), {
165
+ method: "POST",
166
+ headers: { "Content-Type": "application/json" },
167
+ body: JSON.stringify({ device_code: deviceCode })
168
+ });
169
+
170
+ // 1. Success (200)
171
+ if (response.status === 200) {
172
+ const data = await response.json();
173
+ handleSuccess(data);
174
+ return;
175
+ }
176
+
177
+ // 2. Pending (202) -> Continue Polling
178
+ if (response.status === 202) {
179
+ // Schedule next poll
180
+ pollTimer.current = setTimeout(
181
+ () => pollDeviceToken(deviceCode, interval),
182
+ interval * 1000
183
+ );
184
+ return;
185
+ }
186
+
187
+ // 3. Error (4xx/5xx) -> Stop Polling & Show Error
188
+ const errData = await response.json().catch(() => ({ error: "Unknown polling error" }));
189
+
190
+ // Special case: If it's just a 'slow_down' warning (sometimes 400), we just wait longer
191
+ if (errData.error === "slow_down") {
192
+ pollTimer.current = setTimeout(
193
+ () => pollDeviceToken(deviceCode, interval + 5),
194
+ (interval + 5) * 1000
195
+ );
196
+ return;
197
+ }
198
+
199
+ // Terminal errors
200
+ throw new Error(errData.error || `Polling failed: ${response.status}`);
201
+
202
+ } catch (e) {
203
+ console.error("Poll error:", e);
204
+ if (!stopPolling.current) {
205
+ setError(e.message || "Failed to connect to authentication server.");
206
+ setDeviceData(null); // Return to initial state
207
+ }
208
+ }
209
+ };
210
+
211
+ const handleManualCheck = async () => {
212
+ if (!deviceData?.device_code) return;
213
+
214
+ try {
215
+ const response = await fetch(apiUrl("/api/auth/device/poll"), {
216
+ method: "POST",
217
+ headers: { "Content-Type": "application/json" },
218
+ body: JSON.stringify({ device_code: deviceData.device_code })
219
+ });
220
+
221
+ if (response.status === 200) {
222
+ const data = await response.json();
223
+ handleSuccess(data);
224
+ } else if (response.status === 202) {
225
+ // Visual feedback for pending state
226
+ const btn = document.getElementById("manual-check-btn");
227
+ if (btn) {
228
+ const originalText = btn.innerText;
229
+ btn.innerText = "Still Pending...";
230
+ btn.disabled = true;
231
+ setTimeout(() => {
232
+ btn.innerText = originalText;
233
+ btn.disabled = false;
234
+ }, 2000);
235
+ }
236
+ }
237
+ } catch (e) {
238
+ console.error("Manual check failed", e);
239
+ }
240
+ };
241
+
242
+ const handleCancelDeviceFlow = () => {
243
+ stopPolling.current = true;
244
+ if (pollTimer.current) clearTimeout(pollTimer.current);
245
+ setDeviceData(null);
246
+ setError("");
247
+ };
248
+
249
+ // ===========================================================================
250
+ // SHARED HELPERS
251
+ // ===========================================================================
252
+
253
+ function handleSuccess(data) {
254
+ stopPolling.current = true; // Ensure polling stops
255
+ if (pollTimer.current) clearTimeout(pollTimer.current);
256
+
257
+ if (!data.access_token || !data.user) {
258
+ setError("Server returned incomplete session data.");
259
+ return;
260
+ }
261
+
262
+ try {
263
+ localStorage.setItem("github_token", data.access_token);
264
+ localStorage.setItem("github_user", JSON.stringify(data.user));
265
+ } catch (e) {
266
+ console.warn("LocalStorage access denied:", e);
267
+ }
268
+
269
+ if (typeof onAuthenticated === "function") {
270
+ onAuthenticated({
271
+ access_token: data.access_token,
272
+ user: data.user,
273
+ });
274
+ }
275
+ }
276
+
277
+ // --- Design Token System ---
278
+ const theme = {
279
+ bg: "#131316",
280
+ cardBg: "#1C1C1F",
281
+ border: "#27272A",
282
+ accent: "#D95C3D",
283
+ accentHover: "#C44F32",
284
+ textPrimary: "#EDEDED",
285
+ textSecondary: "#A1A1AA",
286
+ font: '"SΓΆhne", "Inter", -apple-system, sans-serif',
287
+ };
288
+
289
+ const styles = {
290
+ container: {
291
+ minHeight: "100vh",
292
+ display: "flex",
293
+ alignItems: "center",
294
+ justifyContent: "center",
295
+ backgroundColor: theme.bg,
296
+ fontFamily: theme.font,
297
+ color: theme.textPrimary,
298
+ letterSpacing: "-0.01em",
299
+ },
300
+ card: {
301
+ backgroundColor: theme.cardBg,
302
+ width: "100%",
303
+ maxWidth: "440px",
304
+ borderRadius: "12px",
305
+ border: `1px solid ${theme.border}`,
306
+ boxShadow: "0 24px 48px -12px rgba(0, 0, 0, 0.6)",
307
+ padding: "48px 40px",
308
+ textAlign: "center",
309
+ position: "relative",
310
+ },
311
+ logoBadge: {
312
+ width: "48px",
313
+ height: "48px",
314
+ backgroundColor: "rgba(217, 92, 61, 0.15)",
315
+ color: theme.accent,
316
+ borderRadius: "10px",
317
+ display: "flex",
318
+ alignItems: "center",
319
+ justifyContent: "center",
320
+ fontSize: "22px",
321
+ fontWeight: "700",
322
+ margin: "0 auto 32px auto",
323
+ border: "1px solid rgba(217, 92, 61, 0.2)",
324
+ },
325
+ h1: {
326
+ fontSize: "24px",
327
+ fontWeight: "600",
328
+ marginBottom: "12px",
329
+ color: theme.textPrimary,
330
+ },
331
+ p: {
332
+ fontSize: "14px",
333
+ color: theme.textSecondary,
334
+ lineHeight: "1.6",
335
+ marginBottom: "40px",
336
+ },
337
+ button: {
338
+ width: "100%",
339
+ height: "48px",
340
+ backgroundColor: theme.accent,
341
+ color: "#FFFFFF",
342
+ border: "none",
343
+ borderRadius: "8px",
344
+ fontSize: "14px",
345
+ fontWeight: "500",
346
+ cursor: (authProcessing || (mode === 'loading')) ? "not-allowed" : "pointer",
347
+ opacity: (authProcessing || (mode === 'loading')) ? 0.7 : 1,
348
+ transition: "background-color 0.2s ease",
349
+ display: "flex",
350
+ alignItems: "center",
351
+ justifyContent: "center",
352
+ gap: "10px",
353
+ boxShadow: "0 4px 12px rgba(217, 92, 61, 0.25)",
354
+ },
355
+ secondaryButton: {
356
+ backgroundColor: "transparent",
357
+ color: "#A1A1AA",
358
+ border: "1px solid #3F3F46",
359
+ padding: "8px 16px",
360
+ borderRadius: "6px",
361
+ fontSize: "12px",
362
+ cursor: "pointer",
363
+ marginTop: "16px",
364
+ minWidth: "100px"
365
+ },
366
+ errorBox: {
367
+ backgroundColor: "rgba(185, 28, 28, 0.15)",
368
+ border: "1px solid rgba(185, 28, 28, 0.3)",
369
+ color: "#FCA5A5",
370
+ padding: "12px",
371
+ borderRadius: "8px",
372
+ fontSize: "13px",
373
+ marginBottom: "24px",
374
+ textAlign: "left",
375
+ },
376
+ configCard: {
377
+ textAlign: "left",
378
+ backgroundColor: "#111",
379
+ border: "1px solid #333",
380
+ padding: "24px",
381
+ borderRadius: "8px",
382
+ marginBottom: "24px",
383
+ },
384
+ codeDisplay: {
385
+ backgroundColor: "#27272A",
386
+ color: theme.accent,
387
+ fontSize: "20px",
388
+ fontWeight: "700",
389
+ padding: "12px",
390
+ borderRadius: "6px",
391
+ textAlign: "center",
392
+ letterSpacing: "2px",
393
+ margin: "12px 0",
394
+ border: `1px dashed ${theme.accent}`,
395
+ cursor: "pointer",
396
+ },
397
+ footer: {
398
+ marginTop: "48px",
399
+ fontSize: "12px",
400
+ color: "#52525B",
401
+ }
402
+ };
403
+
404
+ // --- RENDER: Device Flow UI ---
405
+ const renderDeviceFlow = () => {
406
+ if (!deviceData) {
407
+ return (
408
+ <button
409
+ onClick={startDeviceFlow}
410
+ disabled={authProcessing}
411
+ style={styles.button}
412
+ onMouseOver={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accentHover)}
413
+ onMouseOut={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accent)}
414
+ >
415
+ {authProcessing ? "Connecting..." : "Sign in with GitHub"}
416
+ </button>
417
+ );
418
+ }
419
+
420
+ return (
421
+ <div style={styles.configCard}>
422
+ <h3 style={{marginTop:0, color: '#FFF', fontSize: '16px'}}>Authorize Device</h3>
423
+ <p style={{color: '#AAA', fontSize: '13px', marginBottom:'16px'}}>
424
+ GitPilot needs authorization to access your repositories.
425
+ </p>
426
+
427
+ <div style={{marginBottom: '16px'}}>
428
+ <div style={{color: '#AAA', fontSize: '12px', marginBottom: '4px'}}>1. Copy code:</div>
429
+ <div
430
+ style={styles.codeDisplay}
431
+ onClick={() => {
432
+ navigator.clipboard.writeText(deviceData.user_code);
433
+ }}
434
+ title="Click to copy"
435
+ >
436
+ {deviceData.user_code}
437
+ </div>
438
+ </div>
439
+
440
+ <div>
441
+ <div style={{color: '#AAA', fontSize: '12px', marginBottom: '4px'}}>2. Paste at GitHub:</div>
442
+ <a
443
+ href={deviceData.verification_uri}
444
+ target="_blank"
445
+ rel="noreferrer"
446
+ style={{
447
+ display: 'block',
448
+ backgroundColor: '#FFF',
449
+ color: '#000',
450
+ textDecoration: 'none',
451
+ padding: '10px',
452
+ borderRadius: '6px',
453
+ textAlign: 'center',
454
+ fontWeight: '600',
455
+ fontSize: '14px'
456
+ }}
457
+ >
458
+ Open Activation Page β†—
459
+ </a>
460
+ </div>
461
+
462
+ <div style={{marginTop: '20px', fontSize: '12px', color: '#666', textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px'}}>
463
+ <span style={{animation: 'spin 1s linear infinite', display: 'inline-block'}}>↻</span>
464
+ Waiting for authorization...
465
+ <style>{`@keyframes spin { 100% { transform: rotate(360deg); } }`}</style>
466
+ </div>
467
+
468
+ <div style={{textAlign: 'center', display: 'flex', gap: '10px', justifyContent: 'center'}}>
469
+ <button
470
+ id="manual-check-btn"
471
+ onClick={handleManualCheck}
472
+ style={styles.secondaryButton}
473
+ >
474
+ Check Status
475
+ </button>
476
+ <button
477
+ onClick={handleCancelDeviceFlow}
478
+ style={styles.secondaryButton}
479
+ >
480
+ Cancel
481
+ </button>
482
+ </div>
483
+ </div>
484
+ );
485
+ };
486
+
487
+ // --- RENDER: Config Error ---
488
+ if (missingClientId) {
489
+ return (
490
+ <div style={styles.container}>
491
+ <div style={styles.card}>
492
+ <div style={{...styles.logoBadge, color: "#F59E0B", backgroundColor: "rgba(245, 158, 11, 0.1)", borderColor: "rgba(245, 158, 11, 0.2)"}}>⚠️</div>
493
+ <h1 style={styles.h1}>Configuration Error</h1>
494
+ <p style={styles.p}>Could not connect to GitHub Authentication services.</p>
495
+ <button onClick={() => setMissingClientId(false)} style={{...styles.button, backgroundColor: "#3F3F46"}}>Retry</button>
496
+ </div>
497
+ </div>
498
+ );
499
+ }
500
+
501
+ // --- RENDER: Main ---
502
+ return (
503
+ <div style={styles.container}>
504
+ <div style={styles.card}>
505
+ <div style={styles.logoBadge}>GP</div>
506
+
507
+ <h1 style={styles.h1}>GitPilot Enterprise</h1>
508
+ <p style={styles.p}>
509
+ Agentic AI workflow for your repositories.<br/>
510
+ Secure. Context-aware. Automated.
511
+ </p>
512
+
513
+ {error && <div style={styles.errorBox}>{error}</div>}
514
+
515
+ {mode === "loading" && (
516
+ <div style={{color: '#666', fontSize: '14px'}}>Initializing...</div>
517
+ )}
518
+
519
+ {mode === "web" && (
520
+ <button
521
+ onClick={handleSignInWithGitHub}
522
+ disabled={authProcessing}
523
+ style={styles.button}
524
+ onMouseOver={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accentHover)}
525
+ onMouseOut={(e) => !authProcessing && (e.currentTarget.style.backgroundColor = theme.accent)}
526
+ >
527
+ {authProcessing ? "Connecting..." : (
528
+ <>
529
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405 1.02 0 2.04.135 3 .405 2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" /></svg>
530
+ Sign in with GitHub
531
+ </>
532
+ )}
533
+ </button>
534
+ )}
535
+
536
+ {mode === "device" && renderDeviceFlow()}
537
+
538
+ <div style={styles.footer}>
539
+ &copy; {new Date().getFullYear()} GitPilot Inc.
540
+ </div>
541
+ </div>
542
+ </div>
543
+ );
544
+ }
frontend/components/PlanView.jsx ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+
3
+ export default function PlanView({ plan }) {
4
+ if (!plan) return null;
5
+
6
+ // Calculate totals for each action type
7
+ const totals = { CREATE: 0, MODIFY: 0, DELETE: 0 };
8
+ plan.steps.forEach((step) => {
9
+ step.files.forEach((file) => {
10
+ totals[file.action] = (totals[file.action] || 0) + 1;
11
+ });
12
+ });
13
+
14
+ const theme = {
15
+ bg: "#18181B",
16
+ border: "#27272A",
17
+ textPrimary: "#EDEDED",
18
+ textSecondary: "#A1A1AA",
19
+ successBg: "rgba(16, 185, 129, 0.1)",
20
+ successText: "#10B981",
21
+ warningBg: "rgba(245, 158, 11, 0.1)",
22
+ warningText: "#F59E0B",
23
+ dangerBg: "rgba(239, 68, 68, 0.1)",
24
+ dangerText: "#EF4444",
25
+ };
26
+
27
+ const styles = {
28
+ container: {
29
+ display: "flex",
30
+ flexDirection: "column",
31
+ gap: "20px",
32
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
33
+ },
34
+ header: {
35
+ display: "flex",
36
+ flexDirection: "column",
37
+ gap: "8px",
38
+ paddingBottom: "16px",
39
+ borderBottom: `1px solid ${theme.border}`,
40
+ },
41
+ goal: {
42
+ fontSize: "14px",
43
+ fontWeight: "600",
44
+ color: theme.textPrimary,
45
+ },
46
+ summary: {
47
+ fontSize: "13px",
48
+ color: theme.textSecondary,
49
+ lineHeight: "1.5",
50
+ },
51
+ totals: {
52
+ display: "flex",
53
+ gap: "12px",
54
+ flexWrap: "wrap",
55
+ },
56
+ totalBadge: {
57
+ fontSize: "11px",
58
+ fontWeight: "500",
59
+ padding: "4px 8px",
60
+ borderRadius: "4px",
61
+ border: "1px solid transparent",
62
+ },
63
+ totalCreate: {
64
+ backgroundColor: theme.successBg,
65
+ color: theme.successText,
66
+ borderColor: "rgba(16, 185, 129, 0.2)",
67
+ },
68
+ totalModify: {
69
+ backgroundColor: theme.warningBg,
70
+ color: theme.warningText,
71
+ borderColor: "rgba(245, 158, 11, 0.2)",
72
+ },
73
+ totalDelete: {
74
+ backgroundColor: theme.dangerBg,
75
+ color: theme.dangerText,
76
+ borderColor: "rgba(239, 68, 68, 0.2)",
77
+ },
78
+ stepsList: {
79
+ listStyle: "none",
80
+ padding: 0,
81
+ margin: 0,
82
+ display: "flex",
83
+ flexDirection: "column",
84
+ gap: "24px",
85
+ },
86
+ step: {
87
+ display: "flex",
88
+ flexDirection: "column",
89
+ gap: "8px",
90
+ position: "relative",
91
+ },
92
+ stepHeader: {
93
+ display: "flex",
94
+ alignItems: "baseline",
95
+ gap: "8px",
96
+ fontSize: "13px",
97
+ fontWeight: "600",
98
+ color: theme.textPrimary,
99
+ },
100
+ stepNumber: {
101
+ color: theme.textSecondary,
102
+ fontSize: "11px",
103
+ textTransform: "uppercase",
104
+ letterSpacing: "0.05em",
105
+ },
106
+ stepDescription: {
107
+ fontSize: "13px",
108
+ color: theme.textSecondary,
109
+ lineHeight: "1.5",
110
+ margin: 0,
111
+ },
112
+ fileList: {
113
+ marginTop: "8px",
114
+ display: "flex",
115
+ flexDirection: "column",
116
+ gap: "4px",
117
+ backgroundColor: "#131316",
118
+ padding: "8px 12px",
119
+ borderRadius: "6px",
120
+ border: `1px solid ${theme.border}`,
121
+ },
122
+ fileItem: {
123
+ display: "flex",
124
+ alignItems: "center",
125
+ gap: "10px",
126
+ fontSize: "12px",
127
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
128
+ },
129
+ actionBadge: {
130
+ padding: "2px 6px",
131
+ borderRadius: "4px",
132
+ fontSize: "10px",
133
+ fontWeight: "bold",
134
+ textTransform: "uppercase",
135
+ minWidth: "55px",
136
+ textAlign: "center",
137
+ letterSpacing: "0.02em",
138
+ },
139
+ path: {
140
+ color: "#D4D4D8",
141
+ whiteSpace: "nowrap",
142
+ overflow: "hidden",
143
+ textOverflow: "ellipsis",
144
+ },
145
+ risks: {
146
+ marginTop: "8px",
147
+ fontSize: "12px",
148
+ color: theme.warningText,
149
+ backgroundColor: "rgba(245, 158, 11, 0.05)",
150
+ padding: "8px 12px",
151
+ borderRadius: "6px",
152
+ border: "1px solid rgba(245, 158, 11, 0.1)",
153
+ display: "flex",
154
+ gap: "6px",
155
+ alignItems: "flex-start",
156
+ },
157
+ };
158
+
159
+ const getActionStyle = (action) => {
160
+ switch (action) {
161
+ case "CREATE": return styles.totalCreate;
162
+ case "MODIFY": return styles.totalModify;
163
+ case "DELETE": return styles.totalDelete;
164
+ default: return {};
165
+ }
166
+ };
167
+
168
+ return (
169
+ <div style={styles.container}>
170
+ {/* Header & Summary */}
171
+ <div style={styles.header}>
172
+ <div style={styles.goal}>Goal: {plan.goal}</div>
173
+ <div style={styles.summary}>{plan.summary}</div>
174
+ </div>
175
+
176
+ {/* Totals Summary */}
177
+ <div style={styles.totals}>
178
+ {totals.CREATE > 0 && (
179
+ <span style={{ ...styles.totalBadge, ...styles.totalCreate }}>
180
+ {totals.CREATE} to create
181
+ </span>
182
+ )}
183
+ {totals.MODIFY > 0 && (
184
+ <span style={{ ...styles.totalBadge, ...styles.totalModify }}>
185
+ {totals.MODIFY} to modify
186
+ </span>
187
+ )}
188
+ {totals.DELETE > 0 && (
189
+ <span style={{ ...styles.totalBadge, ...styles.totalDelete }}>
190
+ {totals.DELETE} to delete
191
+ </span>
192
+ )}
193
+ </div>
194
+
195
+ {/* Steps List */}
196
+ <ol style={styles.stepsList}>
197
+ {plan.steps.map((s) => (
198
+ <li key={s.step_number} style={styles.step}>
199
+ <div style={styles.stepHeader}>
200
+ <span style={styles.stepNumber}>Step {s.step_number}</span>
201
+ <span>{s.title}</span>
202
+ </div>
203
+ <p style={styles.stepDescription}>{s.description}</p>
204
+
205
+ {/* Files List */}
206
+ {s.files && s.files.length > 0 && (
207
+ <div style={styles.fileList}>
208
+ {s.files.map((file, idx) => (
209
+ <div key={idx} style={styles.fileItem}>
210
+ <span style={{ ...styles.actionBadge, ...getActionStyle(file.action) }}>
211
+ {file.action}
212
+ </span>
213
+ <span style={styles.path}>{file.path}</span>
214
+ </div>
215
+ ))}
216
+ </div>
217
+ )}
218
+
219
+ {/* Risks */}
220
+ {s.risks && (
221
+ <div style={styles.risks}>
222
+ <span>⚠️</span>
223
+ <span>{s.risks}</span>
224
+ </div>
225
+ )}
226
+ </li>
227
+ ))}
228
+ </ol>
229
+ </div>
230
+ );
231
+ }
frontend/components/ProjectContextPanel.jsx ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import FileTree from "./FileTree.jsx";
3
+ import BranchPicker from "./BranchPicker.jsx";
4
+
5
+ // --- INJECTED STYLES FOR ANIMATIONS ---
6
+ const animationStyles = `
7
+ @keyframes highlight-pulse {
8
+ 0% { background-color: rgba(59, 130, 246, 0.10); }
9
+ 50% { background-color: rgba(59, 130, 246, 0.22); }
10
+ 100% { background-color: transparent; }
11
+ }
12
+ .pulse-context {
13
+ animation: highlight-pulse 1.1s ease-out;
14
+ }
15
+ `;
16
+
17
+ /**
18
+ * ProjectContextPanel (Production-ready)
19
+ *
20
+ * Controlled component:
21
+ * - Branch source of truth is App.jsx:
22
+ * - defaultBranch (prod)
23
+ * - currentBranch (what user sees)
24
+ * - sessionBranches (list of all active AI session branches)
25
+ *
26
+ * Responsibilities:
27
+ * - Show project context + branch dropdown + AI badge/banner
28
+ * - Fetch access status + file count for the currentBranch
29
+ * - Trigger visual pulse on pulseNonce (Hard Switch)
30
+ */
31
+ export default function ProjectContextPanel({
32
+ repo,
33
+ defaultBranch,
34
+ currentBranch,
35
+ sessionBranch, // Active session branch (optional, for specific highlighting)
36
+ sessionBranches = [], // List of all AI branches
37
+ onBranchChange,
38
+ pulseNonce,
39
+ onSettingsClick,
40
+ }) {
41
+ const [appUrl, setAppUrl] = useState("");
42
+ const [fileCount, setFileCount] = useState(0);
43
+
44
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
45
+
46
+ // Data Loading State
47
+ const [analyzing, setAnalyzing] = useState(false);
48
+ const [accessInfo, setAccessInfo] = useState(null);
49
+ const [treeError, setTreeError] = useState(null);
50
+
51
+ // Retry / Refresh Logic
52
+ const [refreshTrigger, setRefreshTrigger] = useState(0);
53
+ const [retryCount, setRetryCount] = useState(0);
54
+ const retryTimeoutRef = useRef(null);
55
+
56
+ // UX State
57
+ const [animateHeader, setAnimateHeader] = useState(false);
58
+ const [toast, setToast] = useState({ visible: false, title: "", msg: "" });
59
+
60
+ // Calculate effective default to prevent 'main' fallback errors
61
+ const effectiveDefaultBranch = defaultBranch || repo?.default_branch || "main";
62
+ const branch = currentBranch || effectiveDefaultBranch;
63
+
64
+ // Determine if we are currently viewing an AI Session branch
65
+ const isAiSession = (sessionBranches.includes(branch)) || (sessionBranch === branch && branch !== effectiveDefaultBranch);
66
+
67
+ // Fetch App URL on mount
68
+ useEffect(() => {
69
+ fetch("/api/auth/app-url")
70
+ .then((res) => res.json())
71
+ .then((data) => {
72
+ if (data.app_url) setAppUrl(data.app_url);
73
+ })
74
+ .catch((err) => console.error("Failed to fetch App URL:", err));
75
+ }, []);
76
+
77
+ // Hard Switch pulse: whenever App increments pulseNonce
78
+ useEffect(() => {
79
+ if (!pulseNonce) return;
80
+ setAnimateHeader(true);
81
+ const t = window.setTimeout(() => setAnimateHeader(false), 1100);
82
+ return () => window.clearTimeout(t);
83
+ }, [pulseNonce]);
84
+
85
+ // Main data fetcher (Access + Tree stats) for currentBranch
86
+ // Stale-while-revalidate: keep previous data visible during fetch
87
+ useEffect(() => {
88
+ if (!repo) return;
89
+
90
+ // Only show full "analyzing" spinner if we have no data yet
91
+ if (!accessInfo) setAnalyzing(true);
92
+ setTreeError(null);
93
+
94
+ if (retryTimeoutRef.current) {
95
+ clearTimeout(retryTimeoutRef.current);
96
+ retryTimeoutRef.current = null;
97
+ }
98
+
99
+ let headers = {};
100
+ try {
101
+ const token = localStorage.getItem("github_token");
102
+ if (token) headers = { Authorization: `Bearer ${token}` };
103
+ } catch (e) {
104
+ console.warn("Unable to read github_token:", e);
105
+ }
106
+
107
+ let cancelled = false;
108
+ const cacheBuster = `&_t=${Date.now()}&retry=${retryCount}`;
109
+
110
+ // A) Access Check (with Stale Cache Fix)
111
+ fetch(`/api/auth/repo-access?owner=${repo.owner}&repo=${repo.name}${cacheBuster}`, {
112
+ headers,
113
+ cache: "no-cache",
114
+ })
115
+ .then(async (res) => {
116
+ if (cancelled) return;
117
+ const data = await res.json().catch(() => ({}));
118
+
119
+ if (!res.ok) {
120
+ setAccessInfo({ can_write: false, app_installed: false, auth_type: "none" });
121
+ return;
122
+ }
123
+
124
+ setAccessInfo(data);
125
+
126
+ // Auto-retry if user has push access but App is not detected yet (Stale Cache)
127
+ if (data.can_write && !data.app_installed && retryCount === 0) {
128
+ retryTimeoutRef.current = setTimeout(() => {
129
+ setRetryCount(1);
130
+ }, 1000);
131
+ }
132
+ })
133
+ .catch(() => {
134
+ if (!cancelled) setAccessInfo({ can_write: false, app_installed: false, auth_type: "none" });
135
+ });
136
+
137
+ // B) Tree count for the selected branch
138
+ // Don't clear fileCount β€” keep stale value visible until new one arrives
139
+ const hadFileCount = fileCount > 0;
140
+ if (!hadFileCount) setAnalyzing(true);
141
+
142
+ fetch(`/api/repos/${repo.owner}/${repo.name}/tree?ref=${encodeURIComponent(branch)}&_t=${Date.now()}`, {
143
+ headers,
144
+ cache: "no-cache",
145
+ })
146
+ .then(async (res) => {
147
+ if (cancelled) return;
148
+ const data = await res.json().catch(() => ({}));
149
+ if (!res.ok) {
150
+ setTreeError(data.detail || "Failed to load tree");
151
+ setFileCount(0);
152
+ return;
153
+ }
154
+ setFileCount(Array.isArray(data.files) ? data.files.length : 0);
155
+ })
156
+ .catch((err) => {
157
+ if (cancelled) return;
158
+ setTreeError(err.message);
159
+ setFileCount(0);
160
+ })
161
+ .finally(() => { if (!cancelled) setAnalyzing(false); });
162
+
163
+ return () => {
164
+ cancelled = true;
165
+ if (retryTimeoutRef.current) clearTimeout(retryTimeoutRef.current);
166
+ };
167
+ // eslint-disable-next-line react-hooks/exhaustive-deps
168
+ }, [repo?.owner, repo?.name, branch, refreshTrigger, retryCount]);
169
+
170
+ const showToast = (title, msg) => {
171
+ setToast({ visible: true, title, msg });
172
+ setTimeout(() => setToast((prev) => ({ ...prev, visible: false })), 3000);
173
+ };
174
+
175
+ const handleManualSwitch = (targetBranch) => {
176
+ if (!targetBranch || targetBranch === branch) {
177
+ setIsDropdownOpen(false);
178
+ return;
179
+ }
180
+
181
+ // Local UI feedback (App.jsx will handle the actual state change)
182
+ const goingAi = sessionBranches.includes(targetBranch);
183
+ showToast(
184
+ goingAi ? "Context Switched" : "Switched to Production",
185
+ goingAi ? `Viewing AI Session: ${targetBranch}` : `Viewing ${targetBranch}.`
186
+ );
187
+
188
+ setIsDropdownOpen(false);
189
+ if (onBranchChange) onBranchChange(targetBranch);
190
+ };
191
+
192
+ const handleRefresh = () => {
193
+ setAnalyzing(true);
194
+ setRetryCount(0);
195
+ setRefreshTrigger((prev) => prev + 1);
196
+ };
197
+
198
+ const handleInstallClick = () => {
199
+ if (!appUrl) return;
200
+ const targetUrl = appUrl.endsWith("/") ? `${appUrl}installations/new` : `${appUrl}/installations/new`;
201
+ window.open(targetUrl, "_blank", "noopener,noreferrer");
202
+ };
203
+
204
+ // --- STYLES ---
205
+ const theme = useMemo(
206
+ () => ({
207
+ bg: "#131316",
208
+ border: "#27272A",
209
+ textPrimary: "#EDEDED",
210
+ textSecondary: "#A1A1AA",
211
+ accent: "#3b82f6",
212
+ warningBorder: "rgba(245, 158, 11, 0.2)",
213
+ warningText: "#F59E0B",
214
+ successColor: "#10B981",
215
+ cardBg: "#18181B",
216
+ aiBg: "rgba(59, 130, 246, 0.10)",
217
+ aiBorder: "rgba(59, 130, 246, 0.30)",
218
+ aiText: "#60a5fa",
219
+ }),
220
+ []
221
+ );
222
+
223
+ const styles = useMemo(
224
+ () => ({
225
+ container: {
226
+ height: "100%",
227
+ borderRight: `1px solid ${theme.border}`,
228
+ backgroundColor: theme.bg,
229
+ display: "flex",
230
+ flexDirection: "column",
231
+ fontFamily: '"SΓΆhne", "Inter", sans-serif',
232
+ position: "relative",
233
+ overflow: "hidden",
234
+ },
235
+ header: {
236
+ padding: "16px 20px",
237
+ borderBottom: `1px solid ${theme.border}`,
238
+ display: "flex",
239
+ alignItems: "center",
240
+ justifyContent: "space-between",
241
+ transition: "background-color 0.3s ease",
242
+ },
243
+ titleGroup: { display: "flex", alignItems: "center", gap: "8px" },
244
+ title: { fontSize: "13px", fontWeight: "600", color: theme.textPrimary },
245
+ repoBadge: {
246
+ backgroundColor: "#27272A",
247
+ color: theme.textSecondary,
248
+ fontSize: "11px",
249
+ padding: "2px 8px",
250
+ borderRadius: "12px",
251
+ border: `1px solid ${theme.border}`,
252
+ fontFamily: "monospace",
253
+ },
254
+ aiBadge: {
255
+ display: "flex",
256
+ alignItems: "center",
257
+ gap: "6px",
258
+ backgroundColor: theme.aiBg,
259
+ color: theme.aiText,
260
+ fontSize: "10px",
261
+ fontWeight: "bold",
262
+ padding: "2px 8px",
263
+ borderRadius: "12px",
264
+ border: `1px solid ${theme.aiBorder}`,
265
+ textTransform: "uppercase",
266
+ letterSpacing: "0.5px",
267
+ },
268
+ content: {
269
+ padding: "16px 20px 12px 20px",
270
+ display: "flex",
271
+ flexDirection: "column",
272
+ gap: "12px",
273
+ },
274
+ statRow: { display: "flex", justifyContent: "space-between", fontSize: "13px", marginBottom: "4px" },
275
+ label: { color: theme.textSecondary },
276
+ value: { color: theme.textPrimary, fontWeight: "500" },
277
+ dropdownContainer: { position: "relative" },
278
+ branchButton: {
279
+ display: "flex",
280
+ alignItems: "center",
281
+ gap: "6px",
282
+ padding: "4px 8px",
283
+ borderRadius: "4px",
284
+ border: `1px solid ${isAiSession ? theme.aiBorder : theme.border}`,
285
+ backgroundColor: isAiSession ? "rgba(59, 130, 246, 0.05)" : "transparent",
286
+ color: isAiSession ? theme.aiText : theme.textPrimary,
287
+ fontSize: "13px",
288
+ cursor: "pointer",
289
+ fontFamily: "monospace",
290
+ },
291
+ dropdownMenu: {
292
+ position: "absolute",
293
+ top: "100%",
294
+ left: 0,
295
+ marginTop: "4px",
296
+ width: "240px",
297
+ backgroundColor: "#1F1F23",
298
+ border: `1px solid ${theme.border}`,
299
+ borderRadius: "6px",
300
+ boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
301
+ zIndex: 50,
302
+ display: isDropdownOpen ? "block" : "none",
303
+ overflow: "hidden",
304
+ },
305
+ dropdownItem: {
306
+ padding: "8px 12px",
307
+ fontSize: "13px",
308
+ color: theme.textSecondary,
309
+ cursor: "pointer",
310
+ display: "flex",
311
+ alignItems: "center",
312
+ gap: "8px",
313
+ borderBottom: `1px solid ${theme.border}`,
314
+ },
315
+ contextBanner: {
316
+ backgroundColor: theme.aiBg,
317
+ borderTop: `1px solid ${theme.aiBorder}`,
318
+ padding: "8px 20px",
319
+ fontSize: "11px",
320
+ color: theme.aiText,
321
+ display: "flex",
322
+ justifyContent: "space-between",
323
+ alignItems: "center",
324
+ },
325
+ toast: {
326
+ position: "absolute",
327
+ top: "16px",
328
+ right: "16px",
329
+ backgroundColor: "#18181B",
330
+ border: `1px solid ${theme.border}`,
331
+ borderLeft: `3px solid ${theme.accent}`,
332
+ borderRadius: "6px",
333
+ padding: "12px",
334
+ boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
335
+ zIndex: 100,
336
+ minWidth: "240px",
337
+ transition: "all 0.3s cubic-bezier(0.16, 1, 0.3, 1)",
338
+ transform: toast.visible ? "translateX(0)" : "translateX(120%)",
339
+ opacity: toast.visible ? 1 : 0,
340
+ },
341
+ toastTitle: { fontSize: "13px", fontWeight: "bold", color: theme.textPrimary, marginBottom: "2px" },
342
+ toastMsg: { fontSize: "11px", color: theme.textSecondary },
343
+ refreshButton: {
344
+ marginTop: "8px",
345
+ height: "32px",
346
+ padding: "0 12px",
347
+ backgroundColor: "transparent",
348
+ color: theme.textSecondary,
349
+ border: `1px solid ${theme.border}`,
350
+ borderRadius: "6px",
351
+ fontSize: "12px",
352
+ cursor: analyzing ? "not-allowed" : "pointer",
353
+ display: "flex",
354
+ alignItems: "center",
355
+ justifyContent: "center",
356
+ gap: "6px",
357
+ },
358
+ settingsBtn: {
359
+ display: "flex",
360
+ alignItems: "center",
361
+ justifyContent: "center",
362
+ width: "28px",
363
+ height: "28px",
364
+ borderRadius: "6px",
365
+ border: `1px solid ${theme.border}`,
366
+ backgroundColor: "transparent",
367
+ color: theme.textSecondary,
368
+ cursor: "pointer",
369
+ padding: 0,
370
+ transition: "color 0.15s, border-color 0.15s",
371
+ },
372
+ treeWrapper: { flex: 1, overflow: "auto", borderTop: `1px solid ${theme.border}` },
373
+ installCard: {
374
+ marginTop: "8px",
375
+ padding: "12px",
376
+ borderRadius: "8px",
377
+ backgroundColor: theme.cardBg,
378
+ border: `1px solid ${theme.warningBorder}`,
379
+ },
380
+ installHeader: {
381
+ display: "flex",
382
+ alignItems: "center",
383
+ gap: "10px",
384
+ fontSize: "14px",
385
+ fontWeight: "600",
386
+ color: theme.textPrimary,
387
+ },
388
+ installText: {
389
+ fontSize: "13px",
390
+ color: theme.textSecondary,
391
+ lineHeight: "1.5",
392
+ },
393
+ }),
394
+ [analyzing, isAiSession, isDropdownOpen, theme, toast.visible]
395
+ );
396
+
397
+ // Determine status text
398
+ let statusText = "Checking...";
399
+ let statusColor = theme.textSecondary;
400
+ let showInstallCard = false;
401
+
402
+ if (!analyzing && accessInfo) {
403
+ if (accessInfo.app_installed) {
404
+ statusText = "Write Access βœ“";
405
+ statusColor = theme.successColor;
406
+ } else if (accessInfo.can_write && retryCount === 0) {
407
+ statusText = "Verifying...";
408
+ } else if (accessInfo.can_write) {
409
+ statusText = "Push Access (No App)";
410
+ statusColor = theme.warningText;
411
+ showInstallCard = true;
412
+ } else {
413
+ statusText = "Read Only";
414
+ statusColor = theme.warningText;
415
+ showInstallCard = true;
416
+ }
417
+ }
418
+
419
+ if (!repo) {
420
+ return (
421
+ <div style={styles.container}>
422
+ <div style={styles.content}>Select a Repo</div>
423
+ </div>
424
+ );
425
+ }
426
+
427
+ return (
428
+ <div style={styles.container}>
429
+ <style>{animationStyles}</style>
430
+
431
+ {/* TOAST */}
432
+ <div style={styles.toast}>
433
+ <div style={styles.toastTitle}>{toast.title}</div>
434
+ <div style={styles.toastMsg}>{toast.msg}</div>
435
+ </div>
436
+
437
+ {/* HEADER */}
438
+ <div style={styles.header} className={animateHeader ? "pulse-context" : ""}>
439
+ <div style={styles.titleGroup}>
440
+ <span style={styles.title}>Project context</span>
441
+ {isAiSession && (
442
+ <span style={styles.aiBadge}>
443
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
444
+ <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
445
+ </svg>
446
+ AI Session
447
+ </span>
448
+ )}
449
+ </div>
450
+ <div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
451
+ {!isAiSession && <span style={styles.repoBadge}>{repo.name}</span>}
452
+ {onSettingsClick && (
453
+ <button
454
+ type="button"
455
+ onClick={onSettingsClick}
456
+ title="Project settings"
457
+ style={styles.settingsBtn}
458
+ >
459
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
460
+ <circle cx="12" cy="12" r="3" />
461
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
462
+ </svg>
463
+ </button>
464
+ )}
465
+ </div>
466
+ </div>
467
+
468
+ {/* CONTENT */}
469
+ <div style={styles.content}>
470
+ {/* Branch selector (Claude-Code-on-Web parity β€” uses BranchPicker with search) */}
471
+ <div style={styles.statRow}>
472
+ <span style={styles.label}>Branch:</span>
473
+ <BranchPicker
474
+ repo={repo}
475
+ currentBranch={branch}
476
+ defaultBranch={effectiveDefaultBranch}
477
+ sessionBranches={sessionBranches}
478
+ onBranchChange={handleManualSwitch}
479
+ />
480
+ </div>
481
+
482
+ {/* Stats */}
483
+ <div style={styles.statRow}>
484
+ <span style={styles.label}>Files:</span>
485
+ <span style={styles.value}>{analyzing ? "…" : fileCount}</span>
486
+ </div>
487
+
488
+ <div style={styles.statRow}>
489
+ <span style={styles.label}>Status:</span>
490
+ <span style={{ ...styles.value, color: statusColor }}>{statusText}</span>
491
+ </div>
492
+
493
+ {/* Tree error (optional display) */}
494
+ {treeError && (
495
+ <div style={{ fontSize: 11, color: theme.warningText }}>
496
+ {treeError}
497
+ </div>
498
+ )}
499
+
500
+ {/* Refresh */}
501
+ <button type="button" style={styles.refreshButton} onClick={handleRefresh} disabled={analyzing}>
502
+ <svg
503
+ width="14"
504
+ height="14"
505
+ viewBox="0 0 24 24"
506
+ fill="none"
507
+ stroke="currentColor"
508
+ strokeWidth="2"
509
+ style={{
510
+ transform: analyzing ? "rotate(360deg)" : "rotate(0deg)",
511
+ transition: "transform 0.6s ease",
512
+ }}
513
+ >
514
+ <path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
515
+ </svg>
516
+ {analyzing ? "Refreshing..." : "Refresh"}
517
+ </button>
518
+
519
+ {/* Install card */}
520
+ {showInstallCard && (
521
+ <div style={styles.installCard}>
522
+ <div style={styles.installHeader}>
523
+ <span>⚑</span>
524
+ <span>Enable Write Access</span>
525
+ </div>
526
+ <p style={{ ...styles.installText, margin: "8px 0" }}>
527
+ Install the GitPilot App to enable AI agent operations.
528
+ </p>
529
+ <p style={{ ...styles.installText, margin: "0 0 8px 0", fontSize: "11px", opacity: 0.7 }}>
530
+ Alternatively, use Folder or Local Git mode for local-first workflows without GitHub.
531
+ </p>
532
+ <button
533
+ type="button"
534
+ style={{
535
+ ...styles.refreshButton,
536
+ width: "100%",
537
+ backgroundColor: theme.accent,
538
+ color: "#fff",
539
+ border: "none",
540
+ }}
541
+ onClick={handleInstallClick}
542
+ >
543
+ Install App
544
+ </button>
545
+ </div>
546
+ )}
547
+ </div>
548
+
549
+ {/* Context banner */}
550
+ {isAiSession && (
551
+ <div style={styles.contextBanner}>
552
+ <span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
553
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
554
+ <circle cx="12" cy="12" r="10"></circle>
555
+ <line x1="12" y1="16" x2="12" y2="12"></line>
556
+ <line x1="12" y1="8" x2="12.01" y2="8"></line>
557
+ </svg>
558
+ You are viewing an AI Session branch.
559
+ </span>
560
+ <span style={{ textDecoration: "underline", cursor: "pointer" }} onClick={() => handleManualSwitch(effectiveDefaultBranch)}>
561
+ Return to {effectiveDefaultBranch}
562
+ </span>
563
+ </div>
564
+ )}
565
+
566
+ {/* File tree (branch-aware) */}
567
+ <div style={styles.treeWrapper}>
568
+ <FileTree repo={repo} refreshTrigger={refreshTrigger} branch={branch} />
569
+ </div>
570
+ </div>
571
+ );
572
+ }
frontend/components/ProjectSettings/ContextTab.jsx ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ export default function ContextTab({ owner, repo }) {
4
+ const [assets, setAssets] = useState([]);
5
+ const [busy, setBusy] = useState(false);
6
+ const [error, setError] = useState("");
7
+ const [uploadHint, setUploadHint] = useState("");
8
+ const inputRef = useRef(null);
9
+
10
+ const canUse = useMemo(() => Boolean(owner && repo), [owner, repo]);
11
+
12
+ async function loadAssets() {
13
+ if (!canUse) return;
14
+ setError("");
15
+ try {
16
+ const res = await fetch(`/api/repos/${owner}/${repo}/context/assets`);
17
+ if (!res.ok) throw new Error(`Failed to list assets (${res.status})`);
18
+ const data = await res.json();
19
+ setAssets(data.assets || []);
20
+ } catch (e) {
21
+ setError(e?.message || "Failed to load assets");
22
+ }
23
+ }
24
+
25
+ useEffect(() => {
26
+ loadAssets();
27
+ // eslint-disable-next-line react-hooks/exhaustive-deps
28
+ }, [owner, repo]);
29
+
30
+ async function uploadFiles(fileList) {
31
+ if (!canUse) return;
32
+ const files = Array.from(fileList || []);
33
+ if (!files.length) return;
34
+
35
+ setBusy(true);
36
+ setError("");
37
+ setUploadHint(`Uploading ${files.length} file(s)...`);
38
+
39
+ try {
40
+ for (const f of files) {
41
+ const form = new FormData();
42
+ form.append("file", f);
43
+
44
+ const res = await fetch(
45
+ `/api/repos/${owner}/${repo}/context/assets/upload`,
46
+ { method: "POST", body: form }
47
+ );
48
+ if (!res.ok) {
49
+ const txt = await res.text().catch(() => "");
50
+ throw new Error(`Upload failed (${res.status}) ${txt}`);
51
+ }
52
+ }
53
+ setUploadHint("Upload complete. Refreshing list...");
54
+ await loadAssets();
55
+ setUploadHint("");
56
+ } catch (e) {
57
+ setError(e?.message || "Upload failed");
58
+ setUploadHint("");
59
+ } finally {
60
+ setBusy(false);
61
+ if (inputRef.current) inputRef.current.value = "";
62
+ }
63
+ }
64
+
65
+ async function deleteAsset(assetId) {
66
+ if (!canUse) return;
67
+ const ok = window.confirm("Delete this asset? This cannot be undone.");
68
+ if (!ok) return;
69
+
70
+ setBusy(true);
71
+ setError("");
72
+ try {
73
+ const res = await fetch(
74
+ `/api/repos/${owner}/${repo}/context/assets/${assetId}`,
75
+ { method: "DELETE" }
76
+ );
77
+ if (!res.ok) throw new Error(`Delete failed (${res.status})`);
78
+ await loadAssets();
79
+ } catch (e) {
80
+ setError(e?.message || "Delete failed");
81
+ } finally {
82
+ setBusy(false);
83
+ }
84
+ }
85
+
86
+ function downloadAsset(assetId) {
87
+ if (!canUse) return;
88
+ window.open(
89
+ `/api/repos/${owner}/${repo}/context/assets/${assetId}/download`,
90
+ "_blank"
91
+ );
92
+ }
93
+
94
+ const empty = !assets || assets.length === 0;
95
+
96
+ return (
97
+ <div style={styles.wrap}>
98
+ <div style={styles.topRow}>
99
+ <div style={styles.left}>
100
+ <div style={styles.h1}>Project Context</div>
101
+ <div style={styles.h2}>
102
+ Upload documents, transcripts, screenshots, etc. (non-destructive,
103
+ additive).
104
+ </div>
105
+ </div>
106
+
107
+ <div style={styles.right}>
108
+ <input
109
+ ref={inputRef}
110
+ type="file"
111
+ multiple
112
+ disabled={!canUse || busy}
113
+ onChange={(e) => uploadFiles(e.target.files)}
114
+ style={styles.fileInput}
115
+ />
116
+ <button
117
+ style={styles.btn}
118
+ disabled={!canUse || busy}
119
+ onClick={() => inputRef.current?.click()}
120
+ >
121
+ Upload
122
+ </button>
123
+ <button
124
+ style={styles.btn}
125
+ disabled={!canUse || busy}
126
+ onClick={loadAssets}
127
+ >
128
+ Refresh
129
+ </button>
130
+ </div>
131
+ </div>
132
+
133
+ <div
134
+ style={styles.dropzone}
135
+ onDragOver={(e) => {
136
+ e.preventDefault();
137
+ e.stopPropagation();
138
+ }}
139
+ onDrop={(e) => {
140
+ e.preventDefault();
141
+ e.stopPropagation();
142
+ if (busy) return;
143
+ uploadFiles(e.dataTransfer.files);
144
+ }}
145
+ >
146
+ <div style={styles.dropText}>
147
+ Drag & drop files here, or click <b>Upload</b>.
148
+ </div>
149
+ <div style={styles.dropSub}>
150
+ Tip: For audio/video, upload a transcript file too.
151
+ </div>
152
+ </div>
153
+
154
+ {uploadHint ? <div style={styles.hint}>{uploadHint}</div> : null}
155
+ {error ? <div style={styles.error}>{error}</div> : null}
156
+
157
+ <div style={styles.tableWrap}>
158
+ <div style={styles.tableHeader}>
159
+ <div style={{ ...styles.col, ...styles.colName }}>File</div>
160
+ <div style={{ ...styles.col, ...styles.colMeta }}>Type</div>
161
+ <div style={{ ...styles.col, ...styles.colMeta }}>Size</div>
162
+ <div style={{ ...styles.col, ...styles.colMeta }}>Indexed</div>
163
+ <div style={{ ...styles.col, ...styles.colActions }}>Actions</div>
164
+ </div>
165
+
166
+ {empty ? (
167
+ <div style={styles.empty}>
168
+ No context assets yet. Upload docs, transcripts, and screenshots to
169
+ improve planning quality.
170
+ </div>
171
+ ) : (
172
+ assets.map((a) => (
173
+ <div key={a.asset_id} style={styles.row}>
174
+ <div style={{ ...styles.col, ...styles.colName }}>
175
+ <div style={styles.fileName}>{a.filename}</div>
176
+ <div style={styles.small}>
177
+ Added: {a.created_at || "-"} | Extracted:{" "}
178
+ {Number(a.extracted_chars || 0).toLocaleString()} chars
179
+ </div>
180
+ </div>
181
+
182
+ <div style={{ ...styles.col, ...styles.colMeta }}>
183
+ <span style={styles.badge}>{a.mime || "unknown"}</span>
184
+ </div>
185
+
186
+ <div style={{ ...styles.col, ...styles.colMeta }}>
187
+ {formatBytes(a.size_bytes || 0)}
188
+ </div>
189
+
190
+ <div style={{ ...styles.col, ...styles.colMeta }}>
191
+ {a.indexed_chunks || 0} chunks
192
+ </div>
193
+
194
+ <div style={{ ...styles.col, ...styles.colActions }}>
195
+ <button
196
+ style={styles.smallBtn}
197
+ disabled={busy}
198
+ onClick={() => downloadAsset(a.asset_id)}
199
+ >
200
+ Download
201
+ </button>
202
+ <button
203
+ style={{ ...styles.smallBtn, ...styles.dangerBtn }}
204
+ disabled={busy}
205
+ onClick={() => deleteAsset(a.asset_id)}
206
+ >
207
+ Delete
208
+ </button>
209
+ </div>
210
+ </div>
211
+ ))
212
+ )}
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ function formatBytes(bytes) {
219
+ const b = Number(bytes || 0);
220
+ if (!b) return "0 B";
221
+ const units = ["B", "KB", "MB", "GB", "TB"];
222
+ let i = 0;
223
+ let v = b;
224
+ while (v >= 1024 && i < units.length - 1) {
225
+ v /= 1024;
226
+ i += 1;
227
+ }
228
+ return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
229
+ }
230
+
231
+ const styles = {
232
+ wrap: { display: "flex", flexDirection: "column", gap: 12 },
233
+ topRow: {
234
+ display: "flex",
235
+ justifyContent: "space-between",
236
+ gap: 12,
237
+ alignItems: "flex-start",
238
+ flexWrap: "wrap",
239
+ },
240
+ left: { minWidth: 280 },
241
+ right: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" },
242
+ h1: { fontSize: 14, fontWeight: 800, color: "#fff" },
243
+ h2: { fontSize: 12, color: "rgba(255,255,255,0.65)", marginTop: 4 },
244
+ fileInput: { display: "none" },
245
+ btn: {
246
+ background: "rgba(255,255,255,0.10)",
247
+ border: "1px solid rgba(255,255,255,0.18)",
248
+ color: "#fff",
249
+ borderRadius: 10,
250
+ padding: "8px 10px",
251
+ cursor: "pointer",
252
+ fontSize: 13,
253
+ },
254
+ dropzone: {
255
+ border: "1px dashed rgba(255,255,255,0.22)",
256
+ borderRadius: 12,
257
+ padding: 16,
258
+ background: "rgba(255,255,255,0.03)",
259
+ },
260
+ dropText: { color: "rgba(255,255,255,0.85)", fontSize: 13 },
261
+ dropSub: { color: "rgba(255,255,255,0.55)", fontSize: 12, marginTop: 6 },
262
+ hint: {
263
+ color: "rgba(255,255,255,0.75)",
264
+ fontSize: 12,
265
+ padding: "8px 10px",
266
+ border: "1px solid rgba(255,255,255,0.12)",
267
+ borderRadius: 10,
268
+ background: "rgba(255,255,255,0.03)",
269
+ },
270
+ error: {
271
+ color: "#ffb3b3",
272
+ fontSize: 12,
273
+ padding: "8px 10px",
274
+ border: "1px solid rgba(255,120,120,0.25)",
275
+ borderRadius: 10,
276
+ background: "rgba(255,80,80,0.08)",
277
+ },
278
+ tableWrap: {
279
+ border: "1px solid rgba(255,255,255,0.12)",
280
+ borderRadius: 12,
281
+ overflow: "hidden",
282
+ },
283
+ tableHeader: {
284
+ display: "grid",
285
+ gridTemplateColumns: "1.6fr 1fr 0.6fr 0.6fr 0.8fr",
286
+ gap: 0,
287
+ padding: "10px 12px",
288
+ background: "rgba(255,255,255,0.03)",
289
+ borderBottom: "1px solid rgba(255,255,255,0.10)",
290
+ fontSize: 12,
291
+ color: "rgba(255,255,255,0.65)",
292
+ },
293
+ row: {
294
+ display: "grid",
295
+ gridTemplateColumns: "1.6fr 1fr 0.6fr 0.6fr 0.8fr",
296
+ padding: "10px 12px",
297
+ borderBottom: "1px solid rgba(255,255,255,0.08)",
298
+ alignItems: "center",
299
+ },
300
+ col: { minWidth: 0 },
301
+ colName: {},
302
+ colMeta: { color: "rgba(255,255,255,0.75)", fontSize: 12 },
303
+ colActions: { display: "flex", gap: 8, justifyContent: "flex-end" },
304
+ fileName: {
305
+ color: "#fff",
306
+ fontSize: 13,
307
+ fontWeight: 700,
308
+ overflow: "hidden",
309
+ textOverflow: "ellipsis",
310
+ whiteSpace: "nowrap",
311
+ },
312
+ small: {
313
+ color: "rgba(255,255,255,0.55)",
314
+ fontSize: 11,
315
+ marginTop: 4,
316
+ overflow: "hidden",
317
+ textOverflow: "ellipsis",
318
+ whiteSpace: "nowrap",
319
+ },
320
+ badge: {
321
+ display: "inline-flex",
322
+ alignItems: "center",
323
+ padding: "2px 8px",
324
+ borderRadius: 999,
325
+ border: "1px solid rgba(255,255,255,0.16)",
326
+ background: "rgba(255,255,255,0.04)",
327
+ fontSize: 11,
328
+ color: "rgba(255,255,255,0.80)",
329
+ maxWidth: "100%",
330
+ overflow: "hidden",
331
+ textOverflow: "ellipsis",
332
+ whiteSpace: "nowrap",
333
+ },
334
+ smallBtn: {
335
+ background: "rgba(255,255,255,0.08)",
336
+ border: "1px solid rgba(255,255,255,0.16)",
337
+ color: "#fff",
338
+ borderRadius: 10,
339
+ padding: "6px 8px",
340
+ cursor: "pointer",
341
+ fontSize: 12,
342
+ },
343
+ dangerBtn: {
344
+ border: "1px solid rgba(255,90,90,0.35)",
345
+ background: "rgba(255,90,90,0.10)",
346
+ },
347
+ empty: {
348
+ padding: 14,
349
+ color: "rgba(255,255,255,0.65)",
350
+ fontSize: 13,
351
+ },
352
+ };
frontend/components/ProjectSettings/ConventionsTab.jsx ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useMemo, useState } from "react";
2
+
3
+ export default function ConventionsTab({ owner, repo }) {
4
+ const [content, setContent] = useState("");
5
+ const [busy, setBusy] = useState(false);
6
+ const [error, setError] = useState("");
7
+
8
+ const canUse = useMemo(() => Boolean(owner && repo), [owner, repo]);
9
+
10
+ async function load() {
11
+ if (!canUse) return;
12
+ setError("");
13
+ setBusy(true);
14
+ try {
15
+ const res = await fetch(`/api/repos/${owner}/${repo}/context`);
16
+ if (!res.ok) throw new Error(`Failed to load conventions (${res.status})`);
17
+ const data = await res.json();
18
+ // backend may return { context: "..."} or { conventions: "..."} depending on implementation
19
+ setContent(data.context || data.conventions || data.memory || data.text || "");
20
+ } catch (e) {
21
+ setError(e?.message || "Failed to load conventions");
22
+ } finally {
23
+ setBusy(false);
24
+ }
25
+ }
26
+
27
+ async function initialize() {
28
+ if (!canUse) return;
29
+ setError("");
30
+ setBusy(true);
31
+ try {
32
+ const res = await fetch(`/api/repos/${owner}/${repo}/context/init`, {
33
+ method: "POST",
34
+ });
35
+ if (!res.ok) {
36
+ const txt = await res.text().catch(() => "");
37
+ throw new Error(`Init failed (${res.status}) ${txt}`);
38
+ }
39
+ await load();
40
+ } catch (e) {
41
+ setError(e?.message || "Init failed");
42
+ } finally {
43
+ setBusy(false);
44
+ }
45
+ }
46
+
47
+ useEffect(() => {
48
+ load();
49
+ // eslint-disable-next-line react-hooks/exhaustive-deps
50
+ }, [owner, repo]);
51
+
52
+ return (
53
+ <div style={styles.wrap}>
54
+ <div style={styles.topRow}>
55
+ <div>
56
+ <div style={styles.h1}>Project Conventions</div>
57
+ <div style={styles.h2}>
58
+ This is the project memory/conventions file used by GitPilot.
59
+ </div>
60
+ </div>
61
+ <div style={styles.actions}>
62
+ <button style={styles.btn} disabled={!canUse || busy} onClick={load}>
63
+ Refresh
64
+ </button>
65
+ <button
66
+ style={styles.btn}
67
+ disabled={!canUse || busy}
68
+ onClick={initialize}
69
+ >
70
+ Initialize
71
+ </button>
72
+ </div>
73
+ </div>
74
+
75
+ {error ? <div style={styles.error}>{error}</div> : null}
76
+
77
+ <div style={styles.box}>
78
+ {content ? (
79
+ <pre style={styles.pre}>{content}</pre>
80
+ ) : (
81
+ <div style={styles.empty}>
82
+ No conventions found yet. Click <b>Initialize</b> to create default
83
+ project memory if supported.
84
+ </div>
85
+ )}
86
+ </div>
87
+
88
+ <div style={styles.note}>
89
+ Editing conventions is intentionally not included here to keep this
90
+ feature additive/non-destructive. You can extend this later with an
91
+ explicit &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,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from "react";
2
+
3
+ export default function SettingsModal({ onClose }) {
4
+ const [settings, setSettings] = useState(null);
5
+ const [models, setModels] = useState([]);
6
+ const [modelsError, setModelsError] = useState(null);
7
+ const [loadingModels, setLoadingModels] = useState(false);
8
+ const [testResult, setTestResult] = useState(null); // { ok: bool, message: string }
9
+ const [testing, setTesting] = useState(false);
10
+
11
+ const loadSettings = async () => {
12
+ const res = await fetch("/api/settings");
13
+ const data = await res.json();
14
+ setSettings(data);
15
+ };
16
+
17
+ useEffect(() => {
18
+ loadSettings();
19
+ }, []);
20
+
21
+ const changeProvider = async (provider) => {
22
+ const res = await fetch("/api/settings/provider", {
23
+ method: "POST",
24
+ headers: { "Content-Type": "application/json" },
25
+ body: JSON.stringify({ provider }),
26
+ });
27
+ const data = await res.json();
28
+ setSettings(data);
29
+
30
+ // Reset models state when provider changes
31
+ setModels([]);
32
+ setModelsError(null);
33
+ };
34
+
35
+ const loadModels = async () => {
36
+ if (!settings) return;
37
+ setLoadingModels(true);
38
+ setModelsError(null);
39
+ try {
40
+ const res = await fetch(
41
+ `/api/settings/models?provider=${settings.provider}`
42
+ );
43
+ const data = await res.json();
44
+ if (data.error) {
45
+ setModelsError(data.error);
46
+ setModels([]);
47
+ } else {
48
+ setModels(data.models || []);
49
+ }
50
+ } catch (err) {
51
+ console.error(err);
52
+ setModelsError("Failed to load models");
53
+ setModels([]);
54
+ } finally {
55
+ setLoadingModels(false);
56
+ }
57
+ };
58
+
59
+ const currentModelForActiveProvider = () => {
60
+ if (!settings) return "";
61
+ const p = settings.provider;
62
+ if (p === "openai") return settings.openai?.model || "";
63
+ if (p === "claude") return settings.claude?.model || "";
64
+ if (p === "watsonx") return settings.watsonx?.model_id || "";
65
+ if (p === "ollama") return settings.ollama?.model || "";
66
+ return "";
67
+ };
68
+
69
+ const changeModel = async (model) => {
70
+ if (!settings) return;
71
+ const provider = settings.provider;
72
+
73
+ let payload = {};
74
+ if (provider === "openai") {
75
+ payload = {
76
+ openai: {
77
+ ...settings.openai,
78
+ model,
79
+ },
80
+ };
81
+ } else if (provider === "claude") {
82
+ payload = {
83
+ claude: {
84
+ ...settings.claude,
85
+ model,
86
+ },
87
+ };
88
+ } else if (provider === "watsonx") {
89
+ payload = {
90
+ watsonx: {
91
+ ...settings.watsonx,
92
+ model_id: model,
93
+ },
94
+ };
95
+ } else if (provider === "ollama") {
96
+ payload = {
97
+ ollama: {
98
+ ...settings.ollama,
99
+ model,
100
+ },
101
+ };
102
+ }
103
+
104
+ const res = await fetch("/api/settings/llm", {
105
+ method: "PUT",
106
+ headers: { "Content-Type": "application/json" },
107
+ body: JSON.stringify(payload),
108
+ });
109
+ const data = await res.json();
110
+ setSettings(data);
111
+ };
112
+
113
+ const testConnection = async () => {
114
+ if (!settings) return;
115
+ setTesting(true);
116
+ setTestResult(null);
117
+ try {
118
+ const res = await fetch(`/api/settings/test?provider=${settings.provider}`);
119
+ const data = await res.json();
120
+ if (!res.ok || data.error) {
121
+ setTestResult({ ok: false, message: data.error || data.detail || "Connection failed" });
122
+ } else {
123
+ setTestResult({ ok: true, message: data.message || "Connection successful" });
124
+ }
125
+ } catch (err) {
126
+ setTestResult({ ok: false, message: err.message || "Connection test failed" });
127
+ } finally {
128
+ setTesting(false);
129
+ }
130
+ };
131
+
132
+ const toggleLiteMode = async () => {
133
+ if (!settings) return;
134
+ const newValue = !settings.lite_mode;
135
+ try {
136
+ const res = await fetch("/api/settings/lite-mode", {
137
+ method: "POST",
138
+ headers: { "Content-Type": "application/json" },
139
+ body: JSON.stringify({ lite_mode: newValue }),
140
+ });
141
+ if (res.ok) {
142
+ setSettings((prev) => ({ ...prev, lite_mode: newValue }));
143
+ }
144
+ } catch (err) {
145
+ console.error("Failed to toggle lite mode:", err);
146
+ }
147
+ };
148
+
149
+ if (!settings) return null;
150
+
151
+ const activeModel = currentModelForActiveProvider();
152
+
153
+ return (
154
+ <div className="modal-backdrop" onClick={onClose}>
155
+ <div className="modal" onClick={(e) => e.stopPropagation()}>
156
+ <div className="modal-header">
157
+ <div className="modal-title">Settings</div>
158
+ <button className="modal-close" type="button" onClick={onClose}>
159
+ βœ•
160
+ </button>
161
+ </div>
162
+
163
+ <div style={{ fontSize: 13, color: "#c3c5dd" }}>
164
+ Select which LLM provider GitPilot should use for planning and chat.
165
+ </div>
166
+
167
+ <div className="provider-list">
168
+ {settings.providers.map((p) => (
169
+ <div
170
+ key={p}
171
+ className={
172
+ "provider-item" + (settings.provider === p ? " active" : "")
173
+ }
174
+ >
175
+ <div className="provider-name">{p}</div>
176
+ <button
177
+ type="button"
178
+ className="chat-btn secondary"
179
+ style={{ padding: "4px 8px", fontSize: 11 }}
180
+ onClick={() => changeProvider(p)}
181
+ disabled={settings.provider === p}
182
+ >
183
+ {settings.provider === p ? "Active" : "Use"}
184
+ </button>
185
+ </div>
186
+ ))}
187
+ </div>
188
+
189
+ {/* Models section */}
190
+ <div
191
+ style={{
192
+ marginTop: 16,
193
+ paddingTop: 12,
194
+ borderTop: "1px solid #2c2d46",
195
+ fontSize: 13,
196
+ }}
197
+ >
198
+ <div style={{ marginBottom: 6, color: "#c3c5dd" }}>
199
+ Active provider: <strong>{settings.provider}</strong>
200
+ </div>
201
+
202
+ <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
203
+ <button
204
+ type="button"
205
+ className="chat-btn secondary"
206
+ style={{ padding: "4px 8px", fontSize: 11 }}
207
+ onClick={testConnection}
208
+ disabled={testing}
209
+ >
210
+ {testing ? "Testing…" : "Test Connection"}
211
+ </button>
212
+ <button
213
+ type="button"
214
+ className="chat-btn secondary"
215
+ style={{ padding: "4px 8px", fontSize: 11 }}
216
+ onClick={loadModels}
217
+ disabled={loadingModels}
218
+ >
219
+ {loadingModels ? "Loading…" : "Display models"}
220
+ </button>
221
+
222
+ {activeModel && (
223
+ <span style={{ fontSize: 12, color: "#9092b5" }}>
224
+ Current model: <code>{activeModel}</code>
225
+ </span>
226
+ )}
227
+ </div>
228
+
229
+ {modelsError && (
230
+ <div style={{ marginTop: 8, color: "#ff8080", fontSize: 12 }}>
231
+ {modelsError}
232
+ </div>
233
+ )}
234
+
235
+ {testResult && (
236
+ <div style={{
237
+ marginTop: 8,
238
+ padding: "6px 10px",
239
+ borderRadius: 6,
240
+ background: testResult.ok ? "#0d3320" : "#3d1111",
241
+ border: `1px solid ${testResult.ok ? "#166534" : "#7f1d1d"}`,
242
+ color: testResult.ok ? "#86efac" : "#fca5a5",
243
+ fontSize: 12,
244
+ }}>
245
+ {testResult.ok ? "βœ“ " : "βœ— "}{testResult.message}
246
+ </div>
247
+ )}
248
+
249
+ {models.length > 0 && (
250
+ <div style={{ marginTop: 10 }}>
251
+ <label
252
+ style={{
253
+ display: "block",
254
+ marginBottom: 4,
255
+ fontSize: 12,
256
+ color: "#c3c5dd",
257
+ }}
258
+ >
259
+ Select model for {settings.provider}:
260
+ </label>
261
+ <select
262
+ style={{
263
+ width: "100%",
264
+ fontSize: 12,
265
+ padding: "4px 6px",
266
+ background: "#14152a",
267
+ color: "#e6e8ff",
268
+ border: "1px solid #2c2d46",
269
+ borderRadius: 4,
270
+ }}
271
+ value={activeModel}
272
+ onChange={(e) => changeModel(e.target.value)}
273
+ >
274
+ <option value="">-- select a model --</option>
275
+ {models.map((m) => (
276
+ <option key={m} value={m}>
277
+ {m}
278
+ </option>
279
+ ))}
280
+ </select>
281
+ </div>
282
+ )}
283
+ </div>
284
+
285
+ {/* Lite Mode section */}
286
+ <div
287
+ style={{
288
+ marginTop: 16,
289
+ paddingTop: 12,
290
+ borderTop: "1px solid #2c2d46",
291
+ fontSize: 13,
292
+ }}
293
+ >
294
+ <div
295
+ style={{
296
+ display: "flex",
297
+ alignItems: "center",
298
+ justifyContent: "space-between",
299
+ marginBottom: 6,
300
+ }}
301
+ >
302
+ <div style={{ color: "#c3c5dd", fontWeight: 600 }}>
303
+ Lite Mode
304
+ </div>
305
+ <button
306
+ type="button"
307
+ onClick={toggleLiteMode}
308
+ style={{
309
+ padding: "4px 14px",
310
+ fontSize: 11,
311
+ fontWeight: 600,
312
+ borderRadius: 12,
313
+ border: "none",
314
+ cursor: "pointer",
315
+ background: settings.lite_mode ? "#166534" : "#2c2d46",
316
+ color: settings.lite_mode ? "#86efac" : "#9092b5",
317
+ transition: "background 0.2s, color 0.2s",
318
+ }}
319
+ >
320
+ {settings.lite_mode ? "ON" : "OFF"}
321
+ </button>
322
+ </div>
323
+ <div style={{ fontSize: 11, color: "#9092b5", lineHeight: 1.5 }}>
324
+ Optimized for small models (under 7B parameters).
325
+ Uses simplified prompts and single-agent execution instead
326
+ of multi-agent pipelines. Recommended for: qwen2.5:1.5b,
327
+ phi-3-mini, gemma-2b, tinyllama, etc.
328
+ </div>
329
+ </div>
330
+ </div>
331
+ </div>
332
+ );
333
+ }
frontend/components/StartupScreen.jsx ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+
3
+ function normalizeProvider(provider) {
4
+ if (!provider) return "Checking...";
5
+ if (typeof provider === "string") return provider.toUpperCase();
6
+ if (typeof provider === "object") {
7
+ return (
8
+ provider.name ||
9
+ provider.provider ||
10
+ provider.type ||
11
+ provider.label ||
12
+ "Checking..."
13
+ );
14
+ }
15
+ return "Checking...";
16
+ }
17
+
18
+ function normalizeVersion(version) {
19
+ if (!version) return "Checking...";
20
+ return String(version);
21
+ }
22
+
23
+ export default function StartupScreen({
24
+ appName = "GitPilot",
25
+ subtitle = "Enterprise Workspace Copilot",
26
+ frontendVersion = "Checking...",
27
+ backendVersion = "Checking...",
28
+ provider = "Checking...",
29
+ statusMessage = "Starting application...",
30
+ detailMessage = "Initializing authentication, provider, and workspace context.",
31
+ phase = "booting",
32
+ }) {
33
+ const providerLabel = normalizeProvider(provider);
34
+ const frontendLabel = normalizeVersion(frontendVersion);
35
+ const backendLabel = normalizeVersion(backendVersion);
36
+
37
+ return (
38
+ <div className="startup-screen" role="status" aria-live="polite">
39
+ <div className="startup-card">
40
+ <div className="startup-brand-row">
41
+ <div className="startup-brand-mark" aria-hidden="true">
42
+ <div className="startup-brand-ring" />
43
+ <div className="startup-brand-core" />
44
+ </div>
45
+
46
+ <div className="startup-brand-copy">
47
+ <div className="startup-title">{appName}</div>
48
+ <div className="startup-subtitle">{subtitle}</div>
49
+ </div>
50
+ </div>
51
+
52
+ <div className="startup-loader-wrap" aria-hidden="true">
53
+ <div className="startup-loader">
54
+ <div className="startup-loader-ring startup-loader-ring-outer" />
55
+ <div className="startup-loader-ring startup-loader-ring-inner" />
56
+ </div>
57
+ </div>
58
+
59
+ <div className="startup-status-block">
60
+ <div className="startup-status">{statusMessage}</div>
61
+ <div className="startup-detail">{detailMessage}</div>
62
+ </div>
63
+
64
+ <div className="startup-phase-row">
65
+ <span className="startup-phase-badge">{phase}</span>
66
+ </div>
67
+
68
+ <div className="startup-meta-grid">
69
+ <div className="startup-meta-item">
70
+ <div className="startup-meta-label">Frontend</div>
71
+ <div className="startup-meta-value">v{frontendLabel}</div>
72
+ </div>
73
+
74
+ <div className="startup-meta-item">
75
+ <div className="startup-meta-label">Backend</div>
76
+ <div className="startup-meta-value">v{backendLabel}</div>
77
+ </div>
78
+
79
+ <div className="startup-meta-item startup-meta-item-wide">
80
+ <div className="startup-meta-label">Provider</div>
81
+ <div className="startup-meta-value">{providerLabel}</div>
82
+ </div>
83
+ </div>
84
+
85
+ <div className="startup-footer">
86
+ Preparing workspace services, restoring session state, and checking
87
+ platform readiness.
88
+ </div>
89
+ </div>
90
+ </div>
91
+ );
92
+ }
frontend/components/StreamingMessage.jsx ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+
3
+ /**
4
+ * StreamingMessage β€” Claude-Code-on-Web parity streaming renderer.
5
+ *
6
+ * Renders agent messages incrementally as they arrive via WebSocket.
7
+ * Shows tool use blocks (bash commands + output), explanatory text,
8
+ * and status indicators.
9
+ */
10
+ export default function StreamingMessage({ events }) {
11
+ if (!events || events.length === 0) return null;
12
+
13
+ return (
14
+ <div style={styles.container}>
15
+ {events.map((evt, idx) => (
16
+ <StreamingEvent key={idx} event={evt} isLast={idx === events.length - 1} />
17
+ ))}
18
+ </div>
19
+ );
20
+ }
21
+
22
+ function StreamingEvent({ event, isLast }) {
23
+ const { type } = event;
24
+
25
+ if (type === "agent_message") {
26
+ return (
27
+ <div style={styles.textBlock}>
28
+ <span>{event.content}</span>
29
+ {isLast && <span style={styles.cursor}>|</span>}
30
+ </div>
31
+ );
32
+ }
33
+
34
+ if (type === "tool_use") {
35
+ return (
36
+ <div style={styles.toolBlock}>
37
+ <div style={styles.toolHeader}>
38
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
39
+ <polyline points="4 17 10 11 4 5" />
40
+ <line x1="12" y1="19" x2="20" y2="19" />
41
+ </svg>
42
+ <span style={styles.toolName}>{event.tool || "terminal"}</span>
43
+ </div>
44
+ <div style={styles.toolInput}>
45
+ <code>$ {event.input}</code>
46
+ </div>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ if (type === "tool_result") {
52
+ return (
53
+ <div style={styles.toolBlock}>
54
+ <div style={styles.toolOutput}>
55
+ <pre style={styles.toolOutputPre}>{event.output}</pre>
56
+ </div>
57
+ </div>
58
+ );
59
+ }
60
+
61
+ if (type === "status_change") {
62
+ const statusLabels = {
63
+ active: "Working...",
64
+ waiting: "Waiting for input",
65
+ completed: "Completed",
66
+ failed: "Failed",
67
+ };
68
+ return (
69
+ <div style={styles.statusLine}>
70
+ <div style={{
71
+ ...styles.statusDot,
72
+ backgroundColor: {
73
+ active: "#F59E0B",
74
+ waiting: "#3B82F6",
75
+ completed: "#10B981",
76
+ failed: "#EF4444",
77
+ }[event.status] || "#6B7280",
78
+ }} />
79
+ <span>{statusLabels[event.status] || event.status}</span>
80
+ </div>
81
+ );
82
+ }
83
+
84
+ if (type === "diff_update") {
85
+ return null; // Handled by DiffStats in parent
86
+ }
87
+
88
+ if (type === "error") {
89
+ return (
90
+ <div style={styles.errorBlock}>
91
+ {event.message}
92
+ </div>
93
+ );
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ const styles = {
100
+ container: {
101
+ display: "flex",
102
+ flexDirection: "column",
103
+ gap: 4,
104
+ },
105
+ textBlock: {
106
+ fontSize: 14,
107
+ lineHeight: 1.6,
108
+ color: "#D4D4D8",
109
+ whiteSpace: "pre-wrap",
110
+ wordBreak: "break-word",
111
+ },
112
+ cursor: {
113
+ display: "inline-block",
114
+ animation: "blink 1s step-end infinite",
115
+ color: "#3B82F6",
116
+ fontWeight: 700,
117
+ },
118
+ toolBlock: {
119
+ margin: "4px 0",
120
+ borderRadius: 6,
121
+ border: "1px solid #27272A",
122
+ overflow: "hidden",
123
+ },
124
+ toolHeader: {
125
+ display: "flex",
126
+ alignItems: "center",
127
+ gap: 6,
128
+ padding: "6px 10px",
129
+ backgroundColor: "#18181B",
130
+ fontSize: 11,
131
+ color: "#71717A",
132
+ fontFamily: "monospace",
133
+ },
134
+ toolName: {
135
+ fontWeight: 600,
136
+ },
137
+ toolInput: {
138
+ padding: "8px 10px",
139
+ backgroundColor: "#0D0D0F",
140
+ fontFamily: "monospace",
141
+ fontSize: 12,
142
+ color: "#10B981",
143
+ whiteSpace: "pre-wrap",
144
+ wordBreak: "break-all",
145
+ },
146
+ toolOutput: {
147
+ padding: "8px 10px",
148
+ backgroundColor: "#0D0D0F",
149
+ maxHeight: 300,
150
+ overflowY: "auto",
151
+ },
152
+ toolOutputPre: {
153
+ margin: 0,
154
+ fontFamily: "monospace",
155
+ fontSize: 11,
156
+ color: "#A1A1AA",
157
+ whiteSpace: "pre-wrap",
158
+ wordBreak: "break-all",
159
+ },
160
+ statusLine: {
161
+ display: "flex",
162
+ alignItems: "center",
163
+ gap: 6,
164
+ padding: "4px 0",
165
+ fontSize: 12,
166
+ color: "#71717A",
167
+ fontStyle: "italic",
168
+ },
169
+ statusDot: {
170
+ width: 6,
171
+ height: 6,
172
+ borderRadius: "50%",
173
+ },
174
+ errorBlock: {
175
+ padding: "8px 12px",
176
+ borderRadius: 6,
177
+ backgroundColor: "rgba(239, 68, 68, 0.08)",
178
+ border: "1px solid rgba(239, 68, 68, 0.2)",
179
+ color: "#FCA5A5",
180
+ fontSize: 13,
181
+ },
182
+ };
frontend/components/ThinkingIndicator.jsx ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/ThinkingIndicator.jsx
2
+ //
3
+ // Compact, enterprise-grade thinking state. Sits inline in the chat
4
+ // timeline as a small assistant-style bubble:
5
+ //
6
+ // ● Reading repository... Β· Β· Β·
7
+ //
8
+ // Design goals (from the bug report):
9
+ // * Calm, precise, technical β€” no large card, no big glow, no
10
+ // all-caps "THINKING" label.
11
+ // * Sits inline next to other chat messages; ~36 px tall, auto width.
12
+ // * Tiny pulsing brand-orange dot as the only accent (no rings,
13
+ // no progress sweep, no nested animated panels).
14
+ // * Muted text, sentence case, task-specific labels that rotate
15
+ // ("Reading repository", "Building plan", "Checking context",
16
+ // "Preparing response").
17
+ // * Three tiny fading dots on the right as a generic "still working"
18
+ // signal.
19
+ //
20
+ // Implementation constraints (this codebase, not the proposal's):
21
+ // * No Tailwind β€” uses plain inline-style objects.
22
+ // * No framer-motion β€” uses CSS @keyframes in one scoped <style> tag.
23
+ // * No icon library β€” there are no glyphs in the final design.
24
+ // * Brand-correct colour β€” GitPilot orange ``#D95C3D`` (matches the
25
+ // Action Plan header and README badges), not Claude's ``#D97757``.
26
+ //
27
+ // API: accepts ``labels: string[]`` (defaults to the standard set)
28
+ // and an optional ``label`` to force a single non-rotating message.
29
+
30
+ import React, { useEffect, useState } from "react";
31
+
32
+ const BRAND_ORANGE = "#D95C3D";
33
+
34
+ const DEFAULT_LABELS = [
35
+ "Reading repository",
36
+ "Building plan",
37
+ "Checking context",
38
+ "Preparing response",
39
+ ];
40
+
41
+ const ROTATION_MS = 1800;
42
+
43
+ // Scoped keyframes. One <style> tag per mount; tiny enough that it
44
+ // would not be worth lifting to a global stylesheet.
45
+ const KEYFRAMES = `
46
+ @keyframes gp-thinking-mount {
47
+ from { opacity: 0; transform: translateY(2px); }
48
+ to { opacity: 1; transform: translateY(0); }
49
+ }
50
+ @keyframes gp-thinking-label {
51
+ from { opacity: 0; transform: translateY(1px); }
52
+ to { opacity: 1; transform: translateY(0); }
53
+ }
54
+ @keyframes gp-thinking-dot-pulse {
55
+ 0%, 100% { opacity: 0.50; transform: scale(1); }
56
+ 50% { opacity: 1.00; transform: scale(1.18); }
57
+ }
58
+ @keyframes gp-thinking-trail {
59
+ 0%, 100% { opacity: 0.25; }
60
+ 50% { opacity: 0.90; }
61
+ }
62
+ `;
63
+
64
+ export default function ThinkingIndicator({ label, labels = DEFAULT_LABELS }) {
65
+ const [step, setStep] = useState(0);
66
+
67
+ // Honour an explicit ``label`` prop (callers that already know what
68
+ // the agent is doing β€” e.g. "Planning changes…" during the plan
69
+ // round-trip) β€” otherwise rotate through the generic set.
70
+ const useRotation = !label && Array.isArray(labels) && labels.length > 1;
71
+
72
+ useEffect(() => {
73
+ if (!useRotation) return undefined;
74
+ const id = setInterval(
75
+ () => setStep((prev) => (prev + 1) % labels.length),
76
+ ROTATION_MS,
77
+ );
78
+ return () => clearInterval(id);
79
+ }, [labels, useRotation]);
80
+
81
+ const currentLabel = label || labels[step] || labels[0] || "Thinking";
82
+
83
+ // Width budget: long-form labels like "Preparing response" wrap to
84
+ // ~140 px at 13 px font. Pin a min-width so the bubble does not
85
+ // jitter as labels rotate.
86
+ const styles = {
87
+ bubble: {
88
+ display: "inline-flex",
89
+ alignItems: "center",
90
+ gap: "8px",
91
+ height: "32px",
92
+ padding: "0 12px",
93
+ borderRadius: "10px",
94
+ border: "1px solid rgba(255, 255, 255, 0.08)",
95
+ background: "rgba(255, 255, 255, 0.035)",
96
+ color: "rgba(255, 255, 255, 0.72)",
97
+ fontSize: "13px",
98
+ fontWeight: 500,
99
+ letterSpacing: "normal",
100
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
101
+ animation: "gp-thinking-mount 220ms ease-out both",
102
+ },
103
+ brandDot: {
104
+ flex: "0 0 auto",
105
+ width: "6px",
106
+ height: "6px",
107
+ borderRadius: "999px",
108
+ background: BRAND_ORANGE,
109
+ animation: "gp-thinking-dot-pulse 1.35s ease-in-out infinite",
110
+ },
111
+ label: {
112
+ minWidth: "120px", // stops bubble width jitter on rotation
113
+ animation: "gp-thinking-label 180ms ease-out both",
114
+ },
115
+ trailRow: {
116
+ display: "inline-flex",
117
+ alignItems: "center",
118
+ gap: "3px",
119
+ paddingLeft: "2px",
120
+ },
121
+ trailDot: {
122
+ width: "4px",
123
+ height: "4px",
124
+ borderRadius: "999px",
125
+ background: "currentColor",
126
+ animation: "gp-thinking-trail 1.2s ease-in-out infinite",
127
+ },
128
+ };
129
+
130
+ return (
131
+ <div
132
+ className="gitpilot-thinking-indicator"
133
+ role="status"
134
+ aria-live="polite"
135
+ aria-label={`${currentLabel} in progress`}
136
+ style={styles.bubble}
137
+ >
138
+ <style>{KEYFRAMES}</style>
139
+ <span style={styles.brandDot} aria-hidden="true" />
140
+ {/* keyed on the label so the fade-in plays each rotation */}
141
+ <span key={currentLabel} style={styles.label}>
142
+ {currentLabel}
143
+ </span>
144
+ <span style={styles.trailRow} aria-hidden="true">
145
+ <span style={{ ...styles.trailDot, animationDelay: "0s" }} />
146
+ <span style={{ ...styles.trailDot, animationDelay: "0.18s" }} />
147
+ <span style={{ ...styles.trailDot, animationDelay: "0.36s" }} />
148
+ </span>
149
+ </div>
150
+ );
151
+ }
frontend/components/UserMenu.jsx ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // frontend/components/UserMenu.jsx
2
+ import React, { useEffect, useRef, useState, useCallback } from "react";
3
+
4
+ /**
5
+ * UserMenu β€” account dropdown attached to the profile avatar in the
6
+ * bottom-left of the sidebar. Follows the Claude Code / ChatGPT pattern:
7
+ * click avatar β†’ popover with Settings, About, Logout.
8
+ *
9
+ * Best practices applied:
10
+ * - Click outside to close (mousedown listener on document)
11
+ * - Escape key closes
12
+ * - ARIA: role="menu" + aria-haspopup + aria-expanded on trigger
13
+ * - Keyboard navigation (Tab / Shift+Tab cycles items, Enter activates)
14
+ * - Position: absolute popover anchored to trigger, opens upward
15
+ * - Brand palette: #D95C3D accent, #1C1C1F card, #27272A border
16
+ * - Respects sidebarCollapsed: when collapsed, only avatar is shown
17
+ * - Animation: subtle fade+translate for polish
18
+ */
19
+
20
+ export default function UserMenu({
21
+ userInfo,
22
+ sidebarCollapsed = false,
23
+ onOpenSettings,
24
+ onOpenAbout,
25
+ onLogout,
26
+ }) {
27
+ const [open, setOpen] = useState(false);
28
+ const [fixedPos, setFixedPos] = useState(null);
29
+ const containerRef = useRef(null);
30
+ const triggerRef = useRef(null);
31
+ const menuRef = useRef(null);
32
+
33
+ // When the sidebar is collapsed, the parent .sidebar has overflow-x:hidden
34
+ // which clips an absolutely-positioned popover. Escape the clip by using
35
+ // position:fixed with coordinates measured from the trigger's bounding
36
+ // rect. Recompute on open, window resize, and scroll.
37
+ useEffect(() => {
38
+ if (!open || !sidebarCollapsed) {
39
+ setFixedPos(null);
40
+ return;
41
+ }
42
+ const compute = () => {
43
+ const el = triggerRef.current;
44
+ if (!el) return;
45
+ const rect = el.getBoundingClientRect();
46
+ setFixedPos({
47
+ left: Math.round(rect.right + 8),
48
+ bottom: Math.round(window.innerHeight - rect.bottom),
49
+ });
50
+ };
51
+ compute();
52
+ window.addEventListener("resize", compute);
53
+ window.addEventListener("scroll", compute, true);
54
+ return () => {
55
+ window.removeEventListener("resize", compute);
56
+ window.removeEventListener("scroll", compute, true);
57
+ };
58
+ }, [open, sidebarCollapsed]);
59
+
60
+ // Close on click outside
61
+ useEffect(() => {
62
+ if (!open) return;
63
+ const handleDocMouseDown = (e) => {
64
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
65
+ setOpen(false);
66
+ }
67
+ };
68
+ document.addEventListener("mousedown", handleDocMouseDown);
69
+ return () => document.removeEventListener("mousedown", handleDocMouseDown);
70
+ }, [open]);
71
+
72
+ // Close on Escape
73
+ useEffect(() => {
74
+ if (!open) return;
75
+ const handleKey = (e) => {
76
+ if (e.key === "Escape") {
77
+ setOpen(false);
78
+ triggerRef.current?.focus();
79
+ }
80
+ };
81
+ document.addEventListener("keydown", handleKey);
82
+ return () => document.removeEventListener("keydown", handleKey);
83
+ }, [open]);
84
+
85
+ // Focus the first menu item when opened
86
+ useEffect(() => {
87
+ if (open && menuRef.current) {
88
+ const firstItem = menuRef.current.querySelector('[role="menuitem"]');
89
+ firstItem?.focus();
90
+ }
91
+ }, [open]);
92
+
93
+ const handleItemClick = useCallback((action) => {
94
+ setOpen(false);
95
+ // Defer to next tick so the dropdown close animation doesn't jitter
96
+ // against the modal open animation.
97
+ window.setTimeout(() => action?.(), 0);
98
+ }, []);
99
+
100
+ if (!userInfo) return null;
101
+
102
+ const displayName = userInfo.name || userInfo.login;
103
+ const login = userInfo.login || "";
104
+
105
+ return (
106
+ <div
107
+ ref={containerRef}
108
+ style={{ position: "relative", width: "100%" }}
109
+ >
110
+ {/* Trigger: avatar + optional name */}
111
+ <button
112
+ ref={triggerRef}
113
+ type="button"
114
+ onClick={() => setOpen((v) => !v)}
115
+ aria-haspopup="menu"
116
+ aria-expanded={open}
117
+ aria-label={`Account menu for ${displayName}`}
118
+ className="user-menu-trigger"
119
+ style={{
120
+ display: "flex",
121
+ alignItems: "center",
122
+ gap: sidebarCollapsed ? 0 : 10,
123
+ width: "100%",
124
+ padding: sidebarCollapsed ? "6px" : "8px 10px",
125
+ background: open ? "#27272A" : "transparent",
126
+ border: "1px solid",
127
+ borderColor: open ? "#D95C3D" : "transparent",
128
+ borderRadius: 10,
129
+ cursor: "pointer",
130
+ color: "#EDEDED",
131
+ textAlign: "left",
132
+ transition: "background 120ms ease, border-color 120ms ease",
133
+ fontFamily: "inherit",
134
+ }}
135
+ onMouseEnter={(e) => {
136
+ if (!open) e.currentTarget.style.background = "#1C1C1F";
137
+ }}
138
+ onMouseLeave={(e) => {
139
+ if (!open) e.currentTarget.style.background = "transparent";
140
+ }}
141
+ >
142
+ {userInfo.avatar_url ? (
143
+ <img
144
+ src={userInfo.avatar_url}
145
+ alt=""
146
+ style={{
147
+ width: 32,
148
+ height: 32,
149
+ borderRadius: "50%",
150
+ flexShrink: 0,
151
+ border: "1px solid #27272A",
152
+ }}
153
+ />
154
+ ) : (
155
+ <div
156
+ aria-hidden="true"
157
+ style={{
158
+ width: 32,
159
+ height: 32,
160
+ borderRadius: "50%",
161
+ flexShrink: 0,
162
+ background: "rgba(217, 92, 61, 0.15)",
163
+ color: "#D95C3D",
164
+ border: "1px solid rgba(217, 92, 61, 0.3)",
165
+ display: "flex",
166
+ alignItems: "center",
167
+ justifyContent: "center",
168
+ fontWeight: 700,
169
+ fontSize: 13,
170
+ }}
171
+ >
172
+ {(displayName || "?").slice(0, 2).toUpperCase()}
173
+ </div>
174
+ )}
175
+
176
+ {!sidebarCollapsed && (
177
+ <div style={{ flex: 1, minWidth: 0, lineHeight: 1.25 }}>
178
+ <div
179
+ style={{
180
+ fontSize: 13,
181
+ fontWeight: 600,
182
+ color: "#EDEDED",
183
+ overflow: "hidden",
184
+ textOverflow: "ellipsis",
185
+ whiteSpace: "nowrap",
186
+ }}
187
+ >
188
+ {displayName}
189
+ </div>
190
+ {login && (
191
+ <div
192
+ style={{
193
+ fontSize: 11,
194
+ color: "#A1A1AA",
195
+ overflow: "hidden",
196
+ textOverflow: "ellipsis",
197
+ whiteSpace: "nowrap",
198
+ }}
199
+ >
200
+ @{login}
201
+ </div>
202
+ )}
203
+ </div>
204
+ )}
205
+
206
+ {!sidebarCollapsed && (
207
+ <svg
208
+ aria-hidden="true"
209
+ width="14"
210
+ height="14"
211
+ viewBox="0 0 16 16"
212
+ fill="none"
213
+ style={{
214
+ flexShrink: 0,
215
+ color: "#A1A1AA",
216
+ transform: open ? "rotate(180deg)" : "rotate(0deg)",
217
+ transition: "transform 120ms ease",
218
+ }}
219
+ >
220
+ <path
221
+ d="M4 6l4 4 4-4"
222
+ stroke="currentColor"
223
+ strokeWidth="1.5"
224
+ strokeLinecap="round"
225
+ strokeLinejoin="round"
226
+ />
227
+ </svg>
228
+ )}
229
+ </button>
230
+
231
+ {/* Dropdown popover */}
232
+ {open && (
233
+ <div
234
+ ref={menuRef}
235
+ role="menu"
236
+ aria-label="Account actions"
237
+ style={
238
+ sidebarCollapsed && fixedPos
239
+ ? {
240
+ position: "fixed",
241
+ left: fixedPos.left,
242
+ bottom: fixedPos.bottom,
243
+ width: 240,
244
+ minWidth: 220,
245
+ background: "#1C1C1F",
246
+ border: "1px solid #27272A",
247
+ borderRadius: 12,
248
+ boxShadow:
249
+ "0 18px 38px -12px rgba(0, 0, 0, 0.7), 0 4px 12px rgba(0, 0, 0, 0.4)",
250
+ padding: 6,
251
+ zIndex: 1000,
252
+ animation: "userMenuFadeIn 140ms ease-out",
253
+ }
254
+ : {
255
+ position: "absolute",
256
+ bottom: "calc(100% + 8px)",
257
+ left: 0,
258
+ right: 0,
259
+ minWidth: 220,
260
+ background: "#1C1C1F",
261
+ border: "1px solid #27272A",
262
+ borderRadius: 12,
263
+ boxShadow:
264
+ "0 18px 38px -12px rgba(0, 0, 0, 0.7), 0 4px 12px rgba(0, 0, 0, 0.4)",
265
+ padding: 6,
266
+ zIndex: 1000,
267
+ animation: "userMenuFadeIn 140ms ease-out",
268
+ }
269
+ }
270
+ >
271
+ {/* Header: show full email/username for context */}
272
+ <div
273
+ style={{
274
+ padding: "8px 12px 10px",
275
+ borderBottom: "1px solid #27272A",
276
+ marginBottom: 6,
277
+ }}
278
+ >
279
+ <div
280
+ style={{
281
+ fontSize: 12,
282
+ color: "#A1A1AA",
283
+ fontWeight: 500,
284
+ overflow: "hidden",
285
+ textOverflow: "ellipsis",
286
+ whiteSpace: "nowrap",
287
+ }}
288
+ >
289
+ Signed in as
290
+ </div>
291
+ <div
292
+ style={{
293
+ fontSize: 13,
294
+ color: "#EDEDED",
295
+ fontWeight: 600,
296
+ overflow: "hidden",
297
+ textOverflow: "ellipsis",
298
+ whiteSpace: "nowrap",
299
+ marginTop: 2,
300
+ }}
301
+ title={displayName}
302
+ >
303
+ {displayName}
304
+ </div>
305
+ </div>
306
+
307
+ <MenuItem
308
+ icon={<SettingsIcon />}
309
+ label="Settings"
310
+ onClick={() => handleItemClick(onOpenSettings)}
311
+ />
312
+ <MenuItem
313
+ icon={<InfoIcon />}
314
+ label="About GitPilot"
315
+ onClick={() => handleItemClick(onOpenAbout)}
316
+ />
317
+
318
+ <div
319
+ role="separator"
320
+ style={{
321
+ height: 1,
322
+ background: "#27272A",
323
+ margin: "6px 4px",
324
+ }}
325
+ />
326
+
327
+ <MenuItem
328
+ icon={<LogoutIcon />}
329
+ label="Log out"
330
+ onClick={() => handleItemClick(onLogout)}
331
+ danger
332
+ />
333
+ </div>
334
+ )}
335
+
336
+ {/* Scoped keyframe animation */}
337
+ <style>{`
338
+ @keyframes userMenuFadeIn {
339
+ from { opacity: 0; transform: translateY(4px); }
340
+ to { opacity: 1; transform: translateY(0); }
341
+ }
342
+ `}</style>
343
+ </div>
344
+ );
345
+ }
346
+
347
+ // ── Menu item primitive ────────────────────────────────────────────
348
+ function MenuItem({ icon, label, onClick, danger = false }) {
349
+ const [hover, setHover] = useState(false);
350
+ const color = danger ? "#f87171" : "#EDEDED";
351
+ return (
352
+ <button
353
+ type="button"
354
+ role="menuitem"
355
+ onClick={onClick}
356
+ onMouseEnter={() => setHover(true)}
357
+ onMouseLeave={() => setHover(false)}
358
+ style={{
359
+ display: "flex",
360
+ alignItems: "center",
361
+ gap: 12,
362
+ width: "100%",
363
+ padding: "9px 12px",
364
+ background: hover ? "#27272A" : "transparent",
365
+ border: "none",
366
+ borderRadius: 8,
367
+ cursor: "pointer",
368
+ color: color,
369
+ fontSize: 13,
370
+ fontWeight: 500,
371
+ textAlign: "left",
372
+ fontFamily: "inherit",
373
+ transition: "background 80ms ease",
374
+ }}
375
+ >
376
+ <span
377
+ aria-hidden="true"
378
+ style={{
379
+ display: "inline-flex",
380
+ alignItems: "center",
381
+ justifyContent: "center",
382
+ width: 16,
383
+ height: 16,
384
+ color: hover && !danger ? "#D95C3D" : color,
385
+ flexShrink: 0,
386
+ transition: "color 80ms ease",
387
+ }}
388
+ >
389
+ {icon}
390
+ </span>
391
+ <span>{label}</span>
392
+ </button>
393
+ );
394
+ }
395
+
396
+ // ── Inline icons (no extra asset loads) ────────────────────────────
397
+ function SettingsIcon() {
398
+ return (
399
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
400
+ <circle cx="12" cy="12" r="3" />
401
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
402
+ </svg>
403
+ );
404
+ }
405
+
406
+ function InfoIcon() {
407
+ return (
408
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
409
+ <circle cx="12" cy="12" r="10" />
410
+ <line x1="12" y1="16" x2="12" y2="12" />
411
+ <line x1="12" y1="8" x2="12.01" y2="8" />
412
+ </svg>
413
+ );
414
+ }
415
+
416
+ function LogoutIcon() {
417
+ return (
418
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
419
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
420
+ <polyline points="16 17 21 12 16 7" />
421
+ <line x1="21" y1="12" x2="9" y2="12" />
422
+ </svg>
423
+ );
424
+ }