Expand plugin host surface (#5205)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The plugin system is the extension boundary for optional product
capabilities
> - Rich plugins need more than a worker entrypoint: they need scoped
database storage, local project folders, managed agents/routines, host
navigation, and reusable UI components
> - The LLM Wiki work exposed those missing host surfaces while keeping
plugin code outside the core control plane
> - This pull request expands the core plugin host, SDK, server APIs,
and UI bridge so plugins can declare and use those surfaces
> - The benefit is that future plugins can integrate with Paperclip
through documented, validated contracts instead of bespoke server or UI
imports

## What Changed

- Added plugin-managed database namespaces and migration tracking,
including Drizzle schema/migration files and SQL validation for
namespace isolation.
- Added server support for plugin local folders, managed agents, managed
routines, scoped plugin APIs, and plugin operation visibility.
- Expanded shared plugin manifest/types/validators and SDK
host/testing/UI exports for richer plugin surfaces.
- Added reusable UI pieces for file trees, managed routines, resizable
sidebars, route sidebars, and plugin bridge initialization.
- Updated plugin docs and example plugins to use the expanded host and
SDK surface.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
packages/shared/src/validators/plugin.test.ts
server/src/__tests__/plugin-database.test.ts
server/src/__tests__/plugin-local-folders.test.ts
server/src/__tests__/plugin-managed-agents.test.ts
server/src/__tests__/plugin-managed-routines.test.ts
server/src/__tests__/plugin-orchestration-apis.test.ts
ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx
ui/src/components/ResizableSidebarPane.test.tsx
ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed:
11 files, 67 tests.
- Confirmed this PR changes 89 files and does not include
`pnpm-lock.yaml` or `.github/workflows/*`.

## Risks

- Medium: this expands plugin host contracts across db/shared/server/ui
and includes a new core migration (`0076_useful_elektra.sql`).
- The plugin database namespace validator is intentionally restrictive;
plugin authors may need follow-up affordances for SQL patterns that
remain blocked.
- Merge this before the LLM Wiki plugin PR so the plugin can resolve the
new SDK and host APIs.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled shell/git/GitHub
workflow. Context window size was not exposed by the runtime.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-05 07:42:57 -05:00 committed by GitHub
parent d6bee62f02
commit 3c73ed26b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 27516 additions and 914 deletions

View file

@ -16,9 +16,47 @@ import {
usePluginData,
usePluginAction,
useHostContext,
useHostLocation,
useHostNavigation,
usePluginStream,
usePluginToast,
} from "./bridge.js";
import { createElement, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { User } from "lucide-react";
import {
FileTree,
type FileTreeProps as HostFileTreeProps,
} from "@/components/FileTree";
import { AgentIcon } from "@/components/AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "@/components/InlineEntitySelector";
import { IssuesList as HostIssuesList } from "@/components/IssuesList";
import { ManagedRoutinesList as HostManagedRoutinesList } from "@/components/ManagedRoutinesList";
import { MarkdownBody } from "@/components/MarkdownBody";
import { accessApi } from "@/api/access";
import { agentsApi } from "@/api/agents";
import { authApi } from "@/api/auth";
import { heartbeatsApi } from "@/api/heartbeats";
import { issuesApi } from "@/api/issues";
import { projectsApi } from "@/api/projects";
import {
buildCompanyUserInlineOptions,
} from "@/lib/company-members";
import { collectLiveIssueIds } from "@/lib/liveIssueIds";
import { useProjectOrder } from "@/hooks/useProjectOrder";
import {
assigneeValueFromSelection,
currentUserAssigneeOption,
parseAssigneeValue,
} from "@/lib/assignees";
import { queryKeys } from "@/lib/queryKeys";
import {
getRecentAssigneeSelectionIds,
sortAgentsByRecency,
trackRecentAssignee,
trackRecentAssigneeUser,
} from "@/lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "@/lib/recent-projects";
// ---------------------------------------------------------------------------
// Global bridge registry
@ -41,6 +79,451 @@ declare global {
var __paperclipPluginBridge__: PluginBridgeRegistry | undefined;
}
type PluginFileTreePathCollection = ReadonlySet<string> | readonly string[];
type PluginFileTreeProps = Omit<
HostFileTreeProps,
| "expandedDirs"
| "checkedFiles"
| "renderFileExtra"
| "fileRowClassName"
| "selectedFile"
| "showCheckboxes"
| "onToggleDir"
| "onSelectFile"
> & {
selectedFile?: string | null;
expandedPaths?: PluginFileTreePathCollection;
checkedPaths?: PluginFileTreePathCollection;
showCheckboxes?: boolean;
onToggleDir?: (path: string) => void;
onSelectFile?: (path: string) => void;
};
function toPathSet(paths?: PluginFileTreePathCollection | null): Set<string> {
return new Set(paths ?? []);
}
function PluginSdkFileTree({
expandedPaths,
checkedPaths,
selectedFile = null,
showCheckboxes = false,
onToggleDir,
onSelectFile,
...props
}: PluginFileTreeProps) {
return createElement(FileTree, {
...props,
selectedFile,
expandedDirs: toPathSet(expandedPaths),
checkedFiles: checkedPaths ? toPathSet(checkedPaths) : undefined,
showCheckboxes,
onToggleDir: onToggleDir ?? (() => undefined),
onSelectFile: onSelectFile ?? (() => undefined),
});
}
type PluginMarkdownBlockProps = {
content: string;
className?: string;
enableWikiLinks?: boolean;
wikiLinkRoot?: string;
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
};
type PluginMarkdownEditorProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
contentClassName?: string;
onBlur?: () => void;
bordered?: boolean;
readOnly?: boolean;
onSubmit?: () => void;
};
type PluginIssuesListFilters = {
status?: string;
projectId?: string;
parentId?: string;
assigneeAgentId?: string;
participantAgentId?: string;
assigneeUserId?: string;
labelId?: string;
workspaceId?: string;
executionWorkspaceId?: string;
originKind?: string;
originKindPrefix?: string;
originId?: string;
descendantOf?: string;
includeRoutineExecutions?: boolean;
};
type PluginIssuesListProps = {
companyId: string | null;
projectId?: string | null;
filters?: PluginIssuesListFilters;
viewStateKey?: string;
initialSearch?: string;
createIssueLabel?: string;
searchWithinLoadedIssues?: boolean;
};
type PluginAssigneePickerSelection = {
assigneeAgentId: string | null;
assigneeUserId: string | null;
};
type PluginAssigneePickerProps = {
companyId?: string | null;
value: string;
onChange: (value: string, selection: PluginAssigneePickerSelection) => void;
placeholder?: string;
noneLabel?: string;
searchPlaceholder?: string;
emptyMessage?: string;
includeUsers?: boolean;
includeTerminatedAgents?: boolean;
className?: string;
onConfirm?: () => void;
};
type PluginProjectPickerProps = {
companyId?: string | null;
value: string;
onChange: (projectId: string) => void;
placeholder?: string;
noneLabel?: string;
searchPlaceholder?: string;
emptyMessage?: string;
includeArchived?: boolean;
className?: string;
onConfirm?: () => void;
};
function PluginSdkMarkdownEditor(props: PluginMarkdownEditorProps) {
const [Editor, setEditor] = useState<ComponentType<PluginMarkdownEditorProps> | null>(null);
useEffect(() => {
let cancelled = false;
import("@/components/MarkdownEditor").then((module) => {
if (!cancelled) setEditor(() => module.MarkdownEditor as ComponentType<PluginMarkdownEditorProps>);
});
return () => {
cancelled = true;
};
}, []);
if (Editor) return createElement(Editor, props);
return createElement("textarea", {
className: props.className,
value: props.value,
placeholder: props.placeholder,
readOnly: props.readOnly,
onBlur: props.onBlur,
onChange: (event) => props.onChange((event.currentTarget as HTMLTextAreaElement).value),
});
}
function compactIssueFilters(filters: PluginIssuesListFilters): PluginIssuesListFilters {
return Object.fromEntries(
Object.entries(filters).filter(([, value]) =>
value !== undefined && value !== null && value !== "" && value !== false,
),
) as PluginIssuesListFilters;
}
function PluginSdkIssuesList({
companyId,
projectId = null,
filters,
viewStateKey = "paperclip:plugin-issues-view",
initialSearch,
createIssueLabel,
searchWithinLoadedIssues = true,
}: PluginIssuesListProps) {
const queryClient = useQueryClient();
const issueFilters = useMemo(
() => compactIssueFilters({
...(filters ?? {}),
projectId: filters?.projectId ?? projectId ?? undefined,
}),
[filters, projectId],
);
const originKindPrefix = issueFilters.originKindPrefix ?? null;
const resolvedProjectId = issueFilters.projectId ?? projectId ?? null;
const issuesQueryKey = useMemo(
() => ["plugins", "sdk-ui", "issues-list", companyId ?? "__no-company__", issueFilters] as const,
[companyId, issueFilters],
);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(companyId ?? "__no-company__"),
queryFn: () => agentsApi.list(companyId!),
enabled: !!companyId,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(companyId ?? "__no-company__"),
queryFn: () => projectsApi.list(companyId!),
enabled: !!companyId,
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(companyId ?? "__no-company__"),
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId!),
enabled: !!companyId,
refetchInterval: 5000,
});
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const { data: issues, isLoading, error } = useQuery({
queryKey: issuesQueryKey,
queryFn: () => issuesApi.list(companyId!, issueFilters),
enabled: !!companyId,
});
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
issuesApi.update(id, data),
onSuccess: () => {
if (!companyId) return;
queryClient.invalidateQueries({ queryKey: ["plugins", "sdk-ui", "issues-list", companyId] });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
if (resolvedProjectId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, resolvedProjectId) });
if (originKindPrefix) {
queryClient.invalidateQueries({
queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, resolvedProjectId, originKindPrefix),
});
}
}
},
});
if (!companyId) {
return createElement("div", { className: "text-sm text-muted-foreground" }, "Select a company to view issues.");
}
return createElement(HostIssuesList, {
issues: issues ?? [],
isLoading,
error: error as Error | null,
agents,
projects,
liveIssueIds,
projectId: resolvedProjectId ?? undefined,
viewStateKey,
initialSearch,
createIssueLabel,
searchWithinLoadedIssues,
onUpdateIssue: (id: string, data: Record<string, unknown>) => updateIssue.mutate({ id, data }),
});
}
function PluginSdkAssigneePicker({
companyId,
value,
onChange,
placeholder = "Assignee",
noneLabel = "No assignee",
searchPlaceholder = "Search assignees...",
emptyMessage = "No assignees found.",
includeUsers = true,
includeTerminatedAgents = false,
className,
onConfirm,
}: PluginAssigneePickerProps) {
const hostContext = useHostContext();
const resolvedCompanyId = companyId ?? hostContext.companyId ?? null;
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
enabled: includeUsers,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(resolvedCompanyId ?? "__no-company__"),
queryFn: () => agentsApi.list(resolvedCompanyId!),
enabled: !!resolvedCompanyId,
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(resolvedCompanyId ?? "__no-company__"),
queryFn: () => accessApi.listUserDirectory(resolvedCompanyId!),
enabled: !!resolvedCompanyId && includeUsers,
});
const recentAssigneeSelectionIds = useMemo(() => getRecentAssigneeSelectionIds(), []);
const recentAssigneeIds = useMemo(
() => recentAssigneeSelectionIds
.map((id) => id.startsWith("agent:") ? id.slice("agent:".length) : null)
.filter((id): id is string => Boolean(id)),
[recentAssigneeSelectionIds],
);
const sortedAgents = useMemo(
() => sortAgentsByRecency(
(agents ?? []).filter((agent) => includeTerminatedAgents || agent.status !== "terminated"),
recentAssigneeIds,
),
[agents, includeTerminatedAgents, recentAssigneeIds],
);
const options = useMemo<InlineEntityOption[]>(
() => [
...(includeUsers ? currentUserAssigneeOption(currentUserId) : []),
...(includeUsers
? buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] })
: []),
...sortedAgents.map((agent) => ({
id: assigneeValueFromSelection({ assigneeAgentId: agent.id }),
label: agent.name,
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
})),
],
[companyMembers?.users, currentUserId, includeUsers, sortedAgents],
);
const selectedAssignee = parseAssigneeValue(value);
const selectedAgent = selectedAssignee.assigneeAgentId
? sortedAgents.find((agent) => agent.id === selectedAssignee.assigneeAgentId)
: null;
return createElement(InlineEntitySelector, {
value,
options,
recentOptionIds: recentAssigneeSelectionIds,
placeholder,
noneLabel,
searchPlaceholder,
emptyMessage,
className,
onConfirm,
onChange: (nextValue: string) => {
const selection = parseAssigneeValue(nextValue);
if (selection.assigneeAgentId) trackRecentAssignee(selection.assigneeAgentId);
if (selection.assigneeUserId) trackRecentAssigneeUser(selection.assigneeUserId);
onChange(nextValue, selection);
},
renderTriggerValue: (option: InlineEntityOption | null) => {
if (!option) return createElement("span", { className: "text-muted-foreground" }, placeholder);
if (selectedAgent) {
return createElement(
FragmentSafe,
null,
createElement(AgentIcon, { icon: selectedAgent.icon, className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" }),
createElement("span", { className: "truncate" }, option.label),
);
}
return createElement("span", { className: "truncate" }, option.label);
},
renderOption: (option: InlineEntityOption) => {
if (!option.id) return createElement("span", { className: "truncate" }, option.label);
const selection = parseAssigneeValue(option.id);
const agent = selection.assigneeAgentId
? sortedAgents.find((entry) => entry.id === selection.assigneeAgentId)
: null;
return createElement(
FragmentSafe,
null,
agent
? createElement(AgentIcon, { icon: agent.icon, className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" })
: createElement(User, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" }),
createElement("span", { className: "truncate" }, option.label),
);
},
});
}
function PluginSdkProjectPicker({
companyId,
value,
onChange,
placeholder = "Project",
noneLabel = "No project",
searchPlaceholder = "Search projects...",
emptyMessage = "No projects found.",
includeArchived = false,
className,
onConfirm,
}: PluginProjectPickerProps) {
const hostContext = useHostContext();
const resolvedCompanyId = companyId ?? hostContext.companyId ?? null;
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(resolvedCompanyId ?? "__no-company__"),
queryFn: () => projectsApi.list(resolvedCompanyId!),
enabled: !!resolvedCompanyId,
});
const visibleProjects = useMemo(
() => (projects ?? []).filter((project) => includeArchived || !project.archivedAt),
[includeArchived, projects],
);
const { orderedProjects } = useProjectOrder({
projects: visibleProjects,
companyId: resolvedCompanyId,
userId: currentUserId,
});
const recentProjectIds = useMemo(() => getRecentProjectIds(), []);
const options = useMemo<InlineEntityOption[]>(
() => orderedProjects.map((project) => ({
id: project.id,
label: project.name,
searchText: project.description ?? "",
})),
[orderedProjects],
);
const selectedProject = orderedProjects.find((project) => project.id === value) ?? null;
return createElement(InlineEntitySelector, {
value,
options,
recentOptionIds: recentProjectIds,
placeholder,
noneLabel,
searchPlaceholder,
emptyMessage,
className,
onConfirm,
onChange: (nextProjectId: string) => {
if (nextProjectId) trackRecentProject(nextProjectId);
onChange(nextProjectId);
},
renderTriggerValue: (option: InlineEntityOption | null) => {
if (!option || !selectedProject) {
return createElement("span", { className: "text-muted-foreground" }, placeholder);
}
return createElement(
FragmentSafe,
null,
createElement("span", {
className: "h-3.5 w-3.5 shrink-0 rounded-sm",
style: { backgroundColor: selectedProject.color ?? "#6366f1" },
}),
createElement("span", { className: "truncate" }, option.label),
);
},
renderOption: (option: InlineEntityOption) => {
if (!option.id) return createElement("span", { className: "truncate" }, option.label);
const project = orderedProjects.find((entry) => entry.id === option.id);
return createElement(
FragmentSafe,
null,
createElement("span", {
className: "h-3.5 w-3.5 shrink-0 rounded-sm",
style: { backgroundColor: project?.color ?? "#6366f1" },
}),
createElement("span", { className: "truncate" }, option.label),
);
},
});
}
function FragmentSafe({ children }: { children?: ReactNode }) {
return createElement("span", { className: "contents" }, children);
}
/**
* Initialize the plugin bridge global registry.
*
@ -62,8 +545,31 @@ export function initPluginBridge(
usePluginData,
usePluginAction,
useHostContext,
useHostLocation,
useHostNavigation,
usePluginStream,
usePluginToast,
MarkdownBlock: ({
content,
className,
enableWikiLinks,
wikiLinkRoot,
resolveWikiLinkHref,
}: PluginMarkdownBlockProps) =>
createElement(MarkdownBody, {
className,
softBreaks: false,
enableWikiLinks,
wikiLinkRoot,
resolveWikiLinkHref,
children: content,
}),
MarkdownEditor: PluginSdkMarkdownEditor,
FileTree: PluginSdkFileTree,
IssuesList: PluginSdkIssuesList,
AssigneePicker: PluginSdkAssigneePicker,
ProjectPicker: PluginSdkProjectPicker,
ManagedRoutinesList: HostManagedRoutinesList,
},
};
}

View file

@ -0,0 +1,306 @@
// @vitest-environment jsdom
import * as React from "react";
import * as ReactDOM from "react-dom";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { MouseEvent as ReactMouseEvent } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it } from "vitest";
import {
FileTree as SdkFileTree,
ManagedRoutinesList as SdkManagedRoutinesList,
MarkdownBlock as SdkMarkdownBlock,
MarkdownEditor as SdkMarkdownEditor,
type FileTreeNode as SdkFileTreeNode,
} from "../../../packages/plugins/sdk/src/ui/components";
import { SidebarProvider, useSidebar } from "@/context/SidebarContext";
import {
PluginBridgeContext,
resolveHostNavigationHref,
shouldHandleHostNavigationClick,
useHostNavigation,
type PluginBridgeContextValue,
} from "./bridge";
import { initPluginBridge } from "./bridge-init";
function clickEvent(
overrides: Partial<ReactMouseEvent<HTMLAnchorElement>> = {},
): ReactMouseEvent<HTMLAnchorElement> {
return {
defaultPrevented: false,
button: 0,
metaKey: false,
altKey: false,
ctrlKey: false,
shiftKey: false,
currentTarget: {
hasAttribute: () => false,
},
...overrides,
} as ReactMouseEvent<HTMLAnchorElement>;
}
afterEach(() => {
delete globalThis.__paperclipPluginBridge__;
});
describe("plugin host navigation", () => {
it("resolves plugin page routes into the active company prefix", () => {
expect(resolveHostNavigationHref("/wiki", "PAP")).toBe("/PAP/wiki");
expect(resolveHostNavigationHref("/wiki?tab=browse#page", "pap")).toBe(
"/PAP/wiki?tab=browse#page",
);
});
it("does not double-prefix active company paths or global host paths", () => {
expect(resolveHostNavigationHref("/PAP/wiki", "PAP")).toBe("/PAP/wiki");
expect(resolveHostNavigationHref("/pap/wiki", "PAP")).toBe("/pap/wiki");
expect(resolveHostNavigationHref("/instance/settings/plugins", "PAP")).toBe(
"/instance/settings/plugins",
);
});
it("intercepts only same-origin plain left-click navigation", () => {
expect(shouldHandleHostNavigationClick(clickEvent(), "/PAP/wiki")).toBe(true);
expect(
shouldHandleHostNavigationClick(clickEvent({ ctrlKey: true }), "/PAP/wiki"),
).toBe(false);
expect(
shouldHandleHostNavigationClick(clickEvent(), "/PAP/wiki", "_blank"),
).toBe(false);
expect(
shouldHandleHostNavigationClick(clickEvent(), "https://example.com/wiki"),
).toBe(false);
});
});
describe("useHostNavigation mobile drawer behavior", () => {
// React 19's `act` requires the env flag and React DOM client.
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function makeBridgeValue(): PluginBridgeContextValue {
return {
pluginId: "test-plugin",
hostContext: {
companyId: "co",
companyPrefix: "PAP",
projectId: null,
entityId: null,
entityType: null,
userId: null,
},
};
}
function setViewport(width: number) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: width,
});
if (typeof window.matchMedia !== "function") {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: (query: string) => ({
matches: /max-width:\s*767px/.test(query) ? width < 768 : false,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}),
});
}
}
it("closes the sidebar drawer on mobile after a same-origin navigate()", () => {
setViewport(390);
let nav: ReturnType<typeof useHostNavigation> | null = null;
let sidebar: ReturnType<typeof useSidebar> | null = null;
function Probe() {
nav = useHostNavigation();
sidebar = useSidebar();
return null;
}
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
React.createElement(
MemoryRouter,
{ initialEntries: ["/PAP/wiki"] },
React.createElement(
SidebarProvider,
null,
React.createElement(
PluginBridgeContext.Provider,
{ value: makeBridgeValue() },
React.createElement(Probe),
),
),
),
);
});
expect(sidebar!.isMobile).toBe(true);
act(() => sidebar!.setSidebarOpen(true));
expect(sidebar!.sidebarOpen).toBe(true);
act(() => nav!.navigate("/wiki?section=ingest"));
expect(sidebar!.sidebarOpen).toBe(false);
act(() => root.unmount());
container.remove();
});
it("leaves the sidebar open on desktop after navigate()", () => {
setViewport(1280);
let nav: ReturnType<typeof useHostNavigation> | null = null;
let sidebar: ReturnType<typeof useSidebar> | null = null;
function Probe() {
nav = useHostNavigation();
sidebar = useSidebar();
return null;
}
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
React.createElement(
MemoryRouter,
{ initialEntries: ["/PAP/wiki"] },
React.createElement(
SidebarProvider,
null,
React.createElement(
PluginBridgeContext.Provider,
{ value: makeBridgeValue() },
React.createElement(Probe),
),
),
),
);
});
expect(sidebar!.isMobile).toBe(false);
expect(sidebar!.sidebarOpen).toBe(true);
act(() => nav!.navigate("/wiki?section=ingest"));
expect(sidebar!.sidebarOpen).toBe(true);
act(() => root.unmount());
container.remove();
});
});
describe("plugin SDK FileTree bridge", () => {
const nodes: SdkFileTreeNode[] = [
{
name: "wiki",
path: "wiki",
kind: "dir",
children: [
{
name: "index.md",
path: "wiki/index.md",
kind: "file",
children: [],
},
],
},
];
it("injects the host FileTree implementation through the bridge runtime", () => {
initPluginBridge(React, ReactDOM);
const html = renderToStaticMarkup(
React.createElement(SdkFileTree, {
nodes,
expandedPaths: ["wiki"],
selectedFile: "wiki/index.md",
onToggleDir: () => undefined,
onSelectFile: () => undefined,
}),
);
expect(html).toContain('role="tree"');
expect(html).toContain("wiki");
expect(html).toContain("index.md");
});
it("throws a clear error when the host FileTree implementation is missing", () => {
globalThis.__paperclipPluginBridge__ = {
react: React,
reactDom: ReactDOM,
sdkUi: {},
};
expect(() =>
renderToStaticMarkup(
React.createElement(SdkFileTree, {
nodes,
expandedPaths: ["wiki"],
onToggleDir: () => undefined,
onSelectFile: () => undefined,
}),
),
).toThrow('Paperclip plugin UI runtime is not initialized for "FileTree"');
});
});
describe("plugin SDK markdown component bridge", () => {
it("injects markdown display and editor components through the bridge runtime", () => {
initPluginBridge(React, ReactDOM);
const registry = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
expect(registry.MarkdownBlock).toBeTypeOf("function");
expect(registry.MarkdownEditor).toBeTypeOf("function");
expect(registry.IssuesList).toBeTypeOf("function");
expect(registry.AssigneePicker).toBeTypeOf("function");
expect(registry.ProjectPicker).toBeTypeOf("function");
expect(registry.ManagedRoutinesList).toBeTypeOf("function");
});
it("renders plugin-provided markdown components when registered by the host", () => {
globalThis.__paperclipPluginBridge__ = {
react: React,
reactDom: ReactDOM,
sdkUi: {
MarkdownBlock: ({ content, enableWikiLinks, wikiLinkRoot }: { content: string; enableWikiLinks?: boolean; wikiLinkRoot?: string }) =>
React.createElement("article", {
"data-wiki-links": enableWikiLinks ? "true" : "false",
"data-wiki-root": wikiLinkRoot,
}, content),
MarkdownEditor: ({ value }: { value: string }) =>
React.createElement("textarea", { value, readOnly: true }),
ManagedRoutinesList: ({ routines }: { routines: Array<{ title: string }> }) =>
React.createElement("section", null, routines.map((routine) => routine.title).join(", ")),
},
};
const markdownHtml = renderToStaticMarkup(React.createElement(SdkMarkdownBlock, {
content: "# Wiki",
enableWikiLinks: true,
wikiLinkRoot: "/wiki/page",
}));
expect(markdownHtml).toContain("# Wiki");
expect(markdownHtml).toContain('data-wiki-links="true"');
expect(markdownHtml).toContain('data-wiki-root="/wiki/page"');
expect(renderToStaticMarkup(React.createElement(SdkMarkdownEditor, { value: "# Wiki", onChange: () => undefined }))).toContain("# Wiki");
expect(renderToStaticMarkup(React.createElement(SdkManagedRoutinesList, {
routines: [{ key: "lint", title: "Run lint", status: "active" }],
}))).toContain("Run lint");
});
});

View file

@ -25,7 +25,8 @@
* @see PLUGIN_SPEC.md §19.7 Error Propagation Through The Bridge
*/
import { createContext, useCallback, useContext, useRef, useState, useEffect } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import { useLocation as useRouterLocation, useNavigate as useRouterNavigate, type NavigateOptions } from "react-router-dom";
import type {
PluginBridgeErrorCode,
PluginLauncherBounds,
@ -35,6 +36,8 @@ import type {
import { pluginsApi } from "@/api/plugins";
import { ApiError } from "@/api/client";
import { useToastActions, type ToastInput } from "@/context/ToastContext";
import { useSidebar } from "@/context/SidebarContext";
import { isGlobalPath, normalizeCompanyPrefix } from "@/lib/company-routes";
// ---------------------------------------------------------------------------
// Bridge error type (mirrors the SDK's PluginBridgeError)
@ -63,6 +66,36 @@ export interface PluginDataResult<T = unknown> {
export type PluginToastInput = ToastInput;
export type PluginToastFn = (input: PluginToastInput) => string | null;
export interface HostNavigationOptions {
replace?: boolean;
state?: unknown;
}
export interface HostNavigationLinkOptions extends HostNavigationOptions {
target?: string;
rel?: string;
}
export interface HostNavigationLinkProps {
href: string;
target?: string;
rel?: string;
onClick(event: ReactMouseEvent<HTMLAnchorElement>): void;
}
export interface HostNavigation {
resolveHref(to: string): string;
navigate(to: string, options?: HostNavigationOptions): void;
linkProps(to: string, options?: HostNavigationLinkOptions): HostNavigationLinkProps;
}
export interface HostLocation {
pathname: string;
search: string;
hash: string;
state?: unknown;
}
// ---------------------------------------------------------------------------
// Host context type (mirrors the SDK's PluginHostContext)
// ---------------------------------------------------------------------------
@ -220,6 +253,81 @@ function serializeRenderEnvironmentSnapshot(
return snapshot ? JSON.stringify(snapshot) : "";
}
function splitPath(path: string): { pathname: string; search: string; hash: string } {
const match = path.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
return {
pathname: match?.[1] ?? path,
search: match?.[2] ?? "",
hash: match?.[3] ?? "",
};
}
function sameOriginPathFromHref(href: string): string | null {
if (!/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) {
return href;
}
if (typeof window === "undefined") return null;
try {
const url = new URL(href, window.location.origin);
if (url.origin !== window.location.origin) return null;
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return null;
}
}
function hasCompanyPrefix(pathname: string, companyPrefix: string): boolean {
const [firstSegment] = pathname.split("/").filter(Boolean);
return firstSegment?.toUpperCase() === normalizeCompanyPrefix(companyPrefix);
}
/**
* Resolve a plugin-provided Paperclip path to the active company scope.
*
* This intentionally handles plugin page roots such as `/wiki`, which cannot
* be listed in the host router's static board-route table ahead of time.
*/
export function resolveHostNavigationHref(
to: string,
companyPrefix: string | null | undefined,
): string {
const sameOriginPath = sameOriginPathFromHref(to);
if (sameOriginPath === null) return to;
const { pathname, search, hash } = splitPath(sameOriginPath);
if (!pathname.startsWith("/") || isGlobalPath(pathname) || !companyPrefix) {
return sameOriginPath;
}
if (hasCompanyPrefix(pathname, companyPrefix)) {
return sameOriginPath;
}
return `/${normalizeCompanyPrefix(companyPrefix)}${pathname}${search}${hash}`;
}
function isPlainLeftClick(event: ReactMouseEvent<HTMLAnchorElement>): boolean {
return (
!event.defaultPrevented &&
event.button === 0 &&
!event.metaKey &&
!event.altKey &&
!event.ctrlKey &&
!event.shiftKey
);
}
export function shouldHandleHostNavigationClick(
event: ReactMouseEvent<HTMLAnchorElement>,
href: string,
target?: string,
): boolean {
if (!isPlainLeftClick(event)) return false;
if (target && target !== "_self") return false;
if (event.currentTarget.hasAttribute("download")) return false;
return sameOriginPathFromHref(href) !== null;
}
/**
* Concrete implementation of `usePluginData<T>(key, params)`.
*
@ -364,6 +472,81 @@ export function useHostContext(): PluginHostContext {
return hostContext;
}
// ---------------------------------------------------------------------------
// useHostNavigation — concrete implementation
// ---------------------------------------------------------------------------
export function useHostNavigation(): HostNavigation {
const { hostContext } = usePluginBridgeContext();
const routerNavigate = useRouterNavigate();
const { isMobile, setSidebarOpen } = useSidebar();
const companyPrefix = hostContext.companyPrefix;
const resolveHref = useCallback(
(to: string) => resolveHostNavigationHref(to, companyPrefix),
[companyPrefix],
);
const navigate = useCallback(
(to: string, options?: HostNavigationOptions) => {
const href = resolveHref(to);
const sameOriginPath = sameOriginPathFromHref(href);
if (sameOriginPath === null) {
window.location.assign(href);
return;
}
routerNavigate(sameOriginPath, options as NavigateOptions | undefined);
// Mirror host sidebar behavior: tapping a link inside the mobile drawer
// dismisses the drawer so the user can see the destination page.
if (isMobile) setSidebarOpen(false);
},
[isMobile, resolveHref, routerNavigate, setSidebarOpen],
);
const linkProps = useCallback(
(to: string, options?: HostNavigationLinkOptions): HostNavigationLinkProps => {
const href = resolveHref(to);
return {
href,
target: options?.target,
rel: options?.rel,
onClick: (event) => {
if (!shouldHandleHostNavigationClick(event, href, options?.target)) return;
event.preventDefault();
navigate(href, options);
},
};
},
[navigate, resolveHref],
);
return useMemo(
() => ({
resolveHref,
navigate,
linkProps,
}),
[linkProps, navigate, resolveHref],
);
}
// ---------------------------------------------------------------------------
// useHostLocation — concrete implementation
// ---------------------------------------------------------------------------
export function useHostLocation(): HostLocation {
const location = useRouterLocation();
return useMemo(
() => ({
pathname: location.pathname,
search: location.search,
hash: location.hash,
state: location.state,
}),
[location.hash, location.pathname, location.search, location.state],
);
}
// ---------------------------------------------------------------------------
// usePluginToast — concrete implementation
// ---------------------------------------------------------------------------

View file

@ -63,6 +63,33 @@ export type ResolvedPluginSlot = PluginUiSlotDeclaration & {
pluginVersion: string;
};
/**
* Returns the unique `routeSidebar` slot that pairs with a single `page` slot
* for the given route, or `null` if no unambiguous pairing exists.
*
* Used to detect when a route is taken over by a plugin's full-page sidebar so
* host chrome (breadcrumb, in-page Back) can be suppressed.
*/
export function resolveRouteSidebarSlot(
slots: ResolvedPluginSlot[],
routePath: string | null,
): ResolvedPluginSlot | null {
if (!routePath) return null;
const pageMatches = slots.filter((slot) => slot.type === "page" && slot.routePath === routePath);
if (pageMatches.length !== 1) return null;
const [pageSlot] = pageMatches;
const sidebarMatches = slots.filter((slot) =>
slot.type === "routeSidebar"
&& slot.routePath === routePath
&& slot.pluginId === pageSlot.pluginId,
);
if (sidebarMatches.length !== 1) return null;
return sidebarMatches[0] ?? null;
}
type PluginSlotComponentProps = {
slot: ResolvedPluginSlot;
context: PluginSlotContext;
@ -257,8 +284,30 @@ function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" |
case "sdk-ui":
source = `
const SDK = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
const { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast } = SDK;
export { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast };
function missing(name) {
return function MissingPaperclipSdkUiComponent() {
throw new Error('Paperclip plugin UI runtime is not initialized for "' + name + '". Ensure the host loaded the plugin bridge before rendering this UI module.');
};
}
const { usePluginData, usePluginAction, useHostContext, useHostLocation, useHostNavigation, usePluginStream, usePluginToast } = SDK;
const MetricCard = SDK.MetricCard ?? missing("MetricCard");
const StatusBadge = SDK.StatusBadge ?? missing("StatusBadge");
const DataTable = SDK.DataTable ?? missing("DataTable");
const TimeseriesChart = SDK.TimeseriesChart ?? missing("TimeseriesChart");
const MarkdownBlock = SDK.MarkdownBlock ?? missing("MarkdownBlock");
const MarkdownEditor = SDK.MarkdownEditor ?? missing("MarkdownEditor");
const KeyValueList = SDK.KeyValueList ?? missing("KeyValueList");
const ActionBar = SDK.ActionBar ?? missing("ActionBar");
const LogView = SDK.LogView ?? missing("LogView");
const JsonTree = SDK.JsonTree ?? missing("JsonTree");
const Spinner = SDK.Spinner ?? missing("Spinner");
const ErrorBoundary = SDK.ErrorBoundary ?? missing("ErrorBoundary");
const FileTree = SDK.FileTree ?? missing("FileTree");
const IssuesList = SDK.IssuesList ?? missing("IssuesList");
const AssigneePicker = SDK.AssigneePicker ?? missing("AssigneePicker");
const ProjectPicker = SDK.ProjectPicker ?? missing("ProjectPicker");
const ManagedRoutinesList = SDK.ManagedRoutinesList ?? missing("ManagedRoutinesList");
export { usePluginData, usePluginAction, useHostContext, useHostLocation, useHostNavigation, usePluginStream, usePluginToast, MetricCard, StatusBadge, DataTable, TimeseriesChart, MarkdownBlock, MarkdownEditor, KeyValueList, ActionBar, LogView, JsonTree, Spinner, ErrorBoundary, FileTree, IssuesList, AssigneePicker, ProjectPicker, ManagedRoutinesList };
`;
break;
}