// frontend/components/ChatPanel.jsx import React, { useEffect, useRef, useState } from "react"; import AssistantMessage from "./AssistantMessage.jsx"; import DiffStats from "./DiffStats.jsx"; import DiffViewer from "./DiffViewer.jsx"; import CreatePRButton from "./CreatePRButton.jsx"; import StreamingMessage from "./StreamingMessage.jsx"; import { SessionWebSocket } from "../utils/ws.js"; // 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(""); // 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); const wsRef = useRef(null); // Ref mirrors streamingEvents so WS callbacks avoid stale closures const streamingEventsRef = useRef([]); useEffect(() => { streamingEventsRef.current = streamingEvents; }, [streamingEvents]); // 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; 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) 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]); } setStreamingEvents([]); } } }, onError: (err) => { console.warn("[ws] Error:", err); setLoadingPlan(false); }, }); ws.connect(); wsRef.current = ws; return () => { ws.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]); // --------------------------------------------------------------------------- // 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) // --------------------------------------------------------------------------- const persistMessage = (sid, role, content) => { if (!sid) return; fetch(`/api/sessions/${sid}/message`, { method: "POST", headers: getHeaders(), body: JSON.stringify({ role, content }), }).catch(() => {}); // best-effort }; const send = async () => { if (!repo || !goal.trim()) return; const text = goal.trim(); // 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 { const res = await fetch("/api/chat/plan", { method: "POST", headers: getHeaders(), body: JSON.stringify({ repo_owner: repo.owner, repo_name: repo.name, goal: text, branch_name: effectiveBranch, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || "Failed to generate plan"); setPlan(data); // Extract summary from nested plan structure or top-level const summary = data.plan?.summary || data.summary || data.message || "Here is the proposed plan for your request."; // Assistant response (Answer + Action Plan) setMessages((prev) => [ ...prev, { from: "ai", role: "assistant", answer: summary, content: summary, plan: data, }, ]); // Persist assistant response to backend session persistMessage(sid, "assistant", summary); // Clear input only after success setGoal(""); } 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); } }; 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("/api/chat/execute", { method: "POST", headers: getHeaders(), body: JSON.stringify({ repo_owner: repo.owner, repo_name: repo.name, plan, branch_name, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || "Execution failed"); setStatus(data.message || "Execution completed."); const completionMsg = { from: "ai", role: "assistant", answer: data.message || "Execution completed.", content: data.message || "Execution completed.", executionLog: data.executionLog, }; // Show completion immediately (keeps old "Execution Log" section) setMessages((prev) => [...prev, 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 (
Tell GitPilot what you want to do with this repository.
It will propose a safe step-by-step plan before any execution.