refactor(macos): remove the custom menu bar hover card (#118116)

* refactor(macos): remove the custom menu bar hover card

The status item showed two hover affordances at once: the native AppKit
tooltip and a custom SwiftUI panel rendering an "Idle / No recent activity"
card. Keep the native tooltip and drop the custom HUD.

The HUD was the only consumer of the status item's hover tracking, so the
NSTrackingArea and onHoverChanged plumbing in StatusItemMouseRouter goes
with it; click routing is unchanged. WorkActivityStore.lastToolLabel had no
other reader either.

* chore(i18n): refresh native i18n inventory after hover card removal

Deleting HoverHUD.swift shifted the MenuBar.swift line numbers that
apps/.i18n/native-source.json records. Regenerated with
`pnpm native:i18n:baseline`; only line numbers change, no strings added
or removed.

* refactor(macos): drop the orphaned anchored chat panel

Removing the hover card left WebChatManager.togglePanel() without a caller,
and it was the only thing that created the menu-bar-anchored chat popover.
Delete that presentation path: togglePanel/panelHidden, the panel controller
state, WebChatPresentation, WebChatPanel, presentAnchored and its dismiss
monitor, and WindowPlacement.anchoredBelowFrame.

Chat is unaffected as a window - WebChatManager.show() still backs the Dock
menu, --chat and deep links. The status item highlight that tracked chat
window visibility is preserved, renamed to onChatWindowVisibilityChanged
now that no panel exists to confuse it with.

Periphery reports no unused code.

* chore: drop CHANGELOG edit from this PR

AGENTS.md: CHANGELOG.md is release-only and release generation derives it
from merged PRs. Release-note context lives in the PR body instead.

* chore: restore CHANGELOG to the branch base

Correct the previous commit, which restored CHANGELOG.md from current
origin/main and so pulled in an unrelated entry added after this branch
forked. Reset to the merge-base content: this PR now touches no changelog.
This commit is contained in:
Peter Steinberger
2026-08-02 13:10:27 -07:00
committed by GitHub
parent d9c9ad8d8a
commit b8a6ea12cc
10 changed files with 128 additions and 781 deletions

View File

@@ -34355,7 +34355,7 @@
},
{
"kind": "ui-call",
"line": 108,
"line": 102,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "New Gateway Window…",
"surface": "apple",
@@ -34363,7 +34363,7 @@
},
{
"kind": "ui-call",
"line": 113,
"line": 107,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "New Thread",
"surface": "apple",
@@ -34371,7 +34371,7 @@
},
{
"kind": "ui-call",
"line": 119,
"line": 113,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Settings...",
"surface": "apple",
@@ -34379,7 +34379,7 @@
},
{
"kind": "ui-call",
"line": 127,
"line": 121,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Back",
"surface": "apple",
@@ -34387,7 +34387,7 @@
},
{
"kind": "ui-call",
"line": 132,
"line": 126,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Forward",
"surface": "apple",
@@ -34395,7 +34395,7 @@
},
{
"kind": "ui-call",
"line": 139,
"line": 133,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Command Palette…",
"surface": "apple",
@@ -34403,7 +34403,7 @@
},
{
"kind": "conditional-branch",
"line": 157,
"line": 147,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "OpenClaw - Voice Wake live meter active",
"surface": "apple",
@@ -34411,7 +34411,7 @@
},
{
"kind": "conditional-branch",
"line": 158,
"line": 148,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "OpenClaw",
"surface": "apple",
@@ -34419,7 +34419,7 @@
},
{
"kind": "conditional-branch",
"line": 455,
"line": 405,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Close Canvas",
"surface": "apple",
@@ -34427,7 +34427,7 @@
},
{
"kind": "conditional-branch",
"line": 455,
"line": 405,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Open Canvas",
"surface": "apple",
@@ -39691,7 +39691,7 @@
},
{
"kind": "ui-named-argument",
"line": 201,
"line": 179,
"path": "apps/macos/Sources/OpenClaw/WebChatManager.swift",
"source": "Could Not Open Gateway Window",
"surface": "apple",
@@ -39699,7 +39699,7 @@
},
{
"kind": "ui-localized-call",
"line": 958,
"line": 934,
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
"source": "Talk mode uses the primary Gateway window",
"surface": "apple",
@@ -39707,7 +39707,7 @@
},
{
"kind": "ui-localized-call",
"line": 960,
"line": 936,
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
"source": "Talk mode off",
"surface": "apple",
@@ -39715,7 +39715,7 @@
},
{
"kind": "ui-localized-call",
"line": 962,
"line": 938,
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
"source": "Talk mode paused",
"surface": "apple",
@@ -39723,7 +39723,7 @@
},
{
"kind": "ui-localized-call",
"line": 965,
"line": 941,
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
"source": "Talk mode ready",
"surface": "apple",
@@ -39731,7 +39731,7 @@
},
{
"kind": "ui-localized-call",
"line": 966,
"line": 942,
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
"source": "Listening",
"surface": "apple",
@@ -39739,7 +39739,7 @@
},
{
"kind": "ui-localized-call",
"line": 967,
"line": 943,
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
"source": "Thinking",
"surface": "apple",
@@ -39747,7 +39747,7 @@
},
{
"kind": "ui-localized-call",
"line": 968,
"line": 944,
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
"source": "Speaking",
"surface": "apple",
@@ -39755,7 +39755,7 @@
},
{
"kind": "ui-localized-call",
"line": 972,
"line": 948,
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
"source": "What would you like to work on?",
"surface": "apple",
@@ -39763,7 +39763,7 @@
},
{
"kind": "ui-localized-call",
"line": 976,
"line": 952,
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
"source": "Check OpenClaw status",
"surface": "apple",
@@ -39771,7 +39771,7 @@
},
{
"kind": "ui-localized-call",
"line": 977,
"line": 953,
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
"source": "Summarize the current OpenClaw status and tell me what needs attention.",
"surface": "apple",
@@ -39779,7 +39779,7 @@
},
{
"kind": "ui-localized-call",
"line": 980,
"line": 956,
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
"source": "What can you do?",
"surface": "apple",
@@ -39787,7 +39787,7 @@
},
{
"kind": "ui-localized-call",
"line": 981,
"line": 957,
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
"source": "Show me what you can help with on this Mac right now.",
"surface": "apple",
@@ -39795,7 +39795,7 @@
},
{
"kind": "ui-localized-call",
"line": 984,
"line": 960,
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
"source": "Catch me up",
"surface": "apple",
@@ -39803,7 +39803,7 @@
},
{
"kind": "ui-localized-call",
"line": 985,
"line": 961,
"path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift",
"source": "Summarize what happened in my threads since yesterday.",
"surface": "apple",

View File

@@ -1,269 +0,0 @@
import AppKit
import Observation
import QuartzCore
import SwiftUI
/// Hover-only HUD anchored to the menu bar item. Click expands into full Web Chat.
@MainActor
@Observable
final class HoverHUDController {
static let shared = HoverHUDController()
struct Model {
var isVisible: Bool = false
var isSuppressed: Bool = false
var hoveringStatusItem: Bool = false
var hoveringPanel: Bool = false
}
private(set) var model = Model()
private var window: NSPanel?
private var hostingView: NSHostingView<HoverHUDView>?
private var dismissMonitor: Any?
private var dismissTask: Task<Void, Never>?
private var showTask: Task<Void, Never>?
private var anchorProvider: (() -> NSRect?)?
private let width: CGFloat = 360
private let height: CGFloat = 74
private let padding: CGFloat = 8
private let hoverShowDelay: TimeInterval = 0.18
func setSuppressed(_ suppressed: Bool) {
self.model.isSuppressed = suppressed
if suppressed {
self.showTask?.cancel()
self.showTask = nil
self.dismiss()
}
}
func statusItemHoverChanged(inside: Bool, anchorProvider: @escaping () -> NSRect?) {
self.model.hoveringStatusItem = inside
self.anchorProvider = anchorProvider
guard !self.model.isSuppressed else { return }
if inside {
self.dismissTask?.cancel()
self.dismissTask = nil
self.showTask?.cancel()
self.showTask = Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(self.hoverShowDelay * 1_000_000_000))
await MainActor.run { [weak self] in
guard let self else { return }
guard !Task.isCancelled else { return }
guard self.model.hoveringStatusItem else { return }
guard !self.model.isSuppressed else { return }
self.present()
}
}
} else {
self.showTask?.cancel()
self.showTask = nil
self.scheduleDismiss()
}
}
func panelHoverChanged(inside: Bool) {
self.model.hoveringPanel = inside
if inside {
self.dismissTask?.cancel()
self.dismissTask = nil
} else if !self.model.hoveringStatusItem {
self.scheduleDismiss()
}
}
func openChat() {
guard let anchorProvider = self.anchorProvider else { return }
self.dismiss()
Task { @MainActor in
let sessionKey = await WebChatManager.shared.preferredSessionKey()
WebChatManager.shared.togglePanel(sessionKey: sessionKey, anchorProvider: anchorProvider)
}
}
func dismiss() {
self.dismissTask?.cancel()
self.dismissTask = nil
self.removeDismissMonitor()
guard let window else {
self.model.isVisible = false
return
}
if !self.model.isVisible {
window.orderOut(nil)
return
}
OverlayPanelFactory.animateDismissAndHide(window: window, offsetX: 0, offsetY: 6, duration: 0.14) {
self.model.isVisible = false
}
}
// MARK: - Private
private func scheduleDismiss() {
self.dismissTask?.cancel()
self.dismissTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 250_000_000)
await MainActor.run {
guard let self else { return }
if self.model.hoveringStatusItem || self.model.hoveringPanel { return }
self.dismiss()
}
}
}
private func present() {
guard !self.model.isSuppressed else { return }
self.ensureWindow()
self.hostingView?.rootView = HoverHUDView(controller: self)
let target = self.targetFrame()
guard let window else { return }
self.installDismissMonitor()
if !self.model.isVisible {
self.model.isVisible = true
let start = target.offsetBy(dx: 0, dy: 8)
OverlayPanelFactory.animatePresent(window: window, from: start, to: target)
} else {
window.orderFrontRegardless()
self.updateWindowFrame(animate: true)
}
}
private func ensureWindow() {
if self.window != nil { return }
let panel = OverlayPanelFactory.makePanel(
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height),
level: .statusBar,
hasShadow: true)
let host = NSHostingView(rootView: HoverHUDView(controller: self))
host.translatesAutoresizingMaskIntoConstraints = false
panel.contentView = host
self.hostingView = host
self.window = panel
}
private func targetFrame() -> NSRect {
guard let anchor = self.anchorProvider?() else {
return WindowPlacement.topRightFrame(
size: NSSize(width: self.width, height: self.height),
padding: self.padding)
}
let screen = NSScreen.screens.first { screen in
screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
} ?? NSScreen.main
let bounds = (screen?.visibleFrame ?? .zero).insetBy(dx: self.padding, dy: self.padding)
return WindowPlacement.anchoredBelowFrame(
size: NSSize(width: self.width, height: self.height),
anchor: anchor,
padding: self.padding,
in: bounds)
}
private func updateWindowFrame(animate: Bool = false) {
OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate)
}
private func installDismissMonitor() {
if ProcessInfo.processInfo.isRunningTests { return }
guard self.dismissMonitor == nil, let window else { return }
self.dismissMonitor = NSEvent.addGlobalMonitorForEvents(matching: [
.leftMouseDown,
.rightMouseDown,
.otherMouseDown,
]) { [weak self] _ in
guard let self, self.model.isVisible else { return }
let pt = NSEvent.mouseLocation
if !window.frame.contains(pt) {
Task { @MainActor in self.dismiss() }
}
}
}
private func removeDismissMonitor() {
OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor)
}
}
private struct HoverHUDView: View {
var controller: HoverHUDController
private let activityStore = WorkActivityStore.shared
private var statusTitle: String {
if self.activityStore.iconState.isWorking { return "Working" }
return "Idle"
}
private var detail: String {
if let current = self.activityStore.current?.label, !current.isEmpty { return current }
if let last = self.activityStore.lastToolLabel, !last.isEmpty { return last }
return "No recent activity"
}
private var symbolName: String {
if self.activityStore.iconState.isWorking {
return self.activityStore.iconState.badgeSymbolName
}
return "moon.zzz.fill"
}
private var dotColor: Color {
if self.activityStore.iconState.isWorking {
return Color(nsColor: NSColor.systemGreen.withAlphaComponent(0.7))
}
return .secondary
}
var body: some View {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(self.dotColor)
.frame(width: 7, height: 7)
.padding(.top, 5)
VStack(alignment: .leading, spacing: 4) {
Text(self.statusTitle)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
Text(self.detail)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.lineLimit(2)
.truncationMode(.middle)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
Image(systemName: self.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
.padding(.top, 1)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.regularMaterial))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Color.black.opacity(0.10), lineWidth: 1))
.contentShape(Rectangle())
.onHover { inside in
self.controller.panelHoverChanged(inside: inside)
}
.onTapGesture {
self.controller.openChat()
}
}
}

View File

@@ -19,17 +19,12 @@ struct OpenClawApp: App {
@State private var statusItem: NSStatusItem?
@State private var statusItemMouseRouter = StatusItemMouseRouter()
@State private var isMenuPresented = false
@State private var isPanelVisible = false
@State private var isChatWindowVisible = false
@State private var tailscaleService = TailscaleService.shared
@MainActor
private func updateStatusHighlight() {
self.statusItem?.button?.highlight(self.isPanelVisible)
}
@MainActor
private func updateHoverHUDSuppression() {
HoverHUDController.shared.setSuppressed(self.isMenuPresented || self.isPanelVisible)
self.statusItem?.button?.highlight(self.isChatWindowVisible)
}
init() {
@@ -66,7 +61,6 @@ struct OpenClawApp: App {
MenuSessionsInjector.shared.install(into: item)
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
self.installStatusItemMouseHandler(for: item)
self.updateHoverHUDSuppression()
}
.menuBarExtraStyle(.menu)
.onChange(of: self.state.isPaused) { _, paused in
@@ -142,10 +136,6 @@ struct OpenClawApp: App {
.keyboardShortcut("k", modifiers: .command)
}
}
.onChange(of: self.isMenuPresented) { _, _ in
self.updateStatusHighlight()
self.updateHoverHUDSuppression()
}
}
private func applyStatusItemAppearance(paused _: Bool, sleeping _: Bool) {
@@ -195,10 +185,9 @@ struct OpenClawApp: App {
@MainActor
private func installStatusItemMouseHandler(for item: NSStatusItem) {
WebChatManager.shared.onPanelVisibilityChanged = { [self] visible in
self.isPanelVisible = visible
WebChatManager.shared.onChatWindowVisibilityChanged = { [self] visible in
self.isChatWindowVisible = visible
self.updateStatusHighlight()
self.updateHoverHUDSuppression()
}
CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in
self.state.canvasPanelVisible = visible
@@ -208,25 +197,15 @@ struct OpenClawApp: App {
self.statusItemMouseRouter.install(
on: item,
onLeftClick: { [self] in
HoverHUDController.shared.dismiss()
self.openDashboardWindow()
},
onRightClick: { [self] in
HoverHUDController.shared.dismiss()
WebChatManager.shared.closePanel()
self.isMenuPresented = true
self.updateStatusHighlight()
},
onHoverChanged: { [self] inside in
HoverHUDController.shared.statusItemHoverChanged(
inside: inside,
anchorProvider: { [self] in self.statusButtonScreenFrame() })
})
}
@MainActor
private func openDashboardWindow() {
HoverHUDController.shared.setSuppressed(true)
self.isMenuPresented = false
AppNavigationActions.openDashboard()
}
@@ -263,10 +242,8 @@ final class StatusItemMouseRouter: NSResponder {
private weak var button: NSView?
private var eventMonitor: Any?
private var trackingArea: NSTrackingArea?
private var onLeftClick: (() -> Void)?
private var onRightClick: (() -> Void)?
private var onHoverChanged: ((Bool) -> Void)?
private let eventMonitorInstaller: EventMonitorInstaller
private let eventMonitorRemover: EventMonitorRemover
@@ -296,27 +273,23 @@ final class StatusItemMouseRouter: NSResponder {
func install(
on item: NSStatusItem,
onLeftClick: @escaping () -> Void,
onRightClick: @escaping () -> Void,
onHoverChanged: @escaping (Bool) -> Void)
onRightClick: @escaping () -> Void)
{
guard let button = item.button else { return }
self.install(
on: button,
onLeftClick: onLeftClick,
onRightClick: onRightClick,
onHoverChanged: onHoverChanged)
onRightClick: onRightClick)
}
func install(
on button: NSView,
onLeftClick: @escaping () -> Void,
onRightClick: @escaping () -> Void,
onHoverChanged: @escaping (Bool) -> Void)
onRightClick: @escaping () -> Void)
{
self.onLeftClick = onLeftClick
self.onRightClick = onRightClick
self.onHoverChanged = onHoverChanged
self.track(button)
self.button = button
guard self.eventMonitor == nil else { return }
self.eventMonitor = Self.installMonitor(using: self.eventMonitorInstaller) { [weak self] event in
@@ -361,35 +334,12 @@ final class StatusItemMouseRouter: NSResponder {
}
}
private func track(_ button: NSView) {
guard self.button !== button else { return }
if let previousButton = self.button, let trackingArea {
previousButton.removeTrackingArea(trackingArea)
}
let trackingArea = NSTrackingArea(
rect: button.bounds,
options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
owner: self,
userInfo: nil)
button.addTrackingArea(trackingArea)
self.button = button
self.trackingArea = trackingArea
}
private static func contains(_ event: NSEvent, in button: NSView) -> Bool {
guard let window = button.window, event.windowNumber == window.windowNumber else { return false }
let point = button.convert(event.locationInWindow, from: nil)
return button.bounds.contains(point)
}
override func mouseEntered(with _: NSEvent) {
self.onHoverChanged?(true)
}
override func mouseExited(with _: NSEvent) {
self.onHoverChanged?(false)
}
@MainActor deinit {
if let eventMonitor {
self.eventMonitorRemover(eventMonitor)

View File

@@ -2,22 +2,6 @@ import AppKit
import Foundation
import OpenClawChatUI
/// A borderless panel that can still accept key focus (needed for typing).
final class WebChatPanel: NSPanel {
override var canBecomeKey: Bool {
true
}
override var canBecomeMain: Bool {
true
}
}
enum WebChatPresentation {
case window
case panel(anchorProvider: () -> NSRect?)
}
struct WebChatRoute: Equatable, Sendable {
let sessionKey: String
let agentID: String?
@@ -74,8 +58,6 @@ final class WebChatManager {
private var windowController: WebChatSwiftUIWindowController?
private var windowRoute: WebChatRoute?
private var panelController: WebChatSwiftUIWindowController?
private var panelRoute: WebChatRoute?
private var currentChatRoute: WebChatRoute?
private var cachedPreferredSessionKey: String?
private var profileWindows: [UUID: ProfileWindowInstance] = [:]
@@ -89,15 +71,14 @@ final class WebChatManager {
private static let lastGatewayProfileIDKey = "openclaw.webchat.lastGatewayProfileID"
var onPanelVisibilityChanged: ((Bool) -> Void)?
var onChatWindowVisibilityChanged: ((Bool) -> Void)?
var activeSessionKey: String? {
self.currentChatRoute?.sessionKey ?? self.panelRoute?.sessionKey ?? self.windowRoute?.sessionKey
self.currentChatRoute?.sessionKey ?? self.windowRoute?.sessionKey
}
func show(sessionKey: String, agentID: String? = nil, draft: String? = nil) {
let route = WebChatRoute(sessionKey: sessionKey, agentID: agentID)
self.closePanel()
if let controller = windowController {
// The window shell switches sessions in place (sidebar, /new);
// full route identity tracks those switches and the global owner.
@@ -114,19 +95,18 @@ final class WebChatManager {
let controller = WebChatSwiftUIWindowController(
sessionKey: route.sessionKey,
agentID: route.agentID,
initialDraft: draft,
presentation: .window)
initialDraft: draft)
controller.onVisibilityChanged = { [weak self, weak controller] visible in
guard let self, let controller else { return }
self.setSessionObserverVisible(visible, owner: controller, connection: .shared)
self.onPanelVisibilityChanged?(visible)
self.onChatWindowVisibilityChanged?(visible)
}
controller.onClosed = { [weak self, weak controller] in
guard let self, let controller else { return }
self.setSessionObserverVisible(false, owner: controller, connection: .shared)
guard self.windowController === controller else { return }
if self.currentChatRoute == self.windowRoute {
self.currentChatRoute = self.panelRoute
self.currentChatRoute = nil
}
self.windowController = nil
self.windowRoute = nil
@@ -147,12 +127,10 @@ final class WebChatManager {
#if DEBUG
func showSwarmFixture() {
self.closePanel()
self.windowController?.close()
let transport = MacSwarmFixtureChatTransport()
let controller = WebChatSwiftUIWindowController(
sessionKey: transport.sessionKey,
presentation: .window,
transport: transport,
windowTitle: "OpenClaw Swarm Fixture",
windowAutosaveName: "OpenClawSwarmFixture")
@@ -224,7 +202,6 @@ final class WebChatManager {
let controller = WebChatSwiftUIWindowController(
sessionKey: route.sessionKey,
agentID: route.agentID,
presentation: .window,
connection: connection,
gatewayID: profile.id,
windowTitle: "\(profile.name) — OpenClaw",
@@ -269,61 +246,14 @@ final class WebChatManager {
self.unavailableProfileIDs.remove(profileID)
}
func togglePanel(
sessionKey: String,
agentID: String? = nil,
anchorProvider: @escaping () -> NSRect?)
{
let route = WebChatRoute(sessionKey: sessionKey, agentID: agentID)
if let controller = panelController {
if !Self.shouldReuseController(currentRoute: self.panelRoute, requestedRoute: route) {
controller.close()
self.panelController = nil
self.panelRoute = nil
} else {
if controller.isVisible {
controller.close()
} else {
controller.presentAnchored(anchorProvider: anchorProvider)
}
return
}
}
let controller = WebChatSwiftUIWindowController(
sessionKey: route.sessionKey,
agentID: route.agentID,
presentation: .panel(anchorProvider: anchorProvider))
controller.onClosed = { [weak self] in
self?.panelHidden()
}
controller.onVisibilityChanged = { [weak self] visible in
self?.onPanelVisibilityChanged?(visible)
}
controller.onSessionKeyChanged = { [weak self, weak controller] key in
guard let self, let controller, self.panelController === controller else { return }
let updatedRoute = (self.panelRoute ?? route).replacingSessionKey(key)
self.panelRoute = updatedRoute
self.currentChatRoute = updatedRoute
}
self.panelController = controller
self.panelRoute = route
self.currentChatRoute = route
controller.presentAnchored(anchorProvider: anchorProvider)
}
func recordActiveSessionKey(_ sessionKey: String) {
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let route = self.currentChatRoute ?? self.panelRoute ?? self.windowRoute
let route = self.currentChatRoute ?? self.windowRoute
self.currentChatRoute = route?.replacingSessionKey(trimmed)
?? WebChatRoute(sessionKey: trimmed, agentID: nil)
}
func closePanel() {
self.panelController?.close()
}
func preferredSessionKey() async -> String {
if let cachedPreferredSessionKey {
return cachedPreferredSessionKey
@@ -337,9 +267,6 @@ final class WebChatManager {
self.windowController?.close()
self.windowController = nil
self.windowRoute = nil
self.panelController?.close()
self.panelController = nil
self.panelRoute = nil
self.currentChatRoute = nil
self.cachedPreferredSessionKey = nil
let profileControllers = self.profileWindows.values.map(\.controller)
@@ -467,11 +394,6 @@ final class WebChatManager {
self.sessionObserverRequests.removeValue(forKey: connection)
}
private func panelHidden() {
self.onPanelVisibilityChanged?(false)
// Keep panel controller cached so reopening doesn't re-bootstrap.
}
static func shouldReuseController(
currentRoute: WebChatRoute?,
requestedRoute: WebChatRoute) -> Bool

View File

@@ -4,7 +4,6 @@ import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import OSLog
import QuartzCore
import SwiftUI
private let webChatSwiftLogger = Logger(subsystem: "ai.openclaw", category: "WebChatSwiftUI")
@@ -13,10 +12,8 @@ private let webChatVerboseLevelDefaultsKey = "openclaw.webchat.verboseLevel"
private enum WebChatSwiftUILayout {
static let windowSize = NSSize(width: 960, height: 700)
static let panelSize = NSSize(width: 480, height: 640)
static let windowMinSize = NSSize(width: 640, height: 420)
static let windowFrameAutosaveName = "OpenClawChatWindow"
static let anchorPadding: CGFloat = 8
}
enum WebChatTracePreferences {
@@ -847,7 +844,6 @@ private struct MacChatSurface: View {
@AppStorage(OpenClawChatWindowShell.assistantToolActivityDefaultsKey)
private var showsToolActivity = WebChatTracePreferences.displayOptions().contains(.toolActivity)
private let isFullWindow: Bool
private let userAccent: Color?
private let usesPrimaryAppRuntime: Bool
private let speech: OpenClawChatSpeechController
@@ -855,14 +851,12 @@ private struct MacChatSurface: View {
init(
viewModel: OpenClawChatViewModel,
isFullWindow: Bool,
userAccent: Color?,
usesPrimaryAppRuntime: Bool,
speech: OpenClawChatSpeechController,
voiceNoteRecorder: OpenClawVoiceNoteRecorder)
{
_viewModel = State(initialValue: viewModel)
self.isFullWindow = isFullWindow
self.userAccent = userAccent
self.usesPrimaryAppRuntime = usesPrimaryAppRuntime
self.speech = speech
@@ -870,39 +864,21 @@ private struct MacChatSurface: View {
}
var body: some View {
Group {
if self.isFullWindow {
OpenClawChatWindowShell(
viewModel: self.viewModel,
userAccent: self.userAccent,
displayOptions: self.displayOptions,
emptyAssistantIntro: Self.emptyAssistantIntro,
emptyAssistantPrompts: Self.emptyAssistantPrompts,
talkControl: self.talkControl,
voiceNoteControl: self.voiceNoteControl,
speech: self.speech,
mediaPlaybackAllowed: {
!AppStateStore.shared.talkEnabled &&
!self.voiceNoteRecorder.ownsPendingChatAttachment
})
} else {
OpenClawChatView(
viewModel: self.viewModel,
showsSessionSwitcher: true,
userAccent: self.userAccent,
emptyAssistantIntro: Self.emptyAssistantIntro,
emptyAssistantPrompts: Self.emptyAssistantPrompts,
talkControl: self.talkControl,
voiceNoteControl: self.voiceNoteControl,
speech: self.speech,
mediaPlaybackAllowed: {
!AppStateStore.shared.talkEnabled &&
!self.voiceNoteRecorder.ownsPendingChatAttachment
})
}
}
.onAppear { self.audioInputCatalog.start() }
.onDisappear { self.audioInputCatalog.stop() }
OpenClawChatWindowShell(
viewModel: self.viewModel,
userAccent: self.userAccent,
displayOptions: self.displayOptions,
emptyAssistantIntro: Self.emptyAssistantIntro,
emptyAssistantPrompts: Self.emptyAssistantPrompts,
talkControl: self.talkControl,
voiceNoteControl: self.voiceNoteControl,
speech: self.speech,
mediaPlaybackAllowed: {
!AppStateStore.shared.talkEnabled &&
!self.voiceNoteRecorder.ownsPendingChatAttachment
})
.onAppear { self.audioInputCatalog.start() }
.onDisappear { self.audioInputCatalog.stop() }
}
private var talkControl: OpenClawChatTalkControl {
@@ -991,7 +967,7 @@ private struct MacChatSurface: View {
hasTalkControl: true,
hasSpeech: true,
hasVoiceNoteControl: true,
displayOptions: self.isFullWindow ? self.displayOptions : [])
displayOptions: self.displayOptions)
}
#endif
}
@@ -1015,7 +991,6 @@ private final class WebChatSessionKeyRelay {
@MainActor
final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
private let presentation: WebChatPresentation
private let sessionKey: String
private let initialActiveAgentID: String?
private let viewModel: OpenClawChatViewModel
@@ -1024,7 +999,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
private let speech: OpenClawChatSpeechController
private let voiceNoteRecorder: OpenClawVoiceNoteRecorder
private var window: NSWindow?
private var dismissMonitor: Any?
var onClosed: (() -> Void)?
var onVisibilityChanged: ((Bool) -> Void)?
/// Fires when the hosted chat switches sessions in place (sidebar,
@@ -1035,7 +1009,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
sessionKey: String,
agentID: String? = nil,
initialDraft: String? = nil,
presentation: WebChatPresentation,
connection: GatewayConnection = .shared,
gatewayID: String? = nil,
windowTitle: String = "OpenClaw Chat",
@@ -1054,7 +1027,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
sessionKey: sessionKey,
agentID: agentID,
initialDraft: initialDraft,
presentation: presentation,
connection: connection,
cachedRoutingIdentity: context?.routingIdentity,
store: context?.store,
@@ -1066,7 +1038,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
sessionKey: String,
agentID: String?,
initialDraft: String? = nil,
presentation: WebChatPresentation,
connection: GatewayConnection = .shared,
cachedRoutingIdentity: OpenClawChatSessionRoutingIdentity?,
store: OpenClawChatSQLiteTranscriptCache?,
@@ -1080,7 +1051,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
self.init(
sessionKey: sessionKey,
initialDraft: initialDraft,
presentation: presentation,
transport: MacGatewayChatTransport(
connection: connection,
outboxGatewayID: store?.gatewayID,
@@ -1097,7 +1067,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
init(
sessionKey: String,
initialDraft: String? = nil,
presentation: WebChatPresentation,
transport: any OpenClawChatTransport,
initialActiveAgentID: String? = nil,
explicitAgentID: String? = nil,
@@ -1108,7 +1077,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
windowAutosaveName: String = WebChatSwiftUILayout.windowFrameAutosaveName)
{
self.sessionKey = sessionKey
self.presentation = presentation
let initialActiveAgentID = WebChatRoute.normalizedAgentID(initialActiveAgentID)
self.initialActiveAgentID = initialActiveAgentID
let voiceNoteRecorder = OpenClawVoiceNoteRecorder()
@@ -1191,32 +1159,17 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
}
}
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
switch presentation {
case .window:
// Full window: native split-view shell with sessions sidebar and
// toolbar pickers bridged into the NSToolbar.
let hosting = NSHostingController(rootView: MacChatSurface(
viewModel: vm,
isFullWindow: true,
userAccent: accent,
usesPrimaryAppRuntime: usesPrimaryAppRuntime,
speech: speech,
voiceNoteRecorder: voiceNoteRecorder))
self.contentController = hosting
case .panel:
// Anchored compact chat panel: single-column chat.
let hosting = NSHostingController(rootView: MacChatSurface(
viewModel: vm,
isFullWindow: false,
userAccent: accent,
usesPrimaryAppRuntime: usesPrimaryAppRuntime,
speech: speech,
voiceNoteRecorder: voiceNoteRecorder))
self.contentController = Self.makePanelContentController(hosting: hosting)
}
// Full window: native split-view shell with sessions sidebar and
// toolbar pickers bridged into the NSToolbar.
let hosting = NSHostingController(rootView: MacChatSurface(
viewModel: vm,
userAccent: accent,
usesPrimaryAppRuntime: usesPrimaryAppRuntime,
speech: speech,
voiceNoteRecorder: voiceNoteRecorder))
self.contentController = hosting
super.init()
self.window = Self.makeWindow(
for: presentation,
contentViewController: self.contentController,
title: windowTitle,
autosaveName: windowAutosaveName)
@@ -1226,12 +1179,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
}
}
deinit {}
var isVisible: Bool {
self.window?.isVisible ?? false
}
func applyDraftIfEmpty(_ draft: String?) {
guard self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let draft,
@@ -1249,8 +1196,7 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
}
func cascade(from source: WebChatSwiftUIWindowController?) {
guard case .window = self.presentation,
let window,
guard let window,
let sourceWindow = source?.window,
sourceWindow !== window
else { return }
@@ -1260,100 +1206,19 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
display: false)
}
func presentAnchored(anchorProvider: () -> NSRect?) {
guard case .panel = self.presentation, let window else { return }
self.installDismissMonitor()
let target = self.reposition(using: anchorProvider)
if !self.isVisible {
let start = target.offsetBy(dx: 0, dy: 8)
window.setFrame(start, display: true)
window.alphaValue = 0
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(target, display: true)
window.animator().alphaValue = 1
}
} else {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
self.onVisibilityChanged?(true)
}
func close() {
switch self.presentation {
case .window:
self.window?.close()
case .panel:
self.window?.orderOut(nil)
self.onVisibilityChanged?(false)
self.onClosed?()
self.removeDismissMonitor()
}
self.window?.close()
}
func windowWillClose(_ notification: Notification) {
guard case .window = self.presentation,
notification.object as? NSWindow === self.window
else { return }
guard notification.object as? NSWindow === self.window else { return }
self.onVisibilityChanged?(false)
self.removeDismissMonitor()
let onClosed = self.onClosed
self.onClosed = nil
self.window = nil
onClosed?()
}
@discardableResult
private func reposition(using anchorProvider: () -> NSRect?) -> NSRect {
guard let window else { return .zero }
guard let anchor = anchorProvider() else {
let frame = WindowPlacement.topRightFrame(
size: WebChatSwiftUILayout.panelSize,
padding: WebChatSwiftUILayout.anchorPadding)
window.setFrame(frame, display: false)
return frame
}
let screen = NSScreen.screens.first { screen in
screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
} ?? NSScreen.main
let bounds = (screen?.visibleFrame ?? .zero).insetBy(
dx: WebChatSwiftUILayout.anchorPadding,
dy: WebChatSwiftUILayout.anchorPadding)
let frame = WindowPlacement.anchoredBelowFrame(
size: WebChatSwiftUILayout.panelSize,
anchor: anchor,
padding: WebChatSwiftUILayout.anchorPadding,
in: bounds)
window.setFrame(frame, display: false)
return frame
}
private func installDismissMonitor() {
if ProcessInfo.processInfo.isRunningTests {
return
}
guard self.dismissMonitor == nil, self.window != nil else { return }
self.dismissMonitor = NSEvent.addGlobalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown])
{ [weak self] _ in
guard let self, let win = self.window else { return }
let pt = NSEvent.mouseLocation
if !win.frame.contains(pt) {
self.close()
}
}
}
private func removeDismissMonitor() {
OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor)
}
static func persistedThinkingLevel(defaults: UserDefaults = .standard) -> String? {
let stored = defaults.string(forKey: webChatThinkingLevelDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1390,109 +1255,40 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
}
private static func makeWindow(
for presentation: WebChatPresentation,
contentViewController: NSViewController,
title: String,
autosaveName: String) -> NSWindow
{
switch presentation {
case .window:
let window = WebChatWindow(
contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.windowSize),
styleMask: [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView],
backing: .buffered,
defer: false)
window.title = title
window.pinnedTitle = title
window.contentViewController = contentViewController
// Attaching an NSHostingController resets scene bridging to `.all`;
// opt back into toolbar items only so SwiftUI cannot restore the title.
(contentViewController as? NSHostingController<MacChatSurface>)?
.sceneBridgingOptions = [.toolbars]
window.isReleasedWhenClosed = false
window.isRestorable = false
// Keep the SwiftUI toolbar controls, but merge their unified row
// with the traffic lights instead of stacking it below a title band.
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.toolbarStyle = .unified
window.titlebarSeparatorStyle = .none
window.isMovableByWindowBackground = true
window.center()
window.setFrameAutosaveName(autosaveName)
WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize)
window.minSize = WebChatSwiftUILayout.windowMinSize
return window
case .panel:
let panel = WebChatPanel(
contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.panelSize),
styleMask: [.borderless],
backing: .buffered,
defer: false)
panel.level = .statusBar
panel.hidesOnDeactivate = true
panel.hasShadow = true
panel.isMovable = false
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.titleVisibility = .hidden
panel.titlebarAppearsTransparent = true
panel.backgroundColor = .clear
panel.isOpaque = false
panel.contentViewController = contentViewController
panel.becomesKeyOnlyIfNeeded = true
panel.contentView?.wantsLayer = true
panel.contentView?.layer?.backgroundColor = NSColor.clear.cgColor
panel.setFrame(
WindowPlacement.topRightFrame(
size: WebChatSwiftUILayout.panelSize,
padding: WebChatSwiftUILayout.anchorPadding),
display: false)
return panel
}
}
private static func makePanelContentController(
hosting: NSHostingController<MacChatSurface>) -> NSViewController
{
let controller = NSViewController()
let effectView = NSVisualEffectView()
effectView.material = .sidebar
effectView.blendingMode = .withinWindow
effectView.state = .active
effectView.wantsLayer = true
effectView.layer?.cornerCurve = .continuous
let cornerRadius: CGFloat = 16
effectView.layer?.cornerRadius = cornerRadius
effectView.layer?.masksToBounds = true
effectView.layer?.backgroundColor = NSColor.clear.cgColor
effectView.translatesAutoresizingMaskIntoConstraints = true
effectView.autoresizingMask = [.width, .height]
let rootView = effectView
hosting.view.translatesAutoresizingMaskIntoConstraints = false
hosting.view.wantsLayer = true
hosting.view.layer?.cornerCurve = .continuous
hosting.view.layer?.cornerRadius = cornerRadius
hosting.view.layer?.masksToBounds = true
hosting.view.layer?.backgroundColor = NSColor.clear.cgColor
controller.addChild(hosting)
effectView.addSubview(hosting.view)
controller.view = rootView
NSLayoutConstraint.activate([
hosting.view.leadingAnchor.constraint(equalTo: effectView.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: effectView.trailingAnchor),
hosting.view.topAnchor.constraint(equalTo: effectView.topAnchor),
hosting.view.bottomAnchor.constraint(equalTo: effectView.bottomAnchor),
])
return controller
let window = WebChatWindow(
contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.windowSize),
styleMask: [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView],
backing: .buffered,
defer: false)
window.title = title
window.pinnedTitle = title
window.contentViewController = contentViewController
// Attaching an NSHostingController resets scene bridging to `.all`;
// opt back into toolbar items only so SwiftUI cannot restore the title.
(contentViewController as? NSHostingController<MacChatSurface>)?
.sceneBridgingOptions = [.toolbars]
window.isReleasedWhenClosed = false
window.isRestorable = false
// Keep the SwiftUI toolbar controls, but merge their unified row
// with the traffic lights instead of stacking it below a title band.
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.toolbarStyle = .unified
window.titlebarSeparatorStyle = .none
window.isMovableByWindowBackground = true
window.center()
window.setFrameAutosaveName(autosaveName)
WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize)
window.minSize = WebChatSwiftUILayout.windowMinSize
return window
}
private func ensureWindowSize() {
guard case .window = self.presentation, let window else { return }
guard let window else { return }
let current = window.frame.size
let min = WebChatSwiftUILayout.windowMinSize
if current.width < min.width || current.height < min.height {

View File

@@ -42,28 +42,6 @@ enum WindowPlacement {
return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight)
}
static func anchoredBelowFrame(size: NSSize, anchor: NSRect, padding: CGFloat, in bounds: NSRect) -> NSRect {
if bounds == .zero {
let x = round(anchor.midX - size.width / 2)
let y = round(anchor.minY - size.height - padding)
return NSRect(x: x, y: y, width: size.width, height: size.height)
}
let clampedWidth = min(size.width, bounds.width)
let clampedHeight = min(size.height, bounds.height)
let desiredX = round(anchor.midX - clampedWidth / 2)
let desiredY = round(anchor.minY - clampedHeight - padding)
let maxX = bounds.maxX - clampedWidth
let maxY = bounds.maxY - clampedHeight
let x = maxX >= bounds.minX ? min(max(desiredX, bounds.minX), maxX) : bounds.minX
let y = maxY >= bounds.minY ? min(max(desiredY, bounds.minY), maxY) : bounds.minY
return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight)
}
static func cascadedFrame(from frame: NSRect, offset: CGFloat = 24, in bounds: NSRect) -> NSRect {
guard bounds != .zero else {
return frame.offsetBy(dx: offset, dy: -offset)

View File

@@ -20,7 +20,6 @@ final class WorkActivityStore {
private(set) var current: Activity?
private(set) var iconState: IconState = .idle
private(set) var lastToolLabel: String?
private(set) var lastToolUpdatedAt: Date?
private var jobs: [String: Activity] = [:]
@@ -63,7 +62,6 @@ final class WorkActivityStore {
let toolKind = Self.mapToolKind(name)
let label = Self.buildLabel(name: name, meta: meta, args: args)
if phase.lowercased() == "start" {
self.lastToolLabel = label
self.lastToolUpdatedAt = Date()
self.toolSeqBySession[sessionKey, default: 0] += 1
let activity = Activity(

View File

@@ -1,26 +0,0 @@
import AppKit
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct HoverHUDControllerTests {
@Test func `hover HUD controller presents and dismisses`() async {
let controller = HoverHUDController()
controller.setSuppressed(false)
controller.statusItemHoverChanged(
inside: true,
anchorProvider: { NSRect(x: 10, y: 10, width: 24, height: 24) })
try? await Task.sleep(nanoseconds: 260_000_000)
controller.panelHoverChanged(inside: true)
controller.panelHoverChanged(inside: false)
controller.statusItemHoverChanged(
inside: false,
anchorProvider: { NSRect(x: 10, y: 10, width: 24, height: 24) })
controller.dismiss()
controller.setSuppressed(true)
}
}

View File

@@ -55,13 +55,18 @@ struct StatusItemMouseRouterTests {
#expect(rightClicks == 1)
}
@Test func `retargets hover tracking without reinstalling the monitor`() throws {
@Test func `retargets clicks without reinstalling the monitor`() throws {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 80, height: 24),
styleMask: .borderless,
backing: .buffered,
defer: false)
let firstButton = NSView(frame: NSRect(x: 0, y: 0, width: 24, height: 24))
let secondButton = NSView(frame: NSRect(x: 0, y: 0, width: 24, height: 24))
let firstTrackingAreaCount = firstButton.trackingAreas.count
let secondTrackingAreaCount = secondButton.trackingAreas.count
let secondButton = NSView(frame: NSRect(x: 40, y: 0, width: 24, height: 24))
window.contentView?.addSubview(firstButton)
window.contentView?.addSubview(secondButton)
var monitorInstallCount = 0
var hoverChanges: [Bool] = []
var leftClicks = 0
let router = StatusItemMouseRouter(
eventMonitorInstaller: { _, _ in
monitorInstallCount += 1
@@ -71,23 +76,27 @@ struct StatusItemMouseRouterTests {
router.install(
on: firstButton,
onLeftClick: {},
onRightClick: {},
onHoverChanged: { hoverChanges.append($0) })
#expect(firstButton.trackingAreas.count == firstTrackingAreaCount + 1)
try router.mouseEntered(with: Self.mouseEvent(.mouseMoved))
try router.mouseExited(with: Self.mouseEvent(.mouseMoved))
onLeftClick: { leftClicks += 1 },
onRightClick: {})
let firstClick = try Self.mouseEvent(
.leftMouseDown,
location: NSPoint(x: 12, y: 12),
windowNumber: window.windowNumber)
#expect(router.route(firstClick) == nil)
#expect(leftClicks == 1)
router.install(
on: secondButton,
onLeftClick: {},
onRightClick: {},
onHoverChanged: { hoverChanges.append($0) })
onLeftClick: { leftClicks += 1 },
onRightClick: {})
#expect(monitorInstallCount == 1)
#expect(firstButton.trackingAreas.count == firstTrackingAreaCount)
#expect(secondButton.trackingAreas.count == secondTrackingAreaCount + 1)
try router.mouseEntered(with: Self.mouseEvent(.mouseMoved))
#expect(hoverChanges == [true, false, true])
#expect(router.route(firstClick) === firstClick)
let secondClick = try Self.mouseEvent(
.leftMouseDown,
location: NSPoint(x: 52, y: 12),
windowNumber: window.windowNumber)
#expect(router.route(secondClick) == nil)
#expect(leftClicks == 2)
}
@Test func `non-target and unrelated events continue to native dispatch`() throws {
@@ -113,13 +122,17 @@ struct StatusItemMouseRouterTests {
#expect(rightClicks == 0)
}
private static func mouseEvent(_ type: NSEvent.EventType) throws -> NSEvent {
private static func mouseEvent(
_ type: NSEvent.EventType,
location: NSPoint = .zero,
windowNumber: Int = 0) throws -> NSEvent
{
try #require(NSEvent.mouseEvent(
with: type,
location: .zero,
location: location,
modifierFlags: [],
timestamp: 0,
windowNumber: 0,
windowNumber: windowNumber,
context: nil,
eventNumber: 1,
clickCount: 1,

View File

@@ -143,7 +143,6 @@ struct WebChatSwiftUISmokeTests {
}
let controller = WebChatSwiftUIWindowController(
sessionKey: "main",
presentation: .window,
transport: TestTransport(),
windowTitle: "Studio — OpenClaw")
let window = try #require(controller._testWindow)
@@ -172,20 +171,9 @@ struct WebChatSwiftUISmokeTests {
controller.close()
}
@Test func `panel controller present and close`() {
let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) }
let controller = WebChatSwiftUIWindowController(
sessionKey: "main",
presentation: .panel(anchorProvider: anchor),
transport: TestTransport())
controller.presentAnchored(anchorProvider: anchor)
controller.close()
}
@Test func `closing a full window releases it and notifies its owner once`() {
let controller = WebChatSwiftUIWindowController(
sessionKey: "main",
presentation: .window,
transport: TestTransport())
var closeCount = 0
var visibilityChanges: [Bool] = []
@@ -222,7 +210,6 @@ struct WebChatSwiftUISmokeTests {
let controller = WebChatSwiftUIWindowController(
sessionKey: "main",
initialDraft: "Wake up, my friend!",
presentation: .window,
transport: TestTransport())
#expect(controller._testDraft == "Wake up, my friend!")
@@ -239,13 +226,11 @@ struct WebChatSwiftUISmokeTests {
let explicit = WebChatSwiftUIWindowController(
sessionKey: "global",
agentID: " Work ",
presentation: .window,
cachedRoutingIdentity: cachedIdentity,
store: nil)
let fallback = WebChatSwiftUIWindowController(
sessionKey: "global",
agentID: nil,
presentation: .window,
cachedRoutingIdentity: cachedIdentity,
store: nil)