Support plugin panels in task sidebars (#17796)

## Thinking Path

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

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Access to plugin-contributed live views from task chat.

**Subsystem affected**

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

**Current behavior**

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

**Proposed behavior**

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

**Reason and benefit**

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

**Breaking changes**

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

**Additional context**

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-26 14:47:15 -07:00
1 parent 718c73dafe
commit c7c12dbc79
16 files changed
+405 -3

No files matched your search

+4
View File
@@ -373,6 +373,10 @@ export interface PaperclipPluginManifestV1 {
exportName: string;
/** For detailTab: which entity types this tab appears on */
entityTypes?: Array<"project" | "issue" | "agent" | "goal" | "run">;
/** Optional agent detail tab mirrored beside a running task.
* Declared board GET route scoped to agent :entityId returns { available: boolean }.
*/
taskSidePanel?: { availabilityPath: string };
/** For page and companySettingsPage: single route segment */
routePath?: string;
}>;
+16
View File
@@ -56,6 +56,22 @@ interfaces with [the paused composer browser suite](../../tests/e2e/paused-compo
Release rechecks tasks reopened after the pause; another active pause still prevents
their execution, and uncertain prior executions must be reconciled before waking.
### Plugin task panels
An installed plugin can offer an agent detail tab in the task side panel through
its optional `taskSidePanel` declaration. The host supplies the task's running
agent. The plugin's authorized availability route decides whether the panel is
shown, and the plugin owns its label and viewer. No contribution appears for a
missing plugin, inactive run, unavailable view or access failure. A closed panel
can be reopened from the launcher while available.
Implementation: [panel discovery](../../ui/src/hooks/useTaskPluginPanels.ts) and
[task panel](../../ui/src/components/task-side-panel/TaskSidePanel.tsx).
Automated: [discovery tests](../../ui/src/hooks/useTaskPluginPanels.test.tsx) and
[task panel tests](../../ui/src/components/task-side-panel/TaskSidePanel.test.tsx)
cover availability, task/agent changes, failures, inactivity and dismissal.
These mocks do not establish a particular plugin's live transport behavior.
## Gotchas
- Review completion and task completion are different events under some policies.
+12
View File
@@ -259,6 +259,18 @@ A card or section rendered on the main dashboard. Use this for at-a-glance metri
An additional tab on a project, issue, agent, goal, or run detail page. Rendered when the user navigates to that entity's detail view. Receives `PluginDetailTabProps` with `context.companyId` set to the active company and `context.entityId` / `context.entityType` guaranteed to be non-null, so you can immediately scope data fetches to the relevant entity. Specify which entity types the tab applies to via the `entityTypes` array in the manifest slot declaration. Requires the `ui.detailTab.register` capability.
Agent detail tabs can also appear in task side panels. Add
`taskSidePanel: { availabilityPath: "/agents/:entityId/view/availability" }` to the
slot and declare that exact path as a board-only GET API route with
`companyResolution: { from: "agent", param: "entityId" }`. The route returns
`{ available: true }` only while its view is in use. The host replaces `:entityId`
with the task's running agent ID, polls availability and mounts the same component
with agent context. The plugin owns availability, labels, content and controls.
No private plugin identifier or provider protocol belongs in Core. Unavailable
views, access failures, inactive runs and missing plugins contribute no task tab.
Closing a tab preserves dismissal during polling; available panels remain in the
launcher. Only tab identity is persisted, never availability responses or secrets.
#### `taskDetailView`
A specialized slot rendered in the context of a task or issue detail view. Similar to `detailTab` but designed for inline content within the task detail layout rather than a separate tab. Receives `context.companyId`, `context.entityId`, and `context.entityType` like `detailTab`. Requires the `ui.detailTab.register` capability.
+6
View File
@@ -497,6 +497,12 @@ export interface PluginUiSlotDeclaration {
* Required for `detailTab`, `taskDetailView`, and `contextMenuItem`.
*/
entityTypes?: PluginUiSlotEntityType[];
/** Also offer this agent detail tab beside a task while its agent is running.
* GET this plugin-relative path with :entityId replaced by the agent ID.
* The board/agent-scoped route returns { available: boolean }.
*/
taskSidePanel?: { availabilityPath: string };
/**
* Optional company-scoped route segment for page, routeSidebar, and
* companySettingsPage slots.
@@ -490,3 +490,25 @@ describe("plugin WebSocket company resolution", () => {
expect(pluginApiRouteDeclarationSchema.safeParse({ ...route, companyResolution, transport: "websocket" }).success).toBe(true);
});
});
describe("plugin task side-panel declarations", () => {
const slot = { type: "detailTab", id: "view", displayName: "Live view", exportName: "LiveView", entityTypes: ["agent"], taskSidePanel: { availabilityPath: "/agents/:entityId/view/availability" } };
const route = { routeKey: "view.availability", method: "GET", path: slot.taskSidePanel.availabilityPath, auth: "board", capability: "api.routes.register", companyResolution: { from: "agent", param: "entityId" } };
const manifest = { id: "example.live-view", apiVersion: 1, version: "1.0.0", displayName: "Live view", description: "Live view", author: "Tests", categories: ["ui"], capabilities: ["ui.detailTab.register", "api.routes.register"], entrypoints: { worker: "worker.js", ui: "ui.js" }, ui: { slots: [slot] }, apiRoutes: [route] };
it("accepts an optional panel backed by an authorized plugin-local agent route", () => {
expect(pluginManifestV1Schema.parse(manifest).ui?.slots?.[0]?.taskSidePanel).toEqual(slot.taskSidePanel);
});
it("accepts a GET availability route when a POST at the same path is declared first", () => {
expect(pluginManifestV1Schema.safeParse({ ...manifest, apiRoutes: [{ ...route, routeKey: "view.update", method: "POST" }, route] }).success).toBe(true);
});
it.each(["https://example.test/:entityId", "//example/:entityId", "/../:entityId", "/agents/:entityId?redirect=elsewhere", "/agents/:agentId/view"])("rejects unsafe or unsupported path %s", availabilityPath => {
expect(pluginUiSlotDeclarationSchema.safeParse({ ...slot, taskSidePanel: { availabilityPath } }).success).toBe(false);
});
it("rejects non-agent and non-detail contributions", () => {
expect(pluginUiSlotDeclarationSchema.safeParse({ ...slot, entityTypes: ["issue"] }).success).toBe(false);
expect(pluginUiSlotDeclarationSchema.safeParse({ ...slot, type: "page" }).success).toBe(false);
});
it.each([[], [{ ...route, method: "POST" }], [{ ...route, auth: "agent" }], [{ ...route, companyResolution: { from: "query", key: "companyId" } }]].map(apiRoutes => ({ apiRoutes })))("requires a declared board GET route scoped to the supplied agent", ({ apiRoutes }) => {
expect(pluginManifestV1Schema.safeParse({ ...manifest, apiRoutes }).success).toBe(false);
});
});
+15
View File
@@ -389,11 +389,17 @@ export const pluginUiSlotDeclarationSchema = z.object({
displayName: z.string().min(1),
exportName: z.string().min(1),
entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(),
taskSidePanel: z.object({
availabilityPath: z.string().regex(/^\/(?:[a-zA-Z0-9_-]+\/)*:entityId(?:\/[a-zA-Z0-9_-]+)*$/),
}).strict().optional(),
routePath: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, {
message: "routePath must be a lowercase single-segment slug (letters, numbers, hyphens)",
}).optional(),
order: z.number().int().optional(),
}).superRefine((value, ctx) => {
if (value.taskSidePanel && (value.type !== "detailTab" || !value.entityTypes?.includes("agent"))) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "taskSidePanel requires an agent detailTab", path: ["taskSidePanel"] });
}
// context-sensitive slots require explicit entity targeting.
const entityScopedTypes = ["detailTab", "taskDetailView", "contextMenuItem", "commentAnnotation", "commentContextMenuItem", "projectSidebarItem"];
if (
@@ -978,6 +984,15 @@ export const pluginManifestV1Schema = z.object({
}
}
for (const [index, slot] of (manifest.ui?.slots ?? []).entries()) {
if (!slot.taskSidePanel) continue;
const route = manifest.apiRoutes?.find(route => route.path === slot.taskSidePanel!.availabilityPath && route.method === "GET");
if (!route || route.auth !== "board" || route.transport
|| route.companyResolution?.from !== "agent" || route.companyResolution.param !== "entityId") {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "taskSidePanel availability requires a declared board GET route scoped to agent :entityId", path: ["ui", "slots", index, "taskSidePanel"] });
}
}
if (manifest.apiRoutes?.some(route => route.transport === "websocket") && !manifest.capabilities.includes("http.outbound")) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "WebSocket routes require http.outbound", path: ["capabilities"] });
}
@@ -13,6 +13,7 @@ import {
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;
@@ -25,6 +26,13 @@ class ResizeObserverStub {
(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 })) } }));
@@ -179,6 +187,61 @@ describe("TaskSidePanel", () => {
);
}
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]");
@@ -1,6 +1,8 @@
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { TaskAttachmentPanel } from "./TaskAttachmentPanel";
import { useTaskBrowsers } from "@/hooks/useTaskBrowsers";
import { taskPluginPanelId, type TaskPluginPanel } from "@/hooks/useTaskPluginPanels";
import { PluginSlotMount } from "@/plugins/slots";
import { TaskBrowserPanel } from "./TaskBrowserPanel";
import {
useCallback,
@@ -19,6 +21,7 @@ import {
} from "@paperclipai/shared";
import {
Globe,
AppWindow,
Box,
FileCode2,
FileText,
@@ -75,6 +78,7 @@ import { useLocation, useNavigate } from "@/lib/router";
import {
readTaskSidePanelState,
taskPanelBrowserTab,
taskPanelPluginTab,
taskPanelAgentTasksTab,
taskPanelArtifactsTab,
taskPanelAttachmentTab,
@@ -94,6 +98,9 @@ import { TaskSkillPanel } from "./TaskSkillPanel";
export interface TaskSidePanelProps {
issue: Issue;
pluginPanels?: TaskPluginPanel[];
openedPluginPanelKeys?: Set<string>;
dismissedPluginPanelKeys?: Set<string>;
openBrowserId?: string | null;
onBrowserOpened?: () => void;
accountScope: string;
@@ -128,10 +135,12 @@ export interface TaskSidePanelProps {
}
const EMPTY_ISSUE_DOCUMENTS: IssueDocument[] = [];
const EMPTY_PLUGIN_PANELS: TaskPluginPanel[] = [];
function tabIcon(tab: SidePanelTabRecord<TaskSidePanelTabPayload>): ReactNode {
switch (tab.payload.kind) {
case "browser": return <Globe />;
case "plugin-panel": return <AppWindow />;
case "properties": return <SlidersHorizontal />;
case "subtasks": return <ListTree />;
case "artifacts": return <Box />;
@@ -238,6 +247,9 @@ function useTaskSidePanelFileRouting() {
}
export function TaskSidePanel({
pluginPanels = EMPTY_PLUGIN_PANELS,
openedPluginPanelKeys,
dismissedPluginPanelKeys,
openBrowserId,
onBrowserOpened,
issue,
@@ -341,6 +353,26 @@ export function TaskSidePanel({
});
}, [accountScope, issue.companyId, issue.id, launcherOpen]);
const controller = useSidePanelTabs<TaskSidePanelTabPayload>({ initialState, onStateChange: persist });
const localOpenedPluginPanels = useRef(new Set<string>());
const openedPluginPanels = openedPluginPanelKeys ?? localOpenedPluginPanels.current;
const localDismissedPluginPanels = useRef(new Set<string>());
const dismissedPluginPanels = dismissedPluginPanelKeys ?? localDismissedPluginPanels.current;
const pluginPanelKey = useCallback((panel: TaskPluginPanel) => JSON.stringify([accountScope, issue.companyId, issue.id, panel.runId, taskPluginPanelId(panel)]), [accountScope, issue.companyId, issue.id]);
useEffect(() => {
const ids = new Set(pluginPanels.map(taskPluginPanelId));
for (const tab of controller.tabs) {
if (tab.payload.kind === "plugin-panel" && !ids.has(tab.id)) controller.closeTab(tab.id);
}
for (const panel of pluginPanels) {
const key = pluginPanelKey(panel);
if (dismissedPluginPanels.has(key)) continue;
const firstArrival = !openedPluginPanels.has(key);
openedPluginPanels.add(key);
if (controller.tabs.some(tab => tab.id === taskPluginPanelId(panel))) continue;
if (firstArrival) setLauncherOpen(false);
controller.openTab(taskPanelPluginTab(panel), firstArrival);
}
}, [dismissedPluginPanels, openedPluginPanels, pluginPanelKey, pluginPanels, controller.tabs, controller.closeTab, controller.openTab]);
const browsersQuery = useTaskBrowsers(issue.id);
const newestLiveBrowser = browsersQuery.data?.findLast((browser) => browser.status === "running" || browser.status === "idle");
useEffect(() => {
@@ -531,6 +563,10 @@ export function TaskSidePanel({
markInteracted();
if (tabId === "subtasks") subtasksDismissedRef.current = true;
const tab = controller.tabs.find((candidate) => candidate.id === tabId);
if (tab?.payload.kind === "plugin-panel") {
const panel = pluginPanels.find(panel => taskPluginPanelId(panel) === tabId);
if (panel) dismissedPluginPanels.add(pluginPanelKey(panel));
}
controller.closeTab(tabId);
if (tabId === controller.activeTabId && (tab?.payload.kind === "workspace-file" || tab?.payload.kind === "files-browser")) {
viewer.close();
@@ -591,6 +627,7 @@ export function TaskSidePanel({
for (const [index, browser] of (browsersQuery.data ?? []).entries()) {
primary.push({ id: `browser:${browser.id}`, label: (browsersQuery.data?.length ?? 0) > 1 ? `Browser ${index + 1}` : "Browser", description: browser.status, icon: <Globe />, alreadyOpen: controller.tabs.some(tab => tab.id === `browser:${browser.id}`) });
}
for (const panel of pluginPanels) primary.push({ id: taskPluginPanelId(panel), label: panel.slot.displayName, icon: <AppWindow />, alreadyOpen: controller.tabs.some(tab => tab.id === taskPluginPanelId(panel)) });
if (fileTabsEnabled) {
primary.push({ id: "files", label: "Files", icon: <FolderOpen />, shortcut: "G F", alreadyOpen: controller.tabs.some((tab) => tab.id === "files") });
}
@@ -638,11 +675,18 @@ export function TaskSidePanel({
});
}
return sections;
}, [browsersQuery.data, conversationAgentId, taskCount, taskLabel, tasksTab?.hasError, controller.tabs, documents, fileTabsEnabled, planDocument, recentFilesQuery.data, recentFilesQuery.isError, recentFilesQuery.isLoading, subtasksAvailable]);
}, [pluginPanels, browsersQuery.data, conversationAgentId, taskCount, taskLabel, tasksTab?.hasError, controller.tabs, documents, fileTabsEnabled, planDocument, recentFilesQuery.data, recentFilesQuery.isError, recentFilesQuery.isLoading, subtasksAvailable]);
function selectLauncherItem(item: SidePanelLauncherItem) {
markInteracted();
if (item.id.startsWith("browser:")) controller.openTab(taskPanelBrowserTab(item.id.slice(8)));
else if (item.id.startsWith("plugin:")) {
const panel = pluginPanels.find(panel => taskPluginPanelId(panel) === item.id);
if (panel) {
dismissedPluginPanels.delete(pluginPanelKey(panel));
controller.openTab(taskPanelPluginTab(panel));
}
}
else if (item.id === "properties") controller.openTab(taskPanelPropertiesTab());
else if (item.id === "subtasks") {
subtasksDismissedRef.current = false;
@@ -729,6 +773,11 @@ export function TaskSidePanel({
content = <SidePanelLauncher sections={launcherSections} onSelect={selectLauncherItem} />;
} else if (activeTab.payload.kind === "browser") {
content = null; // Mounted below independently of the selected tab.
} else if (activeTab.payload.kind === "plugin-panel") {
const panel = pluginPanels.find(panel => taskPluginPanelId(panel) === activeTab.id);
content = panel ? (
<PluginSlotMount key={panel.runId} slot={panel.slot} context={{ companyId: issue.companyId, projectId: issue.projectId, entityId: panel.entityId, entityType: "agent" }} />
) : null;
} else if (activeTab.payload.kind === "properties") {
content = (
<IssueProperties
+100
View File
@@ -0,0 +1,100 @@
// @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 } from "@paperclipai/shared";
import { afterEach, beforeEach, expect, it, vi, type MockInstance } from "vitest";
import { api } from "@/api/client";
import { heartbeatsApi, type LiveRunForIssue } from "@/api/heartbeats";
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[], errorMessage: null as string | null }));
vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => plugin }));
vi.mock("@/context/LiveUpdatesProvider", () => ({ useHasCompanyLiveConnection: () => plugin.connected }));
const slot = { pluginId: "example-plugin", pluginKey: "example.live-view", id: "screen", pluginVersion: "1.0.0", taskSidePanel: { availabilityPath: "/agents/:entityId/view/availability" } } as ResolvedPluginSlot;
const issue = { id: "task", companyId: "company", executionRunId: "run" } as Issue;
const run = { id: "run", agentId: "agent", status: "running" } as LiveRunForIssue;
let root: Root, container: HTMLDivElement, client: QueryClient;
let current: TaskPluginPanel[];
let task: Issue | null;
let enabled: boolean;
let get: MockInstance<typeof api.get>;
let list: MockInstance<typeof heartbeatsApi.liveRunsForIssue>;
function Harness() { current = useTaskPluginPanels(task, enabled); 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.errorMessage = null; plugin.connected = true;
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]);
});
afterEach(async () => {
await act(async () => root.unmount()); client.clear(); container.remove(); vi.restoreAllMocks(); vi.useRealTimers();
});
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", { 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(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("removes the panel 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" } };
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", expect.anything());
});
+56
View File
@@ -0,0 +1,56 @@
import { useMemo } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { api } from "@/api/client";
import { heartbeatsApi } from "@/api/heartbeats";
import { useHasCompanyLiveConnection } from "@/context/LiveUpdatesProvider";
import { liveRunRefetchInterval } from "@/lib/live-runs-cache";
import { queryKeys } from "@/lib/queryKeys";
import { usePluginSlots, type ResolvedPluginSlot } from "@/plugins/slots";
export interface TaskPluginPanel {
entityId: string;
runId: string;
slot: ResolvedPluginSlot;
}
const EMPTY_PANELS: TaskPluginPanel[] = [];
export function taskPluginPanelId(panel: TaskPluginPanel) {
return `plugin:${panel.slot.pluginKey}:${panel.slot.id}:${panel.entityId}`;
}
/** Plugins own availability and content; the host supplies the running agent. */
export function useTaskPluginPanels(issue: Issue | null | undefined, enabled = true): TaskPluginPanel[] {
const savedTask = enabled && !!issue && !issue.id.startsWith("chat:");
const connected = useHasCompanyLiveConnection(issue?.companyId);
const { slots, errorMessage } = usePluginSlots({
slotTypes: ["detailTab"], entityType: "agent", taskSidePanel: true,
companyId: issue?.companyId, enabled: savedTask,
});
const { data: runs, isError: runsError } = useQuery({
queryKey: queryKeys.issues.liveRuns(issue?.id ?? "pending"),
queryFn: () => heartbeatsApi.liveRunsForIssue(issue!.id),
enabled: savedTask && slots.length > 0 && !errorMessage,
refetchInterval: query => liveRunRefetchInterval(connected, query.state, 3000),
retry: false,
});
const run = runs?.find(run => run.status === "running"
&& (!issue?.executionRunId || run.id === issue.executionRunId));
const active = savedTask && slots.length > 0 && !!run && !runsError && !errorMessage;
const availability = useQueries({ queries: slots.map(slot => ({
queryKey: ["task-plugin-panel", issue?.companyId, issue?.id, run?.id, run?.agentId, slot.pluginKey, slot.id, slot.pluginVersion],
queryFn: ({ signal }: { signal: AbortSignal }) => api.get<{ available: boolean }>(
`/plugins/${encodeURIComponent(slot.pluginId)}/api${slot.taskSidePanel!.availabilityPath.replace(":entityId", encodeURIComponent(run!.agentId))}`,
{ signal, cache: "no-store" },
),
enabled: active,
refetchInterval: 3000,
retry: false,
})) });
const available = availability.map(result => !result.isError && result.data?.available === true);
const availableKey = available.join(",");
return useMemo(() => active && run ? slots.flatMap((slot, index) => available[index]
? [{ entityId: run.agentId, runId: run.id, slot }] : []) : EMPTY_PANELS,
[active, run?.agentId, run?.id, slots, availableKey]);
}
+14
View File
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import {
readTaskSidePanelState,
taskPanelDocumentTab,
taskPanelPluginTab,
taskPanelFilesTab,
taskPanelPropertiesTab,
taskPanelSkillTab,
@@ -16,6 +17,19 @@ import {
describe("task side-panel persistence", () => {
beforeEach(() => window.localStorage.clear());
it("round-trips a plugin panel identity", () => {
const entityId = "33333333-3333-4333-8333-333333333333";
const panel = { entityId, slot: { pluginKey: "example.live-view", id: "screen", displayName: "Live view" } };
const id = `plugin:example.live-view:screen:${entityId}`;
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [taskPanelPluginTab(panel)], activeTabId: id },
launcherOpen: false, userInteracted: true, autoPlanHandled: true, updatedAt: 1,
});
expect(readTaskSidePanelState("user-1", "company-1", "task-1", true)?.state).toMatchObject({
tabs: [{ payload: { kind: "plugin-panel", pluginKey: "example.live-view", slotId: "screen", entityId } }], activeTabId: id,
});
});
it("round-trips an intentionally empty task state", () => {
writeTaskSidePanelState("user-1", "company-1", "task-1", {
state: { tabs: [], activeTabId: null },
+7
View File
@@ -31,6 +31,7 @@ export function openSkillPanelState(
export type TaskSidePanelTabPayload =
| { kind: "browser"; browserId: string }
| { kind: "plugin-panel"; pluginKey: string; slotId: string; entityId: string }
| { kind: "properties" }
| { kind: "subtasks" }
| { kind: "artifacts" }
@@ -92,6 +93,8 @@ function parsePayload(value: unknown): TaskSidePanelTabPayload | null {
if (!input) return null;
const kind = input.kind;
if (kind === "browser") return typeof input.browserId === "string" && /^[0-9a-f-]{36}$/i.test(input.browserId) ? { kind, browserId: input.browserId } : null;
if (kind === "plugin-panel") return [input.pluginKey, input.slotId, input.entityId].every(value => typeof value === "string" && value.length > 0)
? { kind, pluginKey: input.pluginKey as string, slotId: input.slotId as string, entityId: input.entityId as string } : null;
if (kind === "properties") return { kind };
if (kind === "subtasks") return { kind };
if (kind === "artifacts") return { kind };
@@ -299,3 +302,7 @@ export function taskPanelAttachmentTab(attachmentId: string, title: string): Sid
export function taskPanelBrowserTab(browserId: string): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: `browser:${browserId}`, type: "browser", label: "Browser", closable: true, contentMode: "full-bleed", payload: { kind: "browser", browserId } };
}
export function taskPanelPluginTab(panel: { entityId: string; slot: { pluginKey: string; id: string; displayName: string } }): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: `plugin:${panel.slot.pluginKey}:${panel.slot.id}:${panel.entityId}`, type: "plugin-panel", label: panel.slot.displayName, closable: true, contentMode: "padded", payload: { kind: "plugin-panel", pluginKey: panel.slot.pluginKey, slotId: panel.slot.id, entityId: panel.entityId } };
}
+1
View File
@@ -2604,6 +2604,7 @@ describe("IssueDetail", () => {
);
});
expect(mockSetPanelVisible).not.toHaveBeenCalled();
expect(mockOpenPanel).not.toHaveBeenCalled();
});
it("opens a plan deep link in the shared mobile side-panel sheet", async () => {
+22 -1
View File
@@ -8,6 +8,7 @@ import { WorkspaceRetryControl } from "../components/WorkspaceRetryButton";
import { isLockedIssueStub, LockedIssueChip } from "@/components/LockedIssueChip";
import { canManageIssuePrivacy } from "../lib/issuePrivacy";
import { TextAttachmentContext } from "../context/TextAttachmentContext";
import { useTaskPluginPanels, taskPluginPanelId } from "@/hooks/useTaskPluginPanels";
import { useTaskBrowsers, useBrowserArrivals } from "@/hooks/useTaskBrowsers";
import { WorkspaceExportRecovery } from "../components/WorkspaceExportRecovery";
import { DispositionRecoveryProvider } from "../components/DispositionRecoveryNotice";
@@ -3955,6 +3956,19 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
openTaskSidePanel();
if (isMobile) setMobilePropsOpen(true);
}, [issue?.id, openTaskSidePanel, isMobile]);
const pluginPanels = useTaskPluginPanels(issue, taskChatShellEnabled);
const revealedPluginPanels = useRef(new Set<string>());
const openedPluginPanelKeys = useRef(new Set<string>());
const dismissedPluginPanelKeys = useRef(new Set<string>());
useEffect(() => {
for (const panel of pluginPanels) {
const key = JSON.stringify([currentUserId, issue?.id, panel.runId, taskPluginPanelId(panel)]);
if (revealedPluginPanels.current.has(key)) continue;
revealedPluginPanels.current.add(key);
openTaskSidePanel();
if (isMobile) setMobilePropsOpen(true);
}
}, [pluginPanels, currentUserId, issue?.id, openTaskSidePanel, isMobile]);
const browserQuery = useTaskBrowsers(issue?.id);
const { openBrowserId, openBrowser: setOpenBrowserId, acknowledgeBrowserOpened: handleBrowserOpened } =
useBrowserArrivals(currentUserId ?? "anonymous", issue?.id, browserQuery.data);
@@ -6071,7 +6085,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
);
useLayoutEffect(() => {
if (!panelIssue || suppressPanelUntilPlan || (conversation && !conversation.issue)) {
if (!panelIssue || suppressPanelUntilPlan || (conversation && !conversation.issue) || (taskChatShellEnabled && isMobile)) {
closePanel();
return;
}
@@ -6110,6 +6124,9 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
openPanel(
<IssueGalleryContext.Provider value={openIssueGallery}>
<TaskSidePanel
pluginPanels={pluginPanels}
openedPluginPanelKeys={openedPluginPanelKeys.current}
dismissedPluginPanelKeys={dismissedPluginPanelKeys.current}
openBrowserId={openBrowserId}
onBrowserOpened={handleBrowserOpened}
key={panelIssue.id}
@@ -6135,6 +6152,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
}
return () => closePanel();
}, [
pluginPanels,
openBrowserId,
handleBrowserOpened,
closePanel,
@@ -8686,6 +8704,9 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
<SheetTitle>Task side panel</SheetTitle>
</SheetHeader>
<TaskSidePanel
pluginPanels={pluginPanels}
openedPluginPanelKeys={openedPluginPanelKeys.current}
dismissedPluginPanelKeys={dismissedPluginPanelKeys.current}
openBrowserId={openBrowserId}
onBrowserOpened={handleBrowserOpened}
key={`${issue.id}:mobile`}
+14
View File
@@ -53,3 +53,17 @@ it("settles when a module import started by another consumer fails", async () =>
expect(container.textContent).toBe("Ready");
expect(fetch).toHaveBeenCalledOnce();
});
it("does not load agent tabs that have not opted into task side panels", async () => {
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
client.setQueryData(queryKeys.plugins.uiContributions, [{ ...contribution,
slots: [{ type: "detailTab", entityTypes: ["agent"], id: "details", displayName: "Details", exportName: "Details" }],
}]);
function TaskConsumer() {
const { slots } = usePluginSlots({ slotTypes: ["detailTab"], entityType: "agent", taskSidePanel: true });
return <span>{slots.length}</span>;
}
await act(async () => root.render(<QueryClientProvider client={client}><TaskConsumer /></QueryClientProvider>));
expect(container.textContent).toBe("0");
expect(fetch).not.toHaveBeenCalled();
});
+3 -1
View File
@@ -108,6 +108,7 @@ export type RegisteredPluginComponent =
type SlotFilters = {
slotTypes: PluginUiSlotType[];
taskSidePanel?: boolean;
entityType?: PluginUiSlotEntityType | null;
companyId?: string | null;
enabled?: boolean;
@@ -662,6 +663,7 @@ export function usePluginSlots(filters: SlotFilters): UsePluginSlotsResult {
for (const contribution of data ?? []) {
for (const slot of contribution.slots) {
if (!allowedTypes.has(slot.type)) continue;
if (filters.taskSidePanel && !slot.taskSidePanel) continue;
if (requiresEntityType(slot.type)) {
if (!filters.entityType) continue;
if (!slot.entityTypes?.includes(filters.entityType)) continue;
@@ -684,7 +686,7 @@ export function usePluginSlots(filters: SlotFilters): UsePluginSlotsResult {
return a.displayName.localeCompare(b.displayName);
});
return rows;
}, [data, filters.entityType, slotTypesKey]);
}, [data, filters.entityType, filters.taskSidePanel, slotTypesKey]);
// A replacement surface must not disappear while an unrelated plugin loads.
const contributions = useMemo(() => {