mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task side panels keep live work beside the conversation.
> - Plugins can already provide agent detail tabs.
> - Those tabs have no generic way to appear beside the task that is
using the agent.
> - This pull request adds an optional task-side-panel declaration to
agent detail tabs.
> - The plugin owns the label, component and availability. Core supplies
the task's running agent.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
Access to plugin-contributed live views from task chat.
**Subsystem affected**
Plugin manifest validation, slot discovery, and task side-panel
UI/state.
**Current behavior**
Agent detail-tab contributions appear on agent pages. A task cannot
offer the same view through a plugin-owned availability decision.
**Proposed behavior**
An agent detail tab can declare `taskSidePanel: { availabilityPath }`.
Core substitutes the running agent into the plugin-local path. A
declared board GET route scoped to that agent returns `{ available:
boolean }`. Available contributions appear in desktop and mobile task
side panels.
**Reason and benefit**
Keep the task and its live plugin views together. Preserve plugin
ownership of provider protocols and product behavior. Core contains no
concrete plugin identifiers, routes, labels or readiness rules.
**Breaking changes**
None. The field is optional. Existing contributions remain unchanged.
Missing, disabled or unavailable plugins add no panel.
**Additional context**
Related: #17111 established the agent detail-tab and scoped API
extension surfaces. Roadmap and related PR searches found no duplicate
of this extension.
## What Changed
- Extend the manifest with optional task-panel metadata. Validate safe
plugin-local paths and require a declared board GET route with
agent-scoped authorization.
- Discover and load only opted-in agent contributions. Poll their
generic availability responses and mount the existing components with
agent context.
- Add generic panel tabs, launcher entries, dismissal and inactive-view
removal in desktop/mobile task chat.
- Reuse the existing live-run polling guard so idle tasks with a healthy
live connection stop polling.
- Add provider-neutral fixtures, schema/authorization tests, task tests
and SDK/feature documentation.
## Verification
- PASS: 301 focused tests including the full task-detail file, generic
panel discovery, persistence, slot loading and manifest validation.
Regressions cover POST-before-GET route order, mobile drawer dismissal,
recovery without focus and the absence of the hidden desktop task panel
on mobile.
- PASS: UI and shared-package typechecks; production UI build; token
gates, feature-map and module-boundary checks; diff and secrets/PII
review.
- PASS on final head: all 795 UI files / 9,186 tests, and all 95
shared-package files / 1,038 tests. Commands: `node
node_modules/vitest/vitest.mjs run --project @paperclipai/ui
--maxWorkers 3` and the corresponding `--project @paperclipai/shared`
command with local socket access.
- On the final head, server shards 7/12 and 10/12 and workspace shard
a-1/2 were interrupted by runner shutdown/communication loss. GitHub
reported infrastructure failures; no assertion failure was surfaced in
the available diagnostics. The queued summary was cancelled, then `gh
run rerun --failed` successfully requested retries for only those three
jobs. They are currently queued for CI runners; the 50 successful checks
remain passing. Interrupted attempts are not counted as passing.
[Final-head CI and
retry](https://github.com/paperclipai/paperclip/actions/runs/40396870920).
- Earlier verification exposed an empty-panel rerender loop, which was
fixed. The sandboxed combined run also failed five shared port-registry
tests because it could not bind loopback listeners; the full shared
rerun with socket access passed. These earlier failures are retained
here rather than reported as passing runs.
- Full workspace typecheck/build were attempted with pinned pnpm 9 after
resolving the initial local package-manager setup. Both stop at the
unchanged Runner Rust step because `cargo` is not installed. Full local
workspace checks are not reported as passing; final CI must provide
those broader checks.
- No live provider or plugin-specific transport acceptance is claimed by
Core's synthetic fixtures.
## Risks
- Availability is polled every three seconds only while an authorized
running agent exists. Plugins own the side effects and meaning of that
read.
- A view uses the running agent's context. It is not a separate per-task
resource.
- Optional metadata adds no backend endpoint or permission bypass. The
host requires existing board and agent authorization.
- Reverting the optional contract and UI integration removes task panels
without a data migration.
## Model Used
OpenAI Codex (GPT-6), with reasoning, repository tools and code
execution. The exact backend snapshot ID and context window size are not
exposed by this session.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
101 lines
6.2 KiB
TypeScript
101 lines
6.2 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { Issue } from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, expect, it, vi, type MockInstance } from "vitest";
|
|
import { api } from "@/api/client";
|
|
import { heartbeatsApi, type LiveRunForIssue } from "@/api/heartbeats";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import type { ResolvedPluginSlot } from "@/plugins/slots";
|
|
import { useTaskPluginPanels, type TaskPluginPanel } from "./useTaskPluginPanels";
|
|
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
const plugin = vi.hoisted(() => ({ connected: true, slots: [] as ResolvedPluginSlot[], errorMessage: null as string | null }));
|
|
vi.mock("@/plugins/slots", () => ({ usePluginSlots: () => plugin }));
|
|
vi.mock("@/context/LiveUpdatesProvider", () => ({ useHasCompanyLiveConnection: () => plugin.connected }));
|
|
const slot = { pluginId: "example-plugin", pluginKey: "example.live-view", id: "screen", pluginVersion: "1.0.0", taskSidePanel: { availabilityPath: "/agents/:entityId/view/availability" } } as ResolvedPluginSlot;
|
|
const issue = { id: "task", companyId: "company", executionRunId: "run" } as Issue;
|
|
const run = { id: "run", agentId: "agent", status: "running" } as LiveRunForIssue;
|
|
let root: Root, container: HTMLDivElement, client: QueryClient;
|
|
let current: TaskPluginPanel[];
|
|
let task: Issue | null;
|
|
let enabled: boolean;
|
|
let get: MockInstance<typeof api.get>;
|
|
let list: MockInstance<typeof heartbeatsApi.liveRunsForIssue>;
|
|
function Harness() { current = useTaskPluginPanels(task, enabled); return null; }
|
|
async function render(next: Issue | null = issue, active = true) {
|
|
task = next; enabled = active;
|
|
await act(async () => root.render(<QueryClientProvider client={client}><Harness /></QueryClientProvider>));
|
|
await act(async () => { if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(30); else await new Promise(resolve => setTimeout(resolve, 30)); });
|
|
}
|
|
async function refresh() {
|
|
await act(async () => { await client.refetchQueries(); });
|
|
await act(async () => { if (vi.isFakeTimers()) await vi.advanceTimersByTimeAsync(30); else await new Promise(resolve => setTimeout(resolve, 30)); });
|
|
}
|
|
beforeEach(() => {
|
|
plugin.slots = [slot]; plugin.errorMessage = null; plugin.connected = true;
|
|
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
|
|
get = vi.spyOn(api, "get").mockResolvedValue({ available: true });
|
|
list = vi.spyOn(heartbeatsApi, "liveRunsForIssue").mockResolvedValue([run]);
|
|
});
|
|
afterEach(async () => {
|
|
await act(async () => root.unmount()); client.clear(); container.remove(); vi.restoreAllMocks(); vi.useRealTimers();
|
|
});
|
|
it("uses the running agent's authorized status and existing plugin slot", async () => {
|
|
await render();
|
|
expect(list).toHaveBeenCalledWith("task");
|
|
expect(get).toHaveBeenCalledWith("/plugins/example-plugin/api/agents/agent/view/availability", { signal: expect.any(AbortSignal), cache: "no-store" });
|
|
expect(current).toEqual([{ entityId: "agent", runId: "run", slot }]);
|
|
});
|
|
it("keeps unavailable panels hidden", async () => {
|
|
get.mockResolvedValue({ available: false }); await render(); expect(current).toEqual([]);
|
|
});
|
|
it.each(["absent", "disabled", "draft", "no-task", "plugin-error"])("does no panel polling when %s", async kind => {
|
|
if (kind === "absent") plugin.slots = [];
|
|
if (kind === "plugin-error") plugin.errorMessage = "Unavailable";
|
|
await render(kind === "no-task" ? null : kind === "draft" ? { ...issue, id: "chat:draft" } : issue, kind !== "disabled");
|
|
expect(current).toEqual([]); expect(get).not.toHaveBeenCalled(); expect(list).not.toHaveBeenCalled();
|
|
});
|
|
it.each(["queued", "succeeded", "another-run"])("ignores a %s run", async kind => {
|
|
list.mockResolvedValue([{ ...run, status: kind === "another-run" ? "running" : kind, id: kind === "another-run" ? "other" : "run" }]);
|
|
await render(); expect(current).toEqual([]); expect(get).not.toHaveBeenCalled();
|
|
});
|
|
it("discovers a panel started after the task is already open, then hides it on stop or access failure", async () => {
|
|
get.mockResolvedValue({ available: false }); await render(); expect(current).toEqual([]);
|
|
get.mockResolvedValue({ available: true }); await refresh(); expect(current[0]?.entityId).toBe("agent");
|
|
get.mockRejectedValue(new Error("Access denied")); await refresh(); expect(current).toEqual([]);
|
|
});
|
|
it("removes the panel when the run ends even if its view is still ready", async () => {
|
|
await render(); expect(current).not.toEqual([]);
|
|
await act(async () => { client.setQueryData(queryKeys.issues.liveRuns("task"), []); });
|
|
await render(); expect(current).toEqual([]);
|
|
});
|
|
it("does not carry an old task's ready panel to a new task or disabled plugin", async () => {
|
|
await render(); expect(current).not.toEqual([]);
|
|
list.mockResolvedValue([]); await render({ ...issue, id: "next-task" }); expect(current).toEqual([]);
|
|
plugin.slots = []; await render(); expect(current).toEqual([]);
|
|
});
|
|
|
|
it("stops idle polling with live updates and resumes fallback polling on disconnect", async () => {
|
|
vi.useFakeTimers(); list.mockResolvedValue([]);
|
|
await render(); expect(list).toHaveBeenCalledTimes(1);
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(6000); });
|
|
expect(list).toHaveBeenCalledTimes(1);
|
|
plugin.connected = false; await render();
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(6000); });
|
|
expect(list).toHaveBeenCalledTimes(3);
|
|
});
|
|
it("uses each plugin's own availability path and excludes only the failing contribution", async () => {
|
|
const second = { ...slot, pluginId: "other-plugin", pluginKey: "example.other-view", taskSidePanel: { availabilityPath: "/views/:entityId/ready" } };
|
|
plugin.slots = [slot, second];
|
|
get.mockImplementation(async path => {
|
|
if (path.includes("other-plugin")) throw new Error("Unavailable");
|
|
return { available: true } as never;
|
|
});
|
|
await render();
|
|
expect(current).toEqual([{ entityId: "agent", runId: "run", slot }]);
|
|
expect(get).toHaveBeenCalledWith("/plugins/other-plugin/api/views/agent/ready", expect.anything());
|
|
});
|