Skip to content

Replay swallowed Tab when coordinator bails on acceptance#363

Merged
FuJacob merged 1 commit into
mainfrom
fix/replay-consumed-tab-on-bail
May 28, 2026
Merged

Replay swallowed Tab when coordinator bails on acceptance#363
FuJacob merged 1 commit into
mainfrom
fix/replay-consumed-tab-on-bail

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented May 28, 2026

Summary

Investigation in this conversation traced the "sometimes pressing Tab in Gmail doesn't accept and misses the capture" symptom to a contract violation in our acceptance path.

After #337 the active accept tap installs whenever the suggestion overlay is visible and unconditionally consumes Tab as long as it's there. But the coordinator's decision to actually accept runs later in acceptSuggestion (SuggestionCoordinator+Acceptance.swift:22) and can still bail along several paths:

  1. AX capability flipped to unsupported between show and Tab.
  2. interactionState.activeSession == nil (background refresh nuked the session while overlay was still up).
  3. prepareAcceptance returns .invalid because the live preceding text no longer reconciles with the session's snapshot.

(3) is the Gmail one. Gmail compose is contentEditable rendered in Chrome/Safari/Arc; browser AX preceding-text reads occasionally lag selection state by a frame, so the reconciliation check returns .invalid more often there. When that happens the coordinator calls passTabThrough(reason:) — which, despite the name, only hides the overlay and clears state. Tab was already gone, swallowed by the tap. From Gmail's perspective the keystroke never arrived; from the user's perspective Cotabby ate their Tab.

This PR makes passTabThrough honest: thread the original CapturedInputEvent from handleInputEvent down through acceptCurrentSuggestion/acceptEntireSuggestion into passTabThrough, and after hideOverlay tears down the accept tap, re-post the captured key to the focused app via the new InputMonitor.replayConsumedAcceptKey(keyCode:flags:). Suppression is armed beforehand so our observer tap recognizes the replay as Cotabby's own work instead of treating it as fresh input.

Validation

  • swiftlint lint --quiet → exit 0
  • xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' build → ** BUILD SUCCEEDED **
  • xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' build-for-testing → ** TEST BUILD SUCCEEDED **

Manual verification needed:

  • Gmail compose: type, wait for a suggestion, press Tab → if Cotabby reconciles → suggestion inserts. If Cotabby bails → focus moves (Gmail's native Tab behavior), instead of nothing happening.
  • Native AppKit text field (TextEdit / Notes): regression check, Tab still inserts the suggestion when there is one.
  • After accept, no double-Tab side effects: the suppression token is consumed exactly once per replay.

Linked issues

Refs #337 (which introduced the consume-before-confirm tap split).

Risk / rollout notes

  • The replay posts at .cghidEventTap, identical to how SuggestionInserter injects accepted suggestion text. Same suppression mechanism, so no new loop risk.
  • The originalEvent parameter has a default value of nil everywhere it was added, so any existing internal caller that doesn't have an event (none today, but defensively) still compiles.
  • If replayConsumedAcceptKey fails to construct a synthetic event (extremely unlikely) we log a warning and silently drop — same user-visible behavior as today, no regression.

Greptile Summary

This PR fixes a real usability bug where pressing Tab in Gmail (and similar browser-hosted contentEditable fields) would silently swallow the keystroke when Cotabby's AX reconciliation check rejected the accept. The fix threads the original CapturedInputEvent through the bail paths in acceptSuggestion and re-posts it via a new replayConsumedAcceptKey method after the accept tap is torn down.

  • InputMonitor.replayConsumedAcceptKey synthesizes a new keyDown+keyUp pair at .cghidEventTap (matching the existing SuggestionInserter pattern) and arms InputSuppressionController beforehand so the observer tap treats the replay as Cotabby's own synthetic work rather than fresh user input.
  • passTabThrough now accepts an optional replay: CapturedInputEvent? parameter, and each bail branch in acceptSuggestion forwards originalEvent into it; the default value of nil keeps all existing callers source-compatible.
  • SuggestionCoordinator+Input.swift unconditionally passes originalEvent: event regardless of whether the overlay was visible — and therefore whether the accept tap was installed. When no suggestion is showing the accept tap is not active, the original Tab already reaches the focused app, and replayConsumedAcceptKey posts a second Tab, producing a double-keystroke on every Tab press in a text field with no suggestion active.

Confidence Score: 3/5

Not safe to merge as-is: the fix correctly handles the Gmail bail case but introduces a double-Tab regression on every Tab press in any text field where no suggestion is currently showing.

The replay mechanism in InputMonitor and the passTabThrough changes are well-structured, but SuggestionCoordinator+Input.swift passes originalEvent: event unconditionally — including when the overlay is hidden and the accept tap was never installed. In that case the original Tab keyDown travels through to the focused app normally, and replayConsumedAcceptKey then posts a second Tab. The suppression token is consumed by the replayed keyDown at the observer tap (not the original), so the focused app sees both events. This would manifest as every Tab press in an enabled text field producing two Tab events whenever Cotabby has no active suggestion, which covers the vast majority of Tab presses throughout the day.

Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift — the two lines that pass originalEvent: event need to be gated on overlayState.isVisible.

Important Files Changed

Filename Overview
Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift Passes originalEvent unconditionally to the acceptance path regardless of overlay visibility, causing replayConsumedAcceptKey to fire — and double-Tab — on every Tab press when no suggestion is active.
Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift Threads originalEvent through all bail paths in acceptSuggestion into passTabThrough; the replay call is correctly placed after hideOverlay (which synchronously tears down the accept tap via the onStateChange chain), so the replayed Tab won't be re-consumed.
Cotabby/Services/Input/InputMonitor.swift New replayConsumedAcceptKey arms suppression for exactly one keyDown before posting the synthesized keyDown+keyUp pair at .cghidEventTap, consistent with how SuggestionInserter handles accepted text.
Cotabby/Models/SuggestionSubsystemContracts.swift Adds replayConsumedAcceptKey to the SuggestionInputMonitoring protocol with a clear doc comment; no issues.

Sequence Diagram

sequenceDiagram
    participant User
    participant HID as HID Event Stream
    participant OT as Observer Tap (head, listen-only)
    participant AT as Accept Tap (tail, active)
    participant Coord as SuggestionCoordinator
    participant IM as InputMonitor
    participant SC as SuppressionController
    participant App as Focused App (e.g. Gmail)

    Note over User,App: Happy path — overlay visible, reconciliation succeeds
    User->>HID: Tab keyDown
    HID->>OT: keyDown
    OT->>Coord: handleInputEvent(.acceptance, event)
    Coord->>Coord: acceptSuggestion → prepareAcceptance → .ready
    Coord->>App: insert suggestion text (via SuggestionInserter)
    HID->>AT: keyDown
    AT-->>HID: nil (consumed)

    Note over User,App: Bail path (this PR) — overlay visible, reconciliation returns .invalid
    User->>HID: Tab keyDown
    HID->>OT: keyDown
    OT->>Coord: handleInputEvent(.acceptance, event)
    Coord->>Coord: acceptSuggestion → passTabThrough(replay: event)
    Coord->>Coord: hideOverlay → destroyAcceptTap (synchronous)
    Coord->>IM: replayConsumedAcceptKey(keyCode, flags)
    IM->>SC: registerSyntheticInsertion(expectedKeyDownCount: 1)
    IM->>HID: post keyDown (replay)
    IM->>HID: post keyUp (replay)
    HID->>AT: keyDown (original — accept tap fires despite invalidation)
    AT-->>HID: nil (consumed)
    HID->>OT: keyDown (replay)
    OT->>SC: consumeIfNeeded() → true
    OT-->>HID: passthrough (listen-only)
    HID->>App: replay keyDown
    HID->>App: replay keyUp

    Note over User,App: Bug (no overlay) — accept tap not installed, originalEvent passed unconditionally
    User->>HID: Tab keyDown (no suggestion visible)
    HID->>OT: keyDown
    OT->>Coord: handleInputEvent(.acceptance, event)
    Coord->>Coord: "activeSession==nil → passTabThrough(replay: event)"
    Coord->>IM: replayConsumedAcceptKey (accept tap was never installed!)
    IM->>HID: post keyDown (replay)
    HID->>App: original keyDown
    HID->>App: original keyUp
    HID->>App: replay keyDown (double-Tab)
    HID->>App: replay keyUp
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Replay swallowed Tab when coordinator ba..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

passTabThrough was misnamed: once the active accept tap consumed Tab,
the function only cleared overlay state — the focused app never saw
the keystroke. In Gmail (and most browser-rendered fields) the
prepareAcceptance reconciliation check returns .invalid often enough
that users perceive Cotabby as randomly eating their Tab keypress
without inserting or moving focus.

Thread the original CapturedInputEvent from handleInputEvent down
through acceptCurrentSuggestion / acceptEntireSuggestion into
passTabThrough, and have it call the new
InputMonitor.replayConsumedAcceptKey after hideOverlay tears down the
accept tap. Suppression is armed before the synthetic post so the
observer tap recognizes the replay as Cotabby's own work.
@FuJacob FuJacob merged commit b54b636 into main May 28, 2026
4 checks passed
Comment on lines 141 to 149
}

if event.kind == .acceptance {
return acceptCurrentSuggestion()
return acceptCurrentSuggestion(originalEvent: event)
}

if event.kind == .fullAcceptance {
return acceptEntireSuggestion()
return acceptEntireSuggestion(originalEvent: event)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Double-keystroke regression when no suggestion is visible

originalEvent is forwarded unconditionally to acceptCurrentSuggestion/acceptEntireSuggestion. When the overlay is hidden the accept tap is not installed, so the original Tab reaches the focused app normally. But if interactionState.activeSession == nil (the typical state when nothing is suggested), acceptSuggestion falls through to passTabThrough(replay: originalEvent), which calls replayConsumedAcceptKey and posts a second Tab to the HID stream. The focused app then receives the original Tab keyDown plus the replayed one — a silent double-Tab on every Tab press without an active suggestion.

The replay should only be dispatched when the accept tap was actually armed, i.e., when the overlay was visible at the moment the keystroke arrived. Gating on overlayState.isVisible before passing originalEvent closes the gap: if the overlay is already hidden the accept tap never consumed the event, so there is nothing to replay.

Suggested change
}
if event.kind == .acceptance {
return acceptCurrentSuggestion()
return acceptCurrentSuggestion(originalEvent: event)
}
if event.kind == .fullAcceptance {
return acceptEntireSuggestion()
return acceptEntireSuggestion(originalEvent: event)
}
if event.kind == .acceptance {
return acceptCurrentSuggestion(originalEvent: overlayState.isVisible ? event : nil)
}
if event.kind == .fullAcceptance {
return acceptEntireSuggestion(originalEvent: overlayState.isVisible ? event : nil)
}

Fix in Codex Fix in Claude Code

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