Right sidebar
A second sidebar on the opposite side of the navigation, used by plugins to mount panes — chat boxes, presence panels, document outlines, inspectors, switchers. VS Code's Secondary Side Bar is the reference.
Pilotiq core ships the chrome (collapse toggle, drag-to-resize, tab strip, mobile sheet, keyboard shortcut, per-basePath persistence); plugins register their bodies through a single registry call.
#Quick start
import { Pilotiq } from '@pilotiq/pilotiq'
import { OutlinePanel } from './right-panels/OutlinePanel.tsx'
Pilotiq.make('admin')
.rightPanel({
id: 'outline',
label: 'Outline',
icon: 'list-tree',
render: OutlinePanel,
defaultWidth: 280,
})That's it. The trigger button auto-mounts in the topbar between the notification bell and the user menu; the body renders inside the sidebar chrome the first time a user clicks it open.
To register multiple panels in one call, use .rightPanels([…]):
Pilotiq.make('admin')
.rightPanels([
{ id: 'outline', label: 'Outline', render: OutlinePanel },
{ id: 'inspector', label: 'Inspector', render: InspectorPanel, sort: 10 },
])Adapter packages typically expose a plugin factory that calls
panel.rightPanel(...) from inside register(panel) so consumers wire
everything via .plugins([…]):
// hypothetical @example/outline-plugin
export function outline(): PilotiqPlugin {
return {
name: '@example/outline-plugin',
register(panel) {
panel.rightPanel({
id: 'outline',
label: 'Outline',
icon: 'list-tree',
render: OutlinePanel,
})
},
}
}
Pilotiq.make('admin').plugins([outline()])#Body component contract
Each contribution's render receives RightPanelProps:
import type { RightPanelProps } from '@pilotiq/pilotiq'
export function OutlinePanel({ basePath, currentPath, activeId }: RightPanelProps) {
return (
<div className="p-4 text-sm">
<p>You are at {currentPath ?? basePath}</p>
</div>
)
}basePath— the URL prefix the panel is mounted under (matchesAppShell.basePath).currentPath— the live pathname; re-renders on SPA navigation so the body can react to which page the user is on.activeId— this contribution's id (useful when a single component is registered against multiple panels and shares state via context).
The body is mounted on first open and stays mounted while the panel is
visible; closing the panel unmounts it. Persist any per-session state
inside React refs, an outer provider, or localStorage if you need it
to survive open/close cycles.
#Programmatic open / close
Any descendant of the panel can drive the surface via useRightSidebar()
from @pilotiq/pilotiq/react:
import { useRightSidebar } from '@pilotiq/pilotiq/react'
function PinThisPanel() {
const sidebar = useRightSidebar()
return (
<button onClick={() => sidebar.setActiveId('inspector')}>
Pin Inspector
</button>
)
}Returned API:
| Field | Description |
|---|---|
open: boolean |
Whether the panel is currently open. |
setOpen(v) |
Open or close. |
toggle() |
Flip open/closed. |
activeId: string | null |
Active contribution's id. |
setActiveId(id) |
Switch tabs (also opens the panel). |
width: number |
Current panel width in px. |
setWidth(px) |
Resize programmatically (clamped to bounds). |
contributions: RightPanelMeta[] |
All visible tabs for this panel. |
bounds: { min, max, default } |
Width clamps from server meta. |
Outside the sidebar tree (e.g., a topbar render hook), use
useRightSidebarOptional() — it returns null when no panel is
mounted, so the caller can render conditionally:
import { useRightSidebarOptional } from '@pilotiq/pilotiq/react'
function CustomTrigger() {
const sidebar = useRightSidebarOptional()
if (!sidebar) return null
return <button onClick={sidebar.toggle}>✨ Open assistant</button>
}#Auth gating
Pass canAccess(user) to gate a contribution behind the panel-level
user resolver. The predicate runs through panelInfo() once per
request, mirroring the shape of Resource.canAccess:
panel.rightPanel({
id: 'admin-tools',
label: 'Admin tools',
render: AdminToolsBody,
canAccess: (user) => Boolean((user as any)?.isAdmin),
})A failing or throwing predicate fails closed — the contribution drops
silently from the tab strip and the registry. Other contributions are
unaffected. The panel-level auth gate (Pilotiq.guard()) still runs
in front of every request; canAccess is purely a per-pane filter.
#Hidden contributions
Pass hidden: true to register a body without surfacing it on the tab
strip. Useful for "trigger-only" panels opened programmatically from
elsewhere in the panel:
panel.rightPanel({
id: 'help-floater',
render: HelpBody,
hidden: true,
})
// then, from a render hook on the topbar:
function HelpButton() {
const sidebar = useRightSidebarOptional()
if (!sidebar) return null
return (
<button onClick={() => sidebar.setActiveId('help-floater')}>
Help
</button>
)
}If every registered contribution is hidden: true, the panel meta is
absent from panelInfo() and the chrome doesn't mount — make sure at
least one contribution is visible or one of your render hooks calls
useRightSidebarOptional() to reach the surface.
#Sort order
Tabs sort by sort ascending (default 100); ties break by
registration order. The first visible contribution becomes the default
active tab when no localStorage value exists.
#Persistence
The panel persists three slots in localStorage under
pilotiq.rightSidebar.<basePath>.<key>:
.open—'true'/'false'.activeId— the active contribution id.width— the user's last-set width in px
<basePath> is the panel's mount path (/admin, /simple, etc.) so a
multi-panel app keeps separate state per surface. Width writes happen
on pointerup only, so dragging doesn't churn Storage between
frames.
#Width bounds
Width clamps to [240, 800] px by default; the active contribution can
narrow the clamp by supplying defaultWidth (also clamped to that
range). Mobile (< 768px) collapses to a fixed-width sheet
(min(20rem, calc(100vw - 3rem))) — the resize handle is hidden.
#Keyboard shortcut
Mod+Shift+\ (Cmd+Shift+\ on macOS, Ctrl+Shift+\ elsewhere) toggles the panel. The shortcut is owned by core and can't be reassigned by plugin authors — it mirrors VS Code's View: Toggle Secondary Side Bar default.
#Custom trigger placement
The default RightSidebarTrigger mounts in the topbar between the
notification bell and the user menu. To replace or relocate it, render
your own affordance via a render hook and call useRightSidebarOptional()
to drive open/close:
import { Heading } from '@pilotiq/pilotiq'
panel.renderHook('panels::topbar.end', () => [
Heading.make('').components([CustomFloatingTrigger]),
])The default trigger is always rendered alongside any custom one — they both flip the same state, so two affordances on screen aren't a problem unless you're tight on header real estate.
#Limitations (v1)
- One open tab at a time. Side-by-side multi-pane is on the v2 list.
- Tab order is registration-time only — no drag-to-reorder.
- Auth state is snapshotted per request; sign-in/out without a nav doesn't refresh the tab strip until the next page load.
- The right sidebar overlays content via
position: fixedand compresses host content with a layout shim. Promotion to a "real" layout column (so content reflows down to the gutter) is deferred.
#Reference
- API plan:
docs/plans/right-sidebar.md - Tests:
packages/pilotiq/src/RightPanel.test.ts - Source:
packages/pilotiq/src/RightPanel.ts,packages/pilotiq/src/react/RightSidebar.tsx,packages/pilotiq/src/react/RightSidebarContext.tsx,packages/pilotiq/src/react/RightSidebarTrigger.tsx