Skip to content

Insert replaced native-tab windows into their old tree slot - #59

Merged
vitorebatista merged 1 commit into
mainfrom
port/native-tab-window-replacement
Aug 27, 2026
Merged

Insert replaced native-tab windows into their old tree slot#59
vitorebatista merged 1 commit into
mainfrom
port/native-tab-window-replacement

Conversation

@vitorebatista

Copy link
Copy Markdown
Owner

Apps that fold native tabs into a single titlebar (Finder, Ghostty, Fork) keep the old tab's AX object alive after you switch tabs, but drop it from AXWindows. AeroSpace went on tracking the stale window and treated the newly focused tab as a brand-new window — so the layout gained a phantom tile and the visible tab was placed by on-window-detected / MRU rather than staying where the tab group was.

This detects that the previously focused window has vanished from the app's live AXWindows list — in getFocusedWindow, and again in the periodic window refresh so the stale tab can't be reported alive and flicker the layout — retires it, and splices the replacement into the exact tree slot the old one occupied: parent, index, weight, floating size, fullscreen state and cached layout geometry.

Ports nikitabobko#2225 (author: @jeajar).

Both checks are gated on !isLeftMouseButtonDown, matching the existing tab-drag-out safeguard, and scoped to the just-focused window only (which is guaranteed to have been on the active Space — a blanket AXWindows/tracked-set intersection would not be).

Fork adaptations (this fork's MacApp diverges from upstream):

  • getFocusedWindow() and runInLoop take no CancellationMode here, and both closures guard on threadGuardedOrNil (the AX-destroy-race hardening from fork PR MacApp: fix AX-object destroy race + wipPids spin in getOrRegister #43) — the new logic is nested inside those guards and reads through the unwrapped local axApp instead of axApp.threadGuarded.
  • debugWindowsIfRecording and MacApp.getAxRect take no CancellationMode.
  • MacWindow.getOrRegister also carries the fork's isAwaitingOnWindowDetected flag and preventNewWindowFlickerIfNeeded; both are preserved. The replacement path deliberately bypasses on-window-detected — a replaced tab is not a newly opened window.

Note: upstream has a competing PR for the same bug (nikitabobko#2206, by @trentkm), which takes an app-bundle-id-list approach. Neither is merged upstream yet; nikitabobko#2225 was chosen as the more general fix.

Tests: the upstream NativeTabWindowReplacementTest (4 cases) applies unchanged and passes — it covers takeNativeTabReplacementBinding: slot/weight preservation in a nested container, nil stale window, already-unbound stale window, and that exactly the given window is unbound. The AX-side detection is runtime Accessibility-API behavior and is not unit-testable here; no fake test was added for it.

./build-debug.sh -Xswiftc -warnings-as-errors ✅ · ./swift-test.sh

Docs: none needed (no command/flag/config change).

Apps that fold native tabs into one titlebar (Finder, Ghostty, Fork) keep the old
tab's AX object alive after you switch tabs, but drop it from AXWindows. AeroSpace
kept tracking the stale window and treated the newly focused tab as a brand new
window, so the layout gained a phantom tile and the visible tab landed wherever
on-window-detected/MRU placement put it.

Detect that the previously focused window has disappeared from the app's live
AXWindows list (in getFocusedWindow, and again in the periodic window refresh so it
can't flicker), retire it, and splice the replacement into the exact tree slot the
old one occupied - parent, index, weight, floating size, fullscreen state and
cached layout geometry.

Ports nikitabobko#2225

Fork adaptations: the fork's `getFocusedWindow()` / `runInLoop` take no
CancellationMode and guard on `threadGuardedOrNil` (AX-destroy-race hardening from
fork PR #43), so the new logic is nested inside those guards and uses the unwrapped
`axApp`; `debugWindowsIfRecording` and `getAxRect` take no CancellationMode here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBcwRe3CktWZGGcHJHP4ds
@vitorebatista
vitorebatista merged commit 3094b8f into main Aug 27, 2026
vitorebatista added a commit that referenced this pull request Aug 27, 2026
…te sync state

Sync state moves to upstream main @ c548c7f / 59 open PRs, reviewed 2026-08-27.
Adds the newly ported upstream PRs (2244, 2232, 2211, 2228, 2225) to the
already-backported list, and records this cycle's skips: focus-follows-mouse PRs
(nikitabobko#2238/nikitabobko#2245, feature absent in the fork), nikitabobko#2220 (duplicate of the already-ported
nikitabobko#2024), nikitabobko#2213 (deletes lastNativeFocusedWindowId, which fork PR #59 depends on),
nikitabobko#2206 (competing implementation of nikitabobko#2225), and six deferred feature PRs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBcwRe3CktWZGGcHJHP4ds
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.

1 participant