1768 lines
102 KiB
Swift
1768 lines
102 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|