@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Śĩďēƀàŕ ƒĩĺţēŕś",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "侧边栏筛选",
|
||||
|
||||
@@ -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": "側邊欄篩選",
|
||||
|
||||
@@ -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": "側邊欄篩選",
|
||||
|
||||
Reference in New Issue
Block a user