Files
PaperClipAI/ui/src/components/task-side-panel/TaskSidePanel.test.tsx
T
DottaandPaperclip 322fa6b65c fix(ui): restore task document authoring and add persistence journey (#18356)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - Task documents keep versioned work beside each conversation.
> - The default chat interface could read general documents but hid
their editor.
> - People also need unfinished edits to survive a tab switch or reload.
> - This pull request restores the shared editor in Artifacts.
> - A portable browser journey checks exact saved content and revision
history.
> - The benefit is a usable document flow with independent deployment
checks.

## Linked Issues or Issue Description

**What happened?**

A person using the default task interface could read a general task
document but could not create or edit one. The existing editor was
available in Classic mode. A pending create could also leave newer text
as a new form after its editor unmounted, causing a conflict against the
person's own successful creation.

**Expected behavior**

A person can create and edit general documents through Artifacts.
Titles, bodies and revision history persist after reopening and
reloading the task. Unfinished text survives tab switches. A successful
create makes retained newer text an edit of that confirmed revision.
Later writes from another actor still cause a conflict.

**Steps to reproduce**

1. Open an ordinary task in the default chat interface, then open
Artifacts.
2. Try to create a general document. The previous interface has no
authoring control.
3. With authoring restored, submit a new document, keep typing, and
switch to Properties before the request finishes.
4. Reopen Artifacts. The previous draft path restores a new form despite
the successful create.

Related public work: #17598 adds document copy/download and #18157 adds
image galleries. The selected actual merged parent is
`f1d780838218f975064f38333946805e0ec103a5` from #18427. It includes the
stable profile-entry ordering repair and the exact recovery-fixture
prerequisite. The resulting PR diff contains 32 document and reporting
files.

## What Changed

- Reuse `IssueDocumentsSection` for general documents in default-chat
Artifacts. Keep Classic authoring in its existing task body. Ordinary
tasks, conversations and Coordinator mode share the same editor options
and signed-in permission wrapper.
- Preserve Plans, file-review documents, attachments, uploads and work
products. Hide duplicate general-document cards. Use the same
general-document key rules for visible rows, validation and refused
writes.
- Forward image uploads, mentions, references, author maps and feedback
to both editor mounts. Keep empty mention/project values stable while
queries load.
- Back up unfinished forms within their signed-in account, task and
editor scope. Preserve text entered before its key. Record a successful
create on its matching retained newer buffer, including after unmount or
an early reopen. Preserve newer title/body edits, real remote conflicts
and matching cancellation cleanup.
- Add the model-free `task-document-persistence` journey. Setup creates
only an owned empty backlog task. The browser creates and edits the
document; normal APIs independently check company, task, actor, exact
content and both revision records.
- Reopen the owned task through the explicit Backlog view, then reload
it. Require exact UI readback and zero native runs. Add only the Doc
registration, definition-hash inputs, report mapping and four closed
checks to the shared harness.
- Count explicit zero-run browser receipts after private journey
metadata is removed for publication. Preserve missing billing evidence,
positive usage and native/protocol accounting.
- Update browser assertions for the restored inline editor while
retaining exact download bytes, revision collisions, paused-state and
no-run checks. Update the feature map, usage docs and shared editor
stories.

## Verification

- Latest focused checks use official Node 24.22.2: 568 journey, catalog,
report, billing and attempt-accounting tests across 25 suites pass. All
four admitted journeys remain registered. The seven relevant UI suites
pass 529 tests, including the task-page container after the parent
merge. Maps, token gates, the PR-scope diff check and the owned
credential-pattern scan pass. The normal QA import preflight also passes
with an empty environment and network access blocked.
- Billing regression: before the repair, the explicit empty public
receipt counted one run, and two positive-usage receipts were discarded
by browser metadata. Those three cases fail before and pass after the
repair. Two additional red regressions cover aggregate usage beside an
empty run list. That ambiguous shape now keeps an unavailable receipt
and a minimum count of one. Missing receipts retain unavailable billing,
and native/protocol one-or-more run totals retain their measurements.
- The previous fresh CI run failed its unchanged snapshot-only recovery
fixture: one global sweep did not call its callback. The original
failure remains recorded. An isolated PGlite probe with all canonical
migrations exercises the real reconciler: it processes two earlier
sources, then reaches the owned third source on the next sweep. It
preserves source evidence, files and run IDs. This proves bounded
pagination, not full native snapshot repair.
- Local native recovery tests could not start PostgreSQL because the
host semaphore limit was reached. That run executed zero cases.
Canonical Linux CI later passed all 143 fixture cases, including the
original snapshot-only case and the new competing-candidate control. The
exact fixture repair is now inherited from #18427 and is absent from
this PR diff.
- The portable oracle rejects 19 stale, missing, foreign or incorrect
outcomes. Result validation also rejects model/provider metadata and
native runs for this case.
- Five real Artifacts-to-Properties unmount regressions fail before the
repair and pass after it. They cover both editor modes, reopening before
and after completion, later typing, and a foreign revision that must
still conflict. The edit-storage failure case also checks that Cancel
clears the promoted fallback copy.
- UI coverage includes default chat, Classic, conversations,
Coordinator, Plan rendering, document-card deduplication, reserved keys,
pending-query stability, permissions and rich editor options.
- Earlier real browser checks passed five upload cases and two
artifact-arrival cases. They retain exact CSV bytes, original/revised
content, collision, paused-state and no-run assertions. Current-head
normal CI runs the updated browser suites again.
- The branch merges only the selected actual parent
`f1d780838218f975064f38333946805e0ec103a5`. The merge has no conflicts
or manual resolution. The SQL service and recovery fixture match that
parent exactly. The own document editor, persistence flow, saved-state
oracle and billing repair retain their previously qualified source
blobs. Follow-up, clarification, question/resumption, screenshot helpers
and the Steering map are byte-identical to the merged parent. Existing
project/search/question/follow-up registrations remain present.
- Required document source blobs remain exact: Doc UI
`22e80663b053363e5f75e1432dd9043dc0e70243`, its unmount tests
`16f3f4ed0dc9b11aaff129e06e6cf2bbf4522713`, readiness helper
`800a747284db19b8484650b6759e8bd1f356839d`, and budget service
`3a15d489f0aa55c22e8f7fe6a6bc6a0e14e5914e`.
- Local typing is limited by borrowed declarations from another
checkout. They omit current `skill_test` and sandbox creation-operation
fields. No dependency declarations or repository config were changed.
The authoritative clean CI Typecheck + Release Registry gate passes on
exact head `2ffce29827ae170420d740940c1c069c7225a74c`.
- Full monorepo typecheck, tests and build were not run locally.
Borrowed build declarations from another checkout limit local typing.
Authoritative [clean
CI](https://github.com/paperclipai/paperclip/actions/runs/41171717008)
passes on exact head `82d15946c5844d3ae41c6ade62fa224053f20a9a`: 55
successful checks, 3 skipped and 1 neutral, with zero pending or failed
checks. Typecheck, build, Runner, server and browser lanes pass.
Greptile gives that same head a fresh 5/5. All 15 review threads are
resolved, with no further thread page. The PR is mergeable and clean.
- Failure history remains recorded. The earlier recovery failure was
repaired with a bounded, owned-source fixture. A later CI worker shut
down after 2,375 Runner unit tests passed. Its one authorized narrow
retry passed those tests and all five native integration suites. The
separate AgentConfig browser failure was reproduced and fixed by the
merged #18427 parent. These earlier passes do not replace current-head
CI.
- Final-source live qualification passed on exact harness and served
application `82d15946c5844d3ae41c6ade62fa224053f20a9a`. QA run
`41177696858` completed all four closed checks and all five phases with
zero native runs. Cleanup passed; the durable manifest is complete with
no pending evidence. The coordinator verified the original grade and
manifest.
- The [public qualification
report](https://pages.paperclip.ing/evals-qa-smoke-41177696858-1/)
records zero agent runs, empty run IDs and empty run usage. Pricing
remains unavailable and billing remains incomplete; the report does not
claim measured zero cost.
- The earlier harness `36c8d9ddd76d0b7ddb28a9a6c674d9e538f6df88` pass
against application `2ffce29827ae170420d740940c1c069c7225a74c` remains
historical evidence. It is separate from the final-source measurement
above.
- The selected parent brings profile-entry ordering, proactive chat, a
database migration, Runner updates, and task-page chat/read behavior.
Its new task-page container is included in the final application
measurement. The coordinator retains the original evidence and owns the
primary merge and daily-plan admission.

## Risks

Artifacts now mounts the existing shared editor. Its autosave, conflict,
revision, annotation and server access contracts remain in place. Tests
cover both interface modes, shared scopes, later remote writes and
unavailable edit storage. Plans and generated file-review documents keep
their existing flows. The PR adds no database schema or API change. The
shared billing repair requires explicit captured empty run IDs and an
unambiguous usage object containing only an empty run list for public
browser-only receipts. Other fields beside an empty list keep
conservative unavailable billing. Missing measurements remain
unavailable. Contradicting positive usage remains counted. The inherited
recovery fixture keeps the production sweep limit and its source,
readback and no-agent-work assertions.

The live journey must fail when authoring controls or saved outcomes are
absent. It must not create the expected document through setup APIs or
switch to another interface to pass. Final deployment verification must
retain the actual app and harness revisions separately.

## Model Used

OpenAI Codex, based on GPT-6, with tool use and code execution. The
exact runtime model ID and context window were not exposed in 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 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-31 01:49:24 -05:00

921 lines
49 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,
taskPanelPluginTab,
readTaskSidePanelState,
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 data-plugin-viewer>Plugin viewer {context.entityId}</div>,
}));
const pluginPanel = { entityId: "33333333-3333-4333-8333-333333333333", runId: "run-1", autoOpen: true, 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>,
}));
const artifactsTabRender = vi.hoisted(() => vi.fn());
const generalDocumentsRender = vi.hoisted(() => vi.fn());
vi.mock("@/components/issue-properties/IssuePropertiesArtifactsTab", () => ({
IssuePropertiesArtifactsTab: (props: unknown) => {
artifactsTabRender(props);
return <div>Artifacts content</div>;
},
TaskGeneralDocuments: (props: unknown) => {
generalDocumentsRender(props);
return <div data-testid="conversation-document-editor">Conversation documents</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 }));
const coordinator = vi.hoisted(() => ({ enabled: false }));
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({
useCoordinatorWorkspaceEnabled: () => ({ enabled: coordinator.enabled, loaded: 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, issueId, revisionId, documentIssueId, currentIssueId, initialDocument }: { documentKey: string; issueId: string; revisionId?: string; documentIssueId?: string; currentIssueId?: string; initialDocument?: IssueDocument }) => <div data-document-owner={issueId} data-gallery-owner={documentIssueId} data-current-task={currentIssueId} data-revision-id={revisionId} data-initial-document={initialDocument?.id}>{`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(() => {
artifactsTabRender.mockClear();
generalDocumentsRender.mockClear();
coordinator.enabled = false;
window.localStorage.clear();
browserFixture.data = [];
browserFixture.control.mockClear();
fixture.documents = [];
fixture.plan = null;
routeFixture.location.search = "";
routeFixture.location.hash = "";
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.each([false, true])("owns document authoring in the resolved chat shell (coordinator: %s)", async coordinatorEnabled => {
coordinator.enabled = coordinatorEnabled;
const documentEditor = { imageUploadHandler: vi.fn(async () => "/api/attachments/image/content"), externalReferences: {} };
await render(panel({ documentEditor }));
const open = container.querySelector<HTMLButtonElement>('button[aria-label="Open a new tab"]');
if (open) await act(async () => open.click());
const artifacts = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).find(item => item.textContent?.includes("Artifacts"));
expect(artifacts).toBeTruthy();
await act(async () => artifacts!.click());
expect(artifactsTabRender).toHaveBeenCalledWith(expect.objectContaining({ documentEditor }));
});
it("offers idle plugin views in the launcher without opening them automatically", async () => {
await render(panel({ pluginPanels: [{ ...pluginPanel, runId: undefined, autoOpen: false }] }));
expect(container.textContent).not.toContain("Plugin viewer");
expect(container.querySelector('[data-side-panel-tab-target^="plugin:"]')).toBeNull();
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Open a new tab"]')?.click());
const item = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find(item => item.textContent?.includes("Live view"));
expect(item).not.toBeUndefined();
await act(async () => item!.click());
expect(container.textContent).toContain(`Plugin viewer ${pluginPanel.entityId}`);
});
it.each([false, true])("restores a saved manual view after discovery (different assignee=%s)", async differentAssignee => {
const tab = taskPanelPluginTab(pluginPanel);
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [tab], activeTabId: tab.id },
launcherOpen: false, userInteracted: true, autoPlanHandled: false, updatedAt: 1,
});
await render(panel({ pluginPanels: differentAssignee
? [{ ...pluginPanel, entityId: "assigned-agent", runId: undefined, autoOpen: false }] : [], pluginPanelsLoading: true }));
expect(container.querySelector('[data-side-panel-tab-target^="plugin:"]')).not.toBeNull();
expect(container.textContent).not.toContain("Plugin viewer");
await render(panel({ pluginPanels: [{ ...pluginPanel, runId: undefined, autoOpen: false }] }));
expect(container.textContent).toContain(`Plugin viewer ${pluginPanel.entityId}`);
await render(panel({ pluginPanels: [] }));
expect(container.querySelector('[data-side-panel-tab-target^="plugin:"]')).toBeNull();
});
it("focuses an already open manual panel when activity starts and keeps it mounted after the run", async () => {
const manual = { ...pluginPanel, autoOpen: false };
await render(panel({ pluginPanels: [manual] }));
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Open a new tab"]')?.click());
const item = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find(item => item.textContent?.includes("Live view"));
await act(async () => item!.click());
await act(async () => container.querySelector<HTMLButtonElement>('#side-panel-tab-properties')?.click());
await render(panel({ pluginPanels: [pluginPanel] }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Live view");
const viewer = container.querySelector("[data-plugin-viewer]");
expect(viewer).not.toBeNull();
await render(panel({ pluginPanels: [{ ...manual, runId: undefined }] }));
expect(container.querySelector("[data-plugin-viewer]")).toBe(viewer);
});
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 only the requested Coordinator document despite background plans, subtasks and artifacts", async () => {
coordinator.enabled = true;
fixture.plan = issueDocument("plan", "Unrequested plan");
fixture.documents = [fixture.plan, issueDocument("report", "Reviewed report")];
routeFixture.location.hash = "#document-report&revision=reviewed-version&interaction=decision-1";
const onArtifactsOpened = vi.fn();
await render(panel({
documentDeepLink: { requestId: 1, documentKey: "report" },
artifactsOpenRequestId: 1, onArtifactsOpened,
showSubtasksTab: true, childIssues: [issue({ id: "child-1" })],
}));
expect([...container.querySelectorAll('[role="tab"]')].map(tab => tab.getAttribute("data-side-panel-tab-target")))
.toEqual(["document:report:reviewed-version"]);
expect(onArtifactsOpened).toHaveBeenCalledWith(1);
});
it("opens another task's exact plan without requiring an origin plan or reusing origin document data", async () => {
coordinator.enabled = true;
fixture.plan = null;
fixture.documents = [issueDocument("plan", "Origin task document must not leak")];
routeFixture.location.hash = "#document-plan&revision=b-v1&interaction=review-on-a&documentIssue=task-b";
await render(panel({ documentDeepLink: { requestId: 1, documentKey: "plan" } }));
const document = container.querySelector("[data-document-owner]");
expect(document?.getAttribute("data-document-owner")).toBe("task-b");
expect(document?.getAttribute("data-gallery-owner")).toBe("task-b");
expect(document?.getAttribute("data-current-task")).toBe("task-1");
expect(document?.getAttribute("data-revision-id")).toBe("b-v1");
expect(document?.hasAttribute("data-initial-document")).toBe(false);
expect(container.textContent).not.toContain("Origin task document must not leak");
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.getAttribute("data-side-panel-tab-target")).toBe("document:task-b:plan:b-v1");
routeFixture.location.hash = "#document-local";
await render(panel());
expect(container.querySelector("[data-gallery-owner]")?.getAttribute("data-gallery-owner")).toBe("task-b");
});
it("preserves explicitly saved Coordinator tabs when a decision link adds its document", async () => {
coordinator.enabled = true;
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [taskPanelPropertiesTab(), taskPanelArtifactsTab()], activeTabId: "properties" },
launcherOpen: false, userInteracted: true, autoPlanHandled: false, updatedAt: Date.now(),
});
fixture.documents = [issueDocument("report", "Report")];
await render(panel({ documentDeepLink: { requestId: 1, documentKey: "report" } }));
expect([...container.querySelectorAll('[role="tab"]')].map(tab => tab.getAttribute("data-side-panel-tab-target")))
.toEqual(["properties", "artifacts", "document:report"]);
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Report");
});
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.each([
{ coordinatorEnabled: false, mobile: false },
{ coordinatorEnabled: false, mobile: true },
{ coordinatorEnabled: true, mobile: false },
{ coordinatorEnabled: true, mobile: true },
])("keeps conversation document authoring beside agent artifacts ($coordinatorEnabled, $mobile)", async ({ coordinatorEnabled, mobile }) => {
coordinator.enabled = coordinatorEnabled;
const conversation = issue({ conversationAgentId: "agent-1" });
const documentEditor = {
imageUploadHandler: vi.fn(async () => "/api/attachments/conversation-image/content"),
mentions: [{ id: "agent-1", name: "Agent", kind: "agent" as const }],
externalReferences: {}, onVote: vi.fn(async () => {}), userProfileMap: new Map(),
};
await render(panel({ issue: conversation, documentEditor, mobile }));
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Open a new tab"]')?.click());
const artifacts = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).find(item => item.textContent?.includes("Artifacts"));
expect(artifacts).toBeTruthy();
await act(async () => artifacts!.click());
expect(container.querySelectorAll('[data-testid="conversation-document-editor"]')).toHaveLength(1);
expect(generalDocumentsRender).toHaveBeenCalledWith(expect.objectContaining({ issue: conversation, documentEditor }));
expect(container.textContent).toContain("Agent artifacts agent-1");
expect(artifactsTabRender).not.toHaveBeenCalled();
});
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.each([false, true])("restores each workspace before persisting a same-task scope switch (mobile=%s)", async (mobile) => {
const classic = { tabs: [taskPanelPropertiesTab()], activeTabId: "properties" };
const experiment = { tabs: [taskPanelArtifactsTab()], activeTabId: "artifacts" };
for (const [scope, state] of [["user-1", classic], ["coordinator:user-1", experiment]] as const) {
writeTaskSidePanelState(scope, "company-1", "task-1", {
state, launcherOpen: false, userInteracted: true, autoPlanHandled: true, updatedAt: 1,
});
}
await render(panel({ accountScope: "user-1", mobile }));
expect(container.textContent).toContain("Properties content");
// A preference refreshed from another window can change scope without
// changing the task or remounting the public TaskSidePanel component.
coordinator.enabled = true;
await render(panel({ accountScope: "coordinator:user-1", mobile }));
expect(container.textContent).toContain("Artifacts content");
expect(container.textContent).not.toContain("Properties content");
expect(readTaskSidePanelState("user-1", "company-1", "task-1", false)?.state).toEqual(classic);
expect(readTaskSidePanelState("coordinator:user-1", "company-1", "task-1", false)?.state).toEqual(experiment);
coordinator.enabled = false;
await render(panel({ accountScope: "user-1", mobile }));
expect(container.textContent).toContain("Properties content");
expect(readTaskSidePanelState("coordinator:user-1", "company-1", "task-1", false)?.state).toEqual(experiment);
});
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 }),
);
});
});