Reusable widget for panes that need N runtime tab instances of the same kind. First consumer is the Claude pane (primary + 0..N secondaries per D-41); the generic shape lets other panes adopt it later without reinventing tab strips. Includes: - Design doc with API sketch, rendering, interaction, persistence boundary, integration sketch for the Claude pane, and three open questions (keyboard scoping for nested cases, overflow, density). - Wireframe of the Claude pane with primary (pinned) + 2 secondaries + add button, accent border on the active tab. - Architecture diagram (sketch mode) showing the host / widget / controller / IPC boundary that keeps the widget domain-free. Co-Authored-By: Claude <noreply@anthropic.com>
7.1 KiB
Multitab pane — design
Ticket: T-83 Drives: T-24 (secondary Claude pane UI wiring) Date: 2026-05-06
Problem
Some panes need to host multiple, dynamically-spawned views of the same kind. The first concrete case is the Claude pane: per D-41, each repo has exactly one primary Claude pane plus zero or more secondary panes spawned at runtime. The user needs a way to:
- See which Claude sessions are open
- Switch between them
- Spawn a new secondary
- Close a secondary (primary has no close affordance)
The kernel's existing TabContribution system addresses a different
need — it lets extensions statically declare which widget shows up in
which panel slot (sidebar, workspace, context). It does not
support dynamic tab instances within a single contribution.
This design fills that gap with a reusable widget, so future panes that need the same shape (potentially the editor — see D-48 — or diff/preview surfaces) can adopt it without reinventing tab strips.
Non-goals
- Replacing
TabContribution. Slot-host tabs are static-by-design; this widget is for inside-a-tab dynamism. - Window-level tab management (browser-style "tear off into a window").
- Editor multi-buffer tabs. D-48 rejected those; revisiting is a separate decision.
API sketch
class MultitabPane<T> extends StatefulWidget {
const MultitabPane({
required this.controller,
required this.tabBuilder,
required this.bodyBuilder,
this.onCloseRequested,
this.onAddRequested,
this.allowReorder = true,
});
final MultitabController<T> controller;
final Widget Function(BuildContext, MultitabEntry<T>) tabBuilder;
final Widget Function(BuildContext, MultitabEntry<T>) bodyBuilder;
final void Function(MultitabEntry<T> entry)? onCloseRequested;
final void Function()? onAddRequested;
final bool allowReorder;
}
class MultitabEntry<T> {
final String id; // stable identity (e.g. "claude.primary")
final String title; // display label
final bool closeable; // primary tabs set this false
final bool reorderable; // primary often pinned to position 0
final T payload; // domain object the bodyBuilder renders
}
class MultitabController<T> extends ChangeNotifier {
List<MultitabEntry<T>> get entries;
MultitabEntry<T>? get active;
void add(MultitabEntry<T> entry, {bool activate = true});
void remove(String id);
void activate(String id);
void reorder(String id, int newIndex);
}
The widget is a thin shell:
- Renders the tab strip via
ClideTabBar(or a reorderable variant) - Calls
bodyBuilder(active)for the visible content - Routes user gestures to controller methods or callbacks
- Emits
onCloseRequested/onAddRequestedso the host decides the actual lifecycle (e.g. Claude pane spawns a new tmux session, doesn't just append a UI tab)
The host owns the controller and the payload type. The widget never touches PTY, IPC, or Claude session naming.
Rendering
The tab strip lives at the top of the pane chrome. Layout:
┌──────────────────────────────────────────────────────┐
│ [primary] [secondary 1] [secondary 2] [+] │
├──────────────────────────────────────────────────────┤
│ │
│ active tab body │
│ │
└──────────────────────────────────────────────────────┘
- Active tab: filled background, bright text
- Inactive: muted background, muted text
- Close glyph (×) appears on hover for
closeabletabs +button at the end ifonAddRequestedis set- Drag-to-reorder respects
reorderable; non-reorderable tabs (primary) are pinned to position 0 and other tabs cannot be dropped before them
Interaction
- Click a tab → activate
- Click × → call
onCloseRequested(entry); host decides whether to confirm, kill the underlying session, etc. - Drag-and-drop → call
controller.reorder(id, newIndex)after the gesture completes; controller enforces pinned positions - Click + → call
onAddRequested(); host creates the new entry and adds it viacontroller.add(...) - Keyboard:
⌘1–⌘9jump to tab N;⌘Wclose active (skipped for non-closeable);⌘⇧[/⌘⇧]cycle prev/next
Persistence
Out of scope for the widget. Hosts that want to persist tab order or which tabs were open across sessions read/write through their own settings layer and seed the controller on init.
Claude pane integration (T-24)
ClaudePane (host)
└── MultitabPane<ClaudeSessionRef>(
controller: claudeTabsController,
tabBuilder: (ctx, e) => Text(e.title),
bodyBuilder: (ctx, e) => ClaudePaneBody(session: e.payload),
onAddRequested: () => kernel.claude.spawnSecondary(),
onCloseRequested: (e) => kernel.claude.closeSecondary(e.payload),
)
ClaudeSessionRef carries the tmux session name + isPrimary. The
controller is seeded with [primary] on boot; secondaries get
appended as the user clicks +. Closing a secondary triggers
pane.close IPC and removes the entry; closing the primary is not
exposed (closeable: false).
What ships in this ticket
T-83 delivers:
MultitabPanewidget +MultitabController+MultitabEntryunderlib/widgets/src/- Unit tests for controller invariants (pinned positions, active selection survives close, reorder bounds)
- Widget tests for the strip (selection, close hover, add button, drag-reorder)
- This design doc
T-24 picks up after and wires the Claude pane to it.
Open questions
-
Q: Where does keyboard handling live? Host or widget? Recommendation: widget owns
⌘W/⌘1–⌘9/ cycle; host wires them via the existing kernel commands surface. Avoids each host reinventing the same shortcuts.Nesting caveat: the widget composes (a Claude tab can host its own
MultitabPane<EditorBuffer>etc.). Shortcut handling must be scoped to the focus subtree, not registered globally — otherwise the outermost pane consumes⌘Weven when the user is typing in a nested tab. Implementation: wrap shortcuts in aShortcuts/Actionswidget inside the pane'sFocusscope so the innermost focused pane wins via Flutter's normal shortcut-resolution chain. -
Q: Tab overflow when many secondaries open? Recommendation: start with horizontal scroll; revisit if it becomes a problem.
-
Q: Tab-strip visual style — match
ClideTabBarexactly, or introduce a denser variant for inside-pane use? Recommendation: reuseClideTabBarinitially; spin off aClideTabBar.densevariant only if visual hierarchy issues emerge.