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.
        let controlled = store.contextProjectID
            .flatMap { store.project($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: Project) -> 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(AppTheme.sidebar)
            .background {
                GeometryReader { proxy in
                    Color.clear
                        .onAppear { sidebarWidth = proxy.size.width }
                        .onChange(of: proxy.size.width) { _, new in sidebarWidth = new }
                }
            }
            // 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 {
                        HomeView()
                    }
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
            .background(AppTheme.background)
            // Transient error bubble (e.g. "Autoship failed: …"). Scoped to the detail
            // pane so it centers over the chat interface, not the full window width.
            .overlay(alignment: .bottom) {
                if let error = store.lastError {
                    let target = store.lastErrorSessionID
                    Text(error)
                        .font(.caption).padding(8)
                        .background(.red.opacity(0.85), in: .rect(cornerRadius: 8))
                        .foregroundStyle(.white).padding()
                        // Click the bubble to dismiss; if it names a responsible chat
                        // (e.g. an autoship failure), jump to that chat on the way out.
                        .onTapGesture {
                            if let target { store.openSessionID = target }
                            store.lastError = nil
                        }
                        .help(target == nil ? "Click to dismiss" : "Click to open the responsible chat")
                        // …and auto-dismiss after a few seconds so it never sticks. The
                        // task is keyed on the message so a new error restarts the timer.
                        .task(id: error) {
                            try? await Task.sleep(for: .seconds(6))
                            if store.lastError == error { store.lastError = nil }
                        }
                        .transition(.opacity)
                }
            }
            .animation(.default, value: store.lastError)
            .navigationTitle(windowTitle)
            // Attach content sheets to the detail pane, not the split-view root. A sheet
            // hosted at the root scopes its presentation scrim to the whole window —
            // including the sidebar's title-bar region — which paints an odd overlay over
            // the traffic lights. Hosting from the detail pane confines it there.
            .sheet(isPresented: $showingAddProject) { AddProjectSheet() }
            .sheet(isPresented: $store.quickTodoPresented) { QuickTodoSheet() }
            .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() }
        .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.projects.isEmpty {
                Text("Add a project to begin").foregroundStyle(.secondary)
            }
            // Flat rows rather than `Section { } header: { }`: the macOS `.sidebar`
            // list style indents everything nested inside a Section (to reserve room
            // for a section collapse triangle), which pushes session titles right
            // until they clip. Rendering the header as a top-level row and the
            // sessions as its siblings keeps them flush to the column edge.
            ForEach(store.projects) { project in
                // 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.id)
                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) { 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.id)
                if !archived.isEmpty {
                    archivedHeader(for: project, count: archived.count)
                        .listRowInsets(EdgeInsets())
                        .listRowBackground(Color.clear)
                        .listRowSeparator(.hidden)
                    if expandedArchive.contains(project.id) {
                        ForEach(archived) { summary in
                            sessionRow(summary)
                                .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.project(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.
    private var windowTitle: String {
        guard let sessionID = store.openSessionID else {
            if let projectID = store.openProjectID, let project = store.project(projectID) {
                return project.name
            }
            return "Nucleic"
        }
        let projectID = store.openSession?.projectID
            ?? store.summaries.first(where: { $0.id == sessionID })?.projectID
        if let projectID, let project = store.project(projectID) { return project.name }
        return "Nucleic"
    }

    /// Below this sidebar width, the trailing swipe shows Archive only (no Delete),
    /// so neither button clips. Above the 320 `minWidth` to leave a comfortable margin.
    private let narrowSidebarThreshold: CGFloat = 360

    /// 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: Project) -> 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)
        // 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.sandbox?.enabled == true {
                            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.")
                        }
                        // 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 {
                    Button("Rename…") {
                        renameProjectDraft = project.name
                        renamingProject = project
                    }
                    // 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 !project.isNucleicControlled {
                        Button("Convert to Nucleic Control…") {
                            convertingProject = project
                        }
                    }
                    Divider()
                    Button("Archive Project") {
                        Task { await store.setProjectArchived(project.id, true) }
                    }
                    Button("Delete Project…", role: .destructive) {
                        deletingProject = project
                    }
                }
                // 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.
                if project.nvrsionActive {
                    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 "
                            + "\(project.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 \(project.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: Project, count: Int) -> some View {
        let isExpanded = expandedArchive.contains(project.id)
        Button {
            withAnimation(.easeInOut(duration: 0.22)) {
                if isExpanded { expandedArchive.remove(project.id) }
                else { expandedArchive.insert(project.id) }
            }
        } label: {
            // Chevron sized to sit at the same leading gutter as the session rows'
            // status dot, so the archived group lines up with everything above it.
            HStack(spacing: 6) {
                Image(systemName: "chevron.right")
                    .font(.caption2.weight(.semibold))
                    .foregroundStyle(.secondary)
                    .rotationEffect(.degrees(isExpanded ? 90 : 0))
                    .frame(width: 8, alignment: .leading)
                Text("Archived (\(count))")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Spacer(minLength: 0)
            }
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .padding(.vertical, 4)
    }

    /// 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: Project, 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.
    @ViewBuilder
    private func recentsRow(_ summary: SessionSummary) -> some View {
        let project = store.project(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 {
        // The selection background lives inside SessionRow (not in
        // `.listRowBackground`) so it travels with the content when swiped —
        // a `.listRowBackground` is anchored to the cell and would stay fixed
        // while `.swipeActions` slides only the row content.
        SessionRow(summary: summary, isSelected: store.openSessionID == summary.id,
                   isWaitingForLock: store.sessionsWaitingForAccess.contains(summary.id),
                   projectName: projectName,
                   isOrchestraActive: store.orchestraActive(summary))
            .contentShape(Rectangle())
            .onTapGesture { store.openSessionID = summary.id }
            .listRowInsets(EdgeInsets())
            .listRowBackground(Color.clear)
            .contextMenu {
                // "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.
                if 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.id, !summary.favorite) }
                }
                Button(summary.archived ? "Unarchive" : "Archive",
                       systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") {
                    Task { await store.setSessionArchived(summary.id, !summary.archived) }
                }
                Divider()
                Button("Delete", systemImage: "trash", role: .destructive) {
                    Task { await store.deleteSession(summary.id) }
                }
            }
            .swipeActions(edge: .leading, allowsFullSwipe: true) {
                Button {
                    Task { await store.setSessionFavorite(summary.id, !summary.favorite) }
                } label: {
                    Label(summary.favorite ? "Unfavorite" : "Favorite",
                          systemImage: summary.favorite ? "star.slash" : "star")
                }
                .tint(.yellow)
            }
            .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                // In a narrow sidebar both buttons can't fit, so the second one
                // (Delete) clips off-screen. Keep only Archive there; Delete is
                // still reachable from the context menu.
                if sidebarWidth >= narrowSidebarThreshold {
                    Button(role: .destructive) {
                        Task { await store.deleteSession(summary.id) }
                    } label: {
                        Label("Delete", systemImage: "trash")
                    }
                }
                Button {
                    Task { await store.setSessionArchived(summary.id, !summary.archived) }
                } label: {
                    Label(summary.archived ? "Unarchive" : "Archive",
                          systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
                }
                .tint(.gray)
            }
    }
}

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

    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)
                }
                HStack(spacing: 4) {
                    if let projectName {
                        Text(projectName)
                            .font(.caption2).foregroundStyle(.secondary).lineLimit(1)
                        Text("·").font(.caption2).foregroundStyle(.tertiary)
                    }
                    Text(summary.statusLabel)
                        .font(.caption2).foregroundStyle(.secondary)
                    if summary.auto {
                        // Against the mid-gray selection highlight the saturated teal
                        // accent nearly disappears; switch to high-contrast white when
                        // the row is selected so the bolt stays legible.
                        Image(systemName: "bolt.fill").font(.system(size: 8))
                            .foregroundStyle(isSelected ? Color.white : palette.accent)
                    }
                    if 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 !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 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 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
    }
}
