From a41c81c04856273a03a36222d41b2047827142d5 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 1 May 2026 15:20:15 +0200 Subject: [PATCH] fix(chat): coalesce composer onChange writes to stop typing lag (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing in the chat composer became unusably laggy because `updateMenuState()` ran on every keystroke and unconditionally wrote both `showMenu` and `selectedIndex`. Two state writes inside one `onChange(of: text)` handler tripped SwiftUI's "action tried to update multiple times per frame" warning, and each redundant write forced a full body re-eval — visible as the slow-HID stalls and the main-thread layout churn the reporter captured in sampling. Two changes: - Compute the new selection up front and write only the deltas. Same semantics; no spurious mutations. - Short-circuit the whole handler when the user is composing normal text (no `/` prefix) and the menu is already hidden — the common case. Stops paying for `SlashCommandMenu.filter` on every keystroke of regular prose. - Replace `.onChange(of: commands.map(\.id))` with `.onChange(of: commands.count)`. The mapped form allocated a fresh `[String]` on every body re-eval; counting is one int read. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Chat/Views/RichChatInputBar.swift | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift index 453e0f3..194c53d 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift @@ -200,7 +200,12 @@ struct RichChatInputBar: View { .onChange(of: text) { _, _ in updateMenuState() } - .onChange(of: commands.map(\.id)) { _, _ in + // Watch `commands.count` rather than `commands.map(\.id)` — the + // mapped form allocates a fresh `[String]` on every body + // re-eval (i.e. every keystroke), which is wasted work even + // when the array compares equal. The count proxy fires when + // the agent advertises new commands. + .onChange(of: commands.count) { _, _ in updateMenuState() } .sheet(isPresented: $showCompressSheet) { @@ -358,17 +363,37 @@ struct RichChatInputBar: View { private func updateMenuState() { let shouldShow = shouldShowMenu + + // Common case: user is composing normal text and the menu is + // already hidden. Skip the filter computation + state writes + // entirely so onChange stays cheap. Without this guard typing + // recomputes `filteredCommands` on every keystroke even when + // the menu can't possibly appear. + guard shouldShow || showMenu else { return } + + // Compute desired selection, then only write what changed. + // SwiftUI emits "onChange action tried to update multiple + // times per frame" when an onChange handler mutates more than + // one piece of state per frame; the warning correlates with + // unusable typing lag because each redundant write triggers + // another body re-eval. + let count = filteredCommands.count + let newSelection: Int + if count == 0 { + newSelection = 0 + } else if selectedIndex >= count { + newSelection = count - 1 + } else if selectedIndex < 0 { + newSelection = 0 + } else { + newSelection = selectedIndex + } + if shouldShow != showMenu { showMenu = shouldShow } - // Re-clamp selection whenever the filtered list may have shrunk. - let count = filteredCommands.count - if count == 0 { - selectedIndex = 0 - } else if selectedIndex >= count { - selectedIndex = count - 1 - } else if selectedIndex < 0 { - selectedIndex = 0 + if newSelection != selectedIndex { + selectedIndex = newSelection } }