FN-7259: add mobile task popup setting

Add an opt-in mobile board-card route that opens task details in the existing popup.

- Add the openMobileTasksInPopup project setting, defaults, types, docs, and changeset.
- Expose the Appearance setting and include it in dashboard settings persistence.
- Route mobile board-card clicks without deep tabs into the task FloatingWindow while preserving dock and main-panel behavior.
- Add coverage for settings defaults, Appearance controls, mobile routing, and task popup sizing.

Files changed:
 .changeset/fn-7259-mobile-task-popups.md           |  7 ++++
 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                     | 45 ++++++++++++++++++--
 .../__tests__/App.openTasksInRightSidebar.test.ts  | 48 +++++++++++++++++++++-
 .../dashboard/app/components/FloatingWindow.css    | 28 +++++++++++++
 .../dashboard/app/components/SettingsModal.tsx     |  1 +
 .../components/__tests__/FloatingWindow.test.tsx   | 22 ++++++++++
 .../settings/sections/AppearanceSection.tsx        |  7 ++++
 .../sections/__tests__/AppearanceSection.test.tsx  | 19 +++++++++
 packages/dashboard/app/hooks/useAppSettings.ts     |  5 +++
 14 files changed, 206 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-7259

Fusion-Task-Lineage: 05f22978-ede7-4d13-940c-5108390959f7

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 23:12:56 -07:00
parent 2a5a108123
commit 17fce43205
14 changed files with 206 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a mobile setting to open board tasks in the existing popup.
category: feature
dev: Adds project setting `openMobileTasksInPopup` and mobile-only board-card routing to task FloatingWindow.

View File

@@ -54,7 +54,10 @@ The **Right Dock Panel** experiment is enabled by default. To disable it, open *
When enabled on desktop or tablet project screens, the right dock is a persistent far-right tools sidebar in the project content row. By default it opens as an overlay so the main content does not reflow. Use the dock toolbar pin action to switch into push mode, where the dock becomes an in-flow pane that shrinks the main content beside it; unpinning returns to overlay mode. The selected tool, open/closed state, pinned push-mode state, width, and expanded modal size persist across reloads.
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, and non-board task-open paths keep their existing behavior.
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 separate mobile popup setting below is enabled, and non-board task-open paths keep their existing behavior.
<!-- FNXC:MobileTaskPopups 2026-06-29-00:00: Mobile board-card task opens have a separate default-off popup setting so phone users can opt into the existing FloatingWindow task popup without changing desktop/tablet right-dock behavior or deep-tab task opens. -->
On mobile, **Settings → Appearance → Open mobile tasks as popups** changes only ordinary board task-card clicks. When enabled, those clicks use the existing task popup/FloatingWindow surface instead of the full-panel task detail; deep `changes`/`retries`/`workflow` opens, list/task-detail links, plugin/graph opens, and desktop/tablet routing keep their existing paths.
<!-- FNXC:DashboardNavigationDocs 2026-06-27-00:00: The right dock now hosts Chat as an inline tool panel; keep this user-facing roster aligned with STATIC_OVERFLOW_VIEW_ENTRIES so users know Chat can also pop out from the dock. -->
<!-- FNXC:RightDockTasks 2026-06-28-19:55: The dock task-detail overlay is now anchored to the first-class Tasks tool tab. Document that Tasks is a dock-only auxiliary surface with a last-viewed detail/list fallback, not a new primary navigation destination. -->

View File

@@ -461,6 +461,7 @@ Sandbox backend precedence is:
| `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. |
| `showWorktreeGrouping` | `boolean` | `false` | Default: off. When off, WIP/processing columns render plain task cards without worktree group shells or worktree-name labels in both legacy and workflow-mode boards. When on, every WIP/processing column groups tasks by worktree and shows worktree names, including workflow-mode columns flagged as counting toward WIP. |
| `openTasksInRightSidebar` | `boolean` | `false` | Default: off. When off, board task-card clicks keep the existing full-panel task detail that replaces the board. When on and the right dock is active on desktop/tablet, board task-card clicks open the task detail in the right sidebar so the board stays visible; mobile or hidden/inactive right-dock states automatically fall back to the full-panel behavior. Non-board task-open paths, including list split detail, right-dock task cards, floating pop-outs, graph/plugin opens, and deep `changes`/`retries`/`workflow` opens, keep their existing behavior. |
| `openMobileTasksInPopup` | `boolean` | `false` | Default: off. When off, mobile board task-card clicks keep the existing full-panel task detail. When on and the dashboard is in mobile viewport mode, ordinary board task-card clicks open the task in the existing task popup/FloatingWindow surface instead. This is mobile-only and board-only: desktop/tablet routing, `openTasksInRightSidebar`, right-dock task cards, list/detail opens, graph/plugin opens, nested task-detail opens, explicit pop-out actions, and deep `changes`/`retries`/`workflow` opens keep their existing behavior. |
| `executorAllowSiblingBranchRename` | `boolean` | `false` | Opt back into the legacy executor behavior that silently allocates sibling branches (`fusion/<task-id>-2`, `-2-2`, …) when the canonical task branch is already checked out elsewhere. When disabled (default), branch conflicts fail loudly and leave the task in `todo` with `status: "failed"` so operators can resolve conflicting branches/worktrees with git tooling before retrying. See [Task Management → Branch conflict handling](./task-management.md#branch-conflict-handling). The dashboard Settings modal exposes the same toggle with warning copy because this legacy mode is discouraged. |
| `worktreeNaming` | `"random" \| "task-id" \| "task-title"` | `"random"` | Naming mode for new worktree directories. |

View File

@@ -150,6 +150,19 @@ describe("settings defaults invariants", () => {
});
});
describe("openMobileTasksInPopup default", () => {
it("keeps openMobileTasksInPopup explicitly false in project defaults", () => {
expect(DEFAULT_PROJECT_SETTINGS.openMobileTasksInPopup).toBe(false);
expect("openMobileTasksInPopup" in DEFAULT_PROJECT_SETTINGS).toBe(true);
expect(PROJECT_SETTINGS_KEYS).toContain("openMobileTasksInPopup");
});
it("keeps openMobileTasksInPopup project-scoped only", () => {
expect("openMobileTasksInPopup" in DEFAULT_GLOBAL_SETTINGS).toBe(false);
expect(GLOBAL_SETTINGS_KEYS).not.toContain("openMobileTasksInPopup");
});
});
describe("quickChatCloseOnOutsideClick default", () => {
it("keeps Quick Chat outside-click dismissal explicitly true in project defaults", () => {
expect(DEFAULT_PROJECT_SETTINGS.quickChatCloseOnOutsideClick).toBe(true);

View File

@@ -317,6 +317,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
recycleWorktrees: false,
showWorktreeGrouping: false,
openTasksInRightSidebar: false,
/*
FNXC:MobileTaskPopups 2026-06-29-00:00:
Default off preserves current mobile board-card task detail behavior. The dashboard only consults this project setting for mobile board-card clicks without a deep tab, and reuses the existing task pop-out surface without changing desktop/right-dock routing.
*/
openMobileTasksInPopup: false,
executorAllowSiblingBranchRename: false,
worktreeNaming: "random",
worktrunk: {

View File

@@ -3717,6 +3717,13 @@ export interface ProjectSettings {
* This project-scoped setting is default-off so current board navigation is unchanged. When enabled, only Board card clicks may route to the tablet/desktop right dock; all non-board task-open paths and dock-inactive/mobile states must preserve the full-panel or existing modal behavior.
*/
openTasksInRightSidebar?: boolean;
/**
* When true, mobile board task-card clicks open task detail in the existing popped-out FloatingWindow task surface instead of the full main-panel task detail. Default: false.
*
* FNXC:MobileTaskPopups 2026-06-29-00:00:
* This project-scoped setting is default-off so mobile board navigation is unchanged until operators opt in. When enabled, it applies only to mobile viewport board-card clicks with no deep initial tab and reuses the existing task pop-out/FloatingWindow path; desktop/tablet right-dock routing and all non-board task-open paths remain governed by their existing settings and handlers.
*/
openMobileTasksInPopup?: boolean;
/** When true, restores the legacy behavior of silently creating sibling
* branches like `fusion/FN-123-2` when the canonical task branch is already
* checked out elsewhere. Default: false. */

View File

@@ -185,6 +185,24 @@ export function shouldOpenBoardTaskInDock(openTasksInRightSidebar: boolean, righ
return !initialTab && openTasksInRightSidebar && rightDockActive;
}
export type BoardTaskOpenRoute = "popup" | "dock" | "main-panel";
export function getBoardTaskOpenRoute(options: {
isMobile: boolean;
openMobileTasksInPopup: boolean;
openTasksInRightSidebar: boolean;
rightDockActive: boolean;
initialTab?: DetailTaskTab;
}): BoardTaskOpenRoute {
if (!options.initialTab && options.isMobile && options.openMobileTasksInPopup) {
return "popup";
}
if (shouldOpenBoardTaskInDock(options.openTasksInRightSidebar, options.rightDockActive, options.initialTab)) {
return "dock";
}
return "main-panel";
}
function AppInner() {
const { t } = useTranslation("app");
const { toasts, addToast, removeToast } = useToast();
@@ -539,6 +557,7 @@ function AppInner() {
capacityRiskBannerEnabled,
capacityRiskTodoThreshold,
openTasksInRightSidebar,
openMobileTasksInPopup,
quickChatButtonMode,
quickChatCloseOnOutsideClick,
maxTotalRetriesBeforeFail,
@@ -1118,14 +1137,31 @@ function AppInner() {
/*
FNXC:OpenTasksInRightSidebar 2026-06-28-00:00:
Board card clicks are the only task-open path governed by openTasksInRightSidebar. When the project setting is enabled and the tablet/desktop right dock is active, the board keeps its current view and asks the dock controller to render task detail; otherwise the existing full main-panel replacement remains the fallback, including mobile and hidden-footer states.
FNXC:MobileTaskPopups 2026-06-29-00:00:
Mobile board-card clicks may opt into the existing task pop-out path, but only for ordinary task opens with no deep initial tab. The route is intentionally ordered as mobile popup, then desktop/tablet right dock, then main-panel fallback so the new setting cannot override deep-tab opens, non-board handlers, or desktop right-dock behavior.
*/
const openBoardTaskDetail = useCallback((task: Task | TaskDetail, initialTab?: DetailTaskTab) => {
if (!shouldOpenBoardTaskInDock(openTasksInRightSidebar, rightDockActive, initialTab)) {
openTaskDetailInMainPanel(task, initialTab);
const route = getBoardTaskOpenRoute({
isMobile,
openMobileTasksInPopup,
openTasksInRightSidebar,
rightDockActive,
initialTab,
});
if (route === "popup") {
popOutTaskDetail(task);
return;
}
rightDock.openTaskInDock(task);
}, [openTaskDetailInMainPanel, openTasksInRightSidebar, rightDock, rightDockActive]);
if (route === "dock") {
rightDock.openTaskInDock(task);
return;
}
openTaskDetailInMainPanel(task, initialTab);
}, [isMobile, openMobileTasksInPopup, openTaskDetailInMainPanel, openTasksInRightSidebar, popOutTaskDetail, rightDock, rightDockActive]);
useEffect(() => {
if (!openTasksInRightSidebar) {
@@ -1598,6 +1634,7 @@ function AppInner() {
onClose={close}
hideHeader
dragHandleSelector=".task-detail-content--embedded > .modal-header"
className="floating-window--task-detail"
>
<TaskDetailContent
task={liveTask}

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { shouldOpenBoardTaskInDock } from "../App";
import { getBoardTaskOpenRoute, shouldOpenBoardTaskInDock } from "../App";
describe("openTasksInRightSidebar board routing", () => {
describe("board task detail routing", () => {
it("opens board card clicks in the dock only when the setting and dock surface are both active", () => {
expect(shouldOpenBoardTaskInDock(true, true)).toBe(true);
expect(shouldOpenBoardTaskInDock(false, true)).toBe(false);
@@ -13,4 +13,48 @@ describe("openTasksInRightSidebar board routing", () => {
expect(shouldOpenBoardTaskInDock(true, true, "retries")).toBe(false);
expect(shouldOpenBoardTaskInDock(true, true, "workflow")).toBe(false);
});
it("routes mobile board card clicks to the popup only when the mobile popup setting is enabled", () => {
expect(getBoardTaskOpenRoute({
isMobile: true,
openMobileTasksInPopup: true,
openTasksInRightSidebar: false,
rightDockActive: false,
})).toBe("popup");
expect(getBoardTaskOpenRoute({
isMobile: true,
openMobileTasksInPopup: false,
openTasksInRightSidebar: false,
rightDockActive: false,
})).toBe("main-panel");
});
it("preserves desktop and tablet right-dock routing precedence", () => {
expect(getBoardTaskOpenRoute({
isMobile: false,
openMobileTasksInPopup: true,
openTasksInRightSidebar: true,
rightDockActive: true,
})).toBe("dock");
expect(getBoardTaskOpenRoute({
isMobile: false,
openMobileTasksInPopup: true,
openTasksInRightSidebar: false,
rightDockActive: true,
})).toBe("main-panel");
});
it("keeps deep-tab opens off the mobile popup path", () => {
for (const initialTab of ["changes", "retries", "workflow"] as const) {
expect(getBoardTaskOpenRoute({
isMobile: true,
openMobileTasksInPopup: true,
openTasksInRightSidebar: true,
rightDockActive: true,
initialTab,
})).toBe("main-panel");
}
});
});

View File

@@ -119,6 +119,34 @@ On mobile/narrow app viewports, opening Quick Chat should present the full Chat
.floating-window--chat .chat-view {
border-radius: 0;
}
/*
FNXC:MobileTaskPopups 2026-06-29-00:00:
Mobile board-card popup opens reuse the existing task-detail FloatingWindow, but the shell must behave like a full-screen mobile sheet. Scope the override to task-detail pop-outs, hide resize handles, and remove drag/resize cursors so no invisible desktop affordances remain on touch layouts.
*/
.floating-window--task-detail {
inset: 0 !important;
width: 100vw !important;
height: 100dvh !important;
min-width: 0 !important;
min-height: 0 !important;
max-width: 100vw !important;
max-height: 100dvh !important;
border: none;
border-radius: 0;
box-shadow: none;
}
.floating-window--task-detail .floating-window__resize-handle {
display: none;
}
.floating-window--task-detail .task-detail-content--embedded,
.floating-window--task-detail .task-detail-content--embedded > .modal-header {
border-radius: 0;
cursor: default;
touch-action: auto;
}
}
.floating-window__title {

View File

@@ -734,6 +734,7 @@ export function SettingsModal({
recycleWorktrees: false,
showWorktreeGrouping: false,
openTasksInRightSidebar: false,
openMobileTasksInPopup: false,
executorAllowSiblingBranchRename: false,
worktreeNaming: "random",
worktreeCopyFiles: [],

View File

@@ -61,6 +61,7 @@ describe("FloatingWindow", () => {
onClose={() => {}}
hideHeader
dragHandleSelector=".task-detail-content--embedded > .modal-header"
className="floating-window--task-detail"
>
<div className="task-detail-content--embedded">
<div className="modal-header">KB-001</div>
@@ -71,12 +72,33 @@ describe("FloatingWindow", () => {
expect(screen.queryByTestId("floating-window-drag-handle-task")).toBeNull();
expect(screen.getByTestId("floating-window-task")).toHaveClass("floating-window--headerless");
expect(screen.getByTestId("floating-window-task")).toHaveClass("floating-window--task-detail");
expect(screen.getByText("KB-001")).toBeInTheDocument();
for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
expect(screen.getByTestId(`floating-window-resize-${dir}`)).toBeTruthy();
}
});
it("scopes mobile sheet sizing and hidden resize handles to task-detail pop-outs", () => {
expect(floatingWindowCss).toContain("FNXC:MobileTaskPopups 2026-06-29-00:00");
expect(floatingWindowCss).toContain(".floating-window--task-detail {");
expect(floatingWindowCss).toContain("width: 100vw !important;");
expect(floatingWindowCss).toContain("height: 100dvh !important;");
expect(floatingWindowCss).toContain(".floating-window--task-detail .floating-window__resize-handle");
expect(floatingWindowCss).toContain("display: none;");
expect(floatingWindowCss).toContain("cursor: default;");
expect(floatingWindowCss).toContain("touch-action: auto;");
});
it("does not apply task-detail mobile sizing to chat floating windows", () => {
const taskRuleIndex = floatingWindowCss.indexOf(".floating-window--task-detail {");
const chatRuleIndex = floatingWindowCss.indexOf(".floating-window--chat {");
expect(taskRuleIndex).toBeGreaterThan(-1);
expect(chatRuleIndex).toBeGreaterThan(-1);
expect(taskRuleIndex).not.toBe(chatRuleIndex);
});
it("focus-to-front: interacting with an older window raises its z-index above the newest", () => {
render(
<>

View File

@@ -44,6 +44,13 @@ export function AppearanceSection({ scopeBanner, form, setForm, themeMode, color
</label>
<small className="form-text text-muted">{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.")}</small>
</div>
<div className="form-group">
<label className="checkbox-label">
<input type="checkbox" checked={form.openMobileTasksInPopup === true} onChange={(e) => setForm((f) => ({ ...f, openMobileTasksInPopup: e.target.checked }))}/>
<span>{t("settings.appearance.openMobileTasksInPopup", "Open mobile tasks as popups")}</span>
</label>
<small className="form-text text-muted">{t("settings.appearance.openMobileTasksInPopupHelp", "When enabled, mobile board task-card clicks use the existing task popup instead of the full-screen task panel. Desktop, right-sidebar, and non-board task opens keep their current behavior.")}</small>
</div>
<div className="form-group">
<label className="checkbox-label">
<input type="checkbox" checked={sessionBannersHidden} onChange={(e) => setSessionBannersHidden(e.target.checked)}/>

View File

@@ -20,6 +20,7 @@ function renderAppearanceSection(formOverrides: Partial<Settings> = {}) {
groupOverlappingFiles: true,
autoMerge: true,
openTasksInRightSidebar: false,
openMobileTasksInPopup: false,
...formOverrides,
} as SettingsFormState;
const setForm = vi.fn((updater: SettingsFormState | ((previous: SettingsFormState) => SettingsFormState)) => {
@@ -60,4 +61,22 @@ describe("AppearanceSection", () => {
expect(screen.getByLabelText("Open tasks in the right sidebar")).toBeChecked();
});
it("renders and updates the mobile task popup checkbox", () => {
const { setForm, getForm } = renderAppearanceSection();
const checkbox = screen.getByLabelText("Open mobile tasks as popups");
expect(checkbox).not.toBeChecked();
fireEvent.click(checkbox);
expect(setForm).toHaveBeenCalledTimes(1);
expect(getForm().openMobileTasksInPopup).toBe(true);
});
it("reflects a persisted enabled mobile task popup value", () => {
renderAppearanceSection({ openMobileTasksInPopup: true });
expect(screen.getByLabelText("Open mobile tasks as popups")).toBeChecked();
});
});

View File

@@ -21,6 +21,7 @@ export interface UseAppSettingsResult {
capacityRiskBannerEnabled: boolean;
capacityRiskTodoThreshold: number;
openTasksInRightSidebar: boolean;
openMobileTasksInPopup: boolean;
quickChatButtonMode: QuickChatButtonMode;
quickChatCloseOnOutsideClick: boolean;
showQuickChatFAB: boolean;
@@ -61,6 +62,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [capacityRiskBannerEnabled, setCapacityRiskBannerEnabled] = useState(false);
const [capacityRiskTodoThreshold, setCapacityRiskTodoThreshold] = useState(20);
const [openTasksInRightSidebar, setOpenTasksInRightSidebar] = useState(false);
const [openMobileTasksInPopup, setOpenMobileTasksInPopup] = useState(false);
const [quickChatButtonMode, setQuickChatButtonMode] = useState<QuickChatButtonMode>("off");
const [quickChatCloseOnOutsideClick, setQuickChatCloseOnOutsideClick] = useState(true);
const [showQuickChatFAB, setShowQuickChatFAB] = useState(false);
@@ -119,6 +121,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setCapacityRiskBannerEnabled(settings.capacityRiskBannerEnabled === true);
setCapacityRiskTodoThreshold(settings.capacityRiskTodoThreshold ?? 20);
setOpenTasksInRightSidebar(settings.openTasksInRightSidebar === true);
setOpenMobileTasksInPopup(settings.openMobileTasksInPopup === true);
setExperimentalFeatures(settings.experimentalFeatures ?? {});
const features = settings.experimentalFeatures ?? {};
/*
@@ -146,6 +149,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setMemoryEnabled(true);
setDevServerEnabled(false);
setOpenTasksInRightSidebar(false);
setOpenMobileTasksInPopup(false);
setQuickChatCloseOnOutsideClick(true);
setTodosEnabled(true);
setGoalsEnabled(true);
@@ -247,6 +251,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
capacityRiskBannerEnabled,
capacityRiskTodoThreshold,
openTasksInRightSidebar,
openMobileTasksInPopup,
quickChatButtonMode,
quickChatCloseOnOutsideClick,
showQuickChatFAB,