diff --git a/.changeset/workflow-board-list-layout.md b/.changeset/workflow-board-list-layout.md new file mode 100644 index 0000000000..7501931d93 --- /dev/null +++ b/.changeset/workflow-board-list-layout.md @@ -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. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 70d0541f70..a83ae49dfa 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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} /> ); @@ -1778,6 +1785,7 @@ function AppInner() { searchQuery={searchQuery} lastFetchTimeMs={lastFetchTimeMs} prAuthAvailable={prAuthAvailable} + onCreateWorkflow={openCreateWorkflowWithNav} /> ); diff --git a/packages/dashboard/app/components/AgentsView.css b/packages/dashboard/app/components/AgentsView.css index 9c4e6a3431..b7ec8b9e3c 100644 --- a/packages/dashboard/app/components/AgentsView.css +++ b/packages/dashboard/app/components/AgentsView.css @@ -54,6 +54,9 @@ flex-direction: column; flex: 1; height: 100%; + width: 100%; + flex: 1 1 auto; + min-width: 0; overflow: hidden; } diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index a166ea9334..91077eca78 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -385,6 +385,7 @@ export function AppModals({ addToast={addToast} projectId={projectId} initialPanel={modalManager.workflowEditorInitialPanel} + initialAction={modalManager.workflowEditorInitialAction} /> diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index d67044a63a..d7d09bfa11 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -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, 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>(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(null); + const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); const draggingTaskIdRef = useRef(null); - const [collapsedLanes, setCollapsedLanes] = useState>(() => { - 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(); - 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(() => { + 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(() => { + 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 = {}; + 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,112 @@ 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 ( -
{ - 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 }) => ( - - ))} -
+
+ {workflowOptions.length > 1 && ( +
+ + {onCreateWorkflow && ( + + )} + {onOpenWorkflowEditor && ( + + )} +
+ )} +
{ + 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 ( + 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 } : {})} + /> + ); + })} +
+
); } diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index ef8380d15f..d89f850d56 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -5,6 +5,9 @@ flex: 1; width: 100%; height: 100%; + width: 100%; + flex: 1 1 auto; + min-width: 0; overflow: hidden; } diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 309f251064..c5c2f21ba2 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -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,27 @@ 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) { + const explicitWorkflowId = workflowId?.startsWith("builtin:") ? undefined : workflowId; + return onQuickCreate({ + ...input, + column, + ...(explicitWorkflowId ? { workflowId: explicitWorkflowId } : {}), + }); + } + return onQuickCreate(input); + }, + [column, onQuickCreate, workflowId, workflowMode], + ); const handleLoadMore = useCallback(() => { setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length)); @@ -420,6 +443,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 +453,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 +473,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 +481,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 +526,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 +674,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, )} {!isCollapsed && (
- {(workflowMode ? Boolean(columnFlags?.intake) : column === "triage") && onQuickCreate && ( + {canCreateInColumn && ( .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 +146,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; + } +} diff --git a/packages/dashboard/app/components/Lane.tsx b/packages/dashboard/app/components/Lane.tsx index c772b95582..b89507ddb2 100644 --- a/packages/dashboard/app/components/Lane.tsx +++ b/packages/dashboard/app/components/Lane.tsx @@ -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) {
{!collapsed && (
- {visibleColumns.map((col) => ( + {visibleColumns.map((col) => { + const isCreateColumn = col.id === createColumnId; + return ( - ))} + ); + })}
)} diff --git a/packages/dashboard/app/components/ListView.css b/packages/dashboard/app/components/ListView.css index 6a3e2fb1b1..71d5bacc62 100644 --- a/packages/dashboard/app/components/ListView.css +++ b/packages/dashboard/app/components/ListView.css @@ -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; } } - diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index e3fbd0903f..2c0673815f 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -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 = { triage: "var(--triage)", @@ -110,14 +111,12 @@ function readStaleOnlyFilter(projectId?: string): boolean { return false; } -function readCollapsedSections(projectId?: string): Set { +function readCollapsedSections(projectId?: string): Set { 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 { // Invalid localStorage data - fall through to default } - return new Set(); + return new Set(); } function readSelectedTaskIds(projectId?: string): Set { @@ -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; + onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; onRetryTask?: (id: string) => Promise; 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(null); const [sortDirection, setSortDirection] = useState("asc"); const [draggingTaskId, setDraggingTaskId] = useState(null); - const [dragOverColumn, setDragOverColumn] = useState(null); - const [selectedColumn, setSelectedColumn] = useState(null); + const [dragOverColumn, setDragOverColumn] = useState(null); + const [selectedColumn, setSelectedColumn] = useState(null); + const [boardWorkflows, setBoardWorkflows] = useState(null); + const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); const viewportMode = useViewportMode(); const isMobile = viewportMode === "mobile"; const { confirm, confirmWithChoice } = useConfirm(); @@ -304,7 +320,7 @@ export function ListView({ const [stalePausedReviewOnlyFilter, setStalePausedReviewOnlyFilter] = useState(false); // Collapsed sections state - initialize from localStorage - const [collapsedSections, setCollapsedSections] = useState>(() => + const [collapsedSections, setCollapsedSections] = useState>(() => readCollapsedSections(projectId), ); @@ -342,12 +358,19 @@ export function ListView({ const [bulkEditEnabled, setBulkEditEnabled] = useState(false); const [selectedTaskIds, setSelectedTaskIds] = useState>(() => readSelectedTaskIds(projectId)); const [selectedTaskId, setSelectedTaskId] = useState(() => readSelectedTaskId(projectId)); - const [selectedTaskSnapshot, setSelectedTaskSnapshot] = useState(null); + const [selectedTaskSnapshot, setSelectedTaskSnapshot] = useState(() => { + const persistedSelection = readSelectedTaskId(projectId); + return persistedSelection ? tasks.find((task) => task.id === persistedSelection) ?? null : null; + }); const [sidebarWidth, setSidebarWidth] = useState(() => readSidebarWidth(projectId)); const splitLayoutRef = useRef(null); const splitSidebarRef = useRef(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,100 @@ export function ListView({ }); }, []); + const workflowMode = boardWorkflows?.flagEnabled === true && boardWorkflows.workflows.length > 0; + const workflowOptions = useMemo(() => { + 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(() => { + 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(() => { + if (!workflowMode || !selectedWorkflow) return LEGACY_LIST_COLUMNS; + return selectedWorkflow.columns.filter((column) => !column.flags.hiddenFromBoard); + }, [selectedWorkflow, workflowMode]); + + const columnNameById = useMemo(() => { + const map = new Map(); + 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(); + 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(); + 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) { + const explicitWorkflowId = selectedWorkflow.id.startsWith("builtin:") ? undefined : selectedWorkflow.id; + return create({ + ...input, + column: input.column ?? createTargetColumn, + workflowId: input.workflowId ?? explicitWorkflowId, + }); + } + return create(input); + }, [addToast, createTargetColumn, onQuickCreate, selectedWorkflow, t, workflowMode]); + // Column display labels const COLUMN_LABELS_MAP: Record = { @@ -509,11 +662,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 +693,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 +722,22 @@ export function ListView({ ? filtered.filter((t) => t.column === selectedColumn) : filtered; - const groups: Record = { - triage: [], - todo: [], - "in-progress": [], - "in-review": [], - done: [], - archived: [], - }; + const groups: Record = {}; + 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 +757,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 +766,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 +788,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 +803,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("__no_change__"); @@ -1018,7 +1184,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 +1258,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 +1355,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 +1492,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 +1505,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 (column === "archived" || columnFlagsById.get(column)?.archived) { addToast(t("listView.archiveViaButton", "Tasks can only be archived via the archive button"), "error"); return; } @@ -1386,7 +1552,7 @@ export function ListView({ addToast(getErrorMessage(err), "error"); } }, - [onMoveTask, addToast, tasks, confirm] + [addToast, columnFlagsById, confirm, onMoveTask, tasks, t] ); const getSortIcon = (field: SortField) => { @@ -1398,6 +1564,40 @@ export function ListView({ ); }; + const renderWorkflowSelector = () => { + if (!workflowMode || workflowOptions.length <= 1 || !selectedWorkflow) return null; + return ( +
+ + {onCreateWorkflow && ( + + )} +
+ ); + }; + const renderViewOptionsPanel = (panelId: string) => (
@@ -1447,9 +1647,12 @@ export function ListView({ {stalePausedReviewOnlyFilter ? t("listView.showAll", "Show all") : t("listView.stalePausedReview", "Stale paused review")}
- {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 +1666,8 @@ export function ListView({ onDrop={(e) => handleColumnDrop(e, column)} data-column={column} > - - {columnLabel(column)} + + {getListColumnLabel(column)} {showPartial ? `${visibleCount} of ${totalCount}` : totalCount} @@ -1564,6 +1767,7 @@ export function ListView({ + {renderWorkflowSelector()}
@@ -1615,13 +1819,14 @@ export function ListView({

{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 && ( ({t("listView.hidden", "{{count}} hidden", { count: hiddenCompletedCount })}) )}

+ {renderWorkflowSelector()} @@ -1634,7 +1839,7 @@ export function ListView({
{selectedColumn ? ( ) : null} {hideDoneTasks ? {t("listView.doneHiddenChip", "Done hidden")} : null} @@ -1665,7 +1870,7 @@ export function ListView({ )}
addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error"))} + onCreate={handleListQuickCreate} addToast={addToast} tasks={tasks} availableModels={availableModels} @@ -1695,9 +1900,10 @@ export function ListView({
) : isMobile ? (
- {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 +1930,8 @@ export function ListView({ size={14} className={`list-section-chevron${!isCollapsed ? " list-section-chevron--expanded" : ""}`} /> - - {columnLabel(column)} + + {getListColumnLabel(column)} {columnTasks.length}
@@ -1882,12 +2088,13 @@ export function ListView({ - {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 +2117,8 @@ export function ListView({ size={14} className={`list-section-chevron${!isCollapsed ? " list-section-chevron--expanded" : ""}`} /> - - {columnLabel(column)} + + {getListColumnLabel(column)} {columnTasks.length} @@ -2019,7 +2226,7 @@ export function ListView({ color: columnColor(task.column), }} > - {columnLabel(task.column)} + {getListColumnLabel(task.column)} )} diff --git a/packages/dashboard/app/components/MailboxModal.css b/packages/dashboard/app/components/MailboxModal.css index d29eb9655d..1ad97c198f 100644 --- a/packages/dashboard/app/components/MailboxModal.css +++ b/packages/dashboard/app/components/MailboxModal.css @@ -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); } diff --git a/packages/dashboard/app/components/ProjectSelector.css b/packages/dashboard/app/components/ProjectSelector.css index 157ffeff20..7acf7fee9e 100644 --- a/packages/dashboard/app/components/ProjectSelector.css +++ b/packages/dashboard/app/components/ProjectSelector.css @@ -584,6 +584,7 @@ flex: 1; min-height: 0; min-width: 0; + width: 100%; overflow: hidden; } @@ -598,4 +599,3 @@ display: none; } } - diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index 36dfe46c51..e6753b5b43 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -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 { diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index d134ee042e..9b9152ff87 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -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 | 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 & { modalRef: React.RefObject }) { const [workflows, setWorkflows] = useState([]); const [activeId, setActiveId] = useState(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(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(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 | 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({
) : null} -
+
)} @@ -2199,6 +2238,15 @@ function InnerEditor({
+ {activeWorkflow ? ( <> {/* Inline name + description strip (KTD-10). Built-ins render as @@ -2681,7 +2729,7 @@ function InnerEditor({ Prompt