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]>
160 lines
6.3 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|