import * as React from "react"; import type { AgentConfigInfo, AgentEvent, AgentInfo, AgentStatusValue, } from "../api/agents"; import { agentsApi, subscribeAgentStream } from "../api/agents"; import type { UpcomingStory } from "../api/workflow"; const { useCallback, useEffect, useRef, useState } = React; interface AgentPanelProps { stories: UpcomingStory[]; } 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 }: { agent: AgentConfigInfo }) { return ( {agent.name} {agent.model && {agent.model}} ); } /** 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 AgentPanel({ stories }: AgentPanelProps) { const [agents, setAgents] = useState>({}); const [roster, setRoster] = useState([]); const [expandedKey, setExpandedKey] = useState(null); const [actionError, setActionError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); const [selectorStory, setSelectorStory] = useState(null); const cleanupRefs = useRef void>>({}); const logEndRefs = useRef>({}); // Load roster and existing agents 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)); 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 handleStart = async (storyId: string, agentName?: string) => { setActionError(null); setSelectorStory(null); try { const info: AgentInfo = await agentsApi.startAgent(storyId, agentName); const key = agentKey(info.story_id, info.agent_name); setAgents((prev) => ({ ...prev, [key]: { agentName: info.agent_name, status: info.status, log: [], sessionId: info.session_id, worktreePath: info.worktree_path, baseBranch: info.base_branch, }, })); setExpandedKey(key); subscribeToAgent(info.story_id, info.agent_name); } catch (err) { const message = err instanceof Error ? err.message : String(err); setActionError(`Failed to start agent for ${storyId}: ${message}`); } }; 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 handleRunClick = (storyId: string) => { if (roster.length <= 1) { handleStart(storyId); } else { setSelectorStory(selectorStory === storyId ? null : storyId); } }; /** Get all active agent keys for a story. */ const getActiveKeysForStory = (storyId: string): string[] => { return Object.keys(agents).filter((key) => { const a = agents[key]; return ( key.startsWith(`${storyId}:`) && (a.status === "running" || a.status === "pending") ); }); }; return (
Agents
{Object.values(agents).filter((a) => a.status === "running").length}{" "} running
{lastRefresh && (
Loaded {formatTimestamp(lastRefresh)}
)}
{/* Roster badges */} {roster.length > 0 && (
{roster.map((a) => ( ))}
)} {actionError && (
{actionError}
)} {stories.length === 0 ? (
No stories available. Add stories to .story_kit/stories/upcoming/.
) : (
{stories.map((story) => { const activeKeys = getActiveKeysForStory(story.story_id); const hasActive = activeKeys.length > 0; // Gather all agent states for this story const storyAgentEntries = Object.entries(agents).filter(([key]) => key.startsWith(`${story.story_id}:`), ); return (
{story.name ?? story.story_id}
{storyAgentEntries.map(([key, a]) => ( {a.agentName} ))} {hasActive ? ( ) : (
{selectorStory === story.story_id && roster.length > 1 && (
{roster.map((r) => ( ))}
)}
)}
{/* Empty state when expanded with no agents */} {expandedKey === story.story_id && storyAgentEntries.length === 0 && (
No agents started. Use the Run button to start an agent.
)} {/* Expanded detail per agent */} {storyAgentEntries.map(([key, a]) => { if (expandedKey !== key) return null; return (
{a.agentName}
{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; }} />
); })}
); })}
)}
); }