feat: background tasks panel (Phase 7)

This commit is contained in:
snowspeeder committed 2026-08-30 08:48:33 -04:00
1 parent 5c35fa9c65
commit af296da6b8
7 files changed
+339 -5

No files matched your search

+14 -4
View File
@@ -26,6 +26,7 @@ final class ControlChannel: NSObject, RoomDelegate {
let content: String?
let result: String?
let error: String?
let createdAt: Double?
}
var onEvent: ((InboundEvent) -> Void)?
@@ -63,19 +64,23 @@ final class ControlChannel: NSObject, RoomDelegate {
}
}
/// Inbound task_event handling (topic-agnostic per web UI). Returns true when consumed.
/// Inbound task_event handling (topic-agnostic per web UI). The agent publishes
/// {type:"task_event", task_id, event, ...} — the discriminator is `event`, not `type`.
func handleTaskEvent(_ data: Data) -> Bool {
guard let json = try? decoder.decode([String: JSONValue].self, from: data),
let type = json["type"]?.stringValue,
json["type"]?.stringValue == "task_event",
let type = json["event"]?.stringValue,
["created", "step", "completed", "failed"].contains(type) else { return false }
let taskObj = json["task"]?.objectValue ?? [:]
let payload = TaskEventPayload(
type: type,
taskId: json["task_id"]?.stringValue,
description: json["description"]?.stringValue,
description: json["description"]?.stringValue ?? taskObj["description"]?.stringValue,
role: json["role"]?.stringValue,
content: json["content"]?.stringValue,
result: json["result"]?.stringValue,
error: json["error"]?.stringValue
error: json["error"]?.stringValue,
createdAt: json["created_at"]?.doubleValue ?? taskObj["created_at"]?.doubleValue
)
onEvent?(.taskEvent(payload))
return true
@@ -137,6 +142,11 @@ indirect enum JSONValue: Decodable {
return nil
}
var doubleValue: Double? {
if case .number(let value) = self { return value }
return nil
}
var objectValue: [String: JSONValue]? {
if case .object(let value) = self { return value }
return nil
+130
View File
@@ -0,0 +1,130 @@
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)
}
}
+3
View File
@@ -33,6 +33,7 @@ final class VoiceSession: ObservableObject {
let controlChannel = ControlChannel()
let transcriptHandler = TranscriptHandler()
let stateModel = ConversationStateModel()
let taskStore = TaskStore()
let mediaController: MediaPlayerController
private let config: ServerConfig
private let tokenClient: TokenClient
@@ -71,6 +72,8 @@ final class VoiceSession: ObservableObject {
selectedModel = model
settings.modelID = model
logger.log("model_changed applied: \(model, privacy: .public)")
case .taskEvent(let payload):
taskStore.apply(payload)
default:
break
}