Skip to content

feat(dispatch): add New Agent In… to spawn an agent into a chosen project - #860

Merged
Juliusolsson05 merged 9 commits into
mainfrom
feat/new-agent-in-project
Sep 11, 2026
Merged

Juliusolsson05 merged 9 commits into
mainfrom
feat/new-agent-in-project

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Sep 11, 2026 •

Copy link
Copy Markdown
Owner

Problem

Filling empty Grid Dispatch lanes needed a detour. With the focused lane empty and its row unbound, resolveDispatchSpawnTarget files a new agent under the classic Dispatch focus (the last agent you selected) or activeTabId. To put a Codex agent for project C into an empty lane you had to select some unrelated agent in C purely to move the target, come back to the lane, then run New Agent… — once per lane.

What this adds

New Agent In… (new-agent-in, Dispatch only). A two-step dialog:

  1. Agent — Claude / Codex / OpenCode / OpenCode Terminal (the shared AGENT_PROVIDER_CHOICES).
  2. Project — projects labelled A · title like the rest of Dispatch. Backspace goes back.

Committing calls the existing createDetachedDispatchAgent(selection, { tabId, anchorSessionId }) — the same explicit-project path the Dispatch project header's "+" and the external agents.create capability use. The agent fills the focused lane, exactly like New Agent….

Design decisions

  • Dispatch only (surface: 'dispatch', also hidden under Tiled Tabs like New Agent…). In the grid a project is a tab one keystroke away and a detached agent spawned from the grid is not visible.
  • Row bindings restrict the list. If the focused lane's row is bound to projects, only those are offered; otherwise every project. The bound-set lookup moved out of resolveDispatchSpawnTarget into focusedLaneBoundProjectTabIds, which both now read, so the picker and the resolver cannot disagree about what a row may hold. The resolver's behavior is unchanged; its bound-row rules are covered by the existing rowScopedRows.test.ts on the real persisted fixture (an earlier version of this PR wrongly said the branch was untested and duplicated those cases — removed in round 2).
  • Initial highlight on the project step is the project plain New Agent… would have used, so Enter, Enter lands in the same project as today (the directory is deliberately that project's own checkout — see Anchor).
  • Anchor (the session whose cwd the new agent borrows) is the project's first session with a directory, grid leaves before detached rows — the same order the header "+" uses, and it avoids borrowing a worktree agent's path. A project with no such session is listed disabled with the reason, not hidden; arrows skip it.
  • A dedicated dialog on DialogContent (Escape, focus trap, outside-click and app input ownership come from the primitive). Not a fifth mode of NewAgentPlacementOverlay (already four intents; its "kind picked ⇒ placement step" invariant would need another branch) and not a palette sub-mode (a two-step flow would need its first answer in store state across palette modes).
  • The dialog derives nothing while closed — the surface is always mounted and re-renders on every workspace change.
  • No default keybinding.

Review round (independent code review before merge)

Verdict was "ready with fixes"; everything below is fixed on this branch (commits 2e793b57, 1b5bd639):

  • Important — Enter on a focused footer button did the opposite of the button. The list's Enter handler called preventDefault (cancelling the focused button's native click) and then committed the highlighted row, so Tab → Cancel/Back → Enter spawned an agent. Footer buttons now own their Enter, per the rule components/ui/dialog-actions.tsx documents; rows keep highlight semantics. The dialog had copied this from ProviderSwitchPickerModal, where Tab → Cancel → Enter started a provider switch on main — filed as bug(ui): Switch Provider keyboard activation can commit a provider the user did not pick #862 and fixed here with a regression test.
  • Held Enter (auto-repeat) is ignored, so one long press cannot pick the agent and commit the default project.
  • One-shot state resets on close instead of open (no stale project step painted when something other than a user event opens the dialog).
  • Removed a redundant cross-step focus effect — Radix FocusScope already refocuses the container when a focused row unmounts; the comment claiming otherwise was wrong.
  • The empty project list now says the row's projects are closed and names Row Projects…. The root cause — closeTab never clears row bindings in memory, which also makes plain New Agent… fail silently with the overlay stuck — predates this PR and is filed as bug(dispatch): closing a project tab leaves grid rows bound to it, so New Agent silently does nothing #863 (not fixed here).
  • Comment/plan accuracy: the header "+" anchor differs when its group's first row is pinned; Enter-Enter matches New Agent… at the project level, while the directory is deliberately the project's own checkout (removing focus from the decision is the point). Command notes now mention the project-scope switch.
  • Kept as-is: row s in test titles is this repo's existing convention for avoiding apostrophes in single-quoted titles (e.g. rowScopedRows.test.ts).

Review round 2 (two orchestrated reviewers via the Agent Code orchestration MCP)

Reviewer A (Codex, adversarial behavior) and Reviewer B (Claude, integration/conventions/tests) both returned "ready with fixes". Fixed in 9e4414e3, c6f778fc, 01c0fb44, 599f7272:

  • Important (A1 + B1) — Space activated the Tab-focused row, not the highlighted one, in this dialog and in Switch Provider on main: arrows moved the highlight while DOM focus stayed on a row, and Space is a native click on the focused button — no Enter handling can intercept it. Rows in both dialogs are no longer tab stops (tabIndex={-1}), so keyboard focus is only ever the dialog surface (arrows + Enter act on the highlight) or the footer (owns its own Enter/Space). bug(ui): Switch Provider keyboard activation can commit a provider the user did not pick #862 was widened to this path; structural regression tests in both dialogs (happy-dom cannot synthesize keyboard clicks).
  • B2 — the focused-control Enter rule is now one exported predicate, focusedControlOwnsEnter in components/ui/dialog-actions.tsx, used by DialogActions (behavior unchanged, now pinned by a new dialog-actions.renderer.test.tsx) and both list dialogs, replacing a copied footer-slot guard. Three more pre-existing dialogs with the pattern — Agent View Mode, Reorder Tabs, Pin Agents — are filed as bug(ui): Enter on a focused Cancel applies the change in Agent View Mode, Reorder Tabs and Pin Agents #867, not fixed here.
  • B3 — the reopen test could not fail, and this body claimed it did. Replaced by a test that watches the DOM during reopen (added and removed nodes) and fails if the stale project step mounts even for one render; verified red with reset-on-open, green with reset-on-close.
  • B4 — duplicate resolver tests removed (see the design note above).
  • B5–B9 — dialog keys declared in controlInteractions.ts; the disabled-row test asserts the native disabled attribute; eligibility is an explicit enabled discriminant on the project model (no longer re-derived from the anchor in three places); a stale 103 + 4 = 107 catalog comment fixed; the command description covers classic Dispatch rows as well as Grid lanes.

Linked issues

Fixes #852
Fixes #862
Refs #863, #867

Verification

Local, Node 24:

  • npm run typecheck (control-sdk + workflow-mcp build + tsc -b on node and web) — exit 0 at every round, last at 599f7272.
  • Round-1 regression tests (each failed first): footer Enter never advances/creates and leaves the native click un-prevented; held Enter ignored; closed-bound-row message; Switch Provider Cancel + Enter never switches (bug(ui): Switch Provider keyboard activation can commit a provider the user did not pick #862).
  • Round-2 regression tests (each failed first): rows are not tab stops in both dialogs; model exposes enabled; the reopen DOM-watch test (red with reset-on-open, green with reset-on-close). DialogActions' pinned tests pass before and after the extraction (characterization).
  • Final targeted run at 599f7272: renderer 27/27 (NewAgentInDialog, ProviderSwitchPickerModal, dialog-actions, dialog, control/documentation), unit 69/69 (newAgentInProjects, dispatchSelectors, rowScopedRows, catalog, featureReference).
  • CI quality-gate and minimum-node-fixture-gate passed on the first two pushes; the round-2 push (599f7272) is gated the same way before merge. main gained fix(workspace): light the status header for terminal-view agent panes #853/fix(dispatch): give every Grid Dispatch lane its own selector strip #859 meanwhile — no file overlap, clean merge.
  • npm run test:contract, npm run check:keybindings — pass.
  • Targeted suites for everything touched — all pass: catalog.test.ts (snapshot 116 → 117, fails before the command exists), taxonomy.test.ts, featureReference.test.ts, dispatchSelectors.test.ts, newAgentInProjects.test.ts, NewAgentInDialog.renderer.test.tsx.

New tests:

  • newAgentInProjects.test.ts — tab order + global letters, bound-row filtering, anchor order (grid leaf before detached worktree row; skips a leaf with no live session), disabled-with-reason, initial highlight = plain New Agent's target, fallback when that target is not offered or not anchorable.
  • NewAgentInDialog.renderer.test.tsx — Enter-Enter lands in New Agent's target project; keyboard and mouse two-step commits pass {kind, providerRuntime} + {tabId, anchorSessionId}; OpenCode Terminal stays a distinct runtime; Backspace goes back without creating; disabled project can't be chosen by click or arrows; repeated Enter spawns once; Cancel never creates; nothing derived while closed.
  • dispatchSelectors.test.ts — the new selector (the resolver's bound-row rules stay covered by rowScopedRows.test.ts, which guarded the extraction).
  • dialog-actions.renderer.test.tsx — the shared focused-control Enter rule and DialogActions' confirm / Cancel-owns-Enter behavior.

Full local suite was not clean, for reasons unrelated to this diff (16 GB machine swapping with three other agent sessions running Vitest concurrently). Every failing file was checked against the base commit 48ea6912:

Failure Evidence it is not this PR
store.test.ts "hydrates legacy standalone prompt templates…", store.performance.test.ts hook, lazy-prose renderer test — 5s/10s timeouts Same branch commit passed all of them on one run and failed on the next; the store migration test also fails on the base commit.
imageAttachment.test.ts "every fixture is traceable to … a real session" Asserts a session file under the developer's ~/.claude/projects/…klay/ that no longer exists on this machine; fails identically on the base commit.
dictation/historyStore.test.ts, control/history/tasks.test.ts Only failed inside the contended full run (historyStore saw a growing count of foreign entries, consistent with a shared temp path colliding with the other sessions' runs); both pass on the base commit in isolation. Main-process code this PR does not touch.
controlHost.system.test.ts — 180s timeout Timed out under the same contention; left to CI's clean runner.

CI's quality-gate is the authoritative run for this PR.

Limitations / follow-ups

  • Not exercised in the running app (repo policy: no app launches from agent sessions). The behavior is covered by the unit + renderer tests above; a quick manual pass — Grid Dispatch → focus an empty lane → New Agent In… → pick agent + project — is worth doing before merge.
  • In project-scope (non-global) Dispatch, choosing another project switches the active project, the same thing the header "+" does after a spawn. That is existing createDetachedDispatchAgent behavior, not changed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SVRSHn7c7qnBoUQo1e2X4k

Juliusolsson05 and others added 3 commits September 10, 2026 16:10
Filling empty Grid Dispatch lanes currently requires selecting an unrelated
agent in the target project first, only to move the spawn target. The plan
records the approved design: a Dispatch-only two-step picker (agent, then
project) that reuses createDetachedDispatchAgent's explicit project override.

Refs #852

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVRSHn7c7qnBoUQo1e2X4k
resolveDispatchSpawnTarget read the focused Grid Dispatch row's project
binding inline. New Agent In needs the same answer to decide which projects
it may offer for that lane, and two separate derivations could drift (the
legacy projectTabId fold and row-length repair live in normalizeGridShape),
letting the picker offer a project the resolver would refuse.

Behavior is unchanged. The resolver's bound-row branch had no coverage, so
its two rules (bound project over stale classic focus; active tab when it is
one of several bound projects) are pinned before the move.

Refs #852

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVRSHn7c7qnBoUQo1e2X4k
Filling an empty Grid Dispatch lane with an agent for another project meant
first selecting some unrelated agent in that project, only to move the spawn
target. New Agent In asks for the agent and then the project, and spawns
through createDetachedDispatchAgent's existing explicit-project override, so
the agent still fills the focused lane exactly like New Agent.

It is Dispatch-only, honours the focused row's project binding, starts on
the project plain New Agent would have used, and lists projects it cannot
anchor as disabled with the reason. The dialog is a dedicated DialogContent
surface rather than another intent on the placement overlay, and it derives
nothing while closed.

The catalog snapshot moves to 117 in this commit, per its own rule; its two
test titles had drifted to 115 and now track the asserted count again.

Refs #852

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVRSHn7c7qnBoUQo1e2X4k
Juliusolsson05 and others added 2 commits September 10, 2026 17:34
The picker's list handler took every Enter: it called preventDefault, which
cancels the focused button's native click, and then committed the highlighted
provider. Tab to Cancel and Enter therefore switched the agent's provider
instead of cancelling, breaking the rule dialog-actions.tsx documents for
focused buttons. Footer buttons now keep their own Enter; the list keeps
highlight semantics for rows.

Fixes #862

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVRSHn7c7qnBoUQo1e2X4k
Review of the new dialog found it copied the Switch Provider Enter bug:
Tab to Cancel or Back and Enter spawned an agent. Footer buttons now own
their Enter, and auto-repeated Enter is ignored so one long press cannot
pick the agent and commit the default project.

One-shot state resets on close instead of open, so an open that does not
come from a user event never paints a stale project step. The cross-step
focus effect is gone because Radix FocusScope already refocuses the
container when a focused row unmounts. The empty project list now names
the real cause (the row is bound only to closed projects, #863) and the
control that fixes it. Comments and the plan state the anchor rule
precisely: same project as New Agent, but the project's own checkout.

Refs #852

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVRSHn7c7qnBoUQo1e2X4k
Juliusolsson05 and others added 4 commits September 10, 2026 18:25
DialogActions already encoded "a focused button, link or textarea owns its
own Enter", but list-style dialogs that handle Enter on DialogContent could
not reach it and copied a footer-slot guard instead (New Agent In, Switch
Provider). Exporting the rule as focusedControlOwnsEnter gives those dialogs,
and the three more that #867 tracks, one definition to call.

Behavior of DialogActions is unchanged; its Enter rule had no tests, so the
confirm and Cancel-owns-Enter paths are pinned alongside the extraction.

Refs #862

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVRSHn7c7qnBoUQo1e2X4k
A provider row could hold keyboard focus while the arrows moved the
highlight elsewhere. Space is a native click on the focused button, so Tab
to a row, ArrowDown, Space switched the agent to a provider other than the
highlighted one. Rows are no longer tab stops: keyboard focus now rests only
on the dialog surface, where arrows and Enter act on the highlight, or on
the footer. The Enter guard uses the shared focusedControlOwnsEnter rule.

Fixes #862

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVRSHn7c7qnBoUQo1e2X4k
The second review found the same focus-versus-highlight split in New Agent
In: Tab to a project row, arrow to another, Space, and the agent was created
in the focused project instead of the highlighted one (step one could pick a
different agent the same way). Rows are no longer tab stops and their focus
sync is gone, so keyboard selection is only ever the highlight.

Eligibility is now an explicit `enabled` discriminant on the project model,
so the dialog no longer re-derives it from the anchor and a future second
disable reason cannot be committable. The reopen test now fails if the stale
project step mounts even briefly, which the previous version could not
detect. The dialog's keys are declared for the control reference, and the
command description covers classic Dispatch rows as well as Grid lanes.

Refs #852

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVRSHn7c7qnBoUQo1e2X4k
The bound-row branch of resolveDispatchSpawnTarget was already covered on
the real persisted fixture in rowScopedRows.test.ts; the two resolver cases
added with the selector extraction restated it on a hand-built state, and
their comment wrongly claimed the branch had been untested. The selector's
own tests stay. Also points a catalog comment at the asserted arithmetic
instead of a number that had drifted to 103 + 4 = 107.

Refs #852

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVRSHn7c7qnBoUQo1e2X4k
@Juliusolsson05
Juliusolsson05 merged commit 79d8df0 into main Sep 11, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the feat/new-agent-in-project branch September 11, 2026 01:37
Juliusolsson05 added a commit that referenced this pull request Sep 11, 2026
Brings #860 (New Agent In), #861 (Reader Mode from the render ledger) and
#870 (Codex cyber-block native clone) into the branch before merge. The
textual merge is clean; eight files changed on both sides, so the merged
tree is re-verified with tsc and the full suite rather than trusting the
pre-merge CI run.

Refs #865

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant