Skip to content

Apply Chromium AX-publish delay to no-session keystroke path#378

Merged
FuJacob merged 1 commit into
mainfrom
fix/chrome-keystroke-swallow-no-session
May 28, 2026
Merged

Apply Chromium AX-publish delay to no-session keystroke path#378
FuJacob merged 1 commit into
mainfrom
fix/chrome-keystroke-swallow-no-session

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented May 28, 2026

Summary

PR #376 routed the with-session textMutation / shortcutMutation reschedules through schedulePredictionAfterHostPublishDelay so Chromium-based editors (Chrome, Edge, Slack, Discord, Notion, every Electron app) have time to publish the new contenteditable text to AX before generation reads it. The no-session path in handleInputEvent still called focusModel.refreshNow() + schedulePrediction() synchronously from inside the CGEvent tap, which reproduces the same "Cotabby swallowed my key" symptom on the first keystroke into a field and on rapid follow-up typing after invalidateActiveSuggestion clears the session.

Validation

xcodebuild build -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
** BUILD SUCCEEDED **

swiftlint lint --quiet → no violations.

Manual: typing rapidly into a Chrome contenteditable while the previous suggestion is mid-flight; new suggestion now reflects the most recently typed character instead of the pre-keystroke text.

Linked issues

Refs #376.

Risk / rollout notes

  • The immediate focusModel.refreshNow() is dropped from this branch; schedulePredictionAfterHostPublishDelay calls focusModel.refreshNow() inside its main-queue trampoline, so AX freshness is preserved at the point that actually matters (right before schedulePrediction materializes the request).
  • 150ms extra latency before generation begins on a fresh keystroke — measured against the previous 0ms it costs the new prediction-trigger one debounce window. Empirically this still feels responsive because the suggestion was generally invisible at this point anyway (no overlay yet for the no-session case).
  • No protocol or test-fixture changes.

Greptile Summary

This PR extends the Chromium AX-publish delay fix from PR #376 to cover the no-session keystroke path in handleInputEvent. The synchronous focusModel.refreshNow() + schedulePrediction() pair that ran directly from inside the CGEvent tap is replaced with schedulePredictionAfterHostPublishDelay(), which defers both calls 150ms onto the main queue so Chromium-based editors have time to publish updated contenteditable text to AX before generation reads it.

  • The no-session entry point now uses the same schedulePredictionAfterHostPublishDelay() helper already wired into the with-session textMutation and shortcutMutation paths.
  • schedulePrediction() retains its own SuggestionAvailabilityEvaluator.disabledReason guard, and generateFromCurrentFocus calls focusModel.refreshNow() again after the debounce, so the 150ms window does not introduce a stale-context hazard.

Confidence Score: 5/5

Safe to merge. The one-line change is a minimal, focused extension of an already-validated pattern.

The diff is a single call-site replacement that mirrors the with-session fix from PR #376 exactly. schedulePrediction() has its own availability guard, and generateFromCurrentFocus calls focusModel.refreshNow() again after the debounce, so neither the 150ms window nor the removed eager refresh can leave the coordinator in a bad state.

No files require special attention.

Important Files Changed

Filename Overview
Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift Replaces the synchronous refreshNow()+schedulePrediction() pair in the no-session path with schedulePredictionAfterHostPublishDelay(), bringing parity with the with-session fix from PR #376. Change is minimal and logically consistent with the existing helper.

Sequence Diagram

sequenceDiagram
    participant CGEventTap
    participant handleInputEvent
    participant MainQueue
    participant focusModel
    participant schedulePrediction
    participant generateFromCurrentFocus

    Note over CGEventTap,handleInputEvent: Before this PR (no-session path)
    CGEventTap->>handleInputEvent: "event (shouldSchedulePrediction=true)"
    handleInputEvent->>focusModel: refreshNow() [pre-keystroke AX state!]
    handleInputEvent->>schedulePrediction: schedulePrediction()
    schedulePrediction->>generateFromCurrentFocus: "debounce -> generate (stale text)"

    Note over CGEventTap,generateFromCurrentFocus: After this PR (no-session path)
    CGEventTap->>handleInputEvent: "event (shouldSchedulePrediction=true)"
    handleInputEvent->>MainQueue: asyncAfter(+150ms)
    Note over MainQueue: host app processes keystroke, Chromium AX tree updates
    MainQueue->>focusModel: refreshNow() [post-keystroke AX state]
    MainQueue->>schedulePrediction: schedulePrediction()
    schedulePrediction->>generateFromCurrentFocus: "debounce -> refreshNow() again -> generate (correct text)"
Loading

Reviews (1): Last reviewed commit: "Apply Chromium AX-publish delay to no-se..." | Re-trigger Greptile

PR #376 routed the with-session textMutation / shortcutMutation reschedules
through schedulePredictionAfterHostPublishDelay so Chromium-based editors
(Chrome, Edge, Slack, Discord, Notion, every Electron app) have time to
publish the new contenteditable text to AX before generation reads it.
The no-session path in handleInputEvent still called focusModel.refreshNow()
+ schedulePrediction() synchronously from inside the CGEvent tap.

In practice this is reached two ways:

- Fresh keystroke into a field that does not yet have an active suggestion
  (after dismissal, focus arrival, or the Tab that exhausted the last
  suggestion).
- Rapid follow-up typing — the previous keystroke just ran the with-session
  branch and called invalidateActiveSuggestion, which clears the session.
  The next keystroke arrives within the 150ms publish-delay window and
  finds activeSession == nil, falling through to this no-session path.

Both produce the same "Cotabby swallowed my key" symptom: the new
suggestion appears, but it reflects the pre-keystroke text because the
synchronous AX read in the CGEvent tap fired before the host app had a
chance to write the new character. Sharing the same delay closes the gap.

The immediate refreshNow() is dropped; schedulePredictionAfterHostPublishDelay
calls focusModel.refreshNow() inside its main-queue trampoline, so AX freshness
is preserved at the point that actually matters (right before schedulePrediction
materializes the request).
@FuJacob FuJacob merged commit 84e32e2 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