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"; import ThinkingIndicator from "./ThinkingIndicator.jsx"; import ContextMeter from "./ContextMeter.jsx"; import TasksPanel from "./TasksPanel.jsx"; import DiffStats from "./DiffStats.jsx"; import DiffViewer from "./DiffViewer.jsx"; import CreatePRButton from "./CreatePRButton.jsx"; import StreamingMessage from "./StreamingMessage.jsx"; import SandboxCanvas from "./SandboxCanvas.jsx"; import FilePreviewPanel from "./FilePreviewPanel.jsx"; import { SessionWebSocket } from "../utils/ws.js"; import { classifyPlan, planSummary } from "../utils/planKind.js"; // Map a file extension to the canonical sandbox language tag. Used // when "Open in Canvas" needs to seed SandboxCanvas with the right // language hint pulled straight from a repo file path. const _LANG_FROM_EXT = { py: "python", js: "javascript", mjs: "javascript", cjs: "javascript", sh: "bash", bash: "bash", }; function languageFromPath(path) { if (!path || !path.includes(".")) return "python"; return _LANG_FROM_EXT[path.split(".").pop().toLowerCase()] || "python"; } // Helper to get headers (inline safety if utility is missing) const getHeaders = () => ({ "Content-Type": "application/json", Authorization: `Bearer ${localStorage.getItem("github_token") || ""}`, }); export default function ChatPanel({ repo, defaultBranch = "main", currentBranch, // do NOT default here; parent must pass the real one onExecutionComplete, sessionChatState, onSessionChatStateChange, sessionId, onEnsureSession, canChat = true, // readiness gate: false disables composer and shows blocker chatBlocker = null, // { message: string, cta?: string, onCta?: () => void } }) { // Initialize state from props or defaults const [messages, setMessages] = useState(sessionChatState?.messages || []); const [goal, setGoal] = useState(""); const [plan, setPlan] = useState(sessionChatState?.plan || null); const [loadingPlan, setLoadingPlan] = useState(false); const [executing, setExecuting] = useState(false); const [status, setStatus] = useState(""); // Batch B9 — populated when a plan whose first step was INDEX is // rejected. Lets us render a small "Run with grep instead?" prompt // so the user doesn't have to retype the goal. const [retryAfterIndexReject, setRetryAfterIndexReject] = useState(null); // Claude-Code-on-Web: WebSocket streaming + diff + PR const [wsConnected, setWsConnected] = useState(false); const [streamingEvents, setStreamingEvents] = useState([]); const [diffData, setDiffData] = useState(null); const [showDiffViewer, setShowDiffViewer] = useState(false); // SandboxCanvas state — opened by the "Open in Canvas" CTA on // post-CREATE next_actions and ExecutionCard footers. ``canvasSpec`` // is { filename, language, code } or null when closed. const [canvasSpec, setCanvasSpec] = useState(null); const [canvasError, setCanvasError] = useState(null); // FilePreviewPanel state — opened by clicking a file row in the // sidebar (gitpilot:open-file). Read-first surface; users can pick // "Prepare Run" / "Open Workspace" / "Ask GitPilot" from there. const [previewPath, setPreviewPath] = useState(null); const [previewContent, setPreviewContent] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); const [previewError, setPreviewError] = useState(null); const [previewErrorCode, setPreviewErrorCode] = useState(null); // "preview" (narrow drawer) or "workspace" (wide editor). const [previewMode, setPreviewMode] = useState("preview"); const wsRef = useRef(null); // Ref mirrors streamingEvents so WS callbacks avoid stale closures const streamingEventsRef = useRef([]); useEffect(() => { streamingEventsRef.current = streamingEvents; }, [streamingEvents]); // Tracks files that were just CREATE'd / MODIFY'd by a fresh execution. // Used to (a) auto-retry once on 404 (GitHub contents API has brief // eventual-consistency lag) and (b) classify the file viewer's empty // state as "still syncing" instead of a generic 404. const fileWasJustCreatedRef = useRef(new Set()); const fileWasJustDeletedRef = useRef(new Set()); // Skip the session-sync useEffect reset when we just created a session // (the parent already seeded the messages into chatBySession) const skipNextSyncRef = useRef(false); const messagesEndRef = useRef(null); const prevMsgCountRef = useRef((sessionChatState?.messages || []).length); // --------------------------------------------------------------------------- // WebSocket connection management // --------------------------------------------------------------------------- useEffect(() => { // Clean up previous connection if (wsRef.current) { wsRef.current.close(); wsRef.current = null; setWsConnected(false); } if (!sessionId) return; // Wait for backend to be reachable before opening WebSocket. // Without this, the WS connects immediately on session creation // and fails repeatedly with "closed before established" when the // backend is still starting up (common on WSL cold start). let cancelled = false; const backendUrl = resolveBackendUrl(); const pingUrl = backendUrl ? `${backendUrl}/api/ping` : '/api/ping'; const waitForBackend = async () => { for (let i = 0; i < 10 && !cancelled; i++) { try { const res = await fetch(pingUrl, { method: 'GET', signal: AbortSignal.timeout(2000) }); if (res.ok) return true; } catch { /* retry */ } await new Promise(r => setTimeout(r, 1500)); } return false; }; waitForBackend().then((ok) => { if (cancelled || !ok) return; connectWs(); }); function connectWs() { const ws = new SessionWebSocket(sessionId, { onConnect: () => setWsConnected(true), onDisconnect: () => setWsConnected(false), onMessage: (data) => { if (data.type === "agent_message") { setStreamingEvents((prev) => [...prev, data]); } else if (data.type === "tool_use" || data.type === "tool_result") { setStreamingEvents((prev) => [...prev, data]); } else if (data.type === "diff_update") { setDiffData(data.stats || data); } else if (data.type === "session_restored") { // Session loaded } }, onStatusChange: (newStatus) => { if (newStatus === "waiting") { // Always clear loading state when agent finishes setLoadingPlan(false); // Consolidate streaming events into a chat message (use ref to // avoid stale closure — streamingEvents state would be stale here). // // We also commit the FINAL consolidated text to the backend session // here. Previously this branch never called persistMessage, so the // assistant turn looked correct in the live view but vanished on the // next session reload — the canonical "streaming truncation" symptom. const events = streamingEventsRef.current; if (events.length > 0) { const textParts = events .filter((e) => e.type === "agent_message") .map((e) => e.content); if (textParts.length > 0) { const consolidated = { from: "ai", role: "assistant", answer: textParts.join(""), content: textParts.join(""), }; setMessages((prev) => [...prev, consolidated]); persistMessage(sessionId, "assistant", consolidated.content); } setStreamingEvents([]); } } }, onError: (err) => { console.warn("[ws] Error:", err); setLoadingPlan(false); }, }); ws.connect(); wsRef.current = ws; } // end connectWs return () => { cancelled = true; if (wsRef.current) wsRef.current.close(); }; }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps // --------------------------------------------------------------------------- // 1) SESSION SYNC: Restore chat when branch, repo, OR session changes // IMPORTANT: Do NOT depend on sessionChatState here (prevents prop/state loop) // --------------------------------------------------------------------------- useEffect(() => { // When send() just created a session, the parent seeded the messages // into chatBySession already. Skip the reset so we don't wipe // the optimistic user message that was already rendered. if (skipNextSyncRef.current) { skipNextSyncRef.current = false; return; } const nextMessages = sessionChatState?.messages || []; const nextPlan = sessionChatState?.plan || null; setMessages(nextMessages); setPlan(nextPlan); // Reset transient UI state on branch/repo/session switch setGoal(""); setStatus(""); setLoadingPlan(false); setExecuting(false); setStreamingEvents([]); setDiffData(null); // Update msg count tracker so auto-scroll doesn't "jump" on switch prevMsgCountRef.current = nextMessages.length; // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentBranch, repo?.full_name, sessionId]); // --------------------------------------------------------------------------- // 1b) FILE ▶ RUN: listen for run-file events from the sidebar. // --------------------------------------------------------------------------- // // FileTree dispatches ``gitpilot:run-file`` with the clicked file's // path. We turn that into a normal chat message ("run ") // which goes through /api/chat/plan, hits the deterministic // short-circuit, and renders an ExecutionPlanCard — exactly the // same flow as typing the command. One handler, one approval surface, // zero duplicated logic. useEffect(() => { const onRunFile = (e) => { const path = e?.detail?.path; if (!path || !repo) return; send({ goal: `run ${path}` }); }; // "Open in Canvas" handler — fetches the file's content from the // active branch and opens SandboxCanvas seeded with it. Logs a // friendly error banner when the fetch fails so a misconfigured // token / wrong branch doesn't silently swallow the click. const onOpenInCanvas = async (e) => { const path = e?.detail?.path; if (!path || !repo) return; setCanvasError(null); const branch = currentBranch || "HEAD"; try { const url = `/api/repos/${repo.owner}/${repo.name}/file` + `?path=${encodeURIComponent(path)}` + `&ref=${encodeURIComponent(branch)}`; 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})`); // Still open the canvas with empty content so the user can // paste something — better than nothing happening on click. setCanvasSpec({ filename: path, language: languageFromPath(path), code: "", }); return; } setCanvasSpec({ filename: path, language: languageFromPath(path), code: data.content || "", }); } catch (err) { setCanvasError(err?.message || "Could not load file for Canvas"); setCanvasSpec({ filename: path, language: languageFromPath(path), code: "", }); } }; // "Open file" — clicking a file row in the sidebar mounts the // read-first FilePreviewPanel. Calmer than dropping straight // into Canvas: the user sees the file, can pick "Prepare Run" // when they're ready (or "Open Workspace" for a wider editing // surface). The ``mode`` detail toggles the panel's geometry: // "preview" ── narrow right drawer for a quick look // "workspace" ── wide right-side editor for serious review const openFile = async (path, mode = "preview") => { if (!path || !repo) return; setPreviewPath(path); setPreviewMode(mode); setPreviewContent(null); setPreviewError(null); setPreviewErrorCode(null); setPreviewLoading(true); // Tell the sidebar which file is currently focused so it can // light up the row with the ◄ marker. try { window.dispatchEvent(new CustomEvent("gitpilot:file-opened", { detail: { path } })); } catch (_e) { /* old browser */ } const branch = currentBranch || "HEAD"; const fetchOnce = async () => { const url = `/api/repos/${repo.owner}/${repo.name}/file` + `?path=${encodeURIComponent(path)}` + `&ref=${encodeURIComponent(branch)}`; const res = await fetch(apiUrl(url), { headers: getHeaders() }); const data = await res.json().catch(() => ({})); return { res, data }; }; try { let { res, data } = await fetchOnce(); // Auto-retry once on 404 for recently created files — GitHub // contents API has brief eventual-consistency lag after a // freshly published commit. if (res.status === 404 && fileWasJustCreatedRef.current?.has(path)) { await new Promise((r) => setTimeout(r, 900)); ({ res, data } = await fetchOnce()); } if (!res.ok) { setPreviewError(data.detail || `HTTP ${res.status}`); setPreviewErrorCode(res.status); } else { setPreviewContent(data.content || ""); } } catch (err) { setPreviewError(err?.message || "Could not load file"); setPreviewErrorCode(null); } finally { setPreviewLoading(false); } }; const onOpenFile = (e) => openFile(e?.detail?.path, "preview"); const onOpenWorkspace = (e) => openFile(e?.detail?.path, "workspace"); // "Ask GitPilot" — seed the chat input with a contextual question // about the clicked file. Pure additive: focuses the input and // pre-fills it; the user can edit or send as-is. const onAskAboutFile = (e) => { const path = e?.detail?.path; if (!path) return; setGoal(`Tell me about ${path}.`); const ta = document.querySelector(".chat-input"); if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } }; window.addEventListener("gitpilot:run-file", onRunFile); window.addEventListener("gitpilot:open-in-canvas", onOpenInCanvas); window.addEventListener("gitpilot:open-file", onOpenFile); window.addEventListener("gitpilot:open-workspace", onOpenWorkspace); window.addEventListener("gitpilot:ask-about-file", onAskAboutFile); return () => { window.removeEventListener("gitpilot:run-file", onRunFile); window.removeEventListener("gitpilot:open-in-canvas", onOpenInCanvas); window.removeEventListener("gitpilot:open-file", onOpenFile); window.removeEventListener("gitpilot:open-workspace", onOpenWorkspace); window.removeEventListener("gitpilot:ask-about-file", onAskAboutFile); }; // ``send`` is stable enough across renders for this use case — // we don't want to re-bind on every keystroke. // eslint-disable-next-line react-hooks/exhaustive-deps }, [repo?.full_name, currentBranch, sessionId]); // --------------------------------------------------------------------------- // 2) PERSISTENCE: Save chat to Parent (no loop now because sync only on branch) // --------------------------------------------------------------------------- useEffect(() => { if (typeof onSessionChatStateChange === "function") { // Avoid wiping parent state on mount if (messages.length > 0 || plan) { onSessionChatStateChange({ messages, plan }); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [messages, plan]); // --------------------------------------------------------------------------- // 3) AUTO-SCROLL: Only scroll when a message is appended (reduces flicker) // --------------------------------------------------------------------------- useEffect(() => { const curCount = messages.length + streamingEvents.length; const prevCount = prevMsgCountRef.current; // Only scroll when new messages are added if (curCount > prevCount) { prevMsgCountRef.current = curCount; requestAnimationFrame(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }); } else { prevMsgCountRef.current = curCount; } }, [messages.length, streamingEvents.length]); // --------------------------------------------------------------------------- // HANDLERS // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Persist a message to the backend session (fire-and-forget). // // The fourth argument carries the *structured* payload of the assistant // response — the Action Plan, the Execution Log, diff stats, etc. The // backend stores it on Message.metadata; on session reload App.jsx // spreads metadata back into the local message via normalizeBackendMessage, // so the same AssistantMessage renderer can re-draw the Plan / Steps / // Create buttons identically to the live view. // // Before this fix the structured payload was dropped at persist time — // the session reloaded as raw text, and the UI degraded to a plain // paragraph. This is the canonical "state loss during hydration" bug. // --------------------------------------------------------------------------- const persistMessage = (sid, role, content, metadata = null) => { if (!sid) return; const body = { role, content }; if (metadata && typeof metadata === "object" && Object.keys(metadata).length > 0) { body.metadata = metadata; } fetch(apiUrl(`/api/sessions/${sid}/message`), { method: "POST", headers: getHeaders(), body: JSON.stringify(body), }).catch(() => {}); // best-effort }; // Pick the structured fields a message can carry across a reload. // Keep this in one place so every call-site stores the same shape and // the renderer never has to guess. const pickAssistantMetadata = (m) => { if (!m || typeof m !== "object") return null; const meta = {}; if (m.plan) meta.plan = m.plan; if (m.executionLog) meta.executionLog = m.executionLog; if (m.diff) meta.diff = m.diff; if (m.actions) meta.actions = m.actions; if (m.nextActions) meta.nextActions = m.nextActions; if (m.branch) meta.branch = m.branch; // Informational plans (READ-only answers to "what does X do?" style // questions) carry no Approve/Reject controls — pin the flag so the // session reload re-renders the same shape. if (m.informational) meta.informational = true; return Object.keys(meta).length > 0 ? meta : null; }; const send = async (overrides = {}) => { if (!repo) return; // Allow callers (e.g. the "Retry with grep" button on a rejected // INDEX plan) to drive send() with a fixed goal and a router flag. const overrideGoal = overrides.goal; const force_no_rag = Boolean(overrides.force_no_rag); const sourceText = overrideGoal != null ? overrideGoal : goal; if (!sourceText || !sourceText.trim()) return; const text = sourceText.trim(); // Clear input immediately (Claude Code behavior) — but only when // the user typed; programmatic retries leave the input alone. if (overrideGoal == null) setGoal(""); // Reset textarea height const ta = document.querySelector(".chat-input"); if (ta) ta.style.height = "40px"; // Optimistic update (user bubble appears immediately) const userMsg = { from: "user", role: "user", text, content: text }; setMessages((prev) => [...prev, userMsg]); setLoadingPlan(true); setStatus(""); setPlan(null); setStreamingEvents([]); // ------- Implicit session creation (Claude Code parity) ------- // Every chat must be backed by a session. If none exists yet, // create one on-demand before sending the plan request. let sid = sessionId; if (!sid && typeof onEnsureSession === "function") { // Derive a short title from the first message const sessionName = text.length > 60 ? text.slice(0, 57) + "..." : text; // Tell the sync useEffect to skip the reset that would otherwise // wipe the optimistic user message when activeSessionId changes. skipNextSyncRef.current = true; sid = await onEnsureSession(sessionName, [userMsg]); if (!sid) { // Session creation failed — continue without session skipNextSyncRef.current = false; } } // Persist user message to backend session persistMessage(sid, "user", text); // Always use HTTP for plan generation (the original reliable flow). // WebSocket is only used for real-time streaming feedback display. const effectiveBranch = currentBranch || defaultBranch || "HEAD"; try { // Timeout after 5 minutes (CrewAI agent can be slow with small models) const planController = new AbortController(); const planTimer = setTimeout(() => planController.abort(), 300000); let res; try { res = await fetch(apiUrl("/api/chat/plan"), { method: "POST", headers: getHeaders(), body: JSON.stringify({ repo_owner: repo.owner, repo_name: repo.name, goal: text, branch_name: effectiveBranch, // Lets the backend record this plan as a Task on the // session so the right-sidebar Tasks panel can trace it. session_id: sid, // Batch B9 — set on the "Retry with grep" path after the // user rejects an INDEX-plan. Tells the router to // suppress RAG / INDEX recommendations. force_no_rag, }), signal: planController.signal, }); } catch (fetchErr) { if (fetchErr.name === "AbortError") { throw new Error("Request timed out after 5 minutes. The LLM may be too slow. Try a faster model."); } throw fetchErr; } finally { clearTimeout(planTimer); } let data; try { data = await res.json(); } catch { throw new Error(`Server error (${res.status}). The LLM may have returned an invalid response. Try a different model or enable Lite Mode in Settings.`); } if (!res.ok) { const detail = data?.detail || data?.error || data?.message || ""; // Friendly message for common LLM failures if (detail.includes("None or empty") || detail.includes("Invalid response from LLM")) { throw new Error( "The LLM returned an empty response. This often happens with small models (deepseek, qwen 0.5b). " + "Try a larger model (llama3, qwen2.5:7b) or enable Lite Mode in Settings." ); } throw new Error(detail || "Failed to generate plan"); } // Classify the plan into one of three kinds so we can render the right // shape — not just "valid or banner". See utils/planKind.js; it lives // there so the rule can be tested directly instead of being re-derived // from a component that needs a browser to run. const summary = planSummary(data); const planKind = classifyPlan(data); if (planKind === "executable") { setPlan(data); const assistantMsg = { from: "ai", role: "assistant", answer: summary, content: summary, plan: data, }; setMessages((prev) => [...prev, assistantMsg]); persistMessage(sid, "assistant", summary, pickAssistantMetadata(assistantMsg)); } else if (planKind === "informational") { // The summary is the answer. No plan card, no Approve/Reject — // there is nothing to execute. We deliberately do NOT attach // ``plan: data`` here so AssistantMessage renders this turn // exactly like a chat reply. setPlan(null); const assistantMsg = { from: "ai", role: "assistant", answer: summary, content: summary, informational: true, }; setMessages((prev) => [...prev, assistantMsg]); persistMessage(sid, "assistant", summary, pickAssistantMetadata(assistantMsg)); } else { // empty — be honest about what we know. The earlier wording // ("got stuck reading the same file twice") was a guess from // an older bug; for the cases that actually still hit this // branch the real signal is just "no actionable steps". setPlan(null); const failureText = "The model returned an empty plan. Try rephrasing more concretely, " + "or pick a stronger model in Settings → Provider."; const failureMsg = { from: "ai", role: "system", content: failureText, }; setMessages((prev) => [...prev, failureMsg]); persistMessage(sid, "system", failureText); setStatus("No actionable plan produced."); return; } } catch (err) { const msg = String(err?.message || err); console.error(err); setStatus(msg); setMessages((prev) => [ ...prev, { from: "ai", role: "system", content: `Error: ${msg}` }, ]); } finally { setLoadingPlan(false); } }; // --------------------------------------------------------------------------- // Reject the active plan — minimal first cut. // // Industry rule we follow from the start: never write to disk on a path the // user did not approve. Rejecting is the cheapest expression of that — // discard the proposed plan locally, leave the workspace untouched, record // the rejection in chat history so the user sees it after a session reload. // // No backend endpoint is needed yet because plans are not persisted as // first-class objects today; they ride along on the assistant message's // metadata. When we later add per-plan state tracking, this handler will // also POST /api/chat/plan/{id}/reject — leaving that for a follow-up. // --------------------------------------------------------------------------- const rejectPlan = () => { if (!plan || executing) return; // Batch B9 — if the rejected plan contained an INDEX step, the // user is implicitly saying "I don't want to build the semantic // index right now". Stash the original goal so we can offer a // one-click "retry with grep" path on the next render. const hadIndexStep = Array.isArray(plan?.steps) && plan.steps.some((s) => Array.isArray(s?.files) && s.files.some((f) => f?.action === "INDEX"), ); const rejectedGoal = plan?.goal || ""; setPlan(null); setStatus("Plan rejected. No files were changed."); const rejectionMsg = { from: "ai", role: "system", content: "Plan rejected. No files were changed.", }; setMessages((prev) => [...prev, rejectionMsg]); if (sessionId) { persistMessage(sessionId, "system", rejectionMsg.content); } if (hadIndexStep && rejectedGoal) { setRetryAfterIndexReject({ goal: rejectedGoal }); } else { setRetryAfterIndexReject(null); } }; const execute = async () => { if (!repo || !plan) return; setExecuting(true); setStatus(""); try { // Guard: currentBranch might be missing if parent didn't pass it yet const safeCurrent = currentBranch || defaultBranch || "HEAD"; const safeDefault = defaultBranch || "main"; // Sticky vs Hard Switch: // - If on default branch -> undefined (backend creates new branch) // - If already on AI branch -> currentBranch (backend updates existing) const branch_name = safeCurrent === safeDefault ? undefined : safeCurrent; const res = await fetch(apiUrl("/api/chat/execute"), { method: "POST", headers: getHeaders(), body: JSON.stringify({ repo_owner: repo.owner, repo_name: repo.name, plan, branch_name, // Lets the backend persist the new branch on the session // record so reopening this session lands on the published // branch, not the one it was created on. session_id: sessionId, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || "Execution failed"); setStatus(data.message || "Execution completed."); // Track files touched by this execution so the file viewer can // give "still syncing" / "deleted" classifications and so the // sidebar refreshes off the freshly-pushed branch tree. if (plan?.steps) { for (const step of plan.steps) { for (const f of step.files || []) { if (f.action === "CREATE" || f.action === "MODIFY") { fileWasJustCreatedRef.current.add(f.path); } else if (f.action === "DELETE") { fileWasJustDeletedRef.current.add(f.path); } } } } // Forget the marker after 30 s so older "syncing" badges don't // stick around forever. window.setTimeout(() => { fileWasJustCreatedRef.current.clear(); fileWasJustDeletedRef.current.clear(); }, 30000); // Ask the sidebar's file tree to refetch off the newly-published // branch. Fires after a small delay so GitHub's contents API has // a chance to catch up. window.setTimeout(() => { try { window.dispatchEvent(new CustomEvent("gitpilot:refresh-tree")); } catch (_e) { /* old browser */ } }, 600); const completionMsg = { from: "ai", role: "assistant", answer: data.message || "Execution completed.", content: data.message || "Execution completed.", executionLog: data.executionLog, diff: data.diff, // Backend-suggested follow-ups (e.g. "Run demo.py" after CREATE // of a runnable file). Rendered as a button row in the // completion message — one click, no typing. nextActions: data.next_actions, branch: data.branch || data.branch_name, }; // Show completion immediately (keeps old "Execution Log" section) setMessages((prev) => [...prev, completionMsg]); // Persist the execution log + diff alongside the message text so // the History view re-renders the green "Execution Log" panel and // the "View diff" affordance. Without this, reloading the session // shows just the one-line "Execution completed." summary. persistMessage( sessionId, "assistant", completionMsg.content, pickAssistantMetadata(completionMsg), ); // Clear active plan UI setPlan(null); // Pass completionMsg upward for seeding branch history if (typeof onExecutionComplete === "function") { onExecutionComplete({ branch: data.branch || data.branch_name, mode: data.mode, commit_url: data.commit_url || data.html_url, message: data.message, completionMsg, sourceBranch: safeCurrent, }); } } catch (err) { console.error(err); setStatus(String(err?.message || err)); } finally { setExecuting(false); } }; // --------------------------------------------------------------------------- // RENDER // --------------------------------------------------------------------------- const isOnSessionBranch = currentBranch && currentBranch !== defaultBranch; return (
{messages.map((m, idx) => { // Success message (App.jsx injected) if (m.isSuccess) { return (
🚀
{m.content}
{m.link && ( View Changes on GitHub → )}
); } // User message if (m.from === "user" || m.role === "user") { return (
{m.text || m.content}
); } // Assistant message (Answer / Plan / Execution Log). // // Lifecycle audit signal: if this message carries a plan, look // ahead in the timeline for any subsequent message that // records an execution log (=> the plan was approved+executed) // or a system "Plan rejected" entry (=> the plan was // rejected). The status is rendered as a small green/grey // badge next to the Action Plan header so users can tell at a // glance — in history — whether a previous plan was acted on. let planStatus = null; if (m.plan) { const after = messages.slice(idx + 1); if (after.some((later) => later.executionLog)) { planStatus = "executed"; } else if ( after.some( (later) => later.role === "system" && typeof later.content === "string" && later.content.includes("Plan rejected"), ) ) { planStatus = "rejected"; } } // Find the plan that was approved for this completion, so the // success receipt can label actions (READ/CREATE/...) instead of // showing only an opaque execution dump. let linkedPlan = null; if (m.executionLog) { for (let i = idx - 1; i >= 0; i--) { if (messages[i].plan?.steps) { linkedPlan = messages[i].plan; break; } } } return (
execute()} nextActions={m.nextActions} relatedPlan={linkedPlan} diff={m.diff} branch={m.branch || currentBranch} /> {/* Diff stats indicator (Claude-Code-on-Web parity) */} {m.diff && ( { setDiffData(m.diff); setShowDiffViewer(true); }} /> )}
); })} {/* Streaming events (real-time agent output) */} {streamingEvents.length > 0 && (
)} {/* Enterprise Pulse — agentic thinking state shown after the user hits Send and before the first streamed/planned chunk arrives. Falls back gracefully to nothing once streamingEvents start flowing in (StreamingMessage takes over the live feedback). */} {loadingPlan && streamingEvents.length === 0 && ( )} {/* Live execution status — visible in the chat timeline while ``executing`` is true, sits between the Action Plan card and where the Execution Log (green panel in AssistantMessage) will land once the backend returns. Removes the "did the app freeze?" feeling caused by only the bottom button saying "Executing…". Reuses the ThinkingIndicator with execution-specific labels. When the executor finishes, ``setExecuting(false)`` removes this bubble and the completionMsg lands in the timeline as a normal assistant message with its green Execution Log block — already rendered by AssistantMessage today. */} {executing && ( )} {!messages.length && !plan && !loadingPlan && streamingEvents.length === 0 && (
💬

Tell GitPilot what you want to do with this repository.

It will propose a safe step-by-step plan before any execution.

)}
{/* Batch B9 — post-Reject "retry with grep" prompt. Renders only when the user rejected a plan whose first step was an INDEX action. One click re-issues the same goal with force_no_rag so the router falls back to grep. */} {retryAfterIndexReject && !loadingPlan && (
Index skipped. Run the same goal with grep instead?
)} {/* Diff stats bar (when agent has made changes) */} {diffData && (
setShowDiffViewer(true)} />
)}
{/* Readiness blocker banner */} {!canChat && chatBlocker && (
{chatBlocker.message || "Chat is not ready yet."} {chatBlocker.cta && chatBlocker.onCta && ( )}
)} {status && (
{status}
)}