From c7c12dbc796e6bf025fb3aa8a626f39ad6751ad1 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Mon, 26 Oct 2026 14:47:15 -0700 Subject: [PATCH] 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 --- doc/plugins/PLUGIN_SPEC.md | 4 + maps/feature-map/task-coordination.md | 16 +++ packages/plugins/sdk/README.md | 12 +++ packages/shared/src/types/plugin.ts | 6 ++ packages/shared/src/validators/plugin.test.ts | 22 ++++ packages/shared/src/validators/plugin.ts | 15 +++ .../task-side-panel/TaskSidePanel.test.tsx | 63 +++++++++++ .../task-side-panel/TaskSidePanel.tsx | 51 ++++++++- ui/src/hooks/useTaskPluginPanels.test.tsx | 100 ++++++++++++++++++ ui/src/hooks/useTaskPluginPanels.ts | 56 ++++++++++ ui/src/lib/task-side-panel-state.test.ts | 14 +++ ui/src/lib/task-side-panel-state.ts | 7 ++ ui/src/pages/IssueDetail.test.tsx | 1 + ui/src/pages/IssueDetail.tsx | 23 +++- ui/src/plugins/slots-loading.test.tsx | 14 +++ ui/src/plugins/slots.tsx | 4 +- 16 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 ui/src/hooks/useTaskPluginPanels.test.tsx create mode 100644 ui/src/hooks/useTaskPluginPanels.ts diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 539b87d51d3..d04a724c385 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -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; }>; diff --git a/maps/feature-map/task-coordination.md b/maps/feature-map/task-coordination.md index 67f58c06244..7220b51e6cc 100644 --- a/maps/feature-map/task-coordination.md +++ b/maps/feature-map/task-coordination.md @@ -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. diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 5db1363d7d7..b571dda2f52 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -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. diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index efe36ab31b3..57585869767 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -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. diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 5876c61d074..8b8136c0b09 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -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); + }); +}); diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 92a827afcb3..94b3c05fe0b 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -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"] }); } diff --git a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx index 2e2a9f61cd6..d249615304a 100644 --- a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx +++ b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx @@ -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 } }) =>
Plugin viewer {context.entityId}
, +})); +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('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('#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(); + const dismissedPluginPanelKeys = new Set(); + await render(panel({ pluginPanels: [pluginPanel], openedPluginPanelKeys, dismissedPluginPanelKeys, mobile: true })); + await act(async () => container.querySelector('button[aria-label^="Switch tabs"]')?.click()); + const close = document.querySelector('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("[data-artifact-upload-slot]"); diff --git a/ui/src/components/task-side-panel/TaskSidePanel.tsx b/ui/src/components/task-side-panel/TaskSidePanel.tsx index 5f7f738ac2c..9774c1fbe1e 100644 --- a/ui/src/components/task-side-panel/TaskSidePanel.tsx +++ b/ui/src/components/task-side-panel/TaskSidePanel.tsx @@ -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; + dismissedPluginPanelKeys?: Set; 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): ReactNode { switch (tab.payload.kind) { case "browser": return ; + case "plugin-panel": return ; case "properties": return ; case "subtasks": return ; case "artifacts": return ; @@ -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({ initialState, onStateChange: persist }); + const localOpenedPluginPanels = useRef(new Set()); + const openedPluginPanels = openedPluginPanelKeys ?? localOpenedPluginPanels.current; + const localDismissedPluginPanels = useRef(new Set()); + 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: , 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: , alreadyOpen: controller.tabs.some(tab => tab.id === taskPluginPanelId(panel)) }); if (fileTabsEnabled) { primary.push({ id: "files", label: "Files", icon: , 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 = ; } 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 ? ( + + ) : null; } else if (activeTab.payload.kind === "properties") { content = ( ({ 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; +let list: MockInstance; +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()); + 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()); +}); diff --git a/ui/src/hooks/useTaskPluginPanels.ts b/ui/src/hooks/useTaskPluginPanels.ts new file mode 100644 index 00000000000..77a43e39887 --- /dev/null +++ b/ui/src/hooks/useTaskPluginPanels.ts @@ -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]); +} diff --git a/ui/src/lib/task-side-panel-state.test.ts b/ui/src/lib/task-side-panel-state.test.ts index 7cc2b4eb8ea..7a19a8cc75b 100644 --- a/ui/src/lib/task-side-panel-state.test.ts +++ b/ui/src/lib/task-side-panel-state.test.ts @@ -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 }, diff --git a/ui/src/lib/task-side-panel-state.ts b/ui/src/lib/task-side-panel-state.ts index 9a7b2217546..203d99c8e66 100644 --- a/ui/src/lib/task-side-panel-state.ts +++ b/ui/src/lib/task-side-panel-state.ts @@ -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 { 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 { + 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 } }; +} diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 3d3726cc97f..335a6e15db4 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -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 () => { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 33d0cf9d6cb..6e79c863101 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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()); + const openedPluginPanelKeys = useRef(new Set()); + const dismissedPluginPanelKeys = useRef(new Set()); + 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( closePanel(); }, [ + pluginPanels, openBrowserId, handleBrowserOpened, closePanel, @@ -8686,6 +8704,9 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS Task side panel 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 {slots.length}; + } + await act(async () => root.render()); + expect(container.textContent).toBe("0"); + expect(fetch).not.toHaveBeenCalled(); +}); diff --git a/ui/src/plugins/slots.tsx b/ui/src/plugins/slots.tsx index 5ea4d700b6f..689f033b591 100644 --- a/ui/src/plugins/slots.tsx +++ b/ui/src/plugins/slots.tsx @@ -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(() => {