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":