src/switcher/state/TabGroupResolverSpecs.md
TabGroupResolver holds the pure decisions behind macOS OS-tab detection — deciding which windows are
inactive tabs of one tabbed window, given the facts AltTab can read. macOS exposes no public API that maps
an inactive tab to its window: _AXUIElementGetWindow on an AXTabButton returns the parent window's WID,
not the tab's own, so tabs must be matched to windows by title (full investigation:
TabbedWindowDetection.swift). Inactive tabs also appear in no CGS list, so the WindowServer-driven
discovery never sees them until the user focuses one. Extracted as a pure kernel from TabGroup (which
keeps the Windows.list reads/writes and the AX/CGS side effects) so the brittle matching is unit-testable
without the Window graph. Operates on the flat TabWindow record — the tab analogue of WindowState,
carrying the pid / wid / size / position / tabbedSiblingWids that grouping needs and WindowState
omits.
Two independent signals locate tabs, used at different times:
matchSiblings) — authoritative but only available for the active tab (an inactive tab
reports no AXTabGroup), read at discovery and on each show. Matches by title, so it's fragile when titles
are dynamic (Terminal renames tabs by cwd/command) or drift between the tab-button title and the window's
own title.geometryGroups) — reactive, AX-free: within one app, windows sharing an exact size where
one holds a Space and the others are Space-less are a tabbed window and its background tabs. Catches the
tab switch that AX missed (the "pop-in"), and fullscreen tabs that expose no readable AXTabGroup.geometryGroups(windows) -> [GeometryGroup] — group same-app, same-size (not full-frame: a
background tab's position goes stale while ordered out) candidates where exactly the visible tab holds a
Space and ≥ 1 sibling is Space-less. Minimized / size-less windows are excluded. A separate real window
is never Space-less, so two visible same-size windows are not collapsed. Output sorted by visibleWid.matchSiblings(active, axTitles, sameAppWindows) -> SiblingMatch — resolve the active tab's AXTabGroup
titles to tracked windows. The active title is removed once (duplicates allowed); each remaining title
matches the first compatible, not-yet-matched same-app window. Returns the group's wids (active first),
the matched wids, untrackedTitles (titles with no window → inactive tabs to discover), and toUntabWids
(former group members that fell out and must have stale tab state cleared).positionsCompatible(a, b) -> Bool — tabs share their parent's frame. An existing tab link wins (a
stale position can't split an already-grouped pair). Unknown position or either fullscreen → title-only
fallback (true). Otherwise within 50px on both axes.dissolution(siblingWids, leaving, presentWids) -> GroupDissolution — when a member leaves (destroyed,
or active tab gone standalone), ≤ 1 still-tracked survivor ⇒ dissolve (a single window can't be a tab
group); otherwise shrink the survivors' group to the remaining wids.Mirrors TabGroupResolverTests.swift 1:1. Helpers build an all-default TabWindow and flip only the knobs
each test exercises.
siblingWids = [active, lwouis].untrackedTitles (to brute-force
discover), not matched.tabbedSiblingWids set whose title is
no longer among the AX titles → in toUntabWids.toUntabWids (only windows carrying stale tab state are cleared).tabbedSiblingWids. Title equality fails, so the sibling is not matched, lands in
toUntabWids (un-grouped → shown as a separate window), and "A" is reported untracked (→ re-discovery).
Pins today's behavior; the stability fix is expected to flip this.b already linked to a (its tabbedSiblingWids contains a.wid)
→ compatible even with far-apart positions.