Skip to content

feat: explode mode layout control — repack, minimize, swap - #2

Open
mthorme wants to merge 8 commits into
mt/mainfrom
mt/feat/explode-mode-layout-control
Open

feat: explode mode layout control — repack, minimize, swap#2
mthorme wants to merge 8 commits into
mt/mainfrom
mt/feat/explode-mode-layout-control

Conversation

@mthorme

@mthorme mthorme commented Aug 7, 2026

Copy link
Copy Markdown
Owner

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

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.tsx attaches a dragover listener calling stopPropagation() 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 — ensureFit is 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.ts65 checks: packing, the 3-in-2-columns regression, exact tiling, whole-pixel rects at awkward sizes (1001x601), swap semantics, order reconciliation
  • Typecheck + biome + lint:theme clean
  • Exercised in a live dev build

Known / deliberate

  • Arrangement is session-scoped — minimized state and custom order reset on app restart. Persisting to the tab store is a natural follow-up, left out rather than guessing where it belongs.
  • xterm's scrollbar can look slightly off — cell heights are whole pixels but not multiples of the terminal row height, so a few pixels of remainder sit below the last row while the scrollbar spans the container. Pre-existing (old fractional track heights were worse); a real fix means snapping to row multiples, which the engine cannot do without knowing each cell's non-terminal chrome.

mthorme and others added 8 commits August 7, 2026 20:13
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant