refactor: isolate composer appkit bridge behind explicit events#24401
Conversation
Replace direct SwiftUI binding mutations from AppKit callbacks with explicit event/command bridge contract. Route focus synchronization through ComposerController instead of scattered DispatchQueue.main.async calls in updateNSView.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0ec0c26108
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .onChange(of: inputText) { | ||
| // Push text changes from external sources (e.g. dictation cancel | ||
| // restoring preDictationText) into the AppKit text view via the | ||
| // bridge command, and keep the controller in sync. | ||
| bridgeCommands.pendingSetText = inputText |
There was a problem hiding this comment.
Push initial composer text through bridge on first render
The bridge now only writes into NSTextView when pendingSetText is populated, but this command is assigned only inside .onChange(of: inputText). Because onChange does not fire for the initial value, any prefilled draft/starter text present when the composer appears is not rendered in AppKit, and the first keystroke can overwrite that unseen draft via the bridge event path.
Useful? React with 👍 / 👎.
| if let shouldFocus = commands.pendingRequestFocus { | ||
| commands.pendingRequestFocus = nil | ||
| if let window = textView.window { |
There was a problem hiding this comment.
Keep focus command queued until text view has a window
pendingRequestFocus is cleared before verifying textView.window exists, so focus requests issued during mount/transition can be dropped when updateNSView runs before window attachment. In those cases there is no retry, which can leave the composer unfocused even though focusIntent is true.
Useful? React with 👍 / 👎.
| let nsRange = NSRange(location: colonOffset, length: length) | ||
|
|
||
| textReplacer.replaceText?(nsRange, entry.emoji) | ||
| bridgeCommands.pendingReplaceRange = (range: nsRange, replacement: entry.emoji) |
There was a problem hiding this comment.
🔴 Emoji insertion via pendingReplaceRange silently deferred because no @State change triggers updateNSView
In the old code, selectEmoji called textReplacer.replaceText?(nsRange, entry.emoji) which synchronously invoked textView.insertText(...) via a closure. The new code instead sets bridgeCommands.pendingReplaceRange, which is only consumed during updateNSView. However, bridgeCommands is a final class held by @State (ComposerView.swift:80), so mutating its properties does not trigger SwiftUI observation — SwiftUI tracks the reference identity, not mutations to the object's fields. Since selectEmoji performs no @State or @Binding writes, SwiftUI has no reason to re-evaluate the view tree and call updateNSView.
All three call sites are affected: the onTab keyboard handler (ComposerView.swift:249), the performSendAction Enter-key path (ComposerView.swift:367), and the EmojiPickerPopup.onSelect button action (ComposerView.swift:113). The emoji insertion is silently deferred until an unrelated event triggers a view update. Worse, if the user types another character before that happens, .onChange(of: inputText) sets pendingSetText, which is processed before pendingReplaceRange in updateNSView (ComposerTextEditor.swift:210-228), potentially making the stored NSRange stale and corrupting text.
By contrast, all other bridge commands (pendingSetText, pendingSetEditable, pendingRequestFocus) are always set alongside a @State mutation that guarantees updateNSView is called.
Prompt for agents
The root cause is that setting a property on the reference-type ComposerBridgeCommands (held by @State) does not trigger SwiftUI to call updateNSView on ComposerTextEditor. Unlike all other bridge commands (pendingSetText, pendingSetEditable, pendingRequestFocus), pendingReplaceRange is the only command not accompanied by a @State or @Binding mutation.
Possible approaches:
1. Keep direct invocation for immediate commands: Restore a mechanism similar to the old TextReplacementProxy where selectEmoji can synchronously call textView.insertText. The coordinator could expose a replaceText method that selectEmoji calls directly. This could coexist with the bridge pattern for deferred commands.
2. Trigger a @State change alongside pendingReplaceRange: Add a dummy @State counter (e.g. @State private var bridgeCommandGeneration = 0) that gets incremented whenever a bridge command is set that needs immediate processing. This forces SwiftUI to re-evaluate the body and call updateNSView. You would need to do this in selectEmoji (ComposerEmojiPicker.swift:18) and pass the counter as a property to ComposerTextEditor so SwiftUI detects the change.
3. Make ComposerBridgeCommands @Observable: If ComposerBridgeCommands were @Observable, property mutations would be tracked by SwiftUI and trigger view updates. However, this would also cause updateNSView to fire on every property set, which the current design tries to avoid for pendingSetText.
Approach 1 is the most targeted fix since pendingReplaceRange is the only command that needs synchronous execution — it originates from user-initiated actions (keyboard/click) that expect immediate visual feedback.
Was this helpful? React with 👍 or 👎 to provide feedback.
This reverts commit 5a4b566. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Part of plan: macos-chat-surface-stabilization.md (PR 9 of 11)