diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index d503686ba57..4b55c9eb3d9 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -338,7 +338,12 @@ final class AppState { if !self.isPreview { Task { await VoiceWakeRuntime.shared.refresh(state: self) } - Task { await TalkModeController.shared.setEnabled(self.talkEnabled) } + // Defer TalkModeController init to next run loop iteration to prevent + // re-entrant access to AppStateStore.shared during init (#36983). + let savedTalkEnabled = self.talkEnabled + DispatchQueue.main.async { + Task { await TalkModeController.shared.setEnabled(savedTalkEnabled) } + } } self.isInitializing = false 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/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 0750da56a5e..41a262081b8 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -154,8 +154,13 @@ struct OpenClawApp: App { handler.onRightClick = { [self] in HoverHUDController.shared.dismiss(reason: "statusItemRightClick") WebChatManager.shared.closePanel() - self.isMenuPresented = true - self.updateStatusHighlight() + WebChatManager.shared.closeWindow() + // Deactivate the app briefly so MenuBarExtra menu can appear + // (SwiftUI menu won't show while an NSWindow is key) + DispatchQueue.main.async { + self.isMenuPresented = true + self.updateStatusHighlight() + } } handler.onHoverChanged = { [self] inside in HoverHUDController.shared.statusItemHoverChanged( @@ -178,9 +183,7 @@ struct OpenClawApp: App { self.isMenuPresented = false Task { @MainActor in let sessionKey = await WebChatManager.shared.preferredSessionKey() - WebChatManager.shared.togglePanel( - sessionKey: sessionKey, - anchorProvider: { [self] in self.statusButtonScreenFrame() }) + WebChatManager.shared.toggleWindow(sessionKey: sessionKey) } } diff --git a/apps/macos/Sources/OpenClaw/WebChatManager.swift b/apps/macos/Sources/OpenClaw/WebChatManager.swift index 47a8c781b8a..9a85b824053 100644 --- a/apps/macos/Sources/OpenClaw/WebChatManager.swift +++ b/apps/macos/Sources/OpenClaw/WebChatManager.swift @@ -59,6 +59,21 @@ final class WebChatManager { controller.show() } + /// Toggle the chat window (show/hide). Used by menu bar left-click. + func toggleWindow(sessionKey: String) { + self.closePanel() + if let controller = self.windowController, self.windowSessionKey == sessionKey { + if controller.isVisible { + controller.close() + self.onPanelVisibilityChanged?(false) + return + } + controller.show() + return + } + self.show(sessionKey: sessionKey) + } + func togglePanel(sessionKey: String, anchorProvider: @escaping () -> NSRect?) { if let controller = self.panelController { if self.panelSessionKey != sessionKey { @@ -93,6 +108,11 @@ final class WebChatManager { self.panelController?.close() } + func closeWindow() { + self.windowController?.close() + self.onPanelVisibilityChanged?(false) + } + func preferredSessionKey() async -> String { if let cachedPreferredSessionKey { return cachedPreferredSessionKey } let key = await GatewayConnection.shared.mainSessionKey() 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 + } + + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift index 3cd290389fe..fd94f2a6dcc 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift @@ -280,6 +280,9 @@ struct OpenClawChatComposer: View { }, onPasteImageAttachment: { data, fileName, mimeType in self.viewModel.addImageAttachment(data: data, fileName: fileName, mimeType: mimeType) + }, + onDropFileURLs: { urls in + self.viewModel.addAttachments(urls: urls) }) .frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight) .padding(.horizontal, 4) @@ -373,7 +376,11 @@ struct OpenClawChatComposer: View { } private var textMaxHeight: CGFloat { + #if os(macOS) + self.style == .onboarding ? 52 : .infinity + #else self.style == .onboarding ? 52 : 64 + #endif } private var isComposerCompacted: Bool { @@ -440,6 +447,7 @@ private struct ChatComposerTextView: NSViewRepresentable { @Binding var shouldFocus: Bool var onSend: () -> Void var onPasteImageAttachment: (_ data: Data, _ fileName: String, _ mimeType: String) -> Void + var onDropFileURLs: (([URL]) -> Void)? func makeCoordinator() -> Coordinator { Coordinator(self) } @@ -472,6 +480,7 @@ private struct ChatComposerTextView: NSViewRepresentable { self.onSend() } textView.onPasteImageAttachment = self.onPasteImageAttachment + textView.onDropFileURLs = self.onDropFileURLs let scroll = NSScrollView() scroll.drawsBackground = false @@ -487,6 +496,7 @@ private struct ChatComposerTextView: NSViewRepresentable { func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? ChatComposerNSTextView else { return } textView.onPasteImageAttachment = self.onPasteImageAttachment + textView.onDropFileURLs = self.onDropFileURLs if self.shouldFocus, let window = scrollView.window { window.makeFirstResponder(textView) @@ -525,6 +535,19 @@ private struct ChatComposerTextView: NSViewRepresentable { private final class ChatComposerNSTextView: NSTextView { var onSend: (() -> Void)? var onPasteImageAttachment: ((_ data: Data, _ fileName: String, _ mimeType: String) -> Void)? + /// Callback to route dropped file URLs to the attachment handler instead of inserting as text. + var onDropFileURLs: (([URL]) -> Void)? + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let pboard = sender.draggingPasteboard + if let urls = pboard.readObjects(forClasses: [NSURL.self], options: [ + .urlReadingFileURLsOnly: true + ]) as? [URL], !urls.isEmpty, let handler = self.onDropFileURLs { + handler(urls) + return true + } + return super.performDragOperation(sender) + } override var readablePasteboardTypes: [NSPasteboard.PasteboardType] { var types = super.readablePasteboardTypes diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index c760fad30d5..9af31942ad6 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -17,6 +17,8 @@ public struct OpenClawChatView: View { @State private var hasPerformedInitialScroll = false @State private var isPinnedToBottom = true @State private var lastUserMessageID: UUID? + @State private var composerHeight: CGFloat = 150 + @State private var composerDragStart: CGFloat? private let showsSessionSwitcher: Bool private let style: Style private let markdownVariant: ChatMarkdownVariant @@ -68,18 +70,66 @@ public struct OpenClawChatView: View { .ignoresSafeArea() } - VStack(spacing: Layout.stackSpacing) { - self.messageList - .padding(.horizontal, Layout.outerPaddingHorizontal) - OpenClawChatComposer( - viewModel: self.viewModel, - style: self.style, - showsSessionSwitcher: self.showsSessionSwitcher) - .padding(.horizontal, Layout.composerPaddingHorizontal) + GeometryReader { geo in + let handleH: CGFloat = 15 + let padV = Layout.outerPaddingVertical * 2 + let listH = max(100, geo.size.height - padV - handleH - self.composerHeight) + + VStack(spacing: 0) { + self.messageList + .padding(.horizontal, Layout.outerPaddingHorizontal) + .frame(height: listH) + .clipped() + + // ── Resize handle ── + VStack(spacing: 0) { + Rectangle() + .fill(Color.primary.opacity(0.15)) + .frame(height: 1) + HStack(spacing: 3) { + ForEach(0..<3, id: \.self) { _ in + RoundedRectangle(cornerRadius: 1) + .fill(Color.primary.opacity(0.3)) + .frame(width: 20, height: 3) + } + } + .frame(maxWidth: .infinity) + .frame(height: 14) + } + .frame(height: handleH) + .contentShape(Rectangle()) + #if os(macOS) + .onHover { inside in + if inside { NSCursor.resizeUpDown.push() } else { NSCursor.pop() } + } + #endif + .highPriorityGesture( + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .onChanged { value in + if self.composerDragStart == nil { + self.composerDragStart = self.composerHeight + } + let minH: CGFloat = 80 + let maxH: CGFloat = geo.size.height - padV - handleH - 100 + let proposed = (self.composerDragStart ?? 150) - value.translation.height + self.composerHeight = min(maxH, max(minH, proposed)) + } + .onEnded { _ in + self.composerDragStart = nil + } + ) + + // ── Composer ── + OpenClawChatComposer( + viewModel: self.viewModel, + style: self.style, + showsSessionSwitcher: self.showsSessionSwitcher) + .padding(.horizontal, Layout.composerPaddingHorizontal) + .frame(height: self.composerHeight) + } + .padding(.vertical, Layout.outerPaddingVertical) } - .padding(.vertical, Layout.outerPaddingVertical) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity, alignment: .top) + .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onAppear { self.viewModel.load() }