From f0888d43c3363eed8a077f874c756e6065dc5962 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 13 Jul 2026 09:27:29 -0700 Subject: [PATCH] FN-7945: route List-view task opens through the movable popup when Open tasks as popups is on Extends the existing board/right-dock "Open tasks as popups" routing so ordinary List row/card and keyboard opens use the same shared movable/resizable FloatingWindow instead of the docked split-pane/mobile detail. - Add openMobileTasksInPopup prop to ListView, threaded through App -> MainContent -> ListView (dashboard/types.ts) - handleRowClick routes to onPopOut (popOutTaskDetail) when the setting is on, on both desktop split-pane and mobile/tablet single-pane; docked behavior is preserved when the setting is off - Restore Enter/Space keyboard activation on list rows to invoke the same handleRowClick path, alongside existing context-menu key handling - Update docs/dashboard-guide.md and docs/settings-reference.md to describe List row/card opens as part of the popup routing surface, and refresh the Appearance settings help copy/FNXC comment accordingly - Add changeset (.changeset/fn-7945-list-view-task-popup.md, minor) describing the user-facing behavior - Extend ListView.test.tsx coverage for the new popup routing and restored keyboard activation Files changed: .changeset/fn-7945-list-view-task-popup.md | 7 ++ docs/dashboard-guide.md | 4 +- docs/settings-reference.md | 2 +- packages/dashboard/app/App.tsx | 1 + packages/dashboard/app/components/ListView.tsx | 48 +++++++++---- .../app/components/__tests__/ListView.test.tsx | 80 +++++++++++++++++++++- .../app/components/dashboard/MainContent.tsx | 2 + .../dashboard/app/components/dashboard/types.ts | 1 + .../settings/sections/AppearanceSection.tsx | 4 +- 9 files changed, 127 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-7945 Fusion-Task-Lineage: 784cb4ee-c493-4ace-bf8b-0e3dbaaef9a3 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7945-list-view-task-popup.md | 7 ++ docs/dashboard-guide.md | 4 +- docs/settings-reference.md | 2 +- packages/dashboard/app/App.tsx | 1 + .../dashboard/app/components/ListView.tsx | 48 +++++++---- .../components/__tests__/ListView.test.tsx | 80 ++++++++++++++++++- .../app/components/dashboard/MainContent.tsx | 2 + .../app/components/dashboard/types.ts | 1 + .../settings/sections/AppearanceSection.tsx | 4 +- 9 files changed, 127 insertions(+), 22 deletions(-) create mode 100644 .changeset/fn-7945-list-view-task-popup.md diff --git a/.changeset/fn-7945-list-view-task-popup.md b/.changeset/fn-7945-list-view-task-popup.md new file mode 100644 index 0000000000..a2d9bad6ae --- /dev/null +++ b/.changeset/fn-7945-list-view-task-popup.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Open tasks as popups now applies to List clicks with the same movable task window as the Board. +category: feature +dev: Threads openMobileTasksInPopup App -> MainContent -> ListView; ListView.handleRowClick routes to onPopOut/popOutTaskDetail (floating-window--task-detail) when enabled, on both desktop split-pane and mobile/tablet single-pane, preserving docked behavior when off. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index e9ef2f78f9..5063f70a01 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -108,12 +108,12 @@ When enabled on desktop or tablet project screens, the right dock is a persisten If **Settings → Appearance → Open tasks in the right sidebar** is enabled, board task-card clicks open task detail inside this right dock and keep the board visible. The setting is default off; mobile or hidden/inactive dock states automatically fall back to the existing full-panel task detail unless the task-popup setting below is enabled, and non-board task-open paths keep their existing behavior. - -**Settings → Appearance → Open tasks as popups** changes 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 → 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, 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 224a096500..649680b668 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -507,7 +507,7 @@ When `pushAfterMerge` is enabled, a completed direct merge first runs `git pull | `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, 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. | +| `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; List row/card opens keep the desktop split-detail pane or the mobile/tablet docked detail; ordinary right-dock Tasks-list clicks open embedded dock detail with the normal back-to-list controls. When on, ordinary board task-card clicks, List row/card opens, and right-dock Tasks-list clicks open the task in the existing task popup/FloatingWindow surface on desktop, tablet, and mobile so the board, List view, 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, context-menu/refine/detail links, 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. | diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 182fa8261a..6b4fd74946 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1369,6 +1369,7 @@ function AppInner() { mergeStrategy, planAutoApproveEnabled, settingsLoaded, + openMobileTasksInPopup, taskDetailChatFirst, skillsEnabled, experimentalFeatures, diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 4431c85ea2..ffe2d29729 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -232,6 +232,8 @@ interface ListViewProps { onPopOut pops the split-pane task detail into a movable, resizable, non-blocking FloatingWindow managed at App level. Wired to the Maximize2 "Pop out" button in TaskDetailContent's header. */ onPopOut?: (task: Task | TaskDetail) => void; + /** Mirrors the Board/right-dock "Open tasks as popups" routing for ordinary List row/card opens. */ + openMobileTasksInPopup?: boolean; addToast: (message: string, type?: ToastType) => void; globalPaused?: boolean; onNewTask?: () => void; @@ -320,6 +322,7 @@ export function ListView({ onResetTask, onDuplicateTask, onPopOut, + openMobileTasksInPopup = false, onOpenDetail, addToast, globalPaused, @@ -1850,20 +1853,6 @@ export function ListView({ }, LIST_TOUCH_CONTEXT_MENU_DELAY_MS); }, [clearLongPressTimer, isMobile, openContextMenuAt]); - const handleListKeyDown = useCallback((event: React.KeyboardEvent, task: Task) => { - if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return; - if (isListContextInteractiveTarget(event.target)) return; - event.preventDefault(); - event.stopPropagation(); - const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); - suppressNextRowClickRef.current = true; - openContextMenuAt( - task, - rect.left + Math.min(rect.width - LIST_CONTEXT_MENU_VIEWPORT_MARGIN, LIST_KEYBOARD_CONTEXT_MENU_OFFSET), - rect.top + Math.min(rect.height - LIST_CONTEXT_MENU_VIEWPORT_MARGIN, LIST_KEYBOARD_CONTEXT_MENU_OFFSET), - ); - }, [openContextMenuAt]); - const handleListPointerMove = useCallback((event: React.PointerEvent) => { const start = longPressStartRef.current; if (!start || start.pointerId !== event.pointerId) return; @@ -1936,6 +1925,14 @@ export function ListView({ return; } closeContextMenu(); + /* + FNXC:ListView 2026-07-13-00:00 (FN-7945): + When "Open tasks as popups" is on, ordinary List row/card and keyboard opens route to the shared movable/resizable popped-out FloatingWindow (`onPopOut` → `popOutTaskDetail`) for Board parity and navigate-while-open behavior. When off, preserve the existing docked split-pane on desktop and docked modal on mobile/tablet. + */ + if (openMobileTasksInPopup && onPopOut) { + onPopOut(task); + return; + } if (useSinglePaneList) { onOpenDetail(task, { origin: "list-mobile" }); return; @@ -1944,9 +1941,30 @@ export function ListView({ setSelectedTaskId(task.id); setSelectedTaskSnapshot(task); }, - [closeContextMenu, onOpenDetail, useSinglePaneList] + [closeContextMenu, onOpenDetail, onPopOut, openMobileTasksInPopup, useSinglePaneList] ); + const handleListKeyDown = useCallback((event: React.KeyboardEvent, task: Task) => { + if (event.key === "Enter" || event.key === " ") { + if (isListContextInteractiveTarget(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + handleRowClick(task); + return; + } + if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return; + if (isListContextInteractiveTarget(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + suppressNextRowClickRef.current = true; + openContextMenuAt( + task, + rect.left + Math.min(rect.width - LIST_CONTEXT_MENU_VIEWPORT_MARGIN, LIST_KEYBOARD_CONTEXT_MENU_OFFSET), + rect.top + Math.min(rect.height - LIST_CONTEXT_MENU_VIEWPORT_MARGIN, LIST_KEYBOARD_CONTEXT_MENU_OFFSET), + ); + }, [handleRowClick, openContextMenuAt]); + // Debounce detail fetches so rapid keyboard/mouse navigation through a // long task list doesn't issue a heavy /tasks/:id request (with log + // comments) per row. Only the task the user lands on triggers a fetch. diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 43ad336145..555e37e4db 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -623,13 +623,15 @@ describe("ListView", () => { const viewportSpy = mockDesktopViewport(); const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })]; const mockOnOpenDetail = vi.fn(); + const onPopOut = vi.fn(); - renderListView({ tasks, onOpenDetail: mockOnOpenDetail }); + renderListView({ tasks, onOpenDetail: mockOnOpenDetail, onPopOut }); const row = screen.getByText("FN-001").closest("tr"); fireEvent.click(row!); expect(mockOnOpenDetail).not.toHaveBeenCalled(); + expect(onPopOut).not.toHaveBeenCalled(); expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-001"); expect(row?.className).toContain("list-row--selected"); await waitFor(() => { @@ -640,22 +642,96 @@ describe("ListView", () => { viewportSpy.mockRestore(); }); + it("routes desktop List row clicks and keyboard opens to the task popup when enabled", () => { + const viewportSpy = mockDesktopViewport(); + const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })]; + const onOpenDetail = vi.fn(); + const onPopOut = vi.fn(); + + renderListView({ tasks, onOpenDetail, onPopOut, openMobileTasksInPopup: true }); + + const row = screen.getByText("FN-001").closest("tr") as HTMLElement; + fireEvent.click(row); + + expect(onPopOut).toHaveBeenCalledWith(tasks[0]); + expect(onPopOut).toHaveBeenCalledTimes(1); + expect(onOpenDetail).not.toHaveBeenCalled(); + expect(screen.queryByTestId("list-split-detail-content")).toBeNull(); + expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBeNull(); + + fireEvent.keyDown(row, { key: "Enter" }); + expect(onPopOut).toHaveBeenCalledTimes(2); + expect(onPopOut).toHaveBeenLastCalledWith(tasks[0]); + expect(onOpenDetail).not.toHaveBeenCalled(); + expect(screen.queryByTestId("list-split-detail-content")).toBeNull(); + viewportSpy.mockRestore(); + }); + + it("falls back to desktop docked detail when popup routing is enabled without onPopOut", async () => { + const viewportSpy = mockDesktopViewport(); + const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })]; + const onOpenDetail = vi.fn(); + + renderListView({ tasks, onOpenDetail, openMobileTasksInPopup: true }); + + const row = screen.getByText("FN-001").closest("tr") as HTMLElement; + fireEvent.click(row); + + expect(onOpenDetail).not.toHaveBeenCalled(); + expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-001"); + await waitFor(() => { + expect(screen.getByTestId("list-split-detail-content")).toBeInTheDocument(); + }); + viewportSpy.mockRestore(); + }); + it("calls onOpenDetail on mobile row click", () => { const viewportSpy = mockMobileViewport(); const tasks = [createMockTask({ id: "FN-001", title: "Test Task" })]; const mockOnOpenDetail = vi.fn(); + const onPopOut = vi.fn(); - renderListView({ tasks, onOpenDetail: mockOnOpenDetail }); + renderListView({ tasks, onOpenDetail: mockOnOpenDetail, onPopOut }); const card = document.querySelector('.list-card[data-id="FN-001"]'); fireEvent.click(card!); expect(mockOnOpenDetail).toHaveBeenCalledWith(tasks[0], { origin: "list-mobile" }); expect(mockOnOpenDetail).toHaveBeenCalledTimes(1); + expect(onPopOut).not.toHaveBeenCalled(); expect(fetchTaskDetail).not.toHaveBeenCalled(); viewportSpy.mockRestore(); }); + it("routes mobile and tablet List cards to the task popup when enabled", () => { + const mobileViewportSpy = mockMobileViewport(); + const mobileTasks = [createMockTask({ id: "FN-001", title: "Mobile popup" })]; + const mobileOnOpenDetail = vi.fn(); + const mobileOnPopOut = vi.fn(); + + const mobileRender = renderListView({ tasks: mobileTasks, onOpenDetail: mobileOnOpenDetail, onPopOut: mobileOnPopOut, openMobileTasksInPopup: true }); + fireEvent.click(document.querySelector('.list-card[data-id="FN-001"]') as HTMLElement); + + expect(mobileOnPopOut).toHaveBeenCalledWith(mobileTasks[0]); + expect(mobileOnOpenDetail).not.toHaveBeenCalled(); + expect(screen.queryByTestId("list-split-detail-content")).toBeNull(); + mobileRender.unmount(); + mobileViewportSpy.mockRestore(); + + const tabletViewportSpy = mockTabletViewport(); + const tabletTasks = [createMockTask({ id: "FN-002", title: "Tablet popup" })]; + const tabletOnOpenDetail = vi.fn(); + const tabletOnPopOut = vi.fn(); + + renderListView({ tasks: tabletTasks, onOpenDetail: tabletOnOpenDetail, onPopOut: tabletOnPopOut, openMobileTasksInPopup: true }); + fireEvent.click(document.querySelector('.list-card[data-id="FN-002"]') as HTMLElement); + + expect(tabletOnPopOut).toHaveBeenCalledWith(tabletTasks[0]); + expect(tabletOnOpenDetail).not.toHaveBeenCalled(); + expect(screen.queryByTestId("list-split-detail-content")).toBeNull(); + tabletViewportSpy.mockRestore(); + }); + it("opens the task context menu from desktop row right-click without selecting or opening detail", async () => { const viewportSpy = mockDesktopViewport(); const onOpenDetail = vi.fn(); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index fbf762aff1..fcf37d26ea 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -74,6 +74,7 @@ export function MainContent({ mergeStrategy, planAutoApproveEnabled, settingsLoaded, + openMobileTasksInPopup, taskDetailChatFirst, skillsEnabled, experimentalFeatures, @@ -917,6 +918,7 @@ export function MainContent({ lastFetchTimeMs={lastFetchTimeMs} prAuthAvailable={prAuthAvailable} autoMerge={autoMerge} + openMobileTasksInPopup={openMobileTasksInPopup} taskDetailChatFirst={taskDetailChatFirst} mergeStrategy={mergeStrategy} onOpenWorkflowEditor={openWorkflowEditorWithNav} diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index c661353676..3bcf9c829c 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -116,6 +116,7 @@ export interface MainContentProps { mergeStrategy: string; planAutoApproveEnabled: boolean; settingsLoaded: boolean; + openMobileTasksInPopup: boolean; taskDetailChatFirst: boolean; skillsEnabled: boolean; experimentalFeatures: Record; diff --git a/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx b/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx index e743dc45ce..d697f0bf3a 100644 --- a/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AppearanceSection.tsx @@ -45,12 +45,12 @@ export function AppearanceSection({ scopeBanner, form, setForm, themeMode, color {t("settings.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.")}
- {/* FNXC:MobileTaskPopups 2026-07-01-12:00: Keep the stored openMobileTasksInPopup key for compatibility, but present the setting as all-viewport ordinary task popup routing because desktop operators also need the board or right-dock Tasks list visible behind task detail. */} + {/* FNXC:MobileTaskPopups 2026-07-13-00:00 (FN-7945): Keep the stored openMobileTasksInPopup key for compatibility, but present the setting as all-viewport ordinary task popup routing because desktop operators also need the board, List view, or right-dock Tasks list visible behind task detail. */} - {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.")} + {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. */}