# Tangrid 1.3.4-beta: Auto Flow tabbed layout becomes unstable after external display connect/disconnect/rotation

## Summary

Tangrid appears to lose or misapply Flow workspace state when an external display is connected, disconnected, or rotated. I normally use Auto Flow mode with "Prefer tabbed layout" enabled. After display changes, tab strips can show incorrectly and windows can be restored or arranged with the wrong geometry. In some cases, newly opened windows still appear as split layout even though the preference is set to prefer tabbed layout.

This looks like a race or state reconciliation issue around `ScreenVirtualWorkspaceAdaptar`, `ScreenSpaceStateObserver`, and `FlowVirtualWorkspace-v2`, especially while display readiness is false and while screen frames are changing from landscape to portrait and back.

## Environment

- Tangrid: `1.3.4-beta`, build `1.3.4`
- macOS: `27.0`, build `26A5368g`
- Machine: Apple Silicon MacBook Pro
- Displays:
  - Built-in Liquid Retina XDR display
  - External display named `Monitor`, WQUXGA, looks like `1920 x 1200 @ 120Hz`, rotation supported
- Preference confirmed in persisted settings:
  - `windowDefaultStrategy = "Tiling"`
  - `tiling.prioritizeFlowLayout = "preferTabbed"`
  - `tiling.singleFlowWindowFloating = false`

## Expected Behavior

When Auto Flow mode is active and `prioritizeFlowLayout` is `preferTabbed`:

1. Display connect/disconnect/rotation should not detach or lose the active Flow workspace in a way that changes layout semantics.
2. Existing tab groups should remain visually attached to the correct screen/space/window group after display geometry stabilizes.
3. New windows should prefer joining the tabbed Flow layout rather than opening as a split layout unless the user explicitly selects split.
4. After rotation, Tangrid should not apply a layout using a stale screen frame or stale visible frame.

## Actual Behavior

After external display changes:

1. Both the built-in and external Flow workspaces are detached when `screenReadinessChanged(isReady: false)` is published.
2. Tangrid still receives active space/window events while screen readiness is false; at least one attach is skipped because `ready:false`.
3. When readiness becomes true, Tangrid restarts AX observers and reloads windows. During this recovery, workspace attach/reconcile can occur while screen geometry appears to still be changing.
4. After rotating the external monitor, Tangrid applies a Flow layout using portrait dimensions, but the target frame is not actually applied. The logged `appliedFrame` is constrained to an old/stale height, which causes incorrect tab/window state.

## Evidence From Logs

The logs were exported at `2026-07-03T04:46:22Z`.

### 1. Preference is not the issue

The persisted setting contains:

```json
{
  "windowDefaultStrategy": "Tiling",
  "tiling": {
    "prioritizeFlowLayout": "preferTabbed",
    "singleFlowWindowFloating": false
  }
}
```

The same `preferTabbed` value also existed in an older May 2026 Tangrid preference backup, so this does not look like a new preference migration problem.

### 2. Screen readiness transitions detach Flow workspaces

Around `2026-07-03 12:42:57`, Tangrid marks the screen configuration as not ready and detaches both Flow workspaces:

```text
ScreenVirtualWorkspaceAdaptar.swift:59
screen configuration not ready; detaching virtual workspaces

ScreenVirtualWorkspaceAdaptar.swift:245
reason:screenNotReady action:dettach screen:Monitor ... workspace:<FlowVirtualWorkspace-v2>

ScreenVirtualWorkspaceAdaptar.swift:245
reason:screenNotReady action:dettach screen:Built-in Retina Display ... workspace:<FlowVirtualWorkspace-v2>

ScreenSpaceStateObserver.swift:540
reason:screenReadinessChanged(isReady: false)
```

Before the system becomes ready again, Tangrid still observes a space change but skips attach because readiness is false:

```text
ScreenVirtualWorkspaceAdaptar.swift:186
reason:spaceDidChange action:skip ready:false screen:Built-in Retina Display ...
```

Then at `2026-07-03 12:43:29`, screen readiness becomes true and AX observers are restarted.

### 3. Rotation produces stale or mismatched geometry

At `2026-07-03 12:45:14`, the external monitor is attached with portrait-like geometry:

```text
FlowVirtualWorkspace.swift:47
scene:Monitor screen frame=(-1200.0, -751.0, 1200.0, 1920.0)
visibleFrame=(-1200.0, -751.0, 1200.0, 1890.0)
```

Immediately after that, Flow reconciliation applies layout, but the actual AX frame is clamped to an old/stale height:

```text
WindowSlot.swift:384
AXWindowSetFrameError(
  targetFrame: (0.0, 36.0, 1200.0, 1854.0),
  appliedFrame: (0.0, 756.0, 1200.0, 1134.0),
  reason: AXLiveWindow.AXWindowSetFrameReason.applyUnstable
)
```

This is followed by another screen-not-ready transition only a few seconds later:

```text
2026-07-03 12:45:20
reason:screenNotReady action:dettach screen:Monitor ... workspace:<FlowVirtualWorkspace-v2>
reason:screenNotReady action:dettach screen:Built-in Retina Display ... workspace:<FlowVirtualWorkspace-v2>
reason:screenReadinessChanged(isReady: false)
```

When readiness returns at `2026-07-03 12:45:31`, windows are reloaded while the external display is then seen again as landscape-like geometry:

```text
FlowVirtualWorkspace.swift:47
scene:Monitor screen frame=(-1920.0, -31.0, 1920.0, 1200.0)
visibleFrame=(-1920.0, -31.0, 1920.0, 1170.0)
```

This suggests Tangrid is reconciling layout during an unstable display geometry window.

### 4. Default and Flow workspace types both appear during display transitions

Older sessions show the same screen-readiness detach/attach behavior with `DefaultVirtualWorkspace` / `SnapVirtualWorkspace`. In the current Auto Flow session, the same pattern appears with `FlowVirtualWorkspace-v2`.

There is also one logged workspace migration:

```text
ScreenVirtualWorkspaceAdaptar.takeWorkspace reason:migrate screen:Monitor ... workspace:<DefaultVirtualWorkspace>
attach source:migrated screen:Built-in Retina Display ... workspace:<DefaultVirtualWorkspace>
```

This may be unrelated, but it is worth checking that the display migration code does not accidentally preserve a `DefaultVirtualWorkspace`/split-oriented workspace when the current preference should prefer Flow tabbed layout.

### 5. Additional logs show Auto Flow rebuilding into BSP/split layout

I also checked a second exported archive:

```text
Tangrid-logs-20260630-092136.zip
Tangrid 1.3.4-beta / build 1.3.4
exportedAt: 2026-06-30T01:21:40Z
```

This archive does not contain explicit preference log lines such as `preferTabbed`, `preferSplit`, or `prioritizeFlowLayout`, so it does not directly prove that the preference read failed. However, it does show Flow workspaces reconciling into BSP split topology.

Example 1, on `2026-06-28 09:50:29`:

```text
FlowVirtualWorkspace.swift:47
attach(screenSpace:) -> Built-in Retina Display

FlowController+WorkspaceReconcile.swift:25
reconcileAttachedWorkspaces. reason:manual applyLayout:true

BSPTree+Rebuild.swift:144
BSP rebuild result root split -> horizontal

FlowController+WorkspaceReconcile.swift:150
Flow workspace reconciled manual [
  "4D5BE3B1-BDDC-4A73-B246-1F43CB4F1B5D:[5250]",
  "5ECE0075-04D8-4383-901D-FF38A1D417CE:[3203, 1658, 5641]"
]
```

Immediately after that, Tangrid tried to apply narrow split frames and hit intrinsic-size clamps:

```text
WindowSlot.swift:384
AXWindowSetFrameError(
  targetFrame: (900.0, 0.0, 450.0, 1130.0),
  appliedFrame: (900.0, 0.0, 480.0, 1130.0),
  reason: AXLiveWindow.AXWindowSetFrameReason.intrinsicClamp
)

WindowSlot.swift:384
AXWindowSetFrameError(
  targetFrame: (1350.0, 0.0, 450.0, 1130.0),
  appliedFrame: (1350.0, 0.0, 500.0, 1130.0),
  reason: AXLiveWindow.AXWindowSetFrameReason.intrinsicClamp
)
```

Example 2, on `2026-06-30 09:15:03`:

```text
FlowVirtualWorkspace.swift:47
attach(screenSpace:) -> Built-in Retina Display
attach(screenSpace:) -> Monitor

FlowController+WorkspaceReconcile.swift:25
reconcileAttachedWorkspaces. reason:manual applyLayout:true

BSPTree+Rebuild.swift:144
BSP rebuild result root split -> horizontal

FlowController+WorkspaceReconcile.swift:150
Flow workspace reconciled manual [
  "5ECE0075-04D8-4383-901D-FF38A1D417CE:[8382, 8142, 8248, 8458]",
  "4D5BE3B1-BDDC-4A73-B246-1F43CB4F1B5D:[]"
]
```

Then multiple split slot frames were rejected or widened by application intrinsic width limits:

```text
WindowSlot.swift:384
targetFrame: (0.0, 0.0, 400.0, 1130.0)
appliedFrame: (0.0, 0.0, 500.0, 1130.0)
reason: intrinsicClamp

WindowSlot.swift:384
targetFrame: (400.0, 0.0, 250.0, 1130.0)
appliedFrame: (400.0, 0.0, 500.0, 1130.0)
reason: intrinsicClamp

WindowSlot.swift:384
targetFrame: (650.0, 0.0, 250.0, 1130.0)
appliedFrame: (650.0, 0.0, 751.0, 1130.0)
reason: intrinsicClamp
```

There is also a later lifecycle case where a newly created Codex window initially appeared half-width and Tangrid tried to set it full-width, but the applied frame remained split-like:

```text
2026-06-30 09:17:02
windowCreated Codex frame:(0.0, 36.0, 900.0, 1094.0)

WindowSlot.swift:384
AXWindowSetFrameError(
  targetFrame: (0.0, 0.0, 1800.0, 1130.0),
  appliedFrame: (0.0, 36.0, 900.0, 1094.0),
  reason: AXLiveWindow.AXWindowSetFrameReason.applyUnstable
)
```

This looks consistent with the user-visible symptom "new windows sometimes do not use tabbed layout and appear as split layout", assuming the active setting at that time was still Auto Flow + Prefer Tabbed. The archive itself does not log the setting value, but current preferences and older preference backup both show `tiling.prioritizeFlowLayout = preferTabbed`.

The interesting code path to inspect is probably:

- why `FlowController.reconcileAttachedWorkspaces(... applyLayout:true)` enters `BSPTree+Rebuild` and produces `root split -> horizontal` when `preferTabbed` should be favored;
- whether "prefer tabbed" is applied only to drag/drop candidate choice but not to automatic lifecycle/window-created reconciliation;
- whether `DefaultVirtualWorkspace` cached during screen-not-ready / update-screen transitions can later influence Flow reconciliation;
- whether intrinsic-clamp/apply-unstable failures should trigger a tabbed fallback instead of leaving a partially split geometry.

### 6. Additional logs show partial AX window registration / ownership loss

There is another related symptom: after connecting or disconnecting displays, some windows are no longer recognized or managed by Tangrid, while other already-tabbed windows continue to be managed normally.

The logs do show this pattern. The important signal is not `owned: 0 normal: 0 []`, because that may simply mean the process has no manageable normal windows. The stronger signal is:

```text
performLoadExistingWindows(...)
owned: 0 normal: 0
[<AXWindow> ... isDefaultFloatingWindow:false ...]
```

That means the AX layer enumerated a normal window, but Tangrid's current ownership/normal-window counters for that app were still zero at that moment.

Example 1, after display readiness recovered on `2026-07-01 13:54:08`:

```text
2026-07-01 13:51:58
screenNotReady -> dettach Built-in Retina Display workspace:<FlowVirtualWorkspace-v2>
screenNotReady -> dettach Monitor workspace:<FlowVirtualWorkspace-v2>
screenReadinessChanged(isReady: false)

2026-07-01 13:54:08
screenReadinessChanged(isReady: true)

2026-07-01 13:54:09
Calendar ... owned: 0 normal: 0 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]
Microsoft Outlook ... owned: 0 normal: 0 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]
Reminders ... owned: 0 normal: 0 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]
```

In the same recovery window, other normal windows were recognized as managed:

```text
GameHub ... owned: 1 normal: 1 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]
ChatGPT-Web ... owned: 1 normal: 1 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]
TablePlus ... owned: 1 normal: 1 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]
```

Example 2, on `2026-07-02 09:43:01`, right after a Monitor Flow workspace was attached and then immediately detached because the screen configuration became not ready:

```text
2026-07-02 09:42:58
FlowVirtualWorkspace attach Monitor

2026-07-02 09:43:01
attachWorkspace source:cached screen:Monitor workspace:<FlowVirtualWorkspace-v2>
screenNotReady -> dettach screen:Monitor workspace:<FlowVirtualWorkspace-v2>
screenReadinessChanged(isReady: false)

Codex ... owned: 0 normal: 0 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]
Code ... owned: 0 normal: 0 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]
```

At the same moment, an already-grouped Helium set remained recognized:

```text
Helium ... owned: 3 normal: 3 ... [
  <AXWindow> ... Default - Helium ...,
  <AXWindow> ... Part of group apartments ...,
  <AXWindow> ... Part of group cmu ...
]
```

Example 3, in the `2026-06-30 09:15-09:16` logs:

```text
2026-06-30 09:15:36
screenReadinessChanged(isReady: true)

GameHub ... owned: 0 normal: 0 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]
Microsoft Outlook ... owned: 0 normal: 0 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]
Bilibili ... owned: 0 normal: 0 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]
Reminders ... owned: 0 normal: 0 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]

Fork ... owned: 1 normal: 1 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]
Codex ... owned: 1 normal: 1 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]
Helium ... owned: 2 normal: 2 ... [<AXWindow> ... isDefaultFloatingWindow:false ...]
```

Then at `2026-06-30 09:16:57`, Tangrid reconciled the built-in Flow workspace to an empty visible window list:

```text
Flow workspace reconciled manual ["5ECE0075-04D8-4383-901D-FF38A1D417CE:[]"]
```

This supports the user-visible report that some windows stop being managed while other already-tabbed windows are still tracked.

This looks like a second manifestation of the same display-readiness race:

- the screen/space state changes to unavailable and Flow workspaces are detached;
- `performLoadExistingWindows` still runs while screen identity or space identity is unstable;
- some windows are enumerated but not converted into owned/managed normal windows;
- already-owned grouped windows can survive because their existing `AXWindow` objects are refreshed or retained;
- later Flow reconciliation may operate on a partial visible-window set.

Suggested checks for this part:

- In `AXProcessorWindowManager.performLoadExistingWindows`, audit why an enumerated normal window can be logged with `owned: 0 normal: 0` and not subsequently produce `applyRegistrationAction` / `Started monitoring window`.
- After `screenReadinessChanged(isReady: true)`, run a second delayed rebind pass for any app where a normal AX window was enumerated but ownership stayed zero.
- Consider blocking or deferring `FlowController.reconcileAttachedWorkspaces(... applyLayout:true)` until the post-readiness window rebind pass has completed.
- Add a debug log that distinguishes "no manageable windows" from "normal windows enumerated but not registered/owned".

## Hypothesis

The likely problem is not that the `preferTabbed` setting fails to persist. It appears to persist correctly.

The more likely cause is that display readiness and screen geometry changes are treated as stable too early. Tangrid detaches Flow workspaces during `screenReadinessChanged(false)`, but space/window events still arrive during that interval. When readiness flips back to true, Tangrid reattaches/reconciles workspaces and restarts AX monitoring while the external display may still be reporting transitional geometry. This can result in:

- stale or wrong screen frames used for Flow layout;
- tab strip/window group state attached to the wrong screen or space;
- normal AX windows enumerated but not re-owned/re-registered after display changes;
- AX frame application failures (`applyUnstable`);
- split fallback or split candidate behavior even though `preferTabbed` is configured.

## Suggested Fix Direction

1. Debounce or coalesce display topology changes before reattaching Flow workspaces.
   - Wait until screen list, display identifiers, space UUIDs, and visible frames are stable for a short interval before calling `reconcileAttachedWorkspaces(... applyLayout:true)`.

2. While `screenReadinessChanged(isReady: false)`, queue or ignore Flow layout-affecting space/window events.
   - In particular, avoid committing tab/split layout state during `ready:false`.

3. After readiness becomes true, force a fresh screen-space snapshot before layout.
   - Re-read `NSScreen.frame`, `visibleFrame`, display ID, and current space UUID, then only reconcile if they match the current active display topology.

4. If `WindowSlot.applyFrame` returns `applyUnstable` after rotation, schedule one retry after the display geometry settles rather than accepting the stale applied frame.

5. Rebind and verify AX windows after display readiness becomes true.
   - If a normal AX window is enumerated with `owned: 0 normal: 0`, ensure it is either explicitly rejected with a reason or re-registered and monitored.

6. Review workspace migration between displays.
   - Ensure that a cached or migrated `DefaultVirtualWorkspace` cannot override Auto Flow `preferTabbed` behavior for the current screen/space after display changes.

## Reproduction Shape

1. Enable Auto Flow mode and set layout priority to "Prefer tabbed layout".
2. Use a MacBook with an external monitor that supports rotation.
3. Create at least one tabbed Flow group across normal app windows.
4. Rotate the external monitor or connect/disconnect it while windows exist on both displays.
5. Observe whether:
   - tab strip appears on the wrong display or wrong window group;
   - windows are restored to old landscape dimensions after portrait rotation;
   - new windows open as split layout despite `preferTabbed`.
   - after connect/disconnect, some normal windows are no longer managed while existing tabbed groups continue to be managed.
