feat(macos): hover HUD for activity

This commit is contained in:
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:
// - 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)) // ~910pt 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()

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 {
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"
}
}

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)))
}
}