Delay post-divergence prediction so Chromium AX can catch up#376
Merged
Conversation
Comment on lines
+185
to
+191
| private func schedulePredictionAfterHostPublishDelay() { | ||
| DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150)) { [weak self] in | ||
| guard let self else { return } | ||
| self.focusModel.refreshNow() | ||
| self.schedulePrediction() | ||
| } | ||
| } |
Contributor
There was a problem hiding this comment.
Non-cancellable
asyncAfter closures accumulate with rapid divergences. Every call to schedulePredictionAfterHostPublishDelay() enqueues a new, non-cancellable closure. With N rapid divergences, N closures will fire in quick succession — each calling focusModel.refreshNow() and schedulePrediction(). While replaceDebouncedWork inside schedulePrediction() collapses the prediction work correctly, the refreshNow() calls are not coalesced and will each trigger an AX read. Storing a cancellable DispatchWorkItem and cancelling the previous one before enqueuing a new one avoids the redundant reads.
Suggested change
| private func schedulePredictionAfterHostPublishDelay() { | |
| DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150)) { [weak self] in | |
| guard let self else { return } | |
| self.focusModel.refreshNow() | |
| self.schedulePrediction() | |
| } | |
| } | |
| private var pendingHostPublishDelayItem: DispatchWorkItem? | |
| private func schedulePredictionAfterHostPublishDelay() { | |
| pendingHostPublishDelayItem?.cancel() | |
| let item = DispatchWorkItem { [weak self] in | |
| guard let self else { return } | |
| self.focusModel.refreshNow() | |
| self.schedulePrediction() | |
| } | |
| pendingHostPublishDelayItem = item | |
| DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150), execute: item) | |
| } |
This was referenced May 28, 2026
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.
User report: in Chrome / Electron inputs, pressing a non-accept key with a suggestion visible feels like Cotabby swallows the key and just regenerates a new suggestion that ignores what was typed.
Root cause: the active-session
.textMutationand.shortcutMutationpaths callfocusModel.refreshNow()+schedulePrediction()synchronously from inside the CGEvent tap. The tap runs BEFORE the host app processes the keystroke. Chromium-based editors then take 100-200ms to publish the new contenteditable text to AX. So generation reads pre-keystroke AX, makes a suggestion against stale text, and visually erases the user's input.Fix: defer the post-divergence reschedule by 150ms. The active suggestion still invalidates immediately (overlay clears) so the user has no stale ghost text in their way; only the regeneration is held back long enough for AX to settle.
schedulePrediction()usesreplaceDebouncedWorkinternally so back-to-back keystrokes still collapse cleanly.Greptile Summary
Adds a 150ms deferred reschedule path (
schedulePredictionAfterHostPublishDelay) for the active-session divergence cases (.textMutationand.shortcutMutation) so that Chromium-based editors have time to publish new contenteditable text to AX before prediction fires. The overlay is still cleared synchronously on divergence; only the regeneration is held back.refreshNow()+schedulePrediction()call, consistent with its existing comment about capturing the freshest snapshot at keystroke time.schedulePrediction()internally usesreplaceDebouncedWork, so back-to-back keystrokes will collapse into a single prediction pass even with multiple deferred closures queued.Confidence Score: 4/5
Safe to merge; the fix correctly targets the Chromium AX timing race and does not touch any prediction logic, acceptance flows, or non-active-session paths.
The change is small and well-scoped. The one thing worth a second look is that
schedulePredictionAfterHostPublishDelayenqueues a new non-cancellableasyncAfterclosure on every divergence, so rapid typing accumulates multiple closures that each callfocusModel.refreshNow(). The redundant AX reads are harmless in practice becausereplaceDebouncedWorkcoalesces the actual prediction work, but replacingasyncAfterwith a stored, cancellableDispatchWorkItemwould make the pattern cleaner and avoid unnecessary overhead.No files require special attention beyond the single helper method added in
SuggestionCoordinator+Input.swift.Important Files Changed
schedulePredictionAfterHostPublishDelay()to defer AX refresh + prediction by 150ms on active-session divergence; the delay is correct and well-targeted, but the helper uses non-cancellableasyncAfterclosures that accumulate with rapid keystrokes.Sequence Diagram
sequenceDiagram participant User participant CGEventTap as CGEvent Tap participant Coordinator as SuggestionCoordinator participant MainQueue as DispatchQueue.main participant FocusModel as focusModel participant Engine as SuggestionEngine User->>CGEventTap: keypress (diverges from suggestion) CGEventTap->>Coordinator: handleInputEvent(_:with:) Coordinator->>Coordinator: invalidateActiveSuggestion() — overlay clears immediately Coordinator->>MainQueue: asyncAfter(+150ms) Note over CGEventTap,Engine: Host app processes keystroke and publishes new AX text (~100–200ms) MainQueue-->>Coordinator: fires after 150ms Coordinator->>FocusModel: refreshNow() — reads settled AX state Coordinator->>Coordinator: schedulePrediction() → replaceDebouncedWork(delay: debounceMs) Note over Coordinator,Engine: After debounce window... Coordinator->>FocusModel: refreshNow() — final AX read inside generateFromCurrentFocus Coordinator->>Engine: generate(request) — uses post-keystroke text Engine-->>Coordinator: suggestion Coordinator-->>User: overlay shows correct suggestionReviews (1): Last reviewed commit: "Delay post-divergence reschedule so Chro..." | Re-trigger Greptile