diff --git a/ui/src/pages/ProjectDetail.test.tsx b/ui/src/pages/ProjectDetail.test.tsx
index 9b6f688f5c..22cca9127a 100644
--- a/ui/src/pages/ProjectDetail.test.tsx
+++ b/ui/src/pages/ProjectDetail.test.tsx
@@ -33,6 +33,16 @@ const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockIssuesList = vi.hoisted(() => vi.fn());
const mockSummarySlotCard = vi.hoisted(() => vi.fn());
+const mockLocation = vi.hoisted(() => ({
+ pathname: "/projects/project-1/plugin-operations",
+ search: "",
+}));
+const mockCompanyContext = vi.hoisted(() => ({
+ companies: [{ id: "company-1", issuePrefix: "PAP" }] as Array<{ id: string; issuePrefix: string }>,
+ selectedCompanyId: "company-1" as string | null,
+}));
+const mockUsePluginSlots = vi.hoisted(() => vi.fn(() => ({ slots: [] as unknown[], isLoading: false })));
+const mockPluginSlotMount = vi.hoisted(() => vi.fn());
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
@@ -47,15 +57,15 @@ vi.mock("../api/resourceMemberships", () => ({ resourceMembershipsApi: mockResou
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children?: ReactNode; to: string }) => {children},
Navigate: ({ to }: { to: string }) =>
{to}
,
- useLocation: () => ({ pathname: "/projects/project-1/plugin-operations", search: "", hash: "", state: null }),
+ useLocation: () => ({ pathname: mockLocation.pathname, search: mockLocation.search, hash: "", state: null }),
useNavigate: () => mockNavigate,
useParams: () => ({ projectId: "project-1" }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
- companies: [{ id: "company-1", issuePrefix: "PAP" }],
- selectedCompanyId: "company-1",
+ companies: mockCompanyContext.companies,
+ selectedCompanyId: mockCompanyContext.selectedCompanyId,
setSelectedCompanyId: vi.fn(),
}),
}));
@@ -63,9 +73,12 @@ vi.mock("../context/PanelContext", () => ({ usePanel: () => ({ closePanel: vi.fn
vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) }));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) }));
vi.mock("@/plugins/slots", () => ({
- PluginSlotMount: () => null,
+ PluginSlotMount: (props: unknown) => {
+ mockPluginSlotMount(props);
+ return ;
+ },
PluginSlotOutlet: () => null,
- usePluginSlots: () => ({ slots: [], isLoading: false }),
+ usePluginSlots: mockUsePluginSlots,
}));
vi.mock("@/plugins/launchers", () => ({ PluginLauncherOutlet: () => null }));
vi.mock("../components/ProjectProperties", () => ({
@@ -166,6 +179,11 @@ describe("ProjectDetail", () => {
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
+ mockLocation.pathname = "/projects/project-1/plugin-operations";
+ mockLocation.search = "";
+ mockCompanyContext.companies = [{ id: "company-1", issuePrefix: "PAP" }];
+ mockCompanyContext.selectedCompanyId = "company-1";
+ mockUsePluginSlots.mockReturnValue({ slots: [], isLoading: false });
mockProjectsApi.get.mockResolvedValue(project());
mockProjectsApi.list.mockResolvedValue([project()]);
mockIssuesApi.list.mockResolvedValue([]);
@@ -227,4 +245,85 @@ describe("ProjectDetail", () => {
originKindPrefix: "plugin:paperclip.missions",
});
});
+
+ describe("plugin detail-tab deep links", () => {
+ const PLUGIN_TAB = "plugin:paperclipai.plugin-llm-wiki:project-knowledge";
+ const knowledgeSlot = {
+ id: "project-knowledge",
+ type: "detailTab",
+ displayName: "Knowledge",
+ entityTypes: ["project"],
+ pluginId: "plugin-llm-wiki",
+ pluginKey: "paperclipai.plugin-llm-wiki",
+ pluginDisplayName: "LLM Wiki",
+ pluginVersion: "0.2.0",
+ };
+
+ async function renderDetail() {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ await act(async () => {
+ root = createRoot(container);
+ root.render(
+
+
+ ,
+ );
+ });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ }
+
+ it("keeps a cold deep link on the plugin tab while contributions are still loading", async () => {
+ mockLocation.pathname = "/projects/project-1";
+ mockLocation.search = `?tab=${encodeURIComponent(PLUGIN_TAB)}`;
+ mockUsePluginSlots.mockReturnValue({ slots: [], isLoading: true });
+
+ await renderDetail();
+
+ expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
+ expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull();
+ });
+
+ it("does not bounce to the issues tab before the company for the slots query resolves", async () => {
+ // Cold reload race: the slots query is disabled until a company is
+ // known, and a disabled query reports isLoading=false.
+ mockLocation.pathname = "/projects/project-1";
+ mockLocation.search = `?tab=${encodeURIComponent(PLUGIN_TAB)}`;
+ mockCompanyContext.companies = [];
+ mockCompanyContext.selectedCompanyId = null;
+ mockUsePluginSlots.mockReturnValue({ slots: [], isLoading: false });
+
+ await renderDetail();
+
+ expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
+ });
+
+ it("renders the registered plugin tab for a direct deep link", async () => {
+ mockLocation.pathname = "/projects/project-1";
+ mockLocation.search = `?tab=${encodeURIComponent(PLUGIN_TAB)}`;
+ mockUsePluginSlots.mockReturnValue({ slots: [knowledgeSlot], isLoading: false });
+
+ await renderDetail();
+
+ expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
+ expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull();
+ expect(mockPluginSlotMount).toHaveBeenCalledWith(expect.objectContaining({
+ slot: expect.objectContaining({ id: "project-knowledge", pluginKey: "paperclipai.plugin-llm-wiki" }),
+ }));
+ expect(container.textContent).toContain("Knowledge");
+ });
+
+ it("falls back to the issues tab once contributions load without the requested tab", async () => {
+ mockLocation.pathname = "/projects/project-1";
+ mockLocation.search = `?tab=${encodeURIComponent(PLUGIN_TAB)}`;
+ mockUsePluginSlots.mockReturnValue({ slots: [], isLoading: false });
+
+ await renderDetail();
+
+ expect(container.querySelector('[data-testid="navigate"]')?.textContent)
+ .toBe("/projects/project-1/issues");
+ });
+ });
});
diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx
index 7041a4a792..db612c6ce0 100644
--- a/ui/src/pages/ProjectDetail.tsx
+++ b/ui/src/pages/ProjectDetail.tsx
@@ -430,6 +430,10 @@ export function ProjectDetail() {
[pluginDetailSlots],
);
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
+ // The slots query is disabled until the project's company resolves, and a
+ // disabled query reports isLoading=false — so "not loading" alone cannot
+ // distinguish "contribution unavailable" from "not asked yet" on cold loads.
+ const pluginTabDecisionLoaded = Boolean(resolvedCompanyId) && !pluginDetailSlotsLoading;
const isolatedWorkspacesEnabled = experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true;
const workspaceTabProjectId = project?.id ?? null;
const { data: workspaceTabIssues = [], isLoading: isWorkspaceTabIssuesLoading, error: workspaceTabIssuesError } = useQuery({
@@ -673,7 +677,10 @@ export function ProjectDetail() {
},
});
- if (pluginTabFromSearch && !pluginDetailSlotsLoading && !activePluginTab) {
+ if (pluginTabFromSearch && !activePluginTab && !error) {
+ if (!pluginTabDecisionLoaded) {
+ return ;
+ }
return ;
}
diff --git a/ui/src/plugins/bridge-init.ts b/ui/src/plugins/bridge-init.ts
index 81800a041c..5cccc7769b 100644
--- a/ui/src/plugins/bridge-init.ts
+++ b/ui/src/plugins/bridge-init.ts
@@ -22,6 +22,7 @@ import {
usePluginToast,
} from "./bridge.js";
import { Component, createElement, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
+import * as ReactJsxRuntimeModule from "react/jsx-runtime";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { User } from "lucide-react";
import {
@@ -72,6 +73,14 @@ import { copyTextToClipboard } from "@/lib/clipboard";
*/
export interface PluginBridgeRegistry {
react: unknown;
+ /**
+ * The host's real `react/jsx-runtime` module. Plugin bundles compiled with
+ * the automatic JSX transform must use these `jsx`/`jsxs` implementations:
+ * reconstructing them via `createElement(type, { children })` loses React's
+ * static-children marking, so dev React demands a key on every multi-child
+ * element inside plugin components.
+ */
+ reactJsxRuntime: unknown;
reactDom: unknown;
sdkUi: Record;
}
@@ -673,6 +682,7 @@ export function initPluginBridge(
): void {
globalThis.__paperclipPluginBridge__ = {
react,
+ reactJsxRuntime: ReactJsxRuntimeModule,
reactDom,
sdkUi: {
usePluginData,
diff --git a/ui/src/plugins/bridge.test.ts b/ui/src/plugins/bridge.test.ts
index 368a283e3c..651db57eff 100644
--- a/ui/src/plugins/bridge.test.ts
+++ b/ui/src/plugins/bridge.test.ts
@@ -1,12 +1,13 @@
// @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 } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import {
FileTree as SdkFileTree,
ManagedRoutinesList as SdkManagedRoutinesList,
@@ -254,6 +255,7 @@ describe("plugin SDK FileTree bridge", () => {
it("throws a clear error when the host FileTree implementation is missing", () => {
globalThis.__paperclipPluginBridge__ = {
react: React,
+ reactJsxRuntime: ReactJsxRuntime,
reactDom: ReactDOM,
sdkUi: {},
};
@@ -287,6 +289,7 @@ describe("plugin SDK markdown component bridge", () => {
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 }) =>
@@ -333,3 +336,42 @@ describe("plugin React shim", () => {
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([]);
+ });
+});
diff --git a/ui/src/plugins/slots.tsx b/ui/src/plugins/slots.tsx
index d93455a883..f5bf01866d 100644
--- a/ui/src/plugins/slots.tsx
+++ b/ui/src/plugins/slots.tsx
@@ -311,12 +311,19 @@ function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" |
source = createReactShimSource(ReactModule);
break;
case "react/jsx-runtime":
+ // Prefer the host's real jsx runtime: rebuilding jsx/jsxs on top of
+ // createElement(type, { children }) drops React's static-children
+ // marking, so dev React emits "unique key" warnings for every
+ // multi-child element rendered by a plugin component.
source = `
- const R = globalThis.__paperclipPluginBridge__?.react;
+ const BRIDGE = globalThis.__paperclipPluginBridge__;
+ const RUNTIME = BRIDGE?.reactJsxRuntime;
+ const R = BRIDGE?.react;
const withKey = ${applyJsxRuntimeKey.toString()};
- export const jsx = (type, props, key) => R.createElement(type, withKey(props, key));
- export const jsxs = (type, props, key) => R.createElement(type, withKey(props, key));
- export const Fragment = R.Fragment;
+ const fallbackJsx = (type, props, key) => R.createElement(type, withKey(props, key));
+ export const jsx = RUNTIME?.jsx ?? fallbackJsx;
+ export const jsxs = RUNTIME?.jsxs ?? fallbackJsx;
+ export const Fragment = RUNTIME?.Fragment ?? R.Fragment;
`;
break;
case "react-dom":