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.
This commit is contained in:
Tobias Aderhold 2026-03-20 14:32:07 +01:00
parent 76433b27a2
commit 044907b906
2 changed files with 149 additions and 0 deletions

View File

@ -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<Bool> {
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")

View File

@ -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
}
}
}