import Foundation import os private let logger = Logger(subsystem: "dev.shane.voice", category: "tasks") struct TaskStep: Identifiable, Equatable { let id = UUID() let role: String? let content: String? } struct TaskItem: Identifiable, Equatable { enum Status: Equatable { case pending case running case completed case failed } let id: String var description: String var status: Status var steps: [TaskStep] var result: String? var error: String? var createdAt: Date var completedAt: Date? } /// Folds task_event messages into task items, mirroring the web UI's tasks Map. @MainActor final class TaskStore: ObservableObject { @Published private(set) var tasks: [String: TaskItem] = [:] var sortedTasks: [TaskItem] { tasks.values.sorted { $0.createdAt > $1.createdAt } } var activeCount: Int { tasks.values.filter { $0.status == .pending || $0.status == .running }.count } func apply(_ payload: ControlChannel.TaskEventPayload) { guard let taskId = payload.taskId, !taskId.isEmpty else { return } switch payload.type { case "created": tasks[taskId] = TaskItem( id: taskId, description: payload.description ?? "Background task", status: .pending, steps: [], result: nil, error: nil, createdAt: date(from: payload.createdAt), completedAt: nil ) case "step": var task = tasks[taskId] ?? TaskItem( id: taskId, description: payload.description ?? "Background task", status: .pending, steps: [], result: nil, error: nil, createdAt: date(from: payload.createdAt), completedAt: nil ) task.steps.append(TaskStep(role: payload.role, content: payload.content)) if task.status == .pending { task.status = .running } tasks[taskId] = task case "completed": var task = tasks[taskId] ?? TaskItem( id: taskId, description: payload.description ?? "Background task", status: .pending, steps: [], result: nil, error: nil, createdAt: date(from: payload.createdAt), completedAt: nil ) task.status = .completed task.result = payload.result task.completedAt = Date() tasks[taskId] = task case "failed": var task = tasks[taskId] ?? TaskItem( id: taskId, description: payload.description ?? "Background task", status: .pending, steps: [], result: nil, error: nil, createdAt: date(from: payload.createdAt), completedAt: nil ) task.status = .failed task.error = payload.error task.completedAt = Date() tasks[taskId] = task default: return } logger.log("task \(payload.type, privacy: .public) id=\(taskId, privacy: .public) steps=\(self.tasks[taskId]?.steps.count ?? 0, privacy: .public)") } func clear() { tasks.removeAll() } #if DEBUG func injectSampleEvents() { let now = Date().timeIntervalSince1970 apply(ControlChannel.TaskEventPayload(type: "created", taskId: "sample-a", description: "Research the best coffee roasters in Portland", role: nil, content: nil, result: nil, error: nil, createdAt: now - 45)) apply(ControlChannel.TaskEventPayload(type: "step", taskId: "sample-a", description: nil, role: "thinking", content: "Searching for Portland roasteries…", result: nil, error: nil, createdAt: nil)) apply(ControlChannel.TaskEventPayload(type: "step", taskId: "sample-a", description: nil, role: "tool_result", content: "Found 12 results across 3 review sites.", result: nil, error: nil, createdAt: nil)) apply(ControlChannel.TaskEventPayload(type: "completed", taskId: "sample-a", description: nil, role: nil, content: nil, result: "Top three: Coava, Stumptown, and Heart Roasters — all scored 4.5+ across reviews.", error: nil, createdAt: nil)) apply(ControlChannel.TaskEventPayload(type: "created", taskId: "sample-b", description: "Compare iPhone 17 vs Pixel 10 battery life", role: nil, content: nil, result: nil, error: nil, createdAt: now - 20)) apply(ControlChannel.TaskEventPayload(type: "step", taskId: "sample-b", description: nil, role: "thinking", content: "Fetching benchmark articles…", result: nil, error: nil, createdAt: nil)) apply(ControlChannel.TaskEventPayload(type: "failed", taskId: "sample-b", description: nil, role: nil, content: nil, result: nil, error: "Search backend timed out after 30s", createdAt: nil)) } #endif } private extension TaskStore { func date(from interval: Double?) -> Date { guard let interval, interval > 0 else { return Date() } return Date(timeIntervalSince1970: interval) } }