Fix accept keybind swallowing keystroke during background refresh#345
Merged
Conversation
The accept-keybind path guarded on `state == .ready`, but `state` can transition to `.debouncing` / `.generating` while a previously ready suggestion is still buffered and its overlay is still on screen — most notably when the visual-context coordinator finishes OCR and calls `schedulePredictionForCurrentFocusIfPossible`. The accept tap is installed for the duration of overlay visibility, so the keystroke is consumed by the tap while `passTabThrough` runs in the coordinator: no text is inserted, and the in-flight background regeneration then replaces the suggestion the user was trying to take. Gate on `interactionState.activeSession` instead. The existing `validateSessionForAcceptance` reconciliation still rejects the accept when the live AX state no longer matches the buffered session.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The accept keybind was gated on
state == .readyinSuggestionCoordinator+Acceptance.swift, but the state machine transitions to.debouncing/.generatingwhile a previously ready suggestion is still buffered and its overlay is still on screen. This happens most reliably when the visual-context coordinator finishes OCR and callsschedulePredictionForCurrentFocusIfPossible, which firesschedulePrediction()without first clearing the active session or hiding the overlay. Because the accept tap is installed for the duration of overlay visibility, the user's accept keystroke is consumed by the tap,passTabThroughruns (no insertion), and the in-flight background regeneration then replaces the suggestion the user was trying to take. End result matches the reported "swallowed accept + regeneration" symptom.Gate on
interactionState.activeSessioninstead.validateSessionForAcceptancestill reconciles against the live AX snapshot and rejects the accept when the buffered session no longer matches the field.Validation
test-without-buildingfailed to loadCotabbyTests.xctestwith the local-signing Team ID mismatch (xctest bundle signed with the user dev cert but loaded into the host app process). CLAUDE.md calls this out explicitly as a local-environment failure, not a code regression, and instructs to proceed oncebuild-for-testingsucceeds.Linked issues
None filed for this specific symptom yet.
Risk / rollout notes
Behavioral change is narrow: an accept keystroke that previously fell into
passTabThroughsolely becausestatewas.debouncing/.generating(whileactiveSessionwas still set) will now flow intoprepareAcceptanceand attempt to insert.validateSessionForAcceptancestill rejects on selected text, prefix/trailing-text divergence, process change, or session exhaustion, so a stale suggestion does not get inserted into a field the user has since edited. Every site that clearsactiveSessionalso hides the overlay, so the existing invariant "overlay visible iffactiveSession != nil" still drives the accept tap's lifecycle.Greptile Summary
This PR fixes a keystroke-swallowing bug where a Tab/accept keypress was silently dropped during a background suggestion refresh. The previous gate (
guard case .ready = state) was too strict becauseschedulePrediction()transitionsstateto.debouncingwithout clearingactiveSessionor hiding the overlay, so the accept tap remained installed but the keystroke fell through topassTabThroughinstead ofprepareAcceptance.acceptSuggestionnow checksinteractionState.activeSession != nilinstead ofstate == .ready, allowing acceptance when a previous suggestion is buffered and on screen even while a background regeneration is in flight.validateSessionForAcceptancestill reconciles against the live AX snapshot and rejects accepts on selected text, prefix/trailing-text divergence, process change, and exhausted sessions — so no stale suggestion can be incorrectly inserted.activeSession = nilalso callshideOverlay, and the accept tap is driven by overlay visibility; soactiveSession != nil ↔ overlay visiblecontinues to hold.Confidence Score: 5/5
Safe to merge; the narrowly scoped guard change is backed by robust session-reconciliation validation and the accept tap's existing lifecycle invariants.
The one-line change is correct:
activeSession != nilaccurately captures "a buffered suggestion is still available," while the oldstate == .readywas incorrectly rejecting accepts during background refreshes.validateSessionForAcceptanceprovides multiple independent rejection conditions (selected text, prefix divergence, process change, exhausted session) so no stale suggestion can slip through. AllactiveSession = nilsites also callhideOverlay, preserving the overlay-visibility invariant that gates the accept tap. The@MainActorisolation means there are no race conditions between the async generation task and the synchronous accept path.No files require special attention.
Important Files Changed
state == .readyguard withactiveSession != nil; the one-line logic change is correct and well-protected by downstreamvalidateSessionForAcceptancereconciliation.Sequence Diagram
sequenceDiagram participant User participant AcceptTap as Accept Tap (overlay visible) participant acceptSuggestion participant validate as validateSessionForAcceptance participant schedulePrediction Note over schedulePrediction: OCR finishes → schedulePrediction() called schedulePrediction->>schedulePrediction: "state = .debouncing (activeSession still set)" User->>AcceptTap: Tab keystroke AcceptTap->>acceptSuggestion: acceptSuggestion() Note over acceptSuggestion: OLD: guard case .ready = state → passTabThrough (swallowed) Note over acceptSuggestion: NEW: guard activeSession != nil → proceeds acceptSuggestion->>validate: prepareAcceptance(from: snapshot) validate->>validate: reconcile vs live AX snapshot alt reconciliation passes validate-->>acceptSuggestion: .ready(liveContext, session, chunk) acceptSuggestion->>acceptSuggestion: insert chunk, cancelPredictionWork() acceptSuggestion-->>User: suggestion accepted else reconciliation fails validate-->>acceptSuggestion: .invalid(reason) acceptSuggestion-->>User: passTabThrough (safe fallback) endReviews (1): Last reviewed commit: "Gate accept on the live session instead ..." | Re-trigger Greptile