use Splitter for debugger layout (#3383)

This commit is contained in:
Jonathan Dobson
2025-09-06 13:49:54 -07:00
committed by GitHub
parent 2e7cc94a9e
commit 68a88706f7
4 changed files with 347 additions and 187 deletions

View File

@@ -3,7 +3,71 @@ import { useMountEffect } from "@/hooks/useMountEffect";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
import { useOnChange } from "@/hooks/useOnChange"; import { useOnChange } from "@/hooks/useOnChange";
function Handle({
direction,
isDragging,
onDoubleClick,
}: {
direction: "vertical" | "horizontal";
isDragging: boolean;
onDoubleClick?: () => void;
}) {
return (
<div
className={cn(
"absolute flex h-[1.25rem] w-[10px] flex-wrap items-center justify-center gap-[2px] bg-slate-800 pb-1 pt-1",
{
"cursor-col-resize": direction === "vertical",
"cursor-row-resize": direction === "horizontal",
"bg-slate-700": isDragging,
},
)}
onDoubleClick={() => onDoubleClick?.()}
>
<div className="flex w-full items-center justify-center gap-[0.15rem]">
<div
className={cn("h-[2px] w-[2px] rounded-full bg-[#666]", {
"bg-[#222]": isDragging,
})}
/>
<div
className={cn("h-[2px] w-[2px] rounded-full bg-[#666]", {
"bg-[#222]": isDragging,
})}
/>
</div>
<div className="flex w-full items-center justify-center gap-[0.15rem]">
<div
className={cn("h-[2px] w-[2px] rounded-full bg-[#666]", {
"bg-[#222]": isDragging,
})}
/>
<div
className={cn("h-[2px] w-[2px] rounded-full bg-[#666]", {
"bg-[#222]": isDragging,
})}
/>
</div>
<div className="flex w-full items-center justify-center gap-[0.15rem]">
<div
className={cn("h-[2px] w-[2px] rounded-full bg-[#666]", {
"bg-[#222]": isDragging,
})}
/>
<div
className={cn("h-[2px] w-[2px] rounded-full bg-[#666]", {
"bg-[#222]": isDragging,
})}
/>
</div>
</div>
);
}
interface Props { interface Props {
className?: string;
classNameLeft?: string;
classNameRight?: string;
/** /**
* The direction of the splitter. If "vertical", the split bar is vertical, * The direction of the splitter. If "vertical", the split bar is vertical,
* etc. * etc.
@@ -38,6 +102,10 @@ interface Props {
* key. * key.
*/ */
storageKey?: string; storageKey?: string;
/**
* Callback fired when the splitter is resized
*/
onResize?: () => void;
} }
type SizingTarget = "left" | "right" | "top" | "bottom"; type SizingTarget = "left" | "right" | "top" | "bottom";
@@ -196,7 +264,16 @@ const setStoredSizing = (
localStorage.setItem(key, sizing); localStorage.setItem(key, sizing);
}; };
function Splitter({ children, direction, split, storageKey }: Props) { function Splitter({
children,
className,
classNameLeft,
classNameRight,
direction,
split,
storageKey,
onResize,
}: Props) {
if (!Array.isArray(children) || children.length !== 2) { if (!Array.isArray(children) || children.length !== 2) {
throw new Error("Splitter must have exactly two children"); throw new Error("Splitter must have exactly two children");
} }
@@ -240,7 +317,9 @@ function Splitter({ children, direction, split, storageKey }: Props) {
const newWidth = (newPixelPos / maxSize) * 100; const newWidth = (newPixelPos / maxSize) * 100;
const clampedWidth = Math.max(0, Math.min(newWidth, 100)); const clampedWidth = Math.max(0, Math.min(newWidth, 100));
setIsClosed(false);
setSplitPosition(clampedWidth); setSplitPosition(clampedWidth);
onResize?.();
}; };
const onMouseUp = () => { const onMouseUp = () => {
@@ -248,6 +327,7 @@ function Splitter({ children, direction, split, storageKey }: Props) {
document.body.classList.remove("no-select-global"); document.body.classList.remove("no-select-global");
document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp); document.removeEventListener("mouseup", onMouseUp);
onResize?.();
}; };
document.addEventListener("mousemove", onMouseMove); document.addEventListener("mousemove", onMouseMove);
@@ -255,6 +335,9 @@ function Splitter({ children, direction, split, storageKey }: Props) {
}; };
const [splitPosition, setSplitPosition] = useState<number>(50); const [splitPosition, setSplitPosition] = useState<number>(50);
const [isClosed, setIsClosed] = useState(false);
const [closedSplitPosition, setClosedSplitPosition] =
useState<number>(splitPosition);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
useMountEffect(() => { useMountEffect(() => {
@@ -287,21 +370,51 @@ function Splitter({ children, direction, split, storageKey }: Props) {
} }
}); });
/**
* A "snap" is:
* - if the splitter is not "closed", then "close it"
* - if the splitter is "closed", then "open it"
*
* "closed" depends on the `split` prop definition. For instance, if the
* `split` prop has `left` defined, then "closing" is the `left` side
* resizing down to 0.
*
* When "closing", the current splitPosition should be memorized and then
* returned to when an "open" happens.
*/
const snap = () => {
if (isClosed) {
setSplitPosition(closedSplitPosition);
} else {
setClosedSplitPosition(splitPosition);
setSplitPosition(0);
}
setIsClosed(!isClosed);
onResize?.();
};
return ( return (
<div <div
className={cn( className={cn(
"splitter flex h-full w-full overflow-hidden", "splitter flex h-full w-full overflow-hidden",
direction === "vertical" ? "flex-row" : "flex-col", direction === "vertical" ? "flex-row" : "flex-col",
className || "",
)} )}
ref={containerRef} ref={containerRef}
> >
{direction === "vertical" ? ( {direction === "vertical" ? (
<> <>
<div <div
className={cn("left h-full", { className={cn(
"pointer-events-none cursor-col-resize select-none opacity-80": "left h-full",
isDragging, {
})} "pointer-events-none cursor-col-resize select-none opacity-80":
isDragging,
"overflow-x-hidden": direction === "vertical",
"overflow-y-hidden": direction !== "vertical",
},
classNameLeft,
)}
style={{ style={{
width: `calc(${splitPosition}% - (${splitterThickness} / 2))`, width: `calc(${splitPosition}% - (${splitterThickness} / 2))`,
}} }}
@@ -310,16 +423,34 @@ function Splitter({ children, direction, split, storageKey }: Props) {
</div> </div>
<div <div
className={cn( className={cn(
"splitter-bar z-[0] h-full w-[5px] cursor-col-resize bg-[#ccc] opacity-10 hover:opacity-90", "splitter-bar relative z-[0] flex h-full w-[10px] cursor-col-resize items-center justify-center opacity-50 hover:opacity-100",
{ "opacity-90": isDragging }, { "opacity-90": isDragging },
)} )}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
></div> onDoubleClick={snap}
>
<div
className={cn("h-full w-[2px] bg-slate-800", {
"bg-slate-700": isDragging,
})}
/>
<Handle
direction={direction}
isDragging={isDragging}
onDoubleClick={snap}
/>
</div>
<div <div
className={cn("right h-full", { className={cn(
"pointer-events-none cursor-col-resize select-none opacity-80": "right h-full",
isDragging, {
})} "pointer-events-none cursor-col-resize select-none opacity-80":
isDragging,
"overflow-x-hidden": direction === "vertical",
"overflow-y-hidden": direction !== "vertical",
},
classNameRight,
)}
style={{ style={{
width: `calc(100% - ${splitPosition}% - (${splitterThickness} / 2))`, width: `calc(100% - ${splitPosition}% - (${splitterThickness} / 2))`,
}} }}

View File

@@ -221,6 +221,7 @@ function convertToParametersYAML(
type Props = { type Props = {
hideBackground?: boolean; hideBackground?: boolean;
hideControls?: boolean;
nodes: Array<AppNode>; nodes: Array<AppNode>;
edges: Array<Edge>; edges: Array<Edge>;
setNodes: (nodes: Array<AppNode>) => void; setNodes: (nodes: Array<AppNode>) => void;
@@ -233,10 +234,12 @@ type Props = {
onDebuggableBlockCountChange?: (count: number) => void; onDebuggableBlockCountChange?: (count: number) => void;
onMouseDownCapture?: () => void; onMouseDownCapture?: () => void;
zIndex?: number; zIndex?: number;
onContainerResize?: number;
}; };
function FlowRenderer({ function FlowRenderer({
hideBackground = false, hideBackground = false,
hideControls = false,
nodes, nodes,
edges, edges,
setNodes, setNodes,
@@ -249,6 +252,7 @@ function FlowRenderer({
onDebuggableBlockCountChange, onDebuggableBlockCountChange,
onMouseDownCapture, onMouseDownCapture,
zIndex, zIndex,
onContainerResize,
}: Props) { }: Props) {
const reactFlowInstance = useReactFlow(); const reactFlowInstance = useReactFlow();
const debugStore = useDebugStore(); const debugStore = useDebugStore();
@@ -485,14 +489,34 @@ function FlowRenderer({
useAutoPan(editorElementRef, nodes); useAutoPan(editorElementRef, nodes);
useEffect(() => {
doLayout(nodes, edges);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onContainerResize]);
const zoomLock = 1 as const; const zoomLock = 1 as const;
const yLockMax = 140 as const; const yLockMax = 140 as const;
/** /**
* TODO(jdo): hack * TODO(jdo): hack
*
* Locks the x position of the flow to an ideal x based on the ideal width
* of the flow. The ideal width is based on differently-width'd blocks.
*/ */
const getXLock = () => { const getXLock = () => {
return 24; const rect = editorElementRef.current?.getBoundingClientRect();
if (!rect) {
return 24;
}
const width = rect.width;
const hasLoopBlock = nodes.some((node) => node.type === "loop");
const hasHttpBlock = nodes.some((node) => node.type === "http_request");
const idealWidth = hasHttpBlock ? 580 : hasLoopBlock ? 498 : 475;
const split = (width - idealWidth) / 2;
return Math.max(24, split);
}; };
useOnChange(debugStore.isDebugMode, (newValue) => { useOnChange(debugStore.isDebugMode, (newValue) => {
@@ -673,7 +697,7 @@ function FlowRenderer({
{!hideBackground && ( {!hideBackground && (
<Background variant={BackgroundVariant.Dots} bgColor="#020617" /> <Background variant={BackgroundVariant.Dots} bgColor="#020617" />
)} )}
<Controls position="bottom-left" /> {!hideControls && <Controls position="bottom-left" />}
</ReactFlow> </ReactFlow>
</BlockActionContext.Provider> </BlockActionContext.Provider>
</div> </div>

View File

@@ -28,6 +28,7 @@ import {
PowerButton, PowerButton,
ReloadButton, ReloadButton,
} from "@/components/FloatingWindow"; } from "@/components/FloatingWindow";
import { Splitter } from "@/components/Splitter";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -127,6 +128,7 @@ function Workspace({
const [showPowerButton, setShowPowerButton] = useState(true); const [showPowerButton, setShowPowerButton] = useState(true);
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
const [windowResizeTrigger, setWindowResizeTrigger] = useState(0); const [windowResizeTrigger, setWindowResizeTrigger] = useState(0);
const [containerResizeTrigger, setContainerResizeTrigger] = useState(0);
const [isReloading, setIsReloading] = useState(false); const [isReloading, setIsReloading] = useState(false);
const credentialGetter = useCredentialGetter(); const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -206,10 +208,10 @@ function Workspace({
const hasLoopBlock = nodes.some((node) => node.type === "loop"); const hasLoopBlock = nodes.some((node) => node.type === "loop");
const hasHttpBlock = nodes.some((node) => node.type === "http_request"); const hasHttpBlock = nodes.some((node) => node.type === "http_request");
const workflowWidth = hasHttpBlock const workflowWidth = hasHttpBlock
? "39rem" ? "35.1rem"
: hasLoopBlock : hasLoopBlock
? "34.25rem" ? "31.25rem"
: "33rem"; : "30rem";
/** /**
* Open a new tab (not window) with the browser session URL. * Open a new tab (not window) with the browser session URL.
@@ -762,195 +764,193 @@ function Workspace({
{/* infinite canvas, sub panels, browser, and timeline when in debug mode */} {/* infinite canvas, sub panels, browser, and timeline when in debug mode */}
{showBrowser && ( {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">
{/* infinite canvas */} <Splitter
<div className="splittah"
className="skyvern-split-left h-full" classNameLeft="flex items-center justify-center"
style={{ direction="vertical"
width: workflowWidth, split={{ left: workflowWidth }}
maxWidth: workflowWidth, onResize={() => setContainerResizeTrigger((prev) => prev + 1)}
}}
> >
<FlowRenderer {/* infinite canvas */}
hideBackground={true} <div className="skyvern-split-left h-full w-full">
nodes={nodes} <FlowRenderer
edges={edges} hideBackground={true}
setNodes={setNodes} hideControls={true}
setEdges={setEdges} nodes={nodes}
onNodesChange={onNodesChange} edges={edges}
onEdgesChange={onEdgesChange} setNodes={setNodes}
initialTitle={initialTitle} setEdges={setEdges}
workflow={workflow} onNodesChange={onNodesChange}
/> onEdgesChange={onEdgesChange}
</div> initialTitle={initialTitle}
workflow={workflow}
onContainerResize={containerResizeTrigger}
/>
</div>
{/* divider*/} {/* browser & timeline & sub-panels in debug mode */}
<div className="mt-[8rem] h-[calc(100%_-_8rem)] w-[1px] bg-slate-800" /> <div className="skyvern-split-right relative flex h-full items-end justify-center bg-[#020617] p-4 pl-6">
{/* sub panels */}
{/* browser & timeline & sub-panels in debug mode */} {workflowPanelState.active && (
<div
className="skyvern-split-right relative flex h-full items-end justify-center bg-[#020617] p-4 pl-6"
style={{
width: `calc(100% - ${workflowWidth})`,
}}
>
{/* sub panels */}
{workflowPanelState.active && (
<div
className={cn("absolute right-6 top-[8.5rem] z-30", {
"left-6": workflowPanelState.content === "nodeLibrary",
})}
style={{
height:
workflowPanelState.content === "nodeLibrary"
? "calc(100vh - 14rem)"
: "unset",
}}
>
{workflowPanelState.content === "cacheKeyValues" && (
<WorkflowCacheKeyValuesPanel
cacheKeyValues={cacheKeyValues}
pending={cacheKeyValuesLoading}
scriptKey={workflow.cache_key ?? "default"}
onDelete={(cacheKeyValue) => {
setToDeleteCacheKeyValue(cacheKeyValue);
setOpenConfirmCacheKeyValueDeleteDialogue(true);
}}
onPaginate={(page) => {
setPage(page);
}}
onSelect={(cacheKeyValue) => {
setCacheKeyValue(cacheKeyValue);
setCacheKeyValueFilter("");
closeWorkflowPanel();
}}
/>
)}
{workflowPanelState.content === "parameters" && (
<WorkflowParametersPanel />
)}
{workflowPanelState.content === "nodeLibrary" && (
<WorkflowNodeLibraryPanel
onNodeClick={(props) => {
addNode(props);
}}
/>
)}
</div>
)}
{/* browser & timeline */}
<div className="flex h-[calc(100%_-_8rem)] w-full gap-6">
{/* browser */}
<div className="flex h-full w-[calc(100%_-_6rem)] flex-1 flex-col items-center justify-center">
<div key={reloadKey} className="w-full flex-1">
{activeDebugSession &&
activeDebugSession.browser_session_id &&
!cycleBrowser.isPending ? (
<BrowserStream
interactive={interactor === "human"}
browserSessionId={activeDebugSession.browser_session_id}
showControlButtons={interactor === "human"}
resizeTrigger={windowResizeTrigger}
/>
) : (
<div className="flex aspect-video w-full flex-col items-center justify-center gap-2 rounded-md border border-slate-800 pb-2 pt-4 text-sm text-slate-400">
Connecting to your browser...
<AnimatedWave text=".‧₊˚ ⋅ ✨★ ‧₊˚ ⋅" />
</div>
)}
</div>
<footer className="flex h-[2rem] w-full items-center justify-start gap-4">
<div className="flex items-center gap-2">
<GlobeIcon /> Live Browser
</div>
{showBreakoutButton && (
<BreakoutButton onClick={() => breakout()} />
)}
<div
className={cn("ml-auto flex items-center gap-2", {
"mr-16": !blockLabel,
})}
>
{showPowerButton && <PowerButton onClick={() => cycle()} />}
<ReloadButton
isReloading={isReloading}
onClick={() => reload()}
/>
</div>
</footer>
</div>
{/* timeline */}
<div
className={cn("z-20 h-full w-[5rem] overflow-visible", {
"pointer-events-none hidden w-[0px] overflow-hidden":
!blockLabel,
})}
>
<div <div
className={cn( className={cn("absolute right-6 top-[8.5rem] z-30", {
"relative h-full w-[25rem] translate-x-[-20.5rem] bg-[#020617] transition-all", "left-6": workflowPanelState.content === "nodeLibrary",
{ })}
"translate-x-[0rem]": timelineMode === "narrow", style={{
group: timelineMode === "narrow", height:
}, workflowPanelState.content === "nodeLibrary"
)} ? "calc(100vh - 14rem)"
onClick={() => { : "unset",
if (timelineMode === "narrow") {
setTimelineMode("wide");
}
}} }}
> >
{/* timeline wide */} {workflowPanelState.content === "cacheKeyValues" && (
<div <WorkflowCacheKeyValuesPanel
className={cn( cacheKeyValues={cacheKeyValues}
"pointer-events-none absolute left-[0.5rem] right-0 top-0 flex h-full w-[400px] flex-col items-end justify-end opacity-0 transition-all duration-1000", pending={cacheKeyValuesLoading}
{ "opacity-100": timelineMode === "wide" }, scriptKey={workflow.cache_key ?? "default"}
onDelete={(cacheKeyValue) => {
setToDeleteCacheKeyValue(cacheKeyValue);
setOpenConfirmCacheKeyValueDeleteDialogue(true);
}}
onPaginate={(page) => {
setPage(page);
}}
onSelect={(cacheKeyValue) => {
setCacheKeyValue(cacheKeyValue);
setCacheKeyValueFilter("");
closeWorkflowPanel();
}}
/>
)}
{workflowPanelState.content === "parameters" && (
<WorkflowParametersPanel />
)}
{workflowPanelState.content === "nodeLibrary" && (
<WorkflowNodeLibraryPanel
onNodeClick={(props) => {
addNode(props);
}}
/>
)}
</div>
)}
{/* browser & timeline */}
<div className="flex h-[calc(100%_-_8rem)] w-full gap-6">
{/* browser */}
<div className="flex h-full w-[calc(100%_-_6rem)] flex-1 flex-col items-center justify-center">
<div key={reloadKey} className="w-full flex-1">
{activeDebugSession &&
activeDebugSession.browser_session_id &&
!cycleBrowser.isPending ? (
<BrowserStream
interactive={interactor === "human"}
browserSessionId={activeDebugSession.browser_session_id}
showControlButtons={interactor === "human"}
resizeTrigger={windowResizeTrigger}
/>
) : (
<div className="flex aspect-video w-full flex-col items-center justify-center gap-2 rounded-md border border-slate-800 pb-2 pt-4 text-sm text-slate-400">
Connecting to your browser...
<AnimatedWave text=".‧₊˚ ⋅ ✨★ ‧₊˚ ⋅" />
</div>
)} )}
> </div>
<div <footer className="flex h-[2rem] w-full items-center justify-start gap-4">
className={cn( <div className="flex items-center gap-2">
"pointer-events-none relative flex h-full w-full flex-col items-start overflow-hidden bg-[#020617]", <GlobeIcon /> Live Browser
{ "pointer-events-auto": timelineMode === "wide" },
)}
>
<DebuggerRun />
</div> </div>
</div> {showBreakoutButton && (
<BreakoutButton onClick={() => breakout()} />
)}
<div
className={cn("ml-auto flex items-center gap-2", {
"mr-16": !blockLabel,
})}
>
{showPowerButton && (
<PowerButton onClick={() => cycle()} />
)}
<ReloadButton
isReloading={isReloading}
onClick={() => reload()}
/>
</div>
</footer>
</div>
{/* divider */} {/* timeline */}
<div className="vertical-line-gradient absolute left-0 top-0 h-full w-[2px]"></div> <div
className={cn("z-20 h-full w-[5rem] overflow-visible", {
{/* slide indicator */} "pointer-events-none hidden w-[0px] overflow-hidden":
<div !blockLabel,
className="absolute left-0 top-0 z-20 flex h-full items-center justify-center p-1 opacity-30 transition-opacity hover:opacity-100 group-hover:opacity-100" })}
onClick={(e) => { >
e.stopPropagation();
setTimelineMode(
timelineMode === "wide" ? "narrow" : "wide",
);
}}
>
{timelineMode === "narrow" && <ChevronLeftIcon />}
{timelineMode === "wide" && <ChevronRightIcon />}
</div>
{/* timeline narrow */}
<div <div
className={cn( className={cn(
"delay-[300ms] pointer-events-none absolute left-0 top-0 h-full w-[6rem] rounded-l-lg opacity-0 transition-all duration-1000", "relative h-full w-[25rem] translate-x-[-20.5rem] bg-[#020617] transition-all",
{ {
"pointer-events-auto opacity-100": "translate-x-[0rem]": timelineMode === "narrow",
timelineMode === "narrow", group: timelineMode === "narrow",
}, },
)} )}
onClick={() => {
if (timelineMode === "narrow") {
setTimelineMode("wide");
}
}}
> >
<DebuggerRunMinimal /> {/* timeline wide */}
<div
className={cn(
"pointer-events-none absolute left-[0.5rem] right-0 top-0 flex h-full w-[400px] flex-col items-end justify-end opacity-0 transition-all duration-1000",
{ "opacity-100": timelineMode === "wide" },
)}
>
<div
className={cn(
"pointer-events-none relative flex h-full w-full flex-col items-start overflow-hidden bg-[#020617]",
{ "pointer-events-auto": timelineMode === "wide" },
)}
>
<DebuggerRun />
</div>
</div>
{/* divider */}
<div className="vertical-line-gradient absolute left-0 top-0 h-full w-[2px]"></div>
{/* slide indicator */}
<div
className="absolute left-0 top-0 z-20 flex h-full items-center justify-center p-1 opacity-30 transition-opacity hover:opacity-100 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
setTimelineMode(
timelineMode === "wide" ? "narrow" : "wide",
);
}}
>
{timelineMode === "narrow" && <ChevronLeftIcon />}
{timelineMode === "wide" && <ChevronRightIcon />}
</div>
{/* timeline narrow */}
<div
className={cn(
"delay-[300ms] pointer-events-none absolute left-0 top-0 h-full w-[6rem] rounded-l-lg opacity-0 transition-all duration-1000",
{
"pointer-events-auto opacity-100":
timelineMode === "narrow",
},
)}
>
<DebuggerRunMinimal />
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </Splitter>
</div> </div>
)} )}
</div> </div>

View File

@@ -21,3 +21,8 @@
.react-flow__handle-bottom { .react-flow__handle-bottom {
bottom: 3px; bottom: 3px;
} }
.react-flow__attribution {
background-color: rgb(2 6 23);
color: white;
}