feat(session-limits): LS7-front — UI limites de session (badge + compte à rebours + filet humain)

Expose la surface produit des limites de session côté React/TS,
au-dessus du câblage backend (9df5923).

- domain/index.ts : 5 variantes ajoutées au union DomainEvent
  (agentRateLimited / ResumeScheduled / ResumeCancelled / Resumed /
  RateLimitSuspected).
- ports/index.ts : cancelResume(agentId) ajouté à InputGateway.
- adapters/input.ts : TauriInputGateway.cancelResume → invoke("cancel_resume").
- adapters/mock/index.ts : MockInputGateway.cancelResume
  (cancelledResumes / cancelResumeResult).
- features/agents/useAgents.ts : état limitByAgent + action cancelResume.
- features/agents/AgentLimitBadge.tsx (nouveau) : badge + compte à rebours
  + bouton Annuler + helpers purs.
- features/agents/AgentsPanel.tsx : câblage du badge.

Tests : useAgentsLimits.test.tsx (13) + AgentLimitBadge.test.tsx (11),
suite agents 63 tests verts, tsc --noEmit propre.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 08:06:56 +02:00
parent 9df592389c
commit 4fad0423e7
9 changed files with 702 additions and 1 deletions

View File

@ -18,6 +18,29 @@ import type {
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. */
@ -37,6 +60,13 @@ export interface AgentsViewModel {
* 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. */
@ -83,6 +113,14 @@ export interface AgentsViewModel {
) => 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>;
}
function describe(e: unknown): string {
@ -93,7 +131,7 @@ function describe(e: unknown): string {
}
export function useAgents(projectId: string): AgentsViewModel {
const { agent, profile, system, terminal } = useGateways();
const { agent, profile, system, terminal, input } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
@ -106,6 +144,9 @@ export function useAgents(projectId: string): AgentsViewModel {
const [delegationSourceByRequester, setDelegationSourceByRequester] = useState<
Record<string, "mcp" | "file">
>({});
const [limitByAgent, setLimitByAgent] = useState<
Record<string, AgentLimitState>
>({});
const refresh = useCallback(async () => {
setBusy(true);
@ -173,6 +214,57 @@ export function useAgents(projectId: string): AgentsViewModel {
: { ...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();
@ -334,6 +426,27 @@ export function useAgents(projectId: string): AgentsViewModel {
[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],
);
return {
agents,
selectedAgentId,
@ -341,6 +454,7 @@ export function useAgents(projectId: string): AgentsViewModel {
profiles,
liveAgents,
delegationSourceByRequester,
limitByAgent,
error,
busy,
runningAgentId,
@ -353,5 +467,6 @@ export function useAgents(projectId: string): AgentsViewModel {
deleteAgent,
launchAgent,
stopAgent,
cancelResume,
};
}