/** * `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"; /** * Per-agent session-limit state (ARCHITECTURE §21), populated from the limit * domain events. An agent absent from the map is in its normal (unlimited) state. */ export interface AgentLimitState { /** * Reset instant in epoch-milliseconds when known (from `agentRateLimited` / * `agentRateLimitSuspected`). Absent ⇒ "limité" without a reliable time. */ limitedUntil?: number; /** * Wake-up deadline in epoch-milliseconds of an armed auto-resume (from * `agentResumeScheduled`). Present ⇒ show the countdown + "Annuler la reprise". */ resumeFireAt?: number; /** * Human net (level 3): the limit was suspected without a reliable time * (`agentRateLimitSuspected`). The UI surfaces an "heure inconnue" state and * asks the user — IdeA never resumes blind. */ suspected?: boolean; } /** 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[]; /** * The entry door (`"mcp"` | `"file"`) the *last* orchestration delegation from * each requester agent arrived through, keyed by requester id. Populated from * `orchestratorRequestProcessed` events; a requester absent here (or an event * without `source`) yields no badge. */ delegationSourceByRequester: Record; /** * Per-agent session-limit state (ARCHITECTURE §21), keyed by agent id. Populated * from the limit domain events (`agentRateLimited`, `agentResumeScheduled`, * `agentResumeCancelled`, `agentResumed`, `agentRateLimitSuspected`). An agent * absent from the map is unlimited. */ limitByAgent: Record; /** 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; /** * Cancels the auto-resume armed for a rate-limited agent (ARCHITECTURE §21): * the "Annuler la reprise" action. Optimistically drops the countdown; the * backend's `agentResumeCancelled` event confirms it. The agent stays "limité" * (no auto-resume will fire). Resolves to the backend verdict (`false` if the * resume had already fired / none was armed). */ cancelResume: (agentId: string) => Promise; /** * Human net (level 3, ARCHITECTURE §21.1): arms a resume at a user-chosen * `resetsAtMs` (epoch-ms) for an agent whose limit was suspected without a * reliable time. No optimistic mutation — the backend re-emits * `agentResumeScheduled`, which flips the badge to the nominal countdown. */ setResumeAt: (agentId: string, resetsAtMs: number) => 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, input } = 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 [delegationSourceByRequester, setDelegationSourceByRequester] = useState< Record >({}); const [limitByAgent, setLimitByAgent] = useState< Record >({}); 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(); } // Record which door a delegation came through (mcp vs file). Absent // `source` (older backend) leaves the map untouched → no badge. if ( event.type === "orchestratorRequestProcessed" && event.source !== undefined ) { const { requesterId, source } = event; setDelegationSourceByRequester((prev) => prev[requesterId] === source ? prev : { ...prev, [requesterId]: source }, ); } // Session-limit lifecycle (ARCHITECTURE §21): fold each event into the // per-agent limit state. `agentResumed` clears the entry entirely. switch (event.type) { case "agentRateLimited": setLimitByAgent((prev) => ({ ...prev, [event.agentId]: { limitedUntil: event.resetsAtMs, resumeFireAt: prev[event.agentId]?.resumeFireAt, suspected: false, }, })); break; case "agentResumeScheduled": setLimitByAgent((prev) => ({ ...prev, [event.agentId]: { ...prev[event.agentId], resumeFireAt: event.fireAtMs, }, })); break; case "agentResumeCancelled": setLimitByAgent((prev) => { const current = prev[event.agentId]; if (!current) return prev; // Keep the agent "limité" but drop the armed resume countdown. const { resumeFireAt: _dropped, ...rest } = current; return { ...prev, [event.agentId]: rest }; }); break; case "agentResumed": setLimitByAgent((prev) => { if (!(event.agentId in prev)) return prev; const { [event.agentId]: _cleared, ...rest } = prev; return rest; }); break; case "agentRateLimitSuspected": setLimitByAgent((prev) => ({ ...prev, [event.agentId]: { ...prev[event.agentId], limitedUntil: event.resetsAtMs, suspected: true, }, })); break; default: break; } }) .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], ); const cancelResume = useCallback( async (agentId: string): Promise => { // Optimistically drop the countdown so the button feels responsive; the // backend's `agentResumeCancelled` event confirms it (and a `false` verdict // — already fired — is reconciled by the eventual `agentResumed`). setLimitByAgent((prev) => { const current = prev[agentId]; if (!current?.resumeFireAt) return prev; const { resumeFireAt: _dropped, ...rest } = current; return { ...prev, [agentId]: rest }; }); try { return (await input?.cancelResume(agentId)) ?? false; } catch (e) { setError(describe(e)); return false; } }, [input], ); const setResumeAt = useCallback( async (agentId: string, resetsAtMs: number): Promise => { // No optimistic mutation: the backend re-emits `agentResumeScheduled`, // which the subscription folds into `limitByAgent` (badge → countdown). try { await input?.setResumeAt(agentId, resetsAtMs); } catch (e) { setError(describe(e)); } }, [input], ); return { agents, selectedAgentId, context, profiles, liveAgents, delegationSourceByRequester, limitByAgent, error, busy, runningAgentId, refresh, refreshLiveAgents, createAgent, selectAgent, saveContext, changeAgentProfile, deleteAgent, launchAgent, stopAgent, cancelResume, setResumeAt, }; }