diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index d8ed44e..d8581c8 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -36,32 +36,32 @@ use crate::dto::{ parse_layout_id, parse_memory_slug, parse_model_server_id, parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id, parse_task_id, parse_template_id, parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto, - AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, - BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, - CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, - CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, - CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, - CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, - DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, - EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, - ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, - GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, - GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, - InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, - LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, - ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, - PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, - ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto, - ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, - ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, - ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, - SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto, - SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, - StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, - SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, - TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, - UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, - UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, + AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto, + ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto, + ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, + CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, + CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, + DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, + DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto, + EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, + FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, + GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, + HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto, + LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, + MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, + ModelServerConfigListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, + ProfileListDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, + ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, + ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, + RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, + ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, + SaveModelServerRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, + SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, + StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, + TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, + UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto, + UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, }; @@ -1437,6 +1437,39 @@ pub async fn get_project_work_state( .map_err(ErrorDto::from) } +/// `get_app_exit_work_guard_state` — aggregate active work across all open projects. +/// +/// # Errors +/// Returns an [`ErrorDto`] if an open project or its work-state read model cannot be read. +#[tauri::command] +pub async fn get_app_exit_work_guard_state( + app: AppHandle, +) -> Result { + crate::read_app_exit_work_guard_state(&app) + .await + .map(AppExitWorkGuardStateDto::from) + .map_err(ErrorDto::from) +} + +/// `confirm_app_exit` — bypass the close guard once and request main-window shutdown. +/// +/// # Errors +/// Returns an [`ErrorDto`] if the main window cannot be closed programmatically. +#[tauri::command] +pub async fn confirm_app_exit(app: AppHandle) -> Result<(), ErrorDto> { + crate::confirm_next_main_window_close(); + if let Some(window) = app.get_webview_window("main") { + window.close().map_err(|err| ErrorDto { + code: "INTERNAL".to_owned(), + message: format!("failed to close main window: {err}"), + })?; + } else { + crate::shutdown_app_after_confirm(&app); + app.exit(0); + } + Ok(()) +} + /// `read_conversation_page` — human, paginated read of a conversation's **full** /// transcript (lot LS6). Archive-aware (segments + active), text never truncated. /// diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index b97428a..3c77c80 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -29,19 +29,62 @@ pub mod templates; pub mod tickets; use std::process::ExitCode; +use std::sync::atomic::{AtomicBool, Ordering}; -use application::SnapshotOpenWindowsInput; +use application::{AppError, GetAppExitWorkGuardStateInput, SnapshotOpenWindowsInput}; use domain::{ PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize, PersistedWindowState, ProjectId, }; use tauri::{ - Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder, + Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, + WebviewWindowBuilder, }; use uuid::Uuid; use state::AppState; +static EXIT_GUARD_CONFIRMED: AtomicBool = AtomicBool::new(false); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MainCloseAction { + AllowShutdown, + PreventAndNotify, +} + +fn decide_main_close_action( + has_work_in_progress: bool, + already_confirmed: bool, +) -> MainCloseAction { + if has_work_in_progress && !already_confirmed { + MainCloseAction::PreventAndNotify + } else { + MainCloseAction::AllowShutdown + } +} + +fn should_install_exit_guard(window_label: &str) -> bool { + window_label == "main" +} + +fn apply_main_close_decision( + guard: application::AppExitWorkGuardState, + already_confirmed: bool, + mut prevent_close: impl FnMut(), + mut emit_guard: impl FnMut(application::AppExitWorkGuardState), + mut shutdown: impl FnMut(), +) -> MainCloseAction { + let action = decide_main_close_action(guard.has_work_in_progress, already_confirmed); + match action { + MainCloseAction::PreventAndNotify => { + prevent_close(); + emit_guard(guard); + } + MainCloseAction::AllowShutdown => shutdown(), + } + action +} + /// The `argv[1]` subcommand token that switches the binary into the headless /// `mcp-server` **bridge** mode (cadrage v5 §1.3) instead of launching Tauri. pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server"; @@ -115,48 +158,35 @@ pub fn run() { // independent of the per-view (navigation/layout) lifecycle — those // must NEVER kill a PTY — and only fires on a genuine app shutdown. // A brutal crash is best-effort and out of scope. - if let Some(window) = app.get_webview_window("main") { + if should_install_exit_guard("main") && app.get_webview_window("main").is_some() { + let window = app + .get_webview_window("main") + .expect("main window existence checked above"); let handle = app.handle().clone(); window.on_window_event(move |event| { - if let tauri::WindowEvent::CloseRequested { .. } = event { - if let Some(state) = handle.try_state::() { - let open_windows = snapshot_open_webview_windows(&handle); - let window_snapshot = - std::sync::Arc::clone(&state.snapshot_open_windows); - let pty = std::sync::Arc::clone(&state.pty_port); - // ORDER IS CRITICAL: freeze `agent_was_running` on every - // agent leaf of every open project FIRST, reading the live - // PTY registry as it stands now; only THEN kill the PTYs. - // If we killed first, the registry would be empty and every - // agent would be persisted as "closed". - let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents); - let model_servers = - std::sync::Arc::clone(&state.ensure_local_model_server); - let embedded_server = std::sync::Arc::clone(&state.embedded_server); - let open_projects = state.open_project_ids(); - let handles = state.terminal_sessions.handles(); - tauri::async_runtime::block_on(async move { - let _ = window_snapshot - .execute(SnapshotOpenWindowsInput { - windows: open_windows, - }) - .await; - for project_id in open_projects { - let _ = snapshot - .execute(application::SnapshotRunningAgentsInput { - project_id, - }) - .await; - } - for h in handles { - let _ = pty.kill(&h).await; - } - let _ = model_servers.stop_on_app_exit().await; - let _ = embedded_server.stop().await; - }); + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + let already_confirmed = consume_exit_guard_confirmation(); + let guard = + tauri::async_runtime::block_on(app_exit_work_guard_state(&handle)); + if let Ok(guard) = guard { + let action = apply_main_close_decision( + guard, + already_confirmed, + || api.prevent_close(), + |guard| { + let _ = handle.emit( + "app-exit-work-guard", + backend::dto::AppExitWorkGuardStateDto::from(guard), + ); + }, + || shutdown_app_after_confirm(&handle), + ); + if action == MainCloseAction::PreventAndNotify { + return; + } + } else { + shutdown_app_after_confirm(&handle); } - - close_non_main_webview_windows(&handle); } }); } @@ -211,6 +241,8 @@ pub fn run() { commands::dismiss_embedder_suggestion, commands::create_agent, commands::list_agents, + commands::get_app_exit_work_guard_state, + commands::confirm_app_exit, tickets::ticket_create, tickets::ticket_read, tickets::ticket_delete, @@ -307,6 +339,79 @@ pub fn run() { .expect("error while running IdeA Tauri application"); } +async fn app_exit_work_guard_state( + handle: &tauri::AppHandle, +) -> Result { + let Some(state) = handle.try_state::() else { + return Ok(application::AppExitWorkGuardState { + has_work_in_progress: false, + busy_agent_count: 0, + active_background_task_count: 0, + details: Vec::new(), + }); + }; + + let mut projects = Vec::new(); + for project_id in state.open_project_ids() { + projects.push(state.project_store.load_project(project_id).await?); + } + state + .get_app_exit_work_guard_state + .execute(GetAppExitWorkGuardStateInput { projects }) + .await +} + +/// Executes the global shutdown teardown after the close guard has allowed exit. +/// +/// The order intentionally mirrors the historical inline `CloseRequested` hook: +/// snapshot windows, snapshot `agent_was_running`, kill PTYs, stop model servers, +/// stop the embedded server, then close secondary webview windows. +pub fn shutdown_app_after_confirm(handle: &tauri::AppHandle) { + if let Some(state) = handle.try_state::() { + let open_windows = snapshot_open_webview_windows(handle); + let window_snapshot = std::sync::Arc::clone(&state.snapshot_open_windows); + let pty = std::sync::Arc::clone(&state.pty_port); + let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents); + let model_servers = std::sync::Arc::clone(&state.ensure_local_model_server); + let embedded_server = std::sync::Arc::clone(&state.embedded_server); + let open_projects = state.open_project_ids(); + let handles = state.terminal_sessions.handles(); + tauri::async_runtime::block_on(async move { + let _ = window_snapshot + .execute(SnapshotOpenWindowsInput { + windows: open_windows, + }) + .await; + for project_id in open_projects { + let _ = snapshot + .execute(application::SnapshotRunningAgentsInput { project_id }) + .await; + } + for h in handles { + let _ = pty.kill(&h).await; + } + let _ = model_servers.stop_on_app_exit().await; + let _ = embedded_server.stop().await; + }); + } + + close_non_main_webview_windows(handle); +} + +pub(crate) fn confirm_next_main_window_close() { + EXIT_GUARD_CONFIRMED.store(true, Ordering::SeqCst); +} + +fn consume_exit_guard_confirmation() -> bool { + EXIT_GUARD_CONFIRMED.swap(false, Ordering::SeqCst) +} + +pub(crate) async fn read_app_exit_work_guard_state( + handle: &tauri::AppHandle, +) -> Result { + app_exit_work_guard_state(handle).await +} + fn close_non_main_webview_windows(handle: &tauri::AppHandle) { for (label, window) in handle.webview_windows() { if !should_close_with_main_window(&label) { @@ -532,8 +637,114 @@ fn persisted_monitor_is_available( #[cfg(test)] mod tests { - use super::{persisted_view_identity_from_label, persisted_window_identity}; + use super::{ + apply_main_close_decision, confirm_next_main_window_close, consume_exit_guard_confirmation, + decide_main_close_action, persisted_view_identity_from_label, persisted_window_identity, + should_install_exit_guard, MainCloseAction, + }; use super::{should_close_with_main_window, PersistedWindowKind}; + use application::AppExitWorkGuardState; + use std::cell::Cell; + + #[test] + fn main_close_without_work_allows_shutdown_without_preventing_close() { + assert_eq!( + decide_main_close_action(false, false), + MainCloseAction::AllowShutdown + ); + } + + #[test] + fn main_close_with_work_prevents_and_notifies_before_shutdown() { + assert_eq!( + decide_main_close_action(true, false), + MainCloseAction::PreventAndNotify + ); + } + + #[test] + fn main_close_with_work_emits_guard_payload_and_skips_shutdown() { + let prevented = Cell::new(false); + let shutdown = Cell::new(false); + let emitted = Cell::new(None); + let guard = AppExitWorkGuardState { + has_work_in_progress: true, + busy_agent_count: 2, + active_background_task_count: 1, + details: Vec::new(), + }; + + let action = apply_main_close_decision( + guard, + false, + || prevented.set(true), + |payload| { + emitted.set(Some(( + payload.busy_agent_count, + payload.active_background_task_count, + ))) + }, + || shutdown.set(true), + ); + + assert_eq!(action, MainCloseAction::PreventAndNotify); + assert!(prevented.get()); + assert_eq!(emitted.get(), Some((2, 1))); + assert!(!shutdown.get()); + } + + #[test] + fn main_close_without_work_runs_shutdown_without_prevent_or_emit() { + let prevented = Cell::new(false); + let shutdown = Cell::new(false); + let emitted = Cell::new(false); + let guard = AppExitWorkGuardState { + has_work_in_progress: false, + busy_agent_count: 0, + active_background_task_count: 0, + details: Vec::new(), + }; + + let action = apply_main_close_decision( + guard, + false, + || prevented.set(true), + |_| emitted.set(true), + || shutdown.set(true), + ); + + assert_eq!(action, MainCloseAction::AllowShutdown); + assert!(!prevented.get()); + assert!(!emitted.get()); + assert!(shutdown.get()); + } + + #[test] + fn confirmed_main_close_bypasses_guard_once_then_rearms() { + confirm_next_main_window_close(); + let first_attempt_confirmed = consume_exit_guard_confirmation(); + assert!(first_attempt_confirmed); + assert_eq!( + decide_main_close_action(true, first_attempt_confirmed), + MainCloseAction::AllowShutdown + ); + + let second_attempt_confirmed = consume_exit_guard_confirmation(); + assert!(!second_attempt_confirmed); + assert_eq!( + decide_main_close_action(true, second_attempt_confirmed), + MainCloseAction::PreventAndNotify + ); + } + + #[test] + fn exit_guard_is_scoped_to_main_window_only() { + assert!(should_install_exit_guard("main")); + assert!(!should_install_exit_guard( + "view-work-00000000-0000-0000-0000-000000000001" + )); + assert!(!should_install_exit_guard("settings")); + } #[test] fn main_window_close_does_not_target_main_again() { diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 820bbc4..53c0eed 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -183,11 +183,13 @@ pub use window::{ RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput, }; pub use workstate::{ - AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AttachLiveAgent, - AttachLiveAgentInput, AttachLiveAgentOutput, BackgroundTaskKindLabel, ConversationLogProvider, - ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary, - GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput, LeanLiveEntry, LeanLiveState, - LiveWorkSession, ProjectWorkState, ReconcileLiveState, ReconcileLiveStateInput, StopLiveAgent, - StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, UpdateLiveState, - UpdateLiveStateInput, LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS, + AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AppExitWorkGuardDetail, + AppExitWorkGuardState, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, + BackgroundTaskKindLabel, ConversationLogProvider, ConversationPreviewStatus, + ConversationTurnWorkPreview, ConversationWorkSummary, GetAppExitWorkGuardState, + GetAppExitWorkGuardStateInput, GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput, + LeanLiveEntry, LeanLiveState, LiveWorkSession, ProjectWorkState, ReconcileLiveState, + ReconcileLiveStateInput, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput, + TicketWorkSource, TicketWorkStatus, UpdateLiveState, UpdateLiveStateInput, + LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS, }; diff --git a/crates/application/src/workstate/mod.rs b/crates/application/src/workstate/mod.rs index 2e5071d..1daab65 100644 --- a/crates/application/src/workstate/mod.rs +++ b/crates/application/src/workstate/mod.rs @@ -72,6 +72,61 @@ pub struct ProjectWorkState { pub conversations: Vec, } +/// Input for [`GetAppExitWorkGuardState::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GetAppExitWorkGuardStateInput { + /// Projects currently open in the application. + pub projects: Vec, +} + +/// Application-wide shutdown guard summary for the close-confirmation UX. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppExitWorkGuardState { + /// Whether at least one active work item would be interrupted by app exit. + pub has_work_in_progress: bool, + /// Number of busy agents across all open projects. + pub busy_agent_count: usize, + /// Number of non-terminal background tasks across all open projects. + pub active_background_task_count: usize, + /// Best-effort detail for compact UX display. + pub details: Vec, +} + +/// One work item contributing to [`AppExitWorkGuardState`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppExitWorkGuardDetail { + /// A manifest agent is currently processing a turn. + BusyAgent { + /// Owning project id. + project_id: domain::ProjectId, + /// Owning project display name. + project_name: String, + /// Agent id. + agent_id: AgentId, + /// Agent display name. + agent_name: String, + /// Busy ticket, if carried by the mediator state. + ticket_id: Option, + }, + /// A first-class background task is queued, running or waiting. + ActiveBackgroundTask { + /// Owning project id. + project_id: domain::ProjectId, + /// Owning project display name. + project_name: String, + /// Owning agent id. + agent_id: AgentId, + /// Owning agent display name. + agent_name: String, + /// Stable task id. + task_id: TaskId, + /// Lifecycle state. + state: BackgroundTaskState, + /// Kind discriminant. + kind: BackgroundTaskKindLabel, + }, +} + /// Best-effort, read-only summary of one conversation visible through the tickets. /// /// Derived live from the [`HandoffStore`] (primary source) with a bounded @@ -276,6 +331,80 @@ pub struct GetProjectWorkState { background_tasks: Option>, } +/// Read-only use case aggregating app-wide work that should guard application exit. +pub struct GetAppExitWorkGuardState { + work_state: Arc, +} + +impl GetAppExitWorkGuardState { + /// Builds the app-exit guard from the existing per-project work-state read model. + #[must_use] + pub fn new(work_state: Arc) -> Self { + Self { work_state } + } + + /// Executes the guard aggregation across all currently open projects. + /// + /// # Errors + /// Propagates the per-project work-state read errors. + pub async fn execute( + &self, + input: GetAppExitWorkGuardStateInput, + ) -> Result { + let mut busy_agent_count = 0; + let mut active_background_task_count = 0; + let mut details = Vec::new(); + + for project in input.projects { + let project_id = project.id; + let project_name = project.name.clone(); + let state = self + .work_state + .execute(GetProjectWorkStateInput { + project: project.clone(), + }) + .await?; + + for agent in state.agents { + if agent.busy.is_busy() { + busy_agent_count += 1; + details.push(AppExitWorkGuardDetail::BusyAgent { + project_id, + project_name: project_name.clone(), + agent_id: agent.agent_id, + agent_name: agent.name.clone(), + ticket_id: agent.busy.ticket(), + }); + } + + for task in agent + .background_tasks + .into_iter() + .filter(|task| !task.state.is_terminal()) + { + active_background_task_count += 1; + details.push(AppExitWorkGuardDetail::ActiveBackgroundTask { + project_id, + project_name: project_name.clone(), + agent_id: agent.agent_id, + agent_name: agent.name.clone(), + task_id: task.task_id, + state: task.state, + kind: task.kind, + }); + } + } + } + + Ok(AppExitWorkGuardState { + has_work_in_progress: busy_agent_count > 0 || active_background_task_count > 0, + busy_agent_count, + active_background_task_count, + details, + }) + } +} + impl GetProjectWorkState { /// Builds the read-model use case from existing stores/registries. /// diff --git a/crates/application/tests/workstate.rs b/crates/application/tests/workstate.rs index a153123..5bf09b4 100644 --- a/crates/application/tests/workstate.rs +++ b/crates/application/tests/workstate.rs @@ -8,7 +8,8 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use application::{ - ConversationLogProvider, ConversationPreviewStatus, GetProjectWorkState, + AppExitWorkGuardDetail, ConversationLogProvider, ConversationPreviewStatus, + GetAppExitWorkGuardState, GetAppExitWorkGuardStateInput, GetProjectWorkState, GetProjectWorkStateInput, HandoffProvider, LiveSessionKind, LiveSessions, StructuredSessions, TerminalSessions, TicketWorkSource, TicketWorkStatus, }; @@ -559,9 +560,12 @@ fn background_task( .unwrap(); match state { BackgroundTaskState::Queued => base, - BackgroundTaskState::Running | BackgroundTaskState::Waiting => { - base.transition(state, created_at_ms + 10).unwrap() - } + BackgroundTaskState::Running => base.transition(state, created_at_ms + 10).unwrap(), + BackgroundTaskState::Waiting => base + .transition(BackgroundTaskState::Running, created_at_ms + 10) + .unwrap() + .transition(BackgroundTaskState::Waiting, created_at_ms + 20) + .unwrap(), BackgroundTaskState::Completed | BackgroundTaskState::Failed | BackgroundTaskState::Cancelled @@ -695,6 +699,162 @@ async fn workstate_attaches_live_structured_session_to_manifest_agent() { assert_eq!(live.kind, LiveSessionKind::Structured); } +#[tokio::test] +async fn app_exit_guard_is_false_without_busy_agent_or_active_background_task() { + let a = agent(10, "alpha"); + let f = fixture(std::slice::from_ref(&a)); + insert_pty(&f.pty, sid(1), a.id, nid(100)); + + let guard = GetAppExitWorkGuardState::new(Arc::new(f.usecase)); + let out = guard + .execute(GetAppExitWorkGuardStateInput { + projects: vec![f.project], + }) + .await + .unwrap(); + + assert!(!out.has_work_in_progress); + assert_eq!(out.busy_agent_count, 0); + assert_eq!(out.active_background_task_count, 0); + assert!(out.details.is_empty()); +} + +#[tokio::test] +async fn app_exit_guard_is_true_with_busy_agent() { + let a = agent(10, "alpha"); + let f = fixture(std::slice::from_ref(&a)); + f.input.set_busy( + a.id, + AgentBusyState::Busy { + ticket: ticket_id(77), + since_ms: 1_700_000_000_100, + }, + ); + + let guard = GetAppExitWorkGuardState::new(Arc::new(f.usecase)); + let out = guard + .execute(GetAppExitWorkGuardStateInput { + projects: vec![f.project.clone()], + }) + .await + .unwrap(); + + assert!(out.has_work_in_progress); + assert_eq!(out.busy_agent_count, 1); + assert_eq!(out.active_background_task_count, 0); + assert_eq!( + out.details, + vec![AppExitWorkGuardDetail::BusyAgent { + project_id: f.project.id, + project_name: "demo".to_owned(), + agent_id: a.id, + agent_name: "alpha".to_owned(), + ticket_id: Some(ticket_id(77)), + }] + ); +} + +#[tokio::test] +async fn app_exit_guard_is_true_with_non_terminal_background_tasks() { + let a = agent(10, "alpha"); + let f = background_fixture(std::slice::from_ref(&a)); + f.store.set_tasks(vec![ + background_task( + 1, + f.project.id, + a.id, + 1_700_000_000_000, + BackgroundTaskState::Queued, + false, + ), + background_task( + 2, + f.project.id, + a.id, + 1_700_000_000_100, + BackgroundTaskState::Running, + false, + ), + background_task( + 3, + f.project.id, + a.id, + 1_700_000_000_200, + BackgroundTaskState::Waiting, + false, + ), + ]); + + let guard = GetAppExitWorkGuardState::new(Arc::new(f.usecase)); + let out = guard + .execute(GetAppExitWorkGuardStateInput { + projects: vec![f.project.clone()], + }) + .await + .unwrap(); + + assert!(out.has_work_in_progress); + assert_eq!(out.busy_agent_count, 0); + assert_eq!(out.active_background_task_count, 3); + assert!(out + .details + .iter() + .all(|detail| matches!(detail, AppExitWorkGuardDetail::ActiveBackgroundTask { .. }))); +} + +#[tokio::test] +async fn app_exit_guard_ignores_terminal_background_tasks() { + let a = agent(10, "alpha"); + let f = background_fixture(std::slice::from_ref(&a)); + f.store.set_tasks(vec![ + background_task( + 1, + f.project.id, + a.id, + 1_700_000_000_000, + BackgroundTaskState::Completed, + false, + ), + background_task( + 2, + f.project.id, + a.id, + 1_700_000_000_100, + BackgroundTaskState::Failed, + false, + ), + background_task( + 3, + f.project.id, + a.id, + 1_700_000_000_200, + BackgroundTaskState::Cancelled, + false, + ), + background_task( + 4, + f.project.id, + a.id, + 1_700_000_000_300, + BackgroundTaskState::Expired, + false, + ), + ]); + + let guard = GetAppExitWorkGuardState::new(Arc::new(f.usecase)); + let out = guard + .execute(GetAppExitWorkGuardStateInput { + projects: vec![f.project], + }) + .await + .unwrap(); + + assert!(!out.has_work_in_progress); + assert_eq!(out.busy_agent_count, 0); + assert_eq!(out.active_background_task_count, 0); + assert!(out.details.is_empty()); +} + #[tokio::test] async fn workstate_includes_busy_state_from_input_mediator() { let a = agent(10, "alpha"); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index d166d16..c438e1e 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -9,12 +9,12 @@ use serde::{Deserialize, Serialize}; use application::{ - AgentBackgroundTaskState, AgentTicketState, AppError, AttachLiveAgentOutput, - BackgroundTaskKindLabel, ConversationPreviewStatus, ConversationTurnWorkPreview, - ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, - HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, - OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, - TurnPage, TurnSource, TurnView, + AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail, + AppExitWorkGuardState, AttachLiveAgentOutput, BackgroundTaskKindLabel, + ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary, + CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport, LayoutKind, + ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, + StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView, }; use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole}; @@ -2275,6 +2275,111 @@ impl From for ProjectWorkStateDto { } } +/// App-wide shutdown guard read model for the exit confirmation flow. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppExitWorkGuardStateDto { + /// Whether at least one active work item would be interrupted by app exit. + pub has_work_in_progress: bool, + /// Number of busy agents across all open projects. + pub busy_agent_count: usize, + /// Number of non-terminal background tasks across all open projects. + pub active_background_task_count: usize, + /// Total active work items. + pub total_work_count: usize, + /// Best-effort compact details for the confirmation dialog. + pub details: Vec, +} + +impl From for AppExitWorkGuardStateDto { + fn from(state: AppExitWorkGuardState) -> Self { + Self { + has_work_in_progress: state.has_work_in_progress, + busy_agent_count: state.busy_agent_count, + active_background_task_count: state.active_background_task_count, + total_work_count: state.busy_agent_count + state.active_background_task_count, + details: state + .details + .into_iter() + .map(AppExitWorkGuardDetailDto::from) + .collect(), + } + } +} + +/// One active work item contributing to [`AppExitWorkGuardStateDto`]. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum AppExitWorkGuardDetailDto { + /// A manifest agent is currently processing a turn. + BusyAgent { + /// Owning project id. + project_id: String, + /// Owning project display name. + project_name: String, + /// Agent id. + agent_id: String, + /// Agent display name. + agent_name: String, + /// Busy ticket id, when available. + ticket_id: Option, + }, + /// A first-class background task is queued, running or waiting. + ActiveBackgroundTask { + /// Owning project id. + project_id: String, + /// Owning project display name. + project_name: String, + /// Owning agent id. + agent_id: String, + /// Owning agent display name. + agent_name: String, + /// Stable task id. + task_id: String, + /// Lifecycle state. + state: String, + /// Kind discriminant. + task_kind: String, + }, +} + +impl From for AppExitWorkGuardDetailDto { + fn from(detail: AppExitWorkGuardDetail) -> Self { + match detail { + AppExitWorkGuardDetail::BusyAgent { + project_id, + project_name, + agent_id, + agent_name, + ticket_id, + } => Self::BusyAgent { + project_id: project_id.to_string(), + project_name, + agent_id: agent_id.to_string(), + agent_name, + ticket_id: ticket_id.map(|id| id.to_string()), + }, + AppExitWorkGuardDetail::ActiveBackgroundTask { + project_id, + project_name, + agent_id, + agent_name, + task_id, + state, + kind, + } => Self::ActiveBackgroundTask { + project_id: project_id.to_string(), + project_name, + agent_id: agent_id.to_string(), + agent_name, + task_id: task_id.to_string(), + state: background_state_label(state).to_owned(), + task_kind: background_kind_label_from_work_state(kind).to_owned(), + }, + } + } +} + /// Request DTO for `attach_live_agent`: bind an already-running agent session to /// a visible layout cell without spawning a new process. #[derive(Debug, Clone, Deserialize)] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index c4d9417..2c5e8a0 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -21,30 +21,30 @@ use application::{ CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, - EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, - GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, - GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent, - LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, - ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, ListProjects, - ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, - LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, - McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, - OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, - PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, - ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, - ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, - ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, - RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, - ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, - RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer, - SaveProfile, SessionLimitService, SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, - SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, - SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, - UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, - UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, - UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions, - UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, - WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, + GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, + GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, + InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, + ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, + ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, + LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, + LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, + MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, + OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, + ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, + ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, + ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, + ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, + RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, + ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice, + RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, + SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, + StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, + UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentMcpToolPermissions, + UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, + UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill, + UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ @@ -1020,6 +1020,8 @@ pub struct BackendCore { pub list_resumable_agents: Arc, /// Read-only live/busy state for the project's manifest agents. pub get_project_work_state: Arc, + /// App-wide work-in-progress guard used before confirmed application exit. + pub get_app_exit_work_guard_state: Arc, /// Human paginated read of a conversation's full transcript (lot LS6). pub read_conversation_page: Arc, /// Best-effort log rotation, triggered off the hot path at thread resume/open (lot LS6). @@ -2233,6 +2235,9 @@ impl BackendCore { ) .with_background_tasks(Arc::clone(&background_tasks_port)), ); + let get_app_exit_work_guard_state = Arc::new(GetAppExitWorkGuardState::new(Arc::clone( + &get_project_work_state, + ))); // Lot LS6 — rotation (hors chemin chaud) + lecture humaine paginée. Tous deux // composent le provider d'archive par root ; la rotation lit aussi le handoff // (plancher `up_to`, INV-LS6). Aucune persistance déclenchée par un `append`. @@ -2543,6 +2548,7 @@ impl BackendCore { change_agent_profile, list_resumable_agents, get_project_work_state, + get_app_exit_work_guard_state, read_conversation_page, rotate_conversation_log, attach_live_agent, 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}. */