fix(tabs): reopen a closed tab into the window already showing its connection - #2829
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Close one of several tabs, press Cmd+Shift+T, and nothing comes back. The closed tab is also gone from File > Reopen Closed Tab, so it cannot be tried again. This happened whenever the connection's window was already showing it, which is the ordinary case.
Root cause
RecentlyClosedTabReopener.reopen(id:)removed the history entry first (consume(id:)) and only then tried to open the tab. For a connection a window already hosts, it registered the tab inRestorationGroupRegistryand sent a.restoreOrDefaultpayload throughWindowManager.openTab. That path hands the payload to the existing workspace'sopen(_:), andEditorTabOpener.applydoes nothing for.restoreOrDefault. Only a newly builtMainContentViewever read the registry, so the group expired after 10 seconds unread. A hosted workspace that was still connecting queued the same payload and later drained it into the same no-op.The empty-window shortcut (
emptyWindowCoordinator) searched the app-wide coordinator registry instead of the window's workspaces and never selected the workspace it found, so a background connection with no tabs got the tab without being shown.Fix
Reopening is now a workspace operation, and the history entry is removed only once the tab is in a tab list.
RecentlyClosedTabStore:consume(id:)is split intorestorableEntry(id:), which folds the overflow text back in and removes nothing, anddiscard(id:).MainContentCoordinator.adoptRestoredTab(_:)(new+ReopenClosedTabextension) adopts the tab and restores hidden columns and filters the way a restored session does, then loads it.ConnectionWorkspace.adoptRestoredTab(_:isStillClosed:onAdopted:)applies the tab when the workspace has a session state, and otherwise queues it fordrainPendingPayloads. Payloads and restored tabs share one queue, so they land in order.isStillClosedis asked right before adopting, so a queued tab that was reopened somewhere else while it waited is not opened twice.WindowManager.reopen(_:connectionId:isStillClosed:onAdopted:)selects the hosting workspace before adopting, so the tab lands in the pane on screen, then brings the window to the front. A connection no workspace hosts gets a seededSessionStateunder.openContent, the wayopenTabInNewWindowdoes it, andonAdoptedfires only when that state actually reached a workspace. A state that reached none leaves the coordinator registry, because its coordinator is registered eagerly and the aggregated save would otherwise write its tab into the saved set, and the app's activation role is recounted since no window appeared.TabRouter.reopenClosedTabconnects first and then goes through the same path.emptyWindowCoordinator,openWindowTab,RestorationGroupRegistry, and its branch inMainContentView+Setup.handleRestoreOrDefault, along with theconsumeDeferredWhenKeyparameter only that branch set.RestoreLoadTimingmoved into that file as a private type.Tests
MainSplitViewControllerPaneSynchronizationTests(the harness usinginjectSessionandrefreshFromActiveSessions):onAdoptedonce.onAdoptedfired exactly once.RecentlyClosedTabStoreTests:restorableEntry(id:)returns the full query of an entry over 500,000 UTF-16 units and leaves the entry and its overflow file in place;discard(id:)removes both, and a reloaded store is empty. The existingconsumecases now use the new API.RecentlyClosedTabReopenerTests(new, temp-dir store, stub adoption): the entry remains whileonAdoptedhas not been called, is gone after it, andisStillClosedfollows the history.ReopenClosedTabUITests(new): on the bundled SQLite sample, open Album and Artist, close Artist with Cmd+W, press Cmd+Shift+T, and expect the Artist tab back and selected.Injected test sessions set
status = .connectedexplicitly.Verification
All through
.claude/skills/fix-issue/scripts/verify.sh:generate: PASSbuild TablePro: PASStest: PASS, 147 executed, 147 passed, 0 failed, acrossRecentlyClosedTabStoreTests,RecentlyClosedTabReopenerTests,MainSplitViewControllerPaneSynchronizationTestsand every suite that references a changed type (AppLaunchCoordinatorTests,ConnectionWindowChromeTests,ConnectionWorkspaceContainersTests,ConnectionWorkspaceRegistryTests,EditorTabOpenerTests,LaunchIntentFailureOwnershipTests,WindowTabGroupingTests,ConnectionFailureClassifierTests,MainContentCoordinatorTabSwitchTests,TabCloseProtectionTests). All 9 new cases are in the log as passed. The same suites passed again at77815959f, 147 of 147. At the final commitffe4b7f4d, two runs hung before the test runner connected (0 cases, inconclusive, not counted); aftertestmanagerdwas restarted the same 13 suites passed, 147 of 147, with all 9 new cases executed.linton every changed file, tests included: PASS, 0 violations, and again onWindowManager.swiftafter the follow-up commit.uitest ReopenClosedTabUITests: did not run locally. The UI test target compiled and the runner launched, then failed before any case with "Timed out while enabling automation mode", 0 cases executed. On this machine that means macOS is holding an unanswered "Enable UI Automation" password prompt, which only the user can dismiss. It was tried once and not retried. CI is the first real run of this suite.Reviewed in two rounds with the
code-reviewskill (Codex is unavailable until 2026-09-19). Round 2 found that the round 1 follow-up'steardown()released a schema provider hold a never-activated coordinator does not own, and that both early returns in the seeded path left the app promoted to the foreground with no window. Both are fixed in the last commit: the failure branch now only removes the coordinator from the registry, as an expired pending entry does, and both returns recount the activation role, asopenInNewWindowdoes.Found while investigating #2820
MainContentView.initializeAndRestoreTabsrunshandleRestoreOrDefaultfor a.restoreOrDefaultor nil payload, andapplyRestoredGroupassignstabManager.tabsoutright. On main this already drops queued payloads, for exampleTabRouter.openTableinto a hosted workspace that is still connecting. It also reaches a reopened tab, see review finding 1 below.host(for:)prefers the frontmost owner and otherwise takes the first one, so a reopened tab can land in the window that is not in front.reopenthen brings that window forward.Found in review, not fixed here
https://claude.ai/code/session_01SNC28GzXZvLwbgAB8G583A