535 lines
18 KiB
TypeScript
535 lines
18 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import type { DockviewApi } from "dockview-react";
|
|
import { useDockviewStore } from "./dockview-store";
|
|
|
|
vi.mock("@/lib/local-storage", () => ({
|
|
getEnvLayout: vi.fn(() => null),
|
|
getEnvLayoutProfile: vi.fn(() => null),
|
|
setEnvLayout: vi.fn(),
|
|
setEnvLayoutProfile: vi.fn(),
|
|
getEnvMaximizeState: vi.fn(() => null),
|
|
setEnvMaximizeState: vi.fn(),
|
|
removeEnvMaximizeState: vi.fn(),
|
|
getGlobalSidebarWidth: vi.fn(() => null),
|
|
getManualRightWidth: 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("./layout-manager", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./layout-manager")>();
|
|
return {
|
|
...actual,
|
|
fromDockviewApi: vi.fn(() => ({ columns: [] })),
|
|
};
|
|
});
|
|
|
|
import {
|
|
getEnvLayoutProfile,
|
|
getEnvMaximizeState,
|
|
setEnvLayout,
|
|
setEnvLayoutProfile,
|
|
} from "@/lib/local-storage";
|
|
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
|
|
import { fromDockviewApi } from "./layout-manager";
|
|
|
|
function makeMockApi(): DockviewApi {
|
|
return {
|
|
width: 800,
|
|
height: 600,
|
|
panels: [],
|
|
groups: [],
|
|
fromJSON: vi.fn(),
|
|
toJSON: vi.fn(() => ({})),
|
|
layout: vi.fn(),
|
|
activeGroup: null,
|
|
onDidActivePanelChange: vi.fn(() => ({ dispose: vi.fn() })),
|
|
getPanel: vi.fn(() => null),
|
|
addPanel: vi.fn(),
|
|
hasMaximizedGroup: vi.fn(() => false),
|
|
} 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: {
|
|
root: {
|
|
type: "branch",
|
|
size: 600,
|
|
data: [{ type: "leaf", size: 600, data: { id: "g-center", views: ["chat"] } }],
|
|
},
|
|
height: 600,
|
|
width: 800,
|
|
orientation: "HORIZONTAL",
|
|
},
|
|
panels: {
|
|
chat: { id: "chat", contentComponent: "chat" },
|
|
},
|
|
activeGroup: "g-center",
|
|
};
|
|
vi.mocked(getEnvMaximizeState).mockImplementation((envId) =>
|
|
envId === "env-a"
|
|
? { preMaximizeLayout: { columns: [] }, maximizedDockviewJson: savedMaximizedJson }
|
|
: null,
|
|
);
|
|
|
|
type TestPanel = {
|
|
id: string;
|
|
api: { component: string; close: () => void };
|
|
group: { id: string; panels: TestPanel[] };
|
|
};
|
|
|
|
const group = { id: "g-center", panels: [] as TestPanel[] };
|
|
const panels: TestPanel[] = [];
|
|
const removePanel = (id: string) => {
|
|
const panelIndex = panels.findIndex((p) => p.id === id);
|
|
if (panelIndex >= 0) panels.splice(panelIndex, 1);
|
|
const groupIndex = group.panels.findIndex((p) => p.id === id);
|
|
if (groupIndex >= 0) group.panels.splice(groupIndex, 1);
|
|
};
|
|
const closeStale = vi.fn(() => removePanel("session:stale"));
|
|
const stalePanel: TestPanel = {
|
|
id: "session:stale",
|
|
api: { component: "chat", close: closeStale },
|
|
group,
|
|
};
|
|
panels.push(stalePanel);
|
|
group.panels.push(stalePanel);
|
|
|
|
const api = {
|
|
...makeMockApi(),
|
|
panels,
|
|
groups: [group],
|
|
getPanel: vi.fn((id: string) => panels.find((p) => p.id === id) ?? null),
|
|
addPanel: vi.fn((opts: { id: string; component: string }) => {
|
|
const panel: TestPanel = {
|
|
id: opts.id,
|
|
api: { component: opts.component, close: () => removePanel(opts.id) },
|
|
group,
|
|
};
|
|
panels.push(panel);
|
|
group.panels.push(panel);
|
|
}),
|
|
} as unknown as DockviewApi;
|
|
|
|
useDockviewStore.setState({ api, currentLayoutEnvId: "env-b" });
|
|
useDockviewStore
|
|
.getState()
|
|
.switchEnvLayout("env-b", "env-a", "session-a", ["session-a", "session-sibling"]);
|
|
|
|
expect(api.addPanel).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: "session:session-a",
|
|
position: { referenceGroup: group.id, index: 0 },
|
|
}),
|
|
);
|
|
expect(api.addPanel).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: "session:session-sibling",
|
|
params: { sessionId: "session-sibling" },
|
|
position: { referenceGroup: group.id, index: 1 },
|
|
inactive: true,
|
|
}),
|
|
);
|
|
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,
|
|
preMaximizeLayout: null,
|
|
maximizedGroupId: null,
|
|
isRestoringLayout: false,
|
|
pendingChatInitialPlacement: null,
|
|
});
|
|
});
|
|
|
|
it("no-ops when switching between sessions of the same env", () => {
|
|
const api = makeMockApi();
|
|
useDockviewStore.setState({ api, currentLayoutEnvId: "env-shared" });
|
|
|
|
useDockviewStore.getState().switchEnvLayout("env-shared", "env-shared", "session-B");
|
|
|
|
// Same env = no layout rebuild + no portal release. This is the entire
|
|
// point of env-keyed layouts: terminals + panels stay put.
|
|
expect(api.fromJSON).not.toHaveBeenCalled();
|
|
expect(panelPortalManager.releaseByEnv).not.toHaveBeenCalled();
|
|
expect(setEnvLayout).not.toHaveBeenCalled();
|
|
expect(useDockviewStore.getState().pendingChatInitialPlacement).toBeNull();
|
|
});
|
|
|
|
it("saves outgoing env + releases its portals when switching to a new env", () => {
|
|
const api = makeMockApi();
|
|
useDockviewStore.setState({ api, currentLayoutEnvId: "env-old" });
|
|
|
|
useDockviewStore.getState().switchEnvLayout("env-old", "env-new", "session-X");
|
|
|
|
expect(setEnvLayout).toHaveBeenCalledWith("env-old", expect.anything());
|
|
expect(setEnvLayoutProfile).toHaveBeenCalledWith("env-old", expect.anything());
|
|
expect(panelPortalManager.releaseByEnv).toHaveBeenCalledWith("env-old");
|
|
expect(useDockviewStore.getState().currentLayoutEnvId).toBe("env-new");
|
|
});
|
|
|
|
it("arms fresh transcript placement for the incoming session without capturing scrollTop", () => {
|
|
const api = makeMockApi();
|
|
useDockviewStore.setState({
|
|
api,
|
|
currentLayoutEnvId: "env-old",
|
|
pendingChatScrollTop: null,
|
|
});
|
|
|
|
useDockviewStore.getState().switchEnvLayout("env-old", "env-new", "session-X");
|
|
|
|
const state = useDockviewStore.getState();
|
|
expect(state.pendingChatInitialPlacement).toEqual({
|
|
sessionId: "session-X",
|
|
token: expect.any(Number),
|
|
});
|
|
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" });
|
|
|
|
useDockviewStore.getState().switchEnvLayout("env-a", "env-b", "session-B");
|
|
const firstRequest = useDockviewStore.getState().pendingChatInitialPlacement;
|
|
useDockviewStore.getState().switchEnvLayout("env-b", "env-c", "session-C");
|
|
const secondRequest = useDockviewStore.getState().pendingChatInitialPlacement;
|
|
|
|
expect(firstRequest).not.toBeNull();
|
|
expect(secondRequest).not.toBeNull();
|
|
useDockviewStore.getState().completePendingChatInitialPlacement(firstRequest?.token ?? -1);
|
|
expect(useDockviewStore.getState().pendingChatInitialPlacement).toEqual(secondRequest);
|
|
|
|
useDockviewStore.getState().completePendingChatInitialPlacement(secondRequest?.token ?? -1);
|
|
expect(useDockviewStore.getState().pendingChatInitialPlacement).toBeNull();
|
|
});
|
|
|
|
it("restores the saved env layout profile when switching to a new env", () => {
|
|
const api = makeMockApi();
|
|
vi.mocked(getEnvLayoutProfile).mockReturnValue({ kind: "built-in", id: "vscode" });
|
|
useDockviewStore.setState({ api, currentLayoutEnvId: null });
|
|
|
|
useDockviewStore.getState().switchEnvLayout(null, "env-saved", "session-X");
|
|
|
|
expect(useDockviewStore.getState().activeLayoutProfile).toEqual({
|
|
kind: "built-in",
|
|
id: "vscode",
|
|
});
|
|
});
|
|
|
|
it("first adoption applies the env's layout without overwriting it or releasing portals", () => {
|
|
const api = makeMockApi();
|
|
useDockviewStore.setState({ api, currentLayoutEnvId: null });
|
|
|
|
useDockviewStore.getState().switchEnvLayout(null, "env-first", "session-Y");
|
|
|
|
// No outgoing env to save/release.
|
|
expect(panelPortalManager.releaseByEnv).not.toHaveBeenCalled();
|
|
expect(useDockviewStore.getState().currentLayoutEnvId).toBe("env-first");
|
|
// Regression: the old "just adopt onReady's layout" shortcut persisted the
|
|
// stale global-fallback layout into the new env via setEnvLayout(newEnvId,
|
|
// toJSON()), overwriting any real saved layout and giving fresh tasks the
|
|
// previous env's proportions. First adoption must apply the env's layout
|
|
// (defaults here, since getEnvLayout is mocked null), never persist over it.
|
|
expect(setEnvLayout).not.toHaveBeenCalledWith("env-first", expect.anything());
|
|
});
|
|
|
|
it("prioritizes an explicit route layout over saved maximize state on first adoption", () => {
|
|
const api = makeMockApi();
|
|
const buildDefaultLayout = vi.fn();
|
|
vi.mocked(getEnvMaximizeState).mockReturnValue({
|
|
preMaximizeLayout: { columns: [] },
|
|
maximizedDockviewJson: { grid: {}, panels: {} },
|
|
});
|
|
useDockviewStore.setState({ api, currentLayoutEnvId: null, buildDefaultLayout });
|
|
|
|
useDockviewStore.getState().switchEnvLayout(null, "env-first", "session-Y", [], "plan");
|
|
|
|
expect(api.fromJSON).not.toHaveBeenCalled();
|
|
expect(buildDefaultLayout).toHaveBeenCalledWith(api, "plan");
|
|
});
|
|
|
|
it("does nothing when api is unset", () => {
|
|
useDockviewStore.setState({ api: null });
|
|
useDockviewStore.getState().switchEnvLayout("env-a", "env-b", null);
|
|
expect(setEnvLayout).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Regression suite for the "maximize Task A → click Task B in sidebar →
|
|
* click Task A again → centre group is shrunk" bug. The root cause is two
|
|
* separate state-management slips during a maximize-then-env-switch sequence:
|
|
*
|
|
* 1. `saveOutgoingEnv` wrote `api.toJSON()` (the 2-column maximize overlay)
|
|
* into the env's regular layout slot. If we ever fall back to that slot
|
|
* (maximize state cleared, slow-path switch, refresh after dropping max
|
|
* state, etc.) the user sees the truncated 2-column layout instead of
|
|
* their real one.
|
|
* 2. `restoreMaximizeFromStorage` only writes `preMaximizeLayout` and
|
|
* 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,
|
|
preMaximizeLayout: null,
|
|
maximizedGroupId: null,
|
|
isRestoringLayout: false,
|
|
});
|
|
});
|
|
|
|
it("persists pre-max (not the 2-col overlay) as the env's regular layout when maximized", () => {
|
|
const api = makeMockApi();
|
|
const preMaxLayout = {
|
|
columns: [
|
|
{ id: "sidebar", pinned: true, width: 200, groups: [] },
|
|
{ id: "center", width: 400, groups: [] },
|
|
{ id: "right", pinned: true, width: 200, groups: [] },
|
|
],
|
|
};
|
|
useDockviewStore.setState({
|
|
api,
|
|
currentLayoutEnvId: "env-a",
|
|
preMaximizeLayout: preMaxLayout as never,
|
|
maximizedGroupId: "g-center",
|
|
});
|
|
|
|
useDockviewStore.getState().switchEnvLayout("env-a", "env-b", "session-b");
|
|
|
|
expect(setEnvLayout).toHaveBeenCalled();
|
|
const [savedEnvId, savedLayout] = vi.mocked(setEnvLayout).mock.calls[0];
|
|
expect(savedEnvId).toBe("env-a");
|
|
// Structural marker: the persisted layout must be the 3-column user
|
|
// layout, not the 2-column maximize overlay from api.toJSON().
|
|
const grid = (savedLayout as { grid?: { root?: { data?: unknown[] } } }).grid;
|
|
const rootChildren = grid?.root?.data;
|
|
expect(Array.isArray(rootChildren)).toBe(true);
|
|
expect((rootChildren as unknown[]).length).toBe(3);
|
|
});
|
|
|
|
it("restores maximizedGroupId when switching back to an env with saved maximize", () => {
|
|
const api = makeMockApi();
|
|
const savedMaximizedJson = {
|
|
grid: {
|
|
root: {
|
|
type: "branch",
|
|
size: 600,
|
|
data: [
|
|
{ type: "leaf", size: 200, data: { id: "g-sidebar", views: ["sidebar"] } },
|
|
{ type: "leaf", size: 600, data: { id: "g-center", views: ["chat"] } },
|
|
],
|
|
},
|
|
height: 600,
|
|
width: 800,
|
|
orientation: "HORIZONTAL",
|
|
},
|
|
panels: {
|
|
sidebar: { id: "sidebar", contentComponent: "sidebar" },
|
|
chat: { id: "chat", contentComponent: "chat" },
|
|
},
|
|
activeGroup: "g-center",
|
|
};
|
|
vi.mocked(getEnvMaximizeState).mockImplementation((envId) =>
|
|
envId === "env-a"
|
|
? {
|
|
preMaximizeLayout: {
|
|
columns: [
|
|
{ id: "center", groups: [{ id: "group-center", panels: [] }] },
|
|
{ id: "right", groups: [{ id: "group-right-top", panels: [] }] },
|
|
],
|
|
},
|
|
maximizedDockviewJson: savedMaximizedJson,
|
|
}
|
|
: null,
|
|
);
|
|
|
|
useDockviewStore.setState({ api, currentLayoutEnvId: "env-b" });
|
|
useDockviewStore.getState().switchEnvLayout("env-b", "env-a", "session-a");
|
|
|
|
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", () => {
|
|
testNormalizesStaleSessionPanelsOnSavedMaximize();
|
|
});
|
|
|
|
it("does not add Agent when the saved maximized group intentionally excludes it", () => {
|
|
vi.mocked(getEnvMaximizeState).mockReturnValueOnce({
|
|
preMaximizeLayout: { columns: [] },
|
|
maximizedDockviewJson: {
|
|
grid: {
|
|
root: {
|
|
type: "leaf",
|
|
size: 600,
|
|
data: { id: "group-right-bottom", views: ["terminal-default"] },
|
|
},
|
|
height: 600,
|
|
width: 800,
|
|
orientation: "HORIZONTAL",
|
|
},
|
|
panels: {
|
|
"terminal-default": { contentComponent: "terminal" },
|
|
},
|
|
activeGroup: "group-right-bottom",
|
|
},
|
|
});
|
|
const api = makeMockApi();
|
|
useDockviewStore.setState({ api, currentLayoutEnvId: "env-b" });
|
|
|
|
useDockviewStore.getState().switchEnvLayout("env-b", "env-a", "session-a");
|
|
|
|
expect(api.addPanel).not.toHaveBeenCalled();
|
|
});
|
|
});
|