feat: explode mode layout control — repack, minimize, swap - #101
Conversation
Explode mode gave no control over its grid. REPACK. `repeat(cols, 1fr) x repeat(rows, 1fr)` 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 a pure layout function, 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 would change the React tree between modes and remount every terminal, losing xterm scrollback on each toggle. `useExplodeMode` tracks container height too, since rects cannot rely on tracks stretching. Rects snap to whole pixels, rounding EDGES so neighbours still tile exactly — a fractional height lets xterm's row count oscillate on sub-pixel jitter, and each flip is a real fit(), SIGWINCH and atlas re-raster. MINIMIZE. A minimized terminal leaves the grid and parks in a header tray beside the "N tasks" label, so survivors repack over it rather than a collapsed strip still holding a row. It stays mounted and inert, never unmounted: it keeps running with its scrollback, which is the point of minimizing rather than closing. Focus never rests on a minimized cell, or shortcuts would route to a terminal the user cannot see. SWAP. A hover-revealed grip drags one cell onto another, exchanging exactly those two positions — 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 lets file/text drags fall through to the terminal. Both handlers sit on the grid in the CAPTURE phase, because Terminal.tsx's own dragover listener calls stopPropagation() unconditionally — a bubbling handler never fires over terminal content, which is most of a cell and all of a full-height one. Arrangement persists on the tab store's `viewState` slice, alongside `treeOpenProjects` and the other view state that lives there for exactly this reason, and is reconciled against the open tabs on read so stale ids are harmless. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| explodeOrder: Array.isArray(state.explodeOrder) | ||
| ? state.explodeOrder.filter((id): id is string => typeof id === 'string') | ||
| : [], |
There was a problem hiding this comment.
Duplicate order entries orphan cells
When persisted explodeOrder contains a duplicate valid task ID, validation and reconciliation preserve it, so layout allocates multiple rectangles for one task while the rectangle map and tab rendering retain only one, leaving visible dead space in the explode layout.
| explodeOrder: Array.isArray(state.explodeOrder) | |
| ? state.explodeOrder.filter((id): id is string => typeof id === 'string') | |
| : [], | |
| explodeOrder: Array.isArray(state.explodeOrder) | |
| ? [...new Set(state.explodeOrder.filter((id): id is string => typeof id === 'string'))] | |
| : [], |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/domains/settings/src/client/useTabStore.ts
Line: 581-583
Comment:
**Duplicate order entries orphan cells**
When persisted `explodeOrder` contains a duplicate valid task ID, validation and reconciliation preserve it, so layout allocates multiple rectangles for one task while the rectangle map and tab rendering retain only one, leaving visible dead space in the explode layout.
```suggestion
explodeOrder: Array.isArray(state.explodeOrder)
? [...new Set(state.explodeOrder.filter((id): id is string => typeof id === 'string'))]
: [],
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.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>
|
Good catch — valid, and reproduced before fixing. With a stored order of Fixed in Reasoning: First occurrence wins, so the user's leftmost placement is the one kept. Added 5 checks covering it (70 total): duplicate collapse, first-occurrence-wins positioning, dedupe combined with appending never-seen tasks, and an end-to-end assertion that no two rects share a task id and the grid is sized for the real count. |
feat: explode mode layout control — repack, minimize, swap
Explode mode is great for watching several agents at once, but it offers no control over the grid. Three changes, each addressing something that bit me using it daily.
1. Repack — no dead cells
repeat(cols, 1fr) x repeat(rows, 1fr)wastes space on uneven counts. Three terminals in two columns puts the third at half height beside an empty cell:Cells now get explicit pixel rects from a pure layout function, packed column-major so the odd one out owns a column at full height.
3/2 → [2,1],5/3 → [2,2,1],4/2 → [2,2](already even, unchanged).Why rects and not grid tracks. The cells are one flat
tabs.mapshared by explode and normal mode (normal mode already positions themabsolute inset-0). Wrapping them in per-column elements for explode mode would change the React tree between modes and remount every terminal — losing xterm scrollback on every toggle. Rects also express "one cell fixed, siblings absorb the rest", which mixedauto/1frtracks cannot do per-column.Rects snap to whole pixels, rounding edges rather than sizes so neighbours still tile exactly. This is not cosmetic: xterm derives its row count from the measured container, so a fractional height can flip between N and N+1 rows on sub-pixel jitter, and each flip is a real
fit(), a SIGWINCH to the pty and a WebGL atlas re-raster.ensureFitis idempotent on a stable geometry, but an oscillating one defeats it.2. 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, never unmounted — it keeps running with its scrollback, which is the point of minimizing rather than closing. Focus is also pushed off a minimized cell, or shortcuts would route to a terminal the user cannot see.
3. Swap — drag one cell onto another
A hover-revealed grip exchanges exactly two positions. Swap, not splice: a splice cascade-shifts the tail, which reads as "everything moved" when one trade was asked for.
Native DnD with a custom mime rather than
@dnd-kit(which is already a dependency): the layout is custom rects, not a DOM-ordered sortable list, and the mime lets file/text drags fall through to the terminal that should handle them.Both handlers sit on the grid in the capture phase.
Terminal.tsxattaches adragoverlistener that callsstopPropagation()unconditionally (it owns file-drop-to-paste-path), so a bubbling handler on the cell never fires once the pointer is over terminal content — which is most of a cell, and effectively all of a full-height one. That made swaps appear to work only between same-sized cells.Persistence
Arrangement (order + tray) rides the tab store's
viewStateslice, alongsidetreeOpenProjectsand the other view state that lives there for exactly this reason — a reload would otherwise throw away a layout the user deliberately built._loadStatevalidates element-by-element, since that JSON is on disk and a malformed entry would flow into layout as an undefined task id. Stale ids from closed tasks are handled by the existing reconcile-against-open-tabs on read.Verification
explodeLayout.test.ts— 65 checks: packing, the 3-in-2-columns case, exact tiling, whole-pixel rects at awkward sizes (1001x601), swap semantics, order reconciliation.useTabStore.test.ts— 3 new cases for partial arrangement writes, restore, and malformed input; all 7 pass.pnpm lint:theme,pnpm lint:server-boundary,pnpm typecheck,pnpm --filter @slayzone/cli build,pnpm build— all clean (i.e. the fullcheckjob).Notes
_setTrpcClientSingletonis exported from the transport client barrel, for the same reason_resetHubClientsalready is: the store persists on a 500ms debounce, so a unit test that flipsisLoadedschedules a tRPC write that fires after the assertions and crashes the run.App.tsxhunk is mostly re-indentation — the explode grid block was previously indented one level shallow, and editing it brought the subtree into line.Happy to split this into separate PRs (repack / minimize / swap) if you'd prefer to take them independently, or to adjust the interaction design — the affordances are deliberately small and hover-only, but that is easy to change.
Greptile Summary
The PR adds persistent explode-mode layout controls while preserving mounted terminal sessions.
Confidence Score: 5/5
The PR appears safe to merge.
No blocking failure remains; current reconciliation removes duplicate task IDs before layout allocation and resolves the previously reported orphan-cell issue.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart LR Persisted[Persisted view state] --> Reconcile[Reconcile and deduplicate open task order] Reconcile --> Split{Minimized?} Split -->|No| Layout[Compute packed pixel rectangles] Split -->|Yes| Tray[Render in minimized tray] Layout --> Cells[Position mounted terminal cells] Tray --> Restore[Restore task] Restore --> Split Cells --> Swap[Drag task onto another task] Swap --> PersistedReviews (2): Last reviewed commit: "fix(explode): collapse duplicate ids so ..." | Re-trigger Greptile