Files
nucleic/Sources/NucleicApp/RootView.swift
T

1768 lines
102 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 AppKit
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
/// Whether the open chat's VM is up, derived by `applyAutoVMMonitor`. Cached so docked-monitor
/// triggers can re-decide immediately instead of waiting for the next registry event.
@State private var openSessionHasVM = false
/// The last VM-registry snapshot seen from `AppStore.macVMChanges()` (or the backstop poll).
/// Cached so the non-registry triggers — chat switches, settings flips, a PiP fan pick — can
/// re-derive the monitor state immediately without an actor round-trip; the stream itself only
/// fires on real VM transitions. Not read by `body`.
@State private var lastKnownRunningVMs: [MacVMEntry] = []
/// The session whose running guest is assigned to the featured (top) PiP window. This is intentionally
/// independent of openSession: it remains cached while its owner is selected (and the PiP is
/// hidden), then appears immediately when the user switches to another session.
@State private var pictureInPictureVMSession: Session?
/// The ordered background sessions with running guests (featured first) that the PiP stack fans out
/// to on hover. Resolved from `AppStore.pictureInPictureVMSessionIDs` each derivation pass and cached
/// so the event-driven `syncPictureInPictureMonitor` calls can reuse it without re-resolving.
@State private var pictureInPictureVMCandidates: [Session] = []
/// Which of the three things the sidebar's top region shows — recent chats (default),
/// system health, or the Control activity panel. Driven by `SidebarModeSwitcher`.
@State private var sidebarMode: SidebarMode = .recents
/// The sidebar omnisearch text. When non-empty it filters, in place, the per-project
/// session rows, the Recents rows, and the active Systems/Control pane's entries (all by
/// their associated session), and surfaces a "To-dos" results group. Empty = no filtering.
@State private var searchText = ""
/// Measured height of one recent-session row, sampled from a hidden row (see
/// `cardHeightSampler`). The swappable top region (recents / overview 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
@State private var renamingProject: Project?
@State private var renameProjectDraft = ""
@State private var deletingProject: Project?
/// The sidebar session row whose "Rename" alert is open (nil = none), plus its draft text.
@State private var renamingSession: SessionSummary?
@State private var renameSessionDraft = ""
/// 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 programmatically typing a one-shot command into the open chat's terminal.
/// Claude authentication now uses the native credential broker instead.
@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
@AppStorage(UltraGlass.enabledKey) private var ultraGlass = false
@AppStorage(HighContrast.enabledKey) private var highContrast = false
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)
}
/// Runs a state change that opens an alert/sheet/dialog *after* the context menu that asked
/// for it has finished tearing down.
///
/// Flipping the presentation state straight from a `contextMenu` button lands while AppKit is
/// still dismissing the NSMenu, and a window-modal presentation begun against that window is
/// silently dropped. The binding stays true, so nothing appears until some *other*
/// presentation churns this window's presentation machinery and flushes the pending one:
/// right-click → "Rename" does nothing, then the chat header's "Rename" opens its own alert,
/// and dismissing that finally surfaces the sidebar's. One main-loop hop puts the mutation
/// after the menu is gone, so the popup shows on the first ask.
private func afterContextMenuDismissal(_ mutate: @escaping @MainActor () -> Void) {
DispatchQueue.main.async { MainActor.assumeIsolated(mutate) }
}
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.
// The omnisearch bar rides just above the switcher as the list's very first
// row, so it shares the switcher's exact layout context (the list's
// `safeAreaPadding` + the 8pt row inset) and lines up flush to the same width.
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 `WindowGlassBackground`).
// Extends through the safe area so the title-bar region above the list is
// shielded too.
//
// The raw material read as a detached slab of desktop blur, so a wash of the
// theme's sidebar color layers over it, densifying the glass a step to match
// the weight of the rest of the chrome.
.background {
ZStack {
WindowGlassBackground()
AppTheme.sidebar.opacity(0.55)
}
.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")
}
ToolbarItem {
// Fleet activity (mesh casting): the merged cross-host feed with an
// unread badge; rows deep-link to their session, local or remote.
ActivityFeedBell()
}
}
} 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 pending = store.openPendingCovalence {
// A Covalence chat still waiting for a runner (item 5): live provisioning
// progress until the real session lands and replaces this view.
CovalenceProvisioningView(pending: pending)
} 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)
}
// Extend the pane's fill through the safe area so the title-bar region above
// the content is backed too — mirroring the sidebar's glass. Without this the
// detail glass stops at the safe-area inset, leaving the strip under the unified
// toolbar (where the header sits) over the bare non-opaque window: in Full Glass
// the header then read as low-opacity desktop with no blur instead of the glass.
.background { DetailSurface().ignoresSafeArea() }
// 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(item: $store.editingTodoID) { EditTodoSheet(todoID: $0) }
.sheet(item: $convertingProject) { ConvertToControlSheet(project: $0) }
}
// The floating ⌘N/⌘⇧N composer — an in-window overlay, deliberately not a sheet (see
// `NewChatComposerOverlay` for why the sheet presentations kept failing). Attached to
// the split-view root so its veil dims the sidebar and detail pane together, the way
// the sheet presentation used to read.
.modifier(NewChatComposerPresenter())
// Pin the unified toolbar's background so it never falls through to the clear
// host window. Full Glass keeps the system toolbar material; with Full Glass off,
// explicitly paint the opaque app surface to match the detail pane below it.
// Keep the "Automatically open VM monitors" watcher live for the whole window: it docks the
// VM Monitor in the right column while the open chat has a VM up and retracts it when the open
// chat has none. Event-driven (plan item 7, docs/MAIN_THREAD_PERFORMANCE_PLAN.md): VM
// boots/shutdowns arrive on the engine's registry change stream; chat switches, settings
// flips, and PiP fan picks re-derive via the triggers below; and a slow reconciliation task
// remains as a defensive backstop.
.task { await watchAutoVMMonitor() }
.task { await reconcileAutoVMMonitor() }
// Settings toggles ("Automatically open VM monitors" / "Float VM monitors in Picture in
// Picture") are plain UserDefaults, so re-derive from the cached registry when defaults
// change. Noisy but cheap: the derivation is pure and assigns nothing when unchanged.
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
Task { await applyAutoVMMonitor(running: lastKnownRunningVMs) }
}
// The user promoted a fanned PiP card — re-derive so the candidate ordering (featured
// first) catches up with the controller's optimistic swap.
.onChange(of: VMMonitorPiPState.shared.userFeaturedSessionID) { _, _ in
Task { await applyAutoVMMonitor(running: lastKnownRunningVMs) }
}
// The PiP yields to a foreground docked monitor, so re-decide the moment either half of that
// changes rather than up to two seconds later: Nucleic coming forward should retract the PiP
// instantly, and sending it to the back should float it right away.
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
syncPictureInPictureMonitor()
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification)) { _ in
syncPictureInPictureMonitor()
}
.onChange(of: panels.hasVMMonitor) { syncPictureInPictureMonitor() }
.onChange(of: store.openSessionID) {
// Switching chats retires the previous chat's "I closed this" — the new chat gets the
// monitor its own VM is due — then re-derives against the cached registry right away
// (the change stream fires on VM transitions, not on chat switches).
panels.resetVMMonitorDismissal()
syncPictureInPictureMonitor()
Task { await applyAutoVMMonitor(running: lastKnownRunningVMs) }
}
// The Subagents panel follows the chat: hidden while the open one has no workers to list,
// put back when the user returns to one that does. Keyed on the *predicate* rather than on
// the session id so it also covers a fan-out starting in the chat you're already in, and
// stays put when both chats have workers.
.onChange(of: store.openSessionHasSubagents, initial: true) { _, hasSubagents in
panels.syncSubagents(hasSubagents: hasSubagents)
}
.modifier(WindowToolbarSurface(fullGlass: ultraGlass))
// …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() }
// Keep the window an ordinary window — level `.normal`, no panel-style collection
// behaviors — so it always goes behind whatever the user clicks, and never paints over
// the Dock or an open menu-bar menu (see `MainWindowLevelGuard`).
.background { MainWindowLevelGuard() }
// A bannered (non-release) build tints the whole unified toolbar, so recolor the AppKit
// title that sits in it — the toolbar buttons handle themselves via `.channelBannerHeader()`.
.background { WindowTitleTint(title: windowTitle) }
.preferredColorScheme(appearance.colorScheme)
// High Contrast swaps values *inside* the theme's `NSColor` providers, which AppKit only
// re-resolves on an appearance change — so re-key the tree on the setting to force every
// one of them to run again. Only ever costs a rebuild when the user flips the toggle.
.id(highContrast)
// Soften the default near-black used by unstyled labels and sidebar titles in light mode.
// Explicit semantic/categorical foreground styles below still take precedence.
.foregroundStyle(AppTheme.defaultText)
.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
}
}
.alert("Rename chat", isPresented: .init(
get: { renamingSession != nil },
set: { if !$0 { renamingSession = nil } })
) {
TextField("Name", text: $renameSessionDraft)
Button("Cancel", role: .cancel) { renamingSession = nil }
Button("Rename") {
if let summary = renamingSession {
Task { await store.renameSession(summary, to: renameSessionDraft) }
}
renamingSession = 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 overview panels render embedded so they scroll with the list.
@ViewBuilder
private var topModeSection: some View {
let query = SearchQuery(searchText)
switch sidebarMode {
case .recents:
// Lead with a status header, the way the Systems 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 allRecents = store.recentSummaries()
// Filter the Recents rows to search matches (title/keywords/project); the header
// rollup still reflects the full recent set.
let recents = query.isEmpty ? allRecents
: allRecents.filter { store.sessionMatches($0, query) }
recentsHeader(allRecents)
// 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 .systems:
SidebarSystemsPanel(bodyHeight: sectionMaxHeight, query: searchText)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.transition(.move(edge: .top).combined(with: .opacity))
case .control:
SidebarControlPanel(bodyHeight: sectionMaxHeight, query: searchText)
.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 omnisearch bar leads the list, directly above the mode switcher. It takes no
// horizontal row inset of its own, so (sitting inside the same list, inheriting the
// list's `safeAreaPadding`) it spans the full row width — exactly as wide as the
// pane divider lines below and the mode switcher. Typing here filters the session
// rows, the active Recents/Systems/Control pane, and surfaces matching to-dos.
SearchField(text: $searchText, placeholder: "Search chats & to-dos")
.padding(.top, 8).padding(.bottom, 8)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
// The mode switcher rides as a list row (rather than pinned in the VStack above) so
// it scrolls away with the content. Like the search bar, it takes no horizontal row
// inset of its own, so its edges span the full row width and line up with the pane
// divider lines below.
SidebarModeSwitcher(
mode: sidebarMode,
deadlocked: !store.deadlockedSessions.isEmpty
) { selectMode($0) }
.padding(.top, 2).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(AppTheme.softText)
}
// 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.
// A live omnisearch query filters the whole tree: each project shows only its
// matching chats, and a project with no match at all drops out entirely.
let query = SearchQuery(searchText)
let searching = !query.isEmpty
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)
let allSessions = store.summaries(for: project)
let allArchived = store.archivedSummaries(for: project)
// Under a search, filter both live and archived chats to matches; the project
// header only renders when at least one of either matches.
let sessions = searching ? allSessions.filter { store.sessionMatches($0, query) } : allSessions
let archived = searching ? allArchived.filter { store.sessionMatches($0, query) } : allArchived
if !searching || !sessions.isEmpty || !archived.isEmpty {
projectHeader(project)
.environment(\.appPalette, pal)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
if sessions.isEmpty && !searching {
Text("No chats yet").font(.caption).foregroundStyle(AppTheme.softText)
}
// Collapsed projects show only their top few chats; the footer below toggles
// it. A search overrides the collapse so every match is visible.
let isCollapsed = !searching && 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)
}
// Covalence chats dispatched from this project that are still waiting for a
// runner (item 5) — a provisioning row until the real session lands.
ForEach(store.pendingCovalenceDispatches.filter { $0.projectID == project.id }) { pending in
pendingCovalenceRow(pending, pal: pal)
.environment(\.appPalette, pal)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
// Only worth offering the collapse once there's something hidden by it (and
// never while searching, when the collapse is overridden).
if !searching && sessions.count > collapsedSessionLimit {
collapseToggle(for: project, total: sessions.count, collapsed: isCollapsed)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
if !archived.isEmpty {
archivedHeader(for: project, count: archived.count)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
// A search forces the archived group open so matching archived chats show.
if searching || 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))
}
}
}
}
}
// The omnisearch "To-dos" results group: matching ideas across every project,
// shown only while searching (the separate to-do index — text + summary).
if searching {
let matchingTodos = store.searchTodos(query)
if !matchingTodos.isEmpty {
todoResultsHeader(count: matchingTodos.count)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
ForEach(matchingTodos) { todo in
todoResultRow(todo)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
}
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, SidebarRowMetrics.listSafeAreaInset)
.scrollContentBackground(.hidden)
// Kill the list's elastic overscroll: the top rubber-band bounce is a relayout that
// makes the unified toolbar's Liquid Glass momentarily re-sample the BuildBanner beneath
// it, flashing the toolbar buttons the banner tint on scroll-up (see the configurator).
.background { SidebarScrollElasticityConfigurator() }
}
/// 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 overview 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"
}
/// Drive the auto-opened VM Monitor for the life of the window. When "Automatically open VM
/// monitors" is on, this docks a monitor in the right column whenever the open chat has a VM up
/// and retracts it when the open chat has none (or the setting is off). Event-driven (plan
/// item 7): each registry snapshot streamed from the VM engine — primed with the current state,
/// then one per boot/readiness/stop/suspend/resume transition — re-derives the monitor and PiP
/// state; the old 2-second poll is gone. Chat switches, settings flips, and PiP fan picks
/// re-derive through their own triggers in `body`, and ``reconcileAutoVMMonitor()`` backstops
/// everything. No-op on unsupported hosts.
private func watchAutoVMMonitor() async {
guard store.macVMSupported else { return }
for await running in await store.macVMChanges() {
await applyAutoVMMonitor(running: running)
}
}
/// Defensive backstop for the change stream (plan item 7): a slow reconciliation poll that
/// re-derives the monitor state even if a registry transition somehow failed to emit (or the
/// stream ended). 30 s — deliberately far off the old 2 s cadence, since routine updates ride
/// the stream and this exists only to catch drift.
private func reconcileAutoVMMonitor() async {
guard store.macVMSupported else { return }
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(30))
await applyAutoVMMonitor(running: await store.runningMacVMs())
}
}
/// One derivation pass of the auto-monitor/PiP state from a VM-registry snapshot — the body of
/// the old 2-second poll, now run per trigger. Observable state is assigned only when the
/// derived value actually changed (plan item 7), so a no-op trigger (a defaults write, a
/// backstop tick) never invalidates any view.
private func applyAutoVMMonitor(running: [MacVMEntry]) async {
if lastKnownRunningVMs != running { lastKnownRunningVMs = running }
let runningNames = running.map(\.name)
let hasVM = openSessionHasRunningVM(in: runningNames)
if openSessionHasVM != hasVM { openSessionHasVM = hasVM }
// The user's hand-picked "top" card biases selection until its VM is gone (or its chat is
// opened), at which point it's cleared and the auto-driver resumes.
let preferred = VMMonitorPiPState.shared.userFeaturedSessionID
let candidateIDs = AppStore.pictureInPictureVMSessionIDs(
runningVMNames: runningNames,
openSessionID: store.openSessionID,
preferredSessionID: preferred,
summaries: store.summaries)
if let preferred, !candidateIDs.contains(preferred) {
VMMonitorPiPState.shared.userFeaturedSessionID = nil
}
let pipSessionID = AppStore.pictureInPictureVMSessionID(
runningVMNames: runningNames,
openSessionID: store.openSessionID,
currentSessionID: preferred ?? pictureInPictureVMSession?.id,
summaries: store.summaries)
if pipSessionID != pictureInPictureVMSession?.id {
if pipSessionID == store.openSessionID {
pictureInPictureVMSession = store.openSession
} else if let pipSessionID {
pictureInPictureVMSession = await store.liveSnapshot(pipSessionID)?.session
} else {
pictureInPictureVMSession = nil
}
}
// Resolve the ordered fan candidates (background VMs, featured first) to Sessions for the
// hover fan-out. The featured one reuses the just-resolved session; the rest come from each
// live controller's cheap snapshot.
var candidates: [Session] = []
for id in candidateIDs {
if let featured = pictureInPictureVMSession, featured.id == id {
candidates.append(featured)
} else if let session = await store.liveSnapshot(id)?.session {
candidates.append(session)
}
}
if pictureInPictureVMCandidates != candidates { pictureInPictureVMCandidates = candidates }
// The docked, in-column monitor (off by default). `autoRevealVMMonitor` respects a monitor
// the user closed by hand, so the close button isn't fighting this driver.
if MacVMSettings.autoOpenVMMonitors, hasVM {
panels.autoRevealVMMonitor()
} else {
panels.autoCloseVMMonitor()
}
syncPictureInPictureMonitor()
}
/// Show or retract the floating Picture-in-Picture monitor (on by default) — the same
/// follow-the-chat rule as the docked monitor, but hovering above every other window instead of
/// docking. Read-only, so it never captures the host's mouse or keyboard.
///
/// It yields whenever its owning session is open: that guest is already available in the active
/// chat, while PiP exists to keep a background session's guest visible. Switching away floats the
/// cached owner immediately; switching back retracts it. A docked monitor for a different, active
/// session can remain live alongside the PiP.
///
/// A *suspended* guest stays floated, not retracted: the monitor shows the frozen still it grabbed the
/// instant it paused under a "Paused" overlay (see ``VMMonitorPanel``), so a suspended VM in the stack
/// keeps its card rather than yanking the whole PiP away.
private func syncPictureInPictureMonitor() {
guard MacVMSettings.pictureInPictureVMMonitors,
let session = pictureInPictureVMSession,
session.id != store.openSessionID
else {
VMMonitorPiPController.shared.hide()
return
}
// Exclude the featured session from the fan candidates it hands to the controller only if it
// somehow isn't first; the controller already fans `candidates` minus the featured one.
VMMonitorPiPController.shared.show(
session: session, candidates: pictureInPictureVMCandidates, store: store)
}
/// Whether the currently open chat has a macOS or Linux VM running right now — the gate for the
/// auto-opened monitor. False with no chat open (the monitor auto-closes at Home).
private func openSessionHasRunningVM(in runningNames: [String]) -> Bool {
guard let sessionID = store.openSessionID else { return false }
let names = [MacVMManager.vmName(for: sessionID), MacVMManager.linuxVMName(for: sessionID)]
return runningNames.contains { names.contains($0) }
}
/// 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(AppTheme.softText)
.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(AppTheme.softTextDim)
} 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…") {
afterContextMenuDismissal {
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…") {
afterContextMenuDismissal { convertingProject = localProject }
}
}
Divider()
Button("Archive Project") {
Task { await store.setProjectArchived(localProject.id, true) }
}
Button("Delete Project…", role: .destructive) {
afterContextMenuDismissal { 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(AppTheme.softText)
}
}
.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(AppTheme.softText)
.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(AppTheme.softText)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 8, alignment: .leading)
Text("Archived (\(count))")
.font(.caption)
.foregroundStyle(AppTheme.softText)
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(AppTheme.softText)
.rotationEffect(.degrees(collapsed ? 0 : 90))
.frame(width: 8, alignment: .leading)
Text(collapsed ? "Show \(total - collapsedSessionLimit) more" : "Show less")
.font(.caption)
.foregroundStyle(AppTheme.softText)
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(AppTheme.softText)
.rotationEffect(.degrees(archivedProjectsExpanded ? 90 : 0))
.frame(width: 8, alignment: .leading)
Text("Archived Projects (\(count))")
.font(.caption)
.foregroundStyle(AppTheme.softText)
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(AppTheme.softText)
.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) {
afterContextMenuDismissal { deletingProject = project }
}
}
}
/// Header for the sidebar omnisearch "To-dos" results group — matching ideas across
/// projects. Mirrors `archivedProjectsHeader`'s caption styling, without a disclosure
/// (the group only appears while a search matches, so there's nothing to collapse).
@ViewBuilder
private func todoResultsHeader(count: Int) -> some View {
HStack(spacing: 6) {
Image(systemName: "checklist")
.font(.caption2.weight(.semibold))
.foregroundStyle(AppTheme.softText)
Text("To-dos (\(count))")
.font(.caption)
.foregroundStyle(AppTheme.softText)
Spacer(minLength: 0)
}
.padding(.vertical, 4)
}
/// One matching to-do in the omnisearch results group: its glanceable summary (or first
/// line of text), tappable to open the existing edit sheet.
@ViewBuilder
private func todoResultRow(_ todo: Todo) -> some View {
let display: String = {
if let summary = todo.summary, !summary.isEmpty { return summary }
return todo.text.split(whereSeparator: \.isNewline).first.map(String.init) ?? todo.text
}()
Button {
store.editingTodoID = todo.id
} label: {
HStack(spacing: 6) {
Image(systemName: "circle")
.font(.system(size: 7))
.foregroundStyle(AppTheme.softTextDim)
.frame(width: 8, alignment: .leading)
Text(display)
.font(.callout)
.foregroundStyle(.primary)
.lineLimit(1)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.vertical, 3)
.help("Open this to-do")
}
/// 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 {
// Autoship hit a failure or conflict and lit the sidebar's "needs attention"
// alarm — the breathing amber pulse plus the warning triangle. Offer an explicit
// "Acknowledge" to quiet it without opening the chat. Opening already retires a
// *failure* marker, but a conflict marker persists until the work lands, so this
// is the only by-hand way to dismiss that one. Local-only: the mesh wire has no
// acknowledge verb (unlike "Mark Done", which now has one for phones).
if summary.hostID == nil, summary.autoShipFailed || summary.autoShipConflict {
Button("Acknowledge", systemImage: "checkmark.shield") {
Task { await store.acknowledgeAutoShipAlarm(summary) }
}
Divider()
}
// "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 rows only: the wire verb (`ClientMsg.markSessionDone`) exists and phones
// use it, but this sidebar doesn't yet track `canMarkSessionDone` per peer, so a
// peer's row stays hidden rather than risking an unknown-tag throw on an old Mac.
if summary.hostID == nil, summary.status == .awaitingInput,
summary.disposition != .completed {
Button("Mark Done", systemImage: "checkmark.circle") {
Task { await store.markSessionDone(summary.id) }
}
Divider()
}
Button("Rename…", systemImage: "pencil") {
afterContextMenuDismissal {
renameSessionDraft = summary.title
renamingSession = summary
}
}
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 another machine (mesh P5). Offered for any live chat, whoever owns it:
// a local one moves from here, a peer's one is moved by its owner on request —
// including "bring it here", which is why this shows on remote rows too.
let destinations = store.transferDestinations(for: summary)
if !destinations.isEmpty {
Menu("Transfer…", systemImage: "macbook") {
ForEach(destinations) { destination in
Button(destination.label) {
Task { await store.transferSession(summary, to: destination) }
}
}
}
}
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)
}
}
}
/// A sidebar row for a Covalence chat still waiting for a runner (item 5): a globe, the queued
/// message, and a live provisioning status. Tapping it reopens the provisioning pane.
@ViewBuilder
private func pendingCovalenceRow(_ pending: PendingCovalenceDispatch, pal: AppPalette) -> some View {
let isSelected = store.openPendingCovalence?.id == pending.id
HStack(spacing: 6) {
if pending.failure != nil {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 9)).foregroundStyle(pal.attention).frame(width: 8, height: 8)
} else {
ProgressView().controlSize(.mini).frame(width: 8, height: 8)
}
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(pending.titleLine).lineLimit(1)
Image(systemName: "globe")
.font(.system(size: 8)).foregroundStyle(AppTheme.softText)
}
Text(store.pendingCovalenceStatus(pending))
.font(.caption2).foregroundStyle(AppTheme.softText).lineLimit(1)
}
Spacer()
}
.padding(.vertical, 4).padding(.horizontal, 6)
.background {
// Same as SessionRow: drawn to the cell rect so the fill sits inside the
// right-click ring rather than floating in it (see `SidebarRowMetrics`).
SidebarRowMetrics.expandToCell(
RoundedRectangle(cornerRadius: SidebarRowMetrics.highlightCornerRadius,
style: .continuous)
.fill(isSelected ? pal.accent.opacity(0.18) : .clear),
verticalGap: 0
)
}
.contentShape(Rectangle())
.onTapGesture { store.reopenPendingCovalence(pending.id) }
.contextMenu {
Button(role: .destructive) {
Task { await store.cancelPendingCovalence(pending.id) }
} label: {
Label("Cancel", systemImage: "xmark.circle")
}
}
}
}
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)" }
}
/// Geometry shared between the sidebar List and the row highlights drawn inside it.
///
/// Right-clicking a row makes AppKit stroke its blue context-menu ring around the *cell* —
/// the full table-row rect, which ignores the list's `safeAreaPadding` and includes the
/// 1pt gap between rows. SwiftUI offers no hook to reshape that ring on macOS
/// (`ContentShapeKinds.contextMenuPreview` is unavailable here), so the fills underneath
/// are drawn to the cell's rect instead: they bleed back over the list's horizontal safe
/// area and the inter-row gap, and use the ring's rounder corner. That way the gray
/// selection pill (and the attention/unseen washes layered on it) sit exactly inside the
/// blue box rather than as a smaller, squarer rectangle floating in it.
enum SidebarRowMetrics {
/// The List's horizontal safe-area inset — row content is inset by it, the cell is not.
static let listSafeAreaInset: CGFloat = 5
/// Half the gap between rows; the cell spans it, the row content doesn't.
static let rowGap: CGFloat = 1
/// Matches the corner AppKit rounds the context-menu ring to.
static let highlightCornerRadius: CGFloat = 8
/// Grows a row-sized highlight out to the cell rect the context-menu ring traces.
/// `verticalGap` is the row's own outer padding — 0 for a row that adds none.
static func expandToCell<V: View>(_ view: V, verticalGap: CGFloat = rowGap) -> some View {
view.padding(.horizontal, -listSafeAreaInset).padding(.vertical, -verticalGap)
}
}
struct SessionRow: View {
@Environment(\.appPalette) private var palette
@Environment(\.colorScheme) private var colorScheme
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
/// The dark selection still needs light glyphs. The much softer light selection lets each
/// glyph keep its normal semantic color instead of turning into a low-contrast white mark.
private var selectionNeutral: Color {
isSelected && colorScheme == .dark ? .white : AppTheme.softText
}
private var selectionAccent: Color {
isSelected && colorScheme == .dark ? .white : palette.accent
}
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(AppTheme.softText)
.help("Arrived from \(arrivedFromName)")
}
}
HStack(spacing: 4) {
if let projectName {
Text(projectName)
.font(.caption2).foregroundStyle(AppTheme.softText).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(AppTheme.softText)
.help("On \(remoteHostName) — mirrored live over the mesh")
}
Text("·").font(.caption2).foregroundStyle(AppTheme.softTextDim)
}
if let movedToName {
Image(systemName: "arrow.up.forward")
.font(.system(size: 8)).foregroundStyle(AppTheme.softText)
Text("Moved to \(movedToName)")
.font(.caption2).foregroundStyle(AppTheme.softText).lineLimit(1)
} else {
Text(summary.statusLabel)
.font(.caption2).foregroundStyle(AppTheme.softText)
}
if movedToName == nil, let foldedHost = summary.foldedHostLabel {
// A chat running on another device — a peer Mac or a Covalence Cloud runner
// — folded under this Mac's section for the same project (every device holds
// the same projects, so the header is shared and the row says where it runs).
Image(systemName: "globe").font(.system(size: 8))
.foregroundStyle(selectionNeutral)
.help("Runs on \(foldedHost) — mirrored live over the mesh")
.accessibilityLabel("On \(foldedHost)")
}
if movedToName == nil, summary.auto {
// The deep dark-mode selection needs white; the subtle light-mode
// selection leaves the semantic accent legible as-is.
Image(systemName: "bolt.fill").font(.system(size: 8))
.foregroundStyle(selectionAccent)
}
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 dark 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(selectionAccent)
.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(AppTheme.softText)
}
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. It keeps the semantic accent on the soft
// light selection and switches to white on the deeper dark selection (the row
// can't be both selected and unread for long, but selection can land before the
// open-driven clear).
Circle().fill(selectionAccent)
.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.
// Grown out to the cell rect so the fill lines up with the blue context-menu
// ring AppKit strokes there — see `SidebarRowMetrics`.
SidebarRowMetrics.expandToCell(
ZStack {
if isSelected {
RoundedRectangle(cornerRadius: SidebarRowMetrics.highlightCornerRadius,
style: .continuous)
.fill(AppTheme.selection)
}
if summary.needsAttention {
AttentionPulseFill()
} else if summary.unseenCompletion {
UnseenCompletionFill()
}
}
)
}
.padding(.vertical, SidebarRowMetrics.rowGap)
.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: SidebarRowMetrics.highlightCornerRadius, 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: SidebarRowMetrics.highlightCornerRadius, 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" }
// Waiting on an armed background watch (`nucleic_monitor`), not on you.
if self == .awaitingInput, disposition == .waitingBackground { return "Monitoring…" }
return displayName
}
}
private struct WindowToolbarSurface: ViewModifier {
let fullGlass: Bool
@ViewBuilder
func body(content: Content) -> some View {
if fullGlass {
content.toolbarBackground(.visible, for: .windowToolbar)
} else {
content
.toolbarBackground(AppTheme.background, for: .windowToolbar)
.toolbarBackground(.visible, for: .windowToolbar)
}
}
}
/// Clears the host window's opaque backing so the behind-window glass materials blur the
/// desktop instead of the window's own content. The sidebar is always material-backed; the
/// detail pane joins it only in Full Glass mode (see `DetailSurface`), and otherwise keeps
/// an opaque `AppTheme.background` fill of its own. Matches 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
}
}
}