import { describe, it, expect, vi } from "vitest"; import type { DockviewApi } from "dockview-react"; import type { TaskSession } from "@/lib/types/http"; import { ensureSessionTabPrecedesNonSessionTabs, findSessionAnchorGroupId, reconcileRemovedSessionPanels, resolveInitialPosition, resolveSessionTabSyncTarget, runAutoSessionTabEffect, shouldActivateSessionPanel, shouldRebuildDefaultForPendingSession, } from "./dockview-session-tabs"; import { CENTER_GROUP, RIGHT_TOP_GROUP } from "@/lib/state/layout-manager"; import { useDockviewStore } from "@/lib/state/dockview-store"; type FakePanel = { id: string; api: { close: ReturnType> }; }; type MoveToOptions = { group: unknown; position: "center"; index: number; skipSetActive: boolean; }; type MoveToCall = { panelId: string; options: MoveToOptions; }; const KEEP = "keep"; const KEEP_PANEL = `session:${KEEP}`; const LEAKED_PANEL = "session:leaked"; const PENDING_SESSION_ID = "session-new"; const TEST_GROUP_CENTER = "group-center"; const CENTER_POSITION = "center"; const SIBLING_PANEL = "session:sibling"; const AUTO_TASK_ID = "task-A"; const PENDING_EFFECT_SESSION_ID = "session-pending"; /** * Builds a fake DockviewApi where `panel.api.close()` mutates the underlying * `panels` array synchronously, mirroring how dockview removes a panel from * its live `panels` getter the moment it's closed. This is what made the * unsnapshotted `for (const panel of api.panels)` loop skip elements. */ function makeApi(panelIds: string[]): { api: DockviewApi; panels: FakePanel[] } { const panels: FakePanel[] = []; for (const id of panelIds) { const panel: FakePanel = { id, api: { close: vi.fn(() => { const idx = panels.indexOf(panel); if (idx !== -1) panels.splice(idx, 1); }), }, }; panels.push(panel); } const api = { panels, getPanel: (id: string) => panels.find((p) => p.id === id) ?? null, } as unknown as DockviewApi; return { api, panels }; } function makeAutoSessionAppStore(taskId: string | null, sessionIds: string[]) { const itemsByTaskId = taskId ? { [taskId]: sessionIds.map((id) => ({ id })) } : {}; return { getState: () => ({ tasks: { activeTaskId: taskId }, taskSessionsByTask: { itemsByTaskId }, }), }; } function makeAutoSessionRefs( prevTaskId = "task-old", prevSessionId: string | null = "session-old", ) { return { sessionTabCreatedRef: { current: new Set() }, prevTaskIdRef: { current: prevTaskId as string | null }, prevSessionIdRef: { current: prevSessionId }, }; } function withDockviewState( updates: Partial>, callback: () => T, ): T { const previous = useDockviewStore.getState(); useDockviewStore.setState(updates); try { return callback(); } finally { useDockviewStore.setState({ api: previous.api, buildDefaultLayout: previous.buildDefaultLayout, currentLayoutEnvId: previous.currentLayoutEnvId, preMaximizeLayout: previous.preMaximizeLayout, pinnedWidths: previous.pinnedWidths, maximizedGroupId: previous.maximizedGroupId, isRestoringLayout: previous.isRestoringLayout, }); } } function makePositionApi(args: { groups: string[]; panels?: Array<{ id: string; groupId: string }>; }): DockviewApi { const panels = args.panels?.map((p) => ({ id: p.id, group: { id: p.groupId }, })) ?? []; return { groups: args.groups.map((id) => ({ id })), panels, getPanel: (id: string) => panels.find((p) => p.id === id) ?? null, } as unknown as DockviewApi; } function panelById(panels: FakePanel[], id: string): FakePanel | undefined { return panels.find((p) => p.id === id); } function makeTabOrderApi(panelIds: string[]): { api: DockviewApi; moveToCalls: MoveToCall[]; } { const moveToCalls: MoveToCall[] = []; const group = { id: TEST_GROUP_CENTER, panels: [] as Array<{ id: string }> }; const panels = panelIds.map((id) => ({ id, group, api: { moveTo: vi.fn((options: MoveToOptions) => { moveToCalls.push({ panelId: id, options }); }), }, })); group.panels = panels; return { api: { getPanel: (id: string) => panels.find((p) => p.id === id) ?? null, } as unknown as DockviewApi, moveToCalls, }; } function expectedMove(panelId: string, index: number): MoveToCall { return { panelId, options: { group: expect.objectContaining({ id: TEST_GROUP_CENTER }), position: CENTER_POSITION, index, skipSetActive: true, }, }; } describe("reconcileRemovedSessionPanels", () => { it("closes a stale tracked panel that's still live in dockview", () => { // createdSet has session-A; A's panel is live; A is no longer in the task's // session list, so it must be closed. const { api, panels } = makeApi(["session:A", KEEP_PANEL]); const aPanel = panelById(panels, "session:A"); const createdSet = new Set(["A", KEEP]); reconcileRemovedSessionPanels(api, createdSet, [KEEP], KEEP); expect(aPanel?.api.close).toHaveBeenCalledTimes(1); expect(createdSet.has("A")).toBe(false); }); it("closes live session panels that were never tracked in createdSet (the leak)", () => { // Reproduces the user-reported leak: dockview has session panels // (e.g. restored from a persisted layout) for sessions that aren't in the // current task's session list. createdSet is empty (or missing them) // because the panels entered via `tryRestoreLayout` / `fromJSON`, not // through ensureSessionPanel. const { api, panels } = makeApi([LEAKED_PANEL, KEEP_PANEL]); const leakedPanel = panelById(panels, LEAKED_PANEL); const keepPanel = panelById(panels, KEEP_PANEL); const createdSet = new Set(["stale-deleted"]); // pollution from a prior right-click delete reconcileRemovedSessionPanels(api, createdSet, [KEEP], KEEP); expect( leakedPanel?.api.close, "leaked session panel must be closed even though it was never tracked", ).toHaveBeenCalledTimes(1); expect(keepPanel?.api.close, "keepSessionId panel must not be closed").not.toHaveBeenCalled(); }); it("closes every leaked panel even when close() mutates api.panels mid-iteration", () => { // Regression for iterator-invalidation: with a live `panels` getter, a // synchronous splice inside `close()` shifts subsequent elements left and // a `for (const p of api.panels)` loop would skip the panel that moved // into the just-vacated index. The implementation snapshots before // iterating, so all leaked panels must still be closed in one pass. const { api, panels } = makeApi([ "session:leak1", "session:leak2", "session:leak3", KEEP_PANEL, ]); const leak1 = panelById(panels, "session:leak1"); const leak2 = panelById(panels, "session:leak2"); const leak3 = panelById(panels, "session:leak3"); const keepPanel = panelById(panels, KEEP_PANEL); reconcileRemovedSessionPanels(api, new Set(), [KEEP], KEEP); expect(leak1?.api.close).toHaveBeenCalledTimes(1); expect(leak2?.api.close).toHaveBeenCalledTimes(1); expect(leak3?.api.close).toHaveBeenCalledTimes(1); expect(keepPanel?.api.close).not.toHaveBeenCalled(); }); it("does not close the keepSessionId panel even if it is missing from createdSet", () => { const { api, panels } = makeApi([KEEP_PANEL]); const keepPanel = panelById(panels, KEEP_PANEL); const createdSet = new Set(); // keep was never tracked reconcileRemovedSessionPanels(api, createdSet, [KEEP], KEEP); expect(keepPanel?.api.close).not.toHaveBeenCalled(); }); it("does not close panels for sessions still present in the task's session list", () => { const { api, panels } = makeApi(["session:a", "session:b"]); const a = panelById(panels, "session:a"); const b = panelById(panels, "session:b"); const createdSet = new Set(); reconcileRemovedSessionPanels(api, createdSet, ["a", "b"], "a"); expect(a?.api.close).not.toHaveBeenCalled(); expect(b?.api.close).not.toHaveBeenCalled(); }); it("ignores non-session panels", () => { const { api, panels } = makeApi(["sidebar", "terminal:1", LEAKED_PANEL]); const sidebar = panelById(panels, "sidebar"); const terminal = panelById(panels, "terminal:1"); const leaked = panelById(panels, LEAKED_PANEL); const createdSet = new Set(); reconcileRemovedSessionPanels(api, createdSet, [], ""); expect(sidebar?.api.close).not.toHaveBeenCalled(); expect(terminal?.api.close).not.toHaveBeenCalled(); expect(leaked?.api.close).toHaveBeenCalledTimes(1); }); it("prunes stale entries from createdSet whose panels are already gone", () => { // Right-click delete path: the panel was removed via // containerApi.removePanel() in onDeleted, but createdSet still holds the // session ID. Reconcile should drop the stale entry. const { api } = makeApi([KEEP_PANEL]); const createdSet = new Set(["already-removed", KEEP]); reconcileRemovedSessionPanels(api, createdSet, [KEEP], KEEP); expect(createdSet.has("already-removed")).toBe(false); expect(createdSet.has(KEEP)).toBe(true); }); }); describe("findSessionAnchorGroupId", () => { function makeApiWithPanels(panels: Array<{ id: string; groupId: string }>): DockviewApi { const enriched = panels.map((p) => ({ id: p.id, group: { id: p.groupId } })); return { panels: enriched, getPanel: (id: string) => enriched.find((p) => p.id === id) ?? null, } as unknown as DockviewApi; } it("returns the group id of a pr-detail anchor panel", () => { // Regression: when a saved layout's session was sanitized away (page load) // or replaced (env switch) but pr-detail remained, the new session would // be added as a right-of-sidebar split instead of joining pr-detail's // group — pulling pr-detail out of the user's grouping with the agent. const api = makeApiWithPanels([{ id: "pr-detail", groupId: "saved-center-group" }]); expect(findSessionAnchorGroupId(api)).toBe("saved-center-group"); }); it("matches keyed pr-detail panels used by multi-repo PR flows", () => { // Multi-repo tasks open one PR tab per PR with id `pr-detail|owner/repo/N` // (see addPRPanel in dockview-panel-actions.ts). Without prefix matching, // findSessionAnchorGroupId would miss them and the new session would land // as a right-of-sidebar split — losing the user's PR/session grouping. const api = makeApiWithPanels([{ id: "pr-detail|owner/repo/123", groupId: "keyed-pr-group" }]); expect(findSessionAnchorGroupId(api)).toBe("keyed-pr-group"); }); it("returns null when no anchor panel exists", () => { const api = makeApiWithPanels([]); expect(findSessionAnchorGroupId(api)).toBeNull(); }); }); describe("resolveInitialPosition", () => { it("creates a center column left of the right sidebar when only the right group remains", () => { useDockviewStore.setState({ centerGroupId: RIGHT_TOP_GROUP }); const api = makePositionApi({ groups: [RIGHT_TOP_GROUP] }); expect(resolveInitialPosition(api)).toEqual({ referenceGroup: RIGHT_TOP_GROUP, direction: "left", }); }); }); describe("ensureSessionTabPrecedesNonSessionTabs", () => { it("moves a restored session tab before a PR tab that was saved first", () => { const { api, moveToCalls } = makeTabOrderApi(["pr-detail", KEEP_PANEL, "preview:file-diff"]); ensureSessionTabPrecedesNonSessionTabs(api, KEEP); expect(moveToCalls).toEqual([expectedMove(KEEP_PANEL, 0)]); }); it("moves interleaved session tabs as a stable block before non-session tabs", () => { const { api, moveToCalls } = makeTabOrderApi(["pr-detail", SIBLING_PANEL, KEEP_PANEL]); ensureSessionTabPrecedesNonSessionTabs(api, KEEP); expect(moveToCalls).toEqual([expectedMove(KEEP_PANEL, 0), expectedMove(SIBLING_PANEL, 0)]); }); it("does not move when session tabs already precede non-session tabs", () => { const { api, moveToCalls } = makeTabOrderApi([KEEP_PANEL, SIBLING_PANEL, "pr-detail"]); ensureSessionTabPrecedesNonSessionTabs(api, KEEP); expect(moveToCalls).toEqual([]); }); it("does not move when the session tab already precedes non-session tabs", () => { const { api, moveToCalls } = makeTabOrderApi([KEEP_PANEL, "pr-detail"]); ensureSessionTabPrecedesNonSessionTabs(api, KEEP); expect(moveToCalls).toEqual([]); }); it("keeps earlier session tabs ahead of the active session tab", () => { const { api, moveToCalls } = makeTabOrderApi(["session:older", "pr-detail", KEEP_PANEL]); ensureSessionTabPrecedesNonSessionTabs(api, KEEP); expect(moveToCalls).toEqual([expectedMove(KEEP_PANEL, 1)]); }); it("does not move when the target session panel is absent", () => { const { api, moveToCalls } = makeTabOrderApi(["pr-detail", SIBLING_PANEL]); ensureSessionTabPrecedesNonSessionTabs(api, KEEP); expect(moveToCalls).toEqual([]); }); }); describe("shouldRebuildDefaultForPendingSession", () => { it("does not rebuild when there is no effective session id", () => { const api = makePositionApi({ groups: [RIGHT_TOP_GROUP], panels: [ { id: "session:missing", groupId: RIGHT_TOP_GROUP }, { id: "files", groupId: RIGHT_TOP_GROUP }, ], }); expect(shouldRebuildDefaultForPendingSession(api, null, [])).toBe(false); }); it("rebuilds default when the active session is pending and no chat group remains", () => { const api = makePositionApi({ groups: [RIGHT_TOP_GROUP], panels: [ { id: "files", groupId: RIGHT_TOP_GROUP }, { id: "changes", groupId: RIGHT_TOP_GROUP }, ], }); expect(shouldRebuildDefaultForPendingSession(api, PENDING_SESSION_ID, [])).toBe(true); }); it("rebuilds before stale session panels are reconciled away", () => { const api = makePositionApi({ groups: ["stale-center", RIGHT_TOP_GROUP], panels: [ { id: "session:old", groupId: "stale-center" }, { id: "files", groupId: RIGHT_TOP_GROUP }, { id: "changes", groupId: RIGHT_TOP_GROUP }, ], }); expect(shouldRebuildDefaultForPendingSession(api, PENDING_SESSION_ID, [])).toBe(true); }); it("does not rebuild while the chat placeholder is still present", () => { const api = makePositionApi({ groups: [CENTER_GROUP, RIGHT_TOP_GROUP], panels: [ { id: "chat", groupId: CENTER_GROUP }, { id: "files", groupId: RIGHT_TOP_GROUP }, ], }); expect(shouldRebuildDefaultForPendingSession(api, PENDING_SESSION_ID, [])).toBe(false); }); it("does not rebuild once the active session is hydrated for the task", () => { const api = makePositionApi({ groups: [RIGHT_TOP_GROUP] }); expect( shouldRebuildDefaultForPendingSession(api, PENDING_SESSION_ID, [PENDING_SESSION_ID]), ).toBe(false); }); }); describe("resolveSessionTabSyncTarget", () => { function makeSession(id: string, taskId: string): TaskSession { return { id, task_id: taskId } as unknown as TaskSession; } it("returns the (activeTaskId, sid) pair for a session that belongs to the active task", () => { const target = resolveSessionTabSyncTarget({ panelId: "session:s-new", activeTaskId: "task-A", activeSessionId: "s-old", taskSessionsById: { "s-new": makeSession("s-new", "task-A") }, environmentIdBySessionId: { "s-new": "env-A" }, }); expect(target).toEqual({ taskId: "task-A", sessionId: "s-new" }); }); it("returns null for non-session panels (sidebar, files, terminal, ...)", () => { for (const panelId of ["sidebar", "files", "changes", "terminal:default", "pr-detail"]) { expect( resolveSessionTabSyncTarget({ panelId, activeTaskId: "task-A", activeSessionId: null, taskSessionsById: {}, environmentIdBySessionId: {}, }), ).toBeNull(); } }); it("returns null when the activated session matches activeSessionId (no-op)", () => { const target = resolveSessionTabSyncTarget({ panelId: "session:s-current", activeTaskId: "task-A", activeSessionId: "s-current", taskSessionsById: { "s-current": makeSession("s-current", "task-A") }, environmentIdBySessionId: { "s-current": "env-A" }, }); expect(target).toBeNull(); }); /** * Regression for a layout-leak observed when creating a new task: during * the task switch dockview can briefly fire `onDidActivePanelChange` for a * stale `session:` panel that still belongs to the previous task. * If we wrote `setActiveSession(newTaskId, oldSid)`, that would poison * `lastSessionByTaskId[newTaskId]` and the next re-entry to the new task * would resolve to the cross-task session, restoring the wrong layout * (e.g. the previous task's files/changes panels). */ it("returns null when the activated session belongs to a different task than activeTaskId", () => { const target = resolveSessionTabSyncTarget({ panelId: "session:s-belongs-to-B", activeTaskId: "task-A", activeSessionId: null, taskSessionsById: { "s-belongs-to-B": makeSession("s-belongs-to-B", "task-B") }, environmentIdBySessionId: { "s-belongs-to-B": "env-B" }, }); expect(target).toBeNull(); }); it("accepts a session that has an env mapping but isn't yet hydrated into taskSessions.items", () => { // The listener can fire before `session.state_changed` lands the new // session in the items map. The env mapping is what guarantees the session // exists at all — once that's present we trust the active task. const target = resolveSessionTabSyncTarget({ panelId: "session:s-pending-hydration", activeTaskId: "task-A", activeSessionId: null, taskSessionsById: {}, environmentIdBySessionId: { "s-pending-hydration": "env-A" }, }); expect(target).toEqual({ taskId: "task-A", sessionId: "s-pending-hydration" }); }); /** * Regression for a one-frame UI glitch: `removeTaskSession` clears * `taskSessions.items[sid]` and `environmentIdBySessionId[sid]` atomically. * A dying panel firing activation between the clear and unmount must not * write `activeSessionId = ` — the env-mapping gate catches it. */ it("returns null when the activated session has no env mapping (deleted or stale)", () => { const target = resolveSessionTabSyncTarget({ panelId: "session:s-deleted", activeTaskId: "task-A", activeSessionId: null, taskSessionsById: {}, environmentIdBySessionId: {}, }); expect(target).toBeNull(); }); it("returns null when there is no active task", () => { const target = resolveSessionTabSyncTarget({ panelId: "session:s", activeTaskId: null, activeSessionId: null, taskSessionsById: {}, environmentIdBySessionId: { s: "env-A" }, }); expect(target).toBeNull(); }); it("returns null for a malformed session: panel id", () => { const target = resolveSessionTabSyncTarget({ panelId: "session:", activeTaskId: "task-A", activeSessionId: null, taskSessionsById: {}, environmentIdBySessionId: {}, }); expect(target).toBeNull(); }); }); describe("shouldActivateSessionPanel", () => { const SID = "s-current"; const SESSION_PANEL_ID = `session:${SID}`; const baseArgs = { prevTaskId: null, prevSessionId: null, currentTaskId: "task-A", currentSessionId: SID, currentActivePanelId: null, }; it("activates when the session panel did not exist before (fresh creation)", () => { expect(shouldActivateSessionPanel({ ...baseArgs, sessionPanelExistedBefore: false })).toBe( true, ); }); it("activates on first mount when no panel is currently active", () => { expect( shouldActivateSessionPanel({ ...baseArgs, sessionPanelExistedBefore: true, currentActivePanelId: null, }), ).toBe(true); }); it("activates on first mount when the restored active panel is already the session", () => { expect( shouldActivateSessionPanel({ ...baseArgs, sessionPanelExistedBefore: true, currentActivePanelId: SESSION_PANEL_ID, }), ).toBe(true); }); /** * Regression: on page refresh, dockview's fromJSON restores the active * panel from the saved layout (e.g. a file diff the user had focused). * We must not force-activate the session panel on top of it. */ it("does NOT override a restored non-session active panel on first mount", () => { expect( shouldActivateSessionPanel({ ...baseArgs, sessionPanelExistedBefore: true, currentActivePanelId: "preview:commit-detail", }), ).toBe(false); expect( shouldActivateSessionPanel({ ...baseArgs, sessionPanelExistedBefore: true, currentActivePanelId: "preview:file-editor", }), ).toBe(false); expect( shouldActivateSessionPanel({ ...baseArgs, sessionPanelExistedBefore: true, currentActivePanelId: "plan", }), ).toBe(false); }); it("activates on intra-task session switch (same task, different session)", () => { expect( shouldActivateSessionPanel({ sessionPanelExistedBefore: true, prevTaskId: "task-A", prevSessionId: "s-old", currentTaskId: "task-A", currentSessionId: SID, currentActivePanelId: "preview:commit-detail", }), ).toBe(true); }); it("does NOT activate on task switch (preserves saved active panel for new task)", () => { expect( shouldActivateSessionPanel({ sessionPanelExistedBefore: true, prevTaskId: "task-A", prevSessionId: "s-old", currentTaskId: "task-B", currentSessionId: SID, currentActivePanelId: "preview:commit-detail", }), ).toBe(false); }); }); describe("runAutoSessionTabEffect", () => { it("updates previous refs when panel ensure is skipped for an unhydrated session", () => { const api = { panels: [{ id: "chat" }], getPanel: (id: string) => (id === "chat" ? { id: "chat" } : null), } as unknown as DockviewApi; const appStore = makeAutoSessionAppStore(AUTO_TASK_ID, []); const refs = makeAutoSessionRefs(); withDockviewState({ api }, () => { runAutoSessionTabEffect(PENDING_EFFECT_SESSION_ID, appStore as never, refs as never); }); expect(refs.prevTaskIdRef.current).toBe(AUTO_TASK_ID); expect(refs.prevSessionIdRef.current).toBe(PENDING_EFFECT_SESSION_ID); }); it("updates previous refs when releasing default layout for a pending session", () => { const api = { panels: [], getPanel: () => null, } as unknown as DockviewApi; const buildDefaultLayout = vi.fn(); const appStore = makeAutoSessionAppStore(AUTO_TASK_ID, []); const refs = makeAutoSessionRefs(); withDockviewState( { api, buildDefaultLayout, currentLayoutEnvId: null, preMaximizeLayout: null, pinnedWidths: new Map(), maximizedGroupId: null, isRestoringLayout: false, }, () => { runAutoSessionTabEffect(PENDING_EFFECT_SESSION_ID, appStore as never, refs as never); }, ); expect(buildDefaultLayout).toHaveBeenCalledWith(api); expect(refs.prevTaskIdRef.current).toBe(AUTO_TASK_ID); expect(refs.prevSessionIdRef.current).toBe(PENDING_EFFECT_SESSION_ID); }); it("updates previous refs when there is no effective session", () => { const api = { panels: [], getPanel: () => null, } as unknown as DockviewApi; const appStore = makeAutoSessionAppStore(AUTO_TASK_ID, []); const refs = makeAutoSessionRefs(); withDockviewState({ api }, () => { runAutoSessionTabEffect(null, appStore as never, refs as never); }); expect(refs.prevTaskIdRef.current).toBe(AUTO_TASK_ID); expect(refs.prevSessionIdRef.current).toBeNull(); }); });