Merge pull request #1505 from Runfusion/fix/workflow-board-list-loop-recovery
fix: stabilize workflow board and loop recovery
This commit is contained in:
5
.changeset/workflow-board-list-layout.md
Normal file
5
.changeset/workflow-board-list-layout.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix workflow board/list workflow selection, custom workflow task creation controls, workflow editor defaults, built-in workflow node prompt display, and executor handling for built-in workflow runs.
|
||||
6
.github/workflows/pr-checks.yml
vendored
6
.github/workflows/pr-checks.yml
vendored
@@ -69,9 +69,6 @@ jobs:
|
||||
- name: Setup Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
@@ -94,9 +91,6 @@ jobs:
|
||||
- name: Setup Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
# Dist-artifact cache (same contract as full-suite.yml): exact-match
|
||||
# key only, NO restore-keys (stale dist is the known failure mode,
|
||||
# FN-4232/FN-4605), NEVER node_modules (breaks Windows pnpm junctions).
|
||||
|
||||
@@ -131,15 +131,16 @@ describe("Merge gate (.github/workflows/pr-checks.yml)", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps build coverage as an explicit PR gate", () => {
|
||||
it("keeps build coverage as an explicit Node/pnpm PR gate", () => {
|
||||
const buildSteps = workflow.jobs?.build?.steps ?? [];
|
||||
expect(findCompositeSetupStep(buildSteps)).toBeDefined();
|
||||
expect(
|
||||
buildSteps.some(
|
||||
(step: any) =>
|
||||
step.name === "Install Bun" ||
|
||||
(typeof step.uses === "string" && step.uses.includes("oven-sh/setup-bun")),
|
||||
),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(
|
||||
buildSteps.some(
|
||||
(step: any) => step.name === "Build" && typeof step.run === "string" && step.run.includes("pnpm build"),
|
||||
|
||||
@@ -1244,6 +1244,11 @@ function AppInner() {
|
||||
pushNav({ type: "modal", close: modalManager.closeWorkflowEditor });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
const openCreateWorkflowWithNav = useCallback(() => {
|
||||
modalManager.openWorkflowEditor("create");
|
||||
pushNav({ type: "modal", close: modalManager.closeWorkflowEditor });
|
||||
}, [modalManager, pushNav]);
|
||||
|
||||
const openUsageWithNav = useCallback((anchorRect?: DOMRect | null) => {
|
||||
modalManager.openUsage(anchorRect);
|
||||
pushNav({ type: "modal", close: modalManager.closeUsage });
|
||||
@@ -1742,6 +1747,8 @@ function AppInner() {
|
||||
onOpenMission={handleOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
onOpenWorkflowEditor={openWorkflowEditorWithNav}
|
||||
onCreateWorkflow={openCreateWorkflowWithNav}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
@@ -1778,6 +1785,7 @@ function AppInner() {
|
||||
searchQuery={searchQuery}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
onCreateWorkflow={openCreateWorkflowWithNav}
|
||||
/>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
TaskComment,
|
||||
TaskCreateInput,
|
||||
AgentLogEntry,
|
||||
Column,
|
||||
ColumnId,
|
||||
MergeResult,
|
||||
Settings,
|
||||
GlobalSettings,
|
||||
@@ -527,7 +527,7 @@ export function batchUpdateTaskModels(
|
||||
|
||||
export function moveTask(
|
||||
id: string,
|
||||
column: Column,
|
||||
column: ColumnId,
|
||||
projectId?: string,
|
||||
optionsOrPosition?: { preserveProgress?: boolean } | number,
|
||||
): Promise<Task> {
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -385,6 +385,7 @@ export function AppModals({
|
||||
addToast={addToast}
|
||||
projectId={projectId}
|
||||
initialPanel={modalManager.workflowEditorInitialPanel}
|
||||
initialAction={modalManager.workflowEditorInitialAction}
|
||||
/>
|
||||
</Suspense>
|
||||
</ModalErrorBoundary>
|
||||
|
||||
@@ -2,18 +2,16 @@ import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIss
|
||||
import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
|
||||
import { sortTasksForDisplayColumn } from "./taskSorting";
|
||||
import { Column } from "./Column";
|
||||
import { Lane } from "./Lane";
|
||||
import "./Lane.css";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api";
|
||||
import { Pencil, Plus } from "lucide-react";
|
||||
import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowDefinition, type BoardWorkflowsPayload } from "../api";
|
||||
import { useBlockerFanout } from "../hooks/useBlockerFanout";
|
||||
import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
|
||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
|
||||
/** localStorage key for persisted lane collapse state (per project). */
|
||||
const LANE_COLLAPSE_STORAGE_KEY = "kb-dashboard-lane-collapsed";
|
||||
|
||||
interface BoardProps {
|
||||
tasks: Task[];
|
||||
projectId?: string;
|
||||
@@ -68,6 +66,10 @@ interface BoardProps {
|
||||
lastFetchTimeMs?: number;
|
||||
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
|
||||
prAuthAvailable?: boolean;
|
||||
/** Opens the workflow editor modal. */
|
||||
onOpenWorkflowEditor?: () => void;
|
||||
/** Opens the workflow editor to create a new workflow. */
|
||||
onCreateWorkflow?: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +89,7 @@ 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 }: BoardProps) {
|
||||
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
const archivedLoadedRef = useRef(false);
|
||||
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
|
||||
@@ -271,18 +273,8 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
// 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 [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
|
||||
const draggingTaskIdRef = useRef<string | null>(null);
|
||||
const [collapsedLanes, setCollapsedLanes] = useState<ReadonlySet<string>>(() => {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LANE_COLLAPSE_STORAGE_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
|
||||
if (Array.isArray(parsed)) return new Set(parsed.filter((x): x is string => typeof x === "string"));
|
||||
} catch {
|
||||
/* ignore corrupt persisted state */
|
||||
}
|
||||
return new Set();
|
||||
});
|
||||
|
||||
// Fetch board workflow lanes for the project. Deliberately NOT keyed on
|
||||
// `tasks` — that refetched on every SSE tick. Instead we refetch on project
|
||||
@@ -328,22 +320,6 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const handleToggleLaneCollapse = useCallback((workflowId: string) => {
|
||||
setCollapsedLanes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(workflowId)) next.delete(workflowId);
|
||||
else next.add(workflowId);
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
window.localStorage.setItem(LANE_COLLAPSE_STORAGE_KEY, JSON.stringify([...next]));
|
||||
} catch {
|
||||
/* ignore quota / serialization errors */
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePromote = useCallback(async (taskId: string) => {
|
||||
await promoteTask(taskId, projectId);
|
||||
}, [projectId]);
|
||||
@@ -352,33 +328,65 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
|
||||
const flagOn = boardWorkflows?.flagEnabled === true;
|
||||
|
||||
// Group visible tasks into lanes by resolved workflow (null → default lane).
|
||||
const lanes = useMemo(() => {
|
||||
if (!boardWorkflows || !flagOn) return [];
|
||||
const { workflows, taskWorkflowIds, defaultWorkflowId } = boardWorkflows;
|
||||
const byId = new Map(workflows.map((w) => [w.id, w] as const));
|
||||
const tasksByWorkflow = new Map<string, Task[]>();
|
||||
for (const task of tasks) {
|
||||
// Archived cards are excluded from lanes (archived columns are hidden).
|
||||
if (task.column === "archived") continue;
|
||||
const workflowId = taskWorkflowIds[task.id] ?? defaultWorkflowId;
|
||||
(tasksByWorkflow.get(workflowId) ?? tasksByWorkflow.set(workflowId, []).get(workflowId)!).push(task);
|
||||
}
|
||||
const result: Array<{ workflow: typeof workflows[number]; tasks: Task[] }> = [];
|
||||
for (const [workflowId, laneTasks] of tasksByWorkflow) {
|
||||
const workflow = byId.get(workflowId);
|
||||
if (!workflow) continue;
|
||||
if (laneTasks.length === 0) continue; // zero-card lanes hidden
|
||||
result.push({ workflow, tasks: laneTasks });
|
||||
}
|
||||
// Default lane first; then by workflow name for stable ordering.
|
||||
result.sort((a, b) => {
|
||||
if (a.workflow.id === defaultWorkflowId) return -1;
|
||||
if (b.workflow.id === defaultWorkflowId) return 1;
|
||||
return a.workflow.name.localeCompare(b.workflow.name);
|
||||
const workflowMode = flagOn && Boolean(boardWorkflows?.workflows.length);
|
||||
const workflowOptions = useMemo<BoardWorkflowDefinition[]>(() => {
|
||||
if (!workflowMode || !boardWorkflows) return [];
|
||||
return [...boardWorkflows.workflows].sort((a, b) => {
|
||||
if (a.id === boardWorkflows.defaultWorkflowId) return -1;
|
||||
if (b.id === boardWorkflows.defaultWorkflowId) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return result;
|
||||
}, [boardWorkflows, flagOn, tasks]);
|
||||
}, [boardWorkflows, workflowMode]);
|
||||
|
||||
const selectedWorkflow = useMemo<BoardWorkflowDefinition | null>(() => {
|
||||
if (!workflowMode) return null;
|
||||
return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId)
|
||||
?? workflowOptions.find((workflow) => workflow.id === boardWorkflows?.defaultWorkflowId)
|
||||
?? workflowOptions[0]
|
||||
?? null;
|
||||
}, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workflowMode) {
|
||||
setSelectedWorkflowId(null);
|
||||
return;
|
||||
}
|
||||
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
|
||||
setSelectedWorkflowId(selectedWorkflow.id);
|
||||
}
|
||||
}, [selectedWorkflow, selectedWorkflowId, workflowMode]);
|
||||
|
||||
const selectedWorkflowTasks = useMemo(() => {
|
||||
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return [];
|
||||
return tasks.filter((task) => {
|
||||
if (task.column === "archived") return false;
|
||||
const workflowId = boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId;
|
||||
return workflowId === selectedWorkflow.id;
|
||||
});
|
||||
}, [boardWorkflows, selectedWorkflow, tasks, workflowMode]);
|
||||
|
||||
const selectedWorkflowColumns = useMemo(() => {
|
||||
if (!selectedWorkflow) return [];
|
||||
return selectedWorkflow.columns.filter((column) => !column.flags.archived && !column.flags.hiddenFromBoard);
|
||||
}, [selectedWorkflow]);
|
||||
|
||||
const selectedWorkflowCreateColumnId = useMemo(() => {
|
||||
return selectedWorkflowColumns.find((column) => column.flags.intake && !column.flags.archived)?.id
|
||||
?? selectedWorkflowColumns.find((column) => !column.flags.archived)?.id;
|
||||
}, [selectedWorkflowColumns]);
|
||||
|
||||
const selectedWorkflowTasksByColumn = useMemo(() => {
|
||||
const grouped: Record<string, Task[]> = {};
|
||||
if (!selectedWorkflow) return grouped;
|
||||
for (const column of selectedWorkflow.columns) grouped[column.id] = [];
|
||||
for (const task of selectedWorkflowTasks) {
|
||||
(grouped[task.column] ??= []).push(task);
|
||||
}
|
||||
for (const column of selectedWorkflow.columns) {
|
||||
grouped[column.id] = sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType);
|
||||
}
|
||||
return grouped;
|
||||
}, [selectedWorkflow, selectedWorkflowTasks]);
|
||||
|
||||
// Card-placed field defs grouped by workflow id (U13/KTD-14). Only recomputes
|
||||
// when the board-workflows payload changes, not on every SSE task tick.
|
||||
@@ -441,66 +449,114 @@ 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.
|
||||
|
||||
if (flagOn) {
|
||||
if (workflowMode && selectedWorkflow) {
|
||||
return (
|
||||
<main
|
||||
className="board board-lanes"
|
||||
id="board"
|
||||
ref={boardRef}
|
||||
onDragStart={(e) => {
|
||||
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
|
||||
if (id) draggingTaskIdRef.current = id;
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
draggingTaskIdRef.current = null;
|
||||
}}
|
||||
>
|
||||
{lanes.map(({ workflow, tasks: laneTasks }) => (
|
||||
<Lane
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
tasks={laneTasks}
|
||||
collapsed={collapsedLanes.has(workflow.id)}
|
||||
onToggleCollapse={handleToggleLaneCollapse}
|
||||
projectId={projectId}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={onMoveTask}
|
||||
onPromote={handlePromote}
|
||||
canDropTask={canDropTask}
|
||||
getDraggingTaskId={getDraggingTaskId}
|
||||
onPauseTask={onPauseTask}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenGroupModal={onOpenGroupModal}
|
||||
addToast={addToast}
|
||||
onQuickCreate={onQuickCreate}
|
||||
onNewTask={onNewTask}
|
||||
autoMerge={autoMerge}
|
||||
onToggleAutoMerge={onToggleAutoMerge}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onRetryTask={onRetryTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
availableModels={availableModels}
|
||||
onPlanningMode={onPlanningMode}
|
||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
isSearchActive={isSearchActive}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
taskCardFieldDefs={taskCardFieldDefs}
|
||||
blockerFanoutMap={blockerFanoutMap}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
<div className="board-workflow-view">
|
||||
{(workflowOptions.length > 1 || onCreateWorkflow || onOpenWorkflowEditor) && (
|
||||
<div className="board-workflow-toolbar">
|
||||
{workflowOptions.length > 1 && (
|
||||
<label className="list-workflow-selector board-workflow-selector">
|
||||
<span>Workflow</span>
|
||||
<select
|
||||
className="select list-workflow-select"
|
||||
value={selectedWorkflow.id}
|
||||
onChange={(event) => setSelectedWorkflowId(event.target.value)}
|
||||
aria-label="Select workflow"
|
||||
>
|
||||
{workflowOptions.map((workflow) => (
|
||||
<option key={workflow.id} value={workflow.id}>
|
||||
{workflow.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{onCreateWorkflow && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm board-workflow-create-btn"
|
||||
onClick={onCreateWorkflow}
|
||||
title="New workflow"
|
||||
aria-label="New workflow"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
)}
|
||||
{onOpenWorkflowEditor && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm board-workflow-edit-btn"
|
||||
onClick={onOpenWorkflowEditor}
|
||||
title="Edit workflows"
|
||||
aria-label="Edit workflows"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<main
|
||||
className="board board-workflow-columns"
|
||||
id="board"
|
||||
ref={boardRef}
|
||||
onDragStart={(e) => {
|
||||
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
|
||||
if (id) draggingTaskIdRef.current = id;
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
draggingTaskIdRef.current = null;
|
||||
}}
|
||||
>
|
||||
{selectedWorkflowColumns.map((columnDef) => {
|
||||
const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId;
|
||||
return (
|
||||
<Column
|
||||
key={columnDef.id}
|
||||
column={columnDef.id as ColumnType}
|
||||
workflowMode
|
||||
workflowId={selectedWorkflow.id}
|
||||
columnDisplayName={columnDef.name}
|
||||
columnFlags={columnDef.flags}
|
||||
tasks={selectedWorkflowTasksByColumn[columnDef.id] ?? []}
|
||||
allTasks={selectedWorkflowTasks}
|
||||
projectId={projectId}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={onMoveTask}
|
||||
onPromote={handlePromote}
|
||||
canDropTask={(taskId) => canDropTask(taskId, columnDef.id, selectedWorkflow.id)}
|
||||
getDraggingTaskId={getDraggingTaskId}
|
||||
onPauseTask={onPauseTask}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenGroupModal={onOpenGroupModal}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onRetryTask={onRetryTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
availableModels={availableModels}
|
||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||
favoriteProviders={favoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
isSearchActive={isSearchActive}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={onOpenMission}
|
||||
lastFetchTimeMs={lastFetchTimeMs}
|
||||
workflowStepNameLookup={workflowStepNameLookup}
|
||||
taskCardFieldDefs={taskCardFieldDefs}
|
||||
blockerFanoutMap={blockerFanoutMap}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMerge={autoMerge}
|
||||
{...(isCreateColumn ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(columnDef.flags.mergeBlocker ? { onToggleAutoMerge } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,8 @@ interface ColumnProps {
|
||||
* column behavior (label, bulk actions, archived detection) from legacy
|
||||
* literals to trait-flag predicates. Flag OFF leaves all behavior legacy. */
|
||||
workflowMode?: boolean;
|
||||
/** Workflow id for column-aware task creation in workflow mode. */
|
||||
workflowId?: string;
|
||||
/** Display name for this column, from the workflow definition. */
|
||||
columnDisplayName?: string;
|
||||
/** Resolved trait flags for this column (workflow mode). */
|
||||
@@ -170,7 +172,7 @@ interface ColumnProps {
|
||||
getDraggingTaskId?: () => string | null;
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
|
||||
const { t } = useTranslation("app");
|
||||
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
|
||||
// scopes `t` to the useTranslation binding, so the shared translateRejection
|
||||
@@ -374,6 +376,26 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
}, [shouldPaginate, tasks, visibleTaskCount]);
|
||||
|
||||
const hiddenTaskCount = Math.max(0, tasks.length - visibleTasks.length);
|
||||
const canCreateInColumn = Boolean(
|
||||
onQuickCreate &&
|
||||
!isArchived &&
|
||||
(workflowMode || column === "triage"),
|
||||
);
|
||||
|
||||
const handleQuickCreate = useCallback(
|
||||
(input: TaskCreateInput) => {
|
||||
if (!onQuickCreate) return Promise.resolve();
|
||||
if (workflowMode) {
|
||||
return onQuickCreate({
|
||||
...input,
|
||||
column,
|
||||
...(workflowId ? { workflowId } : {}),
|
||||
});
|
||||
}
|
||||
return onQuickCreate(input);
|
||||
},
|
||||
[column, onQuickCreate, workflowId, workflowMode],
|
||||
);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length));
|
||||
@@ -420,6 +442,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
const isReviewColumn = workflowMode ? Boolean(columnFlags?.mergeBlocker || columnFlags?.humanReview) : column === "in-review";
|
||||
const hasColumnBulkActions = isTodoLikeColumn || isProcessingColumn || isReviewColumn;
|
||||
const isMenuBusy = isReplanning || isPausingAll || isMovingAllToTodo;
|
||||
const columnLabelText = workflowMode ? (columnDisplayName ?? COLUMN_LABELS[column] ?? column) : COLUMN_LABELS[column];
|
||||
|
||||
const handlePauseAll = useCallback(async () => {
|
||||
if (!onPauseTask) return;
|
||||
@@ -429,7 +452,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: t("column.stopAllTitle", "Stop All Tasks"),
|
||||
message: t("column.stopAllMessage", "Stop all {{count}} {{columnLabel}} task{{plural}}?", { count: pauseEligibleCount, columnLabel: COLUMN_LABELS[column].toLowerCase(), plural: pauseEligibleCount === 1 ? "" : "s" }),
|
||||
message: t("column.stopAllMessage", "Stop all {{count}} {{columnLabel}} task{{plural}}?", { count: pauseEligibleCount, columnLabel: columnLabelText.toLowerCase(), plural: pauseEligibleCount === 1 ? "" : "s" }),
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
@@ -449,7 +472,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
} finally {
|
||||
setIsPausingAll(false);
|
||||
}
|
||||
}, [onPauseTask, pauseEligibleCount, column, pauseEligibleTasks, addToast, confirm]);
|
||||
}, [onPauseTask, pauseEligibleCount, columnLabelText, pauseEligibleTasks, addToast, confirm, t]);
|
||||
|
||||
const handleMoveAllToTodo = useCallback(async () => {
|
||||
setIsMenuOpen(false);
|
||||
@@ -457,7 +480,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: t("column.moveAllToTodoTitle", "Move All to Todo"),
|
||||
message: t("column.moveAllToTodoMessage", "Move all {{count}} {{columnLabel}} task{{plural}} to Todo?", { count: tasks.length, columnLabel: COLUMN_LABELS[column].toLowerCase(), plural: tasks.length === 1 ? "" : "s" }),
|
||||
message: t("column.moveAllToTodoMessage", "Move all {{count}} {{columnLabel}} task{{plural}} to Todo?", { count: tasks.length, columnLabel: columnLabelText.toLowerCase(), plural: tasks.length === 1 ? "" : "s" }),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -502,7 +525,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
} finally {
|
||||
setIsMovingAllToTodo(false);
|
||||
}
|
||||
}, [tasks, column, onMoveTask, addToast, confirm]);
|
||||
}, [tasks, columnLabelText, onMoveTask, addToast, confirm, t]);
|
||||
|
||||
const handleArchiveAll = useCallback(async () => {
|
||||
if (!onArchiveAllDone) return;
|
||||
@@ -650,9 +673,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<div className="column-body">
|
||||
{(workflowMode ? Boolean(columnFlags?.intake) : column === "triage") && onQuickCreate && (
|
||||
{canCreateInColumn && (
|
||||
<QuickEntryBox
|
||||
onCreate={onQuickCreate}
|
||||
onCreate={handleQuickCreate}
|
||||
addToast={addToast}
|
||||
tasks={allTasks ?? []}
|
||||
availableModels={availableModels}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -303,8 +306,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
margin: 0 auto;
|
||||
width: min(100%, 880px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.documents-content-header {
|
||||
@@ -354,8 +356,7 @@
|
||||
}
|
||||
|
||||
.documents-task-list-wrap {
|
||||
width: min(100%, 960px);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.documents-view-list {
|
||||
@@ -732,4 +733,3 @@
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,64 @@
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--board-padding, 12px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.board-workflow-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.board-workflow-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.board-workflow-selector {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.board-workflow-create-btn,
|
||||
.board-workflow-edit-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.board.board-workflow-columns {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.board.board-workflow-columns > .column {
|
||||
flex: 1 0 300px;
|
||||
min-width: 300px;
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
|
||||
.lane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg, 10px);
|
||||
@@ -96,3 +147,25 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.board.board-lanes {
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scroll-snap-type: none;
|
||||
}
|
||||
|
||||
.board.board-lanes > .lane {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.board.board-workflow-columns {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ function LaneComponent(props: LaneProps) {
|
||||
() => workflow.columns.filter((col) => !col.flags.archived && !col.flags.hiddenFromBoard),
|
||||
[workflow.columns],
|
||||
);
|
||||
const createColumnId = useMemo(() => (
|
||||
visibleColumns.find((col) => col.flags.intake && !col.flags.archived)?.id
|
||||
?? visibleColumns.find((col) => !col.flags.archived)?.id
|
||||
), [visibleColumns]);
|
||||
|
||||
// Group + sort tasks by column id (stable per render).
|
||||
const tasksByColumn = useMemo(() => {
|
||||
@@ -157,11 +161,14 @@ function LaneComponent(props: LaneProps) {
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="lane-columns" ref={laneRef}>
|
||||
{visibleColumns.map((col) => (
|
||||
{visibleColumns.map((col) => {
|
||||
const isCreateColumn = col.id === createColumnId;
|
||||
return (
|
||||
<Column
|
||||
key={col.id}
|
||||
column={col.id as ColumnType}
|
||||
workflowMode
|
||||
workflowId={workflow.id}
|
||||
columnDisplayName={col.name}
|
||||
columnFlags={col.flags}
|
||||
tasks={tasksByColumn[col.id] ?? []}
|
||||
@@ -197,10 +204,11 @@ function LaneComponent(props: LaneProps) {
|
||||
blockerFanoutMap={props.blockerFanoutMap}
|
||||
prAuthAvailable={props.prAuthAvailable}
|
||||
autoMerge={props.autoMerge}
|
||||
{...(col.flags.intake ? { onQuickCreate: props.onQuickCreate, onNewTask: props.onNewTask, onPlanningMode: props.onPlanningMode, onSubtaskBreakdown: props.onSubtaskBreakdown } : {})}
|
||||
{...(isCreateColumn ? { onQuickCreate: props.onQuickCreate, onNewTask: props.onNewTask, onPlanningMode: props.onPlanningMode, onSubtaskBreakdown: props.onSubtaskBreakdown } : {})}
|
||||
{...(col.flags.mergeBlocker ? { onToggleAutoMerge: props.onToggleAutoMerge } : {})}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -32,6 +32,30 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.list-workflow-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-workflow-selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
}
|
||||
|
||||
.list-workflow-select {
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
min-height: 30px;
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.list-sidebar-controls__actions .list-new-task-action {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -1129,4 +1153,3 @@
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@ import "./ListView.css";
|
||||
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive } from "lucide-react";
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive, Plus } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core";
|
||||
import { COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core";
|
||||
import { useColumnLabel } from "../i18n/labels";
|
||||
import { sortTasksForDisplayColumn } from "./taskSorting";
|
||||
import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail } from "../api";
|
||||
import { batchUpdateTaskModels, fetchBoardWorkflows, fetchNodes, fetchTaskDetail } from "../api";
|
||||
import { TaskDetailContent } from "./TaskDetailModal";
|
||||
import type { ModelInfo, NodeInfo } from "../api";
|
||||
import type { BoardWorkflowColumn, BoardWorkflowDefinition, BoardWorkflowsPayload, ModelInfo, NodeInfo } from "../api";
|
||||
import { QuickEntryBox } from "./QuickEntryBox";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
@@ -20,6 +20,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project
|
||||
import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
triage: "var(--triage)",
|
||||
@@ -110,14 +111,12 @@ function readStaleOnlyFilter(projectId?: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function readCollapsedSections(projectId?: string): Set<Column> {
|
||||
function readCollapsedSections(projectId?: string): Set<ColumnId> {
|
||||
try {
|
||||
const saved = getScopedItem("kb-dashboard-list-collapsed", projectId);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as Column[];
|
||||
const validColumns = parsed.filter((col): col is Column =>
|
||||
COLUMNS.includes(col as Column)
|
||||
);
|
||||
const parsed = JSON.parse(saved) as unknown[];
|
||||
const validColumns = parsed.filter((col): col is ColumnId => typeof col === "string");
|
||||
if (validColumns.length > 0) {
|
||||
return new Set(validColumns);
|
||||
}
|
||||
@@ -126,7 +125,7 @@ function readCollapsedSections(projectId?: string): Set<Column> {
|
||||
// Invalid localStorage data - fall through to default
|
||||
}
|
||||
|
||||
return new Set<Column>();
|
||||
return new Set<ColumnId>();
|
||||
}
|
||||
|
||||
function readSelectedTaskIds(projectId?: string): Set<string> {
|
||||
@@ -187,7 +186,7 @@ function clampSidebarWidth(width: number, containerWidth: number): number {
|
||||
|
||||
interface ListViewProps {
|
||||
tasks: Task[];
|
||||
onMoveTask: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||
onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onDeleteTask: (id: string, options?: {
|
||||
removeDependencyReferences?: boolean;
|
||||
@@ -234,8 +233,22 @@ interface ListViewProps {
|
||||
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
|
||||
lastFetchTimeMs?: number;
|
||||
prAuthAvailable?: boolean;
|
||||
onCreateWorkflow?: () => void;
|
||||
}
|
||||
|
||||
const LEGACY_LIST_COLUMNS: BoardWorkflowColumn[] = COLUMNS.map((column) => ({
|
||||
id: column,
|
||||
name: column,
|
||||
flags: {
|
||||
intake: column === "triage",
|
||||
countsTowardWip: column === "in-progress",
|
||||
mergeBlocker: column === "in-review",
|
||||
complete: column === "done",
|
||||
archived: column === "archived",
|
||||
hold: column === "todo",
|
||||
},
|
||||
}));
|
||||
|
||||
function shouldShowTaskProgress(task: Task): boolean {
|
||||
return task.status === "executing" || task.column === "in-progress";
|
||||
}
|
||||
@@ -283,14 +296,17 @@ export function ListView({
|
||||
searchQuery = "",
|
||||
lastFetchTimeMs,
|
||||
prAuthAvailable,
|
||||
onCreateWorkflow,
|
||||
}: ListViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const columnLabel = useColumnLabel();
|
||||
const [sortField, setSortField] = useState<SortField | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||||
const [dragOverColumn, setDragOverColumn] = useState<Column | null>(null);
|
||||
const [selectedColumn, setSelectedColumn] = useState<Column | null>(null);
|
||||
const [dragOverColumn, setDragOverColumn] = useState<ColumnId | null>(null);
|
||||
const [selectedColumn, setSelectedColumn] = useState<ColumnId | null>(null);
|
||||
const [boardWorkflows, setBoardWorkflows] = useState<BoardWorkflowsPayload | null>(null);
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobile = viewportMode === "mobile";
|
||||
const { confirm, confirmWithChoice } = useConfirm();
|
||||
@@ -304,7 +320,7 @@ export function ListView({
|
||||
const [stalePausedReviewOnlyFilter, setStalePausedReviewOnlyFilter] = useState<boolean>(false);
|
||||
|
||||
// Collapsed sections state - initialize from localStorage
|
||||
const [collapsedSections, setCollapsedSections] = useState<Set<Column>>(() =>
|
||||
const [collapsedSections, setCollapsedSections] = useState<Set<ColumnId>>(() =>
|
||||
readCollapsedSections(projectId),
|
||||
);
|
||||
|
||||
@@ -342,12 +358,19 @@ export function ListView({
|
||||
const [bulkEditEnabled, setBulkEditEnabled] = useState(false);
|
||||
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<string>>(() => readSelectedTaskIds(projectId));
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(() => readSelectedTaskId(projectId));
|
||||
const [selectedTaskSnapshot, setSelectedTaskSnapshot] = useState<Task | TaskDetail | null>(null);
|
||||
const [selectedTaskSnapshot, setSelectedTaskSnapshot] = useState<Task | TaskDetail | null>(() => {
|
||||
const persistedSelection = readSelectedTaskId(projectId);
|
||||
return persistedSelection ? tasks.find((task) => task.id === persistedSelection) ?? null : null;
|
||||
});
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readSidebarWidth(projectId));
|
||||
const splitLayoutRef = useRef<HTMLDivElement>(null);
|
||||
const splitSidebarRef = useRef<HTMLDivElement>(null);
|
||||
const previousStorageProjectIdRef = useRef(projectId);
|
||||
const boardWorkflowsFetchSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousStorageProjectIdRef.current === projectId) return;
|
||||
previousStorageProjectIdRef.current = projectId;
|
||||
setVisibleColumns(readVisibleColumns(projectId));
|
||||
setHideDoneTasks(readHideDoneTasks(projectId));
|
||||
setStaleOnlyFilter(readStaleOnlyFilter(projectId));
|
||||
@@ -362,6 +385,41 @@ export function ListView({
|
||||
setSidebarWidth(readSidebarWidth(projectId));
|
||||
}, [projectId, tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
const runFetch = () => {
|
||||
const seq = ++boardWorkflowsFetchSeqRef.current;
|
||||
fetchBoardWorkflows(projectId)
|
||||
.then((payload) => {
|
||||
if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload);
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq === boardWorkflowsFetchSeqRef.current) {
|
||||
setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} });
|
||||
}
|
||||
});
|
||||
};
|
||||
runFetch();
|
||||
const onVisible = () => {
|
||||
if (typeof document === "undefined" || document.visibilityState === "visible") runFetch();
|
||||
};
|
||||
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
|
||||
if (typeof window !== "undefined") window.addEventListener("focus", onVisible);
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const unsubscribe = subscribeSse(`/api/events${query}`, {
|
||||
events: {
|
||||
"workflow:created": runFetch,
|
||||
"workflow:updated": runFetch,
|
||||
"workflow:deleted": runFetch,
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
boardWorkflowsFetchSeqRef.current++;
|
||||
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
|
||||
if (typeof window !== "undefined") window.removeEventListener("focus", onVisible);
|
||||
unsubscribe();
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Persist selection to localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -392,6 +450,7 @@ export function ListView({
|
||||
if (!previous || previous.id !== selectedTaskId) {
|
||||
return liveTask;
|
||||
}
|
||||
if (previous === liveTask) return previous;
|
||||
return { ...previous, ...liveTask };
|
||||
});
|
||||
}, [selectedTaskId, tasks]);
|
||||
@@ -488,6 +547,99 @@ export function ListView({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const workflowMode = boardWorkflows?.flagEnabled === true && boardWorkflows.workflows.length > 0;
|
||||
const workflowOptions = useMemo<BoardWorkflowDefinition[]>(() => {
|
||||
if (!workflowMode || !boardWorkflows) return [];
|
||||
return [...boardWorkflows.workflows].sort((a, b) => {
|
||||
if (a.id === boardWorkflows.defaultWorkflowId) return -1;
|
||||
if (b.id === boardWorkflows.defaultWorkflowId) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [boardWorkflows, workflowMode]);
|
||||
|
||||
const selectedWorkflow = useMemo<BoardWorkflowDefinition | null>(() => {
|
||||
if (!workflowMode) return null;
|
||||
return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId)
|
||||
?? workflowOptions.find((workflow) => workflow.id === boardWorkflows?.defaultWorkflowId)
|
||||
?? workflowOptions[0]
|
||||
?? null;
|
||||
}, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workflowMode) {
|
||||
setSelectedWorkflowId(null);
|
||||
return;
|
||||
}
|
||||
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
|
||||
setSelectedWorkflowId(selectedWorkflow.id);
|
||||
}
|
||||
}, [selectedWorkflow, selectedWorkflowId, workflowMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedColumn(null);
|
||||
}, [selectedWorkflowId]);
|
||||
|
||||
const listColumns = useMemo<BoardWorkflowColumn[]>(() => {
|
||||
if (!workflowMode || !selectedWorkflow) return LEGACY_LIST_COLUMNS;
|
||||
return selectedWorkflow.columns.filter((column) => !column.flags.hiddenFromBoard);
|
||||
}, [selectedWorkflow, workflowMode]);
|
||||
|
||||
const columnNameById = useMemo(() => {
|
||||
const map = new Map<ColumnId, string>();
|
||||
for (const column of listColumns) {
|
||||
map.set(column.id, workflowMode ? column.name : columnLabel(column.id));
|
||||
}
|
||||
return map;
|
||||
}, [columnLabel, listColumns, workflowMode]);
|
||||
|
||||
const columnFlagsById = useMemo(() => {
|
||||
const map = new Map<ColumnId, BoardWorkflowColumn["flags"]>();
|
||||
for (const column of listColumns) {
|
||||
map.set(column.id, column.flags);
|
||||
}
|
||||
return map;
|
||||
}, [listColumns]);
|
||||
|
||||
const getListColumnLabel = useCallback((column: ColumnId): string => {
|
||||
return columnNameById.get(column) ?? columnLabel(column);
|
||||
}, [columnLabel, columnNameById]);
|
||||
|
||||
const isArchivedColumn = useCallback((column: ColumnId): boolean => {
|
||||
return workflowMode ? Boolean(columnFlagsById.get(column)?.archived) : column === "archived";
|
||||
}, [columnFlagsById, workflowMode]);
|
||||
|
||||
const isCompleteColumn = useCallback((column: ColumnId): boolean => {
|
||||
return workflowMode ? Boolean(columnFlagsById.get(column)?.complete) : column === "done";
|
||||
}, [columnFlagsById, workflowMode]);
|
||||
|
||||
const selectedWorkflowTaskIds = useMemo(() => {
|
||||
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null;
|
||||
const ids = new Set<string>();
|
||||
for (const task of tasks) {
|
||||
const workflowId = boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId;
|
||||
if (workflowId === selectedWorkflow.id) ids.add(task.id);
|
||||
}
|
||||
return ids;
|
||||
}, [boardWorkflows, selectedWorkflow, tasks, workflowMode]);
|
||||
|
||||
const createTargetColumn = useMemo(() => {
|
||||
const target = listColumns.find((column) => column.flags.intake && !column.flags.archived)
|
||||
?? listColumns.find((column) => !column.flags.archived);
|
||||
return target?.id;
|
||||
}, [listColumns]);
|
||||
|
||||
const handleListQuickCreate = useCallback((input: TaskCreateInput) => {
|
||||
const create = onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error"));
|
||||
if (workflowMode && selectedWorkflow && createTargetColumn) {
|
||||
return create({
|
||||
...input,
|
||||
column: input.column ?? createTargetColumn,
|
||||
workflowId: input.workflowId ?? selectedWorkflow.id,
|
||||
});
|
||||
}
|
||||
return create(input);
|
||||
}, [addToast, createTargetColumn, onQuickCreate, selectedWorkflow, t, workflowMode]);
|
||||
|
||||
|
||||
// Column display labels
|
||||
const COLUMN_LABELS_MAP: Record<ListColumn, string> = {
|
||||
@@ -509,11 +661,11 @@ export function ListView({
|
||||
setSortDirection("asc");
|
||||
}, [sortField]);
|
||||
|
||||
const handleColumnFilter = useCallback((column: Column) => {
|
||||
const handleColumnFilter = useCallback((column: ColumnId) => {
|
||||
setSelectedColumn((prev) => (prev === column ? null : column));
|
||||
}, []);
|
||||
|
||||
const toggleSection = useCallback((column: Column) => {
|
||||
const toggleSection = useCallback((column: ColumnId) => {
|
||||
setCollapsedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(column)) {
|
||||
@@ -540,10 +692,20 @@ export function ListView({
|
||||
)
|
||||
: [...tasks];
|
||||
|
||||
if (selectedWorkflowTaskIds) {
|
||||
filtered = filtered.filter((task) => selectedWorkflowTaskIds.has(task.id));
|
||||
}
|
||||
|
||||
const hiddenCompletedColumns = new Set(
|
||||
listColumns
|
||||
.filter((column) => column.flags.complete || column.flags.archived)
|
||||
.map((column) => column.id),
|
||||
);
|
||||
|
||||
// Then filter out done and archived tasks if hideDoneTasks is enabled
|
||||
// BUT only when no specific column is selected (strict hide semantics)
|
||||
if (hideDoneTasks && !selectedColumn) {
|
||||
filtered = filtered.filter((t) => t.column !== "done" && t.column !== "archived");
|
||||
filtered = filtered.filter((t) => !hiddenCompletedColumns.has(t.column));
|
||||
}
|
||||
|
||||
// Then apply stale-only filter if selected
|
||||
@@ -559,27 +721,22 @@ export function ListView({
|
||||
? filtered.filter((t) => t.column === selectedColumn)
|
||||
: filtered;
|
||||
|
||||
const groups: Record<Column, Task[]> = {
|
||||
triage: [],
|
||||
todo: [],
|
||||
"in-progress": [],
|
||||
"in-review": [],
|
||||
done: [],
|
||||
archived: [],
|
||||
};
|
||||
const groups: Record<string, Task[]> = {};
|
||||
for (const column of listColumns) groups[column.id] = [];
|
||||
|
||||
columnFiltered.forEach((task) => {
|
||||
const column = isColumn(task.column) ? task.column : DEFAULT_COLUMN;
|
||||
groups[column].push(task);
|
||||
const column = workflowMode ? task.column : (isColumn(task.column) ? task.column : DEFAULT_COLUMN);
|
||||
if (groups[column]) groups[column].push(task);
|
||||
});
|
||||
|
||||
for (const column of COLUMNS) {
|
||||
for (const column of listColumns) {
|
||||
const columnId = column.id;
|
||||
if (!sortField) {
|
||||
groups[column] = sortTasksForDisplayColumn(groups[column], column);
|
||||
groups[columnId] = sortTasksForDisplayColumn(groups[columnId], columnId as Column);
|
||||
continue;
|
||||
}
|
||||
|
||||
groups[column] = [...groups[column]].sort((a, b) => {
|
||||
groups[columnId] = [...groups[columnId]].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortField) {
|
||||
case "title":
|
||||
@@ -599,7 +756,7 @@ export function ListView({
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}, [tasks, searchQuery, sortField, sortDirection, hideDoneTasks, staleOnlyFilter, stalePausedReviewOnlyFilter, selectedColumn]);
|
||||
}, [tasks, searchQuery, selectedWorkflowTaskIds, listColumns, workflowMode, hideDoneTasks, selectedColumn, staleOnlyFilter, stalePausedReviewOnlyFilter, sortField, sortDirection]);
|
||||
|
||||
// Calculate total filtered count from groups
|
||||
const filteredCount = useMemo(() => {
|
||||
@@ -608,8 +765,16 @@ export function ListView({
|
||||
|
||||
// Calculate done and archived task counts for stats display
|
||||
const completedTaskCount = useMemo(() => {
|
||||
return tasks.filter((t) => t.column === "done" || t.column === "archived").length;
|
||||
}, [tasks]);
|
||||
const completedColumns = new Set(
|
||||
listColumns
|
||||
.filter((column) => column.flags.complete || column.flags.archived)
|
||||
.map((column) => column.id),
|
||||
);
|
||||
return tasks.filter((task) => {
|
||||
if (selectedWorkflowTaskIds && !selectedWorkflowTaskIds.has(task.id)) return false;
|
||||
return completedColumns.has(task.column);
|
||||
}).length;
|
||||
}, [listColumns, selectedWorkflowTaskIds, tasks]);
|
||||
|
||||
// Calculate hidden done+archived tasks count
|
||||
const hiddenCompletedCount = useMemo(() => {
|
||||
@@ -622,7 +787,7 @@ export function ListView({
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
const visibleTaskIds = Object.values(groupedTasks)
|
||||
.flat()
|
||||
.filter((t) => t.column !== "archived") // Can't bulk edit archived
|
||||
.filter((t) => !isArchivedColumn(t.column)) // Can't bulk edit archived
|
||||
.map((t) => t.id);
|
||||
|
||||
setSelectedTaskIds((prev) => {
|
||||
@@ -637,26 +802,26 @@ export function ListView({
|
||||
return new Set([...prev, ...visibleTaskIds]);
|
||||
}
|
||||
});
|
||||
}, [groupedTasks]);
|
||||
}, [groupedTasks, isArchivedColumn]);
|
||||
|
||||
// Check if all visible tasks are selected
|
||||
const isSelectAll = useMemo(() => {
|
||||
const visibleTaskIds = Object.values(groupedTasks)
|
||||
.flat()
|
||||
.filter((t) => t.column !== "archived");
|
||||
.filter((t) => !isArchivedColumn(t.column));
|
||||
if (visibleTaskIds.length === 0) return false;
|
||||
return visibleTaskIds.every((t) => selectedTaskIds.has(t.id));
|
||||
}, [groupedTasks, selectedTaskIds]);
|
||||
}, [groupedTasks, isArchivedColumn, selectedTaskIds]);
|
||||
|
||||
// Check if some (but not all) visible tasks are selected
|
||||
const isSelectIndeterminate = useMemo(() => {
|
||||
const visibleTaskIds = Object.values(groupedTasks)
|
||||
.flat()
|
||||
.filter((t) => t.column !== "archived");
|
||||
.filter((t) => !isArchivedColumn(t.column));
|
||||
if (visibleTaskIds.length === 0) return false;
|
||||
const selectedCount = visibleTaskIds.filter((t) => selectedTaskIds.has(t.id)).length;
|
||||
return selectedCount > 0 && selectedCount < visibleTaskIds.length;
|
||||
}, [groupedTasks, selectedTaskIds]);
|
||||
}, [groupedTasks, isArchivedColumn, selectedTaskIds]);
|
||||
|
||||
// Bulk edit state and handlers (must be after groupedTasks and clearSelection definition)
|
||||
const [executorModel, setExecutorModel] = useState<string>("__no_change__");
|
||||
@@ -707,16 +872,16 @@ export function ListView({
|
||||
const selectedTasks = Array.from(selectedTaskIds)
|
||||
.map((id) => tasks.find((task) => task.id === id))
|
||||
.filter((task): task is Task => Boolean(task));
|
||||
const archivedTasks = selectedTasks.filter((task) => task.column === "archived");
|
||||
const deletableTasks = selectedTasks.filter((task) => task.column !== "archived");
|
||||
const archivedTasks = selectedTasks.filter((task) => isArchivedColumn(task.column));
|
||||
const deletableTasks = selectedTasks.filter((task) => !isArchivedColumn(task.column));
|
||||
|
||||
if (deletableTasks.length === 0) {
|
||||
addToast(t("listView.bulkDeleteNoTasks", "No selected tasks can be deleted (archived tasks are excluded)"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const doneTasks = deletableTasks.filter((task) => task.column === "done");
|
||||
const otherTasks = deletableTasks.filter((task) => task.column !== "done");
|
||||
const doneTasks = deletableTasks.filter((task) => isCompleteColumn(task.column));
|
||||
const otherTasks = deletableTasks.filter((task) => !isCompleteColumn(task.column));
|
||||
|
||||
let shouldDeleteAll = false;
|
||||
let shouldArchiveDoneInstead = false;
|
||||
@@ -904,7 +1069,7 @@ export function ListView({
|
||||
: t("listView.bulkDeleteSummary", { count: deletedIds.length, skipped: skippedIds.length, failed: failedIds.length, defaultValue_one: "Deleted {{count}} task · {{skipped}} archived skipped · {{failed}} failed", defaultValue_other: "Deleted {{count}} tasks · {{skipped}} archived skipped · {{failed}} failed" });
|
||||
|
||||
addToast(summaryMessage, failedIds.length > 0 ? "error" : "success");
|
||||
}, [addToast, confirm, confirmWithChoice, onArchiveTask, onDeleteTask, selectedTaskIds, tasks]);
|
||||
}, [addToast, confirm, confirmWithChoice, isArchivedColumn, isCompleteColumn, onArchiveTask, onDeleteTask, selectedTaskIds, tasks]);
|
||||
|
||||
const handleBulkPause = useCallback(async () => {
|
||||
if (selectedTaskIds.size === 0) return;
|
||||
@@ -916,7 +1081,7 @@ export function ListView({
|
||||
const selectedTasks = Array.from(selectedTaskIds)
|
||||
.map((id) => tasks.find((task) => task.id === id))
|
||||
.filter((task): task is Task => Boolean(task));
|
||||
const actionableTasks = selectedTasks.filter((task) => task.column !== "archived" && task.paused !== true);
|
||||
const actionableTasks = selectedTasks.filter((task) => !isArchivedColumn(task.column) && task.paused !== true);
|
||||
const skippedCount = selectedTasks.length - actionableTasks.length;
|
||||
|
||||
if (actionableTasks.length === 0) {
|
||||
@@ -955,7 +1120,7 @@ export function ListView({
|
||||
t("listView.bulkPauseSummary", "Paused {{paused}} · {{skipped}} skipped · {{failed}} failed", { paused: pausedIds.length, skipped: skippedCount, failed: failedIds.length }),
|
||||
failedIds.length > 0 ? "error" : "success",
|
||||
);
|
||||
}, [addToast, onPauseTask, selectedTaskIds, tasks]);
|
||||
}, [addToast, isArchivedColumn, onPauseTask, selectedTaskIds, tasks]);
|
||||
|
||||
const handleBulkUnpause = useCallback(async () => {
|
||||
if (selectedTaskIds.size === 0) return;
|
||||
@@ -967,7 +1132,7 @@ export function ListView({
|
||||
const selectedTasks = Array.from(selectedTaskIds)
|
||||
.map((id) => tasks.find((task) => task.id === id))
|
||||
.filter((task): task is Task => Boolean(task));
|
||||
const actionableTasks = selectedTasks.filter((task) => task.column !== "archived" && task.paused === true);
|
||||
const actionableTasks = selectedTasks.filter((task) => !isArchivedColumn(task.column) && task.paused === true);
|
||||
const skippedCount = selectedTasks.length - actionableTasks.length;
|
||||
|
||||
if (actionableTasks.length === 0) {
|
||||
@@ -1006,7 +1171,7 @@ export function ListView({
|
||||
t("listView.bulkUnpauseSummary", "Unpaused {{unpaused}} · {{skipped}} skipped · {{failed}} failed", { unpaused: unpausedIds.length, skipped: skippedCount, failed: failedIds.length }),
|
||||
failedIds.length > 0 ? "error" : "success",
|
||||
);
|
||||
}, [addToast, onUnpauseTask, selectedTaskIds, tasks]);
|
||||
}, [addToast, isArchivedColumn, onUnpauseTask, selectedTaskIds, tasks]);
|
||||
|
||||
const handleBulkArchive = useCallback(async () => {
|
||||
if (selectedTaskIds.size === 0) return;
|
||||
@@ -1018,7 +1183,7 @@ export function ListView({
|
||||
const selectedTasks = Array.from(selectedTaskIds)
|
||||
.map((id) => tasks.find((task) => task.id === id))
|
||||
.filter((task): task is Task => Boolean(task));
|
||||
const actionableTasks = selectedTasks.filter((task) => task.column === "done");
|
||||
const actionableTasks = selectedTasks.filter((task) => isCompleteColumn(task.column));
|
||||
const skippedCount = selectedTasks.length - actionableTasks.length;
|
||||
|
||||
if (actionableTasks.length === 0) {
|
||||
@@ -1092,14 +1257,14 @@ export function ListView({
|
||||
t("listView.bulkArchiveSummary", "Archived {{archived}} · {{skipped}} skipped · {{failed}} failed", { archived: archivedIds.length, skipped: skippedCount, failed: failedIds.length }),
|
||||
failedIds.length > 0 ? "error" : "success",
|
||||
);
|
||||
}, [addToast, confirm, onArchiveTask, selectedTaskIds, tasks]);
|
||||
}, [addToast, confirm, isCompleteColumn, onArchiveTask, selectedTaskIds, tasks]);
|
||||
|
||||
const handleApplyBulkUpdate = useCallback(async () => {
|
||||
if (selectedTaskIds.size === 0) return;
|
||||
|
||||
const taskIds = Array.from(selectedTaskIds).filter((id) => {
|
||||
const task = tasks.find((t) => t.id === id);
|
||||
return task && task.column !== "archived";
|
||||
return task && !isArchivedColumn(task.column);
|
||||
});
|
||||
|
||||
if (taskIds.length === 0) {
|
||||
@@ -1189,7 +1354,7 @@ export function ListView({
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
}, [selectedTaskIds, tasks, executorModel, validatorModel, nodeOverride, projectId, addToast, clearSelection, onTasksUpdated]);
|
||||
}, [selectedTaskIds, tasks, executorModel, validatorModel, nodeOverride, projectId, addToast, clearSelection, isArchivedColumn, onTasksUpdated]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(task: Task) => {
|
||||
@@ -1326,7 +1491,7 @@ export function ListView({
|
||||
}, [isMobile, sidebarWidth]);
|
||||
|
||||
const handleColumnDragOver = useCallback(
|
||||
(e: React.DragEvent, column: Column) => {
|
||||
(e: React.DragEvent, column: ColumnId) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverColumn(column);
|
||||
@@ -1339,14 +1504,14 @@ export function ListView({
|
||||
}, []);
|
||||
|
||||
const handleColumnDrop = useCallback(
|
||||
async (e: React.DragEvent, column: Column) => {
|
||||
async (e: React.DragEvent, column: ColumnId) => {
|
||||
e.preventDefault();
|
||||
setDragOverColumn(null);
|
||||
const taskId = e.dataTransfer.getData("text/plain");
|
||||
if (!taskId) return;
|
||||
|
||||
// Prevent dropping into archived column
|
||||
if (column === "archived") {
|
||||
if (isArchivedColumn(column)) {
|
||||
addToast(t("listView.archiveViaButton", "Tasks can only be archived via the archive button"), "error");
|
||||
return;
|
||||
}
|
||||
@@ -1354,7 +1519,10 @@ export function ListView({
|
||||
try {
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
const hasStepProgress = task?.steps.some((step) => step.status !== "pending") ?? false;
|
||||
const shouldPrompt = (column === "todo" || column === "triage") && hasStepProgress;
|
||||
const targetFlags = columnFlagsById.get(column);
|
||||
const shouldPrompt = hasStepProgress && (
|
||||
column === "todo" || column === "triage" || Boolean(targetFlags?.intake || targetFlags?.hold)
|
||||
);
|
||||
|
||||
let moveOptions: { preserveProgress?: boolean } | undefined;
|
||||
if (shouldPrompt) {
|
||||
@@ -1386,7 +1554,7 @@ export function ListView({
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
},
|
||||
[onMoveTask, addToast, tasks, confirm]
|
||||
[addToast, columnFlagsById, confirm, isArchivedColumn, onMoveTask, tasks, t]
|
||||
);
|
||||
|
||||
const getSortIcon = (field: SortField) => {
|
||||
@@ -1398,6 +1566,44 @@ export function ListView({
|
||||
);
|
||||
};
|
||||
|
||||
const renderWorkflowSelector = () => {
|
||||
if (!workflowMode) return null;
|
||||
const showSelect = workflowOptions.length > 1 && selectedWorkflow;
|
||||
if (!showSelect && !onCreateWorkflow) return null;
|
||||
return (
|
||||
<div className="list-workflow-control">
|
||||
{showSelect && (
|
||||
<label className="list-workflow-selector">
|
||||
<span>{t("listView.workflowLabel", "Workflow")}</span>
|
||||
<select
|
||||
className="select list-workflow-select"
|
||||
value={selectedWorkflow!.id}
|
||||
onChange={(event) => setSelectedWorkflowId(event.target.value)}
|
||||
aria-label={t("listView.workflowSelectLabel", "Select workflow")}
|
||||
>
|
||||
{workflowOptions.map((workflow) => (
|
||||
<option key={workflow.id} value={workflow.id}>
|
||||
{workflow.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{onCreateWorkflow && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm list-workflow-create-btn"
|
||||
onClick={onCreateWorkflow}
|
||||
title={t("workflows.newWorkflow", "New workflow")}
|
||||
aria-label={t("workflows.newWorkflow", "New workflow")}
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderViewOptionsPanel = (panelId: string) => (
|
||||
<div id={panelId} className="list-view-options-panel">
|
||||
<div className="list-view-options-columns">
|
||||
@@ -1447,9 +1653,12 @@ export function ListView({
|
||||
{stalePausedReviewOnlyFilter ? t("listView.showAll", "Show all") : t("listView.stalePausedReview", "Stale paused review")}
|
||||
</button>
|
||||
<div className="list-drop-zones list-drop-zones--sidebar">
|
||||
{COLUMNS.map((column) => {
|
||||
const totalCount = tasks.filter((t) => t.column === column).length;
|
||||
const isCompletedColumn = column === "done" || column === "archived";
|
||||
{listColumns.map((columnDef) => {
|
||||
const column = columnDef.id;
|
||||
const totalCount = selectedWorkflowTaskIds
|
||||
? tasks.filter((task) => task.column === column && selectedWorkflowTaskIds.has(task.id)).length
|
||||
: tasks.filter((task) => task.column === column).length;
|
||||
const isCompletedColumn = Boolean(columnDef.flags.complete || columnDef.flags.archived);
|
||||
const visibleCount = hideDoneTasks && isCompletedColumn ? 0 : totalCount;
|
||||
const showPartial = hideDoneTasks && isCompletedColumn && totalCount > 0;
|
||||
|
||||
@@ -1463,8 +1672,8 @@ export function ListView({
|
||||
onDrop={(e) => handleColumnDrop(e, column)}
|
||||
data-column={column}
|
||||
>
|
||||
<span className={`list-section-dot dot-${column}`} />
|
||||
<span className="drop-zone-label">{columnLabel(column)}</span>
|
||||
<span className={`list-section-dot dot-${column}`} style={{ backgroundColor: columnColor(column) }} />
|
||||
<span className="drop-zone-label">{getListColumnLabel(column)}</span>
|
||||
<span className="drop-zone-count">
|
||||
{showPartial ? `${visibleCount} of ${totalCount}` : totalCount}
|
||||
</span>
|
||||
@@ -1564,6 +1773,7 @@ export function ListView({
|
||||
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
|
||||
{bulkEditEnabled ? t("listView.doneEditing", "Done Editing") : t("listView.bulkEdit", "Bulk Edit")}
|
||||
</button>
|
||||
{renderWorkflowSelector()}
|
||||
<button
|
||||
className="btn btn-sm list-view-options-toggle"
|
||||
onClick={() => setViewOptionsOpen((prev) => !prev)}
|
||||
@@ -1580,7 +1790,7 @@ export function ListView({
|
||||
) : null}
|
||||
<div className="list-stats">
|
||||
{selectedColumn
|
||||
? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: columnLabel(selectedColumn) })
|
||||
? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: getListColumnLabel(selectedColumn) })
|
||||
: t("listView.stats", "{{count}} of {{total}} tasks", { count: filteredCount, total: tasks.length })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1615,13 +1825,14 @@ export function ListView({
|
||||
<div className="list-sidebar-controls__header">
|
||||
<p className="list-stats">
|
||||
{selectedColumn
|
||||
? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: columnLabel(selectedColumn) })
|
||||
? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: getListColumnLabel(selectedColumn) })
|
||||
: t("listView.stats", "{{count}} of {{total}} tasks", { count: filteredCount, total: tasks.length })}
|
||||
{hiddenCompletedCount > 0 && !selectedColumn && (
|
||||
<span className="list-stats-hidden"> ({t("listView.hidden", "{{count}} hidden", { count: hiddenCompletedCount })})</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="list-sidebar-controls__actions">
|
||||
{renderWorkflowSelector()}
|
||||
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
|
||||
{bulkEditEnabled ? t("listView.doneEditing", "Done Editing") : t("listView.bulkEdit", "Bulk Edit")}
|
||||
</button>
|
||||
@@ -1634,7 +1845,7 @@ export function ListView({
|
||||
<div className="list-sidebar-summary-chips">
|
||||
{selectedColumn ? (
|
||||
<button className="btn btn-sm" onClick={clearColumnFilter} aria-label={t("listView.clearColumnFilter", "Clear column filter")}>
|
||||
{t("listView.filterChip", "Filter: {{column}}", { column: columnLabel(selectedColumn) })}
|
||||
{t("listView.filterChip", "Filter: {{column}}", { column: getListColumnLabel(selectedColumn) })}
|
||||
</button>
|
||||
) : null}
|
||||
{hideDoneTasks ? <span className="list-sidebar-chip">{t("listView.doneHiddenChip", "Done hidden")}</span> : null}
|
||||
@@ -1665,7 +1876,7 @@ export function ListView({
|
||||
)}
|
||||
<div className="list-quick-entry-above-table">
|
||||
<QuickEntryBox
|
||||
onCreate={onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error"))}
|
||||
onCreate={handleListQuickCreate}
|
||||
addToast={addToast}
|
||||
tasks={tasks}
|
||||
availableModels={availableModels}
|
||||
@@ -1695,9 +1906,10 @@ export function ListView({
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
<div className="list-cards">
|
||||
{COLUMNS.map((column) => {
|
||||
{listColumns.map((columnDef) => {
|
||||
const column = columnDef.id;
|
||||
if (selectedColumn && column !== selectedColumn) return null;
|
||||
if (hideDoneTasks && (column === "done" || column === "archived") && !selectedColumn) return null;
|
||||
if (hideDoneTasks && (columnDef.flags.complete || columnDef.flags.archived) && !selectedColumn) return null;
|
||||
|
||||
const columnTasks = groupedTasks[column];
|
||||
const isEmpty = columnTasks.length === 0;
|
||||
@@ -1724,8 +1936,8 @@ export function ListView({
|
||||
size={14}
|
||||
className={`list-section-chevron${!isCollapsed ? " list-section-chevron--expanded" : ""}`}
|
||||
/>
|
||||
<span className={`list-section-dot dot-${column}`} />
|
||||
<span className="list-section-title">{columnLabel(column)}</span>
|
||||
<span className={`list-section-dot dot-${column}`} style={{ backgroundColor: columnColor(column) }} />
|
||||
<span className="list-section-title">{getListColumnLabel(column)}</span>
|
||||
<span className="list-section-count">{columnTasks.length}</span>
|
||||
</div>
|
||||
|
||||
@@ -1735,7 +1947,7 @@ export function ListView({
|
||||
<div className="list-empty-cell list-card-empty">{t("listView.noTasks", "No tasks")}</div>
|
||||
) : (
|
||||
columnTasks.map((task) => {
|
||||
const isDoneColumn = task.column === "done";
|
||||
const isDoneColumn = isCompleteColumn(task.column);
|
||||
const visualStatus = isDoneColumn ? "done" : task.status;
|
||||
const isFailed = !isDoneColumn && task.status === "failed";
|
||||
const isPaused = !isDoneColumn && task.paused === true;
|
||||
@@ -1769,7 +1981,7 @@ export function ListView({
|
||||
toggleTaskSelection(task.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={task.column === "archived"}
|
||||
disabled={isArchivedColumn(task.column)}
|
||||
aria-label={t("listView.selectTask", "Select {{taskId}}", { taskId: task.id })}
|
||||
/>
|
||||
</label>
|
||||
@@ -1882,12 +2094,13 @@ export function ListView({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COLUMNS.map((column) => {
|
||||
{listColumns.map((columnDef) => {
|
||||
const column = columnDef.id;
|
||||
// When column filter is active, only show the selected column
|
||||
if (selectedColumn && column !== selectedColumn) return null;
|
||||
|
||||
// Skip done and archived column sections when hideDoneTasks is enabled (unless it's the selected column)
|
||||
if (hideDoneTasks && (column === "done" || column === "archived") && !selectedColumn) return null;
|
||||
if (hideDoneTasks && (columnDef.flags.complete || columnDef.flags.archived) && !selectedColumn) return null;
|
||||
|
||||
const columnTasks = groupedTasks[column];
|
||||
const isEmpty = columnTasks.length === 0;
|
||||
@@ -1910,8 +2123,8 @@ export function ListView({
|
||||
size={14}
|
||||
className={`list-section-chevron${!isCollapsed ? " list-section-chevron--expanded" : ""}`}
|
||||
/>
|
||||
<span className={`list-section-dot dot-${column}`} />
|
||||
<span className="list-section-title">{columnLabel(column)}</span>
|
||||
<span className={`list-section-dot dot-${column}`} style={{ backgroundColor: columnColor(column) }} />
|
||||
<span className="list-section-title">{getListColumnLabel(column)}</span>
|
||||
<span className="list-section-count">{columnTasks.length}</span>
|
||||
</th>
|
||||
</tr>
|
||||
@@ -1927,7 +2140,7 @@ export function ListView({
|
||||
</tr>
|
||||
) : (
|
||||
columnTasks.map((task) => {
|
||||
const isDoneColumn = task.column === "done";
|
||||
const isDoneColumn = isCompleteColumn(task.column);
|
||||
const visualStatus = isDoneColumn ? "done" : task.status;
|
||||
const isFailed = !isDoneColumn && task.status === "failed";
|
||||
const isPaused = !isDoneColumn && task.paused === true;
|
||||
@@ -1964,7 +2177,7 @@ export function ListView({
|
||||
toggleTaskSelection(task.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={task.column === "archived"}
|
||||
disabled={isArchivedColumn(task.column)}
|
||||
aria-label={t("listView.selectTask", "Select {{taskId}}", { taskId: task.id })}
|
||||
/>
|
||||
</td>
|
||||
@@ -2019,7 +2232,7 @@ export function ListView({
|
||||
color: columnColor(task.column),
|
||||
}}
|
||||
>
|
||||
{columnLabel(task.column)}
|
||||
{getListColumnLabel(task.column)}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
|
||||
@@ -635,6 +635,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -666,15 +669,27 @@
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-split-resize-handle {
|
||||
position: relative;
|
||||
width: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
cursor: col-resize;
|
||||
background: color-mix(in srgb, var(--border) 70%, transparent);
|
||||
background: transparent;
|
||||
touch-action: none;
|
||||
transition: background var(--transition-fast);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-split-resize-handle:hover,
|
||||
.mailbox-view .mailbox-split-resize-handle:focus-visible {
|
||||
.mailbox-view .mailbox-split-resize-handle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: var(--space-xs);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-split-resize-handle:hover::before,
|
||||
.mailbox-view .mailbox-split-resize-handle:active::before {
|
||||
background: color-mix(in srgb, var(--todo) 35%, transparent);
|
||||
}
|
||||
|
||||
|
||||
@@ -584,6 +584,7 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -598,4 +599,3 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -259,6 +259,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wf-editor-mobile-back {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-editor-canvas-empty {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
@@ -1185,14 +1189,55 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wf-editor-sidebar {
|
||||
.wf-editor-body--list-stage .wf-editor-sidebar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-height: 30vh;
|
||||
flex: 1 1 auto;
|
||||
max-height: none;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wf-editor-body--list-stage .wf-editor-canvas-wrap,
|
||||
.wf-editor-body--list-stage .wf-editor-inspector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-editor-body--editor-stage .wf-editor-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-editor-body--editor-stage .wf-editor-canvas-wrap {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wf-editor-body--editor-stage .wf-editor-inspector {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-height: 45vh;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wf-editor-mobile-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
gap: var(--space-xs);
|
||||
margin: var(--space-sm) var(--space-sm) 0;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wf-editor-sidebar .wf-column-panel {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -1211,7 +1256,7 @@
|
||||
|
||||
.wf-editor-canvas-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 40vh;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wf-template-list {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type Edge as FlowEdge,
|
||||
} from "@xyflow/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, Library, Sparkles } from "lucide-react";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles } from "lucide-react";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
@@ -84,6 +84,24 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
|
||||
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
|
||||
|
||||
function builtinSeamPrompt(config: Record<string, unknown> | undefined): string {
|
||||
const seam = typeof config?.seam === "string" ? config.seam : "";
|
||||
switch (seam) {
|
||||
case "planning":
|
||||
return "Generate the task plan and write the planning artifact that downstream workflow nodes consume.";
|
||||
case "execute":
|
||||
return "Run Fusion's standard implementation prompt for this task, generated from the task spec, current project context, workflow fields, and available tools.";
|
||||
case "step-execute":
|
||||
return "Run Fusion's step implementation prompt for the active planned step in the task worktree.";
|
||||
case "review":
|
||||
return "Run Fusion's review boundary for completed implementation work and route the task based on the review result.";
|
||||
case "merge":
|
||||
return "Run Fusion's merge boundary: verify merge readiness, apply the configured merge strategy, and update the final task state.";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Adapter descriptor served by GET /api/cli-agents (U15). */
|
||||
interface CliAdapterDescriptorView {
|
||||
id: string;
|
||||
@@ -152,6 +170,8 @@ interface WorkflowNodeEditorProps {
|
||||
* mount (U6/U9: redirect stubs link here via a `?panel=settings` param read by
|
||||
* the editor's mount site). */
|
||||
initialPanel?: "settings";
|
||||
/** When "create" the editor opens with the new-workflow dialog active. */
|
||||
initialAction?: "create";
|
||||
}
|
||||
|
||||
let nodeSeq = 0;
|
||||
@@ -633,10 +653,15 @@ function InnerEditor({
|
||||
addToast,
|
||||
projectId,
|
||||
initialPanel,
|
||||
initialAction,
|
||||
modalRef,
|
||||
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
|
||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [workflowListStageOpen, setWorkflowListStageOpen] = useState(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
||||
return window.matchMedia("(max-width: 768px)").matches;
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
@@ -652,7 +677,7 @@ function InnerEditor({
|
||||
const { confirm } = useConfirm();
|
||||
// Create-workflow dialog (KTD-7) open state + focus-return ref to the
|
||||
// "New workflow" button (NewTaskModal focus pattern).
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(initialAction === "create");
|
||||
const newWorkflowBtnRef = useRef<HTMLButtonElement>(null);
|
||||
// Inline-editable name/description (KTD-10). `name`/`description` mirror the
|
||||
// active workflow and are persisted through handleSave; `editingName`/
|
||||
@@ -1784,6 +1809,13 @@ function InnerEditor({
|
||||
|
||||
const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null;
|
||||
const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? null;
|
||||
const selectedNodePromptValue =
|
||||
selectedNode && (selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate")
|
||||
? String(
|
||||
selectedNode.data.config?.prompt
|
||||
?? (isBuiltin ? builtinSeamPrompt(selectedNode.data.config as Record<string, unknown> | undefined) : ""),
|
||||
)
|
||||
: "";
|
||||
// The edge inspector renders different controls per source-node kind (KTD-2):
|
||||
// step-review → verdict controls; prompt/script/gate/code/foreach →
|
||||
// success/failure select; everything else → a read-only condition note.
|
||||
@@ -1975,8 +2007,14 @@ function InnerEditor({
|
||||
// before the active workflow changes (cancel keeps the current selection).
|
||||
const requestSwitch = useCallback(
|
||||
(id: string) => {
|
||||
if (id === activeId) return;
|
||||
guardedDismiss(() => setActiveId(id));
|
||||
if (id === activeId) {
|
||||
setWorkflowListStageOpen(false);
|
||||
return;
|
||||
}
|
||||
guardedDismiss(() => {
|
||||
setActiveId(id);
|
||||
setWorkflowListStageOpen(false);
|
||||
});
|
||||
},
|
||||
[guardedDismiss, activeId],
|
||||
);
|
||||
@@ -2050,7 +2088,7 @@ function InnerEditor({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="wf-editor-body">
|
||||
<div className={`wf-editor-body${workflowListStageOpen ? " wf-editor-body--list-stage" : " wf-editor-body--editor-stage"}`}>
|
||||
<aside className="wf-editor-sidebar">
|
||||
<button
|
||||
className="wf-editor-new"
|
||||
@@ -2190,6 +2228,7 @@ function InnerEditor({
|
||||
readOnly={isBuiltin}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
initialTab="values"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2199,6 +2238,15 @@ function InnerEditor({
|
||||
</aside>
|
||||
|
||||
<section className="wf-editor-canvas-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="wf-editor-mobile-back"
|
||||
onClick={() => setWorkflowListStageOpen(true)}
|
||||
aria-label={t("workflows.backToWorkflowList", "Back to workflows")}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
<span>{t("common.back", "Back")}</span>
|
||||
</button>
|
||||
{activeWorkflow ? (
|
||||
<>
|
||||
{/* Inline name + description strip (KTD-10). Built-ins render as
|
||||
@@ -2681,7 +2729,7 @@ function InnerEditor({
|
||||
<span>Prompt</span>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={String(selectedNode.data.config?.prompt ?? "")}
|
||||
value={selectedNodePromptValue}
|
||||
onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
padding: var(--space-md);
|
||||
border-left: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wf-settings-panel-header {
|
||||
@@ -46,7 +47,7 @@
|
||||
}
|
||||
|
||||
.wf-settings-tab.is-active {
|
||||
color: var(--text-primary, #fff);
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent, #4f7cff);
|
||||
}
|
||||
|
||||
@@ -137,7 +138,7 @@
|
||||
.wf-setting-id-static {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-2, rgba(255, 255, 255, 0.04));
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -181,7 +182,7 @@
|
||||
.wf-setting-sub > span {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wf-setting--checkbox {
|
||||
@@ -203,7 +204,7 @@
|
||||
.wf-setting-options-label {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wf-setting-option-row {
|
||||
@@ -233,7 +234,7 @@
|
||||
}
|
||||
|
||||
.wf-setting-color-swatch.is-active {
|
||||
outline: 2px solid var(--text-primary, #fff);
|
||||
outline: 2px solid var(--text);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -333,7 +334,7 @@
|
||||
.wf-settings-orphan-id {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wf-settings-orphan-value {
|
||||
|
||||
@@ -69,6 +69,7 @@ interface WorkflowSettingsPanelProps {
|
||||
* no active project (Values tab shows a requires-project state). */
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
initialTab?: "definitions" | "values";
|
||||
}
|
||||
|
||||
const SETTING_TYPES: WorkflowSettingType[] = [
|
||||
@@ -816,9 +817,12 @@ export function WorkflowSettingsPanel({
|
||||
readOnly,
|
||||
projectId,
|
||||
addToast,
|
||||
initialTab,
|
||||
}: WorkflowSettingsPanelProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [tab, setTab] = useState<"definitions" | "values">(() => (settings.length > 0 ? "values" : "definitions"));
|
||||
const [tab, setTab] = useState<"definitions" | "values">(
|
||||
() => initialTab ?? (settings.length > 0 ? "values" : "definitions"),
|
||||
);
|
||||
|
||||
// Bind the projectId active when the panel first mounted for this workflow.
|
||||
// The Values tab uses this bound id; a later change to `projectId` surfaces a
|
||||
|
||||
@@ -7,6 +7,8 @@ import { COLUMNS } from "@fusion/core";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
const fetchBatchMock = vi.fn();
|
||||
const pendingWorkflowSteps = () => new Promise<never>(() => {});
|
||||
const fetchWorkflowStepsMock = vi.fn().mockImplementation(pendingWorkflowSteps);
|
||||
|
||||
vi.mock("../../hooks/useBatchBadgeFetch", () => ({
|
||||
useBatchBadgeFetch: vi.fn(() => ({
|
||||
@@ -17,18 +19,12 @@ vi.mock("../../hooks/useBatchBadgeFetch", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
const fetchBoardWorkflowsMock = vi.fn().mockResolvedValue({
|
||||
flagEnabled: false,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
const pendingBoardWorkflows = () => new Promise<never>(() => {});
|
||||
const fetchBoardWorkflowsMock = vi.fn().mockImplementation(pendingBoardWorkflows);
|
||||
const promoteTaskMock = vi.fn().mockResolvedValue({});
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([
|
||||
{ id: "WS-003", name: "Accessibility Audit", enabled: true },
|
||||
]),
|
||||
fetchWorkflowSteps: (...args: unknown[]) => fetchWorkflowStepsMock(...args),
|
||||
fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args),
|
||||
promoteTask: (...args: unknown[]) => promoteTaskMock(...args),
|
||||
}));
|
||||
@@ -52,10 +48,10 @@ const columnRenderCounts: Record<string, number> = {};
|
||||
|
||||
// Mock child components so we only test Board's own rendering
|
||||
vi.mock("../Column", () => ({
|
||||
Column: React.memo(({ column, tasks, onToggleCollapse, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, workflowStepNameLookup }: { column: string; tasks: Task[]; onToggleCollapse?: () => void; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; isSearchActive?: boolean; workflowStepNameLookup?: ReadonlyMap<string, string> }) => {
|
||||
Column: React.memo(({ column, tasks, onToggleCollapse, onQuickCreate, onNewTask, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, workflowStepNameLookup }: { column: string; tasks: Task[]; onToggleCollapse?: () => void; onQuickCreate?: unknown; onNewTask?: unknown; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; isSearchActive?: boolean; workflowStepNameLookup?: ReadonlyMap<string, string> }) => {
|
||||
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
|
||||
return (
|
||||
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"} data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)}>
|
||||
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"} data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)}>
|
||||
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
|
||||
</div>
|
||||
);
|
||||
@@ -94,16 +90,13 @@ const noopAsync = () => Promise.resolve({} as any);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchBatchMock.mockReset();
|
||||
fetchWorkflowStepsMock.mockReset();
|
||||
fetchWorkflowStepsMock.mockImplementation(pendingWorkflowSteps);
|
||||
promoteTaskMock.mockClear();
|
||||
subscribeSseMock.mockClear();
|
||||
for (const key of Object.keys(sseHandlers)) delete sseHandlers[key];
|
||||
fetchBoardWorkflowsMock.mockReset();
|
||||
fetchBoardWorkflowsMock.mockResolvedValue({
|
||||
flagEnabled: false,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
fetchBoardWorkflowsMock.mockImplementation(pendingBoardWorkflows);
|
||||
try {
|
||||
window.localStorage.clear();
|
||||
} catch {
|
||||
@@ -219,6 +212,9 @@ describe("Board", () => {
|
||||
});
|
||||
|
||||
it("forwards board-level workflow name lookup to columns", async () => {
|
||||
fetchWorkflowStepsMock.mockResolvedValue([
|
||||
{ id: "WS-003", name: "Accessibility Audit", enabled: true },
|
||||
]);
|
||||
renderBoard();
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -926,7 +922,12 @@ describe("Board", () => {
|
||||
}
|
||||
|
||||
it("flag OFF renders the legacy single-lane board byte-identically", async () => {
|
||||
// Default mock: flagEnabled false.
|
||||
fetchBoardWorkflowsMock.mockResolvedValue({
|
||||
flagEnabled: false,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1" })] });
|
||||
// Let the board-workflows fetch resolve (flagEnabled:false) so the async
|
||||
// state settle is wrapped and the legacy board stays the rendered output.
|
||||
@@ -940,16 +941,58 @@ describe("Board", () => {
|
||||
expect(screen.queryByTestId(/^lane-/)).toBeNull();
|
||||
});
|
||||
|
||||
it("tasks with no selection render in the default lane (R16)", async () => {
|
||||
it("tasks with no selection render in the default selected workflow", async () => {
|
||||
enableFlag({ "FN-1": "builtin:coding", "FN-2": "builtin:coding" });
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "in-progress" })] });
|
||||
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined());
|
||||
const lane = screen.getByTestId("lane-builtin:coding");
|
||||
expect(JSON.parse(lane.getAttribute("data-lane-task-ids") || "[]").sort()).toEqual(["FN-1", "FN-2"]);
|
||||
expect(lane.getAttribute("data-lane-count")).toBe("2");
|
||||
await waitFor(() => expect(screen.getByTestId("column-todo")).toBeDefined());
|
||||
expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]);
|
||||
expect(JSON.parse(screen.getByTestId("column-in-progress").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-2"]);
|
||||
expect(screen.queryByLabelText("Select workflow")).toBeNull();
|
||||
});
|
||||
|
||||
it("each card appears in exactly one lane over a mixed fixture", async () => {
|
||||
it("puts create controls on the workflow intake column instead of the first visible column", async () => {
|
||||
const workflow = {
|
||||
id: "wf-intake-second",
|
||||
name: "Intake second",
|
||||
columns: [
|
||||
{ id: "queue", name: "Queue", flags: { hold: true } },
|
||||
{ id: "idea", name: "Idea", flags: { intake: true } },
|
||||
{ id: "shipped", name: "Shipped", flags: { complete: true } },
|
||||
],
|
||||
};
|
||||
enableFlag({ "FN-1": workflow.id }, [workflow]);
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1", column: "queue" })] });
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("column-queue")).toBeDefined());
|
||||
|
||||
expect(screen.getByTestId("column-queue").getAttribute("data-has-new-task")).toBe("no");
|
||||
expect(screen.getByTestId("column-queue").getAttribute("data-has-quick-create")).toBe("no");
|
||||
expect(screen.getByTestId("column-idea").getAttribute("data-has-new-task")).toBe("yes");
|
||||
expect(screen.getByTestId("column-idea").getAttribute("data-has-quick-create")).toBe("yes");
|
||||
});
|
||||
|
||||
it("keeps workflow create and edit actions visible when only one workflow exists", async () => {
|
||||
const onCreateWorkflow = vi.fn();
|
||||
const onOpenWorkflowEditor = vi.fn();
|
||||
enableFlag({ "FN-1": "builtin:coding" }, [DEFAULT_WORKFLOW]);
|
||||
|
||||
renderBoard({
|
||||
tasks: [mkTask({ id: "FN-1", column: "triage" })],
|
||||
onCreateWorkflow,
|
||||
onOpenWorkflowEditor,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("column-triage")).toBeDefined());
|
||||
expect(screen.queryByLabelText("Select workflow")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "New workflow" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit workflows" }));
|
||||
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenWorkflowEditor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders one selected workflow at a time and switches workflows from the dropdown", async () => {
|
||||
const onCreateWorkflow = vi.fn();
|
||||
enableFlag(
|
||||
{ "FN-1": "builtin:coding", "FN-2": "wf-custom", "FN-3": "wf-custom" },
|
||||
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
|
||||
@@ -960,57 +1003,75 @@ describe("Board", () => {
|
||||
mkTask({ id: "FN-2", column: "intake" }),
|
||||
mkTask({ id: "FN-3", column: "intake" }),
|
||||
],
|
||||
onCreateWorkflow,
|
||||
});
|
||||
await waitFor(() => expect(screen.getByTestId("lane-wf-custom")).toBeDefined());
|
||||
const defaultLaneIds = JSON.parse(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-task-ids") || "[]");
|
||||
const customLaneIds = JSON.parse(screen.getByTestId("lane-wf-custom").getAttribute("data-lane-task-ids") || "[]");
|
||||
const all = [...defaultLaneIds, ...customLaneIds].sort();
|
||||
expect(all).toEqual(["FN-1", "FN-2", "FN-3"]);
|
||||
// No id appears twice.
|
||||
expect(new Set(all).size).toBe(all.length);
|
||||
const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
|
||||
expect(selector.value).toBe("builtin:coding");
|
||||
fireEvent.click(screen.getByRole("button", { name: "New workflow" }));
|
||||
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]);
|
||||
expect(screen.queryByTestId("column-intake")).toBeNull();
|
||||
|
||||
fireEvent.change(selector, { target: { value: "wf-custom" } });
|
||||
await waitFor(() => expect(screen.getByTestId("column-intake")).toBeDefined());
|
||||
expect(JSON.parse(screen.getByTestId("column-intake").getAttribute("data-tasks") || "[]").map((task: Task) => task.id).sort()).toEqual(["FN-2", "FN-3"]);
|
||||
expect(screen.queryByTestId("column-todo")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides zero-card lanes and puts the default lane first", async () => {
|
||||
it("keeps the default workflow first in the dropdown even when another workflow has cards", async () => {
|
||||
enableFlag(
|
||||
{ "FN-2": "wf-custom" },
|
||||
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
|
||||
);
|
||||
// Only the custom workflow has a card; the default lane has none → hidden.
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-2", column: "intake" })] });
|
||||
await waitFor(() => expect(screen.getByTestId("lane-wf-custom")).toBeDefined());
|
||||
expect(screen.queryByTestId("lane-builtin:coding")).toBeNull();
|
||||
const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
|
||||
const options = [...selector.options].map((option) => option.value);
|
||||
expect(options).toEqual(["builtin:coding", "wf-custom"]);
|
||||
expect(selector.value).toBe("builtin:coding");
|
||||
expect(screen.queryByTestId("column-intake")).toBeNull();
|
||||
});
|
||||
|
||||
it("orders the default lane first when both have cards", async () => {
|
||||
it("orders the default workflow first when workflow payload order differs", async () => {
|
||||
enableFlag(
|
||||
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
|
||||
[CUSTOM_WORKFLOW, DEFAULT_WORKFLOW],
|
||||
);
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })] });
|
||||
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined());
|
||||
const lanes = screen.getAllByTestId(/^lane-/);
|
||||
expect(lanes[0].getAttribute("data-testid")).toBe("lane-builtin:coding");
|
||||
const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
|
||||
expect([...selector.options].map((option) => option.value)).toEqual(["builtin:coding", "wf-custom"]);
|
||||
});
|
||||
|
||||
it("excludes archived cards from lanes", async () => {
|
||||
it("excludes archived cards from the selected workflow board", async () => {
|
||||
enableFlag({ "FN-1": "builtin:coding", "FN-9": "builtin:coding" });
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-9", column: "archived" })] });
|
||||
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined());
|
||||
const ids = JSON.parse(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-task-ids") || "[]");
|
||||
await waitFor(() => expect(screen.getByTestId("column-todo")).toBeDefined());
|
||||
const ids = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id);
|
||||
expect(ids).toEqual(["FN-1"]);
|
||||
expect(screen.queryByTestId("column-archived")).toBeNull();
|
||||
});
|
||||
|
||||
it("persists lane collapse state to localStorage", async () => {
|
||||
window.localStorage.setItem("kb-dashboard-lane-collapsed", JSON.stringify(["builtin:coding"]));
|
||||
enableFlag({ "FN-1": "builtin:coding" });
|
||||
it("renders selected workflow columns as direct children of the horizontal board", async () => {
|
||||
enableFlag({ "FN-1": "builtin:coding" }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1" })] });
|
||||
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined());
|
||||
expect(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-collapsed")).toBe("true");
|
||||
await waitFor(() => expect(screen.getByRole("main").className).toContain("board-workflow-columns"));
|
||||
expect([...screen.getByRole("main").children].map((child) => child.getAttribute("data-testid"))).toEqual([
|
||||
"column-triage",
|
||||
"column-todo",
|
||||
"column-in-progress",
|
||||
"column-in-review",
|
||||
"column-done",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow:updated SSE invalidation (#1406)", () => {
|
||||
it("re-fetches board-workflows when a workflow:updated SSE event arrives", async () => {
|
||||
fetchBoardWorkflowsMock.mockResolvedValue({
|
||||
flagEnabled: false,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
renderBoard({ projectId: "proj-1" });
|
||||
// Initial mount fetch.
|
||||
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("../WorktreeGroup", () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock("../QuickEntryBox", () => ({
|
||||
QuickEntryBox: ({ favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, autoExpand }: { favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; autoExpand?: boolean }) => (
|
||||
QuickEntryBox: ({ favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, autoExpand, onCreate }: { favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; autoExpand?: boolean; onCreate?: (input: { description: string }) => void }) => (
|
||||
<div
|
||||
data-testid="quick-entry-box"
|
||||
data-favorite-providers={JSON.stringify(favoriteProviders ?? [])}
|
||||
@@ -28,7 +28,9 @@ vi.mock("../QuickEntryBox", () => ({
|
||||
data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"}
|
||||
data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"}
|
||||
data-auto-expand={autoExpand === false ? "false" : "true"}
|
||||
/>
|
||||
>
|
||||
<button type="button" onClick={() => onCreate?.({ description: "Quick task" })}>create</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("lucide-react", () => ({
|
||||
@@ -409,6 +411,28 @@ describe("Column QuickEntryBox", () => {
|
||||
const quickEntry = screen.getByTestId("quick-entry-box");
|
||||
expect(quickEntry.getAttribute("data-auto-expand")).toBe("false");
|
||||
});
|
||||
|
||||
it("preserves selected built-in workflow id when quick-creating in workflow mode", async () => {
|
||||
const onQuickCreate = vi.fn().mockResolvedValue({});
|
||||
render(
|
||||
<Column
|
||||
{...defaultProps}
|
||||
column="triage"
|
||||
workflowMode
|
||||
workflowId="builtin:coding"
|
||||
tasks={[]}
|
||||
onQuickCreate={onQuickCreate}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "create" }));
|
||||
|
||||
await waitFor(() => expect(onQuickCreate).toHaveBeenCalledWith({
|
||||
description: "Quick task",
|
||||
column: "triage",
|
||||
workflowId: "builtin:coding",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column in-progress/in-review bulk actions", () => {
|
||||
|
||||
@@ -91,6 +91,15 @@ describe("Lane", () => {
|
||||
expect(headings).not.toContain("Archived");
|
||||
});
|
||||
|
||||
it("renders creation controls only in the first visible column", () => {
|
||||
render(<Lane {...baseProps()} onQuickCreate={vi.fn()} onNewTask={vi.fn()} />);
|
||||
|
||||
expect(screen.getAllByTestId("quick-entry-box")).toHaveLength(1);
|
||||
expect(screen.getAllByText("+ New Task")).toHaveLength(1);
|
||||
expect(screen.getByTestId("quick-entry-box").closest("[data-column]")?.getAttribute("data-column")).toBe("triage");
|
||||
expect(screen.getByText("+ New Task").closest("[data-column]")?.getAttribute("data-column")).toBe("triage");
|
||||
});
|
||||
|
||||
it("collapses the lane (hides columns) when collapsed", () => {
|
||||
render(<Lane {...baseProps()} collapsed />);
|
||||
expect(screen.queryByText("Triage")).toBeNull();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ListView } from "../ListView";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
@@ -22,12 +23,135 @@ vi.mock("../../api", () => ({
|
||||
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
fetchTaskDetail: vi.fn(),
|
||||
batchUpdateTaskModels: vi.fn(),
|
||||
fetchNodes: vi.fn().mockResolvedValue([]),
|
||||
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }),
|
||||
fetchNodes: vi.fn(() => new Promise(() => {})),
|
||||
fetchBoardWorkflows: vi.fn(() => new Promise(() => {})),
|
||||
api: vi.fn().mockResolvedValue({ sessions: [] }),
|
||||
}));
|
||||
|
||||
import { fetchTaskDetail, batchUpdateTaskModels, fetchNodes } from "../../api";
|
||||
const listViewSseHandlers: Record<string, (event?: unknown) => void> = {};
|
||||
const subscribeSseMock = vi.fn(
|
||||
(_url: string, opts: { events?: Record<string, (event?: unknown) => void> }) => {
|
||||
for (const [name, handler] of Object.entries(opts.events ?? {})) {
|
||||
listViewSseHandlers[name] = handler;
|
||||
}
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: (...args: unknown[]) => (subscribeSseMock as (...a: unknown[]) => () => void)(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../QuickEntryBox", () => ({
|
||||
QuickEntryBox: ({
|
||||
onCreate,
|
||||
addToast,
|
||||
}: {
|
||||
onCreate?: (input: { description: string }) => Promise<unknown>;
|
||||
addToast: (message: string, type?: "error" | "success" | "info" | "warning") => void;
|
||||
}) => {
|
||||
const [value, setValue] = useState("");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
const description = value.trim();
|
||||
if (!description || !onCreate) return;
|
||||
try {
|
||||
await onCreate({ description });
|
||||
setValue("");
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : "Failed to create task", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="quick-entry-box" data-testid="quick-entry-box">
|
||||
<textarea
|
||||
className="quick-entry-input"
|
||||
data-testid="quick-entry-input"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="quick-entry-toggle"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((next) => !next)}
|
||||
>
|
||||
Toggle
|
||||
</button>
|
||||
<div id="quick-entry-controls" data-testid="quick-entry-actions" hidden={!expanded}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="quick-entry-models"
|
||||
onClick={() => setModelMenuOpen((next) => !next)}
|
||||
>
|
||||
Models
|
||||
</button>
|
||||
<button type="button" data-testid="quick-entry-deps">Deps</button>
|
||||
<button type="button" data-testid="quick-entry-save" onClick={() => void submit()}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{modelMenuOpen ? (
|
||||
<div data-testid="model-nested-menu">
|
||||
<button type="button">Plan</button>
|
||||
<button type="button">Executor</button>
|
||||
<button type="button">Reviewer</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../TaskDetailModal", () => ({
|
||||
TaskDetailContent: ({
|
||||
task,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
task: Task | TaskDetail;
|
||||
onOpenDetail?: (task: Task | TaskDetail) => void;
|
||||
}) => (
|
||||
<div data-testid="task-detail-content">
|
||||
<span>{task.id}</span>
|
||||
{(task.dependencies ?? []).map((dependencyId) => (
|
||||
<button
|
||||
key={dependencyId}
|
||||
type="button"
|
||||
role="link"
|
||||
onClick={() =>
|
||||
onOpenDetail?.({
|
||||
id: dependencyId,
|
||||
title: dependencyId,
|
||||
description: dependencyId,
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
status: "pending",
|
||||
paused: false,
|
||||
log: [],
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
prompt: `# ${dependencyId}`,
|
||||
} as TaskDetail)
|
||||
}
|
||||
>
|
||||
{dependencyId}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { fetchTaskDetail, batchUpdateTaskModels, fetchBoardWorkflows, fetchNodes } from "../../api";
|
||||
|
||||
const mockConfirm = vi.fn();
|
||||
const mockConfirmWithChoice = vi.fn();
|
||||
@@ -99,14 +223,36 @@ const renderListView = (
|
||||
if (options.openViewOptions ?? true) {
|
||||
const viewOptionsToggle = screen.queryByRole("button", { name: /view options/i });
|
||||
if (viewOptionsToggle) {
|
||||
fireEvent.click(viewOptionsToggle);
|
||||
act(() => {
|
||||
fireEvent.click(viewOptionsToggle);
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const clickInAct = (element: Element) => {
|
||||
act(() => {
|
||||
fireEvent.click(element);
|
||||
});
|
||||
};
|
||||
|
||||
const clickAndFlush = async (element: Element) => {
|
||||
await act(async () => {
|
||||
fireEvent.click(element);
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const keyDownAndFlush = async (element: Element, init: Parameters<typeof fireEvent.keyDown>[1]) => {
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(element, init);
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const enterBulkEditMode = () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Bulk Edit" }));
|
||||
clickInAct(screen.getByRole("button", { name: "Bulk Edit" }));
|
||||
};
|
||||
|
||||
const showAllColumnsByDefault = () => {
|
||||
@@ -176,12 +322,16 @@ function mockDesktopViewport() {
|
||||
describe("ListView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchNodes).mockImplementation(() => new Promise(() => {}));
|
||||
vi.mocked(fetchBoardWorkflows).mockImplementation(() => new Promise(() => {}));
|
||||
vi.mocked(fetchTaskDetail).mockResolvedValue({
|
||||
...createMockTask(),
|
||||
prompt: "# Detail",
|
||||
} as TaskDetail);
|
||||
mockConfirm.mockReset();
|
||||
mockConfirmWithChoice.mockReset();
|
||||
subscribeSseMock.mockClear();
|
||||
for (const key of Object.keys(listViewSseHandlers)) delete listViewSseHandlers[key];
|
||||
localStorage.clear();
|
||||
ensureMatchMedia();
|
||||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
@@ -398,9 +548,10 @@ describe("ListView", () => {
|
||||
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-001");
|
||||
expect(row?.className).toContain("list-row--selected");
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-001", TEST_PROJECT_ID);
|
||||
expect(screen.getByTestId("list-split-detail-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("task-detail-content")).toHaveTextContent("FN-001");
|
||||
});
|
||||
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -439,6 +590,92 @@ describe("ListView", () => {
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("refreshes workflow columns when workflow metadata SSE arrives", async () => {
|
||||
vi.mocked(fetchBoardWorkflows)
|
||||
.mockResolvedValueOnce({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom",
|
||||
workflows: [
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "backlog", name: "Backlog", flags: { intake: true } },
|
||||
{ id: "complete", name: "Complete", flags: { complete: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom",
|
||||
workflows: [
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "ready", name: "Ready", flags: { intake: true } },
|
||||
{ id: "complete", name: "Complete", flags: { complete: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom" },
|
||||
});
|
||||
|
||||
renderListView({
|
||||
tasks: [createMockTask({ id: "FN-001", column: "backlog", title: "Workflow task" })],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryAllByText("Backlog").length).toBeGreaterThan(0));
|
||||
expect(typeof listViewSseHandlers["workflow:updated"]).toBe("function");
|
||||
|
||||
await act(async () => {
|
||||
listViewSseHandlers["workflow:updated"]?.();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryAllByText("Ready").length).toBeGreaterThan(0));
|
||||
expect(screen.queryAllByText("Backlog")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows a new-workflow action next to the workflow selector", async () => {
|
||||
const onCreateWorkflow = vi.fn();
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [
|
||||
{
|
||||
id: "builtin:coding",
|
||||
name: "Coding",
|
||||
columns: [
|
||||
{ id: "triage", name: "Triage", flags: { intake: true } },
|
||||
{ id: "done", name: "Done", flags: { complete: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "backlog", name: "Backlog", flags: { intake: true } },
|
||||
{ id: "complete", name: "Complete", flags: { complete: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "builtin:coding" },
|
||||
});
|
||||
|
||||
renderListView({
|
||||
tasks: [createMockTask({ id: "FN-001", column: "triage", title: "Workflow task" })],
|
||||
onCreateWorkflow,
|
||||
});
|
||||
|
||||
await screen.findByLabelText("Select workflow");
|
||||
const createButtons = screen.getAllByRole("button", { name: "New workflow" });
|
||||
expect(createButtons.length).toBeGreaterThan(0);
|
||||
fireEvent.click(createButtons[0]);
|
||||
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps embedded selection visible when filters hide the selected row", async () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
const tasks = [
|
||||
@@ -480,16 +717,6 @@ describe("ListView", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001", title: "Parent Task", dependencies: ["FN-002"] })];
|
||||
const mockOnOpenDetail = vi.fn();
|
||||
|
||||
vi.mocked(fetchTaskDetail)
|
||||
.mockResolvedValueOnce({
|
||||
...tasks[0],
|
||||
prompt: "# Parent detail",
|
||||
} as TaskDetail)
|
||||
.mockResolvedValueOnce({
|
||||
...createMockTask({ id: "FN-002", title: "Child Task" }),
|
||||
prompt: "# Child detail",
|
||||
} as TaskDetail);
|
||||
|
||||
renderListView({ tasks, onOpenDetail: mockOnOpenDetail });
|
||||
|
||||
fireEvent.click(screen.getByText("FN-001").closest("tr")!);
|
||||
@@ -502,9 +729,10 @@ describe("ListView", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-002");
|
||||
expect(fetchTaskDetail).toHaveBeenNthCalledWith(2, "FN-002", TEST_PROJECT_ID);
|
||||
expect(screen.getByTestId("task-detail-content")).toHaveTextContent("FN-002");
|
||||
});
|
||||
|
||||
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
||||
expect(mockOnOpenDetail).not.toHaveBeenCalled();
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
@@ -519,8 +747,8 @@ describe("ListView", () => {
|
||||
enterBulkEditMode();
|
||||
const checkbox = within(row).getByRole("checkbox", { name: "Select FN-001" });
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
fireEvent.click(row);
|
||||
clickInAct(checkbox);
|
||||
clickInAct(row);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-selected-tasks"))).toContain("FN-001");
|
||||
@@ -1136,6 +1364,52 @@ describe("ListView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prompts to preserve progress when dropping task with completed steps to a workflow hold column", async () => {
|
||||
const tasks = [createMockTask({
|
||||
id: "FN-001",
|
||||
column: "doing",
|
||||
steps: [
|
||||
{ title: "Step 1", status: "done" },
|
||||
{ title: "Step 2", status: "pending" },
|
||||
],
|
||||
})];
|
||||
const mockOnMoveTask = vi.fn(() => Promise.resolve(tasks[0]));
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom",
|
||||
workflows: [
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "queue", name: "Queue", flags: { hold: true } },
|
||||
{ id: "doing", name: "Doing", flags: { countsTowardWip: true } },
|
||||
{ id: "shipped", name: "Shipped", flags: { complete: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom" },
|
||||
});
|
||||
|
||||
renderListView({ tasks, onMoveTask: mockOnMoveTask });
|
||||
await waitFor(() => expect(document.querySelector('[data-column="queue"].list-drop-zone')).toBeTruthy());
|
||||
|
||||
fireEvent.drop(document.querySelector('[data-column="queue"].list-drop-zone')!, {
|
||||
preventDefault: vi.fn(),
|
||||
dataTransfer: {
|
||||
getData: vi.fn(() => "FN-001"),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: "Preserve Progress?",
|
||||
}));
|
||||
expect(mockOnMoveTask).toHaveBeenCalledWith("FN-001", "queue", { preserveProgress: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set draggable for paused tasks", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001", paused: true })];
|
||||
|
||||
@@ -2029,6 +2303,7 @@ describe("ListView Hide Done Tasks", () => {
|
||||
describe("ListView Quick Entry", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchBoardWorkflows).mockImplementation(() => new Promise(() => {}));
|
||||
});
|
||||
|
||||
it("renders QuickEntryBox when onQuickCreate is provided", () => {
|
||||
@@ -2120,6 +2395,42 @@ describe("ListView Quick Entry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves selected built-in workflow id when quick-creating in workflow mode", async () => {
|
||||
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:default",
|
||||
workflows: [
|
||||
{
|
||||
id: "builtin:default",
|
||||
name: "Default",
|
||||
columns: [{ id: "triage", name: "Triage", flags: { intake: true } }],
|
||||
},
|
||||
{
|
||||
id: "builtin:coding",
|
||||
name: "Coding",
|
||||
columns: [{ id: "triage", name: "Triage", flags: { intake: true } }],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
renderListView({ onQuickCreate: mockOnQuickCreate });
|
||||
|
||||
const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
|
||||
fireEvent.change(selector, { target: { value: "builtin:coding" } });
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(input, { target: { value: "Built-in workflow task" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnQuickCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: "Built-in workflow task",
|
||||
column: "triage",
|
||||
workflowId: "builtin:coding",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when onQuickCreate fails and keeps input content", async () => {
|
||||
const mockOnQuickCreate = vi.fn().mockRejectedValue(new Error("Create failed"));
|
||||
renderListView({ onQuickCreate: mockOnQuickCreate });
|
||||
@@ -2158,16 +2469,18 @@ describe("ListView Quick Entry", () => {
|
||||
renderListView({ onQuickCreate: mockOnQuickCreate });
|
||||
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await keyDownAndFlush(input, { key: "Enter" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(mockOnQuickCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("QuickEntryBox textarea spans full container width in list view (FN-1579)", () => {
|
||||
it("QuickEntryBox textarea spans full container width in list view (FN-1579)", async () => {
|
||||
mockDesktopViewport();
|
||||
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
|
||||
renderListView({ onQuickCreate: mockOnQuickCreate });
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const quickEntryBox = screen.getByTestId("quick-entry-box");
|
||||
const input = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||
@@ -2189,10 +2502,11 @@ describe("ListView Quick Entry", () => {
|
||||
describe("ListView Collapsible Sections", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchBoardWorkflows).mockImplementation(() => new Promise(() => {}));
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("clicking section header toggles collapse and hides task rows", () => {
|
||||
it("clicking section header toggles collapse and hides task rows", async () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "triage", title: "Planning Task 1" }),
|
||||
createMockTask({ id: "FN-002", column: "triage", title: "Planning Task 2" }),
|
||||
@@ -2209,7 +2523,7 @@ describe("ListView Collapsible Sections", () => {
|
||||
r.className.includes("list-section-header") && r.textContent?.includes("Planning")
|
||||
);
|
||||
expect(triageHeader).toBeDefined();
|
||||
fireEvent.click(triageHeader!);
|
||||
await clickAndFlush(triageHeader!);
|
||||
|
||||
// Tasks should be hidden after collapse
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
@@ -2223,7 +2537,7 @@ describe("ListView Collapsible Sections", () => {
|
||||
expect(chevron?.className).not.toContain("list-section-chevron--expanded");
|
||||
});
|
||||
|
||||
it("clicking again expands section and shows task rows", () => {
|
||||
it("clicking again expands section and shows task rows", async () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "triage", title: "Planning Task" }),
|
||||
];
|
||||
@@ -2236,13 +2550,16 @@ describe("ListView Collapsible Sections", () => {
|
||||
);
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(triageHeader!);
|
||||
await clickAndFlush(triageHeader!);
|
||||
|
||||
// Task should be hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
|
||||
// Click again to expand
|
||||
fireEvent.click(triageHeader!);
|
||||
triageHeader = screen.getAllByRole("row").find(r =>
|
||||
r.className.includes("list-section-header") && r.textContent?.includes("Planning")
|
||||
);
|
||||
await clickAndFlush(triageHeader!);
|
||||
|
||||
// Task should be visible again
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
@@ -2259,7 +2576,7 @@ describe("ListView Collapsible Sections", () => {
|
||||
expect(triageHeader?.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("collapse state persists to localStorage", () => {
|
||||
it("collapse state persists to localStorage", async () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "triage", title: "Planning Task" }),
|
||||
];
|
||||
@@ -2270,7 +2587,7 @@ describe("ListView Collapsible Sections", () => {
|
||||
const triageHeader = screen.getAllByRole("row").find(r =>
|
||||
r.className.includes("list-section-header") && r.textContent?.includes("Planning")
|
||||
);
|
||||
fireEvent.click(triageHeader!);
|
||||
await clickAndFlush(triageHeader!);
|
||||
|
||||
// Verify localStorage was updated
|
||||
const saved = localStorage.getItem(scopedStorageKey("kb-dashboard-list-collapsed"));
|
||||
@@ -2303,7 +2620,7 @@ describe("ListView Collapsible Sections", () => {
|
||||
expect(triageHeader?.className).toContain("list-section-header--collapsed");
|
||||
});
|
||||
|
||||
it("multiple sections can be collapsed independently", () => {
|
||||
it("multiple sections can be collapsed independently", async () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "triage", title: "Planning Task" }),
|
||||
createMockTask({ id: "FN-002", column: "todo", title: "Todo Task" }),
|
||||
@@ -2320,10 +2637,10 @@ describe("ListView Collapsible Sections", () => {
|
||||
const todoHeader = allHeaders.find(h => h.textContent?.includes("Todo"));
|
||||
|
||||
// Collapse triage section
|
||||
fireEvent.click(triageHeader!);
|
||||
await clickAndFlush(triageHeader!);
|
||||
|
||||
// Collapse todo section
|
||||
fireEvent.click(todoHeader!);
|
||||
await clickAndFlush(todoHeader!);
|
||||
|
||||
// Planning and todo tasks should be hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
@@ -2344,7 +2661,7 @@ describe("ListView Collapsible Sections", () => {
|
||||
expect(parsed).not.toContain("in-progress");
|
||||
});
|
||||
|
||||
it("sorting still works with collapsed sections", () => {
|
||||
it("sorting still works with collapsed sections", async () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-003", column: "triage", title: "Charlie" }),
|
||||
createMockTask({ id: "FN-001", column: "triage", title: "Alpha" }),
|
||||
@@ -2357,14 +2674,17 @@ describe("ListView Collapsible Sections", () => {
|
||||
const triageHeader = screen.getAllByRole("row").find(r =>
|
||||
r.className.includes("list-section-header") && r.textContent?.includes("Planning")
|
||||
);
|
||||
fireEvent.click(triageHeader!);
|
||||
await clickAndFlush(triageHeader!);
|
||||
|
||||
// Expand triage section
|
||||
fireEvent.click(triageHeader!);
|
||||
const collapsedTriageHeader = screen.getAllByRole("row").find(r =>
|
||||
r.className.includes("list-section-header") && r.textContent?.includes("Planning")
|
||||
);
|
||||
await clickAndFlush(collapsedTriageHeader!);
|
||||
|
||||
// Sort by title
|
||||
const titleHeader = screen.getByRole("columnheader", { name: /title/i });
|
||||
fireEvent.click(titleHeader);
|
||||
await clickAndFlush(titleHeader);
|
||||
|
||||
// Get sorted rows and verify sorting still works
|
||||
const rows = screen.getAllByRole("row").filter(r => r.getAttribute("data-id"));
|
||||
@@ -2373,7 +2693,7 @@ describe("ListView Collapsible Sections", () => {
|
||||
expect(rows[2].textContent).toContain("FN-003"); // Charlie
|
||||
});
|
||||
|
||||
it("filtering still works with collapsed sections", () => {
|
||||
it("filtering still works with collapsed sections", async () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "triage", title: "Alpha Task" }),
|
||||
createMockTask({ id: "FN-002", column: "triage", title: "Beta Task" }),
|
||||
@@ -2385,17 +2705,20 @@ describe("ListView Collapsible Sections", () => {
|
||||
const triageHeader = screen.getAllByRole("row").find(r =>
|
||||
r.className.includes("list-section-header") && r.textContent?.includes("Planning")
|
||||
);
|
||||
fireEvent.click(triageHeader!);
|
||||
await clickAndFlush(triageHeader!);
|
||||
|
||||
// Expand triage section by clicking again
|
||||
fireEvent.click(triageHeader!);
|
||||
const collapsedTriageHeader = screen.getAllByRole("row").find(r =>
|
||||
r.className.includes("list-section-header") && r.textContent?.includes("Planning")
|
||||
);
|
||||
await clickAndFlush(collapsedTriageHeader!);
|
||||
|
||||
// Only Alpha task should be visible (filter is applied via prop)
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.queryByText("FN-002")).toBeNull();
|
||||
});
|
||||
|
||||
it("section header has aria-expanded attribute for accessibility", () => {
|
||||
it("section header has aria-expanded attribute for accessibility", async () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "triage", title: "Planning Task" }),
|
||||
];
|
||||
@@ -2411,13 +2734,13 @@ describe("ListView Collapsible Sections", () => {
|
||||
expect(triageHeader?.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(triageHeader!);
|
||||
await clickAndFlush(triageHeader!);
|
||||
|
||||
// Should have aria-expanded="false" when collapsed
|
||||
expect(triageHeader?.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("collapsed section hides No tasks placeholder", () => {
|
||||
it("collapsed section hides No tasks placeholder", async () => {
|
||||
// Create tasks in one column, leave another column empty
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "triage", title: "Planning Task" }),
|
||||
@@ -2434,7 +2757,7 @@ describe("ListView Collapsible Sections", () => {
|
||||
r.className.includes("list-section-header") && r.textContent?.includes("Todo")
|
||||
);
|
||||
expect(todoHeader).toBeDefined();
|
||||
fireEvent.click(todoHeader!);
|
||||
await clickAndFlush(todoHeader!);
|
||||
|
||||
// When collapsed, the section header should have collapsed class
|
||||
expect(todoHeader?.className).toContain("list-section-header--collapsed");
|
||||
@@ -2448,6 +2771,10 @@ describe("ListView Collapsible Sections", () => {
|
||||
describe("ListView - Bulk Selection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchNodes).mockImplementation(() => new Promise(() => {}));
|
||||
vi.mocked(fetchBoardWorkflows).mockImplementation(() => new Promise(() => {}));
|
||||
subscribeSseMock.mockClear();
|
||||
for (const key of Object.keys(listViewSseHandlers)) delete listViewSseHandlers[key];
|
||||
localStorage.clear();
|
||||
ensureMatchMedia();
|
||||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
@@ -2510,6 +2837,33 @@ describe("ListView - Bulk Selection", () => {
|
||||
expect(checkbox).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables checkbox for workflow archived columns", async () => {
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom",
|
||||
workflows: [
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "active", name: "Active", flags: { countsTowardWip: true } },
|
||||
{ id: "parked", name: "Parked", flags: { archived: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom" },
|
||||
});
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "parked" }),
|
||||
];
|
||||
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} />);
|
||||
await waitFor(() => expect(screen.queryAllByText("Parked").length).toBeGreaterThan(0));
|
||||
enterBulkEditMode();
|
||||
|
||||
expect(screen.getByLabelText("Select FN-001")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows selection count when tasks are selected", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001" }),
|
||||
@@ -2519,7 +2873,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
enterBulkEditMode();
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
fireEvent.click(checkbox);
|
||||
clickInAct(checkbox);
|
||||
|
||||
expect(screen.getByText("1 selected")).toBeDefined();
|
||||
});
|
||||
@@ -2532,11 +2886,11 @@ describe("ListView - Bulk Selection", () => {
|
||||
enterBulkEditMode();
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
fireEvent.click(checkbox);
|
||||
clickInAct(checkbox);
|
||||
expect(screen.getByText("1 selected")).toBeDefined();
|
||||
|
||||
const clearButton = screen.getByRole("button", { name: /^1 selected$/i });
|
||||
fireEvent.click(clearButton);
|
||||
clickInAct(clearButton);
|
||||
|
||||
expect(screen.queryByText("1 selected")).toBeNull();
|
||||
});
|
||||
@@ -2550,7 +2904,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
enterBulkEditMode();
|
||||
|
||||
const selectAllCheckbox = screen.getByLabelText("Select all visible tasks");
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
clickInAct(selectAllCheckbox);
|
||||
|
||||
expect(screen.getByRole("button", { name: /^2 selected$/i })).toBeDefined();
|
||||
});
|
||||
@@ -2580,7 +2934,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
|
||||
// Select a task to show bulk edit toolbar with dropdowns
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
fireEvent.click(checkbox);
|
||||
clickInAct(checkbox);
|
||||
|
||||
expect(screen.getByText("Bulk Edit Models & Node:")).toBeDefined();
|
||||
});
|
||||
@@ -2603,7 +2957,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
enterBulkEditMode();
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
fireEvent.click(checkbox);
|
||||
clickInAct(checkbox);
|
||||
|
||||
expect(screen.getByText("Bulk Edit Models & Node:")).toBeDefined();
|
||||
});
|
||||
@@ -2626,7 +2980,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
enterBulkEditMode();
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
fireEvent.click(checkbox);
|
||||
clickInAct(checkbox);
|
||||
|
||||
const applyButton = screen.getByText("Apply");
|
||||
expect(applyButton).toBeDisabled();
|
||||
@@ -2765,6 +3119,115 @@ describe("ListView - Bulk Selection", () => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Archived 1 · 1 skipped · 0 failed", "success");
|
||||
});
|
||||
|
||||
it("archives workflow complete-column tasks in bulk selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom",
|
||||
workflows: [
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "doing", name: "Doing", flags: { countsTowardWip: true } },
|
||||
{ id: "shipped", name: "Shipped", flags: { complete: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom", "FN-002": "wf-custom" },
|
||||
});
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "shipped" }),
|
||||
createMockTask({ id: "FN-002", column: "doing" }),
|
||||
];
|
||||
const onArchiveTask = vi.fn(async () => createMockTask());
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002"]));
|
||||
|
||||
renderListView({ tasks, onArchiveTask });
|
||||
enterBulkEditMode();
|
||||
await waitFor(() => expect(screen.queryAllByText("Shipped").length).toBeGreaterThan(0));
|
||||
await user.click(screen.getByRole("button", { name: /^archive selected$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onArchiveTask).toHaveBeenCalledTimes(1);
|
||||
expect(onArchiveTask).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Archived 1 · 1 skipped · 0 failed", "success");
|
||||
});
|
||||
|
||||
it("skips workflow archived-column tasks when pausing in bulk", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom",
|
||||
workflows: [
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "active", name: "Active", flags: { countsTowardWip: true } },
|
||||
{ id: "parked", name: "Parked", flags: { archived: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom", "FN-002": "wf-custom" },
|
||||
});
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "active", paused: false }),
|
||||
createMockTask({ id: "FN-002", column: "parked", paused: false }),
|
||||
];
|
||||
const onPauseTask = vi.fn(async () => createMockTask());
|
||||
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002"]));
|
||||
|
||||
renderListView({ tasks, onPauseTask });
|
||||
enterBulkEditMode();
|
||||
await waitFor(() => expect(screen.queryAllByText("Parked").length).toBeGreaterThan(0));
|
||||
await user.click(screen.getByRole("button", { name: /^pause selected$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPauseTask).toHaveBeenCalledTimes(1);
|
||||
expect(onPauseTask).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Paused 1 · 1 skipped · 0 failed", "success");
|
||||
});
|
||||
|
||||
it("skips workflow archived-column tasks when unpausing in bulk", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom",
|
||||
workflows: [
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "active", name: "Active", flags: { countsTowardWip: true } },
|
||||
{ id: "parked", name: "Parked", flags: { archived: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom", "FN-002": "wf-custom" },
|
||||
});
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "active", paused: true }),
|
||||
createMockTask({ id: "FN-002", column: "parked", paused: true }),
|
||||
];
|
||||
const onUnpauseTask = vi.fn(async () => createMockTask());
|
||||
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002"]));
|
||||
|
||||
renderListView({ tasks, onUnpauseTask });
|
||||
enterBulkEditMode();
|
||||
await waitFor(() => expect(screen.queryAllByText("Parked").length).toBeGreaterThan(0));
|
||||
await user.click(screen.getByRole("button", { name: /^unpause selected$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUnpauseTask).toHaveBeenCalledTimes(1);
|
||||
expect(onUnpauseTask).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Unpaused 1 · 1 skipped · 0 failed", "success");
|
||||
});
|
||||
|
||||
it("shows error summary when pause has failures", async () => {
|
||||
const user = userEvent.setup();
|
||||
const tasks = [createMockTask({ id: "FN-001", paused: false })];
|
||||
@@ -2853,6 +3316,48 @@ describe("ListView - Bulk Selection", () => {
|
||||
expect(screen.getByText("1 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses workflow complete and archived flags when bulk delete archives done tasks", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom",
|
||||
workflows: [
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "doing", name: "Doing", flags: { countsTowardWip: true } },
|
||||
{ id: "shipped", name: "Shipped", flags: { complete: true } },
|
||||
{ id: "parked", name: "Parked", flags: { archived: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom", "FN-002": "wf-custom", "FN-003": "wf-custom" },
|
||||
});
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "shipped" }),
|
||||
createMockTask({ id: "FN-002", column: "doing" }),
|
||||
createMockTask({ id: "FN-003", column: "parked" }),
|
||||
];
|
||||
const onArchiveTask = vi.fn(async () => createMockTask());
|
||||
const onDeleteTask = vi.fn(async () => createMockTask());
|
||||
mockConfirmWithChoice.mockResolvedValueOnce("tertiary");
|
||||
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002", "FN-003"]));
|
||||
|
||||
renderListView({ tasks, onArchiveTask, onDeleteTask });
|
||||
enterBulkEditMode();
|
||||
await waitFor(() => expect(screen.queryAllByText("Shipped").length).toBeGreaterThan(0));
|
||||
await user.click(screen.getByRole("button", { name: /delete selected/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onArchiveTask).toHaveBeenCalledTimes(1);
|
||||
expect(onArchiveTask).toHaveBeenCalledWith("FN-001");
|
||||
expect(onDeleteTask).toHaveBeenCalledTimes(1);
|
||||
expect(onDeleteTask).toHaveBeenCalledWith("FN-002");
|
||||
});
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Archived 1, deleted 1, failed 0", "success");
|
||||
});
|
||||
|
||||
it("does nothing when delete confirm is cancelled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const tasks = [createMockTask({ id: "FN-001" })];
|
||||
@@ -2978,7 +3483,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
enterBulkEditMode();
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
fireEvent.click(checkbox);
|
||||
clickInAct(checkbox);
|
||||
|
||||
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-selected-tasks"))).toBe('["FN-001"]');
|
||||
});
|
||||
@@ -2993,7 +3498,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
|
||||
const checkboxes = screen.getAllByLabelText(/Select FN-/);
|
||||
// Select only first task
|
||||
fireEvent.click(checkboxes[0]);
|
||||
clickInAct(checkboxes[0]);
|
||||
|
||||
// Header checkbox should be indeterminate (partially selected)
|
||||
const headerCheckbox = screen.getByLabelText("Select all visible tasks") as HTMLInputElement;
|
||||
@@ -3100,7 +3605,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
|
||||
enterBulkEditMode();
|
||||
fireEvent.click(screen.getByLabelText("Select FN-001"));
|
||||
clickInAct(screen.getByLabelText("Select FN-001"));
|
||||
|
||||
expect(await screen.findByLabelText("Node Override")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("option", { name: "● Node One (Online)" })).toBeInTheDocument();
|
||||
@@ -3112,7 +3617,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
|
||||
enterBulkEditMode();
|
||||
fireEvent.click(screen.getByLabelText("Select FN-001"));
|
||||
clickInAct(screen.getByLabelText("Select FN-001"));
|
||||
|
||||
expect(await screen.findByRole("option", { name: "○ Node Two (Offline)" })).toBeInTheDocument();
|
||||
});
|
||||
@@ -3177,7 +3682,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
|
||||
enterBulkEditMode();
|
||||
fireEvent.click(screen.getByLabelText("Select FN-001"));
|
||||
clickInAct(screen.getByLabelText("Select FN-001"));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Apply" })).toBeDisabled();
|
||||
});
|
||||
@@ -3403,7 +3908,7 @@ describe("ListView - Bulk Selection", () => {
|
||||
});
|
||||
|
||||
enterBulkEditMode();
|
||||
fireEvent.click(screen.getByLabelText("Select FN-002"));
|
||||
clickInAct(screen.getByLabelText("Select FN-002"));
|
||||
|
||||
expect((screen.getByLabelText("Select FN-001") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("Select FN-002") as HTMLInputElement).checked).toBe(true);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { render, screen, waitFor, cleanup, within } from "@testing-library/react
|
||||
import type { WorkflowDefinition, Settings } from "@fusion/core";
|
||||
import type { Agent } from "../../api";
|
||||
import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout, foreachChildFlowId } from "../workflow-flow-mapping";
|
||||
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "@fusion/core";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR, BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "@fusion/core";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkflows: vi.fn(),
|
||||
@@ -39,6 +39,8 @@ vi.mock("../../api", () => ({
|
||||
fetchSettings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
fetchWorkflowSettingValues: vi.fn().mockResolvedValue({ stored: {}, effective: {}, orphaned: [] }),
|
||||
updateWorkflowSettingValues: vi.fn().mockResolvedValue({ stored: {}, effective: {}, orphaned: [] }),
|
||||
}));
|
||||
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
@@ -119,8 +121,16 @@ function v2Def(): WorkflowDefinition {
|
||||
}
|
||||
|
||||
function builtinDef(): WorkflowDefinition {
|
||||
const d = v2Def();
|
||||
return { ...d, id: "builtin:coding", name: "Default coding workflow", description: "Ships with Fusion" };
|
||||
return {
|
||||
id: "builtin:coding",
|
||||
kind: "workflow",
|
||||
name: "Default coding workflow",
|
||||
description: "Ships with Fusion",
|
||||
ir: BUILTIN_CODING_WORKFLOW_IR,
|
||||
layout: {},
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function fragmentDef(): WorkflowDefinition {
|
||||
@@ -203,6 +213,7 @@ describe("WorkflowNodeEditor", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem("fusion:wf-sidebar-settings-collapsed");
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -840,6 +851,31 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
|
||||
expect(approve).toMatchObject({ from: "step-review", to: "step-done" });
|
||||
expect(approve?.kind).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows the built-in seam prompt text in the read-only node inspector", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
await screen.findByTestId("wf-readonly-banner");
|
||||
const promptNodes = await screen.findAllByTestId("wf-node-prompt");
|
||||
const executeNode = promptNodes.find((node) => within(node).queryByText("Execute"));
|
||||
expect(executeNode).toBeTruthy();
|
||||
|
||||
fireEvent.click(executeNode!);
|
||||
|
||||
expect((screen.getByLabelText("Prompt") as HTMLTextAreaElement).value).toContain(
|
||||
"Fusion's standard implementation prompt",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens workflow settings on the Values tab from the editor sidebar", async () => {
|
||||
localStorage.setItem("fusion:wf-sidebar-settings-collapsed", "0");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} projectId="p1" />);
|
||||
|
||||
await screen.findByTestId("wf-settings-values");
|
||||
expect(screen.getByTestId("wf-settings-tab-values")).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
|
||||
// ── U2: edge-condition authoring (compile-banner split) ─────────────────────
|
||||
@@ -932,6 +968,13 @@ describe("WorkflowNodeEditor — U4 create dialog / delete / inline rename / dir
|
||||
expect(screen.getByTestId("wf-create-dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can open directly into the create dialog", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialAction="create" />);
|
||||
|
||||
expect(await screen.findByTestId("wf-create-dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates and activates a workflow on a valid submit", async () => {
|
||||
const addToast = vi.fn();
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface ModalManager {
|
||||
workflowEditorOpen: boolean;
|
||||
/** When the workflow editor opens, which internal panel to pre-select (U9 redirect stubs). */
|
||||
workflowEditorInitialPanel?: "settings";
|
||||
/** When the workflow editor opens, which modal action to start. */
|
||||
workflowEditorInitialAction?: "create";
|
||||
agentsOpen: boolean;
|
||||
scriptsOpen: boolean;
|
||||
setupWizardOpen: boolean;
|
||||
@@ -118,7 +120,7 @@ export interface ModalManager {
|
||||
openGitManager: () => void;
|
||||
closeGitManager: () => void;
|
||||
|
||||
openWorkflowEditor: (initialPanel?: "settings") => void;
|
||||
openWorkflowEditor: (initialPanelOrAction?: "settings" | "create") => void;
|
||||
closeWorkflowEditor: () => void;
|
||||
|
||||
openAgents: () => void;
|
||||
@@ -178,6 +180,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||
const [workflowEditorOpen, setWorkflowEditorOpen] = useState(false);
|
||||
const [workflowEditorInitialPanel, setWorkflowEditorInitialPanel] = useState<"settings" | undefined>(undefined);
|
||||
const [workflowEditorInitialAction, setWorkflowEditorInitialAction] = useState<"create" | undefined>(undefined);
|
||||
const [agentsOpen, setAgentsOpen] = useState(false);
|
||||
const [scriptsOpen, setScriptsOpen] = useState(false);
|
||||
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
|
||||
@@ -343,13 +346,15 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const openGitManager = useCallback(() => setGitManagerOpen(true), []);
|
||||
const closeGitManager = useCallback(() => setGitManagerOpen(false), []);
|
||||
|
||||
const openWorkflowEditor = useCallback((initialPanel?: "settings") => {
|
||||
setWorkflowEditorInitialPanel(initialPanel);
|
||||
const openWorkflowEditor = useCallback((initialPanelOrAction?: "settings" | "create") => {
|
||||
setWorkflowEditorInitialPanel(initialPanelOrAction === "settings" ? "settings" : undefined);
|
||||
setWorkflowEditorInitialAction(initialPanelOrAction === "create" ? "create" : undefined);
|
||||
setWorkflowEditorOpen(true);
|
||||
}, []);
|
||||
const closeWorkflowEditor = useCallback(() => {
|
||||
setWorkflowEditorOpen(false);
|
||||
setWorkflowEditorInitialPanel(undefined);
|
||||
setWorkflowEditorInitialAction(undefined);
|
||||
}, []);
|
||||
|
||||
const openAgents = useCallback(() => setAgentsOpen(true), []);
|
||||
@@ -418,6 +423,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
gitManagerOpen,
|
||||
workflowEditorOpen,
|
||||
workflowEditorInitialPanel,
|
||||
workflowEditorInitialAction,
|
||||
agentsOpen,
|
||||
scriptsOpen,
|
||||
setupWizardOpen,
|
||||
|
||||
@@ -34,7 +34,7 @@ export function useTaskHandlers(options: UseTaskHandlersOptions): UseTaskHandler
|
||||
|
||||
const handleBoardQuickCreate = useCallback(
|
||||
async (input: TaskCreateInput): Promise<Task> => {
|
||||
return createTask({ ...input, column: "triage", source: { sourceType: "dashboard_ui" } });
|
||||
return createTask({ ...input, column: input.column ?? "triage", source: { sourceType: "dashboard_ui" } });
|
||||
},
|
||||
[createTask],
|
||||
);
|
||||
|
||||
@@ -500,7 +500,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
|
||||
const moveTask = useCallback(async (
|
||||
id: string,
|
||||
column: Column,
|
||||
column: ColumnId,
|
||||
optionsOrPosition?: { preserveProgress?: boolean } | number,
|
||||
): Promise<Task> => {
|
||||
return normalizeTask(await api.moveTask(id, column, projectId, optionsOrPosition));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { buildBoardWorkflowsPayload, DEFAULT_WORKFLOW_LANE_ID } from "../board-workflows.js";
|
||||
import type { WorkflowDefinition } from "@fusion/core";
|
||||
import { parseWorkflowIr } from "@fusion/core";
|
||||
@@ -42,6 +42,9 @@ function makeStore(opts: {
|
||||
async getWorkflowDefinition(id: string) {
|
||||
return opts.defs?.[id];
|
||||
},
|
||||
async listWorkflowDefinitions() {
|
||||
return Object.values(opts.defs ?? {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,6 +74,45 @@ describe("buildBoardWorkflowsPayload", () => {
|
||||
"done",
|
||||
"archived",
|
||||
]);
|
||||
expect(defaultWf!.columns.map((c) => c.name)).toEqual([
|
||||
"Triage",
|
||||
"Todo",
|
||||
"In Progress",
|
||||
"In Review",
|
||||
"Done",
|
||||
"Archived",
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes user-defined workflows even when no visible task references them", async () => {
|
||||
const store = makeStore({
|
||||
flagOn: true,
|
||||
selections: {},
|
||||
defs: { "wf-custom": CUSTOM },
|
||||
});
|
||||
const payload = await buildBoardWorkflowsPayload(store as never, ["FN-1"]);
|
||||
expect(payload.workflows.map((w) => w.id).sort()).toEqual([DEFAULT_WORKFLOW_LANE_ID, "wf-custom"]);
|
||||
});
|
||||
|
||||
it("logs when workflow definition listing fails and falls back to referenced workflows", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const store = {
|
||||
...makeStore({ flagOn: true, selections: {} }),
|
||||
async listWorkflowDefinitions() {
|
||||
throw new Error("db unavailable");
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const payload = await buildBoardWorkflowsPayload(store as never, ["FN-1"]);
|
||||
expect(payload.workflows.map((w) => w.id)).toContain(DEFAULT_WORKFLOW_LANE_ID);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[board-workflows] listWorkflowDefinitions failed"),
|
||||
expect.any(Error),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("describes a custom workflow's columns with resolved trait flags", async () => {
|
||||
|
||||
@@ -44,6 +44,23 @@ function makeFakeAgent(text: string) {
|
||||
return { factory, captured };
|
||||
}
|
||||
|
||||
function makeNonStreamingFakeAgent(text: string) {
|
||||
const captured: { systemPrompt?: string; userPrompt?: string } = {};
|
||||
const factory: any = async (opts: any) => {
|
||||
captured.systemPrompt = opts.systemPrompt;
|
||||
const session = {
|
||||
state: { messages: [] as Array<{ role: string; content: string }> },
|
||||
async prompt(userPrompt: string) {
|
||||
captured.userPrompt = userPrompt;
|
||||
this.state.messages.push({ role: "assistant", content: text });
|
||||
},
|
||||
dispose() {},
|
||||
};
|
||||
return { session };
|
||||
};
|
||||
return { factory, captured };
|
||||
}
|
||||
|
||||
/** A fake agent whose prompt rejects; tracks whether dispose() was called so we
|
||||
* can assert the route releases the session even when the model turn throws. */
|
||||
function makeRejectingAgent() {
|
||||
@@ -161,6 +178,17 @@ describe("POST /api/workflows/design (U7/R11/KTD-6)", () => {
|
||||
expect(res.body.strippedApprovalFlags).toBe(false);
|
||||
});
|
||||
|
||||
it("non-streaming agent session without .on() → reads last assistant message", async () => {
|
||||
const { factory, captured } = makeNonStreamingFakeAgent(JSON.stringify(linearIr()));
|
||||
__setCreateFnAgentForDesign(factory);
|
||||
|
||||
const res = await postJson("/api/workflows/design", { prompt: "a coding flow" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.interpreterOnly).toBe(false);
|
||||
expect(res.body.ir.nodes).toHaveLength(3);
|
||||
expect(captured.userPrompt).toContain("Design a workflow");
|
||||
});
|
||||
|
||||
it("fenced + prose-wrapped JSON → still extracted and 200", async () => {
|
||||
const wrapped = `Sure! Here is your workflow:\n\n\`\`\`json\n${JSON.stringify(linearIr())}\n\`\`\`\n\nLet me know if you want changes.`;
|
||||
const { factory } = makeFakeAgent(wrapped);
|
||||
|
||||
@@ -68,16 +68,30 @@ export interface BoardWorkflowsPayload {
|
||||
taskWorkflowIds: Record<string, string>;
|
||||
}
|
||||
|
||||
const BUILTIN_WORKFLOW_COLUMN_LABELS: Record<string, string> = {
|
||||
triage: "Triage",
|
||||
todo: "Todo",
|
||||
"in-progress": "In Progress",
|
||||
"in-review": "In Review",
|
||||
done: "Done",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
function toV2(ir: WorkflowIr): WorkflowIrV2 | undefined {
|
||||
return ir.version === "v2" ? ir : undefined;
|
||||
}
|
||||
|
||||
function describeColumns(ir: WorkflowIr): BoardWorkflowColumn[] {
|
||||
function displayColumnName(id: string, name: string, canonicalizeLifecycle: boolean): string {
|
||||
if (!canonicalizeLifecycle) return name;
|
||||
return BUILTIN_WORKFLOW_COLUMN_LABELS[id] ?? name;
|
||||
}
|
||||
|
||||
function describeColumns(ir: WorkflowIr, canonicalizeLifecycle = false): BoardWorkflowColumn[] {
|
||||
const v2 = toV2(ir);
|
||||
if (!v2) return [];
|
||||
return v2.columns.map((col) => ({
|
||||
id: col.id,
|
||||
name: col.name,
|
||||
name: displayColumnName(col.id, col.name, canonicalizeLifecycle),
|
||||
flags: resolveColumnFlags(col),
|
||||
}));
|
||||
}
|
||||
@@ -102,7 +116,7 @@ async function describeWorkflow(
|
||||
const ir = await resolveWorkflowIrById(store, workflowId);
|
||||
const name = getBuiltinWorkflow(workflowId)?.name ?? ir.name;
|
||||
const fields = describeFields(ir);
|
||||
return { id: workflowId, name, columns: describeColumns(ir), ...(fields ? { fields } : {}) };
|
||||
return { id: workflowId, name, columns: describeColumns(ir, true), ...(fields ? { fields } : {}) };
|
||||
}
|
||||
// Custom workflow: fetch the definition once and derive both IR and name from
|
||||
// it (previously getWorkflowDefinition was called twice per workflow).
|
||||
@@ -129,7 +143,7 @@ async function describeWorkflow(
|
||||
* can return early and the client renders the legacy board.
|
||||
*/
|
||||
export async function buildBoardWorkflowsPayload(
|
||||
store: Pick<TaskStore, "getWorkflowDefinition" | "getTaskWorkflowSelection" | "getSettings">,
|
||||
store: Pick<TaskStore, "getWorkflowDefinition" | "getTaskWorkflowSelection" | "getSettings" | "listWorkflowDefinitions">,
|
||||
taskIds: string[],
|
||||
settingsOverride?: Pick<Settings, "experimentalFeatures">,
|
||||
): Promise<BoardWorkflowsPayload> {
|
||||
@@ -163,6 +177,19 @@ export async function buildBoardWorkflowsPayload(
|
||||
// resolves it (and the client's default-lane-first ordering is stable).
|
||||
referenced.add(DEFAULT_WORKFLOW_LANE_ID);
|
||||
|
||||
try {
|
||||
const definitions = await store.listWorkflowDefinitions();
|
||||
for (const definition of definitions) {
|
||||
if (definition.kind === "fragment") continue;
|
||||
referenced.add(definition.id);
|
||||
}
|
||||
} catch (err) {
|
||||
// Older/partial test stores may not expose definition listing; the referenced
|
||||
// workflow set above is still sufficient for task rendering. Production
|
||||
// failures are logged so empty workflow definitions do not disappear silently.
|
||||
console.warn("[board-workflows] listWorkflowDefinitions failed; using referenced workflows only", err);
|
||||
}
|
||||
|
||||
const workflows: BoardWorkflowDefinition[] = [];
|
||||
for (const workflowId of referenced) {
|
||||
workflows.push(await describeWorkflow(store, workflowId));
|
||||
|
||||
@@ -23,12 +23,14 @@ export function __resetCreateFnAgentForDesign(): void {
|
||||
createFnAgentForDesign = engineCreateFnAgent;
|
||||
}
|
||||
|
||||
/** Minimal session shape used by the one-shot design turn (mirrors the refine
|
||||
* route's RefineAgentSession): subscribe to text deltas, prompt once, dispose. */
|
||||
/** Minimal session shape used by the one-shot design turn. Some agent backends
|
||||
* stream text deltas, while CLI-agent-backed sessions only expose messages on
|
||||
* state after prompt() resolves. */
|
||||
interface DesignAgentSession {
|
||||
on(event: "text", listener: (delta: string) => void): void;
|
||||
on?: (event: "text", listener: (delta: string) => void) => void;
|
||||
prompt(text: string): Promise<void>;
|
||||
dispose(): void;
|
||||
dispose?: () => void;
|
||||
state?: { messages?: unknown };
|
||||
}
|
||||
|
||||
/** Max design prompt length (chars). Over → 400 (mirrors the bounded prompts on
|
||||
@@ -824,7 +826,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
|
||||
|
||||
const designSession = session as unknown as DesignAgentSession;
|
||||
let output = "";
|
||||
designSession.on("text", (delta: string) => {
|
||||
designSession.on?.("text", (delta: string) => {
|
||||
output += delta;
|
||||
});
|
||||
|
||||
@@ -834,7 +836,11 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
|
||||
try {
|
||||
await designSession.prompt(userPrompt);
|
||||
} finally {
|
||||
designSession.dispose();
|
||||
designSession.dispose?.();
|
||||
}
|
||||
|
||||
if (!output.trim()) {
|
||||
output = extractLastAssistantText(designSession.state?.messages);
|
||||
}
|
||||
|
||||
// Extract JSON (handles fences/prose) → JSON.parse → parseWorkflowIr.
|
||||
@@ -888,6 +894,39 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
|
||||
});
|
||||
}
|
||||
|
||||
function extractLastAssistantText(messages: unknown): string {
|
||||
interface AgentMessage {
|
||||
role?: unknown;
|
||||
type?: unknown;
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
const lastMessage = (Array.isArray(messages) ? messages : [])
|
||||
.filter((message): message is AgentMessage => Boolean(message) && typeof message === "object")
|
||||
.filter((message) => message.role === "assistant" || message.type === "assistant")
|
||||
.pop();
|
||||
|
||||
const content = lastMessage?.content;
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((part): part is { type: "text"; text: string } => (
|
||||
Boolean(part) &&
|
||||
typeof part === "object" &&
|
||||
(part as { type?: unknown }).type === "text" &&
|
||||
typeof (part as { text?: unknown }).text === "string"
|
||||
))
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Validate trait availability for an imported IR exactly as the store does on
|
||||
* create (v1 IRs with no columns are a no-op). Kept local to the import route so
|
||||
* the 422 fires BEFORE any write — the store would also reject, but importing
|
||||
|
||||
@@ -1154,6 +1154,42 @@ describe("StuckTaskDetector", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("suppresses another loop classification while accepted recovery is pending", async () => {
|
||||
const onLoopDetected = vi.fn().mockResolvedValue(true);
|
||||
const onStuck = vi.fn();
|
||||
const customStore = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ taskStuckTimeoutMs: 60000 }),
|
||||
});
|
||||
const customDetector = new StuckTaskDetector(customStore, { onLoopDetected, onStuck });
|
||||
const session = createMockSession();
|
||||
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
customDetector.trackTask("FN-201", session);
|
||||
vi.advanceTimersByTime(61000);
|
||||
for (let i = 0; i < 80; i++) {
|
||||
customDetector.recordActivity("FN-201");
|
||||
}
|
||||
|
||||
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBe("loop");
|
||||
await customDetector.killAndRetry("FN-201", 60000);
|
||||
|
||||
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBeNull();
|
||||
|
||||
customDetector.markLoopObserved("FN-201");
|
||||
for (let i = 0; i < 25; i++) {
|
||||
customDetector.recordIgnoredStepUpdate("FN-201");
|
||||
}
|
||||
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBe("no-progress-churn");
|
||||
|
||||
await customDetector.checkNow();
|
||||
expect(onStuck).toHaveBeenCalledWith(expect.objectContaining({
|
||||
taskId: "FN-201",
|
||||
reason: "no-progress-churn",
|
||||
}));
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does NOT call onLoopDetected when reason is inactivity", async () => {
|
||||
const onLoopDetected = vi.fn().mockResolvedValue(true);
|
||||
const onStuck = vi.fn();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Settings, TaskDetail, WorkflowDefinition, WorkflowIr } from "@fusion/core";
|
||||
|
||||
import { WorkflowGraphTaskRunner, type WorkflowGraphRunnerStore } from "../workflow-graph-task-runner.js";
|
||||
@@ -163,6 +163,27 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
|
||||
expect(result.reason).toMatch(/workflow-missing/);
|
||||
});
|
||||
|
||||
it("resolves built-in workflow selections without requiring the store to return a definition", async () => {
|
||||
const calls: string[] = [];
|
||||
const getWorkflowDefinition = vi.fn(async () => undefined);
|
||||
const store: WorkflowGraphRunnerStore = {
|
||||
getTaskWorkflowSelection: () => ({ workflowId: "builtin:coding", stepIds: [] }),
|
||||
getWorkflowDefinition,
|
||||
};
|
||||
const runner = new WorkflowGraphTaskRunner({
|
||||
store,
|
||||
seams: recordingSeams(calls),
|
||||
runCustomNode: async () => ({ outcome: "success" }),
|
||||
});
|
||||
|
||||
const result = await runner.run(task, flagOn);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["execute", "review", "merge"]);
|
||||
expect(result.reason).toBeUndefined();
|
||||
expect(getWorkflowDefinition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back (never strands the task) when the interpreter throws", async () => {
|
||||
// Malformed graph: edge references unknown node → WorkflowIrError inside run().
|
||||
const badIr: WorkflowIr = {
|
||||
|
||||
@@ -419,6 +419,15 @@ export class StuckTaskDetector {
|
||||
return "no-progress-churn";
|
||||
}
|
||||
|
||||
// After onLoopDetected accepts compact-and-resume, the executor owns the
|
||||
// recovery prompt. Do not immediately classify the same stale timestamps as
|
||||
// another loop before the executor has a chance to emit fresh progress. The
|
||||
// deterministic no-progress-churn terminal path above still has to fire if
|
||||
// the recovered session keeps hammering rejected step updates.
|
||||
if (entry.recoveryInProgress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check loop — active but not making progress, with enough activity to be a real loop
|
||||
if (noProgressMs >= timeoutMs && entry.activitySinceProgress >= LOOP_ACTIVITY_THRESHOLD) {
|
||||
return "loop";
|
||||
@@ -640,9 +649,6 @@ export class StuckTaskDetector {
|
||||
const stuckTasks: string[] = [];
|
||||
|
||||
for (const [taskId, entry] of this.tracked) {
|
||||
if (entry.recoveryInProgress) {
|
||||
continue;
|
||||
}
|
||||
const reason = this.classifyStuckReason(taskId, timeoutMs);
|
||||
if (reason !== null) {
|
||||
// U8: suppress flagging while the CLI session is waitingOnInput
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Settings, TaskDetail, WorkflowDefinition } from "@fusion/core";
|
||||
import { isExperimentalFeatureEnabled } from "@fusion/core";
|
||||
import { getBuiltinWorkflow, isBuiltinWorkflowId, isExperimentalFeatureEnabled } from "@fusion/core";
|
||||
|
||||
import { WorkflowGraphExecutor, type WorkflowNodeOutcome } from "./workflow-graph-executor.js";
|
||||
import type {
|
||||
@@ -146,7 +146,9 @@ export class WorkflowGraphTaskRunner {
|
||||
|
||||
let definition: WorkflowDefinition | undefined;
|
||||
try {
|
||||
definition = await this.deps.store.getWorkflowDefinition(selection.workflowId);
|
||||
definition = isBuiltinWorkflowId(selection.workflowId)
|
||||
? getBuiltinWorkflow(selection.workflowId)
|
||||
: await this.deps.store.getWorkflowDefinition(selection.workflowId);
|
||||
} catch (err) {
|
||||
return this.fallBack(task.id, `workflow-load-error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user