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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-03 00:19:15 -07:00
parent 96b2df115e
commit 95325201a6
7 changed files with 210 additions and 5 deletions

View File

@@ -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.

View File

@@ -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.
<!-- FNXC:MobileTaskPopups 2026-07-01-12:00: Board-card task opens have a separate default-off popup setting so operators on desktop, tablet, and mobile can opt into the existing FloatingWindow task popup when they want the board visible; ordinary popup routing takes precedence over the right dock, while deep-tab and non-board opens 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. Deep `changes`/`retries`/`workflow` opens, list/task-detail links, plugin/graph opens, and explicit pop-out actions keep their existing paths.
<!-- FNXC:MobileTaskPopups 2026-07-01-12:00: Board-card task opens have a separate default-off popup setting so operators on desktop, tablet, and mobile can opt into the existing FloatingWindow task popup when they want the board visible; ordinary popup routing takes precedence over the right dock, while deep-tab and non-board opens keep their existing paths.
FNXC:TaskPopupGeometry 2026-07-03-00:00: Desktop/tablet task popups share a persisted geometry key across task IDs so operators can size and place the popup once, then open other tasks without repeating that setup. Mobile remains a full-screen sheet regardless of saved desktop geometry. -->
**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.
<!-- FNXC:DashboardNavigationDocs 2026-06-27-00:00: The right dock now hosts Chat as an inline tool panel; keep this user-facing roster aligned with STATIC_OVERFLOW_VIEW_ENTRIES so users know Chat can also pop out from the dock. -->
<!-- FNXC:RightDockTasks 2026-06-28-19:55: The dock task-detail overlay is now anchored to the first-class Tasks tool tab. Document that Tasks is a dock-only auxiliary surface with a last-viewed detail/list fallback, not a new primary navigation destination. -->

View File

@@ -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/<task-id>-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. |

View File

@@ -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}
>
<TaskDetailContent
task={liveTask}

View File

@@ -0,0 +1,111 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, beforeEach } from "vitest";
import { TASK_DETAIL_FLOATING_GEOMETRY_KEY } from "../App";
import { FloatingWindow } from "../components/FloatingWindow";
function renderTaskDetailPopup(taskId: string) {
return render(
<FloatingWindow
windowKey={`task-detail-${taskId}`}
title={taskId}
onClose={() => {}}
hideHeader
dragHandleSelector=".task-detail-content--embedded > .modal-header"
className="floating-window--task-detail"
persistGeometryKey={TASK_DETAIL_FLOATING_GEOMETRY_KEY}
>
<div className="task-detail-content--embedded">
<div className="modal-header">{taskId}</div>
<div>Task detail body</div>
</div>
</FloatingWindow>
);
}
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(
<>
<FloatingWindow
windowKey="task-detail-FN-7459"
title="FN-7459"
onClose={() => {}}
className="floating-window--task-detail"
persistGeometryKey={TASK_DETAIL_FLOATING_GEOMETRY_KEY}
>
<div>task detail body</div>
</FloatingWindow>
<FloatingWindow
windowKey="mission"
title="Mission"
onClose={() => {}}
persistGeometryKey="floating-window:mission-interview"
>
<div>mission body</div>
</FloatingWindow>
</>
);
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");
});
});

View File

@@ -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;

View File

@@ -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(
<FloatingWindow windowKey="alpha" title="Alpha" onClose={() => {}}>
@@ -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(
<FloatingWindow
windowKey="malformed"
title="Malformed"
onClose={() => {}}
persistGeometryKey="floating-window:malformed"
defaultSize={{ width: 610, height: 430 }}
defaultPosition={{ x: 80, y: 90 }}
>
<div>malformed body</div>
</FloatingWindow>
);
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(
<>
<FloatingWindow
windowKey="task-detail-FN-001"
title="FN-001"
onClose={() => {}}
persistGeometryKey="floating-window:shared-task-detail"
>
<div>task one</div>
</FloatingWindow>
<FloatingWindow
windowKey="task-detail-FN-002"
title="FN-002"
onClose={() => {}}
persistGeometryKey="floating-window:shared-task-detail"
>
<div>task two</div>
</FloatingWindow>
<FloatingWindow windowKey="chat" title="Chat" onClose={() => {}} persistGeometryKey="floating-window:chat">
<div>chat body</div>
</FloatingWindow>
</>
);
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];