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()
This commit is contained in:
Tobias Aderhold 2026-03-20 14:32:07 +01:00
parent 709c730e2a
commit 76433b27a2
3 changed files with 34 additions and 6 deletions

View File

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

View File

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

View File

@ -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()