From 2128aadba26bc5906930efb743003c62f86f4e73 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Thu, 28 May 2026 02:13:26 -0700 Subject: [PATCH 1/2] Hold overlay position across post-accept AX reconciles --- Cotabby.xcodeproj/project.pbxproj | 8 + .../SuggestionCoordinator+Prediction.swift | 28 ++- .../SuggestionOverlayStabilityGate.swift | 58 +++++++ .../SuggestionOverlayStabilityGateTests.swift | 164 ++++++++++++++++++ 4 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 Cotabby/Support/SuggestionOverlayStabilityGate.swift create mode 100644 CotabbyTests/SuggestionOverlayStabilityGateTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index d174b83..31c2146 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 0C98ECB5BCEBA72C693AC1C9 /* SuggestionTextNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B55A4362AB7F0528C661C4C /* SuggestionTextNormalizerTests.swift */; }; 0D8241CD31942A25EC4E0EE4 /* CotabbyDebugOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B2D34A6F3AC9DFD61350F7 /* CotabbyDebugOptions.swift */; }; 0DDC0CFF5558A8F4355836B2 /* OverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F308F6E274CC645E27CB651F /* OverlayController.swift */; }; + 0F3267956257401F39386773 /* SuggestionOverlayStabilityGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2F95847D76893C8A5B504B4 /* SuggestionOverlayStabilityGate.swift */; }; 1003373E13779882503C0E9D /* DisplayCoordinateConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74BD1D4DB27D5D96D1E06096 /* DisplayCoordinateConverter.swift */; }; 14D77F0B8A195AC2FA8D24A9 /* MirrorOverlayLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC83D14A7557BC0196E59007 /* MirrorOverlayLayoutTests.swift */; }; 156E6AB3D24134EEC29FDB93 /* FocusSnapshotResolverSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA705EDFE1C41294F0E381F1 /* FocusSnapshotResolverSelectionTests.swift */; }; @@ -58,6 +59,7 @@ 4AC255BE2D0CCC67B8882C7A /* WelcomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */; }; 4B4DDB569CAD806F765224DE /* CustomRulesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F7F7355967725162DF2D1B /* CustomRulesEditor.swift */; }; 4B54ACE1255873955414CD06 /* PermissionGuidanceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F2C764D29C8D50D0C854FF8 /* PermissionGuidanceController.swift */; }; + 4C6D8ED0A7B45D2EADF06DA5 /* SuggestionOverlayStabilityGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB25ABC4FFB0E63477CDCB0 /* SuggestionOverlayStabilityGateTests.swift */; }; 4CAFD8F3444FEDC9ACAFF529 /* LlamaRuntimeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A804F4DB6FD9BC8C27B2B65F /* LlamaRuntimeModels.swift */; }; 4F369F5284DDCEABF082E59B /* SuggestionAvailabilityEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3609CC88A5280B3AA40414DF /* SuggestionAvailabilityEvaluator.swift */; }; 4F38CE1C2602CF4F41323032 /* PermissionOverlayTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12DD19BCE610808F1E38702D /* PermissionOverlayTrackerTests.swift */; }; @@ -279,6 +281,7 @@ AD9573F3504CAE6891DF9B7D /* AppUpdateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateManager.swift; sourceTree = ""; }; ADBE3E6CC585C1683787C877 /* SuggestionEngineModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineModels.swift; sourceTree = ""; }; AF1E065C7FFB697FCEB2FA5C /* CotabbyTestFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyTestFixtures.swift; sourceTree = ""; }; + B2F95847D76893C8A5B504B4 /* SuggestionOverlayStabilityGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionOverlayStabilityGate.swift; sourceTree = ""; }; B424E2AC97C99D335B0D5751 /* SuggestionTextNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextNormalizer.swift; sourceTree = ""; }; B4B4A2E2DD6733658EC05BD8 /* DownloadFileRescuer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFileRescuer.swift; sourceTree = ""; }; B5679E08C9A09065531C37B5 /* LlamaPromptRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaPromptRenderer.swift; sourceTree = ""; }; @@ -300,6 +303,7 @@ CB58035EFFD65B767949BAE6 /* AXTextGeometryResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXTextGeometryResolver.swift; sourceTree = ""; }; CBD5FCB8CC56AA6138382B2C /* FieldEdgeIconIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldEdgeIconIndicatorView.swift; sourceTree = ""; }; CC1EDFB535AAA2EE0D67828A /* CotabbyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyApp.swift; sourceTree = ""; }; + CDB25ABC4FFB0E63477CDCB0 /* SuggestionOverlayStabilityGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionOverlayStabilityGateTests.swift; sourceTree = ""; }; CE0AA0503128B0FC3951D700 /* SuggestionSessionReconciler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSessionReconciler.swift; sourceTree = ""; }; CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogHandler.swift; sourceTree = ""; }; D2F46767D9D1F0D44E239CA8 /* DownloadFileRescuerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFileRescuerTests.swift; sourceTree = ""; }; @@ -525,6 +529,7 @@ 0D80CC2CCAAFE3F23FB8C37A /* PromptContextSanitizerTests.swift */, 4696A84D17890B154533A08F /* PromptPolicyTests.swift */, C05B0439348261163B37C508 /* SuggestionAvailabilityEvaluatorTests.swift */, + CDB25ABC4FFB0E63477CDCB0 /* SuggestionOverlayStabilityGateTests.swift */, EE94342B888A5A2CCF66BC93 /* SuggestionRequestFactoryTests.swift */, 9C8F07AC52C7A482F5FE34C5 /* SuggestionSessionReconcilerTests.swift */, 19DB9558F4D3AFB108D71649 /* SuggestionStateHelperTests.swift */, @@ -623,6 +628,7 @@ E6423D6CC8CC371D2DA899DE /* PermissionOverlayTracker.swift */, FA4B45B91D4DEAC979C3113E /* PromptContextSanitizer.swift */, 3609CC88A5280B3AA40414DF /* SuggestionAvailabilityEvaluator.swift */, + B2F95847D76893C8A5B504B4 /* SuggestionOverlayStabilityGate.swift */, DDE858CB1E687E3CEB8FDD5B /* SuggestionRequestFactory.swift */, CE0AA0503128B0FC3951D700 /* SuggestionSessionReconciler.swift */, 1CE61E74928C221B8BB261C6 /* SuggestionTextColorCodec.swift */, @@ -844,6 +850,7 @@ A982E243A4D8BC1BF7504B3E /* SuggestionInteractionState.swift in Sources */, 0AF568AB234033BA2DE4CAA7 /* SuggestionModels.swift in Sources */, 02DA43985CDAE6859014F14F /* SuggestionOverlayPresenter.swift in Sources */, + 0F3267956257401F39386773 /* SuggestionOverlayStabilityGate.swift in Sources */, 46F341472191BC451B6BF6B5 /* SuggestionRequestFactory.swift in Sources */, CA5B2D226FBAA5419E78F14F /* SuggestionSessionReconciler.swift in Sources */, 32A2915FAE21CD9CE818A9D9 /* SuggestionSettingsModel.swift in Sources */, @@ -901,6 +908,7 @@ 934885ACC2DEA20B27F10948 /* PromptContextSanitizerTests.swift in Sources */, 3CF1A4E39F24917DF0470A7D /* PromptPolicyTests.swift in Sources */, 88BCD795A14E1C9308F7BB31 /* SuggestionAvailabilityEvaluatorTests.swift in Sources */, + 4C6D8ED0A7B45D2EADF06DA5 /* SuggestionOverlayStabilityGateTests.swift in Sources */, B93AB7E845086F6FBB068369 /* SuggestionRequestFactoryTests.swift in Sources */, 7E9413CE7C999C4612348248 /* SuggestionSessionReconcilerTests.swift in Sources */, CB65A79F164269991FABC32E /* SuggestionStateHelperTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index ac38c65..b57b379 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -314,12 +314,28 @@ extension SuggestionCoordinator { } state = .ready(text: reconciledSession.remainingText, latency: reconciledSession.latency) - presentOverlay( - text: reconciledSession.remainingText, - at: liveContext.caretRect, - context: liveContext, - isRightToLeft: TextDirectionDetector.isRightToLeft(liveContext.precedingText) - ) + // Reconciliation runs both for legitimate context changes (window drag, field switch, + // user typing through the tail) and for the +30ms post-insertion AX refresh that fires + // after every Tab accept. In the post-insertion case the underlying state has not + // meaningfully changed (the overlay already shows the right tail at the predicted + // caret), but AX commonly returns a slightly different `caretRect` / `observedCharWidth` + // than the predicted pair. Re-rendering against those drifted measurements is what + // causes the visible one-frame "shift left and down then snap back" on accept. Hold the + // existing geometry whenever the field, text, and on-screen field bounds have not + // materially moved; the gate below still re-anchors on legitimate context changes. + if SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: overlayState, + newText: reconciledSession.remainingText, + newInputFrameRect: liveContext.inputFrameRect, + newFocusChangeSequence: liveContext.focusChangeSequence + ) { + presentOverlay( + text: reconciledSession.remainingText, + at: liveContext.caretRect, + context: liveContext, + isRightToLeft: TextDirectionDetector.isRightToLeft(liveContext.precedingText) + ) + } if let advancement { logStage( advancement.stage, diff --git a/Cotabby/Support/SuggestionOverlayStabilityGate.swift b/Cotabby/Support/SuggestionOverlayStabilityGate.swift new file mode 100644 index 0000000..b3ccc78 --- /dev/null +++ b/Cotabby/Support/SuggestionOverlayStabilityGate.swift @@ -0,0 +1,58 @@ +import CoreGraphics +import Foundation + +/// File overview: +/// Pure decision for whether a reconcile tick should reposition the visible ghost-text overlay. +/// +/// Why this file exists: +/// `SuggestionCoordinator` reconciles the active suggestion many times: on every focus poll, on +/// every settings publication, and on the +30ms post-insertion refresh that fires after each Tab +/// accept. The post-insertion path is the one that visibly hurts: AX commonly returns a slightly +/// drifted `caretRect` / `observedCharWidth` after a synthesized insertion, and re-rendering +/// against those drifted measurements is what causes the visible one-frame "shift left and down +/// then snap back" the user sees on accept. The gate below holds the existing geometry whenever +/// the field, text, and on-screen field bounds have not materially moved; legitimate context +/// changes (field switch, window drag, text change) still re-anchor. Keeping the rule outside the +/// coordinator means it can be unit-tested in isolation from any AppKit state. +enum SuggestionOverlayStabilityGate { + /// Slack absorbed when comparing `inputFrameRect` between renders. 1pt is enough to swallow + /// the sub-pixel noise that mixed Retina/non-Retina setups produce on consecutive AX reads + /// of the same field, while still catching whole-pixel movements from a real window drag. + private static let inputFrameTolerance: CGFloat = 1 + + /// Returns `true` when the coordinator should call `presentOverlay` for this reconcile tick. + /// Returns `false` to hold the existing overlay geometry exactly as it was last drawn. + /// + /// Re-anchor when: + /// - The overlay is currently hidden (this is a fresh show). + /// - The focus session changed (different field, or the same field after focus toggled). + /// - The displayed text changed (user partially accepted, or typed-through advanced the tail). + /// - The host editor's frame moved on screen (window drag, sheet appear, etc.). + static func shouldRePresent( + currentOverlay: OverlayState, + newText: String, + newInputFrameRect: CGRect?, + newFocusChangeSequence: UInt64 + ) -> Bool { + guard case let .visible(currentText, currentGeometry) = currentOverlay else { + return true + } + if currentGeometry.focusChangeSequence != newFocusChangeSequence { + return true + } + if currentText != newText { + return true + } + switch (currentGeometry.inputFrameRect, newInputFrameRect) { + case (nil, nil): + return false + case (nil, _), (_, nil): + return true + case let (old?, new?): + return abs(old.origin.x - new.origin.x) > inputFrameTolerance + || abs(old.origin.y - new.origin.y) > inputFrameTolerance + || abs(old.size.width - new.size.width) > inputFrameTolerance + || abs(old.size.height - new.size.height) > inputFrameTolerance + } + } +} diff --git a/CotabbyTests/SuggestionOverlayStabilityGateTests.swift b/CotabbyTests/SuggestionOverlayStabilityGateTests.swift new file mode 100644 index 0000000..3baee30 --- /dev/null +++ b/CotabbyTests/SuggestionOverlayStabilityGateTests.swift @@ -0,0 +1,164 @@ +import CoreGraphics +import XCTest +@testable import Cotabby + +/// Tests for the post-accept overlay-stability gate. +/// +/// The bug this gate fixes: after every Tab accept, AX returns slightly drifted `caretRect` / +/// `observedCharWidth` values for the same underlying field state. The +30ms post-insertion +/// reconcile used to call `presentOverlay` with those drifted values, producing a visible +/// one-frame "shift left and down then snap back". The gate stops the reconcile from +/// re-rendering when the field, text, and on-screen field bounds have not materially moved, +/// while still allowing legitimate context changes (window drag, field switch, text change) +/// to re-anchor the overlay. +final class SuggestionOverlayStabilityGateTests: XCTestCase { + private static let inputFrame = CGRect(x: 100, y: 200, width: 400, height: 32) + private static let caretRect = CGRect(x: 140, y: 210, width: 2, height: 18) + + private static func geometry( + caretRect: CGRect = caretRect, + inputFrameRect: CGRect? = inputFrame, + focusChangeSequence: UInt64 = 7 + ) -> SuggestionOverlayGeometry { + SuggestionOverlayGeometry( + caretRect: caretRect, + inputFrameRect: inputFrameRect, + caretQuality: .exact, + observedCharWidth: 8, + isRightToLeft: false, + focusChangeSequence: focusChangeSequence + ) + } + + func test_hiddenOverlay_alwaysReRenders() { + XCTAssertTrue( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: .hidden(reason: "idle"), + newText: "draft", + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 7 + ) + ) + } + + /// The exact scenario the gate exists for: text and field are identical, only the caret rect + /// has drifted by a sub-pixel amount in the latest AX read. Holding the geometry is what + /// prevents the post-accept jitter. + func test_sameFieldSameTextStableFrame_holdsGeometry() { + let current: OverlayState = .visible(text: "draft and send", geometry: Self.geometry()) + + XCTAssertFalse( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: "draft and send", + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 7 + ) + ) + } + + func test_focusSessionChanged_reAnchors() { + let current: OverlayState = .visible( + text: "draft and send", + geometry: Self.geometry(focusChangeSequence: 7) + ) + + XCTAssertTrue( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: "draft and send", + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 8 + ) + ) + } + + func test_displayedTextChanged_reAnchors() { + let current: OverlayState = .visible(text: "draft and send", geometry: Self.geometry()) + + XCTAssertTrue( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: "and send notes tomorrow", + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 7 + ) + ) + } + + /// Window-drag case: the field's screen frame moves by whole-pixel amounts. The gate must + /// re-anchor or the overlay will lag behind the dragged window. + func test_inputFrameMovedBeyondTolerance_reAnchors() { + let movedFrame = Self.inputFrame.offsetBy(dx: 12, dy: 0) + let current: OverlayState = .visible(text: "draft and send", geometry: Self.geometry()) + + XCTAssertTrue( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: "draft and send", + newInputFrameRect: movedFrame, + newFocusChangeSequence: 7 + ) + ) + } + + /// Sub-pixel noise inside the 1pt tolerance must be swallowed — this is the actual + /// post-accept regression we are guarding against. + func test_inputFrameSubPixelNoise_holdsGeometry() { + let nudgedFrame = Self.inputFrame.offsetBy(dx: 0.4, dy: -0.3) + let current: OverlayState = .visible(text: "draft and send", geometry: Self.geometry()) + + XCTAssertFalse( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: "draft and send", + newInputFrameRect: nudgedFrame, + newFocusChangeSequence: 7 + ) + ) + } + + func test_inputFrameAppearedOrDisappeared_reAnchors() { + let visibleWithFrame: OverlayState = .visible( + text: "draft and send", + geometry: Self.geometry(inputFrameRect: Self.inputFrame) + ) + let visibleWithoutFrame: OverlayState = .visible( + text: "draft and send", + geometry: Self.geometry(inputFrameRect: nil) + ) + + XCTAssertTrue( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: visibleWithFrame, + newText: "draft and send", + newInputFrameRect: nil, + newFocusChangeSequence: 7 + ) + ) + XCTAssertTrue( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: visibleWithoutFrame, + newText: "draft and send", + newInputFrameRect: Self.inputFrame, + newFocusChangeSequence: 7 + ) + ) + } + + func test_bothFramesNil_holdsGeometry() { + let current: OverlayState = .visible( + text: "draft and send", + geometry: Self.geometry(inputFrameRect: nil) + ) + + XCTAssertFalse( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: "draft and send", + newInputFrameRect: nil, + newFocusChangeSequence: 7 + ) + ) + } +} From 7b2e84a135cdbb5e676e58023ccec8edddb4b7d4 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Thu, 28 May 2026 02:22:34 -0700 Subject: [PATCH 2/2] Address Greptile review on overlay stability gate - Pin the 1pt tolerance contract with `test_inputFrameAtExactTolerance_holdsGeometry`. The gate uses a strict `>` comparison so a drift of exactly 1.0pt is held; a future change to `>=` will now flip this test instead of silently changing behavior. - Document why `observedCharWidth` is intentionally outside the gate. Including it would re-introduce the post-accept jitter this file exists to suppress; the drag-time tradeoff is acceptable and the right fix for any wrong-layout frame in practice would live in `GhostSuggestionLayout`. --- .../SuggestionOverlayStabilityGate.swift | 11 +++++- .../SuggestionOverlayStabilityGateTests.swift | 36 ++++++++++++++----- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/Cotabby/Support/SuggestionOverlayStabilityGate.swift b/Cotabby/Support/SuggestionOverlayStabilityGate.swift index b3ccc78..6270d23 100644 --- a/Cotabby/Support/SuggestionOverlayStabilityGate.swift +++ b/Cotabby/Support/SuggestionOverlayStabilityGate.swift @@ -34,7 +34,9 @@ enum SuggestionOverlayStabilityGate { newInputFrameRect: CGRect?, newFocusChangeSequence: UInt64 ) -> Bool { - guard case let .visible(currentText, currentGeometry) = currentOverlay else { + // Render mode is the third associated value; it is not part of the stability decision, so + // we ignore it. A mode change still re-anchors because text or geometry will also differ. + guard case let .visible(currentText, currentGeometry, _) = currentOverlay else { return true } if currentGeometry.focusChangeSequence != newFocusChangeSequence { @@ -43,6 +45,13 @@ enum SuggestionOverlayStabilityGate { if currentText != newText { return true } + // `observedCharWidth` is intentionally NOT compared here. Drift in that value also affects + // `GhostSuggestionLayout.singleLineFits` (and therefore the panel-origin branch), so during + // a sustained window drag where `inputFrameRect` also moves, the first re-anchor past the + // tolerance can render with a drifted char-width for one frame. Including char-width in the + // gate would re-introduce the post-accept jitter this file exists to suppress, so we accept + // the drag-time tradeoff. If a future host shows the wrong-layout frame in practice, the fix + // belongs in `GhostSuggestionLayout` (smoothing char-width) rather than this gate. switch (currentGeometry.inputFrameRect, newInputFrameRect) { case (nil, nil): return false diff --git a/CotabbyTests/SuggestionOverlayStabilityGateTests.swift b/CotabbyTests/SuggestionOverlayStabilityGateTests.swift index 3baee30..927b1ab 100644 --- a/CotabbyTests/SuggestionOverlayStabilityGateTests.swift +++ b/CotabbyTests/SuggestionOverlayStabilityGateTests.swift @@ -45,7 +45,7 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase { /// has drifted by a sub-pixel amount in the latest AX read. Holding the geometry is what /// prevents the post-accept jitter. func test_sameFieldSameTextStableFrame_holdsGeometry() { - let current: OverlayState = .visible(text: "draft and send", geometry: Self.geometry()) + let current: OverlayState = .visible(text: "draft and send", geometry: Self.geometry(), mode: .inline) XCTAssertFalse( SuggestionOverlayStabilityGate.shouldRePresent( @@ -60,7 +60,8 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase { func test_focusSessionChanged_reAnchors() { let current: OverlayState = .visible( text: "draft and send", - geometry: Self.geometry(focusChangeSequence: 7) + geometry: Self.geometry(focusChangeSequence: 7), + mode: .inline ) XCTAssertTrue( @@ -74,7 +75,7 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase { } func test_displayedTextChanged_reAnchors() { - let current: OverlayState = .visible(text: "draft and send", geometry: Self.geometry()) + let current: OverlayState = .visible(text: "draft and send", geometry: Self.geometry(), mode: .inline) XCTAssertTrue( SuggestionOverlayStabilityGate.shouldRePresent( @@ -90,7 +91,7 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase { /// re-anchor or the overlay will lag behind the dragged window. func test_inputFrameMovedBeyondTolerance_reAnchors() { let movedFrame = Self.inputFrame.offsetBy(dx: 12, dy: 0) - let current: OverlayState = .visible(text: "draft and send", geometry: Self.geometry()) + let current: OverlayState = .visible(text: "draft and send", geometry: Self.geometry(), mode: .inline) XCTAssertTrue( SuggestionOverlayStabilityGate.shouldRePresent( @@ -106,7 +107,7 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase { /// post-accept regression we are guarding against. func test_inputFrameSubPixelNoise_holdsGeometry() { let nudgedFrame = Self.inputFrame.offsetBy(dx: 0.4, dy: -0.3) - let current: OverlayState = .visible(text: "draft and send", geometry: Self.geometry()) + let current: OverlayState = .visible(text: "draft and send", geometry: Self.geometry(), mode: .inline) XCTAssertFalse( SuggestionOverlayStabilityGate.shouldRePresent( @@ -121,11 +122,13 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase { func test_inputFrameAppearedOrDisappeared_reAnchors() { let visibleWithFrame: OverlayState = .visible( text: "draft and send", - geometry: Self.geometry(inputFrameRect: Self.inputFrame) + geometry: Self.geometry(inputFrameRect: Self.inputFrame), + mode: .inline ) let visibleWithoutFrame: OverlayState = .visible( text: "draft and send", - geometry: Self.geometry(inputFrameRect: nil) + geometry: Self.geometry(inputFrameRect: nil), + mode: .inline ) XCTAssertTrue( @@ -146,10 +149,27 @@ final class SuggestionOverlayStabilityGateTests: XCTestCase { ) } + /// Exactly at the 1pt boundary: drift of 1.0pt is absorbed (strict `>` comparison). + /// Pins the contract so a future change to `>=` would flip a documented branch and fail here. + func test_inputFrameAtExactTolerance_holdsGeometry() { + let exactFrame = Self.inputFrame.offsetBy(dx: 1.0, dy: 0) + let current: OverlayState = .visible(text: "draft and send", geometry: Self.geometry(), mode: .inline) + + XCTAssertFalse( + SuggestionOverlayStabilityGate.shouldRePresent( + currentOverlay: current, + newText: "draft and send", + newInputFrameRect: exactFrame, + newFocusChangeSequence: 7 + ) + ) + } + func test_bothFramesNil_holdsGeometry() { let current: OverlayState = .visible( text: "draft and send", - geometry: Self.geometry(inputFrameRect: nil) + geometry: Self.geometry(inputFrameRect: nil), + mode: .inline ) XCTAssertFalse(