From 5a318bfe575aa7f375b1f95f2df96758a6df31df Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 13:34:05 -0700 Subject: [PATCH] feat(dashboard): execution profiles + await-input UI for workflow nodes Inspector for prompt nodes: executor picker (model via CustomModelDropdown, agent, skill from discovered skills, CLI named script), auto-approve toggle, per-node max retries, and a wait-for-user-input mode with a User input palette preset. Task card shows a 'Needs input' badge for awaiting-user-input status; the task modal workflow tab shows the node's question as a banner. --- .changeset/graph-custom-workflows.md | 2 + .../dashboard/app/components/TaskCard.css | 11 ++ .../dashboard/app/components/TaskCard.tsx | 10 +- .../app/components/TaskDetailModal.tsx | 2 + .../app/components/WorkflowNodeEditor.css | 21 ++ .../app/components/WorkflowNodeEditor.tsx | 184 +++++++++++++++++- .../app/components/WorkflowResultsTab.css | 13 ++ .../app/components/WorkflowResultsTab.tsx | 21 ++ 8 files changed, 255 insertions(+), 9 deletions(-) diff --git a/.changeset/graph-custom-workflows.md b/.changeset/graph-custom-workflows.md index e19e1e1efc..82656cf3d1 100644 --- a/.changeset/graph-custom-workflows.md +++ b/.changeset/graph-custom-workflows.md @@ -3,3 +3,5 @@ --- Add executable custom workflows with a visual graph node editor. Author a workflow as a graph (start → prompt/script/gate steps → end) in a new React Flow–based editor, then select it per task or set a project default. Selected workflows compile to the existing WorkflowStep engine and run at the pre/post-merge boundaries — no changes to the scheduler/executor/merger. Non-linear graphs are rejected with a clear message and reserved for the (deferred) graph interpreter. + +Prompt nodes carry an execution profile: run on a chosen model, as a named agent, as a skill invocation, or as a named project script (CLI) with the prompt passed via FUSION_NODE_PROMPT — plus per-node retries and an auto-approve toggle. "User input" nodes pause the run with a needs-input badge on the task card and a banner in the task modal; replying in comments and unpausing resumes the workflow with the answer. diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 53051a23e8..42edbbfb4d 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -287,6 +287,17 @@ color: var(--triage); } +.card-status-badge.awaiting-input { + background: color-mix(in srgb, var(--color-warning) 14%, transparent); + color: var(--color-warning); + border-color: color-mix(in srgb, var(--color-warning) 45%, transparent); +} + +.card.awaiting-input { + border-left: 3px solid var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 6%, transparent); +} + .card.stuck { border-left: 3px solid var(--triage); background: color-mix(in srgb, var(--triage) 6%, transparent); diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index a2a0e9932a..f8c8c71b6c 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -844,8 +844,9 @@ function TaskCardComponent({ const hasTaskAgeStaleness = shouldShowTaskAgeStalenessBadge(task); const taskAgeStalenessCopy = getTaskAgeStalenessCopy(task.ageStaleness); const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval"; + const isAwaitingInput = task.status === "awaiting-user-input"; const isArchived = task.column === "archived"; - const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string)); + const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && !isAwaitingInput && (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string)); const isDraggable = !disableDrag && !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit/archived or host embedding // Check if this card can be edited inline @@ -1557,7 +1558,7 @@ function TaskCardComponent({ } }, [addToast, isRetrying, onRetryTask, task.id]); - const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`; + const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${isAwaitingInput ? " awaiting-input" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`; const filesChangedButton = (() => { if (task.column === "in-progress") { @@ -1705,6 +1706,11 @@ function TaskCardComponent({ {pausedByAgent ? "paused by agent" : "paused"} )} + {isAwaitingInput && ( + + Needs input + + )} {!isPaused && visualStatus && visualStatus !== "queued" && ( ) : activeTab === "model" ? ( diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index 5f301e061f..3909061b42 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -239,6 +239,27 @@ color: var(--text-tertiary); } +.wf-inspector-note--info { + color: var(--ws-warning); +} + +.wf-field--checkbox { + flex-direction: row; + align-items: center; + gap: var(--space-sm); + cursor: pointer; +} + +.wf-field--checkbox input[type="checkbox"] { + width: auto; + padding: 0; + margin: 0; + background: none; + border: none; + accent-color: var(--todo); + cursor: pointer; +} + /* Canvas nodes */ .wf-node { display: inline-flex; diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index d9a5315fc7..1a188497fc 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -14,7 +14,7 @@ import { type Node as FlowNode, type Edge as FlowEdge, } from "@xyflow/react"; -import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2 } from "lucide-react"; +import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle } from "lucide-react"; import type { WorkflowDefinition } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { @@ -23,12 +23,32 @@ import { updateWorkflow, deleteWorkflow, compileWorkflow, + fetchModels, + fetchAgents, + fetchDiscoveredSkills, + type ModelInfo, } from "../api"; +import type { Agent } from "../api"; +import type { DiscoveredSkill } from "../api"; import type { ToastType } from "../hooks/useToast"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout } from "./workflow-flow-mapping"; +import { CustomModelDropdown } from "./CustomModelDropdown"; + +type ExecutorKind = "model" | "agent" | "skill" | "cli"; + +function getModelDropdownValue(provider: string, modelId: string): string { + return provider && modelId ? `${provider}/${modelId}` : ""; +} + +function parseModelDropdownValue(value: string): { provider: string; modelId: string } { + if (!value) return { provider: "", modelId: "" }; + const slashIndex = value.indexOf("/"); + if (slashIndex === -1) return { provider: "", modelId: "" }; + return { provider: value.slice(0, slashIndex), modelId: value.slice(slashIndex + 1) }; +} interface WorkflowNodeEditorProps { isOpen: boolean; @@ -43,8 +63,9 @@ function newNodeId(): string { return `n-${Date.now().toString(36)}-${nodeSeq}`; } -const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof MessageSquare }> = [ +const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof MessageSquare; presetConfig?: Record }> = [ { kind: "prompt", label: "Prompt", icon: MessageSquare }, + { kind: "prompt", label: "User input", icon: HelpCircle, presetConfig: { awaitInput: true } }, { kind: "script", label: "Script", icon: Terminal }, { kind: "gate", label: "Gate", icon: Shield }, { kind: "merge", label: "Merge boundary", icon: GitMerge }, @@ -108,16 +129,18 @@ function InnerEditor({ ); const addNode = useCallback( - (kind: WorkflowEditorNodeKind) => { + (kind: WorkflowEditorNodeKind, nodeLabel?: string, presetConfig?: Record) => { const id = newNodeId(); - const label = kind === "merge" ? "Merge boundary" : kind.charAt(0).toUpperCase() + kind.slice(1); + const label = nodeLabel ?? (kind === "merge" ? "Merge boundary" : kind.charAt(0).toUpperCase() + kind.slice(1)); + const baseConfig = kind === "gate" ? { gateMode: "gate" } : {}; + const config = presetConfig ? { ...baseConfig, ...presetConfig } : baseConfig; setNodes((ns) => [ ...ns, { id, type: kind, position: { x: 200 + ns.length * 40, y: 240 + (ns.length % 3) * 70 }, - data: { kind, label, config: kind === "gate" ? { gateMode: "gate" } : {} }, + data: { kind, label, config }, deletable: true, }, ]); @@ -200,6 +223,32 @@ function InnerEditor({ }, [activeWorkflow, nodes, edges, projectId, addToast]); const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null; + + // Lazy-loaded executor resources + const [models, setModels] = useState([]); + const [agents, setAgents] = useState([]); + const [skills, setSkills] = useState([]); + + const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model"; + + useEffect(() => { + if (!selectedNode || (selectedNode.data.kind !== "prompt" && selectedNode.data.kind !== "gate")) return; + if (currentExecutor === "model" && models.length === 0) { + fetchModels().then((res) => setModels(res.models)).catch((err) => { + addToast(getErrorMessage(err) || "Failed to load models", "error"); + }); + } else if (currentExecutor === "agent" && agents.length === 0) { + fetchAgents().then(setAgents).catch((err) => { + addToast(getErrorMessage(err) || "Failed to load agents", "error"); + }); + } else if (currentExecutor === "skill" && skills.length === 0) { + fetchDiscoveredSkills(projectId).then(setSkills).catch((err) => { + addToast(getErrorMessage(err) || "Failed to load skills", "error"); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentExecutor, selectedNode?.id]); + const overlayProps = useOverlayDismiss(onClose); return ( @@ -244,8 +293,8 @@ function InnerEditor({ <>
- {PALETTE.map(({ kind, label, icon: Icon }) => ( - ))} @@ -313,6 +362,127 @@ function InnerEditor({ ) : null} + {selectedNode.data.kind === "prompt" ? ( + <> + + + {currentExecutor === "model" && ( + + )} + + {currentExecutor === "agent" && ( + + )} + + {currentExecutor === "skill" && ( + + )} + + {currentExecutor === "cli" && ( + + )} + + + + + + + {Boolean(selectedNode.data.config?.awaitInput) && ( +

+ This node pauses the task until you reply in the task's comments and unpause. The Prompt field above is shown to the user as the question. +

+ )} + + ) : null} + {selectedNode.data.kind === "script" ? (