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.
This commit is contained in:
gsxdsm
2026-06-03 13:34:05 -07:00
parent fd94d0f5f2
commit 5a318bfe57
8 changed files with 255 additions and 9 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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"}
</span>
)}
{isAwaitingInput && (
<span className="card-status-badge awaiting-input">
Needs input
</span>
)}
{!isPaused && visualStatus && visualStatus !== "queued" && (
<span
className={`card-status-badge card-status-badge--${task.column}${isAwaitingApproval ? " awaiting-approval" : ""}${ACTIVE_STATUSES.has(visualStatus) ? " pulsing" : ""}${isFailed ? " failed" : ""}${isStuck ? " stuck" : ""}`}

View File

@@ -2744,6 +2744,8 @@ export function TaskDetailContent({
projectId={projectId}
isTaskInProgress={task.column === "in-progress" && task.status !== "paused"}
onWorkflowStepsChange={handleWorkflowStepsChange}
taskStatus={task.status}
taskPausedReason={task.pausedReason}
/>
</div>
) : activeTab === "model" ? (

View File

@@ -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;

View File

@@ -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<string, unknown> }> = [
{ 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<string, unknown>) => {
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<ModelInfo[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [skills, setSkills] = useState<DiscoveredSkill[]>([]);
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({
<>
<div className="wf-editor-toolbar">
<div className="wf-editor-palette">
{PALETTE.map(({ kind, label, icon: Icon }) => (
<button key={kind} className="wf-palette-btn" onClick={() => addNode(kind)}>
{PALETTE.map(({ kind, label, icon: Icon, presetConfig }) => (
<button key={label} className="wf-palette-btn" onClick={() => addNode(kind, label, presetConfig)}>
<Icon size={13} /> {label}
</button>
))}
@@ -313,6 +362,127 @@ function InnerEditor({
</label>
) : null}
{selectedNode.data.kind === "prompt" ? (
<>
<label className="wf-field">
<span>Executor</span>
<select
value={currentExecutor}
onChange={(e) => updateSelectedData({ config: { executor: e.target.value } })}
>
<option value="model">Model</option>
<option value="agent">Agent</option>
<option value="skill">Skill</option>
<option value="cli">CLI / script</option>
</select>
</label>
{currentExecutor === "model" && (
<label className="wf-field">
<span>Model</span>
<CustomModelDropdown
label="Model"
models={models}
value={getModelDropdownValue(
String(selectedNode.data.config?.modelProvider ?? ""),
String(selectedNode.data.config?.modelId ?? ""),
)}
onChange={(value) => {
const { provider, modelId } = parseModelDropdownValue(value);
updateSelectedData({ config: { modelProvider: provider || undefined, modelId: modelId || undefined } });
}}
/>
</label>
)}
{currentExecutor === "agent" && (
<label className="wf-field">
<span>Agent</span>
<select
value={String(selectedNode.data.config?.agentId ?? "")}
onChange={(e) => updateSelectedData({ config: { agentId: e.target.value || undefined } })}
>
<option value="">— select agent —</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</label>
)}
{currentExecutor === "skill" && (
<label className="wf-field">
<span>Skill</span>
<select
value={String(selectedNode.data.config?.skillName ?? "")}
onChange={(e) => updateSelectedData({ config: { skillName: e.target.value || undefined } })}
>
<option value="">— select skill —</option>
{skills.map((s) => (
<option key={s.id} value={s.name}>{s.name}</option>
))}
</select>
</label>
)}
{currentExecutor === "cli" && (
<label className="wf-field">
<span>Script name</span>
<input
value={String(selectedNode.data.config?.scriptName ?? "")}
onChange={(e) => updateSelectedData({ config: { scriptName: e.target.value } })}
/>
<span className="wf-inspector-note">Named script from project settings. The node prompt is passed via FUSION_NODE_PROMPT.</span>
</label>
)}
<label className="wf-field wf-field--checkbox">
<input
type="checkbox"
checked={Boolean(selectedNode.data.config?.autoApprove)}
onChange={(e) => updateSelectedData({ config: { autoApprove: e.target.checked } })}
/>
<span>Auto-approve requests</span>
</label>
<label className="wf-field">
<span>Max retries</span>
<input
type="number"
min={1}
max={10}
placeholder="default"
value={selectedNode.data.config?.maxRetries != null ? String(selectedNode.data.config.maxRetries) : ""}
onChange={(e) => {
const val = e.target.value.trim();
if (val === "") {
const patch: Record<string, unknown> = { ...selectedNode.data.config };
delete patch.maxRetries;
updateSelectedData({ config: patch });
} else {
const num = parseInt(val, 10);
if (!isNaN(num)) updateSelectedData({ config: { maxRetries: num } });
}
}}
/>
</label>
<label className="wf-field wf-field--checkbox">
<input
type="checkbox"
checked={Boolean(selectedNode.data.config?.awaitInput)}
onChange={(e) => updateSelectedData({ config: { awaitInput: e.target.checked } })}
/>
<span>Wait for user input</span>
</label>
{Boolean(selectedNode.data.config?.awaitInput) && (
<p className="wf-inspector-note wf-inspector-note--info">
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.
</p>
)}
</>
) : null}
{selectedNode.data.kind === "script" ? (
<label className="wf-field">
<span>Script name</span>

View File

@@ -1,4 +1,17 @@
/* ── Workflow Results ── */
.workflow-input-banner {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
background: var(--bg-secondary);
border-bottom: 1px solid var(--ws-warning);
color: var(--ws-warning);
font-size: 0.85rem;
margin-bottom: var(--space-sm);
}
.workflow-results-loading {
display: flex;
align-items: center;

View File

@@ -42,6 +42,17 @@ interface WorkflowResultsTabProps {
projectId?: string;
isTaskInProgress?: boolean;
onWorkflowStepsChange?: (steps: string[]) => void;
taskStatus?: string;
taskPausedReason?: string;
}
/** Extract the user-facing question from a workflow-input paused reason.
* Strips the leading "workflow-input:<nodeId>: " prefix if present. */
function parseWorkflowInputQuestion(pausedReason?: string): string {
if (!pausedReason) return "Reply in the comments and unpause the task to continue.";
const match = /^workflow-input:[^:]+:\s*(.*)$/s.exec(pausedReason);
if (match) return match[1].trim() || "Reply in the comments and unpause the task to continue.";
return pausedReason;
}
interface WorkflowStepOption {
@@ -201,6 +212,8 @@ export function WorkflowResultsTab({
projectId,
isTaskInProgress,
onWorkflowStepsChange,
taskStatus,
taskPausedReason,
}: WorkflowResultsTabProps) {
const [expandedOutputs, setExpandedOutputs] = useState<Record<string, boolean>>({});
const [renderModes, setRenderModes] = useState<Record<string, "markdown" | "plain">>({});
@@ -659,8 +672,16 @@ export function WorkflowResultsTab({
const showConfiguredStepsState = !loading && !hasResults && hasConfiguredSteps;
const showEditHeaderForResults = canEdit && hasResults;
const isAwaitingInput = taskStatus === "awaiting-user-input";
return (
<div className="workflow-results-tab" data-task-id={taskId}>
{isAwaitingInput && (
<div className="workflow-input-banner" role="alert">
<strong>Waiting for your input</strong>
<span>{parseWorkflowInputQuestion(taskPausedReason)}</span>
</div>
)}
{canEdit && onWorkflowStepsChange && (
<div className="workflow-selector-row">
<WorkflowSelector