fix(windows): restauration panel-only des fenêtres détachées suivant le projet en focus (#47)
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 <noreply@anthropic.com>
This commit is contained in:
@ -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<FocusedProjectDto>,
|
||||
}
|
||||
|
||||
#[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<FocusedProjectDto>,
|
||||
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<Option<FocusedProjectDto>, 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=<panel>&project=<projectId>` so the frontend can boot a
|
||||
/// panel-only shell with the regular gateways and read models.
|
||||
/// `index.html?panel=<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<OpenViewWindowResponseDto, ErrorDto> {
|
||||
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<CloseViewWindowResponseDto, ErrorDto> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<commands::ViewPanel> {
|
||||
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());
|
||||
|
||||
@ -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<SnapshotOpenWindows>,
|
||||
/// Load restorable Tauri webview-window state on startup.
|
||||
pub restore_open_windows: Arc<RestoreOpenWindows>,
|
||||
/// Project currently focused by the main window; panel-only windows follow it.
|
||||
focused_project: Mutex<Option<FocusedProjectDto>>,
|
||||
// --- Background tasks (B8) ---
|
||||
/// Spawn a command-backed first-class background task.
|
||||
pub spawn_background_command: Arc<SpawnBackgroundCommand>,
|
||||
@ -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<FocusedProjectDto>) {
|
||||
*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<FocusedProjectDto> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user