196 lines
6.3 KiB
Swift
196 lines
6.3 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
final class WorkActivityStore: ObservableObject {
|
|
static let shared = WorkActivityStore()
|
|
|
|
struct Activity: Equatable {
|
|
let sessionKey: String
|
|
let role: SessionRole
|
|
let kind: ActivityKind
|
|
let label: String
|
|
let startedAt: Date
|
|
var lastUpdate: Date
|
|
}
|
|
|
|
@Published private(set) var current: Activity?
|
|
@Published private(set) var iconState: IconState = .idle
|
|
|
|
private var active: [String: Activity] = [:]
|
|
private var currentSessionKey: String?
|
|
|
|
private let mainSessionKey = "main"
|
|
private let toolResultGrace: TimeInterval = 2.0
|
|
|
|
func handleJob(sessionKey: String, state: String) {
|
|
let isStart = state.lowercased() == "started" || state.lowercased() == "streaming"
|
|
if isStart {
|
|
let activity = Activity(
|
|
sessionKey: sessionKey,
|
|
role: self.role(for: sessionKey),
|
|
kind: .job,
|
|
label: "job",
|
|
startedAt: Date(),
|
|
lastUpdate: Date())
|
|
self.setActive(activity)
|
|
} else {
|
|
self.markIdle(sessionKey: sessionKey)
|
|
}
|
|
}
|
|
|
|
func handleTool(
|
|
sessionKey: String,
|
|
phase: String,
|
|
name: String?,
|
|
meta: String?,
|
|
args: [String: AnyCodable]?)
|
|
{
|
|
let toolKind = Self.mapToolKind(name)
|
|
let label = Self.buildLabel(kind: toolKind, meta: meta, args: args)
|
|
if phase.lowercased() == "start" {
|
|
let activity = Activity(
|
|
sessionKey: sessionKey,
|
|
role: self.role(for: sessionKey),
|
|
kind: .tool(toolKind),
|
|
label: label,
|
|
startedAt: Date(),
|
|
lastUpdate: Date())
|
|
self.setActive(activity)
|
|
} else {
|
|
// Delay removal slightly to avoid flicker on rapid result/start bursts.
|
|
let key = sessionKey
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func resolveIconState(override selection: IconOverrideSelection) {
|
|
switch selection {
|
|
case .system:
|
|
self.iconState = self.deriveIconState()
|
|
case .idle:
|
|
self.iconState = .idle
|
|
default:
|
|
let base = selection.toIconState()
|
|
switch base {
|
|
case let .workingMain(kind),
|
|
let .workingOther(kind):
|
|
self.iconState = .overridden(kind)
|
|
case let .overridden(kind):
|
|
self.iconState = .overridden(kind)
|
|
case .idle:
|
|
self.iconState = .idle
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setActive(_ activity: Activity) {
|
|
self.active[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 {
|
|
self.currentSessionKey = activity.sessionKey
|
|
}
|
|
self.current = self.active[self.currentSessionKey ?? ""]
|
|
self.iconState = self.deriveIconState()
|
|
}
|
|
|
|
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)
|
|
|
|
if self.currentSessionKey == sessionKey {
|
|
self.pickNextSession()
|
|
}
|
|
self.current = self.active[self.currentSessionKey ?? ""]
|
|
self.iconState = self.deriveIconState()
|
|
}
|
|
|
|
private func pickNextSession() {
|
|
// Prefer main if present.
|
|
if let main = self.active[self.mainSessionKey] {
|
|
self.currentSessionKey = main.sessionKey
|
|
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
|
|
}
|
|
}
|
|
|
|
private func role(for sessionKey: String) -> SessionRole {
|
|
sessionKey == self.mainSessionKey ? .main : .other
|
|
}
|
|
|
|
private func deriveIconState() -> IconState {
|
|
guard let activity = self.current else { return .idle }
|
|
switch activity.role {
|
|
case .main: return .workingMain(activity.kind)
|
|
case .other: return .workingOther(activity.kind)
|
|
}
|
|
}
|
|
|
|
private static func mapToolKind(_ name: String?) -> ToolKind {
|
|
switch name?.lowercased() {
|
|
case "bash", "shell": .bash
|
|
case "read": .read
|
|
case "write": .write
|
|
case "edit": .edit
|
|
case "attach": .attach
|
|
default: .other
|
|
}
|
|
}
|
|
|
|
private static func buildLabel(
|
|
kind: ToolKind,
|
|
meta: String?,
|
|
args: [String: AnyCodable]?) -> String
|
|
{
|
|
switch kind {
|
|
case .bash:
|
|
if let cmd = args?["command"]?.value as? String {
|
|
return "bash: \(cmd.split(separator: "\n").first ?? "")"
|
|
}
|
|
return "bash"
|
|
case .read, .write, .edit, .attach:
|
|
if let path = extractPath(args: args, meta: meta) {
|
|
return "\(kind.rawValue): \(path)"
|
|
}
|
|
return kind.rawValue
|
|
case .other:
|
|
if let name = args?["name"]?.value as? String {
|
|
return name
|
|
}
|
|
return "tool"
|
|
}
|
|
}
|
|
|
|
private static func extractPath(args: [String: AnyCodable]?, meta: String?) -> String? {
|
|
if let p = args?["path"]?.value as? String { return self.shortenHome(path: p) }
|
|
if let p = args?["file_path"]?.value as? String { return self.shortenHome(path: p) }
|
|
if let meta { return self.shortenHome(path: meta) }
|
|
return nil
|
|
}
|
|
|
|
private static func shortenHome(path: String) -> String {
|
|
let home = NSHomeDirectory()
|
|
if path.hasPrefix(home) {
|
|
return "~" + path.dropFirst(home.count)
|
|
}
|
|
return path
|
|
}
|
|
}
|