Skip to content
128 changes: 128 additions & 0 deletions docs/superpowers/plans/2026-09-12-merge-project-tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Reuse an open project tab on New Tab, and Merge Project Tabs

Status: implemented, tests green, PR #914 open; first orchestrated review (REQUEST CHANGES, B1 modal seeding race) addressed, re-review pending.
PR: [agent-code#914](https://github.com/Juliusolsson05/agent-code/pull/914). Merge requires explicit approval.

Feature Issue: [agent-code#913](https://github.com/Juliusolsson05/agent-code/issues/913).
Branch: `feat/merge-project-tabs`. Worktree: `.worktrees/merge-project-tabs`.
Base: `origin/main` at `c6adfe83` (2026-09-12). This plan is the first commit.

## Outcome

Two related changes that share one rule, "which tabs hold this directory":

1. **Reuse on open.** When the folder typed into the New Tab path picker is
already held by an open tab, the picker says so and its primary action
becomes "go to tab"; "new tab anyway" stays available for the deliberate
second-tab case. A folder nobody holds behaves exactly as today.
2. **Merge Project Tabs.** A command that folds chosen tabs into a target
tab without touching any process: source grid panes and detached agents
become Dispatch agents of the target; buried records and Dispatch row
project filters are re-pointed; tiled-tab, Spotlight and Reader references
to removed tabs are cleared; the emptied tabs are removed. The modal lists
what moves before anything happens.

## Why the duplicates exist

`Tab` carries no directory. The tab title is the basename of the folder chosen
at creation, and the only durable link between a tab and a directory is the
`cwd` of the sessions it holds. The operator capability `projects.open`
already treats "an existing tab holds a session with exactly this cwd" as
"this project is open" and reuses it unless `createDuplicate` is explicit.
The path picker predates that decision and always creates. Window adoption
appends a closed window's tabs without root dedupe, so it can leave a
duplicate too. Three `agent-code` tabs on 2026-09-11 were created on Sept 3,
7 and 10 through the picker.

## Design

### The shared rule

`findTabsHoldingDirectory(state, cwd)` in `workspace/queries.ts`: tabs (in
tab order) for which `resolveTabSessions` yields at least one session whose
`cwd` equals the directory exactly. `projects.open` switches to it; the path
picker surface uses it to decide the "already open" affordance. Exact match
only: a worktree is a different directory on purpose, and the merge command
is the tool for folding worktree tabs together.

### Reuse on open

`PathPickerSurface` passes `openTabsForPath(expandedPath)` into the modal.
The modal already tracks `resolvedPath`; when that path is held by a tab, it
shows "Already open as A · agent-code" and renders "go to tab" as the
primary action (activate, no spawn) with "new tab anyway" as the secondary
(the existing `onAccept`). Nothing changes for resume rows: resuming a past
conversation still lands in a new tab, which is a separate decision recorded
as out of scope below.

### The merge planner

`mergeProjectTabs(state, tileTabs, { targetTabId, sourceTabIds, now })` in
`workspace/mergeProjectTabs.ts`, a pure function returning either a refusal
(`target_is_source`, `unknown_tab`, `nothing_to_merge`) or the next state
plus a summary of what moved:

- `tabs`: sources removed, order otherwise preserved.
- `activeTabId`: re-pointed to the target when it named a source.
- `detachedSessions`: every source grid leaf gains a `DetachedSessionRecord`
under the target (`surface: 'dispatch'`, `detachedAt: now`); records that
already pointed at a source are re-pointed. `projectTabTitle`/`projectTabIndex`
reflect the target in the new tab array.
- `buried`: `sourceTabId` re-pointed with the target's title and index.
- `dispatchMode.tiled.rows[].projectTabIds` (and the legacy `projectTabId`):
sources replaced by the target, deduplicated. Lanes are session-keyed and
untouched.
- `tileTabs`: sources removed, focus moved, then `sanitizeTileTabsState`
(which exits tiled tabs below two).
- Spotlight and Reader state that named a source tab are cleared by the hook
action, because the pane they zoomed is no longer in a grid.

WHY grid panes go to Dispatch rather than into the target's grid: `Tab.root`
must stay a tile tree the user built; attaching every source pane would turn
one tab into a wall of panes, and `buildDispatchGroups` lists detached
sessions under their tab already, so nothing is hidden. The user attaches
what they want afterwards.

WHY there is no undo: no process is killed and no session leaves the
workspace, so the reverse operation is "detach/attach as you like"; an undo
entry would have to snapshot layout state that the undo stack was not built
to hold. The confirmation lists what moves instead.

The invariant the tests pin: `collectOwnedSessionIds` is identical before
and after a merge. A merge that lost a session would have it deleted by the
next autosave.

### Command and modal

- `merge-project-tabs` in `tabCommands.ts`, category `layout-dispatch`,
surface `app`, visible with two or more tabs, panel-state badge like
Reorder Tabs. Opens `mergeProjectTabsOpen` on the UI shell.
- `MergeProjectTabsModal`: target selector (default: the active tab), a
checkbox list of the other tabs labelled `A · title` with the directories
each holds and its session count, sources pre-ticked when they share a
directory with the target, a summary line ("2 tabs, 9 agents move to
Dispatch under E · agent-code"), Cancel / Merge. The surface calls the
hook action `mergeTabs(target, sources)` which applies the planner and
toasts the summary.

## Tests

- `mergeProjectTabs.test.ts` (unit): the three-tab fixture with detached,
buried, tiled Dispatch rows bound to a source, tiled tabs containing a
source, and a spotlight on a source; asserts every re-pointing above, the
ownership invariant, the target grid untouched, and the refusals.
- `queries.test.ts` or a new `findTabsHoldingDirectory` case: exact match,
worktree excluded, tab order.
- `PathPickerModal.renderer.test.tsx`: with a holder, the primary action
activates and does not accept; "new tab anyway" accepts; without a holder
the primary action accepts as before.
- Command catalog baseline: 122 commands, 25 approved additions.
- `tsc -b` on both projects, `npm run test:contract`, touched suites green.

## Out of scope

- Resume flows (path picker rows and the Conversations picker) still create
a new tab; folding a resumed conversation into an existing tab needs the
detached-agent resume path and is a separate change.
- Window adoption dedupe: the merge command is the cure there.
- Attaching merged panes into the target's grid.
2 changes: 2 additions & 0 deletions src/renderer/src/app-state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export type UiShellSlice = UiShellState & {
closeTileTabsModal: () => void
openReorderTabs: () => void
closeReorderTabs: () => void
openMergeProjectTabs: () => void
closeMergeProjectTabs: () => void
openPinAgents: () => void
closePinAgents: () => void
openSettingsPage: () => 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 @@ -18,6 +18,7 @@ export const createUiShellSlice: StateCreator<
tileTabsModalOpen: false,
tileTabsInitialSelectedIds: [],
reorderTabsOpen: false,
mergeProjectTabsOpen: false,
pinAgentsOpen: false,
settingsPageOpen: false,
agentTitlePromptSessionId: null,
Expand Down Expand Up @@ -120,6 +121,10 @@ export const createUiShellSlice: StateCreator<
set({ reorderTabsOpen: true }, false, 'uiShell/openReorderTabs'),
closeReorderTabs: () =>
set({ reorderTabsOpen: false }, false, 'uiShell/closeReorderTabs'),
openMergeProjectTabs: () =>
set({ mergeProjectTabsOpen: true }, false, 'uiShell/openMergeProjectTabs'),
closeMergeProjectTabs: () =>
set({ mergeProjectTabsOpen: false }, false, 'uiShell/closeMergeProjectTabs'),

openPinAgents: () =>
set({ pinAgentsOpen: true }, false, 'uiShell/openPinAgents'),
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/app-state/uiShell/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export type UiShellState = {
* autosave from recording half-finished reorder attempts that the
* user later cancels with Escape. */
reorderTabsOpen: boolean
/** When true, the Merge Project Tabs modal is open (#913). Same rationale
* as reorderTabsOpen: the target/source draft is modal-local; the merge
* lands on WorkspaceState only when the user confirms. */
mergeProjectTabsOpen: boolean
/** When true, the Pin Agents multi-select modal is open.
*
* Same rationale for living on uiShell as reorderTabsOpen above:
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/app/surfaces/registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ReorderTabsSurface } from '@renderer/features/workspace/surfaces/Reorde
import { PinAgentsSurface } from '@renderer/features/dispatch-pin/surfaces/PinAgentsSurface'
import { BuryPanePromptSurface } from '@renderer/features/workspace/surfaces/BuryPanePromptSurface'
import { RootManagementConfirmSurface } from '@renderer/features/workspace/surfaces/RootManagementConfirmSurface'
import { MergeProjectTabsSurface } from '@renderer/features/workspace/surfaces/MergeProjectTabsSurface'
import { CloseConfirmationSurface } from '@renderer/features/workspace/surfaces/CloseConfirmationSurface'
import { ViewPromptsSurface } from '@renderer/features/workspace/surfaces/ViewPromptsSurface'
import { ConversationsSurface } from '@renderer/features/conversations/surfaces/ConversationsSurface'
Expand Down Expand Up @@ -99,6 +100,9 @@ export const modalSurfaces: SurfaceEntry[] = [
// closes the palette first; it must paint over every established modal so
// the warning is never hidden behind the surface it is warning about.
{ id: 'root-management-confirm', Component: RootManagementConfirmSurface },
// Appended per the contract above; opened only from a command that closes
// the palette first (#913).
{ id: 'merge-project-tabs', Component: MergeProjectTabsSurface },
]

/**
Expand Down
28 changes: 15 additions & 13 deletions src/renderer/src/features/command-palette/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import type { CommandDef } from '@renderer/features/command-palette/types'
// Agent Composer (#683), 115 with API Key Vault (#831), 116 with
// Remove Cybersecurity Block (#848), 117 with New Agent In… (#852),
// 119 with TLDR preview and TLDR MCP (#888), 120 with Root Agent Code
// Management (#906), and 121 with Use Global MCP Settings (#904).
// Management (#906), 121 with Use Global MCP Settings (#904), and 122 with
// Merge Project Tabs (#913).
// 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
Expand All @@ -33,12 +34,13 @@ import type { CommandDef } from '@renderer/features/command-palette/types'

/** The exact ordered catalog after governance (was 102 at main @ 670f4c2d). */
const BASELINE_COMMAND_IDS: readonly string[] = [
// tabCommands (6)
// tabCommands (7)
'new-tab',
'close-tab',
'next-tab',
'prev-tab',
'reorder-tabs',
'merge-project-tabs',
'resume-session',
// windowCommands (1)
'new-window',
Expand Down Expand Up @@ -201,12 +203,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 121 governed commands in registration order', () => {
it('contains exactly the 122 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 121 commands', () => {
it('has exactly 122 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.
Expand All @@ -219,11 +221,11 @@ describe('built-in command catalog — baseline characterization', () => {
// Vault (#831) → 116 with Remove Cybersecurity Block (#848) → 117 with
// New Agent In… (#852) → 119 with TLDR preview and TLDR MCP (#888) → 120
// with Root Agent Code Management (#906) → 121 with Use Global MCP
// Settings (#904).
// Settings (#904) → 122 with Merge Project Tabs (#913).
// 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(121)
expect(builtInCommandCatalog).toHaveLength(122)
})

it('reports no structural defects', () => {
Expand Down Expand Up @@ -257,12 +259,12 @@ describe('generated per-provider split commands', () => {
})

it('accounts for the difference between literal and total command count', () => {
// 121 total - 4 generated = 117 literal `id:` fields across the command
// 122 total - 4 generated = 118 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, New Window, and the later single additions recorded in
// the count test above (through Root Agent Code Management, #906).
expect(builtInCommandCatalog.length - nonDefaultProviders.length * 2).toBe(117)
// the count test above (through Merge Project Tabs, #913).
expect(builtInCommandCatalog.length - nonDefaultProviders.length * 2).toBe(118)
})

it('emits both directions for every non-default provider', () => {
Expand Down Expand Up @@ -361,7 +363,7 @@ describe('governance targets', () => {
})

it('lands on the arithmetic the plan predicted', () => {
// 102 baseline - 5 retirements + 23 additions = 120, checked against the
// 102 baseline - 5 retirements + 25 additions = 122, checked against the
// real catalog rather than trusted as prose.
//
// The subtracted term is the count of APPROVED ADDITIONS and the expected
Expand All @@ -383,9 +385,9 @@ describe('governance targets', () => {
// `remove-cybersecurity-block` (#848), `new-agent-in` (#852),
// `tldr-preview` and `enable-tldr-mcp` (#888),
// `enable-root-agent-code-management` (#906), and
// `use-global-mcp-settings` (#904).
expect(builtInCommandCatalog.length + RETIRED_COMMAND_IDS.length - 24).toBe(102)
expect(builtInCommandCatalog).toHaveLength(121)
// `use-global-mcp-settings` (#904), `merge-project-tabs` (#913).
expect(builtInCommandCatalog.length + RETIRED_COMMAND_IDS.length - 25).toBe(102)
expect(builtInCommandCatalog).toHaveLength(122)
})
})

Expand Down
5 changes: 5 additions & 0 deletions src/renderer/src/features/command-palette/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ export type CommandContext = {
openNewTabPicker: () => void
openTileTabs: () => void
openReorderTabs: () => void
/** Open the Merge Project Tabs modal (#913); the modal performs the merge. */
openMergeProjectTabs: () => void
openSettings: () => void
/** Open the command palette. Exists so ⌘⇧P has a command to name instead
* of a hard-coded callback that nothing could rebind or collision-check. */
Expand Down Expand Up @@ -285,6 +287,7 @@ export type CommandContext = {
closeBulkProviderSwitch: () => void
closeConversations: () => void
closeReorderTabs: () => void
closeMergeProjectTabs: () => void
closePinAgents: () => void
closePathPicker: () => void
closePalette: () => void
Expand Down Expand Up @@ -337,6 +340,8 @@ export type CommandContext = {
remotePanelOpen: boolean
/** The Reorder Tabs modal is on screen. */
reorderTabsOpen: boolean
/** The Merge Project Tabs modal is on screen. */
mergeProjectTabsOpen: boolean
/** The Pin Agents modal is on screen. */
pinAgentsOpen: boolean
/** The path picker (New Tab / Resume) is on screen. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ function OpenCommandPalette({
)
}, [openTileTabsModal, workspace.activeTab, workspace.tileTabs])
const onReorderTabsRequest = useAppStore(state => state.openReorderTabs)
const openMergeProjectTabs = useAppStore(state => state.openMergeProjectTabs)
const onSettingsRequest = useAppStore(state => state.openSettingsPage)
const openPaletteAction = useAppStore(state => state.openCommandPalette)
const openViewPrompts = useAppStore(state => state.openViewPrompts)
Expand All @@ -287,6 +288,7 @@ function OpenCommandPalette({
const closeBulkProviderSwitch = useAppStore(state => state.closeBulkProviderSwitch)
const closeConversations = useAppStore(state => state.closeConversations)
const closeReorderTabs = useAppStore(state => state.closeReorderTabs)
const closeMergeProjectTabs = useAppStore(state => state.closeMergeProjectTabs)
const closePinAgents = useAppStore(state => state.closePinAgents)
const closePathPicker = useAppStore(state => state.closePathPicker)
const openUsageModal = useAppStore(state => state.openUsageModal)
Expand Down Expand Up @@ -356,6 +358,7 @@ function OpenCommandPalette({
const conversationsOpen = useAppStore(state => state.conversationsOpen)
const remotePanelOpen = useAppStore(state => state.remotePanelOpen)
const reorderTabsOpen = useAppStore(state => state.reorderTabsOpen)
const mergeProjectTabsOpen = useAppStore(state => state.mergeProjectTabsOpen)
const pinAgentsOpen = useAppStore(state => state.pinAgentsOpen)
const pathPickerOpen = useAppStore(state => state.pathPickerOpen)
const gitBarOpen = useAppStore(state => state.gitBarOpen)
Expand Down Expand Up @@ -568,6 +571,7 @@ function OpenCommandPalette({
openNewTabPicker: onNewTabRequest,
openTileTabs: onTileTabsRequest,
openReorderTabs: onReorderTabsRequest,
openMergeProjectTabs,
openSettings: onSettingsRequest,
// Reachable through the gateway (keybinding, native menu, programmatic)
// but never rendered as a palette row — see
Expand All @@ -593,6 +597,7 @@ function OpenCommandPalette({
closeBulkProviderSwitch,
closeConversations,
closeReorderTabs,
closeMergeProjectTabs,
closePinAgents,
closePathPicker,
openUsageModal,
Expand Down Expand Up @@ -650,6 +655,7 @@ function OpenCommandPalette({
conversationsOpen,
remotePanelOpen,
reorderTabsOpen,
mergeProjectTabsOpen,
pinAgentsOpen,
pathPickerOpen,
gitBarOpen,
Expand Down Expand Up @@ -685,6 +691,7 @@ function OpenCommandPalette({
onNewTabRequest,
onTileTabsRequest,
onReorderTabsRequest,
openMergeProjectTabs,
onSettingsRequest,
openViewPrompts,
openConversations,
Expand All @@ -703,6 +710,7 @@ function OpenCommandPalette({
closeBulkProviderSwitch,
closeConversations,
closeReorderTabs,
closeMergeProjectTabs,
closePinAgents,
closePathPicker,
openUsageModal,
Expand Down Expand Up @@ -758,6 +766,7 @@ function OpenCommandPalette({
conversationsOpen,
remotePanelOpen,
reorderTabsOpen,
mergeProjectTabsOpen,
pinAgentsOpen,
pathPickerOpen,
gitBarOpen,
Expand Down
Loading