feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)

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.
This commit is contained in:
2026-09-11 21:53:56 +02:00
parent cd744b5f18
commit 1a1429f10e
157 changed files with 4531 additions and 3129 deletions

View File

@ -7,12 +7,13 @@ import type {
} from "@/domain";
import type { ScheduledTaskDefinitionInput } from "@/ports";
import { Button, Field, Input } from "@/shared";
import { useI18n } from "@/i18n";
type Preset = "hourly" | "daily" | "weekly" | "custom";
const DAYS: Array<[ScheduledTaskWeekday, string]> = [
["monday", "Lun"], ["tuesday", "Mar"], ["wednesday", "Mer"],
["thursday", "Jeu"], ["friday", "Ven"], ["saturday", "Sam"], ["sunday", "Dim"],
const DAYS: Array<[ScheduledTaskWeekday, string, string]> = [
["monday", "Lun", "Mon"], ["tuesday", "Mar", "Tue"], ["wednesday", "Mer", "Wed"],
["thursday", "Jeu", "Thu"], ["friday", "Ven", "Fri"], ["saturday", "Sam", "Sat"], ["sunday", "Dim", "Sun"],
];
const control =
@ -35,15 +36,19 @@ export function exactCommand(input: ScheduledTaskDefinitionInput): string {
return [input.command.command, ...input.command.args].map(shellWord).join(" ");
}
export function scheduleLabel(schedule: ScheduledTaskSchedule): string {
type Tr = (french: string, english: string) => string;
const fr: Tr = (french) => french;
export function scheduleLabel(schedule: ScheduledTaskSchedule, tr: Tr = fr): string {
if (schedule.kind === "interval") {
return schedule.everyMinutes === 60
? "Toutes les heures"
: `Toutes les ${schedule.everyMinutes} minutes`;
? tr("Toutes les heures", "Hourly")
: tr(`Toutes les ${schedule.everyMinutes} minutes`, `Every ${schedule.everyMinutes} minutes`);
}
if (schedule.kind === "daily") return `Chaque jour à ${schedule.time}`;
const labels = schedule.days.map((day) => DAYS.find(([key]) => key === day)?.[1] ?? day);
return `Chaque semaine (${labels.join(", ")}) à ${schedule.time}`;
if (schedule.kind === "daily") return tr(`Chaque jour à ${schedule.time}`, `Every day at ${schedule.time}`);
const frLabels = schedule.days.map((day) => DAYS.find(([key]) => key === day)?.[1] ?? day);
const enLabels = schedule.days.map((day) => DAYS.find(([key]) => key === day)?.[2] ?? day);
return tr(`Chaque semaine (${frLabels.join(", ")}) à ${schedule.time}`, `Every week (${enLabels.join(", ")}) at ${schedule.time}`);
}
export function ScheduledTaskForm({
@ -57,6 +62,7 @@ export function ScheduledTaskForm({
onCancel: () => void;
onSubmit: (input: ScheduledTaskDefinitionInput) => void;
}) {
const { tr } = useI18n();
const [name, setName] = useState(task?.name ?? "");
const [description, setDescription] = useState(task?.description ?? "");
const [command, setCommand] = useState(task?.command.command ?? "");
@ -116,22 +122,22 @@ export function ScheduledTaskForm({
return (
<div className="space-y-4">
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm text-content">
Cette tâche pourra modifier ou supprimer des fichiers automatiquement.
{tr("Cette tâche pourra modifier ou supprimer des fichiers automatiquement.", "This task will be able to edit or delete files automatically.")}
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-[9rem_1fr]">
<dt className="text-muted">Commande exacte</dt><dd><code className="break-all">{exactCommand(input)}</code></dd>
<dt className="text-muted">Dossier</dt><dd><code>{input.command.cwd ?? "."}</code> (relatif au projet)</dd>
<dt className="text-muted">Fréquence</dt><dd>{scheduleLabel(input.schedule)}</dd>
<dt className="text-muted">Prochaine exécution</dt><dd>Calculée par IdeA lors de l’activation</dd>
<dt className="text-muted">Fuseau horaire</dt><dd>{input.timezone}</dd>
<dt className="text-muted">Permissions</dt><dd>Lecture, modification et suppression dans le projet</dd>
<dt className="text-muted">Sandbox</dt><dd>Projet — lecture/écriture; aucun accès en écriture hors projet</dd>
<dt className="text-muted">{tr("Commande exacte", "Exact command")}</dt><dd><code className="break-all">{exactCommand(input)}</code></dd>
<dt className="text-muted">{tr("Dossier", "Directory")}</dt><dd><code>{input.command.cwd ?? "."}</code> ({tr("relatif au projet", "relative to project")})</dd>
<dt className="text-muted">{tr("Fréquence", "Frequency")}</dt><dd>{scheduleLabel(input.schedule, tr)}</dd>
<dt className="text-muted">{tr("Prochaine exécution", "Next run")}</dt><dd>{tr("Calculée par IdeA lors de l’activation", "Calculated by IdeA when enabled")}</dd>
<dt className="text-muted">{tr("Fuseau horaire", "Time zone")}</dt><dd>{input.timezone}</dd>
<dt className="text-muted">{tr("Permissions", "Permissions")}</dt><dd>{tr("Lecture, modification et suppression dans le projet", "Read, edit, and delete within the project")}</dd>
<dt className="text-muted">{tr("Bac à sable", "Sandbox")}</dt><dd>{tr("Projet — lecture/écriture ; aucun accès en écriture hors projet", "Project — read/write; no write access outside the project")}</dd>
</dl>
<p className="text-xs text-muted">IdeA doit fonctionner et le projet doit être ouvert pour déclencher cette tâche.</p>
<p className="text-xs text-muted">{tr("IdeA doit fonctionner et le projet doit être ouvert pour déclencher cette tâche.", "IdeA must be running and the project must be open to trigger this task.")}</p>
<div className="flex justify-end gap-2">
<Button onClick={() => setReview(false)}>Retour</Button>
<Button onClick={() => setReview(false)}>{tr("Retour", "Back")}</Button>
<Button variant="primary" loading={saving} onClick={() => onSubmit(input)}>
{task?.approval === "pending" ? "Approuver et activer" : task ? "Enregistrer" : "Créer la tâche"}
{task?.approval === "pending" ? tr("Approuver et activer", "Approve and enable") : task ? tr("Enregistrer", "Save") : tr("Créer la tâche", "Create task")}
</Button>
</div>
</div>
@ -141,45 +147,45 @@ export function ScheduledTaskForm({
return (
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); enabled ? setReview(true) : onSubmit(input); }}>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Nom">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={name} onChange={(e) => setName(e.target.value)} />}</Field>
<Field label="Dossier de travail" hint="Chemin relatif au projet; « . » utilise sa racine.">
<Field label={tr("Nom", "Name")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={name} onChange={(e) => setName(e.target.value)} />}</Field>
<Field label={tr("Dossier de travail", "Working directory")} hint={tr("Chemin relatif au projet ; « . » utilise sa racine.", "Path relative to the project; “.” uses its root.")}>
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={cwd} onChange={(e) => setCwd(e.target.value)} />}
</Field>
</div>
<Field label={task?.approval === "pending" ? "Motif de la demande" : "Description (facultative)"}>
<Field label={task?.approval === "pending" ? tr("Motif de la demande", "Request reason") : tr("Description (facultative)", "Description (optional)")}>
{({ id, describedBy }) => <textarea id={id} aria-describedby={describedBy} rows={2} className={`${control} h-auto py-2`} value={description} onChange={(e) => setDescription(e.target.value)} />}
</Field>
<Field label="Commande" hint="Exécutable exact, sans interpolation shell.">
<Field label={tr("Commande", "Command")} hint={tr("Exécutable exact, sans interpolation shell.", "Exact executable, without shell interpolation.")}>
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={command} onChange={(e) => setCommand(e.target.value)} placeholder="npm" />}
</Field>
<Field label="Arguments" hint="Un argument littéral par ligne.">
<Field label={tr("Arguments", "Arguments")} hint={tr("Un argument littéral par ligne.", "One literal argument per line.")}>
{({ id, describedBy }) => <textarea id={id} aria-describedby={describedBy} rows={3} className={`${control} h-auto py-2 font-mono`} value={args} onChange={(e) => setArgs(e.target.value)} placeholder={'run\nbuild'} />}
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Planification">
<Field label={tr("Planification", "Schedule")}>
{({ id }) => <select id={id} className={control} value={preset} onChange={(e) => setPreset(e.target.value as Preset)}>
<option value="hourly">Toutes les heures</option><option value="daily">Chaque jour</option>
<option value="weekly">Chaque semaine</option><option value="custom">Personnalisée</option>
<option value="hourly">{tr("Toutes les heures", "Hourly")}</option><option value="daily">{tr("Chaque jour", "Daily")}</option>
<option value="weekly">{tr("Chaque semaine", "Weekly")}</option><option value="custom">{tr("Personnalisée", "Custom")}</option>
</select>}
</Field>
{preset === "custom" ? (
<Field label="Intervalle (minutes)" error={Number(minutes) < 5 ? "Minimum : 5 minutes." : undefined}>
<Field label={tr("Intervalle (minutes)", "Interval (minutes)")} error={Number(minutes) < 5 ? tr("Minimum : 5 minutes.", "Minimum: 5 minutes.") : undefined}>
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={5} value={minutes} onChange={(e) => setMinutes(e.target.value)} />}
</Field>
) : preset === "daily" || preset === "weekly" ? (
<Field label="Heure locale">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="time" value={time} onChange={(e) => setTime(e.target.value)} />}</Field>
<Field label={tr("Heure locale", "Local time")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="time" value={time} onChange={(e) => setTime(e.target.value)} />}</Field>
) : <div />}
</div>
{preset === "weekly" && <fieldset><legend className="mb-1 text-xs font-medium text-muted">Jours</legend><div className="flex flex-wrap gap-2">
{DAYS.map(([key, label]) => <label key={key} className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs"><input type="checkbox" checked={days.includes(key)} onChange={() => setDays((current) => current.includes(key) ? current.filter((day) => day !== key) : [...current, key])} />{label}</label>)}
{preset === "weekly" && <fieldset><legend className="mb-1 text-xs font-medium text-muted">{tr("Jours", "Days")}</legend><div className="flex flex-wrap gap-2">
{DAYS.map(([key, frLabel, enLabel]) => <label key={key} className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs"><input type="checkbox" checked={days.includes(key)} onChange={() => setDays((current) => current.includes(key) ? current.filter((day) => day !== key) : [...current, key])} />{tr(frLabel, enLabel)}</label>)}
</div></fieldset>}
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Fuseau horaire" hint="Identifiant IANA, par exemple Europe/Paris.">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={timezone} onChange={(e) => setTimezone(e.target.value)} />}</Field>
<Field label="Limite de durée (minutes)" hint="Vide : aucune limite.">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={1} value={timeoutMinutes} onChange={(e) => setTimeoutMinutes(e.target.value)} />}</Field>
<Field label={tr("Fuseau horaire", "Time zone")} hint={tr("Identifiant IANA, par exemple Europe/Paris.", "IANA identifier, for example Europe/Paris.")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={timezone} onChange={(e) => setTimezone(e.target.value)} />}</Field>
<Field label={tr("Limite de durée (minutes)", "Time limit (minutes)")} hint={tr("Vide : aucune limite.", "Empty: no limit.")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={1} value={timeoutMinutes} onChange={(e) => setTimeoutMinutes(e.target.value)} />}</Field>
</div>
<div className="rounded-md border border-border bg-raised p-3 text-sm"><div className="font-medium">Permissions et sandbox</div><p className="mt-1 text-muted">Projet — lecture/écriture. La commande peut modifier ou supprimer des fichiers dans le projet; l’écriture hors projet est refusée. Le réseau n’est pas isolé par ce profil V1.</p></div>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />Activée</label>
<div className="flex justify-end gap-2"><Button onClick={onCancel}>Annuler</Button><Button type="submit" variant="primary" disabled={!valid} loading={saving}>{enabled ? "Vérifier avant activation" : "Enregistrer"}</Button></div>
<div className="rounded-md border border-border bg-raised p-3 text-sm"><div className="font-medium">{tr("Permissions et bac à sable", "Permissions and sandbox")}</div><p className="mt-1 text-muted">{tr("Projet — lecture/écriture. La commande peut modifier ou supprimer des fichiers dans le projet ; l’écriture hors projet est refusée. Le réseau n’est pas isolé par ce profil V1.", "Project — read/write. The command can edit or delete files in the project; writes outside the project are denied. Network is not isolated by this V1 profile.")}</p></div>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />{tr("Activée", "Enabled")}</label>
<div className="flex justify-end gap-2"><Button onClick={onCancel}>{tr("Annuler", "Cancel")}</Button><Button type="submit" variant="primary" disabled={!valid} loading={saving}>{enabled ? tr("Vérifier avant activation", "Review before enabling") : tr("Enregistrer", "Save")}</Button></div>
</form>
);
}

View File

@ -4,40 +4,44 @@ import type { BackgroundCompletion, ScheduledProjectTask } from "@/domain";
import { Button, FloatingWindow, Panel, Spinner, cn } from "@/shared";
import { exactCommand, scheduleLabel, ScheduledTaskForm } from "./ScheduledTaskForm";
import { useScheduledTasks } from "./useScheduledTasks";
import { useI18n } from "@/i18n";
import type { IdeaLanguage } from "@/ports";
type Tr = (french: string, english: string) => string;
type Editor = { kind: "create" } | { kind: "edit"; task: ScheduledProjectTask };
function dateTime(value?: number | null): string {
if (value == null) return "Indisponible";
return new Intl.DateTimeFormat("fr-FR", { dateStyle: "medium", timeStyle: "short" }).format(value);
function dateTime(value: number | null | undefined, language: IdeaLanguage, tr: Tr): string {
if (value == null) return tr("Indisponible", "Unavailable");
return new Intl.DateTimeFormat(language === "fr" ? "fr-FR" : "en-US", { dateStyle: "medium", timeStyle: "short" }).format(value);
}
function duration(run: BackgroundCompletion): string {
function duration(run: BackgroundCompletion, tr: Tr): string {
if (run.createdAtMs && run.updatedAtMs >= run.createdAtMs && !["running", "pending", "cancelling"].includes(run.status)) {
const seconds = Math.round((run.updatedAtMs - run.createdAtMs) / 1000);
return seconds < 60 ? `${seconds} s` : `${Math.round(seconds / 60)} min`;
}
return run.status === "running" || run.status === "pending" || run.status === "cancelling"
? "En cours"
: "Terminée";
? tr("En cours", "Running")
: tr("Terminée", "Completed");
}
function displayStatus(task: ScheduledProjectTask, run?: BackgroundCompletion) {
if (task.approval === "pending") return { label: "À approuver", tone: "warning" } as const;
if (run && ["running", "pending", "cancelling"].includes(run.status)) return { label: "En cours", tone: "primary" } as const;
function displayStatus(task: ScheduledProjectTask, tr: Tr, run?: BackgroundCompletion) {
if (task.approval === "pending") return { label: tr("À approuver", "Approval required"), tone: "warning" } as const;
if (run && ["running", "pending", "cancelling"].includes(run.status)) return { label: tr("En cours", "Running"), tone: "primary" } as const;
if (run?.status === "failed" || run?.status === "cancelFailed") {
const inaccessible = `${run.summary ?? ""} ${run.stderrTail ?? ""}`.toLowerCase().includes("inaccessible");
return { label: inaccessible ? "Projet inaccessible" : "Échec", tone: "danger" } as const;
return { label: inaccessible ? tr("Projet inaccessible", "Project unavailable") : tr("Échec", "Failed"), tone: "danger" } as const;
}
if (!task.enabled) return { label: "Suspendue", tone: "muted" } as const;
return { label: "Active", tone: "success" } as const;
if (!task.enabled) return { label: tr("Suspendue", "Paused"), tone: "muted" } as const;
return { label: tr("Active", "Active"), tone: "success" } as const;
}
function actorLabel(task: ScheduledProjectTask, names: ReadonlyMap<string, string>): string {
function actorLabel(task: ScheduledProjectTask, names: ReadonlyMap<string, string>, tr: Tr): string {
if (task.createdBy.kind === "agent") {
return `Créée par ${names.get(task.createdBy.agentId) ?? task.createdBy.agentId}`;
return `${tr("Créée par", "Created by")} ${names.get(task.createdBy.agentId) ?? task.createdBy.agentId}`;
}
return task.createdBy.kind === "human" ? "Créée par vous" : "Créée par IdeA";
return task.createdBy.kind === "human" ? tr("Créée par vous", "Created by you") : tr("Créée par IdeA", "Created by IdeA");
}
export function ScheduledTasksPanel({
@ -48,6 +52,7 @@ export function ScheduledTasksPanel({
onOpenWork?: () => void;
}) {
const vm = useScheduledTasks(projectId);
const { language, tr } = useI18n();
const [editor, setEditor] = useState<Editor | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ScheduledProjectTask | null>(null);
const [approveTarget, setApproveTarget] = useState<ScheduledProjectTask | null>(null);
@ -63,24 +68,24 @@ export function ScheduledTasksPanel({
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-content">Tâches planifiées</h2>
<p className="mt-1 text-xs text-muted">IdeA doit fonctionner et le projet rester ouvert pour déclencher les tâches. Les occurrences manquées ne sont pas rattrapées en rafale.</p>
<h2 className="text-base font-semibold text-content">{tr("Tâches planifiées", "Scheduled tasks")}</h2>
<p className="mt-1 text-xs text-muted">{tr("IdeA doit fonctionner et le projet rester ouvert pour déclencher les tâches. Les occurrences manquées ne sont pas rattrapées en rafale.", "IdeA must be running and the project must remain open to trigger tasks. Missed runs are not replayed in a burst.")}</p>
</div>
<Button variant="primary" onClick={() => setEditor({ kind: "create" })}>Nouvelle tâche planifiée</Button>
<Button variant="primary" onClick={() => setEditor({ kind: "create" })}>{tr("Nouvelle tâche planifiée", "New scheduled task")}</Button>
</div>
{vm.error && <div role="alert" className="flex items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 p-3 text-sm text-danger"><span><strong>{vm.error.toLowerCase().includes("inaccessible") ? "Projet inaccessible" : "Erreur"}</strong> — Impossible de charger ou modifier les tâches planifiées. {vm.error}</span><Button size="sm" onClick={() => void vm.refresh()}>Réessayer</Button></div>}
{vm.error && <div role="alert" className="flex items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 p-3 text-sm text-danger"><span><strong>{vm.error.toLowerCase().includes("inaccessible") ? tr("Projet inaccessible", "Project unavailable") : tr("Erreur", "Error")}</strong> — {tr("Impossible de charger ou modifier les tâches planifiées.", "Unable to load or update scheduled tasks.")} {vm.error}</span><Button size="sm" onClick={() => void vm.refresh()}>{tr("Réessayer", "Retry")}</Button></div>}
{vm.tasks.some((task) => {
const run = task.lastTaskId ? vm.runsById.get(task.lastTaskId) : undefined;
return run?.status === "failed" || run?.status === "cancelFailed";
}) && <div role="status" className="rounded-md border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">Une tâche planifiée a échoué. Utilisez « Voir les détails » sur la tâche concernée.</div>}
}) && <div role="status" className="rounded-md border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">{tr("Une tâche planifiée a échoué. Utilisez « Voir les détails » sur la tâche concernée.", "A scheduled task failed. Use “View details” on that task.")}</div>}
{vm.loading && vm.tasks.length === 0 ? <div className="flex justify-center p-8"><Spinner /></div> : vm.tasks.length === 0 ? (
<Panel><p className="text-sm font-medium text-content">Aucune tâche planifiée pour ce projet</p><p className="mt-1 text-sm text-muted">Créez une tâche pour automatiser une commande récurrente sans utiliser le cron du système.</p></Panel>
<Panel><p className="text-sm font-medium text-content">{tr("Aucune tâche planifiée pour ce projet", "No scheduled tasks for this project")}</p><p className="mt-1 text-sm text-muted">{tr("Créez une tâche pour automatiser une commande récurrente sans utiliser le cron du système.", "Create a task to automate a recurring command without using system cron.")}</p></Panel>
) : (
<ul className="space-y-3" aria-label="Tâches planifiées du projet">
<ul className="space-y-3" aria-label={tr("Tâches planifiées du projet", "Project scheduled tasks")}>
{vm.tasks.map((task) => {
const run = task.lastTaskId ? vm.runsById.get(task.lastTaskId) : undefined;
const status = displayStatus(task, run);
const status = displayStatus(task, tr, run);
const requestingAgentId = task.createdBy.kind === "agent" ? task.createdBy.agentId : null;
const pendingAgent = task.approval === "pending" && requestingAgentId !== null;
const running = run && ["running", "pending", "cancelling"].includes(run.status);
@ -88,57 +93,57 @@ export function ScheduledTasksPanel({
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"><span className="font-medium text-content">{task.name}</span><span className={cn("rounded-full px-2 py-0.5 text-xs font-medium", status.tone === "danger" && "bg-danger/10 text-danger", status.tone === "warning" && "bg-warning/10 text-warning", status.tone === "success" && "bg-success/10 text-success", status.tone === "primary" && "bg-primary/10 text-primary", status.tone === "muted" && "bg-raised text-muted")}>{status.label}</span></div>
<p className="mt-1 text-xs text-muted">{actorLabel(task, vm.agentNames)} · {scheduleLabel(task.schedule)} · {task.timezone}</p>
<p className="mt-1 text-xs text-muted">{actorLabel(task, vm.agentNames, tr)} · {scheduleLabel(task.schedule)} · {task.timezone}</p>
</div>
<div className="flex flex-wrap gap-1">
{pendingAgent ? <><Button size="sm" variant="primary" onClick={() => setApproveTarget(task)}>Approuver et activer</Button><Button size="sm" onClick={() => setEditor({ kind: "edit", task })}>Modifier avant d’activer</Button><Button size="sm" variant="danger" loading={vm.busyId === task.id} onClick={() => void vm.setEnabled(task, false)}>Refuser</Button></> : <><Button size="sm" onClick={() => setEditor({ kind: "edit", task })}>Modifier</Button><Button size="sm" disabled={task.approval !== "approved" || Boolean(running)} loading={vm.busyId === task.id} onClick={() => void vm.runNow(task)}>Exécuter maintenant</Button><Button size="sm" loading={vm.busyId === task.id} onClick={() => void vm.setEnabled(task, !task.enabled)}>{task.enabled ? "Suspendre" : "Reprendre"}</Button><Button size="sm" variant="danger" onClick={() => setDeleteTarget(task)}>Supprimer</Button></>}
{pendingAgent ? <><Button size="sm" variant="primary" onClick={() => setApproveTarget(task)}>{tr("Approuver et activer", "Approve and enable")}</Button><Button size="sm" onClick={() => setEditor({ kind: "edit", task })}>{tr("Modifier avant d’activer", "Edit before enabling")}</Button><Button size="sm" variant="danger" loading={vm.busyId === task.id} onClick={() => void vm.setEnabled(task, false)}>{tr("Refuser", "Reject")}</Button></> : <><Button size="sm" onClick={() => setEditor({ kind: "edit", task })}>{tr("Modifier", "Edit")}</Button><Button size="sm" disabled={task.approval !== "approved" || Boolean(running)} loading={vm.busyId === task.id} onClick={() => void vm.runNow(task)}>{tr("Exécuter maintenant", "Run now")}</Button><Button size="sm" loading={vm.busyId === task.id} onClick={() => void vm.setEnabled(task, !task.enabled)}>{task.enabled ? tr("Suspendre", "Pause") : tr("Reprendre", "Resume")}</Button><Button size="sm" variant="danger" onClick={() => setDeleteTarget(task)}>{tr("Supprimer", "Delete")}</Button></>}
</div>
</div>
{pendingAgent && <div className="mt-3 rounded-md border border-warning/50 bg-warning/10 p-3 text-sm"><p className="font-medium">Demandée par {vm.agentNames.get(requestingAgentId) ?? requestingAgentId}</p><p className="mt-1 text-muted">{dateTime(task.createdAtMs)}{task.description ? ` · Motif : ${task.description}` : " · Aucun motif fourni"}</p><p className="mt-2 text-xs">Cette tâche ne s’exécutera pas avant votre approbation.</p></div>}
{pendingAgent && <div className="mt-3 rounded-md border border-warning/50 bg-warning/10 p-3 text-sm"><p className="font-medium">{tr("Demandée par", "Requested by")} {vm.agentNames.get(requestingAgentId) ?? requestingAgentId}</p><p className="mt-1 text-muted">{dateTime(task.createdAtMs, language, tr)}{task.description ? ` · ${tr("Motif", "Reason")} : ${task.description}` : ` · ${tr("Aucun motif fourni", "No reason provided")}`}</p><p className="mt-2 text-xs">{tr("Cette tâche ne s’exécutera pas avant votre approbation.", "This task will not run until you approve it.")}</p></div>}
<dl className="mt-3 grid gap-x-4 gap-y-1 text-xs sm:grid-cols-[8rem_1fr_8rem_1fr]">
<dt className="text-muted">Commande</dt><dd className="break-all font-mono text-content">{exactCommand({ name: task.name, command: task.command, schedule: task.schedule, timezone: task.timezone, enabled: task.enabled })}</dd>
<dt className="text-muted">Dossier</dt><dd className="font-mono">{task.command.cwd ?? "."} (projet)</dd>
<dt className="text-muted">Prochaine exécution</dt><dd>{dateTime(task.nextRunAtMs)}</dd>
<dt className="text-muted">Dernière exécution</dt><dd>{dateTime(task.lastRunAtMs)}</dd>
<dt className="text-muted">Permissions</dt><dd>Modifier/supprimer dans le projet</dd>
<dt className="text-muted">Sandbox</dt><dd>Projet — lecture/écriture; réseau non isolé en V1</dd>
<dt className="text-muted">Limite de durée</dt><dd>{task.timeoutMs ? `${Math.round(task.timeoutMs / 60_000)} min` : "Aucune"}</dd>
<dt className="text-muted">Déclenchement</dt><dd>{run?.origin?.kind === "scheduled" ? "Planifié" : run ? "Manuel" : "Aucune exécution"}</dd>
<dt className="text-muted">{tr("Commande", "Command")}</dt><dd className="break-all font-mono text-content">{exactCommand({ name: task.name, command: task.command, schedule: task.schedule, timezone: task.timezone, enabled: task.enabled })}</dd>
<dt className="text-muted">{tr("Dossier", "Directory")}</dt><dd className="font-mono">{task.command.cwd ?? "."} ({tr("projet", "project")})</dd>
<dt className="text-muted">{tr("Prochaine exécution", "Next run")}</dt><dd>{dateTime(task.nextRunAtMs, language, tr)}</dd>
<dt className="text-muted">{tr("Dernière exécution", "Last run")}</dt><dd>{dateTime(task.lastRunAtMs, language, tr)}</dd>
<dt className="text-muted">{tr("Permissions", "Permissions")}</dt><dd>{tr("Modifier/supprimer dans le projet", "Edit/delete within the project")}</dd>
<dt className="text-muted">{tr("Bac à sable", "Sandbox")}</dt><dd>{tr("Projet — lecture/écriture ; réseau non isolé en V1", "Project — read/write; network not isolated in V1")}</dd>
<dt className="text-muted">{tr("Limite de durée", "Time limit")}</dt><dd>{task.timeoutMs ? `${Math.round(task.timeoutMs / 60_000)} min` : tr("Aucune", "None")}</dd>
<dt className="text-muted">{tr("Déclenchement", "Trigger")}</dt><dd>{run?.origin?.kind === "scheduled" ? tr("Planifié", "Scheduled") : run ? tr("Manuel", "Manual") : tr("Aucune exécution", "No run")}</dd>
</dl>
{task.lastTaskId && <div className="mt-3 flex flex-wrap items-center gap-2"><Button size="sm" variant="ghost" onClick={() => setExpanded(expanded === task.id ? null : task.id)}>{expanded === task.id ? "Masquer les détails" : "Voir les détails"}</Button>{onOpenWork && <Button size="sm" variant="ghost" onClick={onOpenWork}>Ouvrir la tâche de fond {task.lastTaskId.slice(0, 8)}</Button>}</div>}
{expanded === task.id && <div className="mt-2 rounded-md bg-raised p-3 text-xs"><p><span className="text-muted">BackgroundTask :</span> {task.lastTaskId}</p>{run ? <><p className="mt-1"><span className="text-muted">Statut :</span> {run.status} · {duration(run)} · code de sortie {run.exitCode ?? "indisponible"}</p><p className="mt-1"><span className="text-muted">Permissions réellement utilisées :</span> sandbox projet — lecture/écriture</p>{run.summary && <p className="mt-2 whitespace-pre-wrap">{run.summary}</p>}{run.stdoutTail && <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-canvas p-2">{run.stdoutTail}</pre>}{run.stderrTail && <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-danger/5 p-2 text-danger">{run.stderrTail}</pre>}</> : <p className="mt-1 text-muted">Les détails de cette exécution ne sont plus présents dans le WorkState courant.</p>}</div>}
{task.lastTaskId && <div className="mt-3 flex flex-wrap items-center gap-2"><Button size="sm" variant="ghost" onClick={() => setExpanded(expanded === task.id ? null : task.id)}>{expanded === task.id ? tr("Masquer les détails", "Hide details") : tr("Voir les détails", "View details")}</Button>{onOpenWork && <Button size="sm" variant="ghost" onClick={onOpenWork}>{tr("Ouvrir la tâche de fond", "Open background task")} {task.lastTaskId.slice(0, 8)}</Button>}</div>}
{expanded === task.id && <div className="mt-2 rounded-md bg-raised p-3 text-xs"><p><span className="text-muted">BackgroundTask :</span> {task.lastTaskId}</p>{run ? <><p className="mt-1"><span className="text-muted">{tr("Statut", "Status")} :</span> {run.status} · {duration(run, tr)} · {tr("code de sortie", "exit code")} {run.exitCode ?? tr("indisponible", "unavailable")}</p><p className="mt-1"><span className="text-muted">{tr("Permissions réellement utilisées", "Permissions actually used")} :</span> {tr("sandbox projet — lecture/écriture", "project sandbox — read/write")}</p>{run.summary && <p className="mt-2 whitespace-pre-wrap">{run.summary}</p>}{run.stdoutTail && <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-canvas p-2">{run.stdoutTail}</pre>}{run.stderrTail && <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-danger/5 p-2 text-danger">{run.stderrTail}</pre>}</> : <p className="mt-1 text-muted">{tr("Les détails de cette exécution ne sont plus présents dans le WorkState courant.", "Details for this run are no longer available in the current WorkState.")}</p>}</div>}
</li>;
})}
</ul>
)}
<FloatingWindow open={editor !== null} title={editor?.kind === "edit" ? (editor.task.approval === "pending" ? "Modifier avant d’activer" : "Modifier la tâche planifiée") : "Nouvelle tâche planifiée"} size="lg" onClose={() => setEditor(null)}>
<FloatingWindow open={editor !== null} title={editor?.kind === "edit" ? (editor.task.approval === "pending" ? tr("Modifier avant d’activer", "Edit before enabling") : tr("Modifier la tâche planifiée", "Edit scheduled task")) : tr("Nouvelle tâche planifiée", "New scheduled task")} size="lg" onClose={() => setEditor(null)}>
{editor && <ScheduledTaskForm task={editor.kind === "edit" ? editor.task : undefined} saving={vm.busyId === (editor.kind === "edit" ? editor.task.id : "new")} onCancel={() => setEditor(null)} onSubmit={(input) => void (editor.kind === "edit" ? vm.update(editor.task, input) : vm.create(input)).then((saved) => { if (saved) setEditor(null); })} />}
</FloatingWindow>
<FloatingWindow open={deleteTarget !== null} title="Supprimer la tâche planifiée" size="sm" onClose={() => setDeleteTarget(null)}>
<FloatingWindow open={deleteTarget !== null} title={tr("Supprimer la tâche planifiée", "Delete scheduled task")} size="sm" onClose={() => setDeleteTarget(null)}>
{deleteTarget && (() => {
const run = deleteTarget.lastTaskId ? vm.runsById.get(deleteTarget.lastTaskId) : undefined;
const running = run && ["running", "pending", "cancelling"].includes(run.status);
return <div className="space-y-4"><p className="text-sm">Supprimer définitivement cette tâche planifiée ?</p>{running ? <><p className="text-xs text-muted">Une exécution est en cours. La première action retire la planification mais laisse cette exécution se terminer.</p><div className="flex flex-col gap-2"><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget)}>Supprimer après l’exécution</Button><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget, true)}>Arrêter puis supprimer</Button><Button onClick={() => setDeleteTarget(null)}>Annuler</Button></div></> : <div className="flex justify-end gap-2"><Button onClick={() => setDeleteTarget(null)}>Annuler</Button><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget)}>Supprimer définitivement</Button></div>}</div>;
return <div className="space-y-4"><p className="text-sm">{tr("Supprimer définitivement cette tâche planifiée ?", "Permanently delete this scheduled task?")}</p>{running ? <><p className="text-xs text-muted">{tr("Une exécution est en cours. La première action retire la planification mais laisse cette exécution se terminer.", "A run is in progress. The first action removes the schedule but lets this run finish.")}</p><div className="flex flex-col gap-2"><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget)}>{tr("Supprimer après l’exécution", "Delete after run")}</Button><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget, true)}>{tr("Arrêter puis supprimer", "Stop and delete")}</Button><Button onClick={() => setDeleteTarget(null)}>{tr("Annuler", "Cancel")}</Button></div></> : <div className="flex justify-end gap-2"><Button onClick={() => setDeleteTarget(null)}>{tr("Annuler", "Cancel")}</Button><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget)}>{tr("Supprimer définitivement", "Delete permanently")}</Button></div>}</div>;
})()}
</FloatingWindow>
<FloatingWindow open={approveTarget !== null} title="Approuver la tâche planifiée" size="md" onClose={() => setApproveTarget(null)}>
<FloatingWindow open={approveTarget !== null} title={tr("Approuver la tâche planifiée", "Approve scheduled task")} size="md" onClose={() => setApproveTarget(null)}>
{approveTarget && <div className="space-y-4">
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">Cette tâche pourra modifier ou supprimer des fichiers automatiquement.</div>
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">{tr("Cette tâche pourra modifier ou supprimer des fichiers automatiquement.", "This task will be able to edit or delete files automatically.")}</div>
<dl className="grid gap-2 text-sm sm:grid-cols-[8rem_1fr]">
<dt className="text-muted">Commande exacte</dt><dd className="break-all font-mono">{exactCommand({ name: approveTarget.name, command: approveTarget.command, schedule: approveTarget.schedule, timezone: approveTarget.timezone, enabled: true })}</dd>
<dt className="text-muted">Dossier</dt><dd>{approveTarget.command.cwd ?? "."} (relatif au projet)</dd>
<dt className="text-muted">Fréquence</dt><dd>{scheduleLabel(approveTarget.schedule)}</dd>
<dt className="text-muted">Fuseau</dt><dd>{approveTarget.timezone}</dd>
<dt className="text-muted">Prochaine exécution</dt><dd>Calculée par IdeA lors de l’activation</dd>
<dt className="text-muted">Permissions</dt><dd>Lecture, modification et suppression dans le projet</dd>
<dt className="text-muted">Sandbox</dt><dd>Projet — lecture/écriture</dd>
<dt className="text-muted">{tr("Commande exacte", "Exact command")}</dt><dd className="break-all font-mono">{exactCommand({ name: approveTarget.name, command: approveTarget.command, schedule: approveTarget.schedule, timezone: approveTarget.timezone, enabled: true })}</dd>
<dt className="text-muted">{tr("Dossier", "Directory")}</dt><dd>{approveTarget.command.cwd ?? "."} ({tr("relatif au projet", "relative to project")})</dd>
<dt className="text-muted">{tr("Fréquence", "Frequency")}</dt><dd>{scheduleLabel(approveTarget.schedule, tr)}</dd>
<dt className="text-muted">{tr("Fuseau", "Time zone")}</dt><dd>{approveTarget.timezone}</dd>
<dt className="text-muted">{tr("Prochaine exécution", "Next run")}</dt><dd>{tr("Calculée par IdeA lors de l’activation", "Calculated by IdeA when enabled")}</dd>
<dt className="text-muted">{tr("Permissions", "Permissions")}</dt><dd>{tr("Lecture, modification et suppression dans le projet", "Read, edit, and delete within the project")}</dd>
<dt className="text-muted">{tr("Bac à sable", "Sandbox")}</dt><dd>{tr("Projet — lecture/écriture", "Project — read/write")}</dd>
</dl>
<div className="flex justify-end gap-2"><Button onClick={() => setApproveTarget(null)}>Annuler</Button><Button variant="primary" loading={vm.busyId === approveTarget.id} onClick={() => void vm.setEnabled(approveTarget, true).then((ok) => { if (ok) setApproveTarget(null); })}>Approuver et activer</Button></div>
<div className="flex justify-end gap-2"><Button onClick={() => setApproveTarget(null)}>{tr("Annuler", "Cancel")}</Button><Button variant="primary" loading={vm.busyId === approveTarget.id} onClick={() => void vm.setEnabled(approveTarget, true).then((ok) => { if (ok) setApproveTarget(null); })}>{tr("Approuver et activer", "Approve and enable")}</Button></div>
</div>}
</FloatingWindow>
</div>