Files
PaperClipAI/ui/src/components/task-side-panel/TaskSidePanel.test.tsx
T
Nicky LeachandPaperclip c7c12dbc79 Support plugin panels in task sidebars (#17796)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task side panels keep live work beside the conversation.
> - Plugins can already provide agent detail tabs.
> - Those tabs have no generic way to appear beside the task that is
using the agent.
> - This pull request adds an optional task-side-panel declaration to
agent detail tabs.
> - The plugin owns the label, component and availability. Core supplies
the task's running agent.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Access to plugin-contributed live views from task chat.

**Subsystem affected**

Plugin manifest validation, slot discovery, and task side-panel
UI/state.

**Current behavior**

Agent detail-tab contributions appear on agent pages. A task cannot
offer the same view through a plugin-owned availability decision.

**Proposed behavior**

An agent detail tab can declare `taskSidePanel: { availabilityPath }`.
Core substitutes the running agent into the plugin-local path. A
declared board GET route scoped to that agent returns `{ available:
boolean }`. Available contributions appear in desktop and mobile task
side panels.

**Reason and benefit**

Keep the task and its live plugin views together. Preserve plugin
ownership of provider protocols and product behavior. Core contains no
concrete plugin identifiers, routes, labels or readiness rules.

**Breaking changes**

None. The field is optional. Existing contributions remain unchanged.
Missing, disabled or unavailable plugins add no panel.

**Additional context**

Related: #17111 established the agent detail-tab and scoped API
extension surfaces. Roadmap and related PR searches found no duplicate
of this extension.

## What Changed

- Extend the manifest with optional task-panel metadata. Validate safe
plugin-local paths and require a declared board GET route with
agent-scoped authorization.
- Discover and load only opted-in agent contributions. Poll their
generic availability responses and mount the existing components with
agent context.
- Add generic panel tabs, launcher entries, dismissal and inactive-view
removal in desktop/mobile task chat.
- Reuse the existing live-run polling guard so idle tasks with a healthy
live connection stop polling.
- Add provider-neutral fixtures, schema/authorization tests, task tests
and SDK/feature documentation.

## Verification

- PASS: 301 focused tests including the full task-detail file, generic
panel discovery, persistence, slot loading and manifest validation.
Regressions cover POST-before-GET route order, mobile drawer dismissal,
recovery without focus and the absence of the hidden desktop task panel
on mobile.
- PASS: UI and shared-package typechecks; production UI build; token
gates, feature-map and module-boundary checks; diff and secrets/PII
review.
- PASS on final head: all 795 UI files / 9,186 tests, and all 95
shared-package files / 1,038 tests. Commands: `node
node_modules/vitest/vitest.mjs run --project @paperclipai/ui
--maxWorkers 3` and the corresponding `--project @paperclipai/shared`
command with local socket access.
- On the final head, server shards 7/12 and 10/12 and workspace shard
a-1/2 were interrupted by runner shutdown/communication loss. GitHub
reported infrastructure failures; no assertion failure was surfaced in
the available diagnostics. The queued summary was cancelled, then `gh
run rerun --failed` successfully requested retries for only those three
jobs. They are currently queued for CI runners; the 50 successful checks
remain passing. Interrupted attempts are not counted as passing.
[Final-head CI and
retry](https://github.com/paperclipai/paperclip/actions/runs/40396870920).
- Earlier verification exposed an empty-panel rerender loop, which was
fixed. The sandboxed combined run also failed five shared port-registry
tests because it could not bind loopback listeners; the full shared
rerun with socket access passed. These earlier failures are retained
here rather than reported as passing runs.
- Full workspace typecheck/build were attempted with pinned pnpm 9 after
resolving the initial local package-manager setup. Both stop at the
unchanged Runner Rust step because `cargo` is not installed. Full local
workspace checks are not reported as passing; final CI must provide
those broader checks.
- No live provider or plugin-specific transport acceptance is claimed by
Core's synthetic fixtures.

## Risks

- Availability is polled every three seconds only while an authorized
running agent exists. Plugins own the side effects and meaning of that
read.
- A view uses the running agent's context. It is not a separate per-task
resource.
- Optional metadata adds no backend endpoint or permission bypass. The
host requires existing board and agent authorization.
- Reverting the optional contract and UI integration removes task panels
without a data migration.

## Model Used

OpenAI Codex (GPT-6), with reasoning, repository tools and code
execution. The exact backend snapshot ID and context window size are not
exposed by this session.

## 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 described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run focused 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
- [ ] 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-26 14:47:15 -07:00

753 lines
38 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 type { TaskPluginPanel } from "@/hooks/useTaskPluginPanels";
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;
vi.mock("@/plugins/slots", () => ({
PluginSlotMount: ({ context }: { context: { entityId: string } }) => <div>Plugin viewer {context.entityId}</div>,
}));
vi.mock("@/hooks/useTaskPluginPanels", () => ({
taskPluginPanelId: (panel: TaskPluginPanel) => `plugin:${panel.slot.pluginKey}:${panel.slot.id}:${panel.entityId}`,
}));
const pluginPanel = { entityId: "33333333-3333-4333-8333-333333333333", runId: "run-1", slot: { pluginKey: "example.live-view", id: "screen", displayName: "Live view" } } as TaskPluginPanel;
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("adds the active plugin panel, preserves manual dismissal, and removes inactive plugin panels", async () => {
await render(panel({ pluginPanels: [pluginPanel] }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Live view");
expect(container.textContent).toContain(`Plugin viewer ${pluginPanel.entityId}`);
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Close Live view"]')?.click());
await render(panel({ pluginPanels: [pluginPanel] }));
expect(container.querySelector('[data-side-panel-tab-target^="plugin:"]')).toBeNull();
await render(panel({ pluginPanels: [] }));
await render(panel({ pluginPanels: [pluginPanel] }));
expect(container.querySelector('[data-side-panel-tab-target^="plugin:"]')).toBeNull();
// An already handled panel remains available in the launcher without reopening.
expect(container.textContent).not.toContain("Plugin viewer");
await render(panel({ pluginPanels: [] }));
expect(container.querySelector('[data-side-panel-tab-target^="plugin:"]')).toBeNull();
expect(container.textContent).not.toContain("Plugin viewer");
});
it("restores an automatically removed panel without taking focus after availability recovers", async () => {
await render(panel({ pluginPanels: [pluginPanel] }));
await act(async () => container.querySelector<HTMLButtonElement>('#side-panel-tab-properties')?.click());
await render(panel({ pluginPanels: [] }));
expect(container.querySelector('[data-side-panel-tab-target^="plugin:"]')).toBeNull();
await render(panel({ pluginPanels: [pluginPanel] }));
expect(container.querySelector('[data-side-panel-tab-target^="plugin:"]')).not.toBeNull();
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Properties");
expect(container.textContent).not.toContain("Plugin viewer");
});
it("preserves dismissal and saved selection when the mobile drawer remounts", async () => {
const openedPluginPanelKeys = new Set<string>();
const dismissedPluginPanelKeys = new Set<string>();
await render(panel({ pluginPanels: [pluginPanel], openedPluginPanelKeys, dismissedPluginPanelKeys, mobile: true }));
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label^="Switch tabs"]')?.click());
const close = document.querySelector<HTMLButtonElement>('button[aria-label="Close Live view"]');
expect(close).not.toBeNull();
await act(async () => close!.click());
const selected = container.querySelector('[id^="side-panel-tab-"]')?.id;
expect(container.querySelector('button[aria-label^="Switch tabs"]')?.getAttribute("aria-label")).toBe("Switch tabs, 1 open");
expect(container.textContent).not.toContain("Plugin viewer");
await render(null);
await render(panel({ pluginPanels: [pluginPanel], openedPluginPanelKeys, dismissedPluginPanelKeys, mobile: true }));
expect(container.querySelector('[id^="side-panel-tab-"]')?.id).toBe(selected);
expect(container.querySelector('button[aria-label^="Switch tabs"]')?.getAttribute("aria-label")).toBe("Switch tabs, 1 open");
expect(container.textContent).not.toContain("Plugin viewer");
});
it("replaces a previous agent's plugin panel when the task's running agent changes", async () => {
await render(panel({ pluginPanels: [pluginPanel] }));
const next = { ...pluginPanel, entityId: "44444444-4444-4444-8444-444444444444", runId: "run-2" };
await render(panel({ pluginPanels: [next] }));
expect(container.querySelectorAll('[data-side-panel-tab-target^="plugin:"]')).toHaveLength(1);
expect(container.textContent).toContain(`Plugin viewer ${next.entityId}`);
expect(container.textContent).not.toContain(`Plugin viewer ${pluginPanel.entityId}`);
});
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 }),
);
});
});