Repository navigation
feat(workspace): reuse an open project tab on New Tab and add Merge Project Tabs - #914
Conversation
…Tabs Refs #913 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016vtjk6o4nukQp7Vfbt5TDc
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
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
|
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 Verified by the reviewer at HEAD on Node 24: |
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
Problem
One repository ended up as three
agent-codeproject 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.openhas 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
projects.opennow 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.openkeeps refusing withambiguous_ownerbecause an operator has no current tab to prefer.Merge Project Tabs (new command, advanced use of the tab bar)
Design decisions
closeTabkills what it owns anddetachSessionrefuses 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.Tab.rootis a tile tree the user built; attaching every source pane would make one tab a wall of splits, andbuildDispatchGroupsalready lists detached sessions under their tab.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.collectOwnedSessionIdsmust 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-propertyhasSessionMetatest 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
e5f85358except 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.tsc -bclean on both projects under Node 24;npm run test:contractsatisfied; touched unit and renderer suites green.Known limitations and follow-ups
projects.open.🤖 Generated with Claude Code
https://claude.ai/code/session_016vtjk6o4nukQp7Vfbt5TDc