design multitab pane widget (T-83)

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>
This commit is contained in:
2026-05-06 12:04:42 +02:00
co-authored by Claude
parent 4043c2cbe0
commit bdc46a4fdd
6 changed files with 589 additions and 4 deletions
+182
View File
@@ -0,0 +1,182 @@
# 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](../../decisions/architecture.md#d-41-claude-panes-one-primary-per-repo-tmux-backed),
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](../../decisions/architecture.md#d-48-chrome-budget-no-tabs-no-breadcrumbs-keyboard-first)
rejected those; revisiting is a separate decision.
## API sketch
```dart
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` / `onAddRequested` so 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 `closeable` tabs
- `+` button at the end if `onAddRequested` is 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 via `controller.add(...)`
- **Keyboard**: `⌘1``⌘9` jump to tab N; `⌘W` close 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:
1. `MultitabPane` widget + `MultitabController` + `MultitabEntry`
under `lib/widgets/src/`
2. Unit tests for controller invariants (pinned positions, active
selection survives close, reorder bounds)
3. Widget tests for the strip (selection, close hover, add button,
drag-reorder)
4. 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 `⌘W` even when the user
is typing in a nested tab. Implementation: wrap shortcuts in a
`Shortcuts` / `Actions` widget inside the pane's `Focus` scope
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 `ClideTabBar` exactly, or
introduce a denser variant for inside-pane use? Recommendation:
reuse `ClideTabBar` initially; spin off a `ClideTabBar.dense`
variant only if visual hierarchy issues emerge.