feat: background tasks panel (Phase 7)
This commit is contained in:
1 parent
5c35fa9c65
commit
af296da6b8
7 files changed
+339
-5
No files matched your search
@@ -174,6 +174,23 @@ struct ConversationView: View {
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink {
|
||||
TaskPanelView(taskStore: session.taskStore)
|
||||
} label: {
|
||||
Image(systemName: "tray.full")
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if session.taskStore.activeCount > 0 {
|
||||
Text("\(session.taskStore.activeCount)")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(3)
|
||||
.background(Color.red, in: Circle())
|
||||
.offset(x: 6, y: -6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink {
|
||||
MediaPanelView(playerController: session.mediaController, settings: settings)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TaskPanelView: View {
|
||||
@ObservedObject var taskStore: TaskStore
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
if taskStore.sortedTasks.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ForEach(taskStore.sortedTasks) { task in
|
||||
TaskCardView(task: task)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Background tasks")
|
||||
#if DEBUG
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Sample", action: { taskStore.injectSampleEvents() })
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Text("Nothing running")
|
||||
.font(.headline)
|
||||
Text("Ask Hope to research or compare something.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 48)
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskCardView: View {
|
||||
let task: TaskItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
statusDot
|
||||
Text(task.description)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
}
|
||||
if !task.steps.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(task.steps) { step in
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text(stepLabel(step.role))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(stepColor(step.role))
|
||||
Text(step.content ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(stepColor(step.role))
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let result = task.result {
|
||||
block(result, color: .green, background: Color.green.opacity(0.12))
|
||||
}
|
||||
if let error = task.error {
|
||||
block(error, color: .red, background: Color.red.opacity(0.12))
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
private var statusDot: some View {
|
||||
Circle()
|
||||
.fill(dotColor)
|
||||
.frame(width: 9, height: 9)
|
||||
}
|
||||
|
||||
private var dotColor: Color {
|
||||
switch task.status {
|
||||
case .pending: return .gray
|
||||
case .running: return .blue
|
||||
case .completed: return .green
|
||||
case .failed: return .red
|
||||
}
|
||||
}
|
||||
|
||||
private func block(_ text: String, color: Color, background: Color) -> some View {
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(color)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(background, in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private func stepLabel(_ role: String?) -> String {
|
||||
switch role {
|
||||
case "thinking": return "Thinking"
|
||||
case "tool_call": return "Tool"
|
||||
case "tool_result": return "Result"
|
||||
case "text": return "Note"
|
||||
default: return role ?? "Step"
|
||||
}
|
||||
}
|
||||
|
||||
private func stepColor(_ role: String?) -> Color {
|
||||
switch role {
|
||||
case "thinking": return .blue
|
||||
case "tool_call": return .indigo
|
||||
case "tool_result": return .secondary
|
||||
default: return .primary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
TaskPanelView(taskStore: {
|
||||
let store = TaskStore()
|
||||
#if DEBUG
|
||||
store.injectSampleEvents()
|
||||
#endif
|
||||
return store
|
||||
}())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user