Merge feature/ticket83-quit-confirm-work-in-progress into develop
Ticket #83 : popup de confirmation à la fermeture d'IdeA si travail en cours — guard backend (agents busy + tâches d'arrière-plan actives, GetAppExitWorkGuardState) + popup frontend. QA vert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -36,32 +36,32 @@ use crate::dto::{
|
|||||||
parse_layout_id, parse_memory_slug, parse_model_server_id, parse_node_id, parse_profile_id,
|
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_project_id, parse_session_id, parse_skill_id, parse_task_id, parse_template_id,
|
||||||
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
|
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
|
||||||
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachLiveAgentRequestDto,
|
||||||
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto,
|
||||||
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
||||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
||||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
|
||||||
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
|
||||||
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
||||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
||||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
||||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||||
ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto,
|
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
|
||||||
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
ModelServerConfigListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto,
|
||||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto,
|
ProfileListDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
|
ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||||
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
||||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
|
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||||
SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto,
|
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||||
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
|
SaveModelServerRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
||||||
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
|
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
||||||
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||||
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
|
||||||
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||||
UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||||
};
|
};
|
||||||
@ -1437,6 +1437,39 @@ pub async fn get_project_work_state(
|
|||||||
.map_err(ErrorDto::from)
|
.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<AppExitWorkGuardStateDto, ErrorDto> {
|
||||||
|
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**
|
/// `read_conversation_page` — human, paginated read of a conversation's **full**
|
||||||
/// transcript (lot LS6). Archive-aware (segments + active), text never truncated.
|
/// transcript (lot LS6). Archive-aware (segments + active), text never truncated.
|
||||||
///
|
///
|
||||||
|
|||||||
@ -29,19 +29,62 @@ pub mod templates;
|
|||||||
pub mod tickets;
|
pub mod tickets;
|
||||||
|
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use application::SnapshotOpenWindowsInput;
|
use application::{AppError, GetAppExitWorkGuardStateInput, SnapshotOpenWindowsInput};
|
||||||
use domain::{
|
use domain::{
|
||||||
PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize,
|
PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize,
|
||||||
PersistedWindowState, ProjectId,
|
PersistedWindowState, ProjectId,
|
||||||
};
|
};
|
||||||
use tauri::{
|
use tauri::{
|
||||||
Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder,
|
Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow,
|
||||||
|
WebviewWindowBuilder,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use state::AppState;
|
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
|
/// The `argv[1]` subcommand token that switches the binary into the headless
|
||||||
/// `mcp-server` **bridge** mode (cadrage v5 §1.3) instead of launching Tauri.
|
/// `mcp-server` **bridge** mode (cadrage v5 §1.3) instead of launching Tauri.
|
||||||
pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
|
pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
|
||||||
@ -115,48 +158,35 @@ pub fn run() {
|
|||||||
// independent of the per-view (navigation/layout) lifecycle — those
|
// independent of the per-view (navigation/layout) lifecycle — those
|
||||||
// must NEVER kill a PTY — and only fires on a genuine app shutdown.
|
// must NEVER kill a PTY — and only fires on a genuine app shutdown.
|
||||||
// A brutal crash is best-effort and out of scope.
|
// 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();
|
let handle = app.handle().clone();
|
||||||
window.on_window_event(move |event| {
|
window.on_window_event(move |event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
if let Some(state) = handle.try_state::<AppState>() {
|
let already_confirmed = consume_exit_guard_confirmation();
|
||||||
let open_windows = snapshot_open_webview_windows(&handle);
|
let guard =
|
||||||
let window_snapshot =
|
tauri::async_runtime::block_on(app_exit_work_guard_state(&handle));
|
||||||
std::sync::Arc::clone(&state.snapshot_open_windows);
|
if let Ok(guard) = guard {
|
||||||
let pty = std::sync::Arc::clone(&state.pty_port);
|
let action = apply_main_close_decision(
|
||||||
// ORDER IS CRITICAL: freeze `agent_was_running` on every
|
guard,
|
||||||
// agent leaf of every open project FIRST, reading the live
|
already_confirmed,
|
||||||
// PTY registry as it stands now; only THEN kill the PTYs.
|
|| api.prevent_close(),
|
||||||
// If we killed first, the registry would be empty and every
|
|guard| {
|
||||||
// agent would be persisted as "closed".
|
let _ = handle.emit(
|
||||||
let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents);
|
"app-exit-work-guard",
|
||||||
let model_servers =
|
backend::dto::AppExitWorkGuardStateDto::from(guard),
|
||||||
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();
|
|| shutdown_app_after_confirm(&handle),
|
||||||
let handles = state.terminal_sessions.handles();
|
);
|
||||||
tauri::async_runtime::block_on(async move {
|
if action == MainCloseAction::PreventAndNotify {
|
||||||
let _ = window_snapshot
|
return;
|
||||||
.execute(SnapshotOpenWindowsInput {
|
|
||||||
windows: open_windows,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
for project_id in open_projects {
|
|
||||||
let _ = snapshot
|
|
||||||
.execute(application::SnapshotRunningAgentsInput {
|
|
||||||
project_id,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
for h in handles {
|
} else {
|
||||||
let _ = pty.kill(&h).await;
|
shutdown_app_after_confirm(&handle);
|
||||||
}
|
}
|
||||||
let _ = model_servers.stop_on_app_exit().await;
|
|
||||||
let _ = embedded_server.stop().await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
close_non_main_webview_windows(&handle);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -211,6 +241,8 @@ pub fn run() {
|
|||||||
commands::dismiss_embedder_suggestion,
|
commands::dismiss_embedder_suggestion,
|
||||||
commands::create_agent,
|
commands::create_agent,
|
||||||
commands::list_agents,
|
commands::list_agents,
|
||||||
|
commands::get_app_exit_work_guard_state,
|
||||||
|
commands::confirm_app_exit,
|
||||||
tickets::ticket_create,
|
tickets::ticket_create,
|
||||||
tickets::ticket_read,
|
tickets::ticket_read,
|
||||||
tickets::ticket_delete,
|
tickets::ticket_delete,
|
||||||
@ -307,6 +339,79 @@ pub fn run() {
|
|||||||
.expect("error while running IdeA Tauri application");
|
.expect("error while running IdeA Tauri application");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn app_exit_work_guard_state(
|
||||||
|
handle: &tauri::AppHandle,
|
||||||
|
) -> Result<application::AppExitWorkGuardState, AppError> {
|
||||||
|
let Some(state) = handle.try_state::<AppState>() 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::<AppState>() {
|
||||||
|
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<application::AppExitWorkGuardState, AppError> {
|
||||||
|
app_exit_work_guard_state(handle).await
|
||||||
|
}
|
||||||
|
|
||||||
fn close_non_main_webview_windows(handle: &tauri::AppHandle) {
|
fn close_non_main_webview_windows(handle: &tauri::AppHandle) {
|
||||||
for (label, window) in handle.webview_windows() {
|
for (label, window) in handle.webview_windows() {
|
||||||
if !should_close_with_main_window(&label) {
|
if !should_close_with_main_window(&label) {
|
||||||
@ -532,8 +637,114 @@ fn persisted_monitor_is_available(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 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]
|
#[test]
|
||||||
fn main_window_close_does_not_target_main_again() {
|
fn main_window_close_does_not_target_main_again() {
|
||||||
|
|||||||
@ -183,11 +183,13 @@ pub use window::{
|
|||||||
RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput,
|
RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput,
|
||||||
};
|
};
|
||||||
pub use workstate::{
|
pub use workstate::{
|
||||||
AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AttachLiveAgent,
|
AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AppExitWorkGuardDetail,
|
||||||
AttachLiveAgentInput, AttachLiveAgentOutput, BackgroundTaskKindLabel, ConversationLogProvider,
|
AppExitWorkGuardState, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput,
|
||||||
ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary,
|
BackgroundTaskKindLabel, ConversationLogProvider, ConversationPreviewStatus,
|
||||||
GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput, LeanLiveEntry, LeanLiveState,
|
ConversationTurnWorkPreview, ConversationWorkSummary, GetAppExitWorkGuardState,
|
||||||
LiveWorkSession, ProjectWorkState, ReconcileLiveState, ReconcileLiveStateInput, StopLiveAgent,
|
GetAppExitWorkGuardStateInput, GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput,
|
||||||
StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, UpdateLiveState,
|
LeanLiveEntry, LeanLiveState, LiveWorkSession, ProjectWorkState, ReconcileLiveState,
|
||||||
UpdateLiveStateInput, LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS,
|
ReconcileLiveStateInput, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput,
|
||||||
|
TicketWorkSource, TicketWorkStatus, UpdateLiveState, UpdateLiveStateInput,
|
||||||
|
LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -72,6 +72,61 @@ pub struct ProjectWorkState {
|
|||||||
pub conversations: Vec<ConversationWorkSummary>,
|
pub conversations: Vec<ConversationWorkSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Input for [`GetAppExitWorkGuardState::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct GetAppExitWorkGuardStateInput {
|
||||||
|
/// Projects currently open in the application.
|
||||||
|
pub projects: Vec<Project>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<AppExitWorkGuardDetail>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<TicketId>,
|
||||||
|
},
|
||||||
|
/// 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.
|
/// Best-effort, read-only summary of one conversation visible through the tickets.
|
||||||
///
|
///
|
||||||
/// Derived live from the [`HandoffStore`] (primary source) with a bounded
|
/// Derived live from the [`HandoffStore`] (primary source) with a bounded
|
||||||
@ -276,6 +331,80 @@ pub struct GetProjectWorkState {
|
|||||||
background_tasks: Option<Arc<dyn BackgroundTaskStore>>,
|
background_tasks: Option<Arc<dyn BackgroundTaskStore>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read-only use case aggregating app-wide work that should guard application exit.
|
||||||
|
pub struct GetAppExitWorkGuardState {
|
||||||
|
work_state: Arc<GetProjectWorkState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GetAppExitWorkGuardState {
|
||||||
|
/// Builds the app-exit guard from the existing per-project work-state read model.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(work_state: Arc<GetProjectWorkState>) -> 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<AppExitWorkGuardState, AppError> {
|
||||||
|
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 {
|
impl GetProjectWorkState {
|
||||||
/// Builds the read-model use case from existing stores/registries.
|
/// Builds the read-model use case from existing stores/registries.
|
||||||
///
|
///
|
||||||
|
|||||||
@ -8,7 +8,8 @@ use std::sync::{Arc, Mutex};
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
ConversationLogProvider, ConversationPreviewStatus, GetProjectWorkState,
|
AppExitWorkGuardDetail, ConversationLogProvider, ConversationPreviewStatus,
|
||||||
|
GetAppExitWorkGuardState, GetAppExitWorkGuardStateInput, GetProjectWorkState,
|
||||||
GetProjectWorkStateInput, HandoffProvider, LiveSessionKind, LiveSessions, StructuredSessions,
|
GetProjectWorkStateInput, HandoffProvider, LiveSessionKind, LiveSessions, StructuredSessions,
|
||||||
TerminalSessions, TicketWorkSource, TicketWorkStatus,
|
TerminalSessions, TicketWorkSource, TicketWorkStatus,
|
||||||
};
|
};
|
||||||
@ -559,9 +560,12 @@ fn background_task(
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
match state {
|
match state {
|
||||||
BackgroundTaskState::Queued => base,
|
BackgroundTaskState::Queued => base,
|
||||||
BackgroundTaskState::Running | BackgroundTaskState::Waiting => {
|
BackgroundTaskState::Running => base.transition(state, created_at_ms + 10).unwrap(),
|
||||||
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::Completed
|
||||||
| BackgroundTaskState::Failed
|
| BackgroundTaskState::Failed
|
||||||
| BackgroundTaskState::Cancelled
|
| BackgroundTaskState::Cancelled
|
||||||
@ -695,6 +699,162 @@ async fn workstate_attaches_live_structured_session_to_manifest_agent() {
|
|||||||
assert_eq!(live.kind, LiveSessionKind::Structured);
|
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]
|
#[tokio::test]
|
||||||
async fn workstate_includes_busy_state_from_input_mediator() {
|
async fn workstate_includes_busy_state_from_input_mediator() {
|
||||||
let a = agent(10, "alpha");
|
let a = agent(10, "alpha");
|
||||||
|
|||||||
@ -9,12 +9,12 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AgentBackgroundTaskState, AgentTicketState, AppError, AttachLiveAgentOutput,
|
AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail,
|
||||||
BackgroundTaskKindLabel, ConversationPreviewStatus, ConversationTurnWorkPreview,
|
AppExitWorkGuardState, AttachLiveAgentOutput, BackgroundTaskKindLabel,
|
||||||
ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput,
|
ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary,
|
||||||
HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot,
|
CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport, LayoutKind,
|
||||||
OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus,
|
ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState,
|
||||||
TurnPage, TurnSource, TurnView,
|
StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView,
|
||||||
};
|
};
|
||||||
use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole};
|
use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole};
|
||||||
|
|
||||||
@ -2275,6 +2275,111 @@ impl From<ProjectWorkState> 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<AppExitWorkGuardDetailDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppExitWorkGuardState> 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<String>,
|
||||||
|
},
|
||||||
|
/// 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<AppExitWorkGuardDetail> 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
|
/// Request DTO for `attach_live_agent`: bind an already-running agent session to
|
||||||
/// a visible layout cell without spawning a new process.
|
/// a visible layout cell without spawning a new process.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
|||||||
@ -21,30 +21,30 @@ use application::{
|
|||||||
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
|
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
|
||||||
DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
|
DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
|
||||||
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||||
EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions,
|
EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
|
||||||
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
|
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph,
|
||||||
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
|
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase,
|
||||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles,
|
InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput,
|
||||||
ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, ListProjects,
|
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||||
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates,
|
||||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
|
||||||
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
||||||
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
|
||||||
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
|
||||||
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue,
|
||||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
|
||||||
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
||||||
RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
|
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice,
|
||||||
ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
|
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
|
||||||
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer,
|
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice,
|
||||||
SaveProfile, SessionLimitService, SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents,
|
RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService,
|
||||||
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
||||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext,
|
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
||||||
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet,
|
UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentMcpToolPermissions,
|
||||||
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions,
|
UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
||||||
UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill,
|
||||||
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
@ -1020,6 +1020,8 @@ pub struct BackendCore {
|
|||||||
pub list_resumable_agents: Arc<ListResumableAgents>,
|
pub list_resumable_agents: Arc<ListResumableAgents>,
|
||||||
/// Read-only live/busy state for the project's manifest agents.
|
/// Read-only live/busy state for the project's manifest agents.
|
||||||
pub get_project_work_state: Arc<GetProjectWorkState>,
|
pub get_project_work_state: Arc<GetProjectWorkState>,
|
||||||
|
/// App-wide work-in-progress guard used before confirmed application exit.
|
||||||
|
pub get_app_exit_work_guard_state: Arc<GetAppExitWorkGuardState>,
|
||||||
/// Human paginated read of a conversation's full transcript (lot LS6).
|
/// Human paginated read of a conversation's full transcript (lot LS6).
|
||||||
pub read_conversation_page: Arc<ReadConversationPage>,
|
pub read_conversation_page: Arc<ReadConversationPage>,
|
||||||
/// Best-effort log rotation, triggered off the hot path at thread resume/open (lot LS6).
|
/// 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)),
|
.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
|
// 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
|
// 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`.
|
// (plancher `up_to`, INV-LS6). Aucune persistance déclenchée par un `append`.
|
||||||
@ -2543,6 +2548,7 @@ impl BackendCore {
|
|||||||
change_agent_profile,
|
change_agent_profile,
|
||||||
list_resumable_agents,
|
list_resumable_agents,
|
||||||
get_project_work_state,
|
get_project_work_state,
|
||||||
|
get_app_exit_work_guard_state,
|
||||||
read_conversation_page,
|
read_conversation_page,
|
||||||
rotate_conversation_log,
|
rotate_conversation_log,
|
||||||
attach_live_agent,
|
attach_live_agent,
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
|
AppExitWorkGuardState,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
HealthReport,
|
HealthReport,
|
||||||
ReplyChunk,
|
ReplyChunk,
|
||||||
@ -114,6 +115,19 @@ export class HttpSystemGateway implements SystemGateway {
|
|||||||
// folder browser would be its own lot (flagged in the F1 report).
|
// folder browser would be its own lot (flagged in the F1 report).
|
||||||
return unsupportedOnWeb("Native folder picker");
|
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 {
|
export class HttpTerminalGateway implements TerminalGateway {
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
AgentDrift,
|
AgentDrift,
|
||||||
|
AppExitWorkGuardState,
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
DiagnosticWarning,
|
DiagnosticWarning,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
@ -182,6 +183,28 @@ export class MockSystemGateway implements SystemGateway {
|
|||||||
async pickFolder(): Promise<string | null> {
|
async pickFolder(): Promise<string | null> {
|
||||||
return "/home/user/mock-project";
|
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 { listen } from "@tauri-apps/api/event";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
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";
|
import type { SystemGateway } from "@/ports";
|
||||||
|
|
||||||
/** Tauri event name carrying relayed domain events (mirror of `DOMAIN_EVENT`). */
|
/** Tauri event name carrying relayed domain events (mirror of `DOMAIN_EVENT`). */
|
||||||
const DOMAIN_EVENT = "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 {
|
export class TauriSystemGateway implements SystemGateway {
|
||||||
async health(note?: string): Promise<HealthReport> {
|
async health(note?: string): Promise<HealthReport> {
|
||||||
// The backend command takes an optional `request: { note }` (camelCase).
|
// 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.
|
// `open` with `multiple: false` returns a string when a path is chosen.
|
||||||
return typeof result === "string" ? result : null;
|
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 { ProjectsView } from "@/features/projects";
|
||||||
import { FirstRunWizard } from "@/features/first-run";
|
import { FirstRunWizard } from "@/features/first-run";
|
||||||
import { AnnouncementsProvider } from "@/features/announcements";
|
import { AnnouncementsProvider } from "@/features/announcements";
|
||||||
|
import { AppExitConfirmDialog } from "@/features/appExit";
|
||||||
import { Panel, Spinner, Toolbar } from "@/shared";
|
import { Panel, Spinner, Toolbar } from "@/shared";
|
||||||
import { useGateways, shouldUseMock } from "./di";
|
import { useGateways, shouldUseMock } from "./di";
|
||||||
|
|
||||||
@ -114,6 +115,7 @@ export function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<AppExitConfirmDialog />
|
||||||
</AnnouncementsProvider>
|
</AnnouncementsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,43 @@ export interface HealthReport {
|
|||||||
note: string | null;
|
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
|
* Lifecycle status of a local model server during an agent launch (F35, mirror
|
||||||
* of the backend `ModelServerStatusDto`, tagged on `state`, camelCase wire).
|
* 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,
|
Agent,
|
||||||
AgentDrift,
|
AgentDrift,
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
|
AppExitWorkGuardState,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
EmbedderEngines,
|
EmbedderEngines,
|
||||||
EmbedderProfile,
|
EmbedderProfile,
|
||||||
@ -76,6 +77,20 @@ export interface SystemGateway {
|
|||||||
* sites go through this port; the Tauri plugin is only imported in the adapter.
|
* sites go through this port; the Tauri plugin is only imported in the adapter.
|
||||||
*/
|
*/
|
||||||
pickFolder(): Promise<string | null>;
|
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}. */
|
/** Input for {@link AgentGateway.createAgent}. */
|
||||||
|
|||||||
Reference in New Issue
Block a user