Skip to content

Fix last tab issue#385

Merged
Jam-Cai merged 1 commit into
mainfrom
james/fix-tabbing-on-last-suggestion
May 28, 2026
Merged

Fix last tab issue#385
Jam-Cai merged 1 commit into
mainfrom
james/fix-tabbing-on-last-suggestion

Conversation

@Jam-Cai

@Jam-Cai Jam-Cai commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Validation

Linked issues

Risk / rollout notes

Greptile Summary

This PR fixes a race condition where Chrome (and similar apps) would receive a spurious Tab keystroke after Cotabby inserted text on the final suggestion chunk. The root cause is the ordering of the two event taps: the listen-only observer (head-insert) fires first and can trigger overlay dismissal — which previously destroyed the accept tap — before the active accept tap (tail-append) had a chance to swallow the same physical Tab.

  • Three new instance-state fields (pendingObserverAcceptedKeyEvent, isHandlingAcceptKeyObserverEvent, shouldRemoveAcceptTapAfterPendingEvent) coordinate deferred teardown: destroyAcceptTap() now defaults to deferring removal when called from inside an observer accept-key callback, and the accept tap itself handles the deferred cleanup once it has consumed the pending key.
  • All explicit stop(), refresh(), and permission-revoked call-sites pass deferDuringCurrentAcceptKeyEvent: false to preserve the previous immediate-teardown semantics for those paths.

Confidence Score: 4/5

The change is confined to a single file and the happy path is well-reasoned; the main residual risk is a stale pending-event record if the accept tap is system-disabled at a narrow moment between the observer and accept-tap callbacks.

The fix correctly threads three new state flags through the observer and accept tap callbacks on the MainActor, preserving all existing safeguards for every path except the new deferred-consume path where those guards are intentionally bypassed because the decision was already made by the observer. Two minor robustness gaps exist: the system-tap-disabled re-enable path does not clear the pending event record, and the two consumption log messages are identical, making post-hoc log analysis harder.

Cotabby/Services/Input/InputMonitor.swift — specifically the .tapDisabledByTimeout branch in handleAcceptTap and the duplicate debug log string near the new pending-event consumption site.

Important Files Changed

Filename Overview
Cotabby/Services/Input/InputMonitor.swift Adds deferred accept-tap teardown and a pending-event record to prevent the last Tab keystroke from leaking to the host app when the overlay is dismissed inside the observer callback. Three new instance-state fields coordinate the two-tap pipeline; logic is correct for the primary flow with two minor robustness gaps.

Sequence Diagram

sequenceDiagram
    participant User as User presses Tab
    participant OT as Observer Tap head listen-only
    participant Coord as Coordinator onEvent
    participant AT as Accept Tap tail active
    participant App as Host App

    User->>OT: keyDown Tab
    OT->>OT: classify as .acceptance
    OT->>OT: "isHandlingAcceptKeyObserverEvent = true"
    OT->>Coord: onEvent returns true
    Coord->>Coord: hide overlay
    Coord->>OT: setAcceptInterceptionActive false deferred
    OT->>OT: set pendingObserverAcceptedKeyEvent
    OT->>OT: defer clears flag skip immediate destroy
    OT-->>AT: event propagates in chain
    AT->>AT: key matches accept binding
    AT->>AT: pendingObserverAcceptedKeyEvent matches cleared
    AT->>AT: shouldRemoveAcceptTapAfterPendingEvent destroyAcceptTapImmediately
    AT-->>App: return nil event consumed
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Fix last tab issue" | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

@Jam-Cai Jam-Cai marked this pull request as ready for review May 28, 2026 18:55
Comment on lines +348 to +350
CotabbyLogger.app.debug(
"Accept tap consumed keyCode=\(keyCode) modifiers=\(eventModifiers.rawValue)"
)

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.

P2 Duplicate log message makes the two consumption paths indistinguishable

The new deferred-pending-event path (around line 349) emits the exact same .debug string as the original Layer-2 consumption path (~line 384). When grepping logs to diagnose a timing issue it is impossible to tell which path fired. Adding a short qualifier like "pending" to the new path's message would make the two branches immediately distinguishable without any other changes.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

Comment on lines +303 to +308
if isAcceptKeyEvent, shouldConsume, acceptTap != nil {
pendingObserverAcceptedKeyEvent = PendingAcceptedKeyEvent(
keyCode: capturedEvent.keyCode,
modifiers: ShortcutModifierMask(eventFlags: capturedEvent.flags)
)
}

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.

P2 pendingObserverAcceptedKeyEvent has no cleanup path when the accept tap is system-disabled mid-flight

If the system disables the accept tap (.tapDisabledByTimeout) between the observer callback setting pendingObserverAcceptedKeyEvent and the tap's keyDown handler running, handleAcceptTap receives the disable notification, re-enables the tap, and returns — without clearing pendingObserverAcceptedKeyEvent. macOS should re-deliver the original keyDown after re-enable so the pending event is consumed in the next callback, but if the event is somehow dropped (e.g. very short tap disable window), the accept tap stays alive indefinitely with a stale pending entry and shouldRemoveAcceptTapAfterPendingEvent = true. The tap will then consume and destroy itself on the next Tab press even after a new suggestion session starts, leaving the new session's accept tap unexpectedly gone. Clearing pendingObserverAcceptedKeyEvent (and resetting shouldRemoveAcceptTapAfterPendingEvent) in the .tapDisabledByTimeout / .tapDisabledByUserInput branch of handleAcceptTap would close this window.

Fix in Codex Fix in Claude Code

@Jam-Cai Jam-Cai merged commit 60dac12 into main May 28, 2026
4 checks passed
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