From 6387fac34f9fbb54a75c4faf4f4845aab847ffa6 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 13 Jul 2026 12:37:25 +0200 Subject: [PATCH] =?UTF-8?q?fix(windows):=20restauration=20panel-only=20des?= =?UTF-8?q?=20fen=C3=AAtres=20d=C3=A9tach=C3=A9es=20suivant=20le=20projet?= =?UTF-8?q?=20en=20focus=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partie backend. Les fenêtres/panneaux détachés étaient restaurés avec un project_id figé au moment du détachement, si bien qu'ils restaient collés à un projet mort ou incohérent après redémarrage. Ils sont désormais restaurés en mode panel-only, sans project_id figé, et suivent le projet en focus de la fenêtre principale via un event focused-project exposé par la couche fenêtre. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/commands.rs | 100 +++++++++++--------- crates/app-tauri/src/lib.rs | 79 ++++++++-------- crates/app-tauri/src/state.rs | 33 +++++++ crates/application/src/window/usecases.rs | 26 +++-- crates/application/tests/window_usecases.rs | 20 ++-- crates/domain/src/layout.rs | 5 +- 6 files changed, 156 insertions(+), 107 deletions(-) diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 054721e..94e6fb8 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -62,7 +62,7 @@ use crate::dto::{ UpdateTemplateRequestDto, WriteTerminalRequestDto, }; use crate::pty::{PtyBridge, PtyChunk}; -use crate::state::AppState; +use crate::state::{AppState, FocusedProjectDto}; use domain::{SkillRef, SkillScope}; /// `health` — trivial command validating the full IPC pipeline @@ -2303,12 +2303,14 @@ use application::MoveTabToNewWindowInput; /// Event emitted for detached view-window lifecycle changes. pub const VIEW_WINDOW_LIFECYCLE_EVENT: &str = "view-window://lifecycle"; +/// Event emitted when the main-window focused project changes. +pub const FOCUSED_PROJECT_CHANGED_EVENT: &str = "focused-project://changed"; /// Response returned by `open_view_window`. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct OpenViewWindowResponseDto { - /// Stable Tauri window label for this `(panel, projectId)`. + /// Stable Tauri window label for this panel. pub label: String, /// App URL loaded by the panel-only window. pub url: String, @@ -2320,7 +2322,7 @@ pub struct OpenViewWindowResponseDto { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CloseViewWindowResponseDto { - /// Stable Tauri window label for this `(panel, projectId)`. + /// Stable Tauri window label for this panel. pub label: String, /// `true` when a live window was found and close was requested. pub closed: bool, @@ -2334,12 +2336,18 @@ pub struct ViewWindowLifecycleEventDto { pub kind: &'static str, /// Panel id rendered by the detached window. pub panel: String, - /// Project id rendered by the detached window. - pub project_id: String, /// Stable Tauri window label. pub label: String, } +/// Payload emitted on `focused-project://changed`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FocusedProjectChangedEventDto { + /// Focused project, or `null` when no project is focused. + pub project: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ViewPanel { Projects, @@ -2405,19 +2413,18 @@ impl ViewPanel { } } -pub(crate) fn view_window_label(panel: ViewPanel, project_id: domain::ProjectId) -> String { - format!("view-{}-{}", panel.as_str(), project_id.as_uuid().simple()) +pub(crate) fn view_window_label(panel: ViewPanel) -> String { + format!("view-{}", panel.as_str()) } -pub(crate) fn view_window_url(panel: ViewPanel, project_id: domain::ProjectId) -> String { - format!("index.html?panel={}&project={}", panel.as_str(), project_id) +pub(crate) fn view_window_url(panel: ViewPanel) -> String { + format!("index.html?panel={}", panel.as_str()) } pub(crate) fn emit_view_window_lifecycle( app: &AppHandle, kind: &'static str, panel: ViewPanel, - project_id: domain::ProjectId, label: &str, ) { let _ = app.emit( @@ -2425,39 +2432,56 @@ pub(crate) fn emit_view_window_lifecycle( ViewWindowLifecycleEventDto { kind, panel: panel.as_str().to_owned(), - project_id: project_id.to_string(), label: label.to_owned(), }, ); } -/// `open_view_window` — open or focus a detached OS window for one project view. +/// `set_focused_project` — update the backend focused-project state and notify panels. +#[tauri::command] +pub async fn set_focused_project( + app: AppHandle, + project: Option, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + state.set_focused_project(project.clone()); + app.emit( + FOCUSED_PROJECT_CHANGED_EVENT, + FocusedProjectChangedEventDto { project }, + ) + .map_err(internal_window_error) +} + +/// `get_focused_project` — read the backend focused-project state. +#[tauri::command] +pub async fn get_focused_project( + state: State<'_, AppState>, +) -> Result, ErrorDto> { + Ok(state.get_focused_project()) +} + +/// `open_view_window` — open or focus a detached OS window for one panel. /// /// The window is a normal decorated, resizable system window. Its webview loads -/// `index.html?panel=&project=` so the frontend can boot a -/// panel-only shell with the regular gateways and read models. +/// `index.html?panel=` so the frontend can boot a panel-only shell that +/// follows the backend focused-project state. /// /// # Errors -/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel or malformed project -/// id, `NOT_FOUND`/`STORE` if the project cannot be loaded, `INTERNAL` if Tauri +/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel, `INTERNAL` if Tauri /// fails to create/focus the window). #[tauri::command] pub async fn open_view_window( app: AppHandle, panel: String, - project_id: String, - state: State<'_, AppState>, ) -> Result { let panel = ViewPanel::parse(&panel)?; - let project = resolve_project(&project_id, &state).await?; - let project_id = project.id; - let label = view_window_label(panel, project_id); - let url = view_window_url(panel, project_id); + let label = view_window_label(panel); + let url = view_window_url(panel); if let Some(window) = app.get_webview_window(&label) { window.show().map_err(internal_window_error)?; window.set_focus().map_err(internal_window_error)?; - emit_view_window_lifecycle(&app, "focused", panel, project_id, &label); + emit_view_window_lifecycle(&app, "focused", panel, &label); return Ok(OpenViewWindowResponseDto { label, url, @@ -2481,10 +2505,10 @@ pub async fn open_view_window( let event_label = label.clone(); window.on_window_event(move |event| { if let WindowEvent::CloseRequested { .. } = event { - emit_view_window_lifecycle(&event_app, "closed", panel, project_id, &event_label); + emit_view_window_lifecycle(&event_app, "closed", panel, &event_label); } }); - emit_view_window_lifecycle(&app, "opened", panel, project_id, &label); + emit_view_window_lifecycle(&app, "opened", panel, &label); Ok(OpenViewWindowResponseDto { label, @@ -2493,20 +2517,18 @@ pub async fn open_view_window( }) } -/// `close_view_window` — request closing the detached OS window for one project view. +/// `close_view_window` — request closing the detached OS window for one panel. /// /// # Errors -/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel or malformed project -/// id, `INTERNAL` if Tauri fails to close the window). +/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel, `INTERNAL` if Tauri +/// fails to close the window). #[tauri::command] pub async fn close_view_window( app: AppHandle, panel: String, - project_id: String, ) -> Result { let panel = ViewPanel::parse(&panel)?; - let project_id = parse_project_id(&project_id)?; - let label = view_window_label(panel, project_id); + let label = view_window_label(panel); let Some(window) = app.get_webview_window(&label) else { return Ok(CloseViewWindowResponseDto { label, @@ -2531,22 +2553,13 @@ fn internal_window_error(error: impl std::fmt::Display) -> ErrorDto { #[cfg(test)] mod view_window_tests { use super::*; - use domain::ProjectId; - use uuid::Uuid; #[test] fn view_window_label_and_url_are_stable() { - let project_id = ProjectId::from_uuid(Uuid::from_u128(0x123)); let panel = ViewPanel::Tickets; - assert_eq!( - view_window_label(panel, project_id), - "view-tickets-00000000000000000000000000000123" - ); - assert_eq!( - view_window_url(panel, project_id), - "index.html?panel=tickets&project=00000000-0000-0000-0000-000000000123" - ); + assert_eq!(view_window_label(panel), "view-tickets"); + assert_eq!(view_window_url(panel), "index.html?panel=tickets"); } #[test] @@ -2562,12 +2575,11 @@ mod view_window_tests { let payload = ViewWindowLifecycleEventDto { kind: "closed", panel: "tickets".to_owned(), - project_id: Uuid::from_u128(7).to_string(), label: "view-tickets-7".to_owned(), }; let json = serde_json::to_string(&payload).unwrap(); - assert!(json.contains("\"projectId\""), "json was {json}"); + assert!(!json.contains("projectId"), "json was {json}"); assert!(!json.contains("project_id"), "no snake_case leak: {json}"); } } diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 684be79..03dcd11 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -262,6 +262,8 @@ pub fn run() { commands::read_memory_index, commands::recall_memory, commands::resolve_memory_links, + commands::set_focused_project, + commands::get_focused_project, commands::open_view_window, commands::close_view_window, commands::move_tab_to_new_window, @@ -351,25 +353,26 @@ fn persisted_window_identity( return Some((PersistedWindowKind::Main, None, None, None)); } - let (panel, project_id) = persisted_view_identity_from_label(label)?; + let panel = 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)), + None, + Some(commands::view_window_url(panel)), )) } -fn persisted_view_identity_from_label(label: &str) -> Option<(commands::ViewPanel, ProjectId)> { +fn persisted_view_identity_from_label(label: &str) -> Option { let rest = label.strip_prefix("view-")?; - let (panel, project_id) = rest.rsplit_once('-')?; - if project_id.len() != 32 { - return None; + if let Ok(panel) = commands::ViewPanel::parse(rest) { + return Some(panel); } - let panel = commands::ViewPanel::parse(panel).ok()?; - let project_id = ProjectId::from_uuid(Uuid::parse_str(project_id).ok()?); - Some((panel, project_id)) + let (panel, project_id) = rest.rsplit_once('-')?; + if project_id.len() != 32 || Uuid::parse_str(project_id).is_err() { + return None; + } + commands::ViewPanel::parse(panel).ok() } fn restore_open_webview_windows(handle: &tauri::AppHandle) { @@ -396,30 +399,21 @@ fn restore_open_webview_windows(handle: &tauri::AppHandle) { } 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()) + .or_else(|| persisted_view_identity_from_label(&state.label)) 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 { + let label = commands::view_window_label(panel); + if handle.get_webview_window(&label).is_some() { return; } - let url = state - .url - .clone() - .unwrap_or_else(|| commands::view_window_url(panel, project_id)); + let url = commands::view_window_url(panel); - let Ok(window) = WebviewWindowBuilder::new(handle, &state.label, WebviewUrl::App(url.into())) + let Ok(window) = WebviewWindowBuilder::new(handle, &label, WebviewUrl::App(url.into())) .title(format!("IdeA - {}", panel.title())) .inner_size(1120.0, 760.0) .min_inner_size(720.0, 480.0) @@ -435,21 +429,15 @@ fn restore_view_window(handle: &tauri::AppHandle, state: &PersistedWindowState) }; let event_app = handle.clone(); - let event_label = state.label.clone(); + let event_label = 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, - ); + commands::emit_view_window_lifecycle(&event_app, "closed", panel, &event_label); } }); apply_persisted_window_state(handle, &window, state); - commands::emit_view_window_lifecycle(handle, "opened", panel, project_id, &state.label); + commands::emit_view_window_lifecycle(handle, "opened", panel, &label); } fn apply_persisted_window_state( @@ -541,17 +529,30 @@ mod tests { assert!(project_id.is_none()); assert!(url.is_none()); - let (panel, project_id) = - persisted_view_identity_from_label("view-tickets-0000000000000000000000000000002a") - .unwrap(); + let (kind, panel, project_id, url) = + persisted_window_identity("view-tickets-0000000000000000000000000000002a").unwrap(); - assert_eq!(panel.as_str(), "tickets"); + assert_eq!(kind, PersistedWindowKind::View); + assert_eq!(panel.as_deref(), Some("tickets")); + assert!(project_id.is_none()); + assert_eq!(url.as_deref(), Some("index.html?panel=tickets")); assert_eq!( - project_id.to_string(), - "00000000-0000-0000-0000-00000000002a" + persisted_view_identity_from_label("view-tickets-0000000000000000000000000000002a") + .unwrap() + .as_str(), + "tickets" ); } + #[test] + fn persisted_identity_accepts_new_panel_only_view_labels() { + let (kind, panel, project_id, url) = persisted_window_identity("view-agents").unwrap(); + assert_eq!(kind, PersistedWindowKind::View); + assert_eq!(panel.as_deref(), Some("agents")); + assert!(project_id.is_none()); + assert_eq!(url.as_deref(), Some("index.html?panel=agents")); + } + #[test] fn persisted_identity_filters_unknown_or_headless_labels() { assert!(persisted_window_identity("mcp-server").is_none()); diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 14aba45..c9fa950 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -62,6 +62,7 @@ use domain::{ InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, InboxSource, Project, ProjectId, TaskId, TicketId, }; +use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use uuid::Uuid; @@ -89,6 +90,18 @@ use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker}; use crate::pty::PtyBridge; use crate::tickets::AppTicketToolProvider; +/// Focused project snapshot shared with detached panel windows. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FocusedProjectDto { + /// Project id as a string. + pub id: String, + /// Project display name. + pub name: String, + /// Absolute project root. + pub root: String, +} + use infrastructure::StdioTransport; use interprocess::local_socket::tokio::Listener as LocalSocketListener; @@ -907,6 +920,8 @@ pub struct AppState { pub snapshot_open_windows: Arc, /// Load restorable Tauri webview-window state on startup. pub restore_open_windows: Arc, + /// Project currently focused by the main window; panel-only windows follow it. + focused_project: Mutex>, // --- Background tasks (B8) --- /// Spawn a command-backed first-class background task. pub spawn_background_command: Arc, @@ -2376,6 +2391,7 @@ impl AppState { move_tab, snapshot_open_windows, restore_open_windows, + focused_project: Mutex::new(None), spawn_background_command, cancel_background_task, retry_background_task, @@ -2383,6 +2399,23 @@ impl AppState { } } + /// Updates the focused project state. `None` means no project is focused/open. + pub fn set_focused_project(&self, project: Option) { + *self + .focused_project + .lock() + .expect("focused project mutex poisoned") = project; + } + + /// Returns the current focused project, if any. + #[must_use] + pub fn get_focused_project(&self) -> Option { + self.focused_project + .lock() + .expect("focused project mutex poisoned") + .clone() + } + /// Starts a [`FsOrchestratorWatcher`] for `project` if one is not already /// running for it (idempotent). The watcher tails the project's /// `.ideai/requests/` tree and dispatches each request through the shared diff --git a/crates/application/src/window/usecases.rs b/crates/application/src/window/usecases.rs index 2a38ce0..2988e29 100644 --- a/crates/application/src/window/usecases.rs +++ b/crates/application/src/window/usecases.rs @@ -10,7 +10,7 @@ use domain::ids::{TabId, WindowId}; use std::collections::HashSet; use domain::layout::{PersistedWindowKind, PersistedWindowState, WindowStateSnapshot, Workspace}; -use domain::ports::{IdGenerator, ProjectStore, StoreError, WindowStateStore}; +use domain::ports::{IdGenerator, ProjectStore, WindowStateStore}; use crate::error::AppError; @@ -115,21 +115,24 @@ pub struct RestoreOpenWindowsOutput { /// Loads and filters the latest persisted OS window snapshot. pub struct RestoreOpenWindows { windows: Arc, - projects: Arc, + _projects: Arc, } impl RestoreOpenWindows { /// Builds the use case from its ports. #[must_use] pub fn new(windows: Arc, projects: Arc) -> Self { - Self { windows, projects } + Self { + windows, + _projects: projects, + } } /// Loads restorable windows. /// - /// Views are ignored if their project no longer exists or if their persisted - /// identity is incomplete. Duplicate labels are ignored after the first - /// occurrence. + /// Views are panel-only: their persisted project id, including legacy + /// `view--` identities, is ignored. Duplicate labels are ignored + /// after the first occurrence. /// /// # Errors /// [`AppError::Store`] on snapshot read failure. @@ -146,17 +149,10 @@ impl RestoreOpenWindows { match window.kind { PersistedWindowKind::Main => windows.push(window), PersistedWindowKind::View => { - let Some(project_id) = window.project_id else { - continue; - }; - if window.panel.is_none() || window.url.is_none() { + if window.panel.is_none() { continue; } - match self.projects.load_project(project_id).await { - Ok(_) => windows.push(window), - Err(StoreError::NotFound) => {} - Err(e) => return Err(e.into()), - } + windows.push(window); } } } diff --git a/crates/application/tests/window_usecases.rs b/crates/application/tests/window_usecases.rs index 603ab2a..57ff17a 100644 --- a/crates/application/tests/window_usecases.rs +++ b/crates/application/tests/window_usecases.rs @@ -232,12 +232,14 @@ async fn snapshot_open_windows_persists_the_supplied_snapshot() { } #[tokio::test] -async fn restore_open_windows_deduplicates_and_filters_missing_projects() { +async fn restore_open_windows_keeps_panel_views_without_reopening_projects() { let existing = ProjectId::from_uuid(Uuid::from_u128(42)); let missing = ProjectId::from_uuid(Uuid::from_u128(43)); let valid_label = "view-tickets-0000000000000000000000000000002a"; - let mut incomplete = persisted_view("view-tickets-0000000000000000000000000000002c", existing); - incomplete.url = None; + let mut url_missing = persisted_view("view-tickets-0000000000000000000000000000002c", existing); + url_missing.url = None; + let mut no_panel = persisted_view("view-tickets-0000000000000000000000000000002d", existing); + no_panel.panel = None; let store = FakeWindowStateStore(Arc::new(Mutex::new(WindowStateSnapshot::new(vec![ persisted_main(), @@ -245,9 +247,10 @@ async fn restore_open_windows_deduplicates_and_filters_missing_projects() { persisted_view(valid_label, existing), persisted_view(valid_label, existing), persisted_view("view-tickets-0000000000000000000000000000002b", missing), - incomplete, + url_missing, + no_panel, ])))); - let projects = FakeProjectRegistry::new(vec![existing]); + let projects = FakeProjectRegistry::new(vec![]); let uc = RestoreOpenWindows::new(Arc::new(store), Arc::new(projects)); let out = uc.execute().await.unwrap(); @@ -257,6 +260,11 @@ async fn restore_open_windows_deduplicates_and_filters_missing_projects() { .iter() .map(|w| w.label.as_str()) .collect::>(), - vec!["main", valid_label] + vec![ + "main", + valid_label, + "view-tickets-0000000000000000000000000000002b", + "view-tickets-0000000000000000000000000000002c" + ] ); } diff --git a/crates/domain/src/layout.rs b/crates/domain/src/layout.rs index 191d5d3..d3a66a9 100644 --- a/crates/domain/src/layout.rs +++ b/crates/domain/src/layout.rs @@ -1081,15 +1081,14 @@ pub struct PersistedMonitorState { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PersistedWindowState { - /// Stable Tauri label. For detached views this is - /// `view--`. + /// Stable Tauri label. For detached views this is `view-`. pub label: String, /// Window kind. pub kind: PersistedWindowKind, /// Detached view panel id. Present only for [`PersistedWindowKind::View`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub panel: Option, - /// Detached view project id. Present only for [`PersistedWindowKind::View`]. + /// Legacy detached view project id. Ignored by panel-only restore. #[serde(default, skip_serializing_if = "Option::is_none")] pub project_id: Option, /// App URL loaded in the window.