From 936329e1a4844c67d6e2495fd33b561f9a4b7de1 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:09:08 -0700 Subject: [PATCH 1/4] nvrsion: Add Chat: Create TestFlight Versions to Info.plist. Nucleic-Promote: 1 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Info.plist | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Info.plist b/NucleicRemote/NucleicRemote/Info.plist index 2fa3083..0870e52 100644 --- a/NucleicRemote/NucleicRemote/Info.plist +++ b/NucleicRemote/NucleicRemote/Info.plist @@ -10,5 +10,12 @@ $(NUCLEIC_CHANNEL) NSSupportsLiveActivities + + ITSAppUsesNonExemptEncryption + From 07cf01b4e659c13d2d1e94d59f36b80f4ce9f250 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:26:43 -0700 Subject: [PATCH 2/4] nvrsion: Add review feature access for iOS remote: Enable reviewers to test all features in App Store (or TestFlight) reviews by updating BUILD.md, docs/APP_REVIEW_NOTES.md, RemoteStore.swift, RemoteApp.swift, BuildBanner.swift, and SettingsView.swift. Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 283 +++++++++++++++++- .../NucleicRemote/NucleicRemoteApp.swift | 2 + .../NucleicRemote/Views/BuildBanner.swift | 30 ++ .../NucleicRemote/Views/SettingsView.swift | 25 ++ 4 files changed, 337 insertions(+), 3 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 094ed7e..40b4dc7 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -145,9 +145,18 @@ final class RemoteStore: ObservableObject { private var seenSeq: Set = [] private var reconnectAttempts = 0 - /// Offline UI-preview mode (set `NUCLEIC_DEMO=1`): seeds mock state so the surfaces render - /// without a paired Mac. No effect on real use. - private let demoMode = ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] == "1" + /// Offline demo mode: seeds mock state and simulates the agent locally so every surface + /// renders — and the core loops (send, approve, start chat, to-dos) actually respond — + /// without a paired Mac. Reachable in two ways: the `NUCLEIC_DEMO=1` env var (dev / + /// screenshots, forced on at launch) and a persisted in-app toggle + /// (`enterDemo()` / `exitDemo()`) so an App Store reviewer with no Mac can exercise the app + /// (App Review Guideline 2.1). `@Published` so the UI can show a DEMO indicator and the + /// "Leave demo" affordance. + private static let demoModeKey = "nucleic.demoMode" + @Published private(set) var demoMode = ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] == "1" + || UserDefaults.standard.bool(forKey: RemoteStore.demoModeKey) + /// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`. + private var demoTasks: [Task] = [] var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil } var deviceFingerprint: String { identity.fingerprint } @@ -317,6 +326,40 @@ final class RemoteStore: ObservableObject { NotificationRouter.shared.updateBadge(0) } + /// Enter the in-app demo (the "Explore a demo" button): drop any live connection, persist the + /// flag so it survives relaunch (a reviewer may relaunch), and seed the mock world. `isPaired` + /// then returns true, so `RootView` shows the full TabView. + func enterDemo() { + teardown() + demoMode = true + UserDefaults.standard.set(true, forKey: Self.demoModeKey) + reconnectAttempts = 0 + seedDemo() + } + + /// Leave the demo (Settings ▸ Leave demo): cancel any simulated runs, clear the mock world, + /// and return to the real state — reconnect if a Mac is actually paired, else the pairing intro. + func exitDemo() { + demoMode = false + UserDefaults.standard.set(false, forKey: Self.demoModeKey) + demoTasks.forEach { $0.cancel() } + demoTasks.removeAll() + openSessionID = nil + openEvents = [] + openApprovals = [] + openDiff = nil + diffLoading = false + sessions = [] + dashboard = .empty + LiveActivityManager.shared.end() + NotificationRouter.shared.updateBadge(0) + if IdentityStore.loadPairedHost() != nil { + reconnect() + } else { + connectivity = .unpaired + } + } + // MARK: - Intents (UX_IOS §9) func open(_ sessionID: SessionID) { @@ -371,6 +414,21 @@ final class RemoteStore: ObservableObject { event(14, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))), event(15, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))), ] + // If this session is blocked on a human, surface a real approval card so the + // Allow/Deny loop is exercisable in the demo (the seeded `a1` session). + if sessions.first(where: { $0.sessionID == sessionID })?.status == .awaitingApproval { + openApprovals = [ApprovalRequest( + id: Self.demoApprovalID(for: sessionID), + sessionID: sessionID, toolCallID: "t-appr", toolName: "Bash", + input: ["command": "npm run deploy"], title: "Run npm run deploy", + risk: .execute, createdAt: Date())] + } + } + + /// Deterministic approval id for a demo session's seeded approval, so re-opening the same + /// session doesn't stack duplicate cards. + private static func demoApprovalID(for sessionID: SessionID) -> ApprovalID { + ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)") } func closeOpen() { @@ -449,10 +507,191 @@ final class RemoteStore: ObservableObject { // MARK: - Plumbing private func send(_ msg: ClientMsg) { + if demoMode { demoHandle(msg); return } guard let client else { return } Task { await client.send(msg) } } + // MARK: - Demo simulator (offline, interactive) + // + // In demo mode there's no host, so writes can't go over the wire. Instead they mutate the + // already-`@Published` projection directly and (for the agent loop) stream a short canned run, + // so a reviewer can send messages, approve actions, start chats, and manage to-dos and see the + // UI respond — the read-only fixtures (`seedDemo`/`seedDemoTranscript`/`demoDiff`) already + // cover the passive surfaces. + + private func demoHandle(_ msg: ClientMsg) { + switch msg { + case .sendInput(let id, let input): + demoRun(id, userText: input.plainText ?? "") + case .startChat(let req): + demoStartChat(req) + case .approvalRespond(let id, let decision): + demoResolveApproval(id, decision) + case .captureTodo(let req): + demoCaptureTodo(req) + case .setTodoStatus(let id, let status): + demoSetTodoStatus(id, status) + case .deleteTodo(let id): + demoMutateTodos { $0.filter { $0.id != id } } + case .dispatchTodo(let id, _): + demoSetTodoStatus(id, .dispatched) + case .renameSession(let id, let title): + demoUpdateSession(id) { $0.demoCopy(title: title) } + case .setFavorite(let id, let favorite): + demoUpdateSession(id) { $0.demoCopy(favorite: favorite) } + case .setArchived(let id, let archived): + demoUpdateSession(id) { $0.demoCopy(archived: archived) } + case .deleteSession(let id): + sessions.removeAll { $0.sessionID == id } + NotificationRouter.shared.updateBadge(needsYouCount) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) + case .discard(let id): + sessions.removeAll { $0.sessionID == id } + NotificationRouter.shared.updateBadge(needsYouCount) + case .interrupt(let id): + demoUpdateSession(id) { $0.demoCopy(status: .interrupted, disposition: .some(nil)) } + case .integrate(let id, _): + demoAppend(id, .note(NoteEvent(text: "nvrsion: Landed to trunk.", icon: "arrow.triangle.branch"))) + demoUpdateSession(id) { $0.demoCopy(status: .finished, disposition: .some(.completed)) } + case .setSessionModel(let id, let model): + demoUpdateSession(id) { $0.demoCopy(model: .some(model)) } + case .setSessionEffort(let id, let effort): + demoUpdateSession(id) { $0.demoCopy(effort: .some(effort)) } + case .setSessionAuto(let id, let auto): + demoUpdateSession(id) { $0.demoCopy(auto: auto) } + case .setSessionAutoShip(let id, let autoShip): + demoUpdateSession(id) { $0.demoCopy(autoShip: autoShip) } + case .setSessionShipBranch(let id, let branch): + demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) } + case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe, + .ping, .cancelQueuedMessage, .fetchDiff: + break // passive / already handled by the seeded fixtures + } + } + + /// Append a transcript event to the open session (no-op if it isn't the one on screen). + private func demoAppend(_ sessionID: SessionID, _ kind: AgentEvent.Kind) { + guard sessionID == openSessionID else { return } + let seq = (openEvents.map(\.seq).max() ?? 0) + 1 + let backend = sessions.first { $0.sessionID == sessionID }?.backend ?? .claudeCode + openEvents.append(AgentEvent( + sessionID: sessionID, seq: seq, at: Date(), backend: backend, nativeType: nil, kind: kind)) + } + + /// Replace a session summary in the list, keeping the badge / Live Activity in sync. + private func demoUpdateSession(_ id: SessionID, _ transform: (WireSessionSummary) -> WireSessionSummary) { + guard let i = sessions.firstIndex(where: { $0.sessionID == id }) else { return } + sessions[i] = transform(sessions[i]) + NotificationRouter.shared.updateBadge(needsYouCount) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) + } + + /// Stream a short, believable canned turn for a session: assistant prose, a tool call + + /// result, usage, and a finish — flipping the summary running → awaiting-input. Each step + /// re-checks demo mode and cancellation so `exitDemo()` stops it cleanly. Runs entirely on the + /// main actor (the store is `@MainActor`, and a `Task` in an isolated method inherits it). + private func demoRun(_ sessionID: SessionID, userText: String?) { + if let userText, !userText.isEmpty { + demoAppend(sessionID, .userText(TextChunk(messageID: UUID().uuidString, text: userText, isPartial: false))) + } + demoUpdateSession(sessionID) { $0.demoCopy(status: .running, disposition: .some(nil)) } + let toolID = UUID().uuidString + let task = Task { [weak self] in + guard let self else { return } + @MainActor func pause(_ ms: Int) async -> Bool { + try? await Task.sleep(for: .milliseconds(ms)) + if Task.isCancelled { return false } + return self.demoMode + } + guard await pause(600) else { return } + self.demoAppend(sessionID, .assistantText(TextChunk( + messageID: UUID().uuidString, text: "On it — let me take a look.", isPartial: false))) + guard await pause(700) else { return } + self.demoAppend(sessionID, .toolCallStarted(ToolCall( + toolCallID: toolID, name: "Bash", input: ["command": "npm test"]))) + guard await pause(900) else { return } + self.demoAppend(sessionID, .toolResult(ToolResult( + toolCallID: toolID, content: "42 passing\n0 failing", isError: false))) + guard await pause(700) else { return } + self.demoAppend(sessionID, .assistantText(TextChunk( + messageID: UUID().uuidString, + text: "All green — tests pass and the change is in place. Anything else?", + isPartial: false))) + self.demoAppend(sessionID, .usage(Usage( + inputTokens: 12_400, outputTokens: 640, costUSD: 0.0088, contextInputTokens: 12_400))) + self.demoAppend(sessionID, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))) + self.demoUpdateSession(sessionID) { + $0.demoCopy(status: .awaitingInput, disposition: .some(.completed)) + } + } + demoTasks.append(task) + } + + private func demoStartChat(_ req: StartChatRequest) { + let project = dashboard.projects.first { $0.id == req.projectID } + let id = SessionID(rawValue: "demo-\(UUID().uuidString.prefix(8))") + let title = String(req.message.prefix(48)) + let summary = WireSessionSummary( + sessionID: id, projectID: req.projectID.rawValue, + projectName: project?.name ?? "project", backend: BackendID.forModel(req.model) ?? .claudeCode, + status: .running, disposition: nil, title: title.isEmpty ? "New chat" : title, + branch: "nucleic/\(id.rawValue)", lastSeq: 0, diffStat: nil, + pendingApprovalCount: 0, favorite: false, archived: false, + model: req.model, effort: req.effort, auto: req.auto ?? false, + updatedAt: Date()) + sessions.insert(summary, at: 0) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) + demoRun(id, userText: req.message) + } + + private func demoResolveApproval(_ id: ApprovalID, _ decision: Decision) { + openApprovals.removeAll { $0.id == id } + NotificationRouter.shared.withdrawApproval(id) + guard let sessionID = openSessionID else { return } + switch decision { + case .deny, .cancelRun: + demoAppend(sessionID, .note(NoteEvent(text: "You denied the command.", icon: "hand.raised"))) + demoUpdateSession(sessionID) { + $0.demoCopy(status: .awaitingInput, disposition: .some(.awaitingInput), pendingApprovalCount: 0) + } + case .allow, .allowAlways: + demoAppend(sessionID, .toolResult(ToolResult( + toolCallID: "t-appr", content: "Deployed successfully.", isError: false))) + demoUpdateSession(sessionID) { $0.demoCopy(pendingApprovalCount: 0) } + demoRun(sessionID, userText: nil) // wrap up the turn after the approved tool runs + } + } + + private func demoCaptureTodo(_ req: CaptureTodoRequest) { + let project = req.projectID.flatMap { pid in dashboard.projects.first { $0.id == pid } } + let todo = WireTodo( + id: .generate(), text: req.text, summary: nil, projectID: req.projectID, + projectName: project?.name, status: .open, dispatchedSessionID: nil, + triage: nil, updatedAt: Date()) + demoMutateTodos { [todo] + $0 } + } + + private func demoSetTodoStatus(_ id: TodoID, _ status: TodoStatus) { + demoMutateTodos { todos in + todos.map { todo in + todo.id == id + ? WireTodo(id: todo.id, text: todo.text, summary: todo.summary, + projectID: todo.projectID, projectName: todo.projectName, + status: status, dispatchedSessionID: todo.dispatchedSessionID, + triage: todo.triage, updatedAt: Date()) + : todo + } + } + } + + /// Rebuild the dashboard with a transformed to-do list (its fields are `let`). + private func demoMutateTodos(_ transform: ([WireTodo]) -> [WireTodo]) { + dashboard = DashboardSnapshot( + counts: dashboard.counts, activity: dashboard.activity, projects: dashboard.projects, + todos: transform(dashboard.todos), usage: dashboard.usage, statusFeeds: dashboard.statusFeeds) + } + private func makeChannel(_ endpoint: NWEndpoint) -> NWFrameChannel { NWFrameChannel(endpoint: endpoint) } @@ -599,3 +838,41 @@ extension Data { DeviceIdentity.fingerprint(ofStaticKey: self) } } + +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 + /// caller can distinguish "keep" (omit) from "set to nil" (`.some(nil)`). `updatedAt` bumps + /// to now unless given. Only the fields the simulator touches are exposed. + func demoCopy( + status: SessionStatus? = nil, + disposition: TurnDisposition?? = nil, + title: String? = nil, + favorite: Bool? = nil, + archived: Bool? = nil, + pendingApprovalCount: Int? = nil, + diffStat: DiffStat?? = nil, + model: String?? = nil, + effort: String?? = nil, + auto: Bool? = nil, + autoShip: Bool? = nil, + shipBranch: String?? = nil, + updatedAt: Date? = nil + ) -> WireSessionSummary { + WireSessionSummary( + sessionID: sessionID, projectID: projectID, projectName: projectName, backend: backend, + status: status ?? self.status, + disposition: disposition ?? self.disposition, + title: title ?? self.title, branch: branch, lastSeq: lastSeq, + diffStat: diffStat ?? self.diffStat, + pendingApprovalCount: pendingApprovalCount ?? self.pendingApprovalCount, + favorite: favorite ?? self.favorite, + archived: archived ?? self.archived, + queuedMessage: queuedMessage, queuedMessages: queuedMessages, + model: model ?? self.model, effort: effort ?? self.effort, + auto: auto ?? self.auto, autoShip: autoShip ?? self.autoShip, + shipBranch: shipBranch ?? self.shipBranch, + contextInputTokens: contextInputTokens, + updatedAt: updatedAt ?? Date()) + } +} diff --git a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift index 647d985..7a5fc6f 100644 --- a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift +++ b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift @@ -48,6 +48,8 @@ struct RootView: View { VStack(spacing: 0) { // The same non-release warning strip the Mac window carries (dev/canary/beta/rc). BuildBanner() + // A demo-mode strip so sample data is never mistaken for a live Mac connection. + DemoBanner() Group { if store.isPaired { TabView(selection: $tab) { diff --git a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift index 500e9da..e2b9f79 100644 --- a/NucleicRemote/NucleicRemote/Views/BuildBanner.swift +++ b/NucleicRemote/NucleicRemote/Views/BuildBanner.swift @@ -131,3 +131,33 @@ struct BuildBanner: View { } } } + +/// A slim strip shown while the app is in offline demo mode, so sample data is never mistaken +/// for a live connection to a Mac. Sits under the `BuildBanner` in `RootView`. +struct DemoBanner: View { + @EnvironmentObject var store: RemoteStore + + var body: some View { + if store.demoMode { + HStack(spacing: 8) { + Image(systemName: "play.circle.fill").font(.system(size: 14, weight: .bold)) + Text("Demo — sample data, no Mac connected") + .font(.footnote.weight(.semibold)) + .lineLimit(1) + Spacer(minLength: 8) + Text("DEMO") + .font(.caption2.weight(.heavy)) + .tracking(0.9) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.white.opacity(0.18), in: .capsule) + } + .foregroundStyle(.white.opacity(0.95)) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .frame(maxWidth: .infinity) + .background(Color(red: 0.36, green: 0.36, blue: 0.42)) + .accessibilityLabel("Demo mode — sample data, not connected to a Mac.") + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index d657298..04bb3fa 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -14,6 +14,18 @@ struct SettingsView: View { var body: some View { NavigationStack { Form { + if store.demoMode { + Section { + Button(role: .destructive) { store.exitDemo() } label: { + Label("Leave demo", systemImage: "arrow.uturn.left") + } + } header: { + Text("Demo") + } footer: { + Text("You're exploring Nucleic with sample data and a simulated agent. Leave the demo to pair with your Mac.") + } + } + Section { Picker("Theme", selection: $appearanceRaw) { ForEach(AppAppearance.allCases) { appearance in @@ -113,6 +125,19 @@ struct PairingIntroView: View { Label("Scan QR code", systemImage: "camera").font(.headline).padding(.horizontal) } .buttonStyle(.borderedProminent) + + VStack(spacing: 6) { + Button { store.enterDemo() } label: { + Label("Explore a demo — no Mac needed", systemImage: "play.circle") + } + .buttonStyle(.bordered) + Text("Sample data and a simulated agent — no Mac or account required.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + .padding(.top, 8) } .padding() .sheet(isPresented: $showScanner) { From 4493c03de0c6d3dd00507ff5192428e017e5a076 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:34:09 -0700 Subject: [PATCH 3/4] nvrsion: Add horizontal layout adjustment to Stat Cards on HomeView for better visibility, fix iOS pair screen banner position on the pair screen, and adjust Login Button Icon placement. Nucleic-Promote: 1 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/HomeView.swift | 5 ++++- NucleicRemote/NucleicRemote/Views/SettingsView.swift | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index acddf44..9dd27f5 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -99,7 +99,10 @@ struct HomeView: View { } private var statCards: some View { - HStack(spacing: 12) { + // Flow the cards into two rows (3 + 2) so each gets ~a third of the width rather than a + // fifth — enough room for labels like "In progress" and full token counts to render cleanly + // instead of wrapping or truncating. + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), spacing: 12) { StatCard(value: counts.projects, label: "Projects", icon: "folder") StatCard(value: running.count, label: "In progress", icon: "circle.lefthalf.filled") StatCard(value: counts.activeChats, label: "Active", icon: "bolt") diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 04bb3fa..25c2ddd 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -140,6 +140,10 @@ struct PairingIntroView: View { .padding(.top, 8) } .padding() + // Fill the available height so the enclosing VStack reaches full screen and the + // BuildBanner/DemoBanner strips stay pinned at the top. Without this the intro only + // claims its intrinsic height, so the stack gets centered and the banner drifts down. + .frame(maxWidth: .infinity, maxHeight: .infinity) .sheet(isPresented: $showScanner) { PairingScannerView { payload in showScanner = false From 1bf1f4e658e5307f0d75769453a6d186e771047a Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:37:45 -0700 Subject: [PATCH 4/4] =?UTF-8?q?nvrsion:=20Add=20=E2=80=9CTo-do=E2=80=9D=20?= =?UTF-8?q?count=20stat=20card=20to=20iOS=20app.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nucleic-Promote: 1 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Views/HomeView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 9dd27f5..28f6941 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -14,6 +14,9 @@ struct HomeView: View { private var running: [WireSessionSummary] { store.liveSessions.filter { $0.status == .running }.sorted { $0.updatedAt > $1.updatedAt } } + /// Outstanding to-dos — everything not yet done (open + dispatched), matching the count the + /// To-dos tab lists. The Mac surfaces this elsewhere on its home; the phone gets a stat card. + private var openTodoCount: Int { store.dashboard.todos.filter { $0.status != .done }.count } private var activityByDay: [Date: Int] { Dictionary(store.dashboard.activity.map { (Calendar.current.startOfDay(for: $0.day), $0.count) }, uniquingKeysWith: +) @@ -99,13 +102,14 @@ struct HomeView: View { } private var statCards: some View { - // Flow the cards into two rows (3 + 2) so each gets ~a third of the width rather than a - // fifth — enough room for labels like "In progress" and full token counts to render cleanly + // Flow the cards into two rows of three so each gets ~a third of the width rather than a + // sixth — enough room for labels like "In progress" and full token counts to render cleanly // instead of wrapping or truncating. LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), spacing: 12) { StatCard(value: counts.projects, label: "Projects", icon: "folder") StatCard(value: running.count, label: "In progress", icon: "circle.lefthalf.filled") StatCard(value: counts.activeChats, label: "Active", icon: "bolt") + StatCard(value: openTodoCount, label: "To-dos", icon: "checklist") StatCard(value: counts.messages, label: "Messages", icon: "paperplane") StatCard(values: TokenCount.adaptiveCandidates(counts.tokens), label: "Tokens", icon: "text.word.spacing") }