mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
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
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:
parent
6ebf0c6f24
commit
8a118cadfe
10 changed files with 541 additions and 29 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
112
skyvern-frontend/src/routes/workflows/editor/paneFit.test.ts
Normal file
112
skyvern-frontend/src/routes/workflows/editor/paneFit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
84
skyvern-frontend/src/routes/workflows/editor/paneFit.ts
Normal file
84
skyvern-frontend/src/routes/workflows/editor/paneFit.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue