Panel Groups
Panel groups cluster related panels/widgets into one dockview pane — a group shell — so they feel like a single coherent feature with clear visual boundaries rather than N independent panels scattered in the workspace.
Concept
A panel group has one primary panel (the hub entry point) and one or more
companions (secondary panels). When the primary is opened, PanelHost
renders a PaneGroupShell instead of the bare component.
The shell has two zones:
- A toggle strip across the top: one button per companion. Clicking a button shows or hides that companion (independent toggles, like VS Code's sidebar toggles). All companions are hidden by default.
- A content row below: the primary always fills the left area; companions dock at one of three positions (right/bottom/left). One dock per position — opening a second companion at a position it already shares tabs onto that same dock instead of splitting the shell again, so N open companions at one position never costs more than one split.
┌───────────────────────────────────────────────────────────────┐
│ [Peer Chat ✉] [Peer Monitor ◈] [Lobby ⊞] [Agent Relay ⇌] │ ← toggle strip
├─────────────────── ──────────────┬─────────────────────────────┤
│ │ PEER CHAT LOBBY → ↓ ← ✕ │ ← both open at "right", one dock
│ Primary (Peers) ├─────────────────────────────┤
│ always visible │ active tab's content here… │
│ │ │
└─────────────────────────────────┴─────────────────────────────┘
Each dock has a resize handle (drag the 4 px border between the dock and
primary to resize — sized to the active tab) and a position cycle button
(→ ↓ ←) that moves the active companion through right → bottom → left → right
docking; moving it out of a multi-tab dock leaves the others tabbed at the old
position and either joins or starts a dock at the new one.
The entire shell is one dockview pane: it can be split, floated, moved, or maximized as a single unit in the workspace. Companions are not independent panes.
Palette visibility
Only the primary appears in the command palette ("Open widget: Peers"). Companion
widget IDs are suppressed from the auto-generated widget.open:* commands so users
can't accidentally open a companion detached from its group. Companions are only
reachable via the toggle strip of their primary.
Declaring a group
Add panelGroups to the module manifest:
import type { PanelGroupDecl } from '@horrible/core';
panelGroups: [
{
id: 'network.fabric',
label: 'Peer Fabric',
primary: 'network.peers', // only this id appears in the palette
companions: [
{ id: 'network.chat', label: 'Peer Chat', icon: '✉' },
{ id: 'network.monitor', label: 'Peer Monitor', icon: '◈' },
{ id: 'network.lobby', label: 'Lobby', icon: '⊞' },
{ id: 'network.relay', label: 'Agent Relay', icon: '⇌' },
],
} satisfies PanelGroupDecl,
],
The icon field appears in the toggle button alongside the label.
Built-in groups
| Group id | Primary | Companions |
|---|---|---|
network.fabric | network.peers | chat, monitor, lobby, relay |
commons.hub | commons.directory | requests, profile |
flow.studio | flow.library | canvas (flow.editor) |
observability.monitor | observability.io | inspector (observability.logs) |
How it works
registry.getGroupFor(panelId)returns thePanelGroupDeclfor any group member, orundefinedfor unaffiliated panels.registry.commandssuppresseswidget.open:*entries for any widget id that appears in a group'scompanionslist.- Group membership is also relayed to the agent and the
dashREPL:getGroupForbacks agroupIdfield onlist_available_panes/list_open_panesand a dedicatedget_pane_grouptool (packages/core/src/modules/agent/tool-exec.ts,dash.panes.group()— see python-sdk.mdx), so scripted and agent-driven layout logic can discover and reason about groups the same way the companion strip UI does. PanelHostinpackages/ui/src/Workspace.tsxcallsgetGroupFor. If the view is the group primary, it renders<PaneGroupShell group={...} />inside.ws-panel(with the shell-specific zero-padding modifier).PaneGroupShell(packages/ui/src/PaneGroupShell.tsx) holds localuseState<Record<string, CompanionViewState>>tracking which companions are open and each one's current position ('right' | 'bottom' | 'left'); a separateactiveByPosition: Partial<Record<DockPosition, string>>tracks which open companion is the visible tab within each position's shared dock; andsizeByPosition: Record<DockPosition, number>owns the pixel size per dock, not per companion — so switching the active tab within a dock keeps its width/height instead of resetting to whichever tab's own size. Toggling adds/removes anopenStatesentry and updatesactiveByPosition(closing the active tab falls back to another companion still open at that position, if any). The position-cycle button moves only the active companion to the next position, reassigningactiveByPositionfor both the old and new position (the dock'ssizeByPositionentry is independent of which companion is docked there); the resize handle is a 4 px border that firesmousemoveonwindowto updatesizeByPositionwithout dockview intercepting the drag.- Companion panels opened standalone via a direct command still render their raw component — the shell only activates when the primary is opened.
Future: decoupling companions
In a future advanced-settings option, individual companions can be "decoupled" from their primary so they appear as independent panes in the workspace and in the palette. This is intentionally not the default — the grouped UX is strongly preferred.
Types
// packages/sdk/src/types.ts (re-exported from @horrible/core)
interface PanelGroupCompanion {
id: string;
label: string;
icon?: string; // shown in the toggle button
}
interface PanelGroupDecl {
id: string;
label: string;
primary: string;
companions: PanelGroupCompanion[];
}
PanelGroupDecl is part of the public SDK (@horribledashboard/sdk) so
third-party plugins can declare groups too.