diff --git a/docs/superpowers/plans/2026-09-10-new-agent-in-project.md b/docs/superpowers/plans/2026-09-10-new-agent-in-project.md
new file mode 100644
index 000000000..10a074976
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-10-new-agent-in-project.md
@@ -0,0 +1,140 @@
+# New Agent In… — spawn a Dispatch agent into a chosen project
+
+**Issue:** #852
+**Branch:** `feat/new-agent-in-project` (worktree `.worktrees/new-agent-in`, from `origin/main` @ `48ea6912`)
+
+## Problem
+
+Filling empty Grid Dispatch lanes needs a navigational detour. When the focused
+lane is empty and its row is not bound to a project, `resolveDispatchSpawnTarget`
+(`workspace/dispatch/dispatchSelectors.ts`) files a new agent under the classic
+Dispatch focus (the last agent selected) or `activeTabId`. To put an agent for
+project C into an empty lane, the user first selects *some existing agent in
+project C* purely to move "the current project", returns to the empty lane, then
+runs **New Agent…**. Five fresh lanes means five detours.
+
+## What already exists (do not rebuild)
+
+- `createDetachedDispatchAgent(selection, { tabId, anchorSessionId })`
+ (`workspace/hook/actions/pane.ts`) spawns into an explicit project, borrows
+ `cwd` from the anchor session, and still places the agent into the focused
+ lane (`laneIndex` stays focus-derived — the override only names the project).
+ The Dispatch project header's "+" and the external `agents.create` control
+ capability both use it.
+- `AGENT_PROVIDER_CHOICES` (`workspace/providerChoices.ts`) is the shared
+ Claude / Codex / OpenCode / OpenCode Terminal choice list.
+- `ProviderSwitchPickerModal` is the house pattern for a keyboard-driven
+ provider list inside the shared `DialogContent` primitive.
+
+## Decisions (approved in chat)
+
+1. **Dispatch only** (`surface: 'dispatch'`). In the grid a project is a tab one
+ keystroke away, and a detached agent spawned from the grid is invisible there.
+ Also hidden under Tiled Tabs (mirrors `new-agent`): Tiled Tabs covers Dispatch,
+ so a lane spawn would land somewhere the user cannot see.
+2. **Row bindings restrict the project list.** If the focused lane's row is bound
+ to projects, only those are offered — the binding contract (see
+ `resolveDispatchSpawnTarget` and `DispatchRowProjectModal`) says a row's index
+ only lists its bound projects. Unbound rows / classic Dispatch offer every
+ project. The bound-set lookup is extracted into one selector shared with
+ `resolveDispatchSpawnTarget` so the list and the spawn resolver cannot disagree.
+3. **Projects with no anchor are listed, disabled, with a reason** — not omitted.
+4. **No default keybinding.**
+5. **Initial highlight** on the project step is the project plain New Agent…
+ would have used (`resolveDispatchSpawnTarget(state).tabId`) when it is
+ eligible, so Enter-Enter lands in the same project as today. (Same project,
+ not necessarily the same directory: the anchor is the project's first
+ session with a cwd — its own checkout — never the focused/last-selected
+ agent's worktree, because removing focus from the decision is the point.)
+6. **A dedicated dialog**, not a fifth mode on `NewAgentPlacementOverlay` (already
+ four intents; its "kind picked ⇒ placement step" invariant would need another
+ branch) and not a palette sub-mode (`CommandPalette.tsx` is 2.5k lines and each
+ mode threads ~6 ternary chains; the two-step choice would also need store state).
+
+## Units
+
+| Unit | File | Purpose |
+|---|---|---|
+| Bound-projects selector | `workspace/dispatch/dispatchSelectors.ts` | `focusedLaneBoundProjectTabIds(state)` — the focused Tiled Dispatch lane's row binding, `[]` when unbound/classic. `resolveDispatchSpawnTarget` switches to it. |
+| Project model | `features/workspace/lib/newAgentInProjects.ts` | Pure: eligible projects (tab order, A/B/C labels from the full tab list), anchor per project (first session of `resolveTabSessions` with a cwd — same order the header "+" uses), disabled reason, initial tab id. |
+| Dialog | `features/workspace/ui/NewAgentInDialog.tsx` | Two steps: agent → project. ↑/↓ (+ Ctrl-N/P), Enter, click, Backspace back, Esc/Cancel. Commit latch. Calls `createDetachedDispatchAgent`. Derives nothing while closed. |
+| Surface | `features/workspace/surfaces/NewAgentInSurface.tsx` + `app/surfaces/registry.tsx` | Mount; appended at the END of `modalSurfaces` per the registry's paint-order contract. |
+| Store intent | `app-state/uiShell/{types,slice}.ts`, `app-state/types.ts` | `newAgentInOpen` + `openNewAgentIn` / `closeNewAgentIn`. |
+| Command | `features/workspace/commands/paneCommands.ts` | `new-agent-in`, right after `new-agent`; `ui.openNewAgentIn` threaded through `command-palette/types.ts` + `CommandPalette.tsx`. |
+| Catalog snapshot | `features/command-palette/catalog.test.ts` | Same commit as the command (the file's own rule). 116 → 117. |
+| Control reference | `features/workspace/controlReference.ts` | List `new-agent-in` under the Dispatch feature so operators can point users at it. |
+
+## Tasks
+
+1. **Selector + model (TDD).** Write `newAgentInProjects.test.ts` first:
+ unbound → all tabs in order with labels; bound row → only bound tabs; anchor is
+ the first session with a cwd, grid leaf before detached; project with no
+ session is disabled with reason; initial tab = spawn-target tab when eligible,
+ else first enabled. Then implement the selector extraction and the model.
+2. **Dialog (TDD).** Write `NewAgentInDialog.renderer.test.tsx` first (pattern:
+ `ProviderSwitchPickerModal.renderer.test.tsx`): keyboard two-step commit calls
+ `createDetachedDispatchAgent` with `{kind, providerRuntime}` and
+ `{tabId, anchorSessionId}`; click path; Backspace returns to agent step;
+ disabled project cannot be committed; double Enter spawns once; Cancel never
+ commits. Then implement.
+3. **Wiring.** Store intent, surface + registry entry, command, palette `ui`
+ threading, catalog snapshot, control reference.
+4. **Verify once at the end** (Node 24): `npm run typecheck`, `npm test`,
+ `npm run test:contract`, `npm run check:keybindings`.
+
+## Review follow-up (after the PR opened)
+
+An independent review found, and this branch fixed:
+
+- **Enter on a focused footer button** (Cancel/Back) was captured by the list
+ handler, which prevented the button's click and committed the highlighted
+ row — Tab → Cancel → Enter spawned an agent. Footer buttons now own their
+ Enter (the `dialog-actions.tsx` rule). `ProviderSwitchPickerModal`, whose
+ pattern this copied, had the same bug on `main`: filed as #862 and fixed
+ here too.
+- A **held Enter** could pick the agent and commit in one press; auto-repeat
+ is ignored.
+- One-shot state now resets on **close**, not open, so a non-user-event open
+ never paints a stale project step.
+- The redundant cross-step focus effect was removed (Radix FocusScope already
+ refocuses the container when a focused row unmounts).
+- The empty project list now says the row's projects are closed and names
+ **Row Projects…**. Root cause — `closeTab` leaves row bindings in memory —
+ predates this work and is filed as #863 (out of scope).
+- Comment accuracy (header "+" anchor differs when its first row is pinned;
+ Enter-Enter equivalence is project-level) and a project-scope note in the
+ command description.
+
+## Review round 2 (two orchestrated reviewers: A correctness/Codex, B integration/Claude)
+
+Both verdicts "ready with fixes"; all valid findings fixed on this branch:
+
+- **Space activated the Tab-focused row, not the highlighted one** (A1/B1), in
+ both this dialog and Switch Provider: arrows moved the highlight while DOM
+ focus stayed on the row, and Space is a native click on the focused button.
+ Rows in both dialogs are no longer tab stops (`tabIndex={-1}`), so keyboard
+ focus is only ever the dialog surface or the footer; row `onFocus` sync is
+ gone. #862 was widened to cover this path.
+- The focused-control Enter rule is now one exported predicate,
+ `focusedControlOwnsEnter` in `components/ui/dialog-actions.tsx`, used by
+ `DialogActions` and both list dialogs instead of a copied footer-slot guard
+ (B2). Three more pre-existing dialogs with the pattern (Agent View Mode,
+ Reorder Tabs, Pin Agents) are filed as #867 — out of scope here.
+- The reopen test could not fail (B3): replaced by one that watches the DOM
+ during reopen and fails if the stale project step mounts even briefly
+ (verified red with reset-on-open, green with reset-on-close).
+- The resolver's bound-row branch WAS covered, in `rowScopedRows.test.ts`
+ (B4): the duplicate resolver tests were dropped and the comment corrected.
+- Eligibility is an explicit discriminant (`enabled`) on the project model
+ rather than re-derived from the anchor in three places (B7); the disabled
+ row test asserts the native `disabled` attribute (B6).
+- The dialog's keys are declared in `controlInteractions.ts` (B5); the command
+ description covers classic Dispatch rows as well as Grid lanes (B9); a stale
+ "103 + 4 = 107" comment in `catalog.test.ts` was pointed at the assertion
+ instead (B8).
+
+## Out of scope
+
+- A default chord.
+- Changing New Agent… or the header "+" behavior.
+- Grid-mode variant.
diff --git a/src/renderer/src/app-state/types.ts b/src/renderer/src/app-state/types.ts
index 5d2e61423..bfc795574 100644
--- a/src/renderer/src/app-state/types.ts
+++ b/src/renderer/src/app-state/types.ts
@@ -77,6 +77,9 @@ export type UiShellSlice = UiShellState & {
* is captured here rather than resolved at commit time. */
openNewAgentForProject: (tabId: TabId, anchorSessionId: SessionId) => void
closeNewAgentPlacement: () => void
+ /** Open/close the New Agent In… dialog (#852). See `newAgentInOpen`. */
+ openNewAgentIn: () => void
+ closeNewAgentIn: () => void
openTiledDispatchPrompt: () => void
openDispatchRowProjectPicker: (rowIndex: number) => void
closeDispatchRowProjectPicker: () => void
diff --git a/src/renderer/src/app-state/uiShell/slice.ts b/src/renderer/src/app-state/uiShell/slice.ts
index c2e857e1b..033f0030b 100644
--- a/src/renderer/src/app-state/uiShell/slice.ts
+++ b/src/renderer/src/app-state/uiShell/slice.ts
@@ -27,6 +27,7 @@ export const createUiShellSlice: StateCreator<
viewPromptsSessionId: null,
newAgentPlacementOpen: false,
newAgentProjectIntent: null,
+ newAgentInOpen: false,
tiledDispatchPromptOpen: false,
dispatchRowProjectPickerRow: null,
dispatchAttachIntent: null,
@@ -176,6 +177,10 @@ export const createUiShellSlice: StateCreator<
false,
'uiShell/closeNewAgentPlacement',
),
+ openNewAgentIn: () =>
+ set({ newAgentInOpen: true }, false, 'uiShell/openNewAgentIn'),
+ closeNewAgentIn: () =>
+ set({ newAgentInOpen: false }, false, 'uiShell/closeNewAgentIn'),
openTiledDispatchPrompt: () =>
set({ tiledDispatchPromptOpen: true }, false, 'uiShell/openTiledDispatchPrompt'),
closeTiledDispatchPrompt: () =>
diff --git a/src/renderer/src/app-state/uiShell/types.ts b/src/renderer/src/app-state/uiShell/types.ts
index 0d3144960..28aa42f5f 100644
--- a/src/renderer/src/app-state/uiShell/types.ts
+++ b/src/renderer/src/app-state/uiShell/types.ts
@@ -173,6 +173,24 @@ export type UiShellState = {
* clicked group is a correct and always-present source.
*/
newAgentProjectIntent: { tabId: TabId; anchorSessionId: SessionId } | null
+ /**
+ * The New Agent In… dialog (#852) is on screen.
+ *
+ * WHY a plain boolean when `newAgentProjectIntent` above and
+ * `dispatchRowProjectPickerRow` below capture a target up front: those are
+ * opened FROM a specific place (a project header, a row header) whose
+ * identity the later commit must not re-derive. This dialog is opened from a
+ * command and its whole job is to ASK for the project, so there is nothing
+ * to capture. The lane is deliberately not captured either — like New
+ * Agent…, the spawn fills whichever lane is focused when it commits, and the
+ * modal dialog owns app input in between, so the user cannot move lane focus
+ * under it.
+ *
+ * A separate flag rather than another `newAgentPlacementOpen` intent because
+ * it is a separate surface (NewAgentInDialog), not a mode of the placement
+ * overlay — see that component's header for why.
+ */
+ newAgentInOpen: boolean
gitBarOpen: boolean
worktreesBarOpen: boolean
debugPanelOpen: boolean
diff --git a/src/renderer/src/app/surfaces/registry.tsx b/src/renderer/src/app/surfaces/registry.tsx
index d6737a220..9cf7dbc32 100644
--- a/src/renderer/src/app/surfaces/registry.tsx
+++ b/src/renderer/src/app/surfaces/registry.tsx
@@ -30,6 +30,7 @@ import { RewindToPromptSurface } from '@renderer/features/workspace/surfaces/Rew
import { AgentTitlePromptSurface } from '@renderer/features/workspace/surfaces/AgentTitlePromptSurface'
import { ProviderSwitchPickerSurface } from '@renderer/features/workspace/surfaces/ProviderSwitchPickerSurface'
import { KeyVaultModalSurface } from '@renderer/features/key-vault/surfaces/KeyVaultModalSurface'
+import { NewAgentInSurface } from '@renderer/features/workspace/surfaces/NewAgentInSurface'
// The surface registry (issue #494). Adding a surface = write a wrapper
// in the owning feature's surfaces/ folder + add ONE import + ONE array
@@ -89,6 +90,10 @@ export const modalSurfaces: SurfaceEntry[] = [
// established surface below one it used to cover; see the registry contract.
{ id: 'provider-switch-picker', Component: ProviderSwitchPickerSurface },
{ id: 'key-vault', Component: KeyVaultModalSurface },
+ // Appended per the contract above. It is only opened from a command, which
+ // closes the palette first, so it has no stacking relationship to reason
+ // about beyond "a new modal paints over the established ones".
+ { id: 'new-agent-in', Component: NewAgentInSurface },
]
/**
diff --git a/src/renderer/src/components/ui/dialog-actions.renderer.test.tsx b/src/renderer/src/components/ui/dialog-actions.renderer.test.tsx
new file mode 100644
index 000000000..9226ecd83
--- /dev/null
+++ b/src/renderer/src/components/ui/dialog-actions.renderer.test.tsx
@@ -0,0 +1,57 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import { Dialog, DialogContent, DialogTitle } from './dialog'
+import { DialogActions, focusedControlOwnsEnter } from './dialog-actions'
+
+// DialogActions' Enter rule is shared: list dialogs that handle Enter on
+// DialogContent themselves (New Agent In, Switch Provider) call the exported
+// `focusedControlOwnsEnter` rather than copying a guard (#862). These pin the
+// rule where it lives, so a change to it cannot quietly break every consumer.
+
+function harness() {
+ const onConfirm = vi.fn()
+ const onCancel = vi.fn()
+ render(
+ ,
+ )
+ return { onConfirm, onCancel }
+}
+
+describe('DialogActions Enter ownership', () => {
+ it('confirms on an Enter that no control claims', () => {
+ const { onConfirm } = harness()
+
+ fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Enter' })
+
+ expect(onConfirm).toHaveBeenCalledOnce()
+ })
+
+ it('leaves Enter on a focused Cancel to Cancel, so it can never confirm', () => {
+ // The failure this prevents: preventDefault cancels Cancel's native click
+ // and the footer confirms instead — Enter-on-Cancel performing the
+ // destructive action. `true` = default not prevented.
+ const { onConfirm } = harness()
+
+ expect(fireEvent.keyDown(screen.getByRole('button', { name: 'Cancel' }), { key: 'Enter' })).toBe(true)
+
+ expect(onConfirm).not.toHaveBeenCalled()
+ })
+})
+
+describe('focusedControlOwnsEnter', () => {
+ it('claims Enter for buttons, links and textareas only', () => {
+ expect(focusedControlOwnsEnter(document.createElement('button'))).toBe(true)
+ expect(focusedControlOwnsEnter(document.createElement('a'))).toBe(true)
+ expect(focusedControlOwnsEnter(document.createElement('textarea'))).toBe(true)
+ // The dialog surface and ordinary containers do not: Enter there is the
+ // dialog's to handle.
+ expect(focusedControlOwnsEnter(document.createElement('div'))).toBe(false)
+ expect(focusedControlOwnsEnter(null)).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/ui/dialog-actions.tsx b/src/renderer/src/components/ui/dialog-actions.tsx
index 7b826019d..5bcbc1a93 100644
--- a/src/renderer/src/components/ui/dialog-actions.tsx
+++ b/src/renderer/src/components/ui/dialog-actions.tsx
@@ -67,6 +67,30 @@ export type DialogActionsProps = {
children?: React.ReactNode
}
+/**
+ * Whether the element an Enter keydown landed on owns that Enter itself.
+ *
+ * A focused button or link activates on Enter (the browser synthesizes a
+ * click), and a textarea inserts a newline. Any dialog-level Enter handler that
+ * calls `preventDefault` suppresses those, so it must ask this first — or Tab to
+ * Cancel + Enter CONFIRMS, which for a destructive dialog means Enter-on-Cancel
+ * performs the destructive action.
+ *
+ * Exported (rather than living only inside DialogActions' listener) because
+ * list-style dialogs handle Enter on `DialogContent` themselves — New Agent In
+ * and Switch Provider did, and both committed the highlighted row when Cancel
+ * had focus (#862). One predicate keeps "which controls own Enter" in one place
+ * instead of a guard copied into every list dialog.
+ *
+ * Such dialogs must also keep their list rows out of the tab order: a
+ * Tab-focused row would own Enter here and diverge from the arrow-driven
+ * highlight (and Space would click it regardless of any Enter handling).
+ */
+export function focusedControlOwnsEnter(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) return false
+ return target.tagName === 'BUTTON' || target.tagName === 'A' || target.tagName === 'TEXTAREA'
+}
+
export function DialogActions({
confirmLabel,
onConfirm,
@@ -97,17 +121,14 @@ export function DialogActions({
// Shift+Enter is newline everywhere in this app; a modifier means the
// user is composing, not committing.
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return
- const target = event.target as HTMLElement | null
- // A textarea owns its own Enter. Without this the footer would commit
- // the dialog while the user was trying to start a new line — the exact
- // reason several dialogs previously hand-rolled `!shiftKey` checks.
- if (target?.tagName === 'TEXTAREA') return
- // A FOCUSED BUTTON OWNS ITS OWN ENTER. This is the important one:
- // calling preventDefault below suppresses the synthesized click that
- // Enter would have sent to the focused control, so without this guard
- // tabbing to Cancel and pressing Enter would CONFIRM — and for a
- // destructive dialog that means Enter-on-Cancel performs the deletion.
- if (target?.tagName === 'BUTTON' || target?.tagName === 'A') return
+ // A FOCUSED BUTTON OWNS ITS OWN ENTER, and a textarea owns its newline.
+ // This is the important one: calling preventDefault below suppresses the
+ // synthesized click that Enter would have sent to the focused control, so
+ // without this guard tabbing to Cancel and pressing Enter would CONFIRM —
+ // and for a destructive dialog that means Enter-on-Cancel performs the
+ // deletion. The textarea half is the reason several dialogs previously
+ // hand-rolled `!shiftKey` checks. See `focusedControlOwnsEnter`.
+ if (focusedControlOwnsEnter(event.target)) return
if (blocked) return
event.preventDefault()
onConfirm()
diff --git a/src/renderer/src/features/command-palette/catalog.test.ts b/src/renderer/src/features/command-palette/catalog.test.ts
index 90d4286b0..999201626 100644
--- a/src/renderer/src/features/command-palette/catalog.test.ts
+++ b/src/renderer/src/features/command-palette/catalog.test.ts
@@ -12,8 +12,9 @@ import type { CommandDef } from '@renderer/features/command-palette/types'
// This file pinned the exact 102-id before-state, then the 106-id governance
// after-state (102 - 5 retired + 9 added), then 112 with Grid Dispatch's six
// row commands (#681), 113 with New Window (#688), 114 with Clear
-// Agent Composer (#683), 115 with API Key Vault (#831), and 116 with
-// Remove Cybersecurity Block (#848). Keeping ONE snapshot that moved — rather
+// Agent Composer (#683), 115 with API Key Vault (#831), 116 with
+// Remove Cybersecurity Block (#848), and 117 with New Agent In… (#852).
+// Keeping ONE snapshot that moved — rather
// than a "baseline" file and an "after" file — is what makes the plan's
// headline count an assertion anyone can check against running code instead of
// prose.
@@ -39,8 +40,11 @@ const BASELINE_COMMAND_IDS: readonly string[] = [
'resume-session',
// windowCommands (1)
'new-window',
- // paneCommands (31: 27 literal + 4 generated provider splits)
+ // paneCommands (32: 28 literal + 4 generated provider splits)
'new-agent',
+ // Registered directly after New Agent… so the two creation entry points sit
+ // together in the empty-query browse order (#852).
+ 'new-agent-in',
'split-vertical',
'split-horizontal',
'close-pane',
@@ -191,12 +195,12 @@ const NAVIGATION_COMMAND_GROUP: readonly string[] = [
const ids = (): string[] => builtInCommandCatalog.map(c => c.id)
describe('built-in command catalog — baseline characterization', () => {
- it('contains exactly the 115 governed commands in registration order', () => {
+ it('contains exactly the 117 governed commands in registration order', () => {
// Order matters: this is the palette's empty-query browse order.
expect(ids()).toEqual([...BASELINE_COMMAND_IDS])
})
- it('has exactly 115 commands', () => {
+ it('has exactly 117 commands', () => {
// Stated separately from the order assertion because this number is the
// thing that moves, and a bare count failure is a clearer signal than a
// 99-line array diff.
@@ -206,10 +210,12 @@ describe('built-in command catalog — baseline characterization', () => {
// with the two lane-removal commands → 105 with Set Agent Title → 106 with
// New Lane → 112 with Grid Dispatch's six row commands → 113 with New
// Window → 114 with Clear Agent Composer (#683) → 115 with API Key
- // Vault (#831). Each
- // step of that arithmetic was a deliberate edit to this line, which is the
- // entire point of pinning it.
- expect(builtInCommandCatalog).toHaveLength(116)
+ // Vault (#831) → 116 with Remove Cybersecurity Block (#848) → 117 with
+ // New Agent In… (#852). Each step of that arithmetic was a deliberate edit
+ // to this line, which is the entire point of pinning it. (The two test
+ // titles above had drifted to "115" while this line said 116; they now
+ // track it again.)
+ expect(builtInCommandCatalog).toHaveLength(117)
})
it('reports no structural defects', () => {
@@ -226,7 +232,10 @@ describe('built-in command catalog — baseline characterization', () => {
})
describe('generated per-provider split commands', () => {
- // The arithmetic is 103 literal ids + 4 generated = 107. If a provider
+ // The current literal + generated arithmetic is asserted by "accounts for the
+ // difference between literal and total command count" below — not restated
+ // here, because a number in this comment is what drifted to "103 + 4 = 107"
+ // while the assertions moved on. If a provider
// is ever added to AGENT_PROVIDER_KINDS, this invariant is what tells the
// author that the catalog count moved for a legitimate reason, and forces the
// baseline snapshot above to be updated deliberately.
@@ -240,11 +249,12 @@ describe('generated per-provider split commands', () => {
})
it('accounts for the difference between literal and total command count', () => {
- // 116 total - 4 generated = 112 literal `id:` fields across the command
+ // 117 total - 4 generated = 113 literal `id:` fields across the command
// modules. At the original baseline this read 102 - 4 = 98; it moved down by
// the five retirements, then back up by the nine additions, Grid Dispatch's
- // six row commands, and New Window.
- expect(builtInCommandCatalog.length - nonDefaultProviders.length * 2).toBe(112)
+ // six row commands, New Window, and the later single additions recorded in
+ // the count test above (through New Agent In…, #852).
+ expect(builtInCommandCatalog.length - nonDefaultProviders.length * 2).toBe(113)
})
it('emits both directions for every non-default provider', () => {
@@ -343,7 +353,7 @@ describe('governance targets', () => {
})
it('lands on the arithmetic the plan predicted', () => {
- // 102 baseline - 5 retirements + 18 additions = 115, checked against the
+ // 102 baseline - 5 retirements + 20 additions = 117, checked against the
// real catalog rather than trusted as prose.
//
// The subtracted term is the count of APPROVED ADDITIONS and the expected
@@ -361,10 +371,10 @@ describe('governance targets', () => {
// `new-dispatch-row`, `remove-dispatch-row`, `dispatch-row-project`,
// `dispatch-row-child-cap`, `dispatch-focus-row-up`,
// `dispatch-focus-row-down`, `new-window` (#688), and
- // `clear-agent-composer` (#683), `api-key-vault` (#831), and
- // `remove-cybersecurity-block` (#848).
- expect(builtInCommandCatalog.length + RETIRED_COMMAND_IDS.length - 19).toBe(102)
- expect(builtInCommandCatalog).toHaveLength(116)
+ // `clear-agent-composer` (#683), `api-key-vault` (#831),
+ // `remove-cybersecurity-block` (#848), and `new-agent-in` (#852).
+ expect(builtInCommandCatalog.length + RETIRED_COMMAND_IDS.length - 20).toBe(102)
+ expect(builtInCommandCatalog).toHaveLength(117)
})
})
diff --git a/src/renderer/src/features/command-palette/types.ts b/src/renderer/src/features/command-palette/types.ts
index fa93307d3..24e777e27 100644
--- a/src/renderer/src/features/command-palette/types.ts
+++ b/src/renderer/src/features/command-palette/types.ts
@@ -250,6 +250,10 @@ export type CommandContext = {
* session id is the parent agent; the overlay only asks for
* Claude/Codex and then delegates to workspace.createLinkedAgent. */
openLinkedAgent: (sessionId: string) => void
+ /** Open the New Agent In… dialog (#852): agent, then project, into the
+ * focused Dispatch lane. Takes no target — choosing one is the dialog's
+ * job; see uiShell `newAgentInOpen`. */
+ openNewAgentIn: () => void
/** Open the Pin Agents multi-select modal. Lives on uiShell as a
* transient flag — the draft selection state is owned by the
* modal itself, not the store. */
diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx
index 66ad43414..3ea9ce1b0 100644
--- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx
+++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx
@@ -317,6 +317,7 @@ function OpenCommandPalette({
const openTiledDispatchPrompt = useAppStore(state => state.openTiledDispatchPrompt)
const openDispatchAttach = useAppStore(state => state.openDispatchAttach)
const openLinkedAgent = useAppStore(state => state.openLinkedAgent)
+ const openNewAgentIn = useAppStore(state => state.openNewAgentIn)
const openPinAgents = useAppStore(state => state.openPinAgents)
const toggleCaffeinate = useCaffeinateStore(state => state.toggle)
const caffeinateStatus = useCaffeinateStore(state => state.status)
@@ -659,6 +660,7 @@ function OpenCommandPalette({
openTiledDispatchPrompt,
openDispatchAttach,
openLinkedAgent,
+ openNewAgentIn,
openPinAgents,
setAggressiveDebugPersistence,
enterResumeMode,
@@ -768,6 +770,7 @@ function OpenCommandPalette({
openTiledDispatchPrompt,
openDispatchAttach,
openLinkedAgent,
+ openNewAgentIn,
openPinAgents,
setAggressiveDebugPersistence,
enterResumeMode,
diff --git a/src/renderer/src/features/workspace/commands/paneCommands.ts b/src/renderer/src/features/workspace/commands/paneCommands.ts
index 576677505..580fcd901 100644
--- a/src/renderer/src/features/workspace/commands/paneCommands.ts
+++ b/src/renderer/src/features/workspace/commands/paneCommands.ts
@@ -49,6 +49,34 @@ export const paneCommands: CommandDef[] = [
when: ({ workspace }) => Boolean(workspace.activeTab && !workspace.tileTabs),
run: ({ workspace }) => workspace.startNewAgentPlacement(),
},
+ {
+ // New Agent… files a Dispatch agent under whatever project the spawn
+ // resolver derives from FOCUS. With an empty lane in an unbound row that is
+ // the last agent you selected, so aiming at another project meant first
+ // selecting some unrelated agent in it purely to move the target, then
+ // coming back to the lane (#852). This asks for the project instead. It
+ // still fills the focused lane, exactly like New Agent….
+ id: 'new-agent-in',
+ category: 'create',
+ // `dispatch`, not `app`: in the grid a project is a tab one keystroke away,
+ // and a detached agent spawned from the grid lands nowhere visible — the
+ // grid has no lanes to fill and Dispatch rows are not on screen.
+ surface: 'dispatch',
+ // Title per docs/command-style.md: "New X" for creation, and the ellipsis
+ // because the command asks for more input (agent, then project).
+ title: 'New Agent In…',
+ // The scope sentence is there because it surprised the reviewer: in
+ // project-scope Dispatch, spawning into another project makes it the active
+ // project (createDetachedDispatchAgent selects what it creates), so the
+ // other lanes read "Not in this scope" until you switch back. That is the
+ // scope contract working — the new agent has to be visible — not a bug.
+ description: '**What it does:** Starts a **new agent in a project you choose** — in the focused lane in Grid Dispatch, or as a new Dispatch row.\n\n**Use when:** You want an agent for a different project than the one you last selected, e.g. to fill an empty lane.\n\n**Notes:** Pick the agent, then the project. A row limited to certain projects only offers those. In project-scoped Dispatch, choosing another project switches to it.',
+ keywords: ['new', 'agent', 'project', 'lane', 'fill', 'empty', 'dispatch', 'claude', 'codex', 'opencode'],
+ // Same data gate as New Agent…. Tiled Tabs covers Dispatch, so the lane the
+ // agent would fill is not the thing on screen.
+ when: ({ workspace }) => Boolean(workspace.activeTab && !workspace.tileTabs),
+ run: ({ ui }) => ui.openNewAgentIn(),
+ },
{
// `grid` surface — applies to split-vertical, split-horizontal,
// codex-vertical, codex-horizontal, terminal-horizontal and
diff --git a/src/renderer/src/features/workspace/controlInteractions.ts b/src/renderer/src/features/workspace/controlInteractions.ts
index 4eff17ad6..52e9b202a 100644
--- a/src/renderer/src/features/workspace/controlInteractions.ts
+++ b/src/renderer/src/features/workspace/controlInteractions.ts
@@ -5,6 +5,7 @@ export const workspaceInteractions: InteractionReference[] = [
{ id: 'placement.reset', bindings: ['Backspace'], context: 'agent placement preview', description: 'Return selection to the default placement for the anchor.' },
{ id: 'placement.confirm', bindings: ['Enter', 'Escape'], context: 'agent placement or Dispatch shape dialog', description: 'Enter confirms a valid selected placement/shape; Escape dismisses the owning dialog.' },
{ id: 'provider.select', bindings: ['Up', 'Down', 'Ctrl+P', 'Ctrl+N', 'Enter'], context: 'provider switch picker', description: 'Navigate provider choices and confirm the selected provider.' },
+ { id: 'new-agent-in.select', bindings: ['Up', 'Down', 'Ctrl+P', 'Ctrl+N', 'Enter', 'Backspace'], context: 'New Agent In dialog', description: 'Choose the agent, then the project; Enter advances, then creates the agent in the focused Dispatch lane (or as a new Dispatch row); Backspace returns to the agent step.' },
{ id: 'agent-view.select', bindings: ['Up', 'Down', 'Enter'], context: 'agent view-mode picker', description: 'Select and confirm the rendered/terminal view choice.' },
{ id: 'tabs.reorder', bindings: ['Up', 'Down', 'Enter'], context: 'reorder tabs dialog', description: 'Arrows browse until Enter picks a tab; subsequent arrows move that tab, and Enter confirms the reorder.' },
{ id: 'dialogs.confirm', bindings: ['Enter', 'Escape'], context: 'workspace prompts and dialogs', description: 'Confirm a valid form or dismiss the dialog according to its focused control. Read close/rewind impact before confirming.' },
diff --git a/src/renderer/src/features/workspace/controlReference.ts b/src/renderer/src/features/workspace/controlReference.ts
index f351d7d59..6eba67e38 100644
--- a/src/renderer/src/features/workspace/controlReference.ts
+++ b/src/renderer/src/features/workspace/controlReference.ts
@@ -47,7 +47,8 @@ export const controlReference = [
"global-dispatch",
"tiled-dispatch",
"new-dispatch-row",
- "new-tiled-lane"
+ "new-tiled-lane",
+ "new-agent-in"
]
},
{
diff --git a/src/renderer/src/features/workspace/lib/newAgentInProjects.test.ts b/src/renderer/src/features/workspace/lib/newAgentInProjects.test.ts
new file mode 100644
index 000000000..a3d265c49
--- /dev/null
+++ b/src/renderer/src/features/workspace/lib/newAgentInProjects.test.ts
@@ -0,0 +1,150 @@
+import { describe, expect, it } from 'vitest'
+
+import { buildNewAgentInModel } from '@renderer/features/workspace/lib/newAgentInProjects'
+import type { DispatchModeState, TileNode, WorkspaceState } from '@renderer/workspace/types'
+
+// Three projects, each with one grid agent, so labels A/B/C are all in play and
+// a filtered list can prove it keeps the GLOBAL letter rather than re-lettering.
+function leaf(sessionId: string): TileNode {
+ return { type: 'leaf', sessionId }
+}
+
+function makeState(dispatchMode: DispatchModeState | null): WorkspaceState {
+ return {
+ tabs: [
+ { id: 'tabA', title: 'project-a', root: leaf('a1'), focusedSessionId: 'a1' },
+ { id: 'tabB', title: 'project-b', root: leaf('b1'), focusedSessionId: 'b1' },
+ { id: 'tabC', title: 'project-c', root: leaf('c1'), focusedSessionId: 'c1' },
+ ],
+ activeTabId: 'tabA',
+ dispatchMode,
+ sessions: {
+ a1: { cwd: '/work/project-a', kind: 'claude' },
+ b1: { cwd: '/work/project-b', kind: 'codex' },
+ c1: { cwd: '/work/project-c', kind: 'claude' },
+ },
+ detachedSessions: {},
+ buried: [],
+ pinnedSessionIds: [],
+ }
+}
+
+/** Grid Dispatch with two rows of two lanes; lane 2 (row 1) focused and empty. */
+function gridWithFocusedEmptyLane(rowOneProjects?: string[]): DispatchModeState {
+ return {
+ scope: 'global',
+ focusedSessionId: 'a1',
+ tiled: {
+ focusedLane: 2,
+ lanes: [{ selectedSessionId: 'a1' }, {}, {}, {}],
+ rows: [
+ { length: 2 },
+ rowOneProjects ? { length: 2, projectTabIds: rowOneProjects } : { length: 2 },
+ ],
+ },
+ }
+}
+
+describe('buildNewAgentInModel', () => {
+ it('offers every project in tab order with the Dispatch letter labels when the row is unbound', () => {
+ const model = buildNewAgentInModel(makeState(gridWithFocusedEmptyLane()))
+
+ expect(model.projects.map(p => [p.tabId, p.label, p.title])).toEqual([
+ ['tabA', 'A', 'project-a'],
+ ['tabB', 'B', 'project-b'],
+ ['tabC', 'C', 'project-c'],
+ ])
+ })
+
+ it('offers only the focused row s bound projects, keeping their global letters', () => {
+ // Bound to C then B: the list still follows TAB order and still says "C",
+ // because the letter is the name Dispatch uses for that project everywhere.
+ const model = buildNewAgentInModel(makeState(gridWithFocusedEmptyLane(['tabC', 'tabB'])))
+
+ expect(model.projects.map(p => [p.tabId, p.label])).toEqual([
+ ['tabB', 'B'],
+ ['tabC', 'C'],
+ ])
+ })
+
+ it('anchors a project on its first session with a directory, grid leaf before detached rows', () => {
+ const state = makeState(gridWithFocusedEmptyLane())
+ state.sessions.b2 = { cwd: '/work/project-b/.worktrees/task', kind: 'codex' }
+ state.detachedSessions.b2 = {
+ sessionId: 'b2',
+ surface: 'dispatch',
+ projectTabId: 'tabB',
+ projectTabTitle: 'project-b',
+ projectTabIndex: 1,
+ detachedAt: 10,
+ }
+
+ const projectB = buildNewAgentInModel(state).projects.find(p => p.tabId === 'tabB')
+
+ // The grid leaf is the project's own directory; the detached worktree
+ // agent is only the fallback. Same order the Dispatch header "+" uses.
+ expect(projectB).toMatchObject({ enabled: true, anchorSessionId: 'b1', disabledReason: null })
+ })
+
+ it('falls back to a detached row when the grid leaf has no live session behind it', () => {
+ const state = makeState(gridWithFocusedEmptyLane())
+ delete state.sessions.b1
+ state.sessions.b2 = { cwd: '/work/project-b', kind: 'codex' }
+ state.detachedSessions.b2 = {
+ sessionId: 'b2',
+ surface: 'dispatch',
+ projectTabId: 'tabB',
+ projectTabTitle: 'project-b',
+ projectTabIndex: 1,
+ detachedAt: 10,
+ }
+
+ const projectB = buildNewAgentInModel(state).projects.find(p => p.tabId === 'tabB')
+
+ expect(projectB).toMatchObject({ anchorSessionId: 'b2', disabledReason: null })
+ })
+
+ it('lists a project with no session to borrow a directory from as disabled, with the reason', () => {
+ const state = makeState(gridWithFocusedEmptyLane())
+ delete state.sessions.c1
+
+ const projectC = buildNewAgentInModel(state).projects.find(p => p.tabId === 'tabC')
+
+ // `enabled` is the one field consumers branch on; anchor and reason follow
+ // from it, so a future second disabled reason cannot leave a project that
+ // the model calls disabled but the dialog can still commit.
+ expect(projectC).toMatchObject({ enabled: false, anchorSessionId: null })
+ expect(projectC?.disabledReason).toMatch(/no agent/i)
+ })
+
+ it('first highlights the project plain New Agent would have used', () => {
+ // Classic focus b1 + unbound empty lane => the spawn resolver picks tabB.
+ const dispatchMode = gridWithFocusedEmptyLane()
+ dispatchMode.focusedSessionId = 'b1'
+
+ expect(buildNewAgentInModel(makeState(dispatchMode)).initialTabId).toBe('tabB')
+ })
+
+ it('first highlights the first enabled project when the spawn target is not on offer', () => {
+ // A row bound to C whose focused lane still shows A's agent (binding
+ // filters, it never evicts), so plain New Agent would target A — which
+ // this row does not offer.
+ const dispatchMode = gridWithFocusedEmptyLane(['tabC'])
+ dispatchMode.tiled!.lanes[2] = { selectedSessionId: 'a1' }
+
+ expect(buildNewAgentInModel(makeState(dispatchMode)).initialTabId).toBe('tabC')
+ })
+
+ it('never highlights a disabled project', () => {
+ // b1 still exists (so the spawn resolver still targets tabB) but has no
+ // directory, so tabB cannot be anchored. Active tab is C so the expected A
+ // can only come from the "first enabled project" rule, not from activeTabId.
+ const dispatchMode = gridWithFocusedEmptyLane()
+ dispatchMode.focusedSessionId = 'b1'
+ const state = makeState(dispatchMode)
+ state.activeTabId = 'tabC'
+ state.sessions.b1 = { cwd: '', kind: 'codex' }
+
+ expect(buildNewAgentInModel(state).initialTabId).toBe('tabA')
+ })
+})
diff --git a/src/renderer/src/features/workspace/lib/newAgentInProjects.ts b/src/renderer/src/features/workspace/lib/newAgentInProjects.ts
new file mode 100644
index 000000000..6a29005bb
--- /dev/null
+++ b/src/renderer/src/features/workspace/lib/newAgentInProjects.ts
@@ -0,0 +1,140 @@
+import {
+ focusedLaneBoundProjectTabIds,
+ resolveDispatchSpawnTarget,
+} from '@renderer/workspace/dispatch/dispatchSelectors'
+import { resolveTabSessions } from '@renderer/workspace/queries'
+import { tabIndexLabel } from '@renderer/workspace/tile-tree/paneLabels'
+import type { SessionId, TabId, WorkspaceState } from '@renderer/workspace/types'
+
+// ---------------------------------------------------------------------------
+// Project list for New Agent In… (#852).
+//
+// The command exists because filling an empty Grid Dispatch lane used to need
+// a navigational detour: plain New Agent… files the agent under whatever
+// project the spawn resolver derives from focus, so the only way to aim it at
+// project C was to go select some unrelated agent in C first. This module
+// answers "which projects can the picker offer, and how would each one be
+// spawned into?" as a pure function of workspace state, so the dialog stays a
+// dumb list and the rules below are testable without React.
+// ---------------------------------------------------------------------------
+
+type NewAgentInProjectBase = {
+ tabId: TabId
+ /**
+ * A/B/C — the tab's position in the FULL tab list, never re-lettered after
+ * row-binding filtering. The Dispatch index, pane labels and the row-project
+ * picker all name projects this way; a picker that called project C "B"
+ * because B was filtered out would contradict every other surface.
+ */
+ label: string
+ title: string
+}
+
+/**
+ * One row of the project step.
+ *
+ * WHY a union discriminated on `enabled` rather than a nullable anchor the
+ * consumer inspects: eligibility is the MODEL's decision, and the dialog used
+ * to re-derive it from `anchorSessionId !== null` in three places. A future
+ * second reason to disable a project (one that still has an anchor) would then
+ * have been disabled in the model and committable in the dialog. With the
+ * union, `enabled` is the only thing consumers branch on, and TypeScript only
+ * hands out an anchor on the enabled branch.
+ */
+export type NewAgentInProject =
+ | (NewAgentInProjectBase & {
+ enabled: true
+ /**
+ * The existing session whose cwd the new agent borrows.
+ *
+ * WHY an anchor session at all: a Tab has no directory of its own — cwd
+ * lives on sessions — and `createDetachedDispatchAgent` deliberately
+ * takes the override as `{ tabId, anchorSessionId }` for exactly this
+ * reason (see the WHY on `newAgentProjectIntent` in uiShell/types.ts).
+ */
+ anchorSessionId: SessionId
+ disabledReason: null
+ })
+ | (NewAgentInProjectBase & {
+ enabled: false
+ anchorSessionId: null
+ /** Shown on the disabled row, so the user learns why it cannot be picked. */
+ disabledReason: string
+ })
+
+export type NewAgentInModel = {
+ projects: NewAgentInProject[]
+ /** Row to highlight when the project step opens; null when none is enabled. */
+ initialTabId: TabId | null
+}
+
+const NO_ANCHOR_REASON = 'No agent in this project to take a working directory from'
+
+/**
+ * First session of the project that has a directory to borrow.
+ *
+ * Order comes from `resolveTabSessions`: grid leaves first, then detached rows
+ * by `detachedAt`. That matches the Dispatch project header's "+" (it passes
+ * the group's first row) except when that row is pinned — pins are lifted out
+ * of project groups, so the "+" may then anchor on the next row. Grid leaves
+ * first matters on its own: they are usually the project's root checkout,
+ * while detached rows are where worktree agents live — borrowing a worktree
+ * path would start the new agent inside someone else's task branch.
+ *
+ * WHY the anchor deliberately ignores focus, unlike plain New Agent… (which
+ * borrows the focused lane's or last-selected agent's cwd, worktree or not):
+ * this command exists to remove focus from the decision. The user named a
+ * PROJECT; its own checkout is what "in project B" means. Inheriting whichever
+ * worktree agent in B happened to be selected last is the focus-dependence the
+ * command was built to escape.
+ *
+ * A grid leaf can outlive its SessionMeta (resolveTabSessions does not filter
+ * grid ids by existence), and a meta can carry an empty cwd; both are skipped
+ * rather than trusted, because `createDetachedDispatchAgent` would otherwise
+ * fail late with a toast instead of the picker saying so up front.
+ */
+function anchorFor(state: WorkspaceState, tabId: TabId): SessionId | null {
+ for (const sessionId of resolveTabSessions(state, tabId)) {
+ if (state.sessions[sessionId]?.cwd) return sessionId
+ }
+ return null
+}
+
+export function buildNewAgentInModel(state: WorkspaceState): NewAgentInModel {
+ // Row bindings restrict the offer. A binding says "this row holds these
+ // projects" and the row's own index only lists them; spawning another
+ // project's agent into one of its lanes would put an agent where its own
+ // index cannot show it. The spawn resolver honours the same selector.
+ const bound = new Set(focusedLaneBoundProjectTabIds(state))
+
+ const projects: NewAgentInProject[] = []
+ state.tabs.forEach((tab, tabIndex) => {
+ // Iterate TABS (not the binding) so the list keeps tab order: the binding
+ // is a set the user built by clicking, and its insertion order means
+ // nothing to someone scanning A, B, C.
+ if (bound.size > 0 && !bound.has(tab.id)) return
+ const base = { tabId: tab.id, label: tabIndexLabel(tabIndex), title: tab.title }
+ const anchorSessionId = anchorFor(state, tab.id)
+ projects.push(
+ anchorSessionId
+ ? { ...base, enabled: true, anchorSessionId, disabledReason: null }
+ : { ...base, enabled: false, anchorSessionId: null, disabledReason: NO_ANCHOR_REASON },
+ )
+ })
+
+ // Start on the PROJECT plain New Agent… would have used, so Enter, Enter
+ // lands in the same project as the command it sits beside — the new command
+ // only adds the ability to point somewhere else. (Same project, not always
+ // the same directory: see `anchorFor` for why this one uses the project's
+ // own checkout.) When that project is not on offer (a bound row whose lane
+ // still shows another project's agent) or cannot be anchored, fall back to
+ // the first project that can actually take an agent.
+ const spawnTabId = resolveDispatchSpawnTarget(state).tabId
+ const enabled = projects.filter(project => project.enabled)
+ const initialTabId =
+ enabled.find(project => project.tabId === spawnTabId)?.tabId ??
+ enabled[0]?.tabId ??
+ null
+
+ return { projects, initialTabId }
+}
diff --git a/src/renderer/src/features/workspace/surfaces/NewAgentInSurface.tsx b/src/renderer/src/features/workspace/surfaces/NewAgentInSurface.tsx
new file mode 100644
index 000000000..9d3dbca1e
--- /dev/null
+++ b/src/renderer/src/features/workspace/surfaces/NewAgentInSurface.tsx
@@ -0,0 +1,12 @@
+import { NewAgentInDialog } from '@renderer/features/workspace/ui/NewAgentInDialog'
+import { useAppStore } from '@renderer/app-state/hooks'
+import { useWorkspaceContext } from '@renderer/workspace/WorkspaceContext'
+
+// New Agent In… (#852). A boolean flag, not a captured target — choosing the
+// project is what the dialog is for; see UiShellState.newAgentInOpen.
+export function NewAgentInSurface() {
+ const workspace = useWorkspaceContext()
+ const open = useAppStore(state => state.newAgentInOpen)
+ const close = useAppStore(state => state.closeNewAgentIn)
+ return
+}
diff --git a/src/renderer/src/features/workspace/ui/NewAgentInDialog.renderer.test.tsx b/src/renderer/src/features/workspace/ui/NewAgentInDialog.renderer.test.tsx
new file mode 100644
index 000000000..c600f1ee9
--- /dev/null
+++ b/src/renderer/src/features/workspace/ui/NewAgentInDialog.renderer.test.tsx
@@ -0,0 +1,270 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import * as workspaceQueries from '@renderer/workspace/queries'
+import type { Workspace } from '@renderer/workspace/workspaceStore'
+import type { WorkspaceState } from '@renderer/workspace/types'
+
+import { NewAgentInDialog } from './NewAgentInDialog'
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+// Three projects in Grid Dispatch. The focused lane (2) is EMPTY and the classic
+// Dispatch focus is B's agent — the exact situation that used to force the
+// "go select an agent in the right project first" detour. Plain New Agent…
+// would therefore target project B.
+function workspaceState(): WorkspaceState {
+ return {
+ tabs: [
+ { id: 'tabA', title: 'project-a', root: { type: 'leaf', sessionId: 'a1' }, focusedSessionId: 'a1' },
+ { id: 'tabB', title: 'project-b', root: { type: 'leaf', sessionId: 'b1' }, focusedSessionId: 'b1' },
+ { id: 'tabC', title: 'project-c', root: { type: 'leaf', sessionId: 'c1' }, focusedSessionId: 'c1' },
+ ],
+ activeTabId: 'tabA',
+ dispatchMode: {
+ scope: 'global',
+ focusedSessionId: 'b1',
+ tiled: {
+ focusedLane: 2,
+ lanes: [{ selectedSessionId: 'a1' }, { selectedSessionId: 'b1' }, {}],
+ },
+ },
+ sessions: {
+ a1: { cwd: '/work/project-a', kind: 'claude' },
+ b1: { cwd: '/work/project-b', kind: 'codex' },
+ c1: { cwd: '/work/project-c', kind: 'claude' },
+ },
+ detachedSessions: {},
+ buried: [],
+ pinnedSessionIds: [],
+ }
+}
+
+function harness(options: { open?: boolean; state?: WorkspaceState } = {}) {
+ const createDetachedDispatchAgent = vi.fn(async () => 'new-agent')
+ const onClose = vi.fn()
+ const workspace = {
+ state: options.state ?? workspaceState(),
+ createDetachedDispatchAgent,
+ } as unknown as Workspace
+ const mounted = render(
+ ,
+ )
+ const dialog = () => screen.getByRole('dialog')
+ const press = (key: string) => fireEvent.keyDown(dialog(), { key })
+ return { createDetachedDispatchAgent, onClose, mounted, workspace, press }
+}
+
+describe('NewAgentInDialog', () => {
+ it('Enter, Enter creates the default agent in the project plain New Agent would have used', () => {
+ // Accepting both defaults lands in New Agent…'s own target PROJECT (B
+ // here). The directory is B's own checkout by design — see anchorFor.
+ const { createDetachedDispatchAgent, onClose, press } = harness()
+
+ press('Enter')
+ press('Enter')
+
+ expect(onClose).toHaveBeenCalledOnce()
+ expect(createDetachedDispatchAgent).toHaveBeenCalledWith(
+ { kind: 'claude', providerRuntime: undefined },
+ { tabId: 'tabB', anchorSessionId: 'b1' },
+ )
+ })
+
+ it('creates the chosen agent in the chosen project from the keyboard, without selecting an agent in that project first', () => {
+ const { createDetachedDispatchAgent, press } = harness()
+
+ press('ArrowDown') // Claude -> Codex
+ press('Enter')
+ expect(screen.getByText('C · project-c')).toBeInTheDocument()
+ press('ArrowDown') // B (initial) -> C
+ press('Enter')
+
+ expect(createDetachedDispatchAgent).toHaveBeenCalledWith(
+ { kind: 'codex', providerRuntime: undefined },
+ { tabId: 'tabC', anchorSessionId: 'c1' },
+ )
+ })
+
+ it('keeps OpenCode Terminal a distinct runtime choice on the mouse path', () => {
+ const { createDetachedDispatchAgent } = harness()
+
+ fireEvent.click(screen.getByText('OpenCode Terminal').closest('button')!)
+ fireEvent.click(screen.getByText('A · project-a').closest('button')!)
+
+ expect(createDetachedDispatchAgent).toHaveBeenCalledWith(
+ { kind: 'opencode', providerRuntime: 'terminal' },
+ { tabId: 'tabA', anchorSessionId: 'a1' },
+ )
+ })
+
+ it('Backspace on the project step goes back to the agent step without creating anything', () => {
+ const { createDetachedDispatchAgent, press } = harness()
+
+ press('Enter')
+ expect(screen.queryByText('Codex')).not.toBeInTheDocument()
+ press('Backspace')
+
+ expect(screen.getByText('Codex')).toBeInTheDocument()
+ expect(createDetachedDispatchAgent).not.toHaveBeenCalled()
+ })
+
+ it('shows a project it cannot anchor as disabled, and neither click nor arrows can choose it', () => {
+ const state = workspaceState()
+ delete state.sessions.c1
+ const { createDetachedDispatchAgent, press } = harness({ state })
+
+ press('Enter')
+ expect(screen.getByText(/no agent in this project/i)).toBeInTheDocument()
+ // The native attribute, not just the commit guard: it is what assistive
+ // tech announces as unavailable, and the click assertion below would pass
+ // on the guard alone.
+ expect(screen.getByText('C · project-c').closest('button')).toBeDisabled()
+ fireEvent.click(screen.getByText('C · project-c').closest('button')!)
+ expect(createDetachedDispatchAgent).not.toHaveBeenCalled()
+
+ // From B, the only row below is the disabled C: the highlight must stay on
+ // B rather than parking on a row Enter cannot act on.
+ press('ArrowDown')
+ press('Enter')
+ expect(createDetachedDispatchAgent).toHaveBeenCalledWith(
+ { kind: 'claude', providerRuntime: undefined },
+ { tabId: 'tabB', anchorSessionId: 'b1' },
+ )
+ })
+
+ it('cannot create a second agent from a repeated Enter while the first spawn is in flight', () => {
+ // The dialog's `open` prop only drops after the parent reacts to onClose;
+ // until then a fast double Enter would otherwise spawn twice.
+ const { createDetachedDispatchAgent, press } = harness()
+
+ press('Enter')
+ press('Enter')
+ press('Enter')
+
+ expect(createDetachedDispatchAgent).toHaveBeenCalledOnce()
+ })
+
+ it('leaves Enter to a focused footer button instead of advancing or creating', () => {
+ // A focused button owns its own Enter (components/ui/dialog-actions.tsx).
+ // Before this guard, Tab to Cancel + Enter on the project step SPAWNED an
+ // agent: the list handler prevented the button's native click and then
+ // committed the highlighted row. `fireEvent` returning true means the
+ // default was not prevented, so the real browser's click still happens.
+ const { createDetachedDispatchAgent, press } = harness()
+
+ expect(fireEvent.keyDown(screen.getByRole('button', { name: 'Cancel' }), { key: 'Enter' })).toBe(true)
+ expect(screen.getByText('Codex')).toBeInTheDocument() // still on the agent step
+
+ press('Enter')
+ expect(fireEvent.keyDown(screen.getByRole('button', { name: 'Back' }), { key: 'Enter' })).toBe(true)
+ expect(fireEvent.keyDown(screen.getByRole('button', { name: 'Cancel' }), { key: 'Enter' })).toBe(true)
+
+ expect(createDetachedDispatchAgent).not.toHaveBeenCalled()
+ })
+
+ it('ignores a held Enter, so one long press cannot pick the agent and spawn in the same breath', () => {
+ const { createDetachedDispatchAgent, press } = harness()
+
+ press('Enter')
+ fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Enter', repeat: true })
+
+ expect(createDetachedDispatchAgent).not.toHaveBeenCalled()
+ })
+
+ it('never lets keyboard focus rest on a list row, on either step', () => {
+ // Rows are buttons (so a click works), but they must not be tab stops.
+ // A Tab-focused row kept DOM focus while the arrows moved the highlight,
+ // so Space — which the browser delivers as a click on the FOCUSED button —
+ // created the agent in a project other than the highlighted one (and, on
+ // step one, picked a different agent). happy-dom does not synthesize a
+ // keyboard click, so the honest pin is the structure that makes the bug
+ // impossible: no row can receive keyboard focus.
+ const { press } = harness()
+
+ const agentRows = document.querySelectorAll('[data-new-agent-in-choice]')
+ expect(agentRows.length).toBeGreaterThan(0)
+ agentRows.forEach(row => expect(row).toHaveAttribute('tabindex', '-1'))
+
+ press('Enter')
+ const projectRows = document.querySelectorAll('[data-new-agent-in-project]')
+ expect(projectRows.length).toBe(3)
+ projectRows.forEach(row => expect(row).toHaveAttribute('tabindex', '-1'))
+ })
+
+ it('reopens on the agent step without ever mounting the previous project step, and with a fresh latch', () => {
+ // Resetting on OPEN also ends on the agent step — but only after first
+ // mounting the stale project step for one render, which a non-user-event
+ // open (an MCP commands.run) can paint. Watching the DOM during the reopen
+ // is what tells reset-on-close apart from reset-on-open.
+ const { createDetachedDispatchAgent, mounted, workspace, onClose, press } = harness()
+
+ press('Enter')
+ press('Enter') // commits and latches, leaving the dialog on the project step
+ mounted.rerender()
+
+ const observer = new MutationObserver(() => {})
+ observer.observe(document.body, { childList: true, subtree: true })
+ mounted.rerender()
+ // Scan REMOVED nodes too. `rerender` runs inside act(), so by the time the
+ // records are read, reset-on-open has already replaced the stale project
+ // rows — an added portal root no longer contains them. The replaced rows
+ // themselves, though, appear in `removedNodes` with their attributes
+ // intact, which is the evidence that the stale step was mounted.
+ const isProjectRowTree = (node: Node) =>
+ node instanceof Element &&
+ (node.matches('[data-new-agent-in-project]') || node.querySelector('[data-new-agent-in-project]') !== null)
+ const staleProjectStepMounted = observer.takeRecords().some(record =>
+ Array.from(record.addedNodes).some(isProjectRowTree) ||
+ Array.from(record.removedNodes).some(isProjectRowTree),
+ )
+ observer.disconnect()
+
+ expect(staleProjectStepMounted).toBe(false)
+ expect(screen.getByText('Codex')).toBeInTheDocument()
+ press('Enter')
+ press('Enter')
+ expect(createDetachedDispatchAgent).toHaveBeenCalledTimes(2)
+ })
+
+ it('says so when every project the focused row is bound to has been closed', () => {
+ // Closing a tab does not yet clear row bindings in memory (#863), so a row
+ // can be bound only to projects that no longer exist. "No projects are
+ // open" would be false — other projects are — and gives no way forward.
+ const state = workspaceState()
+ state.dispatchMode!.tiled!.rows = [{ length: 3, projectTabIds: ['tab-closed'] }]
+ const { press } = harness({ state })
+
+ press('Enter')
+
+ expect(screen.getByText(/none of this row.s projects are open/i)).toBeInTheDocument()
+ expect(screen.getByText(/row projects/i)).toBeInTheDocument()
+ })
+
+ it('Cancel closes without creating whichever row is highlighted', () => {
+ const { createDetachedDispatchAgent, onClose, press } = harness()
+
+ press('Enter')
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+
+ expect(onClose).toHaveBeenCalledOnce()
+ expect(createDetachedDispatchAgent).not.toHaveBeenCalled()
+ })
+
+ it('derives no project list while closed', () => {
+ // The surface is always mounted and re-renders with every workspace change;
+ // building the list walks every tab's sessions and the Dispatch rows, which
+ // is pure waste for a dialog nobody can see.
+ const enumerateSessions = vi.spyOn(workspaceQueries, 'resolveTabSessions')
+ const { mounted, workspace, onClose } = harness({ open: false })
+
+ expect(enumerateSessions).not.toHaveBeenCalled()
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+
+ mounted.rerender()
+ fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Enter' })
+ expect(enumerateSessions).toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/features/workspace/ui/NewAgentInDialog.tsx b/src/renderer/src/features/workspace/ui/NewAgentInDialog.tsx
new file mode 100644
index 000000000..164378229
--- /dev/null
+++ b/src/renderer/src/features/workspace/ui/NewAgentInDialog.tsx
@@ -0,0 +1,313 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+
+import { Button } from '@renderer/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@renderer/components/ui/dialog'
+import { focusedControlOwnsEnter } from '@renderer/components/ui/dialog-actions'
+import {
+ buildNewAgentInModel,
+ type NewAgentInModel,
+ type NewAgentInProject,
+} from '@renderer/features/workspace/lib/newAgentInProjects'
+import { AGENT_PROVIDER_CHOICES } from '@renderer/workspace/providerChoices'
+import type { TabId } from '@renderer/workspace/types'
+import type { Workspace } from '@renderer/workspace/workspaceStore'
+
+type Props = {
+ open: boolean
+ workspace: Workspace
+ onClose: () => void
+}
+
+type Step = 'agent' | 'project'
+
+// Shared empty model so a closed dialog hands the same reference to every
+// render instead of allocating one per workspace change.
+const CLOSED_MODEL: NewAgentInModel = { projects: [], initialTabId: null }
+
+/**
+ * New Agent In… (#852): choose an agent, then the project it starts in.
+ *
+ * WHY a dedicated dialog rather than another mode on NewAgentPlacementOverlay:
+ * that overlay already carries four intents (create, attach, linked, header
+ * project) and treats "a kind has been picked" as "now show grid placement".
+ * A second, project step would have to be one more special case inside that
+ * invariant. The palette was the other candidate, but a two-step choice there
+ * needs the first answer held in store state across palette modes. This file
+ * is the whole feature's UI, and it composes `DialogContent` so Escape, the
+ * focus trap, outside-click and app input ownership come from the one
+ * primitive that owns them (components/ui/README.md).
+ *
+ * WHY no lane choice here: the spawn keeps New Agent…'s lane semantics — the
+ * agent fills the FOCUSED lane (`createDetachedDispatchAgent` keeps the
+ * focus-derived laneIndex even under a project override). The user's workflow
+ * is "focus the empty lane, fill it", so the lane is already chosen.
+ */
+export function NewAgentInDialog({ open, workspace, onClose }: Props) {
+ const dialogRef = useRef(null)
+ // One-shot latch around the spawn. `open` only drops once the parent reacts
+ // to onClose, so a fast second Enter would otherwise start a second agent.
+ // A ref, not state: it must gate the synchronous key handler, not re-render.
+ const committingRef = useRef(false)
+ const [step, setStep] = useState('agent')
+ const [agentIndex, setAgentIndex] = useState(0)
+ // The highlighted project is held by TAB ID, not list index: the model is
+ // live while the dialog is open (an MCP operator can close an agent or a
+ // tab meanwhile), and an index would silently slide onto a different
+ // project when a row above it disappears.
+ const [projectTabId, setProjectTabId] = useState(null)
+
+ // Derived only while open. The surface is always mounted and re-renders on
+ // every workspace change; the model walks every tab's sessions plus the
+ // Dispatch rows, which is waste for a dialog nobody can see.
+ const model = useMemo(
+ () => (open ? buildNewAgentInModel(workspace.state) : CLOSED_MODEL),
+ [open, workspace.state],
+ )
+
+ useEffect(() => {
+ if (open) return
+ // The instance survives between invocations, so every piece of one-shot
+ // state resets. A leftover `project` step or a set latch would make the
+ // next invocation start mid-flow or refuse to spawn at all.
+ //
+ // WHY on CLOSE rather than on open: an effect runs after the render it
+ // belongs to, so resetting on open paints one frame of the previous
+ // invocation's project step first. That frame is invisible when a click or
+ // key opens the dialog, but not when something outside a user event does
+ // (an MCP `commands.run`). Resetting while hidden means the first visible
+ // frame is always the agent step.
+ setStep('agent')
+ setAgentIndex(0)
+ setProjectTabId(null)
+ committingRef.current = false
+ }, [open])
+
+ const choice = AGENT_PROVIDER_CHOICES[agentIndex] ?? null
+ const enabledProjects = model.projects.filter(project => project.enabled)
+ const highlightedProject =
+ enabledProjects.find(project => project.tabId === projectTabId) ?? null
+
+ const chooseAgent = (index: number) => {
+ setAgentIndex(index)
+ setStep('project')
+ // Start on the project plain New Agent… would have used (the model owns
+ // that rule), so accepting both defaults lands in the same project.
+ setProjectTabId(model.initialTabId)
+ }
+
+ const commit = (project: NewAgentInProject) => {
+ if (!choice || !project.enabled || committingRef.current) return
+ committingRef.current = true
+ // Close before the spawn: it awaits an IPC round trip, and the agent is
+ // about to appear in the lane underneath. createDetachedDispatchAgent owns
+ // failure feedback (toasts), exactly as it does for New Agent… and the
+ // project header's "+".
+ onClose()
+ void workspace.createDetachedDispatchAgent(
+ { kind: choice.kind, providerRuntime: choice.providerRuntime },
+ { tabId: project.tabId, anchorSessionId: project.anchorSessionId },
+ )
+ }
+
+ const moveAgent = (delta: -1 | 1) => {
+ setAgentIndex(index =>
+ Math.max(0, Math.min(AGENT_PROVIDER_CHOICES.length - 1, index + delta)),
+ )
+ }
+
+ const moveProject = (delta: -1 | 1) => {
+ // Arrows walk ENABLED projects only. A highlight parked on a disabled row
+ // turns Enter into a silent no-op, which reads as a broken dialog; the
+ // disabled row stays visible so its reason is still readable.
+ if (enabledProjects.length === 0) return
+ const current = enabledProjects.findIndex(project => project.tabId === projectTabId)
+ const next = current < 0
+ ? 0
+ : Math.max(0, Math.min(enabledProjects.length - 1, current + delta))
+ setProjectTabId(enabledProjects[next]!.tabId)
+ }
+
+ return (
+
+ )
+}
diff --git a/src/renderer/src/features/workspace/ui/ProviderSwitchPickerModal.renderer.test.tsx b/src/renderer/src/features/workspace/ui/ProviderSwitchPickerModal.renderer.test.tsx
index 40e552c3e..ff2ff4398 100644
--- a/src/renderer/src/features/workspace/ui/ProviderSwitchPickerModal.renderer.test.tsx
+++ b/src/renderer/src/features/workspace/ui/ProviderSwitchPickerModal.renderer.test.tsx
@@ -67,6 +67,30 @@ describe('ProviderSwitchPickerModal', () => {
)
})
+ it('leaves Enter to a focused Cancel instead of switching to the highlighted provider (#862)', () => {
+ // The list's Enter handler used to preventDefault (killing Cancel's native
+ // click) and then commit the highlighted row, so Tab -> Cancel -> Enter
+ // started a provider switch. `true` = default not prevented, so the real
+ // browser still delivers Cancel's click.
+ const { switchSessionProvider } = harness()
+
+ expect(fireEvent.keyDown(screen.getByRole('button', { name: 'Cancel' }), { key: 'Enter' })).toBe(true)
+
+ expect(switchSessionProvider).not.toHaveBeenCalled()
+ })
+
+ it('never lets keyboard focus rest on a provider row (#862)', () => {
+ // Tab to a row, ArrowDown to move the highlight, Space: the browser clicks
+ // the FOCUSED row, so the switch went to a provider other than the
+ // highlighted one. happy-dom does not synthesize keyboard clicks, so the
+ // pin is the structure that makes the path impossible.
+ harness()
+
+ const rows = document.querySelectorAll('[data-provider-switch-choice]')
+ expect(rows.length).toBeGreaterThan(0)
+ rows.forEach(row => expect(row).toHaveAttribute('tabindex', '-1'))
+ })
+
it('supports keyboard choice and cancellation without starting an implicit switch', () => {
const first = harness()
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'ArrowDown' })
diff --git a/src/renderer/src/features/workspace/ui/ProviderSwitchPickerModal.tsx b/src/renderer/src/features/workspace/ui/ProviderSwitchPickerModal.tsx
index d3b2d4427..038ca3936 100644
--- a/src/renderer/src/features/workspace/ui/ProviderSwitchPickerModal.tsx
+++ b/src/renderer/src/features/workspace/ui/ProviderSwitchPickerModal.tsx
@@ -9,6 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog'
+import { focusedControlOwnsEnter } from '@renderer/components/ui/dialog-actions'
import {
providerChoiceLabel,
providerSwitchChoices,
@@ -103,6 +104,13 @@ export function ProviderSwitchPickerModal({
return
}
if (event.key === 'Enter' && selected) {
+ // A focused button owns its own Enter (focusedControlOwnsEnter —
+ // the rule components/ui/dialog-actions.tsx documents).
+ // preventDefault below also cancels that button's native click, so
+ // without this guard Tab to Cancel + Enter switched the agent to the
+ // highlighted provider instead of cancelling (#862). Rows are not
+ // tab stops (below), so the only focusable buttons are the footer's.
+ if (focusedControlOwnsEnter(event.target)) return
event.preventDefault()
choose(selected)
}
@@ -132,6 +140,13 @@ export function ProviderSwitchPickerModal({