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 } } }