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