From b056026407c358410f90de65c2568eb5534df0ba Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Fri, 30 Oct 2026 13:44:41 -0700 Subject: [PATCH] fix(ui): keep task plugin views available for manual access (#18363) ## Thinking Path > - Paperclip helps people manage agents and their tasks. > - The task side panel can display a plugin's agent view. > - Users need that view even while a task is idle. > - Activity currently controls both manual discovery and automatic opening. > - This pull request keeps manual access available and uses activity to request automatic opening. > - The same authorized agent viewer serves both entry points. ## Linked Issues or Issue Description **What happened?** A task view disappeared when its run ended. Users could not select it until the current run reported matching activity and its readiness check passed. This also excluded declared manual views without an activity mapping. **Expected behavior** The task launcher offers declared views for its running or assigned agent. Selecting one uses the same viewer as the agent detail page. Matching activity and readiness open the pane and select the view by default, once per run. **Steps to reproduce** Open an idle task assigned to an agent with an installed task-panel contribution. Open the panel launcher. The view is currently missing. Related work: #18131 and #18239. ## What Changed - Return manual panel declarations independently of run activity and readiness. - Mark panels that should open automatically after matching activity and readiness. Keep existing run attribution, retry bounds, and dismissal behavior. - Keep an open viewer mounted when a run ends. Focus an already open manual view when activity starts. - Preserve saved manual tabs while plugin and initial run discovery load; remove contributions after discovery confirms they are absent. - Share the run-scoped dismissal key between the task page and panel controller so dismissed views do not reveal an empty pane. - Use the current running agent for declared manual views that have no activity mapping. - Update the plugin authoring contract and feature recipe. ## Verification - Two new regressions failed before the fix: idle discovery returned no view, and a manual contribution opened automatically. - 105 focused hook and panel tests pass. They cover idle/manual access, declarations without mappings, completion, saved-tab restoration, automatic focus, activity attribution, retry bounds, dismissal, and missing/disabled plugins. - Thirteen task-page panel integration tests pass. New regressions reproduce and fix a dismissed manual view revealing the outer pane. They cover desktop/mobile and both workspace modes, and verify that a later run can still open the view automatically. - `node scripts/check-token-gates.mjs`, `node scripts/check-maps.mjs`, and `git diff --check` pass. - Local UI typecheck failed with the reused dependency cache: workspace modules/exports and generated Storybook catalog files are missing or stale. It reported no errors in the changed files. The final head passed the clean GitHub typecheck. No broad local build or repository test run was attempted. - The preceding head passed [GitHub CI](https://github.com/paperclipai/paperclip/actions/runs/41116028688), with 48 jobs succeeding and one skipped. Its final review scored 5/5 and identified a narrow saved-view restoration race. This revision adds the missing initial-run loading guard. Two delayed-response regressions failed before the correction and now pass; they cover a different assignee and an unassigned task. - The final head `bbde3a28f7ac0390da8f3fe81eb9abd2f3020695` passed [GitHub CI](https://github.com/paperclipai/paperclip/actions/runs/41119772438) and the additional [full CI run](https://github.com/paperclipai/paperclip/actions/runs/41121827023), each with 48 successful jobs and one skipped. Standard Greptile scored 5/5 on this head; both review threads are resolved. The small final loading correction used the permitted exception to another final-review cycle. - The additional full CI run initially failed the canary bundle install because npm advertised a newly published `tslib@2.9.1` archive before its tarball was available (HTTP 404). After the archive became available, one retry of the failed canary and dependent aggregate passed. No dependency or lockfile change was needed. - Hosted acceptance of this follow-up requires merge and deployment; the earlier hosted activation checks cover the previously merged behavior. ## Risks Manual views can now open while idle or before readiness. Each viewer retains its normal authorization and status handling. Declarations without an activity mapping now appear in the launcher. Automatic opening still requires authenticated activity and the authorized readiness result. Saved tabs wait for plugin discovery before removal. ## Model Used OpenAI GPT-6 through Codex, with repository inspection, terminal tools, browser verification, and code execution. The exact serving variant and context window size are not exposed in 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 either (a) linked existing issues with `Fixes: #` / `Closes #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/plugins/PLUGIN_SPEC.md | 6 +- maps/feature-map/task-coordination.md | 24 ++--- packages/plugins/sdk/README.md | 21 +++-- packages/shared/src/types/plugin.ts | 9 +- .../task-side-panel/TaskSidePanel.test.tsx | 52 +++++++++-- .../task-side-panel/TaskSidePanel.tsx | 15 ++-- ui/src/hooks/useTaskPluginPanels.test.tsx | 87 +++++++++++++++++-- ui/src/hooks/useTaskPluginPanels.ts | 35 +++++--- ui/src/pages/IssueDetail.test.tsx | 37 ++++++++ ui/src/pages/IssueDetail.tsx | 19 ++-- 10 files changed, 243 insertions(+), 62 deletions(-) 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