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