Skip to content

revert: isolate composer appkit bridge behind explicit events (#24401)#24417

Merged
TirmanSidhu merged 2 commits into
mainfrom
tirmansidhu/revert-composer-appkit-bridge
Apr 8, 2026
Merged

revert: isolate composer appkit bridge behind explicit events (#24401)#24417
TirmanSidhu merged 2 commits into
mainfrom
tirmansidhu/revert-composer-appkit-bridge

Conversation

@TirmanSidhu

@TirmanSidhu TirmanSidhu commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Reverts #24401 (refactor: isolate composer appkit bridge behind explicit events)

🤖 Generated with Claude Code


Open with Devin

TirmanSidhu and others added 2 commits April 8, 2026 21:00
This reverts commit 5a4b566.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…mposer-appkit-bridge

# Conflicts:
#	clients/macos/vellum-assistant/Features/Chat/ComposerView.swift
@TirmanSidhu TirmanSidhu merged commit 4977470 into main Apr 8, 2026
1 check passed
@TirmanSidhu TirmanSidhu deleted the tirmansidhu/revert-composer-appkit-bridge branch April 8, 2026 20:02

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 438a3d001e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +236 to +240
if let window = textView.window {
coordinator.scheduleFirstResponderUpdate(
in: window,
textView: textView,
shouldFocus: isFocused

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid re-requesting first responder on every render

updateNSView now schedules a first-responder update on every render while the editor is mounted, instead of only when focus intent changes. Because this executes via DispatchQueue.main.async, queued shouldFocus: true work from normal typing can run after the user clicks another control (before the blur binding propagates), and it can immediately make the composer first responder again. That makes blur unreliable and can steal subsequent keystrokes; this call should be gated to real focus transitions.

Useful? React with 👍 / 👎.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 2 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines +236 to 242
if let window = textView.window {
coordinator.scheduleFirstResponderUpdate(
in: window,
textView: textView,
shouldFocus: isFocused
)
}

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.

🟡 Unconditional scheduleFirstResponderUpdate on every updateNSView can steal focus

The old code gated first-responder changes behind a one-shot pendingRequestFocus command that was only set when the SwiftUI layer explicitly wanted to change focus. The new code calls scheduleFirstResponderUpdate(shouldFocus: isFocused) unconditionally on every updateNSView call (lines 236-242). This changes the semantics from "request focus when asked" to "continuously enforce focus state on every render."

This creates a focus-stealing race: when the user clicks away, the focus loss propagates asynchronously (resignFirstResponderscheduleFocusBindingUpdate(false)DispatchQueue.main.async { parent.isFocused = false }). Before that async fires, if any unrelated state change triggers updateNSView, it reads stale isFocused = true and enqueues a makeFirstResponder(textView) call. Although the FIFO ordering of DispatchQueue.main.async ensures the binding-update async fires first, writing to parent.isFocused (a @Binding) does not guarantee a synchronous updateNSView — SwiftUI defers layout to the next render cycle. So pendingFirstResponderValue remains true from the stale render, and the subsequent async block passes its guard (pendingFirstResponderValue == shouldFocus) and calls window.makeFirstResponder(textView), stealing focus.

Example scenario: user clicks "Stop generation" button
  1. Mouse down → textView.resignFirstResponder → scheduleFocusBindingUpdate(false) → enqueue ASYNC_A
  2. Button action → isAssistantBusy changes → isEditable changes → SwiftUI calls updateNSView synchronously
  3. updateNSView reads isFocused = true (stale) → scheduleFirstResponderUpdate(true) → pendingFirstResponderValue = true → enqueue ASYNC_B
  4. ASYNC_A fires → parent.isFocused = false → SwiftUI DEFERS the resulting updateNSView
  5. ASYNC_B fires → pendingFirstResponderValue == true → passes guard → makeFirstResponder(textView) → focus stolen
Prompt for agents
The scheduleFirstResponderUpdate call in updateNSView (ComposerTextEditor.swift lines 236-242) is called unconditionally on every render pass, unlike the old code which gated it behind a one-shot pendingRequestFocus command. This creates a race where stale isFocused values can cause focus-stealing.

To fix this, avoid calling scheduleFirstResponderUpdate on every updateNSView. Instead, only call it when the isFocused value has actually changed since the last call. The coordinator could track the last-requested focus value (e.g. lastRequestedFocus: Bool?) and only schedule a first-responder update when the value differs:

  if coordinator.lastRequestedFocus != isFocused {
      coordinator.lastRequestedFocus = isFocused
      coordinator.scheduleFirstResponderUpdate(in: window, textView: textView, shouldFocus: isFocused)
  }

This restores the one-shot semantics of the old pendingRequestFocus command pattern while keeping the binding-based architecture.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +299 to +313
.onChange(of: composerFocus) {
if textViewIsFocused != composerFocus {
textViewIsFocused = composerFocus
}
isComposerFocused = composerFocus
if composerFocus {
if let window = NSApp.keyWindow as? TitleBarZoomableWindow {
window.clearComposerDismissed()
}
}
}
.onChange(of: textViewIsFocused) {
if composerFocus != textViewIsFocused {
composerFocus = textViewIsFocused
}

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.

🚩 composerController.focusChanged() is never called — focusIntent is stale

The ComposerController class documents itself as accepting explicit events including focusChanged (ComposerController.swift:10), and focusChanged(_ focused:) at ComposerController.swift:98-100 sets focusIntent. In the old code, the bridge's focusChanged event handler called composerController.focusChanged(focused) whenever the text view gained/lost focus, keeping focusIntent in sync.

In the new code, composerController.focusChanged() is never called from ComposerView. The only caller is tests (ComposerControllerTests.swift:433,436,443). This means focusIntent doesn't track actual focus state — it's only set by interactionEnabledChanged() (which overwrites it unconditionally). The sole external reader (ComposerView.swift:167) reads it immediately after interactionEnabledChanged() overwrites it, so no current code path sees a stale value. However, focusIntent is now dead state that violates the controller's documented API contract. If future code reads focusIntent expecting it to reflect reality, it will get incorrect results.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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