fix(chat): coalesce composer onChange writes to stop typing lag (#67)

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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-01 15:20:15 +02:00
parent 88add62997
commit a41c81c048
@@ -200,7 +200,12 @@ struct RichChatInputBar: View {
.onChange(of: text) { _, _ in .onChange(of: text) { _, _ in
updateMenuState() 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() updateMenuState()
} }
.sheet(isPresented: $showCompressSheet) { .sheet(isPresented: $showCompressSheet) {
@@ -358,17 +363,37 @@ struct RichChatInputBar: View {
private func updateMenuState() { private func updateMenuState() {
let shouldShow = shouldShowMenu 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 { if shouldShow != showMenu {
showMenu = shouldShow showMenu = shouldShow
} }
// Re-clamp selection whenever the filtered list may have shrunk. if newSelection != selectedIndex {
let count = filteredCommands.count selectedIndex = newSelection
if count == 0 {
selectedIndex = 0
} else if selectedIndex >= count {
selectedIndex = count - 1
} else if selectedIndex < 0 {
selectedIndex = 0
} }
} }