import { describe, it, expect, vi, beforeEach } from "vitest"; import { prepareAndSwitchTask, buildSwitchToSession, resolveTaskSessionId, selectTaskWithLayout, } from "./task-select-helpers"; const TASK_ID = "task-A"; const PRIMARY = "sess-primary"; const SECONDARY = "secondary"; const OTHER_SESSION_ID = "other-session"; const PRIMARY_ENV_ID = "env-primary"; const SECONDARY_ENV_ID = "env-secondary"; const NEWER_AT = "2026-08-14T12:02:00Z"; const OLDER_AT = "2026-08-14T12:01:00Z"; describe("resolveTaskSessionId", () => { const sessions = [ { id: SECONDARY, task_id: TASK_ID, state: "WAITING_FOR_INPUT", pending_action: "clarification", updated_at: NEWER_AT, }, { id: "primary", task_id: TASK_ID, state: "WAITING_FOR_INPUT", is_primary: true, updated_at: OLDER_AT, }, ] as TaskSession[]; it("selects the newest input-capable session that owns the task action", () => { expect( resolveTaskSessionId({ sessions, preferredSessionId: "primary", taskPendingAction: "clarification", }), ).toBe(SECONDARY); }); it("does not guess a fallback when the only matching owner is terminal", () => { expect( resolveTaskSessionId({ sessions: [{ ...sessions[0], state: "COMPLETED" }, sessions[1]], preferredSessionId: "primary", taskPendingAction: "clarification", }), ).toBe(""); }); it("preserves remembered and primary fallback for a clean task", () => { expect( resolveTaskSessionId({ sessions, preferredSessionId: SECONDARY, taskPendingAction: null, }), ).toBe(SECONDARY); }); }); const { dockviewState } = vi.hoisted(() => ({ dockviewState: { api: null as unknown, buildDefaultLayout: vi.fn(), }, })); vi.mock("@/lib/services/session-launch-service", () => ({ launchSession: vi.fn(), })); vi.mock("@/lib/services/session-launch-helpers", () => ({ buildPrepareRequest: vi.fn(() => ({ request: { taskId: "task-new" } })), })); vi.mock("@/lib/state/dockview-store", () => ({ performLayoutSwitch: vi.fn(), releaseLayoutToDefault: vi.fn(), useDockviewStore: { getState: () => dockviewState }, })); vi.mock("@/lib/links", () => ({ replaceTaskUrl: vi.fn(), })); import { launchSession, type LaunchSessionResponse } from "@/lib/services/session-launch-service"; import { performLayoutSwitch, releaseLayoutToDefault } from "@/lib/state/dockview-store"; import { replaceTaskUrl } from "@/lib/links"; import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; import type { TaskSession } from "@/lib/types/http"; const NEW_TASK_ID = "task-new"; const OLD_SESSION_ID = "old-session"; function makeStore(activeSessionId: string | null, hasLinkedPR = false): StoreApi { const state = { tasks: { activeSessionId }, taskPRs: { byTaskId: hasLinkedPR ? { [NEW_TASK_ID]: [{}] } : ({} as Record), }, environmentIdBySessionId: activeSessionId ? { [activeSessionId]: "env-old" } : {}, }; return { getState: () => state as unknown as AppState, setState: vi.fn(), subscribe: vi.fn(), } as unknown as StoreApi; } function makeEnvStore( envIds: Record, taskSessionsByTask: Record = {}, ): StoreApi { return { getState: () => ({ environmentIdBySessionId: envIds, taskSessionsByTask: { itemsByTaskId: Object.fromEntries( Object.entries(taskSessionsByTask).map(([taskId, sessionIds]) => [ taskId, sessionIds.map((id) => ({ id })), ]), ), }, }) as unknown as AppState, } as unknown as StoreApi; } function makeKanbanStore(args: { activeTaskId?: string | null; activeSessionId: string | null; envIds: Record; lastSessionByTaskId?: Record; sessionTaskIds?: Record; }): StoreApi { const items: Record = {}; for (const [sid, tid] of Object.entries(args.sessionTaskIds ?? {})) { items[sid] = { id: sid, task_id: tid }; } const state = { tasks: { activeTaskId: args.activeTaskId ?? null, activeSessionId: args.activeSessionId, lastSessionByTaskId: args.lastSessionByTaskId ?? {}, }, taskPRs: { byTaskId: {} as Record }, environmentIdBySessionId: args.envIds, taskSessions: { items }, }; return { getState: () => state as unknown as AppState, setState: vi.fn(), subscribe: vi.fn(), } as unknown as StoreApi; } function runSelect(store: StoreApi) { const switchToSession = vi.fn(); selectTaskWithLayout({ taskId: TASK_ID, task: { primarySessionId: PRIMARY }, store, switchToSession, loadTaskSessionsForTask: vi.fn(async () => []), setActiveTask: vi.fn(), setPreparingTaskId: vi.fn(), }); return switchToSession; } async function flushTaskSelection() { await Promise.resolve(); await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); } describe("selectTaskWithLayout pending owner", () => { it("loads sessions before switching even when the primary environment is known", async () => { const store = makeKanbanStore({ activeSessionId: OTHER_SESSION_ID, envIds: { [PRIMARY]: PRIMARY_ENV_ID, [SECONDARY]: SECONDARY_ENV_ID }, }); const switchToSession = vi.fn(); const loadTaskSessionsForTask = vi.fn( async () => [ { id: SECONDARY, task_id: TASK_ID, state: "WAITING_FOR_INPUT", pending_action: "clarification", updated_at: NEWER_AT, }, { id: PRIMARY, task_id: TASK_ID, state: "WAITING_FOR_INPUT", is_primary: true, updated_at: OLDER_AT, }, ] as TaskSession[], ); selectTaskWithLayout({ taskId: TASK_ID, task: { primarySessionId: PRIMARY, taskPendingAction: "clarification" }, store, switchToSession, loadTaskSessionsForTask, setActiveTask: vi.fn(), setPreparingTaskId: vi.fn(), }); expect(switchToSession).not.toHaveBeenCalled(); await flushTaskSelection(); expect(loadTaskSessionsForTask).toHaveBeenCalledWith(TASK_ID, { force: true }); expect(switchToSession).toHaveBeenCalledWith(TASK_ID, SECONDARY, OTHER_SESSION_ID); }); it("uses the status-summary pending owner when the legacy field is empty", async () => { const store = makeKanbanStore({ activeSessionId: OTHER_SESSION_ID, envIds: { [PRIMARY]: PRIMARY_ENV_ID, [SECONDARY]: SECONDARY_ENV_ID }, }); const switchToSession = vi.fn(); const loadTaskSessionsForTask = vi.fn( async () => [ { id: SECONDARY, task_id: TASK_ID, state: "WAITING_FOR_INPUT", pending_action: "clarification", started_at: NEWER_AT, updated_at: NEWER_AT, }, { id: PRIMARY, task_id: TASK_ID, state: "WAITING_FOR_INPUT", is_primary: true, started_at: OLDER_AT, updated_at: OLDER_AT, }, ] as TaskSession[], ); selectTaskWithLayout({ taskId: TASK_ID, task: { primarySessionId: PRIMARY, statusSummary: { pending_action: "clarification" }, }, store, switchToSession, loadTaskSessionsForTask, setActiveTask: vi.fn(), setPreparingTaskId: vi.fn(), }); expect(switchToSession).not.toHaveBeenCalled(); await flushTaskSelection(); expect(switchToSession).toHaveBeenCalledWith(TASK_ID, SECONDARY, OTHER_SESSION_ID); }); }); describe("selectTaskWithLayout pending summary authority", () => { it("lets an explicit summary clear override a stale legacy pending field", () => { const store = makeKanbanStore({ activeSessionId: OTHER_SESSION_ID, envIds: { [PRIMARY]: PRIMARY_ENV_ID }, }); const switchToSession = vi.fn(); selectTaskWithLayout({ taskId: TASK_ID, task: { primarySessionId: PRIMARY, taskPendingAction: "clarification", statusSummary: {}, }, store, switchToSession, loadTaskSessionsForTask: vi.fn(async () => []), setActiveTask: vi.fn(), setPreparingTaskId: vi.fn(), }); expect(switchToSession).toHaveBeenCalledWith(TASK_ID, PRIMARY, OTHER_SESSION_ID); }); it("handles a rejected background refresh after an immediate selection", async () => { const store = makeKanbanStore({ activeSessionId: OTHER_SESSION_ID, envIds: { [PRIMARY]: PRIMARY_ENV_ID }, }); const switchToSession = vi.fn(); selectTaskWithLayout({ taskId: TASK_ID, task: { primarySessionId: PRIMARY }, store, switchToSession, loadTaskSessionsForTask: vi.fn(async () => { throw new Error("refresh rejected"); }), setActiveTask: vi.fn(), setPreparingTaskId: vi.fn(), }); await flushTaskSelection(); expect(switchToSession).toHaveBeenCalledWith(TASK_ID, PRIMARY, OTHER_SESSION_ID); }); it("does not choose a fallback session when pending-owner loading fails", async () => { const store = makeKanbanStore({ activeSessionId: OTHER_SESSION_ID, envIds: { [PRIMARY]: PRIMARY_ENV_ID }, }); const switchToSession = vi.fn(); const setActiveTask = vi.fn(); selectTaskWithLayout({ taskId: TASK_ID, task: { primarySessionId: PRIMARY, taskPendingAction: "clarification" }, store, switchToSession, loadTaskSessionsForTask: vi.fn(async () => { throw new Error("offline"); }), setActiveTask, setPreparingTaskId: vi.fn(), }); await flushTaskSelection(); expect(switchToSession).not.toHaveBeenCalled(); expect(setActiveTask).toHaveBeenCalledWith(TASK_ID); expect(replaceTaskUrl).toHaveBeenCalledWith(TASK_ID); }); it("uses the task-only fallback when pending-owner loading fails without a primary", async () => { const store = makeKanbanStore({ activeSessionId: OTHER_SESSION_ID, envIds: {} }); const setActiveTask = vi.fn(); selectTaskWithLayout({ taskId: TASK_ID, task: { taskPendingAction: "clarification" }, store, switchToSession: vi.fn(), loadTaskSessionsForTask: vi.fn(async () => { throw new Error("offline"); }), setActiveTask, setPreparingTaskId: vi.fn(), }); await flushTaskSelection(); expect(setActiveTask).toHaveBeenCalledWith(TASK_ID); expect(replaceTaskUrl).toHaveBeenCalledWith(TASK_ID); }); }); describe("selectTaskWithLayout sessionless layout cleanup", () => { beforeEach(() => vi.clearAllMocks()); it("navigates without launching when a pending task has no loaded owner", async () => { const outgoingEnvId = "env-outgoing"; const store = makeKanbanStore({ activeSessionId: OTHER_SESSION_ID, envIds: { [OTHER_SESSION_ID]: outgoingEnvId }, }); const setActiveTask = vi.fn(); selectTaskWithLayout({ taskId: TASK_ID, task: { taskPendingAction: "clarification" }, store, switchToSession: vi.fn(), loadTaskSessionsForTask: vi.fn(async () => []), setActiveTask, setPreparingTaskId: vi.fn(), }); await flushTaskSelection(); expect(launchSession).not.toHaveBeenCalled(); expect(releaseLayoutToDefault).toHaveBeenCalledWith(outgoingEnvId); expect(setActiveTask).toHaveBeenCalledWith(TASK_ID); expect(vi.mocked(releaseLayoutToDefault).mock.invocationCallOrder[0]).toBeLessThan( setActiveTask.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, ); expect(replaceTaskUrl).toHaveBeenCalledWith(TASK_ID); }); it("uses the task-only fallback when sessionless loading fails", async () => { const store = makeKanbanStore({ activeSessionId: OTHER_SESSION_ID, envIds: {} }); const setActiveTask = vi.fn(); selectTaskWithLayout({ taskId: TASK_ID, task: undefined, store, switchToSession: vi.fn(), loadTaskSessionsForTask: vi.fn(async () => { throw new Error("offline"); }), setActiveTask, setPreparingTaskId: vi.fn(), }); await flushTaskSelection(); expect(launchSession).not.toHaveBeenCalled(); expect(setActiveTask).toHaveBeenCalledWith(TASK_ID); expect(replaceTaskUrl).toHaveBeenCalledWith(TASK_ID); }); }); describe("prepareAndSwitchTask — outgoing-env panel cleanup", () => { beforeEach(() => { vi.clearAllMocks(); dockviewState.api = null; }); it("releases the outgoing env's panels before awaiting launchSession", async () => { let resolveLaunch: (v: LaunchSessionResponse) => void = () => {}; vi.mocked(launchSession).mockImplementation( () => new Promise((res) => { resolveLaunch = res; }), ); const store = makeStore(OLD_SESSION_ID); const switchToSession = vi.fn(); const setPreparingTaskId = vi.fn(); const promise = prepareAndSwitchTask(NEW_TASK_ID, store, switchToSession, setPreparingTaskId); expect(releaseLayoutToDefault).toHaveBeenCalledTimes(1); expect(switchToSession).not.toHaveBeenCalled(); resolveLaunch({ success: true, task_id: NEW_TASK_ID, session_id: "new-session", state: "ready", }); const result = await promise; expect(result).toBe(true); expect(switchToSession).toHaveBeenCalledTimes(1); expect(switchToSession).toHaveBeenCalledWith(NEW_TASK_ID, "new-session", null); expect(setPreparingTaskId).toHaveBeenLastCalledWith(null); }); it("returns false and does not call switchToSession when launchSession throws", async () => { vi.mocked(launchSession).mockRejectedValue(new Error("network")); const store = makeStore(OLD_SESSION_ID); const switchToSession = vi.fn(); const setPreparingTaskId = vi.fn(); const result = await prepareAndSwitchTask( NEW_TASK_ID, store, switchToSession, setPreparingTaskId, ); expect(result).toBe(false); expect(releaseLayoutToDefault).toHaveBeenCalledTimes(1); expect(switchToSession).not.toHaveBeenCalled(); expect(setPreparingTaskId).toHaveBeenLastCalledWith(null); }); it("returns false and does not call switchToSession when session_id is absent", async () => { vi.mocked(launchSession).mockResolvedValue({} as never); const store = makeStore(OLD_SESSION_ID); const switchToSession = vi.fn(); const setPreparingTaskId = vi.fn(); const result = await prepareAndSwitchTask( NEW_TASK_ID, store, switchToSession, setPreparingTaskId, ); expect(result).toBe(false); expect(releaseLayoutToDefault).toHaveBeenCalledTimes(1); expect(switchToSession).not.toHaveBeenCalled(); expect(setPreparingTaskId).toHaveBeenLastCalledWith(null); }); it("does not rebuild a user's layout when the task has a linked PR", async () => { vi.mocked(launchSession).mockResolvedValue({ success: true, task_id: NEW_TASK_ID, session_id: "new-session", state: "ready", }); dockviewState.api = {}; const result = await prepareAndSwitchTask( NEW_TASK_ID, makeStore(OLD_SESSION_ID, true), vi.fn(), vi.fn(), ); expect(result).toBe(true); expect(dockviewState.buildDefaultLayout).not.toHaveBeenCalled(); }); }); describe("buildSwitchToSession", () => { beforeEach(() => { vi.clearAllMocks(); }); it("performs an env switch when the new session's environment is known", () => { const store = makeEnvStore( { "sess-old": "env-A", "sess-new": "env-B" }, { "task-new": ["sess-new", "sess-sibling"] }, ); const setActiveSession = vi.fn(); const switchToSession = buildSwitchToSession(store, setActiveSession); switchToSession("task-new", "sess-new", "sess-old"); expect(setActiveSession).toHaveBeenCalledWith("task-new", "sess-new"); expect(performLayoutSwitch).toHaveBeenCalledWith("env-A", "env-B", "sess-new", [ "sess-new", "sess-sibling", ]); expect(releaseLayoutToDefault).not.toHaveBeenCalled(); }); it("releases the outgoing layout when the new env is not yet registered", () => { const store = makeEnvStore({ "sess-old": "env-A" }); const setActiveSession = vi.fn(); const switchToSession = buildSwitchToSession(store, setActiveSession); switchToSession("task-new", "sess-new", "sess-old"); expect(setActiveSession).toHaveBeenCalledWith("task-new", "sess-new"); expect(performLayoutSwitch).not.toHaveBeenCalled(); expect(releaseLayoutToDefault).toHaveBeenCalledWith("env-A"); }); it("is a no-op for layout switching when the same session is reselected", () => { const store = makeEnvStore({}); const setActiveSession = vi.fn(); const switchToSession = buildSwitchToSession(store, setActiveSession); switchToSession("task-new", "sess-x", "sess-x"); expect(setActiveSession).toHaveBeenCalledWith("task-new", "sess-x"); expect(performLayoutSwitch).not.toHaveBeenCalled(); expect(releaseLayoutToDefault).not.toHaveBeenCalled(); }); }); /** * Regression for "switching tasks loses the user's last-selected session": * * 1. Task A has sessions [primary, gpt]; user clicks the gpt tab. * 2. User clicks Task B in the sidebar. * 3. User clicks Task A in the sidebar — expected the gpt tab still active. * * Before the fix, `selectTaskWithLayout` always switched to `primarySessionId`, * so step 3 set activeSessionId back to "primary". The dockview slow-path then * closed the gpt panel (it didn't match activeSessionId), and the surviving * sibling tab (Diff) auto-promoted to active. * * The fix tracks the user's last-selected session per task in * `tasks.lastSessionByTaskId` and prefers it over `primarySessionId` on * re-entry, as long as the session still has a known environment. */ describe("selectTaskWithLayout — last-selected session preference", () => { beforeEach(() => { vi.clearAllMocks(); }); it("prefers the user's last-selected session over primarySessionId on re-entry", () => { const LAST = "sess-gpt"; const switchToSession = runSelect( makeKanbanStore({ activeSessionId: "sess-other-task", envIds: { "sess-other-task": "env-B", [PRIMARY]: "env-A", [LAST]: "env-A" }, lastSessionByTaskId: { [TASK_ID]: LAST }, }), ); expect(switchToSession).toHaveBeenCalledWith(TASK_ID, LAST, "sess-other-task"); }); it("falls back to primarySessionId when the remembered session has no env mapping", () => { const switchToSession = runSelect( makeKanbanStore({ activeSessionId: null, envIds: { [PRIMARY]: "env-A" }, lastSessionByTaskId: { [TASK_ID]: "sess-stale" }, }), ); expect(switchToSession).toHaveBeenCalledWith(TASK_ID, PRIMARY, null); }); it("uses primarySessionId when no last-selected session is recorded for the task", () => { const switchToSession = runSelect( makeKanbanStore({ activeSessionId: null, envIds: { [PRIMARY]: "env-A" }, lastSessionByTaskId: {}, }), ); expect(switchToSession).toHaveBeenCalledWith(TASK_ID, PRIMARY, null); }); /** * Regression for a layout-leak observed when creating a new task: the * dockview tab-sync listener can fire `setActiveSession(newTaskId, oldSid)` * during a task switch (stale panel still live), which writes * `lastSessionByTaskId[newTaskId] = oldSid` even though `oldSid` belongs to * a different task. Without this guard, re-entering the new task would * resolve to that cross-task session, restoring the previous task's * env-scoped panels (files/changes) instead of the new task's primary. */ it("falls back to primarySessionId when the remembered session belongs to a different task", () => { const POISONED = "sess-belongs-to-task-B"; const switchToSession = runSelect( makeKanbanStore({ activeSessionId: null, envIds: { [PRIMARY]: "env-A", [POISONED]: "env-B" }, lastSessionByTaskId: { [TASK_ID]: POISONED }, sessionTaskIds: { [POISONED]: "task-B", [PRIMARY]: TASK_ID }, }), ); expect(switchToSession).toHaveBeenCalledWith(TASK_ID, PRIMARY, null); }); }); describe("selectTaskWithLayout — archived tasks", () => { beforeEach(() => { vi.clearAllMocks(); }); it("navigates directly without loading or preparing a session", () => { const store = makeKanbanStore({ activeSessionId: "sess-old", envIds: { "sess-old": "env-old" }, }); const loadTaskSessionsForTask = vi.fn(async () => []); const switchToSession = vi.fn(); const setActiveTask = vi.fn(); selectTaskWithLayout({ taskId: "archived-task", task: { isArchived: true, primarySessionId: "archived-session" }, store, switchToSession, loadTaskSessionsForTask, setActiveTask, setPreparingTaskId: vi.fn(), }); expect(setActiveTask).toHaveBeenCalledWith("archived-task"); expect(replaceTaskUrl).toHaveBeenCalledWith("archived-task"); expect(loadTaskSessionsForTask).not.toHaveBeenCalled(); expect(switchToSession).not.toHaveBeenCalled(); expect(launchSession).not.toHaveBeenCalled(); }); });