mac: left-click webchat panel

This commit is contained in:
Peter Steinberger
2025-12-09 21:29:05 +01:00
parent c35f9c1315
commit ad5c7d97ca
2 changed files with 171 additions and 13 deletions

View File

@@ -35,6 +35,7 @@ struct ClawdisApp: App {
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
self.statusItem = item
self.applyStatusItemAppearance(paused: self.state.isPaused)
self.installStatusItemMouseHandler(for: item)
}
.onChange(of: self.state.isPaused) { _, paused in
self.applyStatusItemAppearance(paused: paused)
@@ -53,6 +54,39 @@ struct ClawdisApp: App {
self.statusItem?.button?.appearsDisabled = paused
}
@MainActor
private func installStatusItemMouseHandler(for item: NSStatusItem) {
guard let button = item.button else { return }
if button.subviews.contains(where: { $0 is StatusItemMouseHandlerView }) { return }
let handler = StatusItemMouseHandlerView()
handler.translatesAutoresizingMaskIntoConstraints = false
handler.onLeftClick = { [self] in self.toggleWebChatPanel() }
handler.onRightClick = { WebChatManager.shared.closePanel() }
button.addSubview(handler)
NSLayoutConstraint.activate([
handler.leadingAnchor.constraint(equalTo: button.leadingAnchor),
handler.trailingAnchor.constraint(equalTo: button.trailingAnchor),
handler.topAnchor.constraint(equalTo: button.topAnchor),
handler.bottomAnchor.constraint(equalTo: button.bottomAnchor)
])
}
@MainActor
private func toggleWebChatPanel() {
self.isMenuPresented = false
WebChatManager.shared.togglePanel(
sessionKey: WebChatManager.shared.preferredSessionKey(),
anchorProvider: { [self] in self.statusButtonScreenFrame() })
}
@MainActor
private func statusButtonScreenFrame() -> NSRect? {
guard let button = self.statusItem?.button, let window = button.window else { return nil }
return window.convertToScreen(button.frame)
}
private var effectiveIconState: IconState {
let selection = self.state.iconOverride
if selection == .system {
@@ -68,6 +102,25 @@ struct ClawdisApp: App {
}
}
/// Transparent overlay that intercepts clicks without stealing MenuBarExtra ownership.
private final class StatusItemMouseHandlerView: NSView {
var onLeftClick: (() -> Void)?
var onRightClick: (() -> Void)?
override func mouseDown(with event: NSEvent) {
if let onLeftClick {
onLeftClick()
} else {
super.mouseDown(with: event)
}
}
override func rightMouseDown(with event: NSEvent) {
self.onRightClick?()
super.rightMouseDown(with: event) // forward to MenuBarExtra so the menu still opens
}
}
final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate {
private var listener: NSXPCListener?
private var state: AppState?

View File

@@ -6,8 +6,18 @@ import WebKit
private let webChatLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat")
enum WebChatPresentation {
case window
case panel(anchorProvider: () -> NSRect?)
var isPanel: Bool {
if case .panel = self { return true }
return false
}
}
@MainActor
final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
final class WebChatWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
private let webView: WKWebView
private let sessionKey: String
private var tunnel: WebChatTunnel?
@@ -15,11 +25,13 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
private let remotePort: Int
private var reachabilityTask: Task<Void, Never>?
private var tunnelRestartEnabled = false
let presentation: WebChatPresentation
init(sessionKey: String) {
init(sessionKey: String, presentation: WebChatPresentation = .window) {
webChatLogger.debug("init WebChatWindowController sessionKey=\(sessionKey, privacy: .public)")
self.sessionKey = sessionKey
self.remotePort = AppStateStore.webChatPort
self.presentation = presentation
let config = WKWebViewConfiguration()
let contentController = WKUserContentController()
@@ -28,15 +40,10 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
self.webView = WKWebView(frame: .zero, configuration: config)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 960, height: 720),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false)
window.title = "Clawd Web Chat"
window.contentView = self.webView
let window = Self.makeWindow(for: presentation, contentView: self.webView)
super.init(window: window)
self.webView.navigationDelegate = self
self.window?.delegate = self
self.loadPlaceholder()
Task { await self.bootstrap() }
@@ -50,6 +57,38 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
self.stopTunnel(allowRestart: false)
}
private static func makeWindow(for presentation: WebChatPresentation, contentView: NSView) -> NSWindow {
switch presentation {
case .window:
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 960, height: 720),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false)
window.title = "Clawd Web Chat"
window.contentView = contentView
return window
case .panel:
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 560),
styleMask: [.nonactivatingPanel, .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 = .windowBackgroundColor
panel.isOpaque = false
panel.contentView = contentView
panel.becomesKeyOnlyIfNeeded = true
return panel
}
}
private func loadPlaceholder() {
let html = """
<html><body style='font-family:-apple-system;padding:24px;color:#888'>Connecting to web chat…</body></html>
@@ -176,6 +215,29 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
self.tunnel = nil
}
func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) {
guard case .panel = self.presentation, let window else { return }
self.repositionPanel(using: anchorProvider)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
window.makeFirstResponder(self.webView)
}
func closePanel() {
guard case .panel = self.presentation else { return }
self.window?.orderOut(nil)
}
private func repositionPanel(using anchorProvider: () -> NSRect?) {
guard let panel = self.window else { return }
guard let anchor = anchorProvider() else { return }
var frame = panel.frame
frame.origin.x = round(anchor.midX - frame.width / 2)
frame.origin.y = anchor.minY - frame.height - 6
panel.setFrame(frame, display: false)
}
private func showError(_ text: String) {
let html = """
<html><body style='font-family:-apple-system;padding:24px;color:#c00'>Web chat failed to connect.<br><br>\(
@@ -202,6 +264,11 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
webChatLogger.error("webchat navigation failed: \(error.localizedDescription, privacy: .public)")
self.showError(error.localizedDescription)
}
func windowDidResignKey(_ notification: Notification) {
guard case .panel = self.presentation else { return }
self.closePanel()
}
}
extension WebChatWindowController {
@@ -244,10 +311,6 @@ final class WebChatManager {
static let shared = WebChatManager()
private var controller: WebChatWindowController?
func preferredSessionKey() -> String {
WorkActivityStore.shared.current?.sessionKey ?? "main"
}
func show(sessionKey: String) {
if self.controller == nil {
self.controller = WebChatWindowController(sessionKey: sessionKey)
@@ -257,6 +320,48 @@ final class WebChatManager {
NSApp.activate(ignoringOtherApps: true)
}
func togglePanel(sessionKey: String, anchorProvider: @escaping () -> NSRect?) {
if let controller, controller.window?.isVisible == true, controller.presentation.isPanel {
controller.shutdown()
controller.closePanel()
self.controller = nil
return
}
if let existing = self.controller {
existing.shutdown()
existing.close()
}
let controller = WebChatWindowController(sessionKey: sessionKey, presentation: .panel(anchorProvider: anchorProvider))
self.controller = controller
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
}
func closePanel() {
guard let controller, controller.presentation.isPanel else { return }
controller.shutdown()
controller.closePanel()
self.controller = nil
}
func preferredSessionKey() -> String {
// Prefer canonical main session; fall back to most recent.
let storePath = SessionLoader.defaultStorePath
if let data = try? Data(contentsOf: URL(fileURLWithPath: storePath)),
let decoded = try? JSONDecoder().decode([String: SessionEntryRecord].self, from: data)
{
if decoded.keys.contains("main") { return "main" }
let sorted = decoded.sorted { a, b -> Bool in
let lhs = a.value.updatedAt ?? 0
let rhs = b.value.updatedAt ?? 0
return lhs > rhs
}
if let first = sorted.first { return first.key }
}
return "+1003"
}
func close() {
self.controller?.shutdown()
self.controller?.close()