From 00951dc9f95d3f4863402f399acfe0e484133bec Mon Sep 17 00:00:00 2001 From: "J. Tyler Bittner" Date: Fri, 17 Apr 2026 00:07:20 -0700 Subject: [PATCH] fix(macOS): enable undo/redo in webchat composer text input (#34962) * fix(macOS): enable undo/redo in webchat composer text input Set `allowsUndo = true` on ChatComposerNSTextView in makeNSView(). NSTextView defaults allowsUndo to false, which prevented Cmd+Z and the Edit menu Undo/Redo items from functioning. Fixes #34898 * fix(macos): enable webchat composer undo/redo (#34962) (thanks @tylerbittner) --------- Co-authored-by: Nimrod Gutman --- CHANGELOG.md | 1 + .../Sources/OpenClawChatUI/ChatComposer.swift | 62 +++++++++++-------- .../ChatComposerTextViewTests.swift | 15 +++++ 3 files changed, 53 insertions(+), 25 deletions(-) create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatComposerTextViewTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af2cd93340..4c8ea25bce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Reply/block streaming: preserve post-stream incomplete-turn error payloads after block streaming already emitted content, so users get the warning instead of silence. (#67991) Thanks @obviyus. - Telegram/streaming: clear the compaction replay guard after visible non-final boundaries so a post-tool assistant reply rotates to a fresh preview instead of editing the pre-compaction message. (#67993) Thanks @obviyus. - Matrix: fix `sessions_spawn --thread` subagent session spawning — thread binding creation, cleanup on session end, and completion-message delivery target resolution now work end-to-end. (#67643) Thanks @eejohnso-ops and @gumadeiras. +- macOS/webchat: enable Undo and Redo in the composer text input by turning on the native `NSTextView` undo manager. (#34962) Thanks @tylerbittner. ## 2026.4.15 diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift index 3cd290389fe..9c2dc0acd56 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift @@ -444,34 +444,18 @@ private struct ChatComposerTextView: NSViewRepresentable { func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSScrollView { - let textView = ChatComposerNSTextView() - textView.delegate = context.coordinator - textView.drawsBackground = false - textView.isRichText = false - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticTextReplacementEnabled = false - textView.isAutomaticDashSubstitutionEnabled = false - textView.isAutomaticSpellingCorrectionEnabled = false - textView.font = .systemFont(ofSize: 14, weight: .regular) - textView.textContainer?.lineBreakMode = .byWordWrapping - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainerInset = NSSize(width: 2, height: 4) - textView.focusRingType = .none + let textView = ChatComposerTextViewFactory.makeConfiguredTextView() + guard let composerTextView = textView as? ChatComposerNSTextView else { + preconditionFailure("ChatComposerTextViewFactory must return ChatComposerNSTextView") + } + composerTextView.delegate = context.coordinator - textView.minSize = .zero - textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - textView.isHorizontallyResizable = false - textView.isVerticallyResizable = true - textView.autoresizingMask = [.width] - textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) - textView.textContainer?.widthTracksTextView = true - - textView.string = self.text - textView.onSend = { [weak textView] in - textView?.window?.makeFirstResponder(nil) + composerTextView.string = self.text + composerTextView.onSend = { [weak composerTextView] in + composerTextView?.window?.makeFirstResponder(nil) self.onSend() } - textView.onPasteImageAttachment = self.onPasteImageAttachment + composerTextView.onPasteImageAttachment = self.onPasteImageAttachment let scroll = NSScrollView() scroll.drawsBackground = false @@ -522,6 +506,34 @@ private struct ChatComposerTextView: NSViewRepresentable { } } +enum ChatComposerTextViewFactory { + // Internal for @testable import coverage of composer text view defaults. + @MainActor + static func makeConfiguredTextView() -> NSTextView { + let textView = ChatComposerNSTextView() + textView.drawsBackground = false + textView.isRichText = false + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.font = .systemFont(ofSize: 14, weight: .regular) + textView.textContainer?.lineBreakMode = .byWordWrapping + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainerInset = NSSize(width: 2, height: 4) + textView.focusRingType = .none + textView.allowsUndo = true + textView.minSize = .zero + textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true + return textView + } +} + private final class ChatComposerNSTextView: NSTextView { var onSend: (() -> Void)? var onPasteImageAttachment: ((_ data: Data, _ fileName: String, _ mimeType: String) -> Void)? diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatComposerTextViewTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatComposerTextViewTests.swift new file mode 100644 index 00000000000..849c8fd0aff --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatComposerTextViewTests.swift @@ -0,0 +1,15 @@ +#if os(macOS) +import AppKit +import Testing +@testable import OpenClawChatUI + +@Suite +@MainActor +struct ChatComposerTextViewTests { + @Test func configuredComposerTextViewEnablesUndo() { + let textView = ChatComposerTextViewFactory.makeConfiguredTextView() + + #expect(textView.allowsUndo) + } +} +#endif