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:
114
frontend/src/features/agents/AgentLimitBadge.tsx
Normal file
114
frontend/src/features/agents/AgentLimitBadge.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* `AgentLimitBadge` — presentational badge for an agent's session-limit state
|
||||
* (ARCHITECTURE §21). All limit *state* is computed in {@link useAgents}; this
|
||||
* component only renders it and owns the 1 s countdown clock (a pure UI concern).
|
||||
*
|
||||
* It shows:
|
||||
* - "limité jusqu'à HH:MM" when the reset time is known, "limité" otherwise;
|
||||
* - a live countdown + an "Annuler la reprise" button while an auto-resume is
|
||||
* armed (`resumeFireAt`);
|
||||
* - a "heure inconnue" human-net note (level 3) when the limit is only
|
||||
* *suspected* with no reliable time — IdeA never resumes blind.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, cn } from "@/shared";
|
||||
import type { AgentLimitState } from "./useAgents";
|
||||
|
||||
/** Formats an epoch-ms instant as a local `HH:MM` wall-clock label. */
|
||||
export function formatResetTime(epochMs: number): string {
|
||||
return new Date(epochMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a remaining duration (ms) as a compact countdown: `Xm Ys` (or `Ys`
|
||||
* under a minute). Clamped at zero — a past deadline reads `0s`.
|
||||
*/
|
||||
export function formatCountdown(remainingMs: number): string {
|
||||
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
||||
}
|
||||
|
||||
export interface AgentLimitBadgeProps {
|
||||
/** The agent's limit state (from {@link useAgents.limitByAgent}). */
|
||||
state: AgentLimitState;
|
||||
/** Invoked when the user clicks "Annuler la reprise". */
|
||||
onCancelResume: () => void;
|
||||
/** Disables the cancel action (e.g. while another request is in flight). */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
export function AgentLimitBadge({
|
||||
state,
|
||||
onCancelResume,
|
||||
busy = false,
|
||||
}: AgentLimitBadgeProps) {
|
||||
const { limitedUntil, resumeFireAt, suspected } = state;
|
||||
|
||||
// Tick a local clock once per second only while a resume countdown is armed,
|
||||
// so the displayed remaining time stays fresh without a global timer.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (resumeFireAt === undefined) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [resumeFireAt]);
|
||||
|
||||
const limitLabel =
|
||||
limitedUntil !== undefined
|
||||
? `limité jusqu'à ${formatResetTime(limitedUntil)}`
|
||||
: "limité";
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span
|
||||
aria-label="session limit"
|
||||
title={
|
||||
suspected
|
||||
? "Limite de session détectée sans heure fiable"
|
||||
: "Limite de session atteinte"
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
"bg-danger/20 text-danger",
|
||||
)}
|
||||
>
|
||||
{limitLabel}
|
||||
</span>
|
||||
|
||||
{suspected && limitedUntil === undefined && (
|
||||
// Human net (level 3, §21.1): the limit is suspected but no reliable time
|
||||
// is known. IdeA never resumes blind. NOTE: there is no backend command
|
||||
// yet to persist a user-entered resume time, so we only surface the state
|
||||
// here. TODO(LS-front): wire an hour-entry form once the backend exposes a
|
||||
// command to record the chosen resume instant — do NOT fabricate one.
|
||||
<span className="text-xs text-muted" role="note">
|
||||
heure inconnue — reprise à préciser
|
||||
</span>
|
||||
)}
|
||||
|
||||
{resumeFireAt !== undefined && (
|
||||
<span className="flex items-center gap-2">
|
||||
<span aria-label="resume countdown" className="text-xs text-muted">
|
||||
reprise dans {formatCountdown(resumeFireAt - now)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="cancel resume"
|
||||
disabled={busy}
|
||||
onClick={onCancelResume}
|
||||
>
|
||||
Annuler la reprise
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user