Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions docs/superpowers/plans/2026-09-10-new-agent-in-project.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions src/renderer/src/app-state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/src/app-state/uiShell/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const createUiShellSlice: StateCreator<
viewPromptsSessionId: null,
newAgentPlacementOpen: false,
newAgentProjectIntent: null,
newAgentInOpen: false,
tiledDispatchPromptOpen: false,
dispatchRowProjectPickerRow: null,
dispatchAttachIntent: null,
Expand Down Expand Up @@ -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: () =>
Expand Down
18 changes: 18 additions & 0 deletions src/renderer/src/app-state/uiShell/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/src/app/surfaces/registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 },
]

/**
Expand Down
57 changes: 57 additions & 0 deletions src/renderer/src/components/ui/dialog-actions.renderer.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Dialog open>
<DialogContent>
<DialogTitle>Close agents</DialogTitle>
<DialogActions confirmLabel="Close 3 Agents" onConfirm={onConfirm} onCancel={onCancel} />
</DialogContent>
</Dialog>,
)
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)
})
})
43 changes: 32 additions & 11 deletions src/renderer/src/components/ui/dialog-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
Loading