feat(SKY-11664): studio editor pane — build-mode canvas at pane widths (#7013)
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

This commit is contained in:
Celal Zamanoğlu 2026-07-02 17:34:52 +03:00 committed by GitHub
parent 6ebf0c6f24
commit 8a118cadfe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 541 additions and 29 deletions

View file

@ -41,6 +41,7 @@ import {
PanOnScrollMode,
ReactFlow,
Viewport,
getNodesBounds,
useNodesInitialized,
useReactFlow,
NodeChange,
@ -93,6 +94,12 @@ import {
import { GlobalCollapseControl } from "./collapse/GlobalCollapseControl";
import { useNodeCollapseStore } from "./collapse/useNodeCollapseStore";
import { isHeightCollapseAnimation } from "./collapse/collapseRelayoutAnimations";
import {
isMeaningfulPaneResize,
isViewportStranded,
PANE_FIT_DEBOUNCE_MS,
paneRefitDuration,
} from "./paneFit";
import { WorkflowScopeContext } from "./WorkflowScopeContext";
import { FitViewControl } from "./controls/FitViewControl";
import { RedoControl } from "./controls/RedoControl";
@ -101,6 +108,7 @@ import { UndoControl } from "./controls/UndoControl";
import { ZoomInControl } from "./controls/ZoomInControl";
import { ZoomOutControl } from "./controls/ZoomOutControl";
import { blockTypeFromNode } from "./nodes/blockTypeFromNode";
import { OPEN_WORKFLOW_SETTINGS_EVENT } from "./nodes/StartNode/types";
import {
ParametersState,
parameterIsSkyvernCredential,
@ -1701,41 +1709,109 @@ function FlowRenderer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [historyApplyTrigger]);
// Fit once, when first visible (non-zero) and measured — a hidden mount fits at
// zero size; once-only so returning to the editor preserves the user's pan/zoom.
const hasInitialFitRef = useRef(false);
// Studio pane fit. The editor pane can mount hidden (display:none while
// closed) and resizes in discrete steps as sibling panes toggle. Fit once
// when the initial Dagre pass has settled AND the pane is visible; after
// that, a debounced ResizeObserver re-fits only when a real geometry change
// leaves the viewport stranded, so it never fights a deliberate pan/zoom.
const hasInitialPaneFitRef = useRef(false);
const lastPaneSizeRef = useRef<{ width: number; height: number } | null>(
null,
);
const layoutSettledRef = useRef(false);
layoutSettledRef.current = layoutPhase !== "pre-layout";
// "initial-load" lands one frame after Dagre positions commit (mid fade-in),
// so fitting here can't read pre-layout node positions. layoutPhase only
// advances once nodesInitialized flips, but guard explicitly so a future
// layout-phase refactor can't reintroduce a zero-size fit.
useEffect(() => {
// Studio-only: the legacy canvas keeps React Flow's own mount fit and must
// not be auto-fit here. centerOffsetX is > 0 only in the studio shell.
if (centerOffsetX <= 0) {
if (
!embedded ||
hasInitialPaneFitRef.current ||
layoutPhase === "pre-layout" ||
!nodesInitialized
) {
return;
}
if (hasInitialFitRef.current) {
const rect = editorElementRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0 || rect.height === 0) {
return;
}
hasInitialPaneFitRef.current = true;
lastPaneSizeRef.current = { width: rect.width, height: rect.height };
runFitView({ duration: 0 });
}, [embedded, layoutPhase, nodesInitialized, runFitView]);
useEffect(() => {
if (!embedded) {
return;
}
const el = editorElementRef.current;
if (!el) {
return;
}
let observer: ResizeObserver | null = null;
const tryFit = () => {
if (hasInitialFitRef.current) {
let timer: number | null = null;
// Covers a pane that was hidden while layout settled; before that, the
// layout-phase effect above owns the first fit.
const initialFit = (size: { width: number; height: number }) => {
if (!layoutSettledRef.current) {
return;
}
const visible =
(editorElementRef.current?.getBoundingClientRect().width ?? 0) > 0;
if (!visible || !nodesInitialized) {
return;
}
hasInitialFitRef.current = true;
hasInitialPaneFitRef.current = true;
lastPaneSizeRef.current = size;
runFitView({ duration: 0 });
observer?.disconnect();
};
observer = new ResizeObserver(tryFit);
const strandedRefit = (size: { width: number; height: number }) => {
const prev = lastPaneSizeRef.current;
if (prev && !isMeaningfulPaneResize(prev, size)) {
return;
}
lastPaneSizeRef.current = size;
const visibleNodes = reactFlowInstance
.getNodes()
.filter((node) => !node.hidden);
if (visibleNodes.length === 0) {
return;
}
const stranded = isViewportStranded({
pane: size,
viewport: reactFlowInstance.getViewport(),
bounds: getNodesBounds(visibleNodes),
});
if (stranded) {
runFitView({ duration: paneRefitDuration() });
}
};
const settle = () => {
timer = null;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return;
}
const size = { width: rect.width, height: rect.height };
if (!hasInitialPaneFitRef.current) {
initialFit(size);
} else {
strandedRefit(size);
}
};
const observer = new ResizeObserver(() => {
// Settle after the burst; per-frame reactions exhaust the
// dimension→layout convergence budget (see dimensionConvergence.ts).
if (timer !== null) {
window.clearTimeout(timer);
}
timer = window.setTimeout(settle, PANE_FIT_DEBOUNCE_MS);
});
observer.observe(el);
tryFit();
return () => observer?.disconnect();
}, [runFitView, nodesInitialized, centerOffsetX]);
return () => {
observer.disconnect();
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [embedded, reactFlowInstance, runFitView]);
const zoomLock = 1 as const;
const yLockMax = 140 as const;
@ -2028,6 +2104,12 @@ function FlowRenderer({
appNode.data.label,
);
}
// The start node's inline editor is its settings accordion.
if (embedded && isWorkflowSettingsStart) {
window.dispatchEvent(
new Event(OPEN_WORKFLOW_SETTINGS_EVENT),
);
}
}}
onPaneClick={() => {
if (readOnly) {

View file

@ -101,7 +101,7 @@ function EdgeWithAddButton({
const isBusy =
(isProcessing || recordingStore.isRecording) &&
debugStore.isDebugMode &&
(debugStore.isDebugMode || debugStore.blockRunsEnabled) &&
settingsStore.isUsingABrowser &&
workflowStatePanel.workflowPanelState.data?.previous === source &&
workflowStatePanel.workflowPanelState.data?.next === target &&

View file

@ -154,6 +154,34 @@ describe("useSelectedBlockUrlSync", () => {
expect(result.current.location.search).toBe("");
});
test("merges mirror writes against the live URL so a concurrent ?panes= write survives", async () => {
// Simulate a pane toggle whose navigate() already hit the real URL while
// this render's closure params still predate it (the stale-prev race).
window.history.replaceState(
null,
"",
"/workflows/wpid_abc/studio?wr=run_1&panes=copilot,editor",
);
try {
const { result } = renderHook(() => useTestHarness(), {
wrapper: makeWrapper("/workflows/wpid_abc/studio?wr=run_1"),
});
act(() => {
useWorkflowPanelStore.getState().setSelectedBlockId("checkout-node");
});
await waitFor(() => {
expect(result.current.search).toContain("selected-block=Checkout+step");
});
const params = new URLSearchParams(result.current.search);
expect(params.get("panes")).toBe("copilot,editor");
expect(params.get("wr")).toBe("run_1");
} finally {
window.history.replaceState(null, "", "/");
}
});
test("does not touch the URL outside the embedded studio", async () => {
const { result } = renderHook(() => useTestHarness(false), {
wrapper: makeWrapper("/workflows/wpid_abc/edit"),

View file

@ -87,6 +87,19 @@ export function useSelectedBlockUrlSync({
return latestNodes && latestNodes.length > 0 ? latestNodes : nodes;
}, [getNodes, nodes]);
// Merge writes against the live URL, not this render's closure: pushState is
// synchronous, so a concurrent navigate (pane toggles writing ?panes=) is
// already visible there while the closure params can be one commit stale and
// would clobber it. window.location is blank under a memory router (tests);
// fall back to the closure, where no such race exists.
const liveParams = useCallback(
(closure: URLSearchParams) =>
window.location.search !== ""
? new URLSearchParams(window.location.search)
: new URLSearchParams(closure),
[],
);
useEffect(() => {
if (!enabled || !selectedBlockLabelParam) {
return;
@ -100,7 +113,7 @@ export function useSelectedBlockUrlSync({
if (!matchedNodeId) {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
const next = liveParams(prev);
next.delete(SELECTED_BLOCK_SEARCH_PARAM);
return next;
},
@ -122,6 +135,7 @@ export function useSelectedBlockUrlSync({
}, [
enabled,
getCurrentNodes,
liveParams,
selectedBlockLabelParam,
setSearchParams,
setSelectedBlockId,
@ -151,7 +165,7 @@ export function useSelectedBlockUrlSync({
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
const next = liveParams(prev);
if (selectedBlockLabel) {
next.set(SELECTED_BLOCK_SEARCH_PARAM, selectedBlockLabel);
} else {
@ -164,6 +178,7 @@ export function useSelectedBlockUrlSync({
}, [
enabled,
getCurrentNodes,
liveParams,
searchParams,
selectedBlockId,
setSearchParams,

View file

@ -117,7 +117,7 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
const isBusy =
(isProcessing || recordingStore.isRecording) &&
debugStore.isDebugMode &&
(debugStore.isDebugMode || debugStore.blockRunsEnabled) &&
settingsStore.isUsingABrowser &&
workflowStatePanel.workflowPanelState.data?.previous === previous &&
workflowStatePanel.workflowPanelState.data?.next === id &&

View file

@ -0,0 +1,152 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, test, vi } from "vitest";
import { StartNode } from "./StartNode";
import {
OPEN_WORKFLOW_SETTINGS_EVENT,
type WorkflowStartNodeData,
} from "./types";
vi.mock("@xyflow/react", async () => {
const actual =
await vi.importActual<typeof import("@xyflow/react")>("@xyflow/react");
return {
...actual,
Handle: () => null,
useReactFlow: () => ({
getNode: () => null,
getNodes: () => [],
}),
};
});
vi.mock("./WorkflowSettingsEditor", () => ({
WorkflowSettingsEditor: () => <div data-testid="workflow-settings-editor" />,
}));
vi.mock("@/routes/workflows/hooks/useToggleScriptForNodeCallback", () => ({
useToggleScriptForNodeCallback: () => vi.fn(),
}));
vi.mock("@/routes/workflows/components/BlockCodeEditor", () => ({
BlockCodeEditor: () => null,
}));
const startNodeData: WorkflowStartNodeData = {
withWorkflowSettings: true,
webhookCallbackUrl: "",
proxyLocation: null,
persistBrowserSession: false,
browserProfileId: null,
browserProfileKey: null,
model: null,
maxScreenshotScrolls: null,
maxElapsedTimeMinutes: null,
extraHttpHeaders: null,
cdpConnectHeaders: null,
editable: true,
runWith: "agent",
codeVersion: null,
scriptCacheKey: null,
aiFallback: true,
runSequentially: false,
sequentialKey: null,
finallyBlockLabel: null,
workflowSystemPrompt: null,
label: "__start_block__",
showCode: false,
};
type StartNodeComponentProps = {
id: string;
data: WorkflowStartNodeData;
parentId?: string;
};
const StartNodeForTest = StartNode as unknown as (
props: StartNodeComponentProps,
) => JSX.Element;
function renderStartNode() {
return render(
<MemoryRouter initialEntries={["/workflows/wpid_abc/studio"]}>
<StartNodeForTest id="start" data={startNodeData} />
</MemoryRouter>,
);
}
afterEach(() => {
cleanup();
});
describe("StartNode workflow settings affordance", () => {
test("renders the Workflow Settings entry collapsed by default", () => {
renderStartNode();
expect(screen.getByText("Workflow Settings")).toBeDefined();
expect(screen.queryByTestId("workflow-settings-editor")).toBeNull();
});
test("a canvas click event expands the settings accordion", () => {
renderStartNode();
act(() => {
window.dispatchEvent(new Event(OPEN_WORKFLOW_SETTINGS_EVENT));
});
expect(screen.getByTestId("workflow-settings-editor")).toBeDefined();
});
test("the accordion trigger still toggles the settings manually", () => {
renderStartNode();
const trigger = screen.getByText("Workflow Settings");
fireEvent.click(trigger);
expect(screen.getByTestId("workflow-settings-editor")).toBeDefined();
fireEvent.click(trigger);
expect(screen.queryByTestId("workflow-settings-editor")).toBeNull();
});
test("the open event keeps already-open settings mounted", () => {
renderStartNode();
fireEvent.click(screen.getByText("Workflow Settings"));
expect(screen.getByTestId("workflow-settings-editor")).toBeDefined();
act(() => {
window.dispatchEvent(new Event(OPEN_WORKFLOW_SETTINGS_EVENT));
});
expect(screen.getByTestId("workflow-settings-editor")).toBeDefined();
});
test("a trigger click that closes is not undone by the bubbled canvas dispatch", () => {
// The canvas onNodeClick dispatches the open event from the same native
// click that toggled the trigger, before React commits the close; the
// listener must read the still-committed "open" value and stay quiet.
renderStartNode();
const trigger = screen.getByText("Workflow Settings");
fireEvent.click(trigger);
expect(screen.getByTestId("workflow-settings-editor")).toBeDefined();
const bubbleDispatch = () =>
window.dispatchEvent(new Event(OPEN_WORKFLOW_SETTINGS_EVENT));
window.addEventListener("click", bubbleDispatch);
try {
fireEvent.click(trigger);
} finally {
window.removeEventListener("click", bubbleDispatch);
}
expect(screen.queryByTestId("workflow-settings-editor")).toBeNull();
});
});

View file

@ -1,12 +1,13 @@
import { Handle, Node, NodeProps, Position, useReactFlow } from "@xyflow/react";
import type { StartNode } from "./types";
import { GearIcon } from "@radix-ui/react-icons";
import { OPEN_WORKFLOW_SETTINGS_EVENT, type StartNode } from "./types";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ProxyLocation } from "@/api/types";
import { Separator } from "@/components/ui/separator";
import {
@ -43,6 +44,7 @@ function StartNode({ id, data, parentId }: NodeProps<StartNode>) {
const workflowSettingsStore = useWorkflowSettingsStore();
const reactFlowInstance = useReactFlow();
const [facing, setFacing] = useState<"front" | "back">("front");
const [settingsAccordionValue, setSettingsAccordionValue] = useState("");
const blockScriptStore = useBlockScriptStore();
const recordingStore = useRecordingStore();
const script = blockScriptStore.scripts.__start_block__;
@ -91,6 +93,27 @@ function StartNode({ id, data, parentId }: NodeProps<StartNode>) {
setFacing(data.showCode ? "back" : "front");
}, [data.showCode]);
// Clicking the start node on the canvas asks for the settings to open; the
// accordion trigger itself still collapses/expands as usual. No rerender
// bump: Radix unmounts closed content, so opening mounts the editor fresh.
const settingsAccordionValueRef = useRef(settingsAccordionValue);
settingsAccordionValueRef.current = settingsAccordionValue;
useEffect(() => {
if (!data.withWorkflowSettings) {
return;
}
const openSettings = () => {
if (settingsAccordionValueRef.current === "settings") {
return;
}
setSettingsAccordionValue("settings");
};
window.addEventListener(OPEN_WORKFLOW_SETTINGS_EVENT, openSettings);
return () => {
window.removeEventListener(OPEN_WORKFLOW_SETTINGS_EVENT, openSettings);
};
}, [data.withWorkflowSettings]);
useEffect(() => {
workflowSettingsStore.setWorkflowSettings(makeStartSettings(data));
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -155,11 +178,23 @@ function StartNode({ id, data, parentId }: NodeProps<StartNode>) {
<Accordion
type="single"
collapsible
onValueChange={() => rerender.bump()}
value={settingsAccordionValue}
onValueChange={(value) => {
setSettingsAccordionValue(value);
rerender.bump();
}}
>
<AccordionItem value="settings" className="mt-4 border-b-0">
<AccordionTrigger className="py-2">
Workflow Settings
{/* Wrapped so the open-state chevron rotation targets
only the trigger's own direct svg. */}
<span className="flex items-center gap-2">
<GearIcon
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
Workflow Settings
</span>
</AccordionTrigger>
<AccordionContent className="pl-6 pr-1 pt-1">
<div key={rerender.key}>

View file

@ -40,6 +40,10 @@ export type StartNodeData = WorkflowStartNodeData | OtherStartNodeData;
export type StartNode = Node<StartNodeData, "start">;
// Window event asking the root start node to expand its Workflow Settings
// accordion (dispatched from the canvas when the start node is clicked).
export const OPEN_WORKFLOW_SETTINGS_EVENT = "open-workflow-settings";
export function isStartNode(node: AppNode): node is StartNode {
return node.type === "start";
}

View file

@ -0,0 +1,112 @@
import { describe, expect, test } from "vitest";
import {
isMeaningfulPaneResize,
isViewportStranded,
PANE_RESIZE_EPSILON_PX,
} from "./paneFit";
describe("isMeaningfulPaneResize", () => {
test("ignores sub-epsilon jitter on both axes", () => {
expect(
isMeaningfulPaneResize(
{ width: 500, height: 800 },
{ width: 500 + PANE_RESIZE_EPSILON_PX - 1, height: 801 },
),
).toBe(false);
});
test("detects a pane-toggle sized width change", () => {
expect(
isMeaningfulPaneResize(
{ width: 1000, height: 800 },
{ width: 330, height: 800 },
),
).toBe(true);
});
test("detects a height-only change", () => {
expect(
isMeaningfulPaneResize(
{ width: 500, height: 800 },
{ width: 500, height: 600 },
),
).toBe(true);
});
});
describe("isViewportStranded", () => {
const chainBounds = { x: 0, y: 0, width: 500, height: 1200 };
test("a freshly fitted viewport is not stranded", () => {
// zoom 0.6 centers the 500-wide chain in a 330-wide pane.
expect(
isViewportStranded({
pane: { width: 330, height: 800 },
viewport: { x: 15, y: 20, zoom: 0.6 },
bounds: chainBounds,
}),
).toBe(false);
});
test("a pane shrink that leaves only a sliver of the chain is stranded", () => {
// Viewport fitted for a 1000-wide pane (chain centered at x=250, zoom 1),
// then the pane shrank to 330: only ~80px of the chain remains visible.
expect(
isViewportStranded({
pane: { width: 330, height: 800 },
viewport: { x: 250, y: 0, zoom: 1 },
bounds: chainBounds,
}),
).toBe(true);
});
test("a viewport panned fully off the flow is stranded", () => {
expect(
isViewportStranded({
pane: { width: 500, height: 800 },
viewport: { x: -2000, y: 0, zoom: 1 },
bounds: chainBounds,
}),
).toBe(true);
});
test("a deliberate zoom into one block keeps the viewport", () => {
// Block region fills the pane: tiny share of the whole flow visible, but
// the pane is fully covered, so the user's focus is preserved.
expect(
isViewportStranded({
pane: { width: 400, height: 300 },
viewport: { x: -50, y: -600, zoom: 1.8 },
bounds: chainBounds,
}),
).toBe(false);
});
test("half the chain visible after a mild resize is not stranded", () => {
expect(
isViewportStranded({
pane: { width: 500, height: 800 },
viewport: { x: 100, y: 0, zoom: 1 },
bounds: chainBounds,
}),
).toBe(false);
});
test("empty flows and hidden panes never count as stranded", () => {
expect(
isViewportStranded({
pane: { width: 500, height: 800 },
viewport: { x: 0, y: 0, zoom: 1 },
bounds: { x: 0, y: 0, width: 0, height: 0 },
}),
).toBe(false);
expect(
isViewportStranded({
pane: { width: 0, height: 0 },
viewport: { x: 0, y: 0, zoom: 1 },
bounds: chainBounds,
}),
).toBe(false);
});
});

View file

@ -0,0 +1,84 @@
// Pure helpers for the studio editor pane's fit-view policy. The pane resizes
// in discrete steps (sibling panes toggling, window resizes), so the canvas
// re-fits only when a real geometry change leaves the viewport stranded.
type Size = { width: number; height: number };
type Viewport = { x: number; y: number; zoom: number };
type Rect = { x: number; y: number; width: number; height: number };
// Settle after a resize burst finishes; reacting per ResizeObserver frame
// re-arms the canvas's dimension→layout cycle faster than its re-entrancy
// budget resets, which drops the final settled relayout.
export const PANE_FIT_DEBOUNCE_MS = 200;
// Sub-pixel flex settling and scrollbar appearance jitter below this delta.
export const PANE_RESIZE_EPSILON_PX = 8;
// Below this share, the visible slice of the flow reads as "lost canvas".
// 0.3 keeps a half-visible chain (mild resize) untouched while a wide-fit
// viewport squeezed into a ~330px pane (~10-25% left visible) re-fits.
const STRANDED_VISIBLE_FRACTION = 0.3;
export function isMeaningfulPaneResize(prev: Size, next: Size): boolean {
return (
Math.abs(prev.width - next.width) >= PANE_RESIZE_EPSILON_PX ||
Math.abs(prev.height - next.height) >= PANE_RESIZE_EPSILON_PX
);
}
/**
* A viewport is stranded when the intersection of the flow's bounding box
* (screen space) with the pane is a sliver: it covers little of the pane AND
* shows little of the flow. A user deliberately zoomed into one block keeps
* their viewport (the block still fills the pane); a pane that shrank past the
* chain gets re-fit.
*/
export function isViewportStranded({
pane,
viewport,
bounds,
}: {
pane: Size;
viewport: Viewport;
bounds: Rect;
}): boolean {
if (pane.width <= 0 || pane.height <= 0) {
return false;
}
if (bounds.width <= 0 || bounds.height <= 0) {
return false;
}
const screenLeft = bounds.x * viewport.zoom + viewport.x;
const screenTop = bounds.y * viewport.zoom + viewport.y;
const screenWidth = bounds.width * viewport.zoom;
const screenHeight = bounds.height * viewport.zoom;
const intersectWidth = Math.max(
0,
Math.min(screenLeft + screenWidth, pane.width) - Math.max(screenLeft, 0),
);
const intersectHeight = Math.max(
0,
Math.min(screenTop + screenHeight, pane.height) - Math.max(screenTop, 0),
);
const intersectArea = intersectWidth * intersectHeight;
const flowVisibleFraction = intersectArea / (screenWidth * screenHeight);
const paneCoveredFraction = intersectArea / (pane.width * pane.height);
return (
flowVisibleFraction < STRANDED_VISIBLE_FRACTION &&
paneCoveredFraction < STRANDED_VISIBLE_FRACTION
);
}
export function paneRefitDuration(): number {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return 0;
}
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
? 0
: 150;
}