mac: left-click webchat panel
This commit is contained in:
@@ -35,6 +35,7 @@ struct ClawdisApp: App {
|
|||||||
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
|
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
|
||||||
self.statusItem = item
|
self.statusItem = item
|
||||||
self.applyStatusItemAppearance(paused: self.state.isPaused)
|
self.applyStatusItemAppearance(paused: self.state.isPaused)
|
||||||
|
self.installStatusItemMouseHandler(for: item)
|
||||||
}
|
}
|
||||||
.onChange(of: self.state.isPaused) { _, paused in
|
.onChange(of: self.state.isPaused) { _, paused in
|
||||||
self.applyStatusItemAppearance(paused: paused)
|
self.applyStatusItemAppearance(paused: paused)
|
||||||
@@ -53,6 +54,39 @@ struct ClawdisApp: App {
|
|||||||
self.statusItem?.button?.appearsDisabled = paused
|
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 {
|
private var effectiveIconState: IconState {
|
||||||
let selection = self.state.iconOverride
|
let selection = self.state.iconOverride
|
||||||
if selection == .system {
|
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 {
|
final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate {
|
||||||
private var listener: NSXPCListener?
|
private var listener: NSXPCListener?
|
||||||
private var state: AppState?
|
private var state: AppState?
|
||||||
|
|||||||
@@ -6,8 +6,18 @@ import WebKit
|
|||||||
|
|
||||||
private let webChatLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat")
|
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
|
@MainActor
|
||||||
final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
final class WebChatWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
|
||||||
private let webView: WKWebView
|
private let webView: WKWebView
|
||||||
private let sessionKey: String
|
private let sessionKey: String
|
||||||
private var tunnel: WebChatTunnel?
|
private var tunnel: WebChatTunnel?
|
||||||
@@ -15,11 +25,13 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
|||||||
private let remotePort: Int
|
private let remotePort: Int
|
||||||
private var reachabilityTask: Task<Void, Never>?
|
private var reachabilityTask: Task<Void, Never>?
|
||||||
private var tunnelRestartEnabled = false
|
private var tunnelRestartEnabled = false
|
||||||
|
let presentation: WebChatPresentation
|
||||||
|
|
||||||
init(sessionKey: String) {
|
init(sessionKey: String, presentation: WebChatPresentation = .window) {
|
||||||
webChatLogger.debug("init WebChatWindowController sessionKey=\(sessionKey, privacy: .public)")
|
webChatLogger.debug("init WebChatWindowController sessionKey=\(sessionKey, privacy: .public)")
|
||||||
self.sessionKey = sessionKey
|
self.sessionKey = sessionKey
|
||||||
self.remotePort = AppStateStore.webChatPort
|
self.remotePort = AppStateStore.webChatPort
|
||||||
|
self.presentation = presentation
|
||||||
|
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
let contentController = WKUserContentController()
|
let contentController = WKUserContentController()
|
||||||
@@ -28,15 +40,10 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
|||||||
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
||||||
|
|
||||||
self.webView = WKWebView(frame: .zero, configuration: config)
|
self.webView = WKWebView(frame: .zero, configuration: config)
|
||||||
let window = NSWindow(
|
let window = Self.makeWindow(for: presentation, contentView: self.webView)
|
||||||
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
|
|
||||||
super.init(window: window)
|
super.init(window: window)
|
||||||
self.webView.navigationDelegate = self
|
self.webView.navigationDelegate = self
|
||||||
|
self.window?.delegate = self
|
||||||
|
|
||||||
self.loadPlaceholder()
|
self.loadPlaceholder()
|
||||||
Task { await self.bootstrap() }
|
Task { await self.bootstrap() }
|
||||||
@@ -50,6 +57,38 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
|||||||
self.stopTunnel(allowRestart: false)
|
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() {
|
private func loadPlaceholder() {
|
||||||
let html = """
|
let html = """
|
||||||
<html><body style='font-family:-apple-system;padding:24px;color:#888'>Connecting to web chat…</body></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
|
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) {
|
private func showError(_ text: String) {
|
||||||
let html = """
|
let html = """
|
||||||
<html><body style='font-family:-apple-system;padding:24px;color:#c00'>Web chat failed to connect.<br><br>\(
|
<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)")
|
webChatLogger.error("webchat navigation failed: \(error.localizedDescription, privacy: .public)")
|
||||||
self.showError(error.localizedDescription)
|
self.showError(error.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func windowDidResignKey(_ notification: Notification) {
|
||||||
|
guard case .panel = self.presentation else { return }
|
||||||
|
self.closePanel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension WebChatWindowController {
|
extension WebChatWindowController {
|
||||||
@@ -244,10 +311,6 @@ final class WebChatManager {
|
|||||||
static let shared = WebChatManager()
|
static let shared = WebChatManager()
|
||||||
private var controller: WebChatWindowController?
|
private var controller: WebChatWindowController?
|
||||||
|
|
||||||
func preferredSessionKey() -> String {
|
|
||||||
WorkActivityStore.shared.current?.sessionKey ?? "main"
|
|
||||||
}
|
|
||||||
|
|
||||||
func show(sessionKey: String) {
|
func show(sessionKey: String) {
|
||||||
if self.controller == nil {
|
if self.controller == nil {
|
||||||
self.controller = WebChatWindowController(sessionKey: sessionKey)
|
self.controller = WebChatWindowController(sessionKey: sessionKey)
|
||||||
@@ -257,6 +320,48 @@ final class WebChatManager {
|
|||||||
NSApp.activate(ignoringOtherApps: true)
|
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() {
|
func close() {
|
||||||
self.controller?.shutdown()
|
self.controller?.shutdown()
|
||||||
self.controller?.close()
|
self.controller?.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user