- 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]>
224 lines
8.9 KiB
Swift
224 lines
8.9 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|