feat(macos): hover HUD for activity
This commit is contained in:
@@ -434,8 +434,8 @@ enum CritterIconRenderer {
|
||||
|
||||
// Bigger, higher-contrast badge:
|
||||
// - Increase diameter so tool activity is noticeable.
|
||||
// - Use a filled "puck" background with a fully-opaque SF Symbol on top.
|
||||
// (The menu bar image is rendered as a template, so "knocking out" the symbol makes it invisible.)
|
||||
// - Draw a filled "puck", then knock out the symbol shape (transparent hole).
|
||||
// This reads better in template-rendered menu bar icons than tiny monochrome glyphs.
|
||||
let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~9–10pt on an 18pt canvas
|
||||
let margin = canvas.snapX(max(0.45, canvas.w * 0.03))
|
||||
let rect = CGRect(
|
||||
@@ -466,19 +466,22 @@ enum CritterIconRenderer {
|
||||
canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45))
|
||||
|
||||
if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) {
|
||||
let pointSize = max(6.0, diameter * 0.80)
|
||||
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .bold)
|
||||
let pointSize = max(7.0, diameter * 0.82)
|
||||
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .black)
|
||||
let symbol = base.withSymbolConfiguration(config) ?? base
|
||||
symbol.isTemplate = true
|
||||
|
||||
let symbolRect = rect.insetBy(dx: diameter * 0.19, dy: diameter * 0.19)
|
||||
let symbolRect = rect.insetBy(dx: diameter * 0.17, dy: diameter * 0.17)
|
||||
canvas.context.saveGState()
|
||||
canvas.context.setBlendMode(.clear)
|
||||
symbol.draw(
|
||||
in: symbolRect,
|
||||
from: .zero,
|
||||
operation: .sourceOver,
|
||||
fraction: min(1.0, 0.96 + 0.04 * strength),
|
||||
fraction: 1,
|
||||
respectFlipped: true,
|
||||
hints: nil)
|
||||
canvas.context.restoreGState()
|
||||
}
|
||||
|
||||
canvas.context.restoreGState()
|
||||
|
||||
285
apps/macos/Sources/Clawdis/HoverHUD.swift
Normal file
285
apps/macos/Sources/Clawdis/HoverHUD.swift
Normal file
@@ -0,0 +1,285 @@
|
||||
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 anchorProvider: (() -> NSRect?)?
|
||||
|
||||
private let width: CGFloat = 360
|
||||
private let height: CGFloat = 74
|
||||
private let padding: CGFloat = 8
|
||||
|
||||
func setSuppressed(_ suppressed: Bool) {
|
||||
self.model.isSuppressed = suppressed
|
||||
if suppressed {
|
||||
self.dismiss(reason: "suppressed")
|
||||
}
|
||||
}
|
||||
|
||||
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.present()
|
||||
} else {
|
||||
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(reason: "openChat")
|
||||
WebChatManager.shared.togglePanel(
|
||||
sessionKey: WebChatManager.shared.preferredSessionKey(),
|
||||
anchorProvider: anchorProvider)
|
||||
}
|
||||
|
||||
func dismiss(reason: String = "explicit") {
|
||||
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
|
||||
}
|
||||
|
||||
let target = window.frame.offsetBy(dx: 0, dy: 6)
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.14
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(target, display: true)
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
Task { @MainActor in
|
||||
window.orderOut(nil)
|
||||
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(reason: "hoverExit")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
window.setFrame(start, display: true)
|
||||
window.alphaValue = 0
|
||||
window.orderFrontRegardless()
|
||||
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.orderFrontRegardless()
|
||||
self.updateWindowFrame(animate: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureWindow() {
|
||||
if self.window != nil { return }
|
||||
let panel = NSPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height),
|
||||
styleMask: [.nonactivatingPanel, .borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = true
|
||||
panel.level = .statusBar
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.isMovable = false
|
||||
panel.isFloatingPanel = true
|
||||
panel.becomesKeyOnlyIfNeeded = true
|
||||
panel.titleVisibility = .hidden
|
||||
panel.titlebarAppearsTransparent = 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) {
|
||||
guard let window else { return }
|
||||
let frame = self.targetFrame()
|
||||
if animate {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.12
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(frame, display: true)
|
||||
}
|
||||
} else {
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func installDismissMonitor() {
|
||||
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(reason: "outsideClick") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeDismissMonitor() {
|
||||
if let monitor = self.dismissMonitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
self.dismissMonitor = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 .green }
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,12 @@ enum IconState: Equatable {
|
||||
|
||||
var badgeSymbolName: String {
|
||||
switch self.activity {
|
||||
case .tool(.bash): "terminal.fill"
|
||||
case .tool(.read): "doc.text.magnifyingglass"
|
||||
case .tool(.bash): "chevron.left.slash.chevron.right"
|
||||
case .tool(.read): "doc"
|
||||
case .tool(.write): "pencil"
|
||||
case .tool(.edit): "square.and.pencil"
|
||||
case .tool(.edit): "pencil.tip"
|
||||
case .tool(.attach): "paperclip"
|
||||
case .tool(.other), .job: "wrench.and.screwdriver.fill"
|
||||
case .tool(.other), .job: "gearshape.fill"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ struct ClawdisApp: App {
|
||||
self.statusItem?.button?.highlight(self.isPanelVisible)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateHoverHUDSuppression() {
|
||||
HoverHUDController.shared.setSuppressed(self.isMenuPresented || self.isPanelVisible)
|
||||
}
|
||||
|
||||
init() {
|
||||
_state = State(initialValue: AppStateStore.shared)
|
||||
}
|
||||
@@ -44,6 +49,7 @@ struct ClawdisApp: App {
|
||||
self.applyStatusItemAppearance(paused: self.state.isPaused)
|
||||
self.installStatusItemMouseHandler(for: item)
|
||||
self.menuInjector.install(into: item)
|
||||
self.updateHoverHUDSuppression()
|
||||
}
|
||||
.onChange(of: self.state.isPaused) { _, paused in
|
||||
self.applyStatusItemAppearance(paused: paused)
|
||||
@@ -65,6 +71,7 @@ struct ClawdisApp: App {
|
||||
.windowResizability(.contentSize)
|
||||
.onChange(of: self.isMenuPresented) { _, _ in
|
||||
self.updateStatusHighlight()
|
||||
self.updateHoverHUDSuppression()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +87,7 @@ struct ClawdisApp: App {
|
||||
WebChatManager.shared.onPanelVisibilityChanged = { [self] visible in
|
||||
self.isPanelVisible = visible
|
||||
self.updateStatusHighlight()
|
||||
self.updateHoverHUDSuppression()
|
||||
}
|
||||
CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in
|
||||
self.state.canvasPanelVisible = visible
|
||||
@@ -88,12 +96,21 @@ struct ClawdisApp: App {
|
||||
|
||||
let handler = StatusItemMouseHandlerView()
|
||||
handler.translatesAutoresizingMaskIntoConstraints = false
|
||||
handler.onLeftClick = { [self] in self.toggleWebChatPanel() }
|
||||
handler.onLeftClick = { [self] in
|
||||
HoverHUDController.shared.dismiss(reason: "statusItemClick")
|
||||
self.toggleWebChatPanel()
|
||||
}
|
||||
handler.onRightClick = { [self] in
|
||||
HoverHUDController.shared.dismiss(reason: "statusItemRightClick")
|
||||
WebChatManager.shared.closePanel()
|
||||
self.isMenuPresented = true
|
||||
self.updateStatusHighlight()
|
||||
}
|
||||
handler.onHoverChanged = { [self] inside in
|
||||
HoverHUDController.shared.statusItemHoverChanged(
|
||||
inside: inside,
|
||||
anchorProvider: { [self] in self.statusButtonScreenFrame() })
|
||||
}
|
||||
|
||||
button.addSubview(handler)
|
||||
NSLayoutConstraint.activate([
|
||||
@@ -106,6 +123,7 @@ struct ClawdisApp: App {
|
||||
|
||||
@MainActor
|
||||
private func toggleWebChatPanel() {
|
||||
HoverHUDController.shared.setSuppressed(true)
|
||||
self.isMenuPresented = false
|
||||
WebChatManager.shared.togglePanel(
|
||||
sessionKey: WebChatManager.shared.preferredSessionKey(),
|
||||
@@ -138,6 +156,8 @@ struct ClawdisApp: App {
|
||||
private final class StatusItemMouseHandlerView: NSView {
|
||||
var onLeftClick: (() -> Void)?
|
||||
var onRightClick: (() -> Void)?
|
||||
var onHoverChanged: ((Bool) -> Void)?
|
||||
private var tracking: NSTrackingArea?
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
if let onLeftClick {
|
||||
@@ -151,6 +171,29 @@ private final class StatusItemMouseHandlerView: NSView {
|
||||
self.onRightClick?()
|
||||
// Do not call super; menu will be driven by isMenuPresented binding.
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
if let tracking {
|
||||
self.removeTrackingArea(tracking)
|
||||
}
|
||||
let options: NSTrackingArea.Options = [
|
||||
.mouseEnteredAndExited,
|
||||
.activeAlways,
|
||||
.inVisibleRect,
|
||||
]
|
||||
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
|
||||
self.addTrackingArea(area)
|
||||
self.tracking = area
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
self.onHoverChanged?(true)
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
self.onHoverChanged?(false)
|
||||
}
|
||||
}
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
@@ -3,6 +3,7 @@ import ClawdisChatUI
|
||||
import ClawdisProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
import QuartzCore
|
||||
import SwiftUI
|
||||
|
||||
private let webChatSwiftLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChatSwiftUI")
|
||||
@@ -175,10 +176,26 @@ final class WebChatSwiftUIWindowController {
|
||||
|
||||
func presentAnchored(anchorProvider: () -> NSRect?) {
|
||||
guard case .panel = self.presentation, let window else { return }
|
||||
self.reposition(using: anchorProvider)
|
||||
self.installDismissMonitor()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -189,38 +206,29 @@ final class WebChatSwiftUIWindowController {
|
||||
self.removeDismissMonitor()
|
||||
}
|
||||
|
||||
private func reposition(using anchorProvider: () -> NSRect?) {
|
||||
guard let window else { return }
|
||||
@discardableResult
|
||||
private func reposition(using anchorProvider: () -> NSRect?) -> NSRect {
|
||||
guard let window else { return .zero }
|
||||
guard let anchor = anchorProvider() else {
|
||||
window.setFrame(
|
||||
WindowPlacement.topRightFrame(
|
||||
size: WebChatSwiftUILayout.panelSize,
|
||||
padding: WebChatSwiftUILayout.anchorPadding),
|
||||
display: false)
|
||||
return
|
||||
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
|
||||
var frame = window.frame
|
||||
if let screen {
|
||||
let bounds = screen.visibleFrame.insetBy(
|
||||
dx: WebChatSwiftUILayout.anchorPadding,
|
||||
dy: WebChatSwiftUILayout.anchorPadding)
|
||||
|
||||
let desiredX = round(anchor.midX - frame.width / 2)
|
||||
let desiredY = anchor.minY - frame.height - WebChatSwiftUILayout.anchorPadding
|
||||
|
||||
let maxX = bounds.maxX - frame.width
|
||||
let maxY = bounds.maxY - frame.height
|
||||
|
||||
frame.origin.x = maxX >= bounds.minX ? min(max(desiredX, bounds.minX), maxX) : bounds.minX
|
||||
frame.origin.y = maxY >= bounds.minY ? min(max(desiredY, bounds.minY), maxY) : bounds.minY
|
||||
} else {
|
||||
frame.origin.x = round(anchor.midX - frame.width / 2)
|
||||
frame.origin.y = anchor.minY - frame.height
|
||||
}
|
||||
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() {
|
||||
|
||||
@@ -42,6 +42,28 @@ 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 ensureOnScreen(
|
||||
window: NSWindow,
|
||||
defaultSize: NSSize,
|
||||
|
||||
@@ -18,9 +18,13 @@ 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 active: [String: Activity] = [:]
|
||||
private var jobs: [String: Activity] = [:]
|
||||
private var tools: [String: Activity] = [:]
|
||||
private var currentSessionKey: String?
|
||||
private var toolSeqBySession: [String: Int] = [:]
|
||||
|
||||
private let mainSessionKey = "main"
|
||||
private let toolResultGrace: TimeInterval = 2.0
|
||||
@@ -35,9 +39,11 @@ final class WorkActivityStore {
|
||||
label: "job",
|
||||
startedAt: Date(),
|
||||
lastUpdate: Date())
|
||||
self.setActive(activity)
|
||||
self.setJobActive(activity)
|
||||
} else {
|
||||
self.markIdle(sessionKey: sessionKey)
|
||||
// Job ended (done/error/aborted/etc). Clear everything for this session.
|
||||
self.clearTool(sessionKey: sessionKey)
|
||||
self.clearJob(sessionKey: sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +57,9 @@ final class WorkActivityStore {
|
||||
let toolKind = Self.mapToolKind(name)
|
||||
let label = Self.buildLabel(kind: toolKind, meta: meta, args: args)
|
||||
if phase.lowercased() == "start" {
|
||||
self.lastToolLabel = label
|
||||
self.lastToolUpdatedAt = Date()
|
||||
self.toolSeqBySession[sessionKey, default: 0] += 1
|
||||
let activity = Activity(
|
||||
sessionKey: sessionKey,
|
||||
role: self.role(for: sessionKey),
|
||||
@@ -58,15 +67,19 @@ final class WorkActivityStore {
|
||||
label: label,
|
||||
startedAt: Date(),
|
||||
lastUpdate: Date())
|
||||
self.setActive(activity)
|
||||
self.setToolActive(activity)
|
||||
} else {
|
||||
// Delay removal slightly to avoid flicker on rapid result/start bursts.
|
||||
let key = sessionKey
|
||||
let seq = self.toolSeqBySession[key, default: 0]
|
||||
Task { [weak self] in
|
||||
let nsDelay = UInt64((self?.toolResultGrace ?? 0) * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nsDelay)
|
||||
await MainActor.run {
|
||||
self?.markIdle(sessionKey: key)
|
||||
guard let self else { return }
|
||||
guard self.toolSeqBySession[key, default: 0] == seq else { return }
|
||||
self.lastToolUpdatedAt = Date()
|
||||
self.clearTool(sessionKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,53 +105,91 @@ final class WorkActivityStore {
|
||||
}
|
||||
}
|
||||
|
||||
private func setActive(_ activity: Activity) {
|
||||
self.active[activity.sessionKey] = activity
|
||||
private func setJobActive(_ activity: Activity) {
|
||||
self.jobs[activity.sessionKey] = activity
|
||||
// Main session preempts immediately.
|
||||
if activity.role == .main {
|
||||
self.currentSessionKey = activity.sessionKey
|
||||
} else if self.currentSessionKey == nil || self.active[self.currentSessionKey!] == nil {
|
||||
} else if self.currentSessionKey == nil || !self.isActive(sessionKey: self.currentSessionKey!) {
|
||||
self.currentSessionKey = activity.sessionKey
|
||||
}
|
||||
self.current = self.active[self.currentSessionKey ?? ""]
|
||||
self.iconState = self.deriveIconState()
|
||||
self.refreshDerivedState()
|
||||
}
|
||||
|
||||
private func markIdle(sessionKey: String) {
|
||||
guard let existing = self.active[sessionKey] else { return }
|
||||
// Update timestamp so replacement prefers newer others.
|
||||
var updated = existing
|
||||
updated.lastUpdate = Date()
|
||||
self.active[sessionKey] = updated
|
||||
self.active.removeValue(forKey: sessionKey)
|
||||
private func setToolActive(_ activity: Activity) {
|
||||
self.tools[activity.sessionKey] = activity
|
||||
// Main session preempts immediately.
|
||||
if activity.role == .main {
|
||||
self.currentSessionKey = activity.sessionKey
|
||||
} else if self.currentSessionKey == nil || !self.isActive(sessionKey: self.currentSessionKey!) {
|
||||
self.currentSessionKey = activity.sessionKey
|
||||
}
|
||||
self.refreshDerivedState()
|
||||
}
|
||||
|
||||
if self.currentSessionKey == sessionKey {
|
||||
private func clearJob(sessionKey: String) {
|
||||
guard self.jobs[sessionKey] != nil else { return }
|
||||
self.jobs.removeValue(forKey: sessionKey)
|
||||
|
||||
if self.currentSessionKey == sessionKey, !self.isActive(sessionKey: sessionKey) {
|
||||
self.pickNextSession()
|
||||
}
|
||||
self.current = self.active[self.currentSessionKey ?? ""]
|
||||
self.iconState = self.deriveIconState()
|
||||
self.refreshDerivedState()
|
||||
}
|
||||
|
||||
private func clearTool(sessionKey: String) {
|
||||
guard self.tools[sessionKey] != nil else { return }
|
||||
self.tools.removeValue(forKey: sessionKey)
|
||||
|
||||
if self.currentSessionKey == sessionKey, !self.isActive(sessionKey: sessionKey) {
|
||||
self.pickNextSession()
|
||||
}
|
||||
self.refreshDerivedState()
|
||||
}
|
||||
|
||||
private func pickNextSession() {
|
||||
// Prefer main if present.
|
||||
if let main = self.active[self.mainSessionKey] {
|
||||
self.currentSessionKey = main.sessionKey
|
||||
if self.isActive(sessionKey: self.mainSessionKey) {
|
||||
self.currentSessionKey = self.mainSessionKey
|
||||
return
|
||||
}
|
||||
// Otherwise, pick most recent by lastUpdate.
|
||||
if let next = self.active.values.max(by: { $0.lastUpdate < $1.lastUpdate }) {
|
||||
self.currentSessionKey = next.sessionKey
|
||||
} else {
|
||||
self.currentSessionKey = nil
|
||||
}
|
||||
|
||||
// Otherwise, pick most recent by lastUpdate across job/tool.
|
||||
let keys = Set(self.jobs.keys).union(self.tools.keys)
|
||||
let next = keys.max(by: { self.lastUpdate(for: $0) < self.lastUpdate(for: $1) })
|
||||
self.currentSessionKey = next
|
||||
}
|
||||
|
||||
private func role(for sessionKey: String) -> SessionRole {
|
||||
sessionKey == self.mainSessionKey ? .main : .other
|
||||
}
|
||||
|
||||
private func isActive(sessionKey: String) -> Bool {
|
||||
self.jobs[sessionKey] != nil || self.tools[sessionKey] != nil
|
||||
}
|
||||
|
||||
private func lastUpdate(for sessionKey: String) -> Date {
|
||||
max(self.jobs[sessionKey]?.lastUpdate ?? .distantPast, self.tools[sessionKey]?.lastUpdate ?? .distantPast)
|
||||
}
|
||||
|
||||
private func currentActivity(for sessionKey: String) -> Activity? {
|
||||
// Prefer tool overlay if present, otherwise job.
|
||||
self.tools[sessionKey] ?? self.jobs[sessionKey]
|
||||
}
|
||||
|
||||
private func refreshDerivedState() {
|
||||
if let key = self.currentSessionKey, !self.isActive(sessionKey: key) {
|
||||
self.currentSessionKey = nil
|
||||
}
|
||||
self.current = self.currentSessionKey.flatMap { self.currentActivity(for: $0) }
|
||||
self.iconState = self.deriveIconState()
|
||||
}
|
||||
|
||||
private func deriveIconState() -> IconState {
|
||||
guard let activity = self.current else { return .idle }
|
||||
guard let sessionKey = self.currentSessionKey,
|
||||
let activity = self.currentActivity(for: sessionKey)
|
||||
else { return .idle }
|
||||
|
||||
switch activity.role {
|
||||
case .main: return .workingMain(activity.kind)
|
||||
case .other: return .workingOther(activity.kind)
|
||||
|
||||
@@ -25,6 +25,37 @@ struct WorkActivityStoreTests {
|
||||
#expect(store.current == nil)
|
||||
}
|
||||
|
||||
@Test func jobStaysWorkingAfterToolResultGrace() async {
|
||||
let store = WorkActivityStore()
|
||||
|
||||
store.handleJob(sessionKey: "main", state: "started")
|
||||
#expect(store.iconState == .workingMain(.job))
|
||||
|
||||
store.handleTool(
|
||||
sessionKey: "main",
|
||||
phase: "start",
|
||||
name: "read",
|
||||
meta: nil,
|
||||
args: ["path": AnyCodable("/tmp/file.txt")])
|
||||
#expect(store.iconState == .workingMain(.tool(.read)))
|
||||
|
||||
store.handleTool(
|
||||
sessionKey: "main",
|
||||
phase: "result",
|
||||
name: "read",
|
||||
meta: nil,
|
||||
args: ["path": AnyCodable("/tmp/file.txt")])
|
||||
|
||||
for _ in 0..<50 {
|
||||
if store.iconState == .workingMain(.job) { break }
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
#expect(store.iconState == .workingMain(.job))
|
||||
|
||||
store.handleJob(sessionKey: "main", state: "done")
|
||||
#expect(store.iconState == .idle)
|
||||
}
|
||||
|
||||
@Test func toolLabelExtractsFirstLineAndShortensHome() {
|
||||
let store = WorkActivityStore()
|
||||
let home = NSHomeDirectory()
|
||||
@@ -65,4 +96,3 @@ struct WorkActivityStoreTests {
|
||||
#expect(store.iconState == .overridden(.tool(.edit)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user