import * as React from "react"; import type { AgentConfigInfo, AgentEvent, AgentStatusValue, } from "../api/agents"; import { agentsApi, subscribeAgentStream } from "../api/agents"; import { settingsApi } from "../api/settings"; import { useLozengeFly } from "./LozengeFlyContext"; const { useCallback, useEffect, useRef, useState } = React; interface AgentState { agentName: string; status: AgentStatusValue; log: string[]; sessionId: string | null; worktreePath: string | null; baseBranch: string | null; terminalAt: number | null; } const formatTimestamp = (value: Date | null): string => { if (!value) return ""; return value.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", }); }; function RosterBadge({ agent }: { agent: AgentConfigInfo }) { const { registerRosterEl } = useLozengeFly(); const badgeRef = useRef(null); // Register this element so fly animations know where to start/end useEffect(() => { const el = badgeRef.current; if (el) registerRosterEl(agent.name, el); return () => registerRosterEl(agent.name, null); }, [agent.name, registerRosterEl]); return ( {agent.name} {agent.model && {agent.model}} available ); } /** Build a composite key for tracking agent state. */ function agentKey(storyId: string, agentName: string): string { return `${storyId}:${agentName}`; } interface AgentPanelProps { /** Increment this to trigger a re-fetch of the agent roster. */ configVersion?: number; /** Increment this to trigger a re-fetch of the agent list (agent state changed). */ stateVersion?: number; } export function AgentPanel({ configVersion = 0, stateVersion = 0, }: AgentPanelProps) { const { hiddenRosterAgents } = useLozengeFly(); const [agents, setAgents] = useState>({}); const [roster, setRoster] = useState([]); 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>>({}); // Re-fetch roster whenever configVersion changes (triggered by agent_config_changed WS event). useEffect(() => { agentsApi .getAgentConfig() .then(setRoster) .catch((err) => console.error("Failed to load agent config:", err)); }, [configVersion]); 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, terminalAt: null, }; switch (event.type) { case "status": { const newStatus = (event.status as AgentStatusValue) ?? current.status; const isTerminal = newStatus === "completed" || newStatus === "failed"; return { ...prev, [key]: { ...current, status: newStatus, terminalAt: isTerminal ? (current.terminalAt ?? Date.now()) : current.terminalAt, }, }; } 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, terminalAt: current.terminalAt ?? Date.now(), }, }; case "error": return { ...prev, [key]: { ...current, status: "failed", log: [ ...current.log, `[ERROR] ${event.message ?? "Unknown error"}`, ], terminalAt: current.terminalAt ?? Date.now(), }, }; case "thinking": // Thinking traces are internal model state — never display them. return prev; default: return prev; } }); }, () => { // SSE error — agent may not be streaming yet }, ); cleanupRefs.current[key] = cleanup; }, []); /** Shared helper: fetch the agent list and update state + SSE subscriptions. */ const refreshAgents = useCallback(() => { agentsApi .listAgents() .then((agentList) => { const agentMap: Record = {}; const now = Date.now(); for (const a of agentList) { const key = agentKey(a.story_id, a.agent_name); const isTerminal = a.status === "completed" || a.status === "failed"; agentMap[key] = { agentName: a.agent_name, status: a.status, log: [], sessionId: a.session_id, worktreePath: a.worktree_path, baseBranch: a.base_branch, terminalAt: isTerminal ? now : null, }; 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)); }, [subscribeToAgent]); // Load existing agents and editor preference on mount useEffect(() => { refreshAgents(); 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 }, []); // Re-fetch agent list when agent state changes (via WebSocket notification). // Skip the initial render (stateVersion=0) since the mount effect handles that. useEffect(() => { if (stateVersion > 0) { refreshAgents(); } }, [stateVersion, refreshAgents]); 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}`); } }; // Agents that have streaming content to show const activeAgents = Object.values(agents).filter((a) => a.log.length > 0); return (
Agents
{Object.values(agents).filter((a) => a.status === "running").length > 0 && (
{ 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 — agents always display in idle state here */} {roster.length > 0 && (
{roster.map((a) => { const isHidden = hiddenRosterAgents.has(a.name); return (
); })}
)} {/* Per-agent streaming output */} {activeAgents.map((agent) => (
{agent.log.length > 0 && (
{agent.log.join("")}
)}
))} {actionError && (
{actionError}
)}
); }