mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
d9c9ad8d8a
commit
b8a6ea12cc
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user