feat(session-limits): LS8-front — filet humain niveau 3 (saisie d'heure de reprise)
UI permettant à l'humain de renseigner l'heure de reprise quand le
niveau 2 détecte une limite sans heure exploitable.
- ports/index.ts : setResumeAt(agentId, resetsAtMs) sur InputGateway.
- adapters/input.ts : setResumeAt → invoke("set_resume_at").
- adapters/mock/index.ts : MockInputGateway.setResumeAt (resumeArmings[]).
- features/agents/useAgents.ts : action setResumeAt (sans mutation optimiste).
- features/agents/AgentLimitBadge.tsx : formulaire de saisie d'heure sur
l'état suspected sans heure + helper pur timeInputToEpochMs (TODO LS7 retiré).
- features/agents/AgentsPanel.tsx : câblage onSetResumeAt.
Tests AgentLimitBadge.test.tsx mis à jour au nouveau contrat + couverture LS8 ;
typecheck propre, suite agents verte.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -13,9 +13,29 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, cn } from "@/shared";
|
||||
import { Button, Input, cn } from "@/shared";
|
||||
import type { AgentLimitState } from "./useAgents";
|
||||
|
||||
/**
|
||||
* Converts a `<input type="time">` 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([], {
|
||||
@ -40,13 +60,19 @@ export interface AgentLimitBadgeProps {
|
||||
state: AgentLimitState;
|
||||
/** Invoked when the user clicks "Annuler la reprise". */
|
||||
onCancelResume: () => void;
|
||||
/** Disables the cancel action (e.g. while another request is in flight). */
|
||||
/**
|
||||
* 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;
|
||||
@ -60,6 +86,24 @@ export function AgentLimitBadge({
|
||||
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)}`
|
||||
@ -82,15 +126,37 @@ export function AgentLimitBadge({
|
||||
{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>
|
||||
{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.
|
||||
<form
|
||||
onSubmit={handleSubmitResumeTime}
|
||||
className="flex items-center gap-2"
|
||||
aria-label="resume time form"
|
||||
>
|
||||
<span className="text-xs text-muted" role="note">
|
||||
heure inconnue
|
||||
</span>
|
||||
<Input
|
||||
type="time"
|
||||
aria-label="resume time"
|
||||
value={resumeTime}
|
||||
disabled={busy}
|
||||
onChange={(e) => setResumeTime(e.target.value)}
|
||||
className="h-8 w-28 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
aria-label="schedule resume"
|
||||
disabled={busy || resumeTime.trim() === ""}
|
||||
>
|
||||
Programmer la reprise
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{resumeFireAt !== undefined && (
|
||||
|
||||
Reference in New Issue
Block a user