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:
7
.changeset/fn-7459-task-popup-geometry.md
Normal file
7
.changeset/fn-7459-task-popup-geometry.md
Normal 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.
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user