From 8e59efc50b162126f33816841e06509634eba65c Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 8 Oct 2026 12:37:55 -0700 Subject: [PATCH] fix(ui): keep sidebar plugin outlets quiet when the server is unreachable (#15588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The board UI sidebar shows plugin contributions: plugin nav slots, plugin launchers, and the plugin panel at the bottom > - These outlets read `GET /plugins/ui-contributions` through React Query > - When the server is unavailable, the refetch fails. Each outlet then replaces its content with a red "Plugin extensions unavailable: Failed to fetch" or "Plugin launchers unavailable" box > - React Query still has the last good data, so the error box removes useful content and adds noise in a persistent part of the UI > - This pull request adds an option to hide the outlet error, and the sidebar uses it > - The benefit is a quiet sidebar during a server outage. The sidebar keeps the last loaded plugin items. Other pages still show the error inline ## Linked Issues or Issue Description No public issue exists. I searched open and closed issues and PRs for related work and found none. **What happened?** When the server is unavailable, the sidebar shows red error boxes in place of plugin items. The text is "Plugin extensions unavailable: Failed to fetch" or "Plugin launchers unavailable: Failed to fetch". **Expected behavior** The sidebar does not show errors while the server is unavailable. It keeps the last loaded content. **Steps to reproduce** 1. Start Paperclip with at least one plugin that contributes a `sidebar` slot, a `sidebar` launcher, or a `sidebarPanel` slot. 2. Open the board UI and let the sidebar load. 3. Stop the server. 4. Wait for the next refetch of `/plugins/ui-contributions`, or focus the window. 5. See the red error boxes in the sidebar. **Paperclip version or commit** `master` at 0ac194450a48a407450921a16c3ef8684dcb85ca **Deployment mode** Not deployment-specific. This is UI behavior. ## What Changed - `PluginSlotOutlet` (`ui/src/plugins/slots.tsx`) and `PluginLauncherOutlet` (`ui/src/plugins/launchers.tsx`) accept a new `errorBehavior: "inline" | "hidden"` prop. The default is `"inline"`, so current behavior does not change. - With `"hidden"`, the outlet does not render the error box. It continues to render the last loaded contributions. - `Sidebar.tsx` and `Sidebar.production.tsx` pass `errorBehavior="hidden"` to the three sidebar outlets. - New test `ui/src/plugins/outlet-errors.test.tsx`. It checks that the inline default still shows the error. It also checks that hidden mode keeps the cached `sidebar` slot, `sidebarPanel` slot, and `sidebar` launcher rendered after a failed refetch. A mutation check confirmed the test fails if hidden mode drops the slots or shows the launcher error. ## Verification - `cd ui && pnpm vitest run src/plugins/ src/components/Sidebar.test.tsx`: 55/55 tests pass. - `cd ui && pnpm typecheck`: clean. - `pnpm check:token-gates`: clean. - Browser check on a local `local_trusted` instance with the kitchen-sink example plugin installed (it contributes a `sidebar` slot, a `sidebar` launcher, and a `sidebarPanel` slot). I loaded the board, stopped the server, waited past the 30s stale time, focused the window, and let the retries finish. I took the "before" screenshot with the `master` versions of the four changed UI files, and the "after" screenshot with this branch. - Not run locally: the full `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build`. CI runs them. **Screenshots (sidebar only):** | Server running | Server stopped, before (`master`) | Server stopped, after (this PR) | | --- | --- | --- | | Sidebar with plugin items loaded | Sidebar with three red plugin error boxes | Sidebar keeps Kitchen Sink item and panel, no errors | Main-content plugin outlets, like the dashboard widget, still show the inline error while the server is down. That is intentional, because this PR changes only the sidebar. ## Risks - Low risk. The new prop is opt-in, and the default is unchanged. - If the first load fails in the sidebar, the outlet shows nothing. It does not show an error. Users can still see the error on other plugin surfaces. - This change does not change request timing or retries. Background refetches still run while the server is unavailable. Only the sidebar display changes. ## Model Used - Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, run as an agent in Claude Code with tool use (file edit, shell, test execution). ## 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 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) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [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 - [x] I have considered and documented any risks above - [x] 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Paperclip Co-authored-by: Claude Opus 5.5 --- ui/src/components/Sidebar.production.tsx | 3 + ui/src/components/Sidebar.tsx | 3 + ui/src/plugins/launchers.tsx | 9 ++- ui/src/plugins/outlet-errors.test.tsx | 85 ++++++++++++++++++++++++ ui/src/plugins/slots.tsx | 9 ++- 5 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 ui/src/plugins/outlet-errors.test.tsx diff --git a/ui/src/components/Sidebar.production.tsx b/ui/src/components/Sidebar.production.tsx index 3bcc09b1eab..423c102ccda 100644 --- a/ui/src/components/Sidebar.production.tsx +++ b/ui/src/components/Sidebar.production.tsx @@ -209,6 +209,7 @@ export function Sidebar() { ) : null} ) : null} Plugin launchers unavailable: {errorMessage} diff --git a/ui/src/plugins/outlet-errors.test.tsx b/ui/src/plugins/outlet-errors.test.tsx new file mode 100644 index 00000000000..90c6ce9343c --- /dev/null +++ b/ui/src/plugins/outlet-errors.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { PluginUiContribution } from "@/api/plugins"; +import { pluginsApi } from "@/api/plugins"; +import { queryKeys } from "@/lib/queryKeys"; +import { PluginLauncherOutlet, PluginLauncherProvider } from "./launchers"; +import { _resetPluginModuleLoader, PluginSlotOutlet, registerPluginReactComponent } from "./slots"; + +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompany: null }) })); + +const contribution: PluginUiContribution = { + pluginId: "fixture", pluginKey: "fixture.sidebar", displayName: "Fixture", version: "1", + uiEntryFile: "index.js", + slots: [ + { type: "sidebar", id: "nav", displayName: "Nav", exportName: "SidebarItem" }, + { type: "sidebarPanel", id: "panel", displayName: "Panel", exportName: "SidebarPanel" }, + ], + launchers: [{ + id: "open", displayName: "Fixture launcher", placementZone: "sidebar", + action: { type: "navigate", target: "/fixture" }, + }], +}; +const context = { companyId: "company-1", companyPrefix: "PAP" }; +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; + +async function render(errorBehavior?: "inline" | "hidden") { + await act(async () => root.render( + + + + + + + + + , + )); +} + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + // Module and session fetches stay pending; components are registered directly. + vi.stubGlobal("__paperclipPluginBridge__", {}); + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); + client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); + vi.spyOn(pluginsApi, "listUiContributions").mockRejectedValue(new TypeError("Failed to fetch")); +}); +afterEach(async () => { + await act(async () => root.unmount()); container.remove(); client.clear(); _resetPluginModuleLoader(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); +}); + +it("shows inline errors by default when contributions fail to load", async () => { + await render(); + await vi.waitFor(() => expect(container.textContent).toContain("unavailable")); + expect(container.textContent).toContain("Plugin extensions unavailable: Failed to fetch"); + expect(container.textContent).toContain("Plugin launchers unavailable: Failed to fetch"); +}); + +it("hides errors and keeps the last loaded slots and launchers when errorBehavior is hidden", async () => { + registerPluginReactComponent("fixture.sidebar", "SidebarItem", () => Fixture sidebar item); + registerPluginReactComponent("fixture.sidebar", "SidebarPanel", () => Fixture sidebar panel); + client.setQueryData(queryKeys.plugins.uiContributions, [contribution]); + await render("hidden"); + expect(container.textContent).toContain("Fixture sidebar item"); + expect(container.textContent).toContain("Fixture sidebar panel"); + + await act(async () => { + await client.refetchQueries({ queryKey: queryKeys.plugins.uiContributions }); + // React Query notifies observers on a timer; let the error render land. + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(client.getQueryState(queryKeys.plugins.uiContributions)?.status).toBe("error"); + expect(container.textContent).not.toContain("unavailable"); + expect(container.textContent).toContain("Fixture sidebar item"); + expect(container.textContent).toContain("Fixture sidebar panel"); + expect(container.textContent).toContain("Fixture launcher"); +}); diff --git a/ui/src/plugins/slots.tsx b/ui/src/plugins/slots.tsx index a86c5a7d722..5ea4d700b6f 100644 --- a/ui/src/plugins/slots.tsx +++ b/ui/src/plugins/slots.tsx @@ -905,6 +905,12 @@ type PluginSlotOutletProps = { itemClassName?: string; errorClassName?: string; missingBehavior?: "hidden" | "placeholder"; + /** + * `hidden` suppresses the inline error and keeps rendering the last loaded + * slots, so ambient chrome (the sidebar) stays quiet while the server is + * unreachable. + */ + errorBehavior?: "inline" | "hidden"; }; export function PluginSlotOutlet({ @@ -915,6 +921,7 @@ export function PluginSlotOutlet({ itemClassName, errorClassName, missingBehavior = "hidden", + errorBehavior = "inline", }: PluginSlotOutletProps) { const { slots, errorMessage } = usePluginSlots({ slotTypes, @@ -922,7 +929,7 @@ export function PluginSlotOutlet({ companyId: context.companyId, }); - if (errorMessage) { + if (errorMessage && errorBehavior === "inline") { return (
Plugin extensions unavailable: {errorMessage}