Fix dead key monitor: gate on live state, not a value-captured Bool

A synthetic-event harness against the extracted modifier showed the
realistic path broken: the panel is hidden when the composer appears, so
onChange(of: isActive) ran on the previous render's modifier value and
installed a monitor whose closure had captured isActive == false — it
passed every key through forever. Arrows/Tab/Escape never worked on a
real Mac.

Install the monitor once for the view's lifetime and gate each event on
an isActive closure that reads the reference-typed model live. Same fix
applies to the iOS onKeyPress guards for consistency. Harness now passes
all paths including deactivate/reactivate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-08-01 11:37:40 +02:00
parent b98df88f1a
commit 90ac3a5b23

View File

@ -82,8 +82,8 @@ struct ContentComposerView: View {
#endif
.submitLabel(.send)
.modifier(AutocompleteKeyboardNavigationModifier(
isActive: conversationUIModel.showAutocomplete
&& !conversationUIModel.autocompleteSuggestions.isEmpty,
isActive: { conversationUIModel.showAutocomplete
&& !conversationUIModel.autocompleteSuggestions.isEmpty },
onMove: { delta in
conversationUIModel.moveAutocompleteSelection(by: delta)
},
@ -413,7 +413,13 @@ private extension ContentComposerView {
/// same reason command suggestions (#1504) use an `NSEvent` local monitor.
/// Mentions follow that mechanism on macOS and keep `.onKeyPress` for iOS 17+.
private struct AutocompleteKeyboardNavigationModifier: ViewModifier {
let isActive: Bool
/// Live activity check, not a captured Bool. The macOS monitor closure is
/// registered once for the view's lifetime; a plain `Bool` would freeze
/// the value captured at install time (this is a value type), so a panel
/// that opens after the monitor installs would never intercept a key.
/// The provider closes over the reference-typed model and reads current
/// state on every event.
let isActive: () -> Bool
let onMove: (Int) -> Void
let onAccept: () -> Bool
let onDismiss: () -> Void
@ -425,36 +431,27 @@ private struct AutocompleteKeyboardNavigationModifier: ViewModifier {
func body(content: Content) -> some View {
#if os(macOS)
content
.onChange(of: isActive) { active in
if active {
installKeyMonitor()
} else {
removeKeyMonitor()
}
}
.onAppear {
if isActive { installKeyMonitor() }
}
.onAppear { installKeyMonitor() }
.onDisappear { removeKeyMonitor() }
#else
if #available(iOS 17.0, *) {
content
.onKeyPress(.upArrow) {
guard isActive else { return .ignored }
guard isActive() else { return .ignored }
onMove(-1)
return .handled
}
.onKeyPress(.downArrow) {
guard isActive else { return .ignored }
guard isActive() else { return .ignored }
onMove(1)
return .handled
}
.onKeyPress(.tab) {
guard isActive else { return .ignored }
guard isActive() else { return .ignored }
return onAccept() ? .handled : .ignored
}
.onKeyPress(.escape) {
guard isActive else { return .ignored }
guard isActive() else { return .ignored }
onDismiss()
return .handled
}
@ -484,7 +481,7 @@ private struct AutocompleteKeyboardNavigationModifier: ViewModifier {
/// consumes the event so return completes instead of sending while the
/// list is up. Inactive monitors pass everything through.
private func handleKeyDown(_ event: NSEvent) -> NSEvent? {
guard isActive,
guard isActive(),
event.modifierFlags.intersection([.command, .option, .control]).isEmpty else {
return event
}