diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index ba6f61541..445462458 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -14,7 +14,7 @@ struct ClawdisApp: App { @StateObject private var activityStore = WorkActivityStore.shared @State private var statusItem: NSStatusItem? @State private var isMenuPresented = false - private let menuDelegate = StatusMenuDelegate() + @State private var isPanelVisible = false init() { _state = StateObject(wrappedValue: AppStateStore.shared) @@ -37,7 +37,6 @@ struct ClawdisApp: App { self.statusItem = item self.applyStatusItemAppearance(paused: self.state.isPaused) self.installStatusItemMouseHandler(for: item) - self.attachMenuDelegate(to: item) } .onChange(of: self.state.isPaused) { _, paused in self.applyStatusItemAppearance(paused: paused) @@ -62,15 +61,18 @@ struct ClawdisApp: App { if button.subviews.contains(where: { $0 is StatusItemMouseHandlerView }) { return } WebChatManager.shared.onPanelVisibilityChanged = { [self] visible in + self.isPanelVisible = visible self.statusItem?.button?.highlight(visible) } - self.menuDelegate.button = button - let handler = StatusItemMouseHandlerView() handler.translatesAutoresizingMaskIntoConstraints = false handler.onLeftClick = { [self] in self.toggleWebChatPanel() } - handler.onRightClick = { WebChatManager.shared.closePanel() } + handler.onRightClick = { [self] in + WebChatManager.shared.closePanel() + self.statusItem?.button?.highlight(false) + self.isMenuPresented = true + } button.addSubview(handler) NSLayoutConstraint.activate([ @@ -81,15 +83,12 @@ struct ClawdisApp: App { ]) } - @MainActor - private func attachMenuDelegate(to item: NSStatusItem) { - guard let menu = item.menu else { return } - self.menuDelegate.button = item.button - menu.delegate = self.menuDelegate - } - @MainActor private func toggleWebChatPanel() { + guard AppStateStore.webChatEnabled else { + self.isMenuPresented = true + return + } self.isMenuPresented = false WebChatManager.shared.togglePanel( sessionKey: WebChatManager.shared.preferredSessionKey(), @@ -132,19 +131,7 @@ private final class StatusItemMouseHandlerView: NSView { override func rightMouseDown(with event: NSEvent) { self.onRightClick?() - super.rightMouseDown(with: event) // forward to MenuBarExtra so the menu still opens - } -} - -private final class StatusMenuDelegate: NSObject, NSMenuDelegate { - weak var button: NSStatusBarButton? - - func menuWillOpen(_ menu: NSMenu) { - self.button?.highlight(true) - } - - func menuDidClose(_ menu: NSMenu) { - self.button?.highlight(false) + // Do not call super; menu will be driven by isMenuPresented binding. } } diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index 64815ea18..eeb38965b 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -28,7 +28,10 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N private var bootWatchTask: Task? let presentation: WebChatPresentation var onPanelClosed: (() -> Void)? + var onVisibilityChanged: ((Bool) -> Void)? private var panelCloseNotified = false + private var localDismissMonitor: Any? + private var observers: [NSObjectProtocol] = [] init(sessionKey: String, presentation: WebChatPresentation = .window) { webChatLogger.debug("init WebChatWindowController sessionKey=\(sessionKey, privacy: .public)") @@ -50,6 +53,10 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N self.loadPlaceholder() Task { await self.bootstrap() } + + if case .panel = presentation { + self.installPanelObservers() + } } @available(*, unavailable) @@ -59,6 +66,8 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N self.reachabilityTask?.cancel() self.bootWatchTask?.cancel() self.stopTunnel(allowRestart: false) + self.removeDismissMonitor() + self.removePanelObservers() } private static func makeWindow(for presentation: WebChatPresentation, contentView: NSView) -> NSWindow { @@ -266,14 +275,18 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N guard case .panel = self.presentation, let window else { return } self.panelCloseNotified = false self.repositionPanel(using: anchorProvider) + self.installDismissMonitor() window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) window.makeFirstResponder(self.webView) + self.onVisibilityChanged?(true) } func closePanel() { guard case .panel = self.presentation else { return } + self.removeDismissMonitor() self.window?.orderOut(nil) + self.onVisibilityChanged?(false) self.notifyPanelClosedOnce() } @@ -324,6 +337,8 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N func windowWillClose(_ notification: Notification) { guard case .panel = self.presentation else { return } + self.removeDismissMonitor() + self.onVisibilityChanged?(false) self.notifyPanelClosedOnce() } @@ -332,6 +347,59 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N self.panelCloseNotified = true self.onPanelClosed?() } + + private func installDismissMonitor() { + guard self.localDismissMonitor == nil, let panel = self.window else { return } + self.localDismissMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown] + ) { [weak self] event in + guard let self else { return event } + if event.window !== panel { + self.closePanel() + } + return event + } + } + + private func removeDismissMonitor() { + if let monitor = self.localDismissMonitor { + NSEvent.removeMonitor(monitor) + self.localDismissMonitor = nil + } + } + + private func installPanelObservers() { + guard let window = self.window else { return } + let nc = NotificationCenter.default + let o1 = nc.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.closePanel() + } + } + let o2 = nc.addObserver( + forName: NSWindow.didChangeOcclusionStateNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + guard let self, case .panel = self.presentation else { return } + if !(window.occlusionState.contains(.visible)) { + self.closePanel() + } + } + } + self.observers.append(contentsOf: [o1, o2]) + } + + private func removePanelObservers() { + let nc = NotificationCenter.default + for o in self.observers { nc.removeObserver(o) } + self.observers.removeAll() + } } extension WebChatWindowController { @@ -396,10 +464,8 @@ final class WebChatManager { if let controller = self.panelController { if controller.window?.isVisible == true { controller.closePanel() - self.onPanelVisibilityChanged?(false) } else { controller.presentAnchoredPanel(anchorProvider: anchorProvider) - self.onPanelVisibilityChanged?(true) } return } @@ -411,14 +477,15 @@ final class WebChatManager { controller.onPanelClosed = { [weak self] in self?.panelHidden() } + controller.onVisibilityChanged = { [weak self] visible in + self?.onPanelVisibilityChanged?(visible) + } controller.presentAnchoredPanel(anchorProvider: anchorProvider) - self.onPanelVisibilityChanged?(true) } func closePanel() { guard let controller = self.panelController else { return } controller.closePanel() - self.onPanelVisibilityChanged?(false) } func preferredSessionKey() -> String { @@ -451,6 +518,7 @@ final class WebChatManager { private func panelHidden() { self.onPanelVisibilityChanged?(false) + self.panelController = nil } }