/** * `useAgents` — view-model hook for the agents feature (L6). * * Owns the agents feature state for a given project and exposes the actions the * UI triggers. Consumes {@link AgentGateway} and {@link ProfileGateway} * exclusively; never touches `invoke()` or `@tauri-apps/api`, keeping the * component layer testable with mock gateways (ARCHITECTURE §1.3). */ import { useCallback, useEffect, useState } from "react"; import type { Agent, AgentProfile, GatewayError, TerminalSession, } from "@/domain"; import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports"; import { useGateways } from "@/app/di"; /** What the agents UI needs from this hook. */ export interface AgentsViewModel { /** All agents known to the project. */ agents: Agent[]; /** Id of the currently selected agent, or `null`. */ selectedAgentId: string | null; /** The `.md` context of the selected agent (loaded on select). */ context: string; /** Profiles available for assigning to a new agent. */ profiles: AgentProfile[]; /** Agents that currently own a live PTY session, as tracked by IdeA. */ liveAgents: LiveAgent[]; /** Last error message, or `null`. */ error: string | null; /** Whether a request is in flight. */ busy: boolean; /** * Id of the agent whose terminal is currently running, or `null`. * Set when the user clicks Launch; cleared when the terminal is stopped. */ runningAgentId: string | null; /** Reloads the agent list. */ refresh: () => Promise; /** Reloads the currently-live agent sessions. */ refreshLiveAgents: () => Promise; /** Creates a new agent and refreshes the list. */ createAgent: (name: string, profileId: string, initialContent?: string) => Promise; /** Selects an agent and loads its context. */ selectAgent: (agentId: string) => Promise; /** Saves the context for the currently selected agent. */ saveContext: (content: string) => Promise; /** * Hot-swaps an agent's runtime AI profile. The caller is expected to have * confirmed with the user first (the conversation history is abandoned). On a * live agent the backend relaunches the session and returns it so the cell can * rebind; the agent list is refreshed locally. Returns the relaunched session, * if any, so the panel can rebind the hosting cell. */ changeAgentProfile: ( agentId: string, profileId: string, rows: number, cols: number, ) => Promise; /** Deletes an agent; deselects if it was selected. */ deleteAgent: (agentId: string) => Promise; /** * Returns an opener compatible with `TerminalView`'s `open` prop for the * given agent. Calling it sets `runningAgentId`. The component unmounting * will call `handle.close()` automatically via the TerminalView cleanup. */ launchAgent: ( agentId: string, options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void, ) => Promise; /** Stops an agent PTY explicitly by closing its live session id. */ stopAgent: (agentId?: string) => Promise; } function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } export function useAgents(projectId: string): AgentsViewModel { const { agent, profile, system, terminal } = useGateways(); const [agents, setAgents] = useState([]); const [selectedAgentId, setSelectedAgentId] = useState(null); const [context, setContext] = useState(""); const [profiles, setProfiles] = useState([]); const [liveAgents, setLiveAgents] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [runningAgentId, setRunningAgentId] = useState(null); const refresh = useCallback(async () => { setBusy(true); setError(null); try { const [agentList, profileList] = await Promise.all([ agent.listAgents(projectId), profile.listProfiles(), ]); setAgents(agentList); setProfiles(profileList); } catch (e) { setError(describe(e)); } finally { setBusy(false); } }, [agent, profile, projectId]); const refreshLiveAgents = useCallback(async () => { try { setLiveAgents(await agent.listLiveAgents(projectId)); } catch { setLiveAgents([]); } }, [agent, projectId]); useEffect(() => { void refresh(); }, [refresh]); useEffect(() => { void refreshLiveAgents(); }, [refreshLiveAgents]); // Live refresh: the agents list is otherwise only reloaded on mount or after a // UI-driven CRUD. An agent created/launched/stopped *out of band* — most // notably by the orchestrator (`spawn_agent`, ARCHITECTURE §14.3) when an AI // delegates agent creation to IdeA — emits `agentLaunched` / `agentExited`. // Subscribing here makes the tab reflect those changes without a manual reload. // `system` may be absent in unit tests that inject a partial gateway set; guard. useEffect(() => { if (!system) return; let unsubscribe: (() => void) | undefined; let cancelled = false; void system .onDomainEvent((event) => { if ( event.type === "agentLaunched" || event.type === "agentExited" || event.type === "agentProfileChanged" ) { void refresh(); void refreshLiveAgents(); } }) .then((un) => { if (cancelled) un(); else unsubscribe = un; }); return () => { cancelled = true; unsubscribe?.(); }; }, [system, refresh, refreshLiveAgents]); const createAgent = useCallback( async (name: string, profileId: string, initialContent?: string) => { setBusy(true); setError(null); try { const created = await agent.createAgent(projectId, { name, profileId, initialContent, }); setAgents((prev) => [...prev, created]); } catch (e) { setError(describe(e)); } finally { setBusy(false); } }, [agent, projectId], ); const selectAgent = useCallback( async (agentId: string) => { setBusy(true); setError(null); try { const ctx = await agent.readContext(projectId, agentId); setSelectedAgentId(agentId); setContext(ctx); } catch (e) { setError(describe(e)); } finally { setBusy(false); } }, [agent, projectId], ); const saveContext = useCallback( async (content: string) => { if (!selectedAgentId) return; setBusy(true); setError(null); try { await agent.updateContext(projectId, selectedAgentId, content); setContext(content); } catch (e) { setError(describe(e)); } finally { setBusy(false); } }, [agent, projectId, selectedAgentId], ); const changeAgentProfile = useCallback( async ( agentId: string, profileId: string, rows: number, cols: number, ): Promise => { setBusy(true); setError(null); try { const { agent: updated, relaunchedSession } = await agent.changeAgentProfile(projectId, agentId, profileId, rows, cols); // Reflect the new profile locally; the `agentProfileChanged` event (when // wired) also triggers a full refresh, but updating here keeps the UI // responsive offline / in tests. setAgents((prev) => prev.map((a) => (a.id === updated.id ? updated : a)), ); // A hot relaunch produces a fresh live session id — keep the live list // in sync so the cell can rebind. void refreshLiveAgents(); return relaunchedSession; } catch (e) { setError(describe(e)); return undefined; } finally { setBusy(false); } }, [agent, projectId, refreshLiveAgents], ); const deleteAgent = useCallback( async (agentId: string) => { setBusy(true); setError(null); try { await agent.deleteAgent(projectId, agentId); setAgents((prev) => prev.filter((a) => a.id !== agentId)); setSelectedAgentId((current) => (current === agentId ? null : current)); if (selectedAgentId === agentId) setContext(""); // Also stop the terminal if the running agent was just deleted. setRunningAgentId((current) => (current === agentId ? null : current)); } catch (e) { setError(describe(e)); } finally { setBusy(false); } }, [agent, projectId, selectedAgentId], ); const launchAgent = useCallback( async ( agentId: string, options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void, ): Promise => { setError(null); try { const handle = await agent.launchAgent(projectId, agentId, options, onData); setRunningAgentId(agentId); void refreshLiveAgents(); return handle; } catch (e) { setError(describe(e)); throw e; } }, [agent, projectId, refreshLiveAgents], ); const stopAgent = useCallback( async (agentId?: string) => { const targetAgentId = agentId ?? runningAgentId; const live = targetAgentId ? liveAgents.find((candidate) => candidate.agentId === targetAgentId) : null; setRunningAgentId((current) => current === targetAgentId || !targetAgentId ? null : current, ); if (live?.sessionId) { try { await terminal?.closeTerminal(live.sessionId); setLiveAgents((current) => current.filter((candidate) => candidate.agentId !== targetAgentId), ); } catch (e) { setError(describe(e)); await refreshLiveAgents(); } } }, [liveAgents, refreshLiveAgents, runningAgentId, terminal], ); return { agents, selectedAgentId, context, profiles, liveAgents, error, busy, runningAgentId, refresh, refreshLiveAgents, createAgent, selectAgent, saveContext, changeAgentProfile, deleteAgent, launchAgent, stopAgent, }; }