Files
nucleic/Sources/NucleicApp/RootView.swift
T
abkslmandClaude Opus 4.8 1ea002e894 Show an icon on the sidebar when autoship fails/disables for a session
When autoship aborts on a merge conflict or merge error it turns itself off; the
chat's sidebar entry now shows an exclamation-triangle marker until the user
re-enables autoship. Unlike the shipped marker (lastEventWasAutoship), this is
sticky session state, not tied to the latest event — a failure needs attention
and shouldn't vanish the moment something else happens.

- Session + SessionSummary gain `autoShipFailed`; persisted via migration
  v13-autoship-failed.
- SessionController.markAutoShipFailed() turns autoship off and sets the marker
  atomically; the merge queue's conflict/failed paths now call it instead of a
  plain setAutoShip(false). setAutoShip(true) clears the marker (re-opt-in). A
  clean user toggle-off leaves it untouched.
- RootView.SessionRow renders the warning icon, taking precedence over the
  shipped icon.

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

524 lines
26 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 showingLockQueue = false
@State private var renamingProject: Project?
@State private var renameProjectDraft = ""
@State private var deletingProject: Project?
/// Global panel-layout state (left/right/bottom panels around the chat), shared
/// by every open chat. Created here so it outlives navigation between sessions.
@State private var panels = PanelLayoutStore()
/// Shared "open file" selection, written by Files panels and read by Editor
/// panels so clicking a file opens it in the editor.
@State private var fileSelection = FileSelectionStore()
/// Shared editing buffer for the Editor pane, so its header controls and body
/// drive the same state.
@State private var editor = EditorModel()
/// Current sidebar width, used to drop the second trailing swipe action (Delete)
/// when the column is too narrow to fit both buttons without clipping.
@State private var sidebarWidth: CGFloat = 0
/// Owns the AppKit sidebar item so the column opens/closes only on our toggle,
/// never on its own when the window resizes (see `SidebarColumnController`).
@State private var sidebarColumn = SidebarColumnController()
/// Projects whose "Archived" group is expanded. Tracked here (rather than a
/// `DisclosureGroup`) so the chevron aligns with the session rows' leading edge.
@State private var expandedArchive: Set<Project.ID> = []
@AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
@AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.dark.rawValue
private var textSize: AppTextSize { AppTextSize(rawValue: textSizeRaw) ?? .medium }
private var appearance: AppAppearance { AppAppearance(rawValue: appearanceRaw) ?? .dark }
private var palette: AppPalette {
AppPalette.make(ColorVisionMode(rawValue: colorVisionRaw) ?? .standard)
}
var body: some View {
@Bindable var store = store
NavigationSplitView {
// We drive selection manually (tap sets `openSessionID`) and draw our own
// gray highlight via `.listRowBackground` below, rather than binding
// `List(selection:)`. On macOS the built-in sidebar selection paints a
// capsule in the *system* accent color (a saturated blue), which `.tint`
// cannot override and which drowns out each row's status dot and icons.
List {
if store.projects.isEmpty {
Text("Add a project to begin").foregroundStyle(.secondary)
}
// Flat rows rather than `Section { } header: { }`: the macOS `.sidebar`
// list style indents everything nested inside a Section (to reserve room
// for a section collapse triangle), which pushes session titles right
// until they clip. Rendering the header as a top-level row and the
// sessions as its siblings keeps them flush to the column edge.
ForEach(store.projects) { project in
projectHeader(project)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
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 {
archivedHeader(for: project, count: archived.count)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
if expandedArchive.contains(project.id) {
ForEach(archived) { summary in
sessionRow(summary).opacity(0.7)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
}
}
}
// Inset the row content from the column edges so text/icons aren't flush
// against the sides. Applied before `.background` so the sidebar fill stays
// full-bleed — only the scrollable content is padded.
.safeAreaPadding(.horizontal, 5)
.scrollContentBackground(.hidden)
.background(AppTheme.sidebar)
.background {
GeometryReader { proxy in
Color.clear
.onAppear { sidebarWidth = proxy.size.width }
.onChange(of: proxy.size.width) { _, new in sidebarWidth = new }
}
}
.background { SidebarColumnConfigurator(controller: sidebarColumn) }
.navigationTitle("Nucleic")
.frame(minWidth: 320)
// Replace NavigationSplitView's built-in toggle with our own as the first
// leading item, so the sidebar button sits immediately right of the traffic
// lights. We toggle the AppKit split-view item directly (see
// `SidebarColumnController`) rather than a `columnVisibility` binding on
// purpose: a SwiftUI visibility binding makes these leading toolbar items
// reflow (the cluster reorders) when the column collapses, whereas driving
// the item leaves the toolbar untouched — the button stays pinned beside the
// traffic lights through open / close / resize. Owning the item also lets us
// pin the column open against AppKit's automatic collapse-on-resize.
.toolbar(removing: .sidebarToggle)
.toolbar {
ToolbarItem {
Button(action: toggleSidebar) {
Label("Toggle Sidebar", systemImage: "sidebar.leading")
// Color the symbol explicitly: the split view's `.tint`
// doesn't reliably reach items hoisted into the window
// toolbar, so in light mode these rendered as a near-
// invisible white. `.primary` adapts to the standard
// label color — black in light mode, white in dark.
.foregroundStyle(.primary)
}
.help("Show or hide the sidebar")
}
ToolbarItem {
Button { store.goHome() } label: {
Label("Home", systemImage: "house")
.foregroundStyle(.primary)
}
.help("Back to the dashboard")
// Intentionally always enabled. A conditional `.disabled` here makes
// the icon flicker — a disabled toolbar item cross-fades between its
// dimmed and active appearance every time the toolbar re-renders
// (which is often, as the sidebar tracks live session state). On the
// dashboard goHome() is a harmless no-op, so there's nothing to guard.
}
ToolbarItem {
Button { showingAddProject = true } label: {
Label("Add Project", systemImage: "folder.badge.plus")
.foregroundStyle(.primary)
}
.help("Add a project")
}
ToolbarItem {
Button { showingLockQueue = true } label: {
// Amber while agents are queued for access, so the toolbar
// surfaces contention at a glance; the accent otherwise.
Label("File Locks", systemImage: store.sessionsWaitingForAccess.isEmpty
? "lock.doc" : "lock.doc.fill")
.foregroundStyle(store.sessionsWaitingForAccess.isEmpty
? Color.primary : palette.attention)
}
.help("See which sessions hold file locks and which are waiting for one")
}
}
} detail: {
VStack(spacing: 0) {
// Pre-prod warning header (red dev / blue beta / gold rc). Scoped to the
// detail pane so the area above the sidebar keeps the plain window chrome;
// renders nothing on a shipping release.
BuildBanner()
Group {
if store.openSessionID != nil {
PaneledChatView()
} else if let projectID = store.openProjectID, let project = store.project(projectID) {
ProjectView(project: project)
} else {
HomeView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(AppTheme.background)
// Transient error bubble (e.g. "Autoship failed: …"). Scoped to the detail
// pane so it centers over the chat interface, not the full window width.
.overlay(alignment: .bottom) {
if let error = store.lastError {
let target = store.lastErrorSessionID
Text(error)
.font(.caption).padding(8)
.background(.red.opacity(0.85), in: .rect(cornerRadius: 8))
.foregroundStyle(.white).padding()
// Click the bubble to dismiss; if it names a responsible chat
// (e.g. an autoship failure), jump to that chat on the way out.
.onTapGesture {
if let target { store.openSessionID = target }
store.lastError = nil
}
.help(target == nil ? "Click to dismiss" : "Click to open the responsible chat")
// …and auto-dismiss after a few seconds so it never sticks. The
// task is keyed on the message so a new error restarts the timer.
.task(id: error) {
try? await Task.sleep(for: .seconds(6))
if store.lastError == error { store.lastError = nil }
}
.transition(.opacity)
}
}
.animation(.default, value: store.lastError)
.navigationTitle(windowTitle)
// Attach content sheets to the detail pane, not the split-view root. A sheet
// hosted at the root scopes its presentation scrim to the whole window —
// including the sidebar's title-bar region — which paints an odd overlay over
// the traffic lights. Hosting from the detail pane confines it there.
.sheet(isPresented: $showingAddProject) { AddProjectSheet() }
.sheet(isPresented: $showingLockQueue) { LockQueueView() }
.sheet(isPresented: $store.quickTodoPresented) { QuickTodoSheet() }
.sheet(item: $store.editingTodoID) { EditTodoSheet(todoID: $0) }
}
// Pin the unified toolbar's glass so it always renders its material. Left to the
// automatic scroll-edge behavior it intermittently dropped to a flat, unblurred
// fill (showing the BuildBanner color flat beneath it) until a resize or sidebar
// toggle forced it to re-materialize; pinning `.visible` keeps the glass on.
.toolbarBackground(.visible, for: .windowToolbar)
// …and re-render the glass when it goes dormant on occlusion / app idle, which
// pinning the background alone doesn't prevent (see `ToolbarGlassKeeper`).
.background { ToolbarGlassKeeper() }
.preferredColorScheme(appearance.colorScheme)
.dynamicTypeSize(textSize.dynamicTypeSize)
.tint(palette.accent)
.environment(panels)
.environment(fileSelection)
.environment(editor)
.environment(\.appPalette, palette)
// App-level: the conflicting agent may not be the foreground session, so the
// prompt isn't scoped to the open chat. `pendingConflict` is private(set), hence
// a read-only isPresented binding; the content reads the live head of the queue.
.sheet(isPresented: .init(
get: { store.pendingConflict != nil }, set: { _ in })
) {
if let prompt = store.pendingConflict { ConflictSheet(prompt: prompt) }
}
.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.")
}
}
/// Toggle the sidebar through the AppKit split-view item we own (see
/// `SidebarColumnController`) rather than a SwiftUI `columnVisibility` binding —
/// see the toolbar comment for why (keeps the leading toolbar buttons from
/// reflowing when the column collapses). Falls back to the system action until
/// the configurator has located the split view.
private func toggleSidebar() {
if sidebarColumn.hasItem {
sidebarColumn.toggle()
} else {
NSApp.keyWindow?.firstResponder?.tryToPerform(
#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
}
}
/// Window title: the app name on the dashboard, the project name once a chat is
/// open within a project.
private var windowTitle: String {
guard let sessionID = store.openSessionID else {
if let projectID = store.openProjectID, let project = store.project(projectID) {
return project.name
}
return "Nucleic"
}
let projectID = store.openSession?.projectID
?? store.summaries.first(where: { $0.id == sessionID })?.projectID
if let projectID, let project = store.project(projectID) { return project.name }
return "Nucleic"
}
/// Below this sidebar width, the trailing swipe shows Archive only (no Delete),
/// so neither button clips. Above the 320 `minWidth` to leave a comfortable margin.
private let narrowSidebarThreshold: CGFloat = 360
@ViewBuilder
private func projectHeader(_ project: Project) -> some View {
HStack {
Button {
store.openProject(project.id)
} label: {
HStack(spacing: 4) {
Text(project.name)
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
.textCase(nil)
if project.sandbox?.enabled == true {
Image(systemName: "shield.lefthalf.filled")
.font(.caption.weight(.semibold))
.foregroundStyle(palette.accent)
.help("\(project.name) runs sessions in a sandbox container")
}
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("Open \(project.name)'s overview")
.contextMenu {
Button("Rename…") {
renameProjectDraft = project.name
renamingProject = project
}
Divider()
Button("Delete Project…", role: .destructive) {
deletingProject = project
}
}
Button {
Task { await store.newSession(in: project) }
} label: {
Image(systemName: "plus.circle")
.font(.title2)
.foregroundStyle(.primary.opacity(0.85))
}
.buttonStyle(.plain)
.padding(.trailing, 6)
.help("New chat in \(project.name)")
}
.padding(.top, 6)
.padding(.bottom, 6)
}
@ViewBuilder
private func archivedHeader(for project: Project, count: Int) -> some View {
let isExpanded = expandedArchive.contains(project.id)
Button {
withAnimation(.easeInOut(duration: 0.22)) {
if isExpanded { expandedArchive.remove(project.id) }
else { expandedArchive.insert(project.id) }
}
} label: {
// Chevron sized to sit at the same leading gutter as the session rows'
// status dot, so the archived group lines up with everything above it.
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 8, alignment: .leading)
Text("Archived (\(count))")
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.vertical, 4)
}
@ViewBuilder
private func sessionRow(_ summary: SessionSummary) -> some View {
// The selection background lives inside SessionRow (not in
// `.listRowBackground`) so it travels with the content when swiped —
// a `.listRowBackground` is anchored to the cell and would stay fixed
// while `.swipeActions` slides only the row content.
SessionRow(summary: summary, isSelected: store.openSessionID == summary.id)
.contentShape(Rectangle())
.onTapGesture { store.openSessionID = summary.id }
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.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) {
// In a narrow sidebar both buttons can't fit, so the second one
// (Delete) clips off-screen. Keep only Archive there; Delete is
// still reachable from the context menu.
if sidebarWidth >= narrowSidebarThreshold {
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 {
@Environment(\.appPalette) private var palette
let summary: SessionSummary
var isSelected: Bool = false
var body: some View {
HStack(spacing: 6) {
Circle().fill(palette.status(summary.status, disposition: summary.disposition))
.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)
// A completed chat the user hasn't opened reads as "unread" —
// emphasize its title alongside the trailing dot below.
.fontWeight(summary.unseenCompletion ? .semibold : .regular)
}
HStack(spacing: 4) {
Text(summary.status.label(disposition: summary.disposition))
.font(.caption2).foregroundStyle(.secondary)
if summary.auto {
// Against the mid-gray selection highlight the saturated teal
// accent nearly disappears; switch to high-contrast white when
// the row is selected so the bolt stays legible.
Image(systemName: "bolt.fill").font(.system(size: 8))
.foregroundStyle(isSelected ? Color.white : palette.accent)
}
}
}
Spacer()
if summary.autoShipFailed {
// Autoship aborted on a conflict/error and turned itself off — a sticky
// "needs attention" marker that persists until the user re-enables
// autoship. Takes precedence over the shipped marker below (a failure
// note is itself the latest autoship event, but the warning is what
// matters here).
Image(systemName: "exclamationmark.triangle.fill").font(.caption2)
.foregroundStyle(palette.attention)
.accessibilityLabel("Autoship failed — turned off")
} else if summary.lastEventWasAutoship {
// Autoship was the most recent thing to happen here (e.g. the branch
// shipped) and nothing's happened since — a glanceable marker that this
// chat reached the merge queue. Cleared once a new turn lands. White on
// the selection highlight so it stays legible.
Image(systemName: "shippingbox.fill").font(.caption2)
.foregroundStyle(isSelected ? Color.white : palette.accent)
.accessibilityLabel("Autoship ran")
}
if summary.pendingApprovalCount > 0 {
Image(systemName: "pause.circle.fill").foregroundStyle(palette.attention)
}
if let diff = summary.diffStat, diff.filesChanged > 0 {
Text("+\(diff.added) \(diff.removed)")
.font(.caption2.monospaced()).foregroundStyle(.secondary)
}
if summary.unseenCompletion {
// Unread dot: the chat finished its work and the user hasn't opened it
// yet. Cleared the moment they do. White on the selection highlight so
// it stays legible (the row can't be both selected and unread for long,
// but selection can land before the open-driven clear).
Circle().fill(isSelected ? Color.white : palette.accent)
.frame(width: 7, height: 7)
.accessibilityLabel("Unread — completed")
}
}
.padding(.vertical, 5)
// A small leading inset keeps the status dot off the selection highlight's
// left edge; the trailing inset keeps the diff stats off the right edge.
// The selection highlight below spans the full width.
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
if isSelected {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(AppTheme.selection)
}
}
.padding(.vertical, 1)
.contentShape(Rectangle())
}
}
extension SessionStatus {
var label: String { displayName }
/// Sidebar label, refined by the last turn's disposition: an idle session whose
/// agent finished the work reads as "Done" rather than "Awaiting input".
func label(disposition: TurnDisposition?) -> String {
if self == .awaitingInput, disposition == .completed { return "Done" }
return displayName
}
}