mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
feat(SKY-11755): studio pane drag-reorder, resizable widths, greedy browser layout (SKY-11756, SKY-11760) (#7047)
This commit is contained in:
parent
6bb409814a
commit
7b4dd9ada8
10 changed files with 984 additions and 33 deletions
|
|
@ -0,0 +1,91 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { StudioPane } from "./StudioShell";
|
||||
|
||||
// Chromium aborts a native drag when the DOM mutates inside the dragstart
|
||||
// task, so the reorder state (drop overlays, source dim) must engage on a
|
||||
// later task. These tests pin that timing contract; only a real mouse drag
|
||||
// can prove the native drag itself survives.
|
||||
describe("StudioPane header drag", () => {
|
||||
const dataTransfer = () => ({ setData: vi.fn(), effectAllowed: "" });
|
||||
|
||||
const renderPane = () => {
|
||||
const reorder = {
|
||||
draggingId: null,
|
||||
placement: null,
|
||||
onStart: vi.fn(),
|
||||
onEnd: vi.fn(),
|
||||
onDrop: vi.fn(),
|
||||
onMove: vi.fn(),
|
||||
};
|
||||
render(
|
||||
<StudioPane
|
||||
id="copilot"
|
||||
open
|
||||
order={0}
|
||||
flex={undefined}
|
||||
reorder={reorder}
|
||||
onClose={vi.fn()}
|
||||
>
|
||||
<div>content</div>
|
||||
</StudioPane>,
|
||||
);
|
||||
return {
|
||||
reorder,
|
||||
header: screen.getByRole("group", { name: "Copilot pane header" }),
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("dragstart sets the drag payload synchronously but engages reorder on a later task", () => {
|
||||
const { reorder, header } = renderPane();
|
||||
const dt = dataTransfer();
|
||||
|
||||
fireEvent.dragStart(header, { dataTransfer: dt });
|
||||
|
||||
expect(dt.setData).toHaveBeenCalledWith(
|
||||
"application/x-skyvern-studio-pane",
|
||||
"copilot",
|
||||
);
|
||||
expect(reorder.onStart).not.toHaveBeenCalled();
|
||||
|
||||
vi.runAllTimers();
|
||||
expect(reorder.onStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a drag cancelled before it engages never turns the reorder state on", () => {
|
||||
const { reorder, header } = renderPane();
|
||||
|
||||
fireEvent.dragStart(header, { dataTransfer: dataTransfer() });
|
||||
fireEvent.dragEnd(header);
|
||||
|
||||
vi.runAllTimers();
|
||||
expect(reorder.onStart).not.toHaveBeenCalled();
|
||||
expect(reorder.onEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a drag starting on a header button is prevented", () => {
|
||||
const { reorder, header } = renderPane();
|
||||
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole("button", { name: "Close Copilot pane" }),
|
||||
);
|
||||
const notPrevented = fireEvent.dragStart(header, {
|
||||
dataTransfer: dataTransfer(),
|
||||
});
|
||||
|
||||
expect(notPrevented).toBe(false);
|
||||
vi.runAllTimers();
|
||||
expect(reorder.onStart).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ import { useRecordingStore } from "@/store/useRecordingStore";
|
|||
import { useStudioShellStore } from "@/store/StudioShellStore";
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
import { deriveDropIndicator } from "../editor/sortable/dropIndicator";
|
||||
import { useDebugSessionQuery } from "../hooks/useDebugSessionQuery";
|
||||
|
||||
import { BrowserPaneActions, BrowserPaneViewPills } from "./BrowserPaneHeader";
|
||||
|
|
@ -26,8 +27,21 @@ import { RunPaneActions, RunPaneStatusBadge } from "./runview/RunPaneHeader";
|
|||
import { StudioBrowserStream } from "./StudioBrowserStream";
|
||||
import { StudioCoachMark } from "./StudioCoachMark";
|
||||
import { studioPanelId, studioTabId } from "./constants";
|
||||
import {
|
||||
clampResizeDelta,
|
||||
movePaneBy,
|
||||
movePaneTo,
|
||||
paneFlex,
|
||||
paneResizable,
|
||||
type PaneWidths,
|
||||
} from "./paneLayout";
|
||||
import { STUDIO_PANE_META } from "./paneMeta";
|
||||
import { STUDIO_PANE_MIN_WIDTH, type StudioPaneId } from "./panes";
|
||||
import {
|
||||
panesListEqual,
|
||||
STUDIO_PANE_MIN_WIDTH,
|
||||
STUDIO_STAGE_GAP_PX,
|
||||
type StudioPaneId,
|
||||
} from "./panes";
|
||||
import { StudioPaneDefaultsProvider } from "./StudioPaneDefaults";
|
||||
import { useStudioPaneDefaults } from "./StudioPaneDefaultsContext";
|
||||
import {
|
||||
|
|
@ -40,18 +54,32 @@ import { StudioTopBar } from "./StudioTopBar";
|
|||
import { StudioWorkflowPanels } from "./StudioWorkflowPanels";
|
||||
import { useStudioPanes } from "./useStudioPanes";
|
||||
|
||||
// The Copilot pane holds a ceiling so a lone chat doesn't stretch across the
|
||||
// whole stage; the shared floors live in panes.ts next to the fit math.
|
||||
const COPILOT_MAX_WIDTH = 440;
|
||||
|
||||
// Below this header width, pane header chrome (view pills, badges) collapses
|
||||
// to icons — same idea as the run hero, measured per pane, not per viewport.
|
||||
const PANE_HEADER_COMPACT_BELOW_PX = 480;
|
||||
|
||||
function StudioPane({
|
||||
// Keyboard step (px) for divider arrow-key resizing.
|
||||
const DIVIDER_KEY_STEP_PX = 24;
|
||||
|
||||
const PANE_DRAG_MIME = "application/x-skyvern-studio-pane";
|
||||
|
||||
type PaneReorder = {
|
||||
draggingId: StudioPaneId | null;
|
||||
// Which edge of this pane the dragged pane would land on; static per drag
|
||||
// (arrayMove semantics, same as the editor's block drag).
|
||||
placement: "above" | "below" | null;
|
||||
onStart: () => void;
|
||||
onEnd: () => void;
|
||||
onDrop: () => void;
|
||||
onMove: (direction: -1 | 1) => void;
|
||||
};
|
||||
|
||||
export function StudioPane({
|
||||
id,
|
||||
open,
|
||||
order,
|
||||
flex,
|
||||
reorder,
|
||||
onClose,
|
||||
headerExtras,
|
||||
headerActions,
|
||||
|
|
@ -60,6 +88,8 @@ function StudioPane({
|
|||
id: StudioPaneId;
|
||||
open: boolean;
|
||||
order: number | undefined;
|
||||
flex: string | undefined;
|
||||
reorder: PaneReorder;
|
||||
onClose: () => void;
|
||||
// Rendered after the pane label (badges, view pills).
|
||||
headerExtras?: ReactNode;
|
||||
|
|
@ -92,26 +122,93 @@ function StudioPane({
|
|||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasChrome]);
|
||||
|
||||
const isDragSource = reorder.draggingId === id;
|
||||
const reorderActive = reorder.draggingId !== null;
|
||||
// Header buttons (pills, actions, close) must keep working normally: a drag
|
||||
// that starts on one is cancelled before it begins.
|
||||
const pointerOnControl = useRef(false);
|
||||
// Chromium aborts a native drag when the DOM changes inside the dragstart
|
||||
// task, and dragstart is a discrete event (sync React flush) — so revealing
|
||||
// the drop overlays must wait for the next task.
|
||||
const dragEngageTimer = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dragEngageTimer.current !== null) {
|
||||
window.clearTimeout(dragEngageTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const [dropHover, setDropHover] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!reorderActive) {
|
||||
setDropHover(false);
|
||||
}
|
||||
}, [reorderActive]);
|
||||
const showDropIndicator =
|
||||
dropHover && !isDragSource && reorder.placement !== null;
|
||||
|
||||
return (
|
||||
<section
|
||||
id={studioPanelId(id)}
|
||||
role="region"
|
||||
aria-label={label}
|
||||
style={{
|
||||
order,
|
||||
minWidth: STUDIO_PANE_MIN_WIDTH[id],
|
||||
maxWidth: id === "copilot" ? COPILOT_MAX_WIDTH : undefined,
|
||||
}}
|
||||
style={{ order, minWidth: STUDIO_PANE_MIN_WIDTH[id], flex }}
|
||||
className={cn(
|
||||
"min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-slate-elevation1",
|
||||
"relative min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-slate-elevation1",
|
||||
open
|
||||
? "flex duration-200 motion-safe:animate-in motion-safe:fade-in"
|
||||
: "hidden",
|
||||
isDragSource && "opacity-60 motion-safe:transition-opacity",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="flex h-9 shrink-0 items-center gap-2 border-b border-border px-3"
|
||||
role="group"
|
||||
tabIndex={0}
|
||||
draggable
|
||||
aria-label={`${label} pane header`}
|
||||
aria-keyshortcuts="Control+Shift+ArrowLeft Control+Shift+ArrowRight"
|
||||
title={`Drag to reorder the ${label} pane (or Ctrl/Cmd+Shift+←/→)`}
|
||||
onPointerDownCapture={(event) => {
|
||||
pointerOnControl.current =
|
||||
event.target instanceof Element &&
|
||||
event.target.closest("button, a, input, select, textarea") !== null;
|
||||
}}
|
||||
onDragStart={(event) => {
|
||||
if (pointerOnControl.current) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Firefox refuses to start a drag without setData; the drop side
|
||||
// also checks this type so foreign drags can't trigger a reorder.
|
||||
event.dataTransfer.setData(PANE_DRAG_MIME, id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
dragEngageTimer.current = window.setTimeout(() => {
|
||||
dragEngageTimer.current = null;
|
||||
reorder.onStart();
|
||||
}, 0);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
// An instantly-cancelled drag can fire dragend before the engage
|
||||
// timer; clearing it keeps the overlays from sticking on.
|
||||
if (dragEngageTimer.current !== null) {
|
||||
window.clearTimeout(dragEngageTimer.current);
|
||||
dragEngageTimer.current = null;
|
||||
}
|
||||
reorder.onEnd();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || !event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
reorder.onMove(event.key === "ArrowLeft" ? -1 : 1);
|
||||
}}
|
||||
className="flex h-9 shrink-0 cursor-grab select-none items-center gap-2 border-b border-border px-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring active:cursor-grabbing"
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-studio-accent" aria-hidden />
|
||||
<span className="min-w-0 truncate text-xs font-medium text-foreground">
|
||||
|
|
@ -133,10 +230,254 @@ function StudioPane({
|
|||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex-1">{children}</div>
|
||||
{/* Full-pane drop surface while a header drag is live; sits over iframe /
|
||||
canvas content that would otherwise swallow dragover events. */}
|
||||
{reorderActive ? (
|
||||
<div
|
||||
data-testid="pane-drop-overlay"
|
||||
className={cn(
|
||||
"absolute inset-0 z-30 rounded-lg",
|
||||
showDropIndicator &&
|
||||
"outline-dashed outline-2 -outline-offset-2 outline-blue-500/60",
|
||||
)}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
setDropHover(true);
|
||||
}}
|
||||
onDragLeave={() => setDropHover(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setDropHover(false);
|
||||
if (!event.dataTransfer.types.includes(PANE_DRAG_MIME)) {
|
||||
return;
|
||||
}
|
||||
reorder.onDrop();
|
||||
}}
|
||||
>
|
||||
{showDropIndicator ? (
|
||||
<div
|
||||
data-testid="pane-drop-indicator"
|
||||
data-placement={reorder.placement}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-y-2 w-1 rounded-full bg-blue-500 shadow-[0_0_6px_rgba(59,130,246,0.6)] motion-safe:animate-in motion-safe:fade-in",
|
||||
reorder.placement === "above" ? "left-1" : "right-1",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer-drag divider between two open panes. It is exactly the inter-pane
|
||||
* gap (STUDIO_STAGE_GAP_PX wide), so the fit math in panes.ts still holds.
|
||||
* During the drag it writes flex pins straight to the pane elements (no React
|
||||
* re-render per frame); the result commits to the store on release.
|
||||
*/
|
||||
function StudioPaneDivider({
|
||||
leftId,
|
||||
rightId,
|
||||
order,
|
||||
panes,
|
||||
onCommit,
|
||||
onReset,
|
||||
}: {
|
||||
leftId: StudioPaneId;
|
||||
rightId: StudioPaneId;
|
||||
order: number;
|
||||
panes: readonly StudioPaneId[];
|
||||
onCommit: (widths: PaneWidths) => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const [active, setActive] = useState(false);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
left: HTMLElement;
|
||||
right: HTMLElement;
|
||||
leftStart: number;
|
||||
rightStart: number;
|
||||
leftFlexBefore: string;
|
||||
rightFlexBefore: string;
|
||||
leftPinned: boolean;
|
||||
rightPinned: boolean;
|
||||
lastDelta: number;
|
||||
} | null>(null);
|
||||
|
||||
const paneElements = () => {
|
||||
const left = document.getElementById(studioPanelId(leftId));
|
||||
const right = document.getElementById(studioPanelId(rightId));
|
||||
return left && right ? { left, right } : null;
|
||||
};
|
||||
|
||||
// A pane close/navigation can unmount the divider mid-drag; put back the
|
||||
// cursor and transitions it was holding onto.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const drag = dragRef.current;
|
||||
if (drag) {
|
||||
dragRef.current = null;
|
||||
drag.left.style.transition = "";
|
||||
drag.right.style.transition = "";
|
||||
document.body.style.cursor = "";
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const beginDrag = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || dragRef.current) {
|
||||
return;
|
||||
}
|
||||
const els = paneElements();
|
||||
if (!els) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
left: els.left,
|
||||
right: els.right,
|
||||
leftStart: els.left.offsetWidth,
|
||||
rightStart: els.right.offsetWidth,
|
||||
leftFlexBefore: els.left.style.flex,
|
||||
rightFlexBefore: els.right.style.flex,
|
||||
leftPinned: paneResizable(leftId, panes),
|
||||
rightPinned: paneResizable(rightId, panes),
|
||||
lastDelta: 0,
|
||||
};
|
||||
// Freeze any width transition so the per-frame pins land instantly.
|
||||
els.left.style.transition = "none";
|
||||
els.right.style.transition = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
setActive(true);
|
||||
};
|
||||
|
||||
const moveDrag = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || event.pointerId !== drag.pointerId) {
|
||||
return;
|
||||
}
|
||||
const delta = Math.round(
|
||||
clampResizeDelta(
|
||||
event.clientX - drag.startX,
|
||||
{ id: leftId, width: drag.leftStart },
|
||||
{ id: rightId, width: drag.rightStart },
|
||||
),
|
||||
);
|
||||
drag.lastDelta = delta;
|
||||
// Only non-greedy neighbors get pinned; a greedy neighbor keeps flexing so
|
||||
// the row always fills (the neighbors' total is preserved either way).
|
||||
if (drag.leftPinned) {
|
||||
drag.left.style.flex = `0 1 ${drag.leftStart + delta}px`;
|
||||
}
|
||||
if (drag.rightPinned) {
|
||||
drag.right.style.flex = `0 1 ${drag.rightStart - delta}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) {
|
||||
return;
|
||||
}
|
||||
dragRef.current = null;
|
||||
drag.left.style.transition = "";
|
||||
drag.right.style.transition = "";
|
||||
document.body.style.cursor = "";
|
||||
setActive(false);
|
||||
if (drag.lastDelta === 0) {
|
||||
// Nothing moved: put back whatever React had written so the DOM and the
|
||||
// (unchanged) store stay in agreement.
|
||||
drag.left.style.flex = drag.leftFlexBefore;
|
||||
drag.right.style.flex = drag.rightFlexBefore;
|
||||
return;
|
||||
}
|
||||
const widths: PaneWidths = {};
|
||||
if (drag.leftPinned) {
|
||||
widths[leftId] = drag.leftStart + drag.lastDelta;
|
||||
}
|
||||
if (drag.rightPinned) {
|
||||
widths[rightId] = drag.rightStart - drag.lastDelta;
|
||||
}
|
||||
if (Object.keys(widths).length === 0) {
|
||||
return;
|
||||
}
|
||||
onCommit(widths);
|
||||
};
|
||||
|
||||
const keyResize = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const els = paneElements();
|
||||
if (!els) {
|
||||
return;
|
||||
}
|
||||
const delta = Math.round(
|
||||
clampResizeDelta(
|
||||
event.key === "ArrowLeft" ? -DIVIDER_KEY_STEP_PX : DIVIDER_KEY_STEP_PX,
|
||||
{ id: leftId, width: els.left.offsetWidth },
|
||||
{ id: rightId, width: els.right.offsetWidth },
|
||||
),
|
||||
);
|
||||
if (delta === 0) {
|
||||
return;
|
||||
}
|
||||
const widths: PaneWidths = {};
|
||||
if (paneResizable(leftId, panes)) {
|
||||
widths[leftId] = els.left.offsetWidth + delta;
|
||||
}
|
||||
if (paneResizable(rightId, panes)) {
|
||||
widths[rightId] = els.right.offsetWidth - delta;
|
||||
}
|
||||
if (Object.keys(widths).length === 0) {
|
||||
return;
|
||||
}
|
||||
onCommit(widths);
|
||||
};
|
||||
|
||||
const leftLabel = STUDIO_PANE_META[leftId].label;
|
||||
const rightLabel = STUDIO_PANE_META[rightId].label;
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={`Resize ${leftLabel} and ${rightLabel}`}
|
||||
tabIndex={0}
|
||||
title="Drag to resize · double-click to reset all widths · arrow keys to adjust"
|
||||
style={{ order, width: STUDIO_STAGE_GAP_PX }}
|
||||
onPointerDown={beginDrag}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onLostPointerCapture={endDrag}
|
||||
onDoubleClick={onReset}
|
||||
onKeyDown={keyResize}
|
||||
className="group relative shrink-0 cursor-col-resize touch-none focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute inset-y-3 left-1/2 w-0.5 -translate-x-1/2 rounded-full motion-safe:transition-colors",
|
||||
active
|
||||
? "bg-muted-foreground"
|
||||
: "bg-transparent group-hover:bg-border group-focus-visible:bg-ring",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spine + panes shell: one vertical spine whose tabs (Copilot, Editor, Browser,
|
||||
* Timeline) each toggle a pane; open panes share the stage side by side in click
|
||||
|
|
@ -153,7 +494,7 @@ export function StudioShell(props: StudioWorkspaceProps) {
|
|||
}
|
||||
|
||||
function StudioStage(props: StudioWorkspaceProps) {
|
||||
const { panes, closePane, openPane } = useStudioPanes();
|
||||
const { panes, closePane, openPane, setPanesOrder } = useStudioPanes();
|
||||
const { registerStageElement } = useStudioPaneDefaults();
|
||||
const { workflowPermanentId } = useParams();
|
||||
const isRecording = useRecordingStore((s) => s.isRecording);
|
||||
|
|
@ -163,6 +504,12 @@ function StudioStage(props: StudioWorkspaceProps) {
|
|||
});
|
||||
const browserSessionId = debugSession?.browser_session_id ?? null;
|
||||
const pipMinimized = useStudioShellStore((s) => s.pipMinimized);
|
||||
const paneWidths = useStudioShellStore((s) => s.paneWidths);
|
||||
const setPaneWidths = useStudioShellStore((s) => s.setPaneWidths);
|
||||
const resetPaneWidths = useStudioShellStore((s) => s.resetPaneWidths);
|
||||
const [draggingPaneId, setDraggingPaneId] = useState<StudioPaneId | null>(
|
||||
null,
|
||||
);
|
||||
const [copilotPortalEl, setCopilotPortalEl] = useState<HTMLElement | null>(
|
||||
null,
|
||||
);
|
||||
|
|
@ -233,6 +580,21 @@ function StudioStage(props: StudioWorkspaceProps) {
|
|||
document.getElementById(studioTabId(id))?.focus();
|
||||
};
|
||||
|
||||
// Reorder is otherwise invisible to assistive tech (only CSS order moves),
|
||||
// so voice the result through the polite live region below.
|
||||
const [reorderAnnouncement, setReorderAnnouncement] = useState("");
|
||||
const commitOrder = (movedId: StudioPaneId, next: StudioPaneId[]) => {
|
||||
if (panesListEqual(next, panes)) {
|
||||
return;
|
||||
}
|
||||
setPanesOrder(next);
|
||||
setReorderAnnouncement(
|
||||
`${STUDIO_PANE_META[movedId].label} pane moved to position ${
|
||||
next.indexOf(movedId) + 1
|
||||
} of ${next.length}`,
|
||||
);
|
||||
};
|
||||
|
||||
// Recording lives in the Browser pane (the live stream is there) with the
|
||||
// live-drafts panel taking over the Copilot pane. Once a commit is in flight
|
||||
// or its blocks are landing, reveal the Editor pane (it shows the loading
|
||||
|
|
@ -267,8 +629,31 @@ function StudioStage(props: StudioWorkspaceProps) {
|
|||
return {
|
||||
id,
|
||||
open: index >= 0,
|
||||
order: index >= 0 ? index : undefined,
|
||||
// Panes take even slots and the dividers between them take odd slots.
|
||||
order: index >= 0 ? index * 2 : undefined,
|
||||
flex: index >= 0 ? paneFlex(id, panes, paneWidths) : undefined,
|
||||
onClose: () => closeWithFocus(id),
|
||||
reorder: {
|
||||
draggingId: draggingPaneId,
|
||||
placement:
|
||||
draggingPaneId === null
|
||||
? null
|
||||
: (deriveDropIndicator({
|
||||
order: [...panes],
|
||||
activeId: draggingPaneId,
|
||||
overId: id,
|
||||
})?.placement ?? null),
|
||||
onStart: () => setDraggingPaneId(id),
|
||||
onEnd: () => setDraggingPaneId(null),
|
||||
onDrop: () => {
|
||||
if (draggingPaneId !== null && draggingPaneId !== id) {
|
||||
commitOrder(draggingPaneId, movePaneTo(panes, draggingPaneId, id));
|
||||
}
|
||||
setDraggingPaneId(null);
|
||||
},
|
||||
onMove: (direction: -1 | 1) =>
|
||||
commitOrder(id, movePaneBy(panes, id, direction)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -279,11 +664,13 @@ function StudioStage(props: StudioWorkspaceProps) {
|
|||
<div className="flex min-h-0 min-w-0 flex-1">
|
||||
<StudioSpine />
|
||||
{/* Panes keep a fixed DOM order (stable mounts for the canvas, chat and
|
||||
stream slots); the CSS order carries the click order instead, so
|
||||
screen-reader/Tab order stays the fixed order, not the visual one. */}
|
||||
stream slots); the CSS order carries the layout order instead, so
|
||||
screen-reader/Tab order stays the fixed order, not the visual one.
|
||||
Reordering (drag or keyboard) only rewrites CSS order — panes and
|
||||
the stream singleton never remount. */}
|
||||
<div
|
||||
ref={registerStageElement}
|
||||
className="relative flex min-h-0 min-w-0 flex-1 gap-3 overflow-hidden p-3"
|
||||
className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden p-3"
|
||||
>
|
||||
<StudioPane {...paneProps("copilot")}>
|
||||
<div className="relative h-full w-full">
|
||||
|
|
@ -323,9 +710,30 @@ function StudioStage(props: StudioWorkspaceProps) {
|
|||
>
|
||||
<RunTab />
|
||||
</StudioPane>
|
||||
{/* Dividers are the inter-pane gaps; stateless, so unlike the panes
|
||||
they can re-render freely as the open list changes. */}
|
||||
{panes.slice(1).map((rightId, index) => (
|
||||
<StudioPaneDivider
|
||||
key={`${panes[index]}:${rightId}`}
|
||||
leftId={panes[index]!}
|
||||
rightId={rightId}
|
||||
order={index * 2 + 1}
|
||||
panes={panes}
|
||||
onCommit={setPaneWidths}
|
||||
onReset={resetPaneWidths}
|
||||
/>
|
||||
))}
|
||||
{panes.length === 0 ? <StudioStageLauncher /> : null}
|
||||
<StudioCoachMark />
|
||||
<StudioWorkflowPanels />
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{reorderAnnouncement}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
|
|
|||
163
skyvern-frontend/src/routes/workflows/studio/paneLayout.test.ts
Normal file
163
skyvern-frontend/src/routes/workflows/studio/paneLayout.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
clampResizeDelta,
|
||||
greedyPaneOf,
|
||||
movePaneBy,
|
||||
movePaneTo,
|
||||
paneFlex,
|
||||
paneResizable,
|
||||
STUDIO_PANE_DEFAULT_WIDTH,
|
||||
} from "./paneLayout";
|
||||
import { STUDIO_PANE_MIN_WIDTH, type StudioPaneId } from "./panes";
|
||||
|
||||
describe("greedyPaneOf", () => {
|
||||
test("browser wins whenever it is open", () => {
|
||||
expect(greedyPaneOf(["copilot", "browser", "editor"])).toBe("browser");
|
||||
expect(greedyPaneOf(["browser"])).toBe("browser");
|
||||
});
|
||||
|
||||
test("editor takes over when the browser is closed", () => {
|
||||
expect(greedyPaneOf(["copilot", "editor", "timeline"])).toBe("editor");
|
||||
});
|
||||
|
||||
test("no greedy pane without browser or editor", () => {
|
||||
expect(greedyPaneOf(["copilot", "timeline"])).toBeUndefined();
|
||||
expect(greedyPaneOf([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("paneResizable", () => {
|
||||
test("every pane except the greedy one can hold a pinned width", () => {
|
||||
const panes: StudioPaneId[] = ["copilot", "editor", "browser", "timeline"];
|
||||
expect(paneResizable("copilot", panes)).toBe(true);
|
||||
expect(paneResizable("editor", panes)).toBe(true);
|
||||
expect(paneResizable("timeline", panes)).toBe(true);
|
||||
expect(paneResizable("browser", panes)).toBe(false);
|
||||
});
|
||||
|
||||
test("without a greedy pane the last pane flexes instead of pinning", () => {
|
||||
const panes: StudioPaneId[] = ["copilot", "timeline"];
|
||||
expect(paneResizable("copilot", panes)).toBe(true);
|
||||
expect(paneResizable("timeline", panes)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("paneFlex", () => {
|
||||
const noWidths = {};
|
||||
|
||||
test("browser takes all remaining space; others sit at the default width", () => {
|
||||
const panes: StudioPaneId[] = ["copilot", "browser", "editor"];
|
||||
expect(paneFlex("browser", panes, noWidths)).toBe("1 1 0%");
|
||||
expect(paneFlex("copilot", panes, noWidths)).toBe(
|
||||
`0 1 ${STUDIO_PANE_DEFAULT_WIDTH}px`,
|
||||
);
|
||||
expect(paneFlex("editor", panes, noWidths)).toBe(
|
||||
`0 1 ${STUDIO_PANE_DEFAULT_WIDTH}px`,
|
||||
);
|
||||
});
|
||||
|
||||
test("editor becomes the greedy pane when the browser is closed", () => {
|
||||
const panes: StudioPaneId[] = ["copilot", "editor", "timeline"];
|
||||
expect(paneFlex("editor", panes, noWidths)).toBe("1 1 0%");
|
||||
expect(paneFlex("copilot", panes, noWidths)).toBe(
|
||||
`0 1 ${STUDIO_PANE_DEFAULT_WIDTH}px`,
|
||||
);
|
||||
});
|
||||
|
||||
test("with neither greedy pane open the remaining panes share equally", () => {
|
||||
const panes: StudioPaneId[] = ["copilot", "timeline"];
|
||||
expect(paneFlex("copilot", panes, noWidths)).toBe(
|
||||
`1 1 ${STUDIO_PANE_DEFAULT_WIDTH}px`,
|
||||
);
|
||||
expect(paneFlex("timeline", panes, noWidths)).toBe(
|
||||
`1 1 ${STUDIO_PANE_DEFAULT_WIDTH}px`,
|
||||
);
|
||||
});
|
||||
|
||||
test("a pinned width pins the pane; the greedy pane ignores its pin", () => {
|
||||
const panes: StudioPaneId[] = ["copilot", "browser"];
|
||||
const widths = { copilot: 412, browser: 900 };
|
||||
expect(paneFlex("copilot", panes, widths)).toBe("0 1 412px");
|
||||
expect(paneFlex("browser", panes, widths)).toBe("1 1 0%");
|
||||
});
|
||||
|
||||
test("without a greedy pane the last pane ignores its pin so the row fills", () => {
|
||||
const panes: StudioPaneId[] = ["copilot", "timeline"];
|
||||
const widths = { copilot: 350, timeline: 500 };
|
||||
expect(paneFlex("copilot", panes, widths)).toBe("0 1 350px");
|
||||
expect(paneFlex("timeline", panes, widths)).toBe(
|
||||
`1 1 ${STUDIO_PANE_DEFAULT_WIDTH}px`,
|
||||
);
|
||||
});
|
||||
|
||||
test("garbage persisted widths fall back to the default", () => {
|
||||
const panes: StudioPaneId[] = ["copilot", "browser"];
|
||||
expect(paneFlex("copilot", panes, { copilot: Number.NaN })).toBe(
|
||||
`0 1 ${STUDIO_PANE_DEFAULT_WIDTH}px`,
|
||||
);
|
||||
expect(paneFlex("copilot", panes, { copilot: -50 })).toBe(
|
||||
`0 1 ${STUDIO_PANE_DEFAULT_WIDTH}px`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampResizeDelta", () => {
|
||||
const left = { id: "copilot" as const, width: 400 };
|
||||
const right = { id: "browser" as const, width: 600 };
|
||||
|
||||
test("passes through deltas that keep both panes above their mins", () => {
|
||||
expect(clampResizeDelta(50, left, right)).toBe(50);
|
||||
expect(clampResizeDelta(-50, left, right)).toBe(-50);
|
||||
});
|
||||
|
||||
test("clamps against the left pane's min width", () => {
|
||||
expect(clampResizeDelta(-500, left, right)).toBe(
|
||||
STUDIO_PANE_MIN_WIDTH.copilot - 400,
|
||||
);
|
||||
});
|
||||
|
||||
test("clamps against the right pane's min width", () => {
|
||||
expect(clampResizeDelta(500, left, right)).toBe(
|
||||
600 - STUDIO_PANE_MIN_WIDTH.browser,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("movePaneTo / movePaneBy", () => {
|
||||
const panes: StudioPaneId[] = ["copilot", "editor", "browser"];
|
||||
|
||||
test("dragged pane takes the target pane's slot (arrayMove semantics)", () => {
|
||||
expect(movePaneTo(panes, "copilot", "browser")).toEqual([
|
||||
"editor",
|
||||
"browser",
|
||||
"copilot",
|
||||
]);
|
||||
expect(movePaneTo(panes, "browser", "copilot")).toEqual([
|
||||
"browser",
|
||||
"copilot",
|
||||
"editor",
|
||||
]);
|
||||
});
|
||||
|
||||
test("dropping on itself or on an unknown pane is a no-op", () => {
|
||||
expect(movePaneTo(panes, "copilot", "copilot")).toEqual(panes);
|
||||
expect(movePaneTo(panes, "timeline", "copilot")).toEqual(panes);
|
||||
expect(movePaneTo(panes, "copilot", "timeline")).toEqual(panes);
|
||||
});
|
||||
|
||||
test("moves one slot left or right, clamped at the row edges", () => {
|
||||
expect(movePaneBy(panes, "editor", -1)).toEqual([
|
||||
"editor",
|
||||
"copilot",
|
||||
"browser",
|
||||
]);
|
||||
expect(movePaneBy(panes, "editor", 1)).toEqual([
|
||||
"copilot",
|
||||
"browser",
|
||||
"editor",
|
||||
]);
|
||||
expect(movePaneBy(panes, "copilot", -1)).toEqual(panes);
|
||||
expect(movePaneBy(panes, "browser", 1)).toEqual(panes);
|
||||
});
|
||||
});
|
||||
106
skyvern-frontend/src/routes/workflows/studio/paneLayout.ts
Normal file
106
skyvern-frontend/src/routes/workflows/studio/paneLayout.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
import { sanitizePaneWidth, type PaneWidths } from "@/store/paneWidths";
|
||||
|
||||
import { STUDIO_PANE_MIN_WIDTH, type StudioPaneId } from "./panes";
|
||||
|
||||
// Re-exported for the shell: pinned widths ride along with the layout math.
|
||||
export type { PaneWidths };
|
||||
|
||||
// Non-greedy panes sit at this width by default; the greedy pane absorbs the
|
||||
// rest of the row.
|
||||
export const STUDIO_PANE_DEFAULT_WIDTH = 300;
|
||||
|
||||
// The browser is the best consumer of free space; the canvas takes over when
|
||||
// the browser is closed. With neither open, every open pane flexes equally.
|
||||
export function greedyPaneOf(
|
||||
panes: readonly StudioPaneId[],
|
||||
): StudioPaneId | undefined {
|
||||
if (panes.includes("browser")) {
|
||||
return "browser";
|
||||
}
|
||||
if (panes.includes("editor")) {
|
||||
return "editor";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// A pane can hold a pinned px width unless it is the row's flexing pane: the
|
||||
// greedy pane, or the last pane when no greedy pane is open. Exactly one open
|
||||
// pane always flexes, so the row fills with no voids and no horizontal scroll.
|
||||
export function paneResizable(
|
||||
id: StudioPaneId,
|
||||
panes: readonly StudioPaneId[],
|
||||
): boolean {
|
||||
const greedy = greedyPaneOf(panes);
|
||||
if (greedy !== undefined) {
|
||||
return id !== greedy;
|
||||
}
|
||||
return panes.indexOf(id) !== panes.length - 1;
|
||||
}
|
||||
|
||||
// CSS flex shorthand for an open pane. Pinned panes keep flex-shrink so a
|
||||
// narrow window squeezes them toward their min-width instead of overflowing.
|
||||
export function paneFlex(
|
||||
id: StudioPaneId,
|
||||
panes: readonly StudioPaneId[],
|
||||
widths: PaneWidths,
|
||||
): string {
|
||||
const greedy = greedyPaneOf(panes);
|
||||
if (id === greedy) {
|
||||
return "1 1 0%";
|
||||
}
|
||||
if (!paneResizable(id, panes)) {
|
||||
// Last pane of a greedy-less row: flexes so the row always fills.
|
||||
return `1 1 ${STUDIO_PANE_DEFAULT_WIDTH}px`;
|
||||
}
|
||||
const pinned = sanitizePaneWidth(widths[id]);
|
||||
if (pinned !== undefined) {
|
||||
return `0 1 ${pinned}px`;
|
||||
}
|
||||
// Without a greedy pane, unpinned panes share the free space equally.
|
||||
return greedy === undefined
|
||||
? `1 1 ${STUDIO_PANE_DEFAULT_WIDTH}px`
|
||||
: `0 1 ${STUDIO_PANE_DEFAULT_WIDTH}px`;
|
||||
}
|
||||
|
||||
// Clamp a divider drag so neither neighbor goes under its min width. The
|
||||
// neighbors' total is preserved, so panes elsewhere in the row never move.
|
||||
export function clampResizeDelta(
|
||||
delta: number,
|
||||
left: { id: StudioPaneId; width: number },
|
||||
right: { id: StudioPaneId; width: number },
|
||||
): number {
|
||||
return Math.min(
|
||||
Math.max(delta, STUDIO_PANE_MIN_WIDTH[left.id] - left.width),
|
||||
right.width - STUDIO_PANE_MIN_WIDTH[right.id],
|
||||
);
|
||||
}
|
||||
|
||||
// arrayMove semantics, shared with the editor's block drag: the dragged pane
|
||||
// takes the target pane's slot.
|
||||
export function movePaneTo(
|
||||
panes: readonly StudioPaneId[],
|
||||
activeId: StudioPaneId,
|
||||
overId: StudioPaneId,
|
||||
): StudioPaneId[] {
|
||||
const from = panes.indexOf(activeId);
|
||||
const to = panes.indexOf(overId);
|
||||
if (from < 0 || to < 0 || from === to) {
|
||||
return [...panes];
|
||||
}
|
||||
return arrayMove([...panes], from, to);
|
||||
}
|
||||
|
||||
export function movePaneBy(
|
||||
panes: readonly StudioPaneId[],
|
||||
id: StudioPaneId,
|
||||
direction: -1 | 1,
|
||||
): StudioPaneId[] {
|
||||
const from = panes.indexOf(id);
|
||||
const to = from + direction;
|
||||
if (from < 0 || to < 0 || to >= panes.length) {
|
||||
return [...panes];
|
||||
}
|
||||
return arrayMove([...panes], from, to);
|
||||
}
|
||||
|
|
@ -27,17 +27,19 @@ export const RUN_APPEND_PANES: readonly StudioPaneId[] = [
|
|||
"timeline",
|
||||
];
|
||||
|
||||
// Width floors from the approved mock; the stage clamps shared links and nudges
|
||||
// on over-tight opens against these same numbers (fitPanesToWidth below).
|
||||
// Copilot / Editor / Timeline share one narrow floor; the browser viewport
|
||||
// keeps a little more room. The stage clamps shared links and nudges on
|
||||
// over-tight opens against these numbers (fitPanesToWidth below), and divider
|
||||
// resizes clamp against them too.
|
||||
export const STUDIO_PANE_MIN_WIDTH: Record<StudioPaneId, number> = {
|
||||
copilot: 260,
|
||||
editor: 220,
|
||||
browser: 260,
|
||||
timeline: 220,
|
||||
editor: 260,
|
||||
browser: 300,
|
||||
timeline: 260,
|
||||
};
|
||||
|
||||
// Stage chrome for the fit math; must match the p-3 + gap-3 on the stage div
|
||||
// in StudioShell.tsx.
|
||||
// Stage chrome for the fit math; must match the stage p-3 and the divider
|
||||
// width (the resize dividers are the inter-pane gap) in StudioShell.tsx.
|
||||
export const STUDIO_STAGE_PADDING_PX = 24;
|
||||
export const STUDIO_STAGE_GAP_PX = 12;
|
||||
|
||||
|
|
|
|||
|
|
@ -86,5 +86,30 @@ export function useStudioPanes() {
|
|||
[applyPanes],
|
||||
);
|
||||
|
||||
return { panes, resolveLivePanes, togglePane, openPane, closePane };
|
||||
// Reorder-only write (drag-and-drop / keyboard move): the live URL keeps
|
||||
// deciding WHICH panes are open; `order` only decides where they sit.
|
||||
const setPanesOrder = useCallback(
|
||||
(order: readonly StudioPaneId[]) =>
|
||||
applyPanes((current) => {
|
||||
const next = order.filter(
|
||||
(id, index) => current.includes(id) && order.indexOf(id) === index,
|
||||
);
|
||||
for (const id of current) {
|
||||
if (!next.includes(id)) {
|
||||
next.push(id);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}),
|
||||
[applyPanes],
|
||||
);
|
||||
|
||||
return {
|
||||
panes,
|
||||
resolveLivePanes,
|
||||
togglePane,
|
||||
openPane,
|
||||
closePane,
|
||||
setPanesOrder,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { type StudioPaneId } from "./panes";
|
||||
import { useStudioPanes } from "./useStudioPanes";
|
||||
|
||||
function OrderProbe({ order }: { order: StudioPaneId[] }) {
|
||||
const { panes, setPanesOrder } = useStudioPanes();
|
||||
return (
|
||||
<div>
|
||||
<output data-testid="panes">{panes.join(",")}</output>
|
||||
<button onClick={() => setPanesOrder(order)}>set-order</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWithPanes(search: string, order: StudioPaneId[]) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[`/studio${search}`]}>
|
||||
<OrderProbe order={order} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("useStudioPanes setPanesOrder", () => {
|
||||
test("commits a reordered list to the URL", () => {
|
||||
renderWithPanes("?panes=copilot,editor,browser", [
|
||||
"editor",
|
||||
"browser",
|
||||
"copilot",
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByText("set-order"));
|
||||
|
||||
expect(screen.getByTestId("panes").textContent).toBe(
|
||||
"editor,browser,copilot",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the open set from the URL: closed panes in the order are dropped, missing ones appended", () => {
|
||||
// "timeline" is not open, so it must not open; "browser" is open but
|
||||
// absent from the requested order, so it keeps a slot at the end.
|
||||
renderWithPanes("?panes=copilot,editor,browser", [
|
||||
"timeline",
|
||||
"editor",
|
||||
"copilot",
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByText("set-order"));
|
||||
|
||||
expect(screen.getByTestId("panes").textContent).toBe(
|
||||
"editor,copilot,browser",
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores duplicate entries in the requested order", () => {
|
||||
renderWithPanes("?panes=copilot,browser", [
|
||||
"browser",
|
||||
"browser",
|
||||
"copilot",
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByText("set-order"));
|
||||
|
||||
expect(screen.getByTestId("panes").textContent).toBe("browser,copilot");
|
||||
});
|
||||
});
|
||||
|
|
@ -17,12 +17,15 @@ describe("StudioShellStore", () => {
|
|||
expect(useStudioShellStore.getState().pipMinimized).toBe(false);
|
||||
});
|
||||
|
||||
test("persists only PiP shell state", () => {
|
||||
test("persists only PiP and pane-width shell state", () => {
|
||||
useStudioShellStore.getState().setPipMinimized(true);
|
||||
|
||||
const raw = localStorage.getItem(STUDIO_SHELL_STORAGE_KEY);
|
||||
expect(raw).not.toBeNull();
|
||||
expect(JSON.parse(raw!).state).toEqual({ pipMinimized: true });
|
||||
expect(JSON.parse(raw!).state).toEqual({
|
||||
pipMinimized: true,
|
||||
paneWidths: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("migrates stale v0 Copilot collapse without restoring it", async () => {
|
||||
|
|
@ -42,4 +45,41 @@ describe("StudioShellStore", () => {
|
|||
"copilotCollapsed" in (state as unknown as Record<string, unknown>),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("merges and persists pane widths; reset clears them", () => {
|
||||
useStudioShellStore.getState().setPaneWidths({ copilot: 412 });
|
||||
useStudioShellStore.getState().setPaneWidths({ editor: 350.4 });
|
||||
|
||||
expect(useStudioShellStore.getState().paneWidths).toEqual({
|
||||
copilot: 412,
|
||||
editor: 350,
|
||||
});
|
||||
const raw = localStorage.getItem(STUDIO_SHELL_STORAGE_KEY);
|
||||
expect(JSON.parse(raw!).state.paneWidths).toEqual({
|
||||
copilot: 412,
|
||||
editor: 350,
|
||||
});
|
||||
|
||||
useStudioShellStore.getState().resetPaneWidths();
|
||||
expect(useStudioShellStore.getState().paneWidths).toEqual({});
|
||||
});
|
||||
|
||||
test("drops non-numeric persisted pane widths on rehydrate", async () => {
|
||||
localStorage.setItem(
|
||||
STUDIO_SHELL_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
state: {
|
||||
pipMinimized: false,
|
||||
paneWidths: { copilot: 320, editor: "wide", browser: -10 },
|
||||
},
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await useStudioShellStore.persist.rehydrate();
|
||||
|
||||
expect(useStudioShellStore.getState().paneWidths).toEqual({
|
||||
copilot: 320,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,25 +1,52 @@
|
|||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
|
||||
import { sanitizePaneWidth, type PaneWidths } from "@/store/paneWidths";
|
||||
|
||||
type StudioShellState = {
|
||||
pipMinimized: boolean;
|
||||
// User-pinned pane widths (px) from divider drags; per user, NOT in the URL
|
||||
// so shared ?panes= links never impose someone else's widths.
|
||||
paneWidths: PaneWidths;
|
||||
setPipMinimized: (minimized: boolean) => void;
|
||||
togglePip: () => void;
|
||||
setPaneWidths: (widths: PaneWidths) => void;
|
||||
resetPaneWidths: () => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
const DEFAULTS = {
|
||||
pipMinimized: false,
|
||||
paneWidths: {} as PaneWidths,
|
||||
};
|
||||
|
||||
export const STUDIO_SHELL_STORAGE_KEY = "skyvern.studioShell";
|
||||
|
||||
function sanitizePaneWidths(raw: unknown): PaneWidths {
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
return {};
|
||||
}
|
||||
const result: PaneWidths = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
const width = sanitizePaneWidth(value);
|
||||
if (width !== undefined) {
|
||||
result[key] = width;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const useStudioShellStore = create<StudioShellState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...DEFAULTS,
|
||||
setPipMinimized: (pipMinimized) => set({ pipMinimized }),
|
||||
togglePip: () => set((state) => ({ pipMinimized: !state.pipMinimized })),
|
||||
setPaneWidths: (widths) =>
|
||||
set((state) => ({
|
||||
paneWidths: sanitizePaneWidths({ ...state.paneWidths, ...widths }),
|
||||
})),
|
||||
resetPaneWidths: () => set({ paneWidths: {} }),
|
||||
reset: () => set(DEFAULTS),
|
||||
}),
|
||||
{
|
||||
|
|
@ -27,12 +54,19 @@ export const useStudioShellStore = create<StudioShellState>()(
|
|||
storage: createJSONStorage(() => localStorage),
|
||||
// v0 also persisted copilotCollapsed; pane visibility now lives in the URL.
|
||||
version: 1,
|
||||
migrate: (persisted) => ({
|
||||
pipMinimized: Boolean(
|
||||
(persisted as { pipMinimized?: unknown } | undefined)?.pipMinimized,
|
||||
),
|
||||
migrate: (persisted) => {
|
||||
const state = persisted as
|
||||
| { pipMinimized?: unknown; paneWidths?: unknown }
|
||||
| undefined;
|
||||
return {
|
||||
pipMinimized: Boolean(state?.pipMinimized),
|
||||
paneWidths: sanitizePaneWidths(state?.paneWidths),
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({
|
||||
pipMinimized: state.pipMinimized,
|
||||
paneWidths: state.paneWidths,
|
||||
}),
|
||||
partialize: (state) => ({ pipMinimized: state.pipMinimized }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
12
skyvern-frontend/src/store/paneWidths.ts
Normal file
12
skyvern-frontend/src/store/paneWidths.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Keys are studio pane ids; kept as plain strings so the store stays agnostic
|
||||
// to pane renames (stale keys are simply never read).
|
||||
export type PaneWidths = Record<string, number>;
|
||||
|
||||
// Pane widths round-trip through localStorage, so treat each value as
|
||||
// untrusted. Lives at the store layer so both StudioShellStore (persistence)
|
||||
// and the studio layout math share one validation contract.
|
||||
export function sanitizePaneWidth(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.round(value)
|
||||
: undefined;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue