From 80f1ab8909a2a1d9a47380683589e26da644669f Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Sun, 31 May 2026 21:34:39 -0700 Subject: [PATCH 1/2] Suppress AX capability flicker on the same focused element 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. --- Cotabby.xcodeproj/project.pbxproj | 8 ++ .../SuggestionCoordinator+Input.swift | 17 ++- .../Coordinators/SuggestionCoordinator.swift | 5 + .../Support/FocusCapabilityFlickerGate.swift | 78 +++++++++++ .../FocusCapabilityFlickerGateTests.swift | 122 ++++++++++++++++++ 5 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 Cotabby/Support/FocusCapabilityFlickerGate.swift create mode 100644 CotabbyTests/FocusCapabilityFlickerGateTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 385002c..0c3583d 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -124,6 +124,7 @@ 6E49ADEB31D04DC77A47DEB0 /* FileLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */; }; 744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; 76FD91607794883F8E121450 /* CaretGeometrySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */; }; + 78A8713A0E5B4C89E2D715BC /* FocusCapabilityFlickerGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */; }; 78FAE5DB691A1B71042B9D20 /* AboutPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */; }; 7B6A63F5DCC2C163CDFD2A5C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BC4F887528AE74AC0DD30314 /* Assets.xcassets */; }; 7C36DBA762E19C8C31676D44 /* MidWordContinuationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1274F897631B1B3A835D157F /* MidWordContinuationPolicyTests.swift */; }; @@ -181,6 +182,7 @@ BBE22CE4EF43247F8775B25D /* FocusPollBackoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09FADF683BE7B3558377FA76 /* FocusPollBackoff.swift */; }; BFCA7FAFDAEBF586AB615567 /* ClipboardRelevanceFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B0D133AB77A2503FB08827 /* ClipboardRelevanceFilterTests.swift */; }; C0B833234748E82D3382631A /* emoji.json in Resources */ = {isa = PBXBuildFile; fileRef = C379D77029D6E88C8C1B9AF7 /* emoji.json */; }; + C0FE11D76BDF01A5470C554D /* FocusCapabilityFlickerGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */; }; C2C958D6E5F5FE1CCC414BCE /* SuggestionSubsystemContracts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB16474A67CE1D210B944C9 /* SuggestionSubsystemContracts.swift */; }; C4C6734678797669055988E0 /* AppUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9573F3504CAE6891DF9B7D /* AppUpdateManager.swift */; }; C618C5595DA9C57C806A3E03 /* SettingsAttentionEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BC293F6125E2B14DCF05AD9 /* SettingsAttentionEvaluatorTests.swift */; }; @@ -269,6 +271,7 @@ 12DD19BCE610808F1E38702D /* PermissionOverlayTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionOverlayTrackerTests.swift; sourceTree = ""; }; 1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiMatcher.swift; sourceTree = ""; }; 18D990E515E1AE4F312F4E95 /* BundledRuntimeLocatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledRuntimeLocatorTests.swift; sourceTree = ""; }; + 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityFlickerGateTests.swift; sourceTree = ""; }; 19BE12C28A4AB8A4A58C2FF7 /* SettingsPaneScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPaneScaffold.swift; sourceTree = ""; }; 19DB9558F4D3AFB108D71649 /* SuggestionStateHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionStateHelperTests.swift; sourceTree = ""; }; 1A8414BEB7E34F57607E37FE /* EmojiVariantResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiVariantResolver.swift; sourceTree = ""; }; @@ -337,6 +340,7 @@ 62EDF1199CC5E18BD7651661 /* EmojiPickerPanelLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelLayout.swift; sourceTree = ""; }; 656F58E56FE9BC087B6F1D33 /* PermissionReminderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionReminderView.swift; sourceTree = ""; }; 67586807ACE8EB13C9014535 /* TickMarkSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TickMarkSlider.swift; sourceTree = ""; }; + 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityFlickerGate.swift; sourceTree = ""; }; 6B2D97BAA3618A7D0357AC44 /* SuggestionWorkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionWorkController.swift; sourceTree = ""; }; 6DC693E00430F46E41CB56E6 /* RequestID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestID.swift; sourceTree = ""; }; 70367FCC1E0F08EE3B8EB26F /* FocusCapabilityResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityResolver.swift; sourceTree = ""; }; @@ -709,6 +713,7 @@ 723E1EFA85D2E61B6C5F33E8 /* EmojiTriggerStateMachineTests.swift */, EE8BB19D8EC9A75CD3458A6B /* EmojiVariantResolverTests.swift */, 54BC85605541E913EE57B258 /* ExtendedContextTests.swift */, + 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */, D4F6D5F94B238F7B4BE7C247 /* FocusCapabilityResolverTests.swift */, 273B4DC844F79B4BE2C8910F /* FocusPollBackoffTests.swift */, BA705EDFE1C41294F0E381F1 /* FocusSnapshotResolverSelectionTests.swift */, @@ -859,6 +864,7 @@ 312C7306D916963F519CE0D9 /* EmojiTriggerStateMachine.swift */, 1A8414BEB7E34F57607E37FE /* EmojiVariantResolver.swift */, CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */, + 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */, 70367FCC1E0F08EE3B8EB26F /* FocusCapabilityResolver.swift */, 09FADF683BE7B3558377FA76 /* FocusPollBackoff.swift */, FE7BF162A12703249726F20A /* FoundationModelPromptRenderer.swift */, @@ -1059,6 +1065,7 @@ 5DF31F465A3A1233260BD3A4 /* EngineAndModelPaneView.swift in Sources */, 5BE53CE921664582F593B7B0 /* FieldEdgeIconIndicatorView.swift in Sources */, 6E49ADEB31D04DC77A47DEB0 /* FileLogHandler.swift in Sources */, + C0FE11D76BDF01A5470C554D /* FocusCapabilityFlickerGate.swift in Sources */, CC98B842D10574C5206BEFA7 /* FocusCapabilityResolver.swift in Sources */, 924489CEE8171F7AD8579D71 /* FocusDebugOverlayController.swift in Sources */, 2402DC57AE2BCF6A686D30ED /* FocusModels.swift in Sources */, @@ -1201,6 +1208,7 @@ ED0843752B297D7E9DB2C468 /* EmojiTriggerStateMachineTests.swift in Sources */, C9B815652CED38966C53A5E8 /* EmojiVariantResolverTests.swift in Sources */, 63054CBDCA87560130BF5ADC /* ExtendedContextTests.swift in Sources */, + 78A8713A0E5B4C89E2D715BC /* FocusCapabilityFlickerGateTests.swift in Sources */, C71B594433F3B411CAE5DE7E /* FocusCapabilityResolverTests.swift in Sources */, A147C5EC3F2214A670F7556E /* FocusPollBackoffTests.swift in Sources */, 156E6AB3D24134EEC29FDB93 /* FocusSnapshotResolverSelectionTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift index 5e2a735..206faaf 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift @@ -23,8 +23,23 @@ extension SuggestionCoordinator { } func handleFocusSnapshotChange(_ snapshot: FocusSnapshot) { + switch capabilityFlickerGate.evaluate(snapshot) { + case .apply: + break + case let .suppress(pendingBlockedReadCount: count): + // Single-poll AX flicker on the same element (see FocusCapabilityFlickerGate). Drop the + // event so the overlay does not bounce; log it so the suppression is observable. + let suppressedReason = snapshot.capability.summary + CotabbyLogger.suggestion.trace( + // swiftlint:disable:next line_length + "Focus snapshot flicker suppressed: app=\(snapshot.applicationName) capability=\(snapshot.capability.shortLabel) reason=\(suppressedReason) pendingBlockedReads=\(count)" + ) + return + } + + let changedReason = snapshot.capability.summary CotabbyLogger.suggestion.trace( - "Focus snapshot changed: app=\(snapshot.applicationName) capability=\(snapshot.capability.shortLabel)" + "Focus snapshot changed: app=\(snapshot.applicationName) capability=\(snapshot.capability.shortLabel) reason=\(changedReason)" ) // Start capturing visual context for a newly focused input even when predictions are // temporarily disabled by transient field states (e.g., "text is selected" or "secure diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/SuggestionCoordinator.swift index fcc8d33..c892a8e 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator.swift @@ -75,6 +75,11 @@ final class SuggestionCoordinator: ObservableObject { /// synchronous `refreshNow()` calls on the main actor. Bumping the token makes older chains /// no-op before they can perform another expensive AX read. var hostPublishPollGeneration: UInt64 = 0 + /// Suppresses single-poll `Supported → Blocked → Supported` flicker on the same focused element + /// so the overlay does not tear down and rebuild on every transient AX redraw. See + /// `FocusCapabilityFlickerGate` for the rationale and the reproduction (Apple Calendar event + /// editor). + var capabilityFlickerGate = FocusCapabilityFlickerGate() /// Correlation ID for the most recently built `SuggestionRequest`. Stamped onto every /// state-transition log line so all events tied to one suggestion (debounce → generating → /// ready → accepted/rejected) can be joined with a single `jq` filter on `request_id`. diff --git a/Cotabby/Support/FocusCapabilityFlickerGate.swift b/Cotabby/Support/FocusCapabilityFlickerGate.swift new file mode 100644 index 0000000..96596e9 --- /dev/null +++ b/Cotabby/Support/FocusCapabilityFlickerGate.swift @@ -0,0 +1,78 @@ +import Foundation + +/// File overview: +/// Suppresses transient `Supported → Blocked → Supported` capability flicker on the *same* focused +/// element so the suggestion overlay does not tear down and rebuild every time a host app momentarily +/// republishes its focused field. +/// +/// Background. `FocusCapabilityResolver` re-derives capability from live AX attributes on every poll +/// with no temporal smoothing. Some Catalyst-style fields (Apple Calendar's event editor is the +/// reproduction) briefly drop one of `textValue` / `selectionRange` / `caretBounds` while they +/// redraw, which collapses capability to Blocked for a single poll and bounces back to Supported on +/// the next. Without this gate every flicker drives `handleFocusSnapshotChange` to call +/// `disablePredictionsPreservingVisualContext` → `OverlayController.hide` → `panel.orderOut(nil)`, +/// which the user sees as the overlay opening and closing several times per second. +/// +/// The gate is intentionally tiny and pure: it tracks the most recently delivered Supported element +/// identity and a consecutive-Blocked counter, and tells the caller whether to apply the new +/// snapshot or keep treating the field as Supported for now. A persistent loss of capability (the +/// real "field went away" case) clears the gate after `requiredConsecutiveBlockedReads` so the +/// downgrade still propagates promptly — at the observed ~80–150 ms poll cadence that is roughly +/// 160–300 ms of suppression, well above the ~13 ms flicker pairs seen in the logs but short enough +/// that genuine focus loss is not perceptible. +struct FocusCapabilityFlickerGate { + /// How many consecutive Blocked snapshots on the same element must be observed before the gate + /// releases the downgrade. Two is enough to swallow the single-poll flicker without delaying + /// real focus-loss perceptibly at typical poll cadence. + static let requiredConsecutiveBlockedReads = 2 + + /// Outcome the caller acts on. + enum Decision: Equatable { + /// Apply this snapshot as-is. + case apply + /// Treat as a transient flicker: pretend the previous Supported snapshot is still current. + /// `pendingBlockedReadCount` is exposed for diagnostic logging only. + case suppress(pendingBlockedReadCount: Int) + } + + private var lastDeliveredSupportedElementID: String? + private var consecutiveBlockedReadCount: Int = 0 + + /// Feed every snapshot through here before letting it drive coordinator state. + mutating func evaluate(_ snapshot: FocusSnapshot) -> Decision { + switch snapshot.capability { + case .supported: + lastDeliveredSupportedElementID = snapshot.context?.elementIdentifier + consecutiveBlockedReadCount = 0 + return .apply + + case .blocked: + // Only debounce when we are still observing the same element that was just Supported. + // A different (or missing) element identifier is a genuine focus change and must + // propagate immediately. + guard let lastID = lastDeliveredSupportedElementID, + let currentID = snapshot.context?.elementIdentifier, + currentID == lastID + else { + lastDeliveredSupportedElementID = nil + consecutiveBlockedReadCount = 0 + return .apply + } + + consecutiveBlockedReadCount += 1 + if consecutiveBlockedReadCount >= Self.requiredConsecutiveBlockedReads { + lastDeliveredSupportedElementID = nil + consecutiveBlockedReadCount = 0 + return .apply + } + return .suppress(pendingBlockedReadCount: consecutiveBlockedReadCount) + + case .unsupported: + // Unsupported is "no focused text input at all" — never debounce; the user has left the + // field and the overlay must hide immediately. + lastDeliveredSupportedElementID = nil + consecutiveBlockedReadCount = 0 + return .apply + } + } +} diff --git a/CotabbyTests/FocusCapabilityFlickerGateTests.swift b/CotabbyTests/FocusCapabilityFlickerGateTests.swift new file mode 100644 index 0000000..a6801c8 --- /dev/null +++ b/CotabbyTests/FocusCapabilityFlickerGateTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import Cotabby + +final class FocusCapabilityFlickerGateTests: XCTestCase { + func testFirstSnapshotIsAlwaysApplied() { + var gate = FocusCapabilityFlickerGate() + XCTAssertEqual(gate.evaluate(supportedSnapshot(elementID: "field-A")), .apply) + } + + func testSingleBlockedFlickerOnSameElementIsSuppressed() { + var gate = FocusCapabilityFlickerGate() + _ = gate.evaluate(supportedSnapshot(elementID: "field-A")) + + let decision = gate.evaluate(blockedSnapshot(elementID: "field-A")) + + XCTAssertEqual(decision, .suppress(pendingBlockedReadCount: 1)) + } + + func testSupportedReturnAfterFlickerResetsTheGate() { + var gate = FocusCapabilityFlickerGate() + _ = gate.evaluate(supportedSnapshot(elementID: "field-A")) + _ = gate.evaluate(blockedSnapshot(elementID: "field-A")) + + XCTAssertEqual(gate.evaluate(supportedSnapshot(elementID: "field-A")), .apply) + + // After the reset, a fresh flicker is suppressed again rather than counted on top of the + // previous run. + XCTAssertEqual( + gate.evaluate(blockedSnapshot(elementID: "field-A")), + .suppress(pendingBlockedReadCount: 1) + ) + } + + func testTwoConsecutiveBlockedReadsReleaseTheDowngrade() { + var gate = FocusCapabilityFlickerGate() + _ = gate.evaluate(supportedSnapshot(elementID: "field-A")) + + XCTAssertEqual( + gate.evaluate(blockedSnapshot(elementID: "field-A")), + .suppress(pendingBlockedReadCount: 1) + ) + XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-A")), .apply) + } + + func testBlockedOnDifferentElementBypassesSuppression() { + var gate = FocusCapabilityFlickerGate() + _ = gate.evaluate(supportedSnapshot(elementID: "field-A")) + + XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-B")), .apply) + } + + func testBlockedWithoutAnyPriorSupportedIsApplied() { + var gate = FocusCapabilityFlickerGate() + XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-A")), .apply) + } + + func testUnsupportedIsNeverSuppressed() { + var gate = FocusCapabilityFlickerGate() + _ = gate.evaluate(supportedSnapshot(elementID: "field-A")) + + XCTAssertEqual(gate.evaluate(unsupportedSnapshot()), .apply) + } + + func testUnsupportedClearsPendingFlickerState() { + var gate = FocusCapabilityFlickerGate() + _ = gate.evaluate(supportedSnapshot(elementID: "field-A")) + _ = gate.evaluate(blockedSnapshot(elementID: "field-A")) + _ = gate.evaluate(unsupportedSnapshot()) + + // Without the clear, the next Blocked would resume the previous counter and downgrade + // immediately. The Unsupported observation must reset everything. + XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-A")), .apply) + } + + func testBlockedWithMissingContextIsAppliedEvenAfterSupported() { + var gate = FocusCapabilityFlickerGate() + _ = gate.evaluate(supportedSnapshot(elementID: "field-A")) + + // Loss of context on the snapshot means we can no longer prove "same element", so the + // gate has to defer to the downstream evaluator instead of holding the field as Supported. + let blockedWithoutContext = FocusSnapshot( + applicationName: "Calendar", + bundleIdentifier: "com.apple.iCal", + capability: .blocked("Text is currently selected."), + context: nil, + inspection: nil + ) + XCTAssertEqual(gate.evaluate(blockedWithoutContext), .apply) + } + + // MARK: - Helpers + + private func supportedSnapshot(elementID: String) -> FocusSnapshot { + FocusSnapshot( + applicationName: "Calendar", + bundleIdentifier: "com.apple.iCal", + capability: .supported, + context: CotabbyTestFixtures.focusedInputSnapshot(elementIdentifier: elementID), + inspection: nil + ) + } + + private func blockedSnapshot(elementID: String) -> FocusSnapshot { + FocusSnapshot( + applicationName: "Calendar", + bundleIdentifier: "com.apple.iCal", + capability: .blocked("Text is currently selected."), + context: CotabbyTestFixtures.focusedInputSnapshot(elementIdentifier: elementID), + inspection: nil + ) + } + + private func unsupportedSnapshot() -> FocusSnapshot { + FocusSnapshot( + applicationName: "Finder", + bundleIdentifier: "com.apple.finder", + capability: .unsupported("No focused text input"), + context: nil, + inspection: nil + ) + } +} From ddaf8ba5a435f4f322de379024712cfd56023829 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Sun, 31 May 2026 23:03:35 -0700 Subject: [PATCH 2/2] Address Greptile review: detail= log key and nil-context test --- .../SuggestionCoordinator+Input.swift | 8 ++++---- .../FocusCapabilityFlickerGateTests.swift | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift index 206faaf..ccdea27 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift @@ -29,17 +29,17 @@ extension SuggestionCoordinator { case let .suppress(pendingBlockedReadCount: count): // Single-poll AX flicker on the same element (see FocusCapabilityFlickerGate). Drop the // event so the overlay does not bounce; log it so the suppression is observable. - let suppressedReason = snapshot.capability.summary + let suppressedDetail = snapshot.capability.summary CotabbyLogger.suggestion.trace( // swiftlint:disable:next line_length - "Focus snapshot flicker suppressed: app=\(snapshot.applicationName) capability=\(snapshot.capability.shortLabel) reason=\(suppressedReason) pendingBlockedReads=\(count)" + "Focus snapshot flicker suppressed: app=\(snapshot.applicationName) capability=\(snapshot.capability.shortLabel) detail=\(suppressedDetail) pendingBlockedReads=\(count)" ) return } - let changedReason = snapshot.capability.summary + let changedDetail = snapshot.capability.summary CotabbyLogger.suggestion.trace( - "Focus snapshot changed: app=\(snapshot.applicationName) capability=\(snapshot.capability.shortLabel) reason=\(changedReason)" + "Focus snapshot changed: app=\(snapshot.applicationName) capability=\(snapshot.capability.shortLabel) detail=\(changedDetail)" ) // Start capturing visual context for a newly focused input even when predictions are // temporarily disabled by transient field states (e.g., "text is selected" or "secure diff --git a/CotabbyTests/FocusCapabilityFlickerGateTests.swift b/CotabbyTests/FocusCapabilityFlickerGateTests.swift index a6801c8..18fab1d 100644 --- a/CotabbyTests/FocusCapabilityFlickerGateTests.swift +++ b/CotabbyTests/FocusCapabilityFlickerGateTests.swift @@ -72,6 +72,24 @@ final class FocusCapabilityFlickerGateTests: XCTestCase { XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-A")), .apply) } + func testSupportedWithoutContextDoesNotArmTheGate() { + var gate = FocusCapabilityFlickerGate() + + // A Supported snapshot with no context (rare but possible — e.g. capability inferred from + // app identity before AX details settle) cannot be used as a reference for "same element" + // checks, so the next Blocked must propagate immediately rather than be silently + // suppressed. + let supportedWithoutContext = FocusSnapshot( + applicationName: "Calendar", + bundleIdentifier: "com.apple.iCal", + capability: .supported, + context: nil, + inspection: nil + ) + XCTAssertEqual(gate.evaluate(supportedWithoutContext), .apply) + XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-A")), .apply) + } + func testBlockedWithMissingContextIsAppliedEvenAfterSupported() { var gate = FocusCapabilityFlickerGate() _ = gate.evaluate(supportedSnapshot(elementID: "field-A"))