266 lines
9.4 KiB
Swift
266 lines
9.4 KiB
Swift
import SwiftUI
|
|
|
|
/// Nearlight Activity: a working log. Plain rows on Ground, no cards;
|
|
/// live updates land in place without reordering while reading.
|
|
struct TaskPanelView: View {
|
|
@ObservedObject var taskStore: TaskStore
|
|
@Environment(\.dynamicTypeSize) private var typeSize
|
|
|
|
var body: some View {
|
|
Group {
|
|
if taskStore.sortedTasks.isEmpty {
|
|
emptyState
|
|
} else {
|
|
taskList
|
|
}
|
|
}
|
|
.navigationTitle("Activity")
|
|
.background(Nearlight.ground)
|
|
}
|
|
|
|
private var taskList: some View {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
ForEach(taskStore.sortedTasks) { task in
|
|
NavigationLink {
|
|
TaskDetailView(taskStore: taskStore, taskID: task.id)
|
|
} label: {
|
|
TaskRowView(task: task)
|
|
}
|
|
.buttonStyle(.plain)
|
|
Divider()
|
|
.overlay(Nearlight.separator)
|
|
}
|
|
}
|
|
.padding(.horizontal, Nearlight.s20)
|
|
}
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(alignment: .leading, spacing: Nearlight.s8) {
|
|
Image(systemName: "checklist")
|
|
.font(.system(size: 28))
|
|
.foregroundStyle(Nearlight.quiet)
|
|
Text("No tasks yet")
|
|
.font(NearlightFont.heading(typeSize))
|
|
Text("Tasks from your conversations will appear here.")
|
|
.font(.body)
|
|
.foregroundStyle(Nearlight.quiet)
|
|
}
|
|
.padding(.top, Nearlight.s32)
|
|
.padding(.horizontal, Nearlight.s20)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.accessibilityIdentifier("activity.empty")
|
|
}
|
|
}
|
|
|
|
/// One activity row: status symbol, title, current step/status, chevron.
|
|
private struct TaskRowView: View {
|
|
let task: TaskItem
|
|
@Environment(\.dynamicTypeSize) private var typeSize
|
|
|
|
var body: some View {
|
|
HStack(spacing: Nearlight.s12) {
|
|
Image(systemName: statusSymbol)
|
|
.font(.system(size: 20))
|
|
.foregroundStyle(statusColor)
|
|
.accessibilityHidden(true)
|
|
VStack(alignment: .leading, spacing: Nearlight.s4) {
|
|
Text(statusTitle)
|
|
.font(NearlightFont.support(typeSize))
|
|
.foregroundStyle(statusColor)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Text(task.description)
|
|
.font(NearlightFont.control(typeSize))
|
|
.foregroundStyle(Nearlight.ink)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
if let latest = latestStep {
|
|
Text(latest)
|
|
.font(NearlightFont.support(typeSize))
|
|
.foregroundStyle(Nearlight.quiet)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
Text(task.createdAt, style: .time)
|
|
.font(NearlightFont.metadata(typeSize))
|
|
.foregroundStyle(Nearlight.quiet)
|
|
.monospacedDigit()
|
|
}
|
|
Spacer(minLength: 0)
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(Nearlight.quiet)
|
|
.accessibilityHidden(true)
|
|
}
|
|
.padding(.vertical, Nearlight.s16)
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
private var statusTitle: String {
|
|
switch task.status {
|
|
case .pending: return "Queued"
|
|
case .running: return "In progress"
|
|
case .completed: return "Complete"
|
|
case .failed: return "Needs attention"
|
|
}
|
|
}
|
|
|
|
private var latestStep: String? {
|
|
if let error = task.error { return error }
|
|
if let result = task.result { return result }
|
|
return task.steps.last?.content
|
|
}
|
|
|
|
private var statusSymbol: String {
|
|
switch task.status {
|
|
case .pending: return "circle.dotted"
|
|
case .running: return "arrow.triangle.2.circlepath"
|
|
case .completed: return "checkmark.circle"
|
|
case .failed: return "exclamationmark.circle"
|
|
}
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
switch task.status {
|
|
case .pending: return Nearlight.quiet
|
|
case .running: return Nearlight.rosewood
|
|
case .completed: return Nearlight.quiet
|
|
case .failed: return Nearlight.alert
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Native push detail for one task: title, status, steps, result/error.
|
|
/// Looks the task up live in the store so removal shows the missing state.
|
|
struct TaskDetailView: View {
|
|
@ObservedObject var taskStore: TaskStore
|
|
let taskID: String
|
|
@Environment(\.dynamicTypeSize) private var typeSize
|
|
|
|
private var task: TaskItem? {
|
|
taskStore.tasks[taskID]
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let task {
|
|
TaskDetailContent(task: task)
|
|
} else {
|
|
Text("This task is no longer available.")
|
|
.font(.body)
|
|
.foregroundStyle(Nearlight.quiet)
|
|
.padding(Nearlight.s20)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
.background(Nearlight.ground)
|
|
.navigationTitle("Task")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
private struct TaskDetailContent: View {
|
|
let task: TaskItem
|
|
@Environment(\.dynamicTypeSize) private var typeSize
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: Nearlight.s24) {
|
|
VStack(alignment: .leading, spacing: Nearlight.s12) {
|
|
Text(task.description)
|
|
.font(NearlightFont.heading(typeSize))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Label(statusTitle, systemImage: statusSymbol)
|
|
.font(NearlightFont.support(typeSize))
|
|
.foregroundStyle(statusColor)
|
|
Text(task.createdAt, style: .time)
|
|
.font(NearlightFont.metadata(typeSize))
|
|
.foregroundStyle(Nearlight.quiet)
|
|
.monospacedDigit()
|
|
}
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text("Steps")
|
|
.font(NearlightFont.heading(typeSize))
|
|
.padding(.bottom, Nearlight.s8)
|
|
if task.steps.isEmpty {
|
|
Text("No steps reported yet.")
|
|
.font(.body)
|
|
.foregroundStyle(Nearlight.quiet)
|
|
} else {
|
|
ForEach(task.steps) { step in
|
|
VStack(alignment: .leading, spacing: Nearlight.s4) {
|
|
if let role = step.role {
|
|
Text(roleLabel(role))
|
|
.font(NearlightFont.support(typeSize).weight(.semibold))
|
|
.foregroundStyle(Nearlight.quiet)
|
|
}
|
|
Text(step.content ?? "")
|
|
.font(.body)
|
|
.foregroundStyle(Nearlight.ink)
|
|
.textSelection(.enabled)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(.vertical, Nearlight.s16)
|
|
}
|
|
}
|
|
}
|
|
if let result = task.result {
|
|
Text(result)
|
|
.font(.body)
|
|
.foregroundStyle(Nearlight.ink)
|
|
.textSelection(.enabled)
|
|
}
|
|
if let error = task.error {
|
|
Text(error)
|
|
.font(.body)
|
|
.foregroundStyle(Nearlight.alert)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
.padding(.horizontal, Nearlight.s20)
|
|
.padding(.top, Nearlight.s24)
|
|
.padding(.bottom, Nearlight.s24)
|
|
}
|
|
}
|
|
|
|
private var statusTitle: String {
|
|
switch task.status {
|
|
case .pending: return "Queued"
|
|
case .running: return "In progress"
|
|
case .completed: return "Complete"
|
|
case .failed: return "Needs attention"
|
|
}
|
|
}
|
|
|
|
private var statusSymbol: String {
|
|
switch task.status {
|
|
case .pending: return "circle.dotted"
|
|
case .running: return "arrow.triangle.2.circlepath"
|
|
case .completed: return "checkmark.circle"
|
|
case .failed: return "exclamationmark.circle"
|
|
}
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
switch task.status {
|
|
case .pending: return Nearlight.quiet
|
|
case .running: return Nearlight.rosewood
|
|
case .completed: return Nearlight.quiet
|
|
case .failed: return Nearlight.alert
|
|
}
|
|
}
|
|
|
|
private func roleLabel(_ role: String) -> String {
|
|
switch role {
|
|
case "thinking": return "Thinking"
|
|
case "tool_call": return "Tool"
|
|
default: return role
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
TaskPanelView(taskStore: TaskStore())
|
|
}
|
|
}
|