// @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 }), ); }); });