Files
clide/docs/design/multitab-pane.md
T
jpmschweitzerandClaude f90ddc345f
test / unit + widget + golden + a11y (push) Failing after 29s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m1s
update decisions/ → governance/ refs after D-21 migration
Stale path references from the move in 63195d1:
- CLAUDE.md: 8 D-record links, the layout tree, the open-questions
  pointer, all rewritten to governance/.
- docs/design/multitab-pane.md + docs/claude-design/README.md:
  cross-references updated.
- lib/clide.dart: doc-comment refs.
- lib/builtin/problems: user-facing message string.
- Makefile: decisions-validate target docstring.

Note: lib/builtin/decisions/ (the in-app decisions panel package)
keeps its name — it's a feature name, not a filesystem-path mirror.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-11 18:38:39 +02:00

7.1 KiB
Raw Blame History

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 / 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.