Merge pull request #1694 from Runfusion/fix/benign-todo-pause-abort-log-noise
Fix benign pause-abort failure notification + task-chat ephemeral agent detection
This commit is contained in:
@@ -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.
|
||||
5
.changeset/fix-task-chat-ephemeral-agent-working.md
Normal file
5
.changeset/fix-task-chat-ephemeral-agent-working.md
Normal file
@@ -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).
|
||||
5
.changeset/fn-6776-board-flash.md
Normal file
5
.changeset/fn-6776-board-flash.md
Normal file
@@ -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.
|
||||
5
.changeset/fn-6796-pause-resume-in-review-recovery.md
Normal file
5
.changeset/fn-6796-pause-resume-in-review-recovery.md
Normal file
@@ -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.
|
||||
5
.changeset/fn-6800-mobile-nav-spacing.md
Normal file
5
.changeset/fn-6800-mobile-nav-spacing.md
Normal file
@@ -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.
|
||||
5
.changeset/planning-subtask-workflow-selection.md
Normal file
5
.changeset/planning-subtask-workflow-selection.md
Normal file
@@ -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.
|
||||
5
.changeset/shadcn-color-variants.md
Normal file
5
.changeset/shadcn-color-variants.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -679,6 +679,7 @@ Runtime action-gate flow (v1):
|
||||
- Worktrees-dir sweeps that list direct children of `<worktreesDir>` (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 `<worktreesDir>/.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 <path>` 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
@@ -1887,6 +1889,8 @@ function AppInner() {
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMerge={autoMerge}
|
||||
onCreateWorkflow={openCreateWorkflowWithNav}
|
||||
workflowColumnsEnabled={experimentalFeatures.workflowColumns === true}
|
||||
settingsLoaded={settingsLoaded}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -3645,6 +3645,7 @@ export function createTaskFromPlanning(
|
||||
branchName?: string;
|
||||
baseBranch?: string;
|
||||
};
|
||||
workflowId?: string | null;
|
||||
},
|
||||
): Promise<Task> {
|
||||
return api<Task>(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,
|
||||
|
||||
@@ -359,6 +359,7 @@ export function AppModals({
|
||||
tasks={tasks}
|
||||
initialPlan={modalManager.planningInitialPlan ?? undefined}
|
||||
projectId={projectId}
|
||||
workflowId={modalManager.planningWorkflowId}
|
||||
resumeSessionId={modalManager.planningResumeSessionId}
|
||||
/>
|
||||
</ModalErrorBoundary>
|
||||
@@ -370,6 +371,7 @@ export function AppModals({
|
||||
initialDescription={modalManager.subtaskInitialDescription ?? ""}
|
||||
onTasksCreated={taskHandlers.handleSubtaskTasksCreated}
|
||||
projectId={projectId}
|
||||
workflowId={modalManager.subtaskWorkflowId}
|
||||
resumeSessionId={modalManager.subtaskResumeSessionId}
|
||||
onOpenGroupModal={openGroupModalWithNav}
|
||||
/>
|
||||
|
||||
69
packages/dashboard/app/components/Board.css
Normal file
69
packages/dashboard/app/components/Board.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>, 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 (
|
||||
<main className="board board-workflows-skeleton" id="board" aria-busy={!empty} aria-label={empty ? "No workflow lanes available" : "Loading workflow lanes"} data-testid={empty ? "board-workflows-empty" : "board-workflows-skeleton"}>
|
||||
{[0, 1, 2].map((index) => (
|
||||
<section className="board-workflows-skeleton__column card" key={index} aria-hidden="true">
|
||||
<div className="board-workflows-skeleton__header" />
|
||||
<div className="board-workflows-skeleton__card" />
|
||||
<div className="board-workflows-skeleton__card board-workflows-skeleton__card--short" />
|
||||
</section>
|
||||
))}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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<BoardWorkflowsPayload | null>(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<string | null>(null);
|
||||
const draggingTaskIdRef = useRef<string | null>(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 <BoardWorkflowSkeleton empty={boardWorkflows?.flagEnabled === true} />;
|
||||
}
|
||||
|
||||
if (workflowMode && selectedWorkflow) {
|
||||
return (
|
||||
<div className="board-workflow-view">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -55,8 +55,8 @@ export interface LaneProps {
|
||||
githubIssueAction?: GithubIssueAction;
|
||||
}) => Promise<Task>;
|
||||
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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/}
|
||||
<div className="left-sidebar-nav__brand" data-testid="sidebar-nav-brand">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon left-sidebar-nav__collapse-toggle"
|
||||
aria-label={isCollapsed ? t("nav.expandSidebar", "Expand sidebar") : t("nav.collapseSidebar", "Collapse sidebar")}
|
||||
title={isCollapsed ? t("nav.expandSidebar", "Expand sidebar") : t("nav.collapseSidebar", "Collapse sidebar")}
|
||||
aria-pressed={isCollapsed}
|
||||
data-testid="sidebar-nav-collapse-toggle"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon left-sidebar-nav__collapse-toggle left-sidebar-nav__collapse-toggle--floating"
|
||||
aria-label={isCollapsed ? t("nav.expandSidebar", "Expand sidebar") : t("nav.collapseSidebar", "Collapse sidebar")}
|
||||
title={isCollapsed ? t("nav.expandSidebar", "Expand sidebar") : t("nav.collapseSidebar", "Collapse sidebar")}
|
||||
aria-pressed={isCollapsed}
|
||||
data-testid="sidebar-nav-collapse-toggle"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||
</button>
|
||||
|
||||
<nav className="left-sidebar-nav__list" aria-label={t("nav.primaryNavAriaLabel", "Primary navigation")}>
|
||||
<div className="left-sidebar-nav__section">{primaryEntries.map(renderEntry)}</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Column, string> = {
|
||||
triage: "var(--triage)",
|
||||
@@ -218,11 +219,11 @@ interface ListViewProps {
|
||||
/**
|
||||
* Called when the user clicks the "Plan" button in the quick entry box.
|
||||
*/
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
||||
/**
|
||||
* Called when the user clicks the "Subtask" button in the quick entry box.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
||||
/**
|
||||
* Called when tasks are updated (e.g., after bulk model update).
|
||||
* Allows parent to refresh task list or handle optimistically.
|
||||
@@ -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<string | null>(null);
|
||||
const [dragOverColumn, setDragOverColumn] = useState<ColumnId | null>(null);
|
||||
const [selectedColumn, setSelectedColumn] = useState<ColumnId | null>(null);
|
||||
const [boardWorkflows, setBoardWorkflows] = useState<BoardWorkflowsPayload | null>(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<string | null>(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({
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderListWorkflowSkeleton = (empty = false) => (
|
||||
<div className="list-view list-view--workflow-skeleton" aria-busy={!empty} aria-label={empty ? t("listView.noWorkflowLanes", "No workflow lanes available") : t("listView.loadingWorkflowLanes", "Loading workflow lanes")} data-testid={empty ? "list-workflows-empty" : "list-workflows-skeleton"}>
|
||||
<div className="list-view-header">
|
||||
<div>
|
||||
<h2>{t("listView.title", "List View")}</h2>
|
||||
<p className="list-subtitle">{empty ? t("listView.noWorkflowLanes", "No workflow lanes available") : t("listView.loadingWorkflowLanes", "Loading workflow lanes")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="list-workflow-skeleton card" aria-hidden="true">
|
||||
<div className="list-workflow-skeleton__row list-workflow-skeleton__row--header" />
|
||||
<div className="list-workflow-skeleton__row" />
|
||||
<div className="list-workflow-skeleton__row list-workflow-skeleton__row--short" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBulkEditToolbars = () => (
|
||||
<>
|
||||
<div className="bulk-edit-toolbar">
|
||||
@@ -1770,6 +1808,14 @@ export function ListView({
|
||||
</>
|
||||
);
|
||||
|
||||
const shouldGateLegacyList = boardWorkflows === null
|
||||
? (workflowColumnsEnabled === true || settingsLoaded === false)
|
||||
: boardWorkflows.flagEnabled === true && boardWorkflows.workflows.length === 0;
|
||||
|
||||
if (shouldGateLegacyList) {
|
||||
return renderListWorkflowSkeleton(boardWorkflows?.flagEnabled === true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="list-view">
|
||||
{isMobile && (
|
||||
|
||||
@@ -78,8 +78,13 @@
|
||||
FNXC:MobileNav 2026-06-20-02:04:
|
||||
Every mobile nav tab must be an equal-width column with a centered icon so inter-icon spacing stays uniform across tab counts, long labels, active state, badges, and status dots.
|
||||
Use a zero flex basis plus min-width:0 so intrinsic label width cannot bias flex distribution; badges and status dots stay absolutely positioned and out of flow.
|
||||
|
||||
FNXC:MobileNav 2026-06-20-16:11:
|
||||
Real-browser geometry showed the SVG centers were uniform after FN-6766, but status dots protruded past Chat/Mailbox icon boxes and made those icon clusters optically right-heavy.
|
||||
Wrap every tab icon in the same token-sized icon slot and keep unread/pending dots inside that slot so icon centering and perceived inter-icon spacing stay identical across tab count, label length, badge, status-dot, and active variants.
|
||||
*/
|
||||
.mobile-nav-tab {
|
||||
--mobile-nav-icon-size: calc(var(--space-lg) + var(--space-sm) - (var(--space-xs) / 2));
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -87,7 +92,7 @@ Use a zero flex basis plus min-width:0 so intrinsic label width cannot bias flex
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: calc(var(--space-xs) / 2);
|
||||
min-height: 36px;
|
||||
min-height: calc(var(--space-xl) + var(--space-md));
|
||||
padding: calc(var(--space-sm) - (var(--space-xs) / 2)) 0;
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -109,19 +114,24 @@ Use a zero flex basis plus min-width:0 so intrinsic label width cannot bias flex
|
||||
}
|
||||
|
||||
.mobile-nav-tab svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: var(--mobile-nav-icon-size);
|
||||
height: var(--mobile-nav-icon-size);
|
||||
}
|
||||
|
||||
.mobile-nav-tab-icon-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
flex: 0 0 var(--mobile-nav-icon-size);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--mobile-nav-icon-size);
|
||||
height: var(--mobile-nav-icon-size);
|
||||
}
|
||||
|
||||
.mobile-nav-chat-unread-dot {
|
||||
position: absolute;
|
||||
top: calc(var(--space-xs) * -1);
|
||||
right: calc(var(--space-xs) * -1);
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.mobile-nav-tab-label {
|
||||
|
||||
@@ -328,7 +328,9 @@ export function MobileNavBar({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LayoutGrid />
|
||||
<span className="mobile-nav-tab-icon-wrapper">
|
||||
<LayoutGrid />
|
||||
</span>
|
||||
<span className="mobile-nav-tab-label">{t("nav.tasks", "Tasks")}</span>
|
||||
</button>
|
||||
|
||||
@@ -340,7 +342,9 @@ export function MobileNavBar({
|
||||
aria-selected={view === "agents"}
|
||||
onClick={() => onChangeView("agents")}
|
||||
>
|
||||
<Bot />
|
||||
<span className="mobile-nav-tab-icon-wrapper">
|
||||
<Bot />
|
||||
</span>
|
||||
<span className="mobile-nav-tab-label">{t("nav.agents", "Agents")}</span>
|
||||
</button>
|
||||
|
||||
@@ -352,7 +356,9 @@ export function MobileNavBar({
|
||||
aria-selected={view === "missions"}
|
||||
onClick={() => onChangeView("missions")}
|
||||
>
|
||||
<Target />
|
||||
<span className="mobile-nav-tab-icon-wrapper">
|
||||
<Target />
|
||||
</span>
|
||||
<span className="mobile-nav-tab-label">{t("nav.missions", "Missions")}</span>
|
||||
</button>
|
||||
|
||||
@@ -407,7 +413,9 @@ export function MobileNavBar({
|
||||
aria-selected={view === "command-center"}
|
||||
onClick={() => onChangeView("command-center")}
|
||||
>
|
||||
<Gauge />
|
||||
<span className="mobile-nav-tab-icon-wrapper">
|
||||
<Gauge />
|
||||
</span>
|
||||
<span className="mobile-nav-tab-label">{t("nav.commandCenter", "Command Center")}</span>
|
||||
</button>
|
||||
|
||||
@@ -420,7 +428,9 @@ export function MobileNavBar({
|
||||
aria-selected={view === "skills"}
|
||||
onClick={() => onChangeView("skills")}
|
||||
>
|
||||
<Zap />
|
||||
<span className="mobile-nav-tab-icon-wrapper">
|
||||
<Zap />
|
||||
</span>
|
||||
<span className="mobile-nav-tab-label">{t("nav.skills", "Skills")}</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -439,7 +449,9 @@ export function MobileNavBar({
|
||||
aria-selected={view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph")}
|
||||
onClick={() => onChangeView(entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph" ? "graph" : pluginTaskView)}
|
||||
>
|
||||
<PluginIcon />
|
||||
<span className="mobile-nav-tab-icon-wrapper">
|
||||
<PluginIcon />
|
||||
</span>
|
||||
<span className="mobile-nav-tab-label">{entry.view.label}</span>
|
||||
</button>
|
||||
);
|
||||
@@ -453,7 +465,9 @@ export function MobileNavBar({
|
||||
aria-selected={false}
|
||||
onClick={() => setIsMoreOpen((prev) => !prev)}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
<span className="mobile-nav-tab-icon-wrapper">
|
||||
<MoreHorizontal />
|
||||
</span>
|
||||
<span className="mobile-nav-tab-label">{t("nav.more", "More")}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -67,6 +67,8 @@ interface PlanningModeModalProps {
|
||||
tasks: Task[];
|
||||
initialPlan?: string;
|
||||
projectId?: string;
|
||||
/** Active workflow lane selected when Planning Mode was opened. */
|
||||
workflowId?: string | null;
|
||||
/** When set, reconnect to a persisted background session instead of starting fresh */
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
@@ -191,7 +193,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
|
||||
};
|
||||
}
|
||||
|
||||
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, resumeSessionId }: PlanningModeModalProps) {
|
||||
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, workflowId, resumeSessionId }: PlanningModeModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [initialPlan, setInitialPlan] = useState("");
|
||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||
@@ -1631,6 +1633,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
...(branchMode === "existing" || branchMode === "custom-new" ? { branchName: branchName.trim() } : {}),
|
||||
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
|
||||
},
|
||||
/*
|
||||
FNXC:WorkflowSelection 2026-06-20-16:48:
|
||||
Planning Mode saves must carry the workflow lane that opened the modal so created tasks do not land on the main board before appearing on the selected sub-board.
|
||||
*/
|
||||
...(workflowId !== undefined ? { workflowId } : {}),
|
||||
});
|
||||
onTaskCreated(task);
|
||||
// Single-task creation should preserve completed planning history, so
|
||||
@@ -1648,7 +1655,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
} finally {
|
||||
setIsCreatingTask(false);
|
||||
}
|
||||
}, [baseBranch, branchMode, branchName, broadcastCompleted, editedSummary, view, projectId, onTaskCreated, handleClose]);
|
||||
}, [baseBranch, branchMode, branchName, broadcastCompleted, editedSummary, view, projectId, workflowId, onTaskCreated, handleClose]);
|
||||
|
||||
const handleStartBreakdown = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
@@ -1702,6 +1709,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
...(branchMode === "existing" || branchMode === "custom-new" ? { branchName: branchName.trim() } : {}),
|
||||
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
|
||||
},
|
||||
/*
|
||||
FNXC:WorkflowSelection 2026-06-20-16:48:
|
||||
Planning breakdown saves create several tasks, and every child must inherit the modal's workflow lane selection.
|
||||
*/
|
||||
...(workflowId !== undefined ? { workflowId } : {}),
|
||||
},
|
||||
);
|
||||
onTasksCreated(result.tasks);
|
||||
@@ -1734,7 +1746,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
} finally {
|
||||
setIsCreatingFromBreakdown(false);
|
||||
}
|
||||
}, [baseBranch, branchMode, branchName, broadcastCompleted, handleClose, view, onTasksCreated, projectId]);
|
||||
}, [baseBranch, branchMode, branchName, broadcastCompleted, handleClose, view, onTasksCreated, projectId, workflowId]);
|
||||
|
||||
const handleBack = useCallback(async () => {
|
||||
if (view.type !== "question" || responseHistory.length === 0) {
|
||||
|
||||
@@ -32,11 +32,13 @@ interface QuickEntryBoxProps {
|
||||
/**
|
||||
* 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;
|
||||
/** Selected workflow lane for AI-assisted create actions. Omit in legacy board mode to preserve project-default inheritance. */
|
||||
workflowId?: string | null;
|
||||
/** Optional project context for API calls */
|
||||
projectId?: string;
|
||||
/**
|
||||
@@ -88,7 +90,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
|
||||
};
|
||||
}
|
||||
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, projectId, autoExpand = true, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, workflowId, projectId, autoExpand = true, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [description, setDescription] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -1343,10 +1345,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
addToast(t("tasks.enterDescriptionFirst", "Enter a description first"), "error");
|
||||
return;
|
||||
}
|
||||
onPlanningMode?.(trimmed);
|
||||
if (workflowId !== undefined) {
|
||||
onPlanningMode?.(trimmed, workflowId);
|
||||
} else {
|
||||
onPlanningMode?.(trimmed);
|
||||
}
|
||||
// Clear the form after triggering planning mode
|
||||
resetForm();
|
||||
}, [description, onPlanningMode, addToast, resetForm]);
|
||||
}, [description, onPlanningMode, workflowId, addToast, resetForm]);
|
||||
|
||||
const handleSubtaskClick = useCallback(() => {
|
||||
const trimmed = description.trim();
|
||||
@@ -1354,10 +1360,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
addToast(t("tasks.enterDescriptionFirst", "Enter a description first"), "error");
|
||||
return;
|
||||
}
|
||||
onSubtaskBreakdown?.(trimmed);
|
||||
if (workflowId !== undefined) {
|
||||
onSubtaskBreakdown?.(trimmed, workflowId);
|
||||
} else {
|
||||
onSubtaskBreakdown?.(trimmed);
|
||||
}
|
||||
// Clear the form after triggering subtask breakdown
|
||||
resetForm();
|
||||
}, [description, onSubtaskBreakdown, addToast, resetForm]);
|
||||
}, [description, onSubtaskBreakdown, workflowId, addToast, resetForm]);
|
||||
|
||||
const handleSaveClick = useCallback(() => {
|
||||
// Save button now creates the task (same as Enter key)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
connectSubtaskStream,
|
||||
createTasksFromBreakdown,
|
||||
fetchAiSession,
|
||||
fetchTaskWorkflow,
|
||||
parseConversationHistory,
|
||||
type SubtaskItem,
|
||||
type ConversationHistoryEntry,
|
||||
@@ -36,6 +37,8 @@ interface SubtaskBreakdownModalProps {
|
||||
onTasksCreated: (tasks: Task[]) => void;
|
||||
parentTaskId?: string;
|
||||
projectId?: string;
|
||||
/** Active workflow lane selected when the breakdown modal was opened. */
|
||||
workflowId?: string | null;
|
||||
resumeSessionId?: string;
|
||||
onOpenGroupModal?: (groupId: string) => void;
|
||||
}
|
||||
@@ -77,7 +80,7 @@ function hasDependencyCycle(subtasks: SubtaskItem[]): boolean {
|
||||
return subtasks.some((item) => visit(item.id));
|
||||
}
|
||||
|
||||
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId, resumeSessionId, onOpenGroupModal }: SubtaskBreakdownModalProps) {
|
||||
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId, workflowId, resumeSessionId, onOpenGroupModal }: SubtaskBreakdownModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const viewportMode = useViewportMode();
|
||||
useMobileScrollLock(isOpen);
|
||||
@@ -545,6 +548,14 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
setError(null);
|
||||
setView({ type: "creating", sessionId });
|
||||
try {
|
||||
let effectiveWorkflowId = workflowId;
|
||||
if (parentTaskId) {
|
||||
try {
|
||||
effectiveWorkflowId = (await fetchTaskWorkflow(parentTaskId, projectId)).workflowId;
|
||||
} catch {
|
||||
effectiveWorkflowId = workflowId;
|
||||
}
|
||||
}
|
||||
const result = await createTasksFromBreakdown(sessionId, subtasks, parentTaskId, projectId, {
|
||||
branchSelection: {
|
||||
mode: branchMode,
|
||||
@@ -552,6 +563,11 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}),
|
||||
},
|
||||
branchAssignment: { mode: branchAssignmentMode },
|
||||
/*
|
||||
FNXC:WorkflowSelection 2026-06-20-16:48:
|
||||
Subtask Breakdown children inherit the parent task's workflow when available, otherwise they keep the workflow lane that opened this modal.
|
||||
*/
|
||||
...(effectiveWorkflowId !== undefined ? { workflowId: effectiveWorkflowId } : {}),
|
||||
});
|
||||
onTasksCreated(result.tasks);
|
||||
resetState();
|
||||
@@ -560,7 +576,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
setError(getErrorMessage(err) || t("subtasks.errorCreateTasks", "Failed to create tasks"));
|
||||
setView({ type: "editing", sessionId });
|
||||
}
|
||||
}, [baseBranch, branchAssignmentMode, branchMode, branchName, isInvalid, onClose, onTasksCreated, parentTaskId, projectId, resetState, sessionId, subtasks]);
|
||||
}, [baseBranch, branchAssignmentMode, branchMode, branchName, isInvalid, onClose, onTasksCreated, parentTaskId, projectId, resetState, sessionId, subtasks, workflowId]);
|
||||
|
||||
const handleRetry = useCallback(async () => {
|
||||
if (view.type !== "error") {
|
||||
|
||||
@@ -53,6 +53,11 @@ const STEERING_BLOCKED_STATUSES = new Set([
|
||||
"needs-replan",
|
||||
]);
|
||||
const REVIEW_STEERABLE_STATUSES = new Set(["reviewing", "merging", "merging-fix", "fixing"]);
|
||||
// The scheduler's waiting/blocked marker for a not-yet-dispatched task
|
||||
// (self-healing.ts documents `status: "queued"` as the blocked marker). A queued
|
||||
// in-progress row has no agent executing yet, so it stays assignment-gated rather
|
||||
// than counting as an implied active session.
|
||||
const SCHEDULER_WAITING_STATUS = "queued";
|
||||
const BOTTOM_FOLLOW_THRESHOLD = 48;
|
||||
const TOP_LOAD_THRESHOLD = 48;
|
||||
|
||||
@@ -172,8 +177,24 @@ function isActiveAgentSession(task: Task | TaskDetail, opts: { sessionLive?: boo
|
||||
const statusAllowsReviewSteering = !task.status || REVIEW_STEERABLE_STATUSES.has(task.status);
|
||||
const columnAllowsSteering = (task.column === "in-progress" && statusAllowsProgressSteering)
|
||||
|| (task.column === "in-review" && statusAllowsReviewSteering);
|
||||
// FNXC:TaskDetailChat 2026-06-20-20:10:
|
||||
// In the default ephemeral-agents mode the scheduler never writes
|
||||
// `assignedAgentId`/`checkedOutBy` — those are only set when
|
||||
// `ephemeralAgentsEnabled === false` (scheduler.ts). An actively-executing
|
||||
// task therefore has no assignment field yet IS being worked, so requiring
|
||||
// `hasAssignedAgent` made the chat always show "no agent is working" for
|
||||
// default-mode tasks. Treat assignment as sufficient-but-not-necessary:
|
||||
// - in-progress with a non-blocked, non-`queued` status is an executing run
|
||||
// (`queued` is the documented waiting marker, self-healing.ts — it stays
|
||||
// assignment-gated);
|
||||
// - in-review with an active review/merge status (REVIEW_STEERABLE_STATUSES)
|
||||
// has a reviewer/merger running. A null-status in-review row is awaiting
|
||||
// human review, not actively worked, so it stays assignment-gated and idle.
|
||||
const executionImpliesActiveAgent =
|
||||
(task.column === "in-progress" && statusAllowsProgressSteering && task.status !== SCHEDULER_WAITING_STATUS)
|
||||
|| (task.column === "in-review" && task.status != null && REVIEW_STEERABLE_STATUSES.has(task.status));
|
||||
return columnAllowsSteering
|
||||
&& hasAssignedAgent;
|
||||
&& (hasAssignedAgent || executionImpliesActiveAgent);
|
||||
}
|
||||
|
||||
function isToolLikeEntry(entry: AgentLogEntry): boolean {
|
||||
|
||||
@@ -127,8 +127,8 @@ export interface TaskFormProps {
|
||||
onGithubRepoOverrideChange?: (value: string) => void;
|
||||
|
||||
// AI-assisted creation callbacks (create mode only)
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
||||
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
||||
onClose?: () => void;
|
||||
|
||||
/** Optional content to render between the primary section and the "More options" toggle. */
|
||||
|
||||
@@ -388,6 +388,69 @@
|
||||
--swatch-sample-4: #27272a;
|
||||
}
|
||||
|
||||
.theme-swatch-shadcn-blue {
|
||||
--swatch-sample-1: #09090b;
|
||||
--swatch-sample-2: #18181b;
|
||||
--swatch-sample-3: #3b82f6;
|
||||
--swatch-sample-4: #27272a;
|
||||
}
|
||||
|
||||
.theme-swatch-shadcn-green {
|
||||
--swatch-sample-1: #09090b;
|
||||
--swatch-sample-2: #18181b;
|
||||
--swatch-sample-3: #22c55e;
|
||||
--swatch-sample-4: #27272a;
|
||||
}
|
||||
|
||||
.theme-swatch-shadcn-red {
|
||||
--swatch-sample-1: #09090b;
|
||||
--swatch-sample-2: #18181b;
|
||||
--swatch-sample-3: #ef4444;
|
||||
--swatch-sample-4: #27272a;
|
||||
}
|
||||
|
||||
.theme-swatch-shadcn-purple {
|
||||
--swatch-sample-1: #09090b;
|
||||
--swatch-sample-2: #18181b;
|
||||
--swatch-sample-3: #8b5cf6;
|
||||
--swatch-sample-4: #27272a;
|
||||
}
|
||||
|
||||
.theme-swatch-shadcn-pink {
|
||||
--swatch-sample-1: #09090b;
|
||||
--swatch-sample-2: #18181b;
|
||||
--swatch-sample-3: #ec4899;
|
||||
--swatch-sample-4: #27272a;
|
||||
}
|
||||
|
||||
.theme-swatch-shadcn-orange {
|
||||
--swatch-sample-1: #09090b;
|
||||
--swatch-sample-2: #18181b;
|
||||
--swatch-sample-3: #f97316;
|
||||
--swatch-sample-4: #27272a;
|
||||
}
|
||||
|
||||
.theme-swatch-shadcn-yellow {
|
||||
--swatch-sample-1: #09090b;
|
||||
--swatch-sample-2: #18181b;
|
||||
--swatch-sample-3: #eab308;
|
||||
--swatch-sample-4: #27272a;
|
||||
}
|
||||
|
||||
.theme-swatch-shadcn-mono {
|
||||
--swatch-sample-1: #09090b;
|
||||
--swatch-sample-2: #18181b;
|
||||
--swatch-sample-3: #ef4444;
|
||||
--swatch-sample-4: #27272a;
|
||||
}
|
||||
|
||||
.theme-swatch-shadcn-black {
|
||||
--swatch-sample-1: #09090b;
|
||||
--swatch-sample-2: #18181b;
|
||||
--swatch-sample-3: #fafafa;
|
||||
--swatch-sample-4: #27272a;
|
||||
}
|
||||
|
||||
.theme-swatch-ayu {
|
||||
--swatch-sample-1: #0f1419;
|
||||
--swatch-sample-2: #131d27;
|
||||
@@ -606,6 +669,69 @@
|
||||
--swatch-sample-4: #e4e4e7;
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-swatch-shadcn-blue {
|
||||
--swatch-sample-1: #ffffff;
|
||||
--swatch-sample-2: #f4f4f5;
|
||||
--swatch-sample-3: #2563eb;
|
||||
--swatch-sample-4: #e4e4e7;
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-swatch-shadcn-green {
|
||||
--swatch-sample-1: #ffffff;
|
||||
--swatch-sample-2: #f4f4f5;
|
||||
--swatch-sample-3: #16a34a;
|
||||
--swatch-sample-4: #e4e4e7;
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-swatch-shadcn-red {
|
||||
--swatch-sample-1: #ffffff;
|
||||
--swatch-sample-2: #f4f4f5;
|
||||
--swatch-sample-3: #dc2626;
|
||||
--swatch-sample-4: #e4e4e7;
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-swatch-shadcn-purple {
|
||||
--swatch-sample-1: #ffffff;
|
||||
--swatch-sample-2: #f4f4f5;
|
||||
--swatch-sample-3: #7c3aed;
|
||||
--swatch-sample-4: #e4e4e7;
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-swatch-shadcn-pink {
|
||||
--swatch-sample-1: #ffffff;
|
||||
--swatch-sample-2: #f4f4f5;
|
||||
--swatch-sample-3: #db2777;
|
||||
--swatch-sample-4: #e4e4e7;
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-swatch-shadcn-orange {
|
||||
--swatch-sample-1: #ffffff;
|
||||
--swatch-sample-2: #f4f4f5;
|
||||
--swatch-sample-3: #ea580c;
|
||||
--swatch-sample-4: #e4e4e7;
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-swatch-shadcn-yellow {
|
||||
--swatch-sample-1: #ffffff;
|
||||
--swatch-sample-2: #f4f4f5;
|
||||
--swatch-sample-3: #ca8a04;
|
||||
--swatch-sample-4: #e4e4e7;
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-swatch-shadcn-mono {
|
||||
--swatch-sample-1: #ffffff;
|
||||
--swatch-sample-2: #f4f4f5;
|
||||
--swatch-sample-3: #dc2626;
|
||||
--swatch-sample-4: #e4e4e7;
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-swatch-shadcn-black {
|
||||
--swatch-sample-1: #ffffff;
|
||||
--swatch-sample-2: #f4f4f5;
|
||||
--swatch-sample-3: #09090b;
|
||||
--swatch-sample-4: #e4e4e7;
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-swatch-ayu {
|
||||
--swatch-sample-1: #fafafa;
|
||||
--swatch-sample-2: #f3f3f3;
|
||||
|
||||
@@ -50,7 +50,9 @@ const pluginViews: PluginDashboardViewEntry[] = [
|
||||
];
|
||||
|
||||
function expectNoSidebarBrandOrProjectAffordances(container: HTMLElement) {
|
||||
expect(screen.queryByTestId("sidebar-nav-brand")).toBeNull();
|
||||
expect(screen.queryByTestId("sidebar-nav-project-selector")).toBeNull();
|
||||
expect(container.querySelector(".left-sidebar-nav__brand")).toBeNull();
|
||||
expect(container.querySelector(".left-sidebar-nav__logo-mark")).toBeNull();
|
||||
expect(container.querySelector(".left-sidebar-nav__wordmark")).toBeNull();
|
||||
}
|
||||
@@ -247,6 +249,28 @@ describe("LeftSidebarNav", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("removes the brand-row shell while keeping the floating toggle reachable in expanded and collapsed states", () => {
|
||||
const { container } = renderSidebar();
|
||||
const sidebar = screen.getByTestId("left-sidebar-nav");
|
||||
const expandedToggle = screen.getByTestId("sidebar-nav-collapse-toggle");
|
||||
|
||||
expectNoSidebarBrandOrProjectAffordances(container);
|
||||
expect(expandedToggle).toHaveClass("left-sidebar-nav__collapse-toggle--floating");
|
||||
expect(expandedToggle).toHaveAttribute("aria-pressed", "false");
|
||||
expect(expandedToggle).toHaveAccessibleName("Collapse sidebar");
|
||||
expect(expandedToggle).toHaveAttribute("title", "Collapse sidebar");
|
||||
|
||||
fireEvent.click(expandedToggle);
|
||||
|
||||
const collapsedToggle = screen.getByTestId("sidebar-nav-collapse-toggle");
|
||||
expect(sidebar.className).toContain("left-sidebar-nav--collapsed");
|
||||
expectNoSidebarBrandOrProjectAffordances(container);
|
||||
expect(collapsedToggle).toHaveClass("left-sidebar-nav__collapse-toggle--floating");
|
||||
expect(collapsedToggle).toHaveAttribute("aria-pressed", "true");
|
||||
expect(collapsedToggle).toHaveAccessibleName("Expand sidebar");
|
||||
expect(collapsedToggle).toHaveAttribute("title", "Expand sidebar");
|
||||
});
|
||||
|
||||
it("toggles collapsed rail mode, keeps bottom settings reachable, and restores it on remount", () => {
|
||||
const firstRender = renderSidebar();
|
||||
const sidebar = screen.getByTestId("left-sidebar-nav");
|
||||
@@ -264,6 +288,9 @@ describe("LeftSidebarNav", () => {
|
||||
firstRender.unmount();
|
||||
renderSidebar();
|
||||
expect(screen.getByTestId("left-sidebar-nav").className).toContain("left-sidebar-nav--collapsed");
|
||||
expect(screen.getByTestId("sidebar-nav-collapse-toggle")).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByTestId("sidebar-nav-collapse-toggle")).toHaveAccessibleName("Expand sidebar");
|
||||
expect(screen.getByTestId("sidebar-nav-collapse-toggle")).toHaveAttribute("title", "Expand sidebar");
|
||||
expect(screen.getByTestId("sidebar-nav-settings")).toBeDefined();
|
||||
expect(screen.getByTestId("sidebar-nav-board")).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -45,12 +45,26 @@ function expectUniformMobileNavColumns(container: HTMLElement, expectedTabCount:
|
||||
expect(tabs).toHaveLength(expectedTabCount);
|
||||
|
||||
const tabRule = extractRuleBlock(mobileNavCss, ".mobile-nav-tab");
|
||||
expect(tabRule).toContain("--mobile-nav-icon-size: calc(var(--space-lg) + var(--space-sm) - (var(--space-xs) / 2))");
|
||||
expect(tabRule).toContain("flex: 1 1 0");
|
||||
expect(tabRule).toContain("min-width: 0");
|
||||
expect(tabRule).toContain("align-items: center");
|
||||
expect(tabRule).toMatch(/padding:\s*[^;]+\s+0;/);
|
||||
expect(tabRule).not.toMatch(/margin-left|margin-right/);
|
||||
|
||||
const iconRule = extractRuleBlock(mobileNavCss, ".mobile-nav-tab svg");
|
||||
expect(iconRule).toContain("width: var(--mobile-nav-icon-size)");
|
||||
expect(iconRule).toContain("height: var(--mobile-nav-icon-size)");
|
||||
|
||||
const iconWrapperRule = extractRuleBlock(mobileNavCss, ".mobile-nav-tab-icon-wrapper");
|
||||
expect(iconWrapperRule).toContain("position: relative");
|
||||
expect(iconWrapperRule).toContain("display: flex");
|
||||
expect(iconWrapperRule).toContain("flex: 0 0 var(--mobile-nav-icon-size)");
|
||||
expect(iconWrapperRule).toContain("align-items: center");
|
||||
expect(iconWrapperRule).toContain("justify-content: center");
|
||||
expect(iconWrapperRule).toContain("width: var(--mobile-nav-icon-size)");
|
||||
expect(iconWrapperRule).toContain("height: var(--mobile-nav-icon-size)");
|
||||
|
||||
const labelRule = extractRuleBlock(mobileNavCss, ".mobile-nav-tab-label");
|
||||
expect(labelRule).toContain("width: 100%");
|
||||
expect(labelRule).toContain("min-width: 0");
|
||||
@@ -59,6 +73,10 @@ function expectUniformMobileNavColumns(container: HTMLElement, expectedTabCount:
|
||||
for (const tab of tabs) {
|
||||
expect(tab.className).toContain("mobile-nav-tab");
|
||||
expect(tab.querySelector(".mobile-nav-tab-label")).toBeInTheDocument();
|
||||
const iconSlots = tab.querySelectorAll(":scope > .mobile-nav-tab-icon-wrapper");
|
||||
expect(iconSlots).toHaveLength(1);
|
||||
expect(tab.querySelector(":scope > svg")).toBeNull();
|
||||
expect(iconSlots[0].querySelector("svg")).toBeInTheDocument();
|
||||
}
|
||||
|
||||
if (container.querySelector(".mobile-nav-tab-badge")) {
|
||||
@@ -66,7 +84,11 @@ function expectUniformMobileNavColumns(container: HTMLElement, expectedTabCount:
|
||||
}
|
||||
|
||||
if (container.querySelector(".mobile-nav-chat-unread-dot")) {
|
||||
expect(extractRuleBlock(mobileNavCss, ".mobile-nav-chat-unread-dot")).toContain("position: absolute");
|
||||
const dotRule = extractRuleBlock(mobileNavCss, ".mobile-nav-chat-unread-dot");
|
||||
expect(dotRule).toContain("position: absolute");
|
||||
expect(dotRule).toContain("top: 0");
|
||||
expect(dotRule).toContain("right: 0");
|
||||
expect(dotRule).not.toContain("*-1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2332,6 +2332,40 @@ describe("QuickEntryBox", () => {
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "Plan", buttonId: "plan-button", callbackProp: "onPlanningMode" as const },
|
||||
{ label: "Subtask", buttonId: "subtask-button", callbackProp: "onSubtaskBreakdown" as const },
|
||||
])("passes selected workflow id through %s quick-entry handoff", async ({ buttonId, callbackProp }) => {
|
||||
const onPlanningMode = vi.fn();
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderQuickEntryBox({ onPlanningMode, onSubtaskBreakdown, workflowId: "WF-123" });
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Create in custom workflow" } });
|
||||
fireEvent.click(screen.getByTestId(buttonId));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(callbackProp === "onPlanningMode" ? onPlanningMode : onSubtaskBreakdown)
|
||||
.toHaveBeenCalledWith("Create in custom workflow", "WF-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("omits workflow id in legacy quick-entry handoff", async () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
renderQuickEntryBox({ onPlanningMode });
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Create with default workflow" } });
|
||||
fireEvent.click(screen.getByTestId("plan-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPlanningMode).toHaveBeenCalledWith("Create with default workflow");
|
||||
});
|
||||
expect(onPlanningMode.mock.calls[0]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("disables Plan and Subtask buttons when description is empty", () => {
|
||||
renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
|
||||
@@ -1802,6 +1802,40 @@ describe("TaskChatTab", () => {
|
||||
expectComposerSendableAfterDraft();
|
||||
});
|
||||
|
||||
it.each([undefined, "planning", "merging", "merging-fix"])(
|
||||
"treats an actively-executing in-progress task as an active session even without an assignment (ephemeral mode): %s status",
|
||||
(status) => {
|
||||
// In the default ephemeral-agents mode the scheduler never writes
|
||||
// assignedAgentId/checkedOutBy, so a running in-progress task has no
|
||||
// assignment field yet IS being worked. It must NOT show the idle
|
||||
// "no agent is working" hint.
|
||||
render(
|
||||
<TaskChatTab
|
||||
task={makeTask({ column: "in-progress", status, assignedAgentId: undefined, checkedOutBy: undefined })}
|
||||
active
|
||||
addToast={vi.fn()}
|
||||
sessionLive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expectActiveSessionCopy();
|
||||
expect(screen.getByLabelText("Message active agent session")).not.toBeDisabled();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a queued (waiting) unassigned in-progress task idle in ephemeral mode", () => {
|
||||
render(
|
||||
<TaskChatTab
|
||||
task={makeTask({ column: "in-progress", status: "queued", assignedAgentId: undefined, checkedOutBy: undefined })}
|
||||
active
|
||||
addToast={vi.fn()}
|
||||
sessionLive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expectIdleSessionHint();
|
||||
});
|
||||
|
||||
it.each(["busy", "ready", "starting", "waitingOnInput"] as const)("treats %s CLI sessions as live", (agentState) => {
|
||||
expect(isCliSessionLive(makeCliSession(agentState))).toBe(true);
|
||||
});
|
||||
@@ -1942,7 +1976,9 @@ describe("TaskChatTab", () => {
|
||||
["in-progress task without an assigned or checked-out agent", makeTask({ column: "in-progress", status: "queued", assignedAgentId: undefined, checkedOutBy: undefined })],
|
||||
["paused in-progress task", makeTask({ column: "in-progress", status: "queued", paused: true })],
|
||||
["user-paused in-progress task", makeTask({ column: "in-progress", status: "queued", userPaused: true })],
|
||||
["in-review task without an assigned or checked-out agent", makeTask({ column: "in-review", status: "reviewing", assignedAgentId: undefined, checkedOutBy: undefined })],
|
||||
// Paused early-return must win over the ephemeral executionImpliesActiveAgent path:
|
||||
// a paused/unassigned in-progress task in an otherwise-active status stays idle.
|
||||
["paused unassigned in-progress task in an active status", makeTask({ column: "in-progress", status: "planning", paused: true, assignedAgentId: undefined, checkedOutBy: undefined })],
|
||||
["paused in-review task", makeTask({ column: "in-review", status: "reviewing", paused: true })],
|
||||
["user-paused in-review task", makeTask({ column: "in-review", status: "reviewing", userPaused: true })],
|
||||
])("keeps the composer sendable with idle guidance for %s", (_label, task) => {
|
||||
@@ -1952,6 +1988,39 @@ describe("TaskChatTab", () => {
|
||||
expectComposerSendableAfterDraft();
|
||||
});
|
||||
|
||||
it.each(["reviewing", "merging", "merging-fix", "fixing"])(
|
||||
"treats an actively-reviewing in-review task as an active session even without an assignment (ephemeral mode): %s status",
|
||||
(status) => {
|
||||
// A reviewer/merger runs ephemerally with no assignedAgentId/checkedOutBy,
|
||||
// so an in-review task in an active review/merge status must NOT show the
|
||||
// idle "no agent is working" hint.
|
||||
render(
|
||||
<TaskChatTab
|
||||
task={makeTask({ column: "in-review", status, assignedAgentId: undefined, checkedOutBy: undefined })}
|
||||
active
|
||||
addToast={vi.fn()}
|
||||
sessionLive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expectActiveSessionCopy();
|
||||
expect(screen.getByLabelText("Message active agent session")).not.toBeDisabled();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a null-status in-review task (awaiting human review) idle without an assignment", () => {
|
||||
render(
|
||||
<TaskChatTab
|
||||
task={makeTask({ column: "in-review", status: undefined, assignedAgentId: undefined, checkedOutBy: undefined })}
|
||||
active
|
||||
addToast={vi.fn()}
|
||||
sessionLive={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expectIdleSessionHint();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["paused in-progress task with a live session", makeTask({ column: "in-progress", status: "queued", paused: true })],
|
||||
["user-paused in-progress task with a live session", makeTask({ column: "in-progress", status: "queued", userPaused: true })],
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { Board } from "../Board";
|
||||
import { ListView } from "../ListView";
|
||||
import { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache";
|
||||
import type { BoardWorkflowsPayload } from "../../api";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
fetchBoardWorkflows: vi.fn(),
|
||||
fetchWorkflowSteps: vi.fn(),
|
||||
fetchNodes: vi.fn(),
|
||||
fetchTaskDetail: vi.fn(),
|
||||
batchUpdateTaskModels: vi.fn(),
|
||||
promoteTask: vi.fn(),
|
||||
fetchModels: vi.fn(),
|
||||
fetchSettings: vi.fn(),
|
||||
fetchGlobalSettings: vi.fn(),
|
||||
api: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchBoardWorkflows: apiMocks.fetchBoardWorkflows,
|
||||
fetchWorkflowSteps: apiMocks.fetchWorkflowSteps,
|
||||
fetchNodes: apiMocks.fetchNodes,
|
||||
fetchTaskDetail: apiMocks.fetchTaskDetail,
|
||||
batchUpdateTaskModels: apiMocks.batchUpdateTaskModels,
|
||||
promoteTask: apiMocks.promoteTask,
|
||||
fetchModels: apiMocks.fetchModels,
|
||||
fetchSettings: apiMocks.fetchSettings,
|
||||
fetchGlobalSettings: apiMocks.fetchGlobalSettings,
|
||||
api: apiMocks.api,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useBlockerFanout", () => ({
|
||||
useBlockerFanout: () => new Map(),
|
||||
}));
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: vi.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../Column", () => ({
|
||||
Column: React.memo(({ column, workflowMode }: { column: string; workflowMode?: boolean }) => (
|
||||
<div className="column" data-testid={`column-${column}`} data-workflow-mode={workflowMode ? "true" : "false"} />
|
||||
)),
|
||||
}));
|
||||
|
||||
const workflowPayload: BoardWorkflowsPayload = {
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "workflow-a",
|
||||
workflows: [
|
||||
{
|
||||
id: "workflow-a",
|
||||
name: "Workflow A",
|
||||
columns: [
|
||||
{ id: "todo", name: "Todo", flags: { intake: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
{ id: "archived", name: "Archived", flags: { archived: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "workflow-b",
|
||||
name: "Workflow B",
|
||||
columns: [
|
||||
{ id: "doing", name: "Doing", flags: { countsTowardWip: true } },
|
||||
{ id: "shipped", name: "Shipped", flags: { complete: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: {},
|
||||
};
|
||||
|
||||
const emptyWorkflowPayload: BoardWorkflowsPayload = {
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "workflow-a",
|
||||
workflows: [],
|
||||
taskWorkflowIds: {},
|
||||
};
|
||||
|
||||
const flagOffPayload: BoardWorkflowsPayload = {
|
||||
flagEnabled: false,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [],
|
||||
taskWorkflowIds: {},
|
||||
};
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function mockViewport(width: number) {
|
||||
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn((query: string) => ({
|
||||
matches: query.includes("768px") ? width <= 768 : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const tasks: Task[] = [];
|
||||
|
||||
const boardProps = {
|
||||
tasks,
|
||||
maxConcurrent: 2,
|
||||
onMoveTask: vi.fn(async () => ({} as Task)),
|
||||
onOpenDetail: vi.fn(),
|
||||
addToast: vi.fn(),
|
||||
onNewTask: vi.fn(),
|
||||
autoMerge: true,
|
||||
onToggleAutoMerge: vi.fn(),
|
||||
};
|
||||
|
||||
const listProps = {
|
||||
tasks,
|
||||
onMoveTask: vi.fn(async () => ({} as Task)),
|
||||
onDeleteTask: vi.fn(async () => ({} as Task)),
|
||||
onMergeTask: vi.fn(async () => ({} as never)),
|
||||
onOpenDetail: vi.fn(),
|
||||
addToast: vi.fn(),
|
||||
onCreateWorkflow: vi.fn(),
|
||||
};
|
||||
|
||||
type Surface = "Board" | "ListView";
|
||||
type Breakpoint = "desktop" | "mobile";
|
||||
|
||||
function renderSurface(surface: Surface, projectId = "project-a") {
|
||||
if (surface === "Board") {
|
||||
return render(<Board {...boardProps} projectId={projectId} workflowColumnsEnabled settingsLoaded />);
|
||||
}
|
||||
return render(<ListView {...listProps} projectId={projectId} workflowColumnsEnabled settingsLoaded />);
|
||||
}
|
||||
|
||||
function expectWorkflowLayout(surface: Surface) {
|
||||
if (surface === "Board") {
|
||||
expect(document.querySelector(".board-workflow-columns")).not.toBeNull();
|
||||
expect(document.querySelector(".board-workflows-skeleton")).toBeNull();
|
||||
expect(document.querySelectorAll('.column[data-workflow-mode="true"]').length).toBeGreaterThan(0);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(screen.getByTestId("workflow-switcher")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("list-workflows-skeleton")).toBeNull();
|
||||
}
|
||||
|
||||
function expectLegacyLayout(surface: Surface) {
|
||||
if (surface === "Board") {
|
||||
expect(document.querySelector(".board-workflow-columns")).toBeNull();
|
||||
expect(document.querySelector(".board-workflows-skeleton")).toBeNull();
|
||||
expect(document.querySelectorAll('.column[data-workflow-mode="false"]').length).toBeGreaterThan(0);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(screen.queryByTestId("list-workflows-skeleton")).toBeNull();
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
expect(screen.getByTestId("list-split-layout")).toBeInTheDocument();
|
||||
}
|
||||
|
||||
function expectSkeleton(surface: Surface, empty = false) {
|
||||
if (surface === "Board") {
|
||||
expect(screen.getByTestId(empty ? "board-workflows-empty" : "board-workflows-skeleton")).toBeInTheDocument();
|
||||
expect(document.querySelector(".board-workflow-columns")).toBeNull();
|
||||
expect(document.querySelectorAll(".column")).toHaveLength(0);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(screen.getByTestId(empty ? "list-workflows-empty" : "list-workflows-skeleton")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
}
|
||||
|
||||
describe("no legacy-board flash before workflow lanes load (FN-6776)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMocks.fetchWorkflowSteps.mockResolvedValue([]);
|
||||
apiMocks.fetchNodes.mockResolvedValue([]);
|
||||
apiMocks.fetchTaskDetail.mockResolvedValue(null);
|
||||
apiMocks.promoteTask.mockResolvedValue({});
|
||||
apiMocks.fetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
apiMocks.fetchSettings.mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} });
|
||||
apiMocks.fetchGlobalSettings.mockResolvedValue({});
|
||||
apiMocks.api.mockResolvedValue({ sessions: [] });
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.sessionStorage.clear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each<Surface>(["Board", "ListView"])("%s renders legacy immediately when workflowColumns is known off", (surface) => {
|
||||
mockViewport(1024);
|
||||
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
if (surface === "Board") {
|
||||
render(<Board {...boardProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded />);
|
||||
} else {
|
||||
render(<ListView {...listProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded />);
|
||||
}
|
||||
|
||||
expectLegacyLayout(surface);
|
||||
});
|
||||
|
||||
it.each<[Surface, Breakpoint]>([
|
||||
["Board", "desktop"],
|
||||
["Board", "mobile"],
|
||||
["ListView", "desktop"],
|
||||
["ListView", "mobile"],
|
||||
])("%s at %s renders skeleton, not legacy, while uncached workflow payload is pending", async (surface, breakpoint) => {
|
||||
mockViewport(breakpoint === "mobile" ? 390 : 1200);
|
||||
const deferred = createDeferred<BoardWorkflowsPayload>();
|
||||
apiMocks.fetchBoardWorkflows.mockReturnValue(deferred.promise);
|
||||
|
||||
renderSurface(surface);
|
||||
|
||||
expectSkeleton(surface);
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(workflowPayload);
|
||||
await deferred.promise;
|
||||
});
|
||||
|
||||
await waitFor(() => expectWorkflowLayout(surface));
|
||||
});
|
||||
|
||||
it.each<[Surface, Breakpoint]>([
|
||||
["Board", "desktop"],
|
||||
["Board", "mobile"],
|
||||
["ListView", "desktop"],
|
||||
["ListView", "mobile"],
|
||||
])("%s at %s renders cached workflow lanes on first paint", (surface, breakpoint) => {
|
||||
mockViewport(breakpoint === "mobile" ? 390 : 1200);
|
||||
writeBoardWorkflowsCache("project-a", workflowPayload);
|
||||
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
renderSurface(surface);
|
||||
|
||||
expectWorkflowLayout(surface);
|
||||
});
|
||||
|
||||
it.each<Surface>(["Board", "ListView"])("%s keeps legacy hidden when the enabled payload has no workflows", async (surface) => {
|
||||
mockViewport(1024);
|
||||
apiMocks.fetchBoardWorkflows.mockResolvedValue(emptyWorkflowPayload);
|
||||
|
||||
renderSurface(surface);
|
||||
|
||||
await waitFor(() => expectSkeleton(surface, true));
|
||||
expectLegacyLayoutHiddenForEmpty(surface);
|
||||
});
|
||||
|
||||
it.each<Surface>(["Board", "ListView"])("%s renders skeleton while settings are not loaded", (surface) => {
|
||||
mockViewport(1024);
|
||||
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
if (surface === "Board") {
|
||||
render(<Board {...boardProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded={false} />);
|
||||
} else {
|
||||
render(<ListView {...listProps} projectId="project-a" workflowColumnsEnabled={false} settingsLoaded={false} />);
|
||||
}
|
||||
|
||||
expectSkeleton(surface);
|
||||
});
|
||||
|
||||
it.each<Surface>(["Board", "ListView"])("%s fetch error exits the skeleton to a terminal legacy layout", async (surface) => {
|
||||
mockViewport(1024);
|
||||
apiMocks.fetchBoardWorkflows.mockRejectedValue(new Error("network"));
|
||||
|
||||
renderSurface(surface);
|
||||
expectSkeleton(surface);
|
||||
|
||||
await waitFor(() => expectLegacyLayout(surface));
|
||||
});
|
||||
|
||||
it.each<Surface>(["Board", "ListView"])("%s does not leak cached workflow layouts across project switches", async (surface) => {
|
||||
mockViewport(1024);
|
||||
writeBoardWorkflowsCache("project-a", workflowPayload);
|
||||
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const view = renderSurface(surface, "project-a");
|
||||
expectWorkflowLayout(surface);
|
||||
|
||||
if (surface === "Board") {
|
||||
view.rerender(<Board {...boardProps} projectId="project-b" workflowColumnsEnabled settingsLoaded />);
|
||||
} else {
|
||||
view.rerender(<ListView {...listProps} projectId="project-b" workflowColumnsEnabled settingsLoaded />);
|
||||
}
|
||||
|
||||
expectSkeleton(surface);
|
||||
});
|
||||
|
||||
it.each<Surface>(["Board", "ListView"])("%s ignores another project's cache when flag-off payload is cached locally", (surface) => {
|
||||
mockViewport(1024);
|
||||
writeBoardWorkflowsCache("project-b", workflowPayload);
|
||||
writeBoardWorkflowsCache("project-a", flagOffPayload);
|
||||
apiMocks.fetchBoardWorkflows.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
renderSurface(surface, "project-a");
|
||||
|
||||
expectLegacyLayout(surface);
|
||||
});
|
||||
});
|
||||
|
||||
function expectLegacyLayoutHiddenForEmpty(surface: Surface) {
|
||||
if (surface === "Board") {
|
||||
expect(document.querySelector(".board-workflow-columns")).toBeNull();
|
||||
expect(document.querySelectorAll(".column")).toHaveLength(0);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(screen.queryByTestId("workflow-switcher")).toBeNull();
|
||||
}
|
||||
@@ -69,4 +69,13 @@ export const COLOR_THEMES: { value: ColorTheme; label: string; className: string
|
||||
{ value: "neon-bloom", label: "Neon Bloom", className: "theme-swatch-neon-bloom" },
|
||||
{ value: "sepia", label: "Sepia", className: "theme-swatch-sepia" },
|
||||
{ value: "shadcn", label: "Shadcn", className: "theme-swatch-shadcn" },
|
||||
{ value: "shadcn-blue", label: "Shadcn Blue", className: "theme-swatch-shadcn-blue" },
|
||||
{ value: "shadcn-green", label: "Shadcn Green", className: "theme-swatch-shadcn-green" },
|
||||
{ value: "shadcn-red", label: "Shadcn Red", className: "theme-swatch-shadcn-red" },
|
||||
{ value: "shadcn-purple", label: "Shadcn Purple", className: "theme-swatch-shadcn-purple" },
|
||||
{ value: "shadcn-pink", label: "Shadcn Pink", className: "theme-swatch-shadcn-pink" },
|
||||
{ value: "shadcn-orange", label: "Shadcn Orange", className: "theme-swatch-shadcn-orange" },
|
||||
{ value: "shadcn-yellow", label: "Shadcn Yellow", className: "theme-swatch-shadcn-yellow" },
|
||||
{ value: "shadcn-mono", label: "Shadcn Mono", className: "theme-swatch-shadcn-mono" },
|
||||
{ value: "shadcn-black", label: "Shadcn Black", className: "theme-swatch-shadcn-black" },
|
||||
];
|
||||
|
||||
@@ -551,6 +551,47 @@ describe("useTheme", () => {
|
||||
document.head.removeChild(style);
|
||||
});
|
||||
|
||||
it("applies representative shadcn color-family design tokens with neutralized glow effects", () => {
|
||||
const style = document.createElement("style");
|
||||
const baseCss = readFileSync(resolve(PACKAGE_ROOT, "app/styles.css"), "utf8");
|
||||
const themeDataCss = readFileSync(resolve(PACKAGE_ROOT, "app/public/theme-data.css"), "utf8");
|
||||
const shadcnVariants = [
|
||||
{ id: "shadcn-blue", accent: "#3b82f6" },
|
||||
{ id: "shadcn-mono", accent: "#ef4444" },
|
||||
{ id: "shadcn-black", accent: "#fafafa" },
|
||||
] as const;
|
||||
style.textContent = `${baseCss}\n${themeDataCss}`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
for (const variant of shadcnVariants) {
|
||||
const block = themeDataCss.match(
|
||||
new RegExp(`\\[data-color-theme="${variant.id}"\\] \\{(?<body>[\\s\\S]*?)\\n\\}`),
|
||||
)?.groups?.body;
|
||||
expect(block).toBeDefined();
|
||||
|
||||
localStorageMock[COLOR_THEME_STORAGE_KEY] = variant.id;
|
||||
renderHook(() => useTheme());
|
||||
|
||||
expect(document.documentElement.getAttribute("data-color-theme")).toBe(variant.id);
|
||||
expect(block).toContain("--btn-border-width: 1px;");
|
||||
expect(block).toContain(`--accent: ${variant.accent};`);
|
||||
expect(block).toContain("--shadow-glow: none;");
|
||||
expect(block).toContain("--cta-glow: none;");
|
||||
expect(block).not.toMatch(/--(?:shadow-glow|glow-success|glow-warning|glow-danger|cta-glow):\s*0 0/);
|
||||
}
|
||||
|
||||
const blueBlock = themeDataCss.match(/\[data-color-theme="shadcn-blue"\] \{(?<body>[\s\S]*?)\n\}/)?.groups
|
||||
?.body;
|
||||
const blackBlock = themeDataCss.match(/\[data-color-theme="shadcn-black"\] \{(?<body>[\s\S]*?)\n\}/)
|
||||
?.groups?.body;
|
||||
expect(blueBlock).toContain("--todo: #60a5fa;");
|
||||
expect(blackBlock).toContain("--todo: #d4d4d8;");
|
||||
expect(blackBlock).toContain("--in-progress: #a1a1aa;");
|
||||
expect(blackBlock).not.toContain("--todo: #60a5fa;");
|
||||
|
||||
document.head.removeChild(style);
|
||||
});
|
||||
|
||||
it("supports all valid theme modes", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
|
||||
@@ -32,9 +32,11 @@ export interface ModalManager {
|
||||
isPlanningOpen: boolean;
|
||||
planningInitialPlan: string | null;
|
||||
planningResumeSessionId: string | undefined;
|
||||
planningWorkflowId: string | null | undefined;
|
||||
isSubtaskOpen: boolean;
|
||||
subtaskInitialDescription: string | null;
|
||||
subtaskResumeSessionId: string | undefined;
|
||||
subtaskWorkflowId: string | null | undefined;
|
||||
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
|
||||
detailTask: (Task | TaskDetail) | null;
|
||||
detailTaskInitialTab: DetailTaskTab;
|
||||
@@ -74,12 +76,12 @@ export interface ModalManager {
|
||||
closeNewTask: () => void;
|
||||
|
||||
openPlanning: () => void;
|
||||
openPlanningWithInitialPlan: (initialPlan: string) => void;
|
||||
openPlanningWithInitialPlan: (initialPlan: string, workflowId?: string | null) => void;
|
||||
resumePlanning: () => void;
|
||||
openPlanningWithSession: (sessionId: string) => void;
|
||||
closePlanning: () => void;
|
||||
|
||||
openSubtaskBreakdown: (description: string) => void;
|
||||
openSubtaskBreakdown: (description: string, workflowId?: string | null) => void;
|
||||
openSubtaskWithSession: (sessionId: string) => void;
|
||||
closeSubtask: () => void;
|
||||
|
||||
@@ -158,9 +160,11 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
|
||||
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
|
||||
const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [planningWorkflowId, setPlanningWorkflowId] = useState<string | null | undefined>(undefined);
|
||||
const [isSubtaskOpen, setIsSubtaskOpen] = useState(false);
|
||||
const [subtaskInitialDescription, setSubtaskInitialDescription] = useState<string | null>(null);
|
||||
const [subtaskResumeSessionId, setSubtaskResumeSessionId] = useState<string | undefined>(undefined);
|
||||
const [subtaskWorkflowId, setSubtaskWorkflowId] = useState<string | null | undefined>(undefined);
|
||||
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
|
||||
const [detailTask, setDetailTask] = useState<(Task | TaskDetail) | null>(null);
|
||||
/**
|
||||
@@ -229,18 +233,33 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
setNewTaskInitialDescription(null);
|
||||
}, []);
|
||||
|
||||
const openPlanning = useCallback(() => setIsPlanningOpen(true), []);
|
||||
const openPlanningWithInitialPlan = useCallback((initialPlan: string) => {
|
||||
const openPlanning = useCallback(() => {
|
||||
// FNXC:PlanningModals 2026-06-20-20:10:
|
||||
// A fresh planning open must clear any resume-session id / initial plan left
|
||||
// by a prior resumePlanning/openPlanningWith* flow; otherwise the modal reopens
|
||||
// into the stale session or pre-fills an old plan instead of starting blank.
|
||||
setPlanningResumeSessionId(undefined);
|
||||
setPlanningInitialPlan(null);
|
||||
setPlanningWorkflowId(undefined);
|
||||
setIsPlanningOpen(true);
|
||||
}, []);
|
||||
const openPlanningWithInitialPlan = useCallback((initialPlan: string, workflowId?: string | null) => {
|
||||
// FNXC:PlanningModals 2026-06-20-20:10: clear a stale resume-session id so the
|
||||
// supplied initial plan is honored rather than being overridden by an old session.
|
||||
setPlanningResumeSessionId(undefined);
|
||||
setPlanningInitialPlan(initialPlan);
|
||||
setPlanningWorkflowId(workflowId);
|
||||
setIsPlanningOpen(true);
|
||||
}, []);
|
||||
const resumePlanning = useCallback(() => {
|
||||
const session = planningSessions[0];
|
||||
if (!session) return;
|
||||
setPlanningWorkflowId(undefined);
|
||||
setPlanningResumeSessionId(session.id);
|
||||
setIsPlanningOpen(true);
|
||||
}, [planningSessions]);
|
||||
const openPlanningWithSession = useCallback((sessionId: string) => {
|
||||
setPlanningWorkflowId(undefined);
|
||||
setPlanningResumeSessionId(sessionId);
|
||||
setIsPlanningOpen(true);
|
||||
}, []);
|
||||
@@ -248,13 +267,19 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
setPlanningResumeSessionId(undefined);
|
||||
setPlanningWorkflowId(undefined);
|
||||
}, []);
|
||||
|
||||
const openSubtaskBreakdown = useCallback((description: string) => {
|
||||
const openSubtaskBreakdown = useCallback((description: string, workflowId?: string | null) => {
|
||||
// FNXC:PlanningModals 2026-06-20-20:10: clear a stale subtask resume-session id
|
||||
// so a new breakdown starts fresh rather than reopening a prior session.
|
||||
setSubtaskResumeSessionId(undefined);
|
||||
setSubtaskInitialDescription(description);
|
||||
setSubtaskWorkflowId(workflowId);
|
||||
setIsSubtaskOpen(true);
|
||||
}, []);
|
||||
const openSubtaskWithSession = useCallback((sessionId: string) => {
|
||||
setSubtaskWorkflowId(undefined);
|
||||
setSubtaskResumeSessionId(sessionId);
|
||||
setIsSubtaskOpen(true);
|
||||
}, []);
|
||||
@@ -262,6 +287,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
setIsSubtaskOpen(false);
|
||||
setSubtaskInitialDescription(null);
|
||||
setSubtaskResumeSessionId(undefined);
|
||||
setSubtaskWorkflowId(undefined);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -423,9 +449,11 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
isPlanningOpen,
|
||||
planningInitialPlan,
|
||||
planningResumeSessionId,
|
||||
planningWorkflowId,
|
||||
isSubtaskOpen,
|
||||
subtaskInitialDescription,
|
||||
subtaskResumeSessionId,
|
||||
subtaskWorkflowId,
|
||||
detailTask,
|
||||
detailTaskInitialTab,
|
||||
detailTaskOrigin,
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
try {
|
||||
var mode = localStorage.getItem('kb-dashboard-theme-mode') || 'dark';
|
||||
var colorTheme = localStorage.getItem('kb-dashboard-color-theme') || 'default';
|
||||
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', 'ayu', 'one-dark', 'nord', 'dracula', 'gruvbox', 'tokyo-night', 'catppuccin-mocha', 'github-dark', 'everforest', 'rose-pine', 'kanagawa', 'night-owl', 'palenight', 'monokai-pro', 'slime', 'brutalist', 'neon-city', 'parchment', 'terminal', 'glass', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'shadcn'];
|
||||
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', 'ayu', 'one-dark', 'nord', 'dracula', 'gruvbox', 'tokyo-night', 'catppuccin-mocha', 'github-dark', 'everforest', 'rose-pine', 'kanagawa', 'night-owl', 'palenight', 'monokai-pro', 'slime', 'brutalist', 'neon-city', 'parchment', 'terminal', 'glass', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'shadcn', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono', 'shadcn-black'];
|
||||
if (!validThemes.includes(colorTheme)) {
|
||||
colorTheme = 'default';
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BoardWorkflowsPayload } from "../../api";
|
||||
import { readBoardWorkflowsCache, writeBoardWorkflowsCache } from "../boardWorkflowsCache";
|
||||
|
||||
const payload: BoardWorkflowsPayload = {
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [
|
||||
{
|
||||
id: "builtin:coding",
|
||||
name: "Coding",
|
||||
columns: [{ id: "todo", name: "Todo", flags: {} }],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-1": "builtin:coding" },
|
||||
};
|
||||
|
||||
describe("boardWorkflowsCache", () => {
|
||||
afterEach(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.sessionStorage.clear();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("round-trips board-workflows payloads per project", () => {
|
||||
writeBoardWorkflowsCache("project-a", payload);
|
||||
|
||||
expect(readBoardWorkflowsCache("project-a")).toEqual(payload);
|
||||
});
|
||||
|
||||
it("keeps project cache keys isolated", () => {
|
||||
writeBoardWorkflowsCache("project-a", payload);
|
||||
|
||||
expect(readBoardWorkflowsCache("project-b")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for missing, corrupt, or invalid entries", () => {
|
||||
expect(readBoardWorkflowsCache("missing")).toBeNull();
|
||||
|
||||
window.sessionStorage.setItem("fusion:board-workflows:corrupt", "{");
|
||||
expect(readBoardWorkflowsCache("corrupt")).toBeNull();
|
||||
|
||||
window.sessionStorage.setItem("fusion:board-workflows:invalid", JSON.stringify({ flagEnabled: true, workflows: {} }));
|
||||
expect(readBoardWorkflowsCache("invalid")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows sessionStorage write failures", () => {
|
||||
vi.spyOn(window.sessionStorage.__proto__, "setItem").mockImplementation(() => {
|
||||
throw new Error("quota");
|
||||
});
|
||||
|
||||
expect(() => writeBoardWorkflowsCache("project-a", payload)).not.toThrow();
|
||||
});
|
||||
|
||||
it("swallows sessionStorage read failures", () => {
|
||||
vi.spyOn(window.sessionStorage.__proto__, "getItem").mockImplementation(() => {
|
||||
throw new Error("private mode");
|
||||
});
|
||||
|
||||
expect(readBoardWorkflowsCache("project-a")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null without window for SSR callers", () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
|
||||
expect(readBoardWorkflowsCache("project-a")).toBeNull();
|
||||
expect(() => writeBoardWorkflowsCache("project-a", payload)).not.toThrow();
|
||||
});
|
||||
});
|
||||
63
packages/dashboard/app/utils/boardWorkflowsCache.ts
Normal file
63
packages/dashboard/app/utils/boardWorkflowsCache.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api";
|
||||
|
||||
const BOARD_WORKFLOWS_CACHE_PREFIX = "fusion:board-workflows:";
|
||||
const DEFAULT_PROJECT_CACHE_KEY = "default";
|
||||
|
||||
function cacheKey(projectId?: string): string {
|
||||
return `${BOARD_WORKFLOWS_CACHE_PREFIX}${projectId ?? DEFAULT_PROJECT_CACHE_KEY}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isBoardWorkflowsPayload(value: unknown): value is BoardWorkflowsPayload {
|
||||
if (!isRecord(value)) return false;
|
||||
if (typeof value.flagEnabled !== "boolean") return false;
|
||||
if (!Array.isArray(value.workflows)) return false;
|
||||
if (typeof value.defaultWorkflowId !== "string") return false;
|
||||
if (!isRecord(value.taskWorkflowIds)) return false;
|
||||
// FNXC:BoardWorkflows 2026-06-20-20:10:
|
||||
// Validate each cached workflow's shape and the taskWorkflowIds value types,
|
||||
// not just the container types. A malformed entry (e.g. `[{}]` from a stale or
|
||||
// partially-written cache) would otherwise pass and later throw in Board/ListView
|
||||
// when accessing workflow.name/columns — re-introducing the legacy flash this
|
||||
// cache exists to prevent. Mirrors the BoardWorkflowDefinition contract (id, name,
|
||||
// columns) and the Record<string,string> taskWorkflowIds map.
|
||||
if (!value.workflows.every((workflow): workflow is BoardWorkflowDefinition => (
|
||||
isRecord(workflow)
|
||||
&& typeof workflow.id === "string"
|
||||
&& typeof workflow.name === "string"
|
||||
&& Array.isArray(workflow.columns)
|
||||
))) return false;
|
||||
if (!Object.values(value.taskWorkflowIds).every((workflowId) => typeof workflowId === "string")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:BoardWorkflows 2026-06-20-08:50:
|
||||
* Cache the last successful board-workflows payload per project in sessionStorage so Board and ListView can render the correct workflow-lane layout immediately on remount and never flash the legacy single-lane board before the async revalidation finishes.
|
||||
*/
|
||||
export function readBoardWorkflowsCache(projectId?: string): BoardWorkflowsPayload | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(cacheKey(projectId));
|
||||
if (!raw) return null;
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return isBoardWorkflowsPayload(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeBoardWorkflowsCache(projectId: string | undefined, payload: BoardWorkflowsPayload): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(cacheKey(projectId), JSON.stringify(payload));
|
||||
} catch {
|
||||
// Private-mode/quota failures should never prevent board rendering.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import type { WorkflowIr } from "@fusion/core";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
type PlanningSession = {
|
||||
summary: {
|
||||
title: string;
|
||||
description: string;
|
||||
suggestedSize: "S" | "M" | "L";
|
||||
priority: "normal";
|
||||
suggestedDependencies: string[];
|
||||
keyDeliverables: string[];
|
||||
};
|
||||
initialPlan: string;
|
||||
history: Array<{ role: string; content: string }>;
|
||||
};
|
||||
|
||||
type SubtaskSession = {
|
||||
initialDescription: string;
|
||||
autoMerge?: boolean;
|
||||
};
|
||||
|
||||
const planningSessions = new Map<string, PlanningSession>();
|
||||
const subtaskSessions = new Map<string, SubtaskSession>();
|
||||
|
||||
vi.mock("../../planning.js", () => ({
|
||||
getSession: (id: string) => planningSessions.get(id),
|
||||
getSummary: (id: string) => planningSessions.get(id)?.summary,
|
||||
releaseSession: vi.fn(),
|
||||
cleanupSession: vi.fn(),
|
||||
formatInterviewQA: vi.fn(() => ""),
|
||||
mergePlanningSubtaskDrafts: vi.fn((_sessionId: string, subtasks: unknown[]) => subtasks),
|
||||
}));
|
||||
|
||||
vi.mock("../../subtask-breakdown.js", () => ({
|
||||
getSubtaskSession: (id: string) => subtaskSessions.get(id),
|
||||
cleanupSubtaskSession: vi.fn(),
|
||||
}));
|
||||
|
||||
function linearIr(name: string): WorkflowIr {
|
||||
return {
|
||||
version: "v1",
|
||||
name,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "lint", kind: "gate", config: { name: "Lint", scriptName: "lint" } },
|
||||
{ id: "spec", kind: "prompt", config: { name: "Spec", prompt: "check" } },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "lint", condition: "success" },
|
||||
{ from: "lint", to: "spec", condition: "success" },
|
||||
{ from: "spec", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function seedPlanningSession(id: string, title = "Planned task"): void {
|
||||
planningSessions.set(id, {
|
||||
summary: {
|
||||
title,
|
||||
description: `${title} description`,
|
||||
suggestedSize: "M",
|
||||
priority: "normal",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: [],
|
||||
},
|
||||
initialPlan: title,
|
||||
history: [],
|
||||
});
|
||||
}
|
||||
|
||||
describe("planning and subtask create routes workflowId", () => {
|
||||
let store: TaskStore;
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
planningSessions.clear();
|
||||
subtaskSessions.clear();
|
||||
rootDir = mkdtempSync(join(tmpdir(), "planning-subtask-wf-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "planning-subtask-wf-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (path: string, body: unknown) =>
|
||||
REQUEST(app, "POST", path, JSON.stringify(body), { "content-type": "application/json" });
|
||||
|
||||
it("POST /planning/create-task assigns the supplied workflowId", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "Planning QA", ir: linearIr("planning-qa") });
|
||||
seedPlanningSession("plan-single", "Single planning task");
|
||||
|
||||
const res = await post("/api/planning/create-task", { sessionId: "plan-single", workflowId: wf.id });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.getTaskWorkflowSelection((res.body as { id: string }).id)?.workflowId).toBe(wf.id);
|
||||
});
|
||||
|
||||
it("POST /planning/create-tasks assigns the supplied workflowId to every created task", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "Planning multi QA", ir: linearIr("planning-multi-qa") });
|
||||
seedPlanningSession("plan-multi", "Multi planning task");
|
||||
|
||||
const res = await post("/api/planning/create-tasks", {
|
||||
planningSessionId: "plan-multi",
|
||||
workflowId: wf.id,
|
||||
subtasks: [
|
||||
{ id: "tmp-1", title: "First child", description: "First child description" },
|
||||
{ id: "tmp-2", title: "Second child", description: "Second child description" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const tasks = (res.body as { tasks: Array<{ id: string }> }).tasks;
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks.map((task) => store.getTaskWorkflowSelection(task.id)?.workflowId)).toEqual([wf.id, wf.id]);
|
||||
});
|
||||
|
||||
it("POST /subtasks/create-tasks assigns the supplied workflowId to every created child", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "Subtask QA", ir: linearIr("subtask-qa") });
|
||||
subtaskSessions.set("subtask-session", { initialDescription: "Break this down" });
|
||||
|
||||
const res = await post("/api/subtasks/create-tasks", {
|
||||
sessionId: "subtask-session",
|
||||
workflowId: wf.id,
|
||||
subtasks: [
|
||||
{ tempId: "tmp-1", title: "First split", description: "First split description" },
|
||||
{ tempId: "tmp-2", title: "Second split", description: "Second split description" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const tasks = (res.body as { tasks: Array<{ id: string }> }).tasks;
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks.map((task) => store.getTaskWorkflowSelection(task.id)?.workflowId)).toEqual([wf.id, wf.id]);
|
||||
});
|
||||
|
||||
it("omitting workflowId preserves default-workflow inheritance", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "Default QA", ir: linearIr("default-qa") });
|
||||
await store.setDefaultWorkflowId(wf.id);
|
||||
seedPlanningSession("plan-default", "Default inherited task");
|
||||
|
||||
const res = await post("/api/planning/create-task", { sessionId: "plan-default" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.getTaskWorkflowSelection((res.body as { id: string }).id)?.workflowId).toBe(wf.id);
|
||||
});
|
||||
|
||||
it("unknown workflowId returns a 4xx instead of a 500 for all create routes", async () => {
|
||||
seedPlanningSession("plan-bad-single", "Bad single");
|
||||
seedPlanningSession("plan-bad-multi", "Bad multi");
|
||||
subtaskSessions.set("subtask-bad", { initialDescription: "Bad subtask" });
|
||||
|
||||
const requests = [
|
||||
post("/api/planning/create-task", { sessionId: "plan-bad-single", workflowId: "WF-404" }),
|
||||
post("/api/planning/create-tasks", {
|
||||
planningSessionId: "plan-bad-multi",
|
||||
workflowId: "WF-404",
|
||||
subtasks: [{ id: "tmp-1", title: "Bad child", description: "Bad child description" }],
|
||||
}),
|
||||
post("/api/subtasks/create-tasks", {
|
||||
sessionId: "subtask-bad",
|
||||
workflowId: "WF-404",
|
||||
subtasks: [{ tempId: "tmp-1", title: "Bad split", description: "Bad split description" }],
|
||||
}),
|
||||
];
|
||||
|
||||
for (const res of await Promise.all(requests)) {
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,27 @@ interface PlanningSubtaskRouteDeps {
|
||||
replayBufferedSSE: (res: import("express").Response, bufferedEvents: SessionBufferedEvent[]) => boolean;
|
||||
}
|
||||
|
||||
function rethrowPlanningWorkflowCreateError(
|
||||
err: unknown,
|
||||
fallbackMessage: string,
|
||||
rethrowAsApiError: ApiRoutesContext["rethrowAsApiError"],
|
||||
): never {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err || fallbackMessage);
|
||||
const isWorkflowClientError =
|
||||
/^Workflow '.*' not found$/.test(message)
|
||||
|| /is a fragment and cannot be selected/.test(message);
|
||||
|
||||
if (isWorkflowClientError) {
|
||||
throw new ApiError(400, message);
|
||||
}
|
||||
|
||||
rethrowAsApiError(err, fallbackMessage);
|
||||
}
|
||||
|
||||
export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: PlanningSubtaskRouteDeps): void {
|
||||
const { router, getProjectContext, planningLogger, rethrowAsApiError } = ctx;
|
||||
const { aiSessionStore, checkSessionLock, parseLastEventId, replayBufferedSSE } = deps;
|
||||
@@ -173,7 +194,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
|
||||
router.post("/subtasks/create-tasks", async (req, res) => {
|
||||
try {
|
||||
const { sessionId, subtasks, parentTaskId, branch, baseBranch, branchSelection, branchAssignment } = req.body as {
|
||||
const { sessionId, subtasks, parentTaskId, branch, baseBranch, branchSelection, branchAssignment, workflowId } = req.body as {
|
||||
sessionId?: string;
|
||||
subtasks?: Array<{ tempId: string; title: string; description: string; size?: "S" | "M" | "L"; dependsOn?: string[] }>;
|
||||
parentTaskId?: string;
|
||||
@@ -181,6 +202,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
baseBranch?: unknown;
|
||||
branchSelection?: unknown;
|
||||
branchAssignment?: unknown;
|
||||
workflowId?: unknown;
|
||||
};
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
@@ -191,6 +213,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
throw badRequest("subtasks must be a non-empty array");
|
||||
}
|
||||
|
||||
if (workflowId !== undefined && workflowId !== null && typeof workflowId !== "string") {
|
||||
throw badRequest("workflowId must be a string or null");
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { getSubtaskSession, cleanupSubtaskSession } = await import("../subtask-breakdown.js");
|
||||
const session = getSubtaskSession(sessionId);
|
||||
@@ -271,6 +297,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
branch: taskBranch,
|
||||
baseBranch: resolvedBaseBranch,
|
||||
branchContext: planningBranchContext,
|
||||
/*
|
||||
FNXC:WorkflowSelection 2026-06-20-16:48:
|
||||
Tasks created from a workflow lane via subtask breakdown must stay on that active workflow instead of falling back to the project default board.
|
||||
*/
|
||||
...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}),
|
||||
});
|
||||
|
||||
tempIdToTaskId.set(item.tempId, task.id);
|
||||
@@ -362,10 +393,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
droppedDependencies,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to create tasks from breakdown");
|
||||
rethrowPlanningWorkflowCreateError(err, "Failed to create tasks from breakdown", rethrowAsApiError);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1025,18 +1053,23 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
*/
|
||||
router.post("/planning/create-task", async (req, res) => {
|
||||
try {
|
||||
const { sessionId, summary: summaryInput, branch, baseBranch, branchSelection } = req.body as {
|
||||
const { sessionId, summary: summaryInput, branch, baseBranch, branchSelection, workflowId } = req.body as {
|
||||
sessionId?: unknown;
|
||||
summary?: unknown;
|
||||
branch?: unknown;
|
||||
baseBranch?: unknown;
|
||||
branchSelection?: unknown;
|
||||
workflowId?: unknown;
|
||||
};
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
if (workflowId !== undefined && workflowId !== null && typeof workflowId !== "string") {
|
||||
throw badRequest("workflowId must be a string or null");
|
||||
}
|
||||
|
||||
const summaryOverride = parsePlanningSummaryOverride(summaryInput);
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
@@ -1135,6 +1168,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
source: { sourceType: "api" },
|
||||
branch: resolvedBranch,
|
||||
baseBranch: resolvedBaseBranch,
|
||||
/*
|
||||
FNXC:WorkflowSelection 2026-06-20-16:48:
|
||||
Planning Mode creates tasks from the board context, so an active workflow lane must be materialized at create time when the client supplies it.
|
||||
*/
|
||||
...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}),
|
||||
});
|
||||
|
||||
// Update task with suggested size if provided.
|
||||
@@ -1164,10 +1202,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
|
||||
res.status(201).json(task);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to create task");
|
||||
rethrowPlanningWorkflowCreateError(err, "Failed to create task", rethrowAsApiError);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1229,7 +1264,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
*/
|
||||
router.post("/planning/create-tasks", async (req, res) => {
|
||||
try {
|
||||
const { planningSessionId, subtasks, branch, baseBranch, branchSelection, branchAssignment } = req.body as {
|
||||
const { planningSessionId, subtasks, branch, baseBranch, branchSelection, branchAssignment, workflowId } = req.body as {
|
||||
planningSessionId?: string;
|
||||
subtasks?: Array<{
|
||||
id: string;
|
||||
@@ -1243,6 +1278,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
baseBranch?: unknown;
|
||||
branchSelection?: unknown;
|
||||
branchAssignment?: unknown;
|
||||
workflowId?: unknown;
|
||||
};
|
||||
|
||||
if (!planningSessionId || typeof planningSessionId !== "string") {
|
||||
@@ -1253,6 +1289,10 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
throw badRequest("subtasks must be a non-empty array");
|
||||
}
|
||||
|
||||
if (workflowId !== undefined && workflowId !== null && typeof workflowId !== "string") {
|
||||
throw badRequest("workflowId must be a string or null");
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { getSession, cleanupSession, formatInterviewQA, mergePlanningSubtaskDrafts } = await import("../planning.js");
|
||||
|
||||
@@ -1364,6 +1404,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
branch: taskBranch,
|
||||
baseBranch: resolvedBaseBranch,
|
||||
branchContext: planningBranchContext,
|
||||
/*
|
||||
FNXC:WorkflowSelection 2026-06-20-16:48:
|
||||
Multi-task Planning Mode creation must apply the selected workflow to every generated child so saved tasks do not jump to the main board first.
|
||||
*/
|
||||
...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}),
|
||||
});
|
||||
|
||||
tempIdToTaskId.set(item.id, task.id);
|
||||
@@ -1411,10 +1456,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
|
||||
res.status(201).json({ tasks: createdTasks });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to create tasks from planning");
|
||||
rethrowPlanningWorkflowCreateError(err, "Failed to create tasks from planning", rethrowAsApiError);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import "./executor-test-helpers.js";
|
||||
import { TaskExecutor } from "../executor.js";
|
||||
import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
|
||||
@@ -82,6 +82,51 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
|
||||
expect((executor as any).pausedAborted.has(task.id)).toBe(false);
|
||||
// FNXC:WorkflowLifecycle the leaked worktree slot must be released to avoid board-wide concurrency blockage.
|
||||
expect((executor as any).activeWorktrees.has(task.id)).toBe(false);
|
||||
// FNXC:WorkflowLifecycle 2026-06-20-19:58 a clean todo row (no stale status/error)
|
||||
// must NOT trigger the reconciliation write — the `live.status != null ||
|
||||
// live.error != null` guard skips it so the common benign re-queue stays a no-op.
|
||||
const clearedClean = store.updateTask.mock.calls.some(
|
||||
(call: unknown[]) => {
|
||||
const patch = call[1] as { status?: unknown; error?: unknown } | undefined;
|
||||
return patch?.status === null && patch?.error === null;
|
||||
},
|
||||
);
|
||||
expect(clearedClean).toBe(false);
|
||||
});
|
||||
|
||||
it("clears a stale failed status when reclassifying a todo pause-abort as benign (no lingering failure notification)", async () => {
|
||||
// FNXC:WorkflowLifecycle 2026-06-20-19:58 a pause-abort parked status:"failed"
|
||||
// on an earlier non-todo observation stays dispatchable (scheduler filters
|
||||
// column+paused, not status) and re-enters this branch in todo. The benign
|
||||
// reclassification must reconcile the row to status:null/error:null —
|
||||
// otherwise the persisted failure survives, the board shows it failed, and
|
||||
// the deferred failure notification fires despite the benign log.
|
||||
const { store, task, executor } = makeHarness({
|
||||
column: "todo",
|
||||
status: "failed",
|
||||
error: "Workflow graph failure surfaced after paused engine abort during pause/resume",
|
||||
});
|
||||
(executor as any).activeWorktrees.set(task.id, task.worktree);
|
||||
|
||||
await invokeGraphFailure(executor, task);
|
||||
|
||||
const clearedFailure = store.updateTask.mock.calls.some(
|
||||
(call: unknown[]) => {
|
||||
const patch = call[1] as { status?: unknown; error?: unknown } | undefined;
|
||||
return patch?.status === null && patch?.error === null;
|
||||
},
|
||||
);
|
||||
expect(clearedFailure).toBe(true);
|
||||
const reParkedFailed = store.updateTask.mock.calls.some(
|
||||
(call: unknown[]) => (call[1] as { status?: string } | undefined)?.status === "failed",
|
||||
);
|
||||
expect(reParkedFailed).toBe(false);
|
||||
expect(logText(store)).toContain("benign, cleared for normal scheduling");
|
||||
// FNXC:WorkflowLifecycle 2026-06-20-19:58 the clear path must emit an
|
||||
// `Auto-recovered:`-prefixed log so NotificationService proactively cancels
|
||||
// the pending failure timer (recoveredStatus path), not just suppress it at
|
||||
// fire time. Prefix is the documented self-healing recovery contract.
|
||||
expect(logText(store)).toContain("Auto-recovered: cleared stale pause-abort failure on todo re-queue");
|
||||
});
|
||||
|
||||
it("STILL parks a non-todo (in-review) pause-abort as operator-action failed", async () => {
|
||||
|
||||
@@ -1200,7 +1200,102 @@ describe("TaskExecutor bounded recovery retries", () => {
|
||||
expect(store.handoffToReview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces genuine hard-cancel pausedAborted in-review graph exits as workflow failures", async () => {
|
||||
it("preserves clean completed in-review rows after benign engine-restart hard-cancel provenance without finalize log", async () => {
|
||||
const store = createMockStore();
|
||||
const steps = [
|
||||
{ name: "Preflight", status: "done" },
|
||||
{ name: "Implement", status: "done" },
|
||||
];
|
||||
const task = {
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
status: undefined,
|
||||
dependencies: [],
|
||||
steps,
|
||||
currentStep: 1,
|
||||
// FNXC:WorkflowLifecycle 2026-06-20-00:00:
|
||||
// FN-6796 symptom coverage must omit the paused-after-completion finalize log so this exercises the benign in-review pause-abort classifier, not the older alreadyFinalizedToReview suppression path.
|
||||
log: [{ timestamp: new Date().toISOString(), action: "Normal review handoff without paused-completion marker" }],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as Task;
|
||||
store.getTask.mockResolvedValue({
|
||||
...task,
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
userPaused: false,
|
||||
status: null,
|
||||
error: null,
|
||||
});
|
||||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||||
(executor as any).markPausedAborted("FN-001", "hard-cancel");
|
||||
|
||||
await (executor as any).handleGraphFailure(task, {
|
||||
disposition: "failed",
|
||||
outcome: "failure",
|
||||
visitedNodeIds: ["execute"],
|
||||
});
|
||||
|
||||
const messages = store.logEntry.mock.calls.map((call) => call[1]).join("\n");
|
||||
expect(messages).toContain("Workflow graph run ended during engine pause/resume while already in-review — benign, in-review state preserved");
|
||||
expect(messages).not.toContain("Workflow graph failure surfaced after paused engine abort during pause/resume");
|
||||
expect(messages).not.toContain("operator action required");
|
||||
expect((executor as any).pausedAborted.has("FN-001")).toBe(false);
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.objectContaining({ status: "failed" }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.handoffToReview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces user hard-cancel in-review graph exits with completed steps as workflow failures", async () => {
|
||||
const store = createMockStore();
|
||||
const steps = [
|
||||
{ name: "Preflight", status: "done" },
|
||||
{ name: "Implement", status: "done" },
|
||||
];
|
||||
const task = {
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
status: undefined,
|
||||
dependencies: [],
|
||||
steps,
|
||||
currentStep: 1,
|
||||
log: [{ timestamp: new Date().toISOString(), action: "Normal review handoff without paused-completion marker" }],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as Task;
|
||||
store.getTask.mockResolvedValue({
|
||||
...task,
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
userPaused: false,
|
||||
status: null,
|
||||
error: null,
|
||||
});
|
||||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||||
await (executor as any).awaitAbortInFlightTaskWork("FN-001", "user move in-progress to todo", { userCanceled: true });
|
||||
|
||||
await (executor as any).handleGraphFailure(task, {
|
||||
disposition: "failed",
|
||||
outcome: "failure",
|
||||
visitedNodeIds: ["execute"],
|
||||
});
|
||||
|
||||
const expectedMessage = "Workflow graph failure surfaced after paused engine abort during pause/resume in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task";
|
||||
expect((executor as any).userCanceledTaskIds.has("FN-001")).toBe(true);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { error: expectedMessage, status: "failed" }, undefined);
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.handoffToReview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces incomplete hard-cancel pausedAborted in-review graph exits as workflow failures", async () => {
|
||||
const store = createMockStore();
|
||||
const steps = [
|
||||
{ name: "Preflight", status: "pending" },
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("FN-4811: active worktree removal liveness gate", () => {
|
||||
expect(execCalls.some((c) => c.includes('git branch -D "fusion/fn-9999"'))).toBe(true);
|
||||
// Must have logged the stale-path cleanup outcome — NOT the generic failure log.
|
||||
const logCalls = store.logEntry.mock.calls.map((c: any[]) => String(c[1] ?? ""));
|
||||
expect(logCalls.some((m: string) => m.includes("Cleaned up stale conflicting worktree admin entry"))).toBe(true);
|
||||
expect(logCalls.some((m: string) => m.includes("Cleaned up stale conflicting worktree"))).toBe(true);
|
||||
expect(logCalls.some((m: string) => m === "Failed to clean up conflicting worktree")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,9 @@ import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||
|
||||
const PARK_ERROR =
|
||||
"Workflow graph failure surfaced after paused engine abort during pause/resume in 'todo' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task";
|
||||
const IN_REVIEW_PARK_ERROR =
|
||||
"Workflow graph failure surfaced after paused engine abort during pause/resume in 'in-review' at node 'execute' — operator action required; retry or explicitly unpause/resume after inspecting the task";
|
||||
const DONE_STEPS = [{ status: "done" }, { status: "done" }];
|
||||
|
||||
function createMockStore(tasks: Task[]): TaskStore & EventEmitter {
|
||||
const emitter = new EventEmitter();
|
||||
@@ -114,11 +117,45 @@ describe("recoverPausedAbortFailures", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("skips paused, executing, in-review, and non-pause-abort failures", async () => {
|
||||
it("clears a completed in-review pause-abort park without moving it backward", async () => {
|
||||
const store = createMockStore([parkTask({
|
||||
id: "FN-7002",
|
||||
column: "in-review",
|
||||
error: IN_REVIEW_PARK_ERROR,
|
||||
steps: DONE_STEPS,
|
||||
autoMerge: true,
|
||||
})]);
|
||||
const clearBinding = vi.fn().mockReturnValue(true);
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getExecutingTaskIds: () => new Set<string>(),
|
||||
clearPhantomExecutorBinding: clearBinding as (taskId: string) => boolean | void,
|
||||
});
|
||||
|
||||
const recovered = await manager.recoverPausedAbortFailures();
|
||||
|
||||
expect(recovered).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-7002", { status: null, error: null });
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(clearBinding).toHaveBeenCalledWith("FN-7002");
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-7002",
|
||||
"Auto-recovered: in-review pause-abort park cleared — preserved for normal review progression",
|
||||
);
|
||||
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mutationType: "task:auto-recover-paused-abort-park",
|
||||
target: "FN-7002",
|
||||
metadata: { fromColumn: "in-review", preservedInReview: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips paused, executing, incomplete in-review, and non-pause-abort failures", async () => {
|
||||
const store = createMockStore([
|
||||
parkTask({ id: "FN-A", paused: true }),
|
||||
parkTask({ id: "FN-B", column: "in-progress" }), // executing (below)
|
||||
parkTask({ id: "FN-C", column: "in-review" }), // in-review park left for operator
|
||||
parkTask({ id: "FN-C", column: "in-review", error: IN_REVIEW_PARK_ERROR }), // incomplete in-review park left for operator
|
||||
parkTask({ id: "FN-D", error: "some other failure", status: "failed" }),
|
||||
]);
|
||||
const manager = new SelfHealingManager(store, {
|
||||
@@ -132,6 +169,55 @@ describe("recoverPausedAbortFailures", () => {
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves guarded in-review pause-abort parks untouched", async () => {
|
||||
const candidates = [
|
||||
parkTask({ id: "FN-U", column: "in-review", error: IN_REVIEW_PARK_ERROR, steps: DONE_STEPS, userPaused: true, autoMerge: true }),
|
||||
parkTask({ id: "FN-X", column: "in-review", error: IN_REVIEW_PARK_ERROR, steps: DONE_STEPS, autoMerge: true }),
|
||||
parkTask({ id: "FN-M", column: "in-review", error: IN_REVIEW_PARK_ERROR, steps: DONE_STEPS, autoMerge: true, mergeDetails: { mergeConfirmed: true } as any }),
|
||||
parkTask({ id: "FN-T", column: "in-review", error: `${IN_REVIEW_PARK_ERROR} merge-conflict`, steps: DONE_STEPS, autoMerge: true }),
|
||||
parkTask({ id: "FN-A", column: "in-review", error: IN_REVIEW_PARK_ERROR, steps: DONE_STEPS, autoMerge: undefined }),
|
||||
];
|
||||
const store = createMockStore(candidates);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
autoMerge: false,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
maintenanceIntervalMs: 0,
|
||||
} as unknown as Settings);
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getExecutingTaskIds: () => new Set<string>(["FN-X"]),
|
||||
});
|
||||
|
||||
const recovered = await manager.recoverPausedAbortFailures();
|
||||
|
||||
expect(recovered).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revalidates in-review recovery against fresh state before clearing the park", async () => {
|
||||
const initial = parkTask({
|
||||
id: "FN-STALE",
|
||||
column: "in-review",
|
||||
error: IN_REVIEW_PARK_ERROR,
|
||||
steps: DONE_STEPS,
|
||||
autoMerge: true,
|
||||
});
|
||||
const store = createMockStore([initial]);
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...initial, paused: true });
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
getExecutingTaskIds: () => new Set<string>(),
|
||||
});
|
||||
|
||||
const recovered = await manager.recoverPausedAbortFailures();
|
||||
|
||||
expect(recovered).toBe(0);
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// FNXC:WorkflowLifecycle greptile P1 (PR #1687): the method self-guards on
|
||||
// global/engine pause at its own entry, so calling it directly (test/API path)
|
||||
// while the operator has frozen the board must be a no-op.
|
||||
|
||||
@@ -6529,6 +6529,32 @@ export class TaskExecutor {
|
||||
return true;
|
||||
}
|
||||
|
||||
private isBenignInReviewPauseAbort(
|
||||
live: TaskDetail,
|
||||
result: WorkflowGraphTaskRunResult,
|
||||
abortProvenance: "global-pause" | "merge-seam" | "hard-cancel" | "completion-finalize" | undefined,
|
||||
pausedAborted: boolean,
|
||||
userCanceled: boolean,
|
||||
): boolean {
|
||||
/*
|
||||
FNXC:WorkflowLifecycle 2026-06-20-00:00:
|
||||
FN-6796: an engine restart/pause-resume abort reaches graph-failure handling as `hard-cancel` provenance even when no user canceled the task. A clean completed `in-review` row in that shape is already handed off for review and must not be stranded with the operator-action pause-abort marker; the discriminator is the in-memory `userCanceledTaskIds` set plus the resting column and clean row state, while global/user pause, merge-seam, terminal merge values, merge-confirmed partial landings, and pre-existing status/error still park exactly as before.
|
||||
*/
|
||||
if (!pausedAborted) return false;
|
||||
if (abortProvenance !== "hard-cancel") return false;
|
||||
if (userCanceled) return false;
|
||||
if (live.column !== "in-review") return false;
|
||||
if (live.userPaused === true) return false;
|
||||
if (live.status != null || live.error != null) return false;
|
||||
if (live.mergeDetails?.mergeConfirmed === true) return false;
|
||||
if (this.isTerminalMergeGraphFailureValue(this.graphFailureValue(result))) return false;
|
||||
const failedNode = result.visitedNodeIds[result.visitedNodeIds.length - 1];
|
||||
if (this.isMergeGraphFailure(failedNode)) return false;
|
||||
if (live.steps.length === 0) return false;
|
||||
if (!live.steps.every((step) => step.status === "done" || step.status === "skipped")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async routeGraphMergeFailureToRetry(
|
||||
live: TaskDetail,
|
||||
result: WorkflowGraphTaskRunResult,
|
||||
@@ -6622,6 +6648,15 @@ export class TaskExecutor {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (genuinePauseAbort && this.isBenignInReviewPauseAbort(live, result, abortProvenance, pausedAborted, this.userCanceledTaskIds.has(task.id))) {
|
||||
this.clearPausedAborted(task.id);
|
||||
this.activeWorktrees.delete(task.id);
|
||||
const inReviewBenign = "Workflow graph run ended during engine pause/resume while already in-review — benign, in-review state preserved";
|
||||
executorLog.log(`${task.id}: ${inReviewBenign}`);
|
||||
await this.store.logEntry(task.id, inReviewBenign, undefined, this.getRunContextFor(task.id));
|
||||
await this.persistTokenUsage(task.id);
|
||||
return;
|
||||
}
|
||||
if (genuinePauseAbort) {
|
||||
/*
|
||||
FNXC:WorkflowLifecycle 2026-06-15-01:45:
|
||||
@@ -6666,6 +6701,29 @@ export class TaskExecutor {
|
||||
const todoBenign = `Workflow graph run ended during ${pauseProvenance} with task re-queued to todo — benign, cleared for normal scheduling`;
|
||||
executorLog.log(`${task.id}: ${todoBenign}`);
|
||||
await this.store.logEntry(task.id, todoBenign, undefined, this.getRunContextFor(task.id));
|
||||
// FNXC:WorkflowLifecycle 2026-06-20-19:58: reconcile a stale
|
||||
// persisted failure with the benign reclassification. A pause-abort
|
||||
// parked `status:"failed"` on an earlier non-todo observation stays
|
||||
// dispatchable (scheduler.ts filters column+paused, NOT status) and
|
||||
// re-enters this branch in `todo`; `recoverPausedAbortFailures` that
|
||||
// would clear it is suppressed during global/engine pause
|
||||
// (self-healing.ts). Leaving the row failed contradicts the benign
|
||||
// log: the board shows it failed AND the deferred failure
|
||||
// notification fires (notification-service fire-time check sees
|
||||
// status === "failed"). Clear status/error here so the row matches
|
||||
// the log, then emit an `Auto-recovered:`-prefixed entry so
|
||||
// NotificationService.maybeSuppressTransientFailedNotification
|
||||
// PROACTIVELY cancels the pending failure timer on the task:updated
|
||||
// event (recoveredStatus path) — rather than relying only on the
|
||||
// fire-time re-check, which is race-contingent when
|
||||
// failureNotificationDelayMs is near 0. The prefix is the documented
|
||||
// contract for self-healing recovery logs (see self-healing.ts /
|
||||
// project-engine.ts). Scoped to the actual-clear path so the common
|
||||
// no-failure benign re-queue is not mislabeled as a recovery.
|
||||
if (live.status != null || live.error != null) {
|
||||
await this.store.updateTask(task.id, { status: null, error: null }, this.getRunContextFor(task.id));
|
||||
await this.store.logEntry(task.id, "Auto-recovered: cleared stale pause-abort failure on todo re-queue — failure notification suppressed", undefined, this.getRunContextFor(task.id));
|
||||
}
|
||||
await this.persistTokenUsage(task.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8132,16 +8132,34 @@ export class SelfHealingManager {
|
||||
typeof t.error === "string" &&
|
||||
t.error.includes(PAUSE_ABORT_PARK_OPERATOR_MARKER) &&
|
||||
t.error.includes(PAUSE_ABORT_PARK_ERROR_MARKER);
|
||||
const isTerminalMergePark = (t: Task): boolean => {
|
||||
const text = typeof t.error === "string" ? t.error.toLowerCase() : "";
|
||||
return text.includes("conflict")
|
||||
|| text.includes("contamination")
|
||||
|| text.includes("foreign")
|
||||
|| text.includes("retry-exhausted")
|
||||
|| text.includes("retries exhausted")
|
||||
|| text.includes("max retries");
|
||||
};
|
||||
const isRecoverableInReviewPauseAbortPark = (t: Task): boolean => {
|
||||
/*
|
||||
FNXC:WorkflowLifecycle 2026-06-20-00:00:
|
||||
FN-6796 defense-in-depth: executor memory that distinguishes benign engine aborts from user hard-cancel is gone after restart, so self-healing may recover only persisted clean `in-review` pause-abort parks: non-paused, not executing, auto-merge eligible, completed steps, no terminal/confirmed merge evidence. User hard-cancel rows rest in `todo`; global/user pauses and autoMerge:false review rows remain operator-controlled.
|
||||
*/
|
||||
return t.column === "in-review"
|
||||
&& allowsAutoMergeProcessing(t, settings)
|
||||
&& t.mergeDetails?.mergeConfirmed !== true
|
||||
&& !isTerminalMergePark(t)
|
||||
&& t.steps.length > 0
|
||||
&& t.steps.every((step) => step.status === "done" || step.status === "skipped");
|
||||
};
|
||||
|
||||
const parked = tasks.filter((t) =>
|
||||
isPausedAbortPark(t) &&
|
||||
!t.paused &&
|
||||
!t.userPaused &&
|
||||
!executingIds.has(t.id) &&
|
||||
// Only recover columns that are safe to requeue. done/archived parks are
|
||||
// terminal and in-review parks may carry merge state — leave those for
|
||||
// the existing review recoverers / operator inspection.
|
||||
(t.column === "todo" || t.column === "in-progress"),
|
||||
(t.column === "todo" || t.column === "in-progress" || isRecoverableInReviewPauseAbortPark(t)),
|
||||
);
|
||||
|
||||
if (parked.length === 0) return 0;
|
||||
@@ -8165,13 +8183,13 @@ export class SelfHealingManager {
|
||||
fresh.paused ||
|
||||
fresh.userPaused ||
|
||||
latestExecutingIds.has(fresh.id) ||
|
||||
!(fresh.column === "todo" || fresh.column === "in-progress")
|
||||
!(fresh.column === "todo" || fresh.column === "in-progress" || isRecoverableInReviewPauseAbortPark(fresh))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.store.updateTask(task.id, { status: null, error: null });
|
||||
if (fresh.column !== "todo") {
|
||||
if (fresh.column !== "todo" && fresh.column !== "in-review") {
|
||||
await this.store.moveTask(task.id, "todo", {
|
||||
preserveProgress: true,
|
||||
moveSource: "engine",
|
||||
@@ -8187,7 +8205,9 @@ export class SelfHealingManager {
|
||||
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
"Auto-recovered: pause-abort park cleared — requeued for normal scheduling",
|
||||
fresh.column === "in-review"
|
||||
? "Auto-recovered: in-review pause-abort park cleared — preserved for normal review progression"
|
||||
: "Auto-recovered: pause-abort park cleared — requeued for normal scheduling",
|
||||
);
|
||||
// FNXC:WorkflowLifecycle 2026-06-20-00:00: audit emission is strictly
|
||||
// best-effort — an audit throw AFTER the successful state mutation must
|
||||
@@ -8201,7 +8221,7 @@ export class SelfHealingManager {
|
||||
domain: "database",
|
||||
mutationType: "task:auto-recover-paused-abort-park",
|
||||
target: task.id,
|
||||
metadata: { fromColumn: fresh.column },
|
||||
metadata: { fromColumn: fresh.column, preservedInReview: fresh.column === "in-review" },
|
||||
});
|
||||
} catch (auditErr: unknown) {
|
||||
log.warn(`Pause-abort park audit emission failed for ${task.id}: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
|
||||
@@ -8214,7 +8234,7 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
if (recovered > 0) {
|
||||
log.log(`Recovered ${recovered} pause-abort park(s) → requeued to todo`);
|
||||
log.log(`Recovered ${recovered} pause-abort park(s) → requeued to todo or preserved in review`);
|
||||
}
|
||||
return recovered;
|
||||
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
Reference in New Issue
Block a user