Files
nucleic/Sources/NucleicApp/RootView.swift
T
abkslmandClaude Opus 4.8 99b5bdaa8e Auto mode, model SKUs + defaults, favorite/archive/delete, summary/title/padding
- Auto mode (per the safety question): wired to Claude's built-in --permission-mode
  auto — its own classifier auto-approves safe actions and routes destructive ones
  to our approval UI ("all but destructive"). Per-chat toggle (bolt) + a global
  "start new chats in Auto" default. Session.auto + RunSpec/ResumeSpec.autoApprove +
  DB v3 column; transcript still shows every auto-approved call.
- Model/effort: pickers list full SKUs (claude-opus-4-8, …[1m], sonnet, haiku) and
  always show the effective concrete value (never "Default"); Settings adds default
  model / default effort / default-auto; new chats inherit them.
- Favorite / archive / delete chats via right-click context menu AND swipe actions;
  favorites sort first with a star, archived collapse into a per-project section,
  archiving closes the open chat. Session.favorite/archived (DB v3).
- Summary reworked into sectioned, glanceable Markdown (Now / Done / Next) rendered
  with MarkdownText; window title shows the project name (app name gone); padding
  below project names.

Tests: newChatsInheritDefaults, favoriteArchiveDeleteChat. Full suite 92 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-12 14:38:06 -07:00

224 lines
8.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import NucleicCore
/// Sidebar (projects → sessions) + detail, the macOS information architecture
/// from UX_MACOS.
struct RootView: View {
@Environment(AppStore.self) private var store
@State private var showingAddProject = false
@State private var renamingProject: Project?
@State private var renameProjectDraft = ""
@State private var deletingProject: Project?
var body: some View {
@Bindable var store = store
NavigationSplitView {
List(selection: $store.openSessionID) {
if store.projects.isEmpty {
Text("Add a project to begin").foregroundStyle(.secondary)
}
ForEach(store.projects) { project in
Section {
let sessions = store.summaries(for: project.id)
if sessions.isEmpty {
Text("No chats yet").font(.caption).foregroundStyle(.secondary)
}
ForEach(sessions) { summary in
sessionRow(summary)
}
let archived = store.archivedSummaries(for: project.id)
if !archived.isEmpty {
DisclosureGroup("Archived (\(archived.count))") {
ForEach(archived) { summary in
sessionRow(summary).opacity(0.7)
}
}
.font(.caption)
}
} header: {
HStack {
Text(project.name)
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
.textCase(nil)
.contextMenu {
Button("Rename…") {
renameProjectDraft = project.name
renamingProject = project
}
Divider()
Button("Delete Project…", role: .destructive) {
deletingProject = project
}
}
Spacer()
Button {
Task { await store.newSession(in: project) }
} label: {
Image(systemName: "plus.circle")
}
.buttonStyle(.plain)
.help("New chat in \(project.name)")
}
.padding(.bottom, 6)
}
}
}
.navigationTitle("Nucleic")
.frame(minWidth: 240)
.toolbar {
ToolbarItem {
Button { showingAddProject = true } label: {
Label("Add Project", systemImage: "folder.badge.plus")
}
}
}
} detail: {
Group {
if store.openSessionID != nil {
SessionDetailView()
} else {
HomeView()
}
}
.navigationTitle(windowTitle)
}
.sheet(isPresented: $showingAddProject) { AddProjectSheet() }
.alert("Rename project", isPresented: .init(
get: { renamingProject != nil },
set: { if !$0 { renamingProject = nil } })
) {
TextField("Name", text: $renameProjectDraft)
Button("Cancel", role: .cancel) { renamingProject = nil }
Button("Rename") {
if let project = renamingProject {
Task { await store.renameProject(project.id, to: renameProjectDraft) }
}
renamingProject = nil
}
}
.confirmationDialog(
"Delete “\(deletingProject?.name ?? "")”?",
isPresented: .init(get: { deletingProject != nil }, set: { if !$0 { deletingProject = nil } }),
titleVisibility: .visible
) {
Button("Delete Project", role: .destructive) {
if let project = deletingProject {
Task { await store.deleteProject(project.id) }
}
deletingProject = nil
}
Button("Cancel", role: .cancel) { deletingProject = nil }
} message: {
Text("Removes the project and all its chats, worktrees, and branches. Your repository itself is not affected.")
}
.overlay(alignment: .bottom) {
if let error = store.lastError {
Text(error)
.font(.caption).padding(8)
.background(.red.opacity(0.85), in: .rect(cornerRadius: 8))
.foregroundStyle(.white).padding()
}
}
}
/// Window title = current project's name (replaces the app/exe name).
private var windowTitle: String {
if let projectID = store.openSession?.projectID, let project = store.project(projectID) {
return project.name
}
return store.projects.first?.name ?? "Nucleic"
}
@ViewBuilder
private func sessionRow(_ summary: SessionSummary) -> some View {
SessionRow(summary: summary)
.tag(Optional(summary.id))
.contextMenu {
Button(summary.favorite ? "Unfavorite" : "Favorite",
systemImage: summary.favorite ? "star.slash" : "star") {
Task { await store.setSessionFavorite(summary.id, !summary.favorite) }
}
Button(summary.archived ? "Unarchive" : "Archive",
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") {
Task { await store.setSessionArchived(summary.id, !summary.archived) }
}
Divider()
Button("Delete", systemImage: "trash", role: .destructive) {
Task { await store.deleteSession(summary.id) }
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
Task { await store.setSessionFavorite(summary.id, !summary.favorite) }
} label: {
Label(summary.favorite ? "Unfavorite" : "Favorite",
systemImage: summary.favorite ? "star.slash" : "star")
}
.tint(.yellow)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
Task { await store.deleteSession(summary.id) }
} label: {
Label("Delete", systemImage: "trash")
}
Button {
Task { await store.setSessionArchived(summary.id, !summary.archived) }
} label: {
Label(summary.archived ? "Unarchive" : "Archive",
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
}
.tint(.gray)
}
}
}
struct SessionRow: View {
let summary: SessionSummary
var body: some View {
HStack(spacing: 8) {
Circle().fill(summary.status.color).frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
if summary.favorite {
Image(systemName: "star.fill").font(.caption2).foregroundStyle(.yellow)
}
Text(summary.title).lineLimit(1)
}
HStack(spacing: 4) {
Text(summary.status.label).font(.caption2).foregroundStyle(.secondary)
if summary.auto {
Image(systemName: "bolt.fill").font(.system(size: 8)).foregroundStyle(.orange)
}
}
}
Spacer()
if summary.pendingApprovalCount > 0 {
Image(systemName: "pause.circle.fill").foregroundStyle(.orange)
}
if let diff = summary.diffStat, diff.filesChanged > 0 {
Text("+\(diff.added) \(diff.removed)")
.font(.caption2.monospaced()).foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
extension SessionStatus {
var label: String { displayName }
var color: Color {
switch self {
case .idle, .provisioning: .gray
case .running: .blue
case .awaitingApproval, .awaitingInput: .orange
case .finished: .green
case .interrupted: .yellow
case .error: .red
}
}
}