Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

func handleFocusSnapshotChange(_ snapshot: FocusSnapshot) {
CotabbyLogger.suggestion.trace(
"Focus snapshot changed: app=\(snapshot.applicationName) capability=\(snapshot.capability.shortLabel) \(focusDiagnostics(for: snapshot))"

Check warning on line 28 in Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line should be 140 characters or less; currently it has 149 characters (line_length)
)
// Start capturing visual context for a newly focused input even when predictions are
// temporarily disabled by transient field states (e.g., "text is selected" or "secure
Expand Down Expand Up @@ -132,9 +132,10 @@
}

if event.shouldSchedulePrediction {
// Capture AX state immediately at keystroke time so the debounce window
// works with the freshest possible snapshot, not whenever the poll timer last fired.
focusModel.refreshNow()
// Refresh the AX snapshot off the synchronous tap path (see
// `scheduleNonBlockingFocusRefresh`) so the resolve cost never delays this keystroke.
// The fresh snapshot lands before the post-debounce generation re-reads it.
scheduleNonBlockingFocusRefresh()
schedulePrediction()
}

Expand All @@ -150,6 +151,34 @@
)
}

/// Refreshes the focus snapshot without blocking keystroke delivery.
///
/// `InputMonitor` runs the event-tap callback (and therefore `handleInputEvent`) synchronously
/// *before* macOS hands the keystroke to the focused app, so a synchronous `refreshNow()` here
/// adds the full Accessibility-resolve latency to every character typed. That resolve grows with
/// the field's text and AX subtree (candidate discovery, deep-geometry walks, full-value reads),
/// which is why typing into large or Chromium-based fields lagged badly while editing existing
/// text. Deferring the refresh to the next main-actor turn keeps the keystroke instant; the
/// snapshot still refreshes promptly, before the user's next event (e.g. a Tab acceptance) and
/// well before the post-debounce `generateFromCurrentFocus`, which re-reads it anyway.
///
/// Coalesced via `isFocusRefreshScheduled`: continuous typing collapses to at most one pending
/// refresh, which reads the latest live AX state when it runs and so already reflects every
/// keystroke queued before it. The 80ms poll remains the backstop for freshness.
func scheduleNonBlockingFocusRefresh() {
guard !isFocusRefreshScheduled else {
return
}
isFocusRefreshScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
self.isFocusRefreshScheduled = false
self.focusModel.refreshNow()
}
Comment on lines +173 to +179
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 The flag is reset to false before refreshNow() is called, which is the right order for allowing re-coalescing. However, in Swift 6 strict-concurrency mode DispatchQueue.main.async closures are not automatically @MainActor-isolated, so the compiler may warn about accessing self.isFocusRefreshScheduled and self.focusModel from a non-isolated context. The existing schedulePostInsertionRefresh uses the same pattern, so this is consistent with the codebase, but annotating the closure or switching to Task { @MainActor [weak self] in … } would silence any strict-concurrency diagnostics if the project ever enables them.

Suggested change
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
self.isFocusRefreshScheduled = false
self.focusModel.refreshNow()
}
Task { @MainActor [weak self] in
guard let self else {
return
}
self.isFocusRefreshScheduled = false
self.focusModel.refreshNow()
}

Fix in Codex Fix in Claude Code

}

/// While a suggestion tail is active, normal typing is interpreted relative to that tail first.
/// This is the same idea as reconciling optimistic UI with the eventual live editor state:
/// keep the existing session only when the user's new input is still consistent with it.
Expand All @@ -165,7 +194,7 @@
clearDiagnostics: false
)
if event.shouldSchedulePrediction {
focusModel.refreshNow()
scheduleNonBlockingFocusRefresh()
schedulePrediction()
}
return false
Expand All @@ -176,7 +205,7 @@
clearDiagnostics: false
)
if event.shouldSchedulePrediction {
focusModel.refreshNow()
scheduleNonBlockingFocusRefresh()
schedulePrediction()
}
return false
Expand Down
3 changes: 3 additions & 0 deletions Cotabby/App/Coordinators/SuggestionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ final class SuggestionCoordinator: ObservableObject {
// barrier task that the next generation must cross before it can ask the runtime for output.
var cacheResetSequence: UInt64 = 0
var pendingCacheReset: (sequence: UInt64, task: Task<Void, Never>)?
// Coalesces off-tap focus refreshes so fast typing can't queue a backlog of full AX resolves on
// the main actor. See `scheduleNonBlockingFocusRefresh`.
var isFocusRefreshScheduled = false

init(
permissionManager: any SuggestionPermissionProviding,
Expand Down
Loading