Watch: activity line on working cards, All threads browsing, a thread's subagents, Load earlier, and markdown messages
This commit is contained in:
1 parent
abd2fd0e13
commit
d916bfbab5
21 files changed
+928
-46
No files matched your search
@@ -0,0 +1,237 @@
|
||||
// All threads, opened from the last row of the feed: hosts, then a host's projects sorted by title,
|
||||
// then a project's threads with settled ones included. Hosts and projects come from
|
||||
// `GET /v1/projects`, threads from `GET /v1/projects/:hostId/:projectId/threads`. Each screen loads
|
||||
// when it appears; nothing polls. A thread opens the usual thread screen.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Every host, offline ones dimmed. Each opens its projects.
|
||||
struct HostsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
/// Snapshots render the rows without their List, which ImageRenderer leaves blank.
|
||||
var scrolls = true
|
||||
|
||||
var body: some View {
|
||||
BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) {
|
||||
if let hosts = model.projects?.hosts {
|
||||
ForEach(hosts) { host in
|
||||
BrowseRow(
|
||||
title: host.label,
|
||||
subtitle: host.status.problem ?? (host.projects.count == 1 ? "1 project" : "\(host.projects.count) projects"),
|
||||
route: .hostProjects(hostId: host.id))
|
||||
.opacity(host.status == .online ? 1 : 0.5)
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("All threads")
|
||||
}
|
||||
}
|
||||
|
||||
/// One host's projects, sorted by title. Each opens its threads.
|
||||
struct ProjectsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let hostId: String
|
||||
var scrolls = true
|
||||
|
||||
var body: some View {
|
||||
let host = model.projects?.hosts.first { $0.id == hostId }
|
||||
BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) {
|
||||
if let host {
|
||||
let projects = host.projects.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
||||
ForEach(projects) { project in
|
||||
BrowseRow(title: project.title, route: .projectThreads(hostId: host.id, projectId: project.id))
|
||||
.feedRow()
|
||||
}
|
||||
if projects.isEmpty {
|
||||
BrowseNote(text: "No projects.")
|
||||
}
|
||||
} else if model.projects != nil {
|
||||
BrowseNote(text: "This host is gone.")
|
||||
}
|
||||
}
|
||||
.navigationTitle(host?.label ?? "Projects")
|
||||
}
|
||||
}
|
||||
|
||||
/// One project's threads, newest first: a status dot, the title, dimmed once finished or settled,
|
||||
/// and the age.
|
||||
struct ProjectThreadsView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let hostId: String
|
||||
let projectId: String
|
||||
@State private var rows: [ThreadRow]?
|
||||
@State private var error: String?
|
||||
private let scrolls: Bool
|
||||
|
||||
/// `rows` starts the screen loaded, for snapshots.
|
||||
init(hostId: String, projectId: String, rows: [ThreadRow]? = nil, scrolls: Bool = true) {
|
||||
self.hostId = hostId
|
||||
self.projectId = projectId
|
||||
self.scrolls = scrolls
|
||||
_rows = State(initialValue: rows)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let title = model.projects?.hosts.first { $0.id == hostId }?.projects.first { $0.id == projectId }?.title
|
||||
BrowseList(scrolls: scrolls, error: error, loaded: rows != nil, reload: load) {
|
||||
if let rows {
|
||||
ForEach(rows) { row in
|
||||
ThreadLine(row: row) {
|
||||
Text(Format.elapsed(since: row.updatedAt))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
.feedRow()
|
||||
}
|
||||
if rows.isEmpty {
|
||||
BrowseNote(text: "No threads yet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(title ?? "Threads")
|
||||
.task {
|
||||
// The title comes from the projects list, which a wristcode:// link may skip.
|
||||
if model.projects == nil { await model.loadProjects() }
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
do {
|
||||
rows = try await model.api.projectThreads(hostId: hostId, projectId: projectId).items
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = model.message(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A thread on one line: status dot, title, and `trailing` on the right. Opens the thread.
|
||||
struct ThreadLine<Trailing: View>: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var row: ThreadRow
|
||||
@ViewBuilder var trailing: Trailing
|
||||
|
||||
var body: some View {
|
||||
Button { model.path.append(row.route) } label: {
|
||||
Card(edge: Theme.card) {
|
||||
HStack(spacing: 7) {
|
||||
Circle()
|
||||
.fill(row.color)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(row.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(row.isActive ? Color.white : Theme.secondaryText)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
trailing
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// The List each browse screen scrolls, with loading, an error and Try again. Loads on appear.
|
||||
private struct BrowseList<Rows: View>: View {
|
||||
var scrolls: Bool
|
||||
var error: String?
|
||||
var loaded: Bool
|
||||
var reload: () async -> Void
|
||||
@ViewBuilder var rows: Rows
|
||||
|
||||
var body: some View {
|
||||
if scrolls {
|
||||
List { content }
|
||||
.listStyle(.plain)
|
||||
.refreshable { await reload() }
|
||||
.task { await reload() }
|
||||
} else {
|
||||
VStack(spacing: 6) { content }
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
if loaded {
|
||||
rows
|
||||
} else if error == nil {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 20)
|
||||
.feedRow()
|
||||
}
|
||||
if let error {
|
||||
BrowseNote(text: error)
|
||||
if !loaded {
|
||||
Button("Try again") { Task { await reload() } }
|
||||
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain card with a title, an optional line under it, and a chevron.
|
||||
private struct BrowseRow: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var route: Route
|
||||
|
||||
var body: some View {
|
||||
Button { model.path.append(route) } label: {
|
||||
Card(edge: Theme.done) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.lineLimit(2)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BrowseNote: View {
|
||||
var text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.horizontal, 8)
|
||||
.feedRow()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Hosts") {
|
||||
NavigationStack {
|
||||
HostsView()
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
|
||||
#Preview("Threads") {
|
||||
NavigationStack {
|
||||
ProjectThreadsView(hostId: "mac-mini", projectId: "p-wc-mac")
|
||||
}
|
||||
.environment(AppModel.preview())
|
||||
.fontDesign(.rounded)
|
||||
}
|
||||
Reference in new issue
Block a user