diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 7e5cd78c..01b7f1f3 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -4440,9 +4440,9 @@ pub struct FocusedProjectChangedEventDto { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ViewPanel { - Projects, Context, Work, + ScheduledTasks, Tickets, Mcp, Agents, @@ -4456,9 +4456,9 @@ pub(crate) enum ViewPanel { impl ViewPanel { pub(crate) fn parse(raw: &str) -> Result { match raw { - "projects" => Ok(Self::Projects), "context" => Ok(Self::Context), "work" => Ok(Self::Work), + "scheduledTasks" => Ok(Self::ScheduledTasks), "tickets" => Ok(Self::Tickets), "mcp" => Ok(Self::Mcp), "agents" => Ok(Self::Agents), @@ -4476,9 +4476,9 @@ impl ViewPanel { pub(crate) const fn as_str(self) -> &'static str { match self { - Self::Projects => "projects", Self::Context => "context", Self::Work => "work", + Self::ScheduledTasks => "scheduledTasks", Self::Tickets => "tickets", Self::Mcp => "mcp", Self::Agents => "agents", @@ -4492,9 +4492,9 @@ impl ViewPanel { pub(crate) const fn title(self) -> &'static str { match self { - Self::Projects => "Projects", Self::Context => "Project context", Self::Work => "Work state", + Self::ScheduledTasks => "Scheduled tasks", Self::Tickets => "Tickets", Self::Mcp => "MCP", Self::Agents => "Agents", @@ -4814,6 +4814,25 @@ mod view_window_tests { assert_eq!(view_panel_from_window_label("view-mcp"), Some(panel)); } + #[test] + fn scheduled_tasks_view_window_contract_is_stable() { + let panel = ViewPanel::parse("scheduledTasks").unwrap(); + + assert_eq!(panel, ViewPanel::ScheduledTasks); + assert_eq!(panel.as_str(), "scheduledTasks"); + assert_eq!(panel.title(), "Scheduled tasks"); + assert_eq!(view_window_label(panel), "view-scheduledTasks"); + assert_eq!(view_window_url(panel), "index.html?panel=scheduledTasks"); + assert_eq!( + view_panel_from_window_label("view-scheduledTasks"), + Some(panel) + ); + assert_eq!( + view_panel_from_window_label("view-scheduledTasks-0000000000000000000000000000002a"), + Some(panel) + ); + } + #[test] fn view_window_rejects_unknown_panel() { let err = ViewPanel::parse("terminal").unwrap_err(); @@ -4822,6 +4841,15 @@ mod view_window_tests { assert!(err.message.contains("invalid view panel: terminal")); } + #[test] + fn projects_panel_is_not_detachable() { + let err = ViewPanel::parse("projects").unwrap_err(); + + assert_eq!(err.code, "INVALID"); + assert!(err.message.contains("invalid view panel: projects")); + assert!(view_panel_from_window_label("view-projects").is_none()); + } + #[test] fn view_window_label_parser_accepts_stable_and_legacy_labels() { assert_eq!( diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index e9f3d080..482c013b 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -1132,6 +1132,26 @@ mod tests { ); } + #[test] + fn persisted_identity_accepts_scheduled_tasks_view_labels() { + let (kind, panel, project_id, plugin_layout, url) = + persisted_window_identity_from_label("view-scheduledTasks").unwrap(); + + assert_eq!(kind, PersistedWindowKind::View); + assert_eq!(panel.as_deref(), Some("scheduledTasks")); + assert!(project_id.is_none()); + assert!(plugin_layout.is_none()); + assert_eq!(url.as_deref(), Some("index.html?panel=scheduledTasks")); + assert_eq!( + persisted_view_identity_from_label( + "view-scheduledTasks-0000000000000000000000000000002a" + ) + .unwrap() + .as_str(), + "scheduledTasks" + ); + } + #[test] fn persisted_identity_accepts_plugin_layout_view_labels() { let plugin_id = domain::PluginId::new("dev.idea.android-plugin").unwrap(); @@ -1157,6 +1177,7 @@ mod tests { fn persisted_identity_filters_unknown_or_headless_labels() { assert!(persisted_window_identity_from_label("mcp-server").is_none()); assert!(persisted_window_identity_from_label("settings").is_none()); + assert!(persisted_window_identity_from_label("view-projects").is_none()); assert!(persisted_window_identity_from_label( "view-unknown-0000000000000000000000000000002a" ) diff --git a/frontend/src/adapters/window.test.ts b/frontend/src/adapters/window.test.ts index 458e4990..8a9a95a7 100644 --- a/frontend/src/adapters/window.test.ts +++ b/frontend/src/adapters/window.test.ts @@ -41,6 +41,14 @@ describe("TauriWindowGateway (#23)", () => { }); }); + it("passes scheduledTasks unchanged to the detached-window command", async () => { + await new TauriWindowGateway().openViewWindow("scheduledTasks"); + + expect(invoke).toHaveBeenCalledWith("open_view_window", { + panel: "scheduledTasks", + }); + }); + it("opens plugin layout windows through the backend command", async () => { invoke.mockResolvedValueOnce({ label: "view-plugin-layout", diff --git a/frontend/src/app/ViewWindow.test.tsx b/frontend/src/app/ViewWindow.test.tsx index 23ca8df2..36700be7 100644 --- a/frontend/src/app/ViewWindow.test.tsx +++ b/frontend/src/app/ViewWindow.test.tsx @@ -10,6 +10,7 @@ import { render, screen, waitFor, act, fireEvent } from "@testing-library/react" import { DIProvider } from "@/app/di"; import { + createMockGateways, MockAgentGateway, MockFocusedProjectGateway, MockProjectGateway, @@ -66,6 +67,13 @@ describe("parseViewWindowParams (#47)", () => { }); }); + it("accepts the detachable scheduled tasks panel", () => { + expect(parseViewWindowParams("?panel=scheduledTasks")).toEqual({ + kind: "panel", + panel: "scheduledTasks", + }); + }); + it("parses a plugin layout window query from the backend base64url contract", () => { expect( parseViewWindowParams( @@ -170,6 +178,29 @@ describe("PluginLayoutWindow (#143)", () => { }); describe("ViewWindow (#47)", () => { + it("renders ScheduledTasksPanel for a focused project", async () => { + const gateways = createMockGateways(); + const project = await gateways.project.createProject("alpha", "/p/a"); + const focusedProject = new MockFocusedProjectGateway(); + gateways.focusedProject = focusedProject; + await focusedProject.setFocusedProject({ + id: project.id, + name: project.name, + root: project.root, + }); + + render( + + + , + ); + + await waitFor(() => expect(screen.getByText("· alpha")).toBeTruthy()); + expect(screen.getByRole("heading", { name: "Tâches planifiées", level: 1 })).toBeTruthy(); + expect(await screen.findByRole("button", { name: "Nouvelle tâche planifiée" })).toBeTruthy(); + expect(await screen.findByText("Aucune tâche planifiée pour ce projet")).toBeTruthy(); + }); + it("keeps the latest focused project when the initial snapshot is delayed", async () => { const system = new MockSystemGateway(); const project = new MockProjectGateway(); diff --git a/frontend/src/features/projects/ProjectsView.detach.test.tsx b/frontend/src/features/projects/ProjectsView.detach.test.tsx index 0f82b02e..564e5581 100644 --- a/frontend/src/features/projects/ProjectsView.detach.test.tsx +++ b/frontend/src/features/projects/ProjectsView.detach.test.tsx @@ -92,6 +92,17 @@ describe("ProjectsView detach to OS window (#23)", () => { expect(screen.queryByRole("dialog", { name: "MCP" })).toBeNull(); }); + it("detaches scheduled tasks through WindowGateway", async () => { + const { windowGateway } = await renderWithProject(); + const openViewWindow = vi.spyOn(windowGateway, "openViewWindow"); + + pickPlacement("Tâches planifiées", "Fenêtre détachée"); + + await waitFor(() => expect(openViewWindow).toHaveBeenCalledWith("scheduledTasks")); + expect(windowGateway.open.has("scheduledTasks")).toBe(true); + expect(screen.queryByRole("dialog", { name: "Tâches planifiées" })).toBeNull(); + }); + it("detaches project settings through the same retry-safe window path", async () => { const { windowGateway } = await renderWithProject();