Skip to content

Delay post-divergence prediction so Chromium AX can catch up#376

Merged
FuJacob merged 1 commit into
mainfrom
fix/chrome-textmutation-defer
May 28, 2026
Merged

Delay post-divergence prediction so Chromium AX can catch up#376
FuJacob merged 1 commit into
mainfrom
fix/chrome-textmutation-defer

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented May 28, 2026

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 .textMutation and .shortcutMutation paths call focusModel.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() uses replaceDebouncedWork internally so back-to-back keystrokes still collapse cleanly.

Greptile Summary

Adds a 150ms deferred reschedule path (schedulePredictionAfterHostPublishDelay) for the active-session divergence cases (.textMutation and .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.

  • The delay is applied only on the active-session path — the no-active-session path retains its immediate refreshNow() + schedulePrediction() call, consistent with its existing comment about capturing the freshest snapshot at keystroke time.
  • schedulePrediction() internally uses replaceDebouncedWork, 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 schedulePredictionAfterHostPublishDelay enqueues a new non-cancellable asyncAfter closure on every divergence, so rapid typing accumulates multiple closures that each call focusModel.refreshNow(). The redundant AX reads are harmless in practice because replaceDebouncedWork coalesces the actual prediction work, but replacing asyncAfter with a stored, cancellable DispatchWorkItem would 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

Filename Overview
Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift Introduces schedulePredictionAfterHostPublishDelay() to defer AX refresh + prediction by 150ms on active-session divergence; the delay is correct and well-targeted, but the helper uses non-cancellable asyncAfter closures 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 suggestion
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Delay post-divergence reschedule so Chro..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

@FuJacob FuJacob merged commit f9a59b0 into main May 28, 2026
@FuJacob FuJacob deleted the fix/chrome-textmutation-defer branch May 28, 2026 11:16
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()
}
}
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 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)
}

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