From 95325201a6cdd537c09e96a7c7f7ceafbce3f77c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 3 Jul 2026 00:19:15 -0700 Subject: [PATCH] FN-7459: remember task popup geometry Remember task-detail popup geometry so operators can reuse one positioned window across tasks. - Share a stable persisted geometry key for task-detail FloatingWindow instances while keeping per-task window identity. - Add regression coverage for shared task popup geometry, malformed persisted data fallback, and isolated non-task windows. - Document desktop/tablet popup geometry persistence and add a release changeset. Files changed: .changeset/fn-7459-task-popup-geometry.md | 7 ++ docs/dashboard-guide.md | 5 +- docs/settings-reference.md | 2 +- packages/dashboard/app/App.tsx | 5 + .../App.taskDetailFloatingGeometry.test.tsx | 111 +++++++++++++++++++++ .../dashboard/app/components/FloatingWindow.tsx | 2 +- .../components/__tests__/FloatingWindow.test.tsx | 83 ++++++++++++++- 7 files changed, 210 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-7459 Fusion-Task-Lineage: 84674283-024f-4ed5-b7a0-6dade1b77f8b Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7459-task-popup-geometry.md | 7 ++ docs/dashboard-guide.md | 5 +- docs/settings-reference.md | 2 +- packages/dashboard/app/App.tsx | 5 + .../App.taskDetailFloatingGeometry.test.tsx | 111 ++++++++++++++++++ .../app/components/FloatingWindow.tsx | 2 +- .../__tests__/FloatingWindow.test.tsx | 83 ++++++++++++- 7 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 .changeset/fn-7459-task-popup-geometry.md create mode 100644 packages/dashboard/app/__tests__/App.taskDetailFloatingGeometry.test.tsx diff --git a/.changeset/fn-7459-task-popup-geometry.md b/.changeset/fn-7459-task-popup-geometry.md new file mode 100644 index 0000000000..cceb739a16 --- /dev/null +++ b/.changeset/fn-7459-task-popup-geometry.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Remember task popup size and position when switching between tasks. +category: fix +dev: Task-detail FloatingWindow instances share the `floating-window:task-detail` geometry key. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 960ba07377..6ae7c2d196 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -56,8 +56,9 @@ When enabled on desktop or tablet project screens, the right dock is a persisten If **Settings → Appearance → Open tasks in the right sidebar** is enabled, board task-card clicks open task detail inside this right dock and keep the board visible. The setting is default off; mobile or hidden/inactive dock states automatically fall back to the existing full-panel task detail unless the task-popup setting below is enabled, and non-board task-open paths keep their existing behavior. - -**Settings → Appearance → Open tasks as popups** changes only ordinary board task-card clicks across desktop, tablet, and mobile viewports. When enabled, those clicks use the existing task popup/FloatingWindow surface instead of the full-panel task detail or right-dock task detail, keeping the board visible in the background. Deep `changes`/`retries`/`workflow` opens, list/task-detail links, plugin/graph opens, and explicit pop-out actions keep their existing paths. + +**Settings → Appearance → Open tasks as popups** changes only ordinary board task-card clicks across desktop, tablet, and mobile viewports. When enabled, those clicks use the existing task popup/FloatingWindow surface instead of the full-panel task detail or right-dock task detail, keeping the board visible in the background. On desktop and tablet, task popups restore the last saved popup size and position between tasks; on mobile, task popups stay full-screen sheets. Deep `changes`/`retries`/`workflow` opens, list/task-detail links, plugin/graph opens, and explicit pop-out actions keep their existing paths. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 6a3c23c6f7..fe146700cb 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -479,7 +479,7 @@ Sandbox backend precedence is: | `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. | | `showWorktreeGrouping` | `boolean` | `false` | Default: off. When off, WIP/processing columns render plain task cards without worktree group shells or worktree-name labels in both legacy and workflow-mode boards. When on, every WIP/processing column groups tasks by worktree and shows worktree names, including workflow-mode columns flagged as counting toward WIP. | | `openTasksInRightSidebar` | `boolean` | `false` | Default: off. When off, board task-card clicks keep the existing full-panel task detail that replaces the board. When on and the right dock is active on desktop/tablet, board task-card clicks open the task detail in the right sidebar so the board stays visible; mobile or hidden/inactive right-dock states automatically fall back to the full-panel behavior. Non-board task-open paths, including list split detail, right-dock task cards, floating pop-outs, graph/plugin opens, and deep `changes`/`retries`/`workflow` opens, keep their existing behavior. | -| `openMobileTasksInPopup` | `boolean` | `false` | Default: off. When off, ordinary board task-card clicks keep the existing fallback behavior: the full-panel task detail, or the right dock when `openTasksInRightSidebar` is on and the dock is active. When on, ordinary board task-card clicks open the task in the existing task popup/FloatingWindow surface on desktop, tablet, and mobile so the board remains visible; this popup route takes precedence over right-dock routing for those ordinary clicks. This setting remains board-only: right-dock task cards, list/detail opens, graph/plugin opens, nested task-detail opens, explicit pop-out actions, and deep `changes`/`retries`/`workflow` opens keep their existing behavior. | +| `openMobileTasksInPopup` | `boolean` | `false` | Default: off. When off, ordinary board task-card clicks keep the existing fallback behavior: the full-panel task detail, or the right dock when `openTasksInRightSidebar` is on and the dock is active. When on, ordinary board task-card clicks open the task in the existing task popup/FloatingWindow surface on desktop, tablet, and mobile so the board remains visible; this popup route takes precedence over right-dock routing for those ordinary clicks. Desktop/tablet task popups restore the last saved popup size and position across task IDs, while mobile task popups remain full-screen sheets. This setting remains board-only: right-dock task cards, list/detail opens, graph/plugin opens, nested task-detail opens, explicit pop-out actions, and deep `changes`/`retries`/`workflow` opens keep their existing behavior. | | `executorAllowSiblingBranchRename` | `boolean` | `false` | Opt back into the legacy executor behavior that silently allocates sibling branches (`fusion/-2`, `-2-2`, …) when the canonical task branch is already checked out elsewhere. When disabled (default), branch conflicts fail loudly and leave the task in `todo` with `status: "failed"` so operators can resolve conflicting branches/worktrees with git tooling before retrying. See [Task Management → Branch conflict handling](./task-management.md#branch-conflict-handling). The dashboard Settings modal exposes the same toggle with warning copy because this legacy mode is discouraged. | | `worktreeNaming` | `"random" \| "task-id" \| "task-title"` | `"random"` | Naming mode for new worktree directories. | diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index b640d9dd21..aec7296195 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -117,6 +117,7 @@ import type { GraphWorkflowSelection } from "./components/GraphWorkflowSwitcherS import "./components/ChatView.css"; const IS_TEST_ENV = import.meta.env.MODE === "test"; +export const TASK_DETAIL_FLOATING_GEOMETRY_KEY = "floating-window:task-detail"; const AgentsView = lazy(() => import("./components/AgentsView").then((m) => ({ default: m.AgentsView }))); const DocumentsView = lazy(() => import("./components/DocumentsView").then((m) => ({ default: m.DocumentsView }))); @@ -1633,6 +1634,9 @@ function AppInner() { FNXC:TaskDetail 2026-06-22-12:20: Task pop-outs use TaskDetailContent's own gray header as the only visible header, matching the one-header fixed task modal while keeping FloatingWindow drag/resize. The generic Maximize title chrome is hidden; close now lives beside edit inside the task header. + + FNXC:TaskPopupGeometry 2026-07-03-00:00: + Every task-detail FloatingWindow keeps its per-task windowKey for DOM identity, dedupe, cascade fallback, and z-index independence, but all task-detail popups share one persisted geometry key so operators do not resize or reposition the popup between tasks. */} {poppedOutTasks.map((snapshot) => { const liveTask = tasks.find((candidate) => candidate.id === snapshot.id) ?? snapshot; @@ -1646,6 +1650,7 @@ function AppInner() { hideHeader dragHandleSelector=".task-detail-content--embedded > .modal-header" className="floating-window--task-detail" + persistGeometryKey={TASK_DETAIL_FLOATING_GEOMETRY_KEY} > {}} + hideHeader + dragHandleSelector=".task-detail-content--embedded > .modal-header" + className="floating-window--task-detail" + persistGeometryKey={TASK_DETAIL_FLOATING_GEOMETRY_KEY} + > +
+
{taskId}
+
Task detail body
+
+ + ); +} + +describe("task-detail FloatingWindow geometry", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("uses one stable task-detail persistence key across different task window identities", () => { + expect(TASK_DETAIL_FLOATING_GEOMETRY_KEY).toBe("floating-window:task-detail"); + }); + + it("restores the saved task popup size and position when a different task opens", () => { + localStorage.setItem( + TASK_DETAIL_FLOATING_GEOMETRY_KEY, + JSON.stringify({ + size: { width: 684, height: 512 }, + position: { x: 144, y: 88 }, + }), + ); + + const first = renderTaskDetailPopup("FN-7459-A"); + const firstPanel = screen.getByTestId("floating-window-task-detail-FN-7459-A"); + expect(firstPanel.style.width).toBe("684px"); + expect(firstPanel.style.height).toBe("512px"); + expect(firstPanel.style.left).toBe("144px"); + expect(firstPanel.style.top).toBe("88px"); + + first.unmount(); + renderTaskDetailPopup("FN-7459-B"); + + const secondPanel = screen.getByTestId("floating-window-task-detail-FN-7459-B"); + expect(secondPanel.style.width).toBe("684px"); + expect(secondPanel.style.height).toBe("512px"); + expect(secondPanel.style.left).toBe("144px"); + expect(secondPanel.style.top).toBe("88px"); + expect(secondPanel).toHaveClass("floating-window--task-detail"); + }); + + it("keeps task-detail geometry isolated from non-task FloatingWindow keys", () => { + localStorage.setItem( + TASK_DETAIL_FLOATING_GEOMETRY_KEY, + JSON.stringify({ + size: { width: 650, height: 490 }, + position: { x: 118, y: 76 }, + }), + ); + localStorage.setItem( + "floating-window:mission-interview", + JSON.stringify({ + size: { width: 540, height: 420 }, + position: { x: 210, y: 120 }, + }), + ); + + render( + <> + {}} + className="floating-window--task-detail" + persistGeometryKey={TASK_DETAIL_FLOATING_GEOMETRY_KEY} + > +
task detail body
+
+ {}} + persistGeometryKey="floating-window:mission-interview" + > +
mission body
+
+ + ); + + const taskPanel = screen.getByTestId("floating-window-task-detail-FN-7459"); + expect(taskPanel.style.width).toBe("650px"); + expect(taskPanel.style.height).toBe("490px"); + expect(taskPanel.style.left).toBe("118px"); + expect(taskPanel.style.top).toBe("76px"); + + const missionPanel = screen.getByTestId("floating-window-mission"); + expect(missionPanel.style.width).toBe("540px"); + expect(missionPanel.style.height).toBe("420px"); + expect(missionPanel.style.left).toBe("210px"); + expect(missionPanel.style.top).toBe("120px"); + }); +}); diff --git a/packages/dashboard/app/components/FloatingWindow.tsx b/packages/dashboard/app/components/FloatingWindow.tsx index b660158471..c688745a5e 100644 --- a/packages/dashboard/app/components/FloatingWindow.tsx +++ b/packages/dashboard/app/components/FloatingWindow.tsx @@ -364,7 +364,7 @@ export function FloatingWindow({ /* FNXC:ChatModal 2026-06-22-14:57: - Quick Chat reopens should restore the last desktop floating-window size and position while still clamping onto the current viewport. Keep persistence generic for other FloatingWindow callers, but opt in with persistGeometryKey so existing task pop-outs remain ephemeral. + Quick Chat reopens should restore the last desktop floating-window size and position while still clamping onto the current viewport. Keep persistence generic and opt-in with persistGeometryKey so each caller controls whether geometry is shared or isolated. */ useEffect(() => { if (!persistGeometryKey || typeof window === "undefined") return; diff --git a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx index 6393615d0f..071bba0dbd 100644 --- a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx +++ b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx @@ -1,6 +1,6 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { readFileSync } from "node:fs"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { FloatingWindow } from "../FloatingWindow"; const floatingWindowCss = readFileSync("app/components/FloatingWindow.css", "utf8"); @@ -16,6 +16,9 @@ JSDOM has no real layout/pointer-capture, so drag math is asserted in the RightD */ describe("FloatingWindow", () => { + beforeEach(() => { + localStorage.clear(); + }); it("renders a non-blocking, click-through transparent overlay with a pointer-events:auto panel", () => { render( {}}> @@ -299,6 +302,84 @@ describe("FloatingWindow", () => { expect(Number.parseFloat(panel.style.left)).toBeLessThan(window.innerWidth); }); + it("falls back to default geometry when persisted geometry is malformed", () => { + localStorage.setItem("floating-window:malformed", "not-json"); + + render( + {}} + persistGeometryKey="floating-window:malformed" + defaultSize={{ width: 610, height: 430 }} + defaultPosition={{ x: 80, y: 90 }} + > +
malformed body
+
+ ); + + const panel = screen.getByTestId("floating-window-malformed"); + expect(panel.style.width).toBe("610px"); + expect(panel.style.height).toBe("430px"); + expect(panel.style.left).toBe("80px"); + expect(panel.style.top).toBe("90px"); + }); + + it("shares geometry only between windows that opt into the same persistence key", () => { + localStorage.setItem( + "floating-window:shared-task-detail", + JSON.stringify({ + size: { width: 660, height: 470 }, + position: { x: 120, y: 96 }, + }), + ); + localStorage.setItem( + "floating-window:chat", + JSON.stringify({ + size: { width: 520, height: 390 }, + position: { x: 220, y: 140 }, + }), + ); + + render( + <> + {}} + persistGeometryKey="floating-window:shared-task-detail" + > +
task one
+
+ {}} + persistGeometryKey="floating-window:shared-task-detail" + > +
task two
+
+ {}} persistGeometryKey="floating-window:chat"> +
chat body
+
+ + ); + + for (const id of ["FN-001", "FN-002"]) { + const panel = screen.getByTestId(`floating-window-task-detail-${id}`); + expect(panel.style.width).toBe("660px"); + expect(panel.style.height).toBe("470px"); + expect(panel.style.left).toBe("120px"); + expect(panel.style.top).toBe("96px"); + } + + const chatPanel = screen.getByTestId("floating-window-chat"); + expect(chatPanel.style.width).toBe("520px"); + expect(chatPanel.style.height).toBe("390px"); + expect(chatPanel.style.left).toBe("220px"); + expect(chatPanel.style.top).toBe("140px"); + }); + it("makes only the mobile chat floating window full-screen", () => { const mobileBlock = floatingWindowCss.match(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.floating-window--chat \.chat-view\s*\{[\s\S]*?\n\}/)?.[0];