more polish on workflows
Some checks failed
CI / Rustfmt (push) Failing after 25s
CI / Clippy (push) Failing after 2m3s
CI / Cargo Audit & Deny (push) Successful in 33s
CI / Web Blocking Checks (push) Failing after 26s
CI / Security Blocking Checks (push) Successful in 8s
CI / Security Advisory Checks (push) Has been cancelled
CI / Web Advisory Checks (push) Has been cancelled
CI / Tests (push) Has been cancelled

This commit is contained in:
2026-03-11 11:21:28 -05:00
parent a7ed135af2
commit b5d6bb2243
25 changed files with 366 additions and 322 deletions

View File

@@ -22,10 +22,6 @@ export type ApiResponse_WorkflowResponse = {
* Workflow description
*/
description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled: boolean;
/**
* Workflow ID
*/
@@ -72,4 +68,3 @@ export type ApiResponse_WorkflowResponse = {
*/
message?: string | null;
};

View File

@@ -14,10 +14,6 @@ export type CreateWorkflowRequest = {
* Workflow description
*/
description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled?: boolean | null;
/**
* Human-readable label
*/
@@ -47,4 +43,3 @@ export type CreateWorkflowRequest = {
*/
version: string;
};

View File

@@ -19,10 +19,6 @@ export type PaginatedResponse_WorkflowSummary = {
* Workflow description
*/
description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled: boolean;
/**
* Workflow ID
*/
@@ -57,4 +53,3 @@ export type PaginatedResponse_WorkflowSummary = {
*/
pagination: PaginationMeta;
};

View File

@@ -14,10 +14,6 @@ export type UpdateWorkflowRequest = {
* Workflow description
*/
description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled?: boolean | null;
/**
* Human-readable label
*/
@@ -39,4 +35,3 @@ export type UpdateWorkflowRequest = {
*/
version?: string | null;
};

View File

@@ -18,10 +18,6 @@ export type WorkflowResponse = {
* Workflow description
*/
description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled: boolean;
/**
* Workflow ID
*/
@@ -63,4 +59,3 @@ export type WorkflowResponse = {
*/
version: string;
};

View File

@@ -14,10 +14,6 @@ export type WorkflowSummary = {
* Workflow description
*/
description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled: boolean;
/**
* Workflow ID
*/
@@ -47,4 +43,3 @@ export type WorkflowSummary = {
*/
version: string;
};

View File

@@ -57,7 +57,6 @@ export class WorkflowsService {
page,
pageSize,
tags,
enabled,
search,
packRef,
}: {
@@ -73,10 +72,6 @@ export class WorkflowsService {
* Filter by tag(s) - comma-separated list
*/
tags?: string | null,
/**
* Filter by enabled status
*/
enabled?: boolean | null,
/**
* Search term for label/description (case-insensitive)
*/
@@ -93,7 +88,6 @@ export class WorkflowsService {
'page': page,
'page_size': pageSize,
'tags': tags,
'enabled': enabled,
'search': search,
'pack_ref': packRef,
},
@@ -125,10 +119,6 @@ export class WorkflowsService {
* Workflow description
*/
description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled: boolean;
/**
* Workflow ID
*/
@@ -216,10 +206,6 @@ export class WorkflowsService {
* Workflow description
*/
description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled: boolean;
/**
* Workflow ID
*/
@@ -308,10 +294,6 @@ export class WorkflowsService {
* Workflow description
*/
description?: string | null;
/**
* Whether the workflow is enabled
*/
enabled: boolean;
/**
* Workflow ID
*/

View File

@@ -63,7 +63,9 @@ const ARROW_LENGTH = 12;
const ARROW_HALF_WIDTH = 5;
const ARROW_DIRECTION_LOOKBACK_PX = 10;
const ARROW_DIRECTION_SAMPLES = 48;
const ARROW_SHAFT_OVERLAP_PX = 2;
// Keep a small amount of shaft under the arrowhead so sample-based trimming
// does not leave a visible gap on simple bezier edges without waypoints.
const ARROW_SHAFT_OVERLAP_PX = 4;
/** Color for each edge type (alias for shared constant) */
const EDGE_COLORS = EDGE_TYPE_COLORS;

View File

@@ -1,11 +1,29 @@
import { useState } from "react";
import { Pencil, Plus, X, LogIn, LogOut } from "lucide-react";
import {
Pencil,
Plus,
X,
LogIn,
LogOut,
SlidersHorizontal,
} from "lucide-react";
import SchemaBuilder from "@/components/common/SchemaBuilder";
import type { ParamDefinition } from "@/types/workflow";
import type { CancellationPolicy, ParamDefinition } from "@/types/workflow";
import { CANCELLATION_POLICY_LABELS } from "@/types/workflow";
interface WorkflowInputsPanelProps {
label: string;
version: string;
description: string;
tags: string[];
cancellationPolicy: CancellationPolicy;
parameters: Record<string, ParamDefinition>;
output: Record<string, ParamDefinition>;
onLabelChange: (label: string) => void;
onVersionChange: (version: string) => void;
onDescriptionChange: (description: string) => void;
onTagsChange: (tags: string[]) => void;
onCancellationPolicyChange: (policy: CancellationPolicy) => void;
onParametersChange: (parameters: Record<string, ParamDefinition>) => void;
onOutputChange: (output: Record<string, ParamDefinition>) => void;
}
@@ -82,8 +100,18 @@ function ParamSummaryList({
}
export default function WorkflowInputsPanel({
label,
version,
description,
tags,
cancellationPolicy,
parameters,
output,
onLabelChange,
onVersionChange,
onDescriptionChange,
onTagsChange,
onCancellationPolicyChange,
onParametersChange,
onOutputChange,
}: WorkflowInputsPanelProps) {
@@ -123,8 +151,103 @@ export default function WorkflowInputsPanel({
<>
<div className="flex flex-col h-full overflow-hidden">
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Input Parameters */}
<div>
<div className="flex items-center gap-1.5 mb-2">
<SlidersHorizontal className="w-3.5 h-3.5 text-blue-500" />
<h4 className="text-xs font-semibold text-gray-600 uppercase tracking-wider">
Workflow
</h4>
</div>
<div className="space-y-2.5 rounded-lg border border-gray-200 bg-white p-3">
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-1">
Label
</label>
<input
type="text"
value={label}
onChange={(e) => onLabelChange(e.target.value)}
className="w-full px-2.5 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Workflow Label"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-1">
Description
</label>
<input
type="text"
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
className="w-full px-2.5 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Workflow description"
/>
</div>
<div className="grid grid-cols-1 gap-2">
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-1">
Version
</label>
<input
type="text"
value={version}
onChange={(e) => onVersionChange(e.target.value)}
className="w-full px-2.5 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="1.0.0"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-1">
Tags
</label>
<input
type="text"
value={tags.join(", ")}
onChange={(e) =>
onTagsChange(
e.target.value
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
)
}
className="w-full px-2.5 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="tag-one, tag-two"
/>
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-gray-600 mb-1">
Cancellation Policy
</label>
<select
value={cancellationPolicy}
onChange={(e) =>
onCancellationPolicyChange(
e.target.value as CancellationPolicy,
)
}
className="w-full px-2.5 py-2 border border-gray-300 rounded-md text-sm text-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
title="Controls how running tasks behave when the workflow is cancelled"
>
{Object.entries(CANCELLATION_POLICY_LABELS).map(
([value, optionLabel]) => (
<option key={value} value={value}>
{optionLabel}
</option>
),
)}
</select>
</div>
</div>
</div>
{/* Input Parameters */}
<div className="border-t border-gray-200 pt-3">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<LogIn className="w-3.5 h-3.5 text-green-500" />

View File

@@ -10,7 +10,6 @@ interface WorkflowsQueryParams {
pageSize?: number;
packRef?: string;
tags?: string;
enabled?: boolean;
search?: string;
}
@@ -23,7 +22,6 @@ export function useWorkflows(params?: WorkflowsQueryParams) {
page: params?.page || 1,
pageSize: params?.pageSize || 50,
tags: params?.tags,
enabled: params?.enabled,
search: params?.search,
packRef: params?.packRef,
});

View File

@@ -15,6 +15,7 @@ import {
ExternalLink,
Copy,
Check,
PanelLeftClose,
} from "lucide-react";
import SearchableSelect from "@/components/common/SearchableSelect";
import yaml from "js-yaml";
@@ -40,7 +41,6 @@ import type {
WorkflowBuilderState,
PaletteAction,
TransitionPreset,
CancellationPolicy,
} from "@/types/workflow";
import {
generateUniqueTaskName,
@@ -54,7 +54,6 @@ import {
removeTaskFromTransitions,
renameTaskInTransitions,
findStartingTaskIds,
CANCELLATION_POLICY_LABELS,
} from "@/types/workflow";
const INITIAL_STATE: WorkflowBuilderState = {
@@ -68,10 +67,13 @@ const INITIAL_STATE: WorkflowBuilderState = {
vars: {},
tasks: [],
tags: [],
enabled: true,
cancellationPolicy: "allow_finish",
};
const ACTIONS_SIDEBAR_WIDTH = 256;
const WORKFLOW_OPTIONS_DEFAULT_WIDTH = 360;
const WORKFLOW_OPTIONS_STORAGE_KEY = "workflow-builder-options-width";
export default function WorkflowBuilderPage() {
const navigate = useNavigate();
const { ref: editRef } = useParams<{ ref?: string }>();
@@ -104,10 +106,19 @@ export default function WorkflowBuilderPage() {
const [initialized, setInitialized] = useState(false);
const [showYamlPreview, setShowYamlPreview] = useState(false);
const [sidebarTab, setSidebarTab] = useState<"actions" | "inputs">("actions");
const [workflowOptionsWidth, setWorkflowOptionsWidth] = useState<number>(() => {
if (typeof window === "undefined") {
return WORKFLOW_OPTIONS_DEFAULT_WIDTH;
}
const saved = window.localStorage.getItem(WORKFLOW_OPTIONS_STORAGE_KEY);
const parsed = saved ? Number(saved) : NaN;
return Number.isFinite(parsed) ? parsed : WORKFLOW_OPTIONS_DEFAULT_WIDTH;
});
const [highlightedTransition, setHighlightedTransition] = useState<{
taskId: string;
transitionIndex: number;
} | null>(null);
const [isResizingSidebar, setIsResizingSidebar] = useState(false);
// Start-node warning toast state
const [startWarningVisible, setStartWarningVisible] = useState(false);
@@ -261,6 +272,71 @@ export default function WorkflowBuilderPage() {
return null;
}, [state.tasks, startingTaskIds]);
const getMaxWorkflowOptionsWidth = useCallback(() => {
if (typeof window === "undefined") {
return WORKFLOW_OPTIONS_DEFAULT_WIDTH;
}
return Math.max(
ACTIONS_SIDEBAR_WIDTH,
Math.floor(window.innerWidth * 0.5),
);
}, []);
const clampWorkflowOptionsWidth = useCallback(
(width: number) =>
Math.min(
Math.max(Math.round(width), ACTIONS_SIDEBAR_WIDTH),
getMaxWorkflowOptionsWidth(),
),
[getMaxWorkflowOptionsWidth],
);
useEffect(() => {
setWorkflowOptionsWidth((prev) => clampWorkflowOptionsWidth(prev));
}, [clampWorkflowOptionsWidth]);
useEffect(() => {
const handleResize = () => {
setWorkflowOptionsWidth((prev) => clampWorkflowOptionsWidth(prev));
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [clampWorkflowOptionsWidth]);
useEffect(() => {
window.localStorage.setItem(
WORKFLOW_OPTIONS_STORAGE_KEY,
String(workflowOptionsWidth),
);
}, [workflowOptionsWidth]);
useEffect(() => {
if (!isResizingSidebar) return;
const handleMouseMove = (event: MouseEvent) => {
setWorkflowOptionsWidth(clampWorkflowOptionsWidth(event.clientX));
};
const handleMouseUp = () => {
setIsResizingSidebar(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizingSidebar, clampWorkflowOptionsWidth]);
// Render-phase state adjustment: detect warning key changes for immediate
// show/hide without refs or synchronous setState inside effects.
const warningKey = startNodeWarning
@@ -475,7 +551,6 @@ export default function WorkflowBuilderPage() {
out_schema:
Object.keys(state.output).length > 0 ? state.output : undefined,
tags: state.tags.length > 0 ? state.tags : undefined,
enabled: state.enabled,
},
});
} else {
@@ -493,7 +568,6 @@ export default function WorkflowBuilderPage() {
out_schema:
Object.keys(state.output).length > 0 ? state.output : undefined,
tags: state.tags.length > 0 ? state.tags : undefined,
enabled: state.enabled,
};
try {
await saveWorkflowFile.mutateAsync(fileData);
@@ -635,6 +709,8 @@ export default function WorkflowBuilderPage() {
const isSaving = saveWorkflowFile.isPending || updateWorkflowFile.isPending;
const isExecuting = requestExecution.isPending;
const sidebarWidth =
sidebarTab === "inputs" ? workflowOptionsWidth : ACTIONS_SIDEBAR_WIDTH;
if (isEditing && workflowLoading) {
return (
@@ -675,7 +751,7 @@ export default function WorkflowBuilderPage() {
disabled={isEditing}
/>
<span className="text-gray-400 text-lg font-light">/</span>
<span className="text-gray-400 text-lg font-light">/</span>
{/* Workflow name */}
<input
@@ -692,24 +768,9 @@ export default function WorkflowBuilderPage() {
/>
<span className="text-gray-400 text-lg font-light"></span>
{/* Label */}
<input
type="text"
value={state.label}
onChange={(e) => updateMetadata({ label: e.target.value })}
className="px-2 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 flex-1 min-w-[160px] max-w-[300px]"
placeholder="Workflow Label"
/>
{/* Version */}
<input
type="text"
value={state.version}
onChange={(e) => updateMetadata({ version: e.target.value })}
className="px-2 py-1.5 border border-gray-300 rounded text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-20"
placeholder="1.0.0"
/>
<span className="truncate text-sm text-gray-600">
{state.label || "Untitled workflow"}
</span>
</div>
</div>
@@ -819,59 +880,6 @@ export default function WorkflowBuilderPage() {
</div>
</div>
{/* Description row (collapsible) */}
<div className="mt-2 flex items-center gap-2">
<input
type="text"
value={state.description}
onChange={(e) => updateMetadata({ description: e.target.value })}
className="flex-1 px-2 py-1 border border-gray-200 rounded text-xs text-gray-600 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="Workflow description (optional)"
/>
<div className="flex items-center gap-1.5 flex-shrink-0">
<input
type="text"
value={state.tags.join(", ")}
onChange={(e) =>
updateMetadata({
tags: e.target.value
.split(",")
.map((t) => t.trim())
.filter(Boolean),
})
}
className="px-2 py-1 border border-gray-200 rounded text-xs text-gray-600 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 w-40"
placeholder="Tags (comma-sep)"
/>
<label className="flex items-center gap-1 text-xs text-gray-600">
<input
type="checkbox"
checked={state.enabled}
onChange={(e) => updateMetadata({ enabled: e.target.checked })}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Enabled
</label>
<select
value={state.cancellationPolicy}
onChange={(e) =>
updateMetadata({
cancellationPolicy: e.target.value as CancellationPolicy,
})
}
className="px-2 py-1 border border-gray-200 rounded text-xs text-gray-600 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-white"
title="Cancellation policy: controls how running tasks behave when the workflow is cancelled"
>
{Object.entries(CANCELLATION_POLICY_LABELS).map(
([value, label]) => (
<option key={value} value={value}>
{label}
</option>
),
)}
</select>
</div>
</div>
</div>
{/* Validation errors panel */}
@@ -987,8 +995,11 @@ export default function WorkflowBuilderPage() {
</div>
) : (
<>
{/* Left sidebar: tabbed Actions / Inputs */}
<div className="w-64 border-r border-gray-200 bg-gray-50 flex flex-col h-full overflow-hidden">
{/* Left sidebar: tabbed Actions / Workflow Options */}
<div
className="border-r border-gray-200 bg-gray-50 flex flex-col h-full overflow-hidden relative flex-shrink-0"
style={{ width: sidebarWidth }}
>
{/* Tab header */}
<div className="flex border-b border-gray-200 bg-white flex-shrink-0">
<button
@@ -1011,7 +1022,7 @@ export default function WorkflowBuilderPage() {
}`}
>
<Settings2 className="w-3.5 h-3.5" />
Inputs
Workflow Options
{Object.keys(state.parameters).length > 0 && (
<span className="text-[10px] bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded-full">
{Object.keys(state.parameters).length}
@@ -1029,8 +1040,22 @@ export default function WorkflowBuilderPage() {
/>
) : (
<WorkflowInputsPanel
label={state.label}
version={state.version}
description={state.description}
tags={state.tags}
cancellationPolicy={state.cancellationPolicy}
parameters={state.parameters}
output={state.output}
onLabelChange={(label) => updateMetadata({ label })}
onVersionChange={(version) => updateMetadata({ version })}
onDescriptionChange={(description) =>
updateMetadata({ description })
}
onTagsChange={(tags) => updateMetadata({ tags })}
onCancellationPolicyChange={(cancellationPolicy) =>
updateMetadata({ cancellationPolicy })
}
onParametersChange={(parameters) =>
setState((prev) => ({ ...prev, parameters }))
}
@@ -1039,6 +1064,30 @@ export default function WorkflowBuilderPage() {
}
/>
)}
{sidebarTab === "inputs" && (
<div
className={`absolute top-0 right-0 h-full w-2 translate-x-1/2 cursor-col-resize group ${
isResizingSidebar ? "z-30" : "z-10"
}`}
onMouseDown={(event) => {
event.preventDefault();
setIsResizingSidebar(true);
}}
title="Resize workflow options panel"
>
<div
className={`mx-auto h-full w-px transition-colors ${
isResizingSidebar
? "bg-blue-500"
: "bg-transparent group-hover:bg-blue-300"
}`}
/>
<div className="absolute top-3 right-0 -translate-y-1/2 translate-x-1/2 rounded-full border border-gray-200 bg-white p-1 text-gray-300 shadow-sm group-hover:text-blue-500">
<PanelLeftClose className="w-3 h-3" />
</div>
</div>
)}
</div>
{/* Center: Canvas */}

View File

@@ -224,8 +224,6 @@ export interface WorkflowBuilderState {
tasks: WorkflowTask[];
/** Tags */
tags: string[];
/** Whether the workflow is enabled */
enabled: boolean;
/** Cancellation policy (default: allow_finish) */
cancellationPolicy: CancellationPolicy;
}
@@ -285,7 +283,6 @@ export interface ActionYamlDefinition {
ref: string;
label: string;
description?: string;
enabled: boolean;
workflow_file: string;
parameters?: Record<string, unknown>;
output?: Record<string, unknown>;
@@ -358,8 +355,6 @@ export interface SaveWorkflowFileRequest {
out_schema?: Record<string, unknown>;
/** Tags */
tags?: string[];
/** Whether the workflow is enabled */
enabled?: boolean;
}
/** An action summary used in the action palette */
@@ -581,7 +576,6 @@ export function builderStateToActionYaml(
const action: ActionYamlDefinition = {
ref: `${state.packRef}.${state.name}`,
label: state.label,
enabled: state.enabled,
workflow_file: `workflows/${state.name}.workflow.yaml`,
};
@@ -751,7 +745,6 @@ export function definitionToBuilderState(
vars: definition.vars || {},
tasks,
tags: definition.tags || [],
enabled: true,
cancellationPolicy: definition.cancellation_policy || "allow_finish",
};
}