diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index e3474bf..cb9d670 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -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 { + // 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 { + return unsupportedOnWeb("Confirming an app exit"); + } } export class HttpTerminalGateway implements TerminalGateway { diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index b03a692..9af7c96 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -7,6 +7,7 @@ import type { Agent, AgentDrift, + AppExitWorkGuardState, AgentProfile, DiagnosticWarning, DomainEvent, @@ -182,6 +183,28 @@ export class MockSystemGateway implements SystemGateway { async pickFolder(): Promise { 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 { + 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 { + this.confirmAppExitCallCount += 1; + } } /** diff --git a/frontend/src/adapters/system.ts b/frontend/src/adapters/system.ts index 53de458..df9da29 100644 --- a/frontend/src/adapters/system.ts +++ b/frontend/src/adapters/system.ts @@ -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 { // 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 { + const unlisten = await listen(APP_EXIT_WORK_GUARD, (e) => { + handler(e.payload); + }); + return unlisten; + } + + async confirmAppExit(): Promise { + await invoke("confirm_app_exit"); + } } diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 1dfc335..4b42458 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -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() { )} + ); } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index cb2c15d..5660abd 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -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). diff --git a/frontend/src/features/appExit/AppExitConfirmDialog.test.tsx b/frontend/src/features/appExit/AppExitConfirmDialog.test.tsx new file mode 100644 index 0000000..88e228f --- /dev/null +++ b/frontend/src/features/appExit/AppExitConfirmDialog.test.tsx @@ -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( + + + , + ); + 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 { + 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(); + }); +}); diff --git a/frontend/src/features/appExit/AppExitConfirmDialog.tsx b/frontend/src/features/appExit/AppExitConfirmDialog.tsx new file mode 100644 index 0000000..18b73d7 --- /dev/null +++ b/frontend/src/features/appExit/AppExitConfirmDialog.tsx @@ -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(null); + const [closing, setClosing] = useState(false); + const [error, setError] = useState(null); + const cancelRef = useRef(null); + const dialogRef = useRef(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( + '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). +
+
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" + > +

+ Du travail est encore en cours +

+

+ {bodyText(guard)} +

+

+ Annulez la fermeture pour laisser les agents et les tâches se terminer. +

+ + {shown.length > 0 && ( +
    + {shown.map((detail, i) => ( +
  • {`• ${detailLine(detail)}`}
  • + ))} + {remaining > 0 &&
  • + {remaining} autre{remaining > 1 ? "s" : ""}
  • } +
+ )} + + {error && ( +

+ IdeA n'a pas pu quitter correctement. Réessayez ou consultez les logs. +

+ )} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/appExit/index.ts b/frontend/src/features/appExit/index.ts new file mode 100644 index 0000000..5fd41b0 --- /dev/null +++ b/frontend/src/features/appExit/index.ts @@ -0,0 +1,6 @@ +/** + * App-exit confirmation feature (ticket #83) — the "work in progress" quit + * guard popup. + */ + +export { AppExitConfirmDialog } from "./AppExitConfirmDialog"; diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 6c617bd..c4b822d 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -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; + /** + * 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; + /** + * Bypasses the guard once and requests the main window to close for real + * (ticket #83) — the user chose "Quitter quand même". + */ + confirmAppExit(): Promise; } /** Input for {@link AgentGateway.createAgent}. */