Skip to content

feat(workspace): reuse an open project tab on New Tab and add Merge Project Tabs - #914

Merged
Juliusolsson05 merged 7 commits into
mainfrom
feat/merge-project-tabs
Sep 12, 2026
Merged

Juliusolsson05 merged 7 commits into
mainfrom
feat/merge-project-tabs

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Sep 12, 2026 •

Copy link
Copy Markdown
Owner

Problem

One repository ended up as three agent-code project tabs (created Sept 3, 7 and 10), each with its own generation of agents and worktree agents spread across them. Dispatch, its row filters and the bulk modals then show three projects for one repo, and every "everyone in this project" action has to be repeated per tab.

Two causes. The New Tab path picker never dedupes: ⌘T creates a fresh tab for a folder that is already open, while the operator's projects.open has reused an existing tab for the exact directory since it shipped. And once duplicates exist, nothing can fold them back together without closing agents.

Implemented behavior

Reuse on open

  • When the typed folder is already held by an open tab, the picker shows "Already open as E · agent-code" and Enter / the primary "go to tab" activate that tab without spawning. When the holder is the tab the user is standing in (the folder ⌘T pre-fills), the hint reads "Already open in this tab (G · agent-code)", the primary reads "stay here" and Enter only closes the picker; it never jumps to an earlier tab that also holds the folder.
  • "new tab anyway" and Shift+Enter keep the deliberate second-tab case. Every footer button forces the action its label names, so a click that lands before the debounced hint has caught up cannot switch tabs under a "new session" label; Enter alone decides at submit time.
  • A folder nobody holds behaves exactly as before. Resume rows are unchanged.
  • The picker and projects.open now share one rule, findTabsHoldingDirectory: a tab holds a directory when one of its sessions runs there. Exact match; a worktree is a different directory on purpose. With several holders the picker prefers the current tab, then the first in tab order; projects.open keeps refusing with ambiguous_owner because an operator has no current tab to prefer.

Merge Project Tabs (new command, advanced use of the tab bar)

  • Pick the tab to keep and the tabs to fold into it. Tabs that share a working directory with the target are pre-ticked; worktree tabs are a deliberate tick. The dialog's draft mounts with the dialog content, so it always starts from the live active tab (the surface registry mounts every modal before the workspace loads).
  • Source grid panes and detached agents become Dispatch agents of the target; buried records and Dispatch row project filters are re-pointed; the tab-index snapshots of the surviving tabs' records are refreshed (removing tabs shifts every later letter); a tiled source hands its slot to the target when the target was not tiled, so the kept tab stays on screen; Spotlight and Reader references to removed tabs are cleared; the emptied tabs are removed; the active tab follows.
  • No session is killed, reloaded or re-spawned. The dialog states what moves ("2 tabs, 9 agents move to E · agent-code") before anything happens, and the toast repeats it. Moved grid panes become Dispatch agents, so after the next launch they wake on first use instead of being live from the start; the command description says so.

Design decisions

  • A pure planner, not a sequence of existing actions. closeTab kills what it owns and detachSession refuses a tab's last grid pane, so neither could express a merge. mergeProjectTabs(state, input) does the whole re-pointing in one function; the hook plans inside the store updater against the live state and gates every side effect (tiled tabs, Spotlight, Reader, toast) on the applied result, so a refused merge changes nothing and never reports success.
  • Grid panes go to Dispatch, not into the target's grid. Tab.root is a tile tree the user built; attaching every source pane would make one tab a wall of splits, and buildDispatchGroups already lists detached sessions under their tab.
  • No undo. Undo-close exists to re-spawn sessions that were killed from a captured SessionMeta. A merge kills nothing, so there is nothing to re-spawn, and reversing it would mean rebuilding the removed tabs' tile trees from a snapshot while the sessions inside have kept running and may since have been attached, buried or closed. Every moved agent stays reachable in the target's Dispatch list, so the manual reverse (new tab, attach) is always available and never lossy.
  • Owned-session invariant. collectOwnedSessionIds must be identical before and after a merge; a session that lost its owner would be deleted by the next autosave. This is the load-bearing test. The planner uses the same own-property hasSessionMeta test as ownership, and a pane that already had a detached record (a pre-existing violation) is counted once.

Review

Orchestrated Claude review, two rounds. Round one: REQUEST CHANGES, one blocking finding (the dialog's seeding effect raced its prune on the first open per app run and kept the wrong tab) plus ten non-blocking findings; all addressed in e5f85358 except the narrow close-tab race, which is noted only. Round two: APPROVE WITH COMMENTS; the remaining comments (PR body, "stay here" clearing Spotlight, a buried-record test gap) are addressed in the final commit.

Linked issues

Fixes #913

Tests and verification

  • mergeProjectTabs.test.ts (new): the three-tab fixture with detached, buried, tiled Dispatch rows bound to sources (including the legacy single binding), lanes, and pins; asserts every re-pointing, the refreshed indexes of the surviving target's own records, the ownership invariant, the untouched target tree, the summary, the refusals (target among sources, unknown tab, empty selection), a pane with no metadata skipped and a pane with a pre-existing detached record counted once. Tiled-tab retargeting incl. slot substitution for an untiled target and exit below two tabs.
  • MergeProjectTabsModal.renderer.test.tsx (new): mounts before the workspace loads and then opens (the review's reproduction), keep-change re-seeds the ticks, a closed tab leaves the draft and a fallback target is never left ticked.
  • queries.directory.test.ts (new): exact match through grid and detached sessions in tab order; worktrees excluded.
  • PathPickerModal.renderer.test.tsx: Enter on the input goes to the holder; the current tab is preferred over an earlier holder; Shift+Enter opens a new tab; a button does what its label says before the hint has caught up; no holder creates as before.
  • Command catalog baseline: 122 commands, 25 approved additions (after merging main, which added Use Global MCP Settings).
  • tsc -b clean on both projects under Node 24; npm run test:contract satisfied; touched unit and renderer suites green.

Known limitations and follow-ups

  • Resume flows (picker rows and the Conversations picker) still open a new tab; folding a resumed conversation into an existing tab needs the detached-agent resume path.
  • Window adoption still appends a closed window's tabs without root dedupe; the merge command is the cure there.
  • A merge into a tab whose close is mid-flight (between its kill list and its removal) files the moved sessions under a tab that is about to vanish; the window is a few milliseconds and detach/create into a closing tab already have the same shape.
  • Path matching compares the stored cwd string; symlinked spellings of one folder are different directories here, as they always were for projects.open.
  • Not exercised in the running app; verified from source and tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_016vtjk6o4nukQp7Vfbt5TDc

Juliusolsson05 and others added 7 commits September 12, 2026 10:51
A Tab carries no directory; the only durable link to a folder is the cwd of
the sessions it holds, and the operator's projects.open has treated "a tab
holds a session with exactly this cwd" as "already open" since it shipped.
The keyboard path predated that rule and minted a fresh tab every time, which
is how one repository ended up as three tabs. The picker now shares the rule:
Enter and the primary action go to the holding tab, "new tab anyway" keeps
the deliberate second-tab case, and the decision is made from the path about
to be opened rather than the debounced hint.

Refs #913

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vtjk6o4nukQp7Vfbt5TDc
Folds chosen tabs into a target without touching any process. closeTab kills
what it owns and detach refuses a tab's last pane, so neither could express
this; a pure planner re-points detached, buried and Dispatch row references,
turns source grid panes into Dispatch agents of the target, removes the
emptied tabs, and the hook applies it through functional updates with
tiled-tab, Spotlight and Reader references cleared. The invariant the tests
pin is that the owned-session set is identical before and after, because a
session that lost its owner would be deleted by the next autosave.

Refs #913

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vtjk6o4nukQp7Vfbt5TDc
…refer the current tab in the picker

Addresses the first orchestrated review of #914 (REQUEST CHANGES).

Blocking B1: the modal draft was seeded by an effect on open that raced the
"tab closed while open" prune in the same commit; on the first open per app
run (surfaces mount before the workspace loads, active id '') the prune won
with the first tab, so the dialog kept tab A with A still ticked, or kept an
unrelated first tab with the active tab's duplicates ticked. The draft now
lives in a child mounted inside the dialog content, so it initialises from
the live active tab on every open and needs no seeding effect; the prune is
one functional update over the whole draft so a fallback target can never
stay ticked. New renderer test mounts before the workspace loads.

Non-blocking findings folded in:
- picker prefers the current tab among holders (Enter no longer jumps from
  G to B), says "stay here" when the holder is the current tab, Shift+Enter
  is the keyboard path for "new tab anyway", buttons force the action their
  label names so a click before the debounce cannot switch tabs under a
  "new session" label; the picker test now presses Enter for real
- mergeTabs plans inside the updater and gates every side effect on the
  applied result instead of toasting success for a refused merge
- planner refreshes projectTabIndex/sourceTabIndex of surviving records
  (indices shift when tabs are removed), uses the ownership hasSessionMeta
  test, counts a pane that already had a detached record once
- a tiled source hands its slot to an untiled target so the kept tab stays
  on screen
- WHY comments: no undo, hibernation after restart, what is deliberately
  left alone; command description states the restart consequence

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vtjk6o4nukQp7Vfbt5TDc
Only the command-catalog characterization test conflicted: main added Use
Global MCP Settings (#904) while this branch added Merge Project Tabs
(#913), so the baseline is now 122 commands, 118 literal ids, 25 additions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vtjk6o4nukQp7Vfbt5TDc
…refresh

Re-review of #914 (APPROVE WITH COMMENTS) follow-ups: the picker surface no
longer activates the tab the user is already in, because activateTab clears
Spotlight and "stay here" must not leave it; the planner test now carries a
buried record of the surviving target and asserts its letter moves too; the
plan doc's catalog count matches the merged baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vtjk6o4nukQp7Vfbt5TDc
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Orchestrated Claude review, two rounds.

Round one: REQUEST CHANGES. Blocking: the Merge Project Tabs dialog's seeding effect raced its "tab closed while open" prune on the first open per app run (surfaces mount before the workspace loads), so it kept the first tab instead of the active one and could leave that tab ticked as a source. Ten non-blocking findings: Enter in the picker could jump to an earlier holder or do nothing, a footer button could act against its label before the debounce settled, merge side effects were not gated on the applied plan, stale tab-index snapshots and tiled focus after a merge, ownership edge cases, missing WHY comments, an untested Enter path, symlink matching, and a narrow close-tab race.

Addressed in e5f85358 (all but the close race, noted in the PR body). Round two: APPROVE WITH COMMENTS. Remaining comments (PR body drift, "stay here" clearing Spotlight, a buried-record test gap) are addressed in the final commit; Shift+Enter as "new tab anyway" is noted as a product choice the hint text advertises.

Verified by the reviewer at HEAD on Node 24: tsc on both projects, the touched unit and renderer suites, and the test contract. Not exercised in the running app.

@Juliusolsson05
Juliusolsson05 merged commit 552914f into main Sep 12, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the feat/merge-project-tabs branch September 12, 2026 19:19
Juliusolsson05 added a commit that referenced this pull request Sep 13, 2026
Every restore respawns under a new session id, and a restored tab gets a
new tab id, but entries still waiting on the stack were captured against
the old ids. Close A (B promoted), close B (tab removed), undo, undo lost
A: its entry named tab T and survivor B, both recreated as T' and B' by
the first undo, so the second judged it stale. With a third agent, A came
back as a trailing row instead of the root.

Successful restores now publish old->new ids to the remaining entries
(UndoCloseStack.remapLineage), rewriting only anchors: sibling leaf,
project tab, promoted survivor and relationship pointers. Tab restores
also re-point restored linked children at their restored parent. Merge
publishes no lineage, so an entry anchored on a merged-away tab stays
stale and is never resurrected (#914).

A promoted survivor's record now refreshes its display title and ordinal
on restore, and the stale "render-body stateRef" comments are rewritten
as history now that #886 made that ref synchronous. Regressions land
with the close executor, whose flows they drive.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(workspace): reuse an open project tab on New Tab and add Merge Project Tabs

1 participant