mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 21:31:17 +00:00
* feat: add background task details across clients * fix: refresh background task clients and translations * fix: keep background task details fresh * fix: ignore deleted background task details * refactor: split background task rendering * fix: use canonical background task ids * refactor: extract background task view types * fix(apps): regenerate task localizations * fix(ios): remove unused task screen import * fix(apps): refresh task localization inventory
392 lines
14 KiB
Swift
392 lines
14 KiB
Swift
import Foundation
|
||
import SwiftUI
|
||
|
||
struct MobileBackgroundTask: Decodable, Identifiable, Equatable {
|
||
struct Timestamp: Decodable, Equatable {
|
||
let milliseconds: Double
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.singleValueContainer()
|
||
if let number = try? container.decode(Double.self) {
|
||
self.milliseconds = number
|
||
return
|
||
}
|
||
let value = try container.decode(String.self)
|
||
let fractionalFormatter = ISO8601DateFormatter()
|
||
fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||
guard let date = fractionalFormatter.date(from: value) ?? ISO8601DateFormatter().date(from: value) else {
|
||
throw DecodingError.dataCorruptedError(
|
||
in: container,
|
||
debugDescription: "Expected a millisecond timestamp or ISO-8601 date")
|
||
}
|
||
self.milliseconds = date.timeIntervalSince1970 * 1000
|
||
}
|
||
}
|
||
|
||
let id: String
|
||
let status: String
|
||
let runtime: String?
|
||
let title: String?
|
||
let agentId: String?
|
||
let sessionKey: String?
|
||
let childSessionKey: String?
|
||
let createdAt: Timestamp?
|
||
let updatedAt: Timestamp?
|
||
let startedAt: Timestamp?
|
||
let endedAt: Timestamp?
|
||
let progressSummary: String?
|
||
let terminalSummary: String?
|
||
let error: String?
|
||
let prompt: String?
|
||
|
||
var displayTitle: String {
|
||
self.title?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||
?? self.id.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||
?? String(localized: "Background task")
|
||
}
|
||
|
||
var isActive: Bool {
|
||
self.status == "queued" || self.status == "running"
|
||
}
|
||
|
||
var statusLabel: String {
|
||
switch self.status {
|
||
case "queued": String(localized: "Queued")
|
||
case "running": String(localized: "Running")
|
||
case "completed": String(localized: "Completed")
|
||
default: String(localized: "Failed")
|
||
}
|
||
}
|
||
|
||
var runtimeLabel: String {
|
||
switch self.runtime {
|
||
case "subagent": String(localized: "Subagent")
|
||
case "cron": String(localized: "Cron")
|
||
case "acp": "ACP"
|
||
case "cli": "CLI"
|
||
default: String(localized: "Task")
|
||
}
|
||
}
|
||
|
||
var output: String? {
|
||
let candidates = if self.status == "failed" || self.status == "timed_out" {
|
||
[self.error, self.terminalSummary, self.progressSummary]
|
||
} else {
|
||
[self.terminalSummary, self.error, self.progressSummary]
|
||
}
|
||
return candidates.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty }.first
|
||
}
|
||
|
||
var activityMilliseconds: Double {
|
||
self.updatedAt?.milliseconds ?? self.endedAt?.milliseconds
|
||
?? self.startedAt?.milliseconds ?? self.createdAt?.milliseconds ?? 0
|
||
}
|
||
}
|
||
|
||
extension String {
|
||
fileprivate var nilIfEmpty: String? {
|
||
self.isEmpty ? nil : self
|
||
}
|
||
}
|
||
|
||
private struct MobileBackgroundTasksEnvelope: Decodable {
|
||
let tasks: [MobileBackgroundTask]
|
||
}
|
||
|
||
private struct MobileBackgroundTaskEnvelope: Decodable {
|
||
let task: MobileBackgroundTask
|
||
}
|
||
|
||
private struct MobileBackgroundTasksListParams: Encodable {
|
||
let agentId: String
|
||
let status: [String]?
|
||
let limit: Int
|
||
}
|
||
|
||
private struct MobileBackgroundTaskGetParams: Encodable {
|
||
let taskId: String
|
||
}
|
||
|
||
enum MobileBackgroundTaskList {
|
||
@MainActor
|
||
static func load(
|
||
request: (_ status: [String], _ limit: Int) async throws -> [MobileBackgroundTask]) async throws
|
||
-> [MobileBackgroundTask]
|
||
{
|
||
// Active first, terminal second: a monotonic active-to-terminal transition
|
||
// is then present in at least one snapshot instead of falling between calls.
|
||
let active = try await request(["queued", "running"], 200)
|
||
let finished = try await request(["completed", "failed", "cancelled", "timed_out"], 100)
|
||
return self.merge(recent: finished, active: active)
|
||
}
|
||
|
||
static func merge(
|
||
recent: [MobileBackgroundTask],
|
||
active: [MobileBackgroundTask]) -> [MobileBackgroundTask]
|
||
{
|
||
var byId: [String: MobileBackgroundTask] = [:]
|
||
for task in recent + active {
|
||
guard let current = byId[task.id] else {
|
||
byId[task.id] = task
|
||
continue
|
||
}
|
||
if task.activityMilliseconds > current.activityMilliseconds ||
|
||
(task.activityMilliseconds == current.activityMilliseconds && !task.isActive)
|
||
{
|
||
byId[task.id] = task
|
||
}
|
||
}
|
||
return byId.values.sorted {
|
||
if $0.activityMilliseconds != $1.activityMilliseconds {
|
||
return $0.activityMilliseconds > $1.activityMilliseconds
|
||
}
|
||
return $0.id < $1.id
|
||
}
|
||
}
|
||
}
|
||
|
||
struct BackgroundTasksScreen: View {
|
||
@Environment(NodeAppModel.self) private var appModel
|
||
let agentID: String
|
||
|
||
@State private var tasks: [MobileBackgroundTask] = []
|
||
@State private var loading = true
|
||
@State private var errorMessage: String?
|
||
|
||
private var activeTasks: [MobileBackgroundTask] {
|
||
self.tasks.filter(\.isActive)
|
||
}
|
||
|
||
private var finishedTasks: [MobileBackgroundTask] {
|
||
self.tasks.filter { !$0.isActive }.prefix(50).map(\.self)
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Group {
|
||
if self.loading, self.tasks.isEmpty {
|
||
ProgressView {
|
||
Text("Loading background tasks…")
|
||
.font(OpenClawType.body)
|
||
}
|
||
} else if let errorMessage, self.tasks.isEmpty {
|
||
ContentUnavailableView(
|
||
"Couldn’t Load Tasks",
|
||
systemImage: "exclamationmark.triangle",
|
||
description: Text(errorMessage).font(OpenClawType.body))
|
||
} else if self.tasks.isEmpty {
|
||
ContentUnavailableView(
|
||
"No Background Tasks",
|
||
systemImage: "clock.arrow.circlepath",
|
||
description: Text("Tasks for this agent will appear here.")
|
||
.font(OpenClawType.body))
|
||
} else {
|
||
List {
|
||
if let errorMessage {
|
||
Text(errorMessage)
|
||
.font(OpenClawType.footnote)
|
||
.foregroundStyle(OpenClawBrand.warn)
|
||
}
|
||
Section {
|
||
if self.activeTasks.isEmpty {
|
||
Text("No running tasks")
|
||
.font(OpenClawType.body)
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(self.activeTasks) { task in
|
||
self.taskLink(task)
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Running").font(OpenClawType.captionMedium)
|
||
}
|
||
Section {
|
||
if self.finishedTasks.isEmpty {
|
||
Text("No finished tasks")
|
||
.font(OpenClawType.body)
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(self.finishedTasks) { task in
|
||
self.taskLink(task)
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Finished").font(OpenClawType.captionMedium)
|
||
}
|
||
}
|
||
.listStyle(.insetGrouped)
|
||
.refreshable { await self.loadTasks() }
|
||
}
|
||
}
|
||
.navigationTitle("Background Tasks")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button {
|
||
Task { await self.loadTasks() }
|
||
} label: {
|
||
Label {
|
||
Text("Refresh").font(OpenClawType.body)
|
||
} icon: {
|
||
Image(systemName: "arrow.clockwise")
|
||
}
|
||
}
|
||
.disabled(self.loading)
|
||
}
|
||
}
|
||
}
|
||
.task { await self.loadTasks() }
|
||
}
|
||
|
||
private func taskLink(_ task: MobileBackgroundTask) -> some View {
|
||
NavigationLink {
|
||
BackgroundTaskDetailScreen(task: task)
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 7) {
|
||
Text(task.displayTitle)
|
||
.font(OpenClawType.subheadMedium)
|
||
.foregroundStyle(.primary)
|
||
.lineLimit(2)
|
||
HStack(spacing: 7) {
|
||
Text(task.statusLabel)
|
||
.font(OpenClawType.captionMedium)
|
||
.foregroundStyle(self.statusColor(task))
|
||
Text(task.runtimeLabel)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
}
|
||
if let output = task.output {
|
||
Text(output)
|
||
.font(OpenClawType.footnote)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(2)
|
||
}
|
||
}
|
||
.padding(.vertical, 3)
|
||
}
|
||
.accessibilityIdentifier("background-task-\(task.id)")
|
||
}
|
||
|
||
private func statusColor(_ task: MobileBackgroundTask) -> Color {
|
||
switch task.status {
|
||
case "completed": OpenClawBrand.ok
|
||
case "failed", "timed_out": OpenClawBrand.warn
|
||
case "queued", "running": OpenClawBrand.accent
|
||
default: .secondary
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func loadTasks() async {
|
||
self.loading = true
|
||
self.errorMessage = nil
|
||
do {
|
||
self.tasks = try await MobileBackgroundTaskList.load { status, limit in
|
||
try await self.requestTasks(status: status, limit: limit)
|
||
}
|
||
} catch {
|
||
self.errorMessage = error.localizedDescription
|
||
}
|
||
self.loading = false
|
||
}
|
||
|
||
private func requestTasks(status: [String]?, limit: Int) async throws -> [MobileBackgroundTask] {
|
||
let params = MobileBackgroundTasksListParams(agentId: self.agentID, status: status, limit: limit)
|
||
let data = try await self.request(method: "tasks.list", params: params)
|
||
return try JSONDecoder().decode(MobileBackgroundTasksEnvelope.self, from: data).tasks
|
||
}
|
||
|
||
private func request(method: String, params: some Encodable) async throws -> Data {
|
||
let payload = try JSONEncoder().encode(params)
|
||
guard let paramsJSON = String(data: payload, encoding: .utf8) else {
|
||
throw CocoaError(.fileReadCorruptFile)
|
||
}
|
||
return try await self.appModel.operatorSession.request(
|
||
method: method,
|
||
paramsJSON: paramsJSON,
|
||
timeoutSeconds: 12)
|
||
}
|
||
}
|
||
|
||
private struct BackgroundTaskDetailScreen: View {
|
||
@Environment(NodeAppModel.self) private var appModel
|
||
@State private var task: MobileBackgroundTask
|
||
@State private var loading = true
|
||
@State private var errorMessage: String?
|
||
|
||
init(task: MobileBackgroundTask) {
|
||
self._task = State(initialValue: task)
|
||
}
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
VStack(alignment: .leading, spacing: 7) {
|
||
Text(self.task.displayTitle)
|
||
.font(OpenClawType.title3)
|
||
HStack(spacing: 8) {
|
||
Text(self.task.statusLabel).font(OpenClawType.captionMedium)
|
||
Text(self.task.runtimeLabel)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
self.detailBlock(
|
||
title: String(localized: "Prompt"),
|
||
body: self.task.prompt ?? (self.loading
|
||
? String(localized: "Loading…")
|
||
: String(localized: "Prompt unavailable.")))
|
||
self.detailBlock(
|
||
title: String(localized: "Output"),
|
||
body: self.task.output ?? String(localized: "No output yet."))
|
||
if let errorMessage {
|
||
Text(errorMessage)
|
||
.font(OpenClawType.footnote)
|
||
.foregroundStyle(OpenClawBrand.warn)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(20)
|
||
}
|
||
.navigationTitle("Task Details")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.task { await self.loadDetail() }
|
||
}
|
||
|
||
private func detailBlock(title: String, body: String) -> some View {
|
||
VStack(alignment: .leading, spacing: 7) {
|
||
Text(title)
|
||
.font(OpenClawType.captionMedium)
|
||
.foregroundStyle(.secondary)
|
||
.textCase(.uppercase)
|
||
Text(body)
|
||
.font(OpenClawType.monoFootnote)
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(12)
|
||
.background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func loadDetail() async {
|
||
self.loading = true
|
||
do {
|
||
let params = MobileBackgroundTaskGetParams(taskId: self.task.id)
|
||
let payload = try JSONEncoder().encode(params)
|
||
guard let paramsJSON = String(data: payload, encoding: .utf8) else {
|
||
throw CocoaError(.fileReadCorruptFile)
|
||
}
|
||
let data = try await self.appModel.operatorSession.request(
|
||
method: "tasks.get",
|
||
paramsJSON: paramsJSON,
|
||
timeoutSeconds: 12)
|
||
self.task = try JSONDecoder().decode(MobileBackgroundTaskEnvelope.self, from: data).task
|
||
} catch {
|
||
self.errorMessage = error.localizedDescription
|
||
}
|
||
self.loading = false
|
||
}
|
||
}
|