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:
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
Reference in New Issue
Block a user