feat: explode mode layout control — repack, minimize, swap - #2
Open
mthorme wants to merge 8 commits into
Open
Conversation
Groundwork for explode-mode layout control. Pure geometry, no DOM, so the packing rules are unit-testable and the renderer stays a dumb rect applier. Packs column-major with no dead cells: items split across columns as evenly as possible with earlier columns taking the remainder, so an odd item out lands alone in a later column at that column's FULL height. Previously a `repeat(cols, 1fr) x repeat(rows, 1fr)` grid left 3-items-in-2-columns as a half-height cell beside an empty one. Returns absolute pixel rects rather than grid tracks because the cells are one flat `tabs.map` shared by explode and normal mode; wrapping them in per-column elements would change the React tree between modes and remount every terminal, losing xterm scrollback on each toggle. Rects also express "one cell fixed, siblings absorb the rest" for minimize, which mixed auto/1fr tracks cannot do per-column. Also included: swap (exchanges exactly two positions rather than splicing, so one drag never cascade-shifts the tail) and order reconciliation against the open tabs. Not yet wired to the renderer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Explode mode gave no control over its grid. Three changes. LAYOUT. The old `repeat(cols, 1fr) x repeat(rows, 1fr)` grid left dead cells on uneven counts: 3 terminals in 2 columns put the third at half height beside an empty cell. Cells now get explicit pixel rects from `computeExplodeLayout`, packed column-major so the odd one out owns a column at FULL height. Rects rather than grid tracks because the cells are one flat `tabs.map` shared with normal mode; wrapping them in per-column elements to express this would change the React tree between modes and remount every terminal, losing xterm scrollback on each toggle. `useExplodeMode` now tracks container height too, since rects cannot rely on tracks stretching. MINIMIZE. A minimized terminal leaves the grid entirely and parks in a header tray beside the "N tasks" label — explode is the one layout with spare header room. The survivors repack over it, so minimizing actually buys space rather than leaving a collapsed strip still holding a row. It stays MOUNTED and inert, not unmounted: it keeps running with its scrollback, which is the point of minimizing rather than closing. Focus is never left on a minimized cell, or shortcuts would route to a terminal the user cannot see. SWAP. A hover-revealed handle drags one cell onto another to exchange exactly those two positions. Swap, not splice: a splice would cascade-shift the tail, reading as "everything moved" when one trade was asked for. Native DnD with a custom mime rather than dnd-kit — the layout is custom rects, not a DOM-ordered sortable list, and the mime keeps dragged text and files falling through to the terminal that should handle them. Arrangement state is session-scoped and reconciled against the open tabs, so a closed task drops out and a newly-opened one appends rather than vanishing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…minals Dragging a cell onto a full-height one did nothing, while two same-sized cells swapped fine. Not a layout bug — the swap geometry is correct in isolation — but an event one. `Terminal.tsx` attaches a `dragover` listener that calls stopPropagation() unconditionally, since it owns file-drop-to-paste-path. A bubbling handler on the cell therefore never fires once the pointer is over terminal content. Whether a swap worked came down to whether the user happened to release over non-terminal chrome — most of a small cell's area is chrome-adjacent, and almost none of a full-height cell's is, which is exactly the size correlation that showed up in use. Move both handlers to the grid container in the CAPTURE phase, which runs before the terminal's listener, and resolve the drop target by walking up from `e.target` to the owning `[data-explode-task-id]` cell. Still gated on the custom mime, so file and text drags fall through to the terminal untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ders Two sources of churn introduced when cells moved from grid tracks to explicit rects. Rects were fractional. xterm derives its row count from the measured container, so a height sitting on a row boundary can flip between N and N+1 rows as the observer reports sub-pixel jitter — and every flip is a real fit(), a SIGWINCH to the pty and a WebGL atlas re-raster. `ensureFit` is idempotent on a STABLE geometry, but an oscillating one defeats it. Rects now snap to whole pixels, rounding EDGES rather than sizes so neighbouring cells still share an exact boundary instead of each drifting by its own rounding error. The ResizeObserver also set width/height straight from `contentRect`, which reports sub-pixel values — so jitter far below one pixel re-rendered the whole app tree and re-laid out every terminal. Round, then bail when unchanged; same-value setState is a React bail-out, so only real size changes propagate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Minimized state and custom order lived in component state, so a renderer reload or a restart silently threw away a layout the user deliberately built — which defeats the point of being able to arrange it at all. Move both onto the tab store's persisted `viewState` slice, alongside `treeOpenProjects` and the other view state that exists in the store for exactly this reason. `setExplodeArrangement` takes a partial so a drag writes only the order and a minimize only the tray, instead of each restating the other and racing it. `_loadState` validates element-by-element: this JSON is on disk, and a malformed entry would otherwise flow into layout as an undefined task id. Stale ids from tasks closed while the app was shut are already handled by the existing reconcile-against-open-tabs on read. Export `_setTrpcClientSingleton` from the transport client barrel, for the same reason `_resetHubClients` is already exported: the store persists on a 500ms debounce, so a unit test that flips `isLoaded` schedules a tRPC write that fires after the assertions and crashes the run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running biome over whole directories swept its reformatting of 22 settings files, and three unrelated line-wraps in App.tsx, into these commits. None of it was part of this change; reverted so the diff is only the feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A duplicate task id in a persisted order survived reconciliation, so the layout allocated a rect for each occurrence while the renderer draws each task once. The grid then sized itself for the larger count and one rect was left unclaimed — visible dead space, the exact defect this layout exists to remove. Reproduced: order ['a','a','b'] over open ['a','b'] yields 3 cells but only 2 rendered rects. Dedupe in `reconcileExplodeOrder` rather than at the persistence boundary: it is the single choke point every read passes through (the ordered list AND the swap path), so the "each open id exactly once" invariant holds for any source of a bad order, not only a hand-edited settings blob. First occurrence wins, so the user's leftmost placement is the one kept. Reported by Greptile on PR review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Explode mode passes `hideTabs`, so the tab bar — the only place a task can be renamed by double-clicking its title — is gone. The compact header that replaces it rendered the title as a plain span, leaving no way to rename a task at all while in explode mode. Wire that span to the existing `useTaskTitleEditing` hook the full header already uses, so focus/select, save-on-blur, Enter and Escape all behave identically — no second implementation to drift. Double-click rather than the full header's single-click: this strip is 40px tall and clicking it is how you focus the cell, so single-click editing would fire by accident constantly. Pointer events are stopped on the input, since the grid's own drag and focus handlers would otherwise blur the field mid-edit. Temporary tasks keep the plain span — they have no title to edit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Explode mode gave no control over its grid. Three changes plus two fixes found while using it.
Repack — no dead cells
The old
repeat(cols, 1fr) x repeat(rows, 1fr)grid wasted space on uneven counts: 3 terminals in 2 columns put the third at half height beside an empty cell. Cells now get explicit pixel rects, packed column-major so the odd one out owns a column at full height.Rects rather than grid tracks because the cells are one flat
tabs.mapshared with normal mode. Wrapping them in per-column elements to express this would change the React tree between modes and remount every terminal, losing xterm scrollback on each toggle.Minimize — to a header tray
A minimized terminal leaves the grid entirely and parks beside the "N tasks" label (explode is the one layout with spare header room). Survivors repack over it, so minimizing actually buys space rather than leaving a collapsed strip still holding a row.
It stays mounted and inert, not unmounted — it keeps running with its scrollback, which is the point of minimizing rather than closing. Focus is never left on a minimized cell, or shortcuts would route to a terminal the user cannot see.
Swap — drag one cell onto another
A hover-revealed grip exchanges exactly two positions. Swap, not splice: a splice cascade-shifts the tail, reading as "everything moved" when one trade was asked for.
Native DnD with a custom mime rather than
@dnd-kit(which is available): the layout is custom rects, not a DOM-ordered sortable list, and the mime keeps file/text drags falling through to the terminal that should handle them.Two fixes found in use
Drops were swallowed over terminals.
Terminal.tsxattaches adragoverlistener callingstopPropagation()unconditionally (it owns file-drop-to-paste-path), so a bubbling cell handler never fired over terminal content. Whether a swap worked came down to landing on non-terminal chrome — which correlates with cell size, so full-height cells appeared un-droppable. Handlers moved to the grid in the capture phase.Sub-pixel churn. Fractional rects let xterm's row count oscillate between N and N+1 on observer jitter, each flip a real
fit(), SIGWINCH and atlas re-raster —ensureFitis idempotent on a stable geometry, but an oscillating one defeats it. Rects snap to whole pixels, rounding edges so neighbours still tile exactly. The ResizeObserver also re-rendered the whole app tree on sub-pixel jitter; now rounded with a bail-out.Verification
explodeLayout.test.ts— 65 checks: packing, the 3-in-2-columns regression, exact tiling, whole-pixel rects at awkward sizes (1001x601), swap semantics, order reconciliationlint:themecleanKnown / deliberate