Lot en un seul commit: les deux features partagent les mêmes contrats (domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes surfaces UI — la séparation par fichiers était impossible sans chirurgie de hunks risquant des états intermédiaires non compilables (convention lot des batches #310-#313 et #315). #325 — icône configurable par projet, watermark CLI custom: - domaine: ProjectIconSet (devicon, simple-icons, lucide, idea), ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon optionnel sur ProjectSettings; sérialisation camelCase et try_from strict - application: UpdateProjectSettings.execute accepte un patch theme_color OU icon (absent = inchangé, null = défaut produit), tests in-dependants avec store mémoire - backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto avec icon optionnel (désérialisation additive), commandes update_project_settings alignées Tauri/HTTP, tests DTO et HTTP (défaut null, set, clear, nom invalide rejeté) - frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en grille multi-lignes dans Paramètres projet, affichage de l'icône dans ProjectTabs et watermark grand format à faible opacité en fond de CLI custom; ports ProjectSettings.icon, Partial<ProjectSettings>, adapters tauri/mock/http et tests alignés #326 — localisation FR/EN et traduction des textes IdeA: - domaine: IdeaLanguage (fr | en), champ language optionnel sur IdeaUserSettings (absence = défaut produit, série additive validée) - backend/web-server: UserSettingsDto.language, update_user_settings accepte language, tests HTTP alignés; fixtures structured_launch_d3 mises à jour - frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression des sources visibles), GeneralSettings avec sélecteur de langue dans les paramètres, setupI18n dans vitest; migration de l'ensemble des textes hardcodés vers le catalogue sur toutes les surfaces (agents, layout, projets, tickets, sprints, web, plugins, permissions, git, templates, skills, mémoire, model servers, embedder, first-run, devices, scheduled tasks, workstate, dialogs), FR conservé par défaut; recherche d'icônes #325 en anglais conformément au ticket QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
183 lines
6.6 KiB
TypeScript
183 lines
6.6 KiB
TypeScript
/**
|
|
* `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 { useI18n } from "@/i18n";
|
|
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([], {
|
|
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 { tr } = useI18n();
|
|
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
|
|
? tr(`limité jusqu'à ${formatResetTime(limitedUntil)}`, `limited until ${formatResetTime(limitedUntil)}`)
|
|
: tr("limité", "limited");
|
|
|
|
return (
|
|
<span className="flex min-w-0 flex-wrap items-center gap-2">
|
|
<span
|
|
aria-label={tr("limite de session", "session limit")}
|
|
title={
|
|
suspected
|
|
? tr("Limite de session détectée sans heure fiable", "Session limit detected without a reliable time")
|
|
: tr("Limite de session atteinte", "Session limit reached")
|
|
}
|
|
className={cn(
|
|
"rounded-full px-2 py-0.5 text-xs font-medium",
|
|
"bg-danger/20 text-danger",
|
|
)}
|
|
>
|
|
{limitLabel}
|
|
</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={tr("formulaire d'heure de reprise", "resume time form")}
|
|
>
|
|
<span className="text-xs text-muted" role="note">
|
|
{tr("heure inconnue", "unknown time")}
|
|
</span>
|
|
<Input
|
|
type="time"
|
|
aria-label={tr("heure de reprise", "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={tr("programmer la reprise", "schedule resume")}
|
|
disabled={busy || resumeTime.trim() === ""}
|
|
>
|
|
{tr("Programmer la reprise", "Schedule resume")}
|
|
</Button>
|
|
</form>
|
|
)}
|
|
|
|
{resumeFireAt !== undefined && (
|
|
<span className="flex items-center gap-2">
|
|
<span aria-label={tr("compte à rebours de reprise", "resume countdown")} className="text-xs text-muted">
|
|
{tr("reprise dans", "resuming in")} {formatCountdown(resumeFireAt - now)}
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
aria-label={tr("annuler la reprise", "cancel resume")}
|
|
disabled={busy}
|
|
onClick={onCancelResume}
|
|
>
|
|
{tr("Annuler la reprise", "Cancel resume")}
|
|
</Button>
|
|
</span>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|