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:
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
48
packages/dashboard/app/components/WorkflowSelector.css
Normal file
48
packages/dashboard/app/components/WorkflowSelector.css
Normal 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);
|
||||
}
|
||||
137
packages/dashboard/app/components/WorkflowSelector.tsx
Normal file
137
packages/dashboard/app/components/WorkflowSelector.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user