Skip to content

feat(dispatch): detached sessions — stop dispatch from poisoning grid layout - #37

Merged
Juliusolsson05 merged 4 commits into
mainfrom
feat/dispatch-backlog
Apr 29, 2026
Merged

Juliusolsson05 merged 4 commits into
mainfrom
feat/dispatch-backlog

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Summary

Decouple session creation from grid placement so spawning ten command-center agents in Dispatch Mode no longer leaves ten unmanageable panes in the tile tree when the user toggles back to grid.

What it does

  • Introduces `DetachedSessionRecord` and `detachedSessions: Record<SessionId, DetachedSessionRecord>` as a parallel session bucket alongside `sessions` and `tabs[].root`.
  • Invariant: a live session is in exactly one of (a tile-tree leaf in `tabs[].root`) or (`detachedSessions`).
  • Dispatch Mode renders both buckets; grid renders only tile leaves.
  • `Tab.focusedSessionId` stays grid-only. Dispatch selection lives on `dispatchMode.focusedSessionId`, so reader / spotlight / resize / split / duplicate / bury commands that assume grid focus don't break.
  • New Agent in Dispatch Mode = kind picker → spawn detached, no placement step.
  • `Attach Detached Agent To Grid…` — promotes a detached session into a grid pane via the existing placement overlay. Synchronous (no spawn), so no UI flicker.
  • `Detach Agent To Dispatch` — moves a focused grid pane into the detached bucket without killing it. Refuses (with toast) for terminals and for the only-leaf-in-tab case.
  • Persistence, autosave, rehydrate, tab close, session close, and id-remap-on-reload all handle detached sessions.

Why

Before this PR, Dispatch Mode was a different rendering of the same tile tree. Creating an agent in Dispatch silently inserted it into the active tab's tree; spawning ten then exiting Dispatch left ten unmanageable panes. The detached bucket fixes this by making session ownership independent of grid placement.

Commit map

  1. `a4d7b7c` — model + persistence + dispatch-mode spawn + lifecycle
  2. `c7518d9` — bidirectional placement (attach + detach + commands + helper)
  3. `5bc97aa` — plan doc

Known limitations / deliberate follow-ups (not in this PR)

  • `Cmd+W` in Dispatch on a detached row still targets the grid focused session via `closeFocused`. The dispatch-aware migration is staged behind `commandTargetSessionId` (added but unused) — small, isolated follow-up.
  • Attach into an empty tab (zero grid leaves) is not supported — refused at the visibility check. In practice empty tabs don't arise because detach already refuses to empty a tab.
  • `feat/dictation-package-submodule` (PR feat: vendor agent-voice-dictation as packages/ submodule #33) merge order: if reviewed before feat: vendor agent-voice-dictation as packages/ submodule #33 merges, the build will need either feat: vendor agent-voice-dictation as packages/ submodule #33 first OR a sibling `agent-voice-dictation` directory at `../agent-voice-dictation`.
  • Future tmux "dispatch / dispatch+mirror" terminology rename in `docs/superpowers/plans/2026-04-13-tmux-persistent-terminals-p1.md` — separate docs PR.

Test plan

  • `npm run build` clean
  • Reviewer: create 5 agents in Dispatch, exit to grid → grid shows only pre-Dispatch panes; the 5 detached agents survive in state.
  • Reviewer: re-enter Dispatch → agents still there, correct activity/status, dispatch focus works.
  • Reviewer: "Attach Detached Agent To Grid…" on a detached agent → placement picker appears, Enter pins it into the chosen position, agent disappears from dispatch list, session/runtime intact (no flicker).
  • Reviewer: "Detach Agent To Dispatch" on a grid agent → pane disappears, agent keeps running, shows up in Dispatch.
  • Reviewer: try to detach the only pane in a tab → toast refuses with "add another agent first".
  • Reviewer: try to detach a terminal → toast refuses.
  • Reviewer: close project tab while it has both grid + detached agents → all of them killed.
  • Reviewer: app restart → both grid and detached sessions rehydrate; dispatch focus restored when it was a detached session.

🤖 Generated with Claude Code

Juliusolsson05 and others added 4 commits April 29, 2026 16:24
…pawn

Decouple session creation from grid placement so spawning ten command-center
agents in Dispatch Mode no longer leaves ten unmanageable panes in the tile
tree when the user toggles back to grid.

## Model
Adds DetachedSessionRecord and a parallel state bucket:

  detachedSessions: Record<SessionId, DetachedSessionRecord>

Invariant: a live session is in exactly one of:
  - a leaf in tabs[].root  (grid placement)
  - detachedSessions       (intentionally not placed in the grid)

Tab.focusedSessionId stays grid-only — it has a hard "must be a leaf in
tab.root" invariant used by reader, spotlight, resize, split, duplicate,
and bury commands. Dispatch selection rides on a new mode-local field:

  dispatchMode.focusedSessionId

…so exiting Dispatch never leaves the grid in an impossible focus state.

## Persistence
- persistence.ts: PersistedWorkspace.detachedSessions added (optional for
  backwards compat with pre-feature workspace.json files).
- useAutoSave.ts: writes detachedSessions on every snapshot.
- rehydrate.ts: remaps detached sessionIds through the same idMap as tile
  leaves and buried records — renderer SessionIds are per-launch routing
  ids, so persisted records have to follow the old->new map or later
  lifecycle actions update the wrong key.

## Dispatch UI
- dispatchSelectors.ts: buildDispatchGroups now emits a row per grid leaf
  AND per detached session for the relevant tab, tagged with placement:
  'grid' | 'detached' so future UI/commands can distinguish them.
- DispatchLayout.tsx: selectActiveRow prefers dispatch focus, falls back
  to grid focus, then first row.
- useKeybinds.ts: Cmd+1..9 and Option+ArrowUp/Down route through
  focusDispatchSession instead of focusSessionInTab.

## Spawn
- pane.ts:createDetachedDispatchAgent — spawns a Claude/Codex session and
  appends a detached record without touching tab.root.
- NewAgentPlacementOverlay.tsx: in Dispatch Mode, becomes a kind picker
  (no placement step) and commits via createDetachedDispatchAgent. Race-
  protected via a committingRef so rapid Enter cannot spawn duplicates.
- App.tsx: overlay is now also rendered when DispatchLayout is the
  active surface.
- paneCommands.ts: New Agent command no longer blocked in Dispatch.

## Lifecycle
- pane.ts:closeSession — handles the detached path: kill backend, drop
  runtime + uuid/screen refs, delete from sessions + detachedSessions,
  clear dispatch focus if it pointed at the closed session.
- session.ts:close — same cleanup pattern for the generic kill path.
- session.ts:reloadAgentSessions — remaps detached sessionIds and
  dispatchMode.focusedSessionId through the post-reload idMap, so a
  reloaded detached agent keeps its dispatch selection.
- tab.ts:closeTab — kills both grid AND detached sessions for the
  closed project tab; clears dispatch focus if it landed in the killed
  set.

Still missing (follow-up commit in this same PR): "Attach Detached To
Grid" command, "Detach Grid To Dispatch" reverse, edge-case cleanup
for tabs whose only sessions are detached.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the two missing commands that complete the detached-sessions
model: a detached agent can be promoted into the grid through the
existing placement overlay, and a focused grid agent can be moved out
to the dispatch detached bucket without being killed.

## Attach Detached → Grid
- pane.ts:attachDetachedToGrid — synchronous setState that removes the
  session from detachedSessions and inserts it into the active tab's
  tile tree at the chosen PlacementTarget. No spawn round-trip; the
  process is already running and the renderer runtime entry already
  exists, so the UI seam is invisible to the user beyond the layout
  change.
- The target tab need not equal the detached record's projectTabId —
  projectTabId was always affinity, not ownership. Pinning a project-A
  detached agent into project-B's grid is the whole point of having
  a placement step.
- Stale-anchor guard: if the chosen split-leaf target's anchor was
  closed between picker open and Enter, the action no-ops cleanly
  rather than silently dropping into the wrong tree.

## Detach Grid → Dispatch
- pane.ts:detachFocusedToDispatch — removes the focused leaf via the
  existing closeLeaf primitive without killing the session, and adds
  a detached record. If Dispatch Mode is active, focuses the freshly
  detached agent so the user sees the result of the action.
- Refuses (with toast) for terminals (the right-hand project terminal
  slot already serves that role) and for the only-leaf-in-tab case
  (closeLeaf would return null and tab.root cannot represent an empty
  tree — the user should add another pane first or close the tab).

## Overlay attach mode
- NewAgentPlacementOverlay accepts attachDetachedSessionId. When set,
  the kind picker is skipped (the session already exists with a known
  kind) and the overlay opens directly on the placement-target step.
  Enter calls attachDetachedToGrid instead of commitNewAgentPlacement.
- App.tsx subscribes to dispatchAttachIntent in the uiShell store; the
  overlay is open when either newAgentPlacementOpen OR dispatchAttachIntent
  is set, and the close handler clears both so the next open starts
  fresh.

## Store + command surface
- uiShell: dispatchAttachIntent: SessionId | null + open/close actions.
- CommandContext.ui: openDispatchAttach passed through CommandPalette.
- paneCommands: attach-detached-to-grid (gated on dispatch-focused
  detached session) and detach-to-dispatch (gated on focused
  non-terminal grid leaf).

## commandTargetSessionId selector
Adds src/renderer/src/workspace/hook/selectors/commandTargetSessionId.ts
as a documented helper for "which session is the user currently
commanding?" — Dispatch focus first, grid focus second. Currently
unused; staged for migrating close/kill/provider-replace/copy-assistant
call sites that should target detached sessions when Dispatch is on
screen. Kept out of the workspace getter so call sites that import this
file are explicitly declaring "I work on detached AND grid sessions"
in the diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the model, invariants, file map, and the deliberate
follow-ups that aren't in this PR (closeFocused dispatch-targeting
migration, attach-into-empty-tab, future tmux dispatch terminology
rename in plan/2026-04-13).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three real type errors in the baseline detached-sessions commit that
the original Session 2 worktree never caught (the project-reference
ordering blocked the typecheck there). All three now clean under
\`npx tsc -p tsconfig.web.json --noEmit\`:

- useKeybinds.ts:dispatchRows — buildDispatchGroups was tightened to
  one (state) arg when detached-sessions landed; the call site here
  still passed workspace.runtimes as a second arg. Drop it.

- NewAgentPlacementOverlay.tsx:arrow handler — anchorSessionId is
  string | null and was passed straight to placementTargetIdForArrow
  which expects string. The component's render guards already prevent
  the null path at render time, but the keydown handler runs at the
  document level so the null branch is reachable in transient states.
  Add an explicit early-return.

- NewAgentPlacementOverlay.tsx:dispatch-mode Enter — option.kind is
  SessionKind, but createDetachedDispatchAgent only accepts
  'claude' | 'codex'. The kindOptions array is already filtered to
  exclude terminal in dispatch mode at runtime, but the typechecker
  cannot infer that from the runtime filter. Re-narrow at the call
  site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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