feat(SKY-11487): advanced full-screen YAML editing mode in workflow editor (#7030)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run

Co-authored-by: AronPerez <aperez0295@gmail.com>
This commit is contained in:
Celal Zamanoğlu 2026-07-03 01:46:34 +03:00 committed by GitHub
parent 720d47d754
commit d735f172be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1006 additions and 43 deletions

View file

@ -12,6 +12,7 @@
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-python": "^6.1.6",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/merge": "^6.8.0",
"@dagrejs/dagre": "^1.1.4",
"@dnd-kit/core": "^6.3.1",
@ -364,6 +365,21 @@
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
@ -1427,6 +1443,17 @@
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",

View file

@ -23,6 +23,7 @@
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-python": "^6.1.6",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/merge": "^6.8.0",
"@dagrejs/dagre": "^1.1.4",
"@dnd-kit/core": "^6.3.1",

View file

@ -3,6 +3,7 @@ import type { ViewUpdate } from "@codemirror/view";
import { json } from "@codemirror/lang-json";
import { python } from "@codemirror/lang-python";
import { html } from "@codemirror/lang-html";
import { yaml } from "@codemirror/lang-yaml";
import { tokyoNightStorm } from "@uiw/codemirror-theme-tokyo-night-storm";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/util/utils";
@ -14,7 +15,9 @@ import {
} from "./oversizedDocument";
import "./code-mirror-overrides.css";
function getLanguageExtension(language: "python" | "json" | "html") {
function getLanguageExtension(
language: "python" | "json" | "html" | "yaml",
): Extension {
switch (language) {
case "python":
return python();
@ -22,13 +25,15 @@ function getLanguageExtension(language: "python" | "json" | "html") {
return json();
case "html":
return html();
case "yaml":
return yaml();
}
}
type Props = {
value: string;
onChange?: (value: string) => void;
language?: "python" | "json" | "html";
language?: "python" | "json" | "html" | "yaml";
lineWrap?: boolean;
readOnly?: boolean;
minHeight?: string;
@ -36,6 +41,10 @@ type Props = {
className?: string;
fontSize?: number;
fullHeight?: boolean;
// Accessible name applied to the CodeMirror editing surface (the
// contenteditable), not the wrapper div.
ariaLabel?: string;
autoFocus?: boolean;
/**
* Additional CodeMirror extensions. Useful for per-use-case concerns
* like linting e.g. the error_code_mapping editor passes a linter
@ -67,6 +76,8 @@ function CodeEditorImpl({
fontSize = 12,
fullHeight = false,
extraExtensions,
ariaLabel,
autoFocus = false,
...restProps
}: Props) {
// `value` is typed `string`, but workflow document panels can pass an
@ -187,8 +198,18 @@ function CodeEditorImpl({
if (fullHeight) {
exts.push(fullHeightExtension);
}
if (ariaLabel) {
exts.push(EditorView.contentAttributes.of({ "aria-label": ariaLabel }));
}
return exts;
}, [language, deeplyNested, effectiveLineWrap, extraExtensions, fullHeight]);
}, [
language,
deeplyNested,
effectiveLineWrap,
extraExtensions,
fullHeight,
ariaLabel,
]);
const style: React.CSSProperties = { fontSize };
if (fullHeight) {
@ -260,6 +281,7 @@ function CodeEditorImpl({
minHeight={minHeight}
maxHeight={maxHeight}
readOnly={readOnly}
autoFocus={autoFocus}
className={cn("cursor-auto", className)}
style={style}
{...restProps}

View file

@ -123,6 +123,7 @@ function getWorkflowElements(version: WorkflowVersion) {
finallyBlockLabel: version.workflow_definition?.finally_block_label ?? null,
workflowSystemPrompt:
version.workflow_definition?.workflow_system_prompt ?? null,
errorCodeMapping: version.workflow_definition?.error_code_mapping ?? null,
};
return getElements(

View file

@ -93,6 +93,7 @@ function Debugger() {
workflow.workflow_definition?.finally_block_label ?? null,
workflowSystemPrompt:
workflow.workflow_definition?.workflow_system_prompt ?? null,
errorCodeMapping: workflow.workflow_definition?.error_code_mapping ?? null,
};
const elements = getElements(blocksToRender, settings, true);

View file

@ -21,6 +21,10 @@ import {
type WorkflowSaveData,
} from "@/store/WorkflowHasChangesStore";
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
import {
commitYamlDraft,
useWorkflowYamlEditorStore,
} from "@/store/WorkflowYamlEditorStore";
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
import { useWorkflowSettingsStore } from "@/store/WorkflowSettingsStore";
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
@ -973,6 +977,11 @@ function FlowRenderer({
}, [constructSaveData, readOnly]);
async function handleSave(): Promise<boolean> {
// With the YAML editor open (e.g. the nav-blocker "Save changes" dialog),
// persist the parsed draft directly instead of the stale pre-edit canvas.
if (useWorkflowYamlEditorStore.getState().active) {
return commitYamlDraft(true);
}
// Validate before saving; block if any workflow errors exist
const errors = getWorkflowErrors(nodes);
if (errors.length > 0) {
@ -989,7 +998,7 @@ function FlowRenderer({
});
return false;
}
await saveWorkflow.mutateAsync();
await saveWorkflow.mutateAsync(undefined);
return true;
}

View file

@ -93,6 +93,7 @@ function WorkflowEditor() {
workflow.workflow_definition?.finally_block_label ?? null,
workflowSystemPrompt:
workflow.workflow_definition?.workflow_system_prompt ?? null,
errorCodeMapping: workflow.workflow_definition?.error_code_mapping ?? null,
};
const elements = getElements(blocksToRender, settings, !isGlobalWorkflow);

View file

@ -19,6 +19,7 @@ import {
} from "@/components/ui/tooltip";
import { statusIsRunningOrQueued } from "@/routes/tasks/types";
import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery";
import { useIsGlobalWorkflow } from "../hooks/useIsGlobalWorkflow";
import { MakeACopyButton } from "./MakeACopyButton";
import { useWorkflowQuery } from "@/routes/workflows/hooks/useWorkflowQuery";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
@ -38,16 +39,6 @@ import { useToggleCodeView } from "./hooks/useToggleCodeView";
import { useWorkflowHeaderCollapseStore } from "./useWorkflowHeaderCollapseStore";
import { WorkflowHeaderCollapseTab } from "./WorkflowHeaderCollapseTab";
function useIsGlobalWorkflow(): boolean {
const { workflowPermanentId } = useParams();
const { data: globalWorkflows } = useGlobalWorkflowsQuery();
return Boolean(
globalWorkflows?.some(
(w) => w.workflow_permanent_id === workflowPermanentId,
),
);
}
function GeneratingCodeButton() {
const showAllCode = useShowAllCodeStore((s) => s.showAllCode);
const toggleCodeView = useToggleCodeView();

View file

@ -0,0 +1,116 @@
import { FocusScope } from "@radix-ui/react-focus-scope";
import { ReloadIcon } from "@radix-ui/react-icons";
import {
commitYamlDraft,
useWorkflowYamlEditorStore,
} from "@/store/WorkflowYamlEditorStore";
import { cn } from "@/util/utils";
import { CodeEditor } from "../components/CodeEditor";
import { YamlModeToggle } from "./YamlModeToggle";
type Props = {
// "fullscreen" (legacy editor): modal overlay covering the whole editor —
// dialog semantics + focus trap. "pane" (studio): swaps the Editor pane's
// content; sibling panes stay usable, so no modal semantics or trap.
variant?: "fullscreen" | "pane";
};
// YAML editing surface, shown while the store is active. Switching back to
// Visual commits the draft into the graph; broken YAML blocks the switch.
function WorkflowYamlEditor({ variant = "fullscreen" }: Props) {
const draft = useWorkflowYamlEditorStore((s) => s.draft);
const error = useWorkflowYamlEditorStore((s) => s.error);
const committing = useWorkflowYamlEditorStore((s) => s.committing);
const setDraft = useWorkflowYamlEditorStore((s) => s.setDraft);
const fullscreen = variant === "fullscreen";
const switchToVisual = () => {
void commitYamlDraft(false);
};
const surface = (
<div
{...(fullscreen
? { role: "dialog", "aria-modal": true }
: { role: "region" })}
aria-label="Workflow YAML editor"
className={cn(
"absolute inset-0 flex flex-col bg-slate-elevation1",
// The pane variant sits under stage-level overlays (Inputs/Schedule
// panels at z-40) but above everything inside the Editor pane.
fullscreen ? "z-50" : "z-30",
)}
onKeyDown={(event) => {
// Standard dialog escape hatch; commit-on-switch means Escape
// behaves exactly like the Visual toggle (invalid YAML stays open).
if (event.key === "Escape" && !committing) {
event.stopPropagation();
switchToVisual();
}
}}
>
<div className="flex items-center justify-between gap-3 border-b border-slate-700 bg-slate-elevation2 px-4 py-2">
<div className="flex min-w-0 items-center gap-3">
<span className="truncate text-xs text-slate-500">
Editing the workflow definition · switch to Visual to apply, then
Save
</span>
{committing ? (
<span className="flex shrink-0 items-center gap-1.5 text-xs text-slate-400">
<ReloadIcon className="size-3 animate-spin" />
Applying
</span>
) : null}
</div>
<YamlModeToggle
mode="code"
onVisual={switchToVisual}
disabled={committing}
/>
</div>
{error ? (
<div
role="alert"
className="border-b border-red-900/50 bg-red-950/60 px-4 py-2 text-sm text-red-200"
>
<strong className="font-semibold">Invalid YAML:</strong> {error}
</div>
) : null}
<div className="min-h-0 flex-1">
<CodeEditor
language="yaml"
value={draft}
onChange={setDraft}
readOnly={committing}
fullHeight
lineWrap={false}
className="h-full"
ariaLabel="Workflow definition YAML editor"
autoFocus
/>
</div>
</div>
);
if (!fullscreen) {
return surface;
}
return (
// Trap focus inside the editor while it's open (the canvas is visually
// covered but still in the tab order); CodeEditor autoFocus lands the
// caret, so prevent FocusScope from stealing focus to the toggle.
<FocusScope
asChild
loop
trapped
onMountAutoFocus={(e) => e.preventDefault()}
>
{surface}
</FocusScope>
);
}
export { WorkflowYamlEditor };

View file

@ -9,7 +9,7 @@ import {
MutableRefObject,
} from "react";
import { nanoid } from "nanoid";
import { stringify as convertToYAML } from "yaml";
import { parse as parseYAML, stringify as convertToYAML } from "yaml";
import {
CheckIcon,
ChevronRightIcon,
@ -34,12 +34,17 @@ import {
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import {
useWorkflowYamlEditorStore,
isWorkflowYamlDirty,
} from "@/store/WorkflowYamlEditorStore";
import { getClient } from "@/api/AxiosClient";
import { DebugSessionApiResponse, ProxyLocation } from "@/api/types";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useMountEffect } from "@/hooks/useMountEffect";
import { useBrowserSessionRateLimit } from "../hooks/useBrowserSessionRateLimit";
import { useDebugSessionQuery } from "../hooks/useDebugSessionQuery";
import { useIsGlobalWorkflow } from "../hooks/useIsGlobalWorkflow";
import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQuery";
import { BrowserSessionStream } from "@/routes/browserSessions/BrowserSessionStream";
import { useBrowserStreamingMode } from "@/hooks/useRuntimeConfig";
@ -96,7 +101,11 @@ import {
BranchContext,
useWorkflowPanelStore,
} from "@/store/WorkflowPanelStore";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import {
useWorkflowHasChangesStore,
useWorkflowSave,
type WorkflowSaveData,
} from "@/store/WorkflowHasChangesStore";
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
import { getCode, getOrderedBlockLabels } from "@/routes/workflows/utils";
@ -134,6 +143,9 @@ import {
generateNodeLabel,
layout,
startNode,
getWorkflowBlocks,
getWorkflowErrors,
upgradeWorkflowDefinitionToVersionTwo,
} from "./workflowEditorUtils";
import { replayPersistedCollapseVisibility } from "./collapse/applyDescendantCollapseVisibility";
import { useNodeCollapseStore } from "./collapse/useNodeCollapseStore";
@ -149,7 +161,7 @@ import { WorkflowHeader } from "./WorkflowHeader";
import { WorkflowHistoryPanel } from "./panels/WorkflowHistoryPanel";
import { WorkflowSchedulePanel } from "./panels/schedulePanel/WorkflowSchedulePanel";
import { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery";
import { WorkflowSettings } from "../types/workflowTypes";
import { WorkflowDefinition, WorkflowSettings } from "../types/workflowTypes";
import { shouldKeepExistingEdgeForInsertion } from "./workflowInsertion";
import { constructCacheKeyValue, getInitialParameters } from "./utils";
@ -162,6 +174,13 @@ import { WorkflowCopilotButton } from "../copilot/WorkflowCopilotButton";
import { resolveCopilotLiveBrowserReady } from "../copilot/browserReadiness";
import type { WorkflowYAMLConversionResponse } from "../copilot/workflowCopilotTypes";
import { WorkflowYamlEditor } from "./WorkflowYamlEditor";
import { YamlModeToggle } from "./YamlModeToggle";
import { useWorkflowYamlEditorLifecycle } from "./hooks/useWorkflowYamlEditorLifecycle";
import {
preservedFinallyBlockLabel,
workflowVersionFromSaveData,
} from "./workflowVersionFromSaveData";
import "./workspace-styles.css";
function getAxiosErrorDetail(error: unknown): string | undefined {
@ -391,6 +410,10 @@ function Workspace({
(s) => s.renderedWidth,
);
const handleOnSave = useSaveWorkflow();
const saveWorkflow = useWorkflowSave({ status: "published" });
// Global/read-only workflows can't be edited in place (the header offers
// "Make a Copy"), so the YAML editor must not open or commit for them.
const isGlobalWorkflow = useIsGlobalWorkflow();
const postHog = usePostHog();
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
@ -480,6 +503,13 @@ function Workspace({
const [isReloading, setIsReloading] = useState(false);
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const yamlEditorActive = useWorkflowYamlEditorStore((s) => s.active);
const yamlEditorDirty = useWorkflowYamlEditorStore(
(s) => s.active && isWorkflowYamlDirty(s),
);
// hasChanges at the moment YAML mode opened, so reverting a YAML edit
// restores the pre-YAML dirty state instead of leaving it stuck true.
const yamlEntryHadChangesRef = useRef(false);
const [shouldFetchDebugSession, setShouldFetchDebugSession] = useState(false);
const blockScriptStore = useBlockScriptStore();
const recordingStore = useRecordingStore();
@ -1444,6 +1474,8 @@ function Workspace({
workflowData.workflow_definition?.finally_block_label ?? null,
workflowSystemPrompt:
workflowData.workflow_definition?.workflow_system_prompt ?? null,
errorCodeMapping:
workflowData.workflow_definition?.error_code_mapping ?? null,
};
const elements = getElements(
@ -1481,6 +1513,205 @@ function Workspace({
}
};
// Serialize the live workflow_definition to YAML and open the full-screen
// editor. Only the definition round-trips; settings stay in the visual panels.
const enterYamlMode = () => {
if (isGlobalWorkflow) {
return;
}
const saveData = workflowChangesStore.getSaveData?.();
if (!saveData) {
toast({
title: "Cannot edit YAML",
description: "The workflow is still loading. Try again in a moment.",
variant: "destructive",
});
return;
}
// The schema `version` and workflow-level settings (finally_block_label,
// workflow_system_prompt, error_code_mapping) are intentionally NOT
// serialized — the YAML editor edits parameters + blocks; the rest is
// preserved from the current workflow on commit.
const yaml = convertToYAML({
parameters: saveData.parameters,
blocks: saveData.blocks,
});
yamlEntryHadChangesRef.current =
useWorkflowHasChangesStore.getState().hasChanges;
useWorkflowYamlEditorStore.getState().open(yaml);
};
// Commit-on-switch: reparse the edited YAML into the graph via the Copilot's
// non-persisting convert endpoint. Returns false on invalid YAML (stays open).
const commitYaml = async (persist: boolean = false): Promise<boolean> => {
const yamlStore = useWorkflowYamlEditorStore.getState();
// Never rebuild an editable canvas from YAML for a read-only workflow.
if (isGlobalWorkflow) {
yamlStore.close();
return false;
}
const saveData = workflowChangesStore.getSaveData?.();
if (!saveData) {
return false;
}
if (!isWorkflowYamlDirty(yamlStore)) {
// The YAML is unchanged, but a save must still persist any pending
// pre-YAML (visual-editor) edits — validate the frozen graph the same way
// the normal visual save does, so invalid edits can't slip through here.
if (persist) {
const errors = getWorkflowErrors(nodes);
if (errors.length > 0) {
toast({
title: "Can not save workflow because of errors:",
description: errors.join(" "),
variant: "destructive",
});
return false;
}
try {
await saveWorkflow.mutateAsync(undefined);
} catch {
// Surfaced by the save mutation's own error toast; keep the editor
// open and honor the Promise<boolean> contract instead of throwing.
return false;
}
}
yamlStore.close();
return true;
}
let parsed: {
parameters?: WorkflowSaveData["parameters"];
blocks?: WorkflowSaveData["blocks"];
};
try {
parsed = parseYAML(yamlStore.draft);
} catch (error) {
yamlStore.setError(
error instanceof Error ? error.message : "Could not parse YAML.",
);
return false;
}
try {
const client = await getClient(credentialGetter, "sans-api-v1");
const response = await client.post<WorkflowYAMLConversionResponse>(
"/workflow/copilot/convert-yaml-to-blocks",
{
workflow_definition_yaml: yamlStore.draft,
workflow_id: saveData.workflow.workflow_id,
},
);
let extraHttpHeaders: Record<string, string> | null;
let cdpConnectHeaders: Record<string, string> | null;
try {
extraHttpHeaders = saveData.settings.extraHttpHeaders
? parseHeaderJson(saveData.settings.extraHttpHeaders)
: null;
cdpConnectHeaders = saveData.settings.cdpConnectHeaders
? parseHeaderJson(saveData.settings.cdpConnectHeaders)
: null;
} catch {
yamlStore.setError(
"Couldn't parse the workflow's HTTP headers — fix them in the visual editor before switching.",
);
return false;
}
// Workflow-level settings aren't in the YAML; carry the current values
// back so the round-trip preserves them. finally_block_label points at a
// top-level block, so drop it if the edit removed/renamed that block —
// otherwise the next save fails on a dangling reference.
const finallyBlockLabel = preservedFinallyBlockLabel(
saveData.settings.finallyBlockLabel,
(response.data.workflow_definition.blocks ?? []).map(
(block) => block.label,
),
);
const definition: WorkflowDefinition = {
...response.data.workflow_definition,
// Keep the convert's detected schema version (it upgrades to 2 for
// conditional / next_block_label routing) so it matches the converted
// graph — the omitted YAML `version` lets the backend detect it.
finally_block_label: finallyBlockLabel,
workflow_system_prompt: saveData.settings.workflowSystemPrompt ?? null,
error_code_mapping: saveData.settings.errorCodeMapping ?? null,
};
const version = workflowVersionFromSaveData(saveData, definition, {
extraHttpHeaders,
cdpConnectHeaders,
});
if (persist) {
// Persist the repaired graph, not the raw draft: the convert endpoint
// repairs dangling next_block_label links, so run its blocks through the
// same getElements -> getWorkflowBlocks normalization the canvas gets.
// Reading the response (not the graph) keeps this race-free vs setNodes.
const { nodes: repairedNodes, edges: repairedEdges } = getElements(
response.data.workflow_definition.blocks ?? [],
{ ...saveData.settings, finallyBlockLabel },
true,
);
// Gate the persist the same way the visual save is gated (a parseable
// draft can still be an editor-invalid workflow, e.g. an empty
// navigation prompt). A plain switch to Visual stays ungated so the
// errors can be fixed on the canvas.
const repairedErrors = getWorkflowErrors(repairedNodes);
if (repairedErrors.length > 0) {
toast({
title: "Can not save workflow because of errors:",
description: repairedErrors.join(" "),
variant: "destructive",
});
return false;
}
// getWorkflowBlocks emits explicit next_block_label routing from the
// graph, so run the same version upgrade the visual save runs — a
// version-1 payload with routing is rejected by the backend.
const { blocks: upgradedBlocks, version: upgradedVersion } =
upgradeWorkflowDefinitionToVersionTwo(
getWorkflowBlocks(repairedNodes, repairedEdges),
response.data.workflow_definition.version ??
saveData.workflowDefinitionVersion,
);
try {
await saveWorkflow.mutateAsync({
blocks: upgradedBlocks,
parameters: parsed?.parameters ?? [],
workflowDefinitionVersion: upgradedVersion,
settings: { ...saveData.settings, finallyBlockLabel },
});
} catch {
// A persist/network failure is already surfaced by the save
// mutation's own error toast — don't relabel it "Invalid YAML" in the
// overlay (the outer catch), and keep the editor open with the draft.
return false;
}
}
applyWorkflowUpdate(version, { persisted: persist });
yamlStore.close();
return true;
} catch (error) {
const detail =
error instanceof AxiosError
? (error.response?.data?.detail ?? error.message)
: error instanceof Error
? error.message
: "Could not convert YAML into workflow blocks.";
yamlStore.setError(detail);
return false;
}
};
useWorkflowYamlEditorLifecycle(commitYaml);
// Reflect YAML-draft dirtiness in the unsaved-changes flag so the existing
// tab-close / navigation guards protect the draft — two-way, so reverting the
// draft restores the dirty state from when YAML mode opened.
useEffect(() => {
if (yamlEditorActive) {
useWorkflowHasChangesStore
.getState()
.setHasChanges(yamlEntryHadChangesRef.current || yamlEditorDirty);
}
}, [yamlEditorActive, yamlEditorDirty]);
const handleSelectState = (selectedVersion: WorkflowVersion) => {
// Close panels
setWorkflowPanelState({
@ -1520,6 +1751,8 @@ function Workspace({
selectedVersion.workflow_definition?.finally_block_label ?? null,
workflowSystemPrompt:
selectedVersion.workflow_definition?.workflow_system_prompt ?? null,
errorCodeMapping:
selectedVersion.workflow_definition?.error_code_mapping ?? null,
};
const elements = getElements(
@ -1779,7 +2012,12 @@ function Workspace({
<>
{/* infinite canvas and sub panels when not in debug mode */}
{!showBrowser && (
<div className="relative flex h-full w-full overflow-hidden overflow-x-hidden">
<div
className="relative flex h-full w-full overflow-hidden overflow-x-hidden"
// The YAML surface covers this subtree visually but it would stay
// in the tab order; inert removes it (pane variant has no trap).
{...(yamlEditorActive ? { inert: "" } : {})}
>
{/* infinite canvas */}
<FlowRenderer
nodes={nodes}
@ -2597,6 +2835,17 @@ function Workspace({
});
}}
/>
{!yamlEditorActive && !isGlobalWorkflow ? (
<div className="absolute right-4 top-3 z-30">
<YamlModeToggle mode="visual" onCode={enterYamlMode} />
</div>
) : null}
{/* Studio: Code mode swaps the Editor pane's content (sibling panes stay
usable); legacy keeps the original full-screen modal overlay. */}
{yamlEditorActive ? (
<WorkflowYamlEditor variant={embedded ? "pane" : "fullscreen"} />
) : null}
</div>
);
}

View file

@ -0,0 +1,51 @@
import { CodeIcon, EyeOpenIcon } from "@radix-ui/react-icons";
import { cn } from "@/util/utils";
const segmentClass = (active: boolean) =>
cn(
"flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors",
active
? "bg-slate-elevation3 text-slate-100"
: "text-slate-400 hover:text-slate-200",
);
type Props = {
mode: "visual" | "code";
onVisual?: () => void;
onCode?: () => void;
disabled?: boolean;
};
// Segmented Visual/Code toggle for the workflow editor. "Code" opens the
// full-screen YAML editor; "Visual" commits the YAML back into the canvas.
export function YamlModeToggle({ mode, onVisual, onCode, disabled }: Props) {
return (
<div className="flex items-center gap-0.5 rounded-md border border-slate-700 bg-slate-elevation1 p-0.5">
{/* The active segment stays focusable (aria-disabled, not disabled) so
keyboard users can tab to it and hear the pressed state. */}
<button
type="button"
aria-pressed={mode === "visual"}
aria-disabled={mode === "visual" || undefined}
disabled={disabled}
onClick={mode === "visual" ? undefined : onVisual}
className={segmentClass(mode === "visual")}
>
<EyeOpenIcon className="size-3" />
Visual
</button>
<button
type="button"
aria-pressed={mode === "code"}
aria-disabled={mode === "code" || undefined}
disabled={disabled}
onClick={mode === "code" ? undefined : onCode}
className={segmentClass(mode === "code")}
>
<CodeIcon className="size-3" />
Code
</button>
</div>
);
}

View file

@ -5,11 +5,12 @@ import { useCallback } from "react";
import { toast } from "@/components/ui/use-toast";
import { useCacheKeyValueStore } from "@/store/CacheKeyValueStore";
import {
useWorkflowHasChangesStore,
useWorkflowSave,
} from "@/store/WorkflowHasChangesStore";
import { useWorkflowSave } from "@/store/WorkflowHasChangesStore";
import { useWorkflowQuery } from "@/routes/workflows/hooks/useWorkflowQuery";
import {
commitYamlDraft,
useWorkflowYamlEditorStore,
} from "@/store/WorkflowYamlEditorStore";
import { getWorkflowErrors } from "../workflowEditorUtils";
import type { AppNode } from "../nodes";
@ -17,13 +18,20 @@ export function useSaveWorkflow(): () => Promise<void> {
const { workflowPermanentId } = useParams();
const reactFlow = useReactFlow<AppNode>();
const saveWorkflow = useWorkflowSave({ status: "published" });
const workflowChangesStore = useWorkflowHasChangesStore();
const setFilter = useCacheKeyValueStore((s) => s.setFilter);
const queryClient = useQueryClient();
const { data: workflow } = useWorkflowQuery({ workflowPermanentId });
const cacheKey = workflow?.cache_key ?? "";
return useCallback(async () => {
// While the YAML editor is open, saving persists the parsed draft directly
// rather than the stale pre-edit canvas — committing applies the graph via
// async setNodes, so a graph-based save here would race it.
if (useWorkflowYamlEditorStore.getState().active) {
await commitYamlDraft(true);
return;
}
const nodes = reactFlow.getNodes();
const errors = getWorkflowErrors(nodes);
if (errors.length > 0) {
@ -41,9 +49,7 @@ export function useSaveWorkflow(): () => Promise<void> {
return;
}
await saveWorkflow.mutateAsync();
workflowChangesStore.setSaidOkToCodeCacheDeletion(false);
await saveWorkflow.mutateAsync(undefined);
queryClient.invalidateQueries({
queryKey: ["cache-key-values", workflowPermanentId, cacheKey],
@ -53,7 +59,6 @@ export function useSaveWorkflow(): () => Promise<void> {
}, [
reactFlow,
saveWorkflow,
workflowChangesStore,
queryClient,
workflowPermanentId,
cacheKey,

View file

@ -0,0 +1,52 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { useWorkflowYamlEditorStore } from "@/store/WorkflowYamlEditorStore";
import { useWorkflowYamlEditorLifecycle } from "./useWorkflowYamlEditorLifecycle";
describe("useWorkflowYamlEditorLifecycle", () => {
beforeEach(() => {
useWorkflowYamlEditorStore.getState().close();
useWorkflowYamlEditorStore.getState().registerCommit(null);
});
test("registers a wrapper that always calls the latest commit closure", async () => {
const first = vi.fn().mockResolvedValue(true);
const second = vi.fn().mockResolvedValue(false);
const { rerender } = renderHook(
({ commit }: { commit: (persist?: boolean) => Promise<boolean> }) =>
useWorkflowYamlEditorLifecycle(commit),
{ initialProps: { commit: first } },
);
await useWorkflowYamlEditorStore.getState().commit?.(true);
expect(first).toHaveBeenCalledWith(true);
rerender({ commit: second });
await useWorkflowYamlEditorStore.getState().commit?.(false);
expect(second).toHaveBeenCalledWith(false);
expect(first).toHaveBeenCalledTimes(1);
});
// Pins the unmount cleanup: the store is global, so a dirty draft left
// active after unmount would leak into the next workflow's editor and could
// be committed there.
test("unmount closes the editor so a dirty draft cannot leak across workflows", () => {
const { unmount } = renderHook(() =>
useWorkflowYamlEditorLifecycle(async () => true),
);
useWorkflowYamlEditorStore.getState().open("blocks: []");
useWorkflowYamlEditorStore.getState().setDraft("blocks: [changed]");
expect(useWorkflowYamlEditorStore.getState().active).toBe(true);
unmount();
const state = useWorkflowYamlEditorStore.getState();
expect(state.active).toBe(false);
expect(state.draft).toBe("");
expect(state.entrySnapshot).toBe("");
expect(state.commit).toBeNull();
});
});

View file

@ -0,0 +1,23 @@
import { useEffect, useRef } from "react";
import { useWorkflowYamlEditorStore } from "@/store/WorkflowYamlEditorStore";
type CommitYaml = (persist?: boolean) => Promise<boolean>;
// Registers a stable wrapper so the YAML-aware save paths and the overlay's
// Visual toggle always call the owning editor's latest commit closure. The
// store is global, so unmount must close() it — otherwise a dirty draft leaks
// into the next workflow's editor (Workspace remounts per workflow id).
export function useWorkflowYamlEditorLifecycle(commitYaml: CommitYaml): void {
const commitYamlRef = useRef(commitYaml);
commitYamlRef.current = commitYaml;
const registerCommit = useWorkflowYamlEditorStore((s) => s.registerCommit);
useEffect(() => {
const commit = (persist?: boolean) => commitYamlRef.current(persist);
registerCommit(commit);
return () => {
registerCommit(null);
useWorkflowYamlEditorStore.getState().close();
};
}, [registerCommit]);
}

View file

@ -35,6 +35,7 @@ const DEFAULT_SETTINGS: WorkflowSettings = {
sequentialKey: null,
finallyBlockLabel: null,
workflowSystemPrompt: null,
errorCodeMapping: null,
};
function op(label: string): OutputParameter {

View file

@ -62,6 +62,7 @@ const startNodeData: WorkflowStartNodeData = {
sequentialKey: null,
finallyBlockLabel: null,
workflowSystemPrompt: null,
errorCodeMapping: null,
label: "__start_block__",
showCode: false,
};

View file

@ -28,6 +28,7 @@ const startNodeData: WorkflowStartNodeData = {
sequentialKey: null,
finallyBlockLabel: null,
workflowSystemPrompt: null,
errorCodeMapping: null,
label: "__start_block__",
showCode: false,
};

View file

@ -24,6 +24,7 @@ export type WorkflowStartNodeData = {
sequentialKey: string | null;
finallyBlockLabel: string | null;
workflowSystemPrompt: string | null;
errorCodeMapping: Record<string, string> | null;
label: "__start_block__";
showCode: boolean;
};

View file

@ -431,7 +431,7 @@ function NodeHeader({
throw new ValidationFailureError();
}
await saveWorkflow.mutateAsync();
await saveWorkflow.mutateAsync(undefined);
if (!workflowPermanentId) {
log.error("Run block: there is no workflowPermanentId");

View file

@ -159,6 +159,7 @@ function getWorkflowElements(version: WorkflowVersion) {
finallyBlockLabel: version.workflow_definition?.finally_block_label ?? null,
workflowSystemPrompt:
version.workflow_definition?.workflow_system_prompt ?? null,
errorCodeMapping: version.workflow_definition?.error_code_mapping ?? null,
};
// Deep clone the blocks to ensure complete isolation from main editor

View file

@ -64,6 +64,7 @@ const DEFAULT_SETTINGS: WorkflowSettings = {
sequentialKey: null,
finallyBlockLabel: null,
workflowSystemPrompt: null,
errorCodeMapping: null,
};
function makeOutputParameter(label: string): OutputParameter {

View file

@ -4,7 +4,11 @@ import { describe, expect, test } from "vitest";
import { ProxyLocation } from "@/api/types";
import type { AppNode } from "../nodes";
import { getElements, getWorkflowBlocks } from "../workflowEditorUtils";
import {
getElements,
getWorkflowBlocks,
getWorkflowSettings,
} from "../workflowEditorUtils";
import {
type CodeBlock,
type OutputParameter,
@ -54,6 +58,7 @@ const DEFAULT_SETTINGS: WorkflowSettings = {
sequentialKey: null,
finallyBlockLabel: null,
workflowSystemPrompt: null,
errorCodeMapping: null,
};
function makeOutputParameter(label: string): OutputParameter {
@ -448,4 +453,38 @@ describe("round-trip reorder → save → reload (M1 top-level)", () => {
const { validationError } = getElements(malformed, DEFAULT_SETTINGS, false);
expect(validationError).toBeNull();
});
// error_code_mapping is not editable in YAML, but it must still ride on the
// start node so it's preserved (not cleared) across a load -> save round-trip.
test("workflow-level error_code_mapping rides on the start node so it survives a save", () => {
const settings: WorkflowSettings = {
...DEFAULT_SETTINGS,
errorCodeMapping: { OUT_OF_STOCK: "item unavailable" },
};
const { nodes } = getElements(buildFiveBlockFixture(), settings, true);
const startNode = nodes.find((node) => node.type === "start");
expect(
(startNode?.data as { errorCodeMapping?: unknown } | undefined)
?.errorCodeMapping,
).toEqual({ OUT_OF_STOCK: "item unavailable" });
});
// The full recovery leg the save path relies on: workflow-level settings ride
// load -> start node -> getWorkflowSettings with zero field loss, so a YAML
// commit (which reattaches settings from this readback) cannot drop them.
test("workflow-level settings survive the getElements -> getWorkflowSettings round-trip", () => {
const settings: WorkflowSettings = {
...DEFAULT_SETTINGS,
errorCodeMapping: { OUT_OF_STOCK: "item unavailable" },
finallyBlockLabel: "B5",
workflowSystemPrompt: "always double-check totals",
};
const { nodes } = getElements(buildFiveBlockFixture(), settings, true);
const recovered = getWorkflowSettings(nodes);
expect(recovered.errorCodeMapping).toEqual({
OUT_OF_STOCK: "item unavailable",
});
expect(recovered.finallyBlockLabel).toBe("B5");
expect(recovered.workflowSystemPrompt).toBe("always double-check totals");
});
});

View file

@ -2004,6 +2004,7 @@ function getElements(
sequentialKey: settings.sequentialKey,
finallyBlockLabel: settings.finallyBlockLabel ?? null,
workflowSystemPrompt: settings.workflowSystemPrompt ?? null,
errorCodeMapping: settings.errorCodeMapping ?? null,
}),
);
@ -3418,6 +3419,7 @@ function getWorkflowSettings(nodes: Array<AppNode>): WorkflowSettings {
sequentialKey: null,
finallyBlockLabel: null,
workflowSystemPrompt: null,
errorCodeMapping: null,
};
const startNodes = nodes.filter(isStartNode);
const startNodeWithWorkflowSettings = startNodes.find(
@ -3453,6 +3455,7 @@ function getWorkflowSettings(nodes: Array<AppNode>): WorkflowSettings {
sequentialKey: data.sequentialKey,
finallyBlockLabel: data.finallyBlockLabel ?? null,
workflowSystemPrompt: data.workflowSystemPrompt ?? null,
errorCodeMapping: data.errorCodeMapping ?? null,
};
}
return defaultSettings;

View file

@ -0,0 +1,138 @@
import { describe, expect, test } from "vitest";
import { ProxyLocation } from "@/api/types";
import type { WorkflowSaveData } from "@/store/WorkflowHasChangesStore";
import type {
WorkflowDefinition,
WorkflowSettings,
} from "../types/workflowTypes";
import {
preservedFinallyBlockLabel,
workflowVersionFromSaveData,
} from "./workflowVersionFromSaveData";
const definition: WorkflowDefinition = {
parameters: [],
blocks: [],
};
function makeSaveData(
settingsOverrides: Partial<WorkflowSettings> = {},
): WorkflowSaveData {
const settings = {
proxyLocation: ProxyLocation.Residential,
webhookCallbackUrl: "https://example.test/hook",
persistBrowserSession: true,
browserProfileId: "bp_1",
browserProfileKey: "key_1",
model: null,
maxScreenshotScrolls: 5,
maxElapsedTimeMinutes: 30,
extraHttpHeaders: null,
cdpConnectHeaders: null,
runWith: "agent",
codeVersion: null,
scriptCacheKey: "cache_1",
aiFallback: true,
runSequentially: false,
sequentialKey: null,
finallyBlockLabel: null,
workflowSystemPrompt: null,
errorCodeMapping: null,
...settingsOverrides,
} as WorkflowSettings;
return {
parameters: [],
blocks: [],
workflowDefinitionVersion: 2,
title: "My Workflow",
settings,
workflow: {
workflow_id: "w_1",
organization_id: "org_1",
workflow_permanent_id: "wpid_1",
version: 3,
description: "desc",
is_saved_task: false,
is_template: false,
totp_verification_url: null,
totp_identifier: null,
status: "published",
created_at: "2026-01-01T00:00:00Z",
modified_at: "2026-01-02T00:00:00Z",
deleted_at: null,
adaptive_caching: true,
folder_id: "f_1",
import_error: "prior import failed",
},
} as unknown as WorkflowSaveData;
}
describe("workflowVersionFromSaveData", () => {
test("carries identity and title, and uses the passed definition", () => {
const version = workflowVersionFromSaveData(makeSaveData(), definition, {
extraHttpHeaders: null,
cdpConnectHeaders: null,
});
expect(version.workflow_id).toBe("w_1");
expect(version.workflow_permanent_id).toBe("wpid_1");
expect(version.title).toBe("My Workflow");
expect(version.version).toBe(3);
expect(version.workflow_definition).toBe(definition);
// import_error is carried over, not reset (would otherwise clear after a
// YAML commit on a workflow with a prior import error).
expect(version.import_error).toBe("prior import failed");
});
test("maps editor settings onto the version", () => {
const version = workflowVersionFromSaveData(makeSaveData(), definition, {
extraHttpHeaders: { a: "b" },
cdpConnectHeaders: null,
});
expect(version.proxy_location).toBe(ProxyLocation.Residential);
expect(version.persist_browser_session).toBe(true);
expect(version.browser_profile_id).toBe("bp_1");
expect(version.cache_key).toBe("cache_1");
expect(version.extra_http_headers).toEqual({ a: "b" });
expect(version.adaptive_caching).toBe(true);
});
test("code_version is null for agent runs and defaults to 2 for code runs", () => {
const agent = workflowVersionFromSaveData(makeSaveData(), definition, {
extraHttpHeaders: null,
cdpConnectHeaders: null,
});
expect(agent.run_with).toBe("agent");
expect(agent.code_version).toBeNull();
const code = workflowVersionFromSaveData(
makeSaveData({ runWith: "code", codeVersion: null }),
definition,
{ extraHttpHeaders: null, cdpConnectHeaders: null },
);
expect(code.run_with).toBe("code");
expect(code.code_version).toBe(2);
});
});
describe("preservedFinallyBlockLabel", () => {
test("keeps the label when its block still exists", () => {
expect(
preservedFinallyBlockLabel("cleanup", ["step_1", "cleanup", "step_2"]),
).toBe("cleanup");
});
test("drops the label when the block was removed or renamed", () => {
expect(preservedFinallyBlockLabel("cleanup", ["step_1", "step_2"])).toBe(
null,
);
expect(preservedFinallyBlockLabel("cleanup", [])).toBe(null);
});
test("returns null for an unset finally_block_label", () => {
expect(preservedFinallyBlockLabel(null, ["step_1"])).toBe(null);
expect(preservedFinallyBlockLabel(undefined, ["step_1"])).toBe(null);
expect(preservedFinallyBlockLabel("", ["step_1"])).toBe(null);
});
});

View file

@ -0,0 +1,73 @@
import type { WorkflowSaveData } from "@/store/WorkflowHasChangesStore";
import type { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery";
import type { WorkflowDefinition } from "../types/workflowTypes";
// finally_block_label references a top-level block. After a YAML edit that
// removed or renamed that block, reattaching the old label would leave a
// dangling reference and the save would fail — so keep it only when the block
// still exists among the committed top-level blocks.
export function preservedFinallyBlockLabel(
finallyBlockLabel: string | null | undefined,
topLevelBlockLabels: Iterable<string>,
): string | null {
if (!finallyBlockLabel) {
return null;
}
return new Set(topLevelBlockLabels).has(finallyBlockLabel)
? finallyBlockLabel
: null;
}
// Builds a WorkflowVersion from the live editor state, swapping in a freshly
// parsed workflow_definition — so YAML mode only ever changes the definition.
// NOTE: every WorkflowVersion field is mapped by hand below. Adding a field to
// the type without adding it here silently drops it on a YAML commit (a
// ghost-reset on next reload), with no type error to catch the omission.
export function workflowVersionFromSaveData(
saveData: WorkflowSaveData,
workflowDefinition: WorkflowDefinition,
headers: {
extraHttpHeaders: Record<string, string> | null;
cdpConnectHeaders: Record<string, string> | null;
},
): WorkflowVersion {
const { settings, workflow } = saveData;
return {
workflow_id: workflow.workflow_id,
organization_id: workflow.organization_id,
is_saved_task: workflow.is_saved_task ?? false,
is_template: workflow.is_template ?? false,
title: saveData.title,
workflow_permanent_id: workflow.workflow_permanent_id,
version: workflow.version ?? 0,
description: workflow.description ?? "",
workflow_definition: workflowDefinition,
proxy_location: settings.proxyLocation,
webhook_callback_url: settings.webhookCallbackUrl,
extra_http_headers: headers.extraHttpHeaders,
cdp_connect_headers: headers.cdpConnectHeaders,
persist_browser_session: settings.persistBrowserSession,
browser_profile_id: settings.browserProfileId,
browser_profile_key: settings.browserProfileKey,
model: settings.model,
totp_verification_url: workflow.totp_verification_url,
totp_identifier: workflow.totp_identifier ?? null,
max_screenshot_scrolls: settings.maxScreenshotScrolls,
max_elapsed_time_minutes: settings.maxElapsedTimeMinutes ?? null,
status: workflow.status,
created_at: workflow.created_at,
modified_at: workflow.modified_at,
deleted_at: workflow.deleted_at ?? null,
run_with: settings.runWith,
cache_key: settings.scriptCacheKey,
ai_fallback: settings.aiFallback,
adaptive_caching: workflow.adaptive_caching ?? false,
code_version:
settings.runWith === "code" ? (settings.codeVersion ?? 2) : null,
run_sequentially: settings.runSequentially,
sequential_key: settings.sequentialKey,
folder_id: workflow.folder_id ?? null,
import_error: workflow.import_error ?? null,
};
}

View file

@ -0,0 +1,15 @@
import { useParams } from "react-router-dom";
import { useGlobalWorkflowsQuery } from "./useGlobalWorkflowsQuery";
// Global (read-only) workflows can't be edited in place — the header offers
// "Make a Copy" instead. Editing surfaces gate on this.
export function useIsGlobalWorkflow(): boolean {
const { workflowPermanentId } = useParams();
const { data: globalWorkflows } = useGlobalWorkflowsQuery();
return Boolean(
globalWorkflows?.some(
(w) => w.workflow_permanent_id === workflowPermanentId,
),
);
}

View file

@ -34,21 +34,11 @@ import { EditableNodeTitle } from "../editor/nodes/components/EditableNodeTitle"
import { EditorOverflowMenu } from "../editor/header/EditorOverflowMenu";
import { MakeACopyButton } from "../editor/MakeACopyButton";
import { useSaveWorkflow } from "../editor/hooks/useSaveWorkflow";
import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery";
import { useIsGlobalWorkflow } from "../hooks/useIsGlobalWorkflow";
import { useWorkflowRunWithWorkflowQuery } from "../hooks/useWorkflowRunWithWorkflowQuery";
import { runOutcomeFromStatus } from "./runProjections";
import { useStudioRunId } from "./useStudioRunId";
function useIsGlobalWorkflow(): boolean {
const { workflowPermanentId } = useParams();
const { data: globalWorkflows } = useGlobalWorkflowsQuery();
return Boolean(
globalWorkflows?.some(
(w) => w.workflow_permanent_id === workflowPermanentId,
),
);
}
function TitleSection({ editable = true }: { editable?: boolean }) {
const { title, setTitle } = useWorkflowTitleStore();
const setHasChanges = useWorkflowHasChangesStore((s) => s.setHasChanges);

View file

@ -643,6 +643,7 @@ export type WorkflowDefinition = {
blocks: Array<WorkflowBlock>;
finally_block_label?: string | null;
workflow_system_prompt?: string | null;
error_code_mapping?: Record<string, string> | null;
};
export type WorkflowApiResponse = {
@ -701,6 +702,7 @@ export type WorkflowSettings = {
sequentialKey: string | null;
finallyBlockLabel: string | null;
workflowSystemPrompt: string | null;
errorCodeMapping: Record<string, string> | null;
};
export type WorkflowModel = JsonObjectExtendable<{ model_name: string }>;

View file

@ -38,6 +38,7 @@ export type WorkflowDefinitionYAML = {
blocks: Array<BlockYAML>;
finally_block_label?: string | null;
workflow_system_prompt?: string | null;
error_code_mapping?: Record<string, string> | null;
};
export type ParameterYAML =

View file

@ -92,17 +92,22 @@ const useWorkflowSave = (opts?: WorkflowSaveOpts) => {
saidOkToCodeCacheDeletion,
setHasChanges,
setSaveIsPending,
setSaidOkToCodeCacheDeletion,
setShowConfirmCodeCacheDeletion,
} = useWorkflowHasChangesStore();
const saveWorkflowMutation = useMutation({
mutationFn: async () => {
const saveData = getSaveData();
mutationFn: async (override?: Partial<SaveData>) => {
const base = getSaveData();
if (!saveData) {
if (!base) {
setHasChanges(false);
return;
}
// YAML-mode saves pass the parsed draft (blocks/parameters/version, plus
// a corrected finally_block_label) so we persist the edit directly
// instead of the graph, which lags a commit's async setNodes.
const saveData: SaveData = override ? { ...base, ...override } : base;
const client = await getClient(credentialGetter);
const extraHttpHeaders: Record<string, string> = {};
@ -210,6 +215,7 @@ const useWorkflowSave = (opts?: WorkflowSaveOpts) => {
finally_block_label: saveData.settings.finallyBlockLabel ?? undefined,
workflow_system_prompt:
saveData.settings.workflowSystemPrompt ?? undefined,
error_code_mapping: saveData.settings.errorCodeMapping ?? undefined,
},
is_saved_task: saveData.workflow.is_saved_task,
status: opts?.status ?? saveData.workflow.status,
@ -235,6 +241,11 @@ const useWorkflowSave = (opts?: WorkflowSaveOpts) => {
);
},
onSuccess: () => {
// The confirmation authorizes exactly one save. Resetting here covers
// every persist path (top bar, nav blocker, YAML mode), so a later save
// can't silently delete cached code without re-prompting.
setSaidOkToCodeCacheDeletion(false);
const saveData = getSaveData();
if (!saveData) {

View file

@ -0,0 +1,57 @@
import { beforeEach, describe, expect, test } from "vitest";
import {
isWorkflowYamlDirty,
useWorkflowYamlEditorStore,
} from "./WorkflowYamlEditorStore";
describe("WorkflowYamlEditorStore", () => {
beforeEach(() => {
// close() intentionally preserves the registered commit, so reset it
// separately to keep tests isolated.
useWorkflowYamlEditorStore.getState().close();
useWorkflowYamlEditorStore.getState().registerCommit(null);
});
test("open activates the editor and snapshots the draft", () => {
useWorkflowYamlEditorStore.getState().open("title: hello");
const state = useWorkflowYamlEditorStore.getState();
expect(state.active).toBe(true);
expect(state.draft).toBe("title: hello");
expect(state.entrySnapshot).toBe("title: hello");
expect(state.error).toBeNull();
expect(isWorkflowYamlDirty(state)).toBe(false);
});
test("editing the draft after open marks it dirty", () => {
useWorkflowYamlEditorStore.getState().open("blocks: []");
useWorkflowYamlEditorStore.getState().setDraft("blocks: [a]");
expect(isWorkflowYamlDirty(useWorkflowYamlEditorStore.getState())).toBe(
true,
);
});
test("setDraft clears a previously surfaced error", () => {
useWorkflowYamlEditorStore.getState().open("a: 1");
useWorkflowYamlEditorStore.getState().setError("could not parse");
expect(useWorkflowYamlEditorStore.getState().error).toBe("could not parse");
useWorkflowYamlEditorStore.getState().setDraft("a: 2");
expect(useWorkflowYamlEditorStore.getState().error).toBeNull();
});
test("close resets session state but preserves the registered commit", () => {
const commit = async () => true;
useWorkflowYamlEditorStore.getState().registerCommit(commit);
useWorkflowYamlEditorStore.getState().open("a: 1");
useWorkflowYamlEditorStore.getState().setDraft("a: 2");
useWorkflowYamlEditorStore.getState().close();
const state = useWorkflowYamlEditorStore.getState();
expect(state.active).toBe(false);
expect(state.draft).toBe("");
expect(state.entrySnapshot).toBe("");
expect(state.committing).toBe(false);
// Workspace registers commit once on mount; close must not drop it or a
// second open would have no way to reparse.
expect(state.commit).toBe(commit);
});
});

View file

@ -0,0 +1,79 @@
import { create } from "zustand";
type WorkflowYamlEditorState = {
active: boolean;
draft: string;
// YAML captured when the editor opened. Used to short-circuit the commit when
// nothing was edited (the graph already matches, so no reparse is needed).
entrySnapshot: string;
error: string | null;
committing: boolean;
// Reparses the draft into the graph and closes on success; returns false on
// invalid YAML so callers abort. With persist=true it also saves the draft
// (for the top-bar/nav "Save" paths). Survives close() — registered on mount.
commit: ((persist?: boolean) => Promise<boolean>) | null;
open: (yaml: string) => void;
setDraft: (yaml: string) => void;
setError: (error: string | null) => void;
setCommitting: (committing: boolean) => void;
registerCommit: (
commit: ((persist?: boolean) => Promise<boolean>) | null,
) => void;
close: () => void;
};
export const useWorkflowYamlEditorStore = create<WorkflowYamlEditorState>(
(set) => ({
active: false,
draft: "",
entrySnapshot: "",
error: null,
committing: false,
commit: null,
open: (yaml) =>
set({
active: true,
draft: yaml,
entrySnapshot: yaml,
error: null,
committing: false,
}),
setDraft: (yaml) => set({ draft: yaml, error: null }),
setError: (error) => set({ error }),
setCommitting: (committing) => set({ committing }),
registerCommit: (commit) => set({ commit }),
close: () =>
set({
active: false,
draft: "",
entrySnapshot: "",
error: null,
committing: false,
}),
}),
);
export function isWorkflowYamlDirty(state: {
draft: string;
entrySnapshot: string;
}): boolean {
return state.draft !== state.entrySnapshot;
}
// Shared entry point for the commit-on-switch flow used by the overlay's Visual
// toggle, the top-bar save, and the nav-blocker "Save changes" dialog. Guards
// against a re-entrant commit and toggles the committing flag around it.
// Returns false when a commit is already running, none is registered, or the
// draft is invalid.
export async function commitYamlDraft(persist: boolean): Promise<boolean> {
const store = useWorkflowYamlEditorStore.getState();
if (store.committing || !store.commit) {
return false;
}
store.setCommitting(true);
try {
return await store.commit(persist);
} finally {
store.setCommitting(false);
}
}