mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 23:39:21 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Plugins can contribute detail tabs to project pages, and plugin UI
bundles render through a host-provided React bridge
> - A cold load of a plugin-tab deep link redirected to the Issues tab,
because the plugin-slots query is disabled until the project's company
resolves and a disabled query reports `isLoading: false`
> - The redirect made shareable plugin-tab URLs and browser
reload/back/forward unreliable for every detail-tab plugin
> - Separately, the bridge shim rebuilt `jsx`/`jsxs` on top of
`createElement(type, { children })`, which drops React's static-children
marking and floods the dev console with "unique key" warnings from
plugin components
> - This pull request holds the tab decision on a skeleton until
contributions actually load, and exposes the host's real
`react/jsx-runtime` on the plugin bridge with the old shim as fallback
> - The benefit is that plugin detail tabs survive direct open, reload,
back, and forward, and plugin surfaces stop emitting spurious React key
warnings
## Linked Issues or Issue Description
**What happened**
Opening a project URL with `?tab=plugin:<publisher>.<plugin>:<tab>`
directly, or reloading while on such a tab, redirected to the project's
Issues tab even though the plugin contribution was registered and
available. The dev console also showed "each child in a list should have
a unique key" warnings pointing at plugin-rendered components.
**Expected behavior**
Direct open, reload, back, and forward keep the requested plugin tab
when its contribution is available. When the contribution truly does not
exist, the page still falls back safely to the Issues tab. Plugin
components with static multi-child JSX render without React key
warnings.
**Steps to reproduce**
1. Install a plugin that contributes a project detail tab.
2. Open the tab, copy the URL, and open it in a new browser tab (or
press reload).
3. Observe the redirect to `/projects/<ref>/issues` before the
plugin-slots query has ever run.
4. With React in development mode, open any plugin tab that renders
sibling elements and observe key warnings in the console.
**Version or commit**
Reproduced on `master` at `733ffbf7c`.
**Deployment mode**
Local development instance (managed runtime).
## What Changed
- `ui/src/pages/ProjectDetail.tsx`: the plugin-tab fallback now waits
until the company is resolved and the plugin-slots query has finished
before it decides. While the decision is pending it renders the detail
`PageSkeleton` instead of navigating away. Loading a project that fails
to load still shows the error state.
- `ui/src/plugins/bridge-init.ts`: the plugin bridge registry now
exposes the host's real `react/jsx-runtime` module as `reactJsxRuntime`.
- `ui/src/plugins/slots.tsx`: the `react/jsx-runtime` shim served to
plugin bundles prefers the bridged runtime's `jsx`/`jsxs`/`Fragment` and
keeps the previous `createElement`-based implementation as a fallback.
- `ui/src/pages/ProjectDetail.test.tsx`: new tests for cold deep links —
pending slots query, disabled query before company resolution,
registered tab render, and fallback when the tab is not contributed.
- `ui/src/plugins/bridge.test.ts`: new tests that the bridge exposes the
real runtime and that unkeyed static children render through it without
key warnings.
## Verification
- `npx vitest run src/pages/ProjectDetail.test.tsx
src/plugins/bridge.test.ts` in `ui/` — 2 files, 18 tests, all pass.
- `pnpm check:token-gates` — all gates clean.
- Manual: open a project with a plugin detail tab, switch to the tab,
reload the browser, use back/forward, and open the URL in a fresh tab.
The tab persists. Remove the plugin and open the same URL. The page
falls back to the Issues tab.
## Risks
- Low risk. The fallback redirect still happens whenever contributions
finish loading without the requested tab; the change only defers it
until the answer is known, so an unavailable contribution cannot get
stuck on the skeleton.
- The jsx-runtime shim keeps the old `createElement` path as a fallback
when the bridged runtime is absent, so older bridge registries keep
working.
## Model Used
- Claude (Anthropic) — `claude-fable-5`, extended thinking enabled,
agentic tool use (file edits, shell, test execution) via Claude Code.
## 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 similar PRs and linked
related PRs above where they exist
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` 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)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
— *the branch predates this rule; squash-merge keeps the branch name off
`master`*
- [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
(code-level docs; no user-facing docs affected)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green — *pending first CI run on this
PR*
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
*pending first review*
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
// @vitest-environment jsdom
|
|
import * as React from "react";
|
|
import * as ReactDOM from "react-dom";
|
|
import * as ReactJsxRuntime from "react/jsx-runtime";
|
|
import { flushSync } from "react-dom";
|
|
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, vi } 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";
|
|
import { _createReactShimSourceForTests } from "./slots";
|
|
|
|
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__;
|
|
});
|
|
|
|
function act(callback: () => void) {
|
|
flushSync(callback);
|
|
}
|
|
|
|
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");
|
|
});
|
|
|
|
it("rewrites legacy instance settings paths into the active company settings scope", () => {
|
|
expect(resolveHostNavigationHref("/instance/settings/plugins", "PAP")).toBe(
|
|
"/PAP/company/settings/instance/plugins",
|
|
);
|
|
expect(resolveHostNavigationHref("/settings/experimental?x=1#auto", "pap")).toBe(
|
|
"/PAP/company/settings/instance/experimental?x=1#auto",
|
|
);
|
|
});
|
|
|
|
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,
|
|
reactJsxRuntime: ReactJsxRuntime,
|
|
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,
|
|
reactJsxRuntime: ReactJsxRuntime,
|
|
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");
|
|
});
|
|
});
|
|
|
|
describe("plugin React shim", () => {
|
|
it("re-exports every named export from the host React module", () => {
|
|
const source = _createReactShimSourceForTests(React);
|
|
|
|
for (const name of Object.keys(React).sort()) {
|
|
if (name === "default") continue;
|
|
if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
|
|
expect(source).toContain(`export const ${name} = R.${name};`);
|
|
}
|
|
|
|
expect(source).toContain("export default R;");
|
|
expect(source).toContain("export const useInsertionEffect = R.useInsertionEffect;");
|
|
expect(source).toContain("export const useId = R.useId;");
|
|
expect(source).toContain("export const useSyncExternalStore = R.useSyncExternalStore;");
|
|
expect(source).toContain("export const startTransition = R.startTransition;");
|
|
});
|
|
});
|
|
|
|
describe("plugin jsx runtime bridge", () => {
|
|
it("exposes the host jsx runtime on the bridge registry", () => {
|
|
initPluginBridge(React, ReactDOM);
|
|
|
|
const runtime = globalThis.__paperclipPluginBridge__?.reactJsxRuntime as typeof ReactJsxRuntime;
|
|
expect(runtime.jsx).toBeTypeOf("function");
|
|
expect(runtime.jsxs).toBeTypeOf("function");
|
|
expect(runtime.Fragment).toBe(ReactJsxRuntime.Fragment);
|
|
});
|
|
|
|
it("renders unkeyed static children through the bridged runtime without key warnings", () => {
|
|
initPluginBridge(React, ReactDOM);
|
|
const runtime = globalThis.__paperclipPluginBridge__?.reactJsxRuntime as typeof ReactJsxRuntime;
|
|
|
|
// Mirror what a compiled plugin bundle emits for static multi-child JSX:
|
|
// jsxs() with an unkeyed children array. The previous shim rebuilt this
|
|
// via createElement(type, { children }), which made dev React demand a
|
|
// key on every static child (LOOA-1547 Panel/ProjectKnowledgeTab noise).
|
|
const warnings: string[] = [];
|
|
const spy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
|
warnings.push(args.map(String).join(" "));
|
|
});
|
|
try {
|
|
renderToStaticMarkup(
|
|
runtime.jsxs("section", {
|
|
children: [
|
|
runtime.jsx("header", { children: "static child one" }),
|
|
runtime.jsx("div", { children: "static child two" }),
|
|
],
|
|
}) as React.ReactElement,
|
|
);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
|
|
expect(warnings.filter((message) => message.includes("key"))).toEqual([]);
|
|
});
|
|
});
|