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
+32 -4
View File
@@ -1,5 +1,5 @@
{
"exported_at": "2026-05-06T09:14:06Z",
"exported_at": "2026-05-06T10:04:42Z",
"decisions": [
{
"id": "D-1",
@@ -2040,11 +2040,11 @@
"type": "task",
"parent_id": "T-4",
"title": "implement welcome screen per hi-fi design",
"status": "ready",
"status": "done",
"priority": "medium",
"decision_ref": "D-43",
"created_at": "2026-04-22 14:08:40",
"updated_at": "2026-05-03 20:34:52"
"updated_at": "2026-05-06 09:17:22"
},
{
"id": "T-22",
@@ -2673,9 +2673,23 @@
"priority": "low",
"created_at": "2026-05-06 08:30:15",
"updated_at": "2026-05-06 08:30:15"
},
{
"id": "T-83",
"type": "task",
"title": "design reusable sortable tab system for multitab panes",
"status": "in_progress",
"priority": "high",
"created_at": "2026-05-06 09:17:39",
"updated_at": "2026-05-06 09:17:50"
}
],
"ticket_deps": [
{
"blocker_id": "T-83",
"blocked_id": "T-24"
}
],
"ticket_deps": null,
"ticket_labels": null,
"history": [
{
@@ -4005,6 +4019,20 @@
"old_value": "in_progress",
"new_value": "done",
"changed_at": "2026-05-06 07:56:10"
},
{
"ticket_id": "T-21",
"field": "status",
"old_value": "ready",
"new_value": "done",
"changed_at": "2026-05-06 09:17:22"
},
{
"ticket_id": "T-83",
"field": "status",
"old_value": "backlog",
"new_value": "in_progress",
"changed_at": "2026-05-06 09:17:50"
}
]
}
+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.
+166
View File
@@ -0,0 +1,166 @@
title: MultitabPane — architecture {
near: top-center
shape: text
style.font-size: 24
style.bold: true
}
direction: down
host: ClaudePane (host) {
shape: rectangle
style.fill: "#13161c"
style.stroke: "#7c5cff"
style.font-color: "#e8ecf2"
state: ChangeNotifier — owns lifecycle {
shape: rectangle
style.fill: "#0e1014"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
}
}
widget: MultitabPane<T> (widget) {
shape: rectangle
style.fill: "#13161c"
style.stroke: "#262a32"
style.font-color: "#e8ecf2"
shell: builds tabstrip + body shell {
shape: rectangle
style.fill: "#0e1014"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
}
}
controller: MultitabController<T> {
shape: rectangle
style.fill: "#13161c"
style.stroke: "#262a32"
style.font-color: "#e8ecf2"
api: |md
add(entry)
remove(id)
activate(id)
reorder(id, idx)
| {
style.font-color: "#a0a8b8"
}
}
entries: List<MultitabEntry<T>> {
shape: rectangle
style.fill: "#0e1014"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
primary: primary {
shape: rectangle
style.fill: "#1a1f28"
style.stroke: "#7c5cff"
style.font-color: "#e8ecf2"
closeable\: false: { shape: text; style.font-color: "#7a8294"; style.font-size: 10 }
reorderable\: false: { shape: text; style.font-color: "#7a8294"; style.font-size: 10 }
}
sec1: secondary 1 {
shape: rectangle
style.fill: "#13161c"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
}
sec2: secondary 2 {
shape: rectangle
style.fill: "#13161c"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
}
}
ipc: kernel.claude / IPC {
shape: rectangle
style.fill: "#13161c"
style.stroke: "#5a8c5a"
style.font-color: "#e8ecf2"
spawn: spawnSecondary() {
shape: rectangle
style.fill: "#0e1014"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
}
close: closeSecondary(ref) {
shape: rectangle
style.fill: "#0e1014"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
}
}
host -> controller: owns {
style.stroke: "#7a8294"
style.font-color: "#7a8294"
}
host -> widget: builds with {
style.stroke: "#7a8294"
style.font-color: "#7a8294"
}
controller -> entries: holds {
style.stroke: "#7a8294"
style.font-color: "#7a8294"
}
widget -> controller: subscribes (Listenable) {
style.stroke: "#7c5cff"
style.font-color: "#a0a8b8"
}
widget -> host: onAddRequested() {
style.stroke: "#d97757"
style.font-color: "#d97757"
}
widget -> host: onCloseRequested(entry) {
style.stroke: "#d97757"
style.font-color: "#d97757"
}
host -> ipc: routes user intent {
style.stroke: "#5a8c5a"
style.font-color: "#5a8c5a"
}
ipc.spawn -> entries.sec2: appends new entry {
style.stroke: "#5a8c5a"
style.font-color: "#5a8c5a"
}
ipc.close -> entries.sec1: removes entry {
style.stroke: "#d97757"
style.font-color: "#d97757"
}
note: |md
### Boundary
**Widget** is generic. It knows
about `MultitabEntry<T>` and routes
user gestures back to the host. It
never touches PTY, IPC, or session
naming.
**Host** owns the controller and
decides what `add` / `close` mean
in the domain — for Claude that's
spawning/killing tmux sessions
via IPC.
This boundary is what makes the
widget reusable: any pane that
needs N runtime instances can
drop it in with their own host
and payload type.
| {
shape: rectangle
style.fill: "#13161c"
style.stroke: "#262a32"
style.font-color: "#a0a8b8"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 KiB

+209
View File
@@ -0,0 +1,209 @@
{
"name": "Claude Pane — multitab",
"shapes": {
"canvas": {
"type": "Rectangle",
"left": 40, "top": 40, "width": 1100, "height": 720,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"pane-header": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 40, "width": 1100, "height": 36,
"fillColor": "#13161c",
"strokeColor": "#1c2028"
},
"pane-title": {
"type": "Text",
"parent": "pane-header",
"left": 56, "top": 50,
"text": "claude — secondary 2",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"pane-subtitle": {
"type": "Text",
"parent": "pane-header",
"left": 56, "top": 64,
"text": "tmux · clide-claude-var-mnt-data-projects-clide-2",
"fontColor": "#7a8294",
"fontSize": 10
},
"tabstrip": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 76, "width": 1100, "height": 32,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"tab-primary": {
"type": "Rectangle",
"parent": "tabstrip",
"left": 56, "top": 80, "width": 132, "height": 28,
"fillColor": "#13161c",
"strokeColor": "#1c2028",
"corners": [4, 4, 0, 0]
},
"tab-primary-pin": {
"type": "Text",
"parent": "tab-primary",
"left": 64, "top": 86,
"text": "📌",
"fontColor": "#7a8294",
"fontSize": 10
},
"tab-primary-text": {
"type": "Text",
"parent": "tab-primary",
"left": 84, "top": 86,
"text": "primary",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"tab-sec-1": {
"type": "Rectangle",
"parent": "tabstrip",
"left": 192, "top": 80, "width": 132, "height": 28,
"fillColor": "#13161c",
"strokeColor": "#1c2028",
"corners": [4, 4, 0, 0]
},
"tab-sec-1-text": {
"type": "Text",
"parent": "tab-sec-1",
"left": 204, "top": 86,
"text": "secondary 1",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"tab-sec-1-close": {
"type": "Text",
"parent": "tab-sec-1",
"left": 304, "top": 86,
"text": "×",
"fontColor": "#5a6478",
"fontSize": 14
},
"tab-sec-2": {
"type": "Rectangle",
"parent": "tabstrip",
"left": 328, "top": 80, "width": 132, "height": 28,
"fillColor": "#1a1f28",
"strokeColor": "#7c5cff",
"corners": [4, 4, 0, 0]
},
"tab-sec-2-text": {
"type": "Text",
"parent": "tab-sec-2",
"left": 340, "top": 86,
"text": "secondary 2",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"tab-sec-2-close": {
"type": "Text",
"parent": "tab-sec-2",
"left": 440, "top": 86,
"text": "×",
"fontColor": "#a0a8b8",
"fontSize": 14
},
"tab-add": {
"type": "Rectangle",
"parent": "tabstrip",
"left": 464, "top": 80, "width": 28, "height": 28,
"fillColor": "#0e1014",
"strokeColor": "#1c2028",
"corners": [4, 4, 0, 0]
},
"tab-add-glyph": {
"type": "Text",
"parent": "tab-add",
"left": 474, "top": 86,
"text": "+",
"fontColor": "#7a8294",
"fontSize": 14
},
"active-divider": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 108, "width": 1100, "height": 2,
"fillColor": "#7c5cff",
"strokeColor": "#7c5cff"
},
"body": {
"type": "Rectangle",
"parent": "canvas",
"left": 40, "top": 110, "width": 1100, "height": 650,
"fillColor": "#0e1014",
"strokeColor": "#1c2028"
},
"body-banner-name": {
"type": "Text",
"parent": "body",
"left": 56, "top": 132,
"text": "Claude Code v2.1.128",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"body-banner-meta": {
"type": "Text",
"parent": "body",
"left": 56, "top": 148,
"text": "Opus 4.7 (1M context) · fresh secondary session",
"fontColor": "#7a8294",
"fontSize": 11
},
"body-msg-prompt": {
"type": "Text",
"parent": "body",
"left": 56, "top": 200,
"text": " dig into the failing test in test/pty/session_test.dart",
"fontColor": "#a0a8b8",
"fontSize": 12
},
"body-msg-resp": {
"type": "Text",
"parent": "body",
"left": 56, "top": 226,
"text": "● Looking at the write-keystrokes test. The shell process\n starts but the echo doesn't appear in the output stream.\n Let me trace the write path…",
"fontColor": "#e8ecf2",
"fontSize": 12
},
"prompt-divider": {
"type": "Rectangle",
"parent": "body",
"left": 40, "top": 700, "width": 1100, "height": 1,
"fillColor": "#1c2028",
"strokeColor": "#1c2028"
},
"prompt": {
"type": "Text",
"parent": "body",
"left": 56, "top": 712,
"text": " Try \"run the test in this pane\"",
"fontColor": "#5a6478",
"fontSize": 12
},
"anno": {
"type": "Text",
"parent": "canvas",
"left": 720, "top": 80,
"text": "active tab gets accent border + bottom rule",
"fontColor": "#5a6478",
"fontSize": 10
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB