Skip to content

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

Open
mthorme wants to merge 2 commits into
debuglebowski:mainfrom
mthorme:mt/upstream/explode-mode-layout-control
Open

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

Conversation

@mthorme

@mthorme mthorme commented Aug 8, 2026

Copy link
Copy Markdown

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:

before                        after
┌────────┬────────┐          ┌────────┬────────┐
│   1    │   2    │          │   1    │        │
├────────┼────────┤          ├────────┤   3    │
│   3    │ (dead) │          │   2    │        │
└────────┴────────┘          └────────┴────────┘

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.map shared by explode and normal mode (normal mode already positions them absolute 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 mixed auto/1fr tracks 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. ensureFit is 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.tsx attaches a dragover listener that calls stopPropagation() 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 viewState slice, alongside treeOpenProjects and the other view state that lives there for exactly this reason — a reload would otherwise throw away a layout the user deliberately built. _loadState validates 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.ts65 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 full check job).
  • Used daily in a local build while developing it; the two bugs above were found that way.

Notes

  • _setTrpcClientSingleton is exported from the transport client barrel, for the same reason _resetHubClients already is: 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.
  • The large App.tsx hunk is mostly re-indentation — the explode grid block was previously indented one level shallow, and editing it brought the subtree into line.
  • Known cosmetic issue: xterm's scrollbar can look slightly off, because cell heights are whole pixels but not multiples of the terminal row height, leaving a few pixels below the last row while the scrollbar spans the container. This predates the change (fractional track heights were worse); a real fix means snapping to row multiples, which the layout cannot do without knowing each cell's non-terminal chrome.

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.

  • Replaces CSS grid tracks with deterministic pixel-based, column-major cell rectangles.
  • Adds terminal minimization into a restorable header tray.
  • Adds drag-and-drop position swapping and persists order and minimized state.
  • Deduplicates reconciled task order, resolving the previously reported orphan-cell failure.

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

Filename Overview
packages/domains/app-shell/src/client/explodeLayout.ts Implements deterministic packed-cell geometry, swap semantics, and order reconciliation with duplicate removal.
packages/domains/app-shell/src/client/useExplodeMode.ts Derives visible and minimized task sets, maintains focus, and persists minimize, restore, and swap operations.
packages/apps/app/src/renderer/src/App.tsx Applies computed rectangles, renders minimized terminals inert but mounted, and wires capture-phase drag-and-drop controls.
packages/domains/settings/src/client/useTabStore.ts Adds persisted explode order and minimized-task state with defensive element-type validation.
packages/domains/app-shell/src/client/explodeLayout.test.ts Covers packing, exact pixel tiling, swapping, reconciliation, and the prior duplicate-order regression.

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 --> Persisted
Loading

Reviews (2): Last reviewed commit: "fix(explode): collapse duplicate ids so ..." | Re-trigger Greptile

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>
Comment on lines +581 to +583
explodeOrder: Array.isArray(state.explodeOrder)
? state.explodeOrder.filter((id): id is string => typeof id === 'string')
: [],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
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>
@mthorme

mthorme commented Aug 8, 2026

Copy link
Copy Markdown
Author

Good catch — valid, and reproduced before fixing. With a stored order of ['a','a','b'] over open tasks ['a','b'], the layout allocated 3 cells but only 2 rendered rects: the grid sizes itself for the padded count, and the renderer's id→rect map keeps one entry per task, so the extra rect is orphaned as visible dead space. Precisely the defect this layout exists to remove.

Fixed in f494fbd7, though I put it in reconcileExplodeOrder rather than in _loadState as suggested.

Reasoning: reconcileExplodeOrder is the single choke point every read passes through — both the ordered list the grid consumes and the swap path, which re-reconciles before writing. Deduping there makes "each open id appears exactly once" an invariant of the function that owns it, so it holds for any source of a bad order, not only a hand-edited settings blob. A dedupe at the persistence boundary would leave the layout still trusting its input.

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.

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