feat(SKY-11755): studio pane drag-reorder, resizable widths, greedy browser layout (SKY-11756, SKY-11760) (#7047)

This commit is contained in:
Celal Zamanoğlu 2026-07-03 16:31:05 +03:00 committed by GitHub
parent 6bb409814a
commit 7b4dd9ada8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 984 additions and 33 deletions

View file

@ -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();
});
});

View file

@ -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

View 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);
});
});

View 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);
}

View file

@ -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;

View file

@ -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,
};
}

View file

@ -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");
});
});

View file

@ -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,
});
});
});

View file

@ -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 }),
},
),
);

View 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;
}