diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index 7666c59a..95e4dfd7 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -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" } ] } diff --git a/docs/design/multitab-pane.md b/docs/design/multitab-pane.md new file mode 100644 index 00000000..c314ea5d --- /dev/null +++ b/docs/design/multitab-pane.md @@ -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 extends StatefulWidget { + const MultitabPane({ + required this.controller, + required this.tabBuilder, + required this.bodyBuilder, + this.onCloseRequested, + this.onAddRequested, + this.allowReorder = true, + }); + + final MultitabController controller; + final Widget Function(BuildContext, MultitabEntry) tabBuilder; + final Widget Function(BuildContext, MultitabEntry) bodyBuilder; + final void Function(MultitabEntry entry)? onCloseRequested; + final void Function()? onAddRequested; + final bool allowReorder; +} + +class MultitabEntry { + 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 extends ChangeNotifier { + List> get entries; + MultitabEntry? get active; + + void add(MultitabEntry 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( + 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` 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. diff --git a/docs/diagrams/multitab-pane.d2 b/docs/diagrams/multitab-pane.d2 new file mode 100644 index 00000000..82250f40 --- /dev/null +++ b/docs/diagrams/multitab-pane.d2 @@ -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 (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 { + 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> { + 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` 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" +} diff --git a/docs/diagrams/multitab-pane.png b/docs/diagrams/multitab-pane.png new file mode 100644 index 00000000..ca73f871 Binary files /dev/null and b/docs/diagrams/multitab-pane.png differ diff --git a/docs/wireframes/main/claude-multitab.json b/docs/wireframes/main/claude-multitab.json new file mode 100644 index 00000000..1cc4b960 --- /dev/null +++ b/docs/wireframes/main/claude-multitab.json @@ -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 + } + } +} diff --git a/docs/wireframes/main/claude-multitab.png b/docs/wireframes/main/claude-multitab.png new file mode 100644 index 00000000..35e43034 Binary files /dev/null and b/docs/wireframes/main/claude-multitab.png differ