import ByreAPI import SwiftUI typealias Todo = Components.Schemas.Todo typealias TodoItem = Components.Schemas.TodoItem private let refreshKinds: Set = ["task.merged", "project.created"] struct ProjectView: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var todos: [String: Result] = [:] @ViewState private var off: (() -> Void)? var body: some View { ScrollView { if let p = store.focusedProject() { ProjectBlock(project: p, todo: todos[p.id]) .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 16) } else { Muted(text: "No project selected.") } } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { off = store.events.add { ev in if refreshKinds.contains(ev.kind), let projectId = ev.projectId { load(projectId) } } refresh() } .onDisappear { off?() off = nil } .onChange(of: store.connected) { if store.connected { refresh() } } .onChange(of: store.projects.map(\.id)) { refresh() } .onChange(of: store.focusedProject()?.id) { if let p = store.focusedProject(), todos[p.id] == nil { load(p.id) } } } private func refresh() { if let p = store.focusedProject() { load(p.id) } } private func load(_ projectId: String) { Task { do { todos[projectId] = .success(try await store.api.client.getTodo(path: .init(projectId: projectId)).ok.body.json) } catch { todos[projectId] = .failure(ApiError(status: 0, message: "Couldn't read TODO.md: \(errorText(error))")) } } } } private struct ProjectBlock: View { let project: Project let todo: Result? @Environment(Store.self) private var store var body: some View { let active = (store.tasks[project.id] ?? []).filter { $0.status == .active } VStack(alignment: .leading, spacing: 0) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(project.name).font(Fonts.font(13, weight: 700)) Text(project.defaultBranch).font(Fonts.font(11)).foregroundStyle(Palette.muted) } .padding(.top, 16) .padding(.bottom, 6) heading("Tasks") TaskRows(active: active) heading("TODO") TodoBlock(project: project, todo: todo, active: active) } .frame(maxWidth: .infinity, alignment: .leading) } private func heading(_ text: String) -> some View { Text(text) .font(Fonts.font(12, weight: 700)) .padding(.top, 14) .padding(.bottom, 4) } } @MainActor private func nested(_ store: Store, _ active: [ByreTask]) -> [(task: ByreTask, depth: Int)] { let ids = Set(active.map(\.id)) var out: [(task: ByreTask, depth: Int)] = [] func add(_ task: ByreTask, _ depth: Int) { out.append((task, depth)) for child in store.byRank(active.filter({ $0.parentTaskId == task.id }).reversed()) { add(child, depth + 1) } } for task in store.byRank(active.filter { $0.parentTaskId == nil || !ids.contains($0.parentTaskId!) }) { add(task, 0) } return out } private struct TaskRows: View { let active: [ByreTask] @Environment(Store.self) private var store var body: some View { if active.isEmpty { Text("No active tasks.") .foregroundStyle(Palette.muted) .padding(.horizontal, 10) .padding(.vertical, 6) } else { VStack(alignment: .leading, spacing: 0) { ForEach(nested(store, active), id: \.task.id) { row in TaskRow(task: row.task, depth: row.depth) } } .padding(.bottom, 8) } } } struct HoverRow: View { var selected = false var indent: CGFloat = 0 let action: () -> Void @ViewBuilder let content: Content @ViewState private var hover = false var body: some View { Button(action: action) { HStack(spacing: 10) { content } .padding(.leading, max(0, 10 + indent)) .padding(.trailing, 10) .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .leading) .background(selected ? Palette.accentTint : hover ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 6)) .contentShape(Rectangle()) } .buttonStyle(.plain) .onHover { hover = $0 } } } private struct TaskRow: View { let task: ByreTask let depth: Int @Environment(Store.self) private var store var body: some View { let ts = store.taskState(task) let color = taskPalette[task.color] let count = store.inbox.filter { $0.taskId == task.id }.count HoverRow(selected: task.id == store.selectedTaskId, indent: task.parentTaskId != nil ? 20 * CGFloat(depth - 1) : 0) { store.focusTask(task.id) } content: { if let parent = task.parentTaskId { Text("↳") .foregroundStyle(Palette.muted) .help("Subtask of \(store.findTask(parent)?.title ?? parent)") } StateMark(state: ts, color: color) Text(task.title) .foregroundStyle(color) .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) if count > 0 { Badge(count: count).help("open questions") } if ts.kind == .idle && store.unreadSummary(task) { Text("new summary") .font(Fonts.font(11, weight: 700)) .foregroundStyle(Palette.accent) .help("A turn summary you haven't read") .accessibilityLabel("New summary from \(task.title), unread") } else { Text(ts.label) .font(Fonts.font(11)) .foregroundStyle(ts.needsYou ? Palette.warn : Palette.muted) .help(ts.title) } } } } private struct TodoBlock: View { let project: Project let todo: Result? let active: [ByreTask] var body: some View { switch todo { case .none: row("Loading…") case .failure(let e): row(e.message, error: true) case .success(let t): if !t.exists { row("No TODO.md on \(project.defaultBranch).") } else if t.items.isEmpty { row("TODO.md has no items.") } else { VStack(alignment: .leading, spacing: 0) { ForEach(Array(t.items.enumerated()), id: \.offset) { i, item in if !item.section.isEmpty && (i == 0 || t.items[i - 1].section != item.section) { Text(item.section) .font(Fonts.font(12, weight: 700)) .foregroundStyle(Palette.muted) .padding(.top, i == 0 ? 10 : 2) .padding(.bottom, 4) } TodoRow(project: project, item: item, task: active.first { $0.todoItem == item.title }) .padding(.bottom, i == t.items.count - 1 || t.items[i + 1].section != item.section ? 8 : 0) } } } } } private func row(_ text: String, error: Bool = false) -> some View { Text(text) .foregroundStyle(error ? Palette.danger : Palette.muted) .padding(.horizontal, 10) .padding(.vertical, 6) } } private struct TodoRow: View { let project: Project let item: TodoItem let task: ByreTask? @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 10) { Text(item.title) .foregroundStyle(task.map { taskPalette[$0.color] } ?? Palette.text) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) action } if !item.body.isEmpty { MarkdownView(blocks: parseMarkdown(item.body), ctx: MDContext(color: Palette.muted)) } } .padding(.horizontal, 10) .padding(.vertical, 6) .background(task != nil && task?.id == store.selectedTaskId ? Palette.accentTint : .clear, in: RoundedRectangle(cornerRadius: 6)) } @ViewBuilder private var action: some View { if let task { let ts = store.taskState(task) Button { store.focusTask(task.id) } label: { HStack(spacing: 6) { StateMark(state: ts, color: taskPalette[task.color]) Text(ts.label) } .font(Fonts.font(11)) .foregroundStyle(Palette.muted) .contentShape(Rectangle()) } .buttonStyle(.plain) .fixedSize() } else { let busy = store.todoStarting.contains("\(project.id)\n\(item.title)") HStack(spacing: 6) { Button(busy ? "Starting…" : "Start") { store.startTodo(project.id, item.title, discuss: false, overlays) } .help("Start a task that gets straight to work") Button("Discuss") { store.startTodo(project.id, item.title, discuss: true, overlays) } .help("Start a task that discusses it with you in chat before building anything") } .buttonStyle(WebButtonStyle()) .disabled(busy) .fixedSize() } } }