Skip to main content

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 idPrimaryCompanions
network.fabricnetwork.peerschat, monitor, lobby, relay
commons.hubcommons.directoryrequests, profile
flow.studioflow.librarycanvas (flow.editor)
observability.monitorobservability.ioinspector (observability.logs)

How it works

  • registry.getGroupFor(panelId) returns the PanelGroupDecl for any group member, or undefined for unaffiliated panels.
  • registry.commands suppresses widget.open:* entries for any widget id that appears in a group's companions list.
  • Group membership is also relayed to the agent and the dash REPL: getGroupFor backs a groupId field on list_available_panes/list_open_panes and a dedicated get_pane_group tool (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.
  • PanelHost in packages/ui/src/Workspace.tsx calls getGroupFor. 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 local useState<Record<string, CompanionViewState>> tracking which companions are open and each one's current position ('right' | 'bottom' | 'left'); a separate activeByPosition: Partial<Record<DockPosition, string>> tracks which open companion is the visible tab within each position's shared dock; and sizeByPosition: 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 an openStates entry and updates activeByPosition (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, reassigning activeByPosition for both the old and new position (the dock's sizeByPosition entry is independent of which companion is docked there); the resize handle is a 4 px border that fires mousemove on window to update sizeByPosition without 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.