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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" : ""}`}
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user