From 954eafebcd4267bedd7c1df83eb6afd7ff6bbb2b Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 12 Jul 2026 05:39:59 -0700 Subject: [PATCH] Merge nucleic/gentle-slate-seal into dev --- .../NucleicRemote/Models/RemoteStore.swift | 21 ++ .../Views/ActivityFeedView.swift | 10 +- .../NucleicRemote/Views/MeshInfoView.swift | 180 ++++++++++++++++++ .../NucleicRemote/Views/SettingsView.swift | 7 + 4 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 NucleicRemote/NucleicRemote/Views/MeshInfoView.swift diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 1dbac35..ee74360 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -90,6 +90,27 @@ final class RemoteStore: ObservableObject { .compactMapValues { RunnerPresence.decode($0) } } + // MARK: Mesh info (read-only projections for the Mesh Info screen) + + /// Live connectivity for a directly-paired Mac, keyed by its `PairedHost.fingerprint` — the + /// same id `connections` is keyed by. `nil` when no connection object exists for it yet. + func connectivity(forPairedHost fingerprint: String) -> Connectivity? { + connections[fingerprint]?.connectivity + } + + /// The mesh's runner-presence cards (Macs / cloud runners advertising capacity), sorted by + /// host name — the phone's window into the Carbon dispatch candidate pool. Keyed by the full + /// `deviceID` (distinct from `PairedHost.fingerprint`), so it's presented as its own section + /// rather than cross-matched against the directly-paired Macs. + var meshRunnerCards: [(hostID: String, presence: RunnerPresence)] { + runnerPresenceByHost + .map { (hostID: $0.key, presence: $0.value) } + .sorted { + $0.presence.hostName.localizedCaseInsensitiveCompare($1.presence.hostName) + == .orderedAscending + } + } + /// Whether the composer should offer "Auto (Mesh)" for the project the phone has selected: /// some connected host advertising `canAcknowledgeDispatch` (other than the project's own /// owner) holds a matching repo. The descriptor comes from the owning host's presence card. diff --git a/NucleicRemote/NucleicRemote/Views/ActivityFeedView.swift b/NucleicRemote/NucleicRemote/Views/ActivityFeedView.swift index f3d0a75..2dd8da9 100644 --- a/NucleicRemote/NucleicRemote/Views/ActivityFeedView.swift +++ b/NucleicRemote/NucleicRemote/Views/ActivityFeedView.swift @@ -76,8 +76,8 @@ private struct ActivityFeedRow: View { store.route(to: event.sessionID) } label: { HStack(alignment: .top, spacing: 10) { - Image(systemName: Self.icon(for: event.kind)) - .foregroundStyle(Self.tint(for: event.kind)) + Image(systemName: ActivityFeedIcon.icon(for: event.kind)) + .foregroundStyle(ActivityFeedIcon.tint(for: event.kind)) .font(.subheadline) .frame(width: 20) .padding(.top, 2) @@ -105,12 +105,16 @@ private struct ActivityFeedRow: View { } private var subtitle: String { - var parts: [String] = [Self.headline(for: event)] + var parts: [String] = [ActivityFeedIcon.headline(for: event)] if !event.projectName.isEmpty { parts.append(event.projectName) } if !event.hostName.isEmpty { parts.append(event.hostName) } return parts.joined(separator: " · ") } +} +/// The shared icon / tint / headline vocabulary for a fleet-activity `FeedEvent`, used by both the +/// activity feed rows and the Mesh Info screen's event log so they read identically. +enum ActivityFeedIcon { static func headline(for event: FeedEvent) -> String { switch event.kind { case .finished: "Finished" diff --git a/NucleicRemote/NucleicRemote/Views/MeshInfoView.swift b/NucleicRemote/NucleicRemote/Views/MeshInfoView.swift new file mode 100644 index 0000000..8f40b43 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/MeshInfoView.swift @@ -0,0 +1,180 @@ +import SwiftUI +import NucleicProtocol + +/// The phone's **Mesh Info** screen — the iOS analogue of the Mac's Carbon Mesh Viewer, reached +/// from Settings ▸ Mesh ▸ "Mesh info". A read-only diagnostics surface over the mesh as this phone +/// sees it: overall connection health, the Macs this phone is directly paired with (live status + +/// transport), the mesh's runner-presence cards (the Carbon dispatch candidate pool with capacity / +/// projects), and a live tail of fleet activity. Structural metadata only — never prompt/draft +/// content, matching the Mac viewer's redaction invariant. +/// +/// The phone links only `NucleicProtocol`, not the Mac's mesh engine, so this renders from +/// `RemoteStore` projections (`connectivity`, `meshRunnerCards`, `activityFeed`) rather than the +/// Mac's `MeshDiagnosticsLog`. Two id namespaces are in play — directly-paired Macs are keyed by +/// `PairedHost.fingerprint`, presence cards by the full `deviceID` — so they're shown as separate +/// sections instead of being cross-matched. +struct MeshInfoView: View { + @EnvironmentObject var store: RemoteStore + + private var pairedHosts: [PairedHost] { IdentityStore.pairedHosts() } + private var runnerCards: [(hostID: String, presence: RunnerPresence)] { store.meshRunnerCards } + + var body: some View { + Form { + statusSection + yourMacsSection + runnerCardsSection + activitySection + } + .navigationTitle("Mesh info") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: Status + + @ViewBuilder + private var statusSection: some View { + Section { + LabeledContent("Connection", value: store.connectivity.label) + LabeledContent("Paired Macs", value: "\(pairedHosts.count)") + if !runnerCards.isEmpty { + LabeledContent("Runner cards", value: "\(runnerCards.count)") + } + } header: { + Text("Status") + } footer: { + Text("The mesh as this device sees it. Read-only — structural metadata only.") + } + } + + // MARK: Your Macs + + @ViewBuilder + private var yourMacsSection: some View { + if !pairedHosts.isEmpty { + Section("Your Macs") { + ForEach(pairedHosts, id: \.fingerprint) { host in + let connectivity = store.connectivity(forPairedHost: host.fingerprint) + HStack(spacing: 10) { + statusDot(online: connectivity?.isLive == true) + Image(systemName: "desktopcomputer").foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(host.hostName.isEmpty ? "Mac" : host.hostName) + Text(connectivity?.label ?? "Not connected") + .font(.footnote).foregroundStyle(.secondary) + } + Spacer() + } + } + } + } + } + + // MARK: Runner presence cards + + @ViewBuilder + private var runnerCardsSection: some View { + if !runnerCards.isEmpty { + Section { + ForEach(runnerCards, id: \.hostID) { card in + RunnerCardRow(presence: card.presence) + } + } header: { + Text("Runner capacity") + } footer: { + Text("Hosts advertising capacity for Carbon-dispatched chats, and the projects " + + "they hold.") + } + } + } + + // MARK: Recent activity + + @ViewBuilder + private var activitySection: some View { + Section("Recent mesh activity") { + if store.activityFeed.isEmpty { + Text("No mesh events yet.") + .font(.footnote).foregroundStyle(.secondary) + } else { + // Newest first, capped — this is a diagnostics tail, not the full feed. + ForEach(Array(store.activityFeed.reversed().prefix(50))) { item in + MeshEventRow(item: item) + } + } + } + } + + // MARK: Bits + + private func statusDot(online: Bool) -> some View { + Circle() + .fill(online ? Color.green : Color.secondary.opacity(0.4)) + .frame(width: 8, height: 8) + } +} + +/// One runner-presence card: host name + kind, load (active / capacity), and its held projects. +private struct RunnerCardRow: View { + let presence: RunnerPresence + + private var capacityLabel: String { + let cap = presence.maxConcurrent.map(String.init) ?? "∞" + return "\(presence.activeSessionCount)/\(cap)" + } + + var body: some View { + HStack(spacing: 10) { + Image(systemName: presence.kind == .cloud ? "cloud" : "desktopcomputer") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(presence.hostName.isEmpty ? "Host" : presence.hostName) + Text("\(presence.projects.count) project(s)" + + (presence.acceptsMeshDispatch ? " · accepts dispatch" : " · dispatch off")) + .font(.footnote).foregroundStyle(.secondary) + } + Spacer() + Text(capacityLabel) + .font(.footnote.monospacedDigit()) + .foregroundStyle(.secondary) + } + } +} + +/// One mesh event row (a fleet-activity `FeedEvent`) — the phone's equivalent of the Mac viewer's +/// event-log rows. Reuses the activity feed's icon/tint/headline vocabulary. +private struct MeshEventRow: View { + let item: ActivityFeedItem + private var event: FeedEvent { item.event } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: ActivityFeedIcon.icon(for: event.kind)) + .foregroundStyle(ActivityFeedIcon.tint(for: event.kind)) + .font(.subheadline) + .frame(width: 20) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(event.sessionTitle.isEmpty ? "Untitled chat" : event.sessionTitle) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + Spacer(minLength: 4) + Text(event.at, format: .relative(presentation: .named)) + .font(.caption2).foregroundStyle(.tertiary) + } + Text(subtitle) + .font(.caption).foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(.vertical, 2) + } + + private var subtitle: String { + var parts: [String] = [ActivityFeedIcon.headline(for: event)] + if !event.projectName.isEmpty { parts.append(event.projectName) } + if !event.hostName.isEmpty { parts.append(event.hostName) } + return parts.joined(separator: " · ") + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 8616d2c..25290c5 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -71,6 +71,13 @@ struct SettingsView: View { let pairedHosts = IdentityStore.pairedHosts() if !pairedHosts.isEmpty { Section("Mesh") { + // A read-only diagnostics view of the mesh as this phone sees it — the + // iOS analogue of the Mac's Carbon Mesh Viewer. + NavigationLink { + MeshInfoView() + } label: { + Label("Mesh info", systemImage: "point.3.connected.trianglepath.dotted") + } ForEach(pairedHosts, id: \.fingerprint) { host in HStack(spacing: 10) { Image(systemName: "desktopcomputer").foregroundStyle(.secondary)