Files
kandev/apps/web/components/task/dockview-layout-setup.scroll-target.test.ts
T
Clément Fiere 0e6c98c0a0 feat(web): add prompt history panel (#2738)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-08-18 16:13:15 +00:00

211 lines
7.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
const SESSION_ONE_PANEL_ID = "session:session-1";
const { mockClearScrollTargetForOwner, mockRelease, dockviewState } = vi.hoisted(() => ({
mockClearScrollTargetForOwner: vi.fn((sessionId: string, hostPanelId: string) => {
if (
dockviewState.scrollTarget?.sessionId === sessionId &&
dockviewState.scrollTarget.hostPanelId === hostPanelId
) {
dockviewState.scrollTarget = null;
}
}),
mockRelease: vi.fn(),
dockviewState: {
isRestoringLayout: false,
scrollTarget: null as null | { sessionId: string; token: number; hostPanelId: string },
},
}));
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: Object.assign(() => ({}), {
getState: () => ({
clearScrollTargetForOwner: mockClearScrollTargetForOwner,
isRestoringLayout: dockviewState.isRestoringLayout,
// Non-maximized so handleMaximizeExitOnLastClose early-returns without
// touching the maximize helpers this test does not mock.
preMaximizeLayout: null,
}),
}),
}));
vi.mock("@/lib/layout/panel-portal-manager", () => ({
panelPortalManager: {
get: vi.fn(() => undefined),
release: mockRelease,
},
}));
import { setupPortalCleanup } from "./dockview-layout-setup";
type RemoveHandler = (panel: { id: string }) => void;
/** Builds a fake dockview API that captures removal handlers and exposes fireRemoval. */
function makeApi() {
const handlers: RemoveHandler[] = [];
return {
onDidRemovePanel: (handler: RemoveHandler) => {
handlers.push(handler);
return { dispose: () => {} };
},
panels: [] as Array<{ id: string }>,
hasMaximizedGroup: () => false,
/** Fires every captured onDidRemovePanel handler with the given panel. */
fireRemoval(panel: { id: string }) {
for (const handler of handlers) handler(panel);
},
};
}
/** Builds a minimal zustand store whose state exposes the given active session id. */
function makeAppStore(activeSessionId: string): StoreApi<AppState> {
return {
getState: () => ({ tasks: { activeSessionId } }) as unknown as AppState,
} as StoreApi<AppState>;
}
beforeEach(() => {
vi.clearAllMocks();
dockviewState.isRestoringLayout = false;
dockviewState.scrollTarget = null;
});
describe("setupPortalCleanup — scroll-target teardown", () => {
it("clears the removed session's target when a session:<id> panel is removed", () => {
const api = makeApi();
setupPortalCleanup(api as never, makeAppStore("session-1"));
api.fireRemoval({ id: "session:session-7" });
expect(mockClearScrollTargetForOwner).toHaveBeenCalledWith("session-7", "session:session-7");
});
it("clears by the active session when the canonical chat panel is removed", () => {
const api = makeApi();
setupPortalCleanup(api as never, makeAppStore("session-9"));
api.fireRemoval({ id: "chat" });
expect(mockClearScrollTargetForOwner).toHaveBeenCalledWith("session-9", "chat");
});
it("leaves targets intact when an unrelated panel is removed", () => {
const api = makeApi();
setupPortalCleanup(api as never, makeAppStore("session-1"));
api.fireRemoval({ id: "files" });
expect(mockClearScrollTargetForOwner).not.toHaveBeenCalled();
});
it("clears the removed session only, never the other sessions' targets", () => {
const api = makeApi();
setupPortalCleanup(api as never, makeAppStore("session-active"));
api.fireRemoval({ id: "session:session-a" });
expect(mockClearScrollTargetForOwner).toHaveBeenCalledTimes(1);
expect(mockClearScrollTargetForOwner).toHaveBeenCalledWith("session-a", "session:session-a");
});
it("clears the latest target when its panel is removed", () => {
// Task 03 teardown policy: removal clears by session and owner (no token),
// so the latest target is invalidated before a stale consumer can use it.
const api = makeApi();
setupPortalCleanup(api as never, makeAppStore("session-1"));
dockviewState.scrollTarget = {
sessionId: "session-1",
token: 2,
hostPanelId: SESSION_ONE_PANEL_ID,
};
api.fireRemoval({ id: SESSION_ONE_PANEL_ID });
expect(mockClearScrollTargetForOwner).toHaveBeenCalledWith("session-1", SESSION_ONE_PANEL_ID);
expect(dockviewState.scrollTarget).toBeNull();
});
it("leaves the latest target intact when an unrelated session's panel is removed", () => {
const api = makeApi();
setupPortalCleanup(api as never, makeAppStore("session-1"));
dockviewState.scrollTarget = {
sessionId: "session-1",
token: 2,
hostPanelId: SESSION_ONE_PANEL_ID,
};
api.fireRemoval({ id: "session:session-9" });
expect(dockviewState.scrollTarget).toEqual({
sessionId: "session-1",
token: 2,
hostPanelId: SESSION_ONE_PANEL_ID,
});
});
it("runs the clear even while a layout restore is in progress (before the restore guard)", () => {
dockviewState.isRestoringLayout = true;
const api = makeApi();
setupPortalCleanup(api as never, makeAppStore("session-1"));
api.fireRemoval({ id: "session:session-3" });
api.fireRemoval({ id: "chat" });
expect(mockClearScrollTargetForOwner).toHaveBeenCalledWith("session-3", "session:session-3");
expect(mockClearScrollTargetForOwner).toHaveBeenCalledWith("session-1", "chat");
});
it("clears the target during a restore detach that keeps the host portal alive", () => {
// Task 03 persistent-portal restore: `fromJSON` DETACHES the canonical
// chat slot but deliberately does NOT release its portal, so the
// TaskChatPanel host stays MOUNTED and its unmount cleanup never runs.
// The removal-path clear (before the restore guard) is the only cleanup —
// assert both halves: the target IS cleared and the portal is NOT
// released (host stays mounted).
dockviewState.isRestoringLayout = true;
const api = makeApi();
setupPortalCleanup(api as never, makeAppStore("session-1"));
api.fireRemoval({ id: "chat" });
expect(mockClearScrollTargetForOwner).toHaveBeenCalledWith("session-1", "chat");
expect(mockRelease).not.toHaveBeenCalled();
});
it("integration: restore detach keeps the REAL portal host mounted while the removal path clears", async () => {
// Exercise the actual PanelPortalManager lifecycle instead of only the
// mocked removal handler: the canonical chat host acquires a portal
// (stays mounted), the restore detaches the slot and fires the removal,
// and the clear must run while the portal entry survives.
const { panelPortalManager: realManager } = await vi.importActual<
typeof import("@/lib/layout/panel-portal-manager")
>("@/lib/layout/panel-portal-manager");
dockviewState.isRestoringLayout = true;
const api = makeApi();
setupPortalCleanup(api as never, makeAppStore("session-1"));
realManager.acquire("chat", "chat", {}, { component: "chat" } as never);
expect(realManager.has("chat")).toBe(true);
api.fireRemoval({ id: "chat" });
expect(mockClearScrollTargetForOwner).toHaveBeenCalledWith("session-1", "chat");
// Detach without release: the portal entry (host) is retained.
expect(mockRelease).not.toHaveBeenCalled();
expect(realManager.has("chat")).toBe(true);
realManager.release("chat");
});
it("does not clear without an active session when the canonical chat is removed", () => {
const api = makeApi();
setupPortalCleanup(api as never, makeAppStore(""));
api.fireRemoval({ id: "chat" });
expect(mockClearScrollTargetForOwner).not.toHaveBeenCalled();
});
});