feat(webui): resize workbench panes

This commit is contained in:
chengyongru
2026-08-11 18:38:39 +08:00
parent 8071ac3b27
commit d0240a9074
20 changed files with 781 additions and 111 deletions
@@ -1062,6 +1062,7 @@ async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices(
"layoutPaneKeys": ["websocket:b", "websocket:a"],
"activePaneKey": "websocket:a",
"layout": "columns",
"splitRatios": [0.35],
}
},
}
@@ -1081,6 +1082,9 @@ async def test_webui_sidebar_state_update_broadcasts_workbench_to_other_devices(
"websocket:b",
"websocket:a",
]
assert event["state"]["workbench"]["tabs"]["tab:websocket:a"]["splitRatios"] == [
0.35
]
@pytest.mark.asyncio
+16
View File
@@ -8,6 +8,7 @@ does not modify agent sessions.
from __future__ import annotations
import json
import math
import os
import time
from pathlib import Path
@@ -79,6 +80,20 @@ def _clean_string_list(value: Any, *, max_len: int = _MAX_KEY_LEN) -> list[str]:
return out
def _clean_split_ratios(value: Any) -> list[float]:
if not isinstance(value, list):
return []
ratios: list[float] = []
for raw_ratio in cast(list[Any], value)[: _MAX_WORKBENCH_PANES - 1]:
if isinstance(raw_ratio, bool) or not isinstance(raw_ratio, (int, float)):
continue
ratio = float(raw_ratio)
if not math.isfinite(ratio):
continue
ratios.append(round(min(0.95, max(0.05, ratio)), 4))
return ratios
def _clean_bool_map(value: Any) -> dict[str, bool]:
if not isinstance(value, dict):
return {}
@@ -175,6 +190,7 @@ def _clean_workbench(value: Any) -> dict[str, Any]:
active_pane_key if active_pane_key in pane_keys else pane_keys[0]
),
"layout": layout,
"splitRatios": _clean_split_ratios(tab.get("splitRatios")),
}
return {"version": 1, "tabs": tabs}
+3
View File
@@ -41,6 +41,7 @@ def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None
"layoutPaneKeys": ["websocket:b", "missing", "websocket:a"],
"activePaneKey": "missing",
"layout": "invalid-layout",
"splitRatios": [0.4, 2, "bad", float("nan")],
},
"tab:websocket:b": {
"paneKeys": ["websocket:b", "websocket:c"],
@@ -75,6 +76,7 @@ def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None
"layoutPaneKeys": ["websocket:b", "websocket:a"],
"activePaneKey": "websocket:a",
"layout": "columns",
"splitRatios": [0.4, 0.95],
},
"tab:websocket:b": {
"explicit": False,
@@ -83,6 +85,7 @@ def test_sidebar_state_normalizes_partial_payload(tmp_path, monkeypatch) -> None
"layoutPaneKeys": ["websocket:c"],
"activePaneKey": "websocket:c",
"layout": "bsp",
"splitRatios": [],
},
},
}
+11
View File
@@ -31,6 +31,7 @@ import {
renameWorkbenchTab,
setWorkbenchLayout,
setWorkbenchPaneLayoutOrder,
setWorkbenchSplitRatios,
workbenchTab,
workbenchTabForPane,
type WorkbenchState,
@@ -2400,6 +2401,9 @@ function Shell({
const renderedWorkbenchLayout = paneChromeEnabled && activeTabState
? activeTabState.layout
: "columns";
const renderedWorkbenchSplitRatios = paneChromeEnabled && activeTabState
? activeTabState.splitRatios
: [];
const sidebarPaneGroups = useMemo(() => {
const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
return Object.fromEntries(sidebarTabPresentations.map((presentation) => {
@@ -2734,6 +2738,7 @@ function Shell({
panes={renderedWorkbenchPanes}
activePaneKey={renderedActivePaneKey}
layout={renderedWorkbenchLayout}
splitRatios={renderedWorkbenchSplitRatios}
chrome={paneChromeEnabled}
showLayoutControl={activeTabVisible}
addPaneDisabled={creatingPane || activePaneLimitReached}
@@ -2751,6 +2756,12 @@ function Shell({
setWorkbenchPaneLayoutOrder(current, activeTabKey, paneKeys)
));
}}
onSplitRatiosChange={(splitRatios) => {
if (!activeTabKey) return;
setWorkbenchState((current) => (
setWorkbenchSplitRatios(current, activeTabKey, splitRatios)
));
}}
renderPane={(pane, context) => {
if (!paneChromeEnabled) {
return (
+193 -106
View File
@@ -8,7 +8,6 @@ import {
type LucideIcon,
} from "lucide-react";
import {
type CSSProperties,
type FocusEvent,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
@@ -38,6 +37,14 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
createWorkbenchLayoutGeometry,
type EffectiveWorkbenchLayout,
resizeHandleRatio,
resizeHandleStyle,
splitRatioBounds,
type WorkbenchResizeHandle,
} from "@/components/workbench/workbench-layout";
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { cn } from "@/lib/utils";
@@ -66,11 +73,15 @@ interface PaneWorkbenchProps {
onAddPane: () => void;
onLayoutChange: (layout: WorkbenchLayout) => void;
onPaneOrderChange: (paneKeys: string[]) => void;
splitRatios?: number[];
onSplitRatiosChange?: (splitRatios: number[]) => void;
renderPane: (pane: WorkbenchPane, context: PaneRenderContext) => ReactNode;
}
const LAYOUT_MOTION_DURATION_MS = 260;
const LAYOUT_MOTION_EASING = "cubic-bezier(0.2, 0, 0, 1)";
const EMPTY_SPLIT_RATIOS: number[] = [];
const IGNORE_SPLIT_RATIO_CHANGE = () => {};
const LAYOUT_CONTROLS: Array<{
icon: LucideIcon;
@@ -84,108 +95,6 @@ const LAYOUT_CONTROLS: Array<{
{ icon: PanelLeft, layout: "main-stack", label: "Main and stack" },
];
type EffectiveWorkbenchLayout = WorkbenchLayout | "compact";
function paneGridStyle(layout: EffectiveWorkbenchLayout, paneCount: number): CSSProperties {
const count = Math.max(1, paneCount);
switch (layout) {
case "columns":
return {
gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))`,
gridTemplateRows: "minmax(0, 1fr)",
};
case "rows":
return {
gridTemplateColumns: "minmax(0, 1fr)",
gridTemplateRows: `repeat(${count}, minmax(0, 1fr))`,
};
case "grid": {
const columns = Math.ceil(Math.sqrt(count));
const rows = Math.ceil(count / columns);
return {
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
};
}
case "bsp":
return {
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
gridTemplateRows: "repeat(4, minmax(0, 1fr))",
};
case "main-stack":
return count === 1
? {
gridTemplateColumns: "minmax(0, 1fr)",
gridTemplateRows: "minmax(0, 1fr)",
}
: {
gridTemplateColumns: "minmax(0, 1.65fr) minmax(0, 1fr)",
gridTemplateRows: `repeat(${count - 1}, minmax(0, 1fr))`,
};
case "compact":
return {
gridTemplateColumns: "minmax(0, 1fr)",
gridTemplateRows: "minmax(0, 1fr)",
};
}
}
interface BspCell {
columnStart: number;
columnEnd: number;
rowStart: number;
rowEnd: number;
}
function bspPaneCells(paneCount: number): BspCell[] {
const cells: BspCell[] = [{
columnStart: 1,
columnEnd: 5,
rowStart: 1,
rowEnd: 5,
}];
for (let paneIndex = 1; paneIndex < paneCount; paneIndex += 1) {
const leaf = cells.pop();
if (!leaf) break;
if (paneIndex % 2 === 1) {
const midpoint = (leaf.columnStart + leaf.columnEnd) / 2;
cells.push(
{ ...leaf, columnEnd: midpoint },
{ ...leaf, columnStart: midpoint },
);
} else {
const midpoint = (leaf.rowStart + leaf.rowEnd) / 2;
cells.push(
{ ...leaf, rowEnd: midpoint },
{ ...leaf, rowStart: midpoint },
);
}
}
return cells;
}
function paneCellStyle(
layout: EffectiveWorkbenchLayout,
paneCount: number,
index: number,
): CSSProperties | undefined {
if (layout === "bsp") {
const cell = bspPaneCells(paneCount)[index];
return cell
? {
gridColumn: `${cell.columnStart} / ${cell.columnEnd}`,
gridRow: `${cell.rowStart} / ${cell.rowEnd}`,
}
: undefined;
}
if (layout === "main-stack" && paneCount >= 2) {
return index === 0
? { gridColumn: 1, gridRow: `1 / span ${paneCount - 1}` }
: { gridColumn: 2, gridRow: index };
}
return undefined;
}
function isPaneAction(target: EventTarget | null): boolean {
return target instanceof Element
&& target.closest("[data-workbench-pane-action]") !== null;
@@ -293,6 +202,8 @@ export function PaneWorkbench({
onAddPane,
onLayoutChange,
onPaneOrderChange,
splitRatios = EMPTY_SPLIT_RATIOS,
onSplitRatiosChange = IGNORE_SPLIT_RATIO_CHANGE,
renderPane,
}: PaneWorkbenchProps) {
const { t } = useTranslation();
@@ -300,10 +211,20 @@ export function PaneWorkbench({
const effectiveLayout: EffectiveWorkbenchLayout = compact ? "compact" : layout;
const [headerPortalTarget, setHeaderPortalTarget] = useState<HTMLElement | null>(null);
const [composerPortalTarget, setComposerPortalTarget] = useState<HTMLElement | null>(null);
const gridRef = useRef<HTMLDivElement | null>(null);
const paneRefs = useRef(new Map<string, HTMLElement>());
const lastRectsRef = useRef(new Map<string, DOMRect>());
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
const animationsRef = useRef(new Map<string, Animation>());
const sourceSplitRatiosKey = splitRatios.join("\u0000");
const [previewSplitRatios, setPreviewSplitRatios] = useState(splitRatios);
const previewSplitRatiosRef = useRef(splitRatios);
const resizeGestureRef = useRef<{
pointerId: number;
handle: WorkbenchResizeHandle;
changed: boolean;
} | null>(null);
const [resizingRatioIndex, setResizingRatioIndex] = useState<number | null>(null);
const sourcePaneOrder = useMemo(
() => panes.map((pane) => pane.key),
[panes],
@@ -337,6 +258,12 @@ export function PaneWorkbench({
setPreviewPaneKeys(sourcePaneOrder);
}, [sourcePaneOrder, sourcePaneOrderKey]);
useEffect(() => {
if (resizeGestureRef.current) return;
previewSplitRatiosRef.current = splitRatios;
setPreviewSplitRatios(splitRatios);
}, [sourceSplitRatiosKey, splitRatios]);
const measurePanes = useCallback(() => {
const rects = new Map<string, DOMRect>();
for (const [key, element] of paneRefs.current) {
@@ -548,7 +475,119 @@ export function PaneWorkbench({
onPaneOrderChange(next);
}, [captureLayout, measurePanes, onPaneOrderChange]);
const gridStyle = paneGridStyle(effectiveLayout, panes.length);
const layoutGeometry = useMemo(() => createWorkbenchLayoutGeometry(
effectiveLayout,
panes.length,
previewSplitRatios,
), [effectiveLayout, panes.length, previewSplitRatios]);
const handleResizePointerDown = useCallback((
handle: WorkbenchResizeHandle,
event: ReactPointerEvent<HTMLButtonElement>,
) => {
if (event.button !== 0 || compact) return;
event.preventDefault();
event.stopPropagation();
previewSplitRatiosRef.current = layoutGeometry.splitRatios;
setPreviewSplitRatios(layoutGeometry.splitRatios);
resizeGestureRef.current = {
pointerId: event.pointerId,
handle,
changed: false,
};
setResizingRatioIndex(handle.ratioIndex);
}, [compact, layoutGeometry.splitRatios]);
const handleResizePointerMove = useCallback((event: globalThis.PointerEvent) => {
const gesture = resizeGestureRef.current;
const grid = gridRef.current;
if (!gesture || !grid || gesture.pointerId !== event.pointerId) return;
const rect = grid.getBoundingClientRect();
const axisExtent = gesture.handle.axis === "vertical" ? rect.width : rect.height;
if (axisExtent <= 0) return;
const normalizedPosition = gesture.handle.axis === "vertical"
? (event.clientX - rect.left) / rect.width
: (event.clientY - rect.top) / rect.height;
const ratio = resizeHandleRatio(gesture.handle, normalizedPosition, axisExtent);
const current = previewSplitRatiosRef.current;
if (Math.abs((current[gesture.handle.ratioIndex] ?? 0) - ratio) < 0.0001) return;
event.preventDefault();
const next = [...current];
next[gesture.handle.ratioIndex] = ratio;
gesture.changed = true;
previewSplitRatiosRef.current = next;
setPreviewSplitRatios(next);
}, []);
const finishResizeGesture = useCallback(() => {
const gesture = resizeGestureRef.current;
if (!gesture) return;
resizeGestureRef.current = null;
setResizingRatioIndex(null);
if (gesture.changed) onSplitRatiosChange([...previewSplitRatiosRef.current]);
}, [onSplitRatiosChange]);
useEffect(() => {
if (resizingRatioIndex === null) return;
const handlePointerMove = (event: globalThis.PointerEvent) => {
const gesture = resizeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
if ((event.buttons & 1) === 0) {
finishResizeGesture();
return;
}
handleResizePointerMove(event);
};
const handlePointerEnd = (event: globalThis.PointerEvent) => {
if (resizeGestureRef.current?.pointerId === event.pointerId) finishResizeGesture();
};
const root = document.documentElement;
const previousCursor = root.style.cursor;
const previousUserSelect = root.style.userSelect;
root.style.cursor = resizeGestureRef.current?.handle.axis === "vertical"
? "col-resize"
: "row-resize";
root.style.userSelect = "none";
window.addEventListener("pointermove", handlePointerMove, { passive: false });
window.addEventListener("pointerup", handlePointerEnd);
window.addEventListener("pointercancel", handlePointerEnd);
window.addEventListener("blur", finishResizeGesture);
return () => {
root.style.cursor = previousCursor;
root.style.userSelect = previousUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerEnd);
window.removeEventListener("pointercancel", handlePointerEnd);
window.removeEventListener("blur", finishResizeGesture);
};
}, [finishResizeGesture, handleResizePointerMove, resizingRatioIndex]);
const handleResizeKeyDown = useCallback((
handle: WorkbenchResizeHandle,
event: KeyboardEvent<HTMLButtonElement>,
) => {
const decreasing = handle.axis === "vertical" ? event.key === "ArrowLeft" : event.key === "ArrowUp";
const increasing = handle.axis === "vertical" ? event.key === "ArrowRight" : event.key === "ArrowDown";
if (!decreasing && !increasing && event.key !== "Home" && event.key !== "End") return;
const rect = gridRef.current?.getBoundingClientRect();
const axisExtent = handle.axis === "vertical" ? rect?.width : rect?.height;
const bounds = splitRatioBounds(handle, axisExtent && axisExtent > 0 ? axisExtent : 1000);
const current = layoutGeometry.splitRatios[handle.ratioIndex] ?? 0.5;
const step = event.shiftKey ? 0.1 : 0.03;
const nextRatio = event.key === "Home"
? bounds.min
: event.key === "End"
? bounds.max
: Math.min(bounds.max, Math.max(bounds.min, current + (increasing ? step : -step)));
event.preventDefault();
const next = [...layoutGeometry.splitRatios];
next[handle.ratioIndex] = nextRatio;
previewSplitRatiosRef.current = next;
setPreviewSplitRatios(next);
onSplitRatiosChange(next);
}, [layoutGeometry.splitRatios, onSplitRatiosChange]);
const gridStyle = layoutGeometry.gridStyle;
const currentLayout = LAYOUT_CONTROLS.find((control) => control.layout === layout)
?? LAYOUT_CONTROLS[0];
const headerActions = chrome ? (
@@ -629,8 +668,9 @@ export function PaneWorkbench({
/>
</header>
) : null}
<div className="min-h-0 flex-1 bg-background">
<div className="relative min-h-0 flex-1 bg-background">
<div
ref={gridRef}
data-testid="pane-grid"
data-layout={effectiveLayout}
className={cn(
@@ -658,7 +698,7 @@ export function PaneWorkbench({
onPointerDownCapture={(event) => handlePanePointerDown(pane.key, event)}
onFocusCapture={(event) => handlePaneFocus(pane.key, event)}
className="workbench-pane relative flex min-h-0 min-w-0 overflow-hidden bg-background"
style={paneCellStyle(effectiveLayout, panes.length, index)}
style={layoutGeometry.paneStyles[index]}
>
{renderPane(pane, {
active,
@@ -713,6 +753,53 @@ export function PaneWorkbench({
);
})}
</div>
{chrome && panes.length > 1 && !compact ? layoutGeometry.resizeHandles.map(
(handle, index) => {
const ratio = layoutGeometry.splitRatios[handle.ratioIndex] ?? 0.5;
const vertical = handle.axis === "vertical";
const bounds = splitRatioBounds(handle, 1000);
return (
<button
key={`${handle.axis}-${handle.ratioIndex}`}
type="button"
role="separator"
aria-orientation={handle.axis}
aria-label={t("workbench.resizePaneBoundary", {
defaultValue: "Resize pane boundary {{index}}",
index: index + 1,
})}
aria-valuemin={Math.round(bounds.min * 100)}
aria-valuemax={Math.round(bounds.max * 100)}
aria-valuenow={Math.round(ratio * 100)}
aria-keyshortcuts={vertical
? "ArrowLeft ArrowRight Home End"
: "ArrowUp ArrowDown Home End"}
data-workbench-pane-action
data-workbench-resize-handle={handle.ratioIndex}
onPointerDown={(event) => handleResizePointerDown(handle, event)}
onKeyDown={(event) => handleResizeKeyDown(handle, event)}
className={cn(
"group host-no-drag absolute z-20 touch-none border-0 bg-transparent p-0 focus-visible:outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
vertical ? "w-3 cursor-col-resize" : "h-3 cursor-row-resize",
)}
style={resizeHandleStyle(handle)}
>
<span
aria-hidden
className={cn(
"pointer-events-none absolute bg-transparent transition-colors duration-100 motion-reduce:transition-none",
"group-hover:bg-foreground/20 group-focus-visible:bg-foreground/25",
resizingRatioIndex === handle.ratioIndex && "bg-foreground/30",
vertical
? "inset-y-0 left-1/2 w-px -translate-x-1/2"
: "inset-x-0 top-1/2 h-px -translate-y-1/2",
)}
/>
</button>
);
},
) : null}
</div>
{chrome ? (
@@ -0,0 +1,434 @@
import type { CSSProperties } from "react";
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
export type EffectiveWorkbenchLayout = WorkbenchLayout | "compact";
interface PaneCell {
xStart: number;
xEnd: number;
yStart: number;
yEnd: number;
}
interface Track {
start: number;
end: number;
}
export interface WorkbenchResizeHandle {
axis: "horizontal" | "vertical";
ratioIndex: number;
position: number;
crossStart: number;
crossEnd: number;
localStart: number;
localEnd: number;
beforeUnitCount: number;
afterUnitCount: number;
}
export interface WorkbenchLayoutGeometry {
gridStyle: CSSProperties;
paneStyles: Array<CSSProperties | undefined>;
resizeHandles: WorkbenchResizeHandle[];
splitRatios: number[];
}
const MIN_RATIO = 0.05;
const MAX_RATIO = 0.95;
const MIN_PANE_EXTENT_PX = 160;
const MAIN_PANE_RATIO = 1.65 / 2.65;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function ratioAt(
ratios: readonly number[],
index: number,
fallback: number,
): number {
const value = ratios[index];
return Number.isFinite(value) ? clamp(value, MIN_RATIO, MAX_RATIO) : fallback;
}
function tracksTemplate(tracks: readonly Track[]): string {
return tracks
.map((track) => {
const weight = Number(((track.end - track.start) * 1000).toFixed(6));
return `minmax(0, ${weight}fr)`;
})
.join(" ");
}
function sequentialTracks(
count: number,
ratios: readonly number[],
ratioOffset: number,
): { tracks: Track[]; handles: WorkbenchResizeHandle[]; resolvedRatios: number[] } {
const tracks: Track[] = [];
const handles: WorkbenchResizeHandle[] = [];
const resolvedRatios: number[] = [];
let start = 0;
for (let index = 0; index < count - 1; index += 1) {
const remainingPaneCount = count - index;
const ratio = ratioAt(ratios, ratioOffset + index, 1 / remainingPaneCount);
const end = start + (1 - start) * ratio;
tracks.push({ start, end });
handles.push({
axis: "vertical",
ratioIndex: ratioOffset + index,
position: end,
crossStart: 0,
crossEnd: 1,
localStart: start,
localEnd: 1,
beforeUnitCount: 1,
afterUnitCount: remainingPaneCount - 1,
});
resolvedRatios.push(ratio);
start = end;
}
tracks.push({ start, end: 1 });
return { tracks, handles, resolvedRatios };
}
function cellStyle(
columnStart: number,
columnEnd: number,
rowStart: number,
rowEnd: number,
): CSSProperties {
return {
gridColumn: `${columnStart} / ${columnEnd}`,
gridRow: `${rowStart} / ${rowEnd}`,
};
}
function singlePaneGeometry(): WorkbenchLayoutGeometry {
return {
gridStyle: {
gridTemplateColumns: "minmax(0, 1fr)",
gridTemplateRows: "minmax(0, 1fr)",
},
paneStyles: [undefined],
resizeHandles: [],
splitRatios: [],
};
}
function columnsGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const split = sequentialTracks(paneCount, ratios, 0);
return {
gridStyle: {
gridTemplateColumns: tracksTemplate(split.tracks),
gridTemplateRows: "minmax(0, 1fr)",
},
paneStyles: split.tracks.map((_, index) => cellStyle(index + 1, index + 2, 1, 2)),
resizeHandles: split.handles,
splitRatios: split.resolvedRatios,
};
}
function rowsGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const split = sequentialTracks(paneCount, ratios, 0);
return {
gridStyle: {
gridTemplateColumns: "minmax(0, 1fr)",
gridTemplateRows: tracksTemplate(split.tracks),
},
paneStyles: split.tracks.map((_, index) => cellStyle(1, 2, index + 1, index + 2)),
resizeHandles: split.handles.map((handle) => ({
...handle,
axis: "horizontal",
})),
splitRatios: split.resolvedRatios,
};
}
function gridGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const columnCount = Math.ceil(Math.sqrt(paneCount));
const rowCount = Math.ceil(paneCount / columnCount);
const columns = sequentialTracks(columnCount, ratios, 0);
const rows = sequentialTracks(rowCount, ratios, columnCount - 1);
const paneCells = Array.from({ length: paneCount }, (_, index) => ({
column: index % columnCount,
row: Math.floor(index / columnCount),
}));
const verticalHandles = columns.handles.map((handle, boundaryIndex) => ({
...handle,
beforeUnitCount: boundaryIndex + 1,
afterUnitCount: columnCount - boundaryIndex - 1,
})).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0);
const horizontalHandles = rows.handles.map((handle, boundaryIndex) => ({
...handle,
axis: "horizontal" as const,
beforeUnitCount: boundaryIndex + 1,
afterUnitCount: rowCount - boundaryIndex - 1,
})).filter((handle) => handle.beforeUnitCount > 0 && handle.afterUnitCount > 0);
return {
gridStyle: {
gridTemplateColumns: tracksTemplate(columns.tracks),
gridTemplateRows: tracksTemplate(rows.tracks),
},
paneStyles: paneCells.map((cell) => cellStyle(
cell.column + 1,
cell.column + 2,
cell.row + 1,
cell.row + 2,
)),
resizeHandles: [...verticalHandles, ...horizontalHandles],
splitRatios: [...columns.resolvedRatios, ...rows.resolvedRatios],
};
}
function mainStackGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const columnRatio = ratioAt(ratios, 0, MAIN_PANE_RATIO);
const stack = sequentialTracks(paneCount - 1, ratios, 1);
const verticalHandle: WorkbenchResizeHandle = {
axis: "vertical",
ratioIndex: 0,
position: columnRatio,
crossStart: 0,
crossEnd: 1,
localStart: 0,
localEnd: 1,
beforeUnitCount: 1,
afterUnitCount: 1,
};
const stackHandles = stack.handles.map((handle) => ({
...handle,
axis: "horizontal" as const,
crossStart: columnRatio,
}));
return {
gridStyle: {
gridTemplateColumns: tracksTemplate([
{ start: 0, end: columnRatio },
{ start: columnRatio, end: 1 },
]),
gridTemplateRows: tracksTemplate(stack.tracks),
},
paneStyles: [
cellStyle(1, 2, 1, paneCount),
...stack.tracks.map((_, index) => cellStyle(2, 3, index + 1, index + 2)),
],
resizeHandles: [verticalHandle, ...stackHandles],
splitRatios: [columnRatio, ...stack.resolvedRatios],
};
}
function uniqueBoundaries(values: readonly number[]): number[] {
return Array.from(new Set(values.map((value) => value.toFixed(8))))
.map(Number)
.sort((left, right) => left - right);
}
function boundaryIndex(boundaries: readonly number[], value: number): number {
return boundaries.findIndex((candidate) => Math.abs(candidate - value) < 0.0000001);
}
function axisUnitCount(cells: readonly PaneCell[], axis: "horizontal" | "vertical"): number {
const boundaries = uniqueBoundaries(cells.flatMap((cell) => axis === "vertical"
? [cell.xStart, cell.xEnd]
: [cell.yStart, cell.yEnd]));
return Math.max(1, boundaries.length - 1);
}
function bspGeometry(
paneCount: number,
ratios: readonly number[],
): WorkbenchLayoutGeometry {
const cells: PaneCell[] = [{ xStart: 0, xEnd: 1, yStart: 0, yEnd: 1 }];
const handles: WorkbenchResizeHandle[] = [];
const resolvedRatios: number[] = [];
for (let paneIndex = 1; paneIndex < paneCount; paneIndex += 1) {
const leaf = cells.pop();
if (!leaf) break;
const ratioIndex = paneIndex - 1;
const ratio = ratioAt(ratios, ratioIndex, 0.5);
resolvedRatios.push(ratio);
if (paneIndex % 2 === 1) {
const position = leaf.xStart + (leaf.xEnd - leaf.xStart) * ratio;
cells.push(
{ ...leaf, xEnd: position },
{ ...leaf, xStart: position },
);
handles.push({
axis: "vertical",
ratioIndex,
position,
crossStart: leaf.yStart,
crossEnd: leaf.yEnd,
localStart: leaf.xStart,
localEnd: leaf.xEnd,
beforeUnitCount: 0,
afterUnitCount: 0,
});
} else {
const position = leaf.yStart + (leaf.yEnd - leaf.yStart) * ratio;
cells.push(
{ ...leaf, yEnd: position },
{ ...leaf, yStart: position },
);
handles.push({
axis: "horizontal",
ratioIndex,
position,
crossStart: leaf.xStart,
crossEnd: leaf.xEnd,
localStart: leaf.yStart,
localEnd: leaf.yEnd,
beforeUnitCount: 0,
afterUnitCount: 0,
});
}
}
for (const handle of handles) {
if (handle.axis === "vertical") {
const beforeCells = cells.filter((cell) => (
cell.xStart >= handle.localStart
&& cell.xEnd <= handle.position + Number.EPSILON
&& cell.yStart >= handle.crossStart
&& cell.yEnd <= handle.crossEnd
));
const afterCells = cells.filter((cell) => (
cell.xStart >= handle.position - Number.EPSILON
&& cell.xEnd <= handle.localEnd
&& cell.yStart >= handle.crossStart
&& cell.yEnd <= handle.crossEnd
));
handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis);
handle.afterUnitCount = axisUnitCount(afterCells, handle.axis);
} else {
const beforeCells = cells.filter((cell) => (
cell.yStart >= handle.localStart
&& cell.yEnd <= handle.position + Number.EPSILON
&& cell.xStart >= handle.crossStart
&& cell.xEnd <= handle.crossEnd
));
const afterCells = cells.filter((cell) => (
cell.yStart >= handle.position - Number.EPSILON
&& cell.yEnd <= handle.localEnd
&& cell.xStart >= handle.crossStart
&& cell.xEnd <= handle.crossEnd
));
handle.beforeUnitCount = axisUnitCount(beforeCells, handle.axis);
handle.afterUnitCount = axisUnitCount(afterCells, handle.axis);
}
}
const columnBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.xStart, cell.xEnd]));
const rowBoundaries = uniqueBoundaries(cells.flatMap((cell) => [cell.yStart, cell.yEnd]));
const columnTracks = columnBoundaries.slice(0, -1).map((start, index) => ({
start,
end: columnBoundaries[index + 1],
}));
const rowTracks = rowBoundaries.slice(0, -1).map((start, index) => ({
start,
end: rowBoundaries[index + 1],
}));
return {
gridStyle: {
gridTemplateColumns: tracksTemplate(columnTracks),
gridTemplateRows: tracksTemplate(rowTracks),
},
paneStyles: cells.map((cell) => cellStyle(
boundaryIndex(columnBoundaries, cell.xStart) + 1,
boundaryIndex(columnBoundaries, cell.xEnd) + 1,
boundaryIndex(rowBoundaries, cell.yStart) + 1,
boundaryIndex(rowBoundaries, cell.yEnd) + 1,
)),
resizeHandles: handles,
splitRatios: resolvedRatios,
};
}
export function createWorkbenchLayoutGeometry(
layout: EffectiveWorkbenchLayout,
paneCount: number,
splitRatios: readonly number[],
): WorkbenchLayoutGeometry {
const count = Math.max(1, paneCount);
if (layout === "compact" || count === 1) return singlePaneGeometry();
switch (layout) {
case "columns":
return columnsGeometry(count, splitRatios);
case "rows":
return rowsGeometry(count, splitRatios);
case "grid":
return gridGeometry(count, splitRatios);
case "main-stack":
return mainStackGeometry(count, splitRatios);
case "bsp":
return bspGeometry(count, splitRatios);
}
}
export function splitRatioBounds(
handle: WorkbenchResizeHandle,
axisExtentPx: number,
): { min: number; max: number } {
const localExtentPx = Math.max(
1,
axisExtentPx * Math.max(0.01, handle.localEnd - handle.localStart),
);
const paneCount = Math.max(2, handle.beforeUnitCount + handle.afterUnitCount);
const paneExtentPx = Math.min(
MIN_PANE_EXTENT_PX,
localExtentPx * 0.8 / paneCount,
);
const min = Math.max(MIN_RATIO, paneExtentPx * handle.beforeUnitCount / localExtentPx);
const max = Math.min(
MAX_RATIO,
1 - paneExtentPx * handle.afterUnitCount / localExtentPx,
);
return min <= max ? { min, max } : { min: 0.4, max: 0.6 };
}
export function resizeHandleRatio(
handle: WorkbenchResizeHandle,
normalizedPosition: number,
axisExtentPx: number,
): number {
const localRatio = (normalizedPosition - handle.localStart)
/ Math.max(0.01, handle.localEnd - handle.localStart);
const bounds = splitRatioBounds(handle, axisExtentPx);
return Number(clamp(localRatio, bounds.min, bounds.max).toFixed(4));
}
export function resizeHandleStyle(handle: WorkbenchResizeHandle): CSSProperties {
if (handle.axis === "vertical") {
return {
left: `${handle.position * 100}%`,
top: `${handle.crossStart * 100}%`,
height: `${(handle.crossEnd - handle.crossStart) * 100}%`,
transform: "translateX(-50%)",
};
}
return {
top: `${handle.position * 100}%`,
left: `${handle.crossStart * 100}%`,
width: `${(handle.crossEnd - handle.crossStart) * 100}%`,
transform: "translateY(-50%)",
};
}
@@ -53,6 +53,14 @@ function normalizeTitle(value: unknown): string | null {
return title || null;
}
function normalizeSplitRatios(value: unknown): number[] {
if (!Array.isArray(value)) return [];
return value
.filter((ratio): ratio is number => typeof ratio === "number" && Number.isFinite(ratio))
.slice(0, MAX_WORKBENCH_PANES - 1)
.map((ratio) => Number(Math.min(0.95, Math.max(0.05, ratio)).toFixed(4)));
}
function normalizeTab(value: unknown): WorkbenchTabState {
const candidate = value && typeof value === "object"
? value as Partial<WorkbenchTabState>
@@ -75,6 +83,7 @@ function normalizeTab(value: unknown): WorkbenchTabState {
? candidate.activePaneKey
: paneKeys[0] ?? "",
layout: isLayout(candidate.layout) ? candidate.layout : "columns",
splitRatios: normalizeSplitRatios(candidate.splitRatios),
};
}
@@ -104,6 +113,7 @@ function defaultWorkbenchTab(
layoutPaneKeys: [paneKey],
activePaneKey: paneKey,
layout: "columns",
splitRatios: [],
};
}
@@ -222,6 +232,7 @@ export function detachWorkbenchPane(
explicit: false,
title: null,
layout: "columns",
splitRatios: [],
}))
: state;
}
@@ -241,6 +252,7 @@ export function detachWorkbenchPane(
activePaneKey: tab.activePaneKey === paneKey
? paneKeys[Math.min(index, paneKeys.length - 1)]
: tab.activePaneKey,
splitRatios: [],
},
[nextTabKey]: defaultWorkbenchTab(paneKey),
},
@@ -260,6 +272,7 @@ export function dissolveWorkbenchTab(
explicit: false,
title: null,
layout: "columns",
splitRatios: [],
}))
: state;
}
@@ -309,6 +322,7 @@ export function attachWorkbenchPane(
activePaneKey: sourceTab.activePaneKey === paneKey
? sourcePaneKeys[Math.min(index, sourcePaneKeys.length - 1)]
: sourceTab.activePaneKey,
splitRatios: [],
};
}
}
@@ -326,6 +340,7 @@ export function attachWorkbenchPane(
paneKeys,
layoutPaneKeys,
activePaneKey: paneKey,
splitRatios: [],
};
return { version: 1, tabs };
}
@@ -348,10 +363,24 @@ export function setWorkbenchLayout(
layout: WorkbenchLayout,
): WorkbenchState {
return updateTab(state, tabKey, (tab) => (
tab.layout === layout ? tab : { ...tab, layout }
tab.layout === layout ? tab : { ...tab, layout, splitRatios: [] }
));
}
export function setWorkbenchSplitRatios(
state: WorkbenchState,
tabKey: string,
splitRatios: readonly number[],
): WorkbenchState {
return updateTab(state, tabKey, (tab) => {
const normalized = normalizeSplitRatios(splitRatios);
return normalized.length === tab.splitRatios.length
&& normalized.every((ratio, index) => ratio === tab.splitRatios[index])
? tab
: { ...tab, splitRatios: normalized };
});
}
export function setWorkbenchPaneLayoutOrder(
state: WorkbenchState,
tabKey: string,
@@ -392,6 +421,10 @@ export function reconcileWorkbench(
activePaneKey: paneKeys.includes(tab.activePaneKey)
? tab.activePaneKey
: paneKeys[0],
splitRatios: paneKeys.length === tab.paneKeys.length
&& paneKeys.every((key, index) => key === tab.paneKeys[index])
? tab.splitRatios
: [],
};
}
+1
View File
@@ -1424,6 +1424,7 @@
"addPane": "Add pane",
"movePane": "Move {{title}} pane",
"movePaneHint": "Drag to move · Arrow keys also work",
"resizePaneBoundary": "Resize pane boundary {{index}}",
"promotePane": "Make {{title}} the primary pane",
"paneActions": "{{title}} pane actions",
"detachPane": "Remove",
+1
View File
@@ -1411,6 +1411,7 @@
"addPane": "Añadir panel",
"movePane": "Mover panel {{title}}",
"movePaneHint": "Arrastra para mover · También puedes usar las flechas",
"resizePaneBoundary": "Redimensionar límite de panel {{index}}",
"promotePane": "Convertir {{title}} en el panel principal",
"paneActions": "Acciones del panel {{title}}",
"detachPane": "Quitar",
+1
View File
@@ -1410,6 +1410,7 @@
"addPane": "Ajouter un volet",
"movePane": "Déplacer le volet {{title}}",
"movePaneHint": "Faites glisser pour déplacer · Les flèches fonctionnent aussi",
"resizePaneBoundary": "Redimensionner la séparation {{index}}",
"promotePane": "Définir {{title}} comme volet principal",
"paneActions": "Actions du volet {{title}}",
"detachPane": "Retirer",
+1
View File
@@ -1410,6 +1410,7 @@
"addPane": "Tambah panel",
"movePane": "Pindahkan panel {{title}}",
"movePaneHint": "Seret untuk memindahkan · Tombol panah juga dapat digunakan",
"resizePaneBoundary": "Ubah ukuran batas panel {{index}}",
"promotePane": "Jadikan {{title}} panel utama",
"paneActions": "Tindakan panel {{title}}",
"detachPane": "Keluarkan",
+1
View File
@@ -1410,6 +1410,7 @@
"addPane": "ペインを追加",
"movePane": "{{title}} ペインを移動",
"movePaneHint": "ドラッグで移動 · 矢印キーでも移動できます",
"resizePaneBoundary": "ペイン境界 {{index}} のサイズを変更",
"promotePane": "{{title}} をメインペインにする",
"paneActions": "{{title}} ペインの操作",
"detachPane": "外す",
+1
View File
@@ -1410,6 +1410,7 @@
"addPane": "창 추가",
"movePane": "{{title}} 창 이동",
"movePaneHint": "드래그하여 이동 · 방향키로도 이동 가능",
"resizePaneBoundary": "창 경계 {{index}} 크기 조절",
"promotePane": "{{title}}을(를) 기본 창으로 설정",
"paneActions": "{{title}} 창 작업",
"detachPane": "제거",
+1
View File
@@ -1424,6 +1424,7 @@
"addPane": "Adicionar painel",
"movePane": "Mover painel {{title}}",
"movePaneHint": "Arraste para mover · As setas também funcionam",
"resizePaneBoundary": "Redimensionar limite do painel {{index}}",
"promotePane": "Tornar {{title}} o painel principal",
"paneActions": "Ações do painel {{title}}",
"detachPane": "Remover",
+1
View File
@@ -1410,6 +1410,7 @@
"addPane": "Thêm khung",
"movePane": "Di chuyển khung {{title}}",
"movePaneHint": "Kéo để di chuyển · Cũng có thể dùng các phím mũi tên",
"resizePaneBoundary": "Đổi kích thước ranh giới khung {{index}}",
"promotePane": "Đặt {{title}} làm khung chính",
"paneActions": "Thao tác cho khung {{title}}",
"detachPane": "Gỡ",
+1
View File
@@ -1424,6 +1424,7 @@
"addPane": "添加窗格",
"movePane": "移动 {{title}} 窗格",
"movePaneHint": "拖动换位 · 也可以使用方向键",
"resizePaneBoundary": "调整窗格边界 {{index}}",
"promotePane": "将 {{title}} 设为主窗格",
"paneActions": "{{title}} 窗格操作",
"detachPane": "移出",
+1
View File
@@ -1410,6 +1410,7 @@
"addPane": "新增窗格",
"movePane": "移動 {{title}} 窗格",
"movePaneHint": "拖曳換位 · 也可以使用方向鍵",
"resizePaneBoundary": "調整窗格邊界 {{index}}",
"promotePane": "將 {{title}} 設為主窗格",
"paneActions": "{{title}} 窗格操作",
"detachPane": "移出",
+1
View File
@@ -377,6 +377,7 @@ export interface WorkbenchTabState {
layoutPaneKeys: string[];
activePaneKey: string;
layout: WorkbenchLayout;
splitRatios: number[];
}
export interface WorkbenchState {
+53 -4
View File
@@ -32,9 +32,11 @@ function rect(left: number, top: number, width: number, height: number): DOMRect
function WorkbenchHarness({
initialLayout = "columns",
onPaneOrderChange = () => {},
onSplitRatiosChange = () => {},
}: {
initialLayout?: "columns" | "rows";
onPaneOrderChange?: (paneKeys: string[]) => void;
onSplitRatiosChange?: (splitRatios: number[]) => void;
} = {}) {
const [state, setState] = useState(() => {
const initial = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "alpha");
@@ -55,6 +57,7 @@ function WorkbenchHarness({
panes={tab.layoutPaneKeys.map((key) => ({ key, title: titles[key] }))}
activePaneKey={tab.activePaneKey}
layout={tab.layout}
splitRatios={tab.splitRatios}
showLayoutControl
onActivatePane={(key) => setState((current) => (
focusWorkbenchPane(current, tabKey, key)
@@ -69,6 +72,7 @@ function WorkbenchHarness({
setWorkbenchPaneLayoutOrder(current, tabKey, paneKeys)
));
}}
onSplitRatiosChange={onSplitRatiosChange}
renderPane={(pane, context) => (
<>
<button type="button">Focus {pane.title}</button>
@@ -98,11 +102,13 @@ function BspWorkbenchHarness() {
panes={panes}
activePaneKey="delta"
layout="bsp"
splitRatios={[]}
showLayoutControl
onActivatePane={vi.fn()}
onAddPane={vi.fn()}
onLayoutChange={vi.fn()}
onPaneOrderChange={vi.fn()}
onSplitRatiosChange={vi.fn()}
renderPane={(pane) => <span>{pane.title}</span>}
/>
);
@@ -129,6 +135,7 @@ describe("PaneWorkbench", () => {
})));
HTMLElement.prototype.animate = animate;
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
if (this.dataset.testid === "pane-grid") return rect(0, 0, 1000, 1000);
if (!this.classList.contains("workbench-pane")) {
return originalGetBoundingClientRect.call(this);
}
@@ -250,6 +257,48 @@ describe("PaneWorkbench", () => {
.toEqual(["Beta", "Alpha"]);
});
it("previews edge resizing locally and commits one ratio when dragging ends", () => {
const onSplitRatiosChange = vi.fn();
render(<WorkbenchHarness onSplitRatiosChange={onSplitRatiosChange} />);
const grid = screen.getByTestId("pane-grid");
const separator = screen.getByRole("separator", {
name: "Resize pane boundary 1",
});
fireEvent.pointerDown(separator, {
button: 0,
pointerId: 7,
clientX: 500,
clientY: 400,
});
fireEvent.pointerMove(window, {
buttons: 1,
pointerId: 7,
clientX: 700,
clientY: 400,
});
expect(grid.style.gridTemplateColumns)
.toBe("minmax(0, 700fr) minmax(0, 300fr)");
expect(onSplitRatiosChange).not.toHaveBeenCalled();
fireEvent.pointerUp(window, { pointerId: 7, clientX: 700, clientY: 400 });
expect(onSplitRatiosChange).toHaveBeenCalledOnce();
expect(onSplitRatiosChange).toHaveBeenCalledWith([0.7]);
});
it("resizes a pane boundary with the matching arrow keys", () => {
const onSplitRatiosChange = vi.fn();
render(<WorkbenchHarness onSplitRatiosChange={onSplitRatiosChange} />);
const separator = screen.getByRole("separator", {
name: "Resize pane boundary 1",
});
fireEvent.keyDown(separator, { key: "ArrowLeft" });
expect(onSplitRatiosChange).toHaveBeenCalledWith([0.47]);
});
it("keeps one shared layout control and animates geometry changes", async () => {
render(<WorkbenchHarness />);
@@ -279,9 +328,9 @@ describe("PaneWorkbench", () => {
const beta = screen.getByTestId("workbench-pane-beta");
const gamma = screen.getByTestId("workbench-pane-gamma");
const delta = screen.getByTestId("workbench-pane-delta");
expect([alpha.style.gridColumn, alpha.style.gridRow]).toEqual(["1 / 3", "1 / 5"]);
expect([beta.style.gridColumn, beta.style.gridRow]).toEqual(["3 / 5", "1 / 3"]);
expect([gamma.style.gridColumn, gamma.style.gridRow]).toEqual(["3 / 4", "3 / 5"]);
expect([delta.style.gridColumn, delta.style.gridRow]).toEqual(["4 / 5", "3 / 5"]);
expect([alpha.style.gridColumn, alpha.style.gridRow]).toEqual(["1 / 2", "1 / 3"]);
expect([beta.style.gridColumn, beta.style.gridRow]).toEqual(["2 / 4", "1 / 2"]);
expect([gamma.style.gridColumn, gamma.style.gridRow]).toEqual(["2 / 3", "2 / 3"]);
expect([delta.style.gridColumn, delta.style.gridRow]).toEqual(["3 / 4", "2 / 3"]);
});
});
+22
View File
@@ -16,6 +16,7 @@ import {
renameWorkbenchTab,
setWorkbenchLayout,
setWorkbenchPaneLayoutOrder,
setWorkbenchSplitRatios,
workbenchTab,
workbenchTabForPane,
type WorkbenchState,
@@ -41,6 +42,7 @@ describe("workbench model", () => {
layoutPaneKeys: ["pane-a"],
activePaneKey: "pane-a",
layout: "columns",
splitRatios: [],
});
});
@@ -60,6 +62,7 @@ describe("workbench model", () => {
layoutPaneKeys: ["pane-a", "pane-c"],
activePaneKey: "pane-c",
layout: "main-stack",
splitRatios: [],
});
expect(workbenchTab(state, betaTabKey)).toEqual({
explicit: false,
@@ -68,6 +71,7 @@ describe("workbench model", () => {
layoutPaneKeys: ["pane-b"],
activePaneKey: "pane-b",
layout: "columns",
splitRatios: [],
});
});
@@ -117,6 +121,7 @@ describe("workbench model", () => {
layoutPaneKeys: ["pane-a"],
activePaneKey: "pane-a",
layout: "columns",
splitRatios: [],
});
expect(workbenchTabForPane(state, "pane-a").tab.paneKeys).toEqual(["pane-a"]);
expect(workbenchTabForPane(state, "pane-b").tab.paneKeys).toEqual(["pane-b"]);
@@ -143,6 +148,7 @@ describe("workbench model", () => {
layoutPaneKeys: ["pane-a"],
activePaneKey: "pane-a",
layout: "columns",
splitRatios: [],
});
});
@@ -190,6 +196,21 @@ describe("workbench model", () => {
expect(ordered.paneKeys).toEqual(["pane-c", "pane-a", "pane-b"]);
});
it("stores resize ratios in the tab and resets them when its geometry changes", () => {
let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a");
const tabKey = workbenchTabForPane(state, "pane-a").tabKey;
state = addWorkbenchPane(state, tabKey, "pane-b");
state = setWorkbenchSplitRatios(state, tabKey, [0.35]);
expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([0.35]);
state = setWorkbenchLayout(state, tabKey, "rows");
expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([]);
state = setWorkbenchSplitRatios(state, tabKey, [0.4]);
state = detachWorkbenchPane(state, tabKey, "pane-b");
expect(workbenchTab(state, tabKey)?.splitRatios).toEqual([]);
});
it("keeps each tab contiguous and ranks it by its latest updated pane", () => {
let state = ensureWorkbenchPaneTab(EMPTY_WORKBENCH_STATE, "pane-a");
const alphaTabKey = workbenchTabForPane(state, "pane-a").tabKey;
@@ -270,6 +291,7 @@ describe("workbench model", () => {
layoutPaneKeys: ["pane-a", "pane-b"],
activePaneKey: "pane-a",
layout: "columns",
splitRatios: [],
});
expect(workbenchTab(reconciled, "duplicate")).toBeNull();
expect(workbenchTabForPane(reconciled, "pane-c").tab.paneKeys).toEqual(["pane-c"]);