wip: Nearlight redesign implementation

This commit is contained in:
snowspeeder committed 2026-09-12 08:14:06 -04:00
1 parent c76afbd11e
commit fa21c81787
9 files changed
+904 -273

No files matched your search

+191 -92
View File
@@ -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())
}
}