UI permettant à l'humain de renseigner l'heure de reprise quand le
niveau 2 détecte une limite sans heure exploitable.
- ports/index.ts : setResumeAt(agentId, resetsAtMs) sur InputGateway.
- adapters/input.ts : setResumeAt → invoke("set_resume_at").
- adapters/mock/index.ts : MockInputGateway.setResumeAt (resumeArmings[]).
- features/agents/useAgents.ts : action setResumeAt (sans mutation optimiste).
- features/agents/AgentLimitBadge.tsx : formulaire de saisie d'heure sur
l'état suspected sans heure + helper pur timeInputToEpochMs (TODO LS7 retiré).
- features/agents/AgentsPanel.tsx : câblage onSetResumeAt.
Tests AgentLimitBadge.test.tsx mis à jour au nouveau contrat + couverture LS8 ;
typecheck propre, suite agents verte.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
494 lines
16 KiB
TypeScript
494 lines
16 KiB
TypeScript
/**
|
|
* `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<string, "mcp" | "file">;
|
|
/**
|
|
* 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<string, AgentLimitState>;
|
|
/** 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<void>;
|
|
/** Reloads the currently-live agent sessions. */
|
|
refreshLiveAgents: () => Promise<void>;
|
|
/** Creates a new agent and refreshes the list. */
|
|
createAgent: (name: string, profileId: string, initialContent?: string) => Promise<void>;
|
|
/** Selects an agent and loads its context. */
|
|
selectAgent: (agentId: string) => Promise<void>;
|
|
/** Saves the context for the currently selected agent. */
|
|
saveContext: (content: string) => Promise<void>;
|
|
/**
|
|
* 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<TerminalSession | undefined>;
|
|
/** Deletes an agent; deselects if it was selected. */
|
|
deleteAgent: (agentId: string) => Promise<void>;
|
|
/**
|
|
* 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<TerminalHandle>;
|
|
/** Stops an agent PTY explicitly by closing its live session id. */
|
|
stopAgent: (agentId?: string) => Promise<void>;
|
|
/**
|
|
* 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<boolean>;
|
|
/**
|
|
* 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<void>;
|
|
}
|
|
|
|
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<Agent[]>([]);
|
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
|
const [context, setContext] = useState<string>("");
|
|
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
|
const [liveAgents, setLiveAgents] = useState<LiveAgent[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [runningAgentId, setRunningAgentId] = useState<string | null>(null);
|
|
const [delegationSourceByRequester, setDelegationSourceByRequester] = useState<
|
|
Record<string, "mcp" | "file">
|
|
>({});
|
|
const [limitByAgent, setLimitByAgent] = useState<
|
|
Record<string, AgentLimitState>
|
|
>({});
|
|
|
|
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<TerminalSession | undefined> => {
|
|
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<TerminalHandle> => {
|
|
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<boolean> => {
|
|
// 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<void> => {
|
|
// 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,
|
|
};
|
|
}
|