feat(dashboard): CLI command field + interactive input/approval banner

Node inspector: CLI executor toggles between a raw command textarea and a named
script. Task modal workflow tab: the await-input banner is now interactive
(reply textarea + Submit & resume), and a new awaiting-cli-approval banner shows
the pending command with an Approve & run action.
This commit is contained in:
gsxdsm
2026-06-03 13:48:28 -07:00
parent ddde3c5b57
commit d6763e589c
5 changed files with 200 additions and 9 deletions

View File

@@ -5,3 +5,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.
CLI nodes can run arbitrary commands (not just named scripts); the first run of an exact command pauses the task for explicit user approval. The task modal's input/approval banner is interactive — reply-and-resume for user-input nodes, approve-and-run for CLI commands.

View File

@@ -426,14 +426,41 @@ function InnerEditor({
)}
{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">
<span>CLI mode</span>
<select
value={String(selectedNode.data.config?.cliMode ?? "command")}
onChange={(e) => updateSelectedData({ config: { cliMode: e.target.value } })}
>
<option value="command">Command</option>
<option value="script">Named script</option>
</select>
</label>
{(selectedNode.data.config?.cliMode ?? "command") === "command" ? (
<label className="wf-field">
<span>Command</span>
<textarea
rows={3}
placeholder="npm test -- --runInBand"
value={String(selectedNode.data.config?.cliCommand ?? "")}
onChange={(e) => updateSelectedData({ config: { cliCommand: e.target.value } })}
/>
<p className="wf-inspector-note wf-inspector-note--info">
Runs an arbitrary command in the task worktree. The first time this exact command runs, the task pauses for your approval. The node prompt is passed via FUSION_NODE_PROMPT.
</p>
</label>
) : (
<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">

View File

@@ -12,6 +12,84 @@
margin-bottom: var(--space-sm);
}
.workflow-input-banner--approval {
border-color: var(--ws-error);
color: var(--ws-error);
}
.workflow-input-actions {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin-top: var(--space-xs);
}
.workflow-input-textarea {
width: 100%;
min-height: calc(var(--space-lg) * 4);
padding: var(--space-xs) var(--space-sm);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 0.85rem;
resize: vertical;
box-sizing: border-box;
}
.workflow-input-textarea:focus {
outline: none;
box-shadow: var(--focus-ring);
}
.workflow-input-submit {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
align-self: flex-start;
padding: var(--space-xs) var(--space-sm);
background: var(--todo);
border: 1px solid var(--todo);
border-radius: var(--radius-sm);
color: var(--bg);
font-size: 0.85rem;
cursor: pointer;
transition: opacity var(--transition-fast);
}
.workflow-input-submit:disabled {
opacity: 0.6;
cursor: default;
}
.workflow-input-resuming {
font-style: italic;
opacity: 0.8;
}
.workflow-input-approval-warning {
font-size: 0.8rem;
opacity: 0.9;
}
.workflow-input-approval-command {
margin: var(--space-xs) 0 0;
padding: var(--space-xs) var(--space-sm);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.82rem;
color: var(--text);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.workflow-input-keep-paused {
font-size: 0.78rem;
opacity: 0.7;
}
.workflow-results-loading {
display: flex;
align-items: center;

View File

@@ -4,7 +4,7 @@ import { Check, ChevronDown, ChevronUp, Maximize2, Pencil, X } from "lucide-reac
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { AgentLogEntry, WorkflowStep, WorkflowStepResult } from "@fusion/core";
import { fetchWorkflowSteps, fetchTaskWorkflow, selectTaskWorkflow } from "../api";
import { fetchWorkflowSteps, fetchTaskWorkflow, selectTaskWorkflow, submitTaskWorkflowInput, approveTaskWorkflowCli } from "../api";
import { WorkflowSelector } from "./WorkflowSelector";
import { useAgentLogs } from "../hooks/useAgentLogs";
import type { Components } from "react-markdown";
@@ -55,6 +55,15 @@ function parseWorkflowInputQuestion(pausedReason?: string): string {
return pausedReason;
}
/** Extract the CLI command from a workflow-cli-approval paused reason.
* Strips the leading "workflow-cli-approval:<nodeId>: " prefix if present. */
function parseCliApprovalCommand(pausedReason?: string): string {
if (!pausedReason) return "";
const match = /^workflow-cli-approval:[^:]+:\s*(.*)$/s.exec(pausedReason);
if (match) return match[1].trim();
return pausedReason;
}
interface WorkflowStepOption {
id: string;
name: string;
@@ -217,6 +226,9 @@ export function WorkflowResultsTab({
}: WorkflowResultsTabProps) {
const [expandedOutputs, setExpandedOutputs] = useState<Record<string, boolean>>({});
const [renderModes, setRenderModes] = useState<Record<string, "markdown" | "plain">>({});
const [inputText, setInputText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [expandedViewStepId, setExpandedViewStepId] = useState<string | null>(null);
const [allWorkflowSteps, setAllWorkflowSteps] = useState<WorkflowStep[]>([]);
const [isEditing, setIsEditing] = useState(false);
@@ -673,6 +685,30 @@ export function WorkflowResultsTab({
const showEditHeaderForResults = canEdit && hasResults;
const isAwaitingInput = taskStatus === "awaiting-user-input";
const isAwaitingCliApproval = taskStatus === "awaiting-cli-approval";
const handleSubmitInput = async () => {
if (!inputText.trim() || submitting) return;
setSubmitting(true);
try {
await submitTaskWorkflowInput(taskId, inputText, projectId);
setInputText("");
setSubmitted(true);
} finally {
setSubmitting(false);
}
};
const handleApproveCli = async () => {
if (submitting) return;
setSubmitting(true);
try {
await approveTaskWorkflowCli(taskId, projectId);
setSubmitted(true);
} finally {
setSubmitting(false);
}
};
return (
<div className="workflow-results-tab" data-task-id={taskId}>
@@ -680,6 +716,52 @@ export function WorkflowResultsTab({
<div className="workflow-input-banner" role="alert">
<strong>Waiting for your input</strong>
<span>{parseWorkflowInputQuestion(taskPausedReason)}</span>
{submitted ? (
<span className="workflow-input-resuming">Resuming…</span>
) : (
<div className="workflow-input-actions">
<textarea
className="workflow-input-textarea"
rows={3}
placeholder="Type your reply…"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
disabled={submitting}
/>
<button
type="button"
className="workflow-input-submit"
onClick={handleSubmitInput}
disabled={submitting || !inputText.trim()}
>
{submitting ? "Submitting…" : "Submit & resume"}
</button>
</div>
)}
</div>
)}
{isAwaitingCliApproval && (
<div className="workflow-input-banner workflow-input-banner--approval" role="alert">
<strong>Approve CLI command?</strong>
<span className="workflow-input-approval-warning">
This command will run in the task worktree. Approving trusts this exact command for future runs.
</span>
<pre className="workflow-input-approval-command"><code>{parseCliApprovalCommand(taskPausedReason)}</code></pre>
{submitted ? (
<span className="workflow-input-resuming">Resuming…</span>
) : (
<div className="workflow-input-actions">
<button
type="button"
className="workflow-input-submit"
onClick={handleApproveCli}
disabled={submitting}
>
{submitting ? "Approving…" : "Approve & run"}
</button>
<span className="workflow-input-keep-paused">To reject, keep the task paused and do not approve.</span>
</div>
)}
</div>
)}
{canEdit && onWorkflowStepsChange && (

View File

@@ -11,6 +11,8 @@ vi.mock("../../api", () => ({
fetchTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null }),
selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null, enabledWorkflowSteps: [] }),
fetchWorkflows: vi.fn().mockResolvedValue([]),
submitTaskWorkflowInput: vi.fn().mockResolvedValue({ ok: true }),
approveTaskWorkflowCli: vi.fn().mockResolvedValue({ approved: "ok" }),
}));
vi.mock("../../hooks/useAgentLogs", () => ({