Files
nucleic/Sources/NucleicApp/HomeView.swift
T

588 lines
26 KiB
Swift

import SwiftUI
import Foundation
import NucleicCore
/// The home dashboard shown when no chat is selected: a greeting, rollup stats,
/// and a GitHub-style activity grid of usage over the last several weeks.
struct HomeView: View {
@Environment(AppStore.self) private var store
@Environment(\.appPalette) private var palette
/// Persisted so the home chat bar reopens on the last project you picked.
@AppStorage("nucleic.home.lastProjectID") private var lastProjectID = ""
@AppStorage(StreakBadge.showKey) private var showStreak = true
@AppStorage(SubmitKeyMode.storageKey) private var submitKeyRaw = SubmitKeyMode.returnSends.rawValue
@State private var composerHeight = ChatInputField.restingHeight
@State private var showingAddProject = false
@State private var modelOverride: String?
@State private var effortOverride: String?
@State private var branches: [String] = []
@State private var selectedBranch: String?
@State private var useWorktree = true
/// nil until the user explicitly toggles, so the button reflects the app default.
@State private var autoOverride: Bool?
@State private var shipOverride: Bool?
/// Measured height of the activity column, mirrored onto the usage card so the two
/// sit at the same height (the card's content is shorter, so it stretches to match).
@State private var activityHeight: CGFloat = 0
/// Measured width of the activity/usage row, so it can reflow from side-by-side to
/// a vertical stack once there isn't room to hold both columns.
@State private var dashboardWidth: CGFloat = 0
/// Below this content width the usage card drops beneath the activity card instead
/// of being squeezed up over it.
private let dashboardStackThreshold: CGFloat = 600
/// Width reserved for the send button, so the bottom controls row can be
/// inset to line up with the text field's trailing edge.
private let sendButtonWidth: CGFloat = 28
private var submitMode: SubmitKeyMode { SubmitKeyMode(rawValue: submitKeyRaw) ?? .returnSends }
/// Consecutive days (ending today, or yesterday if today is still idle) that
/// have at least one message. Derived from the same activity data as the grid.
private var streak: Int {
let calendar = Calendar.current
var day = calendar.startOfDay(for: Date())
// Today not yet active doesn't break a streak that ran through yesterday.
if (store.activityByDay[day] ?? 0) == 0 {
day = calendar.date(byAdding: .day, value: -1, to: day)!
}
var count = 0
while (store.activityByDay[day] ?? 0) > 0 {
count += 1
day = calendar.date(byAdding: .day, value: -1, to: day)!
}
return count
}
private var firstName: String {
if let first = NSFullUserName().split(separator: " ").first, !first.isEmpty {
return String(first)
}
let login = NSUserName()
return login.isEmpty ? "there" : login
}
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
HStack(alignment: .center, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Hi, \(firstName)")
.font(.system(size: 36, weight: .bold))
Text(subtitle).foregroundStyle(.secondary)
}
if showStreak {
Spacer(minLength: 16)
StreakBadge(days: streak)
}
}
dashboardRow
HStack(spacing: 16) {
StatCard(value: store.dashboard.projects, label: "Projects", icon: "folder")
StatCard(value: store.dashboard.chats, label: "Chats", icon: "bubble.left.and.bubble.right")
StatCard(value: store.dashboard.activeChats, label: "Active", icon: "bolt")
StatCard(value: store.dashboard.messages, label: "Messages", icon: "paperplane")
}
TodoSection()
}
.padding(32)
.frame(maxWidth: .infinity, alignment: .leading)
}
.scrollContentBackground(.hidden)
Divider()
chatBar
}
.task { await store.loadDashboard() }
.task { await store.loadTodos() }
.task(id: selectedProject?.id) { await loadBranches() }
.sheet(isPresented: $showingAddProject) { AddProjectSheet() }
}
/// Activity and usage cards: side by side when there's room, but once the row
/// narrows past `dashboardStackThreshold` the usage card drops below the activity
/// card rather than being squeezed up over it.
private var dashboardRow: some View {
let stacked = dashboardWidth > 0 && dashboardWidth < dashboardStackThreshold
let layout = stacked
? AnyLayout(VStackLayout(alignment: .leading, spacing: 28))
: AnyLayout(HStackLayout(alignment: .top, spacing: 28))
return layout {
activityColumn
// Only mirror the activity height side-by-side; stacked, the card sizes
// to its own content at full width.
QuotaCard(matchHeight: stacked ? 0 : activityHeight)
.frame(maxWidth: .infinity, alignment: .top)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(GeometryReader { proxy in
Color.clear
.onAppear { dashboardWidth = proxy.size.width }
.onChange(of: proxy.size.width) { _, width in dashboardWidth = width }
})
}
private var activityColumn: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Activity").font(.headline)
ActivityGrid(activityByDay: store.activityByDay)
HStack(spacing: 5) {
Text("Less").font(.caption2).foregroundStyle(.secondary)
ForEach(0..<4, id: \.self) { level in
RoundedRectangle(cornerRadius: 2)
.fill(palette.activity(level: level))
.frame(width: 11, height: 11)
}
Text("More").font(.caption2).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(GeometryReader { proxy in
Color.clear
.onAppear { activityHeight = proxy.size.height }
.onChange(of: proxy.size.height) { _, height in activityHeight = height }
})
}
private func loadBranches() async {
guard let project = selectedProject else { branches = []; return }
branches = await store.branches(in: project)
if let current = selectedBranch, !branches.contains(current) { selectedBranch = nil }
}
/// Base branch a new chat would start from: the explicit pick, else the
/// project's default branch.
private var effectiveBranch: String {
selectedBranch ?? selectedProject?.defaultBranch.value ?? "main"
}
/// The project a new chat would start in: the last-picked project (persisted),
/// else the store's current/first project.
private var selectedProject: Project? {
if !lastProjectID.isEmpty, let project = store.project(ProjectID(rawValue: lastProjectID)) {
return project
}
return store.currentProject
}
/// A composer pinned to the bottom of the dashboard: pick a project (or add a
/// new one) and start a chat in it directly from home.
private var chatBar: some View {
@Bindable var store = store
return VStack(spacing: 8) {
HStack(spacing: 8) {
Menu {
ForEach(store.projects) { project in
Button(project.name) { lastProjectID = project.id.rawValue }
}
if !store.projects.isEmpty { Divider() }
Button("New Project…", systemImage: "folder.badge.plus") {
showingAddProject = true
}
} label: {
HStack(spacing: 4) {
Image(systemName: "folder")
Text(selectedProject?.name ?? "Select a project")
Image(systemName: "chevron.down").font(.caption2)
}
}
.menuStyle(.button)
.fixedSize()
branchMenu
Toggle(isOn: $useWorktree) {
Label("Worktree", systemImage: "arrow.triangle.branch")
}
.toggleStyle(.checkbox)
.help(useWorktree
? "Run on an isolated worktree branched from \(effectiveBranch)."
: "Run directly in the main checkout on \(effectiveBranch) — no isolation.")
Spacer()
}
.font(.callout)
HStack(alignment: .bottom, spacing: 8) {
ChatInputField(
text: $store.homeDraft, placeholder: "Start a new chat…",
submitMode: submitMode, isEnabled: selectedProject != nil,
height: $composerHeight, onSend: start)
.frame(height: composerHeight)
.padding(8)
.background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 8))
.ultracodeGlow(active: isUltracode)
Button(action: start) {
SubmitKeyIcon(mode: submitMode)
}
.buttonStyle(.plain)
.frame(width: sendButtonWidth)
.help(submitMode.detail)
.disabled(selectedProject == nil || store.homeDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
HStack(spacing: 8) {
autoToggle
// Shipping implies Auto, so the Ship toggle is only meaningful when Auto
// is on. It slides out from behind the Auto button when Auto is enabled
// and tucks back under it when Auto is turned off.
if effectiveAuto {
shipToggle
.transition(.move(edge: .leading).combined(with: .opacity))
}
Spacer()
modelMenu
effortMenu
}
.animation(.easeInOut(duration: 0.25), value: effectiveAuto)
.font(.callout)
.padding(.trailing, sendButtonWidth + 8)
}
.padding(12)
}
// Effective concrete values for the chat about to be started: the user's
// override, or the app default (never shows "Default").
private var effectiveModel: String { modelOverride ?? store.defaultModel ?? ModelCatalog.fallbackModel }
private var effectiveEffort: String { effortOverride ?? store.defaultEffort ?? ModelCatalog.fallbackEffort }
private var defaultModel: String { store.defaultModel ?? ModelCatalog.fallbackModel }
private var defaultEffort: String { store.defaultEffort ?? ModelCatalog.fallbackEffort }
/// Menu item: checkmark on the selected value, a "house" on the app default.
@ViewBuilder
private func choiceLabel(_ display: String, badge: String? = nil, isSelected: Bool, isDefault: Bool) -> some View {
let title = Text(display)
+ (badge.map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
+ (isDefault ? Text(" (default)") : Text(""))
if isSelected {
Label { title } icon: { Image(systemName: "checkmark") }
} else if isDefault {
Label { title } icon: { Image(systemName: "house") }
} else {
title
}
}
private var modelMenu: some View {
Menu {
ForEach(ModelCatalog.models, id: \.self) { sku in
Button { modelOverride = sku } label: {
choiceLabel(ModelCatalog.displayName(sku),
badge: ModelCatalog.contextBadge(for: sku),
isSelected: sku == effectiveModel, isDefault: sku == defaultModel)
}
}
} label: {
Text("Model: \(ModelCatalog.displayName(effectiveModel))")
+ (ModelCatalog.contextBadge(for: effectiveModel).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
}
.menuStyle(.button)
.fixedSize()
.help("Model for the new chat")
}
/// Whether the chat about to start runs in ultracode (orchestration) mode.
private var isUltracode: Bool { ModelCatalog.isUltracode(effectiveEffort) }
private var effortMenu: some View {
Menu {
ForEach(ModelCatalog.efforts, id: \.self) { level in
Button { effortOverride = level } label: {
choiceLabel(level, isSelected: level == effectiveEffort, isDefault: level == defaultEffort)
}
}
// Ultracode sits below the API levels, set apart: it isn't an effort level but an
// orchestration mode (xhigh + standing consent to fan out to subagents).
Divider()
Button { effortOverride = ModelCatalog.ultracodeEffort } label: {
ultracodeMenuItem
}
} label: {
if isUltracode {
UltracodeEffortLabel()
} else {
Text("Effort: \(effectiveEffort)")
}
}
.menuStyle(.button)
.fixedSize()
.help(isUltracode ? ModelCatalog.ultracodeBlurb : "Reasoning effort for the new chat")
}
/// The Ultracode row in an effort menu: a sparkles glyph (a checkmark when it's the
/// selected mode), "Ultracode", and the "(default)" suffix when it's the app default.
@ViewBuilder
private var ultracodeMenuItem: some View {
let selected = isUltracode
let isDefault = ModelCatalog.isUltracode(defaultEffort)
Label {
Text("Ultracode") + (isDefault ? Text(" (default)") : Text(""))
} icon: {
Image(systemName: selected ? "checkmark" : UltracodeStyle.symbol)
}
}
/// Auto-approve mode for the chat about to be started; defaults to the app-wide
/// setting until the user explicitly toggles it. Mirrors the in-session button.
private var effectiveAuto: Bool { autoOverride ?? store.defaultAuto }
private var autoToggle: some View {
Button {
autoOverride = !effectiveAuto
} label: {
Label("Auto", systemImage: effectiveAuto ? "bolt.fill" : "bolt.slash")
.foregroundStyle(effectiveAuto ? palette.accent : Color.secondary)
}
.buttonStyle(.bordered)
.fixedSize()
.help(effectiveAuto
? "Auto-approve mode on: the new chat auto-approves safe actions; destructive ones still ask."
: "Manual approvals: every gated tool in the new chat asks first.")
}
/// Whether autoship can be enabled for the new chat: only Nucleic Control projects may
/// autoship (the hardened, sandboxed path). Mirrors the in-session gate.
private var shipAvailable: Bool { selectedProject?.isNucleicControlled == true }
/// Autoship for the chat about to be started; defaults to the app-wide setting, but is
/// clamped off for a non-Control project so the UI never promises a merge Core won't run.
private var effectiveShip: Bool { (shipOverride ?? store.defaultAutoShip) && shipAvailable }
private var shipToggle: some View {
Button {
guard shipAvailable else { return }
shipOverride = !effectiveShip
} label: {
Label("Merge", systemImage: effectiveShip ? "shippingbox.fill" : "shippingbox")
.foregroundStyle(effectiveShip ? palette.accent : Color.secondary)
}
.buttonStyle(.bordered)
.fixedSize()
.disabled(!shipAvailable)
.help(shipAvailable
? (effectiveShip
? "Autoship on: when the new chat's agent finishes, squash-merge its branch into the destination branch via the merge queue (keeps Auto on)."
: "Autoship off: finished work waits for a manual merge.")
: "Autoship requires Nucleic Control — clone or move this project under Nucleic Control to enable it.")
// macOS suppresses .help tooltips on disabled controls, so overlay an enabled,
// transparent hit area to carry the explanation when Merge is grayed out off-control.
.overlay {
if !shipAvailable {
Color.clear
.contentShape(Rectangle())
.help("Autoship/automerge is only available for Nucleic Control projects — clone or move this project under Nucleic Control to enable it.")
}
}
}
private var branchMenu: some View {
Menu {
ForEach(branches, id: \.self) { name in
Button { selectedBranch = name } label: {
choiceLabel(name, isSelected: name == effectiveBranch,
isDefault: name == selectedProject?.defaultBranch.value)
}
}
} label: {
// Mirror the project selector to its left (icon + name + chevron) so the
// two read as the same control; a plain Text label rendered as a near-
// invisible white field against the chat bar.
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.branch")
Text(effectiveBranch)
Image(systemName: "chevron.down").font(.caption2)
}
}
.menuStyle(.button)
.fixedSize()
.disabled(branches.isEmpty)
.help("Base branch the new chat starts from")
}
private func start() {
let trimmed = store.homeDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard let project = selectedProject, !trimmed.isEmpty else { return }
store.homeDraft = ""
let text = SingularityPreparation.prepare(trimmed)
let base = GitRef(effectiveBranch)
let worktree = useWorktree
let auto = effectiveAuto
let ship = effectiveShip
Task {
await store.startChat(
in: project, message: text, model: effectiveModel, effort: effectiveEffort,
base: base, useWorktree: worktree, auto: auto, autoShip: ship)
}
}
private var subtitle: String {
let stats = store.dashboard
if stats.chats == 0 {
return "No chats yet — pick a project below and start one from the chat bar."
}
let projectWord = stats.projects == 1 ? "project" : "projects"
return "\(stats.activeChats) active of \(stats.chats) chats across \(stats.projects) \(projectWord)."
}
}
/// A day-streak indicator aligned to the greeting: a lightning bolt and the run length.
/// The bolt charges hotter — bigger, brighter, with a stronger electric glow — as
/// the streak lengthens. Dims to grey when the streak is broken (0 days).
struct StreakBadge: View {
static let showKey = "nucleic.home.showStreak"
let days: Int
/// Drives the repeating charge pulse; toggled on appear so the animation runs forever.
@State private var charged = false
/// 0 for a one-day streak, ramping to 1 around three weeks — how charged to glow.
private var intensity: Double { min(1, Double(max(0, days - 1)) / 20) }
/// Electric gradient from a hot white tip down to a deep electric blue base. Short
/// streaks read as a muted steel-blue spark; long ones as a vivid cyan→blue arc.
private var boltGradient: LinearGradient {
func lerp(_ lo: Double, _ hi: Double) -> Double { lo + (hi - lo) * intensity }
let tip = Color(hue: lerp(0.55, 0.50), saturation: lerp(0.40, 0.70), brightness: 1.0)
let mid = Color(hue: lerp(0.58, 0.54), saturation: lerp(0.70, 0.95), brightness: 1.0)
let base = Color(hue: lerp(0.62, 0.60), saturation: lerp(0.85, 1.0), brightness: lerp(0.85, 1.0))
return LinearGradient(colors: [tip, mid, base], startPoint: .top, endPoint: .bottom)
}
/// One pulse cycle: a lazy ~1.5s throb for a fresh streak, accelerating to a frantic
/// ~0.35s flicker once fully charged — the higher the streak, the more electric.
private var pulsePeriod: Double { 1.5 - 1.15 * intensity }
var body: some View {
let active = days > 0
// Pulse amplitudes scale with the streak: a barely-there flutter early, a hard
// strobe of scale, brightness, rotation and glow by the time it's fully charged.
let pulsing = active && charged
let scaleAmp = 0.04 + 0.16 * intensity
let tiltAmp = 1.5 + 6.0 * intensity // degrees of jitter
// The bolt never fades back to base: it rests brighter than base and pulses
// brighter still, so the animation only ever lights it up further.
let restBright = active ? 0.08 + 0.10 * intensity : 0
let peakBright = active ? 0.22 + 0.45 * intensity : 0
// Glow likewise rests boosted and crests stronger — never collapsing to base.
let glowFactor = active ? (pulsing ? 1.4 + 1.5 * intensity : 1.2 + 0.4 * intensity) : 1
return HStack(spacing: 8) {
Image(systemName: "bolt.fill")
// Grows from ~22pt to ~32pt as the charge intensifies.
.font(.system(size: 22 + 10 * intensity))
.foregroundStyle(active ? AnyShapeStyle(boltGradient) : AnyShapeStyle(Color.secondary))
.brightness(pulsing ? peakBright : restBright)
.scaleEffect(pulsing ? 1 + scaleAmp : 1)
.rotationEffect(.degrees(pulsing ? tiltAmp : 0))
.shadow(color: active ? Color(hue: 0.56, saturation: 0.85, brightness: 1.0).opacity((0.30 + 0.50 * intensity) * glowFactor) : .clear,
radius: active ? (1 + 7 * intensity) * glowFactor : 0)
.animation(active ? .easeInOut(duration: pulsePeriod).repeatForever(autoreverses: true) : .default,
value: pulsing)
.onAppear { charged = true }
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("\(days)")
.font(.system(size: 26, weight: .semibold).monospacedDigit())
Text("day streak")
.font(.system(size: 26, weight: .semibold).monospacedDigit())
.foregroundStyle(.secondary)
}
}
.opacity(active ? 1 : 0.55)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
.help(active
? "\(days)-day activity streak — consecutive days with at least one message."
: "No active streak — send a message today to start one.")
}
}
private struct StatCard: View {
let value: Int
let label: String
let icon: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Image(systemName: icon).foregroundStyle(.secondary)
Text("\(value)").font(.system(size: 26, weight: .semibold).monospacedDigit())
Text(label).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
}
}
/// Squares for the last `weeks` weeks, columns = weeks (old → new), rows = Sun…Sat,
/// shaded by that day's message count.
struct ActivityGrid: View {
@Environment(\.appPalette) private var palette
let activityByDay: [Date: Int]
private let cell: CGFloat = 16
private let gap: CGFloat = 3
private let minWeeks = 16
var body: some View {
GeometryReader { geo in
let weeks = max(minWeeks, Int((geo.size.width + gap) / (cell + gap)))
grid(weeks: weeks)
}
.frame(height: cell * 7 + gap * 6)
}
private func grid(weeks: Int) -> some View {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
let weekday = calendar.component(.weekday, from: today) // 1 = Sun … 7 = Sat
let startOfThisWeek = calendar.date(byAdding: .day, value: -(weekday - 1), to: today)!
let gridStart = calendar.date(byAdding: .day, value: -7 * (weeks - 1), to: startOfThisWeek)!
return HStack(alignment: .top, spacing: gap) {
ForEach(0..<weeks, id: \.self) { column in
VStack(spacing: gap) {
ForEach(0..<7, id: \.self) { row in
let date = calendar.date(byAdding: .day, value: column * 7 + row, to: gridStart)!
cellView(date: date, today: today)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func cellView(date: Date, today: Date) -> some View {
let isFuture = date > today
let count = activityByDay[date] ?? 0
return RoundedRectangle(cornerRadius: 2)
.fill(isFuture ? Color.clear : palette.activity(level: Self.level(count)))
.frame(width: cell, height: cell)
.help(isFuture ? "" : "\(count) message\(count == 1 ? "" : "s") · \(Self.dayLabel(date))")
}
static func level(_ count: Int) -> Int {
switch count {
case 0: return 0
case 1...2: return 1
case 3...5: return 2
default: return 3
}
}
static func color(level: Int) -> Color {
switch level {
case 0: return Color.secondary.opacity(0.15)
case 1: return Color.green.opacity(0.4)
case 2: return Color.green.opacity(0.7)
default: return Color.green
}
}
private static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
static func dayLabel(_ date: Date) -> String { formatter.string(from: date) }
}