/** * `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, Input, cn } from "@/shared"; import type { AgentLimitState } from "./useAgents"; /** * Converts a `` value (`"HH:MM"`) into an epoch-ms instant on * the same calendar day as `now`. Returns `null` for an empty/malformed value so * the caller can ignore an incomplete entry. * * No future/past guard on purpose: an instant earlier than `now` is left as-is — * the backend clamps a past time to "now" (⇒ immediate resume), so the front * needs no strict validation. */ export function timeInputToEpochMs(value: string, now: number): number | null { const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim()); if (!match) return null; const hours = Number(match[1]); const minutes = Number(match[2]); if (hours > 23 || minutes > 59) return null; const d = new Date(now); d.setHours(hours, minutes, 0, 0); return d.getTime(); } /** 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; /** * Human net (level 3): invoked with the chosen wake-up instant (epoch-ms) when * the user submits the resume-time form on a suspected-without-time limit. */ onSetResumeAt: (resetsAtMs: number) => void; /** Disables the actions (e.g. while another request is in flight). */ busy?: boolean; } export function AgentLimitBadge({ state, onCancelResume, onSetResumeAt, 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]); // Local resume-time entry (human net form). Bound to a `time` input. const [resumeTime, setResumeTime] = useState(""); // The human-net form shows only when the limit is *suspected* with neither a // reliable reset time nor an armed resume — IdeA must ask before resuming. const needsResumeTime = suspected === true && limitedUntil === undefined && resumeFireAt === undefined; function handleSubmitResumeTime(e: React.FormEvent) { e.preventDefault(); const epochMs = timeInputToEpochMs(resumeTime, Date.now()); if (epochMs === null) return; onSetResumeAt(epochMs); setResumeTime(""); } const limitLabel = limitedUntil !== undefined ? `limité jusqu'à ${formatResetTime(limitedUntil)}` : "limité"; return ( {limitLabel} {needsResumeTime && ( // Human net (level 3, §21.1): the limit is suspected with no reliable // time. IdeA never resumes blind — ask the user for a resume time, then // arm it via `set_resume_at`. A past time is clamped to "now" by the // backend (⇒ immediate resume), so no strict validation is needed here.
heure inconnue setResumeTime(e.target.value)} className="h-8 w-28 text-xs" />
)} {resumeFireAt !== undefined && ( reprise dans {formatCountdown(resumeFireAt - now)} )}
); }