From 34b555f5d2764da17c5d92e11fbbd16105e806de Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 20 Jun 2026 23:02:52 -0700 Subject: [PATCH] FN-6824: move workflow controls into header Move workflow controls into the header when sidebar navigation is active. - Add a Header portal slot that replaces the hidden view toggle under left sidebar navigation. - Portal Board workflow selector, edit, and create controls into the Header slot with inline fallback. - Portal List workflow selector and create controls into the Header slot without adding edit controls. - Cover relocated and inline workflow control behavior for Board, Header, and List views. Files changed: docs/dashboard-guide.md | 2 + packages/dashboard/app/App.tsx | 2 + packages/dashboard/app/components/Board.tsx | 99 ++++++++++++------- packages/dashboard/app/components/Header.css | 34 +++++++ packages/dashboard/app/components/Header.tsx | 11 +++ packages/dashboard/app/components/ListView.tsx | 25 ++++- .../app/components/__tests__/Board.test.tsx | 77 +++++++++++++++ .../app/components/__tests__/Header.test.tsx | 21 ++++ .../app/components/__tests__/ListView.test.tsx | 109 +++++++++++++++++++++ 9 files changed, 342 insertions(+), 38 deletions(-) Fusion-Task-Id: FN-6824 Fusion-Task-Lineage: 01611a6c-2894-4f19-a5f8-262609528f49 --- docs/dashboard-guide.md | 2 + packages/dashboard/app/App.tsx | 2 + packages/dashboard/app/components/Board.tsx | 99 ++++++++++------ packages/dashboard/app/components/Header.css | 34 ++++++ packages/dashboard/app/components/Header.tsx | 11 ++ .../dashboard/app/components/ListView.tsx | 25 +++- .../app/components/__tests__/Board.test.tsx | 77 +++++++++++++ .../app/components/__tests__/Header.test.tsx | 21 ++++ .../components/__tests__/ListView.test.tsx | 109 ++++++++++++++++++ 9 files changed, 342 insertions(+), 38 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 057dd6dfe2..82948b9c8c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -25,6 +25,8 @@ Enable **Left Sidebar Navigation** from **Settings → Experimental Features** t When enabled on desktop or tablet project screens, the sidebar contains the primary destinations (Board, List, Agents, Command Center, Missions, Chat, Documents, Mailbox, and plugin primary views), Header overflow destinations as regular entries (Research, Insights, Skills, Memory, Secrets, Stash Recovery, Evals, Goals, Dev Server, Todos, and plugin overflow views when their flags/plugins are enabled), and a Settings button pinned to the bottom. The Header retains the Fusion brand and project selector, keeps its non-navigation controls, and hides the view-toggle row and **More views** trigger so there is only one canonical navigation surface. +While the sidebar is active on desktop/tablet project screens, Board and List workflow controls move into the Header slot that replaces the hidden view toggle. Board keeps its workflow selector plus edit/create workflow buttons; List keeps its workflow selector plus create workflow button. The standalone workflow row above the board/list content is removed in this mode. When the flag is off, outside project screens, or on mobile, workflow controls remain inline exactly as before. + A small right-border toggle collapses or expands the sidebar without consuming a navigation row; collapsed rail mode keeps accessible labels/titles preserved, and the expanded width can be resized from the right-edge separator. Collapsed state and expanded width are saved in browser `localStorage` (`fusion:left-sidebar-collapsed` and `fusion:left-sidebar-width`) and restored on reload. On mobile viewports (`<=768px`), the sidebar is not rendered even when the experiment is enabled. The existing bottom `MobileNavBar` remains the navigation surface. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index b6c4f87b6d..4baba3bf7f 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1852,6 +1852,7 @@ function AppInner() { onCreateWorkflow={openCreateWorkflowWithNav} workflowColumnsEnabled={experimentalFeatures.workflowColumns === true} settingsLoaded={settingsLoaded} + workflowControlsInHeader={sidebarActive} /> ); @@ -1892,6 +1893,7 @@ function AppInner() { onCreateWorkflow={openCreateWorkflowWithNav} workflowColumnsEnabled={experimentalFeatures.workflowColumns === true} settingsLoaded={settingsLoaded} + workflowControlsInHeader={sidebarActive} /> ); diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index c6a87cbc23..364dd6c571 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -6,6 +6,7 @@ import "./Lane.css"; import "./Board.css"; import type { ToastType } from "../hooks/useToast"; import { useState, useMemo, useEffect, useCallback, useRef } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Pencil, Plus } from "lucide-react"; import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowDefinition, type BoardWorkflowsPayload } from "../api"; @@ -80,6 +81,8 @@ interface BoardProps { workflowColumnsEnabled?: boolean; /** Whether app settings have loaded; false gates the legacy board until the workflow flag is known. */ settingsLoaded?: boolean; + /** Relocates workflow controls into the Header portal slot when sidebar navigation owns the inline chrome. */ + workflowControlsInHeader?: boolean; } @@ -144,12 +147,16 @@ function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) { ); } -export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded }: BoardProps) { +export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) { const { t } = useTranslation("app"); const [archivedCollapsed, setArchivedCollapsed] = useState(true); const archivedLoadedRef = useRef(false); const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP); const boardRef = useRef(null); + const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState(() => { + if (typeof document === "undefined") return null; + return document.getElementById("header-workflow-slot"); + }); const blockerFanoutMap = useBlockerFanout(tasks, { staleHighFanoutAgeThresholdMs: staleHighFanoutBlockerAgeThresholdMs, }); @@ -164,6 +171,14 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask archived: [], }); + useEffect(() => { + if (!workflowControlsInHeader || typeof document === "undefined") { + setHeaderWorkflowSlot(null); + return; + } + setHeaderWorkflowSlot(document.getElementById("header-workflow-slot")); + }, [workflowControlsInHeader]); + useEffect(() => { recordResumeEvent({ view: "Board", @@ -555,44 +570,54 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask } if (workflowMode && selectedWorkflow) { - return ( -
- {(workflowOptions.length > 1 || onCreateWorkflow || onOpenWorkflowEditor) && ( -
- {workflowOptions.length > 1 && ( -
- -
- )} - {onOpenWorkflowEditor && ( - - )} - {onCreateWorkflow && ( - - )} + const shouldRenderWorkflowControls = workflowOptions.length > 1 || onCreateWorkflow || onOpenWorkflowEditor; + const workflowToolbar = shouldRenderWorkflowControls ? ( +
+ {workflowOptions.length > 1 && ( +
+
)} + {onOpenWorkflowEditor && ( + + )} + {onCreateWorkflow && ( + + )} +
+ ) : null; + /* + FNXC:WorkflowControls 2026-06-20-00:00: + Board owns workflow selection state, so the existing selector/edit/create toolbar is portaled to Header only when the left sidebar is the active tablet/desktop navigation surface. If the Header slot is not mounted yet, render inline as the safe fallback so controls are never lost. + */ + const relocatedWorkflowToolbar = workflowControlsInHeader && headerWorkflowSlot && workflowToolbar + ? createPortal(workflowToolbar, headerWorkflowSlot) + : null; + + return ( +
+ {workflowControlsInHeader && headerWorkflowSlot ? relocatedWorkflowToolbar : workflowToolbar}
)} + {hideHeaderViewNav && ( +
+ )} + {/* View Toggle - always inline, even on mobile */} {!hideFullNav && !hideHeaderViewNav && onChangeView && (
diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 7ce242db90..39817abab6 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -1,5 +1,6 @@ import "./ListView.css"; import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive, Plus } from "lucide-react"; @@ -244,6 +245,8 @@ interface ListViewProps { onCreateWorkflow?: () => void; workflowColumnsEnabled?: boolean; settingsLoaded?: boolean; + /** Relocates workflow controls into the Header portal slot when sidebar navigation owns the inline chrome. */ + workflowControlsInHeader?: boolean; } const LEGACY_LIST_COLUMNS: BoardWorkflowColumn[] = COLUMNS.map((column) => ({ @@ -310,6 +313,7 @@ export function ListView({ onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, + workflowControlsInHeader = false, }: ListViewProps) { const { t } = useTranslation("app"); const columnLabel = useColumnLabel(); @@ -329,10 +333,22 @@ export function ListView({ }); const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null; const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); + const [headerWorkflowSlot, setHeaderWorkflowSlot] = useState(() => { + if (typeof document === "undefined") return null; + return document.getElementById("header-workflow-slot"); + }); const viewportMode = useViewportMode(); const isMobile = viewportMode === "mobile"; const { confirm, confirmWithChoice } = useConfirm(); + useEffect(() => { + if (!workflowControlsInHeader || typeof document === "undefined") { + setHeaderWorkflowSlot(null); + return; + } + setHeaderWorkflowSlot(document.getElementById("header-workflow-slot")); + }, [workflowControlsInHeader]); + // Column visibility state - initialize from localStorage or reduced default columns const [visibleColumns, setVisibleColumns] = useState>(() => readVisibleColumns(projectId)); @@ -1605,7 +1621,7 @@ export function ListView({ if (!workflowMode) return null; const showSelect = workflowOptions.length > 1 && selectedWorkflow; if (!showSelect && !onCreateWorkflow) return null; - return ( + const workflowControl = (
{showSelect && selectedWorkflow && ( ); + /* + FNXC:WorkflowControls 2026-06-20-00:00: + ListView keeps its own workflow selection state and only portals its existing selector/create controls into Header when the sidebar header slot exists. It intentionally does not add the Board-only edit affordance. + */ + return workflowControlsInHeader && headerWorkflowSlot + ? createPortal(workflowControl, headerWorkflowSlot) + : workflowControl; }; const renderViewOptionsPanel = (panelId: string) => ( diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index 41e8c3abb3..9464c33f6f 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -1096,6 +1096,83 @@ describe("Board", () => { expect(screen.queryByTestId("board-workflow-collapse-toggle")).toBeNull(); }); + it("relocates workflow selector, edit, and create controls into the header slot", async () => { + const onCreateWorkflow = vi.fn(); + const onOpenWorkflowEditor = vi.fn(); + const headerSlot = document.createElement("div"); + headerSlot.id = "header-workflow-slot"; + headerSlot.className = "header-workflow-slot"; + document.body.appendChild(headerSlot); + enableFlag( + { "FN-1": "builtin:coding", "FN-2": "wf-custom" }, + [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW], + ); + try { + renderBoard({ + tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })], + onCreateWorkflow, + onOpenWorkflowEditor, + workflowControlsInHeader: true, + }); + + const selector = await screen.findByTestId("workflow-switcher"); + await waitFor(() => expect(headerSlot.querySelector(".board-workflow-toolbar")).not.toBeNull()); + expect(headerSlot.contains(selector)).toBe(true); + expect(headerSlot.querySelector(".board-workflow-edit-btn")).not.toBeNull(); + expect(headerSlot.querySelector(".board-workflow-create-btn")).not.toBeNull(); + expect(document.querySelector(".board-workflow-view > .board-workflow-toolbar")).toBeNull(); + + fireEvent.click(selector); + fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-custom")); + await waitFor(() => expect(screen.getByTestId("column-intake")).toBeDefined()); + expect(screen.queryByTestId("column-todo")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Edit workflows" })); + expect(onOpenWorkflowEditor).toHaveBeenCalledWith("wf-custom"); + } finally { + headerSlot.remove(); + } + }); + + it("keeps the board workflow toolbar inline when header relocation is inactive", async () => { + const headerSlot = document.createElement("div"); + headerSlot.id = "header-workflow-slot"; + document.body.appendChild(headerSlot); + enableFlag({ "FN-1": "builtin:coding", "FN-2": "wf-custom" }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]); + try { + renderBoard({ + tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })], + onCreateWorkflow: vi.fn(), + onOpenWorkflowEditor: vi.fn(), + }); + + await screen.findByTestId("workflow-switcher"); + await waitFor(() => expect(document.querySelector(".board-workflow-view > .board-workflow-toolbar")).not.toBeNull()); + expect(headerSlot.querySelector(".board-workflow-toolbar")).toBeNull(); + } finally { + headerSlot.remove(); + } + }); + + it("does not leave a board workflow shell when header relocation has no controls", async () => { + const headerSlot = document.createElement("div"); + headerSlot.id = "header-workflow-slot"; + document.body.appendChild(headerSlot); + enableFlag({ "FN-1": "builtin:coding" }, [DEFAULT_WORKFLOW]); + try { + renderBoard({ + tasks: [mkTask({ id: "FN-1" })], + workflowControlsInHeader: true, + }); + + await waitFor(() => expect(screen.getByTestId("column-todo")).toBeDefined()); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); + expect(document.querySelector(".board-workflow-toolbar")).toBeNull(); + expect(headerSlot.childElementCount).toBe(0); + } finally { + headerSlot.remove(); + } + }); + it("renders one selected workflow at a time and switches workflows from the dropdown", async () => { const onCreateWorkflow = vi.fn(); const onOpenWorkflowEditor = vi.fn(); diff --git a/packages/dashboard/app/components/__tests__/Header.test.tsx b/packages/dashboard/app/components/__tests__/Header.test.tsx index 60af6f511b..98ce9f7433 100644 --- a/packages/dashboard/app/components/__tests__/Header.test.tsx +++ b/packages/dashboard/app/components/__tests__/Header.test.tsx @@ -150,6 +150,27 @@ describe("Header", () => { expect(screen.getByTitle("List view")).toBeDefined(); }); + it("renders the workflow portal slot instead of the view toggle on desktop sidebar nav", () => { + renderHeader({ onChangeView: noop, leftSidebarNavActive: true }, "desktop"); + expect(screen.getByTestId("header-workflow-slot")).toBeInTheDocument(); + expect(screen.queryByTitle("Board view")).toBeNull(); + expect(screen.queryByTitle("List view")).toBeNull(); + }); + + it("renders the workflow portal slot instead of the view toggle on tablet sidebar nav", () => { + renderHeader({ onChangeView: noop, leftSidebarNavActive: true }, "tablet"); + expect(screen.getByTestId("header-workflow-slot")).toBeInTheDocument(); + expect(screen.queryByTitle("Board view")).toBeNull(); + expect(screen.queryByTitle("List view")).toBeNull(); + }); + + it("does not render the workflow portal slot on mobile sidebar nav", () => { + renderHeader({ onChangeView: noop, leftSidebarNavActive: true }, "mobile"); + expect(screen.queryByTestId("header-workflow-slot")).toBeNull(); + expect(screen.queryByTitle("Board view")).not.toBeNull(); + expect(screen.queryByTitle("List view")).not.toBeNull(); + }); + it("shows board view as active by default", () => { renderHeader({ onChangeView: noop }); const boardBtn = screen.getByTitle("Board view"); diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 1772ba4335..d4edae552d 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -810,6 +810,115 @@ describe("ListView", () => { expect(onCreateWorkflow).toHaveBeenCalledTimes(1); }); + it("relocates the list workflow selector and create action into the header slot without adding edit", async () => { + const onCreateWorkflow = vi.fn(); + const headerSlot = document.createElement("div"); + headerSlot.id = "header-workflow-slot"; + headerSlot.className = "header-workflow-slot"; + document.body.appendChild(headerSlot); + vi.mocked(fetchBoardWorkflows).mockResolvedValue({ + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + workflows: [ + { + id: "builtin:coding", + name: "Coding", + columns: [ + { id: "triage", name: "Triage", flags: { intake: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + ], + }, + { + id: "wf-custom", + name: "Custom", + columns: [ + { id: "backlog", name: "Backlog", flags: { intake: true } }, + { id: "complete", name: "Complete", flags: { complete: true } }, + ], + }, + ], + taskWorkflowIds: { "FN-001": "builtin:coding", "FN-002": "wf-custom" }, + }); + try { + renderListView({ + tasks: [ + createMockTask({ id: "FN-001", column: "triage", title: "Coding task" }), + createMockTask({ id: "FN-002", column: "backlog", title: "Custom task" }), + ], + onCreateWorkflow, + workflowControlsInHeader: true, + }); + + const selector = await screen.findByTestId("workflow-switcher"); + await waitFor(() => expect(headerSlot.querySelector(".list-workflow-control")).not.toBeNull()); + expect(headerSlot.contains(selector)).toBe(true); + expect(headerSlot.querySelector(".list-workflow-create-btn")).not.toBeNull(); + expect(headerSlot.querySelector(".board-workflow-edit-btn")).toBeNull(); + expect(document.querySelector(".list-view > .list-workflow-control")).toBeNull(); + + fireEvent.click(selector); + fireEvent.click(screen.getByTestId("workflow-switcher-option-wf-custom")); + await waitFor(() => expect(screen.getByText("Custom task")).toBeInTheDocument()); + expect(screen.queryByText("Coding task")).not.toBeInTheDocument(); + } finally { + headerSlot.remove(); + } + }); + + it("keeps list workflow controls inline when header relocation is inactive", async () => { + const headerSlot = document.createElement("div"); + headerSlot.id = "header-workflow-slot"; + document.body.appendChild(headerSlot); + vi.mocked(fetchBoardWorkflows).mockResolvedValue({ + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + workflows: [ + { id: "builtin:coding", name: "Coding", columns: [{ id: "triage", name: "Triage", flags: { intake: true } }] }, + { id: "wf-custom", name: "Custom", columns: [{ id: "backlog", name: "Backlog", flags: { intake: true } }] }, + ], + taskWorkflowIds: { "FN-001": "builtin:coding" }, + }); + try { + renderListView({ + tasks: [createMockTask({ id: "FN-001", column: "triage", title: "Coding task" })], + onCreateWorkflow: vi.fn(), + }); + + await screen.findByTestId("workflow-switcher"); + await waitFor(() => expect(document.querySelector(".list-view .list-workflow-control")).not.toBeNull()); + expect(headerSlot.querySelector(".list-workflow-control")).toBeNull(); + } finally { + headerSlot.remove(); + } + }); + + it("does not leave a list workflow shell when header relocation has no controls", async () => { + const headerSlot = document.createElement("div"); + headerSlot.id = "header-workflow-slot"; + document.body.appendChild(headerSlot); + vi.mocked(fetchBoardWorkflows).mockResolvedValue({ + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + workflows: [ + { id: "builtin:coding", name: "Coding", columns: [{ id: "triage", name: "Triage", flags: { intake: true } }] }, + ], + taskWorkflowIds: { "FN-001": "builtin:coding" }, + }); + try { + renderListView({ + tasks: [createMockTask({ id: "FN-001", column: "triage", title: "Coding task" })], + workflowControlsInHeader: true, + }); + + await waitFor(() => expect(screen.getByText("Coding task")).toBeInTheDocument()); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); + expect(document.querySelector(".list-workflow-control")).toBeNull(); + expect(headerSlot.childElementCount).toBe(0); + } finally { + headerSlot.remove(); + } + }); + it("keeps embedded selection visible when filters hide the selected row", async () => { const viewportSpy = mockDesktopViewport(); const tasks = [