Files
kandev/apps/web/components/task/mobile/mobile-sessions-section.test.tsx
T
2026-09-12 14:58:49 +02:00

508 lines
20 KiB
TypeScript

import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MobileSessionsPicker } from "./mobile-sessions-section";
import type { AgentProfileOption } from "@/lib/state/slices";
import { repositoryId, type Repository, type TaskSession } from "@/lib/types/http";
const mocks = vi.hoisted(() => ({
activeSessionId: "session-a" as string | null,
sessions: [] as TaskSession[],
agentProfiles: [] as AgentProfileOption[],
repositoriesByWorkspaceId: {} as Record<string, Repository[]>,
messagesBySession: {} as Record<string, unknown[]>,
turnsBySession: {} as Record<string, unknown[]>,
setActiveSession: vi.fn(),
removeSession: vi.fn(),
}));
vi.mock("@/hooks/use-task-sessions", () => ({
useTaskSessions: () => ({ sessions: mocks.sessions, isLoading: false, isLoaded: true }),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
features: { dynamicAgentRouting: false },
tasks: { activeSessionId: mocks.activeSessionId },
agentProfiles: { items: mocks.agentProfiles },
kanban: { tasks: [{ id: "task-1", primarySessionId: "session-a" }] },
repositories: { itemsByWorkspaceId: mocks.repositoriesByWorkspaceId },
messages: { bySession: mocks.messagesBySession },
turns: { bySession: mocks.turnsBySession },
executors: { items: [] },
setActiveSession: mocks.setActiveSession,
}),
}));
vi.mock("@/components/agent-logo", () => ({
AgentLogo: ({ agentName }: { agentName: string }) => (
<span data-testid={`agent-logo-${agentName}`} />
),
}));
vi.mock("@/hooks/domains/session/use-session-actions", () => ({
useSessionActions: () => ({
setPrimary: vi.fn(),
stop: vi.fn(),
resume: vi.fn(),
remove: mocks.removeSession,
}),
isSessionStoppable: () => false,
isSessionDeletable: () => true,
isSessionResumable: () => false,
}));
const PILL_TESTID = "mobile-sessions-pill";
const ICON_CIRCLE_CHECK = "tabler-icon-circle-check";
const SESSION_ACTIONS_LABEL = "Session actions";
const SESSION_BG_STATE_TESTID = "mobile-session-state-session-bg";
const SESSION_A = "session-a";
const SESSION_BG = "session-bg";
const TASK_ID = "task-1";
const START_TIME = "2026-01-01T00:00:00Z";
const SECOND_TIME = "2026-01-01T00:01:00Z";
function session(
id: string,
profileId: string,
startedAt: string,
overrides: Partial<TaskSession> = {},
): TaskSession {
return {
id,
task_id: TASK_ID,
agent_profile_id: profileId,
state: "WAITING_FOR_INPUT",
started_at: startedAt,
updated_at: startedAt,
...overrides,
} as TaskSession;
}
function profile(id: string, label: string, agentName: string): AgentProfileOption {
return {
id,
label: `Mock Agent • ${label}`,
agent_id: `agent-${agentName}`,
agent_name: agentName,
cli_passthrough: false,
};
}
function repository(id: string, name: string): Repository {
return {
id,
workspace_id: "workspace-1",
name,
source_type: "local",
local_path: `/tmp/${name}`,
provider: "",
provider_repo_id: "",
provider_owner: "",
provider_name: "",
default_branch: "main",
worktree_branch_prefix: "kandev/",
pull_before_worktree: false,
setup_script: "",
cleanup_script: "",
dev_script: "",
copy_files: "",
created_at: START_TIME,
updated_at: START_TIME,
} as Repository;
}
afterEach(cleanup);
beforeEach(() => {
mocks.activeSessionId = SESSION_A;
mocks.sessions = [
session(SESSION_A, "profile-a", START_TIME),
session("session-b", "profile-b", SECOND_TIME),
];
mocks.agentProfiles = [
profile("profile-a", "Alpha", "claude"),
profile("profile-b", "Beta", "codex"),
];
mocks.repositoriesByWorkspaceId = {};
mocks.messagesBySession = {};
mocks.turnsBySession = {};
mocks.setActiveSession.mockReset();
mocks.removeSession.mockReset();
});
describe("MobileSessionsPicker selection", () => {
it("uses the effective layout session instead of a stale store session", () => {
render(<MobileSessionsPicker taskId={TASK_ID} sessionId="session-b" fullWidth />);
expect(
screen.getByRole("button", { name: "Active session: Beta. Tap to switch." }),
).toBeTruthy();
fireEvent.click(screen.getByTestId(PILL_TESTID));
expect(screen.getByTestId("mobile-session-row-session-a").getAttribute("aria-current")).toBe(
null,
);
expect(screen.getByTestId("mobile-session-row-session-b").getAttribute("aria-current")).toBe(
"true",
);
});
it("shows the effective session agent icon beside its label", () => {
mocks.activeSessionId = "session-b";
render(<MobileSessionsPicker taskId={TASK_ID} sessionId="session-b" fullWidth />);
const pill = screen.getByTestId(PILL_TESTID);
expect(within(pill).getByTestId("mobile-session-agent-icon")).toBeTruthy();
expect(within(pill).getByTestId("agent-logo-codex")).toBeTruthy();
});
it("disambiguates repository-bound sessions without a workflow task snapshot", () => {
mocks.sessions = [
session(SESSION_A, "profile-a", START_TIME, {
repository_id: repositoryId("repository-a"),
}),
session("session-b", "profile-a", SECOND_TIME, {
repository_id: repositoryId("repository-b"),
}),
];
mocks.agentProfiles = [profile("profile-a", "Alpha", "claude")];
mocks.repositoriesByWorkspaceId = {
"workspace-1": [repository("repository-a", "Frontend"), repository("repository-b", "API")],
};
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_A} fullWidth />);
expect(
screen.getByRole("button", {
name: "Active session: Alpha. Repository: Frontend. Tap to switch.",
}),
).toBeTruthy();
expect(screen.getByTestId(PILL_TESTID).textContent).toContain("Alpha · Frontend");
fireEvent.click(screen.getByTestId(PILL_TESTID));
expect(
within(screen.getByTestId("mobile-session-row-session-a")).getByText("Frontend"),
).toBeTruthy();
expect(
within(screen.getByTestId("mobile-session-row-session-b")).getByText("API"),
).toBeTruthy();
fireEvent.click(screen.getByTestId("mobile-session-row-session-b"));
expect(mocks.setActiveSession).toHaveBeenCalledWith(TASK_ID, "session-b");
});
});
describe("MobileSessionsPicker activity precedence", () => {
it("renders background-running distinctly — matching desktop, not a done check", () => {
// A session whose
// foreground turn is idle while spawned background work runs (RUNNING +
// `background`) must read as background-running on mobile too — the shared
// getSessionStateIcon spinner — distinct from generating and never a done
// check. Tabler renders the icon shape into the svg class
// (`tabler-icon-<name>`), so asserting the class proves the distinction is
// carried by SHAPE (survives a grayscale scan), not hue alone.
mocks.activeSessionId = SESSION_BG;
mocks.sessions = [
session(SESSION_BG, "profile-a", START_TIME, {
state: "WAITING_FOR_INPUT",
foreground_activity: "background",
}),
session("session-gen", "profile-b", SECOND_TIME, {
state: "RUNNING",
foreground_activity: "generating",
}),
session("session-done", "profile-a", "2026-01-01T00:02:00Z", {
state: "COMPLETED",
}),
];
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_BG} fullWidth />);
fireEvent.click(screen.getByTestId(PILL_TESTID));
const bg = screen.getByTestId(SESSION_BG_STATE_TESTID);
const gen = screen.getByTestId("mobile-session-state-session-gen");
const done = screen.getByTestId("mobile-session-state-session-done");
const svgClass = (el: HTMLElement) => el.querySelector("svg")?.getAttribute("class") ?? "";
// background-running: the shared spinner, in motion, and a label that says so.
expect(svgClass(bg)).toContain("tabler-icon-loader-2");
const spinner = bg.querySelector(".animate-spin");
expect(spinner).not.toBeNull();
expect(spinner?.tagName).toBe("SPAN");
const spinnerSvg = spinner?.querySelector("svg");
expect(spinnerSvg).not.toBeNull();
expect(spinnerSvg?.classList.contains("animate-spin")).toBe(false);
expect(bg.textContent).toMatch(/background/i);
// Distinct from generating: a static solid dot, no spin — a different SHAPE,
// so the two read apart even desaturated.
expect(svgClass(gen)).toContain("tabler-icon-circle-filled");
expect(svgClass(gen)).not.toContain("animate-spin");
expect(svgClass(bg)).not.toContain("tabler-icon-circle-filled");
// Never a done check: distinct from a finished session, which shows the check.
expect(svgClass(bg)).not.toContain(ICON_CIRCLE_CHECK);
expect(svgClass(done)).toContain(ICON_CIRCLE_CHECK);
});
it("shows pending clarification instead of background-running", () => {
mocks.activeSessionId = SESSION_BG;
mocks.sessions = [
session(SESSION_BG, "profile-a", START_TIME, {
state: "RUNNING",
foreground_activity: "background",
}),
];
mocks.messagesBySession = {
[SESSION_BG]: [{ type: "clarification_request", metadata: { status: "pending" } }],
};
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_BG} fullWidth />);
fireEvent.click(screen.getByTestId(PILL_TESTID));
const state = screen.getByTestId(SESSION_BG_STATE_TESTID);
expect(state.textContent).toMatch(/waiting for input/i);
expect(state.querySelector("svg")?.getAttribute("class")).toContain(
"tabler-icon-message-question",
);
mocks.messagesBySession = {};
});
it("shows pending permission ahead of clarification and generating", () => {
mocks.activeSessionId = SESSION_A;
mocks.sessions = [
session(SESSION_A, "profile-a", START_TIME, {
state: "RUNNING",
foreground_activity: "generating",
}),
];
mocks.messagesBySession = {
[SESSION_A]: [
{ type: "clarification_request", metadata: { status: "pending" } },
{ type: "permission_request", metadata: { status: "pending" } },
],
};
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_A} fullWidth />);
fireEvent.click(screen.getByTestId(PILL_TESTID));
const state = screen.getByTestId("mobile-session-state-session-a");
expect(state.textContent).toMatch(/permission requested/i);
expect(state.querySelector("svg")?.getAttribute("class")).toContain(
"tabler-icon-shield-question",
);
mocks.messagesBySession = {};
});
});
const BACKGROUND_SPINNER_CLASS = "tabler-icon-loader-2";
describe("MobileSessionsPicker parked-on-background-work (AC-51/52)", () => {
it("renders parked-on-background-work identically to a live background substate", () => {
// A settled session with no live foreground_activity but a positively-sampled
// background process must read the same as a live RUNNING+background session
// — same spinner shape, same label — not the plain waiting-for-input reading.
mocks.activeSessionId = SESSION_BG;
mocks.sessions = [
session(SESSION_BG, "profile-a", START_TIME, {
state: "WAITING_FOR_INPUT",
parked_on_background_work: true,
} as Partial<TaskSession>),
];
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_BG} fullWidth />);
fireEvent.click(screen.getByTestId(PILL_TESTID));
const state = screen.getByTestId(SESSION_BG_STATE_TESTID);
expect(state.querySelector("svg")?.getAttribute("class")).toContain(BACKGROUND_SPINNER_CLASS);
expect(state.querySelector(".animate-spin")).not.toBeNull();
expect(state.textContent).toMatch(/background/i);
});
it("lets pending clarification win over the parked reading", () => {
mocks.activeSessionId = SESSION_BG;
mocks.sessions = [
session(SESSION_BG, "profile-a", START_TIME, {
state: "RUNNING",
parked_on_background_work: true,
} as Partial<TaskSession>),
];
mocks.messagesBySession = {
[SESSION_BG]: [{ type: "clarification_request", metadata: { status: "pending" } }],
};
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_BG} fullWidth />);
fireEvent.click(screen.getByTestId(PILL_TESTID));
const state = screen.getByTestId(SESSION_BG_STATE_TESTID);
expect(state.textContent).toMatch(/waiting for input/i);
expect(state.querySelector("svg")?.getAttribute("class")).toContain(
"tabler-icon-message-question",
);
mocks.messagesBySession = {};
});
});
describe("MobileSessionsPicker pending lifecycle", () => {
it("does not let stale pending input mask starting or terminal labels", () => {
mocks.activeSessionId = SESSION_A;
mocks.sessions = [
session(SESSION_A, "profile-a", START_TIME, {
state: "STARTING",
foreground_activity: "background",
}),
session("session-done", "profile-b", SECOND_TIME, {
state: "COMPLETED",
foreground_activity: "generating",
}),
];
mocks.messagesBySession = {
[SESSION_A]: [{ type: "permission_request", metadata: { status: "pending" } }],
"session-done": [{ type: "clarification_request", metadata: { status: "pending" } }],
};
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_A} fullWidth />);
fireEvent.click(screen.getByTestId(PILL_TESTID));
expect(screen.getByTestId("mobile-session-state-session-a").textContent).toMatch(/starting/i);
expect(screen.getByTestId("mobile-session-state-session-done").textContent).toMatch(
/completed/i,
);
mocks.messagesBySession = {};
});
it("carries the waiting-for-input variants", () => {
// A pending clarification and a pending permission each read distinctly on
// the mobile session row — the question / shield glyphs — never a done check
// or a running dot, matching the sidebar and desktop menus.
mocks.activeSessionId = "session-clar";
mocks.sessions = [
session("session-clar", "profile-a", START_TIME, {
state: "WAITING_FOR_INPUT",
}),
session("session-perm", "profile-b", SECOND_TIME, {
state: "WAITING_FOR_INPUT",
}),
];
mocks.messagesBySession = {
"session-clar": [{ type: "clarification_request", metadata: { status: "pending" } }],
"session-perm": [{ type: "permission_request", metadata: { status: "pending" } }],
};
render(<MobileSessionsPicker taskId={TASK_ID} sessionId="session-clar" fullWidth />);
fireEvent.click(screen.getByTestId(PILL_TESTID));
const clar = screen.getByTestId("mobile-session-state-session-clar");
const perm = screen.getByTestId("mobile-session-state-session-perm");
const svgClass = (el: HTMLElement) => el.querySelector("svg")?.getAttribute("class") ?? "";
expect(svgClass(clar)).toContain("tabler-icon-message-question");
expect(svgClass(clar)).not.toContain(ICON_CIRCLE_CHECK);
expect(svgClass(perm)).toContain("tabler-icon-shield-question");
expect(svgClass(perm)).not.toContain(ICON_CIRCLE_CHECK);
expect(perm.textContent).toMatch(/permission/i);
mocks.messagesBySession = {};
});
});
describe("MobileSessionsPicker session delete confirmation", () => {
it("uses the same modal for the named delete step and restores the picker on Back", async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
try {
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_A} fullWidth />);
fireEvent.click(screen.getByTestId(PILL_TESTID));
const picker = screen.getByRole("dialog", { name: "Sessions" });
const row = screen.getByTestId(`mobile-session-row-${SESSION_A}`);
fireEvent.pointerDown(within(row).getByRole("button", { name: SESSION_ACTIONS_LABEL }));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
expect(await screen.findByRole("dialog", { name: "Delete session?" })).toBe(picker);
expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1);
expect(screen.getByRole("group").textContent).toContain("Alpha");
expect(screen.queryByRole("button", { name: SESSION_ACTIONS_LABEL })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(screen.getByRole("dialog", { name: "Sessions" })).toBe(picker);
expect(row.isConnected).toBe(true);
await vi.waitFor(() =>
expect(document.activeElement).toBe(
within(row).getByRole("button", { name: SESSION_ACTIONS_LABEL }),
),
);
expect(mocks.removeSession).not.toHaveBeenCalled();
} finally {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
}
});
it("morphs the target row into local touch-sized confirmation actions", () => {
mocks.sessions = [session(SESSION_A, "profile-a", START_TIME, { state: "COMPLETED" })];
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_A} fullWidth />);
// Open the picker sheet, then the session's actions menu.
fireEvent.click(screen.getByTestId(PILL_TESTID));
const dotsButton = screen.getByRole("button", { name: SESSION_ACTIONS_LABEL });
fireEvent.pointerDown(dotsButton);
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
const confirmation = screen.getByRole("group", { name: /delete session/i });
expect(screen.queryByRole("alertdialog")).toBeNull();
expect(confirmation.textContent).toContain("permanently delete the conversation history");
expect(confirmation.textContent).toContain("task workspace and its files are kept");
expect(confirmation.textContent).toContain("only session for this task");
const confirm = within(confirmation).getByTestId("mobile-session-delete-confirm");
expect(confirm.className).toContain("h-11");
expect(confirm.className).toContain("min-w-11");
});
it("cancels locally without deleting the session", () => {
mocks.sessions = [session(SESSION_A, "profile-a", START_TIME, { state: "COMPLETED" })];
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_A} fullWidth />);
fireEvent.click(screen.getByTestId(PILL_TESTID));
fireEvent.pointerDown(screen.getByRole("button", { name: SESSION_ACTIONS_LABEL }));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(mocks.removeSession).not.toHaveBeenCalled();
expect(screen.getByTestId(`mobile-session-row-${SESSION_A}`)).toBeTruthy();
expect(screen.queryByRole("group", { name: /delete session/i })).toBeNull();
});
it("resets pending confirmation when the picker closes externally", async () => {
mocks.sessions = [session(SESSION_A, "profile-a", START_TIME, { state: "COMPLETED" })];
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_A} fullWidth />);
fireEvent.click(screen.getByTestId(PILL_TESTID));
fireEvent.pointerDown(screen.getByRole("button", { name: SESSION_ACTIONS_LABEL }));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
expect(screen.getByRole("group", { name: /delete session/i })).toBeTruthy();
const picker = screen.getByRole("dialog", { name: "Sessions" });
expect(picker.getAttribute("data-state")).toBe("open");
fireEvent.keyDown(document, { key: "Escape" });
await vi.waitFor(() => {
expect(picker.getAttribute("data-state")).toBe("closed");
});
expect(mocks.removeSession).not.toHaveBeenCalled();
expect(screen.queryByRole("group", { name: /delete session/i })).toBeNull();
fireEvent.click(screen.getByTestId(PILL_TESTID));
expect(screen.getByRole("dialog", { name: "Sessions" }).getAttribute("data-state")).toBe(
"open",
);
expect(screen.queryByRole("group", { name: /delete session/i })).toBeNull();
});
it("dispatches deletion once after local confirmation", async () => {
mocks.sessions = [session(SESSION_A, "profile-a", START_TIME, { state: "COMPLETED" })];
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_A} fullWidth />);
fireEvent.click(screen.getByTestId(PILL_TESTID));
fireEvent.pointerDown(screen.getByRole("button", { name: SESSION_ACTIONS_LABEL }));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
fireEvent.click(screen.getByTestId("mobile-session-delete-confirm"));
await vi.waitFor(() => expect(mocks.removeSession).toHaveBeenCalledTimes(1));
});
});