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( + + + Close agents + + + , + ) + 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 ( + { + if (!nextOpen) onClose() + }} + > + { + // Focus the surface itself, not the first row: the key handler lives + // here, and a focused row button would also turn Enter into a native + // click on whichever row happened to be first. + // + // No focus management is needed across steps. Clicking a row focuses + // that row's button and the step change unmounts it; Radix's + // FocusScope (inside DialogContent) moves focus back to this + // container when the focused node is removed, so the key handler + // keeps listening. Focus is the primitive's job + // (components/ui/README.md), and an extra effect here duplicated it. + event.preventDefault() + dialogRef.current?.focus() + }} + onKeyDown={event => { + const down = event.key === 'ArrowDown' || (event.ctrlKey && event.key === 'n') + const up = event.key === 'ArrowUp' || (event.ctrlKey && event.key === 'p') + if (down || up) { + event.preventDefault() + if (step === 'agent') moveAgent(down ? 1 : -1) + else moveProject(down ? 1 : -1) + return + } + if (event.key === 'Enter') { + // A FOCUSED BUTTON OWNS ITS OWN ENTER (focusedControlOwnsEnter, the + // rule components/ui/dialog-actions.tsx writes down). Everything + // below calls preventDefault, which also cancels that button's + // native Enter-click, so without this guard Tab to Cancel + Enter + // committed the highlighted project and SPAWNED an agent (#862 is + // the same bug in ProviderSwitchPickerModal, whose pattern this + // copied). Because list rows are not tab stops (see the rows below), + // the only buttons that can hold keyboard focus are the footer's — + // so "any focused button" and "a footer button" are the same set. + if (focusedControlOwnsEnter(event.target)) return + event.preventDefault() + // Held Enter auto-repeats. Without this one long press would pick + // the agent and then commit the default project, starting a real + // agent process the user never chose a project for. + if (event.repeat) return + if (step === 'agent') chooseAgent(agentIndex) + else if (highlightedProject) commit(highlightedProject) + return + } + if (event.key === 'Backspace' && step === 'project') { + // Back, not cancel: picking the wrong agent should cost one key, + // not the whole flow. The dialog has no text input, so Backspace + // has nothing else it could mean here. + event.preventDefault() + setStep('agent') + } + }} + className="w-[500px] max-w-[calc(100vw-64px)]" + > + + New Agent In + + {step === 'agent' || !choice + ? 'Choose the agent, then the project it starts in.' + : `Choose the project for the new ${choice.label} agent.`} + + + + {/* + ROWS ARE BUTTONS BUT NOT TAB STOPS (tabIndex -1 on both steps). + Buttons, so a mouse click works and a disabled row is really + disabled. Not tab stops, because keyboard selection here is the + arrow-driven HIGHLIGHT, and a Tab-focused row splits it in two: the + arrows move the highlight while DOM focus stays behind, and Space — + which the browser delivers as a click on the FOCUSED button, with no + way for an Enter handler to intercept it — then created the agent in + a project other than the highlighted one (and picked a different + agent on step one). With rows out of the tab order, keyboard focus is + only ever on the dialog surface (arrows + Enter act on the highlight) + or on the footer buttons (which own their own Enter and Space). A row + can still take focus from a mouse click, but that click immediately + advances the step or commits, so the split can never persist. + */} +
+ {step === 'agent' ? ( + AGENT_PROVIDER_CHOICES.map((option, index) => { + const focused = index === agentIndex + return ( + + ) + }) + ) : model.projects.length === 0 ? ( + // The command needs an active tab, so an empty list can only mean + // the focused row is bound solely to projects that have since been + // closed (row bindings outlive their tab until #863 is fixed). + // Saying "no projects are open" would be false and leave the user + // stuck; name the control that fixes it instead. +
+ None of this row’s projects are open. Change them with Row Projects… +
+ ) : ( + model.projects.map(project => { + const focused = project.enabled && project.tabId === highlightedProject?.tabId + return ( + + ) + }) + )} +
+ + + + {step === 'agent' + ? '↑↓ choose · Enter next · Esc cancel' + : '↑↓ choose · Enter create · ⌫ back · Esc cancel'} + + {/* Ghost, small: the house footer shape (DialogActions renders Cancel + this way). Neither button is a primary action — the rows are. */} +
+ {step === 'project' ? ( + + ) : null} + +
+
+
+
+ ) +} 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({