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, MCPServersTab, 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; // Clear the session-keyed chat cache's ``plan`` AND append the // completion message synchronously, before any branch change can // trigger ChatPanel's session-sync effect. Two bugs need to be // fixed in the same write: // // 1. Stale plan: without clearing, the sync effect re-reads the // old approved plan and restores the Approve & execute / Reject // plan buttons, enabling accidental double-execution. // // 2. Wiped completion: in hard-switch mode the sync effect runs // BEFORE the persistence effect (declared earlier in // ChatPanel), so it overwrites local ``messages`` with // ``sessionChatState.messages`` — which doesn't yet contain // completionMsg. The user's "Answer / Execution Log" block // then vanishes from the session view. // // By appending normalizedCompletion here, sessionChatState already // carries the completion when the sync effect reads it. No // duplicate is introduced: local ``messages`` already has the same // entry, so the subsequent persistence pass is a no-op write. if (activeSessionId) { 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, diff: completionMsg.diff, } : null; setChatBySession((prev) => { const existing = prev[activeSessionId]; if (!existing) return prev; const noPlanChange = existing.plan == null; if (noPlanChange && !normalizedCompletion) return prev; return { ...prev, [activeSessionId]: { ...existing, messages: normalizedCompletion ? [...(existing.messages || []), normalizedCompletion] : existing.messages, plan: null, }, }; }); } 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", "mcp-servers", "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 === "mcp-servers" && ( )} {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.

))}
); }