// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, IssueDocument } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import {
taskPanelAgentTasksTab,
taskPanelArtifactsTab,
taskPanelDocumentTab,
taskPanelPropertiesTab,
writeTaskSidePanelState,
} from "@/lib/task-side-panel-state";
import { TaskSidePanel } from "./TaskSidePanel";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
const browserFixture = vi.hoisted(() => ({ data: [] as import("@paperclipai/shared").TaskBrowser[], viewer: vi.fn(async () => ({ url: "https://live.browser-use.com/test-viewer" })), control: vi.fn(async () => ({})) }));
vi.mock("@/hooks/useTaskBrowsers", () => ({ useTaskBrowsers: () => ({ data: browserFixture.data, isError: false }) }));
vi.mock("@/api/browser-use", () => ({ browserUseApi: { viewer: browserFixture.viewer, control: browserFixture.control, presence: vi.fn(async () => ({ accepted: true })) } }));
const fixture = vi.hoisted(() => ({
documents: [] as IssueDocument[] | undefined,
plan: null as IssueDocument | null | undefined,
}));
const routeFixture = vi.hoisted(() => ({
location: {
pathname: "/issues/PAP-1",
search: "",
hash: "",
state: null as unknown,
},
navigate: vi.fn(),
}));
vi.mock("@/hooks/useIssueDocuments", () => ({
useIssueDocuments: () => ({ data: fixture.documents }),
}));
vi.mock("@/hooks/useIssuePlanDocument", () => ({
useIssuePlanDocument: () => ({ data: fixture.plan }),
}));
vi.mock("@/lib/router", () => ({
useLocation: () => routeFixture.location,
useNavigate: () => routeFixture.navigate,
}));
vi.mock("@/components/IssueProperties", () => ({
IssueProperties: () =>
Properties content
,
}));
vi.mock("@/components/issue-properties/IssuePropertiesArtifactsTab", () => ({
IssuePropertiesArtifactsTab: () => Artifacts content
,
}));
vi.mock("@/components/issue-properties/IssuePropertiesPlansTab", () => ({
IssuePropertiesPlansTab: () => Plan content
,
}));
vi.mock("@/components/task-detail/TaskDetailRelationsPanel", () => ({
TaskDetailSubtasksPanel: ({ items }: { items: Issue[] }) => (
{`Subtasks content: ${items.length}`}
),
}));
const agentChat = vi.hoisted(() => ({ enabled: true }));
vi.mock("@/hooks/useAgentChatEnabled", () => ({
useAgentChatEnabled: () => ({ enabled: agentChat.enabled, loaded: true }),
}));
vi.mock("@/components/chat/AgentWorkPanels", () => ({
AgentTasksPanel: ({ agentId, excludeIssueId }: { agentId: string; excludeIssueId?: string }) => (
{`Agent tasks ${agentId} excluding ${excludeIssueId}`}
),
AgentArtifactsPanel: ({ agentId }: { agentId: string }) => {`Agent artifacts ${agentId}`}
,
}));
vi.mock("@/components/WorkspaceFileBrowser", () => ({
WorkspaceFileBrowser: () => Files browser
,
}));
vi.mock("./TaskAttachmentPanel", () => ({ TaskAttachmentPanel: ({ attachmentId }: { attachmentId: string }) => Text file {attachmentId}
}));
vi.mock("./TaskDocumentPanel", () => ({
TaskDocumentPanel: ({ documentKey }: { documentKey: string }) => {`Document ${documentKey}`}
,
}));
vi.mock("./TaskWorkspaceFilePanel", () => ({
TaskWorkspaceFilePanel: () => Workspace file
,
}));
function issue(overrides: Partial = {}): Issue {
return {
id: "task-1",
companyId: "company-1",
title: "Portable tabs",
workMode: "agent",
...overrides,
} as Issue;
}
function issueDocument(key: string, title: string): IssueDocument {
return {
id: `document-${key}`,
issueId: "task-1",
key,
title,
body: `# ${title}`,
latestRevisionNumber: 1,
createdAt: "2026-08-23T12:00:00.000Z",
updatedAt: "2026-08-23T12:00:00.000Z",
} as unknown as IssueDocument;
}
describe("TaskSidePanel", () => {
let root: Root;
let container: HTMLDivElement;
let queryClient: QueryClient;
beforeEach(() => {
window.localStorage.clear();
browserFixture.data = [];
browserFixture.control.mockClear();
fixture.documents = [];
fixture.plan = null;
routeFixture.location.search = "";
window.history.replaceState(null, "", routeFixture.location.pathname);
routeFixture.navigate.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
Element.prototype.scrollIntoView = vi.fn();
HTMLElement.prototype.scrollTo = vi.fn();
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 1;
});
});
afterEach(() => {
act(() => root.unmount());
queryClient.clear();
container.remove();
vi.restoreAllMocks();
});
async function render(node: ReactNode) {
await act(async () => {
root.render(
{node}
,
);
});
}
function panel(props: Partial[0]> = {}) {
return (
{}}
fileTabsEnabled={false}
inline
{...props}
/>
);
}
it("opens, deduplicates, switches and closes text attachment tabs", async () => {
const onAttachmentOpened = vi.fn();
await render(panel({ openAttachment: { id: "file-1", title: "AGENTS.md", requestId: 1 }, onAttachmentOpened }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("AGENTS.md");
expect(container.textContent).toContain("Text file file-1");
expect(onAttachmentOpened).toHaveBeenCalledOnce();
await render(panel({ openAttachment: { id: "file-2", title: "summary.txt", requestId: 2 } }));
await render(panel({ openAttachment: { id: "file-1", title: "AGENTS.md", requestId: 3 } }));
expect(container.querySelectorAll('[data-side-panel-tab-target="attachment:file-1"]')).toHaveLength(1);
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("AGENTS.md");
await act(async () => container.querySelector('button[aria-label="Close AGENTS.md"]')?.click());
expect(container.querySelector('[data-side-panel-tab-target="attachment:file-1"]')).toBeNull();
});
it("lets an attachment request take focus from a workspace-file route", async () => {
routeFixture.location.search = "?file=README.md&workspace=project";
window.history.replaceState(null, "", `${routeFixture.location.pathname}${routeFixture.location.search}`);
await render(panel({ fileTabsEnabled: true, openAttachment: { id: "file-1", title: "notes.txt", requestId: 1 } }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("notes.txt");
expect(routeFixture.navigate).toHaveBeenCalledWith(expect.objectContaining({ search: "" }), expect.anything());
});
it("keeps a live browser mounted across tab switches and only hides it on close", async () => {
const id = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
browserFixture.data = [{ id, sessionId: id, issueId: "task-1", status: "running", runStatus: "running", progress: null, error: null, costCents: 0, idleDeadline: null, expiresAt: null, createdAt: new Date().toISOString() }];
await render(panel({ openBrowserId: id }));
const iframe = container.querySelector("iframe");
expect(iframe?.getAttribute("referrerpolicy")).toBe("no-referrer");
expect(iframe?.getAttribute("src")).toContain("test-viewer");
await render(panel());
await act(async () => container.querySelector("#side-panel-tab-properties")?.click());
expect(container.querySelector("iframe")).toBe(iframe);
expect(iframe?.closest("[hidden]")).toBeTruthy();
expect(localStorage.getItem("paperclip:task-side-panel:v1:user-1:company-1")).not.toContain("test-viewer");
await act(async () => container.querySelector(`[id="side-panel-tab-browser:${id}"]`)?.click());
const close = Array.from(container.querySelectorAll("button")).find(b => b.getAttribute("aria-label")?.startsWith("Close Browser"));
expect(close).toBeTruthy();
await act(async () => close?.click());
expect(container.querySelector("iframe")).toBeNull();
expect(browserFixture.control).not.toHaveBeenCalled();
});
it("commits a follow-up browser before acknowledging the open request", async () => {
const first = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const next = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const acknowledged = vi.fn(() => {
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.id).toBe(`side-panel-tab-browser:${next}`);
expect(localStorage.getItem("paperclip:task-side-panel:v1:user-1:company-1")).toContain(next);
});
await render(panel({ openBrowserId: first }));
await render(panel({ openBrowserId: next, onBrowserOpened: acknowledged }));
expect(acknowledged).toHaveBeenCalledTimes(1);
await render(panel());
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.id).toBe(`side-panel-tab-browser:${next}`);
});
it("opens an agent chat on the agent's tasks, not the conversation's artifacts", async () => {
await render(panel({ issue: issue({ conversationAgentId: "agent-1" }) }));
const selected = container.querySelector('[role="tab"][aria-selected="true"]');
expect(selected?.getAttribute("data-side-panel-tab-target")).toBe("agent-tasks");
expect(selected?.textContent).toContain("Tasks");
expect(container.textContent).toContain("Agent tasks agent-1 excluding task-1");
expect(container.textContent).not.toContain("Artifacts content");
});
it("keeps an Artifacts-only layout the user chose", async () => {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [taskPanelArtifactsTab()], activeTabId: "artifacts" },
launcherOpen: false,
userInteracted: true,
autoPlanHandled: false,
updatedAt: 1,
});
await render(panel({ issue: issue({ conversationAgentId: "agent-1" }) }));
expect(Array.from(container.querySelectorAll('[role="tab"]')).map((tab) => tab.getAttribute("data-side-panel-tab-target")))
.toEqual(["artifacts"]);
});
it("falls back to Artifacts when Agent Chat is off and only the Tasks tab was saved", async () => {
agentChat.enabled = false;
try {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [taskPanelAgentTasksTab()], activeTabId: "agent-tasks" },
launcherOpen: false,
userInteracted: true,
autoPlanHandled: false,
updatedAt: 1,
});
await render(panel({ issue: issue({ conversationAgentId: "agent-1" }) }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.getAttribute("data-side-panel-tab-target")).toBe("artifacts");
expect(container.textContent).toContain("Artifacts content");
} finally {
agentChat.enabled = true;
}
});
it("moves a chat's stored Artifacts-only default onto the agent's tasks", async () => {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [taskPanelArtifactsTab()], activeTabId: "artifacts" },
launcherOpen: false,
userInteracted: false,
autoPlanHandled: false,
updatedAt: 1,
});
await render(panel({ issue: issue({ conversationAgentId: "agent-1" }) }));
expect(Array.from(container.querySelectorAll('[role="tab"]')).map((tab) => tab.getAttribute("data-side-panel-tab-target")))
.toEqual(["agent-tasks"]);
});
it("offers the agent's artifacts from the + launcher in an agent chat", async () => {
await render(panel({ issue: issue({ conversationAgentId: "agent-1" }), streamlinedTabs: true }));
await act(async () => container.querySelector('button[aria-label="Open a new tab"]')?.click());
const artifactsItem = Array.from(document.body.querySelectorAll('[role="option"]'))
.find((item) => item.textContent?.includes("Artifacts"));
await act(async () => artifactsItem?.click());
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.getAttribute("data-side-panel-tab-target")).toBe("artifacts");
expect(container.textContent).toContain("Agent artifacts agent-1");
expect(container.textContent).not.toContain("Artifacts content");
});
it("keeps a chat on the conversation's own Artifacts while Agent Chat is off", async () => {
agentChat.enabled = false;
try {
await render(panel({ issue: issue({ conversationAgentId: "agent-1" }) }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.getAttribute("data-side-panel-tab-target")).toBe("artifacts");
expect(container.textContent).toContain("Artifacts content");
expect(container.textContent).not.toContain("Agent tasks");
} finally {
agentChat.enabled = true;
}
});
it("opens Properties on first visit", async () => {
await render(panel());
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
expect(container.textContent).toContain("Properties content");
});
it("does not acknowledge a browser selection that could not be persisted", async () => {
const acknowledged = vi.fn();
const descriptor = Object.getOwnPropertyDescriptor(window, "localStorage")!;
const storage = window.localStorage;
const write = vi.fn(() => { throw new Error("storage full"); });
// jsdom Storage instances ignore an own-method spy. Replace the surface
// explicitly so this failure injection also works with native Storage.
Object.defineProperty(window, "localStorage", {
configurable: true,
value: { getItem: storage.getItem.bind(storage), setItem: write },
});
try {
await render(panel({ openBrowserId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", onBrowserOpened: acknowledged }));
expect(write).toHaveBeenCalled();
expect(acknowledged).not.toHaveBeenCalled();
} finally { Object.defineProperty(window, "localStorage", descriptor); }
});
it("offers the existing live browser from a closed tab and distinguishes session tabs", async () => {
const first = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const next = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const shared = { issueId: "task-1", runStatus: "completed" as const, progress: null, error: null, costCents: 0, idleDeadline: null, expiresAt: null, createdAt: new Date().toISOString() };
browserFixture.data = [
{ ...shared, id: first, sessionId: first, status: "closed" },
{ ...shared, id: next, sessionId: next, status: "idle" },
];
await render(panel({ openBrowserId: first }));
await render(panel());
expect(container.textContent).toContain("Another browser is still open");
expect(container.textContent).not.toContain("Send a task message");
const recovery = Array.from(container.querySelectorAll('button')).find(b => b.textContent === 'Open active browser')!;
await act(async () => recovery.click());
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toBe("Browser 2");
expect(container.querySelector('[role="tab"][aria-selected="false"]')?.textContent).toBe("Properties");
expect(container.querySelector('iframe')).toBeTruthy();
expect(browserFixture.control).not.toHaveBeenCalled();
});
it("adds Artifacts on arrival while preserving the selected tab", async () => {
await render(panel());
await act(async () => container.querySelector("#side-panel-tab-properties")?.click());
await render(panel({ artifactsOpenRequestId: 1 }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
await act(async () => container.querySelector("#side-panel-tab-properties")?.click());
fixture.documents = [issueDocument("report", "Updated report")];
await render(panel({ artifactsOpenRequestId: 1 }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
await render(panel({ artifactsOpenRequestId: 2 }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
expect(container.querySelectorAll('[data-side-panel-tab-target="artifacts"]')).toHaveLength(1);
});
it("reopens a dismissed Artifacts tab only for a new arrival", async () => {
await render(panel({ artifactsOpenRequestId: 1 }));
await act(async () => container.querySelector("#side-panel-tab-artifacts")?.click());
await act(async () => container.querySelector('button[aria-label="Close Artifacts"]')?.click());
await render(panel({ artifactsOpenRequestId: 1 }));
expect(container.querySelector('[data-side-panel-tab-target="artifacts"]')).toBeNull();
await render(panel({ artifactsOpenRequestId: 2 }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
});
it("acknowledges an arrival so remounting the panel preserves a later manual selection", async () => {
const onArtifactsOpened = vi.fn();
await render(panel({ artifactsOpenRequestId: 1, onArtifactsOpened }));
expect(onArtifactsOpened).toHaveBeenCalledExactlyOnceWith(1);
await render(panel({ onArtifactsOpened }));
await act(async () => container.querySelector("#side-panel-tab-properties")?.click());
await render(null);
await render(panel({ onArtifactsOpened }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
expect(onArtifactsOpened).toHaveBeenCalledTimes(1);
});
it("keeps the workspace-file route and selection when an artifact arrives", async () => {
routeFixture.location.search = "?file=ui%2Fsrc%2FApp.tsx&workspace=project";
window.history.replaceState(null, "", `${routeFixture.location.pathname}${routeFixture.location.search}`);
await render(panel({ fileTabsEnabled: true }));
await render(panel({ fileTabsEnabled: true, artifactsOpenRequestId: 1 }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("App.tsx");
expect(container.querySelectorAll('[data-side-panel-tab-target="artifacts"]')).toHaveLength(1);
expect(routeFixture.navigate).not.toHaveBeenCalled();
});
it("preserves an explicitly linked document when artifacts arrive", async () => {
fixture.documents = [issueDocument("agents", "AGENTS.md")];
const documentDeepLink = { requestId: 1, documentKey: "agents" };
await render(panel({ documentDeepLink, artifactsOpenRequestId: 1 }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("AGENTS.md");
expect(container.querySelectorAll('[data-side-panel-tab-target="artifacts"]')).toHaveLength(1);
});
it("handles each document link once without overriding later manual selection", async () => {
fixture.documents = [issueDocument("agents", "AGENTS.md")];
const documentDeepLink = { requestId: 1, documentKey: "agents" };
await render(panel({ documentDeepLink }));
await act(async () => container.querySelector("#side-panel-tab-properties")?.click());
fixture.documents = [...fixture.documents, issueDocument("skill", "SKILL.md")];
await render(panel({ documentDeepLink: { ...documentDeepLink }, artifactsOpenRequestId: 1 }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
expect(container.querySelectorAll('[data-side-panel-tab-target="artifacts"]')).toHaveLength(1);
await render(panel({ documentDeepLink: { ...documentDeepLink, requestId: 2 } }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("AGENTS.md");
});
it("keeps a document selected when an artifact arrives", async () => {
fixture.documents = [issueDocument("report", "Report")];
const documentDeepLink = { documentKey: "report", requestId: 1 };
await render(panel({ documentDeepLink }));
await render(panel({ documentDeepLink, artifactsOpenRequestId: 1 }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Report");
expect(container.querySelectorAll('[data-side-panel-tab-target="artifacts"]')).toHaveLength(1);
expect(container.textContent).toContain("Document report");
});
it("uses the approved pre-rebase tab appearance for Streamlined UI", async () => {
await render(panel({ streamlinedTabs: true }));
const propertiesTab = container.querySelector('[data-side-panel-tab-target="properties"]');
expect(propertiesTab?.className).toContain("text-sm");
expect(propertiesTab?.querySelector("svg")).toBeNull();
expect(container.querySelector('[data-side-panel-tab-wrapper="properties"]')?.className).toContain("mx-1.5");
expect(container.querySelector('button[aria-label="Close Properties"]')?.className)
.toContain("opacity-0");
const addButton = container.querySelector('button[aria-label="Open a new tab"]');
expect(addButton?.className).toContain("h-(--side-panel-tab-height)");
expect(addButton?.className).toContain("w-(--side-panel-tab-height)");
expect(addButton?.className).toContain("hover:bg-accent");
});
it("shows an uncounted Subtasks tab only when Streamlined UI has child tasks", async () => {
const children = [
issue({ id: "child-1", title: "First child" }),
issue({ id: "child-2", title: "Second child" }),
];
await render(panel({ childIssues: children, showSubtasksTab: true, streamlinedTabs: true }));
const propertiesTab = container.querySelector('[data-side-panel-tab-target="properties"]');
const subtasksTab = container.querySelector('[data-side-panel-tab-target="subtasks"]');
expect(propertiesTab?.getAttribute("aria-selected")).toBe("true");
expect(subtasksTab?.textContent?.trim()).toBe("Subtasks");
expect(subtasksTab?.getAttribute("aria-label")).toBe("Subtasks");
await act(async () => subtasksTab?.click());
expect(container.textContent).toContain("Subtasks content: 2");
});
it("adds related tasks without children and preserves the selected plan", async () => {
fixture.plan = issueDocument("plan", "Plan");
fixture.documents = [fixture.plan];
await render(panel({
showSubtasksTab: true,
tasksTab: { count: 0, content: No tasks yet
},
}));
expect(container.querySelector('[data-side-panel-tab-target="subtasks"]')).toBeNull();
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan");
await render(panel({
showSubtasksTab: true,
tasksTab: { count: 1, content: Cross-project follow-up
},
}));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan");
const tasks = container.querySelector('[data-side-panel-tab-target="subtasks"]');
expect(tasks?.textContent?.trim()).toBe("Tasks");
await act(async () => tasks?.click());
expect(container.textContent).toContain("Cross-project follow-up");
expect(container.textContent).not.toContain("Subtasks content");
});
it("exposes failed task loading even when no task count is available", async () => {
await render(panel({ showSubtasksTab: true, tasksTab: { count: 0, hasError: true, content: Could not load all tasks.
} }));
const tasks = container.querySelector('[data-side-panel-tab-target="subtasks"]');
expect(tasks?.textContent?.trim()).toBe("Tasks");
await act(async () => tasks?.click());
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not load all tasks");
});
it("inserts Subtasks after Properties when child tasks load later", async () => {
await render(panel({ childIssues: [], showSubtasksTab: true, streamlinedTabs: true }));
await act(async () => container.querySelector('button[aria-label="Open a new tab"]')?.click());
const artifactsItem = Array.from(document.body.querySelectorAll('[role="option"]'))
.find((item) => item.textContent?.includes("Artifacts"));
await act(async () => artifactsItem?.click());
await render(panel({
childIssues: [issue({ id: "child-1" })],
showSubtasksTab: true,
streamlinedTabs: true,
}));
expect(Array.from(container.querySelectorAll('[role="tab"]')).map((tab) => tab.getAttribute("data-side-panel-tab-target")))
.toEqual(["properties", "subtasks", "artifacts"]);
});
it("hides Subtasks for tasks without children and when Streamlined UI is off", async () => {
await render(panel({ childIssues: [], showSubtasksTab: true }));
expect(container.querySelector('[data-side-panel-tab-target="subtasks"]')).toBeNull();
await render(panel({ childIssues: [issue({ id: "child-1" })], showSubtasksTab: false }));
expect(container.querySelector('[data-side-panel-tab-target="subtasks"]')).toBeNull();
});
it("reopens a closed Subtasks tab from the launcher", async () => {
await render(panel({
childIssues: [issue({ id: "child-1" })],
showSubtasksTab: true,
streamlinedTabs: true,
}));
await act(async () => container.querySelector('button[aria-label="Close Subtasks"]')?.click());
expect(container.querySelector('[data-side-panel-tab-target="subtasks"]')).toBeNull();
await act(async () => container.querySelector('button[aria-label="Open a new tab"]')?.click());
const launcherItem = Array.from(document.body.querySelectorAll('[role="option"]'))
.find((item) => item.textContent?.includes("Subtasks"));
expect(launcherItem).not.toBeUndefined();
await act(async () => launcherItem?.click());
expect(container.querySelector('[data-side-panel-tab-target="subtasks"]')).not.toBeNull();
});
it("does not create a Plan tab for planning mode before a plan exists", async () => {
await render(panel({ issue: issue({ workMode: "planning" }) }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
expect(container.querySelector('[data-side-panel-tab-target="document:plan"]')).toBeNull();
await act(async () => container.querySelector('button[aria-label="Open a new tab"]')?.click());
expect(Array.from(container.querySelectorAll('[role="option"]')).some((item) => item.textContent?.includes("Plan"))).toBe(false);
});
it("opens a newly materialized plan until the user has interacted", async () => {
await render(panel());
fixture.plan = issueDocument("plan", "Plan");
fixture.documents = [fixture.plan];
await render(panel());
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan");
});
it("opens a plan deep link without looping while document metadata loads", async () => {
fixture.documents = undefined;
fixture.plan = undefined;
await render(panel({
issue: issue({ workMode: "planning" }),
documentDeepLink: { requestId: 1, documentKey: "plan" },
}));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan");
});
it("does not auto-open a plan after manual tab interaction", async () => {
await render(panel());
await act(async () => container.querySelector('#side-panel-tab-properties')?.click());
fixture.plan = issueDocument("plan", "Plan");
fixture.documents = [fixture.plan];
await render(panel());
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
expect(container.querySelector('[data-side-panel-tab-target="document:plan"]')).toBeNull();
});
it("lets a persisted intentionally empty state win over defaults", async () => {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [], activeTabId: null },
launcherOpen: false,
userInteracted: true,
autoPlanHandled: true,
updatedAt: 1,
});
await render(panel({ issue: issue({ workMode: "planning" }) }));
expect(container.querySelector('[role="tab"]')).toBeNull();
expect(container.getElementsByTagName("input")[0]?.getAttribute("aria-label")).toBe("Search tabs and resources…");
});
it("removes a Plan tab persisted before a plan document existed", async () => {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: {
tabs: [taskPanelPropertiesTab(), taskPanelDocumentTab("plan", "Plan")],
activeTabId: "document:plan",
},
launcherOpen: false,
userInteracted: false,
autoPlanHandled: false,
updatedAt: 1,
});
await render(panel({ issue: issue({ workMode: "planning" }) }));
expect(container.querySelector('[data-side-panel-tab-target="document:plan"]')).toBeNull();
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
});
it("opens an ordinary document deep link in a deduplicated document tab", async () => {
fixture.documents = [issueDocument("brief", "Implementation brief")];
await render(panel({ documentDeepLink: { requestId: 1, documentKey: "brief" } }));
expect(container.querySelectorAll('[data-side-panel-tab-target="document:brief"]')).toHaveLength(1);
expect(container.textContent).toContain("Document brief");
await render(panel({ documentDeepLink: { requestId: 2, documentKey: "brief" } }));
expect(container.querySelectorAll('[data-side-panel-tab-target="document:brief"]')).toHaveLength(1);
});
it("keeps Files out of the launcher when the experiment is disabled", async () => {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [], activeTabId: null },
launcherOpen: false,
userInteracted: true,
autoPlanHandled: true,
updatedAt: 1,
});
await render(panel());
expect(Array.from(container.querySelectorAll('[role="option"]')).some((item) => item.textContent?.includes("Files"))).toBe(false);
});
it("restores a persisted Properties tab", async () => {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [taskPanelPropertiesTab()], activeTabId: "properties" },
launcherOpen: false,
userInteracted: true,
autoPlanHandled: true,
updatedAt: 1,
});
await render(panel({ issue: issue({ workMode: "planning" }) }));
expect(container.querySelectorAll('[role="tab"]')).toHaveLength(1);
expect(container.textContent).toContain("Properties content");
});
it("opens a URL-backed workspace file tab outside file-viewer provider ancestry", async () => {
routeFixture.location.search = "?file=ui%2Fsrc%2FApp.tsx&line=7&workspace=project";
window.history.replaceState(null, "", `${routeFixture.location.pathname}${routeFixture.location.search}`);
await render(panel({ fileTabsEnabled: true }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("App.tsx");
expect(container.textContent).toContain("Workspace file");
});
it("clears file-viewer URL state when switching to a non-file tab", async () => {
routeFixture.location.search = "?file=ui%2Fsrc%2FApp.tsx&line=7&workspace=project";
window.history.replaceState(null, "", `${routeFixture.location.pathname}${routeFixture.location.search}`);
await render(panel({ fileTabsEnabled: true }));
await act(async () => container.querySelector("#side-panel-tab-properties")?.click());
expect(routeFixture.navigate).toHaveBeenCalledWith(
expect.objectContaining({ pathname: "/issues/PAP-1", search: "" }),
expect.objectContaining({ replace: false }),
);
});
});