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 <noreply@paperclip.ing>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-10-30 13:44:41 -07:00
1 parent fd008979f5
commit b056026407
10 files changed
+243 -62

No files matched your search

+3 -3
View File
@@ -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 */
+13 -11
View File
@@ -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).
+13 -8
View File
@@ -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`
+5 -4
View File
@@ -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 };
};
@@ -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 } }) => <div>Plugin viewer {context.entityId}</div>,
PluginSlotMount: ({ context }: { context: { entityId: string } }) => <div data-plugin-viewer>Plugin viewer {context.entityId}</div>,
}));
vi.mock("@/hooks/useTaskPluginPanels", () => ({
taskPluginPanelId: (panel: TaskPluginPanel) => `plugin:${panel.slot.pluginKey}:${panel.slot.id}:${panel.entityId}`,
}));
const pluginPanel = { entityId: "33333333-3333-4333-8333-333333333333", runId: "run-1", slot: { pluginKey: "example.live-view", id: "screen", displayName: "Live view" } } as TaskPluginPanel;
const 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<HTMLButtonElement>('button[aria-label="Open a new tab"]')?.click());
const item = Array.from(document.body.querySelectorAll<HTMLElement>('[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<HTMLButtonElement>('button[aria-label="Open a new tab"]')?.click());
const item = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find(item => item.textContent?.includes("Live view"));
await act(async () => item!.click());
await act(async () => container.querySelector<HTMLButtonElement>('#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");
@@ -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<string>;
dismissedPluginPanelKeys?: Set<string>;
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<string>());
const dismissedPluginPanels = dismissedPluginPanelKeys ?? localDismissedPluginPanels.current;
const pluginPanelKey = useCallback((panel: TaskPluginPanel) => JSON.stringify([accountScope, issue.companyId, issue.id, panel.runId, taskPluginPanelId(panel)]), [accountScope, issue.companyId, issue.id]);
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 ? (
<PluginSlotMount key={panel.runId} slot={panel.slot} context={{ companyId: issue.companyId, projectId: issue.projectId, entityId: panel.entityId, entityType: "agent" }} />
<PluginSlotMount key={taskPluginPanelId(panel)} slot={panel.slot} context={{ companyId: issue.companyId, projectId: issue.projectId, entityId: panel.entityId, entityType: "agent" }} />
) : null;
} else if (activeTab.payload.kind === "properties") {
content = (
+79 -8
View File
@@ -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<TaskPluginPanel, "autoOpen">[];
let task: Issue | null;
let enabled: boolean;
let get: MockInstance<typeof api.get>;
let list: MockInstance<typeof heartbeatsApi.liveRunsForIssue>;
let history: MockInstance<typeof heartbeatsApi.taskPanelActivations>;
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(<QueryClientProvider client={client}><Harness /></QueryClientProvider>));
@@ -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([]);
});
+24 -11
View File
@@ -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<Issue, "companyId" | "id">) {
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)) };
}
+37
View File
@@ -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<typeof import("@/hooks/useTaskPluginPanels")>(),
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<string>;
};
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;
+13 -6
View File
@@ -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<string>());
const openedPluginPanelKeys = useRef(new Set<string>());
const dismissedPluginPanelKeys = useRef(new Set<string>());
useEffect(() => {
for (const panel of pluginPanels) {
const key = JSON.stringify([currentUserId, issue?.id, panel.runId, taskPluginPanelId(panel)]);
if (!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
<IssueGalleryContext.Provider value={openIssueGallery}>
<TaskSidePanel
pluginPanels={pluginPanels}
pluginPanelsLoading={pluginPanelsLoading}
openedPluginPanelKeys={openedPluginPanelKeys.current}
dismissedPluginPanelKeys={dismissedPluginPanelKeys.current}
openBrowserId={openBrowserId}
onBrowserOpened={handleBrowserOpened}
key={panelIssue.id}
{...sharedProps}
accountScope={coordinatorWorkspaceEnabled ? `coordinator:${currentUserId ?? "anonymous"}` : currentUserId ?? "anonymous"}
accountScope={taskPanelAccountScope}
fileTabsEnabled={fileViewerEnabled}
streamlinedTabs={streamlinedTaskDetailEnabled}
showSubtasksTab={streamlinedTaskDetailEnabled}
@@ -6261,6 +6265,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
return () => closePanel();
}, [
pluginPanels,
pluginPanelsLoading,
taskPanelAccountScope,
openBrowserId,
handleBrowserOpened,
closePanel,
@@ -8855,13 +8861,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
</SheetHeader>
<TaskSidePanel
pluginPanels={pluginPanels}
pluginPanelsLoading={pluginPanelsLoading}
openedPluginPanelKeys={openedPluginPanelKeys.current}
dismissedPluginPanelKeys={dismissedPluginPanelKeys.current}
openBrowserId={openBrowserId}
onBrowserOpened={handleBrowserOpened}
key={`${issue.id}:mobile`}
issue={issue}
accountScope={coordinatorWorkspaceEnabled ? `coordinator:${currentUserId ?? "anonymous"}` : currentUserId ?? "anonymous"}
accountScope={taskPanelAccountScope}
childIssues={childIssues}
issueLinkState={
streamlinedTaskDetailEnabled