diff --git a/.changeset/fn-6776-board-flash.md b/.changeset/fn-6776-board-flash.md new file mode 100644 index 0000000000..5f96f6325c --- /dev/null +++ b/.changeset/fn-6776-board-flash.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Eliminate the legacy board flash before workflow lanes load by caching per-project board workflow metadata and showing a neutral skeleton while metadata resolves. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index fcff2d93b5..374f0ac53c 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -78,6 +78,7 @@ Features: - Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback) - On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll. - Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger and each workflow option show compact Todo / In Progress / Done counts derived from workflow column flags, excluding archived columns. +- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash. ![Board view](./screenshots/dashboard-overview.png) diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index d7e7e4c431..aaee296ab6 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1849,6 +1849,8 @@ function AppInner() { prAuthAvailable={prAuthAvailable} onOpenWorkflowEditor={openWorkflowEditorWithNav} onCreateWorkflow={openCreateWorkflowWithNav} + workflowColumnsEnabled={experimentalFeatures.workflowColumns === true} + settingsLoaded={settingsLoaded} /> ); @@ -1887,6 +1889,8 @@ function AppInner() { prAuthAvailable={prAuthAvailable} autoMerge={autoMerge} onCreateWorkflow={openCreateWorkflowWithNav} + workflowColumnsEnabled={experimentalFeatures.workflowColumns === true} + settingsLoaded={settingsLoaded} /> ); diff --git a/packages/dashboard/app/components/Board.css b/packages/dashboard/app/components/Board.css new file mode 100644 index 0000000000..aa8a71c027 --- /dev/null +++ b/packages/dashboard/app/components/Board.css @@ -0,0 +1,69 @@ +/* +FNXC:BoardWorkflows 2026-06-20-09:02: +The board needs a neutral first-paint shell whenever workflow lanes are enabled but metadata is not ready, so users never see the legacy single-lane board flash before workflow lanes load. +*/ +.board.board-workflows-skeleton { + display: flex; + align-items: stretch; + gap: var(--column-gap, var(--space-md)); + overflow-x: auto; + overflow-y: hidden; + padding: var(--board-padding, var(--space-md)); + background: var(--bg); +} + +.board-workflows-skeleton__column { + flex: 1 0 min(24rem, 85vw); + min-width: min(24rem, 85vw); + display: flex; + flex-direction: column; + gap: var(--space-md); + padding: var(--space-md); + border: 1px solid var(--border); + background: var(--surface); +} + +.board-workflows-skeleton__header, +.board-workflows-skeleton__card { + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--border) 70%, var(--surface)); + animation: board-workflows-skeleton-pulse 1.4s ease-in-out infinite; +} + +.board-workflows-skeleton__header { + block-size: var(--space-lg); + inline-size: 55%; +} + +.board-workflows-skeleton__card { + block-size: calc(var(--space-2xl) * 2); + inline-size: 100%; +} + +.board-workflows-skeleton__card--short { + inline-size: 75%; +} + +@keyframes board-workflows-skeleton-pulse { + 0%, 100% { + opacity: 0.55; + } + + 50% { + opacity: 1; + } +} + +@media (max-width: 768px) { + .board.board-workflows-skeleton { + gap: var(--space-sm); + padding: var(--space-sm); + scroll-snap-type: x proximity; + } + + .board-workflows-skeleton__column { + flex-basis: 86vw; + min-width: 86vw; + scroll-snap-align: center; + } +} diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 29fec0adb7..d64fbd5335 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -3,6 +3,7 @@ import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core"; import { sortTasksForDisplayColumn } from "./taskSorting"; import { Column } from "./Column"; import "./Lane.css"; +import "./Board.css"; import type { ToastType } from "../hooks/useToast"; import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; @@ -15,6 +16,7 @@ import { subscribeSse } from "../sse-bus"; import { getBoardCanDropTaskRejection } from "./boardCanDropTask"; import { WorkflowSwitcher } from "./WorkflowSwitcher"; import { computeWorkflowStatusCounts } from "./workflowStatusCounts"; +import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache"; interface BoardProps { tasks: Task[]; @@ -74,6 +76,10 @@ interface BoardProps { onOpenWorkflowEditor?: (workflowId?: string) => void; /** Opens the workflow editor to create a new workflow. */ onCreateWorkflow?: () => void; + /** Already-resolved app setting for whether workflow lanes should be used. */ + workflowColumnsEnabled?: boolean; + /** Whether app settings have loaded; false gates the legacy board until the workflow flag is known. */ + settingsLoaded?: boolean; } @@ -124,7 +130,21 @@ function areWorkflowNameLookupsEqual(previous: ReadonlyMap, next return true; } -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 }: BoardProps) { +function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) { + return ( +
+ {[0, 1, 2].map((index) => ( + + ))} +
+ ); +} + +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) { const { t } = useTranslation("app"); const [archivedCollapsed, setArchivedCollapsed] = useState(true); const archivedLoadedRef = useRef(false); @@ -330,9 +350,18 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask }, []); // ── U9 multi-lane board (flag-gated) ────────────────────────────────────── + /* + FNXC:BoardWorkflows 2026-06-20-08:58: + Workflow-columns-enabled users must never see the legacy single-lane board while board-workflows metadata is still loading. Hydrate metadata from the project-scoped session cache, reset it on project switches, and show a neutral skeleton while settings or uncached workflow metadata are unknown. + */ // Fetch board-workflows metadata. When the flag is OFF the server returns // { flagEnabled: false } and we render the legacy single-lane board below. - const [boardWorkflows, setBoardWorkflows] = useState(null); + const shouldHydrateBoardWorkflowsCache = workflowColumnsEnabled === true || settingsLoaded === false; + const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => { + const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null; + return cached ? { projectId, payload: cached } : null; + }); + const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null; const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); const draggingTaskIdRef = useRef(null); @@ -344,16 +373,24 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask // when a definition's lanes / column traits change. The visibility/focus // refetch below is retained as a stopgap for missed events / reconnects. const boardWorkflowsFetchSeqRef = useRef(0); + useEffect(() => { + const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null; + setBoardWorkflowsState(cached ? { projectId, payload: cached } : null); + }, [projectId, shouldHydrateBoardWorkflowsCache]); + useEffect(() => { const runFetch = () => { const seq = ++boardWorkflowsFetchSeqRef.current; fetchBoardWorkflows(projectId) .then((payload) => { - if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload); + if (seq === boardWorkflowsFetchSeqRef.current) { + setBoardWorkflowsState({ projectId, payload }); + writeBoardWorkflowsCache(projectId, payload); + } }) .catch(() => { if (seq === boardWorkflowsFetchSeqRef.current) { - setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} }); + setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } }); } }); }; @@ -509,6 +546,14 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask // `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated` // messages. We do NOT eagerly call `/api/github/batch-status` on board load. + const shouldGateLegacyBoard = boardWorkflows === null + ? (workflowColumnsEnabled === true || settingsLoaded === false) + : boardWorkflows.flagEnabled === true && boardWorkflows.workflows.length === 0; + + if (shouldGateLegacyBoard) { + return ; + } + if (workflowMode && selectedWorkflow) { return (
diff --git a/packages/dashboard/app/components/ListView.css b/packages/dashboard/app/components/ListView.css index 62abc29aea..1029795b02 100644 --- a/packages/dashboard/app/components/ListView.css +++ b/packages/dashboard/app/components/ListView.css @@ -1139,3 +1139,56 @@ FN-6529 requires list-view agent-active tasks to use a simple static highlight i min-height: 36px; } } + +/* +FNXC:BoardWorkflows 2026-06-20-09:12: +ListView needs a neutral workflow-loading shell under the same flag-aware gate as Board, avoiding a first-paint fallback to legacy list columns while workflow metadata revalidates. +*/ +.list-view--workflow-skeleton { + gap: var(--space-lg); +} + +.list-workflow-skeleton { + display: flex; + flex-direction: column; + gap: var(--space-md); + padding: var(--space-lg); + border: 1px solid var(--border); + background: var(--surface); +} + +.list-workflow-skeleton__row { + block-size: var(--space-xl); + inline-size: 100%; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--border) 70%, var(--surface)); + animation: list-workflow-skeleton-pulse 1.4s ease-in-out infinite; +} + +.list-workflow-skeleton__row--header { + inline-size: 60%; +} + +.list-workflow-skeleton__row--short { + inline-size: 78%; +} + +@keyframes list-workflow-skeleton-pulse { + 0%, 100% { + opacity: 0.55; + } + + 50% { + opacity: 1; + } +} + +@media (max-width: 768px) { + .list-view--workflow-skeleton { + gap: var(--space-md); + } + + .list-workflow-skeleton { + padding: var(--space-md); + } +} diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index baa4dc49e6..7c796987f0 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -23,6 +23,7 @@ import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from ". import { subscribeSse } from "../sse-bus"; import { WorkflowSwitcher } from "./WorkflowSwitcher"; import { computeWorkflowStatusCounts } from "./workflowStatusCounts"; +import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../utils/boardWorkflowsCache"; const COLUMN_COLOR_MAP: Record = { triage: "var(--triage)", @@ -241,6 +242,8 @@ interface ListViewProps { prAuthAvailable?: boolean; autoMerge?: boolean; onCreateWorkflow?: () => void; + workflowColumnsEnabled?: boolean; + settingsLoaded?: boolean; } const LEGACY_LIST_COLUMNS: BoardWorkflowColumn[] = COLUMNS.map((column) => ({ @@ -305,6 +308,8 @@ export function ListView({ prAuthAvailable, autoMerge, onCreateWorkflow, + workflowColumnsEnabled, + settingsLoaded, }: ListViewProps) { const { t } = useTranslation("app"); const columnLabel = useColumnLabel(); @@ -313,7 +318,16 @@ export function ListView({ const [draggingTaskId, setDraggingTaskId] = useState(null); const [dragOverColumn, setDragOverColumn] = useState(null); const [selectedColumn, setSelectedColumn] = useState(null); - const [boardWorkflows, setBoardWorkflows] = useState(null); + /* + FNXC:BoardWorkflows 2026-06-20-09:07: + ListView shares the board-workflows first-paint invariant with Board: hydrate per-project workflow metadata from sessionStorage and gate legacy list columns while workflowColumns settings or uncached lane metadata are still unknown. + */ + const shouldHydrateBoardWorkflowsCache = workflowColumnsEnabled === true || settingsLoaded === false; + const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => { + const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null; + return cached ? { projectId, payload: cached } : null; + }); + const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null; const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); const viewportMode = useViewportMode(); const isMobile = viewportMode === "mobile"; @@ -393,16 +407,24 @@ export function ListView({ setSidebarWidth(readSidebarWidth(projectId)); }, [projectId, tasks]); + useEffect(() => { + const cached = shouldHydrateBoardWorkflowsCache ? readBoardWorkflowsCache(projectId) : null; + setBoardWorkflowsState(cached ? { projectId, payload: cached } : null); + }, [projectId, shouldHydrateBoardWorkflowsCache]); + useEffect(() => { const runFetch = () => { const seq = ++boardWorkflowsFetchSeqRef.current; fetchBoardWorkflows(projectId) .then((payload) => { - if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload); + if (seq === boardWorkflowsFetchSeqRef.current) { + setBoardWorkflowsState({ projectId, payload }); + writeBoardWorkflowsCache(projectId, payload); + } }) .catch(() => { if (seq === boardWorkflowsFetchSeqRef.current) { - setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} }); + setBoardWorkflowsState({ projectId, payload: { flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} } }); } }); }; @@ -1689,6 +1711,22 @@ export function ListView({
); + const renderListWorkflowSkeleton = (empty = false) => ( +
+
+
+

{t("listView.title", "List View")}

+

{empty ? t("listView.noWorkflowLanes", "No workflow lanes available") : t("listView.loadingWorkflowLanes", "Loading workflow lanes")}

+
+
+