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:
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user