Repository navigation
feat(dispatch): grid dispatch mode with per-row projects and no self-filling slots - #687
Conversation
…lots
Four coupled changes, planned together because they are one idea: a row
is a self-contained dispatch view, and a slot within it is space rather
than an occupant.
The load-bearing decision is that `lanes` stays a flat row-major array
and `focusedLane` stays a scalar. A `{row, col}` coordinate would rewrite
every lane-coherence helper and all nine of their call sites, plus the
six readers of the focus scalar. tiledDispatchSelectors.ts's header
already records that two bug classes came from code maintaining some
lane pointers and missing others; a 2-D coordinate multiplies exactly
that surface. Row metadata rides alongside as an object array because no
coherence helper touches it.
Removing the heal effect also removes `userEmptied`, whose only purpose
was hiding New Lane's empty slot from that healer, and with it the four
follow-up commits' worth of "drop the flag at every writer" maintenance.
Refs #681
Written after Stage 1 was already implemented, which was the wrong ordering, and re-reading the work against the method is what surfaced U1 and U2. U1: the orchestration child cap's threshold, depth rule and default were all specified against a fan-out shape that appears in no recording this repo holds. Running the real selectors over the one real persisted workspace fixture shows three parents with two children each — a cap of 3 never fires on it. The number is recorded as a tunable rather than a finding, and the feature is shaped so being wrong about it means "does nothing yet" rather than "the index lies". U2 is the correctness half and is not a tuning question: buildDispatchGroups emits a child at depth 0 when its parent is absent from the same group, including when the parent is PINNED, so a cap keyed on depth === 1 would silently miss an orphaned child. The census also confirmed two things the design had only argued: globalIndex runs straight through nesting (so collapsing must produce gaps, never renumber), and labels are scope-dependent, which the project-binding filter has to account for. Kept deliberately small — no new corpus, no new extractor. This feature is layout algebra over a structure this PR defines, not multi-source reconciliation over an unknown case set, and the one real fixture it needs already exists. Refs #681
Grid Dispatch stacks rows of lanes, but `lanes` stays a FLAT row-major
array and `focusedLane` stays a flat scalar. A `rows: DispatchLane[][]`
model with a {row, col} coordinate would rewrite remapTiledLanes,
clearTiledLaneSessions and keepTiledLaneSessions plus all nine of their
call sites, and the six readers of the focus scalar — the exact surface
whose header records that two bug classes came from maintaining some
lane pointers and missing others. Flat means none of that code changes
to gain a second dimension.
Row shape rides alongside as a DispatchGridRow[] descriptor. It is an
object array rather than four parallel arrays (lengths, heights,
projects, caps) because one object cannot desynchronize the way four can,
and no coherence helper touches it.
Every mutation lives here and returns a complete coherent shape or null,
never a partial update a caller has to finish correctly, because
sum(rows[].length) === lanes.length is the one invariant in the grid that
can drift. normalizeGridShape repairs rather than validates: it runs on
rehydrate against a file a user can hand-edit, and throwing there takes
the renderer down at launch and disables the autosave that would fix it.
Row lengths are deliberately independent, with no global column count
anywhere in the module. Four lanes on top and two below is the expected
shape; coupling them would make New Lane in one row silently add a lane
to every other row.
The per-row column bounds move here from tiledDispatchSelectors (which
re-exports them) so one module owns every shape rule.
Refs #681
Killing the agent in column 2 of 6 replaced it with whatever was next in the index instead of leaving a hole. The user closed one thing and an unrelated thing appeared in the position they were watching — the layout rearranging itself for reasons they did not ask for and could not predict. Three paths acquired an agent unasked: buildAutoLanes on enter, buildAutoLanes on count growth, and the render-time heal effect on any lane that failed to resolve. All three are gone. A slot is space, and space does not imply an occupant; #673 established that for New Lane and the rule now holds everywhere, which also means no creation path has to justify why it is the exception. Resolution is unchanged and still asserted: a dead or out-of-scope id must not paint. Only the reaction to a null resolution moved, from "refill it" to "render empty". An out-of-scope lane now KEEPS its selection, so flipping scope back restores it — strictly better than the healer, which replaced it irreversibly. The replacement suite is built on the real persisted workspace fixture rather than a hand-built state, and drives the kill through the real clearTiledLaneSessions path. Both new assertions failed against the old healer before it was removed. tiledLaneHealing.renderer.test.tsx is deleted rather than adapted: six of its seven cases existed only to police userEmptied against the healer, and the surviving contracts moved to tiledLaneResolution.renderer.test.tsx. Refs #681
The flag existed for exactly one reason: to make New Lane's deliberate hole invisible to the render-time healer. With no healer (previous commit) every empty lane behaves identically, so the distinction it encoded — WHY a lane is empty — has no reader left. A persisted field with no reader is how the next person reintroduces a behavior nobody wants. It was never free. Because a lane is spread at every writer, the flag had to be dropped at every writer, and it was missed at two of them: the spawn-focus path and the A2! index path, both of which are the ORDINARY way a user fills the lane New Lane just created. A filled lane kept the marker, and when that agent later exited the lane became a hole the healer skipped forever — durably, since the flag round-trips through workspace.json. That took four follow-up commits to close. withLaneSession survives as a plain one-field write. It is kept rather than inlined because lane construction staying in one place is what makes a future field with a maintenance rule cheap; the comment now records that the last such rule is why the helper is worth having. Old workspace.json files carry the flag. It is dropped by the lane spread on rehydrate, so no migration is needed. The agentIndexNavigation case is rewritten rather than deleted: its framing was about the flag, but the contract underneath — the bang intent writes into the FOCUSED lane instead of discovering some other lane already showing that agent — is still real and still worth pinning. Refs #681
A workspace.json written by the single-row layout has no `rows` and a legacy `ratios` array that packs two different things together: index 0 is the sidebar's fraction, 1..N are lane weights. Once each ROW owns its own index fraction those halves have to separate, so the split happens once on read rather than at every consumer. Absent `rows` means one row holding every lane, which is why the common case needs no migration at all — every workspace ever written is already a valid one-row grid by construction. The `ratios` split is the one real migration, and it is a pure function with its own tests rather than a schema version bump, because the target fields are additive and the legacy array is unambiguous. Normalization is composed OUTERMOST in rehydrate so the shape rules see the final lane array, and it sits on the same DispatchModeState seam as remapTiledLanes and keepTiledLaneSessions rather than becoming a fourth thing a caller must remember. normalizeGridShape now returns its input arrays when nothing needed repair. That is a correctness property, not a micro-optimization: this runs on rehydrate and behind reducers, both feeding React, and minting an identical shape on every read would defeat every consumer memoizing on dispatchMode identity. The tests run against the real persisted workspace fixture, whose tiled block carries a genuine legacy ratios array with the index width the user actually dragged — including a guard that fails if that fixture is ever re-recorded from a build that already writes `rows`, since the migration would then be silently untested. Refs #681
…ensity A grid row is a complete dispatch view, so what it lists is a property of the row: it can be bound to a single project, and it can collapse an orchestration parent whose children would otherwise bury every other project in the index. This is a PRESENTATION filter and never reaches buildVisibleDispatchRows. Labels, globalIndex, cmd+N targeting, and lane resolution stay computed from the full canonical set, so a filtered row shows gaps (D12, D15, D17) rather than renumbering. That is the drift class buildVisibleDispatchRows' header warns about: renumber on collapse and the highlighted chip stops naming the session the keystroke acts on. Filtering runs before capping, because a project filter can remove a parent's siblings but never its children, so capping first would count children the row does not show. U2 from the decomposition is resolved here, and the answer was that it was never a bug. Grouping children positionally — the run of depth-1 rows after a depth-0 row — follows the nesting the list actually renders, so an orphaned child (parent pinned, closed, or filtered away) presents as an ordinary top-level agent and is correctly NOT capped. Hiding it under a '+N more' for a parent the user cannot see would be strictly worse. The cap threshold remains a tunable, not a finding: no recording in this repo has a parent with more than two children, so it is inert on every shape we have. The test asserting that is the honest record of it. Refs #681
Every row now owns its index list, project binding, list density, lanes, and height. An earlier draft gave the whole grid one full-height sidebar; that cannot answer 'whose agents am I listing?' once two rows are bound to different projects, leaves the per-row controls with no home, and makes 'which lane does clicking a row select?' unanswerable across four rows. A per-row index answers all three by construction. Two consequences worth calling out as behavior changes: Every lane gets its own strip, including each row's first. Lane 0 had no strip because the sidebar WAS its selector; with a per-row index that special case is meaningless. The index now selects into the FOCUSED lane rather than always lane 0, moving focus into the row first when focus is elsewhere. This is visible in the existing single-row layout. It also removes a real inconsistency: cmd+N and the arrows already acted on the focused lane while the sidebar acted on lane 0. Notably it does NOT introduce a per-row remembered column, which would be a second source of focus truth beside the flat focusedLane — the exact shape of #266/#267/#271/#272. Row heights and per-row index widths are draggable. useResizableSplitter gained a y axis rather than being forked, which its own comment anticipated ('clientY for future vertical splitters'): all three of its correctness properties — window-level capture, cursor lock, preventDefault — are axis-independent, and a second copy would have been a fourth place to get them subtly wrong. SplitHandle gained the matching orientation, including the ARIA detail that a divider between stacked rows is itself a horizontal separator. The row-project picker lists 'Any project' as a value in the same list rather than shipping a bind/unbind pair, the same correction that made Dispatch Scope name both of its ends. Refs #681
Six commands: New Row, Remove Row, Row Project, Orchestrated Agents, and the two row-focus movements. Titles follow docs/command-style.md — stable nouns with state in a getState badge, so "Orchestrated Agents" shows Capped/All rather than becoming "Toggle Orchestrated Agents". New Lane's admission is now per-ROW plus the total ceiling. Measuring the whole lane count against MAX_DISPATCH_TILES would refuse a legal lane in a short row because a different row is full. Its "when" and "run" share one helper: they had duplicated the cap before, which is how a cap ends up enforced in one and not the other. That helper deliberately checks the RAW focusedLane before normalizing. Normalizing first clamps a stale coordinate into a valid one and would quietly insert beside a lane the user is not looking at — the existing test caught exactly that regression, and the strict-target policy it encodes is the same one dispatchTarget applies to lifecycle commands. The tile-count prompt becomes a per-row shape editor. Two inputs labelled "rows" and "columns" would teach a rectangle in the one place the user forms their mental model of the feature, and the rectangle is the exception: projects do not have equal agent counts. A stepper per row teaches that rows are independent, and the block preview SHOWS raggedness, so "4 on top, 2 below" is visible before committing rather than discovered later. enterTiledDispatch therefore takes a length per row instead of a single count. Alt+Shift+Up/Down pair with the existing Alt+Up/Down as "same axis, bigger unit". Verified free and accepted by check:keybindings rather than assumed. They are registered commands, not inline handlers like the four existing Dispatch arrows, so they appear in Keyboard Shortcuts and can be rebound; migrating those four is its own issue. The catalog snapshot moves 106 -> 112 by raising the subtrahend, not the baseline — its own comment forbids bumping the right-hand side, since that keeps the test green while making it assert nothing. Refs #681
Row metadata names two things that can vanish behind autosave's back: a project tab and a set of expanded parent sessions. The prune already claims to keep the serialized model closed under restore and scrubs every pointer aimed at a session; rows added a pointer aimed at a TAB. The binding is the dangerous one. A row bound to a closed project filters its own index to nothing, permanently, and the picker only offers tabs that exist — so there is no UI path back. Repairing at render would not help either, since the stale value is rewritten on the next save. Composed at the same boundary as keepTiledLaneSessions rather than in a close/kill path, for the reason the prune's own header gives: it is the one place that sees the surviving set as authority. Refs #681
Two contracts a pure-function test cannot make. The layout tests assert the grid renders AS a grid: one index per row, one strip per lane including each row's first, uneven rows staying uneven, and each row's binding reaching its own index while its neighbour's stays unnarrowed. The #673 post-mortem is why this is separate from the shape algebra — a splice proven correct by a unit test shipped as a no-op because the component undid it on render. The persistence tests pin that a 4/2 grid round-trips unchanged and that a repair caused by a corrupt length still does not redistribute toward a rectangle. That failure mode is different in kind from the others: a normalization that quietly tidied 4/2 into 3/3 would surface as a layout bug on the next launch, long after the code responsible. Docs record the outcome, including that U2 was resolved as not-a-bug and U1 ships accepted as unverified. Refs #681
Two adversarial reviews (one Claude, one Codex) against #687. The state model held — the flat-lanes bet and the claim that no lane-coherence code had to change were both confirmed — and every finding was in the new UI. Blocking: The shape editor deleted the WRONG row. Its state was a bare number[] of lengths, which cannot express which row went, so removing the middle of three shifted every later row up a slot and a positional apply re-pointed row 1's binding at row 2's contents: it dropped the last row and resized the survivors, evicting agents from rows the user never touched. Rows now carry an explicit sourceRow, and setGridShape follows the focused lane to wherever its row ended up rather than clamping a stale flat index into a different row. Alt+Shift+Up/Down is retracted. It was checked against defaults.ts and accepted by check:keybindings, and both facts were beside the point: that checker knows this app's binding table, not the OS. useKeybinds' own header already records that Option+Shift+Arrow is macOS word selection and load-bearing for every text field including the composer — which is why directional resize uses fn+alt+Arrow. Focus Row Above/Below now ship palette-only, which the plan had already named as the fallback. The strip's "+N more" was a dead button: rendered, titled, and wired to nothing, in the exact case the cap exists for. The shape editor took mount focus on a stepper (NumberInput documents that dialogs must forward inputRef) and committed on Enter from any control, including Add row and the row-remove buttons. Horizontal lane focus wrapped across rows, contradicting the invariant the new row-focus commands document. Keyboard selection ignored a row's project binding while the mouse respected it, so ⌥↓ could drop a project-A agent into a B-bound row — and the empty-lane hint points at that key. ⌘N now resolves by label within the row's offered set, so gapped labels stay honest and an unavailable label does nothing. Spawning into an empty lane in a bound row now files the agent under that row's project instead of the active tab. Cleanup the branch's own conventions called for: a new row took the average sibling height instead of defaulting to 1 and rendering as the tallest row on screen; insertLaneRightIntoTiled, removeLaneFromTiled, setTiledLaneCount and setTiledRatios are deleted — dead, and they rewrote lanes without rows, which is the exact failure gridShape exists to prevent; the command is retitled Nested Agents because Dispatch nests linked and orchestration children identically and the cap cannot tell them apart; stale comments describing the deleted healer are corrected. Refs #681
Review round: two adversarial reviews, all findings resolvedRan two independent reviewers over the branch — one Claude (correctness/state), one Codex (UX/surface). Fixes are in The state model held. Both attacked the central bet and neither broke it: nothing mutates Blocking, fixed
Cleanup, also fixed
Verification
Still knowingly unverified: the child-cap threshold of 3 has never fired on any recording in this repo, and |
The comment, plan and PR said #687 kept the first lane strip-less. It did the opposite. 524671b (#687) made the index follow focus and gave every lane a strip for that reason. 1eb9a47 (#691, a lane-wake fix) then removed the first lane's strip as a side change, on the grounds that the index was its selector, which had already stopped being true. Both PR reviewers caught it with git show. The record now points at the change that actually regressed, and the plan records the review and its resolutions. Refs #850 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J8PfoJxJ1cvJ9akXsWSVZB
Fixes #681
Tiled Dispatch becomes a grid. A row stops being a strip of lanes sharing one sidebar and becomes a complete, self-contained dispatch view: its own index list, its own project binding, its own list density, its own lanes. Up to four stack vertically.
Plan:
docs/superpowers/plans/2026-08-30-grid-dispatch-mode.md· Stage record:docs/decomposition/grid-dispatch.mdThe problem, in four parts
The horizontal axis is exhausted long before the screen is. Every lane is paid for in width, and past roughly four lanes a lane is too narrow to read a feed in — so the nominal cap of 10 was unreachable in practice while vertical space went entirely unused.
Slots filled themselves. Kill the agent in column 2 of 6 and the slot was instantly refilled with whatever was next in the index. The user closed one thing and an unrelated thing appeared in the position they were watching. Three paths did this:
buildAutoLaneson enter,buildAutoLaneson growth, and a render-time healer that refilled any unresolved lane.One global scope wasted the structure rows just bought. The obvious use of a second row is a second project, and nothing expressed that.
An orchestration parent can bury the index. Ten children take ten rows in the list and ten chips in every strip, for agents the user is not watching — the parent is what reports.
Design decision:
lanesstays flatTiledDispatchState.lanesremains a flat, row-major array andfocusedLaneremains a flat scalar. Row shape rides alongside asrows: DispatchGridRow[].The obvious model —
rows: DispatchLane[][]with a{row, col}coordinate — was rejected after reading every consumer.tiledDispatchSelectors.ts's header records that two whole bug classes came from code that maintained some lane pointers and missed others, which is whyremapTiledLanes/clearTiledLaneSessions/keepTiledLaneSessionsexist as the single reusable way to keep lanes coherent. A 2-D coordinate rewrites all three helpers, all nine of their call sites (id remap ×2, kill, close ×2, bury, tab close, undo-close, rehydrate, autosave prune), and the six readers of the focus scalar.Keeping the lane list flat means none of that code changed to gain a second dimension. It also makes the persisted format backward-compatible by construction: absent
rowsmeans one row of every lane, so everyworkspace.jsonever written is already a valid one-row grid.Row metadata is an object array rather than four parallel arrays, because one object cannot desynchronize the way four can, and no coherence helper touches it.
Ragged by design
This is not a rows × columns rectangle. Four lanes on top and two below is the expected shape — projects do not have equal agent counts. There is no global column count anywhere in the state, the reducers, or the UI, and
New Lane/Remove Lanechange only the focused row's length with byte-for-byte their current within-row behavior.The size prompt is therefore a per-row shape editor, not two number inputs. That modal is where the user forms their mental model of the feature, and two inputs labelled "rows" and "columns" would teach the rectangle in the one place it matters. A stepper per row with a block preview shows raggedness before the user commits.
Behavior changes to call out in review
⌘Nand the arrows acted on the focused lane. With a per-row index "the index selects lane 0" is meaningless across four rows. This is visible in the existing single-row layout and is the one change here an existing user could read as a regression rather than a fix.agentIndexNavigationalready uses for a cross-project label.Deletions
buildAutoLanes, the heal effect, andDispatchLane.userEmptiedare all gone. The flag existed solely to hide New Lane's deliberate hole from the healer; with no healer every empty lane behaves identically, so the distinction it encoded has no reader. It was never free — because a lane is spread at every writer, the flag had to be dropped at every writer, was missed at two of them, and took four follow-up commits to close.Verification
test:contract,check:keybindings,typecheck,check:live-resume-probe,test:package, and the full deterministic suite all pass. Coverage baseline met.⌥⇧↑/↓was verified free and accepted bycheck:keybindingsrather than assumed.Tests are built on
testing/fixtures/worktree-context/dispatch-global-d23.json— a real persisted workspace with 4 tabs, 24 sessions, 3 orchestration parents, and a legacyratiosblock. The kill case drives the realclearTiledLaneSessionspath and failed against the old healer before it was removed.gridShape.ts's tests use invented literals, deliberately: it is a pure combinatorial function over a structure this PR defines, so no recording of it can exist. That is argued explicitly in the decomposition rather than left implicit, and it earned its keep immediately by catching a missing legacy migration.Known limitations
MAX_DISPATCH_LANES = 16is a judgment call, not a measurement. Each lane mounts a realrenderWorkspaceLeafwith its own runtime subscriptions.Unrelated pre-existing failures found while working
Neither is touched here; both reproduce on a clean
main.imageAttachment.test.tsassertsexistsSyncon a session JSONL under the developer's home directory. Red locally forever once Claude Code rotates that session; invisible in CI.store.test.tsprompt-template migration times out under--project unit --project renderer, passes under the full suite. Test-isolation defect.🤖 Generated with Claude Code
https://claude.ai/code/session_01VLvdUFXQ7rjUVchjwEPfNf