mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
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:
1 parent
718c73dafe
commit
c7c12dbc79
16 files changed
+405
-3
No files matched your search
@@ -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;
|
||||
}>;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user