mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
## 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>
753 lines
38 KiB
TypeScript
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 }),
|
|
);
|
|
});
|
|
});
|