Files
nucleic/Sources/NucleicApp/RootView.swift
T

1307 lines
75 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
/// Which of the three things the sidebar's top region shows — recent chats (default),
/// the Apple Intelligence queue, or the file-lock queue. Driven by `SidebarModeSwitcher`.
@State private var sidebarMode: SidebarMode = .recents
/// Measured height of one recent-session row, sampled from a hidden row (see
/// `cardHeightSampler`). The swappable top region (recents / queue panels) is capped at
/// eight of these so it never crowds out the project tree below. Seeded with a sensible
/// default for the brief moment before the sample lands (or when there are no recents).
@State private var sessionCardHeight: CGFloat = 44
/// Polls the on-device Apple Foundation Model queue so the mode switcher can show, at a
/// glance, whether the model is busy and how much soft-AI work is waiting behind it.
@State private var afmActivity = AFMActivityMonitor.shared
@State private var renamingProject: Project?
@State private var renameProjectDraft = ""
@State private var deletingProject: Project?
/// The project whose "Convert to Nucleic Control…" sheet is open (nil = none).
@State private var convertingProject: 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()
/// Channel for typing a command into the open chat's terminal — the in-chat "Log in"
/// button uses it to run `claude /login` in the built-in terminal.
@State private var terminalCommands = TerminalCommandBus()
/// 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> = []
/// Whether the "Archived Projects" group at the bottom of the sidebar is expanded.
@State private var archivedProjectsExpanded = false
/// Projects collapsed down to their top `collapsedSessionLimit` chats (favorites +
/// most-recent). A "Show N more" footer toggles membership; absent ⇒ all chats shown.
@State private var collapsedProjects: Set<Project.ID> = []
/// Projects whose nvrsion trunk is mid-promotion from the sidebar integrate button —
/// drives that button's spinner and disabled state so a double-tap can't double-promote.
@State private var promotingProjects: 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 {
// Lavender accent only while operating inside a Nucleic Control project; teal otherwise.
// Resolved through `projectSummary` so a Control project on a peer Mac paints the same
// lavender a local one does (mesh session sync — remote reads as local).
let controlled = store.contextProjectID
.flatMap { store.projectSummary($0)?.isNucleicControlled } ?? false
return AppPalette.make(ColorVisionMode(rawValue: colorVisionRaw) ?? .standard,
controlled: controlled)
}
/// The accent palette for a *specific* project's sidebar rows: lavender for a Nucleic
/// Control project, teal for every other project. The sidebar lists many projects at once,
/// so its rows can't inherit the globally-active project's `palette` (which would paint
/// non-control projects lavender whenever a Control project is open) — each project's header
/// and session rows resolve their own accent from their own control status.
private func palette(for project: ProjectSummary) -> AppPalette {
AppPalette.make(ColorVisionMode(rawValue: colorVisionRaw) ?? .standard,
controlled: project.isNucleicControlled)
}
var body: some View {
@Bindable var store = store
NavigationSplitView {
VStack(spacing: 0) {
// An auto-update prompt, pinned above everything when a background check has
// found an update (invisible otherwise). Stays put while the list scrolls so it
// can't be missed; click to update and relaunch. See `UpdateSidebarBanner`.
UpdateSidebarBanner()
// The mode switcher leads the sidebar list (recents / AI / Control) as its
// first row, so it scrolls away with the content rather than staying pinned
// above it. It used to sit here in the VStack, outside the List's scroll.
sidebarList
}
.background {
GeometryReader { proxy in
Color.clear
.onAppear { sidebarWidth = proxy.size.width }
.onChange(of: proxy.size.width) { _, new in sidebarWidth = new }
}
}
// Back the column with Safari's sidebar material (see `SidebarGlassBackground`).
// NavigationSplitView's built-in Liquid Glass pane mirrors the *detail pane's*
// content behind the sidebar, so the BuildBanner's saturated stripe refracted a
// smeared tint across the sidebar's top. This explicit backdrop sits between that
// mirror layer and the sidebar content: it blurs only what's behind the window
// (the desktop — the window is non-opaque, see `WindowTranslucencyConfigurator`),
// never in-window content, which is exactly how Safari's sidebar stays neutral
// beside vividly-colored page content. Extends through the safe area so the
// title-bar region above the list is shielded too.
.background { SidebarGlassBackground().ignoresSafeArea() }
// Sample one recent-row's height (hidden) so the swappable section can cap
// itself at eight cards regardless of the user's text-size setting.
.background(alignment: .top) { cardHeightSampler }
.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")
}
}
} 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 if let projectID = store.openProjectID,
let summary = store.projectSummary(projectID), summary.isRemote {
// A project on a peer Mac (mesh session sync): the same overview page,
// driven by the mirrored summaries instead of local records.
RemoteProjectView(project: summary)
} 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
// A benign note (e.g. "nothing to promote") is informational, not a failure —
// paint it green so the red styling doesn't make a clean result look like a problem.
let bubble: Color = store.lastErrorIsBenign ? .green : .red
Text(error)
.font(.caption).padding(8)
.background(bubble.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: $store.quickTodoPresented) { QuickTodoSheet() }
.sheet(isPresented: $store.newChatComposerPresented) { NewChatComposerSheet() }
.sheet(item: $store.editingTodoID) { EditTodoSheet(todoID: $0) }
.sheet(item: $convertingProject) { ConvertToControlSheet(project: $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.
// Keep the on-device-model activity light live the whole time the window is up,
// so the toolbar reflects soft-AI work running in any session, not just the open one.
.task { await afmActivity.run() }
.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() }
// Make the window itself non-opaque so the sidebar's behind-window glass material
// samples the desktop, not the window's own content. Without this the main window
// stays opaque (the detail pane paints a full-bleed `AppTheme.background`), so the
// sidebar material has nothing behind it to blur and renders as a flat/opaque fill
// that smears in the adjacent BuildBanner color. The Settings window has no such
// opaque fill, which is why its identical sidebar material reads as clear glass.
.background { WindowTranslucencyConfigurator() }
.preferredColorScheme(appearance.colorScheme)
.dynamicTypeSize(textSize.dynamicTypeSize)
.tint(palette.accent)
.environment(panels)
.environment(fileSelection)
.environment(terminalCommands)
.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: {
if deletingProject?.isNucleicControlled == true {
Text("Removes the project and all its chats, worktrees, and branches. Because this is a Nucleic Control project, its files are permanently deleted from disk.")
} else {
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)
}
}
/// Switch the sidebar's top region to `newMode`, animating both the switcher's pill and
/// the swap of the rows beneath it. The swappable rows carry the same
/// `.move(edge:.top)`+opacity transition the archived rows in this List use (which the
/// macOS list animates reliably), so the new area glides in from the top.
private func selectMode(_ newMode: SidebarMode) {
guard newMode != sidebarMode else { return }
withAnimation(.easeInOut(duration: 0.28)) {
sidebarMode = newMode
}
}
/// The mode-dependent leading region of `sidebarList` — the "Recents area" the switcher
/// drives. Only this swaps between modes; the project tree below is unaffected. Each
/// branch is a top-level List row (not nested in a `Section`) to keep the flush-edge
/// layout, and the two queue panels render `embedded` so they scroll with the list.
@ViewBuilder
private var topModeSection: some View {
switch sidebarMode {
case .recents:
// Lead with a status header, the way the AI and Control panels open with
// their own one-line summary (running/idle, lock counts) and a divider. Here
// it's a glanceable rollup of the recent chats — how many there are, how many
// are working, how many are waiting on you — so the space above the list
// carries information instead of sitting blank.
let recents = store.recentSummaries()
recentsHeader(recents)
// The queue panes carry the divider's lower gap inside their single row; the
// Recents header is its own list row, so its divider reads tighter against the
// first session below. Add the matching gap explicitly.
.padding(.bottom, 8)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
// "Recents" pins the most-recently-active chats across every project, each
// tagged with its project name (see `recentsRow`), so the user lands back on
// what they were doing without hunting per-project.
// Key these rows by a Recents-specific identity, NOT the raw `SessionID`.
// A recent session also appears under its own project further down this
// same `List`, so sharing `id` across both rows is a duplicate-ID bug:
// SwiftUI reuses one row's content for the other, which is why the
// project-name subtext would drop out of Recents or bleed onto the
// in-project row. The distinct id keeps the two rows independent.
ForEach(recents, id: \.recentsRowID) { summary in
recentsRow(summary)
.transition(.move(edge: .top).combined(with: .opacity))
}
case .intelligence:
SidebarIntelligencePanel(bodyHeight: sectionMaxHeight)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.transition(.move(edge: .top).combined(with: .opacity))
case .control:
SidebarControlPanel(bodyHeight: sectionMaxHeight)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
/// The sidebar's main List: a swappable "Recents area" at the top (driven by the mode
/// switcher) sitting above the always-present per-project session tree. Both share one
/// scroll so the column reads as a single list. Pulled out of `body` so it stays legible.
@ViewBuilder
private var sidebarList: some View {
// 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 {
// The mode switcher rides as the list's first row (rather than pinned in the VStack
// above) so it scrolls away with the content. Match the session rows' content inset
// (8 — see `SessionRow` and the embedded queue panels) so the bar's edges line up
// with everything below.
SidebarModeSwitcher(
mode: sidebarMode,
afmActive: afmActivity.hasActivity,
afmWaiting: afmActivity.waiting,
afmFrozen: afmActivity.frozen,
deadlocked: !store.deadlockedSessions.isEmpty
) { selectMode($0) }
.padding(.horizontal, 8).padding(.top, 8).padding(.bottom, 8)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
// Only this leading region changes with the mode; everything below is constant.
// No dedicated spacer below the pane area — a separate list row added the list's own
// inter-row spacing above and below it, which (with the project header's top inset)
// read far too tall. The first project header's top inset carries the separation.
topModeSection
if store.sidebarProjects.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.
//
// One tree for the whole mesh (mesh session sync): `sidebarProjects` interleaves
// every connected peer Mac's projects with this Mac's, sorted by name, and
// `summaries(for:)` serves both origins — so a remote project renders through
// exactly these rows, differentiated only by its header's globe badge. Rows are
// keyed origin-qualified (`rowID` / `sidebarRowID`): a moved session's local
// tombstone and its live twin mirrored back from its new owner share a raw ID,
// and duplicate `ForEach` IDs make SwiftUI bleed one row's content into the other.
ForEach(store.sidebarProjects, id: \.rowID) { project in
// While the project's repo is relocating, lock the whole block: its
// sessions gray out and stop responding to taps/swipes.
let isMoving = store.moveProgress(of: project.id) != nil
// Each project's rows carry their own accent (lavender only for Control
// projects) rather than inheriting the globally-active project's palette, so a
// non-control project's icons stay teal even while a Control project is open.
let pal = palette(for: project)
projectHeader(project)
.environment(\.appPalette, pal)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
let sessions = store.summaries(for: project)
if sessions.isEmpty {
Text("No chats yet").font(.caption).foregroundStyle(.secondary)
}
// Collapsed projects show only their top few chats; the footer below toggles it.
let isCollapsed = collapsedProjects.contains(project.id)
let visibleSessions = isCollapsed ? Array(sessions.prefix(collapsedSessionLimit))
: sessions
ForEach(visibleSessions, id: \.sidebarRowID) { summary in
sessionRow(summary)
.environment(\.appPalette, pal)
.opacity(isMoving ? 0.45 : 1)
.disabled(isMoving)
}
// Only worth offering the collapse once there's something hidden by it.
if sessions.count > collapsedSessionLimit {
collapseToggle(for: project, total: sessions.count, collapsed: isCollapsed)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
let archived = store.archivedSummaries(for: project)
if !archived.isEmpty {
archivedHeader(for: project, count: archived.count)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
if expandedArchive.contains(project.id) {
ForEach(archived, id: \.sidebarRowID) { summary in
sessionRow(summary)
.environment(\.appPalette, pal)
.opacity(isMoving ? 0.45 : 0.7)
.disabled(isMoving)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
}
}
if !store.archivedProjects.isEmpty {
archivedProjectsHeader(count: store.archivedProjects.count)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
if archivedProjectsExpanded {
ForEach(store.archivedProjects) { project in
archivedProjectRow(project)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
}
}
// Pin the sidebar list style: the List used to be the split view's column root
// (which applied `.sidebar` automatically), but nesting it under the mode switcher
// would otherwise drop it to the default inset style and break the flush-edge,
// flat-row layout the comments above depend on.
.listStyle(.sidebar)
// 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)
}
/// A hidden, non-interactive recent-session row used only to measure one card's height
/// into `sessionCardHeight` (rendered outside the List so its geometry reads cleanly).
/// Falls back silently to the seeded default when there are no recents to sample.
@ViewBuilder
private var cardHeightSampler: some View {
if let sample = store.recentSummaries().first {
SessionRow(summary: sample, projectName: store.projectSummary(sample.projectID)?.name,
isOrchestraActive: store.orchestraActive(sample))
.fixedSize(horizontal: false, vertical: true)
.opacity(0)
.allowsHitTesting(false)
.background {
GeometryReader { geo in
Color.clear
.onAppear { sessionCardHeight = geo.size.height }
.onChange(of: geo.size.height) { _, h in sessionCardHeight = h }
}
}
}
}
/// Maximum height for the swappable top region — eight recent-session cards' worth.
/// Recents already cap at eight, so this mainly bounds the queue panels.
private var sectionMaxHeight: CGFloat { sessionCardHeight * 8 }
/// Window title: the app name on the dashboard, the project name once a chat is
/// open within a project. Resolved through `projectSummary` so a chat on a peer Mac
/// titles the window with its project name exactly like a local one.
private var windowTitle: String {
guard let sessionID = store.openSessionID else {
if let projectID = store.openProjectID, let project = store.projectSummary(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.projectSummary(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
/// A move with no real progress (no phase change / download-shrink / copy-growth) for this
/// long is shown as stalled. Deliberately generous: the pipeline now emits a liveness
/// heartbeat throughout its opaque phases (iCloud download/conflict scan, bulk copy), so only a
/// genuinely wedged step goes this long without a single update — a slow-but-working move
/// shouldn't flicker to "Stalled" and back.
private let moveStallSeconds: TimeInterval = 45
/// When a project is collapsed, this many of its top chats (favorites + most-recent, the
/// order `summaries(for:)` returns) stay visible above the "Show N more" footer.
private let collapsedSessionLimit = 3
@ViewBuilder
private func projectHeader(_ project: ProjectSummary) -> some View {
// While a project's repo is relocating it's locked: its open/new-chat buttons are
// disabled and the name dims, with a "Moving, this may take a while…" tag to the right of
// the name and a progress bar + liveness/stall indicator + Cancel below.
let move = store.moveProgress(of: project.id)
// The full local record behind this header, when the project lives on this Mac — the
// project-level mutations (rename/convert/archive/delete) act on it. `nil` for a peer
// Mac's project: those verbs manage this Mac's project *list*, so they stay local-only
// (the sessions inside a remote project carry the full verb set).
let localProject = project.isRemote ? nil : store.project(project.id)
// This project's own accent (lavender only if it's a Control project) — not the
// globally-active project's, so a non-control project keeps teal even while a Control
// project is open.
let pal = palette(for: project)
VStack(alignment: .leading, spacing: 4) {
HStack {
Button {
store.openProject(project.id)
} label: {
HStack(spacing: 4) {
Text(project.name)
.font(.title3.weight(.semibold))
.foregroundStyle(move == nil ? .primary : .secondary)
.textCase(nil)
// Nucleic Control projects show an atom where a plain sandboxed project
// shows the shield.
if project.isNucleicControlled {
Image(systemName: "atom")
.font(.caption.weight(.semibold))
.foregroundStyle(Color(red: 0.62, green: 0.51, blue: 0.93))
.help("\(project.name) is a Nucleic Control project")
} else if project.sandboxed {
Image(systemName: "shield.lefthalf.filled")
.font(.caption.weight(.semibold))
.foregroundStyle(pal.accent)
.help("\(project.name) runs sessions in a sandbox container")
}
// nvrsion projects carry the ∞ badge — the same "nvrsion is on" mark
// shown beside the composer in chat — just right of the control symbol.
if project.nvrsionActive {
Image(systemName: "infinity")
.font(.caption.weight(.semibold))
.foregroundStyle(pal.accent)
.help("nvrsion is on for \(project.name) — edits version "
+ "continuously in a shared workspace.")
}
// A project on a peer Mac carries a globe — the *only* mark that
// differentiates it from a local project (mesh session sync) — sitting
// where the Control/nvrsion badges do.
if project.isRemote {
Image(systemName: "globe")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.help("\(project.name) is on \(project.hostLabel ?? "another Mac")"
+ " — mirrored live over the mesh")
}
// While moving, a "this may take a while" tag sits to the right of the
// name (and badge) in place of the open-chevron, since the project's
// locked and can't be opened.
if move == nil {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
} else {
Text("Moving, this may take a while…")
.font(.caption.weight(.semibold))
.foregroundStyle(pal.accent)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(move != nil)
.help(move == nil ? "Open \(project.name)'s overview" : "\(project.name) is moving…")
.contextMenu {
// Project-level mutations act on this Mac's project list, so they apply
// only to a local project — a remote header offers none (its sessions
// still carry the full menu).
if let localProject {
Button("Rename…") {
renameProjectDraft = localProject.name
renamingProject = localProject
}
// A controlled repo is already under ~/.nucleic/control/ and can't be moved
// or released, so offer the conversion only for non-controlled projects.
if !localProject.isNucleicControlled {
Button("Convert to Nucleic Control…") {
convertingProject = localProject
}
}
Divider()
Button("Archive Project") {
Task { await store.setProjectArchived(localProject.id, true) }
}
Button("Delete Project…", role: .destructive) {
deletingProject = localProject
}
}
}
// Where the per-project "new chat" + used to live. nvrsion projects get a
// chat-free "Integrate" action here (promote the shared trunk → real branch);
// every other project leaves this slot empty. Local-only by construction:
// `nvrsionActive` is carried false for a peer Mac's project (promotion runs
// against this Mac's trunk checkout).
if project.nvrsionActive, let localProject {
let promoting = promotingProjects.contains(project.id)
// Integration squashes the shared trunk into the real branch, so it has to
// wait until no chat in this project is mid-turn — promoting under a running
// agent would land a half-written trunk.
let chatsRunning = store.isAnySessionRunning(in: project.id)
// When there's landed work waiting on the trunk, this is the live "Integrate"
// action (up-arrow). Once everything's promoted and the trunk is back in sync,
// it settles into a dimmed checkmark — nothing to integrate, so there's nothing
// to press.
let pending = store.nvrsionHasPendingIntegration(project.id)
// When the trunk has work but the last (auto or manual) integration couldn't
// complete — a conflict or a hard failure — the button turns into a red ✗ that
// stays clickable for a manual retry once the conflict's resolved on the base branch.
let failure = pending ? store.nvrsionIntegrationFailure(project.id) : nil
let integrateHelp = !pending
? "\(project.name)'s nvrsion trunk is fully integrated into "
+ "\(localProject.defaultBranch.value) — nothing to promote."
: failure.map { "\($0) (Couldn't integrate automatically.)" }
?? (chatsRunning
? "Finish or stop \(project.name)'s running chats before integrating."
: "Integrate \(project.name)'s nvrsion trunk into \(localProject.defaultBranch.value) — "
+ "squash the trunk into your real branch as one commit.")
Button {
Task {
promotingProjects.insert(project.id)
await store.promoteNvrsionTrunk(project.id)
promotingProjects.remove(project.id)
}
} label: {
if promoting {
ProgressView().controlSize(.small)
} else if failure != nil {
Image(systemName: "xmark.circle")
.font(.title3)
.foregroundStyle(.red)
} else if pending {
Image(systemName: "arrow.up.to.line")
.font(.title3)
.foregroundStyle(pal.accent)
} else {
Image(systemName: "checkmark.circle")
.font(.title3)
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
// A flagged failure keeps the button live (so the user can retry after resolving),
// even though it isn't auto-eligible while a chat runs.
.disabled(move != nil || promoting || (chatsRunning && failure == nil) || !pending)
.padding(.trailing, 6)
.help(integrateHelp)
}
}
if let move {
// Re-check liveness every 2s (independent of progress events): if no real progress
// has landed for `moveStallSeconds`, flag the move as stalled — the bar turns amber
// and a "Stalled" badge replaces the working spinner, so a genuinely wedged move
// reads apart from a slow-but-working one. While the user is canceling we show a
// calm "Canceling…" spinner instead (an in-flight step may take a moment to wind
// down — that's expected, not a stall). Cancel is offered until it's requested.
TimelineView(.periodic(from: .now, by: 2)) { context in
let stalled = !move.canceling
&& context.date.timeIntervalSince(move.updatedAt) > moveStallSeconds
VStack(alignment: .leading, spacing: 2) {
ProgressView(value: move.fraction)
.progressViewStyle(.linear)
.controlSize(.small)
.tint(stalled ? pal.attention : pal.accent)
HStack(spacing: 5) {
if move.canceling {
ProgressView().controlSize(.mini) // winding down the current step
} else if stalled {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(pal.attention)
Text("Stalled")
.fontWeight(.semibold)
.foregroundStyle(pal.attention)
} else {
ProgressView().controlSize(.mini) // spinning ⇒ actively working
}
Text(move.phase)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 4)
Button("Cancel") { store.cancelMove(project.id) }
.buttonStyle(.borderless)
.controlSize(.small)
.disabled(move.canceling)
.help("Stop this move and leave the original where it is")
}
.font(.caption2)
}
}
.padding(.trailing, 6)
.transition(.opacity)
}
}
.padding(.top, 6)
.padding(.bottom, 6)
.animation(.easeInOut(duration: 0.25), value: move)
}
@ViewBuilder
private func archivedHeader(for project: ProjectSummary, 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)
}
/// Footer disclosure that collapses a busy project down to its top `collapsedSessionLimit`
/// chats (and back). Shown only when there are more chats than that. Mirrors `archivedHeader`'s
/// chevron-and-caption styling so it lines up with the session rows above it; the chevron
/// points down while expanded, right while collapsed.
@ViewBuilder
private func collapseToggle(for project: ProjectSummary, total: Int, collapsed: Bool) -> some View {
Button {
withAnimation(.easeInOut(duration: 0.22)) {
if collapsed { collapsedProjects.remove(project.id) }
else { collapsedProjects.insert(project.id) }
}
} label: {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(collapsed ? 0 : 90))
.frame(width: 8, alignment: .leading)
Text(collapsed ? "Show \(total - collapsedSessionLimit) more" : "Show less")
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.vertical, 4)
}
/// Collapsible header for the "Archived Projects" group pinned at the bottom of the
/// sidebar — mirrors the per-project archived-chats disclosure (`archivedHeader`).
@ViewBuilder
private func archivedProjectsHeader(count: Int) -> some View {
Button {
withAnimation(.easeInOut(duration: 0.22)) { archivedProjectsExpanded.toggle() }
} label: {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(archivedProjectsExpanded ? 90 : 0))
.frame(width: 8, alignment: .leading)
Text("Archived Projects (\(count))")
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.vertical, 4)
}
/// A dimmed row for an archived project: its name (plus the control atom), with a
/// context menu to restore it or delete it outright.
@ViewBuilder
private func archivedProjectRow(_ project: Project) -> some View {
HStack(spacing: 4) {
Text(project.name)
.font(.callout.weight(.medium))
.foregroundStyle(.secondary)
.textCase(nil)
if project.isNucleicControlled {
Image(systemName: "atom")
.font(.caption2.weight(.semibold))
.foregroundStyle(Color(red: 0.62, green: 0.51, blue: 0.93))
}
Spacer(minLength: 0)
}
.contentShape(Rectangle())
.padding(.vertical, 3)
.opacity(0.8)
.contextMenu {
Button("Unarchive Project") {
Task { await store.setProjectArchived(project.id, false) }
}
Divider()
Button("Delete Project…", role: .destructive) {
deletingProject = project
}
}
}
/// The Recents mode's status header — the analogue of the AI / Control panels'
/// summary line: a clock glyph, a terse rollup of the recent chats, and a divider
/// so the header reads apart from the rows beneath it.
@ViewBuilder
private func recentsHeader(_ recents: [SessionSummary]) -> some View {
// Same header chrome as the AI / Control panes (see `SidebarPane`); Recents has no
// trailing control, and its rows flow into the list below rather than into a scroll.
SidebarPaneHeader(icon: "clock", summary: recentsSummary(recents))
}
/// Terse, glanceable summary of the recent chats — the count of chats active in the
/// last 24h, plus the two states worth surfacing up front from the listed rows: how
/// many are actively working and how many are waiting on the user. Mirrors the AI
/// ("2 running · 1 waiting") and lock summaries.
private func recentsSummary(_ recents: [SessionSummary]) -> String {
guard !recents.isEmpty else { return "No recent chats" }
let working = recents.filter { $0.status == .running || $0.status == .provisioning }.count
let needsYou = recents.filter {
$0.status == .awaitingApproval
|| ($0.status == .awaitingInput && $0.disposition == .awaitingInput)
}.count
// The count is every unarchived chat touched in the last 24h — not the (eight-row)
// listed set — so it reflects the day's real activity, not just what's on screen.
var parts = ["\(store.recentCount()) recent"]
if working > 0 { parts.append("\(working) working") }
if needsYou > 0 { parts.append("\(needsYou) need\(needsYou == 1 ? "s" : "") you") }
return parts.joined(separator: " · ")
}
/// A Recents-section row: the same `SessionRow` used per-project, but tagged with
/// its project name as subtext and carrying its own tap/context/swipe handlers.
/// Resolves through `projectSummary` so a recent chat on a peer Mac carries its project
/// name (and control accent) exactly like a local one.
@ViewBuilder
private func recentsRow(_ summary: SessionSummary) -> some View {
let project = store.projectSummary(summary.projectID)
sessionRow(summary, projectName: project?.name)
// Tag the row with its own project's accent so a recent chat from a non-control
// project stays teal even when the open project is a Control one.
.environment(\.appPalette, project.map(palette(for:)) ?? palette)
}
@ViewBuilder
private func sessionRow(_ summary: SessionSummary, projectName: String? = nil) -> some View {
// A moved-away tombstone (mesh P5) resolves its destination name live from the paired
// list — so it upgrades from the persisted "another Mac" fallback the moment paired Macs
// load after a relaunch (`loadSessions` runs before the sync server populates them).
let movedToName: String? = summary.movedTo.map { moved in
store.pairedDevices.first { $0.deviceID == moved.deviceID }?.label ?? moved.deviceName
}
// Arrived-from provenance (mesh P5): resolve the source Mac's name live from the paired list,
// falling back to a generic label if it's since been unpaired.
let arrivedFromName: String? = summary.arrivedFromDeviceID.map { deviceID in
store.pairedDevices.first { $0.deviceID == deviceID }?.label ?? "another Mac"
}
// A session on a peer Mac (mesh session sync) renders through this same row with the
// same actions — its owner's name feeds the Recents globe marker's tooltip. Verbs
// dispatch by the summary's own origin (`store.setSessionFavorite(summary, …)` &c.),
// so the menu below stays one code path for both.
let remoteHostName: String? = summary.hostID.map { hostID in
store.pairedDevices.first { $0.deviceID == hostID }?.label ?? "another Mac"
}
// 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.
// Selection is origin-qualified: after a move, the local tombstone and the live twin
// mirrored from the new owner share a raw id — matching on id alone would paint both.
return SessionRow(summary: summary,
isSelected: store.openSessionID == summary.id && store.openHostID == summary.hostID,
isWaitingForLock: store.sessionsWaitingForAccess.contains(summary.id),
projectName: projectName,
isOrchestraActive: store.orchestraActive(summary),
movedToName: movedToName, arrivedFromName: arrivedFromName,
remoteHostName: projectName == nil ? nil : remoteHostName)
.contentShape(Rectangle())
.onTapGesture { store.openSession(summary) }
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.contextMenu {
// A moved-away tombstone (mesh P5) lives on another Mac now — none of the mutating
// actions apply here (they'd no-op without a controller), so only offer Delete to
// dismiss the local record.
if summary.movedTo == nil {
// "Awaiting Input" that's actually done: let the user flip it to "Done" by hand
// (its disposition becomes `.completed`) so a chat stuck on the wrong state can be
// cleared without sending it another turn. Only offered while the row reads
// "Awaiting Input" — hidden once it's already Done or in any other state.
// Local-only: the wire has no disposition verb yet.
if summary.hostID == nil, summary.status == .awaitingInput,
summary.disposition != .completed {
Button("Mark Done", systemImage: "checkmark.circle") {
Task { await store.markSessionDone(summary.id) }
}
Divider()
}
Button(summary.favorite ? "Unfavorite" : "Favorite",
systemImage: summary.favorite ? "star.slash" : "star") {
Task { await store.setSessionFavorite(summary, !summary.favorite) }
}
Button(summary.archived ? "Unarchive" : "Archive",
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") {
Task { await store.setSessionArchived(summary, !summary.archived) }
}
// Move to a connected peer Mac (mesh P5). Only offered for a live (non-archived)
// session this Mac owns, when at least one peer Mac is connected and advertising
// the capability (the wire has no "pull from its owner" transfer yet).
let destinations = store.transferDestinations()
if summary.hostID == nil, !summary.archived, !destinations.isEmpty {
Menu("Move to Mac", systemImage: "macbook") {
ForEach(destinations, id: \.deviceID) { peer in
Button(peer.label) {
Task { await store.moveSessionToPeer(summary.id, to: peer.deviceID, label: peer.label) }
}
}
}
}
Divider()
}
Button("Delete", systemImage: "trash", role: .destructive) {
Task { await store.deleteSession(summary) }
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
// Favoriting a moved-away tombstone (mesh P5) would no-op (no controller) — omit it.
if summary.movedTo == nil {
Button {
Task { await store.setSessionFavorite(summary, !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) }
} label: {
Label("Delete", systemImage: "trash")
}
}
// A moved tombstone can't be (un)archived from here — it lives on another Mac.
if summary.movedTo == nil {
Button {
Task { await store.setSessionArchived(summary, !summary.archived) }
} label: {
Label(summary.archived ? "Unarchive" : "Archive",
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
}
.tint(.gray)
}
}
}
}
private extension SessionSummary {
/// List-row identity for the cross-project "Recents" section. The same session
/// also has a row under its own project in the *same* `List`; keying both by the
/// raw `SessionID` is a duplicate-ID bug (SwiftUI merges/reuses the rows). The
/// "recents-" prefix keeps the Recents row distinct so its project-name subtext
/// stays put and never leaks onto the in-project row.
var recentsRowID: String { "recents-\(id.rawValue)" }
}
struct SessionRow: View {
@Environment(\.appPalette) private var palette
let summary: SessionSummary
var isSelected: Bool = false
/// Blocked in the `LockManager` queue — the status dot becomes an hourglass so the
/// sidebar reads "waiting" at a glance, matching the open session's "Waiting for
/// access" label.
var isWaitingForLock: Bool = false
/// When set (e.g. in the cross-project "Recents" section), the project name is
/// shown as subtext below the title so a session reads in context away from its
/// project header.
var projectName: String? = nil
/// Whether Orchestra is *actually in effect* for this chat — drives the gold marker. Gated on
/// the project being under Nucleic Control by the call site (`store.orchestraActive`), not the
/// raw `summary.isOrchestra`, so a stray "orchestra" effort on an uncontrolled project doesn't
/// flash an orchestration the host has withheld (mirrors the composer's effort pill).
var isOrchestraActive: Bool = false
/// When this row is a moved-away tombstone (mesh P5), the destination Mac's current display
/// name (resolved live from the paired-device list by the call site). Non-nil ⇒ the status
/// line reads "Moved to <name>" and the auto/orchestra/autoship markers are suppressed.
var movedToName: String? = nil
/// When this session arrived from another Mac (mesh P5), that Mac's current display name
/// (resolved live by the call site). Non-nil ⇒ a subtle inbound marker sits by the title.
var arrivedFromName: String? = nil
/// When this session lives on a peer Mac (mesh session sync) *and* the row renders outside
/// its project's header (the cross-project Recents section), that Mac's display name —
/// drives a small globe by the project subtext, the same mark the project header carries.
/// Nil under a remote project header (the header's globe already says it) and for local rows.
var remoteHostName: String? = nil
var body: some View {
HStack(spacing: 6) {
if isWaitingForLock {
Image(systemName: "hourglass")
.font(.system(size: 9))
.foregroundStyle(palette.attention)
.frame(width: 8, height: 8)
.accessibilityLabel("Waiting for file access")
} else {
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)
// A session that arrived from another Mac (mesh P5) carries a subtle inbound
// marker; the source name rides its tooltip so it doesn't crowd the row.
if let arrivedFromName {
Image(systemName: "arrow.down.forward")
.font(.system(size: 8)).foregroundStyle(.secondary)
.help("Arrived from \(arrivedFromName)")
}
}
HStack(spacing: 4) {
if let projectName {
Text(projectName)
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
// A chat on a peer Mac carries the same globe its project header
// shows, so a Recents row (which has no header above it) still reads
// its origin at a glance.
if let remoteHostName {
Image(systemName: "globe")
.font(.system(size: 8)).foregroundStyle(.secondary)
.help("On \(remoteHostName) — mirrored live over the mesh")
}
Text("·").font(.caption2).foregroundStyle(.tertiary)
}
if let movedToName {
Image(systemName: "arrow.up.forward")
.font(.system(size: 8)).foregroundStyle(.secondary)
Text("Moved to \(movedToName)")
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
} else {
Text(summary.statusLabel)
.font(.caption2).foregroundStyle(.secondary)
}
if movedToName == nil, 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)
}
if movedToName == nil, isOrchestraActive {
// Orchestration mode marker, sat beside the Auto bolt. Always the
// signature orchestra gold (not switched on selection like the bolt):
// the gold *is* the "orchestra is on" signal, and it stays legible
// against the mid-gray highlight where the teal accent would wash out.
Image(systemName: "music.note.list").font(.system(size: 8))
.foregroundStyle(AppTheme.orchestra)
.accessibilityLabel("Orchestra")
}
if movedToName == nil, !summary.autoShipFailed && !summary.autoShipConflict
&& summary.lastEventWasAutoship {
// The most recent autoship here *succeeded* (the branch shipped)
// and nothing's happened since — a glanceable marker, sat directly
// beside the Auto bolt. Cleared once a new turn lands, or by a
// later failed/conflict autoship (which never lights this). White
// on the selection highlight so it stays legible. Suppressed when
// autoship failed or a conflict is pending (the warning below takes
// precedence).
Image(systemName: "shippingbox.fill").font(.system(size: 8))
.foregroundStyle(isSelected ? Color.white : palette.accent)
.accessibilityLabel("Autoship ran")
}
}
}
Spacer()
if movedToName == nil, summary.autoShipFailed || summary.autoShipConflict {
// Autoship needs attention — either it hit a hard error and turned itself off
// (`autoShipFailed`), or it hit a merge conflict but stayed ARMED
// (`autoShipConflict`). Same glanceable icon; the label distinguishes them. A
// sticky marker that persists until the user resolves it (a clean merge clears
// the conflict; re-enabling clears the failure). The Auto bolt above stays
// visible for an armed-with-conflict chat, so it reads "armed + needs attention".
// Takes precedence over the shipped marker, which is suppressed while it's up.
Image(systemName: "exclamationmark.triangle.fill").font(.caption2)
.foregroundStyle(palette.attention)
.accessibilityLabel(summary.autoShipFailed
? "Autoship failed — turned off"
: "Autoship conflict — needs attention, still armed")
}
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 movedToName == nil, 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 {
// Selection sits underneath; a chat blocked on the user gets a breathing
// amber wash layered over it (over the gray, so a selected-and-blocked chat
// still reads as needing you). The pulse view only exists while needed, so
// its appearance/removal starts and ends the animation. A finished-but-unseen
// chat (the same state that bolds its title) gets a steady green wash instead —
// calmer than the pulse, and it clears the moment the chat is viewed.
ZStack {
if isSelected {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(AppTheme.selection)
}
if summary.needsAttention {
AttentionPulseFill()
} else if summary.unseenCompletion {
UnseenCompletionFill()
}
}
}
.padding(.vertical, 1)
.contentShape(Rectangle())
}
}
/// A soft amber wash behind a sidebar row that's blocked on the user, breathing to pull
/// the eye. It swells from a faint resting tint up to a brighter peak and back on a slow
/// beat — never collapsing to nothing, so the row stays marked even at the trough. Self
/// contained: the caller gates it on `needsAttention`, so it only ever exists while a
/// row needs attention; appearing kicks off the repeating pulse, disappearing ends it.
/// Honors Reduce Motion — holds at a steady mid tint instead of pulsing.
private struct AttentionPulseFill: View {
@Environment(\.appPalette) private var palette
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Toggled true on appear, which — combined with the autoreversing `repeatForever`
/// animation below — bounces the tint between its resting and peak opacity forever.
@State private var pulsing = false
var body: some View {
// Steady mid tint under Reduce Motion; otherwise oscillate faint (0.10) ↔ bright
// (0.30). The status dot and trailing icons stay legible over the wash.
let opacity = reduceMotion ? 0.18 : (pulsing ? 0.30 : 0.10)
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(palette.attention.opacity(opacity))
.animation(reduceMotion ? nil
: .easeInOut(duration: 1.3).repeatForever(autoreverses: true),
value: pulsing)
.onAppear { pulsing = true }
.accessibilityHidden(true)
}
}
/// A soft green wash behind a sidebar row that finished its work but hasn't been opened yet
/// — the same `unseenCompletion` state that bolds the row's title. It's the calm sibling of
/// `AttentionPulseFill`: a finished chat is read-when-you-can, not blocked-on-you, so it holds
/// a steady tint instead of breathing. Self contained: the caller gates it on `unseenCompletion`,
/// so it only exists while the chat is unseen, clearing the instant the chat is viewed. Uses the
/// palette's `success` colour, which stays distinct from `attention` across colour-vision modes.
private struct UnseenCompletionFill: View {
@Environment(\.appPalette) private var palette
var body: some View {
// A steady mid tint — matching the resting weight of the attention wash so the two
// read as siblings — minus the pulse. The status dot and trailing icons stay legible.
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(palette.success.opacity(0.18))
.accessibilityHidden(true)
}
}
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
}
}
/// The sidebar's explicit backdrop — the same glass Safari uses for its sidebar and top
/// bar: the system `.sidebar` material with *behind-window* blending, which samples only
/// what's behind the window (the desktop). The split view's own Liquid Glass sidebar pane
/// samples in-window content too — it mirrors and blurs the detail pane's leading edge
/// under the column — so the BuildBanner's bright channel stripe smeared a distorted tint
/// across the sidebar (canary yellow at the top of a canary build). Layered as the column's
/// background, this material draws over that mirror layer and replaces it with the neutral
/// desktop blur, while staying visually identical to the built-in sidebar glass everywhere
/// the detail content is calm.
private struct SidebarGlassBackground: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.material = .sidebar
view.blendingMode = .behindWindow
view.state = .followsWindowActiveState
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
}
/// Clears the host window's opaque backing so the sidebar's behind-window glass material
/// blurs the desktop instead of the window's own content. The detail pane keeps its own
/// opaque `AppTheme.background` fill, so only the material-backed sidebar region turns
/// translucent — matching the Settings window, which is non-opaque for the same reason.
private struct WindowTranslucencyConfigurator: NSViewRepresentable {
func makeNSView(context: Context) -> NSView { NSView() }
func updateNSView(_ nsView: NSView, context: Context) {
// Hop to the next runloop pass so `nsView.window` is resolved (same pattern as
// `SidebarColumnConfigurator` / `SettingsWindowConfigurator`). SwiftUI can reset
// these on relayout, so reapplying on each update keeps the window non-opaque.
DispatchQueue.main.async {
guard let window = nsView.window else { return }
window.isOpaque = false
window.backgroundColor = .clear
}
}
}