diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index cec5d59..0496521 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -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 diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index b7897a7..8d8d5f0 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -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) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 773b729..acddf44 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -54,11 +54,19 @@ struct HomeView: View { Text(subtitle).font(.subheadline).foregroundStyle(.secondary) } 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 + // 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( diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index ea38e32..64473d3 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -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? diff --git a/NucleicRemote/NucleicRemote/Views/StatusAndQuota.swift b/NucleicRemote/NucleicRemote/Views/StatusAndQuota.swift new file mode 100644 index 0000000..a73f198 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/StatusAndQuota.swift @@ -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) + } +} diff --git a/NucleicRemote/NucleicRemote/Views/TodosView.swift b/NucleicRemote/NucleicRemote/Views/TodosView.swift index 2635025..7ebfa94 100644 --- a/NucleicRemote/NucleicRemote/Views/TodosView.swift +++ b/NucleicRemote/NucleicRemote/Views/TodosView.swift @@ -65,8 +65,13 @@ private struct TodoRow: View { VStack(alignment: .leading, spacing: 2) { Text(todo.summary?.isEmpty == false ? todo.summary! : todo.text) .font(.subheadline).lineLimit(2) - if todo.status == .dispatched { - Text("Dispatched").font(.caption2).foregroundStyle(Palette.active) + HStack(spacing: 6) { + if let triage = todo.triage { + TriageBadge(rawLevel: triage) + } + if todo.status == .dispatched { + Text("Dispatched").font(.caption2).foregroundStyle(Palette.active) + } } } 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() + } + } +}