diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md
index 9e5c8370823..af3b060021b 100644
--- a/doc/plugins/PLUGIN_SPEC.md
+++ b/doc/plugins/PLUGIN_SPEC.md
@@ -409,12 +409,12 @@ 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 }.
+ /** Optional agent detail tab offered beside a task for manual selection.
+ * Declared board GET route scoped to agent :entityId controls readiness for automatic opening.
*/
taskSidePanel?: {
availabilityPath: string;
- /** Match runtime.activity.recorded; absent mappings add no task panel. */
+ /** Match runtime.activity.recorded for automatic opening; manual views need no mapping. */
activation?: { namespace: string; activityKey: string };
};
/** For page and companySettingsPage: single route segment */
diff --git a/maps/feature-map/task-coordination.md b/maps/feature-map/task-coordination.md
index b7ef92dbf7f..950219c3292 100644
--- a/maps/feature-map/task-coordination.md
+++ b/maps/feature-map/task-coordination.md
@@ -59,17 +59,19 @@ 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 and run. The runtime captures Runner context before use and records
-`runtime.activity.recorded` after success. Runner rejects stale contexts and knows
-no plugin or view identity. Core maps the recorded namespace/activity key to the
-panel's declared activation pair and restores it from the run log on reload or
-reconnect. Readiness gets three bounded attempts after matching activity, with
-no periodic availability poll. Declarations without a mapping add no task panel.
-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.
+its optional `taskSidePanel` declaration. The launcher offers that view for the
+running agent, or the task's assigned agent when idle. Manual selection mounts
+the same authorized viewer used on the agent page and needs no activity event.
+The view remains selectable after the run ends.
+
+A declared activation pair controls automatic opening. The runtime captures
+Runner context before use and records `runtime.activity.recorded` after success.
+Core maps that namespace/activity key to the declaration and restores it from
+the run log on reload or reconnect. After matching activity, readiness gets
+three bounded attempts with no periodic availability poll. A ready view opens
+the pane and selects its tab once per run, respecting dismissal during that run.
+Declarations without a mapping support manual selection. Plugins own viewer
+content, readiness, and authorization. Missing or disabled plugins add no view.
Implementation: [panel discovery](../../ui/src/hooks/useTaskPluginPanels.ts) and
[task panel](../../ui/src/components/task-side-panel/TaskSidePanel.tsx).
diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md
index 378b90481ff..18d521e793c 100644
--- a/packages/plugins/sdk/README.md
+++ b/packages/plugins/sdk/README.md
@@ -259,8 +259,10 @@ 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. Declare a mapping to a
-runtime activity independently of the plugin and slot identifiers:
+Agent detail tabs can also appear in task side panels. `taskSidePanel` makes the
+view available for manual selection with the running or assigned agent. Manual
+selection mounts the same component as the agent detail page, including while
+the task is idle. An optional activity mapping also opens it automatically:
```ts
taskSidePanel: {
@@ -272,8 +274,8 @@ taskSidePanel: {
Declare the exact availability path as a board-only GET API route with
`companyResolution: { from: "agent", param: "entityId" }`. Core maps the declared
namespace/activity pair to installed agent detail tabs. Runner knows no plugin
-or UI slot. Declarations without `activation` remain valid for agent detail pages
-and contribute no task panel. Activity is a reported fact, not an authorization grant.
+or UI slot. Declarations without `activation` support manual task-panel selection.
+Activity controls automatic opening and grants no access permission.
#### Runtime activity recording
@@ -317,13 +319,16 @@ Core reads durable activity on mount/reconnect and uses the existing live stream
The authorized run binding identifies company, task and agent; caller payloads
cannot override them. Core replaces `:entityId` with the running agent ID, adds
`runId` and `issueId`, and requires `{ available: true }`. History/live hints merge
-without losing earlier activity. Only matching current-task activity contributes.
+without losing earlier activity. Only matching current-task activity can open a
+panel automatically.
Readiness and transient discovery failures get at most three attempts; authorization
failures are not retried. No periodic readiness polling is added. Dismissal stays
scoped to the current run. Only tab identity is stored in browser state; activity
-remains in the authorized run log. Missing/disabled plugins, failed history reads,
-unavailable views and access failures contribute no task tab. The plugin owns
-labels, content and controls. No private integration identifier belongs in Core.
+remains in the authorized run log. Failed history reads, unavailable views and
+access failures prevent automatic opening. Manual selection uses the viewer's
+own authorization and status handling. Missing or disabled plugins add no view.
+The plugin owns labels, content and controls. No private integration identifier
+belongs in Core.
#### `taskDetailView`
diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts
index b78a1e9c83a..59001b3ffea 100644
--- a/packages/shared/src/types/plugin.ts
+++ b/packages/shared/src/types/plugin.ts
@@ -499,15 +499,16 @@ 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.
- * The run must record the declared runtime activity before a task panel appears.
+ /** Also offer this agent detail tab for manual selection beside a task.
+ * It uses the running agent, or the assigned agent while idle.
+ * Matching run activity and readiness also open the panel automatically.
* GET this plugin-relative path with :entityId replaced by the agent ID,
* and runId/issueId query parameters for the active task.
- * The board/agent-scoped route returns { available: boolean }.
+ * The board-only, agent-scoped route returns { available: boolean }.
*/
taskSidePanel?: {
availabilityPath: string;
- /** Opaque runtime activity mapped to this view. Absent mappings add no task panel. */
+ /** Runtime activity that requests automatic opening; manual selection needs no mapping. */
activation?: { namespace: string; activityKey: string };
};
diff --git a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx
index 10d3caac361..0e9fe399d49 100644
--- a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx
+++ b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx
@@ -11,6 +11,7 @@ import {
taskPanelArtifactsTab,
taskPanelDocumentTab,
taskPanelPropertiesTab,
+ taskPanelPluginTab,
readTaskSidePanelState,
writeTaskSidePanelState,
} from "@/lib/task-side-panel-state";
@@ -28,12 +29,9 @@ 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}
,
+ 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 pluginPanel = { entityId: "33333333-3333-4333-8333-333333333333", runId: "run-1", autoOpen: true, 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 })) } }));
@@ -194,6 +192,50 @@ describe("TaskSidePanel", () => {
);
}
+ it("offers idle plugin views in the launcher without opening them automatically", async () => {
+ await render(panel({ pluginPanels: [{ ...pluginPanel, runId: undefined, autoOpen: false }] }));
+ expect(container.textContent).not.toContain("Plugin viewer");
+ expect(container.querySelector('[data-side-panel-tab-target^="plugin:"]')).toBeNull();
+ await act(async () => container.querySelector('button[aria-label="Open a new tab"]')?.click());
+ const item = Array.from(document.body.querySelectorAll('[role="option"]'))
+ .find(item => item.textContent?.includes("Live view"));
+ expect(item).not.toBeUndefined();
+ await act(async () => item!.click());
+ expect(container.textContent).toContain(`Plugin viewer ${pluginPanel.entityId}`);
+ });
+
+ it.each([false, true])("restores a saved manual view after discovery (different assignee=%s)", async differentAssignee => {
+ const tab = taskPanelPluginTab(pluginPanel);
+ writeTaskSidePanelState("user-1", "company-1", "task-1", {
+ state: { tabs: [tab], activeTabId: tab.id },
+ launcherOpen: false, userInteracted: true, autoPlanHandled: false, updatedAt: 1,
+ });
+ await render(panel({ pluginPanels: differentAssignee
+ ? [{ ...pluginPanel, entityId: "assigned-agent", runId: undefined, autoOpen: false }] : [], pluginPanelsLoading: true }));
+ expect(container.querySelector('[data-side-panel-tab-target^="plugin:"]')).not.toBeNull();
+ expect(container.textContent).not.toContain("Plugin viewer");
+ await render(panel({ pluginPanels: [{ ...pluginPanel, runId: undefined, autoOpen: false }] }));
+ expect(container.textContent).toContain(`Plugin viewer ${pluginPanel.entityId}`);
+ await render(panel({ pluginPanels: [] }));
+ expect(container.querySelector('[data-side-panel-tab-target^="plugin:"]')).toBeNull();
+ });
+
+ it("focuses an already open manual panel when activity starts and keeps it mounted after the run", async () => {
+ const manual = { ...pluginPanel, autoOpen: false };
+ await render(panel({ pluginPanels: [manual] }));
+ await act(async () => container.querySelector('button[aria-label="Open a new tab"]')?.click());
+ const item = Array.from(document.body.querySelectorAll('[role="option"]'))
+ .find(item => item.textContent?.includes("Live view"));
+ await act(async () => item!.click());
+ await act(async () => container.querySelector('#side-panel-tab-properties')?.click());
+ await render(panel({ pluginPanels: [pluginPanel] }));
+ expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Live view");
+ const viewer = container.querySelector("[data-plugin-viewer]");
+ expect(viewer).not.toBeNull();
+ await render(panel({ pluginPanels: [{ ...manual, runId: undefined }] }));
+ expect(container.querySelector("[data-plugin-viewer]")).toBe(viewer);
+ });
+
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");
diff --git a/ui/src/components/task-side-panel/TaskSidePanel.tsx b/ui/src/components/task-side-panel/TaskSidePanel.tsx
index 372b82cb6a1..ae370762feb 100644
--- a/ui/src/components/task-side-panel/TaskSidePanel.tsx
+++ b/ui/src/components/task-side-panel/TaskSidePanel.tsx
@@ -3,7 +3,7 @@ import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceE
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { TaskAttachmentPanel } from "./TaskAttachmentPanel";
import { useTaskBrowsers } from "@/hooks/useTaskBrowsers";
-import { taskPluginPanelId, type TaskPluginPanel } from "@/hooks/useTaskPluginPanels";
+import { taskPluginPanelActivationKey, taskPluginPanelId, type TaskPluginPanel } from "@/hooks/useTaskPluginPanels";
import { PluginSlotMount } from "@/plugins/slots";
import { TaskBrowserPanel } from "./TaskBrowserPanel";
import {
@@ -102,6 +102,7 @@ import { TaskSkillPanel } from "./TaskSkillPanel";
export interface TaskSidePanelProps {
issue: Issue;
pluginPanels?: TaskPluginPanel[];
+ pluginPanelsLoading?: boolean;
openedPluginPanelKeys?: Set;
dismissedPluginPanelKeys?: Set;
openBrowserId?: string | null;
@@ -258,6 +259,7 @@ export function TaskSidePanel(props: TaskSidePanelProps) {
function TaskSidePanelForScope({
pluginPanels = EMPTY_PLUGIN_PANELS,
+ pluginPanelsLoading = false,
openedPluginPanelKeys,
dismissedPluginPanelKeys,
openBrowserId,
@@ -371,22 +373,23 @@ function TaskSidePanelForScope({
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]);
+ const pluginPanelKey = useCallback((panel: TaskPluginPanel) => taskPluginPanelActivationKey(panel, accountScope, issue), [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);
+ if (!pluginPanelsLoading && tab.payload.kind === "plugin-panel" && !ids.has(tab.id)) controller.closeTab(tab.id);
}
for (const panel of pluginPanels) {
+ if (!panel.autoOpen || !panel.runId) continue;
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 && 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]);
+ }, [dismissedPluginPanels, openedPluginPanels, pluginPanelKey, pluginPanels, pluginPanelsLoading, controller.tabs, controller.closeTab, controller.openTab]);
const browsersQuery = useTaskBrowsers(issue.id);
const newestLiveBrowser = browsersQuery.data?.findLast((browser) => browser.status === "running" || browser.status === "idle");
useEffect(() => {
@@ -799,7 +802,7 @@ function TaskSidePanelForScope({
} 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 = (
diff --git a/ui/src/hooks/useTaskPluginPanels.test.tsx b/ui/src/hooks/useTaskPluginPanels.test.tsx
index 9e7e4212512..c013137a057 100644
--- a/ui/src/hooks/useTaskPluginPanels.test.tsx
+++ b/ui/src/hooks/useTaskPluginPanels.test.tsx
@@ -13,20 +13,27 @@ import type { ResolvedPluginSlot } from "@/plugins/slots";
import { useTaskPluginPanels, type TaskPluginPanel } from "./useTaskPluginPanels";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
-const plugin = vi.hoisted(() => ({ connected: true, slots: [] as ResolvedPluginSlot[], healthy: true, epoch: 0, handler: null as ((event: LiveEvent) => void) | null, errorMessage: null as string | null }));
+const plugin = vi.hoisted(() => ({ connected: true, slots: [] as ResolvedPluginSlot[], healthy: true, epoch: 0, handler: null as ((event: LiveEvent) => void) | null, errorMessage: null as string | null, isLoading: false }));
vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => plugin }));
vi.mock("@/context/LiveUpdatesProvider", () => ({ useTaskRunLiveUpdates: vi.fn(() => plugin.connected), useTranscriptLiveStream: (_ids: string[], _options: unknown, handler: (event: LiveEvent) => void) => { plugin.handler = handler; return { healthy: plugin.healthy, epoch: plugin.epoch }; } }));
const slot = { pluginId: "example-plugin", pluginKey: "example.live-view", id: "screen", pluginVersion: "1.0.0", taskSidePanel: { availabilityPath: "/agents/:entityId/view/availability", activation: { namespace: "example.runtime", activityKey: "desktop.used" } } } as ResolvedPluginSlot;
const issue = { id: "task", companyId: "company", executionRunId: "run", assigneeAgentId: "agent" } as Issue;
const run = { id: "run", agentId: "agent", status: "running" } as LiveRunForIssue;
let root: Root, container: HTMLDivElement, client: QueryClient;
-let current: TaskPluginPanel[];
+let panels: TaskPluginPanel[];
+let loading: boolean;
+let current: Omit[];
let task: Issue | null;
let enabled: boolean;
let get: MockInstance;
let list: MockInstance;
let history: MockInstance;
-function Harness() { current = useTaskPluginPanels(task, enabled); return null; }
+function Harness() {
+ const discovery = useTaskPluginPanels(task, enabled);
+ panels = discovery.panels; loading = discovery.isLoading;
+ current = panels.filter(panel => panel.autoOpen).map(({ autoOpen: _autoOpen, ...panel }) => panel);
+ return null;
+}
async function render(next: Issue | null = issue, active = true) {
task = next; enabled = active;
await act(async () => root.render());
@@ -37,7 +44,7 @@ async function refresh() {
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; plugin.healthy = false; plugin.epoch = 0; plugin.handler = null;
+ plugin.slots = [slot]; plugin.isLoading = false; plugin.errorMessage = null; plugin.connected = true; plugin.healthy = false; plugin.epoch = 0; plugin.handler = null;
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
get = vi.spyOn(api, "get").mockResolvedValue({ available: true });
@@ -47,6 +54,70 @@ beforeEach(() => {
afterEach(async () => {
await act(async () => root.unmount()); client.clear(); container.remove(); vi.restoreAllMocks(); vi.useRealTimers();
});
+it("keeps restored views pending until plugin discovery settles", async () => {
+ plugin.slots = []; plugin.isLoading = true;
+ await render(); expect(loading).toBe(true); expect(panels).toEqual([]);
+ plugin.slots = [slot]; plugin.isLoading = false; list.mockResolvedValue([]);
+ await render(); expect(loading).toBe(false);
+ expect(panels).toEqual([{ entityId: "agent", autoOpen: false, slot }]);
+});
+
+it.each(["assigned-agent", null])("waits for the running agent before settling saved views (assignee=%s)", async assigneeAgentId => {
+ const manual = { ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } };
+ plugin.slots = [manual];
+ let resolve!: (value: LiveRunForIssue[]) => void;
+ list.mockReturnValueOnce(new Promise(done => { resolve = done; }));
+ await render({ ...issue, assigneeAgentId });
+ expect(loading).toBe(true);
+ await act(async () => { resolve([{ ...run, agentId: "running-agent" }]); });
+ await render({ ...issue, assigneeAgentId });
+ expect(loading).toBe(false);
+ expect(panels).toEqual([{ entityId: "running-agent", runId: "run", autoOpen: false, slot: manual }]);
+});
+
+it("offers manual access while idle without reading activation history or readiness", async () => {
+ list.mockResolvedValue([]);
+ history.mockResolvedValue([]);
+ await render();
+ expect(panels).toEqual([{ entityId: "agent", autoOpen: false, slot }]);
+ expect(history).not.toHaveBeenCalled();
+ expect(get).not.toHaveBeenCalled();
+});
+
+it("offers the assigned agent's view before activity and after run completion", async () => {
+ history.mockResolvedValue([]);
+ await render();
+ expect(panels).toEqual([{ entityId: "agent", runId: "run", autoOpen: false, slot }]);
+ expect(get).not.toHaveBeenCalled();
+ await act(async () => { client.setQueryData(queryKeys.issues.liveRuns("task"), []); });
+ await render();
+ expect(panels).toEqual([{ entityId: "agent", autoOpen: false, slot }]);
+});
+
+it("offers declared manual views without an activation mapping", async () => {
+ const manual = { ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } };
+ plugin.slots = [manual];
+ await render();
+ expect(panels).toEqual([{ entityId: "agent", runId: "run", autoOpen: false, slot: manual }]);
+ expect(list).toHaveBeenCalledWith("task");
+ expect(history).not.toHaveBeenCalled();
+ expect(get).not.toHaveBeenCalled();
+});
+
+it("uses the running agent for manual views without an activity mapping", async () => {
+ const manual = { ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } };
+ plugin.slots = [manual]; list.mockResolvedValue([{ ...run, agentId: "running-agent" }]);
+ await render();
+ expect(panels).toEqual([{ entityId: "running-agent", runId: "run", autoOpen: false, slot: manual }]);
+ expect(history).not.toHaveBeenCalled(); expect(get).not.toHaveBeenCalled();
+});
+
+it("has no manual agent view for an unassigned idle task", async () => {
+ list.mockResolvedValue([]);
+ await render({ ...issue, assigneeAgentId: null });
+ expect(panels).toEqual([]);
+});
+
it("uses the running agent's authorized status and existing plugin slot", async () => {
await render();
expect(list).toHaveBeenCalledWith("task");
@@ -60,7 +131,7 @@ it.each(["absent", "disabled", "draft", "no-task", "plugin-error"])("does no pan
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();
+ expect(current).toEqual([]); expect(panels).toEqual([]); expect(get).not.toHaveBeenCalled(); expect(list).not.toHaveBeenCalled();
});
it.each(["queued", "succeeded", "another-run"])("ignores a %s run", async kind => {
list.mockResolvedValue([{ ...run, status: kind === "another-run" ? "running" : kind, id: kind === "another-run" ? "other" : "run" }]);
@@ -71,7 +142,7 @@ it("discovers a panel started after the task is already open, then hides it on s
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 () => {
+it("clears automatic activation when the run ends even if its view is still ready", async () => {
await render(); expect(current).not.toEqual([]);
await act(async () => { client.setQueryData(queryKeys.issues.liveRuns("task"), []); });
await render(); expect(current).toEqual([]);
@@ -236,10 +307,10 @@ it.each([500, 403])("bounds failed discovery reads for HTTP %s", async status =>
expect(current).toEqual([]);
});
-it("does not discover a task view without a declared activity mapping", async () => {
+it("does not automatically open a task view without a declared activity mapping", async () => {
plugin.slots = [{ ...slot, taskSidePanel: { availabilityPath: slot.taskSidePanel!.availabilityPath } }];
await render();
- expect(list).not.toHaveBeenCalled(); expect(history).not.toHaveBeenCalled();
+ expect(list).toHaveBeenCalledWith("task"); expect(history).not.toHaveBeenCalled();
expect(get).not.toHaveBeenCalled(); expect(current).toEqual([]);
});
diff --git a/ui/src/hooks/useTaskPluginPanels.ts b/ui/src/hooks/useTaskPluginPanels.ts
index 12c84a73254..2d022669b6c 100644
--- a/ui/src/hooks/useTaskPluginPanels.ts
+++ b/ui/src/hooks/useTaskPluginPanels.ts
@@ -11,7 +11,8 @@ import { usePluginSlots, type ResolvedPluginSlot } from "@/plugins/slots";
export interface TaskPluginPanel {
entityId: string;
- runId: string;
+ runId?: string;
+ autoOpen: boolean;
slot: ResolvedPluginSlot;
}
@@ -21,23 +22,28 @@ 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[] {
+export function taskPluginPanelActivationKey(panel: TaskPluginPanel, accountScope: string, issue: Pick) {
+ return JSON.stringify([accountScope, issue.companyId, issue.id, panel.runId, taskPluginPanelId(panel)]);
+}
+
+/** Plugins own their views; activity controls automatic opening. */
+export function useTaskPluginPanels(issue: Issue | null | undefined, enabled = true): { panels: TaskPluginPanel[]; isLoading: boolean } {
const queryClient = useQueryClient();
const savedTask = enabled && !!issue && !issue.id.startsWith("chat:");
- const { slots: declaredSlots, errorMessage } = usePluginSlots({
+ const { slots: declaredSlots, errorMessage, isLoading } = usePluginSlots({
slotTypes: ["detailTab"], entityType: "agent", taskSidePanel: true,
companyId: issue?.companyId, enabled: savedTask,
});
- const slots = useMemo(() => declaredSlots.filter(slot => slot.taskSidePanel?.activation), [declaredSlots]);
+ const panels = useMemo(() => declaredSlots.filter(slot => slot.taskSidePanel), [declaredSlots]);
+ const slots = useMemo(() => panels.filter(slot => slot.taskSidePanel?.activation), [panels]);
const connected = useTaskRunLiveUpdates({
- companyId: savedTask && slots.length > 0 && !errorMessage ? issue?.companyId : null,
+ companyId: savedTask && panels.length > 0 && !errorMessage ? issue?.companyId : null,
issueId: issue?.id, issueQueryRef: issue?.id ?? "pending", assigneeAgentId: issue?.assigneeAgentId,
});
- const { data: runs, isError: runsError } = useQuery({
+ const { data: runs, isError: runsError, isPending: runsPending } = useQuery({
queryKey: queryKeys.issues.liveRuns(issue?.id ?? "pending"),
queryFn: () => heartbeatsApi.liveRunsForIssue(issue!.id),
- enabled: savedTask && slots.length > 0 && !errorMessage,
+ enabled: savedTask && panels.length > 0 && !errorMessage,
refetchInterval: connected ? false : 3000,
retry: (failures: number, error: Error) => failures < 2 && !(error instanceof ApiError && error.status >= 400 && error.status < 500),
retryDelay: 1000,
@@ -125,7 +131,14 @@ export function useTaskPluginPanels(issue: Issue | null | undefined, enabled = t
const available = availability.map((result, index) => !activationsError && !result.isError && result.data?.available === true
&& activations.some(panel => panel.pluginKey === slots[index]?.pluginKey && panel.slotId === slots[index]?.id));
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]);
+ const agentId = (!runsError && run?.agentId) || issue?.assigneeAgentId;
+ const manualPanels = useMemo(() => savedTask && !errorMessage && agentId ? panels.map(slot => ({
+ entityId: agentId,
+ ...(!runsError && run ? { runId: run.id } : {}),
+ autoOpen: active && available[slots.indexOf(slot)] === true,
+ slot,
+ })) : EMPTY_PANELS,
+ [savedTask, errorMessage, agentId, panels, run?.id, runsError, active, slots, availableKey]);
+ return { panels: manualPanels, isLoading: savedTask && !errorMessage
+ && (isLoading || (panels.length > 0 && runsPending)) };
}
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx
index 7524981a7b0..f25e0a61685 100644
--- a/ui/src/pages/IssueDetail.test.tsx
+++ b/ui/src/pages/IssueDetail.test.tsx
@@ -38,6 +38,7 @@ import {
shouldScrollIssueDetailToTopOnNavigation,
} from "./IssueDetail";
import { queryKeys } from "../lib/queryKeys";
+import { taskPluginPanelActivationKey } from "@/hooks/useTaskPluginPanels";
import {
armIssueDetailInboxQuickArchive,
createIssueDetailLocationState,
@@ -162,6 +163,11 @@ const mockRouteParams = vi.hoisted(() => ({
const mockSidebarState = vi.hoisted(() => ({ isMobile: false }));
const mockIssuePropertiesRender = vi.hoisted(() => vi.fn());
const mockTaskSidePanelRender = vi.hoisted(() => vi.fn());
+const mockTaskPluginPanels = vi.hoisted(() => ({ panels: [] as import("@/hooks/useTaskPluginPanels").TaskPluginPanel[], isLoading: false }));
+vi.mock("@/hooks/useTaskPluginPanels", async importOriginal => ({
+ ...await importOriginal(),
+ useTaskPluginPanels: () => mockTaskPluginPanels,
+}));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbToolbar = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbPanelControl = vi.hoisted(() => vi.fn());
@@ -1334,6 +1340,7 @@ describe("IssueDetail", () => {
beforeEach(() => {
Object.assign(coordinatorPreference, { enabled: false, loaded: true });
+ mockTaskPluginPanels.panels = []; mockTaskPluginPanels.isLoading = false;
localStorage.clear();
sessionStorage.clear();
mockPanelState.panelVisible = true;
@@ -2443,6 +2450,36 @@ describe("IssueDetail", () => {
});
});
+ it.each([
+ { coordinator: false, mobile: false }, { coordinator: false, mobile: true },
+ { coordinator: true, mobile: false }, { coordinator: true, mobile: true },
+ ])("keeps dismissed plugin panes closed (coordinator=$coordinator, mobile=$mobile)", async ({ coordinator, mobile }) => {
+ coordinatorPreference.enabled = coordinator;
+ mockAuthApi.getSession.mockResolvedValue({ session: { userId: "user-1" }, user: { id: "user-1" } });
+ const task = createIssue();
+ mockIssuesApi.get.mockResolvedValue(task);
+ const manual = { entityId: "agent-1", runId: "run-1", autoOpen: false,
+ slot: { pluginKey: "example.view", id: "screen", displayName: "Live view" } } as import("@/hooks/useTaskPluginPanels").TaskPluginPanel;
+ mockTaskPluginPanels.panels = [manual];
+ await act(async () => { renderIssueView(); });
+ await waitForAssertion(() => expect(mockOpenPanel).toHaveBeenCalled());
+ const props = mockOpenPanel.mock.calls.at(-1)![0]?.props.children?.props as {
+ accountScope: string; dismissedPluginPanelKeys: Set;
+ };
+ props.dismissedPluginPanelKeys.add(taskPluginPanelActivationKey(manual, props.accountScope, task));
+ mockPanelState.panelVisible = false;
+ mockSidebarState.isMobile = mobile;
+ mockTaskPluginPanels.panels = [{ ...manual, autoOpen: true }];
+ mockSetPanelVisible.mockClear();
+ await act(async () => { renderIssueView(); }); await flushReact();
+ expect(mockSetPanelVisible).not.toHaveBeenCalledWith(true);
+ expect(container.querySelector('[data-testid="mobile-task-side-panel"]')).toBeNull();
+ mockTaskPluginPanels.panels = [{ ...manual, runId: "run-2", autoOpen: true }];
+ await act(async () => { renderIssueView(); }); await flushReact();
+ expect(mockSetPanelVisible).toHaveBeenCalledWith(true);
+ if (mobile) expect(container.querySelector('[data-testid="mobile-task-side-panel"]')).not.toBeNull();
+ });
+
it("does not register a redundant breadcrumb side-panel toggle in Streamlined UI", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockPanelState.panelVisible = false;
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 81b27703e85..216c051efcd 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -16,7 +16,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 { useTaskPluginPanels, taskPluginPanelActivationKey } from "@/hooks/useTaskPluginPanels";
import { useTaskBrowsers, useBrowserArrivals } from "@/hooks/useTaskBrowsers";
import { WorkspaceExportRecovery } from "../components/WorkspaceExportRecovery";
import { DispositionRecoveryProvider } from "../components/DispositionRecoveryNotice";
@@ -4094,19 +4094,22 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const timeout = window.setTimeout(() => observer.disconnect(), 10_000);
return () => { observer.disconnect(); window.clearTimeout(timeout); };
}, [coordinatorWorkspaceEnabled, issue?.id, location.hash, location.key]);
- const pluginPanels = useTaskPluginPanels(issue, taskChatShellEnabled);
+ const taskPanelAccountScope = coordinatorWorkspaceEnabled ? `coordinator:${currentUserId ?? "anonymous"}` : currentUserId ?? "anonymous";
+ const { panels: pluginPanels, isLoading: pluginPanelsLoading } = 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 (!panel.autoOpen || !panel.runId) continue;
+ const key = taskPluginPanelActivationKey(panel, taskPanelAccountScope, issue!);
if (revealedPluginPanels.current.has(key)) continue;
revealedPluginPanels.current.add(key);
+ if (dismissedPluginPanelKeys.current.has(key)) continue;
openTaskSidePanel();
if (isMobile) setMobilePropsOpen(true);
}
- }, [pluginPanels, currentUserId, issue?.id, openTaskSidePanel, isMobile]);
+ }, [pluginPanels, taskPanelAccountScope, issue?.companyId, issue?.id, openTaskSidePanel, isMobile]);
const browserQuery = useTaskBrowsers(issue?.id);
const { openBrowserId, openBrowser: setOpenBrowserId, acknowledgeBrowserOpened: handleBrowserOpened } =
useBrowserArrivals(currentUserId ?? "anonymous", issue?.id, browserQuery.data);
@@ -6233,13 +6236,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
closePanel();
}, [
pluginPanels,
+ pluginPanelsLoading,
+ taskPanelAccountScope,
openBrowserId,
handleBrowserOpened,
closePanel,
@@ -8855,13 +8861,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS