feat(frontend): popup de confirmation à la fermeture avec travail en cours (#83)

Écoute l'event Tauri `app-exit-work-guard` (émis par le backend quand la
fermeture de la fenêtre main est interceptée) et affiche une popup modale
"Du travail est encore en cours" avec le résumé pluralisé exact du carnet
#83, le détail agents/tâches capé à 5 lignes, et les deux actions Annuler
(focus par défaut, no-op local) / Quitter quand même (danger, appelle
confirm_app_exit). Pas d'option "ne plus demander".

- domain/ports/adapters (Tauri listen+invoke, HTTP desktop-only stub, mock
  avec helpers de test) : onAppExitWorkGuard/confirmAppExit sur
  SystemGateway, suivant le patron déjà utilisé pour focused-project et les
  domain events.
- AppExitConfirmDialog : mounted une fois près de la racine (App.tsx), à
  côté d'AnnouncementsProvider — role="alertdialog", focus trap, Échap =
  Annuler, pas de fermeture au clic extérieur, ne se referme jamais
  automatiquement (un event pendant l'ouverture rafraîchit juste le résumé).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 19:16:29 +02:00
parent 294865f805
commit 8509653e3c
9 changed files with 565 additions and 1 deletions

View File

@ -0,0 +1,229 @@
/**
* Ticket #83 — the app-exit "work in progress" confirmation popup.
*
* Pins the carnet #83 contract: the popup only appears on the backend's
* `app-exit-work-guard` event (never derived locally), with the exact title
* and pluralized body copy, a compact capped detail list, `Annuler` as a pure
* local no-op, and `Quitter quand même` calling `confirmAppExit()`.
*/
import { describe, it, expect } from "vitest";
import { render, screen, act, fireEvent } from "@testing-library/react";
import type { AppExitWorkGuardState } from "@/domain";
import type { Gateways } from "@/ports";
import { MockSystemGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { AppExitConfirmDialog } from "./AppExitConfirmDialog";
function setup() {
const system = new MockSystemGateway();
const gateways = { system } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<AppExitConfirmDialog />
</DIProvider>,
);
return { system };
}
/** The gateway subscribes asynchronously; flush the microtask before emitting. */
async function flush() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function state(over: Partial<AppExitWorkGuardState> = {}): AppExitWorkGuardState {
return {
hasWorkInProgress: true,
busyAgentCount: 0,
activeBackgroundTaskCount: 0,
totalWorkCount: 0,
details: [],
...over,
};
}
const dialog = () => screen.queryByRole("alertdialog");
describe("AppExitConfirmDialog", () => {
it("renders nothing until the app-exit-work-guard event fires", async () => {
await setup();
await flush();
expect(dialog()).toBeNull();
});
it("shows the exact title and singular body for exactly one active work item", async () => {
const { system } = await setup();
await flush();
act(() => {
system.emitAppExitWorkGuard(
state({ busyAgentCount: 1, totalWorkCount: 1 }),
);
});
expect(dialog()).not.toBeNull();
expect(screen.getByText("Du travail est encore en cours")).toBeTruthy();
expect(
screen.getByText(
"1 travail actif sera interrompu si vous quittez IdeA maintenant.",
),
).toBeTruthy();
expect(
screen.getByText(
"Annulez la fermeture pour laisser les agents et les tâches se terminer.",
),
).toBeTruthy();
});
it("pluralizes and distinguishes agent/background-task counts when both are present", async () => {
const { system } = await setup();
await flush();
act(() => {
system.emitAppExitWorkGuard(
state({ busyAgentCount: 2, activeBackgroundTaskCount: 1, totalWorkCount: 3 }),
);
});
expect(
screen.getByText(
"2 agents travaillent encore et 1 tâche de fond est encore active. Ces travaux seront interrompus si vous quittez IdeA maintenant.",
),
).toBeTruthy();
});
it("shows up to 5 detail lines then a '+N autres' summary", async () => {
const { system } = await setup();
await flush();
const details: AppExitWorkGuardState["details"] = Array.from({ length: 7 }, (_, i) => ({
kind: "busyAgent",
projectId: "p1",
projectName: "IdeA",
agentId: `a${i}`,
agentName: `Agent${i}`,
ticketId: null,
}));
act(() => {
system.emitAppExitWorkGuard(
state({ busyAgentCount: 7, totalWorkCount: 7, details }),
);
});
expect(screen.getByText("• Agent Agent0 — IdeA")).toBeTruthy();
expect(screen.getByText("• Agent Agent4 — IdeA")).toBeTruthy();
expect(screen.queryByText("• Agent Agent5 — IdeA")).toBeNull();
expect(screen.getByText("+ 2 autres")).toBeTruthy();
});
it("formats a background-task detail line using the short task id fallback", async () => {
const { system } = await setup();
await flush();
act(() => {
system.emitAppExitWorkGuard(
state({
activeBackgroundTaskCount: 1,
totalWorkCount: 1,
details: [
{
kind: "activeBackgroundTask",
projectId: "p1",
projectName: "IdeA",
agentId: "a1",
agentName: "DevBackend",
taskId: "0123456789abcdef",
state: "running",
taskKind: "command",
},
],
}),
);
});
expect(screen.getByText("• Tâche de fond 01234567 — IdeA")).toBeTruthy();
});
it("'Annuler' closes the popup locally without calling confirmAppExit", async () => {
const { system } = await setup();
await flush();
act(() => {
system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 }));
});
expect(dialog()).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Annuler" }));
expect(dialog()).toBeNull();
expect(system.confirmAppExitCallCount).toBe(0);
});
it("Escape is equivalent to Annuler", async () => {
const { system } = await setup();
await flush();
act(() => {
system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 }));
});
expect(dialog()).not.toBeNull();
fireEvent.keyDown(window, { key: "Escape" });
expect(dialog()).toBeNull();
expect(system.confirmAppExitCallCount).toBe(0);
});
it("'Quitter quand même' calls confirmAppExit", async () => {
const { system } = await setup();
await flush();
act(() => {
system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 }));
});
fireEvent.click(screen.getByRole("button", { name: /Quitter quand même/ }));
await flush();
expect(system.confirmAppExitCallCount).toBe(1);
});
it("focuses 'Annuler' by default when the popup opens", async () => {
const { system } = await setup();
await flush();
act(() => {
system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 }));
});
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Annuler" }));
});
it("does not auto-close when a fresh guard event arrives while already open", async () => {
const { system } = await setup();
await flush();
act(() => {
system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 }));
});
expect(dialog()).not.toBeNull();
act(() => {
system.emitAppExitWorkGuard(
state({ busyAgentCount: 2, activeBackgroundTaskCount: 1, totalWorkCount: 3 }),
);
});
// Still open, summary refreshed in place.
expect(dialog()).not.toBeNull();
expect(
screen.getByText(
"2 agents travaillent encore et 1 tâche de fond est encore active. Ces travaux seront interrompus si vous quittez IdeA maintenant.",
),
).toBeTruthy();
});
});

View File

@ -0,0 +1,222 @@
/**
* `AppExitConfirmDialog` — app-wide confirmation shown when closing the main
* window would interrupt active work (ticket #83). Mounted once near the App
* root, alongside `AnnouncementsProvider`; subscribes to the backend's
* `app-exit-work-guard` event via {@link SystemGateway.onAppExitWorkGuard} and
* renders nothing until the guard actually fires.
*
* The backend owns the decision: it intercepts `WindowEvent::CloseRequested`
* on the `main` window, aggregates active work across every open project, and
* only emits the guard event when closing would interrupt something. This
* component is purely presentational on top of that signal — it never derives
* "work in progress" itself from local state, which would risk missing work
* in a project/tab the frontend hasn't refreshed.
*
* `Quitter quand même` calls `confirmAppExit()`, which bypasses the guard once
* and re-requests the real main-window close (same teardown order as an
* unguarded quit). `Annuler` only clears local dialog state — no backend call,
* the window and every session stay untouched.
*/
import { useEffect, useRef, useState } from "react";
import type { AppExitWorkGuardDetail, AppExitWorkGuardState } from "@/domain";
import { useGateways } from "@/app/di";
import { Button, zIndex } from "@/shared";
const MAX_DETAIL_LINES = 5;
/** The narrative body sentence, per carnet #83's exact wording rules. */
function bodyText(state: AppExitWorkGuardState): string {
if (state.totalWorkCount === 1) {
return "1 travail actif sera interrompu si vous quittez IdeA maintenant.";
}
const parts: string[] = [];
if (state.busyAgentCount > 0) {
parts.push(
state.busyAgentCount === 1
? "1 agent travaille encore"
: `${state.busyAgentCount} agents travaillent encore`,
);
}
if (state.activeBackgroundTaskCount > 0) {
parts.push(
state.activeBackgroundTaskCount === 1
? "1 tâche de fond est encore active"
: `${state.activeBackgroundTaskCount} tâches de fond sont encore actives`,
);
}
return `${parts.join(" et ")}. Ces travaux seront interrompus si vous quittez IdeA maintenant.`;
}
/** One compact detail line, per carnet #83's exact line format. */
function detailLine(detail: AppExitWorkGuardDetail): string {
if (detail.kind === "busyAgent") {
return `Agent ${detail.agentName}${detail.projectName}`;
}
const shortTaskId = detail.taskId.slice(0, 8);
return `Tâche de fond ${shortTaskId}${detail.projectName}`;
}
function describeError(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as { message: unknown }).message);
}
return String(e);
}
const TITLE_ID = "app-exit-confirm-title";
const DESC_ID = "app-exit-confirm-desc";
export function AppExitConfirmDialog() {
const { system } = useGateways();
const [guard, setGuard] = useState<AppExitWorkGuardState | null>(null);
const [closing, setClosing] = useState(false);
const [error, setError] = useState<string | null>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
let unsub: (() => void) | undefined;
system
.onAppExitWorkGuard((state) => {
// Any emission implies the backend just intercepted a close with work
// in progress. If the dialog is already open, this refreshes the
// summary in place rather than closing/reopening it (carnet #83: the
// popup never auto-closes on its own).
setGuard(state);
})
.then((u) => {
if (cancelled) u();
else unsub = u;
})
.catch(() => {
/* guard relay unavailable in this environment (e.g. focused test DI) */
});
return () => {
cancelled = true;
unsub?.();
};
}, [system]);
// Mount-only focus: an open dialog focuses the safe choice once. Re-running
// on every render (e.g. a refreshed summary) would steal focus back from
// wherever the user tabbed to.
useEffect(() => {
if (guard) cancelRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [guard != null]);
function cancel() {
if (closing) return;
setGuard(null);
setError(null);
}
async function confirmExit() {
setClosing(true);
setError(null);
try {
await system.confirmAppExit();
} catch (e) {
setError(describeError(e));
} finally {
setClosing(false);
}
}
useEffect(() => {
if (!guard) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
cancel();
return;
}
if (e.key !== "Tab") return;
// Simple focus trap: wrap Tab/Shift+Tab within the dialog's focusable set.
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (!focusables || focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [guard != null, closing]);
if (!guard) return null;
const shown = guard.details.slice(0, MAX_DETAIL_LINES);
const remaining = guard.details.length - shown.length;
return (
// No `onClick` here: clicking outside the dialog is deliberately a no-op
// (carnet #83 — avoid an ambiguous accidental dismissal of a destructive
// confirmation).
<div
className="fixed inset-0 flex items-center justify-center bg-black/50 p-4"
style={{ zIndex: zIndex.toast }}
>
<div
ref={dialogRef}
role="alertdialog"
aria-modal="true"
aria-labelledby={TITLE_ID}
aria-describedby={DESC_ID}
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-[520px] min-w-[440px] flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl"
>
<h3 id={TITLE_ID} className="text-sm font-semibold text-content">
Du travail est encore en cours
</h3>
<p id={DESC_ID} className="text-sm text-content">
{bodyText(guard)}
</p>
<p className="text-sm text-muted">
Annulez la fermeture pour laisser les agents et les tâches se terminer.
</p>
{shown.length > 0 && (
<ul className="flex flex-col gap-1 rounded-md bg-canvas p-2 text-xs text-muted">
{shown.map((detail, i) => (
<li key={i}>{`${detailLine(detail)}`}</li>
))}
{remaining > 0 && <li>+ {remaining} autre{remaining > 1 ? "s" : ""}</li>}
</ul>
)}
{error && (
<p role="alert" className="text-sm text-danger">
IdeA n'a pas pu quitter correctement. Réessayez ou consultez les logs.
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button ref={cancelRef} size="sm" variant="ghost" disabled={closing} onClick={cancel}>
Annuler
</Button>
<Button
size="sm"
variant="danger"
disabled={closing}
loading={closing}
onClick={() => void confirmExit()}
>
{closing ? "Fermeture…" : "Quitter quand même"}
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,6 @@
/**
* App-exit confirmation feature (ticket #83) — the "work in progress" quit
* guard popup.
*/
export { AppExitConfirmDialog } from "./AppExitConfirmDialog";