diff --git a/apps/macos/Sources/Clawdis/CritterStatusLabel.swift b/apps/macos/Sources/Clawdis/CritterStatusLabel.swift index 083f11fa2..25ad65ee0 100644 --- a/apps/macos/Sources/Clawdis/CritterStatusLabel.swift +++ b/apps/macos/Sources/Clawdis/CritterStatusLabel.swift @@ -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() diff --git a/apps/macos/Sources/Clawdis/HoverHUD.swift b/apps/macos/Sources/Clawdis/HoverHUD.swift new file mode 100644 index 000000000..c895fffb9 --- /dev/null +++ b/apps/macos/Sources/Clawdis/HoverHUD.swift @@ -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? + private var dismissMonitor: Any? + private var dismissTask: Task? + 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() + } + } +} + diff --git a/apps/macos/Sources/Clawdis/IconState.swift b/apps/macos/Sources/Clawdis/IconState.swift index f47ab76c7..ec2738583 100644 --- a/apps/macos/Sources/Clawdis/IconState.swift +++ b/apps/macos/Sources/Clawdis/IconState.swift @@ -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" } } diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 72c3ee2ab..ab90c8c5b 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -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 { diff --git a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift index 6bd5e5eb8..8aba8a52d 100644 --- a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift +++ b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift @@ -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() { diff --git a/apps/macos/Sources/Clawdis/WindowPlacement.swift b/apps/macos/Sources/Clawdis/WindowPlacement.swift index ddbc8e7c7..a088dd743 100644 --- a/apps/macos/Sources/Clawdis/WindowPlacement.swift +++ b/apps/macos/Sources/Clawdis/WindowPlacement.swift @@ -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, diff --git a/apps/macos/Sources/Clawdis/WorkActivityStore.swift b/apps/macos/Sources/Clawdis/WorkActivityStore.swift index 84913f16f..e0a1bdc07 100644 --- a/apps/macos/Sources/Clawdis/WorkActivityStore.swift +++ b/apps/macos/Sources/Clawdis/WorkActivityStore.swift @@ -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) diff --git a/apps/macos/Tests/ClawdisIPCTests/WorkActivityStoreTests.swift b/apps/macos/Tests/ClawdisIPCTests/WorkActivityStoreTests.swift index e899a3c12..a0d3a60d5 100644 --- a/apps/macos/Tests/ClawdisIPCTests/WorkActivityStoreTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/WorkActivityStoreTests.swift @@ -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))) } } -