From 3ee9d90a0dcc49cd2bb8b1bed74521334e5aef32 Mon Sep 17 00:00:00 2001 From: siddseethepalli Date: Mon, 6 Apr 2026 08:23:51 +0000 Subject: [PATCH] fix: prevent composer layout feedback loop by deferring state updates Remove the measuredHeight binding that bounced layout state through SwiftUI and defer focus-binding and first-responder mutations to the next run-loop tick via DispatchQueue.main.async. Both schedulers use a pending-value guard to coalesce rapid changes and avoid stale updates. --- .../Features/Chat/ComposerTextEditor.swift | 59 +++++++++++++------ .../Features/Chat/ComposerView.swift | 3 - 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/clients/macos/vellum-assistant/Features/Chat/ComposerTextEditor.swift b/clients/macos/vellum-assistant/Features/Chat/ComposerTextEditor.swift index 914ecf9995b..f045793b730 100644 --- a/clients/macos/vellum-assistant/Features/Chat/ComposerTextEditor.swift +++ b/clients/macos/vellum-assistant/Features/Chat/ComposerTextEditor.swift @@ -44,7 +44,6 @@ struct ComposerTextEditor: NSViewRepresentable { static let textInsetY: CGFloat = 6 // textContainerInset.height @Binding var text: String - @Binding var measuredHeight: CGFloat @Binding var isFocused: Bool let font: NSFont @@ -221,8 +220,7 @@ struct ComposerTextEditor: NSViewRepresentable { textView.onPasteImage = onPasteImage textView.shouldOverrideReturn = shouldOverrideReturn textView.onFocusChanged = { [weak coordinator = context.coordinator] focused in - guard let coordinator, coordinator.parent.isFocused != focused else { return } - coordinator.parent.isFocused = focused + coordinator?.scheduleFocusBindingUpdate(focused) } if let proxy = textReplacer { @@ -236,11 +234,11 @@ struct ComposerTextEditor: NSViewRepresentable { textView.unregisterDraggedTypes() if let window = textView.window { - if isFocused, textView != window.firstResponder { - window.makeFirstResponder(textView) - } else if !isFocused, textView == window.firstResponder { - window.makeFirstResponder(nil) - } + coordinator.scheduleFirstResponderUpdate( + in: window, + textView: textView, + shouldFocus: isFocused + ) } context.coordinator.measureHeight(textView) @@ -269,6 +267,8 @@ struct ComposerTextEditor: NSViewRepresentable { var lastAppliedFont: NSFont? var lastAppliedLineSpacing: CGFloat? var lastAppliedTextColor: NSColor? + var pendingFocusBindingValue: Bool? + var pendingFirstResponderValue: Bool? weak var textView: ComposerTextView? init(parent: ComposerTextEditor) { @@ -293,15 +293,11 @@ struct ComposerTextEditor: NSViewRepresentable { // becomeFirstResponder / resignFirstResponder callbacks. // This delegate fires only once editing begins (on first // keyDown), so it serves as a secondary sync only. - if !parent.isFocused { - parent.isFocused = true - } + scheduleFocusBindingUpdate(true) } func textDidEndEditing(_ notification: Notification) { - if parent.isFocused { - parent.isFocused = false - } + scheduleFocusBindingUpdate(false) } func textViewDidChangeSelection(_ notification: Notification) { @@ -318,15 +314,42 @@ struct ComposerTextEditor: NSViewRepresentable { let usedHeight = ceil(lm.usedRect(for: tc).height) let contentHeight = usedHeight + textView.textContainerInset.height * 2 let clamped = max(parent.minHeight, min(contentHeight, parent.maxHeight)) - if abs(parent.measuredHeight - clamped) > 0.5 { - parent.measuredHeight = clamped - } // Update the scroll view's intrinsic content size so SwiftUI - // sizes the NSViewRepresentable correctly. + // sizes the NSViewRepresentable correctly without bouncing the + // measured height back through SwiftUI state during view updates. if let scrollView = textView.enclosingScrollView as? IntrinsicScrollView { scrollView.contentHeight = clamped } } + + func scheduleFocusBindingUpdate(_ focused: Bool) { + pendingFocusBindingValue = focused + DispatchQueue.main.async { [weak self] in + guard let self, self.pendingFocusBindingValue == focused else { return } + self.pendingFocusBindingValue = nil + if self.parent.isFocused != focused { + self.parent.isFocused = focused + } + } + } + + func scheduleFirstResponderUpdate( + in window: NSWindow, + textView: ComposerTextView, + shouldFocus: Bool + ) { + pendingFirstResponderValue = shouldFocus + DispatchQueue.main.async { [weak self, weak window, weak textView] in + guard let self, self.pendingFirstResponderValue == shouldFocus else { return } + self.pendingFirstResponderValue = nil + guard let window, let textView else { return } + if shouldFocus, textView != window.firstResponder { + window.makeFirstResponder(textView) + } else if !shouldFocus, textView == window.firstResponder { + window.makeFirstResponder(nil) + } + } + } } } #endif diff --git a/clients/macos/vellum-assistant/Features/Chat/ComposerView.swift b/clients/macos/vellum-assistant/Features/Chat/ComposerView.swift index 42cb9fc57a2..050d0af43f8 100644 --- a/clients/macos/vellum-assistant/Features/Chat/ComposerView.swift +++ b/clients/macos/vellum-assistant/Features/Chat/ComposerView.swift @@ -72,7 +72,6 @@ struct ComposerView: View { #endif @State private var composerFocus: Bool = false @State private var isComposerFocused = false - @State private var measuredTextHeight: CGFloat = 32 @State private var textViewIsFocused: Bool = false @State var cursorPosition: Int = 0 @@ -237,7 +236,6 @@ struct ComposerView: View { .padding(.top, ComposerTextEditor.textInsetY) ComposerTextEditor( text: $inputText, - measuredHeight: $measuredTextHeight, isFocused: $textViewIsFocused, font: nsFont, lineSpacing: 4, @@ -679,4 +677,3 @@ VStreamingWaveform( } } -