Skip to content

feat(dispatch): grid dispatch mode with per-row projects and no self-filling slots - #687

Merged
Juliusolsson05 merged 12 commits into
mainfrom
feat/grid-dispatch-mode
Aug 30, 2026
Merged

Juliusolsson05 merged 12 commits into
mainfrom
feat/grid-dispatch-mode

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

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

The 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: buildAutoLanes on enter, buildAutoLanes on 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: lanes stays flat

TiledDispatchState.lanes remains a flat, row-major array and focusedLane remains a flat scalar. Row shape rides alongside as rows: 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 why remapTiledLanes / clearTiledLaneSessions / keepTiledLaneSessions exist 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 rows means one row of every lane, so every workspace.json ever 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 Lane change 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

  1. A killed agent's slot stays empty. Nothing refills it, ever. An out-of-scope lane renders empty but keeps its selection, so flipping scope back restores it — strictly better than the healer, which replaced it irreversibly.
  2. The index list follows focus. It previously always wrote lane 0 while ⌘N and 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.
  3. Every lane gets its own strip, including each row's first, which costs 46px in a 1-lane layout.
  4. Binding a row promotes scope to global, with the same precedent and reasoning agentIndexNavigation already uses for a cross-project label.

Deletions

buildAutoLanes, the heal effect, and DispatchLane.userEmptied are 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 by check:keybindings rather 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 legacy ratios block. The kill case drives the real clearTiledLaneSessions path 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

  • The child cap threshold (3) is a tunable, not a finding. No recording in this repository has a parent with more than two children, so it has never fired on real data. It is inert on every recorded shape — being wrong about it means "does nothing yet", not "the index lies". Please don't defend the number in review.
  • MAX_DISPATCH_LANES = 16 is a judgment call, not a measurement. Each lane mounts a real renderWorkspaceLeaf with its own runtime subscriptions.
  • The four existing inline Dispatch arrows stay invisible to Keyboard Shortcuts and unbindable. Pre-existing; migrating them is a rebindable-keys change that does not belong inside a layout PR. Worth its own issue.

Unrelated pre-existing failures found while working

Neither is touched here; both reproduce on a clean main.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VLvdUFXQ7rjUVchjwEPfNf

…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
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Review round: two adversarial reviews, all findings resolved

Ran two independent reviewers over the branch — one Claude (correctness/state), one Codex (UX/surface). Fixes are in 29ecd829.

The state model held. Both attacked the central bet and neither broke it: nothing mutates lanes without rows or vice versa, rowStartIndex/rowIndexForLane boundaries are correct, the raw-vs-normalized focusedLane split is not exploitable, nothing depended on the deleted healer for recoverability, and no persisted state could be made to crash or lay out wrong. The claim that no lane-coherence code had to change is true as written. Every finding was in the new UI.

Blocking, fixed

  1. The shape editor deleted the wrong row. Its state was a bare number[], which cannot express which row went — removing the middle of three shifted every later row up a slot, so 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. Ragged shapes were worse and are covered by regression tests.

  2. ⌥⇧↑/↓ retracted — my worst miss. I checked it against defaults.ts and check:keybindings accepted it, and I wrote "verified free" in the PR body on that basis. Both facts were true and both 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 in the app (including our composer)" — which is exactly why directional resize uses fn+alt+Arrow. Dispatch bindings stay live while a text editor owns the target and the router preventDefault()s what it routes, so this would have broken composer selection and moved row focus underneath the user. Focus Row Above/Below now ship palette-only, which the plan had already named as the fallback.

  3. The strip's +N more was a dead button — rendered, titled, wired to nothing, in the exact case the cap exists for.

  4. Shape editor keyboard handling — took mount focus on a stepper (NumberInput documents that dialogs must forward inputRef), and Enter from any control committed, including Add row and the row-remove buttons.

  5. Horizontal lane focus wrapped across rows, contradicting the invariant the new row-focus commands document.

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

Cleanup, also fixed

  1. New Row rendered as the tallest row on screen — heights aren't written on a common scale, so a defaulted 1 beside dragged siblings of 0.7/0.3 dominated them. Now takes the sibling average, the same fix the lane axis already had.
  2. Four dead, invariant-unaware mutators deleted — insertLaneRightIntoTiled, removeLaneFromTiled, setTiledLaneCount, setTiledRatios. They rewrote lanes without rows, which is precisely the failure gridShape exists to prevent.
  3. Retitled Orchestrated Agents → Nested Agents — Dispatch nests manually linked agents and orchestration children identically at depth: 1, so the cap cannot tell them apart. The old title promised behavior the control doesn't have.
  4. Stale comments describing the deleted healer corrected, plus one user-visible command description that still described the old lane-0 sidebar.

Verification

typecheck, check:keybindings (39 binding sets), test:contract, and the full deterministic suite (2177 passing) all green. Only #684 fails locally, unrelated and pre-existing.

Still knowingly unverified: the child-cap threshold of 3 has never fired on any recording in this repo, and MAX_DISPATCH_LANES = 16 is a judgment call, not a measurement.

@Juliusolsson05
Juliusolsson05 merged commit 1816002 into main Aug 30, 2026
1 check passed
@Juliusolsson05
Juliusolsson05 deleted the feat/grid-dispatch-mode branch August 30, 2026 20:01
Juliusolsson05 added a commit that referenced this pull request Sep 11, 2026
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
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.

feat(dispatch): grid dispatch mode with per-row projects and no self-filling slots

1 participant