/**
* `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.
)}
{resumeFireAt !== undefined && (
reprise dans {formatCountdown(resumeFireAt - now)}
)}
);
}