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:
@ -21,6 +21,7 @@
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
AppExitWorkGuardState,
|
||||
DomainEvent,
|
||||
HealthReport,
|
||||
ReplyChunk,
|
||||
@ -114,6 +115,19 @@ export class HttpSystemGateway implements SystemGateway {
|
||||
// folder browser would be its own lot (flagged in the F1 report).
|
||||
return unsupportedOnWeb("Native folder picker");
|
||||
}
|
||||
|
||||
onAppExitWorkGuard(
|
||||
_handler: (state: AppExitWorkGuardState) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
// Desktop-only (ticket #83): there is no interceptable native window to
|
||||
// guard on the web client, so this never fires — an inert unsubscribe,
|
||||
// not a rejection, so callers can subscribe unconditionally.
|
||||
return Promise.resolve(() => {});
|
||||
}
|
||||
|
||||
confirmAppExit(): Promise<void> {
|
||||
return unsupportedOnWeb("Confirming an app exit");
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpTerminalGateway implements TerminalGateway {
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
import type {
|
||||
Agent,
|
||||
AgentDrift,
|
||||
AppExitWorkGuardState,
|
||||
AgentProfile,
|
||||
DiagnosticWarning,
|
||||
DomainEvent,
|
||||
@ -182,6 +183,28 @@ export class MockSystemGateway implements SystemGateway {
|
||||
async pickFolder(): Promise<string | null> {
|
||||
return "/home/user/mock-project";
|
||||
}
|
||||
|
||||
private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>();
|
||||
/** Count of `confirmAppExit()` calls, for test assertions. */
|
||||
confirmAppExitCallCount = 0;
|
||||
|
||||
async onAppExitWorkGuard(
|
||||
handler: (state: AppExitWorkGuardState) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
this.exitGuardListeners.add(handler);
|
||||
return () => {
|
||||
this.exitGuardListeners.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test/dev helper to push an app-exit work guard state to all subscribers. */
|
||||
emitAppExitWorkGuard(state: AppExitWorkGuardState): void {
|
||||
for (const l of this.exitGuardListeners) l(state);
|
||||
}
|
||||
|
||||
async confirmAppExit(): Promise<void> {
|
||||
this.confirmAppExitCallCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -8,12 +8,15 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import type { DomainEvent, HealthReport, Unsubscribe } from "@/domain";
|
||||
import type { AppExitWorkGuardState, DomainEvent, HealthReport, Unsubscribe } from "@/domain";
|
||||
import type { SystemGateway } from "@/ports";
|
||||
|
||||
/** Tauri event name carrying relayed domain events (mirror of `DOMAIN_EVENT`). */
|
||||
const DOMAIN_EVENT = "domain://event";
|
||||
|
||||
/** Tauri event name carrying the app-exit work guard state (ticket #83). */
|
||||
const APP_EXIT_WORK_GUARD = "app-exit-work-guard";
|
||||
|
||||
export class TauriSystemGateway implements SystemGateway {
|
||||
async health(note?: string): Promise<HealthReport> {
|
||||
// The backend command takes an optional `request: { note }` (camelCase).
|
||||
@ -37,4 +40,17 @@ export class TauriSystemGateway implements SystemGateway {
|
||||
// `open` with `multiple: false` returns a string when a path is chosen.
|
||||
return typeof result === "string" ? result : null;
|
||||
}
|
||||
|
||||
async onAppExitWorkGuard(
|
||||
handler: (state: AppExitWorkGuardState) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
const unlisten = await listen<AppExitWorkGuardState>(APP_EXIT_WORK_GUARD, (e) => {
|
||||
handler(e.payload);
|
||||
});
|
||||
return unlisten;
|
||||
}
|
||||
|
||||
async confirmAppExit(): Promise<void> {
|
||||
await invoke("confirm_app_exit");
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import type { DomainEvent, HealthReport } from "@/domain";
|
||||
import { ProjectsView } from "@/features/projects";
|
||||
import { FirstRunWizard } from "@/features/first-run";
|
||||
import { AnnouncementsProvider } from "@/features/announcements";
|
||||
import { AppExitConfirmDialog } from "@/features/appExit";
|
||||
import { Panel, Spinner, Toolbar } from "@/shared";
|
||||
import { useGateways, shouldUseMock } from "./di";
|
||||
|
||||
@ -114,6 +115,7 @@ export function App() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AppExitConfirmDialog />
|
||||
</AnnouncementsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -13,6 +13,43 @@ export interface HealthReport {
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App-exit work guard (ticket #83) — confirmation before quitting IdeA while
|
||||
// agents/background tasks are active. Mirrors the backend
|
||||
// `AppExitWorkGuardStateDto` (main window only; detached windows don't carry
|
||||
// this guard).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** One active work item contributing to {@link AppExitWorkGuardState}. */
|
||||
export type AppExitWorkGuardDetail =
|
||||
| {
|
||||
kind: "busyAgent";
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
ticketId: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "activeBackgroundTask";
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
taskId: string;
|
||||
state: string;
|
||||
taskKind: string;
|
||||
};
|
||||
|
||||
/** App-wide shutdown guard read model, carried by the `app-exit-work-guard` event. */
|
||||
export interface AppExitWorkGuardState {
|
||||
hasWorkInProgress: boolean;
|
||||
busyAgentCount: number;
|
||||
activeBackgroundTaskCount: number;
|
||||
totalWorkCount: number;
|
||||
details: AppExitWorkGuardDetail[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle status of a local model server during an agent launch (F35, mirror
|
||||
* of the backend `ModelServerStatusDto`, tagged on `state`, camelCase wire).
|
||||
|
||||
229
frontend/src/features/appExit/AppExitConfirmDialog.test.tsx
Normal file
229
frontend/src/features/appExit/AppExitConfirmDialog.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
222
frontend/src/features/appExit/AppExitConfirmDialog.tsx
Normal file
222
frontend/src/features/appExit/AppExitConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
frontend/src/features/appExit/index.ts
Normal file
6
frontend/src/features/appExit/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* App-exit confirmation feature (ticket #83) — the "work in progress" quit
|
||||
* guard popup.
|
||||
*/
|
||||
|
||||
export { AppExitConfirmDialog } from "./AppExitConfirmDialog";
|
||||
@ -12,6 +12,7 @@ import type {
|
||||
Agent,
|
||||
AgentDrift,
|
||||
AgentProfile,
|
||||
AppExitWorkGuardState,
|
||||
DomainEvent,
|
||||
EmbedderEngines,
|
||||
EmbedderProfile,
|
||||
@ -76,6 +77,20 @@ export interface SystemGateway {
|
||||
* sites go through this port; the Tauri plugin is only imported in the adapter.
|
||||
*/
|
||||
pickFolder(): Promise<string | null>;
|
||||
/**
|
||||
* Subscribes to the app-exit work-in-progress guard (ticket #83): fired when
|
||||
* closing the main window is intercepted because it would interrupt active
|
||||
* agents/background tasks. Desktop-only — the web transport returns an inert
|
||||
* unsubscribe (never fires; there is no interceptable window to guard).
|
||||
*/
|
||||
onAppExitWorkGuard(
|
||||
handler: (state: AppExitWorkGuardState) => void,
|
||||
): Promise<Unsubscribe>;
|
||||
/**
|
||||
* Bypasses the guard once and requests the main window to close for real
|
||||
* (ticket #83) — the user chose "Quitter quand même".
|
||||
*/
|
||||
confirmAppExit(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Input for {@link AgentGateway.createAgent}. */
|
||||
|
||||
Reference in New Issue
Block a user