Files
PaperClipAI/ui/src/hooks/useTaskPluginPanels.test.tsx
T
Nicky LeachandPaperclip b056026407 fix(ui): keep task plugin views available for manual access (#18363)
## Thinking Path

> - Paperclip helps people manage agents and their tasks.
> - The task side panel can display a plugin's agent view.
> - Users need that view even while a task is idle.
> - Activity currently controls both manual discovery and automatic
opening.
> - This pull request keeps manual access available and uses activity to
request automatic opening.
> - The same authorized agent viewer serves both entry points.

## Linked Issues or Issue Description

**What happened?**

A task view disappeared when its run ended. Users could not select it
until the current run reported matching activity and its readiness check
passed. This also excluded declared manual views without an activity
mapping.

**Expected behavior**

The task launcher offers declared views for its running or assigned
agent. Selecting one uses the same viewer as the agent detail page.
Matching activity and readiness open the pane and select the view by
default, once per run.

**Steps to reproduce**

Open an idle task assigned to an agent with an installed task-panel
contribution. Open the panel launcher. The view is currently missing.
Related work: #18131 and #18239.

## What Changed

- Return manual panel declarations independently of run activity and
readiness.
- Mark panels that should open automatically after matching activity and
readiness. Keep existing run attribution, retry bounds, and dismissal
behavior.
- Keep an open viewer mounted when a run ends. Focus an already open
manual view when activity starts.
- Preserve saved manual tabs while plugin and initial run discovery
load; remove contributions after discovery confirms they are absent.
- Share the run-scoped dismissal key between the task page and panel
controller so dismissed views do not reveal an empty pane.
- Use the current running agent for declared manual views that have no
activity mapping.
- Update the plugin authoring contract and feature recipe.

## Verification

- Two new regressions failed before the fix: idle discovery returned no
view, and a manual contribution opened automatically.
- 105 focused hook and panel tests pass. They cover idle/manual access,
declarations without mappings, completion, saved-tab restoration,
automatic focus, activity attribution, retry bounds, dismissal, and
missing/disabled plugins.
- Thirteen task-page panel integration tests pass. New regressions
reproduce and fix a dismissed manual view revealing the outer pane. They
cover desktop/mobile and both workspace modes, and verify that a later
run can still open the view automatically.
- `node scripts/check-token-gates.mjs`, `node scripts/check-maps.mjs`,
and `git diff --check` pass.
- Local UI typecheck failed with the reused dependency cache: workspace
modules/exports and generated Storybook catalog files are missing or
stale. It reported no errors in the changed files. The final head passed
the clean GitHub typecheck. No broad local build or repository test run
was attempted.
- The preceding head passed [GitHub
CI](https://github.com/paperclipai/paperclip/actions/runs/41116028688),
with 48 jobs succeeding and one skipped. Its final review scored 5/5 and
identified a narrow saved-view restoration race. This revision adds the
missing initial-run loading guard. Two delayed-response regressions
failed before the correction and now pass; they cover a different
assignee and an unassigned task.
- The final head `bbde3a28f7ac0390da8f3fe81eb9abd2f3020695` passed
[GitHub
CI](https://github.com/paperclipai/paperclip/actions/runs/41119772438)
and the additional [full CI
run](https://github.com/paperclipai/paperclip/actions/runs/41121827023),
each with 48 successful jobs and one skipped. Standard Greptile scored
5/5 on this head; both review threads are resolved. The small final
loading correction used the permitted exception to another final-review
cycle.
- The additional full CI run initially failed the canary bundle install
because npm advertised a newly published `tslib@2.9.1` archive before
its tarball was available (HTTP 404). After the archive became
available, one retry of the failed canary and dependent aggregate
passed. No dependency or lockfile change was needed.
- Hosted acceptance of this follow-up requires merge and deployment; the
earlier hosted activation checks cover the previously merged behavior.

## Risks

Manual views can now open while idle or before readiness. Each viewer
retains its normal authorization and status handling. Declarations
without an activity mapping now appear in the launcher. Automatic
opening still requires authenticated activity and the authorized
readiness result. Saved tabs wait for plugin discovery before removal.

## Model Used

OpenAI GPT-6 through Codex, with repository inspection, terminal tools,
browser verification, and code execution. The exact serving variant and
context window size are 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
#` 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-30 13:44:41 -07:00

467 lines
26 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, LiveEvent } from "@paperclipai/shared";
import { afterEach, beforeEach, expect, it, vi, type MockInstance } from "vitest";
import { api, ApiError } from "@/api/client";
import { heartbeatsApi, type LiveRunForIssue } from "@/api/heartbeats";
import { useTaskRunLiveUpdates } from "@/context/LiveUpdatesProvider";
import { createAppQueryClient } from "@/lib/query-client";
import { queryKeys } from "@/lib/queryKeys";
import type { ResolvedPluginSlot } from "@/plugins/slots";
import { useTaskPluginPanels, type TaskPluginPanel } from "./useTaskPluginPanels";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const plugin = vi.hoisted(() => ({ connected: true, slots: [] as ResolvedPluginSlot[], healthy: true, epoch: 0, handler: null as ((event: LiveEvent) => void) | null, errorMessage: null as string | null, isLoading: false }));
vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => plugin }));
vi.mock("@/context/LiveUpdatesProvider", () => ({ useTaskRunLiveUpdates: vi.fn(() => plugin.connected), useTranscriptLiveStream: (_ids: string[], _options: unknown, handler: (event: LiveEvent) => void) => { plugin.handler = handler; return { healthy: plugin.healthy, epoch: plugin.epoch }; } }));
const slot = { pluginId: "example-plugin", pluginKey: "example.live-view", id: "screen", pluginVersion: "1.0.0", taskSidePanel: { availabilityPath: "/agents/:entityId/view/availability", activation: { namespace: "example.runtime", activityKey: "desktop.used" } } } as ResolvedPluginSlot;
const issue = { id: "task", companyId: "company", executionRunId: "run", assigneeAgentId: "agent" } as Issue;
const run = { id: "run", agentId: "agent", status: "running" } as LiveRunForIssue;
let root: Root, container: HTMLDivElement, client: QueryClient;
let panels: TaskPluginPanel[];
let loading: boolean;
let current: Omit<TaskPluginPanel, "autoOpen">[];
let task: Issue | null;
let enabled: boolean;
let get: MockInstance<typeof api.get>;
let list: MockInstance<typeof heartbeatsApi.liveRunsForIssue>;
let history: MockInstance<typeof heartbeatsApi.taskPanelActivations>;
function Harness() {
const discovery = useTaskPluginPanels(task, enabled);
panels = discovery.panels; loading = discovery.isLoading;
current = panels.filter(panel => panel.autoOpen).map(({ autoOpen: _autoOpen, ...panel }) => panel);
return null;
}
async function render(next: Issue | null = issue, active = true) {
task = next; enabled = active;
await act(async () => root.render(<QueryClientProvider client={client}><Harness /></QueryClientProvider>));
await act(async () => { if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(30); else await new Promise(resolve => setTimeout(resolve, 30)); });
}
async function refresh() {
await act(async () => { await client.refetchQueries(); });
await act(async () => { if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(30); else await new Promise(resolve => setTimeout(resolve, 30)); });
}
beforeEach(() => {
plugin.slots = [slot]; plugin.isLoading = false; plugin.errorMessage = null; plugin.connected = true; plugin.healthy = false; plugin.epoch = 0; plugin.handler = null;
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
get = vi.spyOn(api, "get").mockResolvedValue({ available: true });
list = vi.spyOn(heartbeatsApi, "liveRunsForIssue").mockResolvedValue([run]);
history = vi.spyOn(heartbeatsApi, "taskPanelActivations").mockImplementation(async () => plugin.slots.map(slot => ({ pluginKey: slot.pluginKey, slotId: slot.id })));
});
afterEach(async () => {
await act(async () => root.unmount()); client.clear(); container.remove(); vi.restoreAllMocks(); vi.useRealTimers();
});
it("keeps restored views pending until plugin discovery settles", async () => {
plugin.slots = []; plugin.isLoading = true;
await render(); expect(loading).toBe(true); expect(panels).toEqual([]);
plugin.slots = [slot]; plugin.isLoading = false; list.mockResolvedValue([]);
await render(); expect(loading).toBe(false);
expect(panels).toEqual([{ entityId: "agent", autoOpen: false, slot }]);
});
it.each(["assigned-agent", null])("waits for the running agent before settling saved views (assignee=%s)", async assigneeAgentId => {
const manual = { ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } };
plugin.slots = [manual];
let resolve!: (value: LiveRunForIssue[]) => void;
list.mockReturnValueOnce(new Promise(done => { resolve = done; }));
await render({ ...issue, assigneeAgentId });
expect(loading).toBe(true);
await act(async () => { resolve([{ ...run, agentId: "running-agent" }]); });
await render({ ...issue, assigneeAgentId });
expect(loading).toBe(false);
expect(panels).toEqual([{ entityId: "running-agent", runId: "run", autoOpen: false, slot: manual }]);
});
it("offers manual access while idle without reading activation history or readiness", async () => {
list.mockResolvedValue([]);
history.mockResolvedValue([]);
await render();
expect(panels).toEqual([{ entityId: "agent", autoOpen: false, slot }]);
expect(history).not.toHaveBeenCalled();
expect(get).not.toHaveBeenCalled();
});
it("offers the assigned agent's view before activity and after run completion", async () => {
history.mockResolvedValue([]);
await render();
expect(panels).toEqual([{ entityId: "agent", runId: "run", autoOpen: false, slot }]);
expect(get).not.toHaveBeenCalled();
await act(async () => { client.setQueryData(queryKeys.issues.liveRuns("task"), []); });
await render();
expect(panels).toEqual([{ entityId: "agent", autoOpen: false, slot }]);
});
it("offers declared manual views without an activation mapping", async () => {
const manual = { ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } };
plugin.slots = [manual];
await render();
expect(panels).toEqual([{ entityId: "agent", runId: "run", autoOpen: false, slot: manual }]);
expect(list).toHaveBeenCalledWith("task");
expect(history).not.toHaveBeenCalled();
expect(get).not.toHaveBeenCalled();
});
it("uses the running agent for manual views without an activity mapping", async () => {
const manual = { ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } };
plugin.slots = [manual]; list.mockResolvedValue([{ ...run, agentId: "running-agent" }]);
await render();
expect(panels).toEqual([{ entityId: "running-agent", runId: "run", autoOpen: false, slot: manual }]);
expect(history).not.toHaveBeenCalled(); expect(get).not.toHaveBeenCalled();
});
it("has no manual agent view for an unassigned idle task", async () => {
list.mockResolvedValue([]);
await render({ ...issue, assigneeAgentId: null });
expect(panels).toEqual([]);
});
it("uses the running agent's authorized status and existing plugin slot", async () => {
await render();
expect(list).toHaveBeenCalledWith("task");
expect(get).toHaveBeenCalledWith("/plugins/example-plugin/api/agents/agent/view/availability?runId=run&issueId=task", { signal: expect.any(AbortSignal), cache: "no-store" });
expect(current).toEqual([{ entityId: "agent", runId: "run", slot }]);
});
it("keeps unavailable panels hidden", async () => {
get.mockResolvedValue({ available: false }); await render(); expect(current).toEqual([]);
});
it.each(["absent", "disabled", "draft", "no-task", "plugin-error"])("does no panel polling when %s", async kind => {
if (kind === "absent") plugin.slots = [];
if (kind === "plugin-error") plugin.errorMessage = "Unavailable";
await render(kind === "no-task" ? null : kind === "draft" ? { ...issue, id: "chat:draft" } : issue, kind !== "disabled");
expect(current).toEqual([]); expect(panels).toEqual([]); expect(get).not.toHaveBeenCalled(); expect(list).not.toHaveBeenCalled();
});
it.each(["queued", "succeeded", "another-run"])("ignores a %s run", async kind => {
list.mockResolvedValue([{ ...run, status: kind === "another-run" ? "running" : kind, id: kind === "another-run" ? "other" : "run" }]);
await render(); expect(current).toEqual([]); expect(get).not.toHaveBeenCalled();
});
it("discovers a panel started after the task is already open, then hides it on stop or access failure", async () => {
get.mockResolvedValue({ available: false }); await render(); expect(current).toEqual([]);
get.mockResolvedValue({ available: true }); await refresh(); expect(current[0]?.entityId).toBe("agent");
get.mockRejectedValue(new Error("Access denied")); await refresh(); expect(current).toEqual([]);
});
it("clears automatic activation when the run ends even if its view is still ready", async () => {
await render(); expect(current).not.toEqual([]);
await act(async () => { client.setQueryData(queryKeys.issues.liveRuns("task"), []); });
await render(); expect(current).toEqual([]);
});
it("does not carry an old task's ready panel to a new task or disabled plugin", async () => {
await render(); expect(current).not.toEqual([]);
list.mockResolvedValue([]); await render({ ...issue, id: "next-task" }); expect(current).toEqual([]);
plugin.slots = []; await render(); expect(current).toEqual([]);
});
it("stops idle polling with live updates and resumes fallback polling on disconnect", async () => {
vi.useFakeTimers(); list.mockResolvedValue([]);
await render(); expect(list).toHaveBeenCalledTimes(1);
await act(async () => { await vi.advanceTimersByTimeAsync(6000); });
expect(list).toHaveBeenCalledTimes(1);
plugin.connected = false; await render();
await act(async () => { await vi.advanceTimersByTimeAsync(6000); });
expect(list).toHaveBeenCalledTimes(3);
});
it("uses each plugin's own availability path and excludes only the failing contribution", async () => {
const second = { ...slot, pluginId: "other-plugin", pluginKey: "example.other-view", taskSidePanel: { availabilityPath: "/views/:entityId/ready", activation: { namespace: "example.other-runtime", activityKey: "desktop.used" } } };
plugin.slots = [slot, second];
get.mockImplementation(async path => {
if (path.includes("other-plugin")) throw new Error("Unavailable");
return { available: true } as never;
});
await render();
expect(current).toEqual([{ entityId: "agent", runId: "run", slot }]);
expect(get).toHaveBeenCalledWith("/plugins/other-plugin/api/views/agent/ready?runId=run&issueId=task", expect.anything());
});
function event(overrides: Record<string, unknown> = {}): LiveEvent {
return { id: 1, companyId: "company", type: "heartbeat.run.event", createdAt: "2099-01-01T00:00:00.000Z",
payload: { runId: "run", agentId: "agent", issueId: "task", eventType: "runtime.activity.recorded",
payload: { prpEvent: { eventType: "runtime.activity.recorded", sourceKind: "runner", payload: { ...slot.taskSidePanel!.activation } } }, ...overrides } } as LiveEvent;
}
async function emit(value: LiveEvent) {
await act(async () => { plugin.handler?.(value); });
await act(async () => { await vi.advanceTimersByTimeAsync(30); });
}
it("does no availability polling or Launcher discovery until this run activates the panel", async () => {
vi.useFakeTimers(); history.mockResolvedValue([]);
await render(); expect(get).not.toHaveBeenCalled(); expect(history).toHaveBeenCalledTimes(1);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).not.toHaveBeenCalled(); expect(history).toHaveBeenCalledTimes(1);
for (const overrides of [{ runId: "other" }, { agentId: "other" }, { issueId: "other" }, { eventType: "item.completed" }]) await emit(event(overrides));
await emit({ ...event(), companyId: "other" });
await emit(event({ payload: { pluginKey: "example.other", slotId: "screen" } }));
for (const native of [
{ eventType: "runtime.activity.recorded", sourceKind: "control_plane", payload: { ...slot.taskSidePanel!.activation } },
{ eventType: "runtime.activity.recorded", sourceKind: "runner", payload: { namespace: "example.other-runtime", activityKey: "desktop.used" } },
{ eventType: "runtime.activity.recorded", sourceKind: "runner", payload: { namespace: "example.runtime", activityKey: "other.used" } },
]) await emit(event({ payload: { prpEvent: native } }));
expect(get).not.toHaveBeenCalled();
await emit(event());
expect(get).toHaveBeenCalledTimes(1); expect(current[0]?.runId).toBe("run");
await emit(event());
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(1);
});
it("restores an activation from durable run history after reload", async () => {
await render(); expect(current).toHaveLength(1);
expect(history).toHaveBeenCalledWith("run", { signal: expect.any(AbortSignal), cache: "no-store" });
});
it("reconciles activation history and availability after reconnect", async () => {
vi.useFakeTimers(); history.mockResolvedValue([]); await render(); expect(current).toEqual([]);
plugin.healthy = false; await render();
history.mockResolvedValue([{ pluginKey: slot.pluginKey, slotId: slot.id }]);
plugin.healthy = true; plugin.epoch++;
await render(); expect(current).toHaveLength(1);
});
it("does not lose an activation to an older in-flight history read", async () => {
vi.useFakeTimers(); let resolve!: (value: []) => void;
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
await render(); expect(get).not.toHaveBeenCalled();
await emit(event()); expect(current).toHaveLength(1);
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
expect(current).toHaveLength(1);
});
it("merges earlier panel history with a different panel activated during the read", async () => {
vi.useFakeTimers();
const second = { ...slot, id: "second-screen", taskSidePanel: { ...slot.taskSidePanel!, activation: { namespace: "example.runtime", activityKey: "other.used" } } };
plugin.slots = [slot, second];
let resolve!: (value: Array<{ pluginKey: string; slotId: string }>) => void;
history.mockReturnValueOnce(new Promise(done => { resolve = done; }));
await render();
expect(get).not.toHaveBeenCalled();
await emit(event({ payload: { prpEvent: {
eventType: "runtime.activity.recorded", sourceKind: "runner",
payload: { ...second.taskSidePanel.activation },
} } }));
expect(current.map(panel => panel.slot.id)).toEqual([second.id]);
await act(async () => {
resolve([{ pluginKey: slot.pluginKey, slotId: slot.id }]);
await vi.advanceTimersByTimeAsync(30);
});
expect(current.map(panel => panel.slot.id)).toEqual([slot.id, second.id]);
expect(history).toHaveBeenCalledTimes(1);
});
it("fails closed when activation history cannot be read", async () => {
history.mockRejectedValue(new Error("Access denied")); await render();
expect(get).not.toHaveBeenCalled(); expect(current).toEqual([]);
});
it("bounds readiness retries to three attempts after an activation", async () => {
vi.useFakeTimers(); get.mockResolvedValue({ available: false });
await render(); expect(get).toHaveBeenCalledTimes(1);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(3); expect(current).toEqual([]);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(3);
});
it.each(["not-ready", "server-error"])("restarts bounded readiness on fresh activity after %s", async failure => {
vi.useFakeTimers();
if (failure === "not-ready") get.mockResolvedValue({ available: false });
else get.mockRejectedValue(new ApiError("Unavailable", 500, {}));
await render();
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(3);
await emit(event());
await emit(event());
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(6);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(6);
get.mockResolvedValue({ available: true });
await emit(event());
expect(get).toHaveBeenCalledTimes(7); expect(current).toHaveLength(1);
});
it("does not retry denied readiness on fresh activity", async () => {
vi.useFakeTimers(); get.mockRejectedValue(new ApiError("Denied", 403, {}));
await render();
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(1);
get.mockResolvedValue({ available: true });
await emit(event());
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(1); expect(current).toEqual([]);
});
it.each(["runs", "history"])("recovers from a transient %s read without recurring polling", async source => {
vi.useFakeTimers();
const error = new ApiError("Unavailable", 500, {});
if (source === "runs") list.mockRejectedValueOnce(error);
else history.mockRejectedValueOnce(error);
await render();
expect(current).toEqual([]);
await act(async () => { await vi.advanceTimersByTimeAsync(1200); });
expect(current).toHaveLength(1);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(source === "runs" ? list : history).toHaveBeenCalledTimes(2);
});
it.each([500, 403])("bounds failed discovery reads for HTTP %s", async status => {
vi.useFakeTimers();
list.mockRejectedValue(new ApiError("Unavailable", status, {}));
await render();
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(list).toHaveBeenCalledTimes(status === 500 ? 3 : 1);
expect(history).not.toHaveBeenCalled();
expect(current).toEqual([]);
});
it("does not automatically open a task view without a declared activity mapping", async () => {
plugin.slots = [{ ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } }];
await render();
expect(list).toHaveBeenCalledWith("task"); expect(history).not.toHaveBeenCalled();
expect(get).not.toHaveBeenCalled(); expect(current).toEqual([]);
});
it("observes run lifecycle updates using the same UUID key as panel discovery", async () => {
await render({ ...issue, identifier: "DEMO-1" });
expect(useTaskRunLiveUpdates).toHaveBeenLastCalledWith({ companyId: "company", issueId: "task", issueQueryRef: "task", assigneeAgentId: "agent" });
});
it.each(["initial", "reconnect"])("reads again after pending history settles across %s subscription", async phase => {
vi.useFakeTimers();
let resolve!: (value: []) => void;
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
if (phase === "initial") plugin.healthy = true;
await render();
if (phase === "reconnect") { plugin.healthy = true; plugin.epoch++; await render(); }
expect(history).toHaveBeenCalledTimes(1);
// The first snapshot predates activity that arrived before subscription.
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
expect(history).toHaveBeenCalledTimes(2);
expect(list).toHaveBeenCalledTimes(1);
expect(current).toHaveLength(1);
});
it("discards pending subscription recovery after the task changes", async () => {
vi.useFakeTimers(); plugin.healthy = true;
let resolve!: (value: []) => void;
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
await render();
await render({ ...issue, id: "next-task" });
const reads = history.mock.calls.length;
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
expect(history).toHaveBeenCalledTimes(reads);
});
it.each(["not-ready", "server-error"])("reports unexpected failures only after bounded %s readiness checks", async failure => {
vi.useFakeTimers();
const reportError = vi.fn();
client = createAppQueryClient({ connectivity: { reportError: vi.fn(), getSnapshot: () => ({ status: "online" } as never) }, reportError });
if (failure === "not-ready") get.mockResolvedValue({ available: false });
else get.mockRejectedValue(new ApiError("Unexpected failure", 500, {}));
await render();
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(3);
expect(current).toEqual([]);
expect(reportError).toHaveBeenCalledTimes(failure === "not-ready" ? 0 : 1);
});
it.each(["1.0.0", "2.0.0"])("requires current activity after a mapping change at version %s", async pluginVersion => {
vi.useFakeTimers();
await render(); expect(current).toHaveLength(1);
const changed = { ...slot, pluginVersion, taskSidePanel: { ...slot.taskSidePanel!,
activation: { namespace: "example.runtime", activityKey: "new-view.used" } } };
history.mockResolvedValue([]); plugin.slots = [changed];
await render(); expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
await emit(event()); expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
plugin.healthy = true; plugin.epoch++; await render();
expect(current).toEqual([]);
await emit(event({ payload: { prpEvent: { eventType: "runtime.activity.recorded", sourceKind: "runner",
payload: changed.taskSidePanel.activation } } }));
expect(current).toEqual([{ entityId: "agent", runId: "run", slot: changed }]);
expect(get).toHaveBeenCalledTimes(2);
});
it("does not carry an old in-flight activation read into a changed declaration", async () => {
vi.useFakeTimers();
let resolve!: (value: Array<{ pluginKey: string; slotId: string }>) => void;
history.mockReturnValueOnce(new Promise(done => { resolve = done; }));
await render(); await emit(event()); expect(current).toHaveLength(1);
history.mockResolvedValue([]);
plugin.slots = [{ ...slot, pluginVersion: "2.0.0", taskSidePanel: { ...slot.taskSidePanel!,
activation: { namespace: "example.runtime", activityKey: "new-view.used" } } }];
await render(); expect(current).toEqual([]);
await act(async () => { resolve([{ pluginKey: slot.pluginKey, slotId: slot.id }]); await vi.advanceTimersByTimeAsync(30); });
expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
});
it("keeps an unchanged panel active while another declaration changes and history is pending", async () => {
vi.useFakeTimers(); client.setDefaultOptions({ queries: { retry: false, staleTime: 60_000 } });
const second = { ...slot, id: "other-screen", pluginKey: "example.other", pluginId: "other-plugin" };
plugin.slots = [slot, second];
await render(); expect(current).toHaveLength(2);
let resolve!: (value: []) => void;
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
const changed = { ...second, pluginVersion: "2.0.0", taskSidePanel: { ...second.taskSidePanel!,
activation: { namespace: "example.runtime", activityKey: "new-view.used" } } };
plugin.slots = [slot, changed];
await render();
expect(history).toHaveBeenCalledTimes(2);
expect(current).toEqual([{ entityId: "agent", runId: "run", slot }]);
await emit(event({ payload: { prpEvent: { eventType: "runtime.activity.recorded", sourceKind: "runner",
payload: changed.taskSidePanel.activation } } }));
expect(current.map(panel => panel.slot)).toEqual([slot, changed]);
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
expect(current.map(panel => panel.slot)).toEqual([slot, changed]);
});
it("does not seed another task's activation from cached declarations", async () => {
await render(); expect(current).toHaveLength(1);
history.mockResolvedValue([]);
await render({ ...issue, id: "other-task" });
expect(current).toEqual([]); expect(get).toHaveBeenCalledTimes(1);
});
it("does not seed activations from a history read that lost access", async () => {
vi.useFakeTimers();
await render(); expect(current).toHaveLength(1);
history.mockRejectedValue(new ApiError("Denied", 403, {}));
await refresh(); expect(current).toEqual([]);
let resolve!: (value: []) => void;
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
plugin.slots = [slot, { ...slot, id: "other-screen", pluginKey: "example.other" }];
await render(); expect(current).toEqual([]);
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
expect(current).toEqual([]);
});
it("preserves activity when returning to an earlier cached declaration set", async () => {
vi.useFakeTimers();
const second = { ...slot, id: "other-screen", pluginKey: "example.other" };
plugin.slots = [slot, second]; history.mockResolvedValue([]);
await render(); expect(current).toEqual([]);
plugin.slots = [slot, { ...second, pluginVersion: "2.0.0" }];
await render(); await emit(event()); expect(current.map(panel => panel.slot.id)).toEqual([slot.id, second.id]);
let resolve!: (value: []) => void;
history.mockReturnValueOnce(new Promise<[]>(done => { resolve = done; }));
plugin.slots = [slot, second]; await render();
expect(current.map(panel => panel.slot.id)).toEqual([slot.id]);
await act(async () => { resolve([]); await vi.advanceTimersByTimeAsync(30); });
expect(current.map(panel => panel.slot.id)).toEqual([slot.id]);
await act(async () => client.removeQueries({ predicate: query => String(query.queryKey[5]).includes("2.0.0") }));
await render(); expect(current.map(panel => panel.slot.id)).toEqual([slot.id]);
});
it.each(["route", "installation"])("rechecks readiness after a same-version %s change", async change => {
vi.useFakeTimers(); await render(); expect(current).toHaveLength(1);
get.mockResolvedValue({ available: false });
const changed = change === "route"
? { ...slot, taskSidePanel: { ...slot.taskSidePanel!, availabilityPath: "/views/:entityId/ready" } }
: { ...slot, pluginId: "replacement-plugin" };
plugin.slots = [changed]; await render();
expect(current).toEqual([]);
expect(get).toHaveBeenLastCalledWith(`/plugins/${changed.pluginId}/api${changed.taskSidePanel!.availabilityPath.replace(":entityId", "agent")}?runId=run&issueId=task`, expect.anything());
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(get).toHaveBeenCalledTimes(4); expect(current).toEqual([]);
get.mockResolvedValue({ available: true }); await emit(event());
expect(get).toHaveBeenCalledTimes(5); expect(current).toHaveLength(1);
});