Files
kandev/apps/web/lib/state/dockview-env-switch.test.ts
T
José Almeida 46f20e0dfe fix(web): keep dockview group alive on task switch to stop layout collapse (#1308)
* fix(web): keep dockview group alive on task switch to stop layout collapse

On a fast-path env switch, the incoming session chat was added only after
removeEphemeralPanels closed the outgoing chat. When that chat was the last
surviving panel in its group (e.g. the default layout's chat column), removing
it first emptied — and so destroyed — the group. The captured group id then no
longer existed, and post-#1165 the old `referenceGroup: "sidebar"` fallback is
dead, so addPanel ran with an undefined position: dockview dropped the incoming
chat into the active (terminal) group and collapsed the grid root to a vertical
stack. The corrupted layout was then persisted, so it survived reloads.

Add the incoming session panel before removing the outgoing one, anchored at the
outgoing panel's current index, so the group stays alive throughout the swap.
Removing the panels ahead of the insert point shifts the new panel into the same
final slot, so computeSurvivingIndex is no longer needed.

* docs(web): note the raw-index equivalence assumption in env switch
2026-06-08 17:33:54 +01:00

415 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { performEnvSwitch, type EnvSwitchParams } from "./dockview-env-switch";
vi.mock("@/lib/local-storage", () => ({
getEnvLayout: vi.fn(() => null),
}));
vi.mock("./dockview-layout-builders", () => ({
applyLayoutFixups: vi.fn(() => ({
sidebarGroupId: "g1",
centerGroupId: "g2",
rightTopGroupId: "g3",
rightBottomGroupId: "g4",
})),
}));
vi.mock("./layout-manager", () => ({
fromDockviewApi: vi.fn(() => ({ columns: [] })),
savedLayoutMatchesLive: vi.fn(() => false),
layoutStructuresMatch: vi.fn(() => false),
getRootSplitview: vi.fn(() => null),
getPinnedWidth: vi.fn(() => 350),
}));
import { getEnvLayout } from "@/lib/local-storage";
import { layoutStructuresMatch, savedLayoutMatchesLive } from "./layout-manager";
const NEW_SESSION_PANEL_ID = "session:new-session";
function makeMockApi() {
return {
panels: [],
groups: [],
layout: vi.fn(),
fromJSON: vi.fn(),
getPanel: vi.fn(() => null),
addPanel: vi.fn(),
} as unknown as EnvSwitchParams["api"];
}
function makeHealthyLayoutWith(extraPanels: Record<string, { contentComponent: string }>) {
return {
grid: {
root: {
type: "leaf" as const,
size: 800,
data: { id: "g1", views: ["chat"], activeView: "chat" },
},
height: 600,
width: 800,
orientation: "HORIZONTAL" as const,
},
panels: {
chat: { contentComponent: "chat" },
...extraPanels,
},
activeGroup: "g1",
} as unknown as ReturnType<typeof getEnvLayout>;
}
function makeParams(overrides?: Partial<EnvSwitchParams>): EnvSwitchParams {
return {
api: makeMockApi(),
oldEnvId: "old-env",
newEnvId: "new-env",
activeSessionId: "new-session",
safeWidth: 800,
safeHeight: 600,
buildDefault: vi.fn(),
getDefaultLayout: vi.fn(() => ({ columns: [] })),
...overrides,
};
}
function makeTwoLeafSavedLayout(
leaves: Array<{ id: string; views: string[]; activeView: string }>,
activeGroup: string,
): ReturnType<typeof getEnvLayout> {
return {
grid: {
root: {
type: "branch" as const,
data: leaves.map((leaf) => ({ type: "leaf", data: leaf })),
},
height: 600,
width: 800,
orientation: "HORIZONTAL" as const,
},
panels: { chat: { contentComponent: "chat" } },
activeGroup,
} as unknown as ReturnType<typeof getEnvLayout>;
}
describe("performEnvSwitch", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls api.layout on the fast path when structures match", () => {
vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true);
const params = makeParams();
performEnvSwitch(params);
expect(params.api.layout).toHaveBeenCalledWith(800, 600);
});
it("calls api.layout on the fast path when saved layout matches", () => {
vi.mocked(getEnvLayout).mockReturnValueOnce(makeHealthyLayoutWith({}));
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(true);
const params = makeParams();
performEnvSwitch(params);
expect(params.api.layout).toHaveBeenCalledWith(800, 600);
expect(params.api.fromJSON).not.toHaveBeenCalled();
});
it("creates session panel inline on the fast path when it does not exist", () => {
vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true);
const params = makeParams();
performEnvSwitch(params);
expect(params.api.addPanel).toHaveBeenCalledWith(
expect.objectContaining({
id: NEW_SESSION_PANEL_ID,
component: "chat",
params: { sessionId: "new-session" },
}),
);
});
it("skips addPanel on the fast path when the session panel already exists", () => {
vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true);
const panel = { id: NEW_SESSION_PANEL_ID, api: { component: "chat" }, group: { id: "g1" } };
const params = makeParams({
api: {
...makeMockApi(),
getPanel: vi.fn((id: string) => (id === NEW_SESSION_PANEL_ID ? panel : null)),
} as unknown as EnvSwitchParams["api"],
});
performEnvSwitch(params);
expect(params.api.addPanel).not.toHaveBeenCalled();
});
it.each(["file-editor", "browser", "vscode", "commit-detail", "diff-viewer", "pr-detail"])(
"skips fast path when saved layout has ephemeral panels (%s)",
(contentComponent) => {
const savedLayout = makeHealthyLayoutWith({
[`preview:${contentComponent}`]: { contentComponent },
});
vi.mocked(getEnvLayout).mockReturnValueOnce(savedLayout).mockReturnValueOnce(savedLayout);
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(true);
const params = makeParams();
performEnvSwitch(params);
expect(params.api.fromJSON).toHaveBeenCalled();
},
);
it("calls api.layout on the slow path (buildDefault fallback)", () => {
const params = makeParams();
performEnvSwitch(params);
expect(params.api.layout).toHaveBeenCalledWith(800, 600);
expect(params.buildDefault).toHaveBeenCalledWith(params.api);
});
it("preserves the outgoing session panel's tab index when adding the new session on the fast path", () => {
// Regression: the fast-path used to call addPanel with only
// { referenceGroup }, so dockview appended the new session tab to the end
// of the group instead of restoring it to its original slot.
vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true);
const groupPanels = [
{ id: "files", api: { component: "files" } },
{ id: "session:old-session", api: { component: "chat" } },
{ id: "changes", api: { component: "changes" } },
{ id: "terminal-default", api: { component: "terminal" } },
];
const groupId = "center-group";
const outgoing = {
...groupPanels[1],
group: { id: groupId, panels: groupPanels },
};
const api = {
...makeMockApi(),
panels: [outgoing],
groups: [{ id: groupId }],
getPanel: vi.fn(() => null),
} as unknown as EnvSwitchParams["api"];
const params = makeParams({ api });
performEnvSwitch(params);
expect(api.addPanel).toHaveBeenCalledWith(
expect.objectContaining({
id: NEW_SESSION_PANEL_ID,
position: { referenceGroup: groupId, index: 1 },
}),
);
});
});
describe("performEnvSwitch fast-path group survival", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("adds the incoming session panel before closing the outgoing chat (keeps its group alive)", () => {
// Regression: when the outgoing session chat is the only surviving panel in
// its group (e.g. the default layout's chat column), removing it FIRST
// empties — and so destroys — the group. The captured group id then no
// longer exists, and post-#1165 the old `referenceGroup: "sidebar"` fallback
// is dead, so `addPanel` runs with an undefined position and dockview drops
// the incoming chat into whatever group is active (the terminal),
// collapsing the grid root to a vertical stack. Adding the incoming panel
// BEFORE removing the outgoing one keeps the group alive throughout.
vi.mocked(layoutStructuresMatch).mockReturnValueOnce(true);
const order: string[] = [];
const closeOutgoing = vi.fn(() => order.push("close"));
const groupId = "center-group";
const outgoingPanels = [
{
id: "session:old-session",
api: { component: "chat", isActive: true, close: closeOutgoing },
},
];
const outgoing = { ...outgoingPanels[0], group: { id: groupId, panels: outgoingPanels } };
const addPanel = vi.fn(() => order.push("add"));
const api = {
...makeMockApi(),
panels: [outgoing],
groups: [{ id: groupId }],
getPanel: vi.fn(() => null),
addPanel,
} as unknown as EnvSwitchParams["api"];
performEnvSwitch(makeParams({ api }));
expect(addPanel).toHaveBeenCalledWith(
expect.objectContaining({
id: NEW_SESSION_PANEL_ID,
position: { referenceGroup: groupId, index: 0 },
}),
);
expect(order).toEqual(["add", "close"]);
});
});
describe("performEnvSwitch slow-path stale session strip", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("closes stale session chat panels after the slow-path fromJSON", () => {
// Regression: a saved env layout could carry a `session:*` panel from a
// previously-deleted task (phantom). On the slow-path restore, that
// panel would land in the live api as a stray tab. replaceStaleSessionPanels
// must close any session:* panel whose id != the incoming active session.
vi.mocked(getEnvLayout)
.mockReturnValueOnce(makeHealthyLayoutWith({}))
.mockReturnValueOnce(makeHealthyLayoutWith({}));
// Force the slow path: layouts don't structurally match.
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(false);
const closeStale = vi.fn();
const closeFileEditor = vi.fn();
const closeKeep = vi.fn();
const api = {
...makeMockApi(),
// api.fromJSON is a no-op mock; populate `panels` with what would exist
// post-restore so replaceStaleSessionPanels' filter has something to act on.
panels: [
{ id: "session:old-session", api: { component: "chat", close: closeStale } },
{ id: NEW_SESSION_PANEL_ID, api: { component: "chat", close: closeKeep } },
// file editors are NOT session panels — they must NOT be closed.
{ id: "preview:file-editor", api: { component: "file-editor", close: closeFileEditor } },
],
getPanel: vi.fn((id: string) =>
id === NEW_SESSION_PANEL_ID ? { id: NEW_SESSION_PANEL_ID } : null,
),
} as unknown as EnvSwitchParams["api"];
const params = makeParams({ api });
performEnvSwitch(params);
expect(closeStale).toHaveBeenCalledOnce();
expect(closeKeep).not.toHaveBeenCalled();
expect(closeFileEditor).not.toHaveBeenCalled();
expect(params.api.fromJSON).toHaveBeenCalledOnce();
// Keep panel already existed, so no addPanel.
expect(params.api.addPanel).not.toHaveBeenCalled();
});
it("anchors the new session to the stale session's group and tab index", () => {
// Regression: when the saved layout had a phantom session co-tabbed with
// pr-detail (or other siblings the user dragged into the chat group),
// simply closing the phantom orphaned the siblings. The new active session
// would then land as a fresh split next to the sidebar — pulling pr-detail
// out of the user's grouping. The replacement must land in the phantom's
// exact (group, index) so siblings stay tabbed with the agent.
vi.mocked(getEnvLayout)
.mockReturnValueOnce(makeHealthyLayoutWith({}))
.mockReturnValueOnce(makeHealthyLayoutWith({}));
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(false);
const closeStale = vi.fn();
const stalePanelId = "session:phantom-from-other-env";
const groupId = "saved-center-group";
const groupPanels = [
{ id: stalePanelId, api: { component: "chat", close: closeStale } },
{ id: "pr-detail", api: { component: "pr-detail", close: vi.fn() } },
];
const stale = {
...groupPanels[0],
group: { id: groupId, panels: groupPanels },
};
const api = {
...makeMockApi(),
panels: [stale, { id: "pr-detail", api: { component: "pr-detail" }, group: { id: groupId } }],
groups: [{ id: groupId }],
// The active session panel does NOT exist yet — that's the whole point;
// the fromJSON restore only brought back the phantom.
getPanel: vi.fn(() => null),
} as unknown as EnvSwitchParams["api"];
const params = makeParams({ api });
performEnvSwitch(params);
expect(api.addPanel).toHaveBeenCalledWith(
expect.objectContaining({
id: NEW_SESSION_PANEL_ID,
component: "chat",
position: { referenceGroup: groupId, index: 0 },
}),
);
expect(closeStale).toHaveBeenCalledOnce();
});
it("skips addPanel when there is no active session (sessionless task)", () => {
vi.mocked(getEnvLayout)
.mockReturnValueOnce(makeHealthyLayoutWith({}))
.mockReturnValueOnce(makeHealthyLayoutWith({}));
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(false);
const closeStale = vi.fn();
const groupId = "g1";
const groupPanels = [{ id: "session:phantom", api: { component: "chat", close: closeStale } }];
const stale = { ...groupPanels[0], group: { id: groupId, panels: groupPanels } };
const api = {
...makeMockApi(),
panels: [stale],
groups: [{ id: groupId }],
} as unknown as EnvSwitchParams["api"];
performEnvSwitch(makeParams({ api, activeSessionId: null }));
expect(closeStale).toHaveBeenCalledOnce();
expect(api.addPanel).not.toHaveBeenCalled();
});
});
describe("performEnvSwitch fast-path active view restoration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("restores saved per-group active tabs on the fast path", () => {
// Regression: the fast path skips fromJSON, so per-group active tabs
// from the outgoing env would persist into the incoming env. The saved
// layout's activeView for each group must be reapplied.
const setActiveRight = vi.fn();
const setActiveCenter = vi.fn();
const rightGroup = {
id: "right",
panels: [
{ id: "plan", api: { setActive: setActiveRight } },
{ id: "files", api: { setActive: vi.fn() } },
],
};
const centerGroup = {
id: "center",
panels: [{ id: NEW_SESSION_PANEL_ID, api: { setActive: setActiveCenter } }],
};
const savedLayout = makeTwoLeafSavedLayout(
[
{ id: "center", views: ["chat"], activeView: "chat" },
{ id: "right", views: ["plan", "files"], activeView: "plan" },
],
"right",
);
vi.mocked(getEnvLayout).mockReturnValueOnce(savedLayout).mockReturnValueOnce(savedLayout);
vi.mocked(savedLayoutMatchesLive).mockReturnValueOnce(true);
const api = {
...makeMockApi(),
groups: [centerGroup, rightGroup],
getPanel: vi.fn((id: string) => (id === NEW_SESSION_PANEL_ID ? centerGroup.panels[0] : null)),
} as unknown as EnvSwitchParams["api"];
performEnvSwitch(makeParams({ api }));
expect(setActiveRight).toHaveBeenCalled();
// The saved activeGroup ("right") is applied last, so its setActive must
// be the most recent — otherwise center would steal global focus.
const lastRightCall = setActiveRight.mock.invocationCallOrder.at(-1) ?? 0;
const lastCenterCall = setActiveCenter.mock.invocationCallOrder.at(-1) ?? 0;
expect(lastRightCall).toBeGreaterThan(lastCenterCall);
});
});