336 lines
16 KiB
Swift
336 lines
16 KiB
Swift
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
|
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
/// Set by the iPad `Cmd+N` shortcut; opens the new-chat sheet (consumed here).
|
|
var focusNewChat: Binding<Bool> = .constant(false)
|
|
@State private var showNewChat = false
|
|
|
|
private var counts: DashboardCounts { store.dashboard.counts }
|
|
|
|
/// 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) }
|
|
.sorted { $0.lastTurnAt > $1.lastTurnAt }
|
|
}
|
|
/// 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] {
|
|
store.liveSessions.filter { $0.status == .running }.sorted { $0.lastTurnAt > $1.lastTurnAt }
|
|
}
|
|
/// 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: +)
|
|
}
|
|
/// 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: +)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
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 {
|
|
// 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 {
|
|
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()
|
|
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)
|
|
}
|
|
}
|
|
|
|
// "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) }
|
|
|
|
statCards
|
|
|
|
// The Mac's usage gauges (5-hour / weekly windows); hidden when the host
|
|
// doesn't project usage. Codex's card follows, shown once a Codex chat reports.
|
|
QuotaCard(usage: store.dashboard.usage)
|
|
CodexQuotaCard(usage: store.dashboard.codexUsage)
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Activity").font(.headline)
|
|
ActivityGrid(
|
|
activityByDay: activityByDay, tokensByDay: tokensByDay,
|
|
frozenDays: streak.frozenDays)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.card()
|
|
|
|
// 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()
|
|
|
|
if !running.isEmpty { InProgressSessions(sessions: running) }
|
|
|
|
QuickTodos()
|
|
}
|
|
.padding()
|
|
// The greeting stands in for the page's large title (same 34pt bold font), so
|
|
// reserve the large-title band above it. Without the system `.navigationTitle`,
|
|
// the bar collapses and the greeting would otherwise ride ~52pt higher than the
|
|
// large titles on the other tabs (Sessions/Projects/Settings).
|
|
.padding(.top, 52)
|
|
// 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()
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.compactTabBarInset()
|
|
.scrollDismissesKeyboard(.interactively)
|
|
.refreshable { store.refreshSessions() }
|
|
.onChange(of: focusNewChat.wrappedValue) { _, want in
|
|
if want { showNewChat = true; focusNewChat.wrappedValue = false }
|
|
}
|
|
// The floating "+" opens the glass composer (iPad ⌘N via focusNewChat opens the same).
|
|
// Only at regular width — the compact shell carries one shared "+" beside its tab bar.
|
|
.newChatAffordance(isPresented: $showNewChat, enabled: sizeClass == .regular)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// 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")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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))
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
.hoverEffect(.highlight)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.card()
|
|
}
|
|
}
|
|
|
|
private struct StatCard: View {
|
|
/// 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]
|
|
let label: String
|
|
let icon: String
|
|
|
|
init(value: Int, label: String, icon: String) {
|
|
self.init(values: ["\(value)"], label: label, icon: icon)
|
|
}
|
|
|
|
init(value: String, label: String, icon: String) {
|
|
self.init(values: [value], label: label, icon: icon)
|
|
}
|
|
|
|
init(values: [String], label: String, icon: String) {
|
|
self.values = values.isEmpty ? [""] : values
|
|
self.label = label
|
|
self.icon = icon
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Image(systemName: icon).foregroundStyle(Palette.accent)
|
|
// 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)
|
|
}
|
|
}
|
|
Text(label).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.card(padding: 12)
|
|
}
|
|
}
|
|
|
|
/// A lightning bolt whose charge grows with the streak (mirrors the Mac StreakBadge).
|
|
struct StreakBadge: View {
|
|
let days: Int
|
|
/// Streak freezes in reserve (one per five days), shown as a small snowflake tally.
|
|
var freezes: Int = 0
|
|
private var intensity: Double { min(1, Double(max(0, days - 1)) / 20) }
|
|
|
|
var body: some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "bolt.fill")
|
|
.font(.system(size: 18 + 8 * intensity))
|
|
.foregroundStyle(
|
|
LinearGradient(colors: [
|
|
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),
|
|
], startPoint: .top, endPoint: .bottom))
|
|
.shadow(color: Color(hue: 0.56, saturation: 0.85, brightness: 1).opacity(0.3 + 0.5 * intensity),
|
|
radius: 1 + 6 * intensity)
|
|
VStack(alignment: .leading, spacing: -2) {
|
|
Text("\(days)").font(.headline.monospacedDigit())
|
|
Text("day streak").font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
if freezes > 0 {
|
|
HStack(spacing: 2) {
|
|
Image(systemName: "snowflake")
|
|
Text("\(freezes)").monospacedDigit()
|
|
}
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(Palette.frozen)
|
|
}
|
|
}
|
|
.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]
|
|
/// 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] = [:]
|
|
/// 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> = []
|
|
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)!
|
|
// Brightness relative to *this user's* activity, outliers excluded so a few marathon
|
|
// 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)
|
|
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)!
|
|
let isFuture = date > today
|
|
let isFrozen = !isFuture && frozenDays.contains(date)
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(isFuture ? Color.clear
|
|
: isFrozen ? Palette.frozen
|
|
: Palette.activity(intensity: shading.intensity(
|
|
tokens: tokensByDay[date] ?? 0, messages: activityByDay[date] ?? 0)))
|
|
.frame(width: cell, height: cell)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|