| |
| 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"; |
|
|
| |
| 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 } |
| }) { |
| |
| 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(""); |
|
|
| |
| const [wsConnected, setWsConnected] = useState(false); |
| const [streamingEvents, setStreamingEvents] = useState([]); |
| const [diffData, setDiffData] = useState(null); |
| const [showDiffViewer, setShowDiffViewer] = useState(false); |
| const wsRef = useRef(null); |
|
|
| |
| const streamingEventsRef = useRef([]); |
| useEffect(() => { streamingEventsRef.current = streamingEvents; }, [streamingEvents]); |
|
|
| |
| |
| const skipNextSyncRef = useRef(false); |
|
|
| const messagesEndRef = useRef(null); |
| const prevMsgCountRef = useRef((sessionChatState?.messages || []).length); |
|
|
| |
| |
| |
| useEffect(() => { |
| |
| 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") { |
| |
| } |
| }, |
| onStatusChange: (newStatus) => { |
| if (newStatus === "waiting") { |
| |
| setLoadingPlan(false); |
|
|
| |
| |
| 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]); |
|
|
| |
| |
| |
| |
| useEffect(() => { |
| |
| |
| |
| if (skipNextSyncRef.current) { |
| skipNextSyncRef.current = false; |
| return; |
| } |
|
|
| const nextMessages = sessionChatState?.messages || []; |
| const nextPlan = sessionChatState?.plan || null; |
|
|
| setMessages(nextMessages); |
| setPlan(nextPlan); |
|
|
| |
| setGoal(""); |
| setStatus(""); |
| setLoadingPlan(false); |
| setExecuting(false); |
| setStreamingEvents([]); |
| setDiffData(null); |
|
|
| |
| prevMsgCountRef.current = nextMessages.length; |
| |
| }, [currentBranch, repo?.full_name, sessionId]); |
|
|
| |
| |
| |
| useEffect(() => { |
| if (typeof onSessionChatStateChange === "function") { |
| |
| if (messages.length > 0 || plan) { |
| onSessionChatStateChange({ messages, plan }); |
| } |
| } |
| |
| }, [messages, plan]); |
|
|
| |
| |
| |
| useEffect(() => { |
| const curCount = messages.length + streamingEvents.length; |
| const prevCount = prevMsgCountRef.current; |
|
|
| |
| if (curCount > prevCount) { |
| prevMsgCountRef.current = curCount; |
| requestAnimationFrame(() => { |
| messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
| }); |
| } else { |
| prevMsgCountRef.current = curCount; |
| } |
| }, [messages.length, streamingEvents.length]); |
|
|
| |
| |
| |
| |
| |
| |
| const persistMessage = (sid, role, content) => { |
| if (!sid) return; |
| fetch(`/api/sessions/${sid}/message`, { |
| method: "POST", |
| headers: getHeaders(), |
| body: JSON.stringify({ role, content }), |
| }).catch(() => {}); |
| }; |
|
|
| const send = async () => { |
| if (!repo || !goal.trim()) return; |
|
|
| const text = goal.trim(); |
|
|
| |
| const userMsg = { from: "user", role: "user", text, content: text }; |
| setMessages((prev) => [...prev, userMsg]); |
|
|
| setLoadingPlan(true); |
| setStatus(""); |
| setPlan(null); |
| setStreamingEvents([]); |
|
|
| |
| |
| |
| let sid = sessionId; |
| if (!sid && typeof onEnsureSession === "function") { |
| |
| const sessionName = text.length > 60 ? text.slice(0, 57) + "..." : text; |
|
|
| |
| |
| skipNextSyncRef.current = true; |
|
|
| sid = await onEnsureSession(sessionName, [userMsg]); |
| if (!sid) { |
| |
| skipNextSyncRef.current = false; |
| } |
| } |
|
|
| |
| persistMessage(sid, "user", text); |
|
|
| |
| |
| 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); |
|
|
| |
| const summary = |
| data.plan?.summary || data.summary || data.message || |
| "Here is the proposed plan for your request."; |
|
|
| |
| setMessages((prev) => [ |
| ...prev, |
| { |
| from: "ai", |
| role: "assistant", |
| answer: summary, |
| content: summary, |
| plan: data, |
| }, |
| ]); |
|
|
| |
| persistMessage(sid, "assistant", summary); |
|
|
| |
| 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 { |
| |
| const safeCurrent = currentBranch || defaultBranch || "HEAD"; |
| const safeDefault = defaultBranch || "main"; |
|
|
| |
| |
| |
| 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, |
| }; |
|
|
| |
| setMessages((prev) => [...prev, completionMsg]); |
|
|
| |
| setPlan(null); |
|
|
| |
| 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); |
| } |
| }; |
|
|
| |
| |
| |
| const isOnSessionBranch = currentBranch && currentBranch !== defaultBranch; |
|
|
| return ( |
| <div className="chat-container"> |
| <style>{` |
| .chat-container { display: flex; flex-direction: column; height: 100%; } |
| |
| .chat-messages { |
| flex: 1; overflow-y: auto; |
| padding: 20px; |
| display: flex; flex-direction: column; gap: 16px; |
| } |
| |
| .chat-message-user { |
| align-self: flex-end; |
| background: #27272A; |
| color: #fff; |
| padding: 12px 16px; |
| border-radius: 10px; |
| max-width: 85%; |
| font-size: 14px; |
| line-height: 1.5; |
| } |
| |
| |
| .chat-msg-success { |
| align-self: flex-start; |
| width: 100%; |
| background: rgba(16, 185, 129, 0.10); |
| border: 1px solid rgba(16, 185, 129, 0.20); |
| color: #D1FAE5; |
| padding: 12px 16px; |
| border-radius: 10px; |
| display: flex; |
| gap: 12px; |
| font-size: 14px; |
| } |
| .success-icon { font-size: 18px; } |
| .success-link { |
| display: inline-block; |
| margin-top: 6px; |
| font-weight: 600; |
| color: #34D399; |
| text-decoration: none; |
| } |
| .success-link:hover { text-decoration: underline; } |
| |
| .chat-input-box { |
| padding: 16px; |
| border-top: 1px solid #27272A; |
| background: #131316; |
| } |
| |
| .chat-input-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } |
| |
| .chat-input { |
| flex: 1; |
| min-width: 200px; |
| background: #18181B; |
| border: 1px solid #27272A; |
| color: white; |
| padding: 10px 12px; |
| border-radius: 8px; |
| outline: none; |
| font-size: 14px; |
| font-family: inherit; |
| resize: none; |
| min-height: 40px; |
| max-height: 160px; |
| line-height: 1.4; |
| } |
| |
| |
| .chat-btn { |
| height: 38px; |
| padding: 0 14px; |
| border-radius: 8px; |
| font-weight: 700; |
| cursor: pointer; |
| border: 1px solid transparent; |
| font-size: 13px; |
| white-space: nowrap; |
| } |
| |
| |
| .chat-btn.primary { background: #D95C3D; color: #fff; } |
| .chat-btn.primary:hover { filter: brightness(0.98); } |
| .chat-btn.primary:disabled { opacity: 0.55; cursor: not-allowed; } |
| |
| |
| .chat-btn.secondary { |
| background: transparent; |
| border: 1px solid #3F3F46; |
| color: #A1A1AA; |
| } |
| .chat-btn.secondary:hover { background: rgba(255,255,255,0.04); } |
| .chat-btn.secondary:disabled { opacity: 0.55; cursor: not-allowed; } |
| |
| .chat-empty-state { |
| text-align: center; |
| color: #52525B; |
| margin-top: 40px; |
| font-size: 14px; |
| } |
| |
| |
| .ws-indicator { |
| display: inline-flex; |
| align-items: center; |
| gap: 4px; |
| font-size: 10px; |
| color: #71717A; |
| padding: 2px 6px; |
| border-radius: 4px; |
| background: rgba(24, 24, 27, 0.6); |
| } |
| .ws-dot { |
| width: 6px; |
| height: 6px; |
| border-radius: 50%; |
| } |
| |
| @keyframes blink { |
| 0%, 100% { opacity: 1; } |
| 50% { opacity: 0; } |
| } |
| `}</style> |
| |
| <div className="chat-messages"> |
| {messages.map((m, idx) => { |
| // Success message (App.jsx injected) |
| if (m.isSuccess) { |
| return ( |
| <div key={idx} className="chat-msg-success"> |
| <div className="success-icon">🚀</div> |
| <div> |
| <div style={{ whiteSpace: "pre-wrap" }}>{m.content}</div> |
| {m.link && ( |
| <a href={m.link} target="_blank" rel="noreferrer" className="success-link"> |
| View Changes on GitHub → |
| </a> |
| )} |
| </div> |
| </div> |
| ); |
| } |
| |
| // User message |
| if (m.from === "user" || m.role === "user") { |
| return ( |
| <div key={idx} className="chat-message-user"> |
| <span>{m.text || m.content}</span> |
| </div> |
| ); |
| } |
| |
| // Assistant message (Answer / Plan / Execution Log) |
| return ( |
| <div key={idx}> |
| <AssistantMessage |
| answer={m.answer || m.content} |
| plan={m.plan} |
| executionLog={m.executionLog} |
| /> |
| {/* Diff stats indicator (Claude-Code-on-Web parity) */} |
| {m.diff && ( |
| <DiffStats diff={m.diff} onClick={() => { |
| setDiffData(m.diff); |
| setShowDiffViewer(true); |
| }} /> |
| )} |
| </div> |
| ); |
| })} |
| |
| {/* Streaming events (real-time agent output) */} |
| {streamingEvents.length > 0 && ( |
| <div> |
| <StreamingMessage events={streamingEvents} /> |
| </div> |
| )} |
| |
| {loadingPlan && streamingEvents.length === 0 && ( |
| <div className="chat-message-ai" style={{ color: "#A1A1AA", fontStyle: "italic", padding: "10px" }}> |
| Thinking... |
| </div> |
| )} |
| |
| {!messages.length && !plan && !loadingPlan && streamingEvents.length === 0 && ( |
| <div className="chat-empty-state"> |
| <div className="chat-empty-icon">💬</div> |
| <p>Tell GitPilot what you want to do with this repository.</p> |
| <p style={{ fontSize: 12, color: "#676883", marginTop: 4 }}> |
| It will propose a safe step-by-step plan before any execution. |
| </p> |
| </div> |
| )} |
| |
| <div ref={messagesEndRef} /> |
| </div> |
| |
| {/* Diff stats bar (when agent has made changes) */} |
| {diffData && ( |
| <div style={{ |
| padding: "8px 16px", |
| borderTop: "1px solid #27272A", |
| background: "#18181B", |
| }}> |
| <DiffStats diff={diffData} onClick={() => setShowDiffViewer(true)} /> |
| </div> |
| )} |
| |
| <div className="chat-input-box"> |
| {/* Readiness blocker banner */} |
| {!canChat && chatBlocker && ( |
| <div style={{ |
| fontSize: 12, |
| color: "#F59E0B", |
| background: "rgba(245, 158, 11, 0.08)", |
| border: "1px solid rgba(245, 158, 11, 0.2)", |
| borderRadius: 6, |
| padding: "8px 12px", |
| marginBottom: 8, |
| display: "flex", |
| alignItems: "center", |
| justifyContent: "space-between", |
| }}> |
| <span>{chatBlocker.message || "Chat is not ready yet."}</span> |
| {chatBlocker.cta && chatBlocker.onCta && ( |
| <button |
| type="button" |
| onClick={chatBlocker.onCta} |
| style={{ |
| fontSize: 11, |
| fontWeight: 600, |
| color: "#F59E0B", |
| background: "transparent", |
| border: "1px solid rgba(245, 158, 11, 0.3)", |
| borderRadius: 4, |
| padding: "2px 8px", |
| cursor: "pointer", |
| }} |
| > |
| {chatBlocker.cta} |
| </button> |
| )} |
| </div> |
| )} |
| {status && ( |
| <div style={{ fontSize: 11, color: "#ffb3b7", marginBottom: 8 }}> |
| {status} |
| </div> |
| )} |
| |
| <div className="chat-input-row"> |
| <textarea |
| className="chat-input" |
| placeholder={wsConnected ? "Send feedback or instructions..." : "Describe the change you want to make..."} |
| value={goal} |
| rows={1} |
| onChange={(e) => { |
| setGoal(e.target.value); |
| e.target.style.height = "40px"; |
| e.target.style.height = Math.min(e.target.scrollHeight, 160) + "px"; |
| }} |
| onKeyDown={(e) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| if (!loadingPlan && !executing) send(); |
| } |
| }} |
| disabled={!canChat || loadingPlan || executing} |
| /> |
| |
| {/* Always show both buttons (old UX) */} |
| <button |
| className="chat-btn primary" |
| type="button" |
| onClick={send} |
| disabled={!canChat || loadingPlan || executing || !goal.trim()} |
| > |
| {loadingPlan ? "Planning..." : wsConnected ? "Send" : "Generate plan"} |
| </button> |
| |
| <button |
| className="chat-btn secondary" |
| type="button" |
| onClick={execute} |
| disabled={!plan || executing || loadingPlan} |
| > |
| {executing ? "Executing..." : "Approve & execute"} |
| </button> |
| |
| {/* Create PR button (Claude-Code-on-Web parity) */} |
| {isOnSessionBranch && ( |
| <CreatePRButton |
| repo={repo} |
| sessionId={sessionId} |
| branch={currentBranch} |
| defaultBranch={defaultBranch} |
| disabled={executing || loadingPlan} |
| /> |
| )} |
| </div> |
| |
| {/* WebSocket connection indicator */} |
| {sessionId && ( |
| <div style={{ marginTop: 6, display: "flex", alignItems: "center", gap: 8 }}> |
| <span className="ws-indicator"> |
| <span className="ws-dot" style={{ |
| backgroundColor: wsConnected ? "#10B981" : "#EF4444", |
| }} /> |
| {wsConnected ? "Live" : "Connecting..."} |
| </span> |
| </div> |
| )} |
| </div> |
| |
| {/* Diff Viewer overlay */} |
| {showDiffViewer && ( |
| <DiffViewer |
| diff={diffData} |
| onClose={() => setShowDiffViewer(false)} |
| /> |
| )} |
| </div> |
| ); |
| } |
|
|