feat(macos): hover HUD for activity

main
Peter Steinberger 2025-12-19 00:03:58 +01:00
parent 0c06276b48
commit 47510e2912
8 changed files with 512 additions and 70 deletions

View File

@ -434,8 +434,8 @@ enum CritterIconRenderer {
// Bigger, higher-contrast badge: // Bigger, higher-contrast badge:
// - Increase diameter so tool activity is noticeable. // - Increase diameter so tool activity is noticeable.
// - Use a filled "puck" background with a fully-opaque SF Symbol on top. // - Draw a filled "puck", then knock out the symbol shape (transparent hole).
// (The menu bar image is rendered as a template, so "knocking out" the symbol makes it invisible.) // 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)) // ~910pt on an 18pt canvas let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~910pt on an 18pt canvas
let margin = canvas.snapX(max(0.45, canvas.w * 0.03)) let margin = canvas.snapX(max(0.45, canvas.w * 0.03))
let rect = CGRect( let rect = CGRect(
@ -466,19 +466,22 @@ enum CritterIconRenderer {
canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45)) canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45))
if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) { if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) {
let pointSize = max(6.0, diameter * 0.80) let pointSize = max(7.0, diameter * 0.82)
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .bold) let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .black)
let symbol = base.withSymbolConfiguration(config) ?? base let symbol = base.withSymbolConfiguration(config) ?? base
symbol.isTemplate = true 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( symbol.draw(
in: symbolRect, in: symbolRect,
from: .zero, from: .zero,
operation: .sourceOver, operation: .sourceOver,
fraction: min(1.0, 0.96 + 0.04 * strength), fraction: 1,
respectFlipped: true, respectFlipped: true,
hints: nil) hints: nil)
canvas.context.restoreGState()
} }
canvas.context.restoreGState() canvas.context.restoreGState()

View 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()
}
}
}

View File

@ -29,12 +29,12 @@ enum IconState: Equatable {
var badgeSymbolName: String { var badgeSymbolName: String {
switch self.activity { switch self.activity {
case .tool(.bash): "terminal.fill" case .tool(.bash): "chevron.left.slash.chevron.right"
case .tool(.read): "doc.text.magnifyingglass" case .tool(.read): "doc"
case .tool(.write): "pencil" case .tool(.write): "pencil"
case .tool(.edit): "square.and.pencil" case .tool(.edit): "pencil.tip"
case .tool(.attach): "paperclip" case .tool(.attach): "paperclip"
case .tool(.other), .job: "wrench.and.screwdriver.fill" case .tool(.other), .job: "gearshape.fill"
} }
} }

View File

@ -22,6 +22,11 @@ struct ClawdisApp: App {
self.statusItem?.button?.highlight(self.isPanelVisible) self.statusItem?.button?.highlight(self.isPanelVisible)
} }
@MainActor
private func updateHoverHUDSuppression() {
HoverHUDController.shared.setSuppressed(self.isMenuPresented || self.isPanelVisible)
}
init() { init() {
_state = State(initialValue: AppStateStore.shared) _state = State(initialValue: AppStateStore.shared)
} }
@ -44,6 +49,7 @@ struct ClawdisApp: App {
self.applyStatusItemAppearance(paused: self.state.isPaused) self.applyStatusItemAppearance(paused: self.state.isPaused)
self.installStatusItemMouseHandler(for: item) self.installStatusItemMouseHandler(for: item)
self.menuInjector.install(into: item) self.menuInjector.install(into: item)
self.updateHoverHUDSuppression()
} }
.onChange(of: self.state.isPaused) { _, paused in .onChange(of: self.state.isPaused) { _, paused in
self.applyStatusItemAppearance(paused: paused) self.applyStatusItemAppearance(paused: paused)
@ -65,6 +71,7 @@ struct ClawdisApp: App {
.windowResizability(.contentSize) .windowResizability(.contentSize)
.onChange(of: self.isMenuPresented) { _, _ in .onChange(of: self.isMenuPresented) { _, _ in
self.updateStatusHighlight() self.updateStatusHighlight()
self.updateHoverHUDSuppression()
} }
} }
@ -80,6 +87,7 @@ struct ClawdisApp: App {
WebChatManager.shared.onPanelVisibilityChanged = { [self] visible in WebChatManager.shared.onPanelVisibilityChanged = { [self] visible in
self.isPanelVisible = visible self.isPanelVisible = visible
self.updateStatusHighlight() self.updateStatusHighlight()
self.updateHoverHUDSuppression()
} }
CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in
self.state.canvasPanelVisible = visible self.state.canvasPanelVisible = visible
@ -88,12 +96,21 @@ struct ClawdisApp: App {
let handler = StatusItemMouseHandlerView() let handler = StatusItemMouseHandlerView()
handler.translatesAutoresizingMaskIntoConstraints = false handler.translatesAutoresizingMaskIntoConstraints = false
handler.onLeftClick = { [self] in self.toggleWebChatPanel() } handler.onLeftClick = { [self] in
HoverHUDController.shared.dismiss(reason: "statusItemClick")
self.toggleWebChatPanel()
}
handler.onRightClick = { [self] in handler.onRightClick = { [self] in
HoverHUDController.shared.dismiss(reason: "statusItemRightClick")
WebChatManager.shared.closePanel() WebChatManager.shared.closePanel()
self.isMenuPresented = true self.isMenuPresented = true
self.updateStatusHighlight() self.updateStatusHighlight()
} }
handler.onHoverChanged = { [self] inside in
HoverHUDController.shared.statusItemHoverChanged(
inside: inside,
anchorProvider: { [self] in self.statusButtonScreenFrame() })
}
button.addSubview(handler) button.addSubview(handler)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -106,6 +123,7 @@ struct ClawdisApp: App {
@MainActor @MainActor
private func toggleWebChatPanel() { private func toggleWebChatPanel() {
HoverHUDController.shared.setSuppressed(true)
self.isMenuPresented = false self.isMenuPresented = false
WebChatManager.shared.togglePanel( WebChatManager.shared.togglePanel(
sessionKey: WebChatManager.shared.preferredSessionKey(), sessionKey: WebChatManager.shared.preferredSessionKey(),
@ -138,6 +156,8 @@ struct ClawdisApp: App {
private final class StatusItemMouseHandlerView: NSView { private final class StatusItemMouseHandlerView: NSView {
var onLeftClick: (() -> Void)? var onLeftClick: (() -> Void)?
var onRightClick: (() -> Void)? var onRightClick: (() -> Void)?
var onHoverChanged: ((Bool) -> Void)?
private var tracking: NSTrackingArea?
override func mouseDown(with event: NSEvent) { override func mouseDown(with event: NSEvent) {
if let onLeftClick { if let onLeftClick {
@ -151,6 +171,29 @@ private final class StatusItemMouseHandlerView: NSView {
self.onRightClick?() self.onRightClick?()
// Do not call super; menu will be driven by isMenuPresented binding. // 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 { final class AppDelegate: NSObject, NSApplicationDelegate {

View File

@ -3,6 +3,7 @@ import ClawdisChatUI
import ClawdisProtocol import ClawdisProtocol
import Foundation import Foundation
import OSLog import OSLog
import QuartzCore
import SwiftUI import SwiftUI
private let webChatSwiftLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChatSwiftUI") private let webChatSwiftLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChatSwiftUI")
@ -175,10 +176,26 @@ final class WebChatSwiftUIWindowController {
func presentAnchored(anchorProvider: () -> NSRect?) { func presentAnchored(anchorProvider: () -> NSRect?) {
guard case .panel = self.presentation, let window else { return } guard case .panel = self.presentation, let window else { return }
self.reposition(using: anchorProvider)
self.installDismissMonitor() 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) window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true) 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) self.onVisibilityChanged?(true)
} }
@ -189,38 +206,29 @@ final class WebChatSwiftUIWindowController {
self.removeDismissMonitor() self.removeDismissMonitor()
} }
private func reposition(using anchorProvider: () -> NSRect?) { @discardableResult
guard let window else { return } private func reposition(using anchorProvider: () -> NSRect?) -> NSRect {
guard let window else { return .zero }
guard let anchor = anchorProvider() else { guard let anchor = anchorProvider() else {
window.setFrame( let frame = WindowPlacement.topRightFrame(
WindowPlacement.topRightFrame(
size: WebChatSwiftUILayout.panelSize, size: WebChatSwiftUILayout.panelSize,
padding: WebChatSwiftUILayout.anchorPadding), padding: WebChatSwiftUILayout.anchorPadding)
display: false) window.setFrame(frame, display: false)
return return frame
} }
let screen = NSScreen.screens.first { screen in let screen = NSScreen.screens.first { screen in
screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
} ?? NSScreen.main } ?? NSScreen.main
var frame = window.frame let bounds = (screen?.visibleFrame ?? .zero).insetBy(
if let screen {
let bounds = screen.visibleFrame.insetBy(
dx: WebChatSwiftUILayout.anchorPadding, dx: WebChatSwiftUILayout.anchorPadding,
dy: WebChatSwiftUILayout.anchorPadding) dy: WebChatSwiftUILayout.anchorPadding)
let frame = WindowPlacement.anchoredBelowFrame(
let desiredX = round(anchor.midX - frame.width / 2) size: WebChatSwiftUILayout.panelSize,
let desiredY = anchor.minY - frame.height - WebChatSwiftUILayout.anchorPadding anchor: anchor,
padding: WebChatSwiftUILayout.anchorPadding,
let maxX = bounds.maxX - frame.width in: bounds)
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
}
window.setFrame(frame, display: false) window.setFrame(frame, display: false)
return frame
} }
private func installDismissMonitor() { private func installDismissMonitor() {

View File

@ -42,6 +42,28 @@ enum WindowPlacement {
return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) 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( static func ensureOnScreen(
window: NSWindow, window: NSWindow,
defaultSize: NSSize, defaultSize: NSSize,

View File

@ -18,9 +18,13 @@ final class WorkActivityStore {
private(set) var current: Activity? private(set) var current: Activity?
private(set) var iconState: IconState = .idle 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 currentSessionKey: String?
private var toolSeqBySession: [String: Int] = [:]
private let mainSessionKey = "main" private let mainSessionKey = "main"
private let toolResultGrace: TimeInterval = 2.0 private let toolResultGrace: TimeInterval = 2.0
@ -35,9 +39,11 @@ final class WorkActivityStore {
label: "job", label: "job",
startedAt: Date(), startedAt: Date(),
lastUpdate: Date()) lastUpdate: Date())
self.setActive(activity) self.setJobActive(activity)
} else { } 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 toolKind = Self.mapToolKind(name)
let label = Self.buildLabel(kind: toolKind, meta: meta, args: args) let label = Self.buildLabel(kind: toolKind, meta: meta, args: args)
if phase.lowercased() == "start" { if phase.lowercased() == "start" {
self.lastToolLabel = label
self.lastToolUpdatedAt = Date()
self.toolSeqBySession[sessionKey, default: 0] += 1
let activity = Activity( let activity = Activity(
sessionKey: sessionKey, sessionKey: sessionKey,
role: self.role(for: sessionKey), role: self.role(for: sessionKey),
@ -58,15 +67,19 @@ final class WorkActivityStore {
label: label, label: label,
startedAt: Date(), startedAt: Date(),
lastUpdate: Date()) lastUpdate: Date())
self.setActive(activity) self.setToolActive(activity)
} else { } else {
// Delay removal slightly to avoid flicker on rapid result/start bursts. // Delay removal slightly to avoid flicker on rapid result/start bursts.
let key = sessionKey let key = sessionKey
let seq = self.toolSeqBySession[key, default: 0]
Task { [weak self] in Task { [weak self] in
let nsDelay = UInt64((self?.toolResultGrace ?? 0) * 1_000_000_000) let nsDelay = UInt64((self?.toolResultGrace ?? 0) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nsDelay) try? await Task.sleep(nanoseconds: nsDelay)
await MainActor.run { 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) { private func setJobActive(_ activity: Activity) {
self.active[activity.sessionKey] = activity self.jobs[activity.sessionKey] = activity
// Main session preempts immediately. // Main session preempts immediately.
if activity.role == .main { if activity.role == .main {
self.currentSessionKey = activity.sessionKey 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.currentSessionKey = activity.sessionKey
} }
self.current = self.active[self.currentSessionKey ?? ""] self.refreshDerivedState()
self.iconState = self.deriveIconState()
} }
private func markIdle(sessionKey: String) { private func setToolActive(_ activity: Activity) {
guard let existing = self.active[sessionKey] else { return } self.tools[activity.sessionKey] = activity
// Update timestamp so replacement prefers newer others. // Main session preempts immediately.
var updated = existing if activity.role == .main {
updated.lastUpdate = Date() self.currentSessionKey = activity.sessionKey
self.active[sessionKey] = updated } else if self.currentSessionKey == nil || !self.isActive(sessionKey: self.currentSessionKey!) {
self.active.removeValue(forKey: sessionKey) 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.pickNextSession()
} }
self.current = self.active[self.currentSessionKey ?? ""] self.refreshDerivedState()
self.iconState = self.deriveIconState() }
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() { private func pickNextSession() {
// Prefer main if present. // Prefer main if present.
if let main = self.active[self.mainSessionKey] { if self.isActive(sessionKey: self.mainSessionKey) {
self.currentSessionKey = main.sessionKey self.currentSessionKey = self.mainSessionKey
return return
} }
// Otherwise, pick most recent by lastUpdate.
if let next = self.active.values.max(by: { $0.lastUpdate < $1.lastUpdate }) { // Otherwise, pick most recent by lastUpdate across job/tool.
self.currentSessionKey = next.sessionKey let keys = Set(self.jobs.keys).union(self.tools.keys)
} else { let next = keys.max(by: { self.lastUpdate(for: $0) < self.lastUpdate(for: $1) })
self.currentSessionKey = nil self.currentSessionKey = next
}
} }
private func role(for sessionKey: String) -> SessionRole { private func role(for sessionKey: String) -> SessionRole {
sessionKey == self.mainSessionKey ? .main : .other 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 { 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 { switch activity.role {
case .main: return .workingMain(activity.kind) case .main: return .workingMain(activity.kind)
case .other: return .workingOther(activity.kind) case .other: return .workingOther(activity.kind)

View File

@ -25,6 +25,37 @@ struct WorkActivityStoreTests {
#expect(store.current == nil) #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() { @Test func toolLabelExtractsFirstLineAndShortensHome() {
let store = WorkActivityStore() let store = WorkActivityStore()
let home = NSHomeDirectory() let home = NSHomeDirectory()
@ -65,4 +96,3 @@ struct WorkActivityStoreTests {
#expect(store.iconState == .overridden(.tool(.edit))) #expect(store.iconState == .overridden(.tool(.edit)))
} }
} }