mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
fix(ui): keep sidebar plugin outlets quiet when the server is unreachable (#15588)
## 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 0ac194450a
**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) |
| --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/paperclipai/paperclip/pr-assets/sidebar-plugin-outlets-offline/server-running.png"
width="240" alt="Sidebar with plugin items loaded"> | <img
src="https://raw.githubusercontent.com/paperclipai/paperclip/pr-assets/sidebar-plugin-outlets-offline/before-server-stopped.png"
width="240" alt="Sidebar with three red plugin error boxes"> | <img
src="https://raw.githubusercontent.com/paperclipai/paperclip/pr-assets/sidebar-plugin-outlets-offline/after-server-stopped.png"
width="240" alt="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 <noreply@paperclip.ing>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
5 files changed
+107
-2
No files matched your search
@@ -209,6 +209,7 @@ export function Sidebar() {
|
||||
</>
|
||||
) : null}
|
||||
<PluginSlotOutlet
|
||||
errorBehavior="hidden"
|
||||
slotTypes={["sidebar"]}
|
||||
context={pluginContext}
|
||||
className="flex flex-col gap-0.5"
|
||||
@@ -216,6 +217,7 @@ export function Sidebar() {
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
<PluginLauncherOutlet
|
||||
errorBehavior="hidden"
|
||||
placementZones={["sidebar"]}
|
||||
context={pluginContext}
|
||||
className="flex flex-col gap-0.5"
|
||||
@@ -239,6 +241,7 @@ export function Sidebar() {
|
||||
</SidebarSection>
|
||||
|
||||
<PluginSlotOutlet
|
||||
errorBehavior="hidden"
|
||||
slotTypes={["sidebarPanel"]}
|
||||
context={pluginContext}
|
||||
className="flex flex-col gap-3"
|
||||
|
||||
@@ -253,6 +253,7 @@ export function Sidebar({ children }: { children?: ReactNode }) {
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
) : null}
|
||||
<PluginSlotOutlet
|
||||
errorBehavior="hidden"
|
||||
slotTypes={["sidebar"]}
|
||||
context={pluginContext}
|
||||
className="flex flex-col gap-0.5"
|
||||
@@ -260,6 +261,7 @@ export function Sidebar({ children }: { children?: ReactNode }) {
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
<PluginLauncherOutlet
|
||||
errorBehavior="hidden"
|
||||
placementZones={["sidebar"]}
|
||||
context={pluginContext}
|
||||
className="flex flex-col gap-0.5"
|
||||
@@ -302,6 +304,7 @@ export function Sidebar({ children }: { children?: ReactNode }) {
|
||||
)}
|
||||
|
||||
<PluginSlotOutlet
|
||||
errorBehavior="hidden"
|
||||
slotTypes={["sidebarPanel"]}
|
||||
context={pluginContext}
|
||||
className="flex flex-col gap-3"
|
||||
|
||||
@@ -781,6 +781,12 @@ type PluginLauncherOutletProps = {
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
errorClassName?: string;
|
||||
/**
|
||||
* `hidden` suppresses the inline error and keeps rendering the last loaded
|
||||
* launchers, so ambient chrome (the sidebar) stays quiet while the server is
|
||||
* unreachable.
|
||||
*/
|
||||
errorBehavior?: "inline" | "hidden";
|
||||
};
|
||||
|
||||
export function PluginLauncherOutlet({
|
||||
@@ -790,6 +796,7 @@ export function PluginLauncherOutlet({
|
||||
className,
|
||||
itemClassName,
|
||||
errorClassName,
|
||||
errorBehavior = "inline",
|
||||
}: PluginLauncherOutletProps) {
|
||||
const { activateLauncher } = usePluginLauncherRuntime();
|
||||
const { launchers, contributionsByPluginId, errorMessage } = usePluginLaunchers({
|
||||
@@ -799,7 +806,7 @@ export function PluginLauncherOutlet({
|
||||
enabled: !!context.companyId,
|
||||
});
|
||||
|
||||
if (errorMessage) {
|
||||
if (errorMessage && errorBehavior === "inline") {
|
||||
return (
|
||||
<div className={cn("rounded-md border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive", errorClassName)}>
|
||||
Plugin launchers unavailable: {errorMessage}
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter>
|
||||
<PluginLauncherProvider>
|
||||
<PluginSlotOutlet slotTypes={["sidebar"]} context={context} errorBehavior={errorBehavior} />
|
||||
<PluginLauncherOutlet placementZones={["sidebar"]} context={context} errorBehavior={errorBehavior} />
|
||||
<PluginSlotOutlet slotTypes={["sidebarPanel"]} context={context} errorBehavior={errorBehavior} />
|
||||
</PluginLauncherProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
));
|
||||
}
|
||||
|
||||
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<Response>(() => {})));
|
||||
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", () => <span>Fixture sidebar item</span>);
|
||||
registerPluginReactComponent("fixture.sidebar", "SidebarPanel", () => <span>Fixture sidebar panel</span>);
|
||||
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");
|
||||
});
|
||||
@@ -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 (
|
||||
<div className={cn("rounded-md border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive", errorClassName)}>
|
||||
Plugin extensions unavailable: {errorMessage}
|
||||
|
||||
Reference in new issue
Block a user