import * as React from "react"; import type { AgentConfigInfo, AgentEvent, AgentStatusValue, } from "../api/agents"; import { agentsApi, subscribeAgentStream } from "../api/agents"; import { settingsApi } from "../api/settings"; const { useCallback, useEffect, useRef, useState } = React; interface AgentState { agentName: string; status: AgentStatusValue; log: string[]; sessionId: string | null; worktreePath: string | null; baseBranch: string | null; } const STATUS_COLORS: Record = { pending: "#e3b341", running: "#58a6ff", completed: "#7ee787", failed: "#ff7b72", }; const STATUS_LABELS: Record = { pending: "Pending", running: "Running", completed: "Completed", failed: "Failed", }; const formatTimestamp = (value: Date | null): string => { if (!value) return ""; return value.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", }); }; function StatusBadge({ status }: { status: AgentStatusValue }) { return ( {status === "running" && ( )} {STATUS_LABELS[status]} ); } function RosterBadge({ agent, activeStoryId, }: { agent: AgentConfigInfo; activeStoryId: string | null; }) { const isActive = activeStoryId !== null; const storyNumber = activeStoryId?.match(/^(\d+)/)?.[1]; return ( {isActive && ( )} {!isActive && ( )} {agent.name} {agent.model && ( {agent.model} )} {isActive && storyNumber && ( #{storyNumber} )} {!isActive && ( idle )} ); } /** Build a composite key for tracking agent state. */ function agentKey(storyId: string, agentName: string): string { return `${storyId}:${agentName}`; } function DiffCommand({ worktreePath, baseBranch, }: { worktreePath: string; baseBranch: string; }) { const [copied, setCopied] = useState(false); const command = `cd "${worktreePath}" && git difftool ${baseBranch}...HEAD`; const handleCopy = async () => { try { await navigator.clipboard.writeText(command); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { // Fallback: select text for manual copy } }; return (
{command}
); } export function EditorCommand({ worktreePath, editorCommand, }: { worktreePath: string; editorCommand: string; }) { const [copied, setCopied] = useState(false); const command = `${editorCommand} "${worktreePath}"`; const handleCopy = async () => { try { await navigator.clipboard.writeText(command); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { // Fallback: select text for manual copy } }; return (
{command}
); } export function AgentPanel() { const [agents, setAgents] = useState>({}); const [roster, setRoster] = useState([]); const [expandedKey, setExpandedKey] = useState(null); const [actionError, setActionError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); const [editorCommand, setEditorCommand] = useState(null); const [editorInput, setEditorInput] = useState(""); const [editingEditor, setEditingEditor] = useState(false); const cleanupRefs = useRef void>>({}); const logEndRefs = useRef>({}); // Load roster, existing agents, and editor preference on mount useEffect(() => { agentsApi .getAgentConfig() .then(setRoster) .catch((err) => console.error("Failed to load agent config:", err)); agentsApi .listAgents() .then((agentList) => { const agentMap: Record = {}; for (const a of agentList) { const key = agentKey(a.story_id, a.agent_name); agentMap[key] = { agentName: a.agent_name, status: a.status, log: [], sessionId: a.session_id, worktreePath: a.worktree_path, baseBranch: a.base_branch, }; if (a.status === "running" || a.status === "pending") { subscribeToAgent(a.story_id, a.agent_name); } } setAgents(agentMap); setLastRefresh(new Date()); }) .catch((err) => console.error("Failed to load agents:", err)); settingsApi .getEditorCommand() .then((s) => { setEditorCommand(s.editor_command); setEditorInput(s.editor_command ?? ""); }) .catch((err) => console.error("Failed to load editor command:", err)); return () => { for (const cleanup of Object.values(cleanupRefs.current)) { cleanup(); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const subscribeToAgent = useCallback((storyId: string, agentName: string) => { const key = agentKey(storyId, agentName); cleanupRefs.current[key]?.(); const cleanup = subscribeAgentStream( storyId, agentName, (event: AgentEvent) => { setAgents((prev) => { const current = prev[key] ?? { agentName, status: "pending" as AgentStatusValue, log: [], sessionId: null, worktreePath: null, baseBranch: null, }; switch (event.type) { case "status": return { ...prev, [key]: { ...current, status: (event.status as AgentStatusValue) ?? current.status, }, }; case "output": return { ...prev, [key]: { ...current, log: [...current.log, event.text ?? ""], }, }; case "done": return { ...prev, [key]: { ...current, status: "completed", sessionId: event.session_id ?? current.sessionId, }, }; case "error": return { ...prev, [key]: { ...current, status: "failed", log: [ ...current.log, `[ERROR] ${event.message ?? "Unknown error"}`, ], }, }; default: return prev; } }); }, () => { // SSE error — agent may not be streaming yet }, ); cleanupRefs.current[key] = cleanup; }, []); // Auto-scroll log when expanded useEffect(() => { if (expandedKey) { const el = logEndRefs.current[expandedKey]; el?.scrollIntoView({ behavior: "smooth" }); } }, [expandedKey, agents]); const handleStop = async (storyId: string, agentName: string) => { setActionError(null); const key = agentKey(storyId, agentName); try { await agentsApi.stopAgent(storyId, agentName); cleanupRefs.current[key]?.(); delete cleanupRefs.current[key]; setAgents((prev) => { const next = { ...prev }; delete next[key]; return next; }); } catch (err) { const message = err instanceof Error ? err.message : String(err); setActionError(`Failed to stop agent for ${storyId}: ${message}`); } }; const handleSaveEditor = async () => { try { const trimmed = editorInput.trim() || null; const result = await settingsApi.setEditorCommand(trimmed); setEditorCommand(result.editor_command); setEditorInput(result.editor_command ?? ""); setEditingEditor(false); } catch (err) { const message = err instanceof Error ? err.message : String(err); setActionError(`Failed to save editor: ${message}`); } }; return (
Agents
{Object.values(agents).filter((a) => a.status === "running").length}{" "} running
{lastRefresh && (
Loaded {formatTimestamp(lastRefresh)}
)}
{/* Editor preference */}
Editor: {editingEditor ? ( <> setEditorInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleSaveEditor(); if (e.key === "Escape") setEditingEditor(false); }} placeholder="zed, code, cursor..." style={{ fontSize: "0.75em", background: "#111", border: "1px solid #444", borderRadius: "4px", color: "#ccc", padding: "2px 6px", width: "120px", }} /> ) : ( )}
{/* Roster badges — show all configured agents with idle/active state */} {roster.length > 0 && (
{roster.map((a) => { // Find the story this roster agent is currently working on (if any) const activeEntry = Object.entries(agents).find( ([, state]) => state.agentName === a.name && (state.status === "running" || state.status === "pending"), ); const activeStoryId = activeEntry ? activeEntry[0].split(":")[0] : null; return ( ); })}
)} {actionError && (
{actionError}
)} {/* Active agents */} {Object.entries(agents).length > 0 && (
{Object.entries(agents).map(([key, a]) => (
{a.agentName} {key.split(":")[0]}
{(a.status === "running" || a.status === "pending") && ( )}
{expandedKey === key && (
{a.worktreePath && (
Worktree: {a.worktreePath}
)} {a.worktreePath && ( )}
{a.log.length === 0 ? ( {a.status === "pending" || a.status === "running" ? "Waiting for output..." : "No output captured."} ) : ( a.log.map((line, i) => (
{line}
)) )}
{ logEndRefs.current[key] = el; }} />
)}
))}
)}
); }