Files
PaperClipAI/ui/src/lib/task-side-panel-state.ts
T
Nicky LeachandPaperclip c7c12dbc79 Support plugin panels in task sidebars (#17796)
## 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>
2026-10-26 14:47:15 -07:00

309 lines
12 KiB
TypeScript

import type { WorkspaceFileSelector } from "@paperclipai/shared";
import type { SidePanelTabRecord, SidePanelTabsState } from "@/components/side-panel";
const STORAGE_VERSION = 1;
const MAX_TASK_STATES = 50;
export function shouldSuppressTaskPanelUntilPlan(input: {
deferredPlanAvailable: boolean;
panelBeforePlanOverride: boolean;
}) {
return !input.deferredPlanAvailable && !input.panelBeforePlanOverride;
}
export interface OpenSkillPanelState {
skill: { id: string; name: string };
panelBeforePlanOverrideIssueId: string | null;
}
export function openSkillPanelState(
current: Pick<OpenSkillPanelState, "panelBeforePlanOverrideIssueId">,
skill: { id: string; name: string },
issueId: string | null,
panelSuppressedUntilPlan: boolean,
): OpenSkillPanelState {
return {
skill,
panelBeforePlanOverrideIssueId:
panelSuppressedUntilPlan && issueId ? issueId : current.panelBeforePlanOverrideIssueId,
};
}
export type TaskSidePanelTabPayload =
| { kind: "browser"; browserId: string }
| { kind: "plugin-panel"; pluginKey: string; slotId: string; entityId: string }
| { kind: "properties" }
| { kind: "subtasks" }
| { kind: "artifacts" }
| { kind: "agent-tasks" }
| { kind: "attachment"; attachmentId: string }
| { kind: "skill"; skillId: string }
| { kind: "issue-document"; documentKey: string }
| {
kind: "files-browser";
query: string | null;
folderPath: string | null;
projectId: string | null;
workspaceId: string | null;
}
| {
kind: "workspace-file";
path: string;
workspace: WorkspaceFileSelector;
projectId: string | null;
workspaceId: string | null;
line: number | null;
column: number | null;
};
export interface StoredTaskSidePanelState {
state: SidePanelTabsState<TaskSidePanelTabPayload>;
launcherOpen: boolean;
userInteracted: boolean;
autoPlanHandled: boolean;
updatedAt: number;
}
interface TaskSidePanelStore {
version: number;
tasks: Record<string, StoredTaskSidePanelState>;
}
function storageKey(accountScope: string, companyId: string) {
return `paperclip:task-side-panel:v${STORAGE_VERSION}:${accountScope}:${companyId}`;
}
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function nullableString(value: unknown): string | null | undefined {
return value === null ? null : typeof value === "string" ? value : undefined;
}
function positiveInteger(value: unknown): number | null | undefined {
if (value === null) return null;
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
}
function parsePayload(value: unknown): TaskSidePanelTabPayload | null {
const input = record(value);
if (!input) return null;
const kind = input.kind;
if (kind === "browser") return typeof input.browserId === "string" && /^[0-9a-f-]{36}$/i.test(input.browserId) ? { kind, browserId: input.browserId } : null;
if (kind === "plugin-panel") return [input.pluginKey, input.slotId, input.entityId].every(value => typeof value === "string" && value.length > 0)
? { kind, pluginKey: input.pluginKey as string, slotId: input.slotId as string, entityId: input.entityId as string } : null;
if (kind === "properties") return { kind };
if (kind === "subtasks") return { kind };
if (kind === "artifacts") return { kind };
if (kind === "agent-tasks") return { kind };
if (kind === "attachment") {
return typeof input.attachmentId === "string" && input.attachmentId.length > 0 ? { kind, attachmentId: input.attachmentId } : null;
}
if (kind === "skill") {
return typeof input.skillId === "string" && input.skillId.length > 0 ? { kind, skillId: input.skillId } : null;
}
if (kind === "issue-document") {
return typeof input.documentKey === "string" && input.documentKey.length > 0
? { kind, documentKey: input.documentKey }
: null;
}
if (kind === "files-browser") {
const query = nullableString(input.query);
const folderPath = nullableString(input.folderPath);
const projectId = nullableString(input.projectId);
const workspaceId = nullableString(input.workspaceId);
if ([query, folderPath, projectId, workspaceId].some((entry) => typeof entry === "undefined")) return null;
return { kind, query: query!, folderPath: folderPath!, projectId: projectId!, workspaceId: workspaceId! };
}
if (kind === "workspace-file") {
if (typeof input.path !== "string" || input.path.length === 0) return null;
const workspace = input.workspace === "execution" || input.workspace === "project" ? input.workspace : "auto";
const projectId = nullableString(input.projectId);
const workspaceId = nullableString(input.workspaceId);
const line = positiveInteger(input.line);
const column = positiveInteger(input.column);
if ([projectId, workspaceId, line, column].some((entry) => typeof entry === "undefined")) return null;
return {
kind,
path: input.path,
workspace,
projectId: projectId!,
workspaceId: workspaceId!,
line: line!,
column: column!,
};
}
return null;
}
function parseTab(value: unknown, fileTabsEnabled: boolean): SidePanelTabRecord<TaskSidePanelTabPayload> | null {
const input = record(value);
if (!input || typeof input.id !== "string" || typeof input.type !== "string" || typeof input.label !== "string") return null;
const payload = parsePayload(input.payload);
if (!payload) return null;
if (!fileTabsEnabled && (payload.kind === "files-browser" || payload.kind === "workspace-file")) return null;
return {
id: input.id,
type: input.type,
label: input.label,
ariaLabel: typeof input.ariaLabel === "string" ? input.ariaLabel : undefined,
closable: typeof input.closable === "boolean" ? input.closable : true,
contentMode: input.contentMode === "full-bleed" || input.contentMode === "prose" ? input.contentMode : "padded",
payload,
};
}
function parseEntry(value: unknown, fileTabsEnabled: boolean): StoredTaskSidePanelState | null {
const input = record(value);
const rawState = record(input?.state);
if (!input || !rawState || !Array.isArray(rawState.tabs)) return null;
const seen = new Set<string>();
const tabs = rawState.tabs.flatMap((raw) => {
const tab = parseTab(raw, fileTabsEnabled);
if (!tab || seen.has(tab.id)) return [];
seen.add(tab.id);
return [tab];
});
const requestedActive = typeof rawState.activeTabId === "string" ? rawState.activeTabId : null;
return {
state: {
tabs,
activeTabId: requestedActive && seen.has(requestedActive) ? requestedActive : tabs[0]?.id ?? null,
},
launcherOpen: input.launcherOpen === true,
userInteracted: input.userInteracted === true,
autoPlanHandled: input.autoPlanHandled === true,
updatedAt: typeof input.updatedAt === "number" && Number.isFinite(input.updatedAt) ? input.updatedAt : 0,
};
}
function readStore(accountScope: string, companyId: string): TaskSidePanelStore {
if (typeof window === "undefined") return { version: STORAGE_VERSION, tasks: {} };
try {
const parsed = record(JSON.parse(window.localStorage.getItem(storageKey(accountScope, companyId)) ?? "null"));
if (parsed?.version !== STORAGE_VERSION || !record(parsed.tasks)) return { version: STORAGE_VERSION, tasks: {} };
return { version: STORAGE_VERSION, tasks: parsed.tasks as Record<string, StoredTaskSidePanelState> };
} catch {
return { version: STORAGE_VERSION, tasks: {} };
}
}
export function readTaskSidePanelState(
accountScope: string,
companyId: string,
taskId: string,
fileTabsEnabled: boolean,
): StoredTaskSidePanelState | null {
return parseEntry(readStore(accountScope, companyId).tasks[taskId], fileTabsEnabled);
}
export function writeTaskSidePanelState(
accountScope: string,
companyId: string,
taskId: string,
entry: StoredTaskSidePanelState,
) {
if (typeof window === "undefined") return;
try {
const store = readStore(accountScope, companyId);
const tasks = { ...store.tasks, [taskId]: entry };
const retained = Object.entries(tasks)
.sort((left, right) => (right[1].updatedAt ?? 0) - (left[1].updatedAt ?? 0))
.slice(0, MAX_TASK_STATES);
window.localStorage.setItem(storageKey(accountScope, companyId), JSON.stringify({
version: STORAGE_VERSION,
tasks: Object.fromEntries(retained),
} satisfies TaskSidePanelStore));
} catch {
// Storage is an enhancement; panel behavior stays usable when unavailable.
}
}
export function taskPanelPropertiesTab(): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: "properties", type: "properties", label: "Properties", closable: true, contentMode: "padded", payload: { kind: "properties" } };
}
export function taskPanelSubtasksTab(): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: "subtasks", type: "subtasks", label: "Subtasks", closable: true, contentMode: "padded", payload: { kind: "subtasks" } };
}
/** Agent chats only: every task the conversation's agent has worked on. */
export function taskPanelAgentTasksTab(): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: "agent-tasks", type: "agent-tasks", label: "Tasks", closable: true, contentMode: "padded", payload: { kind: "agent-tasks" } };
}
export function taskPanelArtifactsTab(): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: "artifacts", type: "artifacts", label: "Artifacts", closable: true, contentMode: "padded", payload: { kind: "artifacts" } };
}
export function taskPanelSkillTab(skillId: string, label = "Skill"): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: `skill:${skillId}`, type: "skill", label, closable: true, contentMode: "prose", payload: { kind: "skill", skillId } };
}
export function taskPanelDocumentTab(documentKey: string, label: string): SidePanelTabRecord<TaskSidePanelTabPayload> {
return {
id: `document:${documentKey}`,
type: "issue-document",
label,
closable: true,
contentMode: "prose",
payload: { kind: "issue-document", documentKey },
};
}
export function taskPanelFilesTab(): SidePanelTabRecord<TaskSidePanelTabPayload> {
return {
id: "files",
type: "files-browser",
label: "Files",
closable: true,
contentMode: "full-bleed",
payload: { kind: "files-browser", query: null, folderPath: null, projectId: null, workspaceId: null },
};
}
export function taskPanelWorkspaceFileTab(input: {
path: string;
workspace?: WorkspaceFileSelector;
projectId?: string | null;
workspaceId?: string | null;
line?: number | null;
column?: number | null;
}): SidePanelTabRecord<TaskSidePanelTabPayload> {
const projectId = input.projectId ?? null;
const workspaceId = input.workspaceId ?? null;
const scope = projectId && workspaceId ? `${projectId}:${workspaceId}` : input.workspace ?? "auto";
return {
id: `workspace-file:${scope}:${input.path}`,
type: "workspace-file",
label: input.path.split("/").filter(Boolean).at(-1) ?? input.path,
ariaLabel: input.path,
closable: true,
contentMode: "full-bleed",
payload: {
kind: "workspace-file",
path: input.path,
workspace: input.workspace ?? "auto",
projectId,
workspaceId,
line: input.line ?? null,
column: input.column ?? null,
},
};
}
export function taskPanelAttachmentTab(attachmentId: string, title: string): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: `attachment:${attachmentId}`, type: "attachment", label: title, closable: true, contentMode: "full-bleed", payload: { kind: "attachment", attachmentId } };
}
export function taskPanelBrowserTab(browserId: string): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: `browser:${browserId}`, type: "browser", label: "Browser", closable: true, contentMode: "full-bleed", payload: { kind: "browser", browserId } };
}
export function taskPanelPluginTab(panel: { entityId: string; slot: { pluginKey: string; id: string; displayName: string } }): SidePanelTabRecord<TaskSidePanelTabPayload> {
return { id: `plugin:${panel.slot.pluginKey}:${panel.slot.id}:${panel.entityId}`, type: "plugin-panel", label: panel.slot.displayName, closable: true, contentMode: "padded", payload: { kind: "plugin-panel", pluginKey: panel.slot.pluginKey, slotId: panel.slot.id, entityId: panel.entityId } };
}