diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..9c83142145c4befa8d9aab39a10039e3cd251672 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,92 @@ +# ============================================================================= +# GitPilot - Hugging Face Spaces Dockerfile +# ============================================================================= +# Follows the official HF Docker Spaces pattern: +# https://huggingface.co/docs/hub/spaces-sdks-docker +# +# Architecture: +# React UI (Vite build) -> FastAPI backend -> OllaBridge Cloud / any LLM +# ============================================================================= + +# -- Stage 1: Build React frontend ------------------------------------------- +FROM node:20-slim AS frontend-builder + +WORKDIR /build + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci --production=false + +COPY frontend/ ./ +RUN npm run build + +# -- Stage 2: Python runtime ------------------------------------------------- +FROM python:3.12-slim + +# System deps needed at runtime +RUN apt-get update && apt-get install -y --no-install-recommends \ + git curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# HF Spaces runs containers as UID 1000 — create user early (official pattern) +RUN useradd -m -u 1000 user + +USER user + +ENV HOME=/home/user \ + PATH=/home/user/.local/bin:$PATH \ + PYTHONUNBUFFERED=1 \ + GITPILOT_PROVIDER=ollabridge \ + OLLABRIDGE_BASE_URL=https://ruslanmv-ollabridge.hf.space \ + GITPILOT_OLLABRIDGE_MODEL=qwen2.5:1.5b \ + CORS_ORIGINS="*" \ + GITPILOT_CONFIG_DIR=/tmp/gitpilot + +WORKDIR $HOME/app + +# ── Install Python dependencies BEFORE copying source code ────────── +# This ensures pip install layers are cached even when code changes. +COPY --chown=user pyproject.toml README.md ./ + +# Step 1: lightweight deps (cached layer) +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir \ + "fastapi>=0.111.0" \ + "uvicorn[standard]>=0.30.0" \ + "httpx>=0.27.0" \ + "python-dotenv>=1.1.0,<1.2.0" \ + "typer>=0.12.0,<0.24.0" \ + "pydantic>=2.7.0,<2.12.0" \ + "rich>=13.0.0" \ + "pyjwt[crypto]>=2.8.0" + +# Step 2: heavy ML/agent deps (separate layer for better caching) +RUN pip install --no-cache-dir \ + "litellm" \ + "crewai[anthropic]>=0.76.9" \ + "crewai-tools>=0.13.4" \ + "anthropic>=0.39.0" \ + "ibm-watsonx-ai>=1.1.0" \ + "langchain-ibm>=0.3.0" + +# ── Now copy source code (cache-busting only affects layers below) ── +COPY --chown=user gitpilot ./gitpilot + +# Copy built frontend into gitpilot/web/ +COPY --chown=user --from=frontend-builder /build/dist/ ./gitpilot/web/ + +# Step 3: editable install of gitpilot itself (deps already satisfied) +RUN pip install --no-cache-dir --no-deps -e . + +EXPOSE 7860 + +# NOTE: Do NOT add a Docker HEALTHCHECK here. +# HF Spaces has its own HTTP probe on app_port (7860) and ignores the +# Docker HEALTHCHECK directive. + +# Direct CMD — no shell script, fewer failure points. +CMD ["python", "-m", "uvicorn", "gitpilot.api:app", \ + "--host", "0.0.0.0", \ + "--port", "7860", \ + "--workers", "2", \ + "--limit-concurrency", "10", \ + "--timeout-keep-alive", "120"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5de6067d4f4f2254ae1c5014e1c868aac1865212 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +--- +title: GitPilot +emoji: "\U0001F916" +colorFrom: blue +colorTo: indigo +sdk: docker +app_port: 7860 +startup_duration_timeout: 5m +pinned: true +license: mit +short_description: Enterprise AI Coding Assistant for GitHub Repositories +--- + +# GitPilot — Hugging Face Spaces + +**Enterprise-grade AI coding assistant** for GitHub repositories with multi-LLM support, visual workflow insights, and intelligent code analysis. + +## What This Does + +This Space runs the full GitPilot stack: +1. **React Frontend** — Professional dark-theme UI with chat, file browser, and workflow visualization +2. **FastAPI Backend** — 80+ API endpoints for repository management, AI chat, planning, and execution +3. **Multi-Agent AI** — CrewAI orchestration with 7 switchable agent topologies + +## LLM Providers + +GitPilot connects to your favorite LLM provider. Configure in **Admin / LLM Settings**: + +| Provider | Default | API Key Required | +|---|---|---| +| **OllaBridge Cloud** (default) | `qwen2.5:1.5b` | No | +| OpenAI | `gpt-4o-mini` | Yes | +| Anthropic Claude | `claude-sonnet-4-5` | Yes | +| Ollama (local) | `llama3` | No | +| Custom endpoint | Any model | Optional | + +## Quick Start + +1. Open the Space UI +2. Enter your **GitHub Token** (Settings -> GitHub) +3. Select a repository from the sidebar +4. Start chatting with your AI coding assistant + +## API Endpoints + +| Endpoint | Description | +|---|---| +| `GET /api/health` | Health check | +| `POST /api/chat/message` | Chat with AI assistant | +| `POST /api/chat/plan` | Generate implementation plan | +| `GET /api/repos` | List repositories | +| `GET /api/settings` | View/update settings | +| `GET /docs` | Interactive API docs (Swagger) | + +## Connect to OllaBridge Cloud + +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. + +To use your own OllaBridge instance: +1. Go to **Admin / LLM Settings** +2. Select **OllaBridge** provider +3. Enter your OllaBridge URL and model + +## Environment Variables + +Configure via HF Spaces secrets: + +| Variable | Description | Default | +|---|---|---| +| `GITPILOT_PROVIDER` | LLM provider | `ollabridge` | +| `OLLABRIDGE_BASE_URL` | OllaBridge Cloud URL | `https://ruslanmv-ollabridge.hf.space` | +| `GITHUB_TOKEN` | GitHub personal access token | - | +| `OPENAI_API_KEY` | OpenAI API key (if using OpenAI) | - | +| `ANTHROPIC_API_KEY` | Anthropic API key (if using Claude) | - | + +## Links + +- [GitPilot Repository](https://github.com/ruslanmv/gitpilot) +- [OllaBridge Cloud](https://huggingface.co/spaces/ruslanmv/ollabridge) +- [Documentation](https://github.com/ruslanmv/gitpilot#readme) diff --git a/REPO_README.md b/REPO_README.md new file mode 100644 index 0000000000000000000000000000000000000000..0e260acb37f4aca1be35726a43094a9cfa29642e --- /dev/null +++ b/REPO_README.md @@ -0,0 +1,293 @@ +
+ +GitPilot + +# GitPilot + +### The open-source AI coding companion your team can actually trust. + +**Ask. Plan. Code. Ship.**  ·  You approve every change. + + + +[![PyPI](https://img.shields.io/pypi/v/gitcopilot?style=flat-square&color=D95C3D&labelColor=1C1C1F&label=pypi)](https://pypi.org/project/gitcopilot/) +[![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12-D95C3D?style=flat-square&labelColor=1C1C1F)](https://www.python.org/) +[![License: MIT](https://img.shields.io/badge/license-MIT-D95C3D?style=flat-square&labelColor=1C1C1F)](LICENSE) +[![VS Code](https://img.shields.io/badge/VS%20Code-Extension-D95C3D?style=flat-square&labelColor=1C1C1F)](https://marketplace.visualstudio.com/) +[![Tests](https://img.shields.io/badge/tests-854%20passing-D95C3D?style=flat-square&labelColor=1C1C1F)](#contributing) + +[**Get Started**](#get-started)  ·  [VS Code](#vs-code-extension)  ·  [Web App](#web-app)  ·  [How It Works](#how-it-works)  ·  [Providers](#supported-ai-providers) + +
+ +--- + +

+ + + GitPilot loop: Ask, Plan, Code, Ship — you approve every change. + +

+ +## Why GitPilot? + +GitPilot is the AI pair programmer built for teams that take code seriously. It reads your repository, drafts a safe plan, writes the code, runs your tests — and **waits for your approval before touching a single file**. No surprises, no silent commits, no lock-in. + +- 🧭 **Works where you work** — the same experience in VS Code, on the web, and from the terminal. One login, one history, one set of approvals. +- 🔐 **Safe by default** — every file edit, shell command, and git operation asks for permission first. Diffs are shown before they're applied, tests run before anything is committed. +- 🧠 **Your model, your rules** — drop in OpenAI, Anthropic Claude, IBM Watsonx, Ollama (local) or OllaBridge (free cloud). Switch providers in settings without changing a line of code. +- 🏢 **Enterprise-ready, open source** — MIT licensed, 854 passing tests, Docker & Hugging Face deployment recipes, no telemetry, no vendor lock-in. +- 🌍 **Runs anywhere** — your laptop, your private cloud, air-gapped environments, or a managed host. Your repo stays your repo. + +--- + +## What is GitPilot? + +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. + +**Works with any language. Runs on any LLM.** Start free and local with Ollama, or bring your own OpenAI, Claude, or Watsonx key. + +``` +You: "Add input validation to the login form" + +GitPilot: + 1. Reading src/auth/login.ts... + 2. Planning 3 changes... + 3. Editing login.ts (Allow? [Yes] [No]) + 4. Running npm test... 3 passed + 5. Done. +``` + +--- + +## Get Started + +### Option 1: VS Code Extension (recommended) + +Install the extension, configure your LLM, and start chatting: + +``` +1. Open VS Code +2. Install "GitPilot Workspace" from Extensions +3. Click the GitPilot icon in the sidebar +4. Choose your AI provider (OpenAI, Claude, Ollama...) +5. Start asking questions about your code +``` + +### Option 2: Web App + +Run the full web interface with Docker: + +```bash +git clone https://github.com/ruslanmv/gitpilot.git +cd gitpilot +docker compose up +``` + +Open [http://localhost:3000](http://localhost:3000) in your browser. + +### Option 3: Python CLI (fastest) + +```bash +pip install gitcopilot +gitpilot serve +``` + +Open [http://localhost:8000](http://localhost:8000) and you're done. + +> **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. + +--- + +## VS Code Extension + +The sidebar panel gives you everything in one place: + +| Feature | What it does | +|---|---| +| **Chat** | Ask questions, request changes, review code | +| **Plan View** | See the step-by-step plan before changes are made | +| **Diff Preview** | Review proposed edits in VS Code's native diff viewer | +| **Apply / Revert** | One click to apply changes, one click to undo | +| **Quick Actions** | Explain, Review, Fix, Generate Tests, Security Scan | +| **Smart Commit** | AI-generated commit messages | +| **Code Lens** | Inline "Explain / Review" hints on functions | + +### Supported AI Providers + +| Provider | Setup | Free? | +|---|---|---| +| **Ollama** | Install Ollama, run `ollama pull llama3` | Yes | +| **OllaBridge** | Works out of the box (cloud Ollama) | Yes | +| **OpenAI** | Add your API key in settings | Paid | +| **Claude** | Add your Anthropic API key | Paid | +| **Watsonx** | Add IBM credentials | Paid | + +--- + +## Web App + +The web interface includes: + +- Chat with real-time responses +- GitHub integration (connect your repos) +- File tree browser +- Diff viewer with line-by-line changes +- Pull request creation +- Session history with checkpoints +- Multi-repo support + +--- + +## How It Works + +

+ + + 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. + +

+ +GitPilot uses a multi-agent system powered by CrewAI: + +1. **Explorer** reads your repo structure, git log, and key files +2. **Planner** creates a safe step-by-step plan with diffs +3. **Executor** writes code and runs tests, self-correcting on failure +4. **Reviewer** validates the output and summarises what changed + +You approve every change before it's applied. + +--- + +## Project Structure + +``` +gitpilot/ + gitpilot/ Python backend (FastAPI) + frontend/ React web app + extensions/vscode/ VS Code extension + docs/ Documentation and assets + tests/ Test suite +``` + +--- + +## Configuration + +GitPilot works with environment variables or the settings UI. + +**Minimal setup** (Ollama, free, local): + +```bash +# .env +GITPILOT_PROVIDER=ollama +OLLAMA_BASE_URL=http://localhost:11434 +GITPILOT_OLLAMA_MODEL=llama3 +``` + +**Cloud setup** (OpenAI): + +```bash +# .env +GITPILOT_PROVIDER=openai +OPENAI_API_KEY=sk-... +GITPILOT_OPENAI_MODEL=gpt-4o-mini +``` + +**Cloud setup** (Claude): + +```bash +# .env +GITPILOT_PROVIDER=claude +ANTHROPIC_API_KEY=sk-ant-... +GITPILOT_CLAUDE_MODEL=claude-sonnet-4-5 +``` + +All settings can also be changed from the VS Code extension or web UI without editing files. + +--- + +## API + +GitPilot exposes a REST + WebSocket API: + +| Endpoint | What it does | +|---|---| +| `GET /api/status` | Server health check | +| `POST /api/chat/send` | Send a message, get a response | +| `POST /api/v2/chat/stream` | Stream agent events (SSE) | +| `WS /ws/v2/sessions/{id}` | Real-time WebSocket streaming | +| `POST /api/chat/plan` | Generate an execution plan | +| `POST /api/chat/execute` | Execute a plan | +| `GET /api/repos` | List connected repositories | +| `GET /api/sessions` | List chat sessions | + +Full API docs at `http://localhost:8000/docs` (Swagger UI). + +--- + +## Deployment + +### Hugging Face Spaces

+ + Hugging Face Space + +

+ +GitPilot runs on Hugging Face Spaces with OllaBridge (free): + +``` +Runtime: Docker +Port: 7860 +Provider: OllaBridge (cloud Ollama) +``` + +### Docker Compose + +```bash +docker compose up -d +# Backend: http://localhost:8000 +# Frontend: http://localhost:3000 +``` + +### Vercel + +The frontend deploys to Vercel. Set `VITE_BACKEND_URL` to your backend. + +--- + +## Contributing + +```bash +# Backend +cd gitpilot +pip install -e ".[dev]" +pytest + +# Frontend +cd frontend +npm install +npm run dev + +# VS Code Extension +cd extensions/vscode +npm install +make compile +# Press F5 in VS Code to launch debug host +``` + +--- + +## License + +MIT License. See [LICENSE](LICENSE). + +--- + +
+ +**GitPilot** is made by [Ruslan Magana Vsevolodovna](https://github.com/ruslanmv) + +[Star on GitHub](https://github.com/ruslanmv/gitpilot) • [Report a Bug](https://github.com/ruslanmv/gitpilot/issues) • [Request a Feature](https://github.com/ruslanmv/gitpilot/issues) + +
diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..9da1acab57b3a83f0649dc5deb28b33600fe4ad3 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,39 @@ +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Build +dist/ +build/ + +# Environment +.env +.env.local +.env.development +.env.test +.env.production.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# Testing +coverage/ +.nyc_output/ + +# Misc +*.log diff --git a/frontend/App.jsx b/frontend/App.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9847bb709bcdd064fc8ec75cd068c0f56e08a169 --- /dev/null +++ b/frontend/App.jsx @@ -0,0 +1,1173 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import StartupScreen from "./components/StartupScreen.jsx"; +import LoginPage from "./components/LoginPage.jsx"; +import RepoSelector from "./components/RepoSelector.jsx"; +import ProjectContextPanel from "./components/ProjectContextPanel.jsx"; +import ChatPanel from "./components/ChatPanel.jsx"; +import LlmSettings from "./components/LlmSettings.jsx"; +import FlowViewer from "./components/FlowViewer.jsx"; +import Footer from "./components/Footer.jsx"; +import ProjectSettingsModal from "./components/ProjectSettingsModal.jsx"; +import SessionSidebar from "./components/SessionSidebar.jsx"; +import ContextBar from "./components/ContextBar.jsx"; +import AddRepoModal from "./components/AddRepoModal.jsx"; +import UserMenu from "./components/UserMenu.jsx"; +import AboutModal from "./components/AboutModal.jsx"; +import { + WorkspaceModesTab, + SecurityTab, + IntegrationsTab, + SkillsTab, + SessionsTab, + AdvancedTab, +} from "./components/AdminTabs"; +import { apiUrl, safeFetchJSON, fetchStatus } from "./utils/api.js"; +import { initApp } from "./utils/appInit.js"; + +function makeRepoKey(repo) { + if (!repo) return null; + return repo.full_name || `${repo.owner}/${repo.name}`; +} + +function uniq(arr) { + return Array.from(new Set((arr || []).filter(Boolean))); +} + +function getProviderLabel(status) { + if (!status) return "Checking..."; + return ( + status?.provider?.name || + status?.provider_name || + status?.provider?.provider || + "Checking..." + ); +} + +function getBackendVersion(status) { + if (!status) return "Checking..."; + return status?.version || status?.app_version || "Checking..."; +} + +export default function App() { + const frontendVersion = __APP_VERSION__ || "unknown"; + + // ---- Multi-repo context state ---- + const [contextRepos, setContextRepos] = useState([]); + // Each entry: { repoKey: "owner/repo", repo: {...}, branch: "main" } + const [activeRepoKey, setActiveRepoKey] = useState(null); + const [addRepoOpen, setAddRepoOpen] = useState(false); + + const [activePage, setActivePage] = useState("workspace"); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [userInfo, setUserInfo] = useState(null); + + // Startup / enterprise loader state + const [startupPhase, setStartupPhase] = useState("booting"); + const [startupStatusMessage, setStartupStatusMessage] = useState("Starting application..."); + const [startupDetailMessage, setStartupDetailMessage] = useState( + "Initializing authentication, provider, and workspace context." + ); + const [startupStatusSnapshot, setStartupStatusSnapshot] = useState(null); + + // Repo + Session State Machine + const [repoStateByKey, setRepoStateByKey] = useState({}); + const [toast, setToast] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + const [aboutOpen, setAboutOpen] = useState(false); + const [adminTab, setAdminTab] = useState("overview"); + const [adminStatus, setAdminStatus] = useState(null); + + // Fetch admin status when overview tab is active + useEffect(() => { + if (activePage === "admin" && adminTab === "overview") { + fetchStatus() + .then((data) => setAdminStatus(data)) + .catch(() => setAdminStatus(null)); + } + }, [activePage, adminTab]); + + // Claude-Code-on-Web: Session sidebar + Environment state + const [activeSessionId, setActiveSessionId] = useState(null); + const [activeEnvId, setActiveEnvId] = useState("default"); + const [sessionRefreshNonce, setSessionRefreshNonce] = useState(0); + + // Sidebar collapse state (persisted in localStorage) + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { + try { + return localStorage.getItem("gitpilot_sidebar_collapsed") === "true"; + } catch { + return false; + } + }); + + const toggleSidebar = useCallback(() => { + setSidebarCollapsed((prev) => { + const next = !prev; + try { + localStorage.setItem("gitpilot_sidebar_collapsed", String(next)); + } catch {} + return next; + }); + }, []); + + // Keyboard shortcut: Cmd/Ctrl + B to toggle sidebar + useEffect(() => { + const handler = (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "b") { + e.preventDefault(); + toggleSidebar(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [toggleSidebar]); + + // ---- Derived `repo` — keeps all downstream consumers unchanged ---- + const repo = useMemo(() => { + const entry = contextRepos.find((r) => r.repoKey === activeRepoKey); + return entry?.repo || null; + }, [contextRepos, activeRepoKey]); + + const repoKey = activeRepoKey; + + // Convenient selectors + const currentRepoState = repoKey ? repoStateByKey[repoKey] : null; + + const defaultBranch = currentRepoState?.defaultBranch || repo?.default_branch || "main"; + const currentBranch = currentRepoState?.currentBranch || defaultBranch; + const sessionBranches = currentRepoState?.sessionBranches || []; + const lastExecution = currentRepoState?.lastExecution || null; + const pulseNonce = currentRepoState?.pulseNonce || 0; + const chatByBranch = currentRepoState?.chatByBranch || {}; + + // --------------------------------------------------------------------------- + // Multi-repo context management + // --------------------------------------------------------------------------- + const addRepoToContext = useCallback((r) => { + const key = makeRepoKey(r); + if (!key) return; + + setContextRepos((prev) => { + if (prev.some((e) => e.repoKey === key)) { + setActiveRepoKey(key); + return prev; + } + const entry = { repoKey: key, repo: r, branch: r.default_branch || "main" }; + return [...prev, entry]; + }); + + setActiveRepoKey(key); + setAddRepoOpen(false); + }, []); + + const removeRepoFromContext = useCallback((key) => { + setContextRepos((prev) => { + const next = prev.filter((e) => e.repoKey !== key); + setActiveRepoKey((curActive) => { + if (curActive === key) { + return next.length > 0 ? next[0].repoKey : null; + } + return curActive; + }); + return next; + }); + }, []); + + const clearAllContext = useCallback(() => { + setContextRepos([]); + setActiveRepoKey(null); + }, []); + + const handleContextBranchChange = useCallback((targetRepoKey, newBranch) => { + setContextRepos((prev) => + prev.map((e) => + e.repoKey === targetRepoKey ? { ...e, branch: newBranch } : e + ) + ); + + setRepoStateByKey((prev) => { + const cur = prev[targetRepoKey]; + if (!cur) return prev; + return { + ...prev, + [targetRepoKey]: { ...cur, currentBranch: newBranch }, + }; + }); + }, []); + + // Init / reconcile repo state when active repo changes + useEffect(() => { + if (!repoKey || !repo) return; + + setRepoStateByKey((prev) => { + const existing = prev[repoKey]; + const d = repo.default_branch || "main"; + + if (!existing) { + return { + ...prev, + [repoKey]: { + defaultBranch: d, + currentBranch: d, + sessionBranches: [], + lastExecution: null, + pulseNonce: 0, + chatByBranch: { + [d]: { messages: [], plan: null }, + }, + }, + }; + } + + const next = { ...existing }; + next.defaultBranch = d; + + if (!next.chatByBranch?.[d]) { + next.chatByBranch = { + ...(next.chatByBranch || {}), + [d]: { messages: [], plan: null }, + }; + } + + if (!next.currentBranch) next.currentBranch = d; + + return { ...prev, [repoKey]: next }; + }); + }, [repoKey, repo?.id, repo?.default_branch]); + + const showToast = (title, message) => { + setToast({ title, message }); + window.setTimeout(() => setToast(null), 5000); + }; + + // --------------------------------------------------------------------------- + // Session management — every chat is backed by a Session (Claude Code parity) + // --------------------------------------------------------------------------- + + const _creatingSessionRef = useRef(false); + + const [chatBySession, setChatBySession] = useState({}); + + const ensureSession = useCallback( + async (sessionName, seedMessages) => { + if (activeSessionId) return activeSessionId; + if (!repo) return null; + if (_creatingSessionRef.current) return null; + _creatingSessionRef.current = true; + + try { + const token = localStorage.getItem("github_token"); + const headers = { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + const res = await fetch("/api/sessions", { + method: "POST", + headers, + body: JSON.stringify({ + repo_full_name: repoKey, + branch: currentBranch, + name: sessionName || undefined, + repos: contextRepos.map((e) => ({ + full_name: e.repoKey, + branch: e.branch, + mode: e.repoKey === activeRepoKey ? "write" : "read", + })), + active_repo: activeRepoKey, + }), + }); + + if (!res.ok) return null; + const data = await res.json(); + const newId = data.session_id; + + if (seedMessages && seedMessages.length > 0) { + setChatBySession((prev) => ({ + ...prev, + [newId]: { messages: seedMessages, plan: null }, + })); + } + + setActiveSessionId(newId); + setSessionRefreshNonce((n) => n + 1); + return newId; + } catch (err) { + console.warn("Failed to create session:", err); + return null; + } finally { + _creatingSessionRef.current = false; + } + }, + [activeSessionId, repo, repoKey, currentBranch, contextRepos, activeRepoKey] + ); + + const handleNewSession = async () => { + setActiveSessionId(null); + + try { + const token = localStorage.getItem("github_token"); + const headers = { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + const res = await fetch("/api/sessions", { + method: "POST", + headers, + body: JSON.stringify({ + repo_full_name: repoKey, + branch: currentBranch, + repos: contextRepos.map((e) => ({ + full_name: e.repoKey, + branch: e.branch, + mode: e.repoKey === activeRepoKey ? "write" : "read", + })), + active_repo: activeRepoKey, + }), + }); + + if (!res.ok) return; + const data = await res.json(); + setActiveSessionId(data.session_id); + setSessionRefreshNonce((n) => n + 1); + showToast("Session Created", "New session started."); + } catch (err) { + console.warn("Failed to create session:", err); + } + }; + + /** + * Convert a backend Message object to the frontend chat UI shape. + * Backend: { role: "user|assistant|system", content: "...", timestamp, metadata } + * Frontend: { from: "user|ai", role: "user|assistant|system", content, answer, ... } + */ + const normalizeBackendMessage = (m) => { + const role = m.role || "assistant"; + const content = m.content || ""; + if (role === "user") { + return { from: "user", role: "user", content, text: content }; + } + if (role === "system") { + return { from: "ai", role: "system", content }; + } + // assistant + return { + from: "ai", + role: "assistant", + content, + answer: content, + // Preserve any structured metadata the backend stored (plan, diff, etc.) + ...(m.metadata && typeof m.metadata === "object" ? m.metadata : {}), + }; + }; + + /** + * Fetch persisted messages for a session from the backend. + * Returns an array of normalized frontend messages (ready for ChatPanel), + * or an empty array on failure. + */ + const fetchSessionMessages = useCallback(async (sessionId) => { + if (!sessionId) return []; + try { + const token = localStorage.getItem("github_token"); + const headers = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + + const res = await fetch(apiUrl(`/api/sessions/${sessionId}/messages`), { + headers, + }); + if (!res.ok) { + console.warn(`[fetchSessionMessages] ${res.status} for ${sessionId}`); + return []; + } + const data = await res.json(); + const backendMessages = Array.isArray(data.messages) ? data.messages : []; + return backendMessages.map(normalizeBackendMessage); + } catch (err) { + console.warn(`[fetchSessionMessages] Failed to fetch ${sessionId}:`, err); + return []; + } + }, []); + + /** + * Handle click on a session in the sidebar. + * + * Critical ordering: we must hydrate chatBySession BEFORE setting + * activeSessionId, because ChatPanel's session-sync useEffect reads + * sessionChatState only when sessionId changes (it does NOT depend on + * chatBySession to avoid prop/state loops). If we set activeSessionId + * first, ChatPanel would see an empty messages array, then our async + * hydration would complete but ChatPanel wouldn't re-sync. + */ + const handleSelectSession = useCallback(async (session) => { + // 1. Fetch persisted messages first + const messages = await fetchSessionMessages(session.id); + + // 2. Seed the chat cache (ChatPanel will read this via sessionChatState) + setChatBySession((prev) => ({ + ...prev, + [session.id]: { + ...(prev[session.id] || { plan: null }), + messages, + }, + })); + + // 3. NOW activate the session — ChatPanel's sync effect will read + // the hydrated messages from chatBySession[session.id] + setActiveSessionId(session.id); + if (session.branch && session.branch !== currentBranch) { + handleBranchChange(session.branch); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchSessionMessages, currentBranch]); + + const handleDeleteSession = useCallback( + (deletedId) => { + if (deletedId === activeSessionId) { + setActiveSessionId(null); + + setChatBySession((prev) => { + const next = { ...prev }; + delete next[deletedId]; + return next; + }); + + if (repoKey) { + setRepoStateByKey((prev) => { + const cur = prev[repoKey]; + if (!cur) return prev; + const branchKey = cur.currentBranch || cur.defaultBranch || defaultBranch; + return { + ...prev, + [repoKey]: { + ...cur, + chatByBranch: { + ...(cur.chatByBranch || {}), + [branchKey]: { messages: [], plan: null }, + }, + }, + }; + }); + } + } + }, + [activeSessionId, repoKey, defaultBranch] + ); + + // --------------------------------------------------------------------------- + // Chat persistence helpers + // --------------------------------------------------------------------------- + const updateChatForCurrentBranch = (patch) => { + if (!repoKey) return; + + setRepoStateByKey((prev) => { + const cur = prev[repoKey]; + if (!cur) return prev; + + const branchKey = cur.currentBranch || cur.defaultBranch || defaultBranch; + + const existing = cur.chatByBranch?.[branchKey] || { + messages: [], + plan: null, + }; + + return { + ...prev, + [repoKey]: { + ...cur, + chatByBranch: { + ...(cur.chatByBranch || {}), + [branchKey]: { ...existing, ...patch }, + }, + }, + }; + }); + }; + + const currentChatState = useMemo(() => { + const b = currentBranch || defaultBranch; + return chatByBranch[b] || { messages: [], plan: null }; + }, [chatByBranch, currentBranch, defaultBranch]); + + const sessionChatState = useMemo(() => { + if (!activeSessionId) { + return currentChatState; + } + return chatBySession[activeSessionId] || { messages: [], plan: null }; + }, [activeSessionId, chatBySession, currentChatState]); + + const updateSessionChat = (patch) => { + if (activeSessionId) { + setChatBySession((prev) => ({ + ...prev, + [activeSessionId]: { + ...(prev[activeSessionId] || { messages: [], plan: null }), + ...patch, + }, + })); + } else { + updateChatForCurrentBranch(patch); + } + }; + + // --------------------------------------------------------------------------- + // Branch change (manual — for active repo) + // --------------------------------------------------------------------------- + const handleBranchChange = (nextBranch) => { + if (!repoKey) return; + if (!nextBranch || nextBranch === currentBranch) return; + + setRepoStateByKey((prev) => { + const cur = prev[repoKey]; + if (!cur) return prev; + + const nextState = { ...cur, currentBranch: nextBranch }; + + if (nextBranch === cur.defaultBranch) { + nextState.chatByBranch = { + ...nextState.chatByBranch, + [nextBranch]: { messages: [], plan: null }, + }; + } + + return { ...prev, [repoKey]: nextState }; + }); + + setContextRepos((prev) => + prev.map((e) => + e.repoKey === repoKey ? { ...e, branch: nextBranch } : e + ) + ); + + if (nextBranch === defaultBranch) { + showToast("New Session", `Switched to ${defaultBranch}. Chat cleared.`); + } else { + showToast("Context Switched", `Now viewing ${nextBranch}.`); + } + }; + + // --------------------------------------------------------------------------- + // Execution complete + // --------------------------------------------------------------------------- + const handleExecutionComplete = ({ + branch, + mode, + commit_url, + completionMsg, + sourceBranch, + }) => { + if (!repoKey || !branch) return; + + setRepoStateByKey((prev) => { + const cur = + prev[repoKey] || { + defaultBranch, + currentBranch: defaultBranch, + sessionBranches: [], + lastExecution: null, + pulseNonce: 0, + chatByBranch: { [defaultBranch]: { messages: [], plan: null } }, + }; + + const next = { ...cur }; + next.lastExecution = { mode, branch, ts: Date.now() }; + + if (!next.chatByBranch) next.chatByBranch = {}; + + const prevBranchKey = + sourceBranch || cur.currentBranch || cur.defaultBranch || defaultBranch; + + const successSystemMsg = { + role: "system", + isSuccess: true, + link: commit_url, + content: + mode === "hard-switch" + ? `🌱 **Session Started:** Created branch \`${branch}\`.` + : `✅ **Update Published:** Commits pushed to \`${branch}\`.`, + }; + + const normalizedCompletion = + completionMsg && + (completionMsg.answer || completionMsg.content || completionMsg.executionLog) + ? { + from: completionMsg.from || "ai", + role: completionMsg.role || "assistant", + answer: completionMsg.answer, + content: completionMsg.content, + executionLog: completionMsg.executionLog, + } + : null; + + if (mode === "hard-switch") { + next.sessionBranches = uniq([...(next.sessionBranches || []), branch]); + next.currentBranch = branch; + next.pulseNonce = (next.pulseNonce || 0) + 1; + + const existingTargetChat = next.chatByBranch[branch]; + const isExistingSession = + existingTargetChat && (existingTargetChat.messages || []).length > 0; + + if (isExistingSession) { + const appended = [ + ...(existingTargetChat.messages || []), + ...(normalizedCompletion ? [normalizedCompletion] : []), + successSystemMsg, + ]; + + next.chatByBranch[branch] = { + ...existingTargetChat, + messages: appended, + plan: null, + }; + } else { + const prevChat = + (cur.chatByBranch && cur.chatByBranch[prevBranchKey]) || { + messages: [], + plan: null, + }; + + next.chatByBranch[branch] = { + messages: [ + ...(prevChat.messages || []), + ...(normalizedCompletion ? [normalizedCompletion] : []), + successSystemMsg, + ], + plan: null, + }; + } + + if (!next.chatByBranch[next.defaultBranch]) { + next.chatByBranch[next.defaultBranch] = { messages: [], plan: null }; + } + } else if (mode === "sticky") { + next.currentBranch = cur.currentBranch || branch; + + const targetChat = next.chatByBranch[branch] || { messages: [], plan: null }; + + next.chatByBranch[branch] = { + messages: [ + ...(targetChat.messages || []), + ...(normalizedCompletion ? [normalizedCompletion] : []), + successSystemMsg, + ], + plan: null, + }; + } + + return { ...prev, [repoKey]: next }; + }); + + if (mode === "hard-switch") { + showToast("Context Switched", `Active on ${branch}.`); + } else { + showToast("Changes Committed", `Updated ${branch}.`); + } + }; + + // --------------------------------------------------------------------------- + // Auth & startup render + // --------------------------------------------------------------------------- + useEffect(() => { + checkAuthentication(); + }, []); + + const checkAuthentication = async () => { + setStartupPhase("booting"); + setStartupStatusMessage("Starting application..."); + setStartupDetailMessage( + "Initializing authentication, provider, and workspace context." + ); + + try { + setStartupPhase("checking-backend"); + setStartupStatusMessage("Connecting to backend..."); + setStartupDetailMessage( + "Waiting for the server to be ready. This may take a few seconds on first start." + ); + + // Single-source-of-truth init: combines /api/status + /api/auth/status + // in one request. Runs exactly once per page load (StrictMode-safe). + const initResult = await initApp(); + const status = initResult.status; + if (status) { + setStartupStatusSnapshot(status); + setAdminStatus(status); + } + + const token = localStorage.getItem("github_token"); + const user = localStorage.getItem("github_user"); + + if (token && user) { + setStartupPhase("validating-auth"); + setStartupStatusMessage("Validating authentication..."); + setStartupDetailMessage( + "Restoring your GitHub session and confirming access." + ); + + try { + const data = await safeFetchJSON(apiUrl("/api/auth/validate"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ access_token: token }), + timeout: 20000, // 20s — first-load GitHub API validation can be slow + }); + + if (data.authenticated) { + setStartupPhase("restoring-session"); + setStartupStatusMessage("Restoring workspace..."); + setStartupDetailMessage( + "Loading user profile, reconnecting provider state, and preparing the workspace." + ); + + setIsAuthenticated(true); + setUserInfo(JSON.parse(user)); + setIsLoading(false); + return; + } + } catch (err) { + console.error(err); + } + + localStorage.removeItem("github_token"); + localStorage.removeItem("github_user"); + } + + setStartupPhase("ready"); + setStartupStatusMessage("Preparing sign-in..."); + setStartupDetailMessage( + "GitPilot is ready. Please authenticate to continue." + ); + + setIsAuthenticated(false); + setIsLoading(false); + } catch (err) { + console.error(err); + setStartupPhase("fallback"); + setStartupStatusMessage("Starting application..."); + setStartupDetailMessage( + "Continuing with basic startup while backend status is still loading." + ); + setIsAuthenticated(false); + setIsLoading(false); + } + }; + + const handleAuthenticated = (session) => { + setIsAuthenticated(true); + setUserInfo(session.user); + }; + + const handleLogout = () => { + localStorage.removeItem("github_token"); + localStorage.removeItem("github_user"); + setIsAuthenticated(false); + setUserInfo(null); + clearAllContext(); + }; + + if (isLoading) { + return ( + + ); + } + + if (!isAuthenticated) { + return ( + + ); + } + + const hasContext = contextRepos.length > 0; + + return ( +
+
+ + +
+ {activePage === "admin" && ( +
+
+ {["overview", "providers", "workspace-modes", "integrations", "sessions", "skills", "security", "advanced"].map((tab) => ( + + ))} +
+ + {adminTab === "overview" && ( +
+
+
Server
+
+ {adminStatus?.server_ready ? "Connected" : "Checking..."} +
+
127.0.0.1:8000
+
+ +
+
Provider
+
+ {adminStatus?.provider?.name || "Loading..."} +
+
+ {adminStatus?.provider?.configured + ? `${adminStatus.provider.model || "Ready"}` + : "Not configured"} +
+
+ +
+
Workspace Modes
+
+ Folder: {adminStatus?.workspace?.folder_mode_available ? "Yes" : "—"} +
+
+ Local Git: {adminStatus?.workspace?.local_git_available ? "Yes" : "—"} +
+
+ GitHub: {adminStatus?.workspace?.github_mode_available ? "Yes" : "Optional"} +
+
+ +
+
GitHub
+
+ {adminStatus?.github?.connected ? "Connected" : "Optional"} +
+
+ {adminStatus?.github?.username || "Not linked"} +
+
+ +
+
Sessions
+
+
+ +
+
Get Started
+ +
+
+ )} + + {adminTab === "providers" && ( +
+

AI Providers

+ +
+ )} + + {adminTab === "workspace-modes" && ( + { + setActiveSessionId(result.session_id); + setSessionRefreshNonce((n) => n + 1); + setActivePage("workspace"); + }} + /> + )} + + {adminTab === "integrations" && ( + + )} + + {adminTab === "security" && ( + + )} + + {adminTab === "sessions" && ( + { + handleSelectSession(s); + setActivePage("workspace"); + }} + /> + )} + + {adminTab === "skills" && } + + {adminTab === "advanced" && ( + setSettingsOpen(true)} + /> + )} +
+ )} + + {activePage === "flow" && } + + {activePage === "workspace" && + (repo ? ( +
+ setAddRepoOpen(true)} + onBranchChange={handleContextBranchChange} + /> + +
+ + +
+
+ GitPilot chat +
+ + +
+
+
+ ) : ( +
+
🤖
+

Select a repository

+

Select a repo to begin agentic workflow.

+
+ ))} +
+
+ +
+ + {repo && ( + setSettingsOpen(false)} + activeEnvId={activeEnvId} + onEnvChange={setActiveEnvId} + /> + )} + + setAddRepoOpen(false)} + excludeKeys={contextRepos.map((e) => e.repoKey)} + /> + + setAboutOpen(false)} + /> + + {toast && ( +
+
{toast.title}
+
{toast.message}
+
+ )} + + +
+ ); +} \ No newline at end of file diff --git a/frontend/components/AboutModal.jsx b/frontend/components/AboutModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8b2de0c3363e70a77bb7f100bc38a0810eb4a9ba --- /dev/null +++ b/frontend/components/AboutModal.jsx @@ -0,0 +1,488 @@ +// frontend/components/AboutModal.jsx +import React, { useEffect, useCallback, useState } from "react"; +import { apiUrl, safeFetchJSON } from "../utils/api.js"; + +/** + * AboutModal — "About GitPilot" dialog shown from the user menu. + * + * Enterprise design goals: + * - Prominent brand mark matching docs/logo.svg (orange ring + GP monogram) + * - Clear identity: name, tagline, version (frontend + backend) + * - Credits the creator (Ruslan Magana Vsevolodovna) as a link to GitHub + * - Open-source positioning: MIT license + GitHub repo link + * - Action row: View on GitHub, Report Issue, Documentation + * - Accessible: role="dialog", aria-modal, aria-labelledby, Escape to close, + * focus trap via initial focus on close button + * - Brand palette: #D95C3D accent, #1C1C1F card, #27272A border, #EDEDED text + */ + +const FRONTEND_VERSION = + typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "0.2.5"; + +export default function AboutModal({ isOpen, onClose }) { + const [backendVersion, setBackendVersion] = useState(null); + + // Fetch backend version when opened + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + (async () => { + try { + const data = await safeFetchJSON(apiUrl("/api/ping"), { timeout: 4000 }); + if (!cancelled) { + setBackendVersion(data?.version || null); + } + } catch { + if (!cancelled) setBackendVersion(null); + } + })(); + return () => { + cancelled = true; + }; + }, [isOpen]); + + // Escape to close + useEffect(() => { + if (!isOpen) return; + const handleKey = (e) => { + if (e.key === "Escape") onClose?.(); + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [isOpen, onClose]); + + // Lock body scroll while open + useEffect(() => { + if (!isOpen) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [isOpen]); + + const handleBackdropClick = useCallback( + (e) => { + if (e.target === e.currentTarget) onClose?.(); + }, + [onClose] + ); + + if (!isOpen) return null; + + return ( +
+
+ {/* Close button */} + + + {/* Hero: brand mark + name */} +
+ + +

+ GitPilot +

+
+ Enterprise Workspace Copilot +
+ +
+
+
+ + {/* Body */} +
+

+ An agentic AI coding companion for your repositories. Ask, plan, + code, and ship — with multi-LLM support, security scanning, and + VS Code integration. +

+ + {/* Meta table */} +
+ + + + + (e.currentTarget.style.textDecoration = "underline") + } + onMouseLeave={(e) => + (e.currentTarget.style.textDecoration = "none") + } + > + Ruslan Magana Vsevolodovna + + } + isLast + /> +
+
+ + {/* Action row */} +
+ } + label="GitHub" + /> + } + label="Docs" + /> + } + label="Report" + /> +
+ + {/* Footer */} +
+ © {new Date().getFullYear()} GitPilot · Made with care for + developers everywhere +
+
+ + +
+ ); +} + +// ── Brand mark (mirrors docs/logo.svg) ────────────────────────────── +function BrandMark() { + return ( +