nvrsion: Add: Unified session/project representation across all hosts in iOS app, refactor views and models to remove selector dependency.

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-05 02:03:17 -07:00
co-authored by Nucleic
parent e49501fb12
commit 4cc54cb18f
4 changed files with 291 additions and 183 deletions
@@ -38,11 +38,11 @@ final class RemoteStore: ObservableObject {
}
@Published private(set) var connectivity: Connectivity = .unpaired
/// A representative host name for the aggregate — the open session's Mac when a transcript is
/// open, otherwise a live host. Sessions/projects from *every* connected Mac are merged into the
/// flat state above (mesh P3), so no single "active host" is chosen; per-row provenance comes
/// from `hostName(forSession:)` instead.
@Published private(set) var hostName: String = ""
/// The paired Mac this connection currently reflects (mesh P3), by its `HostID` (fingerprint).
/// `hostName`/`sessions`/etc. above are that host's projection; switching hosts re-points them
/// (live: reconnect to a different paired Mac; demo: swap the mock host). `nil` until connected.
@Published private(set) var activeHostID: String?
@Published private(set) var sessions: [WireSessionSummary] = []
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
@Published private(set) var grantedScope: DeviceScope = .approve
@@ -60,6 +60,10 @@ final class RemoteStore: ObservableObject {
// Open session projection.
@Published private(set) var openSessionID: SessionID?
/// The paired Mac that owns the open session (mesh P3). The open transcript's intents route to
/// it, and the host-specific projected values (capabilities/scope/catalog/connectivity) follow
/// it while a session is open. `nil` when nothing is open.
private var openSessionHostID: String?
@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
@@ -180,13 +184,57 @@ final class RemoteStore: ObservableObject {
/// Proactive network-path awareness (shared across every host connection): flips transports the
/// instant Wi-Fi drops or returns, instead of waiting for a dead LAN socket to time out.
let pathMonitor = NetworkPathMonitor()
/// One live connection per paired Mac (mesh P3 multiplexer). All connected at once; the flat
/// `sessions`/`connectivity`/`capabilities`/… above mirror the *active* one (`activeHostID`), so
/// switching hosts is instant (no reconnect). Empty in demo mode (demo seeds state directly).
/// One live connection per paired Mac (mesh P3 multiplexer). All connected at once, and the flat
/// `sessions`/`dashboard` above are the *merged* projection across every one of them — the phone
/// shows all your Macs together, with no active-host selector. Intents route to the Mac that owns
/// the target session/project (see `send`). Empty in demo mode (demo seeds state directly).
private var connections: [String: HostConnection] = [:]
/// The active connection — the Mac whose projection the flat state reflects + whose transcript
/// is open.
private var activeConnection: HostConnection? { activeHostID.flatMap { connections[$0] } }
/// The connection whose projection drives the host-specific flat values (capabilities, scope,
/// catalog, connectivity, host name): the open session's Mac while a transcript is open, else a
/// representative live host (one with a populated catalog, falling back to any live/any host).
private var contextConnection: HostConnection? {
if let id = openSessionHostID, let conn = connections[id] { return conn }
return connections.values.first { $0.connectivity.isLive && !$0.modelCatalog.groups.isEmpty }
?? connections.values.first { $0.connectivity.isLive }
?? connections.values.first
}
/// A live connection to fall back on for host-agnostic intents (e.g. a project-less to-do
/// capture, which has no owning Mac).
private var firstLiveConnection: HostConnection? {
connections.values.first { $0.connectivity.isLive } ?? connections.values.first
}
// MARK: Intent routing (mesh P3) — resolve the Mac that owns a session / project / to-do.
private func connection(owningSession id: SessionID) -> HostConnection? {
connections.values.first { $0.sessions.contains { $0.sessionID == id } }
}
private func connection(owningProject id: ProjectID) -> HostConnection? {
connections.values.first { $0.dashboard.projects.contains { $0.id == id } }
}
private func connection(owningTodo id: TodoID) -> HostConnection? {
connections.values.first { $0.dashboard.todos.contains { $0.id == id } }
}
/// Send to every live connection — for host-agnostic pulls (list sessions/dashboard) and as the
/// safety fallback for an approval whose owning Mac isn't known (a notification-driven decision).
private func broadcastLive(_ msg: ClientMsg) {
for conn in connections.values where conn.connectivity.isLive { conn.send(msg) }
}
/// How many Macs are currently connected — the Sessions list shows a per-row host tag only when
/// more than one, so a single-Mac view stays clean.
var connectedHostCount: Int {
if demoMode { return 1 }
return connections.values.filter { $0.connectivity.isLive }.count
}
/// The name of the Mac that owns a session, for the per-row host tag. `nil` in demo (no
/// connections) or when the session isn't found on any connected Mac.
func hostName(forSession id: SessionID) -> String? {
connection(owningSession: id)?.hostName
}
/// The phone's embedded Tailscale node state, for Settings (nil = not running). Shared across all
/// connections (one embedded node, many dials).
@Published private(set) var tailnetStatus: String?
@@ -207,75 +255,6 @@ final class RemoteStore: ObservableObject {
/// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`.
private var demoTasks: [Task<Void, Never>] = []
// MARK: - Multi-host switching (mesh P3)
/// One selectable Mac for the host switcher — the paired Macs (live) or the mock hosts (demo).
struct HostChoice: Identifiable, Equatable {
let id: String // HostID (fingerprint), or a demo id
let name: String
let isActive: Bool
}
/// The Macs this phone can switch between. Live: the paired-host registry. Demo: the mock hosts.
/// One entry ⇒ the switcher hides itself and the app reads single-host. Today the phone holds one
/// *live* connection at a time (switching reconnects); simultaneous connections are the deferred
/// multiplexer step.
var hostChoices: [HostChoice] {
if demoMode {
return demoHosts.map { HostChoice(id: $0.id, name: $0.name, isActive: $0.id == activeHostID) }
}
return IdentityStore.pairedHosts().map {
HostChoice(id: $0.fingerprint, name: $0.hostName, isActive: $0.fingerprint == activeHostID)
}
}
/// Switch which paired Mac is active. Live: every Mac is already connected, so this is instant —
/// close the current transcript, re-point at the target connection, and mirror its state. Demo:
/// swap the mock host's projection, preserving in-demo edits.
func switchHost(to id: String) {
guard id != activeHostID else { return }
if demoMode {
if let i = demoHosts.firstIndex(where: { $0.id == activeHostID }) {
demoHosts[i].sessions = sessions // keep any in-demo mutations
demoHosts[i].dashboard = dashboard
}
guard let next = demoHosts.first(where: { $0.id == id }) else { return }
applyDemoHost(next)
} else {
// Close the transcript open on the old host.
activeConnection?.openSessionID = nil
openSessionID = nil; openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
activeHostID = id
// The target should already be connected; (re)dial only if it isn't.
if let host = IdentityStore.pairedHost(id: id) {
let conn = connection(for: host)
if !conn.connectivity.isLive { conn.reconnect(to: host) }
}
mirrorActive()
}
}
/// Mock hosts for the multi-host demo. Each carries its own name + sessions + dashboard;
/// switching swaps the flat active-host projection. Empty outside demo.
private struct DemoHost {
let id: String
let name: String
var sessions: [WireSessionSummary]
var dashboard: DashboardSnapshot
}
private var demoHosts: [DemoHost] = []
/// Project a mock host onto the flat active-host state (demo host switch).
private func applyDemoHost(_ host: DemoHost) {
activeHostID = host.id
hostName = host.name
sessions = host.sessions
dashboard = host.dashboard
openSessionID = nil; openEvents = []; openApprovals = []; openDiff = nil
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
NotificationRouter.shared.updateBadge(needsYouCount)
}
var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
var deviceFingerprint: String { identity.fingerprint }
@@ -411,11 +390,13 @@ final class RemoteStore: ObservableObject {
fiveHour: WireUsageWindow(utilization: 18, resetsAt: Date().addingTimeInterval(2 * 3600)),
sevenDay: WireUsageWindow(utilization: 40, resetsAt: nil)),
statusFeeds: [])
demoHosts = [
DemoHost(id: "demo-1", name: "Andrew's Mac", sessions: host1Sessions, dashboard: host1Dashboard),
DemoHost(id: "demo-2", name: "Studio Mac", sessions: host2Sessions, dashboard: host2Dashboard),
]
applyDemoHost(demoHosts[0])
// Mesh P3: seed BOTH demo Macs' worlds merged into the flat aggregate, exactly as the app
// presents real paired Macs — every host's sessions/projects/to-dos shown together, no
// switcher. `demoHandle` then mutates this aggregate directly for the interactive demo.
sessions = host1Sessions + host2Sessions
dashboard = DashboardSnapshot.merged([host1Dashboard, host2Dashboard])
LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions)
NotificationRouter.shared.updateBadge(needsYouCount)
}
/// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host.
@@ -461,8 +442,7 @@ final class RemoteStore: ObservableObject {
connections[id]?.teardown()
let conn = makeConnection(hostID: id, hostName: payload.hostName)
connections[id] = conn
activeHostID = id
mirrorActive()
rebuildAggregate()
conn.pair(with: payload)
}
@@ -481,13 +461,14 @@ final class RemoteStore: ObservableObject {
}
/// The callbacks one `HostConnection` uses to drive shared/aggregate state. `hostID` is captured
/// so a change updates the flat projection only for the active host, while badges, notifications,
/// and the single open transcript are handled globally across hosts.
/// so the open-transcript forwarding fires only for the Mac that owns the open session, while the
/// merged list/dashboard, badges, and notifications are rebuilt across every host on any change.
private func callbacks(for hostID: String) -> HostConnection.Callbacks {
var cb = HostConnection.Callbacks()
cb.didUpdate = { [weak self] in
guard let self else { return }
if hostID == self.activeHostID { self.mirrorActive() }
// Any host's change re-merges the aggregate the flat state binds to (mesh P3).
self.rebuildAggregate()
self.refreshAggregate()
self.flushPendingNotificationDecision()
// A host that just connected (or reconnected) needs the current Live Activity token
@@ -495,22 +476,22 @@ final class RemoteStore: ObservableObject {
self.syncLiveActivityRegistration()
}
cb.openSnapshot = { [weak self] snap in
guard let self, hostID == self.activeHostID, snap.summary.sessionID == self.openSessionID else { return }
guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return }
self.openEvents = snap.recentEvents
self.openApprovals = snap.pendingApprovals
}
cb.openEvents = { [weak self] batch in
guard let self, hostID == self.activeHostID, batch.sessionID == self.openSessionID else { return }
guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
self.openEvents.append(contentsOf: batch.events)
}
cb.openDiff = { [weak self] diff in
guard let self, hostID == self.activeHostID, diff.sessionID == self.openSessionID else { return }
guard let self, hostID == self.openSessionHostID, diff.sessionID == self.openSessionID else { return }
self.openDiff = diff
self.diffLoading = false
}
cb.approvalRequested = { [weak self] req, title in
guard let self else { return }
if hostID == self.activeHostID, req.sessionID == self.openSessionID,
if hostID == self.openSessionHostID, req.sessionID == self.openSessionID,
!self.openApprovals.contains(where: { $0.id == req.id }) {
self.openApprovals.append(req)
}
@@ -532,7 +513,7 @@ final class RemoteStore: ObservableObject {
cb.didPair = { [weak self] host in
guard let self else { return }
IdentityStore.savePairedHost(host)
self.activeHostID = host.fingerprint
self.rebuildAggregate()
}
cb.meshRosterChanged = { [weak self] in
// Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac
@@ -546,32 +527,66 @@ final class RemoteStore: ObservableObject {
return cb
}
/// Mirror the active connection's projection onto the flat @Published state the UI binds to.
private func mirrorActive() {
guard let c = activeConnection else { return }
connectivity = c.connectivity
hostName = c.hostName
sessions = c.sessions
capabilities = c.capabilities
grantedScope = c.grantedScope
modelCatalog = c.modelCatalog
dashboard = c.dashboard
meshPeers = c.meshPeers
/// Re-merge every connected Mac's projection into the flat @Published state the UI binds to
/// (mesh P3): all hosts' sessions and dashboards shown together, with the host-specific values
/// (host name, capabilities, catalog, connectivity, scope) following the open session's Mac —
/// or a representative one. No-op in demo, which seeds the aggregate directly.
private func rebuildAggregate() {
guard !demoMode else { return }
sessions = aggregatedSessions()
dashboard = DashboardSnapshot.merged(connections.values.map(\.dashboard))
meshPeers = connections.values.flatMap(\.meshPeers)
let ctx = contextConnection
hostName = ctx?.hostName ?? ""
capabilities = ctx?.capabilities
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
modelCatalog = ctx?.modelCatalog ?? .empty
if let id = openSessionHostID, let conn = connections[id] {
// While a transcript is open, the composer/controls act on *that* Mac — its connectivity
// gates send and its scope drives the control affordances.
connectivity = conn.connectivity
grantedScope = conn.grantedScope
} else {
connectivity = aggregateConnectivity()
// Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still
// enforces scope when the intent lands there).
grantedScope = connections.values
.filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve
}
}
/// Every connected Mac's live (non-archived) sessions — the aggregate the app-icon badge and
/// Live Activity summarize across hosts.
private var allLiveSessions: [WireSessionSummary] {
if demoMode { return demoHosts.flatMap(\.sessions).filter { !$0.archived } }
return connections.values.flatMap(\.sessions).filter { !$0.archived }
/// The flat sessions list: every connected Mac's sessions, deduped by id (ids are globally
/// unique). Views handle sorting/grouping.
private func aggregatedSessions() -> [WireSessionSummary] {
var seen = Set<SessionID>()
var result: [WireSessionSummary] = []
for conn in connections.values {
for summary in conn.sessions where seen.insert(summary.sessionID).inserted {
result.append(summary)
}
}
return result
}
/// One connectivity value for the whole app when no transcript is open (the chip + global
/// gating): connected if any Mac is live, else the most-informative in-progress/offline state.
private func aggregateConnectivity() -> Connectivity {
let all = connections.values.map(\.connectivity)
guard !all.isEmpty else { return .unpaired }
if let live = all.first(where: { $0.isLive }) { return live }
if all.contains(where: { if case .connecting = $0 { return true } else { return false } }) { return .connecting }
if all.contains(where: { if case .reconnecting = $0 { return true } else { return false } }) { return .reconnecting }
if all.contains(.hostOffline) { return .hostOffline }
return all.first ?? .unpaired
}
/// Refresh cross-host aggregates: the app-icon badge (needs-you across *all* Macs) + the Live
/// Activity summary.
/// Activity summary. `sessions`/`liveSessions` are already the merged aggregate.
private func refreshAggregate() {
let needs = allLiveSessions.filter { $0.status.needsYou($0.disposition) }.count
NotificationRouter.shared.updateBadge(needs)
LiveActivityManager.shared.sync(hostName: hostName, sessions: allLiveSessions)
NotificationRouter.shared.updateBadge(needsYouCount)
LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions)
}
/// Tear down every live connection (leaving the paired registry intact) — for entering demo.
@@ -594,33 +609,43 @@ final class RemoteStore: ObservableObject {
let conn = connection(for: host)
if !conn.connectivity.isLive { conn.reconnect(to: host) }
}
if activeHostID == nil || connections[activeHostID!] == nil {
activeHostID = hosts.last?.fingerprint
}
mirrorActive()
rebuildAggregate()
refreshAggregate()
}
/// Unpair this device entirely — drop every paired Mac and its connection (the "Unpair this
/// device" button). Nothing left to show, so the app returns to the pairing intro.
func unpair() {
// Forget + drop the active Mac's connection specifically (the switcher may have made it not
// the last). Other Macs' connections keep running.
if let id = activeHostID {
connections[id]?.teardown()
connections[id] = nil
IdentityStore.removePairedHost(id: id)
for (id, conn) in connections { conn.teardown(); connections[id] = nil }
for host in IdentityStore.pairedHosts() { IdentityStore.removePairedHost(id: host.fingerprint) }
IdentityStore.clearPairedHost()
finishUnpairIfEmpty()
}
/// Forget one paired Mac (the Settings ▸ Mesh per-row remove): drop just its connection +
/// registry record. Other Macs keep running and stay in the merged view.
func unpair(_ hostID: String) {
connections[hostID]?.teardown()
connections[hostID] = nil
IdentityStore.removePairedHost(id: hostID)
if IdentityStore.pairedHosts().isEmpty {
finishUnpairIfEmpty()
} else {
IdentityStore.clearPairedHost()
}
activeHostID = nil
// Mesh P3: if other Macs remain paired, switch to one (already connected) rather than unpairing.
if let next = IdentityStore.pairedHosts().last {
activeHostID = next.fingerprint
reconnect()
return
rebuildAggregate()
refreshAggregate()
}
}
/// Wind the app back to the unpaired state once no Macs remain: clear the open transcript + flat
/// aggregate and spin the embedded Tailscale node down.
private func finishUnpairIfEmpty() {
openSessionID = nil
openSessionHostID = nil
openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
connectivity = .unpaired
sessions = []
dashboard = .empty
// Nothing left to dial — spin the embedded Tailscale node down if it was running.
Task { await TailnetNode.shared.stop() }
tailnetStatus = nil
@@ -645,9 +670,8 @@ final class RemoteStore: ObservableObject {
UserDefaults.standard.set(false, forKey: Self.demoModeKey)
demoTasks.forEach { $0.cancel() }
demoTasks.removeAll()
demoHosts = []
activeHostID = nil
openSessionID = nil
openSessionHostID = nil
openEvents = []
openApprovals = []
openDiff = nil
@@ -667,15 +691,19 @@ final class RemoteStore: ObservableObject {
func open(_ sessionID: SessionID) {
openSessionID = sessionID
// Record which Mac owns this session (mesh P3) so its connection forwards the transcript and
// the host-specific projected values follow it.
openSessionHostID = connection(owningSession: sessionID)?.hostID
openEvents = []
openApprovals = []
openDiff = nil
diffLoading = false
markOpened(sessionID)
if demoMode { seedDemoTranscript(sessionID); return }
// The open transcript belongs to the active host; tell its connection so it forwards the
// snapshot/events (and dedupes them), then subscribe.
activeConnection?.openSessionID = sessionID
// Tell the owning connection so it forwards the snapshot/events (and dedupes them), re-derive
// the context host (capabilities/scope/catalog/connectivity now follow it), then subscribe.
connection(owningSession: sessionID)?.openSessionID = sessionID
rebuildAggregate()
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
}
@@ -759,16 +787,25 @@ final class RemoteStore: ObservableObject {
send(.unsubscribe(target))
markOpened(target) // everything up to now has been seen
guard openSessionID == target else { return }
activeConnection?.openSessionID = nil
connection(owningSession: target)?.openSessionID = nil
openSessionID = nil
openSessionHostID = nil
openEvents = []
openApprovals = []
openDiff = nil
diffLoading = false
// Context reverts to the aggregate now that no transcript is open.
if !demoMode { rebuildAggregate() }
}
func respond(_ approval: ApprovalRequest, _ decision: Decision) {
send(.approvalRespond(approval.id, decision))
// Route to the Mac that owns the approval's session (the approval message carries no
// sessionID of its own). Demo resolves locally.
if demoMode {
demoHandle(.approvalRespond(approval.id, decision))
} else {
connection(owningSession: approval.sessionID)?.send(.approvalRespond(approval.id, decision))
}
openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms
}
@@ -830,12 +867,54 @@ final class RemoteStore: ObservableObject {
// MARK: - Plumbing
/// Route an intent to the active host's connection (mesh P3). Every current intent — subscribe,
/// send-input, approvals, favorite/archive/delete, start-chat — targets a session or project on
/// the Mac whose list is on screen, i.e. the active one.
/// Route an intent to the Mac that owns its target (mesh P3). Sessions/projects/to-dos are shown
/// merged across every connected Mac, so an intent goes to the connection that owns the session,
/// project, or to-do it names — not to a single "active" host. Host-agnostic pulls (list
/// sessions/dashboard) broadcast to every live Mac; a project-less to-do capture (no owner) goes
/// to the first live Mac. Demo has no connections and mutates the seeded aggregate directly.
private func send(_ msg: ClientMsg) {
if demoMode { demoHandle(msg); return }
activeConnection?.send(msg)
switch msg {
// Session-owning intents → the Mac that has this session.
case .subscribe(let s):
connection(owningSession: s.sessionID)?.send(msg)
case .unsubscribe(let id), .interrupt(let id), .deleteSession(let id), .discard(let id),
.integrate(let id, _), .renameSession(let id, _), .setFavorite(let id, _),
.setArchived(let id, _), .setSessionModel(let id, _), .setSessionEffort(let id, _),
.setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _),
.sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id):
connection(owningSession: id)?.send(msg)
// Project-owning intents → the Mac that has this project.
case .startChat(let req):
connection(owningProject: req.projectID)?.send(msg)
case .dispatchTodo(_, let projectID):
connection(owningProject: projectID)?.send(msg)
// To-do-owning intents → the Mac that has this to-do.
case .setTodoStatus(let id, _), .deleteTodo(let id):
connection(owningTodo: id)?.send(msg)
// A capture with a project goes to that Mac; a project-less one has no owner → first live.
case .captureTodo(let req):
if let pid = req.projectID, let conn = connection(owningProject: pid) { conn.send(msg) }
else { firstLiveConnection?.send(msg) }
// Approvals carry no sessionID — `respond(_:_:)` routes by the approval's session directly;
// a stray one here (or a notification-driven decision) broadcasts and the owner resolves it.
case .approvalRespond:
broadcastLive(msg)
// Host-agnostic pulls → every live Mac.
case .listSessions, .listDashboard:
broadcastLive(msg)
// Never originated from here (connection-internal, or handled by dedicated loops).
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
.registerLiveActivity, .endLiveActivity,
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript:
break
}
}
// MARK: - Live Activity push registration (UX_IOS §5.3)
@@ -1109,6 +1188,59 @@ extension Data {
}
}
extension DashboardSnapshot {
/// Merge every connected Mac's dashboard into one aggregate projection (mesh P3), so Home,
/// Projects, and To-Dos show all your Macs together: projects and to-dos concatenated, activity
/// summed per day, counts summed (active-days recomputed from the merged activity), subscription
/// usage taken from the first Mac that reports it (a user's Macs share one account, so their
/// windows match — summing would double-count), and provider status feeds unioned by provider
/// (provider status is global, not per-Mac).
static func merged(_ snaps: [DashboardSnapshot]) -> DashboardSnapshot {
guard snaps.count != 1 else { return snaps[0] }
guard !snaps.isEmpty else { return .empty }
let projects = snaps.flatMap(\.projects)
let todos = snaps.flatMap(\.todos)
// Activity summed per start-of-day.
var byDay: [Date: (count: Int, tokens: Int)] = [:]
for snap in snaps {
for day in snap.activity {
let key = Calendar.current.startOfDay(for: day.day)
var entry = byDay[key] ?? (0, 0)
entry.count += day.count
entry.tokens += day.tokens
byDay[key] = entry
}
}
let activity = byDay
.map { ActivityDay(day: $0.key, count: $0.value.count, tokens: $0.value.tokens) }
.sorted { $0.day < $1.day }
let counts = DashboardCounts(
projects: projects.count,
chats: snaps.reduce(0) { $0 + $1.counts.chats },
activeChats: snaps.reduce(0) { $0 + $1.counts.activeChats },
messages: snaps.reduce(0) { $0 + $1.counts.messages },
activeDays: activity.filter { $0.count > 0 || $0.tokens > 0 }.count,
tokens: snaps.reduce(0) { $0 + $1.counts.tokens })
let usage = snaps.compactMap(\.usage).first
var seenProviders = Set<String>()
var statusFeeds: [WireStatusFeed] = []
for snap in snaps {
for feed in snap.statusFeeds where seenProviders.insert(feed.provider).inserted {
statusFeeds.append(feed)
}
}
return DashboardSnapshot(
counts: counts, activity: activity, projects: projects,
todos: todos, usage: usage, statusFeeds: statusFeeds)
}
}
extension WireSessionSummary {
/// A copy with selected fields overridden — the demo simulator's only way to "mutate" a
/// summary, whose stored properties are all `let`. Nullable fields use a double optional so a