diff --git a/NucleicRemote/NucleicRemote/Models/CastCache.swift b/NucleicRemote/NucleicRemote/Models/CastCache.swift new file mode 100644 index 0000000..05d6450 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Models/CastCache.swift @@ -0,0 +1,47 @@ +import Foundation +import NucleicProtocol + +/// The on-device snapshot of the phone's mesh-cast ledger (`CastLedger`) — the activity feed, +/// mesh messages, and runner-presence cards it has received, plus (implicitly) the cursors a +/// reconnect subscribes with. Like `SessionCache`, a *projection cache*, never canonical: a +/// live host always supersedes it; it exists so a relaunch renders the feed instantly and +/// resubscribes O(gap) instead of re-pulling every channel from zero. +/// +/// One JSON file under Application Support (`NucleicCastCache/casts.json`) — the ledger's +/// per-channel retention already bounds it. Reads/writes hop onto a utility `Task.detached`. +enum CastCache { + private static var root: URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + else { return nil } + return base.appendingPathComponent("NucleicCastCache", isDirectory: true) + } + private static var castsURL: URL? { root?.appendingPathComponent("casts.json") } + + /// Load the persisted ledger — called once at launch, before any host connects. The ledger's + /// init re-applies retention, so a policy change between builds re-caps on load. + static func loadLedger() -> CastLedger { + guard let url = castsURL, let data = try? Data(contentsOf: url), + let casts = try? JSONDecoder().decode([WireCast].self, from: data) + else { return CastLedger() } + return CastLedger(casts: casts) + } + + /// Persist the ledger's snapshot. Off the main actor; callers debounce bursts. + static func save(_ ledger: CastLedger) async { + let casts = ledger.allCasts + await Task.detached(priority: .utility) { + guard let root, let url = castsURL else { return } + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + if let data = try? JSONEncoder().encode(casts) { + try? data.write(to: url, options: .atomic) + } + }.value + } + + /// Drop the cache — on full unpair (no Mac left whose casts this device should hold). + static func clear() { + guard let root else { return } + try? FileManager.default.removeItem(at: root) + } +} diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index c9eff82..5e1a7d3 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -116,6 +116,17 @@ final class HostConnection { /// The outcome of a `createProject` this phone sent (CLOUD_RUNTIME §4.3), correlated by /// requestID — settle the Add Project sheet (the project row rides the dashboard push). var projectCreated: (WireProjectCreated) -> Void = { _ in } + /// The cursors this connection's `castSubscribe` carries (mesh casting) — the merged + /// ledger's tips across EVERY origin, so any connected Mac tops up any origin's gap. + var castCursors: () -> [CastCursor] = { [] } + /// A live batch of mesh casts from this host — RemoteStore applies it to the merged + /// ledger (identity dedup absorbs the same cast arriving via N Macs). + var castsReceived: ([WireCast]) -> Void = { _ in } + /// One catch-up batch for one (origin, channel) of the cast feed. + var castCatchUp: (CastCatchUp) -> Void = { _ in } + /// A mesh-dispatch ack from this host (mesh dispatch) — RemoteStore correlates it by + /// `requestID` to resume the waiting `dispatchChatToMesh`. + var chatStarted: (WireChatStarted) -> Void = { _ in } } private let callbacks: Callbacks @@ -396,13 +407,15 @@ final class HostConnection { releaseChannel: BuildInfo.current.channel.releaseChannel, // Mesh "join": advertise roster gossip so a host pushes its group view — the phone // then auto-learns and connects to every Mac in the mesh, not just the one it scanned. - // Also offer this phone as a low-tier AFM executor (ANTIMATTER_RUNNER §5) when the + // Also offer this phone as a low-tier AFM executor (CARBON_RUNNER §5) when the // OS has Foundation Models — a runner host's mesh queue may place background work // here; the interactive tiers stay on desktop-class devices by the queue's rules. clientCaps: WireClientCapabilities( mesh: 1, canSyncRoster: true, canProvideIntelligence: PhoneIntelligenceExecutor.isSupported, - intelligenceProfile: PhoneIntelligenceExecutor.profile)) + intelligenceProfile: PhoneIntelligenceExecutor.profile, + // Mesh casting: receive the activity feed / messages / runner presence. + canCast: true)) self.client = client consume(client, pairingPayload: pairingPayload) } @@ -504,6 +517,12 @@ final class HostConnection { callbacks.didUpdate() send(.listSessions) send(.listDashboard) + // Mesh casting: subscribe with the merged ledger's cursors (every origin we hold) — + // this host serves the gaps for its own casts AND any peer's casts it mirrors, so a + // phone converges even when a cast's origin is offline. + if welcome.capabilities.canCast { + send(.castSubscribe(CastSubscribe(cursors: callbacks.castCursors()))) + } // Warm-resubscribe from what we already have, so a reconnect on a long transcript replays // only the gap rather than snapping back to the host's 200-event tail. if let id = openSessionID { @@ -654,18 +673,28 @@ final class HostConnection { // resolves (success or failure). Correlation by requestID happens in RemoteStore. callbacks.projectCreated(outcome) case .intelligenceRequest(let request): - // A runner host delegated one AFM job here (docs/ANTIMATTER_RUNNER.md §5) — it only + // A runner host delegated one AFM job here (docs/CARBON_RUNNER.md §5) — it only // ever sends these after this app advertised `canProvideIntelligence`. Generate off // the event stream (a model call takes seconds) and answer with the same id. Task { [weak self] in let result = await PhoneIntelligenceExecutor.execute(request) self?.send(.intelligenceResult(result)) } + case .chatStarted(let outcome): + // Mesh-dispatch ack — up to RemoteStore's requestID correlation map (the "Auto + // (Mesh)" composer destination awaits it). + callbacks.chatStarted(outcome) + case .casts(let batch): + // Live mesh casts — up to the merged ledger (identity dedup across N connections). + callbacks.castsReceived(batch.casts) + case .castCatchUp(let catchUp): + // One catch-up batch for one (origin, channel) — `reset` handling lives in the ledger. + callbacks.castCatchUp(catchUp) case .credentialNeeded, .credentialUpdate, // The owner's runner-pool credential (item 4) — inert until the phone grows a // pool-management surface; Macs are the managers today. .runnerPoolCredential: - // Antimatter runner credential verbs (docs/ANTIMATTER_RUNNER.md §6): a runner host + // Carbon runner credential verbs (docs/CARBON_RUNNER.md §6): a runner host // asking for / mirroring sealed credentials. Inert here until the phone-side vault // lands — and a host only sends these to clients that advertised the matching // `WireClientCapabilities`, which this app doesn't yet. diff --git a/NucleicRemote/NucleicRemote/Models/PhoneIntelligenceExecutor.swift b/NucleicRemote/NucleicRemote/Models/PhoneIntelligenceExecutor.swift index 1917e4b..ce4b24e 100644 --- a/NucleicRemote/NucleicRemote/Models/PhoneIntelligenceExecutor.swift +++ b/NucleicRemote/NucleicRemote/Models/PhoneIntelligenceExecutor.swift @@ -4,7 +4,7 @@ import NucleicProtocol import FoundationModels #endif -/// The phone-side executor for delegated intelligence work (docs/ANTIMATTER_RUNNER.md §5, +/// The phone-side executor for delegated intelligence work (docs/CARBON_RUNNER.md §5, /// item 6): a runner host pushes `HostMsg.intelligenceRequest` at this device — the mesh AFM /// queue only ever sends it the lower tiers (`completion`/`background`), which don't need /// desktop tok/s — and this renders the shared `IntelligenceDelegate` template on the local diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 7841000..92821b0 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -58,6 +58,150 @@ final class RemoteStore: ObservableObject { private var cachedSummaries: [WireSessionSummary] = SessionCache.loadSummaries() /// Debounced disk-write tasks for the offline cache (coalesce a burst of updates into one write). private var summaryPersistTask: Task? + + /// The merged mesh-cast ledger (mesh casting) — the activity feed / messages / runner + /// presence from every origin, deduped on cast identity across all connected Macs. Seeded + /// from disk at launch (renders offline, and its tips are the reconnect cursors), persisted + /// debounced on change. Feed/dispatch surfaces read projections of this. + @Published private(set) var castLedger: CastLedger = CastCache.loadLedger() + private var castPersistTask: Task? + + /// The merged fleet feed, oldest-first — the "activity" channel decoded across every origin. + /// Seeded from the ledger on appear (renders offline), appended by `applyCasts`. + @Published private(set) var activityFeed: [ActivityFeedItem] = [] + /// When the user last viewed the feed — rows newer than this count as unread. + @Published private(set) var feedLastSeenAt: Date = + UserDefaults.standard.object(forKey: "nucleic.feed.lastSeenAt") as? Date ?? .distantPast + var feedUnreadCount: Int { + activityFeed.reduce(0) { $0 + ($1.event.at > feedLastSeenAt ? 1 : 0) } + } + func markFeedSeen() { + feedLastSeenAt = Date() + UserDefaults.standard.set(feedLastSeenAt, forKey: "nucleic.feed.lastSeenAt") + } + + /// In-flight mesh dispatches awaiting their `chatStarted` ack, keyed by requestID. + private var chatStartedWaiters: [String: CheckedContinuation] = [:] + + /// The latest runner-presence card per host (mesh dispatch), decoded from the merged cast + /// ledger's `runner.presence` state channel — the dispatcher's candidate pool. + private var runnerPresenceByHost: [String: RunnerPresence] { + castLedger.stateValues(channel: MeshCastChannel.runnerPresence) + .compactMapValues { RunnerPresence.decode($0) } + } + + /// 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. + func meshDispatchAvailable(forProject projectID: ProjectID) -> Bool { + meshDispatchCandidates(forProject: projectID) != nil + } + + /// Resolve the (descriptor, candidates) for a mesh dispatch, or nil when it isn't offerable. + private func meshDispatchCandidates( + forProject projectID: ProjectID + ) -> (descriptor: ProjectDescriptor, candidates: [MeshDispatchScorer.Candidate])? { + let presences = runnerPresenceByHost + // The chosen project belongs to one host; find its descriptor from that host's card. + guard let owner = connection(owningProject: projectID)?.hostID, + let descriptor = presences[owner]?.projects.first(where: { $0.projectID == projectID }) + else { return nil } + var candidates: [MeshDispatchScorer.Candidate] = [] + for (hostID, presence) in presences { + guard let conn = connections[hostID] else { continue } + candidates.append(MeshDispatchScorer.Candidate( + hostID: hostID, presence: presence, + live: conn.connectivity.isLive, + canAcknowledgeDispatch: conn.capabilities.canAcknowledgeDispatch)) + } + // Offer only when at least one eligible host actually holds the repo. + guard !MeshDispatchScorer.rank( + candidates: candidates, project: descriptor, backend: nil).isEmpty + else { return nil } + return (descriptor, candidates) + } + + /// Dispatch a new chat to the abstract **Mesh** destination from the phone: rank eligible + /// hosts by the shared scorer and try them in order, one at a time with a correlated, + /// idempotent ack (a timeout retries the same candidate once before moving on). On success + /// routes to the landed session. Mirrors `AppStore.dispatchChatToMesh`. + func dispatchChatToMesh( + projectID: ProjectID, message: String, model: String? = nil, + effort: String? = nil, auto: Bool? = nil + ) { + let text = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, let (descriptor, candidates) = + meshDispatchCandidates(forProject: projectID) else { return } + Task { @MainActor in + var excluded: Set = [] + var tried = 0 + while true { + guard let pick = MeshDispatchScorer.rank( + candidates: candidates, project: descriptor, + backend: nil, excluding: excluded).first + else { + showError(tried == 0 + ? "No mesh runner can take this chat right now." + : "No mesh runner could take this chat (\(tried) tried).", sessionID: nil) + return + } + tried += 1 + // The phone hands over no clone URL, so every pick is a holder (non-nil id). + guard let targetProjectID = pick.projectID, + let conn = connections[pick.hostID], conn.connectivity.isLive else { + excluded.insert(pick.hostID); continue + } + let requestID = UUID().uuidString + let request = StartChatRequest( + projectID: targetProjectID, message: text, model: model, effort: effort, + auto: auto, requestID: requestID) + var outcome = await sendDispatch(request, on: conn, timeout: .seconds(10)) + if outcome == nil { + outcome = await sendDispatch(request, on: conn, timeout: .seconds(5)) + } + if let sessionID = outcome?.sessionID { + // The session lives on the target host; refresh its list and route to it. + conn.send(.listSessions) + conn.send(.listDashboard) + route(to: sessionID) + return + } + if let failure = outcome?.error { + showError("\(conn.hostName): \(failure)", sessionID: nil) + } + excluded.insert(pick.hostID) + } + } + } + + private func sendDispatch( + _ request: StartChatRequest, on conn: HostConnection, timeout: Duration + ) async -> WireChatStarted? { + guard let requestID = request.requestID else { return nil } + conn.send(.startChat(request)) + return await withCheckedContinuation { continuation in + chatStartedWaiters[requestID] = continuation + Task { @MainActor in + try? await Task.sleep(for: timeout) + self.chatStartedWaiters.removeValue(forKey: requestID)?.resume(returning: nil) + } + } + } + + /// Apply freshly received casts (live or catch-up) and persist if anything changed. + private func applyCasts(_ apply: (inout CastLedger) -> [WireCast]) { + var ledger = castLedger + let fresh = apply(&ledger) + guard !fresh.isEmpty else { return } + castLedger = ledger + activityFeed = ActivityFeedItem.merged(activityFeed, adding: fresh) + castPersistTask?.cancel() + castPersistTask = Task { [ledger] in + try? await Task.sleep(for: .milliseconds(500)) + guard !Task.isCancelled else { return } + await CastCache.save(ledger) + } + } private var transcriptPersistTask: Task? @Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []) @Published private(set) var grantedScope: DeviceScope = .approve @@ -122,7 +266,7 @@ final class RemoteStore: ObservableObject { /// "Add a project" (Projects tab): the phone asks a connected host advertising /// `canCreateProjects` to clone a git URL and register it (CLOUD_RUNTIME §4.3) — how a - /// fresh Antimatter runner gets its first project. The outcome arrives asynchronously as + /// fresh Carbon runner gets its first project. The outcome arrives asynchronously as /// `HostMsg.projectCreated`, correlated by the request id below; the project row itself /// rides the dashboard push. enum AddProjectState: Equatable { @@ -460,6 +604,10 @@ final class RemoteStore: ObservableObject { if isPaired { // Show the saved chat history immediately, before any host connects. if sessions.isEmpty { sessions = cachedSummaries } + // …and the cached fleet feed (mesh casting) — live catch-up merges on top. + if activityFeed.isEmpty { + activityFeed = ActivityFeedItem.merged([], adding: castLedger.allCasts) + } // Assume connected while the first handshake completes, so launch doesn't open on a // grayed-out "Disconnected" shell for the second it takes to come up. beginOptimisticWindow() @@ -924,6 +1072,21 @@ final class RemoteStore: ObservableObject { IdentityStore.savePairedHost(host) self.rebuildAggregate() } + cb.castCursors = { [weak self] in + // Mesh casting: subscribe with the MERGED ledger's tips (every origin), so this host + // serves only the gaps — including gaps for origins it merely mirrors. + self?.castLedger.cursors ?? [] + } + cb.castsReceived = { [weak self] casts in + self?.applyCasts { $0.apply(casts) } + } + cb.castCatchUp = { [weak self] batch in + self?.applyCasts { $0.applyCatchUp(batch) } + } + cb.chatStarted = { [weak self] outcome in + // Mesh dispatch: resume the waiting `dispatchChatToMesh` by requestID. + self?.chatStartedWaiters.removeValue(forKey: outcome.requestID)?.resume(returning: outcome) + } cb.meshRosterChanged = { [weak self] in // Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac // (connecting the newcomer) and drop any that left — without switching the active host. @@ -1318,6 +1481,9 @@ final class RemoteStore: ObservableObject { // No Mac left whose history to hold — drop the offline cache too. cachedSummaries = [] SessionCache.clear() + castLedger = CastLedger() + activityFeed = [] + CastCache.clear() sessions = [] dashboard = .empty // Nothing left to dial — spin the embedded Tailscale node down if it was running. @@ -1825,7 +1991,7 @@ final class RemoteStore: ObservableObject { // Never originated from here (connection-internal, or handled by dedicated loops). // `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by // `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch. - // The runner verbs (intelligence results, credential mesh — ANTIMATTER_RUNNER §5–6) will + // The runner verbs (intelligence results, credential mesh — CARBON_RUNNER §5–6) will // ride their own executor/vault loops when the phone side lands; nothing routes them here. // `createProject` (CLOUD_RUNTIME §4.3) will go straight to a user-chosen host when the // phone grows that UI — a brand-new project has no owner to route by. @@ -1833,7 +1999,10 @@ final class RemoteStore: ObservableObject { .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground, .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, .requestPairingCode, .cancelPairingCode, .respondMacPair, - .intelligenceResult, .credentialManifest, .credentialProvision, .createProject: + .intelligenceResult, .credentialManifest, .credentialProvision, .createProject, + // Cast subscriptions are per-connection (each `HostConnection` subscribes on its + // own ready, with the merged ledger's cursors) — nothing routes them here. + .castSubscribe: break } } @@ -2021,10 +2190,12 @@ final class RemoteStore: ObservableObject { // "Add a device" mint is handled directly against a live host, not via demoHandle; // the demo path short-circuits in `requestPairingCode()` with a stand-in code. .requestPairingCode, .cancelPairingCode, .respondMacPair, - // Antimatter runner verbs (ANTIMATTER_RUNNER §5–6) — demo has no runner host. + // Carbon runner verbs (CARBON_RUNNER §5–6) — demo has no runner host. .intelligenceResult, .credentialManifest, .credentialProvision, // Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on. - .createProject: + .createProject, + // Mesh casting (demo has no host to cast). + .castSubscribe: break // passive / already handled by the seeded fixtures (demo has no mesh peers) } } diff --git a/NucleicRemote/NucleicRemote/Views/ActivityFeedView.swift b/NucleicRemote/NucleicRemote/Views/ActivityFeedView.swift new file mode 100644 index 0000000..f3d0a75 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/ActivityFeedView.swift @@ -0,0 +1,150 @@ +import SwiftUI +import NucleicProtocol + +/// The phone's fleet activity feed (mesh casting): notable events from every Mac and runner — +/// finished/needs-input turns, approvals, ships, transfers, dispatches — merged across origins +/// and surviving late joins (an hour-later app open replays the gap). A passive history +/// surface: it never alerts; rows deep-link to their session via the notification route. +/// +/// `RecentActivitySection` is the Home dashboard's three-row peek; `ActivityFeedListView` is +/// the pushed full list. +struct RecentActivitySection: View { + @EnvironmentObject var store: RemoteStore + + var body: some View { + if !store.activityFeed.isEmpty { + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("Recent activity", systemImage: "bell") + .font(.headline) + Spacer() + NavigationLink { + ActivityFeedListView() + } label: { + Text("See all") + .font(.subheadline) + } + } + VStack(spacing: 0) { + ForEach(store.activityFeed.suffix(3).reversed()) { item in + ActivityFeedRow(item: item) + if item.id != store.activityFeed.suffix(3).first?.id { + Divider().padding(.leading, 30) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .card() + .onAppear { store.markFeedSeen() } + } + } +} + +struct ActivityFeedListView: View { + @EnvironmentObject var store: RemoteStore + + var body: some View { + List(store.activityFeed.reversed()) { item in + ActivityFeedRow(item: item) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + .listStyle(.plain) + .navigationTitle("Activity") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if store.activityFeed.isEmpty { + ContentUnavailableView( + "No fleet activity yet", systemImage: "bell.slash", + description: Text("Finished chats, approvals, ships, and transfers from every Mac and runner land here.")) + } + } + .onAppear { store.markFeedSeen() } + } +} + +private struct ActivityFeedRow: View { + @EnvironmentObject var store: RemoteStore + let item: ActivityFeedItem + + private var event: FeedEvent { item.event } + + var body: some View { + Button { + // Route like a notification tap: jump to the Sessions tab and push the session on + // its owning host (works for any mesh host's session the phone mirrors). + store.route(to: event.sessionID) + } label: { + HStack(alignment: .top, spacing: 10) { + Image(systemName: Self.icon(for: event.kind)) + .foregroundStyle(Self.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)) + .foregroundStyle(.primary) + .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, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var subtitle: String { + var parts: [String] = [Self.headline(for: event)] + if !event.projectName.isEmpty { parts.append(event.projectName) } + if !event.hostName.isEmpty { parts.append(event.hostName) } + return parts.joined(separator: " · ") + } + + static func headline(for event: FeedEvent) -> String { + switch event.kind { + case .finished: "Finished" + case .needsInput: "Waiting for you" + case .needsApproval: event.detail.map { "Needs approval: \($0)" } ?? "Needs approval" + case .shipped: event.detail.map { "Shipped · \($0)" } ?? "Shipped" + case .transferredOut: "Moved to \(event.counterpartHostName ?? "another Mac")" + case .transferredIn: "Arrived from \(event.counterpartHostName ?? "another Mac")" + case .error: event.detail.map { "Error: \($0)" } ?? "Error" + case .dispatched: "Dispatched here via Mesh" + default: event.kind.rawValue // a newer build's kind — render generically, never drop + } + } + + static func icon(for kind: FeedEvent.Kind) -> String { + switch kind { + case .finished: "checkmark.circle" + case .needsInput: "bubble.left.and.exclamationmark.bubble.right" + case .needsApproval: "hand.raised" + case .shipped: "arrow.triangle.merge" + case .transferredOut: "arrow.up.right.square" + case .transferredIn: "arrow.down.left.square" + case .error: "exclamationmark.triangle" + case .dispatched: "paperplane" + default: "circle" + } + } + + static func tint(for kind: FeedEvent.Kind) -> Color { + switch kind { + case .finished, .shipped: .green + case .needsInput, .needsApproval: .orange + case .error: .red + default: .secondary + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index aae3fcd..ab417d7 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -18,6 +18,9 @@ struct StartChatComposer: View { /// project page's "+", so the chat always lands in that project. var lockedProject: WireProject? = nil @State private var projectID: ProjectID? + /// Mesh dispatch: run on the project's own Mac (false) or auto-route to the best eligible + /// mesh host (true). Shown only when a connected peer could actually take it. + @State private var runOnMesh = false @State private var autoOverride: Bool? @State private var model: String? @State private var effort = MobileEfforts.fallback @@ -39,6 +42,11 @@ struct StartChatComposer: View { lockedProject ?? projects.first { $0.id == projectID } } private var controlled: Bool { selected?.isNucleicControlled ?? false } + /// Whether the "Run on: This Mac / Mesh" picker should appear for the current selection. + private var meshAvailable: Bool { + guard let id = selected?.id else { return false } + return store.meshDispatchAvailable(forProject: id) + } /// Auto-approve for the chat about to start: the user's choice off-control, but forced on for a /// Nucleic Control project. The host locks Auto on for those chats regardless of what the phone /// sends (they run autonomously — nvrsion needs it: see `AppStore.createSession`), so the @@ -79,6 +87,23 @@ struct StartChatComposer: View { ? "Auto-approve is always on for Nucleic Control chats — they run autonomously." : "Auto-approve safe actions; destructive ones still ask.") } + // Mesh dispatch: when an eligible peer holds this repo, offer "Auto (Mesh)" so the + // chat routes to whichever host fits rather than always its own owner. + if meshAvailable { + Menu { + Picker("Run on", selection: $runOnMesh) { + Text("This Mac").tag(false) + Text("Mesh (automatic)").tag(true) + } + .pickerStyle(.inline) + } label: { + Label( + runOnMesh ? "Mesh" : "This Mac", + systemImage: runOnMesh ? "antenna.radiowaves.left.and.right" : "desktopcomputer") + .font(.caption) + .foregroundStyle(Palette.accent) + } + } HStack { ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil) Spacer() @@ -132,12 +157,20 @@ struct StartChatComposer: View { .focused(focus) Button { if let project = selected { - let branch = baseBranch.trimmingCharacters(in: .whitespaces) - store.startChat( - in: project.id, message: draft, model: model, effort: effort, - baseBranch: branch.isEmpty ? nil : branch, - useWorktree: useWorktree, auto: effectiveAuto, - attachments: attachments.wireAttachments) + if runOnMesh && meshAvailable { + // Mesh dispatch: route to the best eligible host (attachments can't + // ride a dispatch, same as a remote start). + store.dispatchChatToMesh( + projectID: project.id, message: draft, model: model, + effort: effort, auto: effectiveAuto) + } else { + let branch = baseBranch.trimmingCharacters(in: .whitespaces) + store.startChat( + in: project.id, message: draft, model: model, effort: effort, + baseBranch: branch.isEmpty ? nil : branch, + useWorktree: useWorktree, auto: effectiveAuto, + attachments: attachments.wireAttachments) + } draft = "" attachments = [] attachmentsOverflowed = false diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index ab2aac9..cab269c 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -97,6 +97,10 @@ struct HomeView: View { if !running.isEmpty { InProgressSessions(sessions: running) } + // Fleet activity (mesh casting): the merged cross-host feed's newest three + // rows, with "See all" pushing the full list. Renders offline from the cache. + RecentActivitySection() + QuickTodos() } .padding() diff --git a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift index 49b6983..394ebd0 100644 --- a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift +++ b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift @@ -52,7 +52,7 @@ struct ProjectsView: View { .refreshable { store.refreshSessions() } .toolbar { // "Add a project" (CLOUD_RUNTIME §4.3): clone a git URL on a connected host — - // how a fresh Antimatter runner gets its first project. Hidden when no live + // how a fresh Carbon runner gets its first project. Hidden when no live // host advertises `canCreateProjects` (an older Mac would reject the verb). if store.canCreateProject { ToolbarItem(placement: .primaryAction) { diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 7d1a3eb..1109ec1 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -153,7 +153,7 @@ struct SettingsView: View { Button { showScanner = true } label: { - Label("Join an Antimatter mesh", systemImage: "qrcode.viewfinder") + Label("Join a Carbon mesh", systemImage: "circle.dotted.and.circle") } Button { showManualPair = true @@ -454,7 +454,7 @@ struct AddDeviceView: View { } header: { Text("Scan to join") } footer: { - Text("On a new iPhone or iPad, open Nucleic Remote ▸ Join an Antimatter mesh and scan this. " + Text("On a new iPhone or iPad, open Nucleic Remote ▸ Join a Carbon mesh and scan this. " + "It joins the whole group — every Mac and device here.") }