feat(dashboard): surface workflow selection on tasks and project settings (U8)

Add WorkflowSelector: a per-task picker in the task detail workflow tab that
applies a workflow (selection returns the resulting enabledWorkflowSteps so the
controlled steps list refreshes in place), and a ProjectDefaultWorkflowField in
Project General settings for the default new tasks inherit.
This commit is contained in:
gsxdsm
2026-06-03 09:51:26 -07:00
parent d071aecda6
commit 07a0a0c68f
8 changed files with 246 additions and 11 deletions

View File

@@ -11161,7 +11161,7 @@ ${stepsSection}`;
* (no orphaned steps). Throws WorkflowCompileError for non-linear graphs
* before any state is written.
*/
async selectTaskWorkflow(taskId: string, workflowId: string): Promise<void> {
async selectTaskWorkflow(taskId: string, workflowId: string): Promise<string[]> {
const def = await this.getWorkflowDefinition(workflowId);
if (!def) throw new Error(`Workflow '${workflowId}' not found`);
// Compile first so a non-linear graph aborts before we mutate anything.
@@ -11181,6 +11181,7 @@ ${stepsSection}`;
await this.updateTask(taskId, { enabledWorkflowSteps: ids });
this.writeTaskWorkflowSelection(taskId, workflowId, ids);
return ids;
}
/** Clear a task's workflow selection and its enabled steps. */

View File

@@ -4959,16 +4959,20 @@ export function fetchTaskWorkflow(taskId: string, projectId?: string): Promise<{
);
}
/** Select (or clear, with null) a workflow for a task. */
/** Select (or clear, with null) a workflow for a task. Returns the resulting
* enabled step ids so callers can reflect the change without a refetch. */
export function selectTaskWorkflow(
taskId: string,
workflowId: string | null,
projectId?: string,
): Promise<{ workflowId: string | null }> {
return api<{ workflowId: string | null }>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/workflow`, projectId), {
method: "PUT",
body: JSON.stringify({ workflowId }),
});
): Promise<{ workflowId: string | null; enabledWorkflowSteps: string[] }> {
return api<{ workflowId: string | null; enabledWorkflowSteps: string[] }>(
withProjectId(`/tasks/${encodeURIComponent(taskId)}/workflow`, projectId),
{
method: "PUT",
body: JSON.stringify({ workflowId }),
},
);
}
/** Read the project default workflow. */

View File

@@ -16,6 +16,7 @@ import {
import type { AgentPermissionPolicyRules, Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteSettings, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api";
import { ProjectDefaultWorkflowField } from "./WorkflowSelector";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import type { ToastType } from "../hooks/useToast";
@@ -2347,6 +2348,10 @@ export function SettingsModal({
{prefixError && <small className="field-error">{prefixError}</small>}
{!prefixError && <small>Prefix for new task IDs (e.g. KB, PROJ)</small>}
</div>
<div className="form-group">
<ProjectDefaultWorkflowField projectId={projectId} addToast={addToast} />
<small>New tasks inherit this custom workflow's steps (overridable per task)</small>
</div>
<div className="form-group">
<label htmlFor="requirePlanApproval" className="checkbox-label">
<input

View File

@@ -4,7 +4,8 @@ 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 } from "../api";
import { fetchWorkflowSteps, fetchTaskWorkflow, selectTaskWorkflow } from "../api";
import { WorkflowSelector } from "./WorkflowSelector";
import { useAgentLogs } from "../hooks/useAgentLogs";
import type { Components } from "react-markdown";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
@@ -206,6 +207,31 @@ export function WorkflowResultsTab({
const [expandedViewStepId, setExpandedViewStepId] = useState<string | null>(null);
const [allWorkflowSteps, setAllWorkflowSteps] = useState<WorkflowStep[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
// Load the task's current workflow selection (if any).
useEffect(() => {
let cancelled = false;
fetchTaskWorkflow(taskId, projectId)
.then((res) => {
if (!cancelled) setSelectedWorkflowId(res.workflowId);
})
.catch(() => {
/* selection is optional; ignore load failures */
});
return () => {
cancelled = true;
};
}, [taskId, projectId]);
const handleWorkflowSelect = useCallback(
async (workflowId: string | null) => {
const res = await selectTaskWorkflow(taskId, workflowId, projectId);
setSelectedWorkflowId(res.workflowId);
onWorkflowStepsChange?.(res.enabledWorkflowSteps);
},
[taskId, projectId, onWorkflowStepsChange],
);
// Check if any result has pending status
const hasPendingStep = results.some((r) => r.status === "pending");
@@ -635,6 +661,16 @@ export function WorkflowResultsTab({
return (
<div className="workflow-results-tab" data-task-id={taskId}>
{canEdit && onWorkflowStepsChange && (
<div className="workflow-selector-row">
<WorkflowSelector
value={selectedWorkflowId}
onChange={handleWorkflowSelect}
projectId={projectId}
label="Custom workflow"
/>
</div>
)}
{showConfiguredStepsState ? (
<div className="workflow-configured-steps" data-testid="workflow-configured-steps">
<div className="workflow-configured-header" data-testid="workflow-configured-header">

View File

@@ -0,0 +1,48 @@
.workflow-selector {
display: flex;
align-items: flex-end;
gap: var(--space-sm);
}
.workflow-selector-label {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-size: 0.8rem;
color: var(--text-muted);
flex: 1;
min-width: 0;
}
.workflow-selector-label-text {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.workflow-selector select {
padding: var(--space-xs) var(--space-sm);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font: inherit;
}
.workflow-selector select:focus {
outline: none;
box-shadow: var(--focus-ring);
}
.workflow-selector-manage {
padding: var(--space-xs) var(--space-sm);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
cursor: pointer;
}
.workflow-selector-manage:hover {
background: var(--bg-tertiary);
}

View File

@@ -0,0 +1,137 @@
import "./WorkflowSelector.css";
import { useCallback, useEffect, useState } from "react";
import { Workflow as WorkflowIcon } from "lucide-react";
import type { WorkflowDefinition } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import { fetchWorkflows, fetchProjectDefaultWorkflow, setProjectDefaultWorkflow } from "../api";
import type { ToastType } from "../hooks/useToast";
interface WorkflowSelectorProps {
/** Currently selected workflow id, or null for none. */
value: string | null;
/** Apply a selection. Receives the chosen workflow id, or null to clear. */
onChange: (workflowId: string | null) => void | Promise<void>;
projectId?: string;
addToast?: (message: string, type?: ToastType) => void;
disabled?: boolean;
label?: string;
/** Optional affordance to open the graph editor. */
onManage?: () => void;
}
export function WorkflowSelector({
value,
onChange,
projectId,
addToast,
disabled,
label = "Workflow",
onManage,
}: WorkflowSelectorProps) {
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [loading, setLoading] = useState(false);
const [applying, setApplying] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchWorkflows(projectId)
.then((data) => {
if (!cancelled) setWorkflows(data);
})
.catch((err) => addToast?.(getErrorMessage(err) || "Failed to load workflows", "error"))
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [projectId, addToast]);
const handleChange = useCallback(
async (next: string) => {
const workflowId = next === "" ? null : next;
setApplying(true);
try {
await onChange(workflowId);
} catch (err) {
addToast?.(getErrorMessage(err) || "Failed to apply workflow", "error");
} finally {
setApplying(false);
}
},
[onChange, addToast],
);
return (
<div className="workflow-selector" data-testid="workflow-selector">
<label className="workflow-selector-label">
<span className="workflow-selector-label-text">
<WorkflowIcon size={14} aria-hidden /> {label}
</span>
<select
value={value ?? ""}
disabled={disabled || loading || applying}
onChange={(e) => void handleChange(e.target.value)}
>
<option value="">None</option>
{workflows.map((w) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
</label>
{onManage && (
<button type="button" className="workflow-selector-manage" onClick={onManage}>
Manage…
</button>
)}
</div>
);
}
interface ProjectDefaultWorkflowFieldProps {
projectId?: string;
addToast?: (message: string, type?: ToastType) => void;
onManage?: () => void;
}
/** Self-contained project-default workflow picker for the settings modal. */
export function ProjectDefaultWorkflowField({ projectId, addToast, onManage }: ProjectDefaultWorkflowFieldProps) {
const [value, setValue] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchProjectDefaultWorkflow(projectId)
.then((res) => {
if (!cancelled) setValue(res.workflowId);
})
.catch(() => {
/* default is optional; ignore load failures */
});
return () => {
cancelled = true;
};
}, [projectId]);
const handleChange = useCallback(
async (workflowId: string | null) => {
const res = await setProjectDefaultWorkflow(workflowId, projectId);
setValue(res.workflowId);
addToast?.(workflowId ? "Default workflow set" : "Default workflow cleared", "success");
},
[projectId, addToast],
);
return (
<WorkflowSelector
value={value}
onChange={handleChange}
projectId={projectId}
addToast={addToast}
label="Default workflow for new tasks"
onManage={onManage}
/>
);
}

View File

@@ -8,6 +8,9 @@ import type { AgentLogEntry, WorkflowStep, WorkflowStepResult } from "@fusion/co
vi.mock("../../api", () => ({
fetchWorkflowSteps: vi.fn(),
fetchTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null }),
selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null, enabledWorkflowSteps: [] }),
fetchWorkflows: vi.fn().mockResolvedValue([]),
}));
vi.mock("../../hooks/useAgentLogs", () => ({

View File

@@ -136,14 +136,15 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
const workflowId = (req.body ?? {}).workflowId;
if (workflowId === null || workflowId === undefined) {
await store.clearTaskWorkflowSelection(req.params.taskId);
res.json({ workflowId: null });
res.json({ workflowId: null, enabledWorkflowSteps: [] });
return;
}
if (typeof workflowId !== "string") {
throw badRequest("workflowId must be a string or null");
}
let enabledWorkflowSteps: string[] = [];
try {
await store.selectTaskWorkflow(req.params.taskId, workflowId);
enabledWorkflowSteps = await store.selectTaskWorkflow(req.params.taskId, workflowId);
} catch (selectErr: unknown) {
if (selectErr instanceof WorkflowCompileError || selectErr instanceof WorkflowIrError) {
throw new ApiError(422, selectErr.message);
@@ -153,7 +154,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
}
throw selectErr;
}
res.json({ workflowId });
res.json({ workflowId, enabledWorkflowSteps });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);