From 7246df22f628d7f4aaaa26241d07f0a5dc9f3609 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 13 Jul 2026 08:58:51 -0700 Subject: [PATCH] FN-7944: add setting to keep task popups attached to their Board/List view Adds an opt-in project setting so open task-detail popups stay attached to the Board or List view where they were opened, instead of floating over every main-content view. - New project setting taskPopupsBoardListOnly (default: off) in settings-schema.ts and ProjectSettings type, with default preserved via settings-defaults tests. - usePoppedOutTasks now stores each popup's originating TaskView alongside its task snapshot (PoppedOutTaskEntry), keeping legacy tasks output for existing callers. - App.tsx adds isTaskPopupVisibleForView() gating helper and filters popped-out entries to the current view for rendering/keyboard-close handling, while hidden popups remain mounted in hook state (not cleared) so switching back to the originating view restores them with shared persisted geometry. - Settings -> Appearance gets a new "Keep task popups on their Board/List view" checkbox (AppearanceSection.tsx) with i18n strings and updated settings search text in SettingsModal. - Documentation updated in docs/dashboard-guide.md and docs/settings-reference.md to describe the render-only hide/restore behavior. - New/updated tests: App.taskPopupViewGating.test.tsx, usePoppedOutTasks.test.ts, AppearanceSection.test.tsx, settings-default-descriptions.test.tsx, settings-defaults.test.ts. Files changed: docs/dashboard-guide.md | 5 +- docs/settings-reference.md | 1 + .../core/src/__tests__/settings-defaults.test.ts | 13 +++ packages/core/src/settings-schema.ts | 5 + packages/core/src/types.ts | 7 ++ packages/dashboard/app/App.tsx | 49 +++++++-- .../app/__tests__/App.taskPopupViewGating.test.tsx | 113 +++++++++++++++++++++ .../dashboard/app/components/SettingsModal.tsx | 3 +- .../settings/sections/AppearanceSection.tsx | 8 ++ .../sections/__tests__/AppearanceSection.test.tsx | 21 ++++ .../settings-default-descriptions.test.tsx | 1 + .../app/hooks/__tests__/usePoppedOutTasks.test.ts | 14 +++ packages/dashboard/app/hooks/useAppSettings.ts | 4 + packages/dashboard/app/hooks/usePoppedOutTasks.ts | 27 +++-- packages/i18n/locales/en/app.json | 2 + 15 files changed, 255 insertions(+), 18 deletions(-) Fusion-Task-Id: FN-7944 Fusion-Task-Lineage: 4b8ced0e-1853-429f-8482-163821a35ae6 Co-authored-by: Fusion (runfusion.ai) --- docs/dashboard-guide.md | 5 +- docs/settings-reference.md | 1 + .../src/__tests__/settings-defaults.test.ts | 13 ++ packages/core/src/settings-schema.ts | 5 + packages/core/src/types.ts | 7 ++ packages/dashboard/app/App.tsx | 49 ++++++-- .../App.taskPopupViewGating.test.tsx | 113 ++++++++++++++++++ .../app/components/SettingsModal.tsx | 3 +- .../settings/sections/AppearanceSection.tsx | 8 ++ .../__tests__/AppearanceSection.test.tsx | 21 ++++ .../settings-default-descriptions.test.tsx | 1 + .../hooks/__tests__/usePoppedOutTasks.test.ts | 14 +++ .../dashboard/app/hooks/useAppSettings.ts | 4 + .../dashboard/app/hooks/usePoppedOutTasks.ts | 27 +++-- packages/i18n/locales/en/app.json | 2 + 15 files changed, 255 insertions(+), 18 deletions(-) create mode 100644 packages/dashboard/app/__tests__/App.taskPopupViewGating.test.tsx diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index fa146c910a..e9ef2f78f9 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -111,9 +111,12 @@ If **Settings → Appearance → Open tasks in the right sidebar** is enabled, b +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. --> **Settings → Appearance → Open tasks as popups** changes ordinary board task-card clicks 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 or right-dock task detail, keeping the board 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/task-detail links outside the right-dock Tasks list, 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, each open task-detail popup stays attached to the Board or List view where it was opened: 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. + diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 17abaac232..224a096500 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -508,6 +508,7 @@ When `pushAfterMerge` is enabled, a completed direct merge first runs `git pull | `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, floating pop-outs, graph/plugin opens, and deep `changes`/`retries`/`workflow` opens, keep their existing behavior; ordinary right-dock Tasks-list cards are governed by `openMobileTasksInPopup` first and otherwise use embedded dock detail. | | `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; ordinary right-dock Tasks-list clicks open embedded dock detail with the normal back-to-list controls. When on, ordinary board task-card clicks and right-dock Tasks-list clicks open the task in the existing task popup/FloatingWindow surface on desktop, tablet, and mobile so the board or dock list 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 and use the board/task-detail layer rather than the global utility layer, while their Activity dropdown stays above and attached during popup drag/resize; mobile task popups remain full-screen sheets. Deep `changes`/`retries`/`workflow` opens, list/detail opens outside the right-dock Tasks list, graph/plugin opens, nested task-detail opens, and explicit pop-out actions keep their existing behavior. | +| `taskPopupsBoardListOnly` | `boolean` | `false` | Project-scoped Appearance setting. Default: off, so open task popups remain visible over every main-content view. When on, each open task-detail popup is attached to the Board or List view where it was opened: switching to Command Center, Agents, Settings, another task view, or the other Board/List view hides it without closing or clearing popup state; returning to the originating Board/List view re-renders the same popup with its shared persisted size/position. | | `showCostBadgeOnCards` | `boolean` | `false` | Default: off. When enabled from Settings → Appearance, board cards with positive recorded token usage show a read-time derived model-cost badge beside the execution-time badge. Unpriced models display `—`, and tasks with no token usage render no badge shell. | | `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/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index 77231271a2..4dc2997f71 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -177,6 +177,19 @@ describe("settings defaults invariants", () => { }); }); + describe("taskPopupsBoardListOnly default", () => { + it("keeps taskPopupsBoardListOnly explicitly false in project defaults", () => { + expect(DEFAULT_PROJECT_SETTINGS.taskPopupsBoardListOnly).toBe(false); + expect("taskPopupsBoardListOnly" in DEFAULT_PROJECT_SETTINGS).toBe(true); + expect(PROJECT_SETTINGS_KEYS).toContain("taskPopupsBoardListOnly"); + }); + + it("keeps taskPopupsBoardListOnly project-scoped only", () => { + expect("taskPopupsBoardListOnly" in DEFAULT_GLOBAL_SETTINGS).toBe(false); + expect(GLOBAL_SETTINGS_KEYS).not.toContain("taskPopupsBoardListOnly"); + }); + }); + describe("showCostBadgeOnCards default", () => { it("keeps showCostBadgeOnCards explicitly false in project defaults", () => { expect(DEFAULT_PROJECT_SETTINGS.showCostBadgeOnCards).toBe(false); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 64fb55c94a..d0a9e17fc8 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -399,6 +399,11 @@ 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. + */ + taskPopupsBoardListOnly: false, + /* 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 73f7299579..2a5f7abbe1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4055,6 +4055,13 @@ export interface ProjectSettings { * This project-scoped setting is default-off so board navigation is unchanged until operators opt in. When enabled, it applies to board-card clicks on every viewport with no deep initial tab and reuses the existing task pop-out/FloatingWindow path; the popup route takes precedence over right-dock routing for those ordinary clicks while all non-board task-open paths remain governed by their existing settings and handlers. */ openMobileTasksInPopup?: boolean; + /** + * When true, open task-detail popups render only on the Board/List view where they were opened. Default: false. + * + * 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. + */ + taskPopupsBoardListOnly?: boolean; /** * FNXC:TaskCardCostBadge 2026-07-11-12:15: * Default-off project setting that lets operators opt board cards into showing derived read-time task cost next to the execution-time badge. Missing/false preserves existing card density and no badge shell renders unless a task has positive token usage. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 1ed60c492f..182fa8261a 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -224,6 +224,15 @@ export interface DashboardShortcutPopupHandlers { closeTerminal: () => void; } +export function isTaskPopupVisibleForView(options: { + taskPopupsBoardListOnly: boolean; + taskView: TaskView; + originTaskView?: TaskView; +}): boolean { + if (!options.taskPopupsBoardListOnly) return true; + return (options.originTaskView === "board" || options.originTaskView === "list") && options.originTaskView === options.taskView; +} + /* 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. @@ -464,7 +473,10 @@ function AppInner() { 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). */ - const { tasks: poppedOutTasks, popOut: popOutTaskDetail, close: closePoppedOutTask } = usePoppedOutTasks(); + const { entries: poppedOutTaskEntries, popOut: popOutTaskDetail, close: closePoppedOutTask } = usePoppedOutTasks(); + const popOutTaskDetailForCurrentView = useCallback((task: Task | TaskDetail) => { + popOutTaskDetail(task, taskView); + }, [popOutTaskDetail, taskView]); const boardSourceTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks; const [graphWorkflowSelection, setGraphWorkflowSelection] = useState(null); @@ -620,6 +632,7 @@ function AppInner() { capacityRiskTodoThreshold, openTasksInRightSidebar, openMobileTasksInPopup, + taskPopupsBoardListOnly, showCostBadgeOnCards, modelPricingOverrides, taskDetailChatFirst, @@ -642,6 +655,21 @@ function AppInner() { refresh: refreshAppSettings, } = useAppSettings(currentProject?.id); + const taskPopupsVisibleOnCurrentView = useCallback((originTaskView?: TaskView) => isTaskPopupVisibleForView({ + taskPopupsBoardListOnly, + taskView, + originTaskView, + }), [taskPopupsBoardListOnly, taskView]); + /* + FNXC:TaskPopupViewGating 2026-07-13-00:00: + Default-off preserves today's globally visible task popups. When enabled, a popup is render-attached to the Board/List view where it was opened: switching views unmounts the FloatingWindow without clearing the hook snapshot, and returning to that same view remounts it with the shared persisted geometry. + */ + const visiblePoppedOutTaskEntries = useMemo( + () => poppedOutTaskEntries.filter((entry) => taskPopupsVisibleOnCurrentView(entry.originTaskView)), + [poppedOutTaskEntries, taskPopupsVisibleOnCurrentView], + ); + const visiblePoppedOutTasks = useMemo(() => visiblePoppedOutTaskEntries.map((entry) => entry.task), [visiblePoppedOutTaskEntries]); + const pluginDashboardViews = useMemo(() => { /* FNXC:RoadmapsNavigation 2026-06-22-18:50: @@ -1027,7 +1055,7 @@ function AppInner() { */ return closeTopmostDashboardPopupForShortcut( { - poppedOutTaskIds: poppedOutTasks.map((task) => task.id), + poppedOutTaskIds: visiblePoppedOutTasks.map((task) => task.id), quickChatOpen, terminalOpen: modalManager.terminalOpen, modalClosers: [ @@ -1056,7 +1084,7 @@ function AppInner() { closeTerminal: closeTerminalWithNav, }, ); - }, [closePoppedOutTask, closeTerminalWithNav, modalManager, poppedOutTasks, quickChatOpen]); + }, [closePoppedOutTask, closeTerminalWithNav, modalManager, quickChatOpen, visiblePoppedOutTasks]); const openFilesWithNav = useCallback((workspace?: string, initialFile?: string | null) => { modalManager.openFiles(workspace, initialFile); @@ -1256,7 +1284,7 @@ function AppInner() { // Props for the extracted switch (see components/dashboard/MainContent.tsx). // Every value is passed by its App name; the switch renders the same subtrees as before. - const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openTaskPopup: popOutTaskDetail, openMobileTasksInPopup, openFileInBrowser, onMoveTask: moveTask, onDeleteTask: deleteTask, onArchiveTask: archiveTask, onRevertTask: revertTask, onMergeTask: mergeTask, onRetryTask: retryTask, onBypassReview: bypassReview, onResetTask: resetTask, onDuplicateTask: duplicateTask, onTaskUpdated: (task: Task) => ingestCreatedTasks([task]), openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, taskDetailChatFirst, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, todosEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible }); + const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openTaskPopup: popOutTaskDetailForCurrentView, openMobileTasksInPopup, openFileInBrowser, onMoveTask: moveTask, onDeleteTask: deleteTask, onArchiveTask: archiveTask, onRevertTask: revertTask, onMergeTask: mergeTask, onRetryTask: retryTask, onBypassReview: bypassReview, onResetTask: resetTask, onDuplicateTask: duplicateTask, onTaskUpdated: (task: Task) => ingestCreatedTasks([task]), openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, taskDetailChatFirst, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, todosEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible }); /* FNXC:OpenTasksInRightSidebar 2026-06-28-00:00: @@ -1275,7 +1303,7 @@ function AppInner() { }); if (route === "popup") { - popOutTaskDetail(task); + popOutTaskDetailForCurrentView(task); return; } @@ -1285,7 +1313,7 @@ function AppInner() { } openTaskDetailInMainPanel(task, initialTab); - }, [isMobile, openMobileTasksInPopup, openTaskDetailInMainPanel, openTasksInRightSidebar, popOutTaskDetail, rightDock, rightDockActive]); + }, [isMobile, openMobileTasksInPopup, openTaskDetailInMainPanel, openTasksInRightSidebar, popOutTaskDetailForCurrentView, rightDock, rightDockActive]); useEffect(() => { if (!openTasksInRightSidebar) { @@ -1357,7 +1385,7 @@ function AppInner() { agentsEnabled, agentOnboardingEnabled, handleOpenTaskLogs, - popOutTaskDetail, + popOutTaskDetail: popOutTaskDetailForCurrentView, selectedPrId, insightsEnabled, handleInsightTaskCreate, @@ -1777,8 +1805,11 @@ 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-13-00:00: + Rendering uses visible entries only; the source hook keeps hidden popup snapshots mounted in React state rather than clearing them on view change. This distinction lets the opt-in setting attach each popup to its originating Board/List view while default-off continues to render all open popups globally. */} - {poppedOutTasks.map((snapshot) => { + {visiblePoppedOutTaskEntries.map(({ task: snapshot }) => { const liveTask = tasks.find((candidate) => candidate.id === snapshot.id) ?? snapshot; const close = () => closePoppedOutTask(snapshot.id); return ( @@ -1798,7 +1829,7 @@ function AppInner() { projectId={currentProject?.id} tasks={tasks} embedded - onOpenDetail={popOutTaskDetail} + onOpenDetail={popOutTaskDetailForCurrentView} onMoveTask={moveTask} onDeleteTask={deleteTask} onMergeTask={mergeTask} diff --git a/packages/dashboard/app/__tests__/App.taskPopupViewGating.test.tsx b/packages/dashboard/app/__tests__/App.taskPopupViewGating.test.tsx new file mode 100644 index 0000000000..de27e1eaa7 --- /dev/null +++ b/packages/dashboard/app/__tests__/App.taskPopupViewGating.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { Task } from "@fusion/core"; +import { isTaskPopupVisibleForView, TASK_DETAIL_FLOATING_GEOMETRY_KEY } from "../App"; +import { FloatingWindow } from "../components/FloatingWindow"; +import type { PoppedOutTaskEntry } from "../hooks/usePoppedOutTasks"; +import type { TaskView } from "../hooks/useViewState"; + +function task(id: string): Task { + return { id, title: id, status: "todo" } as Task; +} + +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}
+
+
+ ))} + + ); +} + +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(); +} + +describe("App task popup view gating", () => { + it("keeps default/off popups visible regardless of the active view", () => { + render( + , + ); + + expect(screen.getByTestId("floating-window-task-detail-FN-7944-A")).toBeInTheDocument(); + expect(screen.getByTestId("floating-window-overlay-task-detail-FN-7944-A")).toBeInTheDocument(); + }); + + 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("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("does not render popups opened away from Board/List when attachment is enabled", () => { + render( + , + ); + + expectNoTaskPopupShell("FN-7944-command"); + }); +}); diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 9784a0fa94..6f589fb72d 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -419,7 +419,7 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [ { id: "global-general", label: "General", labelKey: "settings.nav.globalGeneral", scope: "global", searchableText: ["global defaults", "modal outside dismiss", "agent logs", "persist tool output", "thinking logs", "GitLab instance URL", "global tracking repo"] }, { id: "keyboard-shortcuts", label: "Keyboard Shortcuts", labelKey: "settings.nav.keyboardShortcuts", scope: "global", searchableText: ["keyboard shortcuts", "hotkeys", "quick chat shortcut", "terminal shortcut", "open files", "open settings", "command center", "new task shortcut", "record shortcut"] }, { id: "authentication", label: "Authentication", labelKey: "settings.nav.authentication", scope: undefined, icon: Globe, searchableText: ["login", "OAuth", "API key", "custom providers", "Anthropic", "OpenAI", "provider credentials"] }, - { id: "appearance", label: "Appearance", labelKey: "settings.nav.appearance", scope: "global", searchableText: ["theme", "color", "sidebar", "dock", "task popup", "open tasks as popups", "quick chat"] }, + { id: "appearance", label: "Appearance", labelKey: "settings.nav.appearance", scope: "global", searchableText: ["theme", "color", "sidebar", "dock", "task popup", "task popups", "board list popups", "popup view attachment", "open tasks as popups", "quick chat"] }, { id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global", searchableText: ["ntfy", "webhook", "events", "failure notifications", "sticky", "toast"] }, { id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global", searchableText: ["sync", "node", "distributed", "heartbeat", "coordination"] }, { id: "global-models", label: "Models", labelKey: "settings.nav.globalModels", scope: "global", searchableText: ["global models", "model presets", "favorite providers", "model pricing overrides", "LiteLLM pricing", "token pricing"] }, @@ -1006,6 +1006,7 @@ export function SettingsModal({ showWorktreeGrouping: false, openTasksInRightSidebar: false, openMobileTasksInPopup: false, + taskPopupsBoardListOnly: false, 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 80de3b7cfc..e743dc45ce 100644 --- a/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx @@ -52,6 +52,14 @@ export function AppearanceSection({ scopeBanner, form, setForm, themeMode, color {t("settings.appearance.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.")} +
+ {/* 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. */} + + {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.")} +
{/* 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. */}