feat(windows): persistance et restauration des fenêtres au redémarrage (#40)

Persiste l'état des fenêtres (layout/position) et le restaure au
relancement de l'application. Découpage hexagonal :
- domaine : modèle et port d'état des fenêtres (layout, ports)
- application : use cases de persistance/restauration
- infrastructure : store window_state (adapter de persistance)
- présentation : câblage app-tauri (state, commands, lib)
Couvert par des tests ciblés domaine/application/infrastructure.

Depend de #39 (fermeture des fenêtres auxiliaires).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 08:00:20 +02:00
parent 55087b5d9b
commit 2eb51d3335
15 changed files with 887 additions and 40 deletions

View File

@ -2341,7 +2341,7 @@ pub struct ViewWindowLifecycleEventDto {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ViewPanel {
pub(crate) enum ViewPanel {
Projects,
Context,
Work,
@ -2355,7 +2355,7 @@ enum ViewPanel {
}
impl ViewPanel {
fn parse(raw: &str) -> Result<Self, ErrorDto> {
pub(crate) fn parse(raw: &str) -> Result<Self, ErrorDto> {
match raw {
"projects" => Ok(Self::Projects),
"context" => Ok(Self::Context),
@ -2374,7 +2374,7 @@ impl ViewPanel {
}
}
const fn as_str(self) -> &'static str {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Projects => "projects",
Self::Context => "context",
@ -2389,7 +2389,7 @@ impl ViewPanel {
}
}
const fn title(self) -> &'static str {
pub(crate) const fn title(self) -> &'static str {
match self {
Self::Projects => "Projects",
Self::Context => "Project context",
@ -2405,15 +2405,15 @@ impl ViewPanel {
}
}
fn view_window_label(panel: ViewPanel, project_id: domain::ProjectId) -> String {
pub(crate) fn view_window_label(panel: ViewPanel, project_id: domain::ProjectId) -> String {
format!("view-{}-{}", panel.as_str(), project_id.as_uuid().simple())
}
fn view_window_url(panel: ViewPanel, project_id: domain::ProjectId) -> String {
pub(crate) fn view_window_url(panel: ViewPanel, project_id: domain::ProjectId) -> String {
format!("index.html?panel={}&project={}", panel.as_str(), project_id)
}
fn emit_view_window_lifecycle(
pub(crate) fn emit_view_window_lifecycle(
app: &AppHandle,
kind: &'static str,
panel: ViewPanel,

View File

@ -26,7 +26,15 @@ pub mod tickets;
use std::process::ExitCode;
use tauri::Manager;
use application::SnapshotOpenWindowsInput;
use domain::{
PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize,
PersistedWindowState, ProjectId,
};
use tauri::{
Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder,
};
use uuid::Uuid;
use state::AppState;
@ -99,6 +107,9 @@ pub fn run() {
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
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);
// ORDER IS CRITICAL: freeze `agent_was_running` on every
// agent leaf of every open project FIRST, reading the live
@ -111,6 +122,11 @@ pub fn run() {
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 {
@ -130,6 +146,8 @@ pub fn run() {
});
}
restore_open_webview_windows(app.handle());
Ok(())
})
.invoke_handler(tauri::generate_handler![
@ -270,9 +288,233 @@ fn should_close_with_main_window(label: &str) -> bool {
label != "main"
}
fn snapshot_open_webview_windows(handle: &tauri::AppHandle) -> Vec<PersistedWindowState> {
handle
.webview_windows()
.into_iter()
.filter_map(|(label, window)| snapshot_webview_window(&label, &window))
.collect()
}
fn snapshot_webview_window(label: &str, window: &WebviewWindow) -> Option<PersistedWindowState> {
let (kind, panel, project_id, url) = persisted_window_identity(label)?;
let outer_position = window
.outer_position()
.ok()
.map(|p| PersistedWindowPosition { x: p.x, y: p.y });
let outer_size = window.outer_size().ok().map(|s| PersistedWindowSize {
width: s.width,
height: s.height,
});
let monitor = window.current_monitor().ok().flatten().map(|m| {
let position = m.position();
let size = m.size();
PersistedMonitorState {
name: m.name().cloned(),
scale_factor: Some(m.scale_factor()),
position: Some(PersistedWindowPosition {
x: position.x,
y: position.y,
}),
size: Some(PersistedWindowSize {
width: size.width,
height: size.height,
}),
}
});
Some(PersistedWindowState {
label: label.to_owned(),
kind,
panel,
project_id,
url,
visible: window.is_visible().unwrap_or(true),
maximized: window.is_maximized().unwrap_or(false),
fullscreen: window.is_fullscreen().unwrap_or(false),
outer_position,
outer_size,
monitor,
last_focused_at: None,
})
}
fn persisted_window_identity(
label: &str,
) -> Option<(
PersistedWindowKind,
Option<String>,
Option<ProjectId>,
Option<String>,
)> {
if label == "main" {
return Some((PersistedWindowKind::Main, None, None, None));
}
let (panel, project_id) = persisted_view_identity_from_label(label)?;
Some((
PersistedWindowKind::View,
Some(panel.as_str().to_owned()),
Some(project_id),
Some(commands::view_window_url(panel, project_id)),
))
}
fn persisted_view_identity_from_label(label: &str) -> Option<(commands::ViewPanel, ProjectId)> {
let rest = label.strip_prefix("view-")?;
let (panel, project_id) = rest.rsplit_once('-')?;
if project_id.len() != 32 {
return None;
}
let panel = commands::ViewPanel::parse(panel).ok()?;
let project_id = ProjectId::from_uuid(Uuid::parse_str(project_id).ok()?);
Some((panel, project_id))
}
fn restore_open_webview_windows(handle: &tauri::AppHandle) {
let Some(state) = handle.try_state::<AppState>() else {
return;
};
let restore = std::sync::Arc::clone(&state.restore_open_windows);
let Ok(output) = tauri::async_runtime::block_on(async move { restore.execute().await }) else {
return;
};
for window_state in output.windows {
match window_state.kind {
PersistedWindowKind::Main => {
if let Some(window) = handle.get_webview_window("main") {
apply_persisted_window_state(handle, &window, &window_state);
}
}
PersistedWindowKind::View => {
restore_view_window(handle, &window_state);
}
}
}
}
fn restore_view_window(handle: &tauri::AppHandle, state: &PersistedWindowState) {
if handle.get_webview_window(&state.label).is_some() {
return;
}
let Some(panel) = state
.panel
.as_deref()
.and_then(|raw| commands::ViewPanel::parse(raw).ok())
else {
return;
};
let Some(project_id) = state.project_id else {
return;
};
let expected_label = commands::view_window_label(panel, project_id);
if expected_label != state.label {
return;
}
let url = state
.url
.clone()
.unwrap_or_else(|| commands::view_window_url(panel, project_id));
let Ok(window) = WebviewWindowBuilder::new(handle, &state.label, WebviewUrl::App(url.into()))
.title(format!("IdeA - {}", panel.title()))
.inner_size(1120.0, 760.0)
.min_inner_size(720.0, 480.0)
.resizable(true)
.maximizable(true)
.minimizable(true)
.closable(true)
.decorations(true)
.visible(state.visible)
.build()
else {
return;
};
let event_app = handle.clone();
let event_label = state.label.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
commands::emit_view_window_lifecycle(
&event_app,
"closed",
panel,
project_id,
&event_label,
);
}
});
apply_persisted_window_state(handle, &window, state);
commands::emit_view_window_lifecycle(handle, "opened", panel, project_id, &state.label);
}
fn apply_persisted_window_state(
handle: &tauri::AppHandle,
window: &WebviewWindow,
state: &PersistedWindowState,
) {
if persisted_monitor_is_available(handle, state.monitor.as_ref()) {
if let Some(size) = state.outer_size {
let _ = window.set_size(PhysicalSize::new(size.width, size.height));
}
if let Some(position) = state.outer_position {
let _ = window.set_position(PhysicalPosition::new(position.x, position.y));
}
} else {
let _ = window.center();
}
if state.fullscreen {
let _ = window.set_fullscreen(true);
} else if state.maximized {
let _ = window.maximize();
}
if state.visible {
let _ = window.show();
} else {
let _ = window.hide();
}
}
fn persisted_monitor_is_available(
handle: &tauri::AppHandle,
monitor: Option<&PersistedMonitorState>,
) -> bool {
let Some(saved) = monitor else {
return true;
};
let Ok(monitors) = handle.available_monitors() else {
return false;
};
monitors.into_iter().any(|current| {
if let Some(saved_name) = saved.name.as_deref() {
if current.name().is_some_and(|name| name == saved_name) {
return true;
}
}
let position_matches = saved.position.is_some_and(|p| {
let current_position = current.position();
p.x == current_position.x && p.y == current_position.y
});
let size_matches = saved.size.is_some_and(|s| {
let current_size = current.size();
s.width == current_size.width && s.height == current_size.height
});
position_matches && size_matches
})
}
#[cfg(test)]
mod tests {
use super::should_close_with_main_window;
use super::{persisted_view_identity_from_label, persisted_window_identity};
use super::{should_close_with_main_window, PersistedWindowKind};
#[test]
fn main_window_close_does_not_target_main_again() {
@ -290,4 +532,33 @@ mod tests {
fn main_window_close_targets_other_auxiliary_windows() {
assert!(should_close_with_main_window("settings"));
}
#[test]
fn persisted_identity_accepts_main_and_stable_view_labels() {
let (kind, panel, project_id, url) = persisted_window_identity("main").unwrap();
assert_eq!(kind, PersistedWindowKind::Main);
assert!(panel.is_none());
assert!(project_id.is_none());
assert!(url.is_none());
let (panel, project_id) =
persisted_view_identity_from_label("view-tickets-0000000000000000000000000000002a")
.unwrap();
assert_eq!(panel.as_str(), "tickets");
assert_eq!(
project_id.to_string(),
"00000000-0000-0000-0000-00000000002a"
);
}
#[test]
fn persisted_identity_filters_unknown_or_headless_labels() {
assert!(persisted_window_identity("mcp-server").is_none());
assert!(persisted_window_identity("settings").is_none());
assert!(
persisted_window_identity("view-unknown-0000000000000000000000000000002a").is_none()
);
assert!(persisted_window_identity("view-tickets-not-a-project").is_none());
}
}

View File

@ -33,13 +33,14 @@ use application::{
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
ResolveMemoryLinks, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile,
SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents,
SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint,
UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet,
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill,
UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RotateConversationLog,
SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout,
SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent,
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions,
UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext,
UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
};
use async_trait::async_trait;
use domain::ports::{
@ -49,7 +50,7 @@ use domain::ports::{
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator,
IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore,
ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore,
ToolInvoker, WakeError, WakeReason,
ToolInvoker, WakeError, WakeReason, WindowStateStore,
};
use domain::profile::{
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
@ -71,13 +72,14 @@ use infrastructure::{
FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore,
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore,
Git2Repository, HeuristicHandoffSummarizer, HttpOpenAiCompatibleProbe, IdeaiContextStore,
InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime, LocalFileSystem,
LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
SystemClock, SystemMillisClock, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HttpOpenAiCompatibleProbe,
IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime,
LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox,
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
StructuredSessionFactory, SystemClock, SystemMillisClock, TicketToolProvider,
TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall,
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
VECTOR_ONNX_ENABLED,
};
use crate::chat::ChatBridge;
@ -900,6 +902,10 @@ pub struct AppState {
// --- Windows (L10) ---
/// Detach a tab into a new OS window (persists the workspace topology).
pub move_tab: Arc<MoveTabToNewWindow>,
/// Persist open Tauri webview-window state on main-window shutdown.
pub snapshot_open_windows: Arc<SnapshotOpenWindows>,
/// Load restorable Tauri webview-window state on startup.
pub restore_open_windows: Arc<RestoreOpenWindows>,
// --- Background tasks (B8) ---
/// Spawn a command-backed first-class background task.
pub spawn_background_command: Arc<SpawnBackgroundCommand>,
@ -1054,10 +1060,15 @@ impl AppState {
Arc::clone(&fs) as Arc<dyn FileSystem>,
app_data_dir.to_string_lossy().into_owned(),
));
let window_state_store = Arc::new(FsWindowStateStore::new(
Arc::clone(&fs) as Arc<dyn FileSystem>,
app_data_dir.to_string_lossy().into_owned(),
));
// Port-typed handles for injection.
let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>;
let window_state_port = Arc::clone(&window_state_store) as Arc<dyn WindowStateStore>;
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
// --- Use cases (ports injected as Arc<dyn Port>) ---
@ -2211,6 +2222,12 @@ impl AppState {
Arc::clone(&store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
));
let snapshot_open_windows =
Arc::new(SnapshotOpenWindows::new(Arc::clone(&window_state_port)));
let restore_open_windows = Arc::new(RestoreOpenWindows::new(
Arc::clone(&window_state_port),
Arc::clone(&store_port),
));
Self {
health,
@ -2340,6 +2357,8 @@ impl AppState {
fs_port: Arc::clone(&fs_port),
home_dir,
move_tab,
snapshot_open_windows,
restore_open_windows,
spawn_background_command,
cancel_background_task,
retry_background_task,