FN-8754: adapt List task details to available width

Adapt List task detail routing to the usable content width.

- Measure the List container to choose split-pane or modal detail routing.
- Preserve phone and popup-preference routing while allowing wide tablets to use the split pane.
- Add responsive routing coverage and document the behavior.

Files changed:
 .changeset/fn-8754-responsive-list-detail.md       |   7 +
 docs/dashboard-guide.md                            |   4 +-
 packages/dashboard/app/components/ListView.css     |  25 +--
 packages/dashboard/app/components/ListView.tsx     |  47 +++++-
 .../app/components/__tests__/ListView.test.tsx     | 169 +++++++++++++++++++--
 5 files changed, 216 insertions(+), 36 deletions(-)

Fusion-Task-Id: FN-8754

Fusion-Task-Lineage: 2051e7d5-a5a9-47b8-bfc2-42586b9554cc

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-02 23:24:11 -07:00
parent 73fe461db5
commit d39acb5c55
5 changed files with 216 additions and 36 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Make List task details adapt to available content width.
category: feature
dev: List uses measured usable width for split detail routing; popup preference still overrides it.

View File

@@ -383,10 +383,10 @@ Features:
- Bulk selection + batch model, node, and task thinking-level updates
- Bulk Pause / Unpause / Archive actions from the selection toolbar (`Pause selected`, `Unpause selected`, `Archive selected`) for fast batch task state management.
- Bulk delete from the selection toolbar (`Delete selected`): archived selections are skipped automatically, and dependency-conflict failures can be force-deleted per task after a danger confirmation that removes dependency references.
- Desktop List view keeps the two-pane table/detail split. Tablet-width and mobile viewports use the single-pane card layout so list controls and quick-add stay full-width; tapping a task opens detail instead of selecting an embedded split pane.
- List measures its usable content width before routing an ordinary task open. When that surface has room for both panes, the existing table/detail split opens detail on the right; constrained surfaces use the existing task-detail modal instead. Phones remain single-pane even if a browser reports a large synthetic measurement. **Settings → Appearance → Open tasks as popups** remains an explicit override and opens the existing task popup instead of either adaptive route.
- List rows and tablet/mobile cards support the same task context menu as Board cards from right-click, keyboard context menu / Shift+F10, or touch long-press without changing ordinary row selection or tap-to-open behavior. Selecting an action applies that exact action once and dismisses the menu, including **Plan** for tasks still in Planning/ideas/hold columns and **Refine** for completed tasks; the opened refinement feedback modal stays open until the operator closes it intentionally.
<!-- FNXC:TaskDetailRefine 2026-07-12-00:00: List-originated Refine uses the same task-detail feedback modal as Board and must preserve the stay-open invariant across desktop and mobile activation.
FNXC:ListView 2026-07-10-00:00: FN-7809 makes tablet List view use the same single-pane card/detail route as mobile because the desktop split sidebar clipped the primary controls and quick-add composer at 769–1024px.
FNXC:ListView 2026-08-03-05:47: FN-8754 replaces the viewport-only tablet route with measured usable List width. A tablet with room for both existing panes keeps the split detail; constrained List surfaces keep the modal route, while phones remain single-pane and the popup preference takes precedence.
FNXC:ListContextMenu 2026-06-29-00:00: List context menus are alternate action entry points only; desktop left-click still selects the split-pane detail and mobile tap still opens detail while long-press suppresses the follow-up tap.
FNXC:ListContextMenu 2026-06-30-00:20: Keyboard access is part of the Board/List context-menu contract, so docs must include the context-menu key and Shift+F10 alongside pointer and touch entry points.
FNXC:DoneTaskRefine 2026-07-01-00:00: Completed List row/card context menus route Refine to the existing task-detail feedback modal so desktop right-click and mobile long-press share the same refinement flow.

View File

@@ -1009,8 +1009,10 @@ itself reuses the board's `.btn.btn-secondary.btn-sm` primitive so List and Boar
}
/* === List View Single-Pane Responsive ===
FNXC:ListView 2026-07-10-00:00 (FN-7809):
Tablet-width List view shares the mobile single-pane scaffolding so the full-width toolbar and QuickEntryBox are not clipped by the desktop split sidebar. Mobile behavior stays unchanged; desktop split rules remain active above the tablet tier.
FNXC:ListView 2026-08-03-05:47 (FN-8754):
The component now decides split capability from measured List width. Keep these compact rules available
at tablet widths, but do not hide split chrome with a viewport-only media rule: a tablet List surface
can have sufficient room after dashboard chrome is accounted for.
*/
@media (max-width: 1024px) {
.list-toolbar {
@@ -1041,21 +1043,6 @@ Tablet-width List view shares the mobile single-pane scaffolding so the full-wid
overflow-x: auto;
}
.list-split-layout {
display: block;
}
.list-split-sidebar,
.list-split-detail {
overflow: visible;
border: none;
padding: 0;
}
.list-split-resize-handle,
.list-split-detail {
display: none;
}
.list-table {
min-width: auto;
@@ -1077,8 +1064,8 @@ Tablet-width List view shares the mobile single-pane scaffolding so the full-wid
/* === List View Single-Pane Cards (FN-1140, FN-7809) === */
@media (max-width: 1024px) {
/* Hide the table on mobile — card layout is rendered via JS */
.list-table {
/* The JS-measured single-pane route renders cards; a wide tablet keeps its table and split detail. */
.list-view--single-pane .list-table {
display: none;
}

View File

@@ -235,6 +235,14 @@ function readSidebarWidth(projectId?: string): number {
const LIST_SIDEBAR_MIN_WIDTH = 64; // FNXC:ListView 2026-06-22-00:00: The desktop task-list split sidebar minimum is 64 (was 120) so users can shrink the left panel much further; task titles wrap to two lines (.list-split-sidebar .list-cell-title) so they stay legible at narrow widths. Resize, keyboard, and ARIA paths share one clamp value.
const LIST_SIDEBAR_MAX_RATIO = 0.65;
const LIST_SIDEBAR_KEYBOARD_STEP = 16;
const LIST_MINIMUM_USABLE_TASK_LIST_WIDTH = 320;
const LIST_MINIMUM_USABLE_DETAIL_WIDTH = 480;
export const LIST_MINIMUM_SPLIT_LAYOUT_WIDTH = LIST_MINIMUM_USABLE_TASK_LIST_WIDTH + LIST_MINIMUM_USABLE_DETAIL_WIDTH;
/** Returns whether the List surface can keep both its task list and embedded detail usable. */
export function canUseListSplitLayout(containerWidth: number): boolean {
return containerWidth >= LIST_MINIMUM_SPLIT_LAYOUT_WIDTH;
}
function getSidebarMaxWidth(containerWidth: number): number {
return Math.max(LIST_SIDEBAR_MIN_WIDTH, containerWidth * LIST_SIDEBAR_MAX_RATIO);
@@ -425,11 +433,19 @@ export function ListView({
});
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
const [listContainerWidth, setListContainerWidth] = useState<number | null>(null);
/*
FNXC:ListView 2026-07-10-00:00 (FN-7809):
Tablet-width List view must use the same single-pane layout as mobile because the desktop two-pane sidebar leaves too little horizontal room and clips the primary actions plus expanded QuickEntryBox. Keep touch-only long-press behavior on `isMobile`; this gate only controls split-vs-single-pane structure and detail routing.
FNXC:ListView 2026-08-03-05:47:
Available List width—not the global viewport label—owns split-versus-modal routing. A measured
surface must leave 320px for task navigation and 480px for the existing embedded detail; real
phones remain single-pane even when a synthetic measurement is large. When measurement support is
unavailable, retain the established desktop split and constrained tablet modal fallbacks.
*/
const useSinglePaneList = viewportMode === "mobile" || viewportMode === "tablet";
const canRenderSplitLayout = viewportMode !== "mobile"
&& (listContainerWidth !== null
? canUseListSplitLayout(listContainerWidth)
: viewportMode === "desktop");
const useSinglePaneList = !canRenderSplitLayout;
const { confirm, confirmWithChoice } = useConfirm();
useEffect(() => {
@@ -493,6 +509,11 @@ export function ListView({
});
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readSidebarWidth(projectId));
const splitLayoutRef = useRef<HTMLDivElement>(null);
const [splitLayoutContainer, setSplitLayoutContainer] = useState<HTMLDivElement | null>(null);
const setSplitLayoutRef = useCallback((node: HTMLDivElement | null) => {
splitLayoutRef.current = node;
setSplitLayoutContainer(node);
}, []);
const splitSidebarRef = useRef<HTMLDivElement>(null);
// FNXC:ListView 2026-06-22-18:00: Holds the active pointer-drag teardown so move/up/cancel/unmount all detach the same listeners — prevents the "window mousemove with no cleanup" leak called out by the frontend-races review.
const splitResizeTeardownRef = useRef<(() => void) | null>(null);
@@ -550,6 +571,24 @@ export function ListView({
});
}, [selectedTaskId, tasks]);
useLayoutEffect(() => {
if (!splitLayoutContainer) return;
const measureContainer = (observedWidth?: number) => {
const width = observedWidth ?? (splitLayoutContainer.getBoundingClientRect().width || splitLayoutContainer.clientWidth);
setListContainerWidth(width > 0 ? width : null);
};
measureContainer();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
measureContainer(entries[0]?.contentRect.width);
});
observer.observe(splitLayoutContainer);
return () => observer.disconnect();
}, [splitLayoutContainer]);
useEffect(() => {
if (useSinglePaneList || typeof ResizeObserver === "undefined") return;
const container = splitLayoutRef.current;
@@ -2949,7 +2988,7 @@ export function ListView({
)}
<div className="list-table-container">
<div className={useSinglePaneList ? "" : "list-split-layout"} data-testid={useSinglePaneList ? undefined : "list-split-layout"} ref={splitLayoutRef}>
<div className={useSinglePaneList ? "" : "list-split-layout"} data-testid={useSinglePaneList ? undefined : "list-split-layout"} ref={setSplitLayoutRef}>
<div
className={useSinglePaneList ? "" : "list-split-sidebar"}
data-testid={useSinglePaneList ? undefined : "list-split-sidebar"}

View File

@@ -4,7 +4,7 @@ import { describe, it, expect, vi } from "vitest";
import { useEffect, useState } from "react";
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ListView } from "../ListView";
import { ListView, LIST_MINIMUM_SPLIT_LAYOUT_WIDTH } from "../ListView";
import type { Task, TaskDetail } from "@fusion/core";
import { scopedKey } from "../../utils/projectStorage";
import { ALL_WORKFLOWS_BOARD_VIEW_ID, BOARD_WORKFLOW_SELECTION_STORAGE_KEY } from "../../utils/boardWorkflowSelection";
@@ -413,6 +413,33 @@ function mockDesktopViewport() {
}));
}
function installControlledResizeObserver() {
const callbacks = new Set<ResizeObserverCallback>();
class ControlledResizeObserver {
constructor(private readonly observerCallback: ResizeObserverCallback) {
callbacks.add(observerCallback);
}
observe() {}
unobserve() {}
disconnect() {
callbacks.delete(this.observerCallback);
}
}
const OriginalResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = ControlledResizeObserver as unknown as typeof ResizeObserver;
return {
resize(width: number) {
for (const callback of callbacks) {
callback([{ contentRect: { width } } as ResizeObserverEntry], {} as ResizeObserver);
}
},
restore() {
globalThis.ResizeObserver = OriginalResizeObserver;
},
};
}
/*
FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9):
The default workflow's real lane set, used both as the resolved fetch value and as
@@ -2322,6 +2349,132 @@ describe("ListView", () => {
viewportSpy.mockRestore();
});
it("uses measured List width rather than tablet viewport classification for detail routing", async () => {
const viewportSpy = mockTabletViewport();
const resizeObserver = installControlledResizeObserver();
const task = createMockTask({ id: "FN-8754", title: "Measured tablet task" });
const onOpenDetail = vi.fn();
try {
renderListView({ tasks: [task], onOpenDetail });
// The constrained control remains the existing card/modal route.
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH - 1));
const constrainedCard = document.querySelector('.list-card[data-id="FN-8754"]') as HTMLElement;
fireEvent.keyDown(constrainedCard, { key: "Enter" });
expect(onOpenDetail).toHaveBeenCalledWith(task, { origin: "list-mobile" });
expect(screen.queryByTestId("list-split-detail")).toBeNull();
onOpenDetail.mockClear();
// At the named usable boundary, the same tablet surface owns the existing split detail.
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH));
const boundaryRow = document.querySelector('tr[data-id="FN-8754"]') as HTMLElement;
fireEvent.keyDown(boundaryRow, { key: " " });
expect(onOpenDetail).not.toHaveBeenCalled();
expect(screen.getAllByTestId("list-split-detail-content")).toHaveLength(1);
expect(screen.getByTestId("task-detail-content")).toHaveTextContent("FN-8754");
expect(screen.getByTestId("list-split-resize-handle")).toHaveAttribute("role", "separator");
// Above the boundary pointer opens use that same single embedded host.
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH + 1));
fireEvent.click(document.querySelector('tr[data-id="FN-8754"]') as HTMLElement);
expect(onOpenDetail).not.toHaveBeenCalled();
expect(screen.getAllByTestId("list-split-detail-content")).toHaveLength(1);
} finally {
resizeObserver.restore();
viewportSpy.mockRestore();
}
});
it("routes a constrained desktop List surface through the modal without split chrome", async () => {
const viewportSpy = mockDesktopViewport();
const resizeObserver = installControlledResizeObserver();
const task = createMockTask({ id: "FN-8754-desktop", title: "Constrained desktop task" });
const onOpenDetail = vi.fn();
try {
renderListView({ tasks: [task], onOpenDetail });
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH - 1));
fireEvent.click(document.querySelector('.list-card[data-id="FN-8754-desktop"]') as HTMLElement);
expect(onOpenDetail).toHaveBeenCalledWith(task, { origin: "list-mobile" });
expect(screen.queryByTestId("list-split-detail")).toBeNull();
expect(screen.queryByTestId("list-split-resize-handle")).toBeNull();
} finally {
resizeObserver.restore();
viewportSpy.mockRestore();
}
});
it("keeps phones single-pane when a synthetic List measurement is wide", async () => {
const viewportSpy = mockMobileViewport();
const resizeObserver = installControlledResizeObserver();
const task = createMockTask({ id: "FN-8754-mobile", title: "Phone task" });
const onOpenDetail = vi.fn();
try {
renderListView({ tasks: [task], onOpenDetail });
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH + 1));
fireEvent.click(document.querySelector('.list-card[data-id="FN-8754-mobile"]') as HTMLElement);
expect(onOpenDetail).toHaveBeenCalledWith(task, { origin: "list-mobile" });
expect(screen.queryByTestId("list-split-detail")).toBeNull();
} finally {
resizeObserver.restore();
viewportSpy.mockRestore();
}
});
it("keeps the explicit popup preference above measured tablet split routing", async () => {
const viewportSpy = mockTabletViewport();
const resizeObserver = installControlledResizeObserver();
const task = createMockTask({ id: "FN-8754-popup", title: "Popup wins" });
const onOpenDetail = vi.fn();
const onPopOut = vi.fn();
try {
renderListView({ tasks: [task], onOpenDetail, onPopOut, openMobileTasksInPopup: true });
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH + 1));
fireEvent.click(document.querySelector('tr[data-id="FN-8754-popup"]') as HTMLElement);
expect(onPopOut).toHaveBeenCalledWith(task);
expect(onOpenDetail).not.toHaveBeenCalled();
expect(screen.queryByTestId("list-split-detail-content")).toBeNull();
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBeNull();
} finally {
resizeObserver.restore();
viewportSpy.mockRestore();
}
});
it("removes and restores split chrome across List width transitions without opening a modal", async () => {
const viewportSpy = mockTabletViewport();
const resizeObserver = installControlledResizeObserver();
const task = createMockTask({ id: "FN-8754-resize", title: "Resize task" });
const onOpenDetail = vi.fn();
try {
renderListView({ tasks: [task], onOpenDetail });
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH + 1));
fireEvent.click(document.querySelector('tr[data-id="FN-8754-resize"]') as HTMLElement);
expect(screen.getByTestId("task-detail-content")).toHaveTextContent("FN-8754-resize");
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH - 1));
expect(screen.queryByTestId("list-split-detail")).toBeNull();
expect(screen.queryByTestId("list-split-resize-handle")).toBeNull();
expect(onOpenDetail).not.toHaveBeenCalled();
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-8754-resize");
await act(async () => resizeObserver.resize(LIST_MINIMUM_SPLIT_LAYOUT_WIDTH + 1));
expect(screen.getAllByTestId("list-split-detail-content")).toHaveLength(1);
expect(screen.getByTestId("task-detail-content")).toHaveTextContent("FN-8754-resize");
expect(onOpenDetail).not.toHaveBeenCalled();
} finally {
resizeObserver.restore();
viewportSpy.mockRestore();
}
});
it("renders tablet List view as a single full-width pane without split chrome", () => {
const viewportSpy = mockTabletViewport();
const tasks = [createMockTask({ id: "FN-001", title: "Tablet task" })];
@@ -2978,19 +3131,13 @@ describe("ListView", () => {
expect(singlePaneToolbarRule).toContain("justify-content: center");
});
it("loads CSS fixture rules that apply single-pane non-clipping layout at tablet while keeping desktop split rules", () => {
it("keeps measured tablet split chrome visible while scoping card hiding to single-pane List", () => {
const css = loadAllAppCss();
const splitHideRule = css.match(/@media\s*\(max-width:\s*1024px\)[\s\S]*?\.list-split-resize-handle,\s*\n\s*\.list-split-detail\s*\{[^}]*display:\s*none;[^}]*\}/)?.[0] ?? "";
const cardRule = css.match(/@media\s*\(max-width:\s*1024px\)[\s\S]*?\.list-table\s*\{[^}]*display:\s*none;[^}]*\}[\s\S]*?\.list-cards\s*\{[^}]*width:\s*100%;[^}]*\}/)?.[0] ?? "";
const singlePaneCardRule = css.match(/\.list-view--single-pane \.list-table\s*\{[^}]*display:\s*none;[^}]*\}/)?.[0] ?? "";
const desktopSplitRule = css.match(/\.list-split-layout\s*\{[^}]*grid-template-columns:\s*auto 0 minmax\(0, 1fr\);[^}]*\}/)?.[0] ?? "";
expect(splitHideRule).toContain("max-width: 1024px");
expect(splitHideRule).toContain(".list-split-resize-handle");
expect(splitHideRule).toContain("display: none");
expect(cardRule).toContain(".list-table");
expect(cardRule).toContain("display: none");
expect(cardRule).toContain(".list-cards");
expect(cardRule).toContain("width: 100%");
expect(css).not.toMatch(/\.list-split-resize-handle,\s*\n\s*\.list-split-detail\s*\{[^}]*display:\s*none/);
expect(singlePaneCardRule).toContain("display: none");
expect(desktopSplitRule).toContain("grid-template-columns: auto 0 minmax(0, 1fr)");
});