diff --git a/clients/macos/vellum-assistant/Features/Chat/ComposerEmojiPicker.swift b/clients/macos/vellum-assistant/Features/Chat/ComposerEmojiPicker.swift index 11cee97787c..b69017abc15 100644 --- a/clients/macos/vellum-assistant/Features/Chat/ComposerEmojiPicker.swift +++ b/clients/macos/vellum-assistant/Features/Chat/ComposerEmojiPicker.swift @@ -56,16 +56,16 @@ extension ComposerView { if let trigger = emojiTriggerRange() { let results = EmojiCatalog.search(query: trigger.filter) if !results.isEmpty { - withAnimation(VAnimation.fast) { showEmojiMenu = true } + showEmojiMenu = true if emojiFilter != trigger.filter { emojiSelectedIndex = 0 } emojiFilter = trigger.filter } else { - withAnimation(VAnimation.fast) { showEmojiMenu = false } + showEmojiMenu = false } } else { - withAnimation(VAnimation.fast) { showEmojiMenu = false } + showEmojiMenu = false } } @@ -83,7 +83,7 @@ extension ComposerView { textReplacer.replaceText?(nsRange, entry.emoji) - withAnimation(VAnimation.fast) { showEmojiMenu = false } + showEmojiMenu = false emojiSelectedIndex = 0 } @@ -101,7 +101,7 @@ extension ComposerView { case .tab: selectEmoji(filtered[emojiSelectedIndex]) case .dismiss: - withAnimation(VAnimation.fast) { showEmojiMenu = false } + showEmojiMenu = false suppressEmojiReopen = true } } diff --git a/clients/macos/vellum-assistant/Features/Chat/ComposerSlashCommands.swift b/clients/macos/vellum-assistant/Features/Chat/ComposerSlashCommands.swift index 2b047a9bf7c..8fc3c423c33 100644 --- a/clients/macos/vellum-assistant/Features/Chat/ComposerSlashCommands.swift +++ b/clients/macos/vellum-assistant/Features/Chat/ComposerSlashCommands.swift @@ -92,21 +92,21 @@ extension ComposerView { let filter = String(text.dropFirst()) let filtered = filteredSlashCommands(filter) if !filtered.isEmpty { - withAnimation(VAnimation.fast) { showSlashMenu = true } + showSlashMenu = true if slashFilter != filter { slashSelectedIndex = 0 } slashFilter = filter } else { - withAnimation(VAnimation.fast) { showSlashMenu = false } + showSlashMenu = false } } else { - withAnimation(VAnimation.fast) { showSlashMenu = false } + showSlashMenu = false } } func selectSlashCommand(_ command: SlashCommand) { - withAnimation(VAnimation.fast) { showSlashMenu = false } + showSlashMenu = false slashSelectedIndex = 0 inputText = Self.slashCommandInputTextForSelection(command) if command.shouldAutoSendOnSelect { @@ -132,9 +132,9 @@ extension ComposerView { suppressSlashReopen = true } inputText = newText - withAnimation(VAnimation.fast) { showSlashMenu = false } + showSlashMenu = false case .dismiss: - withAnimation(VAnimation.fast) { showSlashMenu = false } + showSlashMenu = false inputText = "" } } diff --git a/clients/macos/vellum-assistant/Features/Chat/ComposerView.swift b/clients/macos/vellum-assistant/Features/Chat/ComposerView.swift index ab8294a53ca..5cc05ed455e 100644 --- a/clients/macos/vellum-assistant/Features/Chat/ComposerView.swift +++ b/clients/macos/vellum-assistant/Features/Chat/ComposerView.swift @@ -9,6 +9,24 @@ import AppKit private let composerLog = Logger(subsystem: Bundle.appBundleIdentifier, category: "Composer") +@MainActor +private final class ComposerMenuRefreshScheduler { + private var generation = 0 + + func schedule(_ action: @escaping @MainActor () -> Void) { + generation += 1 + let currentGeneration = generation + DispatchQueue.main.async { [weak self] in + guard let self, self.generation == currentGeneration else { return } + action() + } + } + + func cancel() { + generation += 1 + } +} + struct ComposerView: View { private let composerMaxHeight: CGFloat = 300 private let composerActionButtonSize: CGFloat = 32 @@ -84,6 +102,7 @@ struct ComposerView: View { @State var emojiFilter = "" @State var emojiSelectedIndex = 0 @State var textReplacer = TextReplacementProxy() + @State private var menuRefreshScheduler = ComposerMenuRefreshScheduler() /// Snapshot of inputText captured when dictation starts, used to restore on cancel. @State private var preDictationText: String = "" /// Live amplitude from VoiceInputManager, bypassing ChatViewModel's 100ms coalescing. @@ -102,6 +121,18 @@ struct ComposerView: View { return nil } + private func scheduleComposerMenuRefresh() { + menuRefreshScheduler.schedule { + if inputText.isEmpty { + showSlashMenu = false + showEmojiMenu = false + } else { + updateSlashState() + updateEmojiState() + } + } + } + var body: some View { VStack(spacing: VSpacing.sm) { // Slash command popup (above the composer) @@ -141,8 +172,6 @@ struct ComposerView: View { } #endif .fixedSize(horizontal: false, vertical: true) - .animation(VAnimation.fast, value: showSlashMenu) - .animation(VAnimation.fast, value: showEmojiMenu) .padding(.horizontal, VSpacing.lg) .padding(.top, VSpacing.sm) .frame(maxWidth: VSpacing.chatColumnMaxWidth) @@ -177,6 +206,7 @@ struct ComposerView: View { if enabled, !hasPendingConfirmation { composerFocus = true } else if !enabled { + menuRefreshScheduler.cancel() composerFocus = false showSlashMenu = false showEmojiMenu = false @@ -331,16 +361,11 @@ struct ComposerView: View { composerFocus = true } .onChange(of: inputText) { - if inputText.isEmpty { - withAnimation(VAnimation.fast) { showSlashMenu = false; showEmojiMenu = false } - } else { - updateSlashState() - updateEmojiState() - } + scheduleComposerMenuRefresh() } .onChange(of: cursorPosition) { if !inputText.isEmpty { - updateEmojiState() + scheduleComposerMenuRefresh() } } }