From 7cec0780542d0d34692c76b0961ffb036ec2fa1b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 19:03:33 -0700 Subject: [PATCH] FN-8016: scope task popups to their opening view Scope task-detail popups to their origin dashboard view by default. - Default per-view popup scoping while retaining a legacy global-popup opt-out. - Key popup lifecycle, navigation, and Escape dismissal by task and origin view. - Update settings copy, documentation, localization, and regression coverage. Files changed: .changeset/fn-8016-task-popup-view-scoping.md | 7 ++ docs/dashboard-guide.md | 4 +- .../core/src/__tests__/settings-defaults.test.ts | 4 +- packages/core/src/settings-schema.ts | 6 +- packages/core/src/types.ts | 6 +- packages/dashboard/app/App.tsx | 67 ++++++----- .../app/__tests__/App.keyboard-shortcuts.test.tsx | 14 ++- .../app/__tests__/App.taskPopupViewGating.test.tsx | 125 +++++++-------------- .../dashboard/app/components/SettingsModal.tsx | 2 +- .../settings/sections/AppearanceSection.tsx | 6 +- .../sections/__tests__/AppearanceSection.test.tsx | 18 ++- .../app/hooks/__tests__/useAppSettings.test.ts | 15 +++ .../app/hooks/__tests__/usePoppedOutTasks.test.ts | 28 ++--- packages/dashboard/app/hooks/useAppSettings.ts | 8 +- packages/dashboard/app/hooks/usePoppedOutTasks.ts | 14 +-- packages/i18n/locales/en/app.json | 4 +- packages/i18n/src/resources.d.ts | 4 +- 17 files changed, 158 insertions(+), 174 deletions(-) Fusion-Task-Id: FN-8016 Fusion-Task-Lineage: e33beeae-0ce3-4202-95dc-6fb2d26f9770 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8016-task-popup-view-scoping.md | 7 + docs/dashboard-guide.md | 4 +- .../src/__tests__/settings-defaults.test.ts | 4 +- packages/core/src/settings-schema.ts | 6 +- packages/core/src/types.ts | 6 +- packages/dashboard/app/App.tsx | 67 +++++----- .../__tests__/App.keyboard-shortcuts.test.tsx | 14 +- .../App.taskPopupViewGating.test.tsx | 125 ++++++------------ .../app/components/SettingsModal.tsx | 2 +- .../settings/sections/AppearanceSection.tsx | 6 +- .../__tests__/AppearanceSection.test.tsx | 18 ++- .../hooks/__tests__/useAppSettings.test.ts | 15 +++ .../hooks/__tests__/usePoppedOutTasks.test.ts | 28 ++-- .../dashboard/app/hooks/useAppSettings.ts | 8 +- .../dashboard/app/hooks/usePoppedOutTasks.ts | 14 +- packages/i18n/locales/en/app.json | 4 +- packages/i18n/src/resources.d.ts | 4 +- 17 files changed, 158 insertions(+), 174 deletions(-) create mode 100644 .changeset/fn-8016-task-popup-view-scoping.md diff --git a/.changeset/fn-8016-task-popup-view-scoping.md b/.changeset/fn-8016-task-popup-view-scoping.md new file mode 100644 index 0000000000..7e24596e8f --- /dev/null +++ b/.changeset/fn-8016-task-popup-view-scoping.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Task-detail popups now open in — and stay scoped to — the view where you opened them. +category: fix +dev: isTaskPopupVisibleForView now scopes by origin view for all views (not just Board/List); taskPopupsBoardListOnly defaults on and popups dedupe per (task id, origin view) so the same task can open independently in multiple views. Escape/keyboard close carries (taskId, originView) identity. FN-8016. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index bc432f1351..54ac5ea0d5 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -113,10 +113,10 @@ If **Settings → Appearance → Open tasks in the right sidebar** is enabled, b 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. FNXC:RightDockTaskPopup 2026-07-03-00:00: The same task-popup preference also applies to ordinary clicks in the right-dock Tasks list so that opted-in operators never lose the list to embedded detail by clicking a dock task row. FNXC:TaskPopupLayer 2026-07-04-18:36: Ordinary task popups sit on the board/task-detail layer instead of the global floating-utility stack so the board/list context remains visually primary while Terminal, Quick Chat, file browser, workflow editor, and other utility windows keep their top utility stacking. -FNXC:TaskPopupViewGating 2026-07-13-00:00: The default-off popup view attachment setting must be documented as render-only hiding, not closing, because popup snapshots and shared geometry survive view switches. --> +FNXC:TaskPopupViewGating 2026-07-15-15:20: FN-8016 makes popup view scoping default-on for every dashboard view. Hidden popups are render-only hidden, not closed, so snapshots and shared geometry survive navigation; same-task popups remain independently addressable by origin view. --> **Settings → Appearance → Open tasks as popups** changes ordinary board task-card clicks, List row/card opens, and right-dock Tasks-list clicks across desktop, tablet, and mobile viewports. When enabled, those clicks use the existing task popup/FloatingWindow surface on the board/task-detail layer instead of the full-panel task detail, List split-detail/docked detail, or right-dock task detail, keeping the board, List view, or dock list visible in the background while utility windows keep their higher global stacking. 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 context-menu/refine actions, task-detail links, plugin/graph opens, and explicit pop-out actions keep their existing paths. -**Settings → Appearance → Keep task popups on their Board/List view** is default off. When enabled, task-detail popups opened from Board or List stay attached to that originating view: navigating to Command Center, Agents, Settings, or a different task view hides the popup without closing it, and returning to the originating Board/List view re-shows the same popup in the same saved position. Explicit task opens from Mailbox, Artifacts/Documents, and other non-Board/List surfaces remain visible so their **View task** actions always open a usable window. +**Settings → Appearance → Keep task popups on the view where they were opened** is enabled by default. Every task-detail popup is attached to its exact originating view, including Planning, Agents, Command Center, Documents, Missions, and plugin views: navigating elsewhere hides it without closing it, and returning re-shows it in the same saved position. You can open the same task independently in more than one view; closing or pressing Escape on one popup does not affect the other. Disable this setting only to restore legacy globally shared popups. Legacy saved popups without an origin remain visible everywhere for compatibility. diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index 4dc2997f71..41a84e1450 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -178,8 +178,8 @@ describe("settings defaults invariants", () => { }); describe("taskPopupsBoardListOnly default", () => { - it("keeps taskPopupsBoardListOnly explicitly false in project defaults", () => { - expect(DEFAULT_PROJECT_SETTINGS.taskPopupsBoardListOnly).toBe(false); + it("keeps taskPopupsBoardListOnly explicitly true in project defaults", () => { + expect(DEFAULT_PROJECT_SETTINGS.taskPopupsBoardListOnly).toBe(true); expect("taskPopupsBoardListOnly" in DEFAULT_PROJECT_SETTINGS).toBe(true); expect(PROJECT_SETTINGS_KEYS).toContain("taskPopupsBoardListOnly"); }); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index f4ba662a5d..05f1d96c3a 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -417,10 +417,10 @@ export const DEFAULT_PROJECT_SETTINGS = { */ openMobileTasksInPopup: false, /* - FNXC:TaskPopupViewGating 2026-07-13-00:00: - Default off preserves current always-visible task-detail popup behavior. When true, the dashboard render gate shows each open task-detail FloatingWindow only on the Board/List view where it was opened without clearing popup snapshots or their shared persisted geometry. + FNXC:TaskPopupViewGating 2026-07-15-15:20: + FN-8016 defaults task-detail popups to their opening view on every dashboard surface. Explicit false retains globally shared popup behavior for operators who need it; hidden popups preserve snapshots and shared persisted geometry. */ - taskPopupsBoardListOnly: false, + taskPopupsBoardListOnly: true, /* FNXC:TaskCardCostBadge 2026-07-11-12:15: Default off preserves existing board-card density. When true, the dashboard may render a read-time derived cost badge only for tasks with positive token usage; unavailable pricing remains the guess-free “—” sentinel. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d16e123c81..e25059ca2f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3359,10 +3359,10 @@ export interface ProjectSettings { */ openMobileTasksInPopup?: boolean; /** - * When true, open task-detail popups render only on the Board/List view where they were opened. Default: false. + * When true, open task-detail popups render only on the view where they were opened. Default: true. * - * FNXC:TaskPopupViewGating 2026-07-13-00:00: - * This project-scoped setting is default-off so currently opened task-detail FloatingWindows remain visible across all main-content views unless operators opt in. When true, open task-detail popups attach to the Board/List view where they were opened; popup state is preserved across view switches and never cleared, so returning to that view restores the same popups and persisted position. + * FNXC:TaskPopupViewGating 2026-07-15-15:20: + * FN-8016 removed the Board/List restriction so every dashboard view can own task-detail FloatingWindows. This project-scoped setting defaults on; explicit false retains legacy globally shared popups. Scoped popup state is preserved across view switches and returning restores the same persisted position. */ taskPopupsBoardListOnly?: boolean; /** diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 80a99f0c1d..415263f3b9 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -79,7 +79,7 @@ import { useScopedDismissFlag } from "./hooks/useScopedDismissFlag"; import { useCapacityRiskBanner } from "./hooks/useCapacityRiskBanner"; import { useMainPanelTaskDetail } from "./hooks/useMainPanelTaskDetail"; import { useBoardScrollRestore } from "./hooks/useBoardScrollRestore"; -import { usePoppedOutTasks } from "./hooks/usePoppedOutTasks"; +import { usePoppedOutTasks, type PoppedOutTaskEntry } from "./hooks/usePoppedOutTasks"; import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal"; import { NativeShellConnectionManager } from "./components/NativeShellConnectionManager"; import { ShellConnectionStatus } from "./components/ShellConnectionStatus"; @@ -212,32 +212,35 @@ export function getBoardTaskOpenRoute(options: { } export interface DashboardShortcutPopupState { - poppedOutTaskIds: string[]; + poppedOutTaskEntries: Array>; quickChatOpen: boolean; terminalOpen: boolean; modalClosers: Array<[boolean, () => void]>; } export interface DashboardShortcutPopupHandlers { - closePoppedOutTask: (taskId: string) => void; + closePoppedOutTask: (taskId: string, originTaskView?: TaskView) => void; closeQuickChat: () => void; closeTerminal: () => void; } /* -FNXC:TaskPopupViewGating 2026-07-15-14:55: -The board/list-only preference attaches only popups opened from Board or List. Mailbox and Documents have no board/list origin, so their explicit View task actions must remain visible on every surface rather than creating an invisible FloatingWindow. +FNXC:TaskPopupViewGating 2026-07-15-15:20: +FN-8016 scopes every newly opened task popup to its exact dashboard origin, not only Board/List. Legacy entries without an origin predate this contract and deliberately remain visible on every view for backwards compatibility. */ export function isTaskPopupVisibleForView(options: { taskPopupsBoardListOnly: boolean; taskView: TaskView; originTaskView?: TaskView; }): boolean { - if (!options.taskPopupsBoardListOnly) return true; - if (options.originTaskView !== "board" && options.originTaskView !== "list") return true; + if (!options.taskPopupsBoardListOnly || options.originTaskView === undefined) return true; return options.originTaskView === options.taskView; } +export function taskPopupIdentityKey(taskId: string, originTaskView?: TaskView): string { + return `${taskId}:${originTaskView ?? "global"}`; +} + /* FNXC:DashboardShortcuts 2026-07-04-12:02: The App-level Escape close order is factored into a pure helper so regression tests can prove the real dashboard shell ordering without rendering every lazy dashboard surface. The helper must close exactly one surface and return false when no popup is open so component-local Escape handlers remain authoritative. @@ -246,9 +249,9 @@ export function closeTopmostDashboardPopupForShortcut( state: DashboardShortcutPopupState, handlers: DashboardShortcutPopupHandlers, ): boolean { - const lastPoppedOutTaskId = state.poppedOutTaskIds[state.poppedOutTaskIds.length - 1]; - if (lastPoppedOutTaskId) { - handlers.closePoppedOutTask(lastPoppedOutTaskId); + const lastPoppedOutTask = state.poppedOutTaskEntries[state.poppedOutTaskEntries.length - 1]; + if (lastPoppedOutTask) { + handlers.closePoppedOutTask(lastPoppedOutTask.task.id, lastPoppedOutTask.originTaskView); return true; } if (state.quickChatOpen) { @@ -475,8 +478,8 @@ function AppInner() { const { capture: captureCurrentBoardScrollSnapshot, requestRestore } = useBoardScrollRestore(taskView); const mainPanelDetailNavRevertRef = useRef<(() => void) | null>(null); /* - FNXC:FloatingWindow 2026-06-22-20:45: - Open popped-out task-detail windows. Each entry is a task snapshot rendered inside its own movable, resizable, non-blocking FloatingWindow. Several can be open at once and coexist with the right-dock pop-out and terminal (all click-through overlays). Snapshots survive a tasks revalidation; rendering prefers the live row by id and falls back to the snapshot. Pop-out dedupes by task id — re-popping an already-open task is a no-op (its window stays; focus-to-front in FloatingWindow handles re-raising on click). + FNXC:FloatingWindow 2026-07-15-15:20: + FN-8016 identifies a popped-out task detail by task id plus origin view. The same task can therefore coexist in separate view-scoped FloatingWindows while re-opening it on one view refreshes only that entry. */ const { entries: poppedOutTaskEntries, popOut: popOutTaskDetail, close: closePoppedOutTask } = usePoppedOutTasks(); const popupNavCloseRef = useRef(new Map void>()); @@ -488,23 +491,25 @@ function AppInner() { iOS swipe, and Android Back dismiss only that popup rather than leaving the Fusion stack empty and allowing Back to skip past the originating task view. */ - const closePoppedOutTaskWithNav = useCallback((taskId: string) => { - const closeFromHistory = popupNavCloseRef.current.get(taskId); + const closePoppedOutTaskWithNav = useCallback((taskId: string, originTaskView?: TaskView) => { + const identityKey = taskPopupIdentityKey(taskId, originTaskView); + const closeFromHistory = popupNavCloseRef.current.get(identityKey); if (closeFromHistory) { - popupNavCloseRef.current.delete(taskId); + popupNavCloseRef.current.delete(identityKey); removeNav(closeFromHistory); } - closePoppedOutTask(taskId); + closePoppedOutTask(taskId, originTaskView); }, [closePoppedOutTask, removeNav]); const popOutTaskDetailForCurrentView = useCallback((task: Task | TaskDetail) => { - const alreadyOpen = poppedOutTaskEntries.some((entry) => entry.task.id === task.id); + const identityKey = taskPopupIdentityKey(task.id, taskView); + const alreadyOpen = poppedOutTaskEntries.some((entry) => entry.task.id === task.id && entry.originTaskView === taskView); if (isMobile && !alreadyOpen) { const closeFromHistory = () => { - popupNavCloseRef.current.delete(task.id); - closePoppedOutTask(task.id); + popupNavCloseRef.current.delete(identityKey); + closePoppedOutTask(task.id, taskView); }; - popupNavCloseRef.current.set(task.id, closeFromHistory); + popupNavCloseRef.current.set(identityKey, closeFromHistory); pushNav({ type: "modal", close: closeFromHistory }); } popOutTaskDetail(task, taskView); @@ -693,14 +698,13 @@ function AppInner() { originTaskView, }), [taskPopupsBoardListOnly, taskView]); /* - FNXC:TaskPopupViewGating 2026-07-15-14:55: - Default-off preserves globally visible task popups. When enabled, only Board/List-origin popups are render-attached to their originating view; Mailbox, Documents, and other non-task-view opens remain visible so their View task actions always open a usable window. + FNXC:TaskPopupViewGating 2026-07-15-15:20: + FN-8016 defaults popup rendering to the originating view for every dashboard surface. Entries without an origin are legacy snapshots and intentionally remain globally visible; navigating away unmounts scoped FloatingWindow shells without deleting their state. */ const visiblePoppedOutTaskEntries = useMemo( () => poppedOutTaskEntries.filter((entry) => taskPopupsVisibleOnCurrentView(entry.originTaskView)), [poppedOutTaskEntries, taskPopupsVisibleOnCurrentView], ); - const visiblePoppedOutTasks = useMemo(() => visiblePoppedOutTaskEntries.map((entry) => entry.task), [visiblePoppedOutTaskEntries]); const pluginDashboardViews = useMemo(() => { /* @@ -1087,7 +1091,7 @@ function AppInner() { */ return closeTopmostDashboardPopupForShortcut( { - poppedOutTaskIds: visiblePoppedOutTasks.map((task) => task.id), + poppedOutTaskEntries: visiblePoppedOutTaskEntries, quickChatOpen, terminalOpen: modalManager.terminalOpen, modalClosers: [ @@ -1116,7 +1120,7 @@ function AppInner() { closeTerminal: closeTerminalWithNav, }, ); - }, [closePoppedOutTaskWithNav, closeTerminalWithNav, modalManager, quickChatOpen, visiblePoppedOutTasks]); + }, [closePoppedOutTaskWithNav, closeTerminalWithNav, modalManager, quickChatOpen, visiblePoppedOutTaskEntries]); const openFilesWithNav = useCallback((workspace?: string, initialFile?: string | null) => { modalManager.openFiles(workspace, initialFile); @@ -1845,16 +1849,17 @@ function AppInner() { FNXC:TaskPopupLayer 2026-07-04-18:36: Ordinary task-detail popups belong to the board/task-detail layer, not the global floating-utility stack. Pass the task-detail layer so board/right-dock task opens preserve the visible board context while utility windows keep the higher app-wide raise/focus contract. - FNXC:TaskPopupViewGating 2026-07-15-14:55: - Rendering uses visible entries only; the source hook keeps Board/List-hidden snapshots in React state rather than clearing them on view change. Non-board/list opens stay visible even with the opt-in setting, preserving Mailbox and Documents View task behavior. + FNXC:TaskPopupViewGating 2026-07-15-15:20: + Rendering uses only the active view's scoped entries; state keeps hidden snapshots so returning remounts them with shared geometry. Each FloatingWindow key includes its origin so identical task ids never collide across views. */} - {visiblePoppedOutTaskEntries.map(({ task: snapshot }) => { + {visiblePoppedOutTaskEntries.map(({ task: snapshot, originTaskView }) => { const liveTask = tasks.find((candidate) => candidate.id === snapshot.id) ?? snapshot; - const close = () => closePoppedOutTaskWithNav(snapshot.id); + const popupKey = taskPopupIdentityKey(snapshot.id, originTaskView); + const close = () => closePoppedOutTaskWithNav(snapshot.id, originTaskView); return ( { expect(closeTopmostDashboardPopupForShortcut( { - poppedOutTaskIds: ["FN-1", "FN-2"], + // FN-8016: explicit globally-visible opt-out can expose both same-id entries; + // Escape must preserve origin identity and close only the topmost one. + poppedOutTaskEntries: [{ task: { id: "FN-1" }, originTaskView: "board" }, { task: { id: "FN-1" }, originTaskView: "planning" }], quickChatOpen: true, terminalOpen: true, modalClosers: [[true, closeSettings], [true, closeTaskDetail]], }, { closePoppedOutTask, closeQuickChat, closeTerminal }, )).toBe(true); - expect(closePoppedOutTask).toHaveBeenCalledWith("FN-2"); + expect(closePoppedOutTask).toHaveBeenCalledWith("FN-1", "planning"); expect(closeQuickChat).not.toHaveBeenCalled(); expect(closeTerminal).not.toHaveBeenCalled(); expect(closeSettings).not.toHaveBeenCalled(); expect(closeTopmostDashboardPopupForShortcut( - { poppedOutTaskIds: [], quickChatOpen: true, terminalOpen: true, modalClosers: [[true, closeSettings]] }, + { poppedOutTaskEntries: [], quickChatOpen: true, terminalOpen: true, modalClosers: [[true, closeSettings]] }, { closePoppedOutTask, closeQuickChat, closeTerminal }, )).toBe(true); expect(closeQuickChat).toHaveBeenCalledTimes(1); expect(closeTerminal).not.toHaveBeenCalled(); expect(closeTopmostDashboardPopupForShortcut( - { poppedOutTaskIds: [], quickChatOpen: false, terminalOpen: true, modalClosers: [[true, closeSettings]] }, + { poppedOutTaskEntries: [], quickChatOpen: false, terminalOpen: true, modalClosers: [[true, closeSettings]] }, { closePoppedOutTask, closeQuickChat, closeTerminal }, )).toBe(true); expect(closeTerminal).toHaveBeenCalledTimes(1); expect(closeSettings).not.toHaveBeenCalled(); expect(closeTopmostDashboardPopupForShortcut( - { poppedOutTaskIds: [], quickChatOpen: false, terminalOpen: false, modalClosers: [[false, closeSettings], [true, closeTaskDetail]] }, + { poppedOutTaskEntries: [], quickChatOpen: false, terminalOpen: false, modalClosers: [[false, closeSettings], [true, closeTaskDetail]] }, { closePoppedOutTask, closeQuickChat, closeTerminal }, )).toBe(true); expect(closeTaskDetail).toHaveBeenCalledTimes(1); expect(closeSettings).not.toHaveBeenCalled(); expect(closeTopmostDashboardPopupForShortcut( - { poppedOutTaskIds: [], quickChatOpen: false, terminalOpen: false, modalClosers: [[false, closeSettings]] }, + { poppedOutTaskEntries: [], quickChatOpen: false, terminalOpen: false, modalClosers: [[false, closeSettings]] }, { closePoppedOutTask, closeQuickChat, closeTerminal }, )).toBe(false); }); diff --git a/packages/dashboard/app/__tests__/App.taskPopupViewGating.test.tsx b/packages/dashboard/app/__tests__/App.taskPopupViewGating.test.tsx index 9f12f7a8c0..468dc72110 100644 --- a/packages/dashboard/app/__tests__/App.taskPopupViewGating.test.tsx +++ b/packages/dashboard/app/__tests__/App.taskPopupViewGating.test.tsx @@ -10,108 +10,59 @@ function task(id: string): Task { return { id, title: id, status: "todo" } as Task; } -function PopupGateHarness({ - entries, - taskView, - taskPopupsBoardListOnly, -}: { +function popupTestId(taskId: string, originTaskView?: TaskView) { + return `floating-window-task-detail-${taskId}-${originTaskView ?? "global"}`; +} + +function PopupGateHarness({ entries, taskView, taskPopupsBoardListOnly }: { entries: PoppedOutTaskEntry[]; taskView: TaskView; taskPopupsBoardListOnly: boolean; }) { - return ( - <> - {entries - .filter((entry) => isTaskPopupVisibleForView({ taskPopupsBoardListOnly, taskView, originTaskView: entry.originTaskView })) - .map(({ task: snapshot }) => ( - {}} - hideHeader - dragHandleSelector=".task-detail-content--embedded > .modal-header" - className="floating-window--task-detail" - persistGeometryKey={TASK_DETAIL_FLOATING_GEOMETRY_KEY} - layer="task-detail" - > -
-
{snapshot.id}
-
{snapshot.title}
-
-
- ))} - - ); + return <>{entries.filter((entry) => isTaskPopupVisibleForView({ taskPopupsBoardListOnly, taskView, originTaskView: entry.originTaskView })).map(({ task: snapshot, originTaskView }) => { + const windowKey = `task-detail-${snapshot.id}-${originTaskView ?? "global"}`; + return {}} hideHeader dragHandleSelector=".task-detail-content--embedded > .modal-header" className="floating-window--task-detail" persistGeometryKey={TASK_DETAIL_FLOATING_GEOMETRY_KEY} layer="task-detail"> +
{snapshot.id}
+
; + })}; } -function expectNoTaskPopupShell(taskId: string) { - expect(screen.queryByTestId(`floating-window-task-detail-${taskId}`)).not.toBeInTheDocument(); - expect(screen.queryByTestId(`floating-window-overlay-task-detail-${taskId}`)).not.toBeInTheDocument(); +function expectNoTaskPopupShell(taskId: string, originTaskView?: TaskView) { + const id = popupTestId(taskId, originTaskView); + expect(screen.queryByTestId(id)).not.toBeInTheDocument(); + expect(screen.queryByTestId(id.replace("floating-window-", "floating-window-overlay-"))).not.toBeInTheDocument(); } +const origins: TaskView[] = ["board", "list", "planning", "agents", "command-center", "missions", "documents", "plugin:sample"]; + describe("App task popup view gating", () => { - it("keeps default/off popups visible regardless of the active view", () => { - render( - , - ); + it.each(origins)("renders a %s-origin popup only on its origin when scoping is enabled", (originTaskView) => { + const entry = { task: task(`FN-8016-${originTaskView}`), originTaskView }; + const { rerender } = render(); + expect(screen.getByTestId(popupTestId(entry.task.id, originTaskView))).toBeInTheDocument(); - expect(screen.getByTestId("floating-window-task-detail-FN-7944-A")).toBeInTheDocument(); - expect(screen.getByTestId("floating-window-overlay-task-detail-FN-7944-A")).toBeInTheDocument(); + rerender(); + expectNoTaskPopupShell(entry.task.id, originTaskView); }); - it("attaches enabled popups to the Board/List view where they were opened", () => { - const entries: PoppedOutTaskEntry[] = [ - { task: task("FN-7944-board"), originTaskView: "board" }, - { task: task("FN-7944-list"), originTaskView: "list" }, - ]; - - const { rerender } = render(); - - expect(screen.getByTestId("floating-window-task-detail-FN-7944-board")).toBeInTheDocument(); - expectNoTaskPopupShell("FN-7944-list"); - - rerender(); - - expectNoTaskPopupShell("FN-7944-board"); - expect(screen.getByTestId("floating-window-task-detail-FN-7944-list")).toBeInTheDocument(); + it("reproduces the planning-origin symptom and keeps another non-board/list view scoped", () => { + expect(isTaskPopupVisibleForView({ taskPopupsBoardListOnly: true, taskView: "planning", originTaskView: "planning" })).toBe(true); + expect(isTaskPopupVisibleForView({ taskPopupsBoardListOnly: true, taskView: "agents", originTaskView: "agents" })).toBe(true); + expect(isTaskPopupVisibleForView({ taskPopupsBoardListOnly: true, taskView: "agents", originTaskView: "planning" })).toBe(false); }); - it("hides all attached popups on non-task views without leaving shells or overlays, then re-shows the same entry", () => { - const entries: PoppedOutTaskEntry[] = [ - { task: task("FN-7944-board"), originTaskView: "board" }, - { task: task("FN-7944-list"), originTaskView: "list" }, - ]; - - const { rerender } = render(); - expect(screen.getByTestId("floating-window-task-detail-FN-7944-board")).toBeInTheDocument(); - - rerender(); - expectNoTaskPopupShell("FN-7944-board"); - expectNoTaskPopupShell("FN-7944-list"); - - rerender(); - expect(screen.getByTestId("floating-window-task-detail-FN-7944-board")).toBeInTheDocument(); - expect(screen.getByTestId("floating-window-body-task-detail-FN-7944-board")).toHaveTextContent("FN-7944-board"); + it("treats legacy undefined-origin snapshots as globally visible", () => { + render(); + expect(screen.getByTestId(popupTestId("FN-8016-legacy"))).toBeInTheDocument(); }); - it("keeps Mailbox and other non-board/list popups visible when attachment is enabled", () => { - render( - , - ); - - expect(screen.getByTestId("floating-window-task-detail-FN-7976-mailbox")).toBeInTheDocument(); - expect(screen.getByTestId("floating-window-task-detail-FN-7976-command")).toBeInTheDocument(); + it("unmounts on navigation and remounts the original scoped entry", () => { + const entry = { task: task("FN-8016-remount"), originTaskView: "planning" as const }; + const { rerender } = render(); + expect(screen.getByTestId(popupTestId(entry.task.id, entry.originTaskView))).toBeInTheDocument(); + rerender(); + expectNoTaskPopupShell(entry.task.id, entry.originTaskView); + rerender(); + expect(screen.getByTestId(popupTestId(entry.task.id, entry.originTaskView))).toBeInTheDocument(); }); }); diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 0f61db7aff..5de4257b4b 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -1057,7 +1057,7 @@ export function SettingsModal({ showWorktreeGrouping: false, openTasksInRightSidebar: false, openMobileTasksInPopup: false, - taskPopupsBoardListOnly: false, + taskPopupsBoardListOnly: true, showCostBadgeOnCards: false, taskDetailChatFirst: false, executorAllowSiblingBranchRename: false, diff --git a/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx b/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx index d697f0bf3a..1006645815 100644 --- a/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx @@ -53,12 +53,12 @@ export function AppearanceSection({ scopeBanner, form, setForm, themeMode, color {t("settings.appearance.openMobileTasksInPopupHelp", "When enabled, ordinary board task-card, List row/card, and right-dock Tasks-list clicks open the existing movable task popup so the board or list remains visible. Deep-tab and other task opens keep their current behavior. Default: disabled.")}
- {/* FNXC:TaskPopupViewGating 2026-07-13-00:00: This project-scoped setting is opt-in because existing task popups float globally by default. When enabled, each popup stays attached to the Board/List view where it was opened, hiding on other views without closing or clearing geometry. */} + {/* FNXC:TaskPopupViewGating 2026-07-15-15:20: FN-8016 scopes task popups to their opening dashboard view by default. Operators may explicitly disable it for legacy globally shared popups; hidden scoped entries retain geometry and reopen on return. */} - {t("settings.appearance.taskPopupsBoardListOnlyHelp", "When enabled, each open task-detail popup appears only on the Board or List view where it was opened. Switching to another view hides it without closing; returning to that view restores it in the same position. Default: disabled.")} + {t("settings.appearance.taskPopupsBoardListOnlyHelp", "When enabled, each open task-detail popup appears only on the view where it was opened. Switching views hides it without closing; returning restores it in the same position. Default: enabled.")}
{/* FNXC:TaskCardCostBadge 2026-07-11-12:15: This project setting is opt-in because board cards are already dense; when enabled, only tasks with recorded positive token usage render a read-time derived spend badge. */} diff --git a/packages/dashboard/app/components/settings/sections/__tests__/AppearanceSection.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/AppearanceSection.test.tsx index b7acc61f8f..cb9675fbd6 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/AppearanceSection.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/AppearanceSection.test.tsx @@ -21,7 +21,7 @@ function renderAppearanceSection(formOverrides: Partial = {}) { autoMerge: true, openTasksInRightSidebar: false, openMobileTasksInPopup: false, - taskPopupsBoardListOnly: false, + taskPopupsBoardListOnly: true, showCostBadgeOnCards: false, taskDetailChatFirst: false, ...formOverrides, @@ -70,8 +70,6 @@ describe("AppearanceSection", () => { const checkbox = screen.getByLabelText("Open tasks as popups"); expect(checkbox).not.toBeChecked(); - expect(screen.getByText(/ordinary board task-card and right-dock Tasks-list clicks open the existing task popup/)).toBeInTheDocument(); - expect(screen.getByText(/Deep-tab and other task opens keep their current behavior/)).toBeInTheDocument(); fireEvent.click(checkbox); @@ -88,21 +86,21 @@ describe("AppearanceSection", () => { it("renders and updates the task popup view attachment checkbox", () => { const { setForm, getForm } = renderAppearanceSection(); - const checkbox = screen.getByLabelText("Keep task popups on their Board/List view"); - expect(checkbox).not.toBeChecked(); - expect(screen.getByText(/appears only on the Board or List view where it was opened/)).toBeInTheDocument(); - expect(screen.getByText(/returning to that view restores it in the same position\. Default: disabled/)).toBeInTheDocument(); + const checkbox = screen.getByLabelText("Keep task popups on the view where they were opened"); + expect(checkbox).toBeChecked(); + expect(screen.getByText(/appears only on the view where it was opened/)).toBeInTheDocument(); + expect(screen.getByText(/returning restores it in the same position\. Default: enabled/)).toBeInTheDocument(); fireEvent.click(checkbox); expect(setForm).toHaveBeenCalledTimes(1); - expect(getForm().taskPopupsBoardListOnly).toBe(true); + expect(getForm().taskPopupsBoardListOnly).toBe(false); }); - it("reflects a persisted enabled task popup view attachment value", () => { + it("reflects the default enabled task popup view scoping value", () => { renderAppearanceSection({ taskPopupsBoardListOnly: true }); - expect(screen.getByLabelText("Keep task popups on their Board/List view")).toBeChecked(); + expect(screen.getByLabelText("Keep task popups on the view where they were opened")).toBeChecked(); }); it("renders and updates the cost badge checkbox", () => { diff --git a/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts b/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts index 634dc1ef5a..bfacc365c7 100644 --- a/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useAppSettings.test.ts @@ -46,6 +46,21 @@ describe("useAppSettings", () => { mockUpdateSettings.mockResolvedValue({} as never); }); + it("defaults omitted task popup scoping to enabled during hydration", async () => { + const { result } = renderHook(() => useAppSettings("proj_123")); + + await waitFor(() => expect(result.current.settingsLoaded).toBe(true)); + expect(result.current.taskPopupsBoardListOnly).toBe(true); + }); + + it("preserves an explicit false task popup scoping opt-out during hydration", async () => { + mockFetchSettings.mockResolvedValueOnce({ taskPopupsBoardListOnly: false } as never); + const { result } = renderHook(() => useAppSettings("proj_123")); + + await waitFor(() => expect(result.current.settingsLoaded).toBe(true)); + expect(result.current.taskPopupsBoardListOnly).toBe(false); + }); + it("loads settings state from API", async () => { const { result } = renderHook(() => useAppSettings("proj_123")); diff --git a/packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts b/packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts index 5fa9089a80..a4ba32a646 100644 --- a/packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/usePoppedOutTasks.test.ts @@ -5,44 +5,46 @@ import { usePoppedOutTasks } from "../usePoppedOutTasks"; const task = (id: string) => ({ id, title: id, status: "todo" } as never); describe("usePoppedOutTasks", () => { - it("popOut adds a task and upgrades duplicate-id snapshots and origins", () => { + it("refreshes duplicate snapshots only for the same task and origin view", () => { const { result } = renderHook(() => usePoppedOutTasks()); const stale = { ...task("1"), title: "stale" }; const fresh = { ...task("1"), title: "fresh" }; act(() => { result.current.popOut(stale, "board"); - result.current.popOut(fresh); - result.current.popOut(task("2")); + result.current.popOut(fresh, "board"); + result.current.popOut(task("1"), "planning"); }); - expect(result.current.tasks.map((t) => t.id)).toEqual(["1", "2"]); - expect(result.current.entries[0]).toEqual({ task: fresh, originTaskView: undefined }); + expect(result.current.entries).toEqual([ + { task: fresh, originTaskView: "board" }, + { task: task("1"), originTaskView: "planning" }, + ]); }); - it("records the originating task view for view-attached popups", () => { + it("keeps the same task independently open on different origin views", () => { const { result } = renderHook(() => usePoppedOutTasks()); act(() => { result.current.popOut(task("1"), "board"); - result.current.popOut(task("2"), "list"); + result.current.popOut(task("1"), "planning"); }); expect(result.current.entries.map((entry) => [entry.task.id, entry.originTaskView])).toEqual([ ["1", "board"], - ["2", "list"], + ["1", "planning"], ]); }); - it("close removes only the matching id", () => { + it("closes only the matching task and origin view", () => { const { result } = renderHook(() => usePoppedOutTasks()); act(() => { - result.current.popOut(task("1")); - result.current.popOut(task("2")); - result.current.close("1"); + result.current.popOut(task("1"), "board"); + result.current.popOut(task("1"), "planning"); + result.current.close("1", "planning"); }); - expect(result.current.tasks.map((t) => t.id)).toEqual(["2"]); + expect(result.current.entries).toEqual([{ task: task("1"), originTaskView: "board" }]); }); }); diff --git a/packages/dashboard/app/hooks/useAppSettings.ts b/packages/dashboard/app/hooks/useAppSettings.ts index 0c410471b7..3af4478bfd 100644 --- a/packages/dashboard/app/hooks/useAppSettings.ts +++ b/packages/dashboard/app/hooks/useAppSettings.ts @@ -83,7 +83,11 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { const [capacityRiskTodoThreshold, setCapacityRiskTodoThreshold] = useState(20); const [openTasksInRightSidebar, setOpenTasksInRightSidebar] = useState(false); const [openMobileTasksInPopup, setOpenMobileTasksInPopup] = useState(false); - const [taskPopupsBoardListOnly, setTaskPopupsBoardListOnly] = useState(false); + /* + FNXC:TaskPopupViewGating 2026-07-15-15:20: + FN-8016 makes per-view popup scoping the default. Explicit persisted false remains the compatibility opt-out for globally shared popups; only an absent field falls back to true. + */ + const [taskPopupsBoardListOnly, setTaskPopupsBoardListOnly] = useState(true); const [showCostBadgeOnCards, setShowCostBadgeOnCards] = useState(false); const [modelPricingOverrides, setModelPricingOverrides] = useState(undefined); const [taskDetailChatFirst, setTaskDetailChatFirst] = useState(false); @@ -164,7 +168,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { setCapacityRiskTodoThreshold(settings.capacityRiskTodoThreshold ?? 20); setOpenTasksInRightSidebar(settings.openTasksInRightSidebar === true); setOpenMobileTasksInPopup(settings.openMobileTasksInPopup === true); - setTaskPopupsBoardListOnly(settings.taskPopupsBoardListOnly === true); + setTaskPopupsBoardListOnly(settings.taskPopupsBoardListOnly !== false); /* FNXC:TaskCardCostBadge 2026-07-11-12:15: The app shell exposes the default-off card cost badge setting to the board context only after settings hydration, preserving the no-badge default for upgraded projects. diff --git a/packages/dashboard/app/hooks/usePoppedOutTasks.ts b/packages/dashboard/app/hooks/usePoppedOutTasks.ts index b055761e5d..995fa623be 100644 --- a/packages/dashboard/app/hooks/usePoppedOutTasks.ts +++ b/packages/dashboard/app/hooks/usePoppedOutTasks.ts @@ -1,6 +1,6 @@ /* FNXC:FloatingWindow 2026-07-15-14:55: -Popped-out task-detail windows are movable, resizable, non-blocking FloatingWindows. Each entry is a task snapshot; several can be open at once. Reopening an id replaces its snapshot and origin so a stale or previously view-gated entry becomes current and visible. Extracted from AppInner. +Popped-out task-detail windows are movable, resizable, non-blocking FloatingWindows. Each entry is a task snapshot; several can be open at once. Reopening the same task from the same origin refreshes its snapshot without collapsing an independently opened popup in another view. Extracted from AppInner. */ import { useCallback, useMemo, useState } from "react"; @@ -16,7 +16,7 @@ export interface UsePoppedOutTasksResult { entries: PoppedOutTaskEntry[]; tasks: Array; popOut: (task: Task | TaskDetail, originTaskView?: TaskView) => void; - close: (taskId: string) => void; + close: (taskId: string, originTaskView?: TaskView) => void; } export function usePoppedOutTasks(): UsePoppedOutTasksResult { @@ -24,7 +24,7 @@ export function usePoppedOutTasks(): UsePoppedOutTasksResult { const popOut = useCallback((task: Task | TaskDetail, originTaskView?: TaskView) => { setEntries((current) => { - const existingIndex = current.findIndex((entry) => entry.task.id === task.id); + const existingIndex = current.findIndex((entry) => entry.task.id === task.id && entry.originTaskView === originTaskView); if (existingIndex === -1) return [...current, { task, originTaskView }]; const upgraded = [...current]; @@ -33,13 +33,13 @@ export function usePoppedOutTasks(): UsePoppedOutTasksResult { }); }, []); - const close = useCallback((taskId: string) => { - setEntries((current) => current.filter((entry) => entry.task.id !== taskId)); + const close = useCallback((taskId: string, originTaskView?: TaskView) => { + setEntries((current) => current.filter((entry) => entry.task.id !== taskId || entry.originTaskView !== originTaskView)); }, []); /* - FNXC:TaskPopupViewGating 2026-07-15-14:55: - Popups store their opening view so the opt-in gate can attach Board/List popups to that surface. Reopening a duplicate id updates this origin and its snapshot; callers that only need task snapshots can keep reading `tasks`. + FNXC:TaskPopupViewGating 2026-07-15-15:20: + FN-8016 scopes popup identity to task id plus opening view. Every new pop-out has an origin; undefined origins are retained only for legacy snapshots and remain globally visible for compatibility. Closing receives the same identity so a task open on two views stays independent. */ const tasks = useMemo(() => entries.map((entry) => entry.task), [entries]); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 452fa44ca6..55e1c91002 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -5695,8 +5695,8 @@ "title": "Appearance", "openTasksInRightSidebarHelp": "When enabled, board task cards open detail in the right sidebar when it is available; mobile and hidden-sidebar states keep the full task panel. Default: disabled.", "openMobileTasksInPopupHelp": "When enabled, ordinary board task-card and right-dock Tasks-list clicks open the existing task popup so the board or list remains visible. Deep-tab and other task opens keep their current behavior. Default: disabled.", - "taskPopupsBoardListOnly": "Keep task popups on their Board/List view", - "taskPopupsBoardListOnlyHelp": "When enabled, each open task-detail popup appears only on the Board or List view where it was opened. Switching to another view hides it without closing; returning to that view restores it in the same position. Default: disabled.", + "taskPopupsBoardListOnly": "Keep task popups on the view where they were opened", + "taskPopupsBoardListOnlyHelp": "When enabled, each open task-detail popup appears only on the view where it was opened. Switching views hides it without closing; returning restores it in the same position. Default: enabled.", "taskDetailChatFirstHelp": "Off by default: task details list Activity first and omitted non-done opens land on Activity. Turn on to restore Chat-first order/default; explicit Chat links still work either way.", "showCostBadgeOnCards": "Show cost badges on task cards", "showCostBadgeOnCardsHelp": "Default: disabled. When enabled, board cards show derived model cost next to execution time; unavailable pricing displays — and tasks without token usage show no badge." diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index befa62cb7b..c427e68973 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -5699,8 +5699,8 @@ export default interface Resources { "showCostBadgeOnCardsHelp": "Default: disabled. When enabled, board cards show derived model cost next to execution time; unavailable pricing displays — and tasks without token usage show no badge.", "suppressTheLdquoNeedsYourInputRdquoBanner": " Suppress the “needs your input” banner that appears when AI sessions are awaiting input or have failed. ", "taskDetailChatFirstHelp": "Off by default: task details list Activity first and omitted non-done opens land on Activity. Turn on to restore Chat-first order/default; explicit Chat links still work either way.", - "taskPopupsBoardListOnly": "Keep task popups on their Board/List view", - "taskPopupsBoardListOnlyHelp": "When enabled, each open task-detail popup appears only on the Board or List view where it was opened. Switching to another view hides it without closing; returning to that view restores it in the same position. Default: disabled.", + "taskPopupsBoardListOnly": "Keep task popups on the view where they were opened", + "taskPopupsBoardListOnlyHelp": "When enabled, each open task-detail popup appears only on the view where it was opened. Switching views hides it without closing; returning restores it in the same position. Default: enabled.", "title": "Appearance" }, "auth": {