Files
PaperClipAI/ui/src/components/Sidebar.tsx
T
8e59efc50b 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>
2026-10-08 12:37:55 -07:00

318 lines
14 KiB
TypeScript

import {
Inbox,
ListChecks,
CircleCheck,
Target,
LayoutDashboard,
DollarSign,
History,
Search,
SquarePen,
Network,
Boxes,
Repeat,
Layers,
GitBranch,
Package,
Settings,
FolderOpen,
Unplug,
MessagesSquare,
MessageCircle,
GanttChartSquare,
LayoutGrid,
Users,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { SidebarSection } from "./SidebarSection";
import { SidebarNavItem } from "./SidebarNavItem";
import { SidebarAgents } from "./SidebarAgents";
import { SidebarProjects } from "./SidebarProjects";
import { SidebarStarredProjects } from "./SidebarStarredProjects";
import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
import { SidebarRecentTasks } from "./SidebarRecentTasks";
import { useDialogActions } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { attentionApi } from "../api/attention";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { attentionBadgeCount } from "../lib/attention";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { PluginSlotOutlet } from "@/plugins/slots";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
import { primarySidebarStyles } from "./primary-sidebar-styles";
export function Sidebar({ children }: { children?: ReactNode }) {
const { openNewIssue } = useDialogActions();
const { enabled: agentChatEnabled } = useAgentChatEnabled();
// Every labeled section is collapsible (session-scoped, default open) —
// one policy across static nav groups and the data-driven sections.
const [workOpen, setWorkOpen] = useState(true);
const [organizationOpen, setOrganizationOpen] = useState(true);
const { selectedCompanyId, selectedCompany } = useCompany();
const { collapsed, peeking } = useSidebar();
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const rail = collapsed && !peeking;
const inboxBadge = useInboxBadge(selectedCompanyId);
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!);
const sharedLiveRuns = useSharedPollingQuery({
companyId: selectedCompanyId,
resourceKey: "live-runs",
queryKey: liveRunsQueryKey,
enabled: !!selectedCompanyId,
// Event-sourced via LiveUpdatesProvider (GitHub issue 9627) + reconnect reconcile — no
// interval poll needed. Polling here also re-armed React Query's timer on
// every live-event cache write, a major source of steady-state churn.
refetchInterval: false,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({
queryKey: liveRunsQueryKey,
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
enabled: sharedLiveRuns.enabled,
refetchInterval: sharedLiveRuns.refetchInterval,
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
const liveRunCount = liveRuns?.length ?? 0;
const liveIssueIds = new Set(
(liveRuns ?? []).flatMap((run) => run.issueId ? [run.issueId] : []),
);
// PAP-670 splits the nav reorganization across two experimental flags.
// Agent Chat: Chat leads Work as one row with its own agent rail (the
// streamlined shell only — the legacy shell keeps per-agent rows), and
// Workspaces leaves to make room. Combined Inbox + Task List: Inbox becomes
// views inside Tasks, so its row goes and its badge rides on Tasks.
const chatRail = agentChatEnabled && streamlinedUiEnabled;
// The merged Tasks page only exists in the streamlined shell, so the legacy
// shell keeps its Inbox row even with the flag on.
const combinedInboxTasks = streamlinedUiEnabled && experimentalSettings?.enableCombinedInboxTasks === true;
const showWorkspacesLink = !chatRail && experimentalSettings?.enableIsolatedWorkspaces === true;
const showPipelines = experimentalSettings?.enablePipelines === true;
const showStatusCards = experimentalSettings?.enableStatusCards === true;
const goalsLinkPending = experimentalSettings === undefined;
const showGoalsLink = experimentalSettings?.enableGoalsSidebarLink === true;
// Decisions (attention home) is an experimental surface (PAP-13481): the nav
// item is hidden entirely until the flag is enabled (same no-flash pattern as
// showWorkspacesLink — it defaults hidden, so no placeholder is needed).
const showDecisions = experimentalSettings?.enableDecisions === true;
const { data: attentionFeed } = useQuery({
queryKey: queryKeys.attention(selectedCompanyId!),
queryFn: () => attentionApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && showDecisions,
refetchInterval: 60_000,
});
const attentionCount = attentionBadgeCount(attentionFeed);
const showCases = experimentalSettings?.enableCases === true;
// Conference Room Chat flag (PAP-136/PAP-137): the Conference Room nav item
// is a new surface, hidden entirely while the flag is off (same no-flash
// pattern as showWorkspacesLink above).
const conferenceRoomChatEnabled = experimentalSettings?.enableConferenceRoomChat === true;
const pluginContext = {
companyId: selectedCompanyId,
companyPrefix: selectedCompany?.issuePrefix ?? null,
};
return (
<aside
className={cn(
"w-full h-full min-h-0 flex flex-col",
streamlinedUiEnabled
? primarySidebarStyles.surface
: "border-r border-border bg-background",
)}
>
{/* Top bar: company name, aligned with top sections and borderless.
Search deliberately does NOT live here:
the header's spare width goes to the workspace/organization name,
which is the user's orientation anchor and truncates otherwise.
Search is the first nav item below instead. */}
<div className="flex h-(--sz-60px) shrink-0 items-center gap-1 px-3">
<SidebarCompanyMenu />
</div>
<nav className={primarySidebarStyles.nav}>
<div className={primarySidebarStyles.group}>
{/* New Task button aligned with nav items */}
{(() => {
const newTaskButton = (
<button
onClick={() => openNewIssue()}
data-slot="icon-button"
aria-label={rail ? "New Task" : undefined}
className={cn(
"flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium text-foreground/80 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<SquarePen className="h-4 w-4 shrink-0" />
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "truncate"}>New Task</span>
</button>
);
return rail ? (
<Tooltip>
<TooltipTrigger asChild>{newTaskButton}</TooltipTrigger>
<TooltipContent side="right">New Task</TooltipContent>
</Tooltip>
) : (
newTaskButton
);
})()}
{/* Search moved out of the header so the workspace name keeps the
width; a nav row also keeps search reachable from the
collapsed rail, where the old header icon was dropped entirely.
Cmd/Ctrl+K remains the keyboard path (command palette). */}
<SidebarNavItem to="/search" label="Search" icon={Search} />
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
{!combinedInboxTasks ? (
<SidebarNavItem
to="/inbox"
label="Inbox"
icon={Inbox}
badge={inboxBadge.inbox}
badgeLabel="unread"
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
alert={inboxBadge.failedRuns > 0}
/>
) : null}
{agentChatEnabled && !chatRail ? <SidebarNavItem to="/chats" label="Chat" icon={MessageCircle} /> : null}
{showDecisions ? (
<SidebarNavItem
to="/decisions"
label="Decisions"
icon={ListChecks}
badge={attentionCount}
badgeLabel="decisions"
/>
) : null}
{showStatusCards ? (
<SidebarNavItem to="/status" label="Status" icon={LayoutGrid} textBadge="beta" />
) : null}
{conferenceRoomChatEnabled ? (
<SidebarNavItem to="/board-chat" label="Conference Room" icon={MessagesSquare} />
) : null}
</div>
<SidebarSection label="Work" collapsible={{ open: workOpen, onOpenChange: setWorkOpen }}>
{/* Agent Chat: Chat leads the Work group as a single row — the
agents you talk to live in the Chat surface's own secondary rail
(ChatContextualSidebar), not in the primary nav. */}
{chatRail ? (
<SidebarNavItem to="/chats" label="Chat" icon={MessageCircle} />
) : null}
{/* Combined Inbox + Task List: Inbox is a view inside Tasks, so the
unread/failed-run badge rides on Tasks. */}
{combinedInboxTasks ? (
<SidebarNavItem
to="/issues"
label="Tasks"
icon={CircleCheck}
badge={inboxBadge.inbox}
badgeLabel="unread"
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
alert={inboxBadge.failedRuns > 0}
/>
) : (
<SidebarNavItem to="/issues" label="Tasks" icon={CircleCheck} />
)}
{streamlinedUiEnabled ? (
<>
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
<SidebarStarredProjects />
</>
) : null}
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
{showCases ? (
<SidebarNavItem to="/cases" label="Cases" icon={Layers} textBadge="beta" />
) : null}
{showPipelines ? (
<SidebarNavItem to="/pipelines" label="Pipelines" icon={GitBranch} />
) : null}
{showGoalsLink ? (
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
) : goalsLinkPending ? (
<div
data-testid="sidebar-goals-placeholder"
className="h-8 pointer-coarse:h-7"
aria-hidden="true"
/>
) : null}
{showWorkspacesLink ? (
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
<PluginSlotOutlet
errorBehavior="hidden"
slotTypes={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
itemClassName="text-(length:--text-compact) font-medium"
missingBehavior="placeholder"
/>
<PluginLauncherOutlet
errorBehavior="hidden"
placementZones={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
itemClassName="text-(length:--text-compact) font-medium"
/>
</SidebarSection>
{streamlinedUiEnabled ? (
<SidebarSection
label="Org"
collapsible={{ open: organizationOpen, onOpenChange: setOrganizationOpen }}
>
<SidebarNavItem to="/agents" label="Agents" icon={Users} />
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
<SidebarNavItem to="/apps" label="Connectors" icon={Unplug} />
<SidebarNavItem to="/activity" label="Audit" icon={History} />
</SidebarSection>
) : null}
{children ?? (streamlinedUiEnabled ? <SidebarAgents streamlined /> : null)}
{streamlinedUiEnabled ? (
<SidebarRecentTasks companyId={selectedCompanyId} liveIssueIds={liveIssueIds} />
) : (
<>
<SidebarProjects />
<SidebarAgents />
<SidebarSection
label="Organization"
collapsible={{ open: organizationOpen, onOpenChange: setOrganizationOpen }}
>
<SidebarNavItem to="/org" label="Org" icon={Network} />
<SidebarNavItem to="/apps" label="Connectors" icon={Unplug} />
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
<SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
</SidebarSection>
</>
)}
<PluginSlotOutlet
errorBehavior="hidden"
slotTypes={["sidebarPanel"]}
context={pluginContext}
className="flex flex-col gap-3"
itemClassName="rounded-lg border border-border p-3"
missingBehavior="placeholder"
/>
</nav>
</aside>
);
}