mac: left-click webchat panel
This commit is contained in:
@@ -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?
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user