fix: persist right-panel visibility across layouts (#3657) (#3661)

This commit is contained in:
Carlos Florêncio
2026-09-15 09:36:57 +01:00
committed by GitHub
parent eeeb6f5586
commit df2704f5e2
43 changed files with 4456 additions and 387 deletions
@@ -25,8 +25,8 @@ describe("BranchPickerList", () => {
const options = screen.getAllByRole("option");
expect(options.map((option) => option.textContent?.trim())).toEqual([
"main",
"feature/local",
"develop",
"feature/local",
]);
expect(options[0].getAttribute("aria-selected")).toBe("true");
expect(options[0].className).toContain("bg-card");
@@ -74,4 +74,23 @@ describe("BranchPickerList", () => {
expect(screen.queryByTestId("recovery-branch-option-feature/local-only")).toBeNull();
expect(screen.getByTestId("recovery-branch-option-main")).toBeTruthy();
});
it("puts the default branch first when the current branch is unavailable", () => {
render(
<BranchPickerList
branches={[
{ name: "feature/local-rebase", type: "remote", remote: "origin" },
{ name: "main", type: "remote", remote: "origin" },
]}
isLoadingBranches={false}
currentBase="missing-base"
onSelect={() => {}}
remoteOnly
/>,
);
expect(screen.getAllByRole("option")[0]?.getAttribute("data-testid")).toBe(
"base-branch-picker-option-main",
);
});
});
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
import { prioritizeSelectedOption } from "@/lib/utils/selector-options";
import type { Branch } from "@/lib/types/http";
import { useTranslation } from "react-i18next";
import { sortBranches } from "../branch-picker-options";
type BranchPickerListProps = {
branches: Branch[];
@@ -44,7 +45,7 @@ export function BranchPickerList({
}, [filter, uniqueByName]);
const orderedBranches = useMemo(() => {
if (filter.trim()) return filtered;
return prioritizeSelectedOption(filtered, currentBase, (branch) => branch.name);
return prioritizeSelectedOption(sortBranches(filtered), currentBase, (branch) => branch.name);
}, [currentBase, filter, filtered]);
return (
@@ -13,6 +13,7 @@ import {
resolveRestoredLayoutProfile,
useDockviewStore,
performLayoutSwitch,
hasRightColumn,
} from "@/lib/state/dockview-store";
import { restoreEnvLayout } from "./dockview-layout-restore";
import {
@@ -65,7 +66,9 @@ import {
resolveEffectiveDefaultLayout,
type LayoutProfileIdentity,
} from "@/lib/layout/layout-profiles";
import type { LayoutState } from "@/lib/state/layout-manager";
import { fromDockviewApi, type LayoutState } from "@/lib/state/layout-manager";
import { getEnvLayout } from "@/lib/local-storage";
import { getRightPaneToggleState, readHiddenRightPane } from "@/lib/state/dockview-right-pane";
import { registerDockviewRoot, unregisterDockviewRoot } from "@/lib/state/dockview-measure";
// ---------------------------------------------------------------------------
@@ -348,14 +351,23 @@ function setupReadyDockview({ api, appStore, layout, refs }: ReadyDockviewSetup)
const restored =
!layout.initialLayout &&
restoreEnvLayout(api, currentEnvId, appStore, DESKTOP_VALID_COMPONENTS);
const hiddenRightPane =
restored && currentEnvId ? readHiddenRightPane(getEnvLayout(currentEnvId)) : null;
useDockviewStore.setState({ hiddenRightPane });
if (!restored) {
layout.buildDefaultLayout(
api,
layout.initialLayout ?? (layout.compact ? "compact" : undefined),
);
} else {
const restoredLayout = fromDockviewApi(api);
const preMaximizeLayout = useDockviewStore.getState().preMaximizeLayout;
const paneState = getRightPaneToggleState(preMaximizeLayout ?? restoredLayout, hiddenRightPane);
useDockviewStore.setState({
activeLayoutProfile: resolveRestoredLayoutProfile(api, currentEnvId),
rightPanelsVisible: hasRightColumn(preMaximizeLayout ?? restoredLayout),
rightPaneVisible: paneState.visible,
rightPaneAvailable: paneState.available,
});
}
@@ -2,14 +2,8 @@
import { useCallback, useState } from "react";
import { type IDockviewHeaderActionsProps } from "dockview-react";
import {
IconPlus,
IconTerminal2,
IconPlayerPlay,
IconLayoutSidebarRightCollapse,
} from "@tabler/icons-react";
import { IconPlus, IconTerminal2, IconPlayerPlay } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
@@ -39,8 +33,6 @@ import { useOptionalPortForwardingVisibility } from "./port-forwarding-visibilit
const HEADER_ACTION_BUTTON_CLASS =
"h-6 w-6 p-0 cursor-pointer text-muted-foreground hover:bg-muted/70 hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring";
const RAW_HEADER_ACTION_BUTTON_CLASS =
"inline-flex h-6 w-6 items-center justify-center rounded-[5px] text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer";
const HEADER_ICON_CLASS = "h-3.5 w-3.5";
function useLeftHeaderState(
@@ -261,20 +253,17 @@ export function RightHeaderActions(props: IDockviewHeaderActionsProps) {
const { group } = props;
const centerGroupId = useDockviewStore((s) => s.centerGroupId);
const sidebarGroupId = useDockviewStore((s) => s.sidebarGroupId);
const rightTopGroupId = useDockviewStore((s) => s.rightTopGroupId);
const rightBottomGroupId = useDockviewStore((s) => s.rightBottomGroupId);
const isSidebarGroup = group.id === sidebarGroupId;
if (isSidebarGroup) return <SidebarRightActions />;
const isCenterGroup = group.id === centerGroupId;
const isRightTopGroup = group.id === rightTopGroupId;
const isTerminalGroup = group.id === rightBottomGroupId;
return (
<div className="flex items-center gap-0.5 pr-0.5">
{isCenterGroup && <CenterRightActions />}
{isRightTopGroup && <RightTopGroupActions />}
{isTerminalGroup && <TerminalGroupRightActions />}
<GroupSplitCloseActions {...props} />
</div>
@@ -353,26 +342,6 @@ function SidebarRightActions() {
);
}
function RightTopGroupActions() {
const { t } = useTranslation();
const toggleRightPanels = useDockviewStore((s) => s.toggleRightPanels);
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={RAW_HEADER_ACTION_BUTTON_CLASS}
onClick={toggleRightPanels}
aria-label={t("task:hideRightPanels")}
>
<IconLayoutSidebarRightCollapse className={HEADER_ICON_CLASS} />
</button>
</TooltipTrigger>
<TooltipContent>{t("task:hideRightPanels")}</TooltipContent>
</Tooltip>
);
}
function CenterRightActions() {
const hasDevScript = Boolean(useActiveSessionDevScript());
@@ -1,6 +1,6 @@
import type { DockviewReadyEvent, SerializedDockview } from "dockview-react";
import type { StoreApi } from "zustand";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { hasRightColumn, useDockviewStore } from "@/lib/state/dockview-store";
import { applyLayoutFixups } from "@/lib/state/dockview-layout-builders";
import { isLayoutShapeHealthy } from "@/lib/state/dockview-layout-health";
import { measureDockviewContainer } from "@/lib/state/dockview-measure";
@@ -15,6 +15,7 @@ import {
removeEnvMaximizeState,
} from "@/lib/local-storage";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
import { stripHiddenRightPaneMetadata } from "@/lib/state/dockview-right-pane";
const debug = createDebugLogger("dockview:restore");
@@ -167,6 +168,7 @@ function applySavedMaximize(
const { width, height } = measureDockviewContainer(api);
api.layout(width, height);
const ids = applyLayoutFixups(api, undefined, manualRightWidth);
const preMaximizeLayout = savedMax.preMaximizeLayout as unknown as LayoutState;
// The maximize JSON is 2-column — captureRightTarget skips it (sv.length < 3).
// Seed the right target directly so enforcePinnedTargets can snap the column
// back to the saved width when the user exits maximize mode.
@@ -175,8 +177,9 @@ function applySavedMaximize(
}
useDockviewStore.setState({
...ids,
preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LayoutState,
preMaximizeLayout,
maximizedGroupId: ids.centerGroupId,
rightPanelsVisible: hasRightColumn(preMaximizeLayout),
});
}
@@ -236,7 +239,7 @@ function tryRestoreEnvLayout(
phantomSessionIds: phantomSessionIds ? Array.from(phantomSessionIds) : [],
});
}
const sanitized = sanitizeLayout(envLayout, validComponents, {
const sanitized = sanitizeLayout(stripHiddenRightPaneMetadata(envLayout), validComponents, {
excludeSessionIds: phantomSessionIds,
});
if (!sanitized) {
@@ -19,6 +19,7 @@ import {
} from "@/lib/local-storage";
import { resolveResponsiveRightWidth } from "@/lib/state/layout-manager/right-width";
import { setSashDragging as setPinnedEnforcementSashDragging } from "@/lib/state/dockview-pinned-enforce";
import { withHiddenRightPaneMetadata } from "@/lib/state/dockview-right-pane";
import { getDockviewElement, measureDockviewGridWidth } from "@/lib/state/dockview-measure";
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
import { stopVscode } from "@/lib/api/domains/vscode-api";
@@ -364,7 +365,11 @@ export function setupGroupTracking(api: DockviewReadyEvent["api"]): () => void {
useDockviewStore.setState({ activeGroupId: group?.id ?? null });
});
useDockviewStore.setState({ activeGroupId: api.activeGroup?.id ?? null });
const d2 = api.onDidLayoutChange(() => trackPinnedWidths(api));
const d2 = api.onDidLayoutChange(() => {
trackPinnedWidths(api);
const store = useDockviewStore.getState();
if (!store.isRestoringLayout) store.refreshRightPaneState();
});
trackPinnedWidths(api);
return () => {
d1.dispose();
@@ -392,7 +397,7 @@ export function setupLayoutPersistence(
const envId = envIdRef.current;
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(json));
if (envId) {
setEnvLayout(envId, json);
setEnvLayout(envId, withHiddenRightPaneMetadata(json, live.hiddenRightPane));
setEnvLayoutProfile(envId, live.activeLayoutProfile);
}
if (isDebug()) {
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarRightCollapse } from "@tabler/icons-react";
import { IconLayoutSidebarLeftCollapse } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useLayoutStore } from "@/lib/state/layout-store";
@@ -15,7 +15,6 @@ export function DocumentControls({ activeSessionId }: DocumentControlsProps) {
const { t } = useTranslation();
const toggleColumn = useLayoutStore((state) => state.toggleColumn);
const showColumn = useLayoutStore((state) => state.showColumn);
const toggleRightPanel = useLayoutStore((state) => state.toggleRightPanel);
const layoutBySession = useLayoutStore((state) => state.columnsBySessionId);
const layoutState = useMemo(() => {
@@ -28,7 +27,6 @@ export function DocumentControls({ activeSessionId }: DocumentControlsProps) {
}
const leftHidden = !layoutState.left;
const rightHidden = !layoutState.right;
return (
<div className="inline-flex items-center rounded-md border border-border/70 bg-background">
@@ -61,35 +59,6 @@ export function DocumentControls({ activeSessionId }: DocumentControlsProps) {
<TooltipContent>{t("task:hideSidebar")}</TooltipContent>
</Tooltip>
)}
{rightHidden ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
className="cursor-pointer rounded-none"
onClick={() => toggleRightPanel(activeSessionId)}
>
<IconLayoutSidebarRightCollapse className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("task:showRightPanel")}</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
className="cursor-pointer rounded-none"
onClick={() => toggleRightPanel(activeSessionId)}
>
<IconLayoutSidebarRightCollapse className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("task:hideRightPanel")}</TooltipContent>
</Tooltip>
)}
</div>
);
}
@@ -0,0 +1,87 @@
import type { ReactNode } from "react";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useLayoutStore } from "@/lib/state/layout-store";
import { SessionTabletLayout } from "./session-tablet-layout";
vi.mock("react-resizable-panels", () => ({
Group: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Panel: ({ id, children }: { id: string; children: ReactNode }) => (
<div data-testid={`tablet-panel-${id}`}>{children}</div>
),
}));
vi.mock("./session-task-switcher-sheet", () => ({
SessionTaskSwitcherSheet: () => null,
}));
vi.mock("../task-center-panel", () => ({
TaskCenterPanel: () => <div data-testid="tablet-center-panel" />,
}));
vi.mock("../task-right-panel", () => ({
TaskRightPanel: () => <div data-testid="tablet-right-panel" />,
}));
vi.mock("../task-files-panel", () => ({
TaskFilesPanel: () => <div data-testid="tablet-files-panel" />,
}));
vi.mock("@/components/task/browser-panel", () => ({
BrowserPanel: () => <div data-testid="tablet-browser-panel" />,
}));
vi.mock("@/components/task/preview-controller", () => ({
PreviewController: () => null,
}));
vi.mock("@/lib/layout/use-default-layout", () => ({
useDefaultLayout: () => ({
defaultLayout: undefined,
onLayoutChanged: vi.fn(),
}),
}));
vi.mock("@/hooks/use-session-layout-state", () => ({
useSessionLayoutState: () => ({
activeTaskId: "task-1",
effectiveSessionId: "session-1",
sessionKey: "session-1",
selectedDiff: null,
handleClearSelectedDiff: vi.fn(),
openFileRequest: null,
handleOpenFile: vi.fn(),
handleFileOpenHandled: vi.fn(),
isTaskSwitcherOpen: false,
setMobileSessionTaskSwitcherOpen: vi.fn(),
}),
}));
vi.mock("../dockview-review-dialog", () => ({
TaskReviewDialogMount: () => null,
}));
afterEach(cleanup);
describe("SessionTabletLayout right-panel visibility", () => {
beforeEach(() => {
useLayoutStore.setState({
columnsBySessionId: {
"session-1": {
left: true,
chat: true,
right: false,
preview: false,
document: false,
},
},
});
});
it("does not render the right column when the session layout hides it", () => {
render(<SessionTabletLayout workspaceId="workspace-1" workflowId="workflow-1" />);
expect(screen.queryByTestId("tablet-right-panel")).toBeNull();
expect(screen.getByTestId("tablet-center-panel")).toBeTruthy();
});
});
@@ -174,13 +174,15 @@ export const SessionTabletLayout = memo(function SessionTabletLayout({
</Panel>
{/* Right Panel: Files + Terminal stacked */}
<Panel id="right" minSize="250px" className="min-h-0 min-w-0">
<TaskRightPanel
topPanel={topFilesPanel}
sessionId={sessionForPreview}
repositoryId={repository?.id ?? null}
/>
</Panel>
{layoutState.right && (
<Panel id="right" minSize="250px" className="min-h-0 min-w-0">
<TaskRightPanel
topPanel={topFilesPanel}
sessionId={sessionForPreview}
repositoryId={repository?.id ?? null}
/>
</Panel>
)}
</Group>
{/* Task Switcher Sheet - same as mobile */}
@@ -0,0 +1,150 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
state: {
isSupported: true,
isReady: true,
isMaximized: false,
isAvailable: true,
rightPanelsVisible: true,
toggleRightPanels: vi.fn(),
},
}));
const HIDE_RIGHT_PANE = "Hide right pane";
const SHOW_RIGHT_PANE = "Show right pane";
const RIGHT_PANE_UNAVAILABLE = "No separate right pane to hide";
const RIGHT_PANE_UNAVAILABLE_WHILE_MAXIMIZED =
"Right pane is unavailable while a panel is maximized";
const TOGGLE_TEST_ID = "task-right-panels-toggle";
const ARIA_LABEL_ATTRIBUTE = "aria-label";
vi.mock("@/hooks/use-task-right-panels-toggle", () => ({
useTaskRightPanelsToggle: () => mocks.state,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
if (key === "task:hideRightPane") return HIDE_RIGHT_PANE;
if (key === "task:showRightPane") return SHOW_RIGHT_PANE;
if (key === "task:rightPaneUnavailable") return RIGHT_PANE_UNAVAILABLE;
if (key === "task:rightPaneUnavailableWhileMaximized") {
return RIGHT_PANE_UNAVAILABLE_WHILE_MAXIMIZED;
}
return key;
},
}),
}));
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import { TaskRightPanelsToggle } from "./task-right-panels-toggle";
afterEach(cleanup);
beforeEach(() => {
mocks.state = {
isSupported: true,
isReady: true,
isMaximized: false,
isAvailable: true,
rightPanelsVisible: true,
toggleRightPanels: vi.fn(),
};
});
describe("TaskRightPanelsToggle", () => {
it("keeps the action in one button position and exposes the next action", () => {
render(<TaskRightPanelsToggle sessionId="session-1" />);
const button = screen.getByTestId(TOGGLE_TEST_ID);
expect(button.getAttribute(ARIA_LABEL_ATTRIBUTE)).toBe(HIDE_RIGHT_PANE);
expect(button.getAttribute("aria-expanded")).toBe("true");
expect(button.getAttribute("title")).toBe(HIDE_RIGHT_PANE);
button.focus();
fireEvent.click(button);
expect(mocks.state.toggleRightPanels).toHaveBeenCalledTimes(1);
expect(document.activeElement).toBe(button);
});
it("switches to the show action when right panels are hidden", () => {
mocks.state.rightPanelsVisible = false;
render(<TaskRightPanelsToggle sessionId="session-1" />);
const button = screen.getByTestId(TOGGLE_TEST_ID);
expect(button.getAttribute(ARIA_LABEL_ATTRIBUTE)).toBe(SHOW_RIGHT_PANE);
expect(button.getAttribute("aria-expanded")).toBe("false");
});
it("uses the shared touch-sized icon button", () => {
render(<TaskRightPanelsToggle sessionId="session-1" />);
expect(screen.getByTestId(TOGGLE_TEST_ID).className).toContain(
"[@media(pointer:coarse)]:size-11",
);
});
it("disables the action while the selected layout is not ready", () => {
mocks.state.isReady = false;
render(<TaskRightPanelsToggle sessionId="session-1" />);
expect((screen.getByTestId(TOGGLE_TEST_ID) as HTMLButtonElement).disabled).toBe(true);
});
it("explains why the action is disabled while a panel is maximized", () => {
mocks.state.isMaximized = true;
mocks.state.isReady = false;
render(<TaskRightPanelsToggle sessionId="session-1" />);
const button = screen.getByTestId(TOGGLE_TEST_ID) as HTMLButtonElement;
expect(button.disabled).toBe(true);
expect(button.getAttribute(ARIA_LABEL_ATTRIBUTE)).toBeNull();
expect(button.getAttribute("title")).toBe(RIGHT_PANE_UNAVAILABLE_WHILE_MAXIMIZED);
expect(button.parentElement?.tagName).toBe("SPAN");
expect(button.parentElement?.getAttribute("tabindex")).toBe("0");
expect(button.parentElement?.getAttribute(ARIA_LABEL_ATTRIBUTE)).toBe(
RIGHT_PANE_UNAVAILABLE_WHILE_MAXIMIZED,
);
});
it("explains when the current layout has no separate right pane", () => {
mocks.state.isAvailable = false;
render(<TaskRightPanelsToggle sessionId="session-1" />);
const button = screen.getByTestId(TOGGLE_TEST_ID) as HTMLButtonElement;
expect(button.disabled).toBe(true);
expect(button.getAttribute("title")).toBe(RIGHT_PANE_UNAVAILABLE);
expect(button.parentElement?.getAttribute(ARIA_LABEL_ATTRIBUTE)).toBe(RIGHT_PANE_UNAVAILABLE);
});
it("restores focus after a layout transition disables the button", () => {
const toggleRightPanels = vi.fn(() => {
mocks.state.isReady = false;
});
mocks.state.toggleRightPanels = toggleRightPanels;
const { rerender } = render(<TaskRightPanelsToggle sessionId="session-1" />);
const button = screen.getByTestId(TOGGLE_TEST_ID);
button.focus();
fireEvent.click(button);
rerender(<TaskRightPanelsToggle sessionId="session-1" />);
expect((button as HTMLButtonElement).disabled).toBe(true);
mocks.state.isReady = true;
rerender(<TaskRightPanelsToggle sessionId="session-1" />);
expect(document.activeElement).toBe(button);
});
it("does not render on phones", () => {
mocks.state.isSupported = false;
render(<TaskRightPanelsToggle sessionId="session-1" />);
expect(screen.queryByTestId(TOGGLE_TEST_ID)).toBeNull();
});
});
@@ -0,0 +1,80 @@
"use client";
import { IconLayoutSidebarRightCollapse, IconLayoutSidebarRightExpand } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useTaskRightPanelsToggle } from "@/hooks/use-task-right-panels-toggle";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
type TaskRightPanelsToggleProps = {
sessionId?: string | null;
};
export function TaskRightPanelsToggle({ sessionId = null }: TaskRightPanelsToggleProps) {
const { t } = useTranslation();
const { isSupported, isReady, isMaximized, isAvailable, rightPanelsVisible, toggleRightPanels } =
useTaskRightPanelsToggle(sessionId);
const buttonRef = useRef<HTMLButtonElement>(null);
const restoreFocusRef = useRef(false);
const previousReadyRef = useRef(isReady);
useEffect(() => {
if (isReady && !previousReadyRef.current && restoreFocusRef.current) {
buttonRef.current?.focus();
restoreFocusRef.current = false;
}
previousReadyRef.current = isReady;
}, [isReady]);
if (!isSupported) return null;
let label: string;
if (isMaximized) {
label = t("task:rightPaneUnavailableWhileMaximized");
} else if (!isAvailable) {
label = t("task:rightPaneUnavailable");
} else if (rightPanelsVisible) {
label = t("task:hideRightPane");
} else {
label = t("task:showRightPane");
}
const canActivate = isReady && isAvailable;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={canActivate ? -1 : 0}
aria-label={canActivate ? undefined : label}
className="inline-flex"
>
<Button
type="button"
size="icon"
variant="ghost"
className="cursor-pointer text-muted-foreground hover:bg-muted/70 hover:text-foreground"
data-testid="task-right-panels-toggle"
ref={buttonRef}
aria-label={canActivate ? label : undefined}
aria-expanded={rightPanelsVisible}
title={label}
disabled={!canActivate}
onClick={() => {
restoreFocusRef.current = document.activeElement === buttonRef.current;
toggleRightPanels();
}}
>
{rightPanelsVisible ? (
<IconLayoutSidebarRightCollapse className="h-3.5 w-3.5" />
) : (
<IconLayoutSidebarRightExpand className="h-3.5 w-3.5" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
+5 -3
View File
@@ -10,6 +10,7 @@ import { useOfficeProject } from "@/hooks/use-office-workspace-data";
import { TaskTopBarTitle } from "@/components/task/task-top-bar-title";
import { EditorsMenu } from "@/components/task/editors-menu";
import { LayoutPresetSelector } from "@/components/task/layout-preset-selector";
import { TaskRightPanelsToggle } from "@/components/task/task-right-panels-toggle";
import { DocumentControls } from "@/components/task/document/document-controls";
import { PRTopbarButton } from "@/components/github/pr-topbar-button";
import { MRTopbarButton } from "@/components/gitlab/mr-topbar-button";
@@ -346,15 +347,16 @@ function TopbarToolsGroup({
const showDebugToggle = isDebugUI() && onToggleDebugOverlay;
return (
<TopbarCluster label={t("task:taskTools")} className="[&_button]:h-7 [&_button]:text-xs">
<TopbarCluster label={t("task:taskTools")}>
<TaskRightPanelsToggle sessionId={activeSessionId ?? null} />
{!isArchived && (
<>
<div className="inline-flex items-center gap-1 [&_button]:h-7 [&_button]:text-xs">
<LayoutPresetSelector />
<EditorsMenu
activeSessionId={activeSessionId ?? null}
embeddedVscodeSupported={embeddedVscodeSupported ?? false}
/>
</>
</div>
)}
{showDebugToggle && (
<DebugOverlayToggle
+3 -2
View File
@@ -1,4 +1,4 @@
import { type Page } from "@playwright/test";
import { devices, type Page } from "@playwright/test";
import { execFileSync, execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
@@ -344,7 +344,7 @@ export const test = backendFixture.extend<
// Resets user settings to the E2E workspace/workflow before each test so that
// SSR always resolves to the correct workspace regardless of what commitSettings
// may have written during previous tests.
testPage: async ({ browser, backend, apiClient, seedData }, use) => {
testPage: async ({ browser, backend, apiClient, seedData }, use, testInfo) => {
await backend.ensureReady();
// A suite-level test may restart the worker backend after the worker-scoped
// seed fixture ran. Health only proves that the listener is serving; it does
@@ -410,6 +410,7 @@ export const test = backendFixture.extend<
});
});
const context = await browser.newContext({
...(testInfo.project.name === "mobile-chrome" ? devices["Pixel 5"] : {}),
baseURL: backend.frontendUrl,
});
const page = await context.newPage();
@@ -0,0 +1,53 @@
import { test, expect } from "../../fixtures/test-base";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { openTaskSession } from "../../helpers/session";
async function createTask(apiClient: ApiClient, seedData: SeedData) {
return apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile right-panel navigation",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
}
test.describe("mobile right-panel navigation", () => {
test("keeps Files and Terminal in the existing full-screen navigation", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await createTask(apiClient, seedData);
const session = await openTaskSession(testPage, task.id);
await expect(testPage.getByTestId("mobile-task-layout")).toBeVisible();
await expect(testPage.getByTestId("tablet-task-layout")).toHaveCount(0);
await expect(testPage.getByTestId("task-right-panels-toggle")).toHaveCount(0);
expect((await testPage.viewportSize())?.width).toBe(393);
await expect
.poll(() => testPage.evaluate(() => matchMedia("(any-pointer: coarse)").matches))
.toBe(true);
const chat = testPage.getByRole("button", { name: "Chat", exact: true });
const files = testPage.getByRole("button", { name: "Files", exact: true });
const terminal = testPage.getByRole("button", { name: "Terminal", exact: true });
await expect(chat).toBeVisible();
await expect(files).toBeVisible();
await expect(terminal).toBeVisible();
await files.tap();
await expect(testPage.getByTestId("file-tree-scroll")).toBeVisible();
await terminal.tap();
await expect(session.terminal).toBeVisible();
await chat.tap();
await expect(session.activeChat()).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage, "mobile right-panel navigation");
});
});
@@ -80,6 +80,10 @@ const rowOverflow = (row: Locator): Promise<number> =>
const rowScrollLeft = (row: Locator): Promise<number> =>
row.evaluate((element) => element.scrollLeft);
// Leave less room than the two-tab minimum after Dockview's header actions are
// accounted for, while staying above the production column minimum.
const NARROW_RIGHT_COLUMN_WIDTH = 200;
test.describe("narrow tab strip", () => {
test("squeezes tab titles rather than the close buttons", async ({
testPage,
@@ -94,7 +98,7 @@ test.describe("narrow tab strip", () => {
expect(wide.map((tab) => tab.truncated)).not.toContain(true);
expect(wide.every((tab) => tab.closeWidth > 0)).toBe(true);
await resizeColumnViaSplitview(testPage, "right", 240);
await resizeColumnViaSplitview(testPage, "right", NARROW_RIGHT_COLUMN_WIDTH);
const narrow = await probeTabStrip(strip);
expect(narrow.map((tab) => tab.title)).toEqual(wide.map((tab) => tab.title));
@@ -112,7 +116,7 @@ test.describe("narrow tab strip", () => {
seedData,
}) => {
await openWideTask(testPage, apiClient, seedData, "Horizontal tab scroll");
await resizeColumnViaSplitview(testPage, "right", 240);
await resizeColumnViaSplitview(testPage, "right", NARROW_RIGHT_COLUMN_WIDTH);
const { row } = await markFilesGroup(testPage);
const filesTab = row.locator(".dv-default-tab").filter({ hasText: /^Files$/ });
@@ -0,0 +1,377 @@
import { expect, type Page } from "@playwright/test";
import { test, type SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { getDockviewGroupWidth } from "../../helpers/dockview-resize";
import { SessionPage } from "../../pages/session-page";
async function createTask(apiClient: ApiClient, seedData: SeedData, title: string) {
return apiClient.createTaskWithAgent(seedData.workspaceId, title, seedData.agentProfileId, {
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
}
type DesktopTaskOptions = {
viewport: { width: number; height: number };
layout?: string;
};
async function openDesktopTask(
page: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
options: DesktopTaskOptions,
) {
await page.setViewportSize(options.viewport);
const task = await createTask(apiClient, seedData, title);
if (!task.session_id) throw new Error(`${title} did not return a session_id`);
await page.goto(`/t/${task.id}${options.layout ? `?layout=${options.layout}` : ""}`);
const session = new SessionPage(page);
await session.waitForLoad();
await session.waitForDockviewReady();
return { session, sessionId: task.session_id };
}
test.describe("right-panel visibility", () => {
test("desktop hides and restores the right column while reclaiming center width", async ({
testPage,
apiClient,
seedData,
}) => {
const { session, sessionId } = await openDesktopTask(
testPage,
apiClient,
seedData,
"Desktop right-panel visibility",
{ viewport: { width: 1600, height: 900 } },
);
const toggle = testPage.getByTestId("task-right-panels-toggle");
await expect(toggle).toBeVisible();
await expect(toggle).toBeEnabled();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(session.files).toBeVisible();
await expect(session.terminal).toBeVisible();
const centerWidthBefore = await testPage.evaluate((id) => {
type Api = {
getPanel: (panelId: string) => { group: { width: number } } | undefined;
};
const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
return api?.getPanel(`session:${id}`)?.group.width ?? 0;
}, sessionId);
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(session.files).toHaveCount(0);
await expect(session.terminal).toHaveCount(0);
await expect(session.activeChat()).toBeVisible();
await expect
.poll(
() =>
testPage.evaluate((id) => {
type Api = {
getPanel: (panelId: string) => { group: { width: number } } | undefined;
};
const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
return api?.getPanel(`session:${id}`)?.group.width ?? 0;
}, sessionId),
{ message: "center group did not reclaim the right-column width" },
)
.toBeGreaterThan(centerWidthBefore + 100);
await testPage.reload();
await session.waitForLoad();
await session.waitForDockviewReady();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(session.files).toHaveCount(0);
await expect(session.terminal).toHaveCount(0);
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(session.files).toBeVisible();
await expect(session.terminal).toBeVisible();
await testPage.reload();
await session.waitForLoad();
await session.waitForDockviewReady();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(session.files).toBeVisible();
await expect(session.terminal).toBeVisible();
await session.expectLayoutHealthy();
});
test("compact desktop disables the action when it has one workbench region", async ({
testPage,
apiClient,
seedData,
}) => {
const { session } = await openDesktopTask(
testPage,
apiClient,
seedData,
"Compact desktop right-panel visibility",
{ viewport: { width: 900, height: 800 } },
);
await expect(testPage.getByTestId("tablet-task-layout")).toHaveCount(0);
const toggle = testPage.getByTestId("task-right-panels-toggle");
await expect(toggle).toBeVisible();
await expect(toggle).toBeDisabled();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(toggle).toHaveAttribute("title", "No separate right pane to hide");
await expect(toggle.locator("..")).toHaveAttribute(
"aria-label",
"No separate right pane to hide",
);
await testPage.reload();
await session.waitForLoad();
await session.waitForDockviewReady();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(toggle).toBeDisabled();
await expect(session.activeChat()).toBeVisible();
});
test("Plan and Preview layouts toggle their contextual right pane", async ({
testPage,
apiClient,
seedData,
}) => {
const { session } = await openDesktopTask(
testPage,
apiClient,
seedData,
"Plan contextual right pane",
{ viewport: { width: 1600, height: 900 }, layout: "plan" },
);
const toggle = testPage.getByTestId("task-right-panels-toggle");
const plan = testPage.getByTestId("plan-panel");
await expect(toggle).toBeEnabled();
await expect(plan).toBeVisible();
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(plan).toHaveCount(0);
await expect(session.activeChat()).toBeVisible();
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(plan).toBeVisible();
await testPage.reload();
await session.waitForLoad();
await session.waitForDockviewReady();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(plan).toBeVisible();
});
test("Preview layout toggles Browser instead of a standard right stack", async ({
testPage,
apiClient,
seedData,
}) => {
const { session } = await openDesktopTask(
testPage,
apiClient,
seedData,
"Preview contextual right pane",
{ viewport: { width: 1600, height: 900 } },
);
await testPage.getByTestId("layout-preset-trigger").click();
await testPage.locator('[data-testid="layout-preset-item"][data-preset-id="preview"]').click();
const toggle = testPage.getByTestId("task-right-panels-toggle");
const browser = testPage.getByTestId("browser-panel");
await expect(toggle).toBeEnabled();
await expect(browser).toBeVisible();
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(browser).toHaveCount(0);
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(browser).toBeVisible();
await testPage.reload();
await session.waitForLoad();
await session.waitForDockviewReady();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(browser).toBeVisible();
});
test("keeps the toggle disabled while maximized and restores visibility after exit and reload", async ({
testPage,
apiClient,
seedData,
}) => {
const { session } = await openDesktopTask(
testPage,
apiClient,
seedData,
"Maximized right-panel visibility",
{ viewport: { width: 1600, height: 900 } },
);
const toggle = testPage.getByTestId("task-right-panels-toggle");
await expect(toggle).toBeEnabled();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await session.clickTab("Files");
await session.clickMaximize();
await session.expectMaximized();
await expect(toggle).toBeDisabled();
await expect(toggle).toHaveAttribute(
"title",
"Right pane is unavailable while a panel is maximized",
);
await expect(toggle.locator("..")).toHaveAttribute(
"aria-label",
"Right pane is unavailable while a panel is maximized",
);
await session.clickMaximize();
await expect(toggle).toBeEnabled();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(session.files).toBeVisible();
await testPage.reload();
await session.waitForLoad();
await session.waitForDockviewReady();
await expect(toggle).toBeEnabled();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(session.files).toBeVisible();
await session.expectLayoutHealthy();
});
test("supports keyboard activation while keeping focus on the persistent control", async ({
testPage,
apiClient,
seedData,
}) => {
await openDesktopTask(testPage, apiClient, seedData, "Keyboard right-panel visibility", {
viewport: { width: 1600, height: 900 },
});
const toggle = testPage.getByTestId("task-right-panels-toggle");
await expect(toggle).toBeEnabled();
await toggle.focus();
await expect(toggle).toBeFocused();
await toggle.press("Enter");
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(toggle).toBeFocused();
await toggle.press("Space");
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(toggle).toBeFocused();
});
test("tablet hides Files and Terminal, preserves Chat, and restores the session choice", async ({
tabletTestPage,
apiClient,
seedData,
}) => {
const task = await createTask(apiClient, seedData, "Tablet right-panel visibility");
if (!task.session_id) throw new Error("tablet task did not return a session_id");
const { sessions } = await apiClient.listTaskSessions(task.id);
const environmentId = sessions.find(
(session) => session.id === task.session_id,
)?.task_environment_id;
if (!environmentId) throw new Error("tablet task is missing an environment id");
const terminal = await apiClient.wsRequest<{ terminal_id: string }>("user_shell.create", {
task_id: task.id,
task_environment_id: environmentId,
});
await tabletTestPage.goto(`/t/${task.id}`);
const session = new SessionPage(tabletTestPage);
const tabletFiles = tabletTestPage.getByTestId("file-tree-scroll");
const tabletTerminalTab = tabletTestPage.getByTestId(`terminal-tab-${terminal.terminal_id}`);
await session.waitForLoad();
await expect(tabletTestPage.getByTestId("tablet-task-layout")).toBeVisible();
const toggle = tabletTestPage.getByTestId("task-right-panels-toggle");
await expect(toggle).toBeVisible();
await expect(toggle).toBeEnabled();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(tabletFiles).toBeVisible();
await expect(tabletTerminalTab).toBeVisible();
await toggle.tap();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(tabletFiles).toHaveCount(0);
await expect(tabletTerminalTab).toHaveCount(0);
await expect(session.activeChat()).toBeVisible();
await assertNoDocumentHorizontalOverflow(tabletTestPage, "tablet right-panel visibility");
const stored = await tabletTestPage.evaluate((sessionId) => {
const raw = window.localStorage.getItem("layout-columns-by-session");
const layouts = raw ? (JSON.parse(raw) as Record<string, { right?: boolean }>) : {};
return layouts[sessionId]?.right;
}, task.session_id);
expect(stored).toBe(false);
await tabletTestPage.reload();
await session.waitForLoad();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(tabletFiles).toHaveCount(0);
await toggle.tap();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(tabletFiles).toBeVisible();
await expect(tabletTerminalTab).toBeVisible();
});
});
for (const layout of ["default", "plan", "preview"] as const) {
test(`restores manually resized ${layout} pane width after toggling`, async ({
testPage,
apiClient,
seedData,
}) => {
const { session } = await openDesktopTask(
testPage,
apiClient,
seedData,
`Resized ${layout} pane`,
{ viewport: { width: 1600, height: 900 } },
);
if (layout !== "default") {
await testPage.getByTestId("layout-preset-trigger").click();
await testPage
.locator(`[data-testid="layout-preset-item"][data-preset-id="${layout}"]`)
.click();
await expect(
testPage.getByTestId(layout === "plan" ? "plan-panel" : "browser-panel"),
).toBeVisible();
}
const panelId = { default: "files", preview: "browser", plan: "plan" }[layout]!;
const before = await getDockviewGroupWidth(testPage, panelId);
const sashBox = await testPage.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const api = (window as any).__dockviewApi__;
const sashes = api.component.gridview.root.splitview.sashes;
const rect = sashes.at(-1).container.getBoundingClientRect();
return { x: rect.x + rect.width / 2, y: rect.y + 100 };
});
await testPage.mouse.move(sashBox.x, sashBox.y);
await testPage.mouse.down();
await testPage.mouse.move(sashBox.x - 100, sashBox.y, { steps: 10 });
await testPage.mouse.up();
await expect.poll(() => getDockviewGroupWidth(testPage, panelId)).toBeGreaterThan(before + 50);
const resized = await getDockviewGroupWidth(testPage, panelId);
const toggle = testPage.getByTestId("task-right-panels-toggle");
for (let cycle = 0; cycle < 3; cycle++) {
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await session.waitForDockviewReady();
await expect
.poll(async () => Math.abs((await getDockviewGroupWidth(testPage, panelId)) - resized))
.toBeLessThanOrEqual(2);
}
});
}
@@ -44,6 +44,43 @@ async function openTaskSession(page: Page, title: string): Promise<SessionPage>
type SessionTabHistoryEntry = { id: string; text: string };
type E2EStoreWindow = Window & {
__KANDEV_E2E_STORE__?: {
getState: () => {
kanbanMulti: {
snapshots: Record<
string,
{
steps: Array<{ id: string }>;
tasks: Array<{ id: string; workflowStepId: string }>;
}
>;
};
workflows: { activeId: string | null };
};
};
};
/** Wait for the workflow snapshot source that renders the Kanban cards to hydrate. */
async function waitForKanbanTask(page: Page, workflowId: string, taskId: string): Promise<void> {
await page.waitForFunction(
({ expectedWorkflowId, expectedTaskId }) => {
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
const state = store?.getState();
if (!state) return false;
if (state.workflows.activeId !== expectedWorkflowId) return false;
const snapshot = state.kanbanMulti.snapshots[expectedWorkflowId];
if (!snapshot) return false;
const stepIds = new Set(snapshot.steps.map((step) => step.id));
return snapshot.tasks.some(
(task) => task.id === expectedTaskId && stepIds.has(task.workflowStepId),
);
},
{ expectedWorkflowId: workflowId, expectedTaskId: taskId },
{ timeout: 30_000 },
);
}
async function recordSessionTabHistory(page: Page): Promise<void> {
await page.addInitScript(() => {
const history: SessionTabHistoryEntry[][] = [];
@@ -265,10 +302,13 @@ test.describe("Session resume (ACP mode)", () => {
const visibleWorkspacePath = session.files.getByTestId("file-browser-workspace-path");
await expect(visibleWorkspacePath).toHaveText(expectedDisplayPath, { timeout: 30_000 });
// Keeping Files active makes reload reconstruct the workspace-only execution
// before automatic session resume promotes and persists that execution.
// Reload can foreground Changes while the resumed execution refreshes its
// working tree. Wait for the hydrated workbench, then select Files before
// asserting the workspace path.
await backend.restart();
await testPage.reload();
await session.waitForLoad();
await session.clickTab("Files");
await expect(session.files).toBeVisible({ timeout: 30_000 });
await session.clickSessionChatTab();
@@ -415,16 +455,22 @@ test.describe("Session resume (TUI passthrough mode)", () => {
const tuiProfile = await createTUIProfile(apiClient, "TUI Resume");
// 2. Create task with TUI agent
await apiClient.createTaskWithAgent(seedData.workspaceId, "TUI Resume Task", tuiProfile.id, {
description: "hello from resume test",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"TUI Resume Task",
tuiProfile.id,
{
description: "hello from resume test",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
// 3. Navigate and wait for TUI terminal to load
const kanban = new KanbanPage(testPage);
await kanban.goto();
await waitForKanbanTask(testPage, seedData.workflowId, task.id);
const card = kanban.taskCardByTitle("TUI Resume Task");
await expect(card).toBeVisible({ timeout: 15_000 });
@@ -490,7 +536,7 @@ test.describe("Session resume (TUI passthrough mode)", () => {
// 2. TUI profile + multi-repo task
const tuiProfile = await createTUIProfile(apiClient, "TUI Multi-Repo Resume");
await apiClient.createTaskWithAgent(
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"TUI Multi-Repo Resume Task",
tuiProfile.id,
@@ -504,6 +550,7 @@ test.describe("Session resume (TUI passthrough mode)", () => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await waitForKanbanTask(testPage, seedData.workflowId, task.id);
const card = kanban.taskCardByTitle("TUI Multi-Repo Resume Task");
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
@@ -399,6 +399,8 @@ test.describe("@-mention autocomplete: adversarial QA", () => {
const reference = dialog.getByTestId("task-prompt-reference").first();
const remove = reference.getByTestId("task-prompt-reference-remove");
await expect(reference.getByTestId("custom-prompt-mention")).toBeVisible();
await expect(remove).toBeVisible();
const metrics = await readPromptReferenceMetrics(reference);
expect(metrics.shell.height).toBeCloseTo(24, 0);
expect(metrics.labelFontSize).toBe("12px");
@@ -490,6 +492,8 @@ test.describe("@-mention autocomplete: adversarial QA", () => {
const mention = reference.getByTestId("custom-prompt-mention");
const remove = reference.getByTestId("task-prompt-reference-remove");
await expect(mention).toHaveAttribute("aria-label", `Custom prompt: ${promptName}`);
await expect(mention).toBeVisible();
await expect(remove).toBeVisible();
const metrics = await readPromptReferenceMetrics(reference);
expect(metrics.labelText).toBe(`@${promptName}`);
expect(metrics.labelScrollWidth).toBeGreaterThan(metrics.labelClientWidth);
@@ -0,0 +1,169 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
breakpoint: {
isMobile: false,
isTablet: false,
usesDesktopWorkbench: true,
},
dock: {
api: null as object | null,
rightPanelsVisible: true,
rightPaneVisible: true,
rightPaneAvailable: true,
isRestoringLayout: false,
preMaximizeLayout: null as object | null,
toggleRightPanels: vi.fn(),
},
layout: {
columnsBySessionId: {} as Record<string, { right: boolean }>,
toggleRightPanel: vi.fn(),
},
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => mocks.breakpoint,
}));
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: (selector: (state: typeof mocks.dock) => unknown) => selector(mocks.dock),
}));
vi.mock("@/lib/state/layout-store", () => ({
useLayoutStore: (selector: (state: typeof mocks.layout) => unknown) => selector(mocks.layout),
}));
import { useTaskRightPanelsToggle } from "./use-task-right-panels-toggle";
function setDesktop(overrides: Partial<typeof mocks.dock> = {}) {
mocks.breakpoint = {
isMobile: false,
isTablet: false,
usesDesktopWorkbench: true,
};
Object.assign(mocks.dock, {
api: {},
rightPanelsVisible: true,
rightPaneVisible: true,
rightPaneAvailable: true,
isRestoringLayout: false,
preMaximizeLayout: null,
...overrides,
});
}
beforeEach(() => {
vi.clearAllMocks();
mocks.breakpoint = {
isMobile: false,
isTablet: false,
usesDesktopWorkbench: true,
};
mocks.dock.api = null;
mocks.dock.rightPanelsVisible = true;
mocks.dock.rightPaneVisible = true;
mocks.dock.rightPaneAvailable = true;
mocks.dock.isRestoringLayout = false;
mocks.dock.preMaximizeLayout = null;
mocks.layout.columnsBySessionId = {};
});
describe("useTaskRightPanelsToggle", () => {
it("waits for the desktop API and settled restoration before enabling", () => {
const { result, rerender } = renderHook(() => useTaskRightPanelsToggle("session-1"));
expect(result.current.isSupported).toBe(true);
expect(result.current.isReady).toBe(false);
setDesktop({ isRestoringLayout: true });
rerender();
expect(result.current.isReady).toBe(false);
mocks.dock.isRestoringLayout = false;
rerender();
expect(result.current.isReady).toBe(true);
act(() => result.current.toggleRightPanels());
expect(mocks.dock.toggleRightPanels).toHaveBeenCalledTimes(1);
expect(mocks.layout.toggleRightPanel).not.toHaveBeenCalled();
});
it("routes a coarse-pointer tablet toggle to the effective session layout", () => {
mocks.breakpoint = {
isMobile: false,
isTablet: true,
usesDesktopWorkbench: false,
};
mocks.dock.api = {};
mocks.dock.rightPanelsVisible = true;
mocks.layout.columnsBySessionId = {
"session-1": { right: false },
};
const { result } = renderHook(() => useTaskRightPanelsToggle("session-1"));
expect(result.current.isSupported).toBe(true);
expect(result.current.isReady).toBe(true);
expect(result.current.rightPanelsVisible).toBe(false);
act(() => result.current.toggleRightPanels());
expect(mocks.layout.toggleRightPanel).toHaveBeenCalledWith("session-1");
expect(mocks.dock.toggleRightPanels).not.toHaveBeenCalled();
});
it("disables the desktop toggle while a group is maximized", () => {
setDesktop({ preMaximizeLayout: { columns: [] } });
const { result } = renderHook(() => useTaskRightPanelsToggle("session-1"));
expect(result.current.isMaximized).toBe(true);
expect(result.current.isReady).toBe(false);
act(() => result.current.toggleRightPanels());
expect(mocks.dock.toggleRightPanels).not.toHaveBeenCalled();
});
it("does not write an empty tablet session key", () => {
mocks.breakpoint = {
isMobile: false,
isTablet: true,
usesDesktopWorkbench: false,
};
const { result } = renderHook(() => useTaskRightPanelsToggle(null));
expect(result.current.isSupported).toBe(true);
expect(result.current.isReady).toBe(false);
act(() => result.current.toggleRightPanels());
expect(mocks.layout.toggleRightPanel).not.toHaveBeenCalled();
});
it("disables the desktop toggle when the live layout has no separate right pane", () => {
setDesktop({ rightPaneAvailable: false });
const { result } = renderHook(() => useTaskRightPanelsToggle("session-1"));
expect(result.current.isAvailable).toBe(false);
expect(result.current.isReady).toBe(true);
act(() => result.current.toggleRightPanels());
expect(mocks.dock.toggleRightPanels).not.toHaveBeenCalled();
});
it("does not expose or mutate wider layouts on phones", () => {
mocks.breakpoint = {
isMobile: true,
isTablet: false,
usesDesktopWorkbench: false,
};
mocks.dock.api = {};
mocks.layout.columnsBySessionId = { "session-1": { right: false } };
const { result } = renderHook(() => useTaskRightPanelsToggle("session-1"));
expect(result.current.isSupported).toBe(false);
expect(result.current.isReady).toBe(false);
act(() => result.current.toggleRightPanels());
expect(mocks.dock.toggleRightPanels).not.toHaveBeenCalled();
expect(mocks.layout.toggleRightPanel).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,73 @@
"use client";
import { useCallback } from "react";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { useLayoutStore } from "@/lib/state/layout-store";
export type TaskRightPanelsToggleState = {
isSupported: boolean;
isReady: boolean;
isMaximized: boolean;
isAvailable: boolean;
rightPanelsVisible: boolean;
toggleRightPanels: () => void;
};
/**
* Select the layout owner for the task header's right-panel action.
*
* Fine-pointer workbenches use Dockview's environment layout. Coarse-pointer
* tablet workspaces use the session layout store, even if a Dockview API from
* a previous desktop render is still present.
*/
export function useTaskRightPanelsToggle(
effectiveSessionId: string | null | undefined,
): TaskRightPanelsToggleState {
const { isMobile, isTablet, usesDesktopWorkbench } = useResponsiveBreakpoint();
const dockviewApi = useDockviewStore((state) => state.api);
const dockviewVisible = useDockviewStore((state) => state.rightPaneVisible);
const dockviewAvailable = useDockviewStore((state) => state.rightPaneAvailable);
const isRestoringLayout = useDockviewStore((state) => state.isRestoringLayout);
const preMaximizeLayout = useDockviewStore((state) => state.preMaximizeLayout);
const toggleDockviewRightPanels = useDockviewStore((state) => state.toggleRightPanels);
const columnsBySessionId = useLayoutStore((state) => state.columnsBySessionId);
const toggleTabletRightPanel = useLayoutStore((state) => state.toggleRightPanel);
const isSupported = !isMobile && (isTablet || usesDesktopWorkbench);
let rightPanelsVisible = dockviewVisible;
if (isTablet && effectiveSessionId) {
rightPanelsVisible = columnsBySessionId[effectiveSessionId]?.right ?? true;
}
const isMaximized = usesDesktopWorkbench && preMaximizeLayout !== null;
const isReady = isTablet
? Boolean(effectiveSessionId)
: usesDesktopWorkbench && Boolean(dockviewApi) && !isRestoringLayout && !isMaximized;
const isAvailable = isTablet ? Boolean(effectiveSessionId) : dockviewAvailable;
const toggleRightPanels = useCallback(() => {
if (!isSupported || !isReady || !isAvailable) return;
if (isTablet) {
if (effectiveSessionId) toggleTabletRightPanel(effectiveSessionId);
return;
}
toggleDockviewRightPanels();
}, [
effectiveSessionId,
isReady,
isSupported,
isAvailable,
isTablet,
toggleDockviewRightPanels,
toggleTabletRightPanel,
]);
return {
isSupported,
isReady: isSupported && isReady,
isMaximized,
isAvailable,
rightPanelsVisible,
toggleRightPanels,
};
}
@@ -23,6 +23,14 @@ vi.mock("@/lib/layout/panel-portal-manager", () => ({
},
}));
vi.mock("./layout-manager", async (importOriginal) => {
const actual = await importOriginal<typeof import("./layout-manager")>();
return {
...actual,
fromDockviewApi: vi.fn(() => ({ columns: [] })),
};
});
import {
getEnvLayoutProfile,
getEnvMaximizeState,
@@ -30,6 +38,7 @@ import {
setEnvLayoutProfile,
} from "@/lib/local-storage";
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
import { fromDockviewApi } from "./layout-manager";
function makeMockApi(): DockviewApi {
return {
@@ -48,6 +57,51 @@ function makeMockApi(): DockviewApi {
} as unknown as DockviewApi;
}
function flushRaf(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
const visibleRightLayout = {
columns: [
{
id: "center",
groups: [
{
id: "runtime-center",
panels: [{ id: "session:session-b", component: "chat", title: "Agent" }],
},
],
},
{
id: "right",
pinned: true,
groups: [
{
id: "group-right-top",
panels: [{ id: "files", component: "files", title: "Files" }],
},
],
},
],
};
const hiddenRightLayout = {
columns: [
{
id: "center",
groups: [
{
id: "runtime-center",
panels: [
{ id: "session:session-a", component: "chat", title: "Agent" },
{ id: "files", component: "files", title: "Files" },
],
},
],
},
],
};
function testNormalizesStaleSessionPanelsOnSavedMaximize(): void {
const savedMaximizedJson = {
grid: {
@@ -132,9 +186,12 @@ function testNormalizesStaleSessionPanelsOnSavedMaximize(): void {
expect(closeStale).toHaveBeenCalledOnce();
}
// eslint-disable-next-line max-lines-per-function
describe("switchEnvLayout — root fix for terminal/layout swapping", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fromDockviewApi).mockReset().mockReturnValue({ columns: [] });
vi.mocked(getEnvMaximizeState).mockReset().mockReturnValue(null);
useDockviewStore.setState({
api: null,
currentLayoutEnvId: null,
@@ -189,6 +246,41 @@ describe("switchEnvLayout — root fix for terminal/layout swapping", () => {
expect(state.pendingChatScrollTop).toBeNull();
});
it("refreshes right-panel visibility across env switches and both toggle directions", async () => {
const api = makeMockApi();
useDockviewStore.setState({
api,
currentLayoutEnvId: "env-a",
rightPanelsVisible: false,
});
vi.mocked(fromDockviewApi).mockReturnValue(visibleRightLayout);
useDockviewStore.getState().switchEnvLayout("env-a", "env-b", "session-b");
await flushRaf();
expect(useDockviewStore.getState().rightPanelsVisible).toBe(true);
// B's visible layout follows the hide action and must update its label/state.
vi.mocked(fromDockviewApi).mockReturnValue(visibleRightLayout);
useDockviewStore.getState().toggleRightPanels();
await flushRaf();
expect(useDockviewStore.getState().rightPanelsVisible).toBe(false);
// Returning to A restores its hidden layout, rather than retaining B's
// outgoing visibility value.
vi.mocked(fromDockviewApi).mockReturnValue(hiddenRightLayout);
useDockviewStore.getState().switchEnvLayout("env-b", "env-a", "session-a");
await flushRaf();
expect(useDockviewStore.getState().rightPanelsVisible).toBe(false);
// A has one workbench region and no retained recovery metadata, so Show
// remains disabled instead of fabricating the standard sidebar.
vi.mocked(fromDockviewApi).mockReturnValue(hiddenRightLayout);
useDockviewStore.getState().toggleRightPanels();
await flushRaf();
expect(useDockviewStore.getState().rightPanelsVisible).toBe(false);
expect(useDockviewStore.getState().rightPaneAvailable).toBe(false);
});
it("ignores completion from a superseded env-switch placement", () => {
const api = makeMockApi();
useDockviewStore.setState({ api, currentLayoutEnvId: "env-a" });
@@ -273,9 +365,12 @@ describe("switchEnvLayout — root fix for terminal/layout swapping", () => {
* forgets `maximizedGroupId` — leaving the store in an inconsistent
* half-maximized state on the way back.
*/
// eslint-disable-next-line max-lines-per-function
describe("switchEnvLayout — maximize+sidebar-switch regression", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fromDockviewApi).mockReset().mockReturnValue({ columns: [] });
vi.mocked(getEnvMaximizeState).mockReset().mockReturnValue(null);
useDockviewStore.setState({
api: null,
currentLayoutEnvId: null,
@@ -338,7 +433,15 @@ describe("switchEnvLayout — maximize+sidebar-switch regression", () => {
};
vi.mocked(getEnvMaximizeState).mockImplementation((envId) =>
envId === "env-a"
? { preMaximizeLayout: { columns: [] }, maximizedDockviewJson: savedMaximizedJson }
? {
preMaximizeLayout: {
columns: [
{ id: "center", groups: [{ id: "group-center", panels: [] }] },
{ id: "right", groups: [{ id: "group-right-top", panels: [] }] },
],
},
maximizedDockviewJson: savedMaximizedJson,
}
: null,
);
@@ -348,6 +451,53 @@ describe("switchEnvLayout — maximize+sidebar-switch regression", () => {
const state = useDockviewStore.getState();
expect(state.preMaximizeLayout).not.toBeNull();
expect(state.maximizedGroupId).toBeTruthy();
expect(state.rightPanelsVisible).toBe(true);
});
it("restores hidden right-panel visibility with a saved maximize state", () => {
const api = makeMockApi();
const savedMaximizedJson = {
grid: {
root: {
type: "leaf",
size: 600,
data: { id: "g-center", views: ["chat", "files"] },
},
height: 600,
width: 800,
orientation: "HORIZONTAL",
},
panels: {
chat: { id: "chat", contentComponent: "chat" },
files: { id: "files", contentComponent: "files" },
},
activeGroup: "g-center",
};
vi.mocked(getEnvMaximizeState).mockReturnValue({
preMaximizeLayout: {
columns: [
{
id: "center",
groups: [
{
id: "runtime-center",
panels: [
{ id: "session:session-a", component: "chat", title: "Agent" },
{ id: "files", component: "files", title: "Files" },
],
},
],
},
],
},
maximizedDockviewJson: savedMaximizedJson,
});
useDockviewStore.setState({ api, currentLayoutEnvId: "env-b" });
useDockviewStore.getState().switchEnvLayout("env-b", "env-a", "session-a");
expect(useDockviewStore.getState().preMaximizeLayout).not.toBeNull();
expect(useDockviewStore.getState().rightPanelsVisible).toBe(false);
});
it("normalizes stale session panels when restoring a saved maximize layout", () => {
+20 -2
View File
@@ -30,6 +30,7 @@ import {
restoreSavedActiveViews,
} from "./dockview-env-switch-active-views";
import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "./dockview-env-scoped-components";
import { stripHiddenRightPaneMetadata } from "./dockview-right-pane";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
import {
snapshotColumnWidths,
@@ -68,7 +69,24 @@ const EPHEMERAL_COMPONENTS = ENV_SCOPED_DOCKVIEW_COMPONENTS;
function getHealthyEnvLayout(envId: string): object | null {
const saved = getEnvLayout(envId);
if (!saved) return null;
return isLayoutShapeHealthy(saved) ? saved : null;
const dockviewLayout = stripHiddenRightPaneMetadata(saved);
return isLayoutShapeHealthy(dockviewLayout) ? dockviewLayout : null;
}
function restoreSerializedDockview(api: DockviewApi, next: SerializedDockview): void {
const previous = typeof api.toJSON === "function" ? api.toJSON() : null;
try {
api.fromJSON(next);
} catch (error) {
if (previous) {
try {
api.fromJSON(previous);
} catch {
// A failed rollback cannot be repaired by retrying without risking another partial mutation.
}
}
throw error;
}
}
/** Check whether a serialized dockview layout contains ephemeral panels. */
@@ -597,7 +615,7 @@ export function performEnvSwitch(params: EnvSwitchParams): LayoutGroupIds {
`savedRight=${savedRightColumnWidth(saved as SerializedDockview) ?? "-"}`,
);
}
api.fromJSON(saved as SerializedDockview);
restoreSerializedDockview(api, saved as SerializedDockview);
// Saved layout may carry a stale session panel from a previously-deleted
// task (phantom). Replace stale session panels with the incoming active
// session in the same (group, tab-index), then close the stale ones —
@@ -83,7 +83,6 @@ import { removeEnvMaximizeState, setEnvLayout } from "@/lib/local-storage";
import { persistEnvLayoutNow, useDockviewStore } from "./dockview-store";
import {
applyLayout,
defaultLayout,
fromDockviewApi,
getPresetLayout,
resolveNamedIntent,
@@ -179,6 +178,7 @@ describe("persistEnvLayoutNow", () => {
// previously held isRestoringLayout=true for the whole rAF and never wrote.
function resetStoreForIntegration() {
vi.clearAllMocks();
vi.mocked(fromDockviewApi).mockReset().mockReturnValue({ columns: [] });
useDockviewStore.setState({
api: null,
currentLayoutEnvId: null,
@@ -188,6 +188,7 @@ function resetStoreForIntegration() {
pinnedWidths: new Map(),
userDefaultLayout: null,
userDefaultLayoutProfile: { kind: "built-in", id: "default" },
defaultPreset: "default",
});
}
@@ -462,95 +463,6 @@ describe("buildDefaultLayout — effective default widths", () => {
});
});
describe("toggleRightPanels — center fallback", () => {
beforeEach(resetStoreForIntegration);
it("keeps a center fallback PR Details tab when hiding right panels", async () => {
const api = makeStoreApi();
vi.mocked(fromDockviewApi).mockReturnValue({
columns: [
{
id: "center",
groups: [
{
id: "group-center",
panels: [
{ id: "chat", component: "chat", title: "Agent" },
{ id: "pr-detail", component: "pr-detail", title: "PR Details" },
],
},
],
},
{
id: "right",
pinned: true,
groups: [
{
id: "group-right-top",
panels: [{ id: "files", component: "files", title: "Files" }],
},
],
},
],
});
useDockviewStore.setState({ api, rightPanelsVisible: true, defaultPreset: "default" });
useDockviewStore.getState().toggleRightPanels();
const appliedState = vi.mocked(applyLayout).mock.calls.at(-1)?.[1];
expect(appliedState?.columns.map((column) => column.id)).toEqual(["center"]);
expect(appliedState?.columns[0]?.groups[0]?.panels.map((panel) => panel.id)).toEqual([
"chat",
"pr-detail",
]);
await flushRaf();
});
it("keeps a center fallback PR Details tab when showing right panels", async () => {
const api = makeStoreApi();
vi.mocked(fromDockviewApi).mockReturnValue({
columns: [
{
id: "center",
groups: [
{
id: "group-center",
panels: [
{ id: "chat", component: "chat", title: "Agent" },
{ id: "pr-detail", component: "pr-detail", title: "PR Details" },
],
},
],
},
],
});
vi.mocked(defaultLayout).mockReturnValue({
columns: [
{
id: "right",
pinned: true,
groups: [
{
id: "group-right-top",
panels: [{ id: "files", component: "files", title: "Files" }],
},
],
},
],
});
useDockviewStore.setState({ api, rightPanelsVisible: false, defaultPreset: "default" });
useDockviewStore.getState().toggleRightPanels();
const appliedState = vi.mocked(applyLayout).mock.calls.at(-1)?.[1];
expect(appliedState?.columns[0]?.groups[0]?.panels.map((panel) => panel.id)).toEqual([
"chat",
"pr-detail",
]);
await flushRaf();
});
});
describe("toggleRightPanels", () => {
beforeEach(resetStoreForIntegration);
@@ -0,0 +1,380 @@
import { describe, expect, it } from "vitest";
import type { LayoutColumn, LayoutState } from "./layout-manager/types";
import { toSerializedDockview } from "./layout-manager/serializer";
import {
captureRightPane,
getRightPaneToggleState,
readHiddenRightPane,
restoreRightPane,
stripHiddenRightPaneMetadata,
withHiddenRightPaneMetadata,
} from "./dockview-right-pane";
const panel = (id: string, component = id, params?: Record<string, unknown>) => ({
id,
component,
title: id,
...(params ? { params } : {}),
});
function centerColumn(): LayoutColumn {
return {
id: "center",
groups: [
{
id: "center-group",
activePanel: "session:session-a",
panels: [panel("session:session-a", "chat"), panel("changes")],
},
],
};
}
function nestedRightColumn(): LayoutColumn {
const top = {
id: "right-top",
activePanel: "browser",
panels: [panel("browser", "browser", { url: "https://example.test" }), panel("plan")],
};
const bottom = {
id: "right-bottom",
activePanel: "terminal",
panels: [panel("terminal", "terminal", { terminalId: "terminal-a" })],
};
return {
id: "browser-region",
width: 420,
groups: [top, bottom],
tree: {
type: "branch",
size: 420,
children: [
{ type: "leaf", size: 280, group: top },
{ type: "leaf", size: 140, group: bottom },
],
},
};
}
function visibleLayout(columns: LayoutColumn[]): LayoutState {
return { columns, rootOrientation: "HORIZONTAL" };
}
describe("contextual right-pane selection and recovery", () => {
it("captures the actual final region and restores its nested tree, tabs, and parameters", () => {
// @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.8
// @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.9
const original = visibleLayout([centerColumn(), nestedRightColumn()]);
const captured = captureRightPane(original);
expect(captured?.layout.columns.map((column) => column.id)).toEqual(["center"]);
expect(captured?.hiddenRightPane.column).toEqual(original.columns[1]);
const changedRemaining = {
...captured!.layout,
columns: [
{
...captured!.layout.columns[0],
groups: [
{
...captured!.layout.columns[0].groups[0],
activePanel: "changes",
},
],
},
],
};
const restored = restoreRightPane(changedRemaining, captured!.hiddenRightPane);
expect(restored?.columns[0]?.groups[0]?.activePanel).toBe("changes");
expect(restored?.columns[1]).toEqual(original.columns[1]);
expect(restored?.columns[1]?.tree).toEqual(nestedRightColumn().tree);
});
it("selects only the outermost final region in a three-column layout", () => {
// @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.8
const layout = visibleLayout([
centerColumn(),
{ id: "plan", groups: [{ panels: [panel("plan")] }] },
{ id: "browser", groups: [{ panels: [panel("browser")] }] },
]);
const captured = captureRightPane(layout);
expect(captured?.hiddenRightPane.sourceColumnId).toBe("browser");
expect(captured?.layout.columns.map((column) => column.id)).toEqual(["center", "plan"]);
});
it("does not expose a target for a single region, a vertical split, or an Agent region", () => {
// @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.3
// @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.8
const single = visibleLayout([centerColumn()]);
const vertical = {
...visibleLayout([centerColumn(), { id: "stack", groups: [{ panels: [panel("files")] }] }]),
rootOrientation: "VERTICAL" as const,
};
const agentOnRight = visibleLayout([
centerColumn(),
{ id: "other", groups: [{ panels: [panel("session:session-b", "chat")] }] },
]);
expect(getRightPaneToggleState(single, null)).toEqual({
available: false,
visible: false,
hidden: false,
});
expect(getRightPaneToggleState(vertical, null).available).toBe(false);
expect(getRightPaneToggleState(agentOnRight, null).available).toBe(false);
});
});
it("deduplicates panels reopened in the remaining layout without losing the retained subtree", () => {
// @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.10
const original = visibleLayout([
centerColumn(),
{
id: "right",
groups: [{ id: "right-group", panels: [panel("files"), panel("shared")] }],
},
]);
const captured = captureRightPane(original)!;
const current = visibleLayout([
{
...centerColumn(),
groups: [
{
...centerColumn().groups[0],
panels: [...centerColumn().groups[0].panels, panel("shared")],
},
],
},
]);
const restored = restoreRightPane(current, captured.hiddenRightPane);
expect(restored?.columns[1]?.groups[0]?.panels.map((item) => item.id)).toEqual(["files"]);
const ids = restored!.columns.flatMap((column) =>
column.groups.flatMap((group) => group.panels.map((item) => item.id)),
);
expect(new Set(ids).size).toBe(ids.length);
});
it("rejects recovery metadata that cannot produce a safe serialized layout", () => {
const captured = captureRightPane(visibleLayout([centerColumn(), nestedRightColumn()]))!;
const malformedGroupId = JSON.parse(JSON.stringify(captured.hiddenRightPane)) as {
column: { groups: Array<{ id?: unknown }> };
};
malformedGroupId.column.groups[0].id = 42;
const malformedTree = {
...captured.hiddenRightPane,
column: {
...captured.hiddenRightPane.column,
tree: {
...captured.hiddenRightPane.column.tree!,
size: Number.NaN,
},
},
};
expect(readHiddenRightPane({ kandevHiddenRightPane: malformedGroupId })).toBeNull();
expect(readHiddenRightPane({ kandevHiddenRightPane: malformedTree })).toBeNull();
const current = captured.layout;
expect(restoreRightPane(current, malformedTree)).toBeNull();
expect(current.columns.map((column) => column.id)).toEqual(["center"]);
});
it("keeps the split axis when deduplication removes a nested sibling", () => {
const browserGroup = { id: "browser-group", panels: [panel("browser")] };
const planGroup = { id: "plan-group", panels: [panel("plan")] };
const terminalGroup = {
id: "terminal-group",
activePanel: "terminal",
panels: [panel("terminal", "terminal")],
};
const original = visibleLayout([
{
...centerColumn(),
groups: [
...centerColumn().groups,
{ id: "reopened-terminal", panels: [panel("terminal", "terminal")] },
],
},
{
id: "browser-region",
width: 420,
groups: [browserGroup, planGroup, terminalGroup],
tree: {
type: "branch",
size: 420,
children: [
{
type: "branch",
size: 280,
children: [
{ type: "leaf", size: 140, group: browserGroup },
{ type: "leaf", size: 140, group: planGroup },
],
},
{ type: "leaf", size: 140, group: terminalGroup },
],
},
},
]);
const captured = captureRightPane(original)!;
const restored = restoreRightPane(captured.layout, captured.hiddenRightPane);
expect(restored?.columns[1]?.tree).toMatchObject({ type: "branch" });
const tree = restored?.columns[1]?.tree;
expect(tree?.type).toBe("branch");
if (!tree || tree.type !== "branch") return;
expect(tree.children).toHaveLength(1);
expect(tree.children[0]?.type).toBe("branch");
const serialized = toSerializedDockview(restored!, 1200, 800, new Map());
const root = serialized as unknown as {
grid: {
root: {
data: Array<
{ type: "leaf"; data: { views: string[] } } | { type: "branch"; data: unknown[] }
>;
};
};
};
const rightNode = root.grid.root.data[1];
expect(rightNode?.type).toBe("branch");
if (!rightNode || rightNode.type !== "branch") return;
expect(rightNode.data).toHaveLength(1);
const survivingSplit = rightNode.data[0] as {
type: "branch";
data: Array<{ type: "leaf"; data: { views: string[] } }>;
};
expect(survivingSplit.type).toBe("branch");
expect(survivingSplit.data.map((child) => child.data.views)).toEqual([["browser"], ["plan"]]);
expect(survivingSplit.data).not.toContainEqual({ type: "leaf", data: { views: [] } });
});
it("keeps generated group IDs distinct from live explicit IDs", () => {
const original = visibleLayout([
{
...centerColumn(),
groups: [{ ...centerColumn().groups[0], id: "group-1" }],
},
{ id: "plan", groups: [{ panels: [panel("plan")] }] },
]);
const captured = captureRightPane(original)!;
const restored = restoreRightPane(captured.layout, captured.hiddenRightPane)!;
const serialized = toSerializedDockview(restored, 1200, 800, new Map()) as unknown as {
grid: {
root: {
data: Array<
| { type: "leaf"; data: { id: string } }
| { type: "branch"; data: Array<{ type: "leaf"; data: { id: string } }> }
>;
};
};
};
const ids = serialized.grid.root.data.flatMap((node) =>
node.type === "leaf" ? [node.data.id] : node.data.map((child) => child.data.id),
);
expect(ids).toEqual(["group-1", "group-2"]);
expect(new Set(ids).size).toBe(ids.length);
});
it("restores non-pinned pane width across repeated hide and show cycles", () => {
let current = visibleLayout([
{ ...centerColumn(), width: 600 },
{ id: "plan", width: 600, groups: [{ id: "plan-group", panels: [panel("plan")] }] },
]);
for (let cycle = 0; cycle < 3; cycle++) {
const captured = captureRightPane(current)!;
current = restoreRightPane(captured.layout, captured.hiddenRightPane, {
totalWidth: 1200,
pinnedWidths: new Map(),
})!;
expect(current.columns.map((column) => column.width)).toEqual([600, 600]);
const serialized = toSerializedDockview(current, 1200, 800, new Map());
const root = serialized as unknown as {
grid: { root: { data: Array<{ size: number }> } };
};
expect(root.grid.root.data.map((node) => node.size)).toEqual([600, 600]);
}
});
it("clamps a retained non-pinned pane to viewport space while preserving live proportions", () => {
const original = visibleLayout([
{ ...centerColumn(), width: 400 },
{ id: "preview", width: 200, groups: [{ panels: [panel("preview")] }] },
{ id: "plan", width: 600, groups: [{ panels: [panel("plan")] }] },
]);
const captured = captureRightPane(original)!;
const edited = {
...captured.layout,
columns: captured.layout.columns.map((column, index) => ({
...column,
width: index === 0 ? 800 : 200,
})),
};
const narrow = restoreRightPane(edited, captured.hiddenRightPane, {
totalWidth: 900,
pinnedWidths: new Map(),
})!;
expect(narrow.columns.map((column) => column.width)).toEqual([288, 72, 540]);
const wide = restoreRightPane(edited, captured.hiddenRightPane, {
totalWidth: 1600,
pinnedWidths: new Map(),
})!;
expect(wide.columns.map((column) => column.width)).toEqual([800, 200, 600]);
});
it("keeps a retained pane authoritative until its layout context is invalid", () => {
// @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.5
// @covers AC-UI-RIGHT-PANEL-VISIBILITY-001.10
const captured = captureRightPane(
visibleLayout([centerColumn(), { id: "plan", groups: [{ panels: [panel("plan")] }] }]),
)!;
expect(getRightPaneToggleState(captured.layout, captured.hiddenRightPane)).toEqual({
available: true,
visible: false,
hidden: true,
});
expect(
getRightPaneToggleState(
visibleLayout([{ id: "unrelated", groups: [{ panels: [panel("files")] }] }]),
captured.hiddenRightPane,
),
).toEqual({ available: false, visible: false, hidden: false });
const reopened = visibleLayout([
centerColumn(),
{ id: "reopened", groups: [{ panels: [panel("plan")] }] },
]);
expect(getRightPaneToggleState(reopened, captured.hiddenRightPane)).toEqual({
available: true,
visible: true,
hidden: false,
});
});
it("round-trips and strips environment recovery metadata", () => {
const captured = captureRightPane(
visibleLayout([centerColumn(), { id: "plan", groups: [{ panels: [panel("plan")] }] }]),
)!;
const serialized = withHiddenRightPaneMetadata(
{ grid: { root: { type: "branch" } }, panels: {} },
captured.hiddenRightPane,
);
expect(readHiddenRightPane(serialized)).toEqual(captured.hiddenRightPane);
expect(stripHiddenRightPaneMetadata(serialized)).toEqual({
grid: { root: { type: "branch" } },
panels: {},
});
expect(readHiddenRightPane({ kandevHiddenRightPane: { version: 99 } })).toBeNull();
});
+519
View File
@@ -0,0 +1,519 @@
import type {
LayoutColumn,
LayoutGroup,
LayoutNode,
LayoutOrientation,
LayoutPanel,
LayoutState,
} from "./layout-manager/types";
import { LAYOUT_PINNED_MIN_PX } from "./layout-manager/caps";
import { getPinnedWidth } from "./layout-manager/sizing";
export const HIDDEN_RIGHT_PANE_METADATA_KEY = "kandevHiddenRightPane";
const HIDDEN_RIGHT_PANE_VERSION = 1 as const;
export type HiddenRightPane = {
version: typeof HIDDEN_RIGHT_PANE_VERSION;
sourceIndex: number;
sourceColumnId: string;
rootOrientation: LayoutOrientation;
column: LayoutColumn;
context: {
columnIds: string[];
panelIds: string[];
};
};
export type RightPaneToggleState = {
available: boolean;
visible: boolean;
hidden: boolean;
};
type PaneSelection = {
index: number;
column: LayoutColumn;
};
function groupsInColumn(column: LayoutColumn): LayoutGroup[] {
if (!column.tree) return column.groups;
return collectGroups(column.tree);
}
function collectGroups(node: LayoutNode): LayoutGroup[] {
return node.type === "leaf" ? [node.group] : node.children.flatMap(collectGroups);
}
function panelIdsInColumn(column: LayoutColumn): string[] {
return groupsInColumn(column).flatMap((group) => group.panels.map((panel) => panel.id));
}
function panelIdsInLayout(layout: LayoutState): string[] {
return layout.columns.flatMap(panelIdsInColumn);
}
function hasAgentPanel(column: LayoutColumn): boolean {
return panelIdsInColumn(column).some(
(id) => id === "chat" || id.startsWith("session:") || id === "agent",
);
}
function hasPanels(column: LayoutColumn): boolean {
return panelIdsInColumn(column).length > 0;
}
function isHorizontal(layout: LayoutState): boolean {
return (layout.rootOrientation ?? "HORIZONTAL") === "HORIZONTAL";
}
function workbenchColumnEntries(layout: LayoutState): Array<PaneSelection> {
return layout.columns.flatMap((column, index) =>
column.id === "sidebar" ? [] : [{ index, column }],
);
}
/** Select the actual final side-by-side workbench region. */
export function selectRightPane(layout: LayoutState): PaneSelection | null {
if (!isHorizontal(layout)) return null;
const columns = workbenchColumnEntries(layout);
if (columns.length < 2) return null;
const target = columns.at(-1);
if (!target || !hasPanels(target.column) || hasAgentPanel(target.column)) return null;
return target;
}
function makeContext(layout: LayoutState, sourceIndex: number): HiddenRightPane["context"] {
const remaining = layout.columns.filter((_, index) => index !== sourceIndex);
return {
columnIds: remaining.filter((column) => column.id !== "sidebar").map((column) => column.id),
panelIds: panelIdsInLayout({ ...layout, columns: remaining }).sort(),
};
}
/** Capture the selected pane and the layout left after hiding it. */
export function captureRightPane(
layout: LayoutState,
): { layout: LayoutState; hiddenRightPane: HiddenRightPane } | null {
const selection = selectRightPane(layout);
if (!selection) return null;
const { index, column } = selection;
return {
layout: { ...layout, columns: layout.columns.filter((_, i) => i !== index) },
hiddenRightPane: {
version: HIDDEN_RIGHT_PANE_VERSION,
sourceIndex: index,
sourceColumnId: column.id,
rootOrientation: layout.rootOrientation ?? "HORIZONTAL",
column,
context: makeContext(layout, index),
},
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
function isFiniteNonNegative(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
function isSerializableValue(value: unknown, ancestors = new Set<object>()): boolean {
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (typeof value !== "object") return false;
if (ancestors.has(value)) return false;
ancestors.add(value);
const valid = Array.isArray(value)
? value.every((item) => isSerializableValue(item, ancestors))
: Object.values(value).every((item) => isSerializableValue(item, ancestors));
ancestors.delete(value);
return valid;
}
function isLayoutPanel(value: unknown): value is LayoutPanel {
if (!isRecord(value)) return false;
return (
isNonEmptyString(value.id) &&
isNonEmptyString(value.component) &&
typeof value.title === "string" &&
(value.tabComponent === undefined || isNonEmptyString(value.tabComponent)) &&
(value.params === undefined || (isRecord(value.params) && isSerializableValue(value.params)))
);
}
function isLayoutGroup(value: unknown): value is LayoutGroup {
if (!isRecord(value) || !Array.isArray(value.panels)) return false;
if (
value.panels.length === 0 ||
!value.panels.every(isLayoutPanel) ||
(value.id !== undefined && !isNonEmptyString(value.id)) ||
(value.activePanel !== undefined && !isNonEmptyString(value.activePanel))
) {
return false;
}
return (
value.activePanel === undefined || value.panels.some((panel) => panel.id === value.activePanel)
);
}
function isLayoutNode(value: unknown): value is LayoutNode {
if (!isRecord(value) || (value.type !== "leaf" && value.type !== "branch")) return false;
if (value.size !== undefined && !isFiniteNonNegative(value.size)) return false;
if (value.type === "leaf") return isLayoutGroup(value.group);
return (
Array.isArray(value.children) && value.children.length > 0 && value.children.every(isLayoutNode)
);
}
function isOptionalNonNegative(value: unknown): boolean {
return value === undefined || isFiniteNonNegative(value);
}
function isOptionalPositive(value: unknown): boolean {
return value === undefined || (isFiniteNonNegative(value) && value > 0);
}
function hasValidColumnGeometry(value: Record<string, unknown>): boolean {
if (!isOptionalNonNegative(value.width)) return false;
if (!isOptionalPositive(value.minWidth) || !isOptionalPositive(value.maxWidth)) return false;
if (typeof value.minWidth !== "number" || typeof value.maxWidth !== "number") return true;
return value.maxWidth >= value.minWidth;
}
function isLayoutColumn(value: unknown): value is LayoutColumn {
if (!isRecord(value) || !isNonEmptyString(value.id)) return false;
if (value.pinned !== undefined && typeof value.pinned !== "boolean") return false;
if (!hasValidColumnGeometry(value)) return false;
if (!Array.isArray(value.groups) || !value.groups.every(isLayoutGroup)) return false;
if (value.tree !== undefined && !isLayoutNode(value.tree)) return false;
if (value.tree === undefined) return true;
const treeGroups = collectGroups(value.tree);
const groups = value.groups as LayoutGroup[];
return (
treeGroups.length === groups.length &&
treeGroups.every((group, index) => sameLayoutGroup(group, groups[index]))
);
}
function sameSerializableValue(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left) || Array.isArray(right)) {
return (
Array.isArray(left) &&
Array.isArray(right) &&
left.length === right.length &&
left.every((item, index) => sameSerializableValue(item, right[index]))
);
}
if (isRecord(left) || isRecord(right)) {
if (!isRecord(left) || !isRecord(right)) return false;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
return (
leftKeys.length === rightKeys.length &&
leftKeys.every((key) => key in right && sameSerializableValue(left[key], right[key]))
);
}
return false;
}
function sameLayoutPanel(left: LayoutPanel, right: LayoutPanel): boolean {
return (
left.id === right.id &&
left.component === right.component &&
left.title === right.title &&
left.tabComponent === right.tabComponent &&
sameSerializableValue(left.params, right.params)
);
}
function sameLayoutGroup(left: LayoutGroup, right: LayoutGroup): boolean {
return (
left.id === right.id &&
left.activePanel === right.activePanel &&
left.panels.length === right.panels.length &&
left.panels.every((panel, index) => sameLayoutPanel(panel, right.panels[index]))
);
}
function hasUniquePanelIds(column: LayoutColumn): boolean {
const ids = panelIdsInColumn(column);
return new Set(ids).size === ids.length;
}
function hasUniqueGroupIds(column: LayoutColumn): boolean {
const ids = groupsInColumn(column)
.map((group) => group.id)
.filter((id): id is string => id !== undefined);
return new Set(ids).size === ids.length;
}
function isValidLayoutState(value: unknown): value is LayoutState {
if (!isRecord(value) || !Array.isArray(value.columns)) return false;
if (
value.rootOrientation !== undefined &&
value.rootOrientation !== "HORIZONTAL" &&
value.rootOrientation !== "VERTICAL"
) {
return false;
}
const columns = value.columns as unknown[];
if (!columns.every(isLayoutColumn)) return false;
const validColumns = columns as LayoutColumn[];
const columnIds = validColumns.map((column) => column.id);
if (new Set(columnIds).size !== columnIds.length) return false;
if (!validColumns.every(hasUniqueGroupIds)) return false;
const groupIds = validColumns
.flatMap(groupsInColumn)
.map((group) => group.id)
.filter((id): id is string => id !== undefined);
if (new Set(groupIds).size !== groupIds.length) return false;
const panelIds = panelIdsInLayout({ columns: validColumns });
return new Set(panelIds).size === panelIds.length;
}
function isUniqueNonEmptyStringList(value: unknown): value is string[] {
return (
Array.isArray(value) && value.length === new Set(value).size && value.every(isNonEmptyString)
);
}
function isValidHiddenContext(value: unknown): value is HiddenRightPane["context"] {
return (
isRecord(value) &&
isUniqueNonEmptyStringList(value.columnIds) &&
isUniqueNonEmptyStringList(value.panelIds)
);
}
function isHiddenRightPane(value: unknown): value is HiddenRightPane {
if (!isRecord(value)) return false;
if (
value.version !== HIDDEN_RIGHT_PANE_VERSION ||
typeof value.sourceIndex !== "number" ||
!Number.isInteger(value.sourceIndex) ||
value.sourceIndex < 0 ||
!isNonEmptyString(value.sourceColumnId) ||
(value.rootOrientation !== "HORIZONTAL" && value.rootOrientation !== "VERTICAL")
) {
return false;
}
if (!isLayoutColumn(value.column)) return false;
if (!hasUniquePanelIds(value.column) || !hasUniqueGroupIds(value.column)) return false;
if (value.sourceColumnId !== value.column.id) return false;
return isValidHiddenContext(value.context);
}
/** Read and validate optional recovery metadata from an env layout record. */
export function readHiddenRightPane(record: object | null): HiddenRightPane | null {
if (!isRecord(record)) return null;
const value = record[HIDDEN_RIGHT_PANE_METADATA_KEY];
return isHiddenRightPane(value) ? value : null;
}
/** Remove app-owned metadata before passing a record to Dockview. */
export function stripHiddenRightPaneMetadata(record: object): object {
if (!isRecord(record) || !(HIDDEN_RIGHT_PANE_METADATA_KEY in record)) return record;
const { [HIDDEN_RIGHT_PANE_METADATA_KEY]: _metadata, ...dockviewRecord } = record;
return dockviewRecord;
}
/** Compose an env layout record without storing hidden recovery in portable layouts. */
export function withHiddenRightPaneMetadata(
layout: object,
hiddenRightPane: HiddenRightPane | null,
): object {
const record = { ...(isRecord(layout) ? layout : {}) } as Record<string, unknown>;
if (hiddenRightPane) record[HIDDEN_RIGHT_PANE_METADATA_KEY] = hiddenRightPane;
else delete record[HIDDEN_RIGHT_PANE_METADATA_KEY];
return record;
}
function hasContextOverlap(layout: LayoutState, hiddenRightPane: HiddenRightPane): boolean {
const currentColumnIds = new Set(
layout.columns.filter((column) => column.id !== "sidebar").map((column) => column.id),
);
const currentPanelIds = new Set(panelIdsInLayout(layout));
return (
hiddenRightPane.context.columnIds.length === 0 ||
hiddenRightPane.context.columnIds.some((id) => currentColumnIds.has(id)) ||
hiddenRightPane.context.panelIds.some((id) => currentPanelIds.has(id))
);
}
function isCompatibleWithCurrentLayout(
layout: LayoutState,
hiddenRightPane: HiddenRightPane,
): boolean {
if (!isValidLayoutState(layout) || !isHiddenRightPane(hiddenRightPane)) return false;
if (!isHorizontal(layout)) return false;
if ((layout.rootOrientation ?? "HORIZONTAL") !== hiddenRightPane.rootOrientation) return false;
if (workbenchColumnEntries(layout).length === 0) return false;
return hasContextOverlap(layout, hiddenRightPane);
}
function isPaneFullyPresent(layout: LayoutState, hiddenRightPane: HiddenRightPane): boolean {
const currentPanelIds = new Set(panelIdsInLayout(layout));
const hiddenPanelIds = panelIdsInColumn(hiddenRightPane.column);
return hiddenPanelIds.length > 0 && hiddenPanelIds.every((id) => currentPanelIds.has(id));
}
function filterGroup(group: LayoutGroup, usedPanelIds: Set<string>): LayoutGroup | null {
const panels = group.panels.filter((panel) => {
if (usedPanelIds.has(panel.id)) return false;
usedPanelIds.add(panel.id);
return true;
});
if (panels.length === 0) return null;
const activePanel = panels.some((panel) => panel.id === group.activePanel)
? group.activePanel
: panels[0].id;
return { ...group, panels, activePanel };
}
function filterNode(node: LayoutNode, usedPanelIds: Set<string>): LayoutNode | null {
if (node.type === "leaf") {
const group = filterGroup(node.group, usedPanelIds);
return group ? { ...node, group } : null;
}
const children = node.children
.map((child) => filterNode(child, usedPanelIds))
.filter(Boolean) as LayoutNode[];
if (children.length === 0) return null;
return { ...node, children };
}
function filterColumn(column: LayoutColumn, usedPanelIds: Set<string>): LayoutColumn | null {
if (column.tree) {
const tree = filterNode(column.tree, usedPanelIds);
if (!tree) return null;
return { ...column, tree, groups: collectGroups(tree) };
}
const groups = column.groups
.map((group) => filterGroup(group, usedPanelIds))
.filter(Boolean) as LayoutGroup[];
return groups.length > 0 ? { ...column, groups } : null;
}
function rebalanceRestoredFlexWidths(
columns: LayoutColumn[],
restoredColumnId: string,
options: { totalWidth?: number; pinnedWidths?: ReadonlyMap<string, number> },
): LayoutColumn[] {
const { totalWidth, pinnedWidths } = options;
if (typeof totalWidth !== "number" || !Number.isFinite(totalWidth) || totalWidth <= 0) {
return columns;
}
const restoredColumn = columns.find((column) => column.id === restoredColumnId);
if (!restoredColumn || restoredColumn.pinned || restoredColumn.width === undefined) {
return columns;
}
const pinnedTotal = columns.reduce(
(total, column) =>
total +
(column.pinned ? getPinnedWidth(column, totalWidth, pinnedWidths?.get(column.id)) : 0),
0,
);
const availableFlex = Math.floor(totalWidth - pinnedTotal);
const flexColumns = columns.filter((column) => !column.pinned);
const remainingFlex = flexColumns.filter((column) => column.id !== restoredColumnId);
if (availableFlex <= 0 || remainingFlex.length === 0) {
return availableFlex > 0
? columns.map((column) =>
column.id === restoredColumnId ? { ...column, width: availableFlex } : column,
)
: columns;
}
const minimum = Math.max(
1,
Math.min(LAYOUT_PINNED_MIN_PX, Math.floor(availableFlex / flexColumns.length)),
);
const targetWidth = Math.max(
minimum,
Math.min(restoredColumn.width, availableFlex - minimum * remainingFlex.length),
);
const remainingWidth = availableFlex - targetWidth;
const weights = remainingFlex.map((column) =>
typeof column.width === "number" && Number.isFinite(column.width) && column.width > 0
? column.width
: 1,
);
const totalWeight = weights.reduce((total, width) => total + width, 0);
let allocated = 0;
let remainingIndex = 0;
return columns.map((column) => {
if (column.id === restoredColumnId) return { ...column, width: targetWidth };
if (column.pinned) return column;
const weight = weights[remainingIndex] ?? 0;
const isLast = remainingIndex === remainingFlex.length - 1;
const width = isLast
? remainingWidth - allocated
: Math.round((remainingWidth * weight) / totalWeight);
allocated += width;
remainingIndex++;
return { ...column, width };
});
}
/** Restore a retained pane into the current layout, preserving live edits. */
export function restoreRightPane(
layout: LayoutState,
hiddenRightPane: HiddenRightPane,
options: {
totalWidth?: number;
pinnedWidths?: ReadonlyMap<string, number>;
} = {},
): LayoutState | null {
if (
!isHiddenRightPane(hiddenRightPane) ||
!isCompatibleWithCurrentLayout(layout, hiddenRightPane)
) {
return null;
}
if (layout.columns.some((column) => column.id === hiddenRightPane.sourceColumnId)) {
return null;
}
const usedPanelIds = new Set(panelIdsInLayout(layout));
const column = filterColumn(hiddenRightPane.column, usedPanelIds);
if (!column) return null;
const columns = [...layout.columns];
const insertionIndex = Math.min(hiddenRightPane.sourceIndex, columns.length);
columns.splice(insertionIndex, 0, column);
const restored = {
...layout,
columns: rebalanceRestoredFlexWidths(columns, column.id, options),
};
return isValidLayoutState(restored) ? restored : null;
}
/** Derive the control state. A retained pane always wins over a new target. */
export function getRightPaneToggleState(
layout: LayoutState,
hiddenRightPane: HiddenRightPane | null,
): RightPaneToggleState {
if (
hiddenRightPane &&
isCompatibleWithCurrentLayout(layout, hiddenRightPane) &&
!isPaneFullyPresent(layout, hiddenRightPane)
) {
return { available: true, visible: false, hidden: true };
}
const selection = selectRightPane(layout);
return selection
? { available: true, visible: true, hidden: false }
: { available: false, visible: false, hidden: false };
}
@@ -0,0 +1,591 @@
import { beforeEach, expect, it, vi } from "vitest";
import type { DockviewApi, SerializedDockview } from "dockview-react";
import { removeEnvMaximizeState, setEnvLayout } from "@/lib/local-storage";
const CENTER_COLUMN_ID = "center";
const CENTER_GROUP_ID = "group-center";
const RIGHT_COLUMN_ID = "right";
const RIGHT_GROUP_ID = "group-right-top";
const SESSION_PANEL_ID = "session:session-a";
const PR_DETAILS_PANEL_ID = "pr-detail";
const PR_DETAILS_PANEL_TITLE = "PR Details";
const MOCK_TERMINAL = vi.hoisted(() => ({ id: "terminal-default" }));
vi.mock("@/lib/local-storage", () => ({
setEnvLayout: vi.fn(),
getEnvLayout: vi.fn(() => null),
getEnvLayoutProfile: vi.fn(() => null),
setEnvLayoutProfile: vi.fn(),
getEnvMaximizeState: vi.fn(() => null),
setEnvMaximizeState: vi.fn(),
removeEnvMaximizeState: vi.fn(),
getGlobalSidebarWidth: vi.fn(() => null),
setGlobalSidebarWidth: vi.fn(),
clearGlobalSidebarWidth: vi.fn(),
}));
vi.mock("@/lib/layout/panel-portal-manager", () => ({
panelPortalManager: { releaseByEnv: vi.fn(), reconcile: vi.fn() },
}));
vi.mock("./dockview-scroll-preserve", () => ({
preserveChatScrollDuringLayout: vi.fn(),
}));
vi.mock("./dockview-measure", () => ({
measureDockviewContainer: vi.fn(() => ({ width: 800, height: 600 })),
}));
vi.mock("./dockview-pinned-enforce", () => ({
enforcePinnedTargets: vi.fn(),
}));
vi.mock("./dockview-layout-builders", () => ({
applyLayoutFixups: vi.fn(() => ({
sidebarGroupId: "g-sidebar",
centerGroupId: "g-center",
rightTopGroupId: "g-right-top",
rightBottomGroupId: "g-right-bottom",
})),
focusOrAddPanel: vi.fn(),
}));
vi.mock("./layout-manager", async (importOriginal) => {
const actual = await importOriginal<typeof import("./layout-manager")>();
return {
...actual,
SIDEBAR_GROUP: "sidebar",
CENTER_GROUP: "group-center",
RIGHT_TOP_GROUP: "group-right-top",
RIGHT_BOTTOM_GROUP: "group-right-bottom",
TERMINAL_DEFAULT_ID: MOCK_TERMINAL.id,
LAYOUT_SIDEBAR_RATIO: 0.2,
LAYOUT_RIGHT_RATIO: 0.25,
LAYOUT_PINNED_MIN_PX: 200,
computeSidebarMaxPx: vi.fn(() => 350),
computeRightMaxPx: vi.fn(() => 500),
getPresetLayout: vi.fn(() => ({ columns: [] })),
applyLayout: vi.fn(() => ({
sidebarGroupId: "g-sidebar",
centerGroupId: "g-center",
rightTopGroupId: "g-right-top",
rightBottomGroupId: "g-right-bottom",
})),
getPinnedWidth: vi.fn(() => 350),
getRootSplitview: vi.fn(() => null),
fromDockviewApi: vi.fn(() => ({ columns: [] })),
filterEphemeral: vi.fn((state: unknown) => state),
defaultLayout: vi.fn(() => ({ columns: [] })),
mergeCurrentPanelsIntoPreset: vi.fn((_api: unknown, preset: unknown) => preset),
toSerializedDockview: vi.fn((state: unknown) => state),
injectIntentPanels: vi.fn(),
applyActivePanelOverrides: vi.fn(),
resolveNamedIntent: vi.fn(),
setPinnedTarget: vi.fn(),
clearPinnedTarget: vi.fn(),
getPinnedTarget: vi.fn(() => undefined),
layoutStructuresMatch: vi.fn(() => false),
savedLayoutMatchesLive: vi.fn(() => false),
};
});
import { applyLayout, defaultLayout, fromDockviewApi } from "./layout-manager";
import { captureRightPane } from "./dockview-right-pane";
import { hasRightColumn, useDockviewStore } from "./dockview-store";
function makeStoreApi(): DockviewApi {
return {
width: 800,
height: 600,
panels: [],
groups: [],
layout: vi.fn(),
fromJSON: vi.fn(),
toJSON: vi.fn(() => ({ columns: [{ id: CENTER_COLUMN_ID }] })),
getPanel: vi.fn(() => null),
addPanel: vi.fn(),
activeGroup: null,
hasMaximizedGroup: vi.fn(() => false),
exitMaximizedGroup: vi.fn(),
onDidActivePanelChange: vi.fn(() => ({ dispose: vi.fn() })),
} as unknown as DockviewApi;
}
function flushRaf(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
function resetStore() {
vi.clearAllMocks();
useDockviewStore.setState({
api: null,
currentLayoutEnvId: null,
preMaximizeLayout: null,
maximizedGroupId: null,
isRestoringLayout: false,
pinnedWidths: new Map(),
userDefaultLayout: null,
userDefaultLayoutProfile: { kind: "built-in", id: "default" },
activeLayoutProfile: { kind: "built-in", id: "default" },
defaultPreset: "default",
rightPanelsVisible: true,
rightPaneVisible: true,
rightPaneAvailable: false,
hiddenRightPane: null,
});
}
beforeEach(resetStore);
it("hides the actual rightmost Plan region instead of rebuilding the default sidebar", async () => {
const api = makeStoreApi();
vi.mocked(fromDockviewApi).mockReturnValue({
columns: [
{
id: CENTER_COLUMN_ID,
groups: [
{
id: CENTER_GROUP_ID,
panels: [{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" }],
},
],
},
{
id: "plan",
groups: [{ id: "group-plan", panels: [{ id: "plan", component: "plan", title: "Plan" }] }],
},
],
});
useDockviewStore.setState({ api, rightPanelsVisible: true, defaultPreset: "plan" });
useDockviewStore.getState().toggleRightPanels();
const appliedState = vi.mocked(applyLayout).mock.calls.at(-1)?.[1];
expect(appliedState?.columns.map((column) => column.id)).toEqual([CENTER_COLUMN_ID]);
await flushRaf();
});
it("retains the hidden pane when applying a toggle layout fails", () => {
const api = makeStoreApi();
const originalDockview = {
grid: { root: { type: "branch", data: ["agent", "plan"], size: 600 } },
panels: { [SESSION_PANEL_ID]: { id: SESSION_PANEL_ID, contentComponent: "chat" } },
} as unknown as SerializedDockview;
let liveDockview: SerializedDockview = originalDockview;
let fromJsonCalls = 0;
vi.mocked(api.toJSON).mockImplementation(() => liveDockview);
vi.mocked(api.fromJSON).mockImplementation((next) => {
liveDockview = next;
if (fromJsonCalls++ === 0) throw new Error("Dockview rejected the new layout");
});
const fullLayout = {
rootOrientation: "HORIZONTAL" as const,
columns: [
{
id: CENTER_COLUMN_ID,
groups: [
{
id: CENTER_GROUP_ID,
panels: [{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" }],
},
],
},
{
id: "plan",
groups: [{ id: "group-plan", panels: [{ id: "plan", component: "plan", title: "Plan" }] }],
},
],
};
const captured = captureRightPane(fullLayout)!;
vi.mocked(fromDockviewApi).mockReturnValue(captured.layout);
vi.mocked(applyLayout).mockImplementationOnce((dockviewApi) => {
dockviewApi.fromJSON({ grid: { root: { type: "branch", data: [] } }, panels: {} } as never);
throw new Error("layout failed");
});
useDockviewStore.setState({
api,
hiddenRightPane: captured.hiddenRightPane,
rightPaneVisible: false,
rightPaneAvailable: true,
});
useDockviewStore.getState().toggleRightPanels();
expect(useDockviewStore.getState().hiddenRightPane).toEqual(captured.hiddenRightPane);
expect(useDockviewStore.getState().rightPaneVisible).toBe(false);
expect(useDockviewStore.getState().rightPaneAvailable).toBe(true);
expect(useDockviewStore.getState().isRestoringLayout).toBe(false);
expect(api.fromJSON).toHaveBeenCalledTimes(2);
expect(api.fromJSON).toHaveBeenLastCalledWith(originalDockview);
expect(liveDockview).toBe(originalDockview);
expect(setEnvLayout).not.toHaveBeenCalled();
});
it("recognizes the right column from its stable column or group identity", () => {
expect(
hasRightColumn({
columns: [
{ id: CENTER_COLUMN_ID, groups: [{ id: CENTER_GROUP_ID, panels: [] }] },
{ id: RIGHT_COLUMN_ID, groups: [] },
],
}),
).toBe(true);
expect(
hasRightColumn({
columns: [{ id: "custom-column", groups: [{ id: RIGHT_GROUP_ID, panels: [] }] }],
}),
).toBe(true);
expect(
hasRightColumn({
columns: [{ id: CENTER_COLUMN_ID, groups: [{ id: CENTER_GROUP_ID, panels: [] }] }],
}),
).toBe(false);
});
it("does not fabricate a right column when the layout has one workbench region", async () => {
const api = makeStoreApi();
vi.mocked(defaultLayout).mockReturnValue({
columns: [
{
id: RIGHT_COLUMN_ID,
pinned: true,
groups: [
{
id: RIGHT_GROUP_ID,
panels: [
{ id: "files", component: "files", title: "Files" },
{ id: "changes", component: "changes", title: "Changes" },
],
},
{
id: "group-right-bottom",
panels: [{ id: MOCK_TERMINAL.id, component: "terminal", title: "Terminal" }],
},
],
},
],
});
vi.mocked(fromDockviewApi).mockReturnValue({
columns: [
{
id: CENTER_COLUMN_ID,
groups: [
{
id: CENTER_GROUP_ID,
panels: [
{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" },
{ id: "browser", component: "browser", title: "Browser" },
{
id: PR_DETAILS_PANEL_ID,
component: PR_DETAILS_PANEL_ID,
title: PR_DETAILS_PANEL_TITLE,
},
{ id: "files", component: "files", title: "Files" },
{ id: "changes", component: "changes", title: "Changes" },
{ id: MOCK_TERMINAL.id, component: "terminal", title: "Terminal" },
],
},
],
},
],
});
useDockviewStore.setState({
api,
rightPanelsVisible: false,
defaultPreset: "compact",
activeLayoutProfile: { kind: "built-in", id: "compact" },
});
useDockviewStore.getState().toggleRightPanels();
expect(applyLayout).not.toHaveBeenCalled();
expect(defaultLayout).not.toHaveBeenCalled();
expect(useDockviewStore.getState().rightPaneAvailable).toBe(false);
await flushRaf();
});
it("does not mutate the temporary maximized layout", () => {
const api = makeStoreApi();
useDockviewStore.setState({
api,
rightPanelsVisible: true,
preMaximizeLayout: {
columns: [
{
id: CENTER_COLUMN_ID,
groups: [
{
id: CENTER_GROUP_ID,
panels: [{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" }],
},
],
},
{
id: RIGHT_COLUMN_ID,
groups: [
{ id: RIGHT_GROUP_ID, panels: [{ id: "files", component: "files", title: "Files" }] },
],
},
],
},
});
useDockviewStore.getState().toggleRightPanels();
expect(applyLayout).not.toHaveBeenCalled();
expect(api.layout).not.toHaveBeenCalled();
expect(useDockviewStore.getState().rightPanelsVisible).toBe(true);
});
it("keeps visibility coherent through maximize, exit, and regular-layout persistence", async () => {
const api = makeStoreApi();
vi.mocked(fromDockviewApi).mockReturnValue({
columns: [
{
id: CENTER_COLUMN_ID,
groups: [
{
id: CENTER_GROUP_ID,
panels: [{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" }],
},
],
},
{
id: RIGHT_COLUMN_ID,
groups: [
{ id: RIGHT_GROUP_ID, panels: [{ id: "files", component: "files", title: "Files" }] },
],
},
],
});
useDockviewStore.setState({
api,
currentLayoutEnvId: "env-maximize",
rightPanelsVisible: false,
});
useDockviewStore.getState().maximizeGroup(CENTER_GROUP_ID);
expect(useDockviewStore.getState().preMaximizeLayout).not.toBeNull();
expect(useDockviewStore.getState().rightPanelsVisible).toBe(true);
await flushRaf();
useDockviewStore.getState().exitMaximizedLayout();
expect(useDockviewStore.getState().preMaximizeLayout).toBeNull();
expect(useDockviewStore.getState().rightPanelsVisible).toBe(true);
await flushRaf();
expect(removeEnvMaximizeState).toHaveBeenCalledWith("env-maximize");
expect(setEnvLayout).toHaveBeenCalledWith("env-maximize", expect.anything());
});
it("preserves right-named panels that belong to a mixed center column", async () => {
const api = makeStoreApi();
vi.mocked(fromDockviewApi).mockReturnValue({
columns: [
{
id: CENTER_COLUMN_ID,
groups: [
{
id: CENTER_GROUP_ID,
panels: [
{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" },
{ id: "files", component: "files", title: "Files" },
{
id: PR_DETAILS_PANEL_ID,
component: PR_DETAILS_PANEL_ID,
title: PR_DETAILS_PANEL_TITLE,
},
],
},
],
},
{
id: RIGHT_COLUMN_ID,
groups: [
{
id: RIGHT_GROUP_ID,
panels: [{ id: "terminal", component: "terminal", title: "Terminal" }],
},
],
},
],
});
useDockviewStore.setState({ api, rightPanelsVisible: true, defaultPreset: "default" });
useDockviewStore.getState().toggleRightPanels();
const appliedState = vi.mocked(applyLayout).mock.calls.at(-1)?.[1];
expect(appliedState?.columns.map((column) => column.id)).toEqual([CENTER_COLUMN_ID]);
expect(appliedState?.columns[0]?.groups[0]?.panels.map((panel) => panel.id)).toEqual([
SESSION_PANEL_ID,
"files",
PR_DETAILS_PANEL_ID,
]);
await flushRaf();
});
it("removes an identified right column even when it contains a custom panel", async () => {
const api = makeStoreApi();
vi.mocked(fromDockviewApi).mockReturnValue({
columns: [
{
id: CENTER_COLUMN_ID,
groups: [
{
id: CENTER_GROUP_ID,
panels: [{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" }],
},
],
},
{
id: RIGHT_COLUMN_ID,
groups: [
{ id: RIGHT_GROUP_ID, panels: [{ id: "plugin", component: "plugin", title: "Plugin" }] },
],
},
],
});
useDockviewStore.setState({ api, rightPanelsVisible: true, defaultPreset: "default" });
useDockviewStore.getState().toggleRightPanels();
const appliedState = vi.mocked(applyLayout).mock.calls.at(-1)?.[1];
expect(appliedState?.columns.map((column) => column.id)).toEqual([CENTER_COLUMN_ID]);
await flushRaf();
});
it("does not restore a fabricated right column when only the center remains", async () => {
const api = makeStoreApi();
vi.mocked(fromDockviewApi).mockReturnValue({
columns: [
{
id: CENTER_COLUMN_ID,
groups: [
{
id: CENTER_GROUP_ID,
panels: [
{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" },
{ id: "files", component: "files", title: "Files" },
],
},
],
},
],
});
useDockviewStore.setState({ api, rightPanelsVisible: false, defaultPreset: "default" });
useDockviewStore.getState().toggleRightPanels();
expect(applyLayout).not.toHaveBeenCalled();
expect(defaultLayout).not.toHaveBeenCalled();
expect(useDockviewStore.getState().rightPaneAvailable).toBe(false);
await flushRaf();
});
it("keeps a center fallback PR Details tab when hiding right panels", async () => {
const api = makeStoreApi();
vi.mocked(fromDockviewApi).mockReturnValue({
columns: [
{
id: CENTER_COLUMN_ID,
groups: [
{
id: CENTER_GROUP_ID,
panels: [
{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" },
{
id: PR_DETAILS_PANEL_ID,
component: PR_DETAILS_PANEL_ID,
title: PR_DETAILS_PANEL_TITLE,
},
],
},
],
},
{
id: RIGHT_COLUMN_ID,
pinned: true,
groups: [
{ id: RIGHT_GROUP_ID, panels: [{ id: "files", component: "files", title: "Files" }] },
],
},
],
});
useDockviewStore.setState({ api, rightPanelsVisible: true, defaultPreset: "default" });
useDockviewStore.getState().toggleRightPanels();
const appliedState = vi.mocked(applyLayout).mock.calls.at(-1)?.[1];
expect(appliedState?.columns.map((column) => column.id)).toEqual([CENTER_COLUMN_ID]);
expect(appliedState?.columns[0]?.groups[0]?.panels.map((panel) => panel.id)).toEqual([
SESSION_PANEL_ID,
PR_DETAILS_PANEL_ID,
]);
await flushRaf();
});
it("does not show a default sidebar when showing from a single workbench region", async () => {
const api = makeStoreApi();
vi.mocked(fromDockviewApi).mockReturnValue({
columns: [
{
id: CENTER_COLUMN_ID,
groups: [
{
id: CENTER_GROUP_ID,
panels: [
{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" },
{
id: PR_DETAILS_PANEL_ID,
component: PR_DETAILS_PANEL_ID,
title: PR_DETAILS_PANEL_TITLE,
},
],
},
],
},
],
});
useDockviewStore.setState({ api, rightPanelsVisible: false, defaultPreset: "default" });
useDockviewStore.getState().toggleRightPanels();
expect(applyLayout).not.toHaveBeenCalled();
expect(defaultLayout).not.toHaveBeenCalled();
expect(useDockviewStore.getState().rightPaneAvailable).toBe(false);
await flushRaf();
});
it("persists a settled visibility toggle for the current environment", async () => {
const api = makeStoreApi();
vi.mocked(fromDockviewApi).mockReturnValue({
columns: [
{
id: CENTER_COLUMN_ID,
groups: [
{
id: CENTER_GROUP_ID,
panels: [{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" }],
},
],
},
{
id: RIGHT_COLUMN_ID,
groups: [
{ id: RIGHT_GROUP_ID, panels: [{ id: "files", component: "files", title: "Files" }] },
],
},
],
});
useDockviewStore.setState({
api,
currentLayoutEnvId: "env-1",
rightPanelsVisible: true,
defaultPreset: "default",
});
useDockviewStore.getState().toggleRightPanels();
await flushRaf();
expect(setEnvLayout).toHaveBeenCalledWith("env-1", expect.anything());
});
+243 -159
View File
@@ -21,7 +21,6 @@ import {
CENTER_GROUP,
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
TERMINAL_DEFAULT_ID,
getPresetLayout,
applyLayout,
computePinnedMaxPxFor,
@@ -29,8 +28,8 @@ import {
getRootSplitview,
LAYOUT_PINNED_MIN_PX,
fromDockviewApi,
isRightColumn as isLayoutRightColumn,
filterEphemeral,
defaultLayout,
mergeCurrentPanelsIntoPreset,
toSerializedDockview,
normalizeReusableSessionPanels,
@@ -40,6 +39,14 @@ import type { BuiltInPreset, LayoutState, LayoutGroupIds } from "./layout-manage
import type { CommitDetailTarget } from "@/components/task/changes-diff-target";
import type { ReviewItemSummary } from "@/lib/plugins/types";
import { performEnvSwitch, replaceStaleSessionPanels } from "./dockview-env-switch";
import {
captureRightPane,
getRightPaneToggleState,
readHiddenRightPane,
restoreRightPane,
withHiddenRightPaneMetadata,
type HiddenRightPane,
} from "./dockview-right-pane";
import { enforcePinnedTargets } from "./dockview-pinned-enforce";
import {
injectIntentPanels,
@@ -68,8 +75,6 @@ const debugSwitch = createDebugLogger("dockview:store-switch");
const debugSave = createDebugLogger("dockview:save");
const debugWidths = createDebugLogger("dockview:widths");
const RIGHT_PANEL_IDS = new Set(["changes", "files", TERMINAL_DEFAULT_ID]);
const DEFAULT_LAYOUT_PROFILE: LayoutProfileIdentity = { kind: "built-in", id: "default" };
function profileForCustomLayout(layout: Pick<SavedLayoutConfig, "id">): LayoutProfileIdentity {
@@ -276,12 +281,16 @@ type DockviewStore = {
sidebarGroupId: string;
sidebarVisible: boolean;
rightPanelsVisible: boolean;
rightPaneVisible: boolean;
rightPaneAvailable: boolean;
hiddenRightPane: HiddenRightPane | null;
/** Identity of the active built-in or saved custom layout profile. */
activeLayoutProfile: LayoutProfileIdentity;
toggleSidebar: () => void;
toggleRightPanels: () => void;
setSidebarVisible: (visible: boolean) => void;
setRightPanelsVisible: (visible: boolean) => void;
refreshRightPaneState: () => void;
applyBuiltInPreset: (preset: BuiltInPreset, resetWidths?: boolean) => void;
defaultPreset: BuiltInPreset;
setDefaultPreset: (preset: BuiltInPreset) => void;
@@ -369,10 +378,9 @@ function applyDeferredPanelActions(api: DockviewApi, actions: DeferredPanelActio
* Build the pinnedWidths updates for a width sync, tracking only the VISIBLE
* default right column.
*
* In plan/preview/vscode layouts the side column inherits merged files/changes
* panels and `fromDockviewApi` mislabels it "right"; storing its width as the
* right override would then leak into the default layout when toggling back
* (e.g. plan-mode off snapping the right column to the plan column's width).
* In plan/preview/vscode layouts a side column can inherit merged files or
* changes panels. The serializer preserves the center/right ownership before
* this sync runs, so only the canonical right column receives the override.
*
* Sidebar is intentionally excluded: its persisted width is a global pref
* written only by explicit sash drag, and syncing live layout-change widths
@@ -574,69 +582,66 @@ function applyLayoutAndSet(
// `pane-resize-sidebar.spec.ts:41` flake mode: cap=301 from a 601px stale
// measurement clamps the 430px sidebar override down to 301).
const measured = preMeasured ?? measureDockviewContainer(api);
const ids = applyLayout(api, state, pinnedWidths, measured.width, measured.height);
set(ids);
return ids;
const previousLayout = typeof api.toJSON === "function" ? api.toJSON() : undefined;
try {
const ids = applyLayout(api, state, pinnedWidths, measured.width, measured.height);
set(ids);
return ids;
} catch (error) {
// Dockview can clear its grid before reporting a deserialization error. Roll back once so
// every programmatic layout transition either commits completely or leaves the live grid intact.
try {
if (previousLayout) {
api.fromJSON(previousLayout);
}
} catch {
// A failed rollback cannot be repaired by retrying without risking another partial mutation.
}
throw error;
}
}
/** True when the column is the default right column or hosts a right-side group. */
function isRightColumn(column: LayoutState["columns"][number]): boolean {
return (
column.id === "right" ||
column.groups.some((group) => group.id === RIGHT_TOP_GROUP || group.id === RIGHT_BOTTOM_GROUP)
);
function restoreSerializedDockview(api: DockviewApi, next: SerializedDockview): void {
const previous = api.toJSON();
try {
api.fromJSON(next);
} catch (error) {
try {
api.fromJSON(previous);
} catch {
// A failed rollback cannot be repaired by retrying without risking another partial mutation.
}
throw error;
}
}
/**
* True when the column holds at least one right-owned panel (changes, files,
* terminal — or pr-detail when the column is a right column).
*/
function columnHasRightPanel(column: LayoutState["columns"][number]): boolean {
const includesLayoutOwnedPRDetails = isRightColumn(column);
return column.groups.some((group) =>
group.panels.some(
(panel) =>
RIGHT_PANEL_IDS.has(panel.id) || (includesLayoutOwnedPRDetails && panel.id === "pr-detail"),
),
);
/** True when the column belongs to the right-side workbench surface. */
export const isRightColumn = isLayoutRightColumn;
export function hasRightColumn(state: LayoutState): boolean {
return state.columns.some(isRightColumn);
}
/**
* Return a copy of the layout with right-owned tabs (changes/files/terminal,
* plus pr-detail in right columns) stripped from their groups; groups and
* columns left empty are dropped, and `activePanel` falls back to the first
* remaining panel.
*/
function removeRightPanelTabs(state: LayoutState): LayoutState {
const columns = state.columns
.map((col) => {
const includesLayoutOwnedPRDetails = isRightColumn(col);
const groups = col.groups
.map((group) => {
const panels = group.panels.filter(
(panel) =>
!RIGHT_PANEL_IDS.has(panel.id) &&
!(includesLayoutOwnedPRDetails && panel.id === "pr-detail"),
);
if (panels.length === group.panels.length) return group;
const activePanel = panels.some((panel) => panel.id === group.activePanel)
? group.activePanel
: panels[0]?.id;
return { ...group, panels, activePanel };
})
.filter((group) => group.panels.length > 0);
return { ...col, groups };
})
.filter((col) => col.groups.length > 0);
return { columns };
function visibilityForLayout(
layout: LayoutState,
hiddenRightPane: HiddenRightPane | null,
): Pick<DockviewStore, "rightPanelsVisible" | "rightPaneVisible" | "rightPaneAvailable"> {
if (!Array.isArray(layout.columns)) {
return {
rightPanelsVisible: false,
rightPaneVisible: false,
rightPaneAvailable: false,
};
}
const paneState = getRightPaneToggleState(layout, hiddenRightPane);
return {
rightPanelsVisible: hasRightColumn(layout),
rightPaneVisible: paneState.visible,
rightPaneAvailable: paneState.available,
};
}
/**
* Build the store's visibility actions. `toggleRightPanels` shows/hides the
* right column, capturing live widths and chat scroll before the layout swap
* and re-enforcing pinned targets afterwards; the legacy sidebar toggles are
* kept as no-ops since the embedded sidebar moved to the unified AppSidebar.
*/
/** Build the store's visibility actions for the contextual right pane. */
function buildVisibilityActions(set: StoreSet, get: StoreGet) {
return {
// Legacy dockview-embedded sidebar is gone after the unified AppSidebar
@@ -646,52 +651,81 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
/* moved to UI slice: toggleAppSidebar */
},
toggleRightPanels: () => {
const { api, rightPanelsVisible, defaultPreset } = get();
if (!api) return;
if (!rightPanelsVisible && defaultPreset === "compact") return;
const { api, hiddenRightPane, preMaximizeLayout } = get();
if (!api || preMaximizeLayout) return;
const targetEnvId = get().currentLayoutEnvId;
const liveWidths = captureLiveWidths(api, set);
preserveChatScrollDuringLayout();
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
if (rightPanelsVisible) {
const current = fromDockviewApi(api);
const withoutRight: LayoutState = {
columns: current.columns.filter((column) => !columnHasRightPanel(column)),
};
set({ isRestoringLayout: true, rightPanelsVisible: false });
applyLayoutAndSet(api, withoutRight, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
const current = fromDockviewApi(api);
let nextLayout: LayoutState;
let nextHiddenRightPane: HiddenRightPane | null;
if (hiddenRightPane) {
const restored = restoreRightPane(current, hiddenRightPane, {
totalWidth: safeWidth,
pinnedWidths: liveWidths,
});
if (!restored) {
set({ hiddenRightPane: null, ...visibilityForLayout(current, null) });
return;
}
nextLayout = restored;
nextHiddenRightPane = null;
} else {
const defLayout = defaultLayout();
const rightCol = defLayout.columns.find((c) => c.id === "right");
if (!rightCol) return;
const current = removeRightPanelTabs(fromDockviewApi(api));
const withRight: LayoutState = {
columns: [...current.columns, rightCol],
};
set({ isRestoringLayout: true, rightPanelsVisible: true });
applyLayoutAndSet(api, withRight, liveWidths, set);
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
const captured = captureRightPane(current);
if (!captured) {
set({ hiddenRightPane: null, ...visibilityForLayout(current, null) });
return;
}
nextLayout = captured.layout;
nextHiddenRightPane = captured.hiddenRightPane;
}
set({
isRestoringLayout: true,
hiddenRightPane: nextHiddenRightPane,
...visibilityForLayout(nextLayout, nextHiddenRightPane),
});
try {
applyLayoutAndSet(api, nextLayout, liveWidths, set);
} catch {
set({
isRestoringLayout: false,
hiddenRightPane,
...visibilityForLayout(current, hiddenRightPane),
});
return;
}
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
const { currentLayoutEnvId, preMaximizeLayout } = get();
if (currentLayoutEnvId === targetEnvId) {
persistEnvLayoutNow(api, targetEnvId, preMaximizeLayout);
}
});
},
setSidebarVisible: (_visible: boolean) => {
/* moved to UI slice: setAppSidebarCollapsed */
},
setRightPanelsVisible: (visible: boolean) => {
const { rightPanelsVisible } = get();
if (rightPanelsVisible === visible) return;
const { rightPaneVisible } = get();
if (rightPaneVisible === visible) return;
get().toggleRightPanels();
},
refreshRightPaneState: () => {
const { api, hiddenRightPane } = get();
if (!api) return;
const current = fromDockviewApi(api);
const paneState = getRightPaneToggleState(current, hiddenRightPane);
const nextHidden = hiddenRightPane && !paneState.hidden ? null : hiddenRightPane;
set({ hiddenRightPane: nextHidden, ...visibilityForLayout(current, nextHidden) });
},
};
}
@@ -712,6 +746,7 @@ function buildPresetActions(set: StoreSet, get: StoreGet) {
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
set({
isRestoringLayout: true,
hiddenRightPane: null,
activeLayoutProfile: { kind: "built-in", id: preset },
});
const presetState = getPresetLayout(preset);
@@ -724,7 +759,10 @@ function buildPresetActions(set: StoreSet, get: StoreGet) {
safeWidth,
resetWidths,
);
const ids = applyLayout(api, state, cleanedWidths, safeWidth, safeHeight);
const ids = applyLayoutAndSet(api, state, cleanedWidths, set, {
width: safeWidth,
height: safeHeight,
});
if (isDebug()) {
const applied =
[...cleanedWidths].map(([k, v]) => `${k}:${Math.round(v)}`).join(",") || "-";
@@ -736,7 +774,7 @@ function buildPresetActions(set: StoreSet, get: StoreGet) {
set({
...ids,
sidebarVisible: true,
rightPanelsVisible: preset === "default",
...visibilityForLayout(state, null),
pinnedWidths: cleanedWidths,
});
const targetEnvId = get().currentLayoutEnvId;
@@ -764,6 +802,7 @@ function buildPresetActions(set: StoreSet, get: StoreGet) {
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
set({
isRestoringLayout: true,
hiddenRightPane: null,
activeLayoutProfile: profileForCustomLayout(layout),
});
const { appliedState, oldFormatRestoreFailed } = restoreCustomLayout({
@@ -775,10 +814,12 @@ function buildPresetActions(set: StoreSet, get: StoreGet) {
set,
});
const hasSidebar = !!api.getPanel("sidebar");
const colCount = appliedState?.columns?.length ?? api.groups.length;
const sidebarCols = hasSidebar ? 1 : 0;
const hasRight = colCount > sidebarCols + 1;
set({ sidebarVisible: hasSidebar, rightPanelsVisible: hasRight });
const restoredLayout = fromDockviewApi(api);
const effectiveLayout = restoredLayout.columns.length > 0 ? restoredLayout : appliedState;
set({
sidebarVisible: hasSidebar,
...visibilityForLayout(effectiveLayout, null),
});
const targetEnvId = get().currentLayoutEnvId;
requestAnimationFrame(() => {
api.layout(safeWidth, safeHeight);
@@ -830,15 +871,16 @@ function restoreCustomLayout({
opts?.sessionIds ?? [],
);
const savedWidths = resolveCustomLayoutPinnedWidths(activeState.columns, safeWidth);
set({
...applyLayout(api, activeState, savedWidths, safeWidth, safeHeight),
pinnedWidths: savedWidths,
const ids = applyLayoutAndSet(api, activeState, savedWidths, set, {
width: safeWidth,
height: safeHeight,
});
set({ ...ids, pinnedWidths: savedWidths });
return { appliedState: activeState, oldFormatRestoreFailed: false };
}
try {
api.fromJSON(layout.layout as unknown as SerializedDockview);
restoreSerializedDockview(api, layout.layout as unknown as SerializedDockview);
replaceStaleSessionPanels(api, opts?.activeSessionId ?? null, opts?.sessionIds ?? []);
set(applyLayoutFixups(api));
return { appliedState: state, oldFormatRestoreFailed: false };
@@ -871,7 +913,7 @@ function restoreMaximizeFromStorage(
const saved = getEnvMaximizeState(envId);
if (!saved) return false;
try {
api.fromJSON(saved.maximizedDockviewJson as SerializedDockview);
restoreSerializedDockview(api, saved.maximizedDockviewJson as SerializedDockview);
replaceStaleSessionPanels(api, activeSessionId, currentSessionIds);
// After fromJSON, `api.width/height` reflect the JSON's recorded grid
// dims, which may not match the live container. Always lay out against
@@ -886,7 +928,12 @@ function restoreMaximizeFromStorage(
// `centerGroupId`. Tracking it keeps the store consistent with what
// `maximizeGroup` would have set (so toggle/exit logic doesn't operate on
// a half-restored maximize).
set({ ...ids, preMaximizeLayout: preMax, maximizedGroupId: ids.centerGroupId });
set({
...ids,
preMaximizeLayout: preMax,
maximizedGroupId: ids.centerGroupId,
...visibilityForLayout(preMax, readHiddenRightPane(getEnvLayout(envId))),
});
} catch {
// Drop the bad blob so the next switch/reload doesn't keep reattempting
// the same failing fromJSON before falling back. Self-healing.
@@ -942,13 +989,19 @@ export function persistEnvLayoutNow(
// While maximized, api.toJSON() is the 2-column overlay; the regular layout has its own slot via saveOutgoingEnv.
if (preMaximizeLayout !== null) return;
try {
setEnvLayout(envId, api.toJSON());
const state = useDockviewStore.getState();
setEnvLayout(envId, withHiddenRightPaneMetadata(api.toJSON(), state.hiddenRightPane));
setEnvLayoutProfile(envId, useDockviewStore.getState().activeLayoutProfile);
} catch {
/* ignore serialization/storage failures */
}
}
function resolveEnvDefaultPinnedWidths(get: StoreGet, width: number): Map<string, number> {
const defaultLayout = get().userDefaultLayout;
return defaultLayout ? resolveCustomLayoutPinnedWidths(defaultLayout.columns, width) : new Map();
}
/** Save the outgoing env's layout & maximize state, then release its portals. */
function saveOutgoingEnv(
api: DockviewApi,
@@ -991,7 +1044,13 @@ function saveOutgoingEnv(
// shrunken layout that resurfaces on the next reload.
const { width, height } = measureDockviewContainer(api);
const preMaxSerialized = toSerializedDockview(preMaximizeLayout, width, height, pinnedWidths);
setEnvLayout(oldEnvId, preMaxSerialized as unknown as object);
setEnvLayout(
oldEnvId,
withHiddenRightPaneMetadata(
preMaxSerialized as unknown as object,
useDockviewStore.getState().hiddenRightPane,
),
);
setEnvLayoutProfile(oldEnvId, useDockviewStore.getState().activeLayoutProfile);
} catch (err) {
console.warn("saveOutgoingEnv: serialize failed", err);
@@ -1001,7 +1060,10 @@ function saveOutgoingEnv(
removeEnvMaximizeState(oldEnvId);
try {
const json = api.toJSON();
setEnvLayout(oldEnvId, json);
setEnvLayout(
oldEnvId,
withHiddenRightPaneMetadata(json, useDockviewStore.getState().hiddenRightPane),
);
setEnvLayoutProfile(oldEnvId, useDockviewStore.getState().activeLayoutProfile);
if (isDebug()) {
debugWidths(
@@ -1073,10 +1135,12 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) {
set({ preMaximizeLayout: null, maximizedGroupId: null });
const manualRightWidth = getManualRightWidth(newEnvId);
const savedEnvLayout = getEnvLayout(newEnvId);
const hiddenRightPane = readHiddenRightPane(savedEnvLayout);
const savedLayoutProfile = getEnvLayoutProfile(newEnvId);
set({
isRestoringLayout: true,
currentLayoutEnvId: newEnvId,
hiddenRightPane,
pinnedWidths: manualRightWidth === null ? new Map() : new Map([["right", manualRightWidth]]),
});
try {
@@ -1105,16 +1169,13 @@ function buildEnvSwitchAction(set: StoreSet, get: StoreGet) {
safeHeight: measured.height,
buildDefault: (a, intentName) => get().buildDefaultLayout(a, intentName),
getDefaultLayout: () => get().userDefaultLayout ?? getPresetLayout(get().defaultPreset),
getDefaultPinnedWidths: (width) => {
const defaultLayout = get().userDefaultLayout;
return defaultLayout
? resolveCustomLayoutPinnedWidths(defaultLayout.columns, width)
: new Map();
},
getDefaultPinnedWidths: (width) => resolveEnvDefaultPinnedWidths(get, width),
initialLayout,
});
const restoredLayout = fromDockviewApi(api);
set({
...ids,
...visibilityForLayout(restoredLayout, get().hiddenRightPane),
activeLayoutProfile: resolveEnvSwitchProfile({
hasFirstAdoptionRouteLayout,
savedEnvLayout,
@@ -1178,7 +1239,12 @@ function buildMaximizeActions(set: StoreSet, get: StoreGet) {
groups: [{ panels: targetGroup.panels, activePanel: targetGroup.activePanel }],
});
const maximizedLayout: LayoutState = { columns };
set({ isRestoringLayout: true, preMaximizeLayout: current, maximizedGroupId: groupId });
set({
isRestoringLayout: true,
preMaximizeLayout: current,
maximizedGroupId: groupId,
...visibilityForLayout(current, get().hiddenRightPane),
});
const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
applyLayoutAndSet(api, maximizedLayout, liveWidths, set);
requestAnimationFrame(() => {
@@ -1200,7 +1266,12 @@ function buildMaximizeActions(set: StoreSet, get: StoreGet) {
const safeWidth = measured.width;
const safeHeight = measured.height;
const liveWidths = get().pinnedWidths;
set({ isRestoringLayout: true, preMaximizeLayout: null, maximizedGroupId: null });
set({
isRestoringLayout: true,
preMaximizeLayout: null,
maximizedGroupId: null,
...visibilityForLayout(preMaximizeLayout, get().hiddenRightPane),
});
if (currentLayoutEnvId) {
removeEnvMaximizeState(currentLayoutEnvId);
}
@@ -1210,6 +1281,7 @@ function buildMaximizeActions(set: StoreSet, get: StoreGet) {
enforceFromStore(api, get);
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
persistEnvLayoutNow(api, currentLayoutEnvId, null);
});
},
};
@@ -1276,12 +1348,19 @@ function performBuildDefault(
basePreset,
safeWidth,
);
set({ isRestoringLayout: true, pinnedWidths, activeLayoutProfile });
set({
isRestoringLayout: true,
pinnedWidths,
activeLayoutProfile,
hiddenRightPane: null,
});
const ids = applyLayout(api, state, pinnedWidths, safeWidth, safeHeight);
const ids = applyLayoutAndSet(api, state, pinnedWidths, set, {
width: safeWidth,
height: safeHeight,
});
const hasSidebar = state.columns.some((c) => c.id === "sidebar");
const hasRight = state.columns.length > (hasSidebar ? 2 : 1);
set({ ...ids, sidebarVisible: hasSidebar, rightPanelsVisible: hasRight });
set({ ...ids, sidebarVisible: hasSidebar, ...visibilityForLayout(state, null) });
const pending = get().deferredPanelActions;
if (pending.length > 0) {
@@ -1312,8 +1391,10 @@ function resetToEffectiveDefault(set: StoreSet, get: StoreGet): void {
return;
}
if (preMaximizeLayout) {
set({ preMaximizeLayout: null, maximizedGroupId: null });
set({ preMaximizeLayout: null, maximizedGroupId: null, hiddenRightPane: null });
if (currentLayoutEnvId) removeEnvMaximizeState(currentLayoutEnvId);
} else {
set({ hiddenRightPane: null });
}
get().buildDefaultLayout(api);
requestAnimationFrame(() => {
@@ -1375,12 +1456,8 @@ function resolveActiveFile(api: DockviewApi, panelId: string | undefined): Activ
return activeFileState(null, null, activePanelComponent);
}
export const useDockviewStore = create<DockviewStore>((set, get) => ({
api: null,
activeFilePath: null,
activeFileRepo: null,
activePanelComponent: null,
setApi: (api) => {
function buildSetApiAction(set: StoreSet): DockviewStore["setApi"] {
return (api) => {
set({
api,
activeFilePath: null,
@@ -1403,37 +1480,41 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
w.__setPinnedTarget__ = setPinnedTarget;
w.__setGlobalSidebarWidth__ = setGlobalSidebarWidth;
}
if (api) {
api.onDidActivePanelChange((event) => {
set(resolveActiveFile(api, event?.id));
if (!api) return;
api.onDidActivePanelChange((event) => {
set(resolveActiveFile(api, event?.id));
});
// Track per-panel param-change subscriptions so they can be disposed when
// the panel is removed instead of relying on dockview's internal cleanup.
const paramSubs = new Map<string, { dispose: () => void }>();
api.onDidAddPanel((panel) => {
// The preview file-editor panel reuses a single dockview panel and swaps
// its params.path when the user previews a different file. Dockview does
// not refire onDidActivePanelChange for params-only updates.
if (panel.id !== "preview:file-editor" && panel.id !== "preview:file-diff") return;
paramSubs.get(panel.id)?.dispose();
const sub = panel.api.onDidParametersChange(() => {
if (!panel.api.isActive) return;
set(resolveActiveFile(api, panel.id));
});
// Track per-panel param-change subscriptions so they can be disposed when
// the panel is removed (e.g. across env switches that re-create the
// preview panel) instead of relying on dockview's internal cleanup.
const paramSubs = new Map<string, { dispose: () => void }>();
api.onDidAddPanel((panel) => {
// The preview file-editor panel reuses a single dockview panel and swaps
// its `params.path` via `updateParameters` when the user previews a
// different file. Dockview does not refire `onDidActivePanelChange` for
// params-only updates on an already-active panel, so subscribe to the
// panel's own parameter-change event and refresh the active file identity.
if (panel.id !== "preview:file-editor" && panel.id !== "preview:file-diff") return;
paramSubs.get(panel.id)?.dispose();
const sub = panel.api.onDidParametersChange(() => {
if (!panel.api.isActive) return;
set(resolveActiveFile(api, panel.id));
});
paramSubs.set(panel.id, sub);
});
api.onDidRemovePanel((panel) => {
const sub = paramSubs.get(panel.id);
if (sub) {
sub.dispose();
paramSubs.delete(panel.id);
}
});
}
},
paramSubs.set(panel.id, sub);
});
api.onDidRemovePanel((panel) => {
const sub = paramSubs.get(panel.id);
if (!sub) return;
sub.dispose();
paramSubs.delete(panel.id);
});
};
}
export const useDockviewStore = create<DockviewStore>((set, get) => ({
api: null,
activeFilePath: null,
activeFileRepo: null,
activePanelComponent: null,
setApi: buildSetApiAction(set),
activeGroupId: null,
selectedDiff: null,
setSelectedDiff: (diff) => set({ selectedDiff: diff }),
@@ -1449,6 +1530,9 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
sidebarGroupId: SIDEBAR_GROUP,
sidebarVisible: false,
rightPanelsVisible: true,
rightPaneVisible: true,
rightPaneAvailable: false,
hiddenRightPane: null,
activeLayoutProfile: DEFAULT_LAYOUT_PROFILE,
pinnedWidths: new Map(),
setPinnedWidth: (columnId, width) => {
+8 -1
View File
@@ -9,6 +9,7 @@ export type {
LayoutBranchNode,
LayoutIntent,
LayoutIntentPanel,
LayoutOrientation,
} from "./types";
// Constants
@@ -59,7 +60,13 @@ export type { BuiltInPreset } from "./presets";
export { computeColumnWidths, computeGroupHeights, getPinnedWidth } from "./sizing";
// Serializer
export { toSerializedDockview, fromDockviewApi, filterEphemeral } from "./serializer";
export {
toSerializedDockview,
fromDockviewApi,
filterEphemeral,
isCenterColumn,
isRightColumn,
} from "./serializer";
// Session panel normalization for reusable saved layouts
export {
@@ -1,8 +1,17 @@
import { describe, expect, it } from "vitest";
import { filterEphemeral, toSerializedDockview } from "./serializer";
import { panel, PROMPT_HISTORY_PANEL_ID } from "./constants";
import type { DockviewApi } from "dockview-react";
import { filterEphemeral, fromDockviewApi, toSerializedDockview } from "./serializer";
import {
panel,
CENTER_GROUP,
PROMPT_HISTORY_PANEL_ID,
RIGHT_BOTTOM_GROUP,
RIGHT_TOP_GROUP,
} from "./constants";
import type { LayoutPanel, LayoutState } from "./types";
const SESSION_PANEL_ID = "session:session-a";
/** Builds an ephemeral LayoutPanel whose title is its id. */
function ephemeralPanel(id: string, component: string): LayoutPanel {
return { id, component, title: id };
@@ -21,7 +30,7 @@ function layoutWithPromptHistory(extraPanels: LayoutPanel[] = []): LayoutState {
id: "center",
groups: [
{
id: "group-center",
id: CENTER_GROUP,
panels: [panel(PROMPT_HISTORY_PANEL_ID), ...extraPanels],
},
],
@@ -30,6 +39,189 @@ function layoutWithPromptHistory(extraPanels: LayoutPanel[] = []): LayoutState {
} as unknown as LayoutState;
}
type CapturedGroup = {
id: string;
panels: Array<{ id: string; component: string; title?: string }>;
};
function makeCapturedApi(columns: CapturedGroup[][]): DockviewApi {
const leaf = (group: CapturedGroup) => ({
view: {
id: group.id,
panels: group.panels.map((item) => ({
id: item.id,
title: item.title ?? item.id,
view: { contentComponent: item.component },
})),
activePanel: group.panels[0] ? { id: group.panels[0].id } : undefined,
},
});
const rootChildren = columns.map((groups) =>
groups.length === 1
? leaf(groups[0])
: {
children: groups.map(leaf),
splitview: { getViewSize: (index: number) => 300 + index * 10 },
},
);
return {
component: {
gridview: {
root: {
children: rootChildren,
splitview: { getViewSize: (index: number) => 700 - index * 300 },
},
},
},
} as unknown as DockviewApi;
}
describe("fromDockviewApi — right-column ownership", () => {
it("preserves the production root split orientation", () => {
const api = makeCapturedApi([
[
{
id: "runtime-agent",
panels: [{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" }],
},
],
]) as unknown as {
component: { gridview: { root: { splitview: { orientation: string } } } };
};
api.component.gridview.root.splitview.orientation = "VERTICAL";
const captured = fromDockviewApi(api as unknown as DockviewApi);
expect(captured.rootOrientation).toBe("VERTICAL");
});
it("captures a live Agent session as the compact center column", () => {
const captured = fromDockviewApi(
makeCapturedApi([
[
{
id: "runtime-center",
panels: [
{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" },
{ id: "files", component: "files", title: "Files" },
{ id: "changes", component: "changes", title: "Changes" },
{ id: "terminal-default", component: "terminal", title: "Terminal" },
],
},
],
]),
);
expect(captured.columns).toHaveLength(1);
expect(captured.columns[0]?.id).toBe("center");
expect(captured.columns[0]?.pinned).toBeUndefined();
const serialized = toSerializedDockview(captured, 1000, 800, new Map());
const root = (serialized as unknown as { grid: { root: { data: unknown[] } } }).grid.root;
expect((root.data[0] as { data: { id: string } }).data.id).toBe("runtime-center");
});
it("keeps a mixed session center separate from a nested right column", () => {
const captured = fromDockviewApi(
makeCapturedApi([
[
{
id: "runtime-agent",
panels: [
{ id: SESSION_PANEL_ID, component: "chat", title: "Agent" },
{ id: "files", component: "files", title: "Files" },
{ id: "pr-detail", component: "pr-detail", title: "PR Details" },
],
},
],
[
{
id: RIGHT_TOP_GROUP,
panels: [
{ id: "session:session-b", component: "chat" },
{ id: "files", component: "files" },
],
},
{ id: RIGHT_BOTTOM_GROUP, panels: [{ id: "terminal-default", component: "terminal" }] },
],
]),
);
expect(captured.columns.map((column) => column.id)).toEqual(["center", "right"]);
expect(captured.columns[0]?.groups[0]?.panels.map((item) => item.id)).toEqual([
SESSION_PANEL_ID,
"files",
"pr-detail",
]);
expect(captured.columns[1]?.groups.map((group) => group.id)).toEqual([
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
]);
});
});
describe("contextual pane width ownership", () => {
it.each(["browser", "plan", "vscode"])(
"keeps a %s pane unpinned after merging tool tabs",
(component) => {
const captured = fromDockviewApi(
makeCapturedApi([
[{ id: CENTER_GROUP, panels: [{ id: SESSION_PANEL_ID, component: "chat" }] }],
[
{
id: "group-1",
panels: [
{ id: component, component },
{ id: "files", component: "files" },
{ id: "changes", component: "changes" },
],
},
],
]),
);
expect(captured.columns[1]).toMatchObject({ id: component, width: 400 });
expect(captured.columns[1].pinned).toBeUndefined();
},
);
it("keeps canonical right groups pinned even with a Browser tab", () => {
const captured = fromDockviewApi(
makeCapturedApi([
[{ id: CENTER_GROUP, panels: [{ id: SESSION_PANEL_ID, component: "chat" }] }],
[
{
id: RIGHT_TOP_GROUP,
panels: [
{ id: "browser", component: "browser" },
{ id: "files", component: "files" },
],
},
],
]),
);
expect(captured.columns[1]).toMatchObject({ id: "right", pinned: true });
});
it("recognizes a content pane after its tool tabs are reordered", () => {
const captured = fromDockviewApi(
makeCapturedApi([
[{ id: CENTER_GROUP, panels: [{ id: SESSION_PANEL_ID, component: "chat" }] }],
[
{
id: "group-1",
panels: [
{ id: "files", component: "files" },
{ id: "browser:custom", component: "browser" },
],
},
],
]),
);
expect(captured.columns[1]).toMatchObject({ id: "browser:custom" });
expect(captured.columns[1].pinned).toBeUndefined();
});
});
describe("filterEphemeral — prompt-history survival", () => {
it("keeps the prompt-history panel when filtering a captured layout", () => {
const filtered = filterEphemeral(layoutWithPromptHistory());
+93 -17
View File
@@ -1,5 +1,12 @@
import type { DockviewApi, SerializedDockview } from "dockview-react";
import type { LayoutState, LayoutColumn, LayoutGroup, LayoutPanel, LayoutNode } from "./types";
import type {
LayoutState,
LayoutColumn,
LayoutGroup,
LayoutPanel,
LayoutNode,
LayoutOrientation,
} from "./types";
import { computeColumnWidths, computeGroupHeights } from "./sizing";
import {
SIDEBAR_LOCK,
@@ -8,6 +15,9 @@ import {
STRUCTURAL_COMPONENTS,
TERMINAL_DEFAULT_ID,
canonicalPanelTitle,
CENTER_GROUP,
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
} from "./constants";
import { panelTitle } from "./panel-title";
@@ -36,10 +46,15 @@ type SerializedBranchNode = {
type SerializedGridNode = SerializedLeafNode | SerializedBranchNode;
type SerializationCtx = { counter: number };
type SerializationCtx = { counter: number; usedGroupIds: Set<string> };
function nextGroupId(ctx: SerializationCtx): string {
return `group-${++ctx.counter}`;
let groupId: string;
do {
groupId = `group-${++ctx.counter}`;
} while (ctx.usedGroupIds.has(groupId));
ctx.usedGroupIds.add(groupId);
return groupId;
}
// ─── Serialization: LayoutState → SerializedDockview ───────────────────────
@@ -51,6 +66,7 @@ function serializeGroup(
ctx: SerializationCtx,
): SerializedLeafNode {
const groupId = group.id ?? nextGroupId(ctx);
if (group.id) ctx.usedGroupIds.add(group.id);
const views = group.panels.map((p) => p.id);
const isSidebar = columnId === "sidebar";
return {
@@ -173,7 +189,15 @@ export function toSerializedDockview(
totalHeight: number,
pinnedWidths: Map<string, number>,
): SerializedDockview {
const ctx: SerializationCtx = { counter: 0 };
const ctx: SerializationCtx = {
counter: 0,
usedGroupIds: new Set(
state.columns.flatMap((column) =>
column.groups.flatMap((group) => (group.id ? [group.id] : [])),
),
),
};
const rootOrientation = state.rootOrientation ?? "HORIZONTAL";
const widths = computeColumnWidths(state.columns, totalWidth, pinnedWidths);
const root: SerializedBranchNode = {
@@ -187,7 +211,7 @@ export function toSerializedDockview(
root,
width: totalWidth,
height: totalHeight,
orientation: "HORIZONTAL",
orientation: rootOrientation,
},
panels: serializePanels(state),
activeGroup: undefined,
@@ -248,9 +272,52 @@ function captureNode(
return { type: "leaf", group, size };
}
/** Panel IDs that indicate the "right" column. */
/** Panel IDs that indicate the "right" column when no structural identity exists. */
const RIGHT_PANEL_IDS = new Set(["files", "changes"]);
function hasCanonicalRightIdentity(column: Pick<LayoutColumn, "id" | "groups">): boolean {
const groups = column.groups ?? [];
return (
column.id === "right" ||
groups.some((group) => group.id === RIGHT_TOP_GROUP || group.id === RIGHT_BOTTOM_GROUP)
);
}
/** True when the column contains the Agent surface or the canonical center group. */
export function isCenterColumn(column: Pick<LayoutColumn, "id" | "groups">): boolean {
if (hasCanonicalRightIdentity(column)) return false;
const groups = column.groups ?? [];
return (
column.id === "center" ||
groups.some(
(group) =>
group.id === CENTER_GROUP ||
group.panels.some(
(panel) =>
panel.component === "chat" || panel.id === "chat" || panel.id.startsWith("session:"),
),
)
);
}
/** Content panes retain their flexible sizing when tool tabs are merged into them. */
function contextualPanel(column: Pick<LayoutColumn, "groups">): LayoutPanel | undefined {
return column.groups
.flatMap((group) => group.panels)
.find(
(panel) =>
panel.component === "plan" || panel.component === "browser" || panel.component === "vscode",
);
}
/** True when the column owns the standard right-side groups or column ID. */
export function isRightColumn(column: Pick<LayoutColumn, "id" | "groups">): boolean {
if (hasCanonicalRightIdentity(column)) return true;
if (isCenterColumn(column) || contextualPanel(column)) return false;
const groups = column.groups ?? [];
return groups.some((group) => group.panels.some((panel) => RIGHT_PANEL_IDS.has(panel.id)));
}
/** Determine column ID and pinned status from its groups. */
function inferColumnMeta(
groups: LayoutGroup[],
@@ -258,22 +325,30 @@ function inferColumnMeta(
): { columnId: string; isPinned: boolean } {
if (groups.length === 0) return { columnId: `col-${index}`, isPinned: false };
const allPanelIds = new Set(groups.flatMap((g) => g.panels.map((p) => p.id)));
const column = { id: `col-${index}`, groups };
if (allPanelIds.has("sidebar")) return { columnId: "sidebar", isPinned: true };
if (allPanelIds.has("chat")) return { columnId: "center", isPinned: false };
// Column containing files/changes panels is the "right" column
for (const id of allPanelIds) {
if (RIGHT_PANEL_IDS.has(id)) return { columnId: "right", isPinned: true };
if (groups.some((group) => group.panels.some((panel) => panel.id === "sidebar"))) {
return { columnId: "sidebar", isPinned: true };
}
if (isCenterColumn(column)) return { columnId: "center", isPinned: false };
const firstPanelId = groups[0].panels[0]?.id;
if (isRightColumn(column)) return { columnId: "right", isPinned: true };
const firstPanelId = contextualPanel(column)?.id ?? groups[0].panels[0]?.id;
if (firstPanelId) return { columnId: firstPanelId, isPinned: false };
return { columnId: `col-${index}`, isPinned: false };
}
function readRootOrientation(
root: { orientation?: unknown } | null | undefined,
splitview: { orientation?: unknown } | null | undefined,
): LayoutOrientation {
return root?.orientation === "VERTICAL" || splitview?.orientation === "VERTICAL"
? "VERTICAL"
: "HORIZONTAL";
}
/**
* Walk the dockview grid tree and map back to LayoutState.
* Captures both the recursive tree structure (for faithful restoration)
@@ -281,9 +356,10 @@ function inferColumnMeta(
*/
export function fromDockviewApi(api: DockviewApi): LayoutState {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sv = (api as any).component?.gridview?.root?.splitview;
const root = (api as any).component?.gridview?.root;
const sv = root?.splitview;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rootChildren = (api as any).component?.gridview?.root?.children;
const rootChildren = root?.children;
if (!rootChildren || !sv) {
console.warn("fromDockviewApi: no root splitview or children found");
@@ -308,7 +384,7 @@ export function fromDockviewApi(api: DockviewApi): LayoutState {
});
}
return { columns };
return { columns, rootOrientation: readRootOrientation(root, sv) };
}
// ─── Ephemeral Filtering ───────────────────────────────────────────────────
@@ -46,9 +46,13 @@ export type LayoutColumn = {
tree?: LayoutNode;
};
export type LayoutOrientation = "HORIZONTAL" | "VERTICAL";
/** Complete declarative layout state. */
export type LayoutState = {
columns: LayoutColumn[];
/** Orientation of Dockview's outermost split. Older snapshots are horizontal. */
rootOrientation?: LayoutOrientation;
};
// ─── Layout Intent ──────────────────────────────────────────────────────────
+6
View File
@@ -705,7 +705,11 @@
"hidePreparationDetails": "Hide preparation details",
"hidePreviousAgentError": "Hide previous agent error",
"hideRightPanel": "Hide right panel",
"hideRightPane": "Hide right pane",
"hideRightPanels": "Hide right panels",
"rightPaneUnavailable": "No separate right pane to hide",
"rightPaneUnavailableWhileMaximized": "Right pane is unavailable while a panel is maximized",
"rightPanelsUnavailableWhileMaximized": "Right panels are unavailable while a panel is maximized",
"hideSidebar": "Hide sidebar",
"hideTheCommentListTheComments": "Hide the comment list. The comments stay queued and will still be sent next time you submit (here or from the chat box).",
"host": "Host",
@@ -1528,6 +1532,8 @@
"showPreparationDetails": "Show preparation details",
"showRawText": "Show raw text",
"showRightPanel": "Show right panel",
"showRightPane": "Show right pane",
"showRightPanels": "Show right panels",
"showSidebar": "Show sidebar",
"sibling": "Sibling",
"sidebarFilters": "Sidebar filters",
+6
View File
@@ -705,7 +705,11 @@
"hidePreparationDetails": "Ĥĩďē ƥŕēƥàŕàţĩōń ďēţàĩĺś",
"hidePreviousAgentError": "Ĥĩďē ƥŕēvĩōũś àĝēńţ ēŕŕōŕ",
"hideRightPanel": "Ĥĩďē ŕĩĝĥţ ƥàńēĺ",
"hideRightPane": "Ĥĩďē ŕĩĝĥţ ƥàńē",
"hideRightPanels": "Ĥĩďē ŕĩĝĥţ ƥàńēĺś",
"rightPaneUnavailable": "Ńō śēƥàŕàţē ŕĩĝĥţ ƥàńē ƭō ĥĩďē",
"rightPaneUnavailableWhileMaximized": "Ŕĩĝĥţ ƥàńē ĩś ũńàvàĩĺàƀĺē ŵĥĩĺē à ƥàńēĺ ĩś ḿàxĩḿĩźēď",
"rightPanelsUnavailableWhileMaximized": "Ŕĩĝĥţ ƥàńēĺś àŕē ũńàvàĩĺàƀĺē ŵĥĩĺē à ƥàńēĺ ĩś ḿàxĩḿĩźēď",
"hideSidebar": "Ĥĩďē śĩďēƀàŕ",
"hideTheCommentListTheComments": "Ĥĩďē ţĥē ćōḿḿēńţ ĺĩśţ. Ţĥē ćōḿḿēńţś śţàŷ qũēũēď àńď ŵĩĺĺ śţĩĺĺ ƀē śēńţ ńēxţ ţĩḿē ŷōũ śũƀḿĩţ (ĥēŕē ōŕ ƒŕōḿ ţĥē ćĥàţ ƀōx).",
"host": "Ĥōśţ",
@@ -1528,6 +1532,8 @@
"showPreparationDetails": "Śĥōŵ ƥŕēƥàŕàţĩōń ďēţàĩĺś",
"showRawText": "Śĥōŵ ŕàŵ ţēxţ",
"showRightPanel": "Śĥōŵ ŕĩĝĥţ ƥàńēĺ",
"showRightPane": "Śĥōŵ ŕĩĝĥţ ƥàńē",
"showRightPanels": "Śĥōŵ ŕĩĝĥţ ƥàńēĺś",
"showSidebar": "Śĥōŵ śĩďēƀàŕ",
"sibling": "Śĩƀĺĩńĝ",
"sidebarFilters": "Śĩďēƀàŕ ƒĩĺţēŕś",
+6
View File
@@ -686,7 +686,11 @@
"hidePreparationDetails": "Ocultar os detalhes da preparação",
"hidePreviousAgentError": "Ocultar o erro anterior do agente",
"hideRightPanel": "Ocultar o painel direito",
"hideRightPane": "Ocultar o painel direito",
"hideRightPanels": "Ocultar os painéis da direita",
"rightPaneUnavailable": "Não existe um painel direito separado para ocultar",
"rightPaneUnavailableWhileMaximized": "O painel direito não está disponível enquanto um painel está maximizado",
"rightPanelsUnavailableWhileMaximized": "Os painéis da direita não estão disponíveis enquanto um painel está maximizado",
"hideSidebar": "Ocultar a barra lateral",
"hideTheCommentListTheComments": "Oculta a lista de comentários. Os comentários ficam em fila e continuam a ser enviados da próxima vez que submeter (aqui ou a partir da caixa de chat).",
"host": "Anfitrião",
@@ -1471,6 +1475,8 @@
"showPreparationDetails": "Mostrar os detalhes da preparação",
"showRawText": "Mostrar o texto em bruto",
"showRightPanel": "Mostrar o painel direito",
"showRightPane": "Mostrar o painel direito",
"showRightPanels": "Mostrar os painéis da direita",
"showSidebar": "Mostrar a barra lateral",
"sibling": "Irmã",
"sidebarFilters": "Filtros da barra lateral",
+6
View File
@@ -685,7 +685,11 @@
"hidePreparationDetails": "隐藏准备详情",
"hidePreviousAgentError": "隐藏先前的智能体错误",
"hideRightPanel": "隐藏右侧面板",
"hideRightPane": "隐藏右侧窗格",
"hideRightPanels": "隐藏右侧面板",
"rightPaneUnavailable": "没有可隐藏的独立右侧窗格",
"rightPaneUnavailableWhileMaximized": "面板最大化时,右侧窗格不可用",
"rightPanelsUnavailableWhileMaximized": "面板最大化时,右侧面板不可用",
"hideSidebar": "隐藏侧边栏",
"hideTheCommentListTheComments": "隐藏评论列表。评论仍会排队,下次提交时仍会发送(在此处或从聊天框)。",
"host": "主机",
@@ -1470,6 +1474,8 @@
"showPreparationDetails": "显示准备详情",
"showRawText": "显示原始文本",
"showRightPanel": "显示右侧面板",
"showRightPane": "显示右侧窗格",
"showRightPanels": "显示右侧面板",
"showSidebar": "显示侧边栏",
"sibling": "同级",
"sidebarFilters": "侧边栏筛选",
+6
View File
@@ -685,7 +685,11 @@
"hidePreparationDetails": "隱藏準備詳情",
"hidePreviousAgentError": "隱藏先前的代理程式錯誤",
"hideRightPanel": "隱藏右側面板",
"hideRightPane": "隱藏右側窗格",
"hideRightPanels": "隱藏右側面板",
"rightPaneUnavailable": "沒有可隱藏的獨立右側窗格",
"rightPaneUnavailableWhileMaximized": "面板最大化時,右側窗格不可用",
"rightPanelsUnavailableWhileMaximized": "面板最大化時,右側面板不可用",
"hideSidebar": "隱藏側邊欄",
"hideTheCommentListTheComments": "隱藏評論列表。評論仍會排隊,下次提交時仍會傳送(在此處或從聊天框)。",
"host": "主機",
@@ -1470,6 +1474,8 @@
"showPreparationDetails": "顯示準備詳情",
"showRawText": "顯示原始文字",
"showRightPanel": "顯示右側面板",
"showRightPane": "顯示右側窗格",
"showRightPanels": "顯示右側面板",
"showSidebar": "顯示側邊欄",
"sibling": "同級",
"sidebarFilters": "側邊欄篩選",
+6
View File
@@ -685,7 +685,11 @@
"hidePreparationDetails": "隱藏準備詳情",
"hidePreviousAgentError": "隱藏先前的代理程式錯誤",
"hideRightPanel": "隱藏右側面板",
"hideRightPane": "隱藏右側窗格",
"hideRightPanels": "隱藏右側面板",
"rightPaneUnavailable": "沒有可隱藏的獨立右側窗格",
"rightPaneUnavailableWhileMaximized": "面板最大化時,右側窗格不可用",
"rightPanelsUnavailableWhileMaximized": "面板最大化時,右側面板不可用",
"hideSidebar": "隱藏側邊欄",
"hideTheCommentListTheComments": "隱藏評論清單。評論仍會排隊,下次提交時仍會傳送(在此處或從聊天框)。",
"host": "主機",
@@ -1470,6 +1474,8 @@
"showPreparationDetails": "顯示準備詳情",
"showRawText": "顯示原始文字",
"showRightPanel": "顯示右側面板",
"showRightPane": "顯示右側窗格",
"showRightPanels": "顯示右側面板",
"showSidebar": "顯示側邊欄",
"sibling": "同級",
"sidebarFilters": "側邊欄篩選",