2026-06-13 02:20:53 -07:00
|
|
|
import SwiftUI
|
|
|
|
|
import NucleicProtocol
|
|
|
|
|
|
|
|
|
|
/// The mobile home dashboard — the phone's mirror of the Mac home (UX_MACOS §2): a greeting +
|
|
|
|
|
/// streak, a GitHub-style activity grid, rollup stat cards, a quick to-do peek, and a
|
|
|
|
|
/// start-chat composer (control scope).
|
|
|
|
|
struct HomeView: View {
|
|
|
|
|
@EnvironmentObject var store: RemoteStore
|
2026-07-05 17:44:06 -07:00
|
|
|
/// Set by the iPad `Cmd+N` shortcut; opens the new-chat sheet (consumed here).
|
2026-07-04 01:18:35 -07:00
|
|
|
var focusNewChat: Binding<Bool> = .constant(false)
|
2026-07-05 17:44:06 -07:00
|
|
|
@State private var showNewChat = false
|
2026-06-13 02:20:53 -07:00
|
|
|
|
|
|
|
|
private var counts: DashboardCounts { store.dashboard.counts }
|
2026-06-13 23:16:13 -07:00
|
|
|
|
2026-07-04 15:33:04 -07:00
|
|
|
/// Sessions blocking on a human — the dashboard's "which session needs me?" answer (the Mac
|
|
|
|
|
/// home §2). Uses turn disposition so a finished-the-work chat doesn't count; archived hidden.
|
|
|
|
|
private var needsAttention: [WireSessionSummary] {
|
|
|
|
|
store.liveSessions
|
|
|
|
|
.filter { $0.status.needsYou($0.disposition) }
|
nvrsion: Add: Reconnect Icon Update, fix mesh iPhone discovery for new Macs, fix session order adjustment to pin sidebar sessions for remote projects, fix session status synchronization for remote viewer Macs, fix session order adjustment for iOS app session list, fix session status synchronization for remote sessions, fix iOS app live activity height by removing the “<n> files changed” line, fix Tool Call Card Mesh so AI bash summaries transit the mesh layer to other Macs, replace “Resolved by mac-ui” in transcripts with device ID, fix Chat: Print Wait Message, fix Chat: Reconnect Icon Update, fix Chat: Activity Theme Adjustment for “attention needed” live activity theme.
2026-07-05 04:24:25 -07:00
|
|
|
.sorted { $0.lastTurnAt > $1.lastTurnAt }
|
2026-07-04 15:33:04 -07:00
|
|
|
}
|
2026-06-13 23:16:13 -07:00
|
|
|
/// Sessions actively working a turn — drives both the "In progress" stat card and the
|
|
|
|
|
/// live list pinned above the to-dos.
|
|
|
|
|
private var running: [WireSessionSummary] {
|
nvrsion: Add: Reconnect Icon Update, fix mesh iPhone discovery for new Macs, fix session order adjustment to pin sidebar sessions for remote projects, fix session status synchronization for remote viewer Macs, fix session order adjustment for iOS app session list, fix session status synchronization for remote sessions, fix iOS app live activity height by removing the “<n> files changed” line, fix Tool Call Card Mesh so AI bash summaries transit the mesh layer to other Macs, replace “Resolved by mac-ui” in transcripts with device ID, fix Chat: Print Wait Message, fix Chat: Reconnect Icon Update, fix Chat: Activity Theme Adjustment for “attention needed” live activity theme.
2026-07-05 04:24:25 -07:00
|
|
|
store.liveSessions.filter { $0.status == .running }.sorted { $0.lastTurnAt > $1.lastTurnAt }
|
2026-06-13 23:16:13 -07:00
|
|
|
}
|
2026-07-02 19:37:45 -07:00
|
|
|
/// 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 }
|
2026-06-13 02:20:53 -07:00
|
|
|
private var activityByDay: [Date: Int] {
|
|
|
|
|
Dictionary(store.dashboard.activity.map { (Calendar.current.startOfDay(for: $0.day), $0.count) },
|
|
|
|
|
uniquingKeysWith: +)
|
2026-06-27 19:14:40 -07:00
|
|
|
}
|
|
|
|
|
/// Tokens used per day — the activity grid's primary intensity signal (falls back to message
|
|
|
|
|
/// count for days without recorded usage). Mirrors the Mac home.
|
|
|
|
|
private var tokensByDay: [Date: Int] {
|
|
|
|
|
Dictionary(store.dashboard.activity.map { (Calendar.current.startOfDay(for: $0.day), $0.tokens) },
|
|
|
|
|
uniquingKeysWith: +)
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-26 01:18:03 -07:00
|
|
|
/// Current activity streak with streak freezes folded in — the run of consecutive active
|
|
|
|
|
/// days, where a banked freeze (one earned per five days) bridges a missed day. Shared with
|
|
|
|
|
/// the Mac via `StreakState`, so both devices report the same streak and frozen days.
|
|
|
|
|
private var streakState: StreakState {
|
|
|
|
|
StreakState.compute(activityByDay: activityByDay)
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var greeting: String {
|
|
|
|
|
switch Calendar.current.component(.hour, from: Date()) {
|
|
|
|
|
case 5..<12: "Good morning"
|
|
|
|
|
case 12..<17: "Good afternoon"
|
|
|
|
|
case 17..<22: "Good evening"
|
|
|
|
|
default: "Hello"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
2026-06-26 01:18:11 -07:00
|
|
|
// Computed once and threaded down so the badge and grid agree on the streak and which
|
|
|
|
|
// days a freeze bridged.
|
|
|
|
|
let streak = streakState
|
|
|
|
|
return NavigationStack {
|
2026-06-13 02:20:53 -07:00
|
|
|
ScrollView {
|
|
|
|
|
VStack(alignment: .leading, spacing: 22) {
|
|
|
|
|
HStack(alignment: .top) {
|
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
|
|
|
Text(greeting).font(.largeTitle.bold())
|
|
|
|
|
Text(subtitle).font(.subheadline).foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
Spacer()
|
2026-07-02 04:20:25 -07:00
|
|
|
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)
|
|
|
|
|
}
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-04 15:33:04 -07:00
|
|
|
// "Which session needs me?" — pinned above everything when a chat is blocking
|
|
|
|
|
// on you (an approval or your next prompt), so it leads the dashboard.
|
|
|
|
|
if !needsAttention.isEmpty { NeedsAttentionSection(sessions: needsAttention) }
|
|
|
|
|
|
2026-06-13 02:20:53 -07:00
|
|
|
statCards
|
|
|
|
|
|
2026-07-02 04:20:25 -07:00
|
|
|
// The Mac's usage gauges (5-hour / weekly windows); hidden when the host
|
|
|
|
|
// doesn't project usage.
|
|
|
|
|
QuotaCard(usage: store.dashboard.usage)
|
|
|
|
|
|
2026-06-13 02:20:53 -07:00
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
Text("Activity").font(.headline)
|
2026-06-27 19:14:44 -07:00
|
|
|
ActivityGrid(
|
|
|
|
|
activityByDay: activityByDay, tokensByDay: tokensByDay,
|
|
|
|
|
frozenDays: streak.frozenDays)
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.card()
|
|
|
|
|
|
2026-06-13 23:16:13 -07:00
|
|
|
if !running.isEmpty { InProgressSessions(sessions: running) }
|
|
|
|
|
|
2026-06-13 02:20:53 -07:00
|
|
|
QuickTodos()
|
|
|
|
|
}
|
|
|
|
|
.padding()
|
2026-07-03 23:59:29 -07:00
|
|
|
// Cap + center the dashboard on a wide iPad so it reads like the Mac home rather
|
|
|
|
|
// than a stretched phone; a no-op at phone width.
|
|
|
|
|
.readableColumn()
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
|
|
|
|
.navigationTitle("Home")
|
|
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
2026-06-26 20:23:14 -07:00
|
|
|
.scrollDismissesKeyboard(.interactively)
|
2026-06-13 02:20:53 -07:00
|
|
|
.refreshable { store.refreshSessions() }
|
|
|
|
|
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var subtitle: String {
|
|
|
|
|
if counts.chats == 0 { return "Start a chat below to get going." }
|
|
|
|
|
let p = counts.projects == 1 ? "project" : "projects"
|
|
|
|
|
return "\(counts.activeChats) active of \(counts.chats) chats · \(counts.projects) \(p)"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var statCards: some View {
|
2026-07-02 19:37:45 -07:00
|
|
|
// 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
|
2026-07-02 19:34:09 -07:00
|
|
|
// instead of wrapping or truncating.
|
|
|
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), spacing: 12) {
|
2026-06-13 02:20:53 -07:00
|
|
|
StatCard(value: counts.projects, label: "Projects", icon: "folder")
|
2026-06-13 23:16:13 -07:00
|
|
|
StatCard(value: running.count, label: "In progress", icon: "circle.lefthalf.filled")
|
2026-06-13 02:20:53 -07:00
|
|
|
StatCard(value: counts.activeChats, label: "Active", icon: "bolt")
|
2026-07-02 19:37:45 -07:00
|
|
|
StatCard(value: openTodoCount, label: "To-dos", icon: "checklist")
|
2026-06-13 02:20:53 -07:00
|
|
|
StatCard(value: counts.messages, label: "Messages", icon: "paperplane")
|
2026-06-27 20:10:41 -07:00
|
|
|
StatCard(values: TokenCount.adaptiveCandidates(counts.tokens), label: "Tokens", icon: "text.word.spacing")
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 15:33:04 -07:00
|
|
|
/// The dashboard's attention list: chats blocking on you (an approval or your next prompt),
|
|
|
|
|
/// pinned at the very top. Tapping a row drops into that session. Header tinted in the attention
|
|
|
|
|
/// colour so it reads as urgent, matching the Mac home's "Needs attention" lead.
|
|
|
|
|
private struct NeedsAttentionSection: View {
|
|
|
|
|
let sessions: [WireSessionSummary]
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
HStack {
|
|
|
|
|
Label("Needs you", systemImage: "exclamationmark.triangle.fill")
|
|
|
|
|
.font(.headline).foregroundStyle(Palette.attention)
|
|
|
|
|
Spacer()
|
|
|
|
|
Text("\(sessions.count)")
|
|
|
|
|
.font(.subheadline.weight(.semibold)).foregroundStyle(Palette.attention)
|
|
|
|
|
}
|
|
|
|
|
ForEach(sessions.prefix(6), id: \.sessionID) { summary in
|
|
|
|
|
NavigationLink {
|
|
|
|
|
SessionDetailView(sessionID: summary.sessionID)
|
|
|
|
|
} label: {
|
|
|
|
|
SessionRow(summary: summary)
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
.hoverEffect(.highlight)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.card()
|
|
|
|
|
.overlay(
|
|
|
|
|
RoundedRectangle(cornerRadius: 14)
|
|
|
|
|
.strokeBorder(Palette.attention.opacity(0.35), lineWidth: 1))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 23:16:13 -07:00
|
|
|
/// Live peek at sessions actively running a turn, pinned above the to-do list on Home. Tapping
|
|
|
|
|
/// a row drops into the session transcript.
|
|
|
|
|
private struct InProgressSessions: View {
|
|
|
|
|
let sessions: [WireSessionSummary]
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
HStack {
|
|
|
|
|
Text("In progress").font(.headline)
|
|
|
|
|
Spacer()
|
|
|
|
|
Text("\(sessions.count)").foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
ForEach(sessions.prefix(4), id: \.sessionID) { summary in
|
|
|
|
|
NavigationLink {
|
|
|
|
|
SessionDetailView(sessionID: summary.sessionID)
|
|
|
|
|
} label: {
|
|
|
|
|
SessionRow(summary: summary)
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
2026-07-04 01:18:35 -07:00
|
|
|
.hoverEffect(.highlight)
|
2026-06-13 23:16:13 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.card()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 02:20:53 -07:00
|
|
|
private struct StatCard: View {
|
2026-06-27 20:10:58 -07:00
|
|
|
/// Candidate renderings of the value, widest first: the card shows the fullest one its width
|
|
|
|
|
/// allows (via `ViewThatFits`) and falls back to the narrowest. A single entry for the plain
|
|
|
|
|
/// integer cards; several for the token card, so a wide card shows more digits than "1.6B".
|
|
|
|
|
let values: [String]
|
2026-06-13 02:20:53 -07:00
|
|
|
let label: String
|
|
|
|
|
let icon: String
|
2026-06-27 19:15:00 -07:00
|
|
|
|
|
|
|
|
init(value: Int, label: String, icon: String) {
|
2026-06-27 20:10:58 -07:00
|
|
|
self.init(values: ["\(value)"], label: label, icon: icon)
|
2026-06-27 19:15:00 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
init(value: String, label: String, icon: String) {
|
2026-06-27 20:10:58 -07:00
|
|
|
self.init(values: [value], label: label, icon: icon)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
init(values: [String], label: String, icon: String) {
|
|
|
|
|
self.values = values.isEmpty ? [""] : values
|
2026-06-27 19:15:00 -07:00
|
|
|
self.label = label
|
|
|
|
|
self.icon = icon
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 02:20:53 -07:00
|
|
|
var body: some View {
|
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
|
|
|
Image(systemName: icon).foregroundStyle(Palette.accent)
|
2026-06-27 20:10:58 -07:00
|
|
|
// Show the fullest value the card's width fits, shrinking toward the compact form when
|
|
|
|
|
// narrow; ViewThatFits renders the last candidate if none fit, so it never overflows.
|
|
|
|
|
ViewThatFits(in: .horizontal) {
|
|
|
|
|
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
|
|
|
|
Text(value).font(.title2.weight(.semibold).monospacedDigit())
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-13 02:20:53 -07:00
|
|
|
Text(label).font(.caption).foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.card(padding: 12)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 01:23:09 -07:00
|
|
|
/// A lightning bolt whose charge grows with the streak (mirrors the Mac StreakBadge).
|
2026-06-13 02:20:53 -07:00
|
|
|
struct StreakBadge: View {
|
|
|
|
|
let days: Int
|
2026-06-26 01:18:24 -07:00
|
|
|
/// Streak freezes in reserve (one per five days), shown as a small snowflake tally.
|
|
|
|
|
var freezes: Int = 0
|
2026-06-13 02:20:53 -07:00
|
|
|
private var intensity: Double { min(1, Double(max(0, days - 1)) / 20) }
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
HStack(spacing: 6) {
|
2026-06-15 01:23:09 -07:00
|
|
|
Image(systemName: "bolt.fill")
|
2026-06-13 02:20:53 -07:00
|
|
|
.font(.system(size: 18 + 8 * intensity))
|
|
|
|
|
.foregroundStyle(
|
|
|
|
|
LinearGradient(colors: [
|
2026-06-15 01:23:09 -07:00
|
|
|
Color(hue: 0.55 - 0.05 * intensity, saturation: 0.5 + 0.2 * intensity, brightness: 1),
|
|
|
|
|
Color(hue: 0.60, saturation: 0.85 + 0.15 * intensity, brightness: 1),
|
2026-06-13 02:20:53 -07:00
|
|
|
], startPoint: .top, endPoint: .bottom))
|
2026-06-15 01:23:09 -07:00
|
|
|
.shadow(color: Color(hue: 0.56, saturation: 0.85, brightness: 1).opacity(0.3 + 0.5 * intensity),
|
|
|
|
|
radius: 1 + 6 * intensity)
|
2026-06-13 02:20:53 -07:00
|
|
|
VStack(alignment: .leading, spacing: -2) {
|
|
|
|
|
Text("\(days)").font(.headline.monospacedDigit())
|
|
|
|
|
Text("day streak").font(.caption2).foregroundStyle(.secondary)
|
|
|
|
|
}
|
2026-06-26 01:18:24 -07:00
|
|
|
if freezes > 0 {
|
|
|
|
|
HStack(spacing: 2) {
|
|
|
|
|
Image(systemName: "snowflake")
|
|
|
|
|
Text("\(freezes)").monospacedDigit()
|
|
|
|
|
}
|
|
|
|
|
.font(.caption.weight(.semibold))
|
|
|
|
|
.foregroundStyle(Palette.frozen)
|
|
|
|
|
}
|
2026-06-13 02:20:53 -07:00
|
|
|
}
|
|
|
|
|
.padding(.horizontal, 12).padding(.vertical, 8)
|
|
|
|
|
.background(Color(.secondarySystemBackground), in: Capsule())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// GitHub-style activity grid: columns = weeks (old→new), rows = Sun…Sat.
|
|
|
|
|
struct ActivityGrid: View {
|
|
|
|
|
let activityByDay: [Date: Int]
|
2026-06-27 19:15:07 -07:00
|
|
|
/// Tokens used per day — the primary intensity signal; days absent here fall back to their
|
|
|
|
|
/// message count (mirrors the Mac grid via `ActivityShading`).
|
|
|
|
|
var tokensByDay: [Date: Int] = [:]
|
2026-06-26 01:18:29 -07:00
|
|
|
/// Missed days a streak freeze kept alive — drawn in the frozen blue rather than the
|
|
|
|
|
/// empty-day gray (mirrors the Mac grid).
|
|
|
|
|
var frozenDays: Set<Date> = []
|
2026-06-13 02:20:53 -07:00
|
|
|
private let cell: CGFloat = 13
|
|
|
|
|
private let gap: CGFloat = 3
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
GeometryReader { geo in
|
|
|
|
|
let weeks = max(8, Int((geo.size.width + gap) / (cell + gap)))
|
|
|
|
|
grid(weeks: weeks)
|
|
|
|
|
}
|
|
|
|
|
.frame(height: cell * 7 + gap * 6)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func grid(weeks: Int) -> some View {
|
|
|
|
|
let cal = Calendar.current
|
|
|
|
|
let today = cal.startOfDay(for: Date())
|
|
|
|
|
let weekday = cal.component(.weekday, from: today)
|
|
|
|
|
let startOfWeek = cal.date(byAdding: .day, value: -(weekday - 1), to: today)!
|
|
|
|
|
let gridStart = cal.date(byAdding: .day, value: -7 * (weeks - 1), to: startOfWeek)!
|
2026-06-27 15:46:04 -07:00
|
|
|
// Brightness relative to *this user's* activity, outliers excluded so a few marathon
|
2026-06-27 19:15:18 -07:00
|
|
|
// days don't crush the everyday range. Shades by tokens-per-day, falling back to message
|
|
|
|
|
// count for days without recorded usage. Computed once for the whole grid (mirrors Mac).
|
|
|
|
|
let shading = ActivityShading(tokensByDay: tokensByDay, messagesByDay: activityByDay)
|
2026-06-13 02:20:53 -07:00
|
|
|
return HStack(alignment: .top, spacing: gap) {
|
|
|
|
|
ForEach(0..<weeks, id: \.self) { col in
|
|
|
|
|
VStack(spacing: gap) {
|
|
|
|
|
ForEach(0..<7, id: \.self) { row in
|
|
|
|
|
let date = cal.date(byAdding: .day, value: col * 7 + row, to: gridStart)!
|
2026-06-26 01:18:36 -07:00
|
|
|
let isFuture = date > today
|
|
|
|
|
let isFrozen = !isFuture && frozenDays.contains(date)
|
2026-06-13 02:20:53 -07:00
|
|
|
RoundedRectangle(cornerRadius: 2)
|
2026-06-26 01:18:36 -07:00
|
|
|
.fill(isFuture ? Color.clear
|
|
|
|
|
: isFrozen ? Palette.frozen
|
2026-06-27 19:15:18 -07:00
|
|
|
: Palette.activity(intensity: shading.intensity(
|
|
|
|
|
tokens: tokensByDay[date] ?? 0, messages: activityByDay[date] ?? 0)))
|
2026-06-13 02:20:53 -07:00
|
|
|
.frame(width: cell, height: cell)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
}
|
|
|
|
|
}
|