Skip to content

Suppress AX capability flicker on the same focused element#494

Merged
FuJacob merged 2 commits into
mainfrom
fix/calendar-capability-hysteresis
Jun 1, 2026
Merged

Suppress AX capability flicker on the same focused element#494
FuJacob merged 2 commits into
mainfrom
fix/calendar-capability-hysteresis

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented Jun 1, 2026

Summary

Apple Calendar's event-editor field momentarily drops one of the required AX attributes during its own redraws, which collapsed FocusCapabilityResolver to Blocked for a single poll (~13 ms) and bounced back to Supported on the next. Each flip drove SuggestionCoordinator to hide and re-show the overlay, so users saw the suggestion UI flickering several times per second and felt input lag whenever they typed in a Calendar event.

This adds a small pure FocusCapabilityFlickerGate that swallows the first Supported → Blocked transition on the same elementIdentifier; a second consecutive Blocked read still propagates so genuine focus loss is not delayed.

Validation

Verified the root cause from ~/Library/Logs/Cotabby/cotabby.jsonl: same com.apple.iCal-38455-… element flips Blocked → Supported in 12-13 ms pairs every ~750 ms, with the same code path producing 6 such pairs across a 30 s Calendar window vs. 2 total for Xcode.

xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' \
  build -derivedDataPath build/DerivedData
# ** BUILD SUCCEEDED **

xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' \
  build-for-testing -derivedDataPath build/DerivedData
# ** TEST BUILD SUCCEEDED **

swiftlint lint --quiet Cotabby/Support/FocusCapabilityFlickerGate.swift \
  Cotabby/App/Coordinators/SuggestionCoordinator.swift \
  Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift \
  CotabbyTests/FocusCapabilityFlickerGateTests.swift
# exit 0

xcodebuild test fails locally with a code-signing / Team ID mismatch on the test bundle (the known issue called out in .claude/CLAUDE.md); the new FocusCapabilityFlickerGateTests cover first-snapshot, single-flicker suppression, return-to-Supported reset, two-consecutive-Blocked release, different-element bypass, no-prior-Supported passthrough, and Unsupported clear-state.

Linked issues

None filed. Repro: focus the title or notes field of a new Calendar event, watch overlay open and close repeatedly and ghost text fail to keep up.

Risk / rollout notes

  • Behavior change is limited to the same-element Supported → Blocked edge. Different element, prior Unsupported, and the second consecutive Blocked read all pass through unchanged, so legitimate focus loss is delayed by at most one additional poll (~80-150 ms in practice).
  • Also adds the disabled reason= to the Focus snapshot changed and Focus snapshot flicker suppressed log lines so the next investigation can attribute the exact Blocked branch (selection-length vs. secure-field vs. missing capability) without re-instrumenting.
  • project.pbxproj regenerated via xcodegen generate from project.yml to pick up the two new files.

Greptile Summary

This PR introduces FocusCapabilityFlickerGate, a small pure struct that suppresses transient Supported → Blocked → Supported AX capability flicker on the same focused element, preventing SuggestionCoordinator from tearing down and rebuilding the suggestion overlay on every ~750 ms AX redraw cycle in Apple Calendar's event editor.

  • FocusCapabilityFlickerGate: Tracks the last delivered supported element ID and a consecutive-blocked counter; suppresses the first blocked read on the same element and propagates the second, capping suppression latency at roughly one extra poll interval (~80–150 ms).
  • SuggestionCoordinator+Input: Gates handleFocusSnapshotChange with the new evaluator before any overlay mutation, with trace logging for suppressed events and an added detail= field for the passed-through path.
  • Tests: Nine XCTest cases cover all state-machine branches including the nil-context edge cases.

Confidence Score: 5/5

Safe to merge; the gate is a pure value type with no side effects, all non-flicker paths are unchanged, and genuine focus loss is delayed by at most one poll cycle.

The change is self-contained — a small struct with clearly bounded state (two fields), an exhaustive switch over a three-case enum, and nine unit tests that cover every branch including nil-context edge cases. The integration in the coordinator is a single early-return guard; all existing downstream logic is unaffected. There are no mutations of shared state, no async coordination, and no new dependencies introduced.

No files require special attention.

Important Files Changed

Filename Overview
Cotabby/Support/FocusCapabilityFlickerGate.swift New pure struct implementing hysteresis on AX capability flicker; logic is correct, state transitions are well-defined, and edge cases (nil context, different element, consecutive blocked, unsupported) are all handled.
Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift Gate is correctly integrated as the first thing in handleFocusSnapshotChange; suppressed events return early before any overlay mutation, and non-suppressed events fall through unchanged.
Cotabby/App/Coordinators/SuggestionCoordinator.swift Adds capabilityFlickerGate as a var stored property; placement is appropriate and the property is only mutated through the single handleFocusSnapshotChange entry point.
CotabbyTests/FocusCapabilityFlickerGateTests.swift Nine focused unit tests cover all meaningful branches including nil-context edge cases flagged in the previous review thread.
Cotabby.xcodeproj/project.pbxproj Regenerated via xcodegen to include the two new Swift files; mechanical change, no logic involved.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[FocusSnapshot arrives] --> B{capabilityFlickerGate.evaluate}
    B --> |.supported| C[Store elementID\nReset counter\nReturn .apply]
    B --> |.unsupported| D[Clear state\nReturn .apply]
    B --> |.blocked| E{Same element\nas last supported?}
    E --> |No / nil context| F[Clear state\nReturn .apply]
    E --> |Yes| G[Increment\nconsecutiveBlockedCount]
    G --> H{count >= 2?}
    H --> |Yes| I[Clear state\nReturn .apply]
    H --> |No| J[Return .suppress\npendingBlockedReadCount=count]
    C --> K[handleFocusSnapshotChange\ncontinues normally]
    D --> K
    F --> K
    I --> K
    J --> L[Log suppression\nReturn early — overlay unchanged]
Loading

Reviews (2): Last reviewed commit: "Address Greptile review: detail= log key..." | Re-trigger Greptile

Apple Calendar's event-editor field intermittently drops one of the required
AX attributes (textValue / selectionRange / caretBounds) during its own
redraws, collapsing FocusCapabilityResolver to Blocked for a single ~13 ms
poll before bouncing back to Supported on the next. FocusCapabilityResolver
has no temporal smoothing, FocusTrackingModel publishes without
removeDuplicates, and SuggestionCoordinator routes every Blocked snapshot to
OverlayController.hide -> panel.orderOut, so each flicker tore down and
rebuilt the overlay several times per second, lagging input.

Add a tiny pure FocusCapabilityFlickerGate that swallows the first Blocked
snapshot on the same elementIdentifier after a Supported one; a second
consecutive Blocked still propagates so genuine focus loss is not delayed.
Wire it into handleFocusSnapshotChange and log the disabledReason on both
the change and suppression paths so future log dives can attribute which
Blocked branch fired.
Comment thread Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift Outdated
Comment thread CotabbyTests/FocusCapabilityFlickerGateTests.swift
@FuJacob FuJacob merged commit b52223b into main Jun 1, 2026
@FuJacob FuJacob deleted the fix/calendar-capability-hysteresis branch June 1, 2026 06:03
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