diff --git a/.changeset/fix-benign-todo-pause-abort-failure-notification.md b/.changeset/fix-benign-todo-pause-abort-failure-notification.md new file mode 100644 index 0000000000..bf6c5e122a --- /dev/null +++ b/.changeset/fix-benign-todo-pause-abort-failure-notification.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Clear the stale `failed` status when a pause/resume abort is reclassified as a benign todo re-queue, so the task no longer surfaces as failed on the board and the deferred failure notification is suppressed. Previously a pause-abort parked `status:"failed"` on an earlier non-todo observation stayed dispatchable (the scheduler filters on column+paused, not status), re-entered the benign-todo branch, and was logged benign while the row stayed failed — firing a contradictory failure alert during global pause when self-healing recovery was suppressed. The clear path also emits an `Auto-recovered:`-prefixed log so the notification service proactively cancels the pending failure timer instead of relying only on the fire-time re-check. diff --git a/.changeset/fix-task-chat-ephemeral-agent-working.md b/.changeset/fix-task-chat-ephemeral-agent-working.md new file mode 100644 index 0000000000..6842db64aa --- /dev/null +++ b/.changeset/fix-task-chat-ephemeral-agent-working.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix the task detail chat always showing "No agent is working on this task" for in-progress tasks. The active-session check required a persistent `assignedAgentId`/`checkedOutBy`, but in the default ephemeral-agents mode the scheduler never sets those fields, so an actively-executing task always read as idle. An assignment is now sufficient-but-not-necessary: a non-blocked, non-`queued` in-progress task counts as a live agent session on its own (`queued` stays assignment-gated, in-review is unchanged). 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/.changeset/fn-6796-pause-resume-in-review-recovery.md b/.changeset/fn-6796-pause-resume-in-review-recovery.md new file mode 100644 index 0000000000..82028c2c96 --- /dev/null +++ b/.changeset/fn-6796-pause-resume-in-review-recovery.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Recover benign in-review pause/resume abort parks without requiring operator intervention while preserving hard-cancel, pause, and terminal merge safeguards. diff --git a/.changeset/fn-6800-mobile-nav-spacing.md b/.changeset/fn-6800-mobile-nav-spacing.md new file mode 100644 index 0000000000..c130f3c445 --- /dev/null +++ b/.changeset/fn-6800-mobile-nav-spacing.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix mobile bottom navigation icon alignment so unread indicators use a centered token-sized icon slot without visually skewing tab spacing. diff --git a/.changeset/planning-subtask-workflow-selection.md b/.changeset/planning-subtask-workflow-selection.md new file mode 100644 index 0000000000..7e074a038e --- /dev/null +++ b/.changeset/planning-subtask-workflow-selection.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Carry the selected workflow lane through Planning Mode and Subtask Breakdown task creation so saved tasks appear on the active workflow instead of falling back to the main board. diff --git a/.changeset/shadcn-color-variants.md b/.changeset/shadcn-color-variants.md new file mode 100644 index 0000000000..826b9a55a5 --- /dev/null +++ b/.changeset/shadcn-color-variants.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add Shadcn color-variant dashboard themes for blue, green, red, purple, pink, orange, yellow, mono, and black variants. diff --git a/AGENTS.md b/AGENTS.md index a57f0f4c3b..f68a9512d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,6 +193,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - FN-6292: self-healing emits `task:reconcile-dependency-blocking-lease` when it rebounds an in-progress holder whose stale file-scope lease blocks an unmet dependency, and `task:reconcile-dependency-blocking-lease-no-action` when triple-proof blocks that backward move. - FN-6736: self-healing emits `task:reclaim-phantom-executor-binding` when it proves an in-memory executor-active binding is stale, clears the binding, and requeues the in-progress task with worktree/progress preserved. - FN-6783: task-store open and self-healing housekeeping emit `task:reconcile-orphaned-task-dir` when they non-destructively re-import a valid live `.fusion/tasks/{ID}/task.json` directory that has no task row anywhere, preserving soft-deleted/archived/tombstoned IDs. +- FN-6782/FN-6796: self-healing emits `task:auto-recover-paused-abort-park` when it clears a benign pause-abort operator park, requeueing safe `todo`/`in-progress` rows or preserving a clean auto-merge-eligible `in-review` row for review progression. - FN-6793/FN-6797: self-healing emits `task:reconcile-in-review-unmet-dependencies` when it rebounds an `in-review` task whose declared dependencies are still unmet, and `task:reconcile-in-review-unmet-dependencies-no-action` when pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation blocks that backward move. diff --git a/docs/architecture.md b/docs/architecture.md index d6ce2180de..92f1dd93a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -679,6 +679,7 @@ Runtime action-gate flow (v1): - Worktrees-dir sweeps that list direct children of `` (pool idle scan, orphan cleanup/reap, self-healing unregistered-orphan reap, and cap enforcement) must exclude the `.ai-merge` container by name; those one-level sweeps never inspect or recycle clean rooms beneath it. Batch 1 sweeps stale AI merge clean-room worktrees under the new `/.ai-merge/` root and still scans legacy `.fusion/ai-merge/` plus legacy `tmpdir()` locations for pre-relocation leftovers; candidates are bounded to names starting with `fusion-ai-merge-`. `runAiMerge` registers each live clean-room worktree in `activeSessionRegistry` with kind `ai-merge` as soon as the directory exists and keeps both raw and canonical paths registered for the duration of the merge, so the dedicated periodic sweep and pre-merge prune defer when either path is active (including concurrent same-task merge attempts). The default age gate is 2 hours; task-aware cleanup uses a 10-minute grace period for `done`/`archived` tasks and for genuinely missing/deleted task rows, and every removal path is clamped by the same 10-minute minimum-age floor so a freshly created worktree is never reaped. Transient `getTask` lookup failures (for example SQLite busy/parse errors) are not treated as deletion evidence; they log a warning, emit `lookup-error` only if eventually removed, and retain the conservative 2-hour gate. The sweep canonicalizes paths before checking `activeSessionRegistry`, attempts `git worktree remove --force ` before filesystem removal, runs `git worktree prune` after cleanup attempts, and emits `worktree:tempdir-sweep` run-audit telemetry for removal attempts and failures. Fresh directories, active-session paths, and individual removal failures are skipped/logged without aborting the maintenance cycle. - `recoverGhostReviewTasks()` is a fallback only for idle, non-terminal `in-review` states. Terminal/actionable states (notably `status: "failed"`) are preserved and **not** auto-kicked back to `todo`. + - `recoverPausedAbortFailures()` clears executor pause/resume abort parks only when the durable row is safe to recover. `todo`/`in-progress` rows are requeued for normal scheduling, while clean `in-review` rows (completed steps, not paused/user-paused/executing, auto-merge eligible, no confirmed or terminal merge evidence) have `status`/`error` cleared in place so review progression can continue. User hard-cancel, global/user pause, `autoMerge:false`, terminal merge, and live-execution guards remain operator-actionable. Successful recovery emits `task:auto-recover-paused-abort-park` with `preservedInReview` metadata. - `reattach-orphaned-assigned-executions` is a forward-resume safety net for durable-agent assignments. During startup recovery and periodic maintenance, after orphaned-agent and stale-heartbeat-run repairs, self-healing finds `in-progress` tasks with an `assignedAgentId` whose agent has no active heartbeat run and no active executor session after the orphan grace window. It re-dispatches in place via `executor.resumeTaskForAgent(agentId)` (the same seam used by clean `HeartbeatMonitor.onRunCompleted` and guarded by executor double-execution checks), emits `task:reattach-orphaned-execution`, and never moves the task backward. This complements engine-start `executor.resumeOrphaned()` and leaves unassigned/role-based execution recovery to the existing startup/limbo/stuck-task paths. - Mission validation has a dedicated stale-run reaper: startup recovery and Batch 2 maintenance call `reapStaleMissionValidatorRuns()` when wired by the runtime, using `VALIDATOR_RUN_STALE_MAX_AGE_MS` (currently 6 hours). The sweep terminates ownerless `mission_validator_runs.status='running'` rows as `error`, writes the reap reason into `summary`, leaves `lastValidatorRunId` pointing at the now-terminal run, and emits run-audit telemetry with `mutationType: "mission:validator-run-reaped"` plus `runId`/`featureId`/`missionId`/`triggerType`/`elapsedMs` metadata. Active mission features move to `loopState="needs_fix"` + `lastValidatorStatus="error"` unless their parent mission is already `complete`/`archived`. @@ -730,6 +731,7 @@ Guardrails: this routine does **not** retry merges, does **not** apply to mixed/ ### Observability and reflection - `AgentLogger` (`agent-logger.ts`) — structured per-agent run logging - `RunAudit` (`run-audit.ts`) — mutation audit tracking (DB/git/filesystem) + - FN-6782/FN-6796: `task:auto-recover-paused-abort-park` records self-healing recovery of pause-abort operator parks. Metadata includes the source column and whether recovery preserved a clean `in-review` row instead of requeueing to `todo`. - FN-4956: Layer 3 merge-conflict arbitration now scope-partitions conflicted files before AI resolution. Out-of-scope conflicts are deterministically resolved to the integration branch (`git checkout --ours`) and unstaged, while only in-scope conflicts flow to AI. Integration branch defaults are resolved via `resolveIntegrationBranch(rootDir, settings)`. Audit events: `merge:layer3:foreign-file-skipped` and `merge:layer3:scope-override-bypass`. - FN-5655 goal anchoring observability adds `database`-domain mutation types `goal:injection-applied`, `goal:injection-skipped`, and `goal:retrieval-invoked` so Slice 2 cite-rate tracking has a prompt-independent signal. Metadata uses counts/IDs only (`count`, `lane`, `toolName`, optional `truncated`/`reason`/`notFound`) and never stores prompt bodies or goal titles/descriptions. These events surface through `GET /api/agents/:id/runs/:runId/audit` and support the existing `startTime`/`endTime` filters. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index fcff2d93b5..d27613b07e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -25,7 +25,7 @@ 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. -The sidebar can be collapsed to an icon-only rail with 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. +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. @@ -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) @@ -223,6 +224,8 @@ Planning Mode now includes branch controls on the summary screen before you crea These values are sent with the Planning Mode create-task request as `branchSelection`, so created tasks persist branch/base-branch settings consistently with other branch-aware task creation flows. +When Planning Mode or Subtask Breakdown is opened from a workflow-filtered board lane, the create request also carries that active workflow selection. Single-task planning saves, planning breakdown saves, and subtask-breakdown saves create their tasks directly on the selected workflow lane instead of briefly landing on the default board. + Completed single-task planning sessions remain in the Planning Mode history after you create the task, and selecting one restores the completed summary instead of restarting the composer. History rows are deduplicated by session id even if the initial load and live session updates arrive out of order, and deleting a history entry now waits for the server delete to persist (failures keep the row visible and surface an error instead of silently disappearing until refresh). ## New Task Modal Branch Strategy @@ -1279,7 +1282,7 @@ Non-Command-Center dashboard CSS uses `--text` as the canonical primary text tok ### Theme system -Dark/light modes via `data-theme`; 57 color themes via `data-color-theme` (lazy-loaded from `app/public/theme-data.css`), including the Shadcn zinc-neutral theme with an orange default highlight/accent. Air is the minimal, borderless, paper-like preset with near-monochrome tokens and CSS-only chrome flattening. +Dark/light modes via `data-theme`; 66 color themes via `data-color-theme` (lazy-loaded from `app/public/theme-data.css`), including the Shadcn zinc-neutral theme with an orange default highlight/accent and its color family: Shadcn Blue/Green/Red/Purple/Pink/Orange/Yellow, Shadcn Mono (grayscale with red accent), and Shadcn Black (pure black and white). Air is the minimal, borderless, paper-like preset with near-monochrome tokens and CSS-only chrome flattening. - **Base tokens** (`--bg`, `--surface`, etc.) — redefine in `:root`, `[data-theme="light"]`, and every theme block. - **Semantic tokens** (`--autopilot-pulse`, `--event-error-text`, `--badge-mission-*`, `--fab-*`) — `:root` + `[data-theme="light"]` only; no per-color-theme overrides. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 28e58ee1d9..5bca54843c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -330,6 +330,16 @@ export const COLOR_THEMES = [ "neon-bloom", "sepia", "shadcn", + // FNXC:DashboardTheming 2026-06-19-16:07: FN-6756 extends the published color-theme union with shadcn-family accent variants; keep dashboard theme options, bootstrap validation, swatches, and theme-data token blocks in lockstep with this ordered list. + "shadcn-blue", + "shadcn-green", + "shadcn-red", + "shadcn-purple", + "shadcn-pink", + "shadcn-orange", + "shadcn-yellow", + "shadcn-mono", + "shadcn-black", ] as const; export type ColorTheme = (typeof COLOR_THEMES)[number]; diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index d7e7e4c431..fed57307fd 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1245,8 +1245,8 @@ function AppInner() { pushNav({ type: "modal", close: modalManager.closePlanning }); }, [modalManager, pushNav]); - const openPlanningWithInitialPlanWithNav = useCallback((initialPlan: string) => { - modalManager.openPlanningWithInitialPlan(initialPlan); + const openPlanningWithInitialPlanWithNav = useCallback((initialPlan: string, workflowId?: string | null) => { + modalManager.openPlanningWithInitialPlan(initialPlan, workflowId); pushNav({ type: "modal", close: modalManager.closePlanning }); }, [modalManager, pushNav]); @@ -1255,8 +1255,8 @@ function AppInner() { pushNav({ type: "modal", close: modalManager.closePlanning }); }, [modalManager, pushNav]); - const openSubtaskBreakdownWithNav = useCallback((description: string) => { - modalManager.openSubtaskBreakdown(description); + const openSubtaskBreakdownWithNav = useCallback((description: string, workflowId?: string | null) => { + modalManager.openSubtaskBreakdown(description, workflowId); pushNav({ type: "modal", close: modalManager.closeSubtask }); }, [modalManager, pushNav]); @@ -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/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 3f9faf891c..e09ba58744 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -3645,6 +3645,7 @@ export function createTaskFromPlanning( branchName?: string; baseBranch?: string; }; + workflowId?: string | null; }, ): Promise { return api(withProjectId("/planning/create-task", projectId), { @@ -3654,6 +3655,7 @@ export function createTaskFromPlanning( ...(options?.branch !== undefined ? { branch: options.branch } : {}), ...(options?.baseBranch !== undefined ? { baseBranch: options.baseBranch } : {}), ...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}), + ...(options?.workflowId !== undefined ? { workflowId: options.workflowId } : {}), }), }); } @@ -3687,6 +3689,7 @@ export function createTasksFromPlanning( branchAssignment?: { mode: "shared" | "per-task-derived"; }; + workflowId?: string | null; }, ): Promise<{ tasks: Task[] }> { return api<{ tasks: Task[] }>(withProjectId("/planning/create-tasks", projectId), { @@ -3696,6 +3699,7 @@ export function createTasksFromPlanning( subtasks, ...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}), ...(options?.branchAssignment ? { branchAssignment: options.branchAssignment } : {}), + ...(options?.workflowId !== undefined ? { workflowId: options.workflowId } : {}), }), }); } @@ -5670,6 +5674,7 @@ export function createTasksFromBreakdown( baseBranch?: string; }; branchAssignment?: { mode: "shared" | "per-task-derived" }; + workflowId?: string | null; }, ): Promise<{ tasks: Task[]; parentTaskClosed?: boolean }> { return api<{ tasks: Task[]; parentTaskClosed?: boolean }>(withProjectId("/subtasks/create-tasks", projectId), { @@ -5681,6 +5686,7 @@ export function createTasksFromBreakdown( ...(options?.baseBranch !== undefined ? { baseBranch: options.baseBranch } : {}), ...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}), ...(options?.branchAssignment ? { branchAssignment: options.branchAssignment } : {}), + ...(options?.workflowId !== undefined ? { workflowId: options.workflowId } : {}), subtasks: subtasks.map((subtask) => ({ tempId: subtask.id, title: subtask.title, diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index c8b0de8c6c..97b5ce9050 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -359,6 +359,7 @@ export function AppModals({ tasks={tasks} initialPlan={modalManager.planningInitialPlan ?? undefined} projectId={projectId} + workflowId={modalManager.planningWorkflowId} resumeSessionId={modalManager.planningResumeSessionId} /> @@ -370,6 +371,7 @@ export function AppModals({ initialDescription={modalManager.subtaskInitialDescription ?? ""} onTasksCreated={taskHandlers.handleSubtaskTasksCreated} projectId={projectId} + workflowId={modalManager.subtaskWorkflowId} resumeSessionId={modalManager.subtaskResumeSessionId} onOpenGroupModal={openGroupModalWithNav} /> 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..c6a87cbc23 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[]; @@ -50,11 +52,11 @@ interface BoardProps { /** * Called when the user clicks the "Plan" button in the inline create card. */ - onPlanningMode?: (initialPlan: string) => void; + onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; /** * Called when the user clicks the "Subtask" button in the inline create card. */ - onSubtaskBreakdown?: (description: string) => void; + onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void; onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void; favoriteProviders?: string[]; favoriteModels?: string[]; @@ -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/Column.tsx b/packages/dashboard/app/components/Column.tsx index 011dd7e56c..4eaa9057c1 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -121,11 +121,11 @@ interface ColumnProps { /** * Called when the user clicks the "Plan" button in the inline create card. */ - onPlanningMode?: (initialPlan: string) => void; + onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; /** * Called when the user clicks the "Subtask" button in the inline create card. */ - onSubtaskBreakdown?: (description: string) => void; + onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void; onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void; favoriteProviders?: string[]; favoriteModels?: string[]; @@ -689,6 +689,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, availableModels={availableModels} onPlanningMode={onPlanningMode} onSubtaskBreakdown={onSubtaskBreakdown} + workflowId={workflowMode ? workflowId : undefined} projectId={projectId} autoExpand={false} favoriteProviders={favoriteProviders} diff --git a/packages/dashboard/app/components/InlineCreateCard.tsx b/packages/dashboard/app/components/InlineCreateCard.tsx index 82d689cd12..1376695c8d 100644 --- a/packages/dashboard/app/components/InlineCreateCard.tsx +++ b/packages/dashboard/app/components/InlineCreateCard.tsx @@ -39,11 +39,11 @@ interface InlineCreateCardProps { /** * Called when the user clicks the "Plan" button to open planning mode. */ - onPlanningMode?: (initialPlan: string) => void; + onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; /** * Called when the user clicks the "Subtask" button to trigger subtask breakdown. */ - onSubtaskBreakdown?: (description: string) => void; + onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void; } function getNodeStatusLabel(status: NodeInfo["status"], t?: (key: string, defaultValue: string) => string): string { @@ -693,7 +693,11 @@ export function InlineCreateCard({ addToast(t("inline.enterDescriptionFirst", "Enter a description first"), "error"); return; } - onPlanningMode?.(trimmed); + if (selectedWorkflowId !== null) { + onPlanningMode?.(trimmed, selectedWorkflowId); + } else { + onPlanningMode?.(trimmed); + } // Clear the input after triggering planning mode setDescription(""); setSelectedWorkflowId(null); @@ -713,7 +717,7 @@ export function InlineCreateCard({ setIsModelModalOpen(false); setShowPresets(false); setIsExpanded(false); - }, [description, onPlanningMode, addToast]); + }, [description, onPlanningMode, selectedWorkflowId, addToast]); const handleSubtaskClick = useCallback(() => { const trimmed = description.trim(); @@ -721,7 +725,11 @@ export function InlineCreateCard({ addToast(t("inline.enterDescriptionFirst", "Enter a description first"), "error"); return; } - onSubtaskBreakdown?.(trimmed); + if (selectedWorkflowId !== null) { + onSubtaskBreakdown?.(trimmed, selectedWorkflowId); + } else { + onSubtaskBreakdown?.(trimmed); + } // Clear the input after triggering subtask breakdown setDescription(""); setSelectedWorkflowId(null); @@ -741,7 +749,7 @@ export function InlineCreateCard({ setIsModelModalOpen(false); setShowPresets(false); setIsExpanded(false); - }, [description, onSubtaskBreakdown, addToast]); + }, [description, onSubtaskBreakdown, selectedWorkflowId, addToast]); const truncate = (s: string, len: number) => s.length > len ? s.slice(0, len) + "…" : s; diff --git a/packages/dashboard/app/components/Lane.tsx b/packages/dashboard/app/components/Lane.tsx index 1c9d1289f1..0479f755a6 100644 --- a/packages/dashboard/app/components/Lane.tsx +++ b/packages/dashboard/app/components/Lane.tsx @@ -55,8 +55,8 @@ export interface LaneProps { githubIssueAction?: GithubIssueAction; }) => Promise; availableModels?: ModelInfo[]; - onPlanningMode?: (initialPlan: string) => void; - onSubtaskBreakdown?: (description: string) => void; + onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; + onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void; onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void; favoriteProviders?: string[]; favoriteModels?: string[]; diff --git a/packages/dashboard/app/components/LeftSidebarNav.css b/packages/dashboard/app/components/LeftSidebarNav.css index bcba44d744..04edbcc453 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.css +++ b/packages/dashboard/app/components/LeftSidebarNav.css @@ -16,20 +16,25 @@ The experimental sidebar is a persistent desktop/tablet navigation replacement f color: var(--text); } -.left-sidebar-nav__brand { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--space-sm); - padding: var(--space-md); - border-bottom: 1px solid var(--border); - min-height: calc(var(--space-2xl) + var(--space-xl)); -} - .left-sidebar-nav__collapse-toggle { flex-shrink: 0; } +.left-sidebar-nav__collapse-toggle--floating { + position: absolute; + top: var(--space-sm); + right: calc((var(--space-lg) + var(--space-xs)) * -1); + z-index: 2; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: calc(var(--space-lg) + var(--space-md)); + min-height: calc(var(--space-lg) + var(--space-md)); + padding: var(--space-xs); + border-radius: var(--radius-full); + box-shadow: var(--shadow-sm); +} + .left-sidebar-nav__list { display: flex; flex: 1; @@ -166,17 +171,12 @@ The experimental sidebar is a persistent desktop/tablet navigation replacement f display: none; } -.left-sidebar-nav--collapsed .left-sidebar-nav__brand, .left-sidebar-nav--collapsed .left-sidebar-nav__item { justify-content: center; padding-right: 0; padding-left: 0; } -.left-sidebar-nav--collapsed .left-sidebar-nav__collapse-toggle { - width: auto; -} - @media (max-width: 768px) { .left-sidebar-nav { display: none; diff --git a/packages/dashboard/app/components/LeftSidebarNav.tsx b/packages/dashboard/app/components/LeftSidebarNav.tsx index b5b4eec6c6..b2272e60d3 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.tsx +++ b/packages/dashboard/app/components/LeftSidebarNav.tsx @@ -396,22 +396,20 @@ export function LeftSidebarNav({ style={isCollapsed ? undefined : { width: sidebarWidth, minWidth: sidebarWidth }} > {/* - FNXC:Navigation 2026-06-20-00:00: - The sidebar no longer renders its own Fusion logo, wordmark, or project dropdown because those affordances already live in the top Header. Keep this brand row as the collapse control host so expanded and rail states retain a reachable toggle without leaving empty logo/project shells. + FNXC:Navigation 2026-06-20-12:00: + The sidebar collapse affordance must not consume a dedicated brand row now that logo, wordmark, and project controls live in Header. Float the single toggle on the resize border so expanded and rail states retain the same reachable click target without an empty header shell. */} -
- -
+
); + const renderListWorkflowSkeleton = (empty = false) => ( +
+
+
+

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

+

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

+
+
+