From 76433b27a2c208df96fe0929f68146b1c0281f18 Mon Sep 17 00:00:00 2001 From: Tobias Aderhold Date: Fri, 20 Mar 2026 14:32:07 +0100 Subject: [PATCH] feat(macos): windowed chat mode + Talk Mode crash fix (#36983) Replace the menu bar NSPopover with a proper resizable NSWindow for the chat interface. Left-click the menu bar icon to toggle the window. - Chat opens as a standard macOS window (titled, closable, resizable, miniaturizable) instead of a borderless popover anchored to the menu bar - Window stays open when clicking desktop, enabling drag-and-drop workflows - Add WebChatManager.toggleWindow() and closeWindow() for menu bar toggle - Fix Talk Mode crash on launch (#36983): defer TalkModeController.setEnabled() to next run loop to prevent Swift exclusivity violation in AppState.init() --- apps/macos/Sources/OpenClaw/AppState.swift | 7 ++++++- apps/macos/Sources/OpenClaw/MenuBar.swift | 13 +++++++----- .../Sources/OpenClaw/WebChatManager.swift | 20 +++++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) 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/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()