From 044907b9068f6d3b1192f09b36ce06ef1e152522 Mon Sep 17 00:00:00 2001 From: Tobias Aderhold Date: Fri, 20 Mar 2026 14:32:07 +0100 Subject: [PATCH] feat(macos): gear menu toolbar in chat window title bar Add NSToolbar with a gear dropdown to the chat window, replacing the broken right-click menu bar interaction. Gear menu: Talk Mode toggle, Settings (Cmd+,), Quit (Cmd+Q). Also adds Talk Mode toggle to Settings > General. --- .../Sources/OpenClaw/GeneralSettings.swift | 15 ++ .../Sources/OpenClaw/WebChatSwiftUI.swift | 134 ++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index 633879367ea..aa8c795ecf7 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -67,6 +67,13 @@ struct GeneralSettings: View { subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.", binding: self.$state.peekabooBridgeEnabled) + if voiceWakeSupported { + SettingsToggleRow( + title: "Talk Mode", + subtitle: "Enable hands-free voice conversation with your assistant.", + binding: self.talkBinding) + } + SettingsToggleRow( title: "Enable debug tools", subtitle: "Show the Debug tab with development utilities.", @@ -101,6 +108,14 @@ struct GeneralSettings: View { set: { self.state.isPaused = !$0 }) } + private var talkBinding: Binding { + Binding( + get: { self.state.talkEnabled }, + set: { newValue in + Task { await self.state.setTalkEnabled(newValue) } + }) + } + private var connectionSection: some View { VStack(alignment: .leading, spacing: 10) { Text("OpenClaw runs") diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 86c225f9ef0..92803213ca2 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import ObjectiveC import OpenClawChatUI import OpenClawKit import OpenClawProtocol @@ -362,6 +363,21 @@ final class WebChatSwiftUIWindowController { window.minSize = WebChatSwiftUILayout.windowMinSize window.contentView?.wantsLayer = true window.contentView?.layer?.backgroundColor = NSColor.clear.cgColor + + // Add toolbar with gear menu + let toolbar = NSToolbar(identifier: "OpenClawChatToolbar") + toolbar.displayMode = .iconOnly + if #available(macOS 26.0, *) { + window.toolbarStyle = .unifiedCompact + } else { + window.toolbarStyle = .unified + } + let toolbarDelegate = ChatWindowToolbarDelegate() + toolbar.delegate = toolbarDelegate + window.toolbar = toolbar + // Retain the delegate (toolbar doesn't retain its delegate) + objc_setAssociatedObject(window, &chatToolbarDelegateKey, toolbarDelegate, .OBJC_ASSOCIATION_RETAIN) + return window case .panel: let panel = WebChatPanel( @@ -456,3 +472,121 @@ final class WebChatSwiftUIWindowController { ColorHexSupport.color(fromHex: raw) } } + + +// MARK: - Chat window toolbar + +nonisolated(unsafe) private var chatToolbarDelegateKey: UInt8 = 0 + +private extension NSToolbarItem.Identifier { + static let chatGearMenu = NSToolbarItem.Identifier("chatGearMenu") +} + +@MainActor +final class ChatWindowToolbarDelegate: NSObject, NSToolbarDelegate { + func toolbar( + _ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? + { + guard itemIdentifier == .chatGearMenu else { return nil } + + let item = NSMenuToolbarItem(itemIdentifier: itemIdentifier) + let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium) + item.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings")? + .withSymbolConfiguration(config) + item.label = "Settings" + item.toolTip = "App settings and Talk Mode" + item.menu = self.buildGearMenu() + item.showsIndicator = true + return item + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.flexibleSpace, .chatGearMenu] + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.flexibleSpace, .chatGearMenu] + } + + private func buildGearMenu() -> NSMenu { + let menu = NSMenu() + + // Talk Mode toggle + let talkItem = NSMenuItem( + title: AppStateStore.shared.talkEnabled ? "Stop Talk Mode" : "Talk Mode", + action: #selector(toggleTalkMode), + keyEquivalent: "") + talkItem.target = self + talkItem.state = AppStateStore.shared.talkEnabled ? .on : .off + talkItem.image = NSImage(systemSymbolName: "waveform.circle.fill", accessibilityDescription: nil) + if !voiceWakeSupported { + talkItem.isEnabled = false + } + menu.addItem(talkItem) + + menu.addItem(.separator()) + + // Settings + let settingsItem = NSMenuItem( + title: "Settings…", + action: #selector(openSettings), + keyEquivalent: ",") + settingsItem.keyEquivalentModifierMask = .command + settingsItem.target = self + settingsItem.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: nil) + menu.addItem(settingsItem) + + menu.addItem(.separator()) + + // Quit + let quitItem = NSMenuItem( + title: "Quit OpenClaw", + action: #selector(quitApp), + keyEquivalent: "q") + quitItem.keyEquivalentModifierMask = .command + quitItem.target = self + menu.addItem(quitItem) + + // Refresh menu items each time it opens + menu.delegate = self + + return menu + } + + @objc private func toggleTalkMode() { + Task { await AppStateStore.shared.setTalkEnabled(!AppStateStore.shared.talkEnabled) } + } + + @objc private func toggleCanvas() { + Task { + if AppStateStore.shared.canvasPanelVisible { + CanvasManager.shared.hideAll() + } else { + let sessionKey = await GatewayConnection.shared.mainSessionKey() + _ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil) + } + } + } + + @objc private func openSettings() { + SettingsWindowOpener.shared.open() + } + + @objc private func quitApp() { + NSApp.terminate(nil) + } +} + +extension ChatWindowToolbarDelegate: NSMenuDelegate { + func menuNeedsUpdate(_ menu: NSMenu) { + // Update Talk Mode item state + if let talkItem = menu.items.first(where: { $0.action == #selector(toggleTalkMode) }) { + let enabled = AppStateStore.shared.talkEnabled + talkItem.title = enabled ? "Stop Talk Mode" : "Talk Mode" + talkItem.state = enabled ? .on : .off + } + + } +}