diff --git a/.env.template b/.env.template index 7bf0617..e686099 100644 --- a/.env.template +++ b/.env.template @@ -21,11 +21,19 @@ GITPILOT_GITHUB_TOKEN= # Alternative PAT variable name (either works) # GITHUB_TOKEN= -# Option 3: OAuth App (Advanced) +# Option 3: OAuth App (Advanced — "Continue with GitHub" Web Flow) # Create OAuth App at: https://github.com/settings/developers +# NOTE: The Web Flow only activates when GITHUB_CLIENT_SECRET is set. Without it +# the app automatically uses the Device Flow (no secret / no callback URL). GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -GITHUB_REDIRECT_URI=http://localhost:8000/api/auth/callback +# Browser callback after authorizing on GitHub. This must point at the FRONTEND +# /auth page (which exchanges the code), NOT the API. When left blank it +# defaults to "{GITPILOT_PUBLIC_BASE_URL}/auth". Register this exact URL as the +# OAuth/GitHub App "Authorization callback URL". +# Local dev: http://localhost:5173/auth +# Production: https://gitpilot.ruslanmv.com/auth +GITHUB_REDIRECT_URI= # Priority: GitHub App > OAuth > PAT # For quickstart: Use PAT diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ac7ed9..d780a18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Coder API** (`POST /repair` + `GET /repair/health`), bearer-token gated (`GITPILOT_API_TOKEN`), mounted into the main app — turns a repair-plan into a dry-run patch preview for SelfRepair / matrix-maintainer over HTTPS. +- **Runtime-aware onboarding** — `/api/status` now reports `workspace.runtime` (`cloud`/`local`); in a cloud workspace the empty state guides you to connect GitHub and pick a repository, while a local install offers Folder / Local Git paths with GitHub optional. +- **Account-first auth across split deployments** — portable `X-GitPilot-Session` token (cross-origin Vercel↔HF), a dedicated email-verification screen, a Settings → Account tab (update name, change password, delete account), and "GitHub not linked" now shows a calm Connect prompt instead of a repo-fetch error. ### Changed — `make run` now starts the MCP Context Forge stack by default diff --git a/frontend/App.jsx b/frontend/App.jsx index 4e35067..ec3de88 100644 --- a/frontend/App.jsx +++ b/frontend/App.jsx @@ -24,8 +24,9 @@ import { SessionsTab, AdvancedTab, SandboxTab, + AccountTab, } from "./components/AdminTabs"; -import { apiUrl, safeFetchJSON, fetchStatus } from "./utils/api.js"; +import { apiUrl, safeFetchJSON, fetchStatus, getAuthHeaders, clearSessionToken, startSession } from "./utils/api.js"; import { initApp } from "./utils/appInit.js"; function makeRepoKey(repo) { @@ -267,7 +268,7 @@ export default function App() { ...(token ? { Authorization: `Bearer ${token}` } : {}), }; - const res = await fetch("/api/sessions", { + const res = await fetch(apiUrl("/api/sessions"), { method: "POST", headers, body: JSON.stringify({ @@ -317,7 +318,7 @@ export default function App() { ...(token ? { Authorization: `Bearer ${token}` } : {}), }; - const res = await fetch("/api/sessions", { + const res = await fetch(apiUrl("/api/sessions"), { method: "POST", headers, body: JSON.stringify({ @@ -857,6 +858,7 @@ export default function App() { const me = await safeFetchJSON(apiUrl("/api/account/me"), { method: "GET", credentials: "include", + headers: getAuthHeaders(), // X-GitPilot-Session survives cross-origin timeout: 8000, }); if (me && me.id) { @@ -895,13 +897,38 @@ export default function App() { }; const handleLogout = () => { + // Best-effort backend logout (clears the session cookie); ignore failures. + try { + fetch(apiUrl("/api/account/logout"), { + method: "POST", + credentials: "include", + headers: getAuthHeaders(), + }).catch(() => {}); + } catch { /* ignore */ } localStorage.removeItem("github_token"); localStorage.removeItem("github_user"); + clearSessionToken(); setIsAuthenticated(false); setUserInfo(null); clearAllContext(); }; + // In-workspace "Connect GitHub": let an already-signed-in account user run + // the GitHub device flow to link their repos, then return to the workspace. + // This renders even when authenticated (unlike the sign-in screen below). + if (typeof window !== "undefined") { + const qs = new URLSearchParams(window.location.search); + if (window.location.pathname.startsWith("/auth") && qs.get("connect") === "github") { + return ( + + ); + } + } + // Public pages must render instantly — never gated behind backend startup. // (A marketing landing page that shows "Connecting to backend…" is bad for // users and for SEO crawlers.) Only a returning GitHub-token session, which @@ -1037,7 +1064,18 @@ export default function App() { {!sidebarCollapsed && ( <> {!hasContext && ( - addRepoToContext(r)} /> + addRepoToContext(r)} + workspace={startupStatusSnapshot?.workspace} + onOpenLocal={async (payload) => { + const result = await startSession(payload); + setActiveSessionId(result.session_id); + setSessionRefreshNonce((n) => n + 1); + setActivePage("workspace"); + showToast?.("Workspace ready", "Local project opened."); + return result; + }} + /> )} {repo && ( @@ -1058,6 +1096,10 @@ export default function App() { { + setActivePage("admin"); + setAdminTab("account"); + }} onOpenSettings={() => { setActivePage("admin"); setAdminTab("advanced"); @@ -1071,9 +1113,20 @@ export default function App() {
{activePage === "admin" && ( -
+
- {["overview", "providers", "workspace-modes", "integrations", "mcp-servers", "sandbox", "sessions", "skills", "security", "advanced"].map((tab) => ( + {["overview", "account", "providers", "workspace-modes", "integrations", "mcp-servers", "sandbox", "sessions", "skills", "security", "advanced"].map((tab) => (
)} + {adminTab === "account" && ( + + )} + {adminTab === "providers" && (

AI Providers

@@ -1174,6 +1231,7 @@ export default function App() { {adminTab === "workspace-modes" && ( { setActiveSessionId(result.session_id); setSessionRefreshNonce((n) => n + 1); @@ -1248,6 +1306,7 @@ export default function App() { onBranchChange={handleBranchChange} pulseNonce={pulseNonce} lastExecution={lastExecution} + runtime={startupStatusSnapshot?.workspace?.runtime} onSettingsClick={() => setSettingsOpen(true)} /> diff --git a/frontend/components/AdminTabs/AccountTab.jsx b/frontend/components/AdminTabs/AccountTab.jsx new file mode 100644 index 0000000..15e8821 --- /dev/null +++ b/frontend/components/AdminTabs/AccountTab.jsx @@ -0,0 +1,305 @@ +import React, { useEffect, useState } from "react"; +import { apiUrl, getAuthHeaders, clearSessionToken } from "../../utils/api.js"; + +// Account settings — the GitPilot account (your email/password identity). +// This is intentionally separate from the GitHub connection, which only grants +// access to repositories. Here you manage who you are; GitHub manages what you +// can touch. +export default function AccountTab({ onLogout }) { + const [account, setAccount] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(""); + + // Profile + const [name, setName] = useState(""); + const [savingProfile, setSavingProfile] = useState(false); + const [profileMsg, setProfileMsg] = useState(""); + + // Password + const [curPw, setCurPw] = useState(""); + const [newPw, setNewPw] = useState(""); + const [confirmPw, setConfirmPw] = useState(""); + const [savingPw, setSavingPw] = useState(false); + const [pwMsg, setPwMsg] = useState(null); // {kind, text} + + // Delete (danger zone) + const [confirmDelete, setConfirmDelete] = useState(false); + const [delPw, setDelPw] = useState(""); + const [deleting, setDeleting] = useState(false); + const [delErr, setDelErr] = useState(""); + + const jsonHeaders = () => ({ "Content-Type": "application/json", ...getAuthHeaders() }); + + const loadAccount = async () => { + setLoading(true); + setLoadError(""); + try { + const res = await fetch(apiUrl("/api/account/me"), { + credentials: "include", + headers: getAuthHeaders(), + }); + if (res.status === 401) { + setAccount(null); + return; + } + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || "Could not load your account."); + setAccount(data); + setName(data.name || ""); + } catch (e) { + setLoadError(e.message || "Could not load your account."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadAccount(); + }, []); + + const isPasswordAccount = account?.provider === "password"; + + const saveProfile = async () => { + setSavingProfile(true); + setProfileMsg(""); + try { + const res = await fetch(apiUrl("/api/account/profile"), { + method: "PATCH", + credentials: "include", + headers: jsonHeaders(), + body: JSON.stringify({ name: name.trim() || null }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || "Could not save your profile."); + setAccount(data); + setProfileMsg("Saved."); + setTimeout(() => setProfileMsg(""), 3000); + } catch (e) { + setProfileMsg(e.message || "Could not save your profile."); + } finally { + setSavingProfile(false); + } + }; + + const changePassword = async () => { + setPwMsg(null); + if (newPw.length < 8) { + setPwMsg({ kind: "err", text: "New password must be at least 8 characters." }); + return; + } + if (newPw !== confirmPw) { + setPwMsg({ kind: "err", text: "New password and confirmation don't match." }); + return; + } + setSavingPw(true); + try { + const res = await fetch(apiUrl("/api/account/password/change"), { + method: "POST", + credentials: "include", + headers: jsonHeaders(), + body: JSON.stringify({ current_password: curPw, new_password: newPw }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || "Could not change your password."); + setPwMsg({ kind: "ok", text: "Password changed successfully." }); + setCurPw(""); setNewPw(""); setConfirmPw(""); + } catch (e) { + setPwMsg({ kind: "err", text: e.message || "Could not change your password." }); + } finally { + setSavingPw(false); + } + }; + + const deleteAccount = async () => { + setDeleting(true); + setDelErr(""); + try { + const res = await fetch(apiUrl("/api/account/delete"), { + method: "POST", + credentials: "include", + headers: jsonHeaders(), + body: JSON.stringify({ password: delPw || null }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.detail || "Could not delete your account."); + // Gone for good — clear local session and return to a signed-out state. + clearSessionToken(); + if (typeof onLogout === "function") onLogout(); + window.location.href = "/"; + } catch (e) { + setDelErr(e.message || "Could not delete your account."); + setDeleting(false); + } + }; + + if (loading) { + return ( +
+

Account

+

Loading your account…

+
+ ); + } + + // Signed in with GitHub only (no GitPilot email account) — explain the split. + if (!account) { + return ( +
+

Account

+

+ This is your GitPilot account — your email/password identity, + separate from the GitHub connection that grants repository access. +

+
+
You're signed in with GitHub
+
+ {loadError + ? loadError + : "You don't have a GitPilot email account yet. GitHub sign-in gives you repository access; create a GitPilot account to manage a profile, password, and account settings here."} +
+
+
+ ); + } + + return ( +
+

Account

+

+ Your GitPilot account — your identity and sign-in. This is + separate from the GitHub connection, which only grants repository access. +

+ + {/* Profile */} +
+
Profile
+ + + +
+ {account.email_verified ? "✓ Verified" : "Not verified"} + {" · "}Sign-in method: {account.provider} +
+ + + setName(e.target.value)} + placeholder="Your name" + /> + +
+ + {profileMsg && {profileMsg}} +
+
+ + {/* Password */} + {isPasswordAccount && ( +
+
Change password
+ + {pwMsg && ( +
+ {pwMsg.text} +
+ )} + + + setCurPw(e.target.value)} + placeholder="Current password" + autoComplete="current-password" + /> + + + setNewPw(e.target.value)} + placeholder="At least 8 characters" + autoComplete="new-password" + /> + + + setConfirmPw(e.target.value)} + placeholder="Re-enter new password" + autoComplete="new-password" + /> + +
+ +
+
+ )} + + {/* Danger zone */} +
+
Danger zone
+
+ Permanently delete your GitPilot account and all of its data. This cannot be undone. +
+ + {!confirmDelete ? ( + + ) : ( +
+
+ This is permanent. {isPasswordAccount ? "Enter your password to confirm." : "Confirm to proceed."} +
+ {delErr &&
{delErr}
} + {isPasswordAccount && ( + setDelPw(e.target.value)} + placeholder="Your password" + autoComplete="current-password" + style={{ marginBottom: 12 }} + /> + )} +
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/components/AdminTabs/IntegrationsTab.jsx b/frontend/components/AdminTabs/IntegrationsTab.jsx index e6c4d25..7cdb4d4 100644 --- a/frontend/components/AdminTabs/IntegrationsTab.jsx +++ b/frontend/components/AdminTabs/IntegrationsTab.jsx @@ -38,32 +38,14 @@ export default function IntegrationsTab({ userInfo, onDisconnect, showToast }) { }; }, []); - const handleConnect = async () => { + const handleConnect = () => { + // Link GitHub without leaving the account: the in-workspace connect flow + // runs the GitHub device flow (or web OAuth if a secret is configured) and + // returns here. Works the same whether or not an account is already signed + // in. See App.jsx (?connect=github) and AuthPage connectMode. setConnecting(true); setError(null); - try { - if (authStatus?.mode === "web") { - // Web OAuth flow — redirect to GitHub authorization URL - const { authorization_url, state } = await safeFetchJSON( - apiUrl("/api/auth/url"), - { timeout: 5000 } - ); - if (state) { - sessionStorage.setItem("gitpilot_oauth_state", state); - } - // Full page redirect (OAuth providers don't support iframes) - window.location.href = authorization_url; - } else { - // Device flow — the LoginPage already handles this. - showToast?.( - "Device flow", - "GitHub device flow is configured. Sign out and sign in again to reconnect." - ); - } - } catch (err) { - setError(err?.message || "Failed to start OAuth flow"); - setConnecting(false); - } + window.location.href = "/auth?connect=github&view=github"; }; const handleDisconnect = () => { diff --git a/frontend/components/AdminTabs/WorkspaceModesTab.jsx b/frontend/components/AdminTabs/WorkspaceModesTab.jsx index 124373c..093aa5a 100644 --- a/frontend/components/AdminTabs/WorkspaceModesTab.jsx +++ b/frontend/components/AdminTabs/WorkspaceModesTab.jsx @@ -51,12 +51,17 @@ const MODES = [ }, ]; -export default function WorkspaceModesTab({ onSessionStarted, showToast }) { +export default function WorkspaceModesTab({ onSessionStarted, showToast, runtime }) { const [activeModeId, setActiveModeId] = useState(null); const [inputValue, setInputValue] = useState(""); const [submittingId, setSubmittingId] = useState(null); const [errorByMode, setErrorByMode] = useState({}); + // In a cloud workspace there is no user-owned filesystem, so folder and + // local-Git modes can't work — only GitHub-sourced code makes sense. + const isCloud = runtime === "cloud"; + const modes = isCloud ? MODES.filter((m) => m.id === "github") : MODES; + const handleCardClick = (mode) => { if (submittingId) return; setActiveModeId(mode.id); @@ -109,11 +114,19 @@ export default function WorkspaceModesTab({ onSessionStarted, showToast }) {

Workspace Modes

- Choose how you want GitPilot to interact with your code. You can switch modes at any time. + {isCloud + ? "You're in a cloud workspace, so GitPilot works from a GitHub repository. Folder and Local Git modes are only available when running GitPilot on your own machine." + : "Choose how you want GitPilot to interact with your code. You can switch modes at any time."}

-
- {MODES.map((mode) => { +
+ {modes.map((mode) => { const isActive = activeModeId === mode.id; const isSubmitting = submittingId === mode.id; const error = errorByMode[mode.id]; diff --git a/frontend/components/AdminTabs/index.js b/frontend/components/AdminTabs/index.js index 4775db1..a08b740 100644 --- a/frontend/components/AdminTabs/index.js +++ b/frontend/components/AdminTabs/index.js @@ -8,3 +8,4 @@ export { default as SkillsTab } from "./SkillsTab.jsx"; export { default as SessionsTab } from "./SessionsTab.jsx"; export { default as AdvancedTab } from "./AdvancedTab.jsx"; export { default as SandboxTab } from "./SandboxTab.jsx"; +export { default as AccountTab } from "./AccountTab.jsx"; diff --git a/frontend/components/AssistantMessage.jsx b/frontend/components/AssistantMessage.jsx index 9a19965..ed96829 100644 --- a/frontend/components/AssistantMessage.jsx +++ b/frontend/components/AssistantMessage.jsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { apiUrl } from "../utils/api.js"; import PlanView from "./PlanView.jsx"; import RunnableCodeBlock, { splitFences } from "./RunnableCodeBlock.jsx"; import ExecutionPlanCard from "./ExecutionPlanCard.jsx"; @@ -35,7 +36,7 @@ export default function AssistantMessage({ const body = ep.file ? { language: ep.language, code: null } : { language: ep.language, code: ep.inline_code, timeout_sec: ep.timeout_sec }; - const res = await fetch("/api/sandbox/run", { + const res = await fetch(apiUrl("/api/sandbox/run"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); diff --git a/frontend/components/AuthPage.jsx b/frontend/components/AuthPage.jsx index e066e55..e6c0541 100644 --- a/frontend/components/AuthPage.jsx +++ b/frontend/components/AuthPage.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from "react"; import "./auth.css"; import { resolveBackendUrl } from "../utils/backend.js"; +import { getSessionToken, setSessionToken } from "../utils/api.js"; // Premium /auth — GitPilot account (email/password) + "Continue with GitHub". // GitPilot account = who you are; GitHub = repository access (the device flow). @@ -13,11 +14,23 @@ import { resolveBackendUrl } from "../utils/backend.js"; const api = (path) => `${resolveBackendUrl()}${path}`; +// The account session also travels in a header (not just the cross-site cookie) +// so email/password sign-in survives the Vercel-frontend / HF-backend split. +function sessionHeaders() { + const t = getSessionToken(); + return t ? { "X-GitPilot-Session": t } : {}; +} + +// Persist the portable session token returned by account endpoints. +function rememberSession(data) { + if (data && data.session_token) setSessionToken(data.session_token); +} + async function post(path, body) { const r = await fetch(api(path), { method: "POST", - headers: { "content-type": "application/json" }, - credentials: "include", // receive/send the HttpOnly session cookie + headers: { "content-type": "application/json", ...sessionHeaders() }, + credentials: "include", // also send/receive the session cookie when same-origin body: JSON.stringify(body || {}), }); let data = {}; @@ -26,7 +39,11 @@ async function post(path, body) { } async function getJSON(path, opts = {}) { - const r = await fetch(api(path), { credentials: "include", ...opts }); + const r = await fetch(api(path), { + credentials: "include", + ...opts, + headers: { ...sessionHeaders(), ...(opts.headers || {}) }, + }); let data = {}; try { data = await r.json(); } catch { /* empty body */ } return { ok: r.ok, status: r.status, data }; @@ -40,10 +57,19 @@ const Eye = ({ off }) => (off : ); -export default function AuthPage({ onAuthenticated, backendReady = false }) { +export default function AuthPage({ onAuthenticated, backendReady = false, connectMode = false }) { const params = new URLSearchParams(window.location.search); + const isVerifyLink = + !!params.get("token") && window.location.pathname.includes("verify-email"); + // connectMode / ?view=github: jump straight to the GitHub device flow (used by + // the in-workspace "Connect GitHub" button for already-signed-in accounts). + const wantsGithub = !!params.get("code") || params.get("view") === "github" || connectMode; const [mode, setMode] = useState(params.get("mode") === "signup" ? "signup" : "signin"); - const [view, setView] = useState("email"); // "email" | "github" + // Pick the opening view: GitHub flow → github; an email confirmation link + // (?token= on /verify-email) → verify; otherwise email. + const [view, setView] = useState( + wantsGithub ? "github" : isVerifyLink ? "verify" : "email" + ); // "email" | "github" | "verify" const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [name, setName] = useState(""); @@ -59,18 +85,45 @@ export default function AuthPage({ onAuthenticated, backendReady = false }) { const stopPolling = useRef(false); const ghStarted = useRef(false); - // Email-verification links land here with ?token=... + // Email-verification state (dedicated screen, not a silent redirect) + const [verifyPhase, setVerifyPhase] = useState("verifying"); // "verifying" | "success" | "error" + const [verifyMsg, setVerifyMsg] = useState(""); + const [verifyUser, setVerifyUser] = useState(null); + const [resendBusy, setResendBusy] = useState(false); + const verifyStarted = useRef(false); + + // Exchange the email-confirmation token once, then show success/error in place. useEffect(() => { + if (view !== "verify" || verifyStarted.current) return; + verifyStarted.current = true; const token = params.get("token"); - if (token && window.location.pathname.includes("verify-email")) { - (async () => { - const r = await post("/api/account/verify-email", { token }); - if (r.ok && typeof onAuthenticated === "function") onAuthenticated({ user: r.data }); - else setNote({ kind: "err", text: r.data.detail || "Invalid or expired link." }); - })(); - } + setVerifyPhase("verifying"); + (async () => { + const r = await post("/api/account/verify-email", { token }); + // Strip the token from the URL so a refresh can't replay a stale link. + window.history.replaceState({}, document.title, "/auth"); + if (r.ok) { + rememberSession(r.data); + setVerifyUser(r.data); + setVerifyPhase("success"); + } else { + setVerifyMsg(r.data.detail || "This confirmation link is invalid or has expired."); + setVerifyPhase("error"); + } + })(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [view]); + + const resendVerification = async () => { + if (!email) { setNote({ kind: "err", text: "Enter your email to resend the link." }); return; } + setResendBusy(true); + try { + await post("/api/account/resend-verification", { email }); + setNote({ kind: "ok", text: "If your account needs confirmation, a new link is on its way." }); + } finally { + setResendBusy(false); + } + }; // ── GitHub authorization (device + web OAuth), all inside the same card ── function finishGitHub(data) { @@ -88,6 +141,10 @@ export default function AuthPage({ onAuthenticated, backendReady = false }) { if (typeof onAuthenticated === "function") { onAuthenticated({ access_token: data.access_token, user: data.user }); } + // Linked from inside the workspace → reload into it with the new GitHub token. + if (connectMode) { + window.location.href = "/"; + } } async function pollDevice(deviceCode, interval) { @@ -196,7 +253,10 @@ export default function AuthPage({ onAuthenticated, backendReady = false }) { try { if (mode === "signin") { const r = await post("/api/account/login", { email, password }); - if (r.ok && typeof onAuthenticated === "function") onAuthenticated({ user: r.data }); + if (r.ok) { + rememberSession(r.data); + if (typeof onAuthenticated === "function") onAuthenticated({ user: r.data }); + } else if (r.status === 403) setNote({ kind: "err", text: "Verify your email before signing in." }); else setNote({ kind: "err", text: r.data.detail || "Invalid email or password." }); } else { @@ -217,13 +277,78 @@ export default function AuthPage({ onAuthenticated, backendReady = false }) { setNote({ kind: "ok", text: "If an account exists, we'll send a reset link." }); }; + // ── Email-confirmation card (landed from the verify-email link) ── + if (view === "verify") { + return ( +
+ ← Back to home +
+ GP + + {verifyPhase === "verifying" && ( + <> +

Confirming your email…

+

Activating your GitPilot account. This only takes a moment.

+
+ + )} + + {verifyPhase === "success" && ( + <> + +

Email confirmed

+

+ Welcome{verifyUser?.name ? `, ${verifyUser.name}` : ""}! Your GitPilot + account is active. +

+ + + )} + + {verifyPhase === "error" && ( + <> +

Confirmation link problem

+
{verifyMsg}
+

Links expire after 15 minutes. Enter your email to get a fresh one.

+ {note &&
{note.text}
} +
+ setEmail(e.target.value)} + placeholder="Email" + /> +
+ + + + )} + +
© {new Date().getFullYear()} GitPilot Inc.
+
+
+ ); + } + // ── GitHub authorization card (same shell, no nested rectangle) ── if (view === "github") { return (
← Back to home
- + {connectMode + ? + : } GP {ghPhase === "connecting" && ( diff --git a/frontend/components/ChatPanel.jsx b/frontend/components/ChatPanel.jsx index 5424b78..de39b4e 100644 --- a/frontend/components/ChatPanel.jsx +++ b/frontend/components/ChatPanel.jsx @@ -1,4 +1,5 @@ import { resolveBackendUrl } from "../utils/backend.js"; +import { apiUrl } from "../utils/api.js"; // frontend/components/ChatPanel.jsx import React, { useEffect, useRef, useState } from "react"; import AssistantMessage from "./AssistantMessage.jsx"; @@ -255,7 +256,7 @@ export default function ChatPanel({ const url = `/api/repos/${repo.owner}/${repo.name}/file` + `?path=${encodeURIComponent(path)}` + `&ref=${encodeURIComponent(branch)}`; - const res = await fetch(url, { headers: getHeaders() }); + const res = await fetch(apiUrl(url), { headers: getHeaders() }); const data = await res.json().catch(() => ({})); if (!res.ok) { setCanvasError(data.detail || `Could not load ${path} (HTTP ${res.status})`); @@ -303,7 +304,7 @@ export default function ChatPanel({ const url = `/api/repos/${repo.owner}/${repo.name}/file` + `?path=${encodeURIComponent(path)}` + `&ref=${encodeURIComponent(branch)}`; - const res = await fetch(url, { headers: getHeaders() }); + const res = await fetch(apiUrl(url), { headers: getHeaders() }); const data = await res.json().catch(() => ({})); return { res, data }; }; @@ -412,7 +413,7 @@ export default function ChatPanel({ if (metadata && typeof metadata === "object" && Object.keys(metadata).length > 0) { body.metadata = metadata; } - fetch(`/api/sessions/${sid}/message`, { + fetch(apiUrl(`/api/sessions/${sid}/message`), { method: "POST", headers: getHeaders(), body: JSON.stringify(body), @@ -498,7 +499,7 @@ export default function ChatPanel({ let res; try { - res = await fetch("/api/chat/plan", { + res = await fetch(apiUrl("/api/chat/plan"), { method: "POST", headers: getHeaders(), body: JSON.stringify({ @@ -713,7 +714,7 @@ export default function ChatPanel({ // - If already on AI branch -> currentBranch (backend updates existing) const branch_name = safeCurrent === safeDefault ? undefined : safeCurrent; - const res = await fetch("/api/chat/execute", { + const res = await fetch(apiUrl("/api/chat/execute"), { method: "POST", headers: getHeaders(), body: JSON.stringify({ diff --git a/frontend/components/ContextMeter.jsx b/frontend/components/ContextMeter.jsx index acd60ea..8504dfc 100644 --- a/frontend/components/ContextMeter.jsx +++ b/frontend/components/ContextMeter.jsx @@ -17,6 +17,7 @@ //