import * as React from "react"; import type { 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 { status: AgentStatusValue; log: string[]; sessionId: string | null; worktreePath: 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]} ); } export function AgentPanel({ stories }: AgentPanelProps) { const [agents, setAgents] = useState>({}); const [expandedStory, setExpandedStory] = useState(null); const [actionError, setActionError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); const cleanupRefs = useRef void>>({}); const logEndRefs = useRef>({}); // Load existing agents on mount useEffect(() => { agentsApi .listAgents() .then((agentList) => { const agentMap: Record = {}; for (const a of agentList) { agentMap[a.story_id] = { status: a.status, log: [], sessionId: a.session_id, worktreePath: a.worktree_path, }; // Re-subscribe to running agents if (a.status === "running" || a.status === "pending") { subscribeToAgent(a.story_id); } } 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) => { // Clean up existing subscription cleanupRefs.current[storyId]?.(); const cleanup = subscribeAgentStream( storyId, (event: AgentEvent) => { setAgents((prev) => { const current = prev[storyId] ?? { status: "pending" as AgentStatusValue, log: [], sessionId: null, worktreePath: null, }; switch (event.type) { case "status": return { ...prev, [storyId]: { ...current, status: (event.status as AgentStatusValue) ?? current.status, }, }; case "output": return { ...prev, [storyId]: { ...current, log: [...current.log, event.text ?? ""], }, }; case "done": return { ...prev, [storyId]: { ...current, status: "completed", sessionId: event.session_id ?? current.sessionId, }, }; case "error": return { ...prev, [storyId]: { ...current, status: "failed", log: [ ...current.log, `[ERROR] ${event.message ?? "Unknown error"}`, ], }, }; default: return prev; } }); }, () => { // SSE error — agent may not be streaming yet }, ); cleanupRefs.current[storyId] = cleanup; }, []); // Auto-scroll log when expanded useEffect(() => { if (expandedStory) { const el = logEndRefs.current[expandedStory]; el?.scrollIntoView({ behavior: "smooth" }); } }, [expandedStory, agents]); const handleStart = async (storyId: string) => { setActionError(null); try { const info: AgentInfo = await agentsApi.startAgent(storyId); setAgents((prev) => ({ ...prev, [storyId]: { status: info.status, log: [], sessionId: info.session_id, worktreePath: info.worktree_path, }, })); setExpandedStory(storyId); subscribeToAgent(storyId); } catch (err) { const message = err instanceof Error ? err.message : String(err); setActionError(`Failed to start agent for ${storyId}: ${message}`); } }; const handleStop = async (storyId: string) => { setActionError(null); try { await agentsApi.stopAgent(storyId); cleanupRefs.current[storyId]?.(); delete cleanupRefs.current[storyId]; setAgents((prev) => { const next = { ...prev }; delete next[storyId]; return next; }); } catch (err) { const message = err instanceof Error ? err.message : String(err); setActionError(`Failed to stop agent for ${storyId}: ${message}`); } }; const isAgentActive = (storyId: string): boolean => { const agent = agents[storyId]; return agent?.status === "running" || agent?.status === "pending"; }; return (
Agents
{Object.values(agents).filter((a) => a.status === "running").length}{" "} running
{lastRefresh && (
Loaded {formatTimestamp(lastRefresh)}
)}
{actionError && (
{actionError}
)} {stories.length === 0 ? (
No stories available. Add stories to .story_kit/stories/upcoming/.
) : (
{stories.map((story) => { const agent = agents[story.story_id]; const isExpanded = expandedStory === story.story_id; return (
{story.name ?? story.story_id}
{agent && } {isAgentActive(story.story_id) ? ( ) : ( )}
{isExpanded && agent && (
{agent.worktreePath && (
Worktree: {agent.worktreePath}
)}
{agent.log.length === 0 ? ( {agent.status === "pending" || agent.status === "running" ? "Waiting for output..." : "No output captured."} ) : ( agent.log.map((line, i) => (
{line}
)) )}
{ logEndRefs.current[story.story_id] = el; }} />
)}
); })}
)}
); }