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:
@@ -49,6 +49,10 @@ final class RemoteStore: ObservableObject {
|
|||||||
@Published private(set) var openSessionID: SessionID?
|
@Published private(set) var openSessionID: SessionID?
|
||||||
@Published private(set) var openEvents: [AgentEvent] = []
|
@Published private(set) var openEvents: [AgentEvent] = []
|
||||||
@Published private(set) var openApprovals: [ApprovalRequest] = []
|
@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):
|
/// 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.
|
/// 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
|
connectivity = .connected
|
||||||
hostName = "Andrew's Mac"
|
hostName = "Andrew's Mac"
|
||||||
grantedScope = .control
|
grantedScope = .control
|
||||||
capabilities = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName])
|
capabilities = WireCapabilities(
|
||||||
|
canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName], canFetchDiff: true)
|
||||||
modelCatalog = WireModelCatalog(
|
modelCatalog = WireModelCatalog(
|
||||||
groups: [
|
groups: [
|
||||||
[WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode,
|
[WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode,
|
||||||
@@ -179,14 +184,52 @@ final class RemoteStore: ObservableObject {
|
|||||||
],
|
],
|
||||||
todos: [
|
todos: [
|
||||||
WireTodo(id: TodoID(rawValue: "t1"), text: "Add dark mode to settings", summary: "Dark mode in settings",
|
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",
|
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,
|
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,
|
/// 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.
|
/// and on success pin the host key for future IK reconnects.
|
||||||
func pair(with payload: PairingPayload) {
|
func pair(with payload: PairingPayload) {
|
||||||
@@ -240,12 +283,23 @@ final class RemoteStore: ObservableObject {
|
|||||||
openSessionID = sessionID
|
openSessionID = sessionID
|
||||||
openEvents = []
|
openEvents = []
|
||||||
openApprovals = []
|
openApprovals = []
|
||||||
|
openDiff = nil
|
||||||
|
diffLoading = false
|
||||||
seenSeq.removeAll()
|
seenSeq.removeAll()
|
||||||
markOpened(sessionID)
|
markOpened(sessionID)
|
||||||
if demoMode { seedDemoTranscript(sessionID); return }
|
if demoMode { seedDemoTranscript(sessionID); return }
|
||||||
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
|
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).
|
/// Record that the user looked at this session now (clears its unseen-completion wash).
|
||||||
func markOpened(_ sessionID: SessionID) {
|
func markOpened(_ sessionID: SessionID) {
|
||||||
lastOpenedAt[sessionID] = Date()
|
lastOpenedAt[sessionID] = Date()
|
||||||
@@ -287,6 +341,8 @@ final class RemoteStore: ObservableObject {
|
|||||||
openSessionID = nil
|
openSessionID = nil
|
||||||
openEvents = []
|
openEvents = []
|
||||||
openApprovals = []
|
openApprovals = []
|
||||||
|
openDiff = nil
|
||||||
|
diffLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func respond(_ approval: ApprovalRequest, _ decision: Decision) {
|
func respond(_ approval: ApprovalRequest, _ decision: Decision) {
|
||||||
@@ -418,6 +474,10 @@ final class RemoteStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
case .approvalResolved(let resolved):
|
case .approvalResolved(let resolved):
|
||||||
openApprovals.removeAll { $0.id == resolved.id }
|
openApprovals.removeAll { $0.id == resolved.id }
|
||||||
|
case .sessionDiff(let diff):
|
||||||
|
guard diff.sessionID == openSessionID else { break }
|
||||||
|
openDiff = diff
|
||||||
|
diffLoading = false
|
||||||
case .wireError(let error):
|
case .wireError(let error):
|
||||||
// Not fatal — surface as a transient bubble (the Mac's last-error overlay).
|
// 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
|
// Losing an approval race isn't an error worth interrupting for; the card
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ struct QuickTodos: View {
|
|||||||
}.buttonStyle(.plain)
|
}.buttonStyle(.plain)
|
||||||
Text(todo.summary?.isEmpty == false ? todo.summary! : todo.text)
|
Text(todo.summary?.isEmpty == false ? todo.summary! : todo.text)
|
||||||
.font(.subheadline).lineLimit(1)
|
.font(.subheadline).lineLimit(1)
|
||||||
|
if let triage = todo.triage { TriageBadge(rawLevel: triage) }
|
||||||
Spacer()
|
Spacer()
|
||||||
if let name = todo.projectName {
|
if let name = todo.projectName {
|
||||||
Text(name).font(.caption2).foregroundStyle(.secondary)
|
Text(name).font(.caption2).foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -54,11 +54,19 @@ struct HomeView: View {
|
|||||||
Text(subtitle).font(.subheadline).foregroundStyle(.secondary)
|
Text(subtitle).font(.subheadline).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if streak.streak > 0 { StreakBadge(days: streak.streak, freezes: streak.availableFreezes) }
|
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
|
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) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Activity").font(.headline)
|
Text("Activity").font(.headline)
|
||||||
ActivityGrid(
|
ActivityGrid(
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import NucleicProtocol
|
|||||||
struct SessionDetailView: View {
|
struct SessionDetailView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
let sessionID: SessionID
|
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 draft = ""
|
||||||
@State private var showRename = false
|
@State private var showRename = false
|
||||||
@State private var renameDraft = ""
|
@State private var renameDraft = ""
|
||||||
@@ -33,7 +35,11 @@ struct SessionDetailView: View {
|
|||||||
|
|
||||||
if tab == 0 {
|
if tab == 0 {
|
||||||
TranscriptList(events: store.openEvents)
|
TranscriptList(events: store.openEvents)
|
||||||
|
} else if store.capabilities.canFetchDiff || store.openDiff != nil {
|
||||||
|
SessionDiffView(diff: store.openDiff, loading: store.diffLoading)
|
||||||
|
.onAppear { store.fetchDiff(sessionID) }
|
||||||
} else {
|
} else {
|
||||||
|
// Older host with no on-demand diff — the stat summary is all it projects.
|
||||||
DiffSummaryView(diffStat: summary?.diffStat)
|
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 {
|
struct DiffSummaryView: View {
|
||||||
let diffStat: DiffStat?
|
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,8 +65,13 @@ private struct TodoRow: View {
|
|||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(todo.summary?.isEmpty == false ? todo.summary! : todo.text)
|
Text(todo.summary?.isEmpty == false ? todo.summary! : todo.text)
|
||||||
.font(.subheadline).lineLimit(2)
|
.font(.subheadline).lineLimit(2)
|
||||||
if todo.status == .dispatched {
|
HStack(spacing: 6) {
|
||||||
Text("Dispatched").font(.caption2).foregroundStyle(Palette.active)
|
if let triage = todo.triage {
|
||||||
|
TriageBadge(rawLevel: triage)
|
||||||
|
}
|
||||||
|
if todo.status == .dispatched {
|
||||||
|
Text("Dispatched").font(.caption2).foregroundStyle(Palette.active)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user