Files
multica/apps/ios/Multica/Views/Tasks/TaskRunsView.swift
Jiayuan 31c2476a7b feat(ios): add MVP iOS app with issue management and agent log viewing
SwiftUI app (iOS 17+) with zero third-party dependencies:
- Passwordless email authentication with Keychain token storage
- Workspace selection and multi-workspace support
- Issue list grouped by status with search and filtering
- Issue detail with inline status/priority/assignee editing
- Comments with threaded display and compose
- Agent task runs history and real-time execution log streaming
- WebSocket integration for live updates
2026-04-02 23:50:28 +08:00

207 lines
7.2 KiB
Swift

import SwiftUI
struct TaskRunsView: View {
@Bindable var viewModel: IssueDetailViewModel
var body: some View {
LazyVStack(alignment: .leading, spacing: 0) {
if viewModel.taskRuns.isEmpty && !viewModel.isLoading {
VStack(spacing: 8) {
Image(systemName: "cpu")
.font(.title)
.foregroundStyle(.secondary)
Text("No agent runs yet")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Assign this issue to an agent to trigger execution.")
.font(.caption)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
// Active task with live logs
if let activeTask = viewModel.activeTask, activeTask.status.isActive {
VStack(alignment: .leading, spacing: 8) {
HStack {
ProgressView()
.controlSize(.small)
Text("Running")
.font(.subheadline.bold())
.foregroundStyle(.purple)
Spacer()
Text("Started \(relativeDate(activeTask.startedAt ?? activeTask.createdAt))")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.top, 12)
TaskMessagesView(messages: viewModel.taskMessages, isLive: true)
}
.background(.purple.opacity(0.03))
Divider()
}
// Historical runs
ForEach(viewModel.taskRuns.filter { t in
viewModel.activeTask.map { $0.id != t.id } ?? true
}) { task in
NavigationLink {
TaskRunDetailView(task: task)
} label: {
TaskRunRow(task: task)
}
Divider().padding(.leading)
}
}
}
private func relativeDate(_ isoString: String) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let date = formatter.date(from: isoString) ?? ISO8601DateFormatter().date(from: isoString) else {
return ""
}
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .abbreviated
return relative.localizedString(for: date, relativeTo: Date())
}
}
struct TaskRunRow: View {
let task: AgentTask
var body: some View {
HStack(spacing: 10) {
Image(systemName: task.status.iconName)
.foregroundStyle(taskColor)
.font(.body)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(task.status.label)
.font(.subheadline.weight(.medium))
if let error = task.error {
Text(error)
.font(.caption2)
.foregroundStyle(.red)
.lineLimit(1)
}
}
HStack(spacing: 12) {
Text(formatDate(task.createdAt))
.font(.caption2)
.foregroundStyle(.secondary)
if let started = task.startedAt, let completed = task.completedAt {
Text(duration(from: started, to: completed))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal)
.padding(.vertical, 10)
}
private var taskColor: Color {
switch task.status {
case .completed: .green
case .failed: .red
case .running: .purple
case .cancelled: .gray
default: .secondary
}
}
private func formatDate(_ iso: String) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let date = formatter.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) else { return iso }
let df = DateFormatter()
df.dateStyle = .short
df.timeStyle = .short
return df.string(from: date)
}
private func duration(from start: String, to end: String) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let s = formatter.date(from: start) ?? ISO8601DateFormatter().date(from: start),
let e = formatter.date(from: end) ?? ISO8601DateFormatter().date(from: end) else { return "" }
let interval = e.timeIntervalSince(s)
if interval < 60 { return "\(Int(interval))s" }
if interval < 3600 { return "\(Int(interval / 60))m \(Int(interval.truncatingRemainder(dividingBy: 60)))s" }
return "\(Int(interval / 3600))h \(Int((interval.truncatingRemainder(dividingBy: 3600)) / 60))m"
}
}
struct TaskRunDetailView: View {
let task: AgentTask
@State private var messages: [TaskMessage] = []
@State private var isLoading = true
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Task info header
HStack {
Image(systemName: task.status.iconName)
.foregroundStyle(taskColor)
Text(task.status.label)
.font(.headline)
Spacer()
}
.padding(.horizontal)
if let error = task.error {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.padding(.horizontal)
}
Divider()
if isLoading {
ProgressView("Loading execution log...")
.frame(maxWidth: .infinity)
.padding()
} else {
TaskMessagesView(messages: messages, isLive: false)
}
}
.padding(.top)
}
.navigationTitle("Run Details")
.navigationBarTitleDisplayMode(.inline)
.task {
do {
messages = try await APIClient.shared.listTaskMessages(taskId: task.id)
} catch {
// silently fail
}
isLoading = false
}
}
private var taskColor: Color {
switch task.status {
case .completed: .green
case .failed: .red
case .running: .purple
case .cancelled: .gray
default: .secondary
}
}
}