Archived
wip: Nearlight redesign implementation
This commit is contained in:
1 parent
c76afbd11e
commit
fa21c81787
9 files changed
+904
-273
No files matched your search
@@ -1,141 +1,240 @@
|
||||
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 {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 20) {
|
||||
if taskStore.sortedTasks.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
Text(taskStore.activeCount > 0 ? "Hope is working on \(taskStore.activeCount) task\(taskStore.activeCount == 1 ? "" : "s"). You can keep talking." : "Results from the things you’ve asked Hope to work on.")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
ForEach(taskStore.sortedTasks) { task in
|
||||
TaskCardView(task: task)
|
||||
}
|
||||
}
|
||||
Group {
|
||||
if taskStore.sortedTasks.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
taskList
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Activity")
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.background(Nearlight.ground)
|
||||
}
|
||||
|
||||
private var taskList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(taskStore.sortedTasks) { task in
|
||||
NavigationLink {
|
||||
TaskDetailView(task: task)
|
||||
} label: {
|
||||
TaskRowView(task: task)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Divider()
|
||||
.overlay(Nearlight.separator)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
ContentUnavailableView {
|
||||
Label("A little help, in the background", systemImage: "checklist")
|
||||
} description: {
|
||||
Text("Ask Hope to research a topic or compare your options. Follow the progress and read the results here.")
|
||||
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, 48)
|
||||
.padding(.top, Nearlight.s32)
|
||||
.padding(.horizontal, Nearlight.s20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityIdentifier("activity.empty")
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskCardView: View {
|
||||
/// One activity row: status symbol, title, current step/status, chevron.
|
||||
private struct TaskRowView: View {
|
||||
let task: TaskItem
|
||||
@State private var showSteps = false
|
||||
@Environment(\.dynamicTypeSize) private var typeSize
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Label(statusTitle, systemImage: statusSymbol)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(dotColor)
|
||||
Spacer()
|
||||
Text(task.createdAt, style: .time).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Text(task.description).font(.headline).fixedSize(horizontal: false, vertical: true)
|
||||
if !task.steps.isEmpty {
|
||||
DisclosureGroup("\(task.steps.count) progress updates", isExpanded: $showSteps) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(task.steps) { step in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(stepLabel(step.role))
|
||||
.font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||||
Text(step.content ?? "")
|
||||
.font(.subheadline).textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}.padding(.top, 8)
|
||||
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)
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
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))
|
||||
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(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16))
|
||||
.padding(.vertical, Nearlight.s16)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var statusTitle: String {
|
||||
switch task.status {
|
||||
case .pending: "Queued"
|
||||
case .running: "In progress"
|
||||
case .completed: "Complete"
|
||||
case .failed: "Needs attention"
|
||||
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.
|
||||
struct TaskDetailView: 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)
|
||||
}
|
||||
.background(Nearlight.ground)
|
||||
.navigationTitle("Task")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
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: "clock"
|
||||
case .running: "arrow.triangle.2.circlepath"
|
||||
case .completed: "checkmark.circle.fill"
|
||||
case .failed: "exclamationmark.circle"
|
||||
case .pending: return "circle.dotted"
|
||||
case .running: return "arrow.triangle.2.circlepath"
|
||||
case .completed: return "checkmark.circle"
|
||||
case .failed: return "exclamationmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var dotColor: Color {
|
||||
private var statusColor: Color {
|
||||
switch task.status {
|
||||
case .pending: return .gray
|
||||
case .running: return HopeStyle.tint
|
||||
case .completed: return .green
|
||||
case .failed: return .red
|
||||
case .pending: return Nearlight.quiet
|
||||
case .running: return Nearlight.rosewood
|
||||
case .completed: return Nearlight.quiet
|
||||
case .failed: return Nearlight.alert
|
||||
}
|
||||
}
|
||||
|
||||
private func block(_ text: String, color: Color, background: Color) -> some View {
|
||||
Text(text)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func stepLabel(_ role: String?) -> String {
|
||||
private func roleLabel(_ 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
|
||||
default: return role
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
TaskPanelView(taskStore: {
|
||||
let store = TaskStore()
|
||||
#if DEBUG
|
||||
store.injectSampleEvents()
|
||||
#endif
|
||||
return store
|
||||
}())
|
||||
TaskPanelView(taskStore: TaskStore())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user