Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/TodosView.swift
T
abkslmandClaude Fable 5 480b29207f sync: project quota, provider status, todo triage, and full diffs to the phone
Four Mac surfaces the iOS remote couldn't render now ride the wire,
forward-compatibly (decodeIfPresent defaults; unknown HostMsg tags
already decode to .unknown):

- DashboardSnapshot gains `usage` (WireSubscriptionUsage — the Mac's
  5-hour/weekly quota gauges) and `statusFeeds` (WireStatusFeed —
  active provider incidents); the host re-pushes the dashboard when a
  poll changes either.
- WireTodo gains `triage` (raw TriageLevel name); the phone renders
  the Mac's TriageBadge honoring the encouraging/classic label
  setting.
- ClientMsg.fetchDiff / HostMsg.sessionDiff deliver the full worktree
  patch on demand (capped at 512 KB with a truncated flag), gated on
  the new WireCapabilities.canFetchDiff so a new phone never sends it
  to an old host. iOS renders a file list + colored unified patch,
  falling back to the diffstat summary against older hosts.

Round-trip and legacy-decode tests cover the new fields; the full
suite passes apart from the pre-existing fake-backend fixture gaps
and the flaky nvrsion lock-domain test (same failure rate on the
base commit).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-02 16:06:32 -07:00

160 lines
6.3 KiB
Swift

import SwiftUI
import NucleicProtocol
/// The to-do inbox (mirrors the Mac's idea inbox): capture ideas, group by project, then
/// complete, dispatch (start an agent), or delete each — all control scope.
struct TodosView: View {
@EnvironmentObject var store: RemoteStore
@State private var draft = ""
private var open: [WireTodo] { store.dashboard.todos.filter { $0.status != .done } }
/// Group open todos by project name ("Unassigned" bucket last).
private var groups: [(name: String, todos: [WireTodo])] {
let byProject = Dictionary(grouping: open) { $0.projectName ?? "Unassigned" }
return byProject
.map { ($0.key, $0.value.sorted { $0.updatedAt > $1.updatedAt }) }
.sorted { ($0.name == "Unassigned" ? "zzz" : $0.name) < ($1.name == "Unassigned" ? "zzz" : $1.name) }
}
var body: some View {
NavigationStack {
List {
Section {
HStack {
TextField("Capture an idea…", text: $draft)
.textFieldStyle(.roundedBorder)
.onSubmit(capture)
.keyboardDismissable()
Button(action: capture) { Image(systemName: "plus.circle.fill").font(.title3) }
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
}
}
if open.isEmpty {
ContentUnavailableView("Inbox zero", systemImage: "checkmark.circle",
description: Text("Capture an idea above to dispatch later."))
}
ForEach(groups, id: \.name) { group in
Section(group.name.uppercased()) {
ForEach(group.todos) { todo in TodoRow(todo: todo) }
}
}
}
.navigationTitle("To-dos")
.scrollDismissesKeyboard(.interactively)
.refreshable { store.refreshSessions() }
}
}
private func capture() {
store.captureTodo(draft, projectID: nil)
draft = ""
}
}
private struct TodoRow: View {
@EnvironmentObject var store: RemoteStore
let todo: WireTodo
var body: some View {
HStack(spacing: 10) {
Button { store.completeTodo(todo.id) } label: {
Image(systemName: todo.status == .dispatched ? "circle.dashed" : "circle")
.foregroundStyle(.secondary)
}.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 2) {
Text(todo.summary?.isEmpty == false ? todo.summary! : todo.text)
.font(.subheadline).lineLimit(2)
HStack(spacing: 6) {
if let triage = todo.triage {
TriageBadge(rawLevel: triage)
}
if todo.status == .dispatched {
Text("Dispatched").font(.caption2).foregroundStyle(Palette.active)
}
}
}
Spacer()
dispatchButton
}
.swipeActions {
Button(role: .destructive) { store.deleteTodo(todo.id) } label: {
Label("Delete", systemImage: "trash")
}
}
}
/// Dispatch to the todo's project, or pick one if unassigned.
@ViewBuilder
private var dispatchButton: some View {
if todo.status == .open && store.canControl {
if let pid = todo.projectID {
Button { store.dispatchTodo(todo.id, in: pid) } label: {
Image(systemName: "play.circle.fill").foregroundStyle(Palette.accent)
}.buttonStyle(.plain)
} else if !store.dashboard.projects.isEmpty {
Menu {
ForEach(store.dashboard.projects) { project in
Button(project.name) { store.dispatchTodo(todo.id, in: project.id) }
}
} label: {
Image(systemName: "play.circle").foregroundStyle(Palette.accent)
}
}
}
}
}
/// A small colored badge showing an idea's triage level — the Mac's `TriageBadge`, fed by
/// the wire's raw level name. Honors the encouraging/classic labeling setting (same storage
/// key as the Mac); an unrecognized level from a newer host renders no badge.
struct TriageBadge: View {
let rawLevel: String
@AppStorage("nucleic.triageEncouragingLabels") private var encouraging = true
private var label: String? {
switch (rawLevel, encouraging) {
case ("critical", true): "Max Impact"
case ("high", true): "High Impact"
case ("medium", true): "Worthwhile"
case ("low", true): "Someday"
case ("critical", false): "Critical"
case ("high", false): "High"
case ("medium", false): "Medium"
case ("low", false): "Low"
default: nil
}
}
/// The Mac's badge hues: a red→orange→yellow→gray urgency scale for classic labels, a
/// magenta→blue→green→slate reward scale for the encouraging framing.
private var tint: Color? {
switch (rawLevel, encouraging) {
case ("critical", false): .red
case ("high", false): .orange
case ("medium", false): .yellow
case ("low", false): .gray
case ("critical", true): Color(red: 0.95, green: 0.33, blue: 0.78) // vivid magenta
case ("high", true): Color(red: 0.26, green: 0.54, blue: 1.00) // bright blue
case ("medium", true): Color(red: 0.18, green: 0.78, blue: 0.45) // green
case ("low", true): Color(red: 0.56, green: 0.60, blue: 0.67) // calm slate
default: nil
}
}
var body: some View {
if let label, let tint {
HStack(spacing: 4) {
Circle().fill(tint).frame(width: 7, height: 7)
Text(label)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 7).padding(.vertical, 3)
.background(tint.opacity(0.14), in: .capsule)
.overlay(Capsule().strokeBorder(tint.opacity(0.35), lineWidth: 0.5))
.fixedSize()
}
}
}