diff --git a/frontend/src/adapters/input.ts b/frontend/src/adapters/input.ts index b2c78af..e2ce146 100644 --- a/frontend/src/adapters/input.ts +++ b/frontend/src/adapters/input.ts @@ -44,4 +44,10 @@ export class TauriInputGateway implements InputGateway { // `request` envelope, and returns whether a pending resume was disarmed. return invoke("cancel_resume", { agentId }); } + + async setResumeAt(agentId: string, resetsAtMs: number): Promise { + // `set_resume_at` takes bare `agent_id` + `resets_at_ms` (camelCase), like + // `cancel_resume`; arms the cancellable resume at the chosen instant. + await invoke("set_resume_at", { agentId, resetsAtMs }); + } } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 9ef45d3..7c04261 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1561,6 +1561,8 @@ export class MockInputGateway implements InputGateway { readonly frontAttached: { agentId: string; attached: boolean }[] = []; /** All recorded cancel-resume calls, in order. */ readonly cancelledResumes: string[] = []; + /** All recorded set-resume-at armings (human net, level 3), in order. */ + readonly resumeArmings: { agentId: string; resetsAtMs: number }[] = []; /** * What {@link cancelResume} resolves to (mirrors the backend `bool`: whether a * pending resume was disarmed). Tests can flip it to exercise the "already @@ -1588,6 +1590,10 @@ export class MockInputGateway implements InputGateway { this.cancelledResumes.push(agentId); return this.cancelResumeResult; } + + async setResumeAt(agentId: string, resetsAtMs: number): Promise { + this.resumeArmings.push({ agentId, resetsAtMs }); + } } /** In-memory permissions gateway. */ diff --git a/frontend/src/features/agents/AgentLimitBadge.test.tsx b/frontend/src/features/agents/AgentLimitBadge.test.tsx index 44822b0..70bb544 100644 --- a/frontend/src/features/agents/AgentLimitBadge.test.tsx +++ b/frontend/src/features/agents/AgentLimitBadge.test.tsx @@ -1,13 +1,16 @@ /** - * LS7-front — {@link AgentLimitBadge} presentation + its pure helpers + * LS7/LS8-front — {@link AgentLimitBadge} presentation + its pure helpers * (ARCHITECTURE §21). * - * Two layers: - * - the exported pure helpers `formatCountdown` / `formatResetTime` (edge cases: - * 0, negative, sub-minute, multi-minute, midnight, seconds-stripped); - * - the rendered badge across its three states (limité jusqu'à HH:MM / - * "limité" without a time / "heure inconnue" for suspected without a reset), - * plus the "Annuler la reprise" button wiring when a resume is armed. + * Three layers: + * - the exported pure helpers `formatCountdown` / `formatResetTime` / + * `timeInputToEpochMs` (edge cases: 0, negative, sub-minute, multi-minute, + * midnight, seconds-stripped, malformed/empty time input); + * - the rendered badge across its states (limité jusqu'à HH:MM / "limité" + * without a time / the "heure inconnue" resume-time form for a suspected limit + * without a reset), plus the "Annuler la reprise" button wiring when a resume + * is armed; + * - the human-net form (LS8): submitting a time arms a resume via onSetResumeAt. */ import { describe, it, expect, vi } from "vitest"; @@ -17,6 +20,7 @@ import { AgentLimitBadge, formatCountdown, formatResetTime, + timeInputToEpochMs, } from "./AgentLimitBadge"; describe("formatCountdown", () => { @@ -74,13 +78,48 @@ describe("formatResetTime", () => { }); }); +describe("timeInputToEpochMs", () => { + it("maps HH:MM to the same calendar day as `now`", () => { + const now = new Date(2026, 0, 15, 9, 0, 0).getTime(); + const ms = timeInputToEpochMs("14:30", now); + expect(ms).not.toBeNull(); + const d = new Date(ms!); + expect(d.getFullYear()).toBe(2026); + expect(d.getMonth()).toBe(0); + expect(d.getDate()).toBe(15); + expect(d.getHours()).toBe(14); + expect(d.getMinutes()).toBe(30); + expect(d.getSeconds()).toBe(0); + expect(d.getMilliseconds()).toBe(0); + }); + + it("returns a past instant unchanged (backend clamps to now)", () => { + const now = new Date(2026, 0, 15, 18, 0, 0).getTime(); + const ms = timeInputToEpochMs("08:00", now); + expect(ms).not.toBeNull(); + expect(ms!).toBeLessThan(now); + }); + + it("returns null for empty or malformed input", () => { + const now = Date.now(); + expect(timeInputToEpochMs("", now)).toBeNull(); + expect(timeInputToEpochMs("nope", now)).toBeNull(); + expect(timeInputToEpochMs("25:00", now)).toBeNull(); + expect(timeInputToEpochMs("12:60", now)).toBeNull(); + }); +}); + describe("AgentLimitBadge (render)", () => { const noop = () => {}; it("shows 'limité jusqu'à HH:MM' when the reset time is known", () => { const at = new Date(2026, 0, 15, 14, 30, 0).getTime(); render( - , + , ); const expected = `limité jusqu'à ${formatResetTime(at)}`; expect(screen.getByText(expected)).toBeTruthy(); @@ -89,30 +128,74 @@ describe("AgentLimitBadge (render)", () => { }); it("shows plain 'limité' when no reset time is known", () => { - render(); - expect(screen.getByText("limité")).toBeTruthy(); - }); - - it("shows the 'heure inconnue' note for a suspected limit without a time", () => { render( - , + , ); expect(screen.getByText("limité")).toBeTruthy(); - expect( - screen.getByText(/heure inconnue — reprise à préciser/), - ).toBeTruthy(); }); - it("does NOT show the 'heure inconnue' note when a suspected limit has a time", () => { + it("shows the 'heure inconnue' resume-time form for a suspected limit without a time", () => { + render( + , + ); + expect(screen.getByText("limité")).toBeTruthy(); + expect(screen.getByText(/heure inconnue/)).toBeTruthy(); + // The human-net form: a resume-time input + a "schedule resume" button. + expect(screen.getByLabelText("resume time")).toBeTruthy(); + expect(screen.getByLabelText("schedule resume")).toBeTruthy(); + }); + + it("does NOT show the resume-time form when a suspected limit has a time", () => { const at = new Date(2026, 0, 15, 14, 30, 0).getTime(); render( , ); expect(screen.getByText(`limité jusqu'à ${formatResetTime(at)}`)).toBeTruthy(); expect(screen.queryByText(/heure inconnue/)).toBeNull(); + expect(screen.queryByLabelText("resume time")).toBeNull(); + }); + + it("submitting the resume-time form arms a resume at the chosen epoch-ms", () => { + const onSetResumeAt = vi.fn(); + render( + , + ); + fireEvent.change(screen.getByLabelText("resume time"), { + target: { value: "23:45" }, + }); + fireEvent.click(screen.getByLabelText("schedule resume")); + expect(onSetResumeAt).toHaveBeenCalledTimes(1); + const ms = onSetResumeAt.mock.calls[0][0] as number; + const d = new Date(ms); + expect(d.getHours()).toBe(23); + expect(d.getMinutes()).toBe(45); + }); + + it("does not arm a resume when the time input is empty (button disabled)", () => { + const onSetResumeAt = vi.fn(); + render( + , + ); + expect( + (screen.getByLabelText("schedule resume") as HTMLButtonElement).disabled, + ).toBe(true); + expect(onSetResumeAt).not.toHaveBeenCalled(); }); it("renders the countdown + calls onCancelResume when a resume is armed", () => { @@ -122,6 +205,7 @@ describe("AgentLimitBadge (render)", () => { , ); // Countdown label present (≈ "reprise dans 1m 30s"). @@ -139,6 +223,7 @@ describe("AgentLimitBadge (render)", () => { , ); diff --git a/frontend/src/features/agents/AgentLimitBadge.tsx b/frontend/src/features/agents/AgentLimitBadge.tsx index f2d8889..16e9f52 100644 --- a/frontend/src/features/agents/AgentLimitBadge.tsx +++ b/frontend/src/features/agents/AgentLimitBadge.tsx @@ -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 `` 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} - {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. - - heure inconnue — reprise à préciser - + {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 && ( diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx index ce3d7da..6e0763e 100644 --- a/frontend/src/features/agents/AgentsPanel.tsx +++ b/frontend/src/features/agents/AgentsPanel.tsx @@ -381,6 +381,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) { state={limitState} busy={vm.busy} onCancelResume={() => void vm.cancelResume(a.id)} + onSetResumeAt={(resetsAtMs) => + void vm.setResumeAt(a.id, resetsAtMs) + } /> )} diff --git a/frontend/src/features/agents/useAgents.ts b/frontend/src/features/agents/useAgents.ts index d60410e..4e2a56e 100644 --- a/frontend/src/features/agents/useAgents.ts +++ b/frontend/src/features/agents/useAgents.ts @@ -121,6 +121,13 @@ export interface AgentsViewModel { * resume had already fired / none was armed). */ cancelResume: (agentId: string) => Promise; + /** + * Human net (level 3, ARCHITECTURE §21.1): arms a resume at a user-chosen + * `resetsAtMs` (epoch-ms) for an agent whose limit was suspected without a + * reliable time. No optimistic mutation — the backend re-emits + * `agentResumeScheduled`, which flips the badge to the nominal countdown. + */ + setResumeAt: (agentId: string, resetsAtMs: number) => Promise; } function describe(e: unknown): string { @@ -447,6 +454,19 @@ export function useAgents(projectId: string): AgentsViewModel { [input], ); + const setResumeAt = useCallback( + async (agentId: string, resetsAtMs: number): Promise => { + // No optimistic mutation: the backend re-emits `agentResumeScheduled`, + // which the subscription folds into `limitByAgent` (badge → countdown). + try { + await input?.setResumeAt(agentId, resetsAtMs); + } catch (e) { + setError(describe(e)); + } + }, + [input], + ); + return { agents, selectedAgentId, @@ -468,5 +488,6 @@ export function useAgents(projectId: string): AgentsViewModel { launchAgent, stopAgent, cancelResume, + setResumeAt, }; } diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 030d189..61a1c88 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -561,6 +561,15 @@ export interface InputGateway { * `agentResumeCancelled` on success, which clears the countdown in the UI. */ cancelResume(agentId: string): Promise; + /** + * Human net (level 3, ARCHITECTURE §21.1): arms a resume at a user-chosen + * instant for an agent whose limit was *suspected* without a reliable reset + * time. `resetsAtMs` is the chosen wake-up in epoch-milliseconds; the backend + * clamps a past instant to "now" (⇒ immediate resume). This arms the same + * cancellable resume as the automatic path and re-emits `agentResumeScheduled`, + * so the UI flips on its own from "heure inconnue" to the nominal countdown. + */ + setResumeAt(agentId: string, resetsAtMs: number): Promise; } /**