Add split tab groups: VS Code-style splits and tmux-style shortcuts#224
Conversation
Sessions can now split the editor stage into multiple tab groups arranged in a resizable layout tree. Tabs move between groups by drag-and-drop with 5-zone drop targets or keyboard splits; each group keeps its own active tab and the layout persists per session across restarts. - Layout tree (groups as leaves, n-ary splits as branches) stored as JSON in a new sessions.panel_layout column, reconciled against live panels on every load - allotment-based recursive renderer; single-group sessions render exactly as before (no Allotment wrapper, no new chrome) - Shortcuts: Mod+backslash split right, Mod+Shift+backslash split down, Mod+Alt+Arrows directional group focus, Mod+Shift+Z zoom toggle; Undo Commit rebound to Mod+Alt+Z; existing tab shortcuts now act on the focused group - Tab strip extracted from PanelTabBar and shared by the top bar (primary group) and secondary group strips; permanent tabs draggable but still not closable - New panels:get-layout / panels:set-layout IPC with server-side panel id validation; onPanelDeleted preload event for layout reconciliation - Legacy folder-id sessions rebuild hardened to copy by column intersection instead of SELECT *
…allowlist - movePanel center drops now adjust the insert index when reordering within the same group to a later position, so the tab lands where the drop indicator showed instead of one slot past it - DropOverlay uses the dropZoneFor util instead of duplicating the 25%-band math inline - drop dead clearLayout store action - ignore stored layouts with an unknown version on load - panel rename updates the store immutably via updatePanelState instead of mutating panel.title in place - add panels:get-layout / panels:set-layout to the daemon registry surface allowlist test (fixes CI)
parsakhaz
left a comment
There was a problem hiding this comment.
PR Review
Issue context: no linked issue; reviewed against the PR description, which is detailed enough to act as the spec.
Quality Gates
- Typecheck: PASS (frontend, main, shared)
- Lint: PASS (0 errors). 166 pre-existing warnings; this PR adds exactly one new one (unused eslint-disable in SessionView.tsx:278, see inline comment)
- main daemon registry test: 10/10 passing with the new channels in the allowlist
Verification notes
I executed the pure tree math in panelLayout.ts directly to spot-check it: same-group reorder in both directions (the off-by-one fix in 415a2f1 is correct), n-ary sibling insert with size redistribution ([0.4, 0.3, 0.3] from splitting the 0.6 pane), edge-drop collapsing the empty source group, reconcile dedup + orphan adoption + dead-id pruning + focus repair, split collapse on remove, directional focus, and zero-size guards. all correct, and node ids stay stable through normalize as documented. also confirmed panel:deleted has a real emitter (panelManager.ts:241), and the undo-commit rebind to mod+alt+z genuinely works with a terminal focused because TerminalPanel already releases mod+alt+letter chords that match a registered hotkey.
Must-Fix (0)
none found.
Should-Fix (3)
frontend/src/components/SessionView.tsx:278- the eslint-disable directive on the load effect deps is now unused and reports as a new lint warning (the checklist claims no new warnings). drop it.frontend/src/components/panels/TerminalPanel.tsx:654- releasing backslash on ctrlOrMeta means macOS loses Ctrl+\ (SIGQUIT) in non-TUI terminals even though the app hotkey there is Cmd+. worth platform-gating this one release since it swallows a real terminal signal, unlike the other shortcuts in that block.frontend/src/components/SessionView.tsx:1586- the inline arrow for onStripDrop creates a new function identity every render, which defeats memo(PanelTabBar) entirely. wrap in useCallback.
Suggestions (4)
SessionView.tsx:798- fixActive runs even when the closed panel was not the group's active tab, so closing a background tab switches the active tab. this faithfully ports the old behavior, but vs code keeps the current tab; gating ongroup.activePanelId === panel.idwould match it.SplitLayout.tsx- allotment's defaultSizes is mount-only, so after an n-ary insert into an existing split the on-screen sizes can diverge from the persisted model sizes until a sash drag or reload. probably acceptable, just flagging the known divergence.SessionView.tsxdebouncedPersist - the 500ms debounce only flushes on session switch/unmount, so a split followed by an immediate app quit loses the layout; a beforeunload flush would close that window. also the setTimeout body duplicates flushLayoutPersist, the timer callback could just call it.PanelGroupView.tsx- secondary strips render tabs with role="tab" but the container has no role="tablist" (the primary strip gets one from PanelTabBar).
Completeness
checked the PR description claims against the code: layout tree + reconcile on load, panels:get-layout / panels:set-layout with server-side id validation and daemon registration (allowlist test updated and passing), preload panel:deleted event, migration placed after the sessions table rebuilds with the column-intersection copy fix, IntlBackslash handling in both hotkeyStore and the terminal release, zoom-exit-on-structural-change, sizes persisted on drag end only, single-group sessions rendering without an Allotment wrapper. all present. unit tests for panelLayout.ts are explicitly deferred as a follow-up; I'd second that since the tree math is the part most worth locking in.
one informational note on the migration fix: a DB that still needs the INTEGER folder_id rebuild AND somehow has data in late-added columns would silently lose that late-column data (the columns get re-added empty afterwards). that replaces an outright crash so it's a strict improvement, just worth knowing the tradeoff exists.
Summary
solid, well-commented implementation. the pure-function layout tree with central self-healing in applyLayout is the right architecture, the idempotent addPanelToGroup kills the create/event race cleanly, and the server-side validation on set-layout is a nice touch given the daemon exposure. nothing blocking; the three should-fix items are small. ready to merge after the lint directive cleanup, with the mac SIGQUIT release as the one judgment call.
| return () => { | ||
| flushLayoutPersist(); | ||
| }; | ||
| }, [activeSession?.id, setPanels, setActivePanelInStore, setLayoutInStore, setFocusedGroupInStore, flushLayoutPersist]); // eslint-disable-line react-hooks/exhaustive-deps |
There was a problem hiding this comment.
this directive is unused now (the deps array is complete), so eslint reports it as a new warning. drop the comment.
|
|
||
| // Split tab groups: Mod+\ and Mod+Shift+\ (Ctrl+\ is SIGQUIT - must release!) | ||
| // ISO/international keyboards report the key as IntlBackslash | ||
| if (ctrlOrMeta && (e.code === 'Backslash' || e.code === 'IntlBackslash')) return false; |
There was a problem hiding this comment.
on mac this also releases Ctrl+\ (ctrlOrMeta), but the app hotkey there is only Cmd+, so non-TUI terminals lose SIGQUIT for nothing in return. the other releases in this block follow the same ctrlOrMeta convention, but none of them swallow a real terminal signal; worth platform-gating just this one (TUI mode is unaffected either way since the passthrough guard sits above).
| primaryGroupFocused={!primaryGroupNode || primaryGroupNode.id === focusedGroupId} | ||
| onDragStart={handleDragStart} | ||
| onDragEnd={handleDragEnd} | ||
| onStripDrop={primaryGroupNode ? (panelId, idx) => handleStripDrop(primaryGroupNode.id, panelId, idx) : undefined} |
There was a problem hiding this comment.
new arrow identity on every render, so memo(PanelTabBar) never bails out and the whole tab bar re-renders with SessionView. wrap in a useCallback keyed on primaryGroupNode?.id.
| if (group) { | ||
| const remainingInGroup = group.panelIds.filter(id => id !== panel.id); | ||
| const panelIndex = group.panelIds.indexOf(panel.id); | ||
| const nextInGroup = remainingInGroup[Math.min(panelIndex, remainingInGroup.length - 1)]; |
There was a problem hiding this comment.
fixActive runs even when the closed panel wasn't the group's active tab, so closing a background tab via its hover X switches the active tab to the closed tab's neighbor. same as the old non-layout path, so non-blocking, but vs code keeps the current tab here; gating this block on group.activePanelId === panel.id would match it.
Description
Sessions can now split the editor stage into multiple tab groups, VS Code style. Drag a tab onto another group's edge to split in that direction (5-zone drop targets), drop on the center to move it, or split from the keyboard. Each group keeps its own active tab, sashes resize groups, and the whole layout (structure, sizes, active tabs, focused group) persists per session across restarts.
Sessions that never split are pixel-identical to today: no extra tab row, no Allotment wrapper in the DOM, no new chrome. The pinned bottom terminal, detail panel, swapped layout, and immersive mode are untouched.
How it works:
sessions.panel_layoutcolumn, reconciled against live panels on every load so stale or orphaned panels self-healpanels:get-layout/panels:set-layoutIPC (registered for the remote daemon too) with server-side panel id validation, plus apanel:deletedpreload event for layout reconciliationKeyboard:
Mod+\split right,Mod+Shift+\split down (works on ISO keyboards via IntlBackslash)Mod+Alt+Arrowsmove focus between groups (geometric nearest-in-direction)Mod+Shift+Zzoom toggle (tmux prefix+z equivalent); any structural change or focus move exits zoomMod+A/Mod+D,Mod+Shift+1-9,Mod+Wnow act on the focused groupMod+Shift+ZtoMod+Alt+Z(which also fixes it never having worked with a terminal focused)Type of Change
Checklist
panelLayout.ts)pnpm typecheckandpnpm lintlocallypnpm electron-devCritical Areas Modified
Additional Notes
panel_layoutcolumn-add sits after the sessions table rebuilds inrunMigrations()(the rebuild schemas don't carry late-added columns; this is the same reasonactive_panel_idlives there). The legacy folder-id rebuild also moved fromINSERT ... SELECT *to an explicit column-intersection copy, so it no longer breaks when the live table has extra columns.Ctrl+\is released to the app instead of sending SIGQUIT to the PTY (outside TUI mode), matching how the other app shortcuts are handled.display: none), so xterm never reflows; sash resizes refit through the existing debounced ResizeObserver.