Files
PaperClipAI/ui/src/components/task-side-panel/TaskSidePanel.test.tsx
T
TonioandPaperclip 043f2514e6 fix(ui): keep task tabs steady when switching views (#17068)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - A task side panel contains properties, artifacts, and document
views.
> - These views share one row of tabs.
> - Selecting Artifacts adds an upload control beside the row.
> - That control reduces the space available to the tabs and changes
their widths.
> - This pull request reserves the upload control space while the
Artifacts tab is open.
> - Switching views now keeps the tab widths and positions stable.

## Linked Issues or Issue Description

Refs #14949. This fixes a layout shift observed after that PR merged.

**What happened?**

In a narrow task panel, tabs shrink when Artifacts is selected. They
expand when another tab is selected. The upload control enters and
leaves the header during selection.

**Expected behavior**

Tab selection must not change the space available to the tab row. Tabs
must keep equal widths while the panel width and open tabs remain
unchanged.

**Steps to reproduce**

1. Open Properties, Artifacts, and a document or attachment in a task
side panel.
2. Make the side panel narrow enough for tabs to compress.
3. Switch between Artifacts and the other tabs.
4. Observe the tab widths and positions as the upload control appears
and disappears.

## What Changed

- Keep an upload control slot in the header while an upload-capable
Artifacts tab is open.
- Hide the control and make it inert when another tab is active. Remove
the slot when Artifacts is closed. Agent conversations do not reserve
upload space.
- Add a component regression test for selection, focus exclusion, and
closing the Artifacts tab.
- Add a constrained production-panel Storybook case with assertions for
equal widths, stable widths across selections, and upload-control
visibility.

## Verification

- The focused TaskSidePanel and SidePanelTabs suites passed: 52 tests.
- UI typecheck, UI build, and token gates passed after replaying the fix
onto current master.
- The Storybook catalog check and all 28 map/catalog tests passed.
- The browser ran `html-artifacts-03-task-journey--stable-tab-widths`
successfully. All three tabs measured 71.328125px before and after
selecting Properties, the report, and Artifacts.
- Review the Stable Tab Widths story under HTML artifacts / 03 Task
journey. The fixture mounts the production task panel. Upload requests
are outside its scope.
- Full repository typecheck, tests, and build were not rerun locally.
This machine has no Cargo, and an earlier broad test run hit unrelated
server workspace/runtime failures. Full PR CI passed on commit
`019e324c0eca815f90d9797194e768fb48cb46b6`.

- All 55 successful PR checks are green on the latest head, with no
pending or failing checks. The failed end-to-end shard passed on one
rerun after a transient database lock timeout during test cleanup; no
code changes were needed for the rerun. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/39557963507).

## Risks

- A blank upload-control space remains when Artifacts is open but
inactive. This keeps the tab row stable.
- Hidden controls use both visibility and inert state to exclude them
from keyboard navigation and accessibility exposure.
- No API, database, upload permission, or saved-tab format changes.

## Model Used

- OpenAI Codex, GPT-6. The runtime did not expose a more specific model
ID or context window. Used reasoning, code execution, and browser
inspection.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-21 22:21:42 -07:00

690 lines
34 KiB
TypeScript

// @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: () => <div>Properties content</div>,
}));
vi.mock("@/components/issue-properties/IssuePropertiesArtifactsTab", () => ({
IssuePropertiesArtifactsTab: () => <div>Artifacts content</div>,
}));
vi.mock("@/components/issue-properties/IssuePropertiesPlansTab", () => ({
IssuePropertiesPlansTab: () => <div>Plan content</div>,
}));
vi.mock("@/components/task-detail/TaskDetailRelationsPanel", () => ({
TaskDetailSubtasksPanel: ({ items }: { items: Issue[] }) => (
<div>{`Subtasks content: ${items.length}`}</div>
),
}));
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 }) => (
<div>{`Agent tasks ${agentId} excluding ${excludeIssueId}`}</div>
),
AgentArtifactsPanel: ({ agentId }: { agentId: string }) => <div>{`Agent artifacts ${agentId}`}</div>,
}));
vi.mock("@/components/WorkspaceFileBrowser", () => ({
WorkspaceFileBrowser: () => <div>Files browser</div>,
}));
vi.mock("./TaskAttachmentPanel", () => ({ TaskAttachmentPanel: ({ attachmentId }: { attachmentId: string }) => <div>Text file {attachmentId}</div> }));
vi.mock("./TaskDocumentPanel", () => ({
TaskDocumentPanel: ({ documentKey }: { documentKey: string }) => <div>{`Document ${documentKey}`}</div>,
}));
vi.mock("./TaskWorkspaceFilePanel", () => ({
TaskWorkspaceFilePanel: () => <div>Workspace file</div>,
}));
function issue(overrides: Partial<Issue> = {}): 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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>{node}</TooltipProvider>
</QueryClientProvider>,
);
});
}
function panel(props: Partial<Parameters<typeof TaskSidePanel>[0]> = {}) {
return (
<TaskSidePanel
issue={issue()}
accountScope="user-1"
onUpdate={() => {}}
fileTabsEnabled={false}
inline
{...props}
/>
);
}
it("keeps the upload slot mounted and inert when switching away from Artifacts", async () => {
await render(panel({ streamlinedTabs: true, artifactsOpenRequestId: 1 }));
const slot = container.querySelector<HTMLElement>("[data-artifact-upload-slot]");
expect(slot).not.toBeNull();
await act(async () => container.querySelector<HTMLButtonElement>("#side-panel-tab-artifacts")?.click());
expect(slot?.getAttribute("aria-hidden")).toBe("false");
expect(slot?.hasAttribute("inert")).toBe(false);
expect(slot?.className).not.toContain("invisible");
await act(async () => container.querySelector<HTMLButtonElement>("#side-panel-tab-properties")?.click());
expect(container.querySelector("[data-artifact-upload-slot]")).toBe(slot);
expect(slot?.getAttribute("aria-hidden")).toBe("true");
expect(slot?.hasAttribute("inert")).toBe(true);
expect(slot?.className).toContain("invisible");
await act(async () => container.querySelector<HTMLButtonElement>("#side-panel-tab-artifacts")?.click());
expect(container.querySelector("[data-artifact-upload-slot]")).toBe(slot);
expect(slot?.hasAttribute("inert")).toBe(false);
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Close Artifacts"]')?.click());
expect(container.querySelector("[data-artifact-upload-slot]")).toBeNull();
});
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<HTMLButtonElement>('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<HTMLButtonElement>("#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<HTMLButtonElement>(`[id="side-panel-tab-browser:${id}"]`)?.click());
const close = Array.from(container.querySelectorAll<HTMLButtonElement>("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<HTMLButtonElement>('button[aria-label="Open a new tab"]')?.click());
const artifactsItem = Array.from(document.body.querySelectorAll<HTMLElement>('[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<HTMLButtonElement>("#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<HTMLButtonElement>("#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<HTMLButtonElement>("#side-panel-tab-artifacts")?.click());
await act(async () => container.querySelector<HTMLButtonElement>('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<HTMLButtonElement>("#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<HTMLButtonElement>("#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<HTMLElement>('[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"]')?.parentElement?.className).toContain("mx-0.75");
expect(container.querySelector<HTMLButtonElement>('button[aria-label="Close Properties"]')?.className)
.toContain("opacity-100");
const addButton = container.querySelector<HTMLButtonElement>('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<HTMLElement>('[data-side-panel-tab-target="properties"]');
const subtasksTab = container.querySelector<HTMLButtonElement>('[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: <div>No tasks yet</div> },
}));
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: <div>Cross-project follow-up</div> },
}));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan");
const tasks = container.querySelector<HTMLButtonElement>('[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: <div role="alert">Could not load all tasks.</div> } }));
const tasks = container.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('button[aria-label="Open a new tab"]')?.click());
const artifactsItem = Array.from(document.body.querySelectorAll<HTMLElement>('[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<HTMLButtonElement>('button[aria-label="Close Subtasks"]')?.click());
expect(container.querySelector('[data-side-panel-tab-target="subtasks"]')).toBeNull();
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Open a new tab"]')?.click());
const launcherItem = Array.from(document.body.querySelectorAll<HTMLElement>('[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<HTMLButtonElement>('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<HTMLButtonElement>('#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<HTMLButtonElement>("#side-panel-tab-properties")?.click());
expect(routeFixture.navigate).toHaveBeenCalledWith(
expect.objectContaining({ pathname: "/issues/PAP-1", search: "" }),
expect.objectContaining({ replace: false }),
);
});
});