Skip to content

feat(dispatch): remove a chosen lane from Tiled Dispatch - #625

Merged
Juliusolsson05 merged 2 commits into
mainfrom
feat/dispatch-lane-removal
Jul 28, 2026
Merged

feat(dispatch): remove a chosen lane from Tiled Dispatch#625
Juliusolsson05 merged 2 commits into
mainfrom
feat/dispatch-lane-removal

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Adds two commands so a Tiled Dispatch slot can be reclaimed at a position of your choosing.

The problem

Tiled Dispatch's size is a single count, and shrinking by count always drops the tail:

const lanes = next < tiled.lanes.length
  ? tiled.lanes.slice(0, next)   // <- always the last lanes
  : buildAutoLanes(prev, next, tiled.lanes)

With seven lanes open and the finished agent in lane 3, going 7 → 6 removes lane 7. You then re-select several lanes by hand to get back to the arrangement you wanted.

The obvious workaround doesn't work either. Closing the agent in lane 3 doesn't shrink the grid — clearTiledLaneSessions empties that lane and auto-fill re-homes another agent into it. Count stays 7.

So there was no way to shrink the tiled grid at a chosen position.

Two commands, not one flag

Command Agent Lane
Close Agent and Remove Lane closed removed, count −1
Remove Lane keeps running, stays in the index removed, count −1

The default is destructive, and a command that sometimes ends a session is the kind of thing that surprises you at speed. The titles carry the difference: Close is this catalog's established verb for ending a session (Close Focused Session, Close Tab, Close Old Agents), so the destructive one leads with it. Putting "Remove Lane" first would bury the irreversible half, and "Remove Agent" was rejected outright — it reads non-destructive and isn't.

Close first, splice second

closeSession runs its own confirmation for irreversible closes. Splicing the lane first would shrink the grid while you were still deciding, and a declined confirm would leave the layout changed with the agent alive — the worst of both.

That required closeSession to report whether it actually closed, so it now returns Promise<boolean>. The two orchestration call sites adapt locally to their Promise<void> contract rather than widening a cross-process type for one caller.

Details worth a look

  • Focus is not a plain clamp. A lane removed before the focused one shifts indices down, so holding focusedLane constant would silently move focus to the next agent. Adjusted explicitly, then clamped.
  • Lane 0 is a real agent lane — it just has no mini-list, because it's selected from the full index. Removing it promotes lane 1 into that role. No special-casing, but the descriptions say so rather than pretending the grid is homogeneous.
  • ratios are dropped on removal, matching setTiledLaneCount — they're positional and a removal invalidates them.

Tests

tiledLaneRemoval.test.ts — 8 cases over the pure removeLaneFromTiled: middle removal keeps neighbours, focus follows its agent when an earlier lane goes, clamps when the last lane was focused, refuses at the floor and on bad indices, always drops ratios, never mutates its input.

Verification

  • npx tsc -b → exit 0
  • NODE_ENV=test npx vitest run → 255 files, 1758 tests passing
  • npm run check:keybindings → OK
  • npm run test:contract → satisfied

Not verified: appearance and the live close-confirm flow — I haven't run the app.

🤖 Generated with Claude Code

Juliusolsson05 and others added 2 commits July 28, 2026 17:22
Tiled Dispatch's size is a single count, and shrinking by count always
drops the tail (lanes.slice(0, next)). With seven lanes open and the
finished agent in lane three, 7 -> 6 removes lane seven and leaves the
user re-selecting the rest by hand.

Closing that agent instead does not shrink anything either: the lane
empties and buildAutoLanes' auto-fill re-homes another agent into it, so
the count stays put. There was no way at all to reclaim a slot at a
position of the user's choosing.

Two commands rather than one with a flag. The default is destructive, and
a command that sometimes ends a session and sometimes does not is the
kind of thing that surprises someone moving fast:

  Close Agent and Remove Lane — closes the agent, then removes its lane
  Remove Lane                 — lane goes, agent keeps running

The titles carry the difference. `Close` is this catalog's established
verb for ending a session (Close Focused Session, Close Tab, Close Old
Agents), so the destructive one leads with it; putting "Remove Lane"
first would bury the irreversible half. "Remove Agent" was rejected
outright — it reads non-destructive and is not.

The destructive command closes FIRST and only splices if the close
actually happened. closeSession runs its own confirmation for
irreversible closes, so splicing first would shrink the grid while the
user was still deciding, and a declined confirm would leave the layout
changed with the agent alive. That required closeSession to report
whether it closed, so it now returns Promise<boolean>; the two
orchestration call sites adapt to their Promise<void> contract locally
rather than widening a cross-process type for one caller.

Lane 0 is a real agent lane that simply has no mini-list of its own —
it is selected from the full index — so removing it promotes lane 1 into
that role. No special-casing, but the descriptions say so rather than
pretending the grid is homogeneous.

The splice/clamp/ratio rules live in a pure removeLaneFromTiled so they
are testable without a hook. Note the focus rule is not a plain clamp: a
lane removed BEFORE the focused one shifts indices down, so holding
focusedLane constant would silently move focus to the next agent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both returned NON-BLOCKING, and the mechanics came back clean: every
closeSession exit path reports correctly, the orchestration wrappers
preserve rejection/options/timing, all six focus combinations are right,
and the cross-await race I most suspected does not exist —
clearTiledLaneSessions only maps selectedSessionId and never touches
lanes.length or order, so the captured laneIndex stays valid.

Four real findings fixed.

ratios was being dropped wholesale, and index 0 is not a lane boundary —
it is the index-SIDEBAR fraction, with only ratios.slice(1) as lane
weights. So removing a lane also snapped a deliberately-dragged sidebar
back to its default. Copying setTiledLaneCount was wrong here: a count
increase has no honest answer because it would have to invent a weight
for a new lane, but a removal does. Now keeps ratios[0], drops the
removed lane's weight, and yields exactly lanes.length weights so
normalizedLaneWeights does not discard them.

removeLaneFromTiled had been inserted between buildAutoLanes' doc block
and buildAutoLanes itself, orphaning a load-bearing comment onto the
wrong symbol. Moved below.

The destructive command's guard tested that a lane's session id was SET,
not that it resolved. A lane holds a dead id for the render between a
session disappearing and the heal effect clearing it, so the command
could be admitted and then do neither of the two things its title
promises. Now tests liveness.

"remove agent" matched neither command — the fuzzy matcher works over
title and keywords only, and the subsequence fails against both titles
while no keyword carried both tokens. That is the phrase a user types for
the destructive one. Added as a keyword. Rejecting "Remove Agent" as a
title was right; leaving it unreachable was not.

Also: this PR had introduced "grid" as a third noun for one concept
alongside "tiles" and "lanes", in a Dispatch command, where the style doc
says Dispatch is deliberately not a grid. Now "layout". Two focus
combinations were untested — including the most common real invocation,
where neither the adjust nor the clamp fires — plus a test that never
asserted the lane list. catalog.test.ts's header still claimed a 102-id
after-state.

NOT fixed, deliberately: an orchestrating agent whose close is declined
is told the child closed. requestCloseConfirmation resolves false rather
than throwing, so the result builders report success unconditionally and
skippedSessionIds stays empty. The Promise<boolean> added here is the
missing half of that fix, but threading it changes what a cross-process
caller is told and deserves its own review. The comment claiming the MCP
layer has no use for the signal was false and now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 merged commit b04a37a into main Jul 28, 2026
1 check passed
@Juliusolsson05
Juliusolsson05 deleted the feat/dispatch-lane-removal branch July 28, 2026 15:46
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