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. diff --git a/frontend/src/adapters/focusedProject.test.ts b/frontend/src/adapters/focusedProject.test.ts new file mode 100644 index 0000000..e2a3154 --- /dev/null +++ b/frontend/src/adapters/focusedProject.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const invoke = vi.fn(); +const listen = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invoke(...args), +})); +vi.mock("@tauri-apps/api/event", () => ({ + listen: (...args: unknown[]) => listen(...args), +})); + +import { TauriFocusedProjectGateway } from "./focusedProject"; + +describe("TauriFocusedProjectGateway (#47)", () => { + beforeEach(() => { + invoke.mockReset().mockResolvedValue(null); + listen.mockReset(); + }); + + it("set/get relay to the backend focused-project commands", async () => { + const gw = new TauriFocusedProjectGateway(); + + await gw.setFocusedProject({ id: "p1", name: "alpha", root: "/p/a" }); + expect(invoke).toHaveBeenCalledWith("set_focused_project", { + project: { id: "p1", name: "alpha", root: "/p/a" }, + }); + + await gw.setFocusedProject(null); + expect(invoke).toHaveBeenCalledWith("set_focused_project", { + project: null, + }); + + invoke.mockResolvedValueOnce({ id: "p2", name: "beta", root: "/p/b" }); + await expect(gw.getFocusedProject()).resolves.toEqual({ + id: "p2", + name: "beta", + root: "/p/b", + }); + expect(invoke).toHaveBeenCalledWith("get_focused_project"); + + // A `null`/undefined focus normalises to `null`. + invoke.mockResolvedValueOnce(null); + await expect(gw.getFocusedProject()).resolves.toBeNull(); + }); + + it("onFocusedProjectChanged listens on the channel and forwards the project (or null)", async () => { + let raw: ((e: { payload: unknown }) => void) | undefined; + const unlisten = vi.fn(); + listen.mockImplementation((_name: string, cb: typeof raw) => { + raw = cb; + return Promise.resolve(unlisten); + }); + + const gw = new TauriFocusedProjectGateway(); + const handler = vi.fn(); + const off = await gw.onFocusedProjectChanged(handler); + + expect(listen).toHaveBeenCalledWith( + "focused-project://changed", + expect.any(Function), + ); + + raw?.({ payload: { project: { id: "p1", name: "alpha", root: "/p/a" } } }); + expect(handler).toHaveBeenLastCalledWith({ + id: "p1", + name: "alpha", + root: "/p/a", + }); + + raw?.({ payload: { project: null } }); + expect(handler).toHaveBeenLastCalledWith(null); + + off(); + expect(unlisten).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/adapters/focusedProject.ts b/frontend/src/adapters/focusedProject.ts new file mode 100644 index 0000000..8f51345 --- /dev/null +++ b/frontend/src/adapters/focusedProject.ts @@ -0,0 +1,48 @@ +/** + * Tauri adapter for {@link FocusedProjectGateway} (ticket #47). Bridges the + * main-window ⇄ detached-panel focused-project channel to the backend commands + * and event. Like the sibling adapters, this is the only layer allowed to touch + * `@tauri-apps/api`; components reach it exclusively through the port. + * + * Command/event names and payloads mirror the backend contract owned by + * DevBackend: `set_focused_project` / `get_focused_project` and the + * `focused-project://changed` event whose payload is `{ project: FocusedProject + * | null }`. + */ + +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; + +import type { Unsubscribe } from "@/domain"; +import type { FocusedProject, FocusedProjectGateway } from "@/ports"; + +/** The Tauri event carrying focused-project transitions. */ +const FOCUSED_PROJECT_CHANGED = "focused-project://changed"; + +/** Backend payload for `focused-project://changed`. */ +interface FocusedProjectChanged { + project: FocusedProject | null; +} + +export class TauriFocusedProjectGateway implements FocusedProjectGateway { + async setFocusedProject(project: FocusedProject | null): Promise { + // The backend command takes `project: Option`; `null` + // clears the focus (no project open). + await invoke("set_focused_project", { project }); + } + + async getFocusedProject(): Promise { + const project = await invoke("get_focused_project"); + return project ?? null; + } + + async onFocusedProjectChanged( + handler: (project: FocusedProject | null) => void, + ): Promise { + const unlisten = await listen( + FOCUSED_PROJECT_CHANGED, + (e) => handler(e.payload.project ?? null), + ); + return unlisten; + } +} diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 421727d..cb06a3c 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -30,6 +30,7 @@ import { TauriWorkStateGateway } from "./workState"; import { TauriConversationGateway } from "./conversation"; import { TauriTicketGateway } from "./ticket"; import { TauriWindowGateway } from "./window"; +import { TauriFocusedProjectGateway } from "./focusedProject"; import { LocalStorageUiPreferencesGateway } from "./uiPreferences"; function notImplemented(what: string): never { @@ -68,6 +69,7 @@ export function createTauriGateways(): Gateways { conversation: new TauriConversationGateway(), ticket: new TauriTicketGateway(), window: new TauriWindowGateway(), + focusedProject: new TauriFocusedProjectGateway(), uiPreferences: new LocalStorageUiPreferencesGateway(), }; } @@ -91,5 +93,6 @@ export { TauriConversationGateway, TauriTicketGateway, TauriWindowGateway, + TauriFocusedProjectGateway, LocalStorageUiPreferencesGateway, }; diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index e6b55c0..46f21a5 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -91,6 +91,8 @@ import type { UiPreferencesGateway, ViewWindowClosed, WindowGateway, + FocusedProject, + FocusedProjectGateway, WorkStateGateway, } from "@/ports"; import { normalizeProjectWorkState } from "../workStateNormalization"; @@ -1094,27 +1096,25 @@ class MockRemoteGateway implements RemoteGateway { } /** - * In-memory {@link WindowGateway} for tests/dev (#23). Tracks which views are - * "detached" in a set keyed by `panel|projectId`, and lets tests simulate the - * OS closing a window via {@link simulateOsClose}. `closeViewWindow` also emits - * the close event, mirroring the backend (a programmatic close still notifies). + * In-memory {@link WindowGateway} for tests/dev (#23, panel-only in #47). Tracks + * which views are "detached" in a set keyed by `panel` alone (a panel-only + * window follows the focused project, so no project id is part of the key), and + * lets tests simulate the OS closing a window via {@link simulateOsClose}. + * `closeViewWindow` also emits the close event, mirroring the backend (a + * programmatic close still notifies). */ export class MockWindowGateway implements WindowGateway { - /** Currently-open detached windows, keyed `panel|projectId`. */ + /** Currently-open detached windows, keyed by `panel`. */ readonly open = new Set(); private readonly listeners = new Set<(e: ViewWindowClosed) => void>(); - private key(panel: string, projectId: string): string { - return `${panel}|${projectId}`; + async openViewWindow(panel: string): Promise { + this.open.add(panel); } - async openViewWindow(panel: string, projectId: string): Promise { - this.open.add(this.key(panel, projectId)); - } - - async closeViewWindow(panel: string, projectId: string): Promise { - if (this.open.delete(this.key(panel, projectId))) { - this.emit({ panel, projectId }); + async closeViewWindow(panel: string): Promise { + if (this.open.delete(panel)) { + this.emit({ panel }); } } @@ -1128,9 +1128,9 @@ export class MockWindowGateway implements WindowGateway { } /** Test hook: simulate the user closing the OS window (fires the event). */ - simulateOsClose(panel: string, projectId: string): void { - this.open.delete(this.key(panel, projectId)); - this.emit({ panel, projectId }); + simulateOsClose(panel: string): void { + this.open.delete(panel); + this.emit({ panel }); } private emit(event: ViewWindowClosed): void { @@ -1138,6 +1138,38 @@ export class MockWindowGateway implements WindowGateway { } } +/** + * In-memory {@link FocusedProjectGateway} for tests/dev (#47). Holds the current + * focus in memory and fans changes out to subscribers — the mock counterpart to + * the backend's shared focused-project state + `focused-project://changed` + * event. `setFocusedProject` always notifies (mirrors the backend emit), so a + * detached-window test can drive a panel window's focus transitions. + */ +export class MockFocusedProjectGateway implements FocusedProjectGateway { + private focused: FocusedProject | null = null; + private readonly listeners = new Set< + (project: FocusedProject | null) => void + >(); + + async setFocusedProject(project: FocusedProject | null): Promise { + this.focused = project; + for (const l of this.listeners) l(project); + } + + async getFocusedProject(): Promise { + return this.focused; + } + + async onFocusedProjectChanged( + handler: (project: FocusedProject | null) => void, + ): Promise { + this.listeners.add(handler); + return () => { + this.listeners.delete(handler); + }; + } +} + /** * The pre-filled reference catalogue the mock serves — mirror of the backend's * **selectable** catalogue (§17.3/D7): only profiles drivable in structured mode @@ -2596,6 +2628,7 @@ export function createMockGateways(): Gateways { conversation: new MockConversationGateway(), ticket: new MockTicketGateway(systemGateway), window: new MockWindowGateway(), + focusedProject: new MockFocusedProjectGateway(), uiPreferences: new MockUiPreferencesGateway(), }; } diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index 348d7ba..64b8712 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -17,6 +17,7 @@ describe("createMockGateways", () => { "agent", "conversation", "embedder", + "focusedProject", "git", "input", "layout", diff --git a/frontend/src/adapters/window.test.ts b/frontend/src/adapters/window.test.ts index d0bb369..46d9a76 100644 --- a/frontend/src/adapters/window.test.ts +++ b/frontend/src/adapters/window.test.ts @@ -17,17 +17,15 @@ describe("TauriWindowGateway (#23)", () => { listen.mockReset(); }); - it("open/close relay camelCase payloads to the backend commands", async () => { + it("open/close relay panel-only payloads to the backend commands (#47)", async () => { const gw = new TauriWindowGateway(); - await gw.openViewWindow("tickets", "proj-1"); + await gw.openViewWindow("tickets"); expect(invoke).toHaveBeenCalledWith("open_view_window", { panel: "tickets", - projectId: "proj-1", }); - await gw.closeViewWindow("git", "proj-2"); + await gw.closeViewWindow("git"); expect(invoke).toHaveBeenCalledWith("close_view_window", { panel: "git", - projectId: "proj-2", }); }); @@ -51,19 +49,19 @@ describe("TauriWindowGateway (#23)", () => { // opened / focused are dropped… raw?.({ - payload: { kind: "opened", panel: "git", projectId: "p", label: "view-git-p" }, + payload: { kind: "opened", panel: "git", label: "view-git" }, }); raw?.({ - payload: { kind: "focused", panel: "git", projectId: "p", label: "view-git-p" }, + payload: { kind: "focused", panel: "git", label: "view-git" }, }); expect(handler).not.toHaveBeenCalled(); - // …only closed reaches the port handler, narrowed to { panel, projectId }. + // …only closed reaches the port handler, narrowed to { panel } (#47). raw?.({ - payload: { kind: "closed", panel: "git", projectId: "p", label: "view-git-p" }, + payload: { kind: "closed", panel: "git", label: "view-git" }, }); expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith({ panel: "git", projectId: "p" }); + expect(handler).toHaveBeenCalledWith({ panel: "git" }); off(); expect(unlisten).toHaveBeenCalled(); diff --git a/frontend/src/adapters/window.ts b/frontend/src/adapters/window.ts index b2abdbe..fd7f33b 100644 --- a/frontend/src/adapters/window.ts +++ b/frontend/src/adapters/window.ts @@ -19,34 +19,38 @@ import type { ViewWindowClosed, WindowGateway } from "@/ports"; /** The single Tauri event carrying detached-window lifecycle transitions. */ const VIEW_WINDOW_LIFECYCLE = "view-window://lifecycle"; -/** Backend lifecycle payload (discriminated on `kind`). */ +/** + * Backend lifecycle payload (discriminated on `kind`). Ticket #47 dropped the + * `projectId` field — a detached window is panel-only and follows the focused + * project, so lifecycle events carry only `panel` + `label`. + */ interface ViewWindowLifecycle { kind: "opened" | "focused" | "closed"; panel: string; - projectId: string; label: string; } export class TauriWindowGateway implements WindowGateway { - async openViewWindow(panel: string, projectId: string): Promise { - // camelCase args (Tauri maps them to the command's `panel` / `project_id`). - await invoke("open_view_window", { panel, projectId }); + async openViewWindow(panel: string): Promise { + // Panel-only window (#47): no project id — the window follows the focused + // project published on `focused-project://changed`. + await invoke("open_view_window", { panel }); } - async closeViewWindow(panel: string, projectId: string): Promise { - await invoke("close_view_window", { panel, projectId }); + async closeViewWindow(panel: string): Promise { + await invoke("close_view_window", { panel }); } async onViewWindowClosed( handler: (event: ViewWindowClosed) => void, ): Promise { // One backend channel for opened/focused/closed; the port only surfaces the - // close, so drop the other transitions and forward `{ panel, projectId }`. + // close, so drop the other transitions and forward `{ panel }`. const unlisten = await listen( VIEW_WINDOW_LIFECYCLE, (e) => { if (e.payload.kind !== "closed") return; - handler({ panel: e.payload.panel, projectId: e.payload.projectId }); + handler({ panel: e.payload.panel }); }, ); return unlisten; diff --git a/frontend/src/app/ViewWindow.test.tsx b/frontend/src/app/ViewWindow.test.tsx index 6c5579f..41ab38e 100644 --- a/frontend/src/app/ViewWindow.test.tsx +++ b/frontend/src/app/ViewWindow.test.tsx @@ -1,14 +1,17 @@ /** - * #23 — the panel-only entry. `parseViewWindowParams` validates the - * `?panel=&project=` query, and `ViewWindow` resolves the project through the - * gateway then renders only the requested view (here: the tickets view). + * #23/#47 — the panel-only entry. `parseViewWindowParams` validates the + * `?panel=` query (no project id anymore), and `ViewWindow` follows the main + * window's focused project: it shows an "open a project" shell while no project + * is focused and mounts the requested view once one is, without ever opening a + * project itself. */ import { describe, it, expect } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, act } from "@testing-library/react"; import { DIProvider } from "@/app/di"; import { MockAgentGateway, + MockFocusedProjectGateway, MockProjectGateway, MockSystemGateway, MockTicketGateway, @@ -16,45 +19,124 @@ import { import type { Gateways } from "@/ports"; import { ViewWindow, parseViewWindowParams } from "./ViewWindow"; -describe("parseViewWindowParams (#23)", () => { - it("parses a valid panel + project", () => { +describe("parseViewWindowParams (#47)", () => { + it("parses a valid panel-only query (ignores any legacy project param)", () => { + expect(parseViewWindowParams("?panel=tickets")).toEqual({ + panel: "tickets", + }); + // A restored legacy URL that still carries `project` parses to panel only. expect(parseViewWindowParams("?panel=tickets&project=p-1")).toEqual({ panel: "tickets", - projectId: "p-1", }); }); - it("rejects a missing project, missing panel, or the non-detachable projects panel", () => { - expect(parseViewWindowParams("?panel=tickets")).toBeNull(); + it("rejects a missing panel or the non-detachable projects panel", () => { expect(parseViewWindowParams("?project=p-1")).toBeNull(); - expect(parseViewWindowParams("?panel=projects&project=p-1")).toBeNull(); - expect(parseViewWindowParams("?panel=bogus&project=p-1")).toBeNull(); + expect(parseViewWindowParams("?panel=projects")).toBeNull(); + expect(parseViewWindowParams("?panel=bogus")).toBeNull(); expect(parseViewWindowParams("")).toBeNull(); }); }); -describe("ViewWindow (#23)", () => { - it("resolves the project and renders only the requested view", async () => { +describe("ViewWindow (#47)", () => { + it("shows the 'open a project' shell when no project is focused", async () => { + const system = new MockSystemGateway(); + const gateways = { + system, + project: new MockProjectGateway(), + agent: new MockAgentGateway(), + ticket: new MockTicketGateway(system), + focusedProject: new MockFocusedProjectGateway(), + } as unknown as Gateways; + + render( + + + , + ); + + // The panel title chrome is present, but no project-scoped view mounts… + expect(screen.getByRole("heading", { name: "Tickets" })).toBeTruthy(); + await waitFor(() => + expect(screen.getByText(/Ouvrez un projet/i)).toBeTruthy(), + ); + expect(screen.queryByLabelText("search tickets")).toBeNull(); + }); + + it("mounts the view for the focused project and follows focus changes", async () => { const system = new MockSystemGateway(); const project = new MockProjectGateway(); const created = await project.createProject("alpha", "/p/a"); + const focusedProject = new MockFocusedProjectGateway(); const gateways = { system, project, agent: new MockAgentGateway(), ticket: new MockTicketGateway(system), + focusedProject, } as unknown as Gateways; render( - + , ); - // The window chrome shows the panel title and the resolved project name. - expect(screen.getByRole("heading", { name: "Tickets" })).toBeTruthy(); + // No focus yet → shell. + await waitFor(() => + expect(screen.getByText(/Ouvrez un projet/i)).toBeTruthy(), + ); + + // The main window publishes a focused project → the window follows it. + await act(async () => { + await focusedProject.setFocusedProject({ + id: created.id, + name: "alpha", + root: "/p/a", + }); + }); + await waitFor(() => expect(screen.getByText("· alpha")).toBeTruthy()); - // The tickets view itself mounted (its search box is present). + await waitFor(() => + expect(screen.getByLabelText("search tickets")).toBeTruthy(), + ); + + // Focus cleared (project closed) → back to the shell, view unmounts. + await act(async () => { + await focusedProject.setFocusedProject(null); + }); + await waitFor(() => + expect(screen.getByText(/Ouvrez un projet/i)).toBeTruthy(), + ); + expect(screen.queryByLabelText("search tickets")).toBeNull(); + }); + + it("reads the current focus at mount (restored window with a live focus)", async () => { + const system = new MockSystemGateway(); + const project = new MockProjectGateway(); + const created = await project.createProject("beta", "/p/b"); + const focusedProject = new MockFocusedProjectGateway(); + // Focus already set before the window mounts (main window opened first). + await focusedProject.setFocusedProject({ + id: created.id, + name: "beta", + root: "/p/b", + }); + const gateways = { + system, + project, + agent: new MockAgentGateway(), + ticket: new MockTicketGateway(system), + focusedProject, + } as unknown as Gateways; + + render( + + + , + ); + + await waitFor(() => expect(screen.getByText("· beta")).toBeTruthy()); await waitFor(() => expect(screen.getByLabelText("search tickets")).toBeTruthy(), ); diff --git a/frontend/src/app/ViewWindow.tsx b/frontend/src/app/ViewWindow.tsx index f5aaa08..4a96bee 100644 --- a/frontend/src/app/ViewWindow.tsx +++ b/frontend/src/app/ViewWindow.tsx @@ -1,22 +1,28 @@ /** - * `ViewWindow` — the panel-only entry (ticket #23). + * `ViewWindow` — the panel-only entry (ticket #23, reworked in #47). * - * A detached OS window loads the same SPA with `?panel=&project=`; + * A detached OS window loads the same SPA with `?panel=` (no project id); * {@link main} routes to this component instead of the full {@link App} when a - * `panel` param is present. It re-instantiates the DI (same adapters/gateways - * as the main window — views are gateway/read-model-driven, so no heavy state - * is transferred) and renders **only** the requested view for the given - * project. + * `panel` param is present. It re-instantiates the DI (same adapters/gateways as + * the main window — views are gateway/read-model-driven, so no heavy state is + * transferred) and renders **only** the requested view for the project the main + * window currently has in focus. * - * Pure presentation over the ports: it resolves the project through the - * {@link ProjectGateway} (for its name/root) and hands off to the shared - * {@link ViewPanelBody}. No `invoke()`, no cross-window messaging. + * Focus, not embedding (#47): the window never carries — nor reopens — a project + * id. It reads the current focus from the {@link FocusedProjectGateway} at mount + * and then tracks {@link FocusedProjectGateway.onFocusedProjectChanged}. While no + * project is focused it mounts **no** project-scoped panel and shows an "open a + * project" shell, so a window restored at restart never surfaces a stale + * project's data. + * + * Pure presentation over the ports: no `invoke()`, no cross-window messaging, + * and crucially no `openProject()` — opening a project is the main window's job. */ import { useEffect, useState } from "react"; -import type { Project } from "@/domain"; -import { Panel, Spinner } from "@/shared"; +import type { FocusedProject } from "@/ports"; +import { Panel } from "@/shared"; import { PANEL_TITLE, type PanelId } from "@/features/projects"; import { ViewPanelBody, type ViewPanelId } from "@/features/projects"; import { useGateways } from "./di"; @@ -26,49 +32,59 @@ const DETACHABLE = new Set( (Object.keys(PANEL_TITLE) as PanelId[]).filter((p) => p !== "projects"), ); -/** Parsed `?panel=&project=` params, or `null` when absent/invalid. */ +/** Parsed `?panel=` params, or `null` when absent/invalid. */ export interface ViewWindowParams { panel: ViewPanelId; - projectId: string; } -/** Reads and validates the panel-only params from a query string. */ +/** Reads and validates the panel-only param from a query string (#47: no project). */ export function parseViewWindowParams( search: string, ): ViewWindowParams | null { const params = new URLSearchParams(search); const panel = params.get("panel"); - const projectId = params.get("project"); - if (!panel || !projectId || !DETACHABLE.has(panel)) return null; - return { panel: panel as ViewPanelId, projectId }; + if (!panel || !DETACHABLE.has(panel)) return null; + return { panel: panel as ViewPanelId }; } export interface ViewWindowProps { panel: ViewPanelId; - projectId: string; } -export function ViewWindow({ panel, projectId }: ViewWindowProps) { - const { project } = useGateways(); - const [resolved, setResolved] = useState(null); - const [error, setError] = useState(null); +export function ViewWindow({ panel }: ViewWindowProps) { + const { focusedProject } = useGateways(); + // The focused project this window follows. `undefined` = not yet resolved + // (initial read in flight); `null` = resolved, no project focused. + const [focus, setFocus] = useState( + undefined, + ); - // Resolve the project to get its root (the agents panel needs it) and name - // (window title). Opening is idempotent on the backend registry. + // Read the current focus at mount, then track changes. Never opens a project: + // the window is a pure follower of the main window's focus (#47). useEffect(() => { let cancelled = false; - project - .openProject(projectId) - .then((p) => { - if (!cancelled) setResolved(p); + let unsubscribe: (() => void) | undefined; + focusedProject + .getFocusedProject() + .then((project) => { + if (!cancelled) setFocus(project); }) - .catch((e: unknown) => { - if (!cancelled) setError(describeError(e)); + .catch(() => { + if (!cancelled) setFocus(null); + }); + void focusedProject + .onFocusedProjectChanged((project) => { + if (!cancelled) setFocus(project); + }) + .then((un) => { + if (cancelled) un(); + else unsubscribe = un; }); return () => { cancelled = true; + unsubscribe?.(); }; - }, [project, projectId]); + }, [focusedProject]); return (
@@ -76,35 +92,28 @@ export function ViewWindow({ panel, projectId }: ViewWindowProps) {

{PANEL_TITLE[panel]}

- {resolved && ( - · {resolved.name} + {focus && ( + · {focus.name} )}
- {error ? ( - -

Error: {error}

-
- ) : resolved ? ( + {focus ? ( + // Only mount a project-scoped panel when a project is actually focused + // — the panels require a real `projectRoot`, never a placeholder. ) : ( -
- - Loading… -
+ +

+ Ouvrez un projet dans la fenêtre principale pour afficher ce + panneau. +

+
)}
); } - -function describeError(e: unknown): string { - if (e && typeof e === "object" && "message" in e) { - return String((e as { message: unknown }).message); - } - return String(e); -} diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index 0d16e25..c6819d7 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -13,15 +13,16 @@ if (!root) { throw new Error("missing #root element"); } -// #23: a detached view window loads the SPA with `?panel=&project=`. When those -// params are present and valid, render only that view; otherwise the full app. +// #23/#47: a detached view window loads the SPA with `?panel=` (panel-only, no +// project id). When the param is present and valid, render only that view — it +// follows the main window's focused project; otherwise the full app. const viewParams = parseViewWindowParams(window.location.search); ReactDOM.createRoot(root).render( {viewParams ? ( - + ) : ( )} diff --git a/frontend/src/features/projects/ProjectsView.detach.test.tsx b/frontend/src/features/projects/ProjectsView.detach.test.tsx index ed5b71f..06b1e40 100644 --- a/frontend/src/features/projects/ProjectsView.detach.test.tsx +++ b/frontend/src/features/projects/ProjectsView.detach.test.tsx @@ -12,6 +12,7 @@ import { MockAgentGateway, MockGitGateway, MockProfileGateway, + MockFocusedProjectGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway, @@ -24,7 +25,7 @@ import { ProjectsView } from "./ProjectsView"; async function renderWithProject() { const project = new MockProjectGateway(); - const created = await project.createProject("alpha", "/p/a"); + await project.createProject("alpha", "/p/a"); const agentGateway = new MockAgentGateway(); const windowGateway = new MockWindowGateway(); const gateways = { @@ -36,6 +37,7 @@ async function renderWithProject() { git: new MockGitGateway(), workState: new MockWorkStateGateway(), window: windowGateway, + focusedProject: new MockFocusedProjectGateway(), } as unknown as Gateways; render( @@ -46,7 +48,7 @@ async function renderWithProject() { await waitFor(() => expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1), ); - return { windowGateway, projectId: created.id }; + return { windowGateway }; } /** @@ -62,7 +64,7 @@ function pickPlacement(panelTitle: string, placementLabel: string) { describe("ProjectsView detach to OS window (#23)", () => { it("« Fenêtre détachée » detaches a view: opens the OS window and marks the slot detached", async () => { - const { windowGateway, projectId } = await renderWithProject(); + const { windowGateway } = await renderWithProject(); // A docked view is visible in the main window first… pickPlacement("Git", "Ancré à gauche"); @@ -72,7 +74,7 @@ describe("ProjectsView detach to OS window (#23)", () => { pickPlacement("Git", "Fenêtre détachée"); await waitFor(() => - expect(windowGateway.open.has(`git|${projectId}`)).toBe(true), + expect(windowGateway.open.has("git")).toBe(true), ); // The slot is detached ⇒ nothing renders for Git in the main window. await waitFor(() => @@ -82,11 +84,11 @@ describe("ProjectsView detach to OS window (#23)", () => { }); it("re-toggles the placement out of detached when the OS window closes", async () => { - const { windowGateway, projectId } = await renderWithProject(); + const { windowGateway } = await renderWithProject(); pickPlacement("Git", "Fenêtre détachée"); await waitFor(() => - expect(windowGateway.open.has(`git|${projectId}`)).toBe(true), + expect(windowGateway.open.has("git")).toBe(true), ); // The « Fenêtre détachée » submenu leaf shows the active marker (●) while @@ -103,7 +105,7 @@ describe("ProjectsView detach to OS window (#23)", () => { fireEvent.click(screen.getByRole("button", { name: "Panneaux" })); // close // The backend reports the OS window closed ⇒ the slot leaves "detached". - windowGateway.simulateOsClose("git", projectId); + windowGateway.simulateOsClose("git"); openGitSubmenu(); await waitFor(() => expect(detachedLeaf().textContent).not.toContain("●"), diff --git a/frontend/src/features/projects/ProjectsView.focus.test.tsx b/frontend/src/features/projects/ProjectsView.focus.test.tsx new file mode 100644 index 0000000..acd47a9 --- /dev/null +++ b/frontend/src/features/projects/ProjectsView.focus.test.tsx @@ -0,0 +1,78 @@ +/** + * #47 — `ProjectsView` is the single writer of the focused-project channel: it + * publishes the active project (or `null` when none is active) so detached + * panel-only windows can follow it. This drives the open → publish → close → + * publish-null flow and asserts what reaches the {@link FocusedProjectGateway}. + */ +import { describe, it, expect } from "vitest"; +import { render, screen, within, waitFor, fireEvent } from "@testing-library/react"; + +import { + MockAgentGateway, + MockFocusedProjectGateway, + MockGitGateway, + MockProfileGateway, + MockProjectGateway, + MockSystemGateway, + MockTemplateGateway, + MockWindowGateway, + MockWorkStateGateway, +} from "@/adapters/mock"; +import type { FocusedProject, Gateways } from "@/ports"; +import { DIProvider } from "@/app/di"; +import { ProjectsView } from "./ProjectsView"; + +async function renderView() { + const project = new MockProjectGateway(); + const created = await project.createProject("alpha", "/p/a"); + const agentGateway = new MockAgentGateway(); + const focusedProject = new MockFocusedProjectGateway(); + // Record every published focus so we can assert the sequence. + const published: (FocusedProject | null)[] = []; + await focusedProject.onFocusedProjectChanged((p) => published.push(p)); + const gateways = { + system: new MockSystemGateway(), + project, + agent: agentGateway, + profile: new MockProfileGateway(), + template: new MockTemplateGateway(agentGateway), + git: new MockGitGateway(), + workState: new MockWorkStateGateway(), + window: new MockWindowGateway(), + focusedProject, + } as unknown as Gateways; + render( + + + , + ); + return { focusedProject, published, projectId: created.id }; +} + +describe("ProjectsView focus publishing (#47)", () => { + it("publishes the active project on open and null when it is closed", async () => { + const { focusedProject, published, projectId } = await renderView(); + + // Mount with no active project publishes a `null` focus. + await waitFor(() => expect(published).toContainEqual(null)); + + // Open the project → publishes the { id, name, root } snapshot. + fireEvent.click(await screen.findByRole("button", { name: "Open" })); + await waitFor(() => + expect(within(screen.getByRole("tablist")).getAllByRole("tab")).toHaveLength(1), + ); + await waitFor(() => + expect(focusedProject.getFocusedProject()).resolves.toEqual({ + id: projectId, + name: "alpha", + root: "/p/a", + }), + ); + + // Close the tab → back to no active project → publishes `null`. + fireEvent.click(screen.getByRole("button", { name: "close alpha" })); + await waitFor(() => + expect(focusedProject.getFocusedProject()).resolves.toBeNull(), + ); + }); +}); diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index f37fc25..3b498b7 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -102,7 +102,7 @@ function isTerminalBackgroundTaskEvent( export function ProjectsView() { const vm = useProjects(); - const { system, window: windowGateway } = useGateways(); + const { system, window: windowGateway, focusedProject } = useGateways(); const [name, setName] = useState(""); const [root, setRoot] = useState(""); // Placement of every open view (#22): each panel is "closed" (absent), @@ -141,6 +141,17 @@ export function ProjectsView() { setViewerConversationId(null); }, [active?.id]); + // Publish the focused project (#47) so detached panel-only windows follow the + // main window: they render this project, or an "open a project" shell when + // none is active. Publish on EVERY change of `active` — including + // switching-away to null — so a restored panel window never shows a stale + // project. Optional-chained: unit tests may omit this gateway. + useEffect(() => { + void focusedProject?.setFocusedProject( + active ? { id: active.id, name: active.name, root: active.root } : null, + ); + }, [focusedProject, active?.id, active?.name, active?.root]); + useEffect(() => { let unsubscribe: (() => void) | undefined; let cancelled = false; @@ -227,15 +238,16 @@ export function ProjectsView() { }); } - // Detach a view into its own OS window (#23): ask the backend to open the - // window, then mark the slot "detached" so nothing renders for it in the main - // window. Requires an active project (the window is project-scoped). On - // failure the placement is left untouched (no ghost "detached" slot). + // Detach a view into its own OS window (#23, panel-only in #47): ask the + // backend to open the window, then mark the slot "detached" so nothing renders + // for it in the main window. The window is panel-only and follows the focused + // project (published above), so no project id is passed. Requires an active + // project only so a detached window opens onto something rather than the empty + // shell. On failure the placement is left untouched (no ghost "detached" slot). function detachPanel(panel: PanelId) { if (!active) return; - const projectId = active.id; void windowGateway - .openViewWindow(panel, projectId) + .openViewWindow(panel) .then(() => setPlacement(panel, "detached")) .catch(() => { /* window failed to open; keep the current placement */ diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index e2fa381..9cb8eee 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -937,40 +937,84 @@ export interface TicketGateway { * Payload of the OS window-lifecycle event that fires when a detached view * window closes (ticket #23). Lets the main window re-toggle the view's * placement out of `"detached"`. + * + * Ticket #47: a detached window is now **panel-only** and follows the main + * window's focused project rather than embedding a project id, so the close + * event no longer carries a `projectId` — the placement is keyed by `panel`. */ export interface ViewWindowClosed { /** The detached panel id (a `PanelId`, kept as `string` at the port seam). */ panel: string; - /** The project the detached window was showing. */ - projectId: string; } /** - * Detaching a View into its own OS window (ticket #23). + * Detaching a View into its own OS window (ticket #23, reworked in #47). * * The backend owns the Tauri `WebviewWindow` lifecycle (create/focus/close, * anti-duplicate registry). This port is the UI's only door to it — components * never call `invoke()` directly. `panel` is a `PanelId`-shaped string; it is * typed as `string` here so ports need not depend on the `features/` layer. + * + * A detached window is **panel-only**: it no longer carries a project id and + * instead follows the focused project published through + * {@link FocusedProjectGateway}. Windows are therefore keyed by `panel` alone. */ export interface WindowGateway { /** * Opens — or focuses, if already open — a separate OS window rendering only - * `panel` for `projectId` (the backend's `open_view_window` command). + * `panel` (the backend's `open_view_window` command). The window follows the + * main window's focused project; no project id is passed. */ - openViewWindow(panel: string, projectId: string): Promise; - /** Closes the detached window for `panel`/`projectId` if one is open. */ - closeViewWindow(panel: string, projectId: string): Promise; + openViewWindow(panel: string): Promise; + /** Closes the detached window for `panel` if one is open. */ + closeViewWindow(panel: string): Promise; /** * Subscribes to detached-window close events (OS close or programmatic). The - * handler receives the `{ panel, projectId }` that closed. Returns an - * unsubscribe. + * handler receives the `{ panel }` that closed. Returns an unsubscribe. */ onViewWindowClosed( handler: (event: ViewWindowClosed) => void, ): Promise; } +/** + * A focused-project snapshot (ticket #47). Mirrors the backend + * `FocusedProjectDto` — the minimal identity a panel-only window needs to mount + * a project-scoped view (id for the read-models, name for chrome, root as the + * agents panel's cwd base). + */ +export interface FocusedProject { + id: string; + name: string; + root: string; +} + +/** + * The focused-project channel between the main window and detached panel-only + * windows (ticket #47). + * + * The main window is the single writer: it publishes the currently active + * project (or `null` when none is open) via {@link setFocusedProject}. Detached + * windows are readers: they read the current focus at mount + * ({@link getFocusedProject}) then track changes via + * {@link onFocusedProjectChanged}. This decouples a restored panel window from + * any stale embedded project id — with no focus it shows an "open a project" + * shell and mounts no project-scoped panel. + */ +export interface FocusedProjectGateway { + /** Publishes the focused project (or `null` when no project is active). */ + setFocusedProject(project: FocusedProject | null): Promise; + /** Reads the current focused project; `null` when none is focused. */ + getFocusedProject(): Promise; + /** + * Subscribes to focused-project changes. The handler receives the new focus + * (or `null`). Returns an unsubscribe. + */ + onFocusedProjectChanged( + handler: (project: FocusedProject | null) => void, + ): Promise; +} + /** * Thin UI-preferences port (ticket #29). Persists small, per-surface pieces of * UI state (like the ticket filters) so they survive a restart / reopen. This is @@ -1018,5 +1062,6 @@ export interface Gateways { conversation: ConversationGateway; ticket: TicketGateway; window: WindowGateway; + focusedProject: FocusedProjectGateway; uiPreferences: UiPreferencesGateway; }