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]>
This commit is contained in:
2026-07-02 16:06:32 -07:00
co-authored by Claude Fable 5
parent a33ab80313
commit 480b29207f
6 changed files with 438 additions and 8 deletions
@@ -49,6 +49,10 @@ final class RemoteStore: ObservableObject {
@Published private(set) var openSessionID: SessionID?
@Published private(set) var openEvents: [AgentEvent] = []
@Published private(set) var openApprovals: [ApprovalRequest] = []
/// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the
/// user opens the Diff tab and the host advertises `canFetchDiff`. Nil until it arrives.
@Published private(set) var openDiff: WireSessionDiff?
@Published private(set) var diffLoading = false
/// A transient host-reported error (the mobile echo of the Mac's last-error bubble):
/// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds.
@@ -123,7 +127,8 @@ final class RemoteStore: ObservableObject {
connectivity = .connected
hostName = "Andrew's Mac"
grantedScope = .control
capabilities = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName])
capabilities = WireCapabilities(
canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName], canFetchDiff: true)
modelCatalog = WireModelCatalog(
groups: [
[WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode,
@@ -179,14 +184,52 @@ final class RemoteStore: ObservableObject {
],
todos: [
WireTodo(id: TodoID(rawValue: "t1"), text: "Add dark mode to settings", summary: "Dark mode in settings",
projectID: p2, projectName: "website", status: .open, dispatchedSessionID: nil, updatedAt: Date()),
projectID: p2, projectName: "website", status: .open, dispatchedSessionID: nil,
triage: "high", updatedAt: Date()),
WireTodo(id: TodoID(rawValue: "t2"), text: "Investigate the memory leak in the sync server", summary: "Sync server memory leak",
projectID: p1, projectName: "nucleic", status: .open, dispatchedSessionID: nil, updatedAt: Date()),
projectID: p1, projectName: "nucleic", status: .open, dispatchedSessionID: nil,
triage: "critical", updatedAt: Date()),
WireTodo(id: TodoID(rawValue: "t3"), text: "Write release notes", summary: nil,
projectID: nil, projectName: nil, status: .open, dispatchedSessionID: nil, updatedAt: Date()),
projectID: nil, projectName: nil, status: .open, dispatchedSessionID: nil,
triage: "low", updatedAt: Date()),
],
usage: WireSubscriptionUsage(
fiveHour: WireUsageWindow(utilization: 42, resetsAt: Date().addingTimeInterval(3 * 3600)),
sevenDay: WireUsageWindow(utilization: 78, resetsAt: Date().addingTimeInterval(2.4 * 86_400))),
statusFeeds: [
WireStatusFeed(provider: "claude", providerName: "Claude", incidents: []),
WireStatusFeed(provider: "openai", providerName: "OpenAI", incidents: [
WireStatusIncident(
id: "i1", title: "Elevated errors on Codex", url: URL(string: "https://status.openai.com"),
updatedAt: Date(), state: "Monitoring", isResolved: false, components: ["Codex"]),
]),
WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []),
])
}
/// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host.
private func demoDiff(_ sessionID: SessionID) -> WireSessionDiff {
WireSessionDiff(
sessionID: sessionID,
stat: DiffStat(filesChanged: 2, added: 312, removed: 40),
files: [
WireFileDiff(path: "auth/middleware.ts", oldPath: nil, status: "modified", added: 290, removed: 38),
WireFileDiff(path: "auth/session.ts", oldPath: nil, status: "added", added: 22, removed: 2),
],
patch: """
diff --git a/auth/middleware.ts b/auth/middleware.ts
--- a/auth/middleware.ts
+++ b/auth/middleware.ts
@@ -10,7 +10,9 @@ export function requireSession(req: Request) {
- const token = req.headers.get("x-auth")
+ const header = req.headers.get("authorization") ?? ""
+ const token = header.replace(/^Bearer /, "")
+ if (!token) throw new AuthError("missing bearer token")
return verify(token)
}
""")
}
/// Pair from a scanned QR (SYNC §4.2): connect (LAN hint first, else Bonjour), run XXpsk0,
/// and on success pin the host key for future IK reconnects.
func pair(with payload: PairingPayload) {
@@ -240,12 +283,23 @@ final class RemoteStore: ObservableObject {
openSessionID = sessionID
openEvents = []
openApprovals = []
openDiff = nil
diffLoading = false
seenSeq.removeAll()
markOpened(sessionID)
if demoMode { seedDemoTranscript(sessionID); return }
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
}
/// Ask the host for the open session's full patch (Diff tab). No-op when the host
/// doesn't advertise the capability — the view falls back to the diffstat summary.
func fetchDiff(_ sessionID: SessionID) {
if demoMode { openDiff = demoDiff(sessionID); return }
guard capabilities.canFetchDiff else { return }
diffLoading = openDiff == nil
send(.fetchDiff(sessionID))
}
/// Record that the user looked at this session now (clears its unseen-completion wash).
func markOpened(_ sessionID: SessionID) {
lastOpenedAt[sessionID] = Date()
@@ -287,6 +341,8 @@ final class RemoteStore: ObservableObject {
openSessionID = nil
openEvents = []
openApprovals = []
openDiff = nil
diffLoading = false
}
func respond(_ approval: ApprovalRequest, _ decision: Decision) {
@@ -418,6 +474,10 @@ final class RemoteStore: ObservableObject {
}
case .approvalResolved(let resolved):
openApprovals.removeAll { $0.id == resolved.id }
case .sessionDiff(let diff):
guard diff.sessionID == openSessionID else { break }
openDiff = diff
diffLoading = false
case .wireError(let error):
// Not fatal — surface as a transient bubble (the Mac's last-error overlay).
// Losing an approval race isn't an error worth interrupting for; the card
@@ -138,6 +138,7 @@ struct QuickTodos: View {
}.buttonStyle(.plain)
Text(todo.summary?.isEmpty == false ? todo.summary! : todo.text)
.font(.subheadline).lineLimit(1)
if let triage = todo.triage { TriageBadge(rawLevel: triage) }
Spacer()
if let name = todo.projectName {
Text(name).font(.caption2).foregroundStyle(.secondary)
@@ -54,11 +54,19 @@ struct HomeView: View {
Text(subtitle).font(.subheadline).foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 8) {
if streak.streak > 0 { StreakBadge(days: streak.streak, freezes: streak.availableFreezes) }
// The Mac's service-status pill, fed by the dashboard projection.
StatusFeedPill(feeds: store.dashboard.statusFeeds)
}
}
statCards
// The Mac's usage gauges (5-hour / weekly windows); hidden when the host
// doesn't project usage.
QuotaCard(usage: store.dashboard.usage)
VStack(alignment: .leading, spacing: 10) {
Text("Activity").font(.headline)
ActivityGrid(
@@ -6,7 +6,9 @@ import NucleicProtocol
struct SessionDetailView: View {
@EnvironmentObject var store: RemoteStore
let sessionID: SessionID
@State private var tab = 0
// Initial tab (0 = Transcript, 1 = Diff); overridable via NUCLEIC_DETAIL_TAB for
// offline UI previews, matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION.
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] ?? "") ?? 0
@State private var draft = ""
@State private var showRename = false
@State private var renameDraft = ""
@@ -33,7 +35,11 @@ struct SessionDetailView: View {
if tab == 0 {
TranscriptList(events: store.openEvents)
} else if store.capabilities.canFetchDiff || store.openDiff != nil {
SessionDiffView(diff: store.openDiff, loading: store.diffLoading)
.onAppear { store.fetchDiff(sessionID) }
} else {
// Older host with no on-demand diff — the stat summary is all it projects.
DiffSummaryView(diffStat: summary?.diffStat)
}
@@ -304,6 +310,112 @@ struct TranscriptList: View {
}
}
/// The Diff tab with the real patch (the Mac Diff tab, phone form): a per-file summary list
/// over the unified patch, colored +/− per line, fetched on demand via `fetchDiff`.
struct SessionDiffView: View {
let diff: WireSessionDiff?
let loading: Bool
var body: some View {
if let diff, diff.stat.filesChanged > 0 {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
fileList(diff)
PatchText(patch: diff.patch)
if diff.truncated {
Label("Patch truncated — open the Mac for the rest.",
systemImage: "scissors")
.font(.caption).foregroundStyle(.secondary)
}
}
.padding()
}
} else if loading {
ProgressView("Fetching diff…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ContentUnavailableView("No changes yet", systemImage: "doc")
}
}
private func fileList(_ diff: WireSessionDiff) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("\(diff.stat.filesChanged) file\(diff.stat.filesChanged == 1 ? "" : "s") changed · +\(diff.stat.added) −\(diff.stat.removed)")
.font(.subheadline.weight(.semibold))
ForEach(diff.files) { file in
HStack(spacing: 8) {
Image(systemName: statusIcon(file.status))
.font(.caption)
.foregroundStyle(statusColor(file.status))
Text(file.path)
.font(.caption.monospaced())
.lineLimit(1).truncationMode(.middle)
Spacer(minLength: 8)
Text("+\(file.added)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.success)
Text("−\(file.removed)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.danger)
}
}
}
.card()
}
private func statusIcon(_ status: String) -> String {
switch status {
case "added", "untracked": "plus.circle"
case "deleted": "minus.circle"
case "renamed": "arrow.right.circle"
default: "pencil.circle"
}
}
private func statusColor(_ status: String) -> Color {
switch status {
case "added", "untracked": Palette.success
case "deleted": Palette.danger
default: .secondary
}
}
}
/// The unified patch, one `Text` per line with the classic +/− coloring. Lines scroll
/// horizontally as a block so long lines don't wrap into noise.
private struct PatchText: View {
let patch: String
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(patch.components(separatedBy: "\n").enumerated()), id: \.offset) { _, line in
Text(line.isEmpty ? " " : line)
.font(.caption.monospaced())
.foregroundStyle(lineColor(line))
.frame(maxWidth: .infinity, alignment: .leading)
.background(lineBackground(line))
}
}
.textSelection(.enabled)
.padding(8)
}
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
}
private func lineColor(_ line: String) -> Color {
if line.hasPrefix("+++") || line.hasPrefix("---") { return .secondary }
if line.hasPrefix("+") { return Palette.success }
if line.hasPrefix("-") { return Palette.danger }
if line.hasPrefix("@@") { return Palette.active }
if line.hasPrefix("diff ") { return .secondary }
return Palette.primaryText
}
private func lineBackground(_ line: String) -> Color {
if line.hasPrefix("+++") || line.hasPrefix("---") { return .clear }
if line.hasPrefix("+") { return Palette.success.opacity(0.10) }
if line.hasPrefix("-") { return Palette.danger.opacity(0.10) }
return .clear
}
}
struct DiffSummaryView: View {
let diffStat: DiffStat?
@@ -0,0 +1,191 @@
import SwiftUI
import NucleicProtocol
/// The Mac's usage gauges and provider-status pill, rendered from the dashboard's wire
/// projections (`DashboardSnapshot.usage` / `.statusFeeds`) — same thresholds, icons, and
/// copy as `QuotaIndicator` / `StatusFeedIndicator` on desktop.
// MARK: - Shared formatting (mirrors the Mac's QuotaFormat)
enum QuotaFormat {
static let fiveHourIcon = "clock"
static let weeklyIcon = "calendar"
static func percent(_ value: Double) -> String { "\(Int(value.rounded()))%" }
/// "6d 23h" / "2h 14m" / "14m" — the Mac's countdown formatting.
static func duration(_ interval: TimeInterval) -> String {
let total = Int(interval)
let days = total / 86_400
let hours = (total % 86_400) / 3600
let minutes = (total % 3600) / 60
if days > 0 { return "\(days)d \(hours)h" }
if hours > 0 { return "\(hours)h \(minutes)m" }
if minutes > 0 { return "\(minutes)m" }
return "<1m"
}
static func resetCaption(_ window: WireUsageWindow, now: Date) -> String? {
guard let resetsAt = window.resetsAt else { return nil }
let remaining = resetsAt.timeIntervalSince(now)
guard remaining > 0 else { return "resetting…" }
return "resets in \(duration(remaining))"
}
/// Calm until 75%, amber to 90%, red beyond — the Mac's escalation.
static func color(forPercent value: Double) -> Color {
switch value {
case ..<75: return Palette.accent
case ..<90: return Palette.attention
default: return Palette.danger
}
}
}
// MARK: - Quota card (the Mac's QuotaCard, dashboard form)
/// Labeled progress bars for the 5-hour and weekly windows with reset countdowns.
/// Renders nothing when the host hasn't projected usage (older host, logged out) so the
/// home screen doesn't carry a permanently empty card.
struct QuotaCard: View {
let usage: WireSubscriptionUsage?
var body: some View {
if let usage, usage.peakUtilization != nil {
TimelineView(.periodic(from: .now, by: 30)) { context in
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "gauge.with.dots.needle.67percent").foregroundStyle(.secondary)
Text("Usage").font(.headline)
}
windowRow("5-hour limit", icon: QuotaFormat.fiveHourIcon, usage.fiveHour, now: context.date)
windowRow("Weekly limit", icon: QuotaFormat.weeklyIcon, usage.sevenDay, now: context.date)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.card()
}
}
@ViewBuilder
private func windowRow(_ label: String, icon: String, _ window: WireUsageWindow?, now: Date) -> some View {
if let window {
let utilization = window.utilization(at: now)
let color = QuotaFormat.color(forPercent: utilization)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 5) {
Image(systemName: icon).font(.caption).foregroundStyle(.secondary)
Text(label).font(.subheadline)
Spacer()
Text(QuotaFormat.percent(utilization))
.font(.subheadline.monospacedDigit().weight(.semibold))
.foregroundStyle(color)
}
bar(fraction: utilization / 100, color: color)
if let caption = QuotaFormat.resetCaption(window, now: now) {
Text(caption).font(.caption2).foregroundStyle(.secondary)
}
}
}
}
private func bar(fraction: Double, color: Color) -> some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.18))
Capsule().fill(color)
.frame(width: max(3, geo.size.width * min(1, max(0, fraction))))
}
}
.frame(height: 6)
}
}
// MARK: - Status pill (the Mac's StatusFeedIndicator)
/// A compact service-status pill: a calm green check while every watched provider is
/// operational, amber naming the affected providers when any has an active incident.
/// Tapping opens a sheet listing current incidents with links to the status pages.
/// Renders nothing before the host projects any feed (older host / first poll pending).
struct StatusFeedPill: View {
let feeds: [WireStatusFeed]
@State private var showingSheet = false
private var active: [WireStatusIncident] { feeds.flatMap(\.activeIncidents) }
private var affected: [String] { feeds.filter(\.hasActiveIncident).map(\.providerName) }
var body: some View {
if !feeds.isEmpty {
Button { showingSheet = true } label: {
if active.isEmpty {
Label("Operational", systemImage: "checkmark.circle.fill")
.foregroundStyle(Palette.success)
} else {
Label(affected.joined(separator: ", "), systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(Palette.attention)
}
}
.buttonStyle(.plain)
.font(.caption)
.sheet(isPresented: $showingSheet) { StatusFeedSheet(feeds: feeds) }
}
}
}
/// The pill's detail: current incidents grouped by provider (the Mac popover, sheet-form).
private struct StatusFeedSheet: View {
let feeds: [WireStatusFeed]
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
ForEach(feeds) { feed in
Section(feed.providerName) {
if feed.activeIncidents.isEmpty {
Label("Operational", systemImage: "checkmark.circle.fill")
.foregroundStyle(Palette.success)
.font(.subheadline)
} else {
ForEach(feed.activeIncidents) { incident in
incidentRow(incident)
}
}
}
}
}
.navigationTitle("Service status")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
@ViewBuilder
private func incidentRow(_ incident: WireStatusIncident) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(incident.title).font(.subheadline.weight(.medium))
HStack(spacing: 6) {
if let state = incident.state {
Text(state).font(.caption).foregroundStyle(Palette.attention)
}
if !incident.components.isEmpty {
Text(incident.components.joined(separator: " · "))
.font(.caption).foregroundStyle(.secondary)
}
if let updated = incident.updatedAt {
Text(updated.formatted(date: .omitted, time: .shortened))
.font(.caption).foregroundStyle(.tertiary)
}
}
if let url = incident.url {
Link("Status page", destination: url).font(.caption)
}
}
.padding(.vertical, 2)
}
}
@@ -65,10 +65,15 @@ private struct TodoRow: View {
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
}
@@ -99,3 +104,56 @@ private struct TodoRow: View {
}
}
}
/// 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()
}
}
}