From b45df4c83b2cec64cc1e99b71e5c62e290d5b07e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 22 Jun 2026 02:45:01 -0700 Subject: [PATCH] feat(dashboard): board task detail opens as a full main-panel view Clicking a board card opens its detail in a full main-content view (replacing the board) with a 'Back to board' button, instead of the TaskDetailModal overlay. Only the Board entry point changes; list-view embed, right-dock cards, and other openDetail callers keep the modal. New 'task-detail' task view; embedded TaskDetailContent prefers the live task. Works within the mobile shell (swipe-back reverts to board). Tests updated (70 pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dashboard/app/App.tsx | 122 +++++++++++++++++- .../__tests__/navigation-history.test.tsx | 25 +++- packages/dashboard/app/hooks/useViewState.ts | 7 +- packages/dashboard/app/styles.css | 42 ++++++ 4 files changed, 187 insertions(+), 9 deletions(-) diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 4c13b6b8aa..bbf2b1a102 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -13,6 +13,8 @@ import { Header, useViewportMode } from "./components/Header"; import { Board } from "./components/Board"; import { TaskCard } from "./components/TaskCard"; import { ListView } from "./components/ListView"; +import { TaskDetailContent } from "./components/TaskDetailModal"; +import { ArrowLeft } from "lucide-react"; import { ProjectOverview } from "./components/ProjectOverview"; import { MissionManager } from "./components/MissionManager"; import { MailboxView } from "./components/MailboxView"; @@ -546,6 +548,12 @@ function AppInner() { } ); + /* + FNXC:Navigation 2026-06-22-00:00: + Snapshot of the task whose detail is shown in the main panel (Board card click → full-panel detail). Kept as a snapshot so the view survives a tasks revalidation; renderMainContent prefers the live row from `tasks` by id and falls back to this snapshot. + */ + const [mainPanelDetailTask, setMainPanelDetailTask] = useState(null); + const previousTaskViewRef = useRef(taskView); useEffect(() => { @@ -1257,6 +1265,21 @@ function AppInner() { pushNav({ type: "modal", close: modalManager.closeDetailTask }); }, [modalManager, pushNav]); + /* + FNXC:Navigation 2026-06-22-00:00: + Board card clicks open task detail as a full main-content view that replaces the board (design: "Full main panel (replaces board)"), instead of the TaskDetailModal overlay. We store a snapshot of the clicked task and navigate to the registered `task-detail` view; renderMainContent renders TaskDetailContent embedded with a Back-to-board button. Only the Board uses this handler — list-view split-detail, right-dock cards, and other openDetail callers keep the modal behavior. + */ + const openTaskDetailInMainPanel = useCallback((task: Task | TaskDetail) => { + setMainPanelDetailTask(task); + handleTaskViewChange("task-detail"); + }, [handleTaskViewChange]); + + // FNXC:Navigation 2026-06-22-00:00: Leaving task-detail clears the snapshot so a stale task never lingers if the view is reopened empty. + const closeTaskDetailMainPanel = useCallback(() => { + setMainPanelDetailTask(null); + handleTaskViewChange("board"); + }, [handleTaskViewChange]); + /* FNXC:Settings 2026-06-22-00:00: Settings is now a main-content destination. The header/sidebar entry points navigate to the embedded `settings` view (carrying the requested deep-link section via setSettingsSection) instead of opening the modal overlay. handleTaskViewChange owns the back-navigation history entry, so no modal nav entry is pushed here. @@ -1962,6 +1985,103 @@ function AppInner() { ); } + /* + FNXC:Navigation 2026-06-22-00:00: + Board-opened task detail renders as a full main-content view that replaces the board. A Back-to-board button sits above an embedded TaskDetailContent (same props ListView passes to its split-detail pane). The live task is preferred from `tasks` by id so the detail updates on revalidation; the stored snapshot is the fallback. If neither resolves (snapshot cleared), fall back to the board so the panel is never blank. + */ + if (taskView === "task-detail") { + const liveDetailTask = mainPanelDetailTask + ? (tasks.find((candidate) => candidate.id === mainPanelDetailTask.id) ?? mainPanelDetailTask) + : null; + if (!liveDetailTask) { + return ( + + + + ); + } + return ( + +
+
+ +
+
+ setMainPanelDetailTask(value)} + onMoveTask={moveTask} + onDeleteTask={deleteTask} + onMergeTask={mergeTask} + onRetryTask={retryTask} + onResetTask={resetTask} + onDuplicateTask={duplicateTask} + onTaskUpdated={(updatedTask) => { + setMainPanelDetailTask((previous) => { + if (!previous || previous.id !== updatedTask.id) return previous; + return { ...previous, ...updatedTask }; + }); + }} + addToast={addToast} + prAuthAvailable={prAuthAvailable} + autoMergeEnabled={autoMerge} + /> +
+
+
+ ); + } + if (taskView === "board") { return ( @@ -1974,7 +2094,7 @@ function AppInner() { maxConcurrent={maxConcurrent} onMoveTask={moveTask} onPauseTask={pauseTask} - onOpenDetail={openDetailTask} + onOpenDetail={openTaskDetailInMainPanel} onOpenGroupModal={openGroupModalWithNav} addToast={addToast} onQuickCreate={handleBoardQuickCreate} diff --git a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx index 6f09884183..93468d469c 100644 --- a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx +++ b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx @@ -166,6 +166,12 @@ vi.mock("../../components/TaskDetailModal", () => ({ ), + // FNXC:Navigation 2026-06-22-00:00: Board card clicks now open task detail in the full main panel via TaskDetailContent (not the modal). The mock exposes a stable testid so the embedded-panel popstate tests can assert on the new surface. + TaskDetailContent: ({ task }: { task: { id: string; title?: string } }) => ( +
+

{task.title ?? task.id}

+
+ ), })); vi.mock("../../components/SettingsModal", () => ({ @@ -572,16 +578,18 @@ describe("Navigation history integration", () => { await renderMobileAppAndWait(); + // FNXC:Navigation 2026-06-22-00:00: Board card click opens the full main-panel task detail (TaskDetailContent), and mobile popstate (swipe back) reverts the pushed `task-detail` view entry back to the board. fireEvent.click(screen.getByTestId("open-task-FN-1")); await waitFor(() => { - expect(screen.getByTestId("task-detail-modal")).toBeTruthy(); + expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy(); }); dispatchPopState({ navIndex: 0 }); await waitFor(() => { - expect(screen.queryByTestId("task-detail-modal")).toBeNull(); + expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull(); + expect(screen.getByTestId("board-view")).toBeTruthy(); }); }); @@ -605,31 +613,34 @@ describe("Navigation history integration", () => { await renderMobileAppAndWait(); + // FNXC:Navigation 2026-06-22-00:00: Board card click opens the full main-panel detail; the "Back to board" button reverts to the board, and a subsequent reopen + mobile popstate must also dismiss it (the regression this test guards). fireEvent.click(screen.getByTestId("open-task-FN-1")); await waitFor(() => { - expect(screen.getByTestId("task-detail-modal")).toBeTruthy(); + expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy(); }); - fireEvent.click(screen.getByRole("button", { name: "Close" })); + fireEvent.click(screen.getByRole("button", { name: "Back to board" })); // removeNav drives history.back(); consume the self-triggered popstate // before reopening so the next popstate represents the user's swipe-back. dispatchPopState({ navIndex: 0 }); await waitFor(() => { - expect(screen.queryByTestId("task-detail-modal")).toBeNull(); + expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull(); + expect(screen.getByTestId("board-view")).toBeTruthy(); }); fireEvent.click(screen.getByTestId("open-task-FN-1")); await waitFor(() => { - expect(screen.getByTestId("task-detail-modal")).toBeTruthy(); + expect(screen.getByTestId("task-detail-main-panel-content")).toBeTruthy(); }); dispatchPopState({ navIndex: 0 }); await waitFor(() => { - expect(screen.queryByTestId("task-detail-modal")).toBeNull(); + expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull(); + expect(screen.getByTestId("board-view")).toBeTruthy(); }); }); diff --git a/packages/dashboard/app/hooks/useViewState.ts b/packages/dashboard/app/hooks/useViewState.ts index 82c351a43b..449d826171 100644 --- a/packages/dashboard/app/hooks/useViewState.ts +++ b/packages/dashboard/app/hooks/useViewState.ts @@ -9,7 +9,7 @@ export type ViewMode = "overview" | "project"; FNXC:ViewState 2026-06-22-00:00: Workflows, Import Tasks, and Automations are promoted to top-level main-content task views (left-sidebar destinations) instead of modal-only overlays, so they render in the main panel like Command Center. */ -export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "todos" | "planning" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "pull-requests" | "workflows" | "import-tasks" | "automations" | "settings"; +export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "todos" | "planning" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "pull-requests" | "workflows" | "import-tasks" | "automations" | "settings" | "task-detail"; export type PluginTaskView = `plugin:${string}:${string}`; export type TaskView = BuiltInTaskView | PluginTaskView; @@ -52,6 +52,11 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [ Settings is promoted from a modal-only overlay into a top-level main-content task view so the header/sidebar Settings entry points dock it in the main panel like Command Center, while preserving deep-link section navigation. */ "settings", + /* + FNXC:Navigation 2026-06-22-00:00: + Clicking a task card on the Board opens its detail as a full main-content view ("Full main panel (replaces board)") with a Back-to-board button, instead of the TaskDetailModal overlay. The detail is hosted under this registered `task-detail` task view so navigation/persistence treat it like any other docked main-panel destination. + */ + "task-detail", ]; function isBuiltInTaskView(value: string | null): value is BuiltInTaskView { diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 7462c9c203..f4fa7c7c70 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -3836,3 +3836,45 @@ Toast text must contrast its status background across every dashboard theme and font-size: 11px; color: var(--text-muted); } + +/* +FNXC:Navigation 2026-06-22-00:00: +Board card clicks open task detail as a full main-content view that replaces the board ("Full main panel" design). This layout fills the main content area: a fixed Back-to-board row over a scrollable embedded TaskDetailContent body. Theme tokens only; mobile shell renders it unchanged because the panel just fills its host. +*/ +.task-detail-main-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.task-detail-main-panel-back-row { + flex: 0 0 auto; + padding: var(--space-lg); + border-bottom: 1px solid var(--border); + background: var(--bg); +} + +.task-detail-main-panel-back-btn { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + font-size: 13px; + color: var(--text-muted); + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; +} + +.task-detail-main-panel-back-btn:hover { + color: var(--text); + background: var(--card-hover); +} + +.task-detail-main-panel-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; +}