import SwiftUI
import AppKit
import NucleicCore
import OSLog

/// Transcript + approval bar + integrate/discard controls for the open session
/// (UX_MACOS "Session detail").
struct SessionDetailView: View {
    @Environment(AppStore.self) private var store
    @Environment(\.appPalette) private var palette
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    /// Panel layout for the session's side panels.
    @Environment(PanelLayoutStore.self) private var panels
    @State private var integrating = false
    /// In-flight flag for the nvrsion trunk promotion ("Integrate") action, mirroring
    /// `integrating` for the per-session merge — disables the button while it runs.
    @State private var promoting = false
    @State private var draft = ""
    /// Files/images staged in the composer (attach button, paste, or drag) to send with the
    /// next message; materialized into the worktree on send (see `send()`).
    @State private var attachments: [ComposerAttachment] = []
    /// True while a file/image drag hovers the composer, to light up the drop zone.
    @State private var composerDropTargeted = false
    @State private var composerHeight = ChatInputField.restingHeight
    @State private var renaming = false
    @State private var renameDraft = ""
    @State private var summaryExpanded = false
    /// Which chat `summaryExpanded` currently describes. Lets the status handler tell a
    /// genuine live turn-transition (animate the card open) from the status delta of a chat
    /// switch (snap, no animated slide of the transcript) without depending on onChange order.
    @State private var summaryStatusSession: SessionID?
    /// Drives the composer's orchestra glow, which announces the mode when you switch a chat into
    /// orchestra and then retires once you've actually run a turn under it — a permanent halo
    /// would just be noise. `orchestraGlowSession` ties the flags to the open chat so switching
    /// chats re-arms the announcement; `orchestraTurnStarted`/`orchestraTurnElapsed` track a turn
    /// beginning and then finishing under orchestra (enabling it mid-turn doesn't count — orchestra
    /// only affects the *next* turn).
    @State private var orchestraGlowSession: SessionID?
    @State private var orchestraTurnStarted = false
    @State private var orchestraTurnElapsed = false
    /// Branch names for the open session's project, populated for the autoship
    /// destination picker inside `shipControl`.
    @State private var shipBranches: [String] = []
    /// The user message awaiting a "revert to here" confirmation (its canonical `seq`).
    @State private var revertSeq: UInt64?
    /// Live scroll-position tracking for the transcript, updated by
    /// `onScrollGeometryChange`. True while the user is parked at (within a hair of) the
    /// bottom, so live output keeps following; once they scroll up it flips false,
    /// following stops, and the jump-to-bottom chevron appears. Starts true — a freshly
    /// opened chat is anchored at the bottom.
    @State private var isScrolledToBottom = true
    /// The live phase of the transcript scroll. Content growth and bottom-anchor re-pins also
    /// change scroll geometry, so distance alone cannot tell a real user scroll from layout.
    /// Only an interactive/decelerating phase is allowed to disengage following (see
    /// `scrollableTranscript`), which prevents streaming text from flipping the gate itself.
    @State private var transcriptScrollPhase: ScrollPhase = .idle
    /// The scroll geometry reduced to the only thresholds that affect bottom following. Keeping
    /// the region (instead of the continuously-changing point distance) makes a long swipe update
    /// SwiftUI state only as it crosses a boundary, and lets a newly-started drag apply the latest
    /// geometry even when content growth crossed that boundary before the gesture began.
    @State private var transcriptBottomRegion: TranscriptScrollFollowPolicy.Region = .atBottom
    /// True from an explicit jump (chevron / send) until geometry confirms arrival at the
    /// bottom. Scroll-phase and geometry callbacks are independently scheduled; without this
    /// latch, a stale deceleration reading can land after the jump and immediately disengage
    /// following again.
    @State private var jumpingToTranscriptBottom = false
    /// Memoizes the last transcript projection across body re-evaluations (see
    /// `projectedTranscript`); a reference box so it survives the value-type view's re-inits.
    @State private var projectionCache = ProjectionCache()
    /// The exact store revision most recently projected by the off-main worker. This observable
    /// edge wakes the Markdown/first-layout tasks after the cache's unobserved value is installed.
    @State private var transcriptProjectionReadyToken: ProjectionToken?
    /// Bumped to force a scroll to the bottom (e.g. when the user sends a message),
    /// overriding the at-bottom gate.
    @State private var scrollToBottomRequest = 0
    /// True for a short window right after a chat opens, while its transcript runs its
    /// first layout passes. Scroll-follow and prose-reflow animations are suppressed
    /// during this window so a freshly opened chat *snaps* to the bottom in one pass
    /// instead of easing/oscillating into place as the initial measurements settle —
    /// the tiny up-and-down jitter on open. Cleared a few frames after the open.
    @State private var transcriptSettling = true
    /// Live width of the chat column, captured from `body`'s outer VStack. The transcript
    /// eases its width changes across a horizontal window resize (see the `.animation` in
    /// `scrollableTranscript`), keyed to its own GeometryReader width. The composer, dividers,
    /// and approval bars sit outside that reader, so they need the same live width to ease in
    /// lockstep — otherwise they snap straight to the window edge while the transcript trails
    /// behind, and the two look horizontally disconnected mid-drag. This mirrors that width so
    /// `chatColumn` can key an identical animation off it.
    @State private var columnWidth: CGFloat = 0
    /// Gates the transcript's visibility on open: false from the moment a chat opens until its
    /// transcript has loaded and its initial layout has pinned to the bottom, then flipped true to
    /// reveal it in one shot. The bottom anchor settles over a few layout passes; holding the
    /// view invisible across them means a freshly opened chat *appears* already at the tail with
    /// no on-screen scroll/jitter, instead of visibly snapping into place over those first frames.
    @State private var transcriptRevealed = false
    /// The session whose long-history list has been explicitly pinned to the final sentinel after
    /// its first real tail chunk mounted. This is separate from the passive bottom-follow state:
    /// the first native-list layout can otherwise stop at the last message while later log/status
    /// rows are still resolving below it.
    @State private var transcriptInitialBottomPinnedSessionID: SessionID?
    /// Total scroll-content height of the open transcript, tracked live from the scroll
    /// view's geometry. The reveal above waits for this to stop changing (the initial layout
    /// and async tool-summary lines grow it for a beat after open), so the chat is shown only
    /// once it has truly come to rest at the bottom — never mid-settle.
    @State private var transcriptContentHeight: CGFloat = 0
    /// The session whose first-paint Markdown has been loaded into `MarkdownText`'s in-memory
    /// caches. First layout is gated on this edge as well as AppStore's history-ready edge. Long
    /// histories need only their visible tail here; their full persisted snapshot follows off-main.
    @State private var markdownReadySessionID: SessionID?
    /// One observable edge for the opportunistic length-sidecar load. The actual lookup table lives
    /// in the unobserved projection cache so recording each row does not invalidate the whole List.
    @State private var transcriptLengthCacheReadySessionID: SessionID?
    /// Exact long-history range table whose top-level Markdown has completed the continuous
    /// off-main warm pass. Individual visible rows become ready in local row state, avoiding a
    /// full transcript invalidation for every two-row chunk.
    @State private var transcriptMarkdownReadyHydrationToken: TranscriptSegmentHydrationToken?
    @AppStorage(TranscriptDisplay.showDebugKey) private var showDebugLines = false
    @AppStorage(TranscriptDisplay.showLockEventsKey) private var showLockEvents = true
    @AppStorage(SubmitKeyMode.storageKey) private var submitKeyRaw = SubmitKeyMode.modifierSends.rawValue
    /// Whether the service-status pill shows here (it always shows on home); "Only on home
    /// view" hides it from the chat composer.
    @AppStorage(StatusIndicatorVisibility.storageKey) private var statusVisibilityRaw = StatusIndicatorVisibility.everywhere.rawValue
    /// Override: under "Only on home view", still surface the pill here when this chat's model's
    /// provider has an active incident (see `showsStatusIndicator`).
    @AppStorage(StatusIndicatorVisibility.chatIncidentOverrideKey) private var showStatusInChatOnIncident = true
    /// Intelligence routing: on by default; the Settings toggle restores the manual
    /// Model + Effort menus. A routed chat keeps its recorded purpose, while this local
    /// level mirrors the in-session slider and re-resolves the next turn on release.
    @AppStorage(ModelCatalog.intelligenceSliderEnabledKey) private var intelligenceSliderEnabled = true
    @State private var sliderIntelligenceLevel = IntelligenceLevel.fallback
    @State private var sliderOrchestraSelected = false
    @State private var sliderOrchestraPreview: Bool?

    private var submitMode: SubmitKeyMode { SubmitKeyMode(rawValue: submitKeyRaw) ?? .modifierSends }

    /// Width reserved for the send button, so the bottom controls row can be
    /// inset to line up with the text field's trailing edge.
    private let sendButtonWidth: CGFloat = 28

    /// Horizontal breathing room between transcript text and the column edges, and
    /// the base prose size — chat is for reading, so it gets generous margins and a
    /// larger default than the macOS 13pt body. The floating summary card is inset
    /// by the same amount so it lines up with the transcript's trailing edge.
    private let transcriptInset: CGFloat = 28
    private let transcriptFontSize: CGFloat = 15
    /// Room beneath the last response, before the composer / approval bar. Rendered
    /// as a trailing spacer (see `transcript`) so it's part of the scrollable content.
    private let transcriptBottomPadding: CGFloat = 36

    /// Caps the transcript to a word-processor-style measure, centered in the
    /// viewport, so prose lines stay a comfortable length on wide windows instead of
    /// running edge to edge.
    private let contentMaxWidth: CGFloat = 720

    /// The summary card's expanded width — the breakpoint below uses this (not the
    /// live width) so the card doesn't hop between margin and overlay as it grows.
    private let summaryCardMaxWidth: CGFloat = 240
    /// Clearance kept between the transcript column and a margin-docked card — both the
    /// gap the card leaves beside the prose and the slack in the fit test below.
    private let summaryCardColumnGap: CGFloat = 12

    /// Whether the side margin beside the centered column is wide enough to hold the
    /// (expanded) summary card — at which point the card moves out of the transcript
    /// into the right margin instead of floating over the prose. The card docks flush
    /// against the column edge (not the window edge), so the margin only needs to fit
    /// the card itself plus one gap — no window inset — which keeps the switch from
    /// demanding excessive width.
    private func summaryCardFitsInMargin(width: CGFloat) -> Bool {
        let sideMargin = (width - contentMaxWidth) / 2
        return sideMargin >= summaryCardMaxWidth + summaryCardColumnGap
    }

    private var session: Session? { store.openSession }

    /// Whether the open session's project is governed by nvrsion (shared trunk). When it is,
    /// every session edits and lands into the one `nucleic/trunk` and the real branch only moves
    /// by project-level promotion — so a per-session branch indicator and a redirectable merge
    /// destination are meaningless, and both are hidden in the composer.
    private var nvrsionActive: Bool {
        session.flatMap { store.project($0.projectID) }?.nvrsionActive == true
    }

    /// Effective Auto state for the composer. Under Nucleic Control every chat runs
    /// autonomously — Auto is forced on and its toggle is locked (nvrsion in particular
    /// can't function without it) — so this reads on regardless of the stored flag.
    /// Off-control it's just the session's own toggle.
    private var autoOn: Bool { isControlProject || (session?.auto ?? false) }

    /// An archived chat is read-only: the composer is dimmed and inert, its messages
    /// can't be reverted, and the send button is replaced by Unarchive (see `composer`).
    private var isArchived: Bool { session?.archived ?? false }

    /// Another device's live typing in this chat's composer (mesh composer streaming). While
    /// present, this composer is locked and renders the streamed draft; it unlocks when the
    /// typer sends or goes idle (their tombstone — or the local expiry guard — clears it).
    private var remoteTyping: ComposerTypingState? {
        guard let id = session?.id else { return nil }
        return store.composerTypingBySession[id]
    }

    // Effective concrete values (never shows "Default"): the session's override, or
    // the app default. Selecting a value sets a concrete override.
    private var effectiveModel: String { session?.model ?? store.defaultModel ?? ModelCatalog.fallbackModel }
    private var effectiveEffort: String {
        let raw = ModelCatalog.clampedEffort(
            session?.effort ?? store.defaultEffort ?? ModelCatalog.fallbackEffort,
            for: effectiveModel, codexPro: store.isCodexPro)
        // Orchestra requires Nucleic Control; if the session's project isn't under control, fall
        // back to its underlying level so the menu never shows it active where it can't run.
        if ModelCatalog.isOrchestra(raw), !isControlProject {
            return OrchestrationMode.resolvedEffort(raw) ?? ModelCatalog.fallbackEffort
        }
        return raw
    }

    /// Session context-window occupancy: the most recent turn's *final-call* input
    /// (`contextInputTokens`) against the model's window. Not the summed token fields —
    /// those accumulate across an agentic turn's many calls and overflow the window.
    /// Nil until a turn reports per-call usage.
    private var contextUsage: ContextWindowUsage? {
        let used = projectionCache.value.metadata.contextInputTokens
        guard let used, used > 0 else { return nil }
        return ContextWindowUsage(
            usedTokens: used, windowTokens: ModelCatalog.contextWindow(for: effectiveModel))
    }

    /// Whose subscription quota the composer pill reports. Inferred from the effective model —
    /// the model picker doubles as the backend selector, so switching a chat to a GPT SKU should
    /// switch the pill to Codex usage even before the session's recorded backend catches up —
    /// falling back to that recorded backend for SKUs the catalog doesn't map.
    private var quotaBackend: BackendID? { BackendID.forModel(effectiveModel) ?? session?.backend }

    var body: some View {
        VStack(spacing: 0) {
            header
            Divider()
            transcript
            if let approval = store.openApprovals.first {
                Divider().chatColumn(maxWidth: contentMaxWidth, inset: transcriptInset, resizeWidth: columnWidth, settling: transcriptSettling)
                Group {
                    if approval.toolName == AskUserQuestion.toolName,
                        let questions = AskUserQuestion.questions(from: approval.input)
                    {
                        AskUserQuestionBar(request: approval, questions: questions)
                    } else {
                        ApprovalBar(request: approval)
                    }
                }
                .chatColumn(maxWidth: contentMaxWidth, inset: transcriptInset, resizeWidth: columnWidth, settling: transcriptSettling)
            }
            Divider().chatColumn(maxWidth: contentMaxWidth, inset: transcriptInset, resizeWidth: columnWidth, settling: transcriptSettling)
            composer
        }
        // Mirror the column's live width so the composer/divider/approval column (which live
        // outside the transcript's GeometryReader) can ease their width in lockstep with the
        // transcript during a horizontal window resize — keeping them horizontally locked
        // instead of snapping ahead while the transcript's eased reflow trails behind.
        .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { columnWidth = $0 }
        // The summary card auto-expands to show a finished turn's recap (idle, awaiting input)
        // or its live "Working…" progress (running). On a *live* transition within the chat
        // you're already viewing, that expand animates — a nice cue. But on a chat *switch* the
        // status also "changes" (the previous chat's status → this one's), and animating the
        // expand then grew the card's hidden space-twin inside the transcript, sliding all the
        // prose down for the length of the animation. That slow slide — text composited (thin
        // and fuzzy) while it moves — is what read as the transcript jittering on load. So snap
        // to the resting state on open with no animation, and reserve the animation for genuine
        // live transitions. `summaryStatusSession` records which chat the expand state belongs
        // to, making the live-vs-open decision independent of onChange firing order.
        .onChange(of: store.openSessionID) { _, id in
            transcriptSettling = true
            transcriptRevealed = false
            transcriptInitialBottomPinnedSessionID = nil
            transcriptContentHeight = 0
            isScrolledToBottom = true
            transcriptScrollPhase = .idle
            transcriptBottomRegion = .atBottom
            jumpingToTranscriptBottom = false
            markdownReadySessionID = nil
            transcriptProjectionReadyToken = nil
            transcriptLengthCacheReadySessionID = nil
            projectionCache.resetProjection()
            projectionCache.resetChunkHydration()
            transcriptMarkdownReadyHydrationToken = nil
            summaryStatusSession = id
            summaryExpanded = summaryShouldRestExpanded
        }
        .onChange(of: session?.status) { _, newStatus in
            guard summaryStatusSession == store.openSessionID else { return }
            if newStatus == .awaitingInput || newStatus == .running {
                withAnimation { summaryExpanded = true }
            }
        }
        // Re-arm the composer's orchestra glow for whichever chat is on screen — a fresh chat (or a
        // switch back to one that already ran an orchestra turn) starts the announcement over.
        .onChange(of: store.openSessionID, initial: true) { _, id in
            orchestraGlowSession = id
            orchestraTurnStarted = false
            orchestraTurnElapsed = false
            sliderIntelligenceLevel = recordedIntelligenceLevel
            sliderOrchestraSelected = isOrchestra
            sliderOrchestraPreview = nil
        }
        .onChange(of: isOrchestra) { _, selected in
            sliderOrchestraSelected = selected
            if selected { sliderIntelligenceLevel = .max }
        }
        // Retire that glow once a turn has run start-to-finish under orchestra: arm on an orchestra
        // turn beginning, extinguish when it ends. Guarded to the open chat so a trailing status
        // change from a chat we just left can't touch the new one's glow.
        .onChange(of: isBusy) { _, busy in
            guard orchestraGlowSession == store.openSessionID else { return }
            if busy {
                if isOrchestra { orchestraTurnStarted = true }
            } else if orchestraTurnStarted {
                orchestraTurnElapsed = true
            }
        }
        // While a turn is in flight, keep the shared-container download indicator fresh so the
        // working row can attribute a slow first response to the sandbox still downloading. Cheap
        // (one actor hop, no I/O); polls only while busy and refreshes once on the way out to clear
        // a stale value. Restarts whenever `isBusy` flips.
        .task(id: isBusy) {
            await store.refreshContainerDownload()
            while isBusy && !Task.isCancelled {
                try? await Task.sleep(for: .seconds(1))
                await store.refreshContainerDownload()
            }
        }
        // Projection/coalescing is the last whole-history operation between canonical events and
        // render chunks. Keep it entirely off the main actor; a full authoritative replacement can
        // then take as long as it needs without freezing the already-visible startup tail.
        .task(id: transcriptProjectionWorkToken, priority: .userInitiated) {
            await refreshTranscriptProjection()
        }
        // Warm the exact assistant prose installed by the store without making a long history's
        // entire parse snapshot part of first paint. A virtualized transcript warms only its tail,
        // reveals immediately, then continues backward through the rest off-main; the native list
        // won't construct those older rows until they approach the viewport. Short transcripts
        // retain the all-before-layout path because their eager stack needs every row. A turn
        // transition refreshes the complete durable snapshot without hiding an already-open chat.
        .task(id: MarkdownPrewarmToken(
            session: store.openSessionID,
            historyReady: store.openTranscriptAvailableSessionID,
            transcriptVersion: store.openTranscriptVersion,
            projectionReadyVersion: transcriptProjectionReadyToken?.version,
            busy: isBusy))
        {
            guard let sessionID = store.openSessionID,
                  store.openTranscriptAvailableSessionID == sessionID,
                  let projectionReady = transcriptProjectionReadyToken,
                  projectionReady == currentProjectionToken
            else { return }
            let projection = projectedTranscript()
            let usesVirtualizedLayout = TranscriptRenderSegmenter.shouldVirtualize(
                rowCount: projection.items.count)

            if markdownReadySessionID != sessionID {
                if usesVirtualizedLayout {
                    // Warm only Markdown directly rendered by the newest two-row segment. A
                    // collapsed subagent/tool row can own thousands of descendants; traversing
                    // those here made one nominally tiny chunk take seconds. The complete nested
                    // snapshot follows at background priority after visible chunks are ready.
                    let tailSegment = projection.segments.last
                    let tailItems = tailSegment.map {
                        Array(projection.items[$0.range])
                    } ?? []
                    let tail = Self.assistantMarkdownForMount(in: tailItems)
                    _ = await MarkdownText.prewarm(
                        tail, sessionID: sessionID, diskAccess: .memoryOnly)
                    guard !Task.isCancelled, store.openSessionID == sessionID else { return }
                    // The tail row seeds its own local readiness from this completed warm-up.
                    // No root-level mounted set is mutated: one dictionary write per chunk forced
                    // SwiftUI to rebuild the complete long-history List during rapid scrolling.
                    markdownReadySessionID = sessionID
                    // The complete snapshot is a separate, reveal-keyed task below. Returning is
                    // important: even collecting all body strings is an O(history) main-actor walk
                    // and must not sneak back into the first-paint critical path.
                    return
                } else {
                    // Eager top-level layout is small, but any one collapsed tool/subagent row may
                    // own a massive nested stream. Only its header mounts now; descendants warm in
                    // the background snapshot below.
                    let bodies = Self.assistantMarkdownForMount(in: projection.items)
                    _ = await MarkdownText.prewarm(
                        bodies, sessionID: sessionID, diskAccess: .memoryOnly)
                    guard !Task.isCancelled, store.openSessionID == sessionID else { return }
                    markdownReadySessionID = sessionID
                    return
                }
            }
            // Once visible, both layout modes use the background-priority snapshot task below.
            // Keeping whole-history collection out of this user-initiated task is essential for a
            // transcript whose apparent single tool row contains a very large nested stream.
            return
        }
        // Finish the durable Markdown snapshot only after text is on screen and a live turn is not
        // producing revisions. Long histories also wait for the continuous top-level pass. This
        // keeps disk snapshot reconstruction out of both first paint and rapid streaming/scrolling;
        // the `busy` edge starts one exact refresh when the turn settles.
        .task(id: MarkdownSnapshotRefreshToken(
            session: store.openSessionID,
            historyReady: store.openTranscriptReadySessionID,
            projectionReadyVersion: transcriptProjectionReadyToken?.version,
            busy: isBusy,
            revealed: transcriptRevealed,
            segmentsHydrated: transcriptMarkdownReadyHydrationToken), priority: .background)
        {
            guard transcriptRevealed,
                  !isBusy,
                  let sessionID = store.openSessionID,
                  store.openTranscriptReadySessionID == sessionID,
                  markdownReadySessionID == sessionID,
                  transcriptProjectionReadyToken == currentProjectionToken
            else { return }
            let projection = projectedTranscript()
            let virtualized = TranscriptRenderSegmenter.shouldVirtualize(
                rowCount: projection.items.count)
            guard !virtualized
                    || transcriptMarkdownReadyHydrationToken
                        == transcriptSegmentHydrationToken(for: projection)
            else { return }
            let items = projection.items
            let bodies = await Task.detached(priority: .background) {
                Self.assistantMarkdown(in: items)
            }.value
            guard !Task.isCancelled, store.openSessionID == sessionID else { return }
            _ = await MarkdownText.prewarm(bodies, sessionID: sessionID)
        }
        .toolbar {
            ToolbarItemGroup {
                // No Stop item here: the composer's own stop-circle already interrupts the
                // running turn, and status refreshes itself, so the toolbar stays uncluttered.
                if let session, let project = store.project(session.projectID) {
                    BuildRunControls(
                        project: project,
                        workingDirectory: session.worktreePath,
                        compact: true)
                }
                Menu {
                    if nvrsionActive {
                        // Whole-trunk promote blocks while any chat in this project is mid-turn —
                        // squashing a half-written trunk out from under a running agent would land
                        // partial work. The per-chat promote below only waits on *this* chat.
                        let chatsRunning = session.map { store.isAnySessionRunning(in: $0.projectID) } ?? false
                        let thisChatRunning = session?.status.hasTurnInFlight ?? true
                        Section("nvrsion") {
                            // Ship just this chat's landed work — no need to wait for a long-running
                            // sibling to finish (NVRSION §6, per-session promotion).
                            Button("Integrate this chat → \(nvrsionPromoteTarget)") {
                                Task { await promoteSession() }
                            }
                            .disabled(promoting || thisChatRunning)
                            Button("Integrate trunk → \(nvrsionPromoteTarget)") {
                                Task { await promoteTrunk() }
                            }
                            .disabled(promoting || chatsRunning)
                        }
                    }
                    Section("Merge into \(targetBranch)") {
                        Button("Squash & merge") { Task { await integrate(.squash) } }
                        Button("Merge commit") { Task { await integrate(.merge) } }
                        Button("Rebase & fast-forward") { Task { await integrate(.rebase) } }
                    }
                    Section {
                        Button("Open in Terminal", systemImage: "terminal") { openInTerminal() }
                        Button("Reveal in Finder", systemImage: "folder") { revealInFinder() }
                    }
                    Section {
                        Button("Copy Branch Name", systemImage: "arrow.triangle.branch") {
                            copyToClipboard(session?.branch, note: "Copied branch name: \(session?.branch ?? "")")
                        }
                        Button("Copy Worktree Path", systemImage: "doc.on.doc") {
                            copyToClipboard(session?.worktreePath, note: "Copied worktree path")
                        }
                    }
                    Section {
                        Button("Release File Locks", systemImage: "lock.open") {
                            Task {
                                await store.releaseOpenSessionLocks()
                                await store.noteToOpenSession(
                                    "Released file locks — agents waiting on these files can proceed.",
                                    icon: "lock.open")
                            }
                        }
                        .help("Clear this chat's hold on its changed files so other agents waiting on them can proceed. "
                            + "Use if a lock persists after the work stalls; the lock re-arms once this chat merges or edits again.")
                    }
                } label: {
                    Label("Git", systemImage: "arrow.triangle.branch")
                }
                .tint(.white)
                .disabled(integrating)
                .help("Merge, or open this chat's worktree for any git command")
                Button { Task { await exportSession() } } label: {
                    Label("Export", systemImage: "square.and.arrow.up")
                }
                .help("Export the full chat (with backend/debug info) to a file")
                Button(role: .destructive) { Task { await store.discardOpenSession() } } label: {
                    Label("Discard", systemImage: "trash")
                }
            }
        }
        .alert("Rename chat", isPresented: $renaming) {
            TextField("Name", text: $renameDraft)
            Button("Cancel", role: .cancel) {}
            Button("Rename") { Task { await store.renameOpenSession(to: renameDraft) } }
        }
        .confirmationDialog(
            "Revert to this message?",
            isPresented: Binding(get: { revertSeq != nil }, set: { if !$0 { revertSeq = nil } }),
            presenting: revertSeq
        ) { seq in
            Button("Revert & Edit", role: .destructive) { Task { await performRevert(seq) } }
            Button("Cancel", role: .cancel) { revertSeq = nil }
        } message: { _ in
            Text("This deletes this message and everything after it and rewinds the agent's "
                + "memory to before it. Your files are left unchanged. The message goes back "
                + "into the composer so you can edit and resend it.")
        }
    }

    /// Perform the revert and seed the composer with the reverted message for editing.
    private func performRevert(_ seq: UInt64) async {
        revertSeq = nil
        if let text = await store.revertOpenSession(toSeq: seq) {
            draft = text
        }
    }

    private var defaultModel: String { store.defaultModel ?? ModelCatalog.fallbackModel }
    private var defaultEffort: String { store.defaultEffort ?? ModelCatalog.fallbackEffort }

    /// Menu item: checkmark on the selected value, a "house" on the app default.
    @ViewBuilder
    private func choiceLabel(_ display: String, badge: String? = nil, isSelected: Bool, isDefault: Bool) -> some View {
        let title = Text(display)
            + (badge.map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
            + (isDefault ? Text("  (default)") : Text(""))
        if isSelected {
            Label { title } icon: { Image(systemName: "checkmark") }
        } else if isDefault {
            Label { title } icon: { Image(systemName: "house") }
        } else {
            title
        }
    }

    private var modelMenu: some View {
        Menu {
            // A chat that leaves routed mode uses this same-backend picker. The Intelligence
            // slider remains available only while routing provenance is present.
            // A session's backend is fixed at creation, so only offer same-backend models.
            ForEach(ModelCatalog.models(for: session?.backend ?? .claudeCode), id: \.self) { sku in
                Button { Task { await store.setOpenSessionModel(sku) } } label: {
                    choiceLabel(ModelCatalog.displayName(sku),
                                badge: ModelCatalog.contextBadge(for: sku),
                                isSelected: sku == effectiveModel, isDefault: sku == defaultModel)
                }
            }
        } label: {
            (Text("Model: \(ModelCatalog.displayName(effectiveModel))")
                + (ModelCatalog.contextBadge(for: effectiveModel).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text("")))
                .padding(.horizontal, 8)
                .padding(.vertical, 4)
                .contentShape(Rectangle())
        }
        .menuStyle(.button)
        .buttonStyle(.plain)
        .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 6))
        .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.quaternary, lineWidth: 1))
        .fixedSize()
        .help("Model for the next turn")
    }

    /// Whether the next turn runs in orchestra (orchestration) mode.
    private var isOrchestra: Bool { ModelCatalog.isOrchestra(effectiveEffort) }

    /// The accent for the composer's controls: the project-type accent normally (teal, or the
    /// Nucleic Control lavender), swapped to the orchestra gold while orchestra is the active
    /// effort — so the Auto/Merge toggles and the mode badge read as "orchestra is on" together
    /// with the composer's glow and effort pill. An archived chat is inert (no glow), so it
    /// keeps the plain project accent.
    private var composerAccent: Color {
        isOrchestra && !isArchived ? AppTheme.orchestra : palette.accent
    }

    /// Whether the composer wears the orchestra glow: orchestra is the active effort, the chat
    /// isn't archived (inert), and a full turn hasn't yet elapsed under orchestra — the glow is an
    /// announcement that fades once you've run a turn with it (see the glow state above).
    private var showsOrchestraGlow: Bool {
        (sliderOrchestraPreview ?? sliderOrchestraSelected)
            && !isArchived && !orchestraTurnElapsed
    }

    /// Whether the open session's project is under Nucleic Control — the gate for Orchestra.
    /// Resolved through `projectSummary` so a Control chat on a peer Mac themes and gates
    /// identically to a local one (mesh session sync; the owner applies the real control gate).
    private var isControlProject: Bool {
        session.flatMap { store.projectSummary($0.projectID) }?.isNucleicControlled == true
    }

    private var effortMenu: some View {
        Menu {
            ForEach(
                ModelCatalog.efforts(for: effectiveModel, codexPro: store.isCodexPro),
                id: \.self
            ) { level in
                Button { Task { await store.setOpenSessionEffort(level) } } label: {
                    choiceLabel(ModelCatalog.effortDisplayName(level),
                                isSelected: level == effectiveEffort, isDefault: level == defaultEffort)
                }
            }
            // Orchestra is an orchestration mode, not an API level — set it apart below the
            // canonical efforts (xhigh + standing consent to fan out to parallel subagents).
            // Switching it affects the next turn and remains gated to Control projects.
            Divider()
            Button { Task { await store.setOpenSessionEffort(ModelCatalog.orchestraEffort) } } label: {
                orchestraMenuItem(available: isControlProject)
            }
            .disabled(!isControlProject)
            .help(!isControlProject
                ? ModelCatalog.orchestraRequiresControlHelp
                : ModelCatalog.orchestraBlurb)
        } label: {
            Group {
                if isOrchestra {
                    OrchestraEffortLabel()
                } else {
                    Text("\(ModelCatalog.effortNoun(for: effectiveModel)): \(ModelCatalog.effortDisplayName(effectiveEffort))")
                }
            }
            .padding(.horizontal, 8)
            .padding(.vertical, 4)
            .contentShape(Rectangle())
        }
        .menuStyle(.button)
        .buttonStyle(.plain)
        .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 6))
        .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(
            isOrchestra ? AnyShapeStyle(AppTheme.orchestra.opacity(0.6)) : AnyShapeStyle(.quaternary),
            lineWidth: 1))
        .fixedSize()
        .help(isOrchestra ? ModelCatalog.orchestraBlurb : "Reasoning effort for the next turn")
    }

    /// The Orchestra row in the effort menu: a sparkles glyph (checkmark when selected),
    /// "Orchestra", and either "(default)", or — when unavailable because the project isn't
    /// under Nucleic Control — a grayed "Requires Nucleic Control" note.
    @ViewBuilder
    private func orchestraMenuItem(available: Bool) -> some View {
        let isDefault = ModelCatalog.isOrchestra(defaultEffort)
        Label {
            Text("Orchestra")
                + (available
                    ? (isDefault ? Text("  (default)") : Text(""))
                    : Text("  — \(ModelCatalog.orchestraRequiresControlNote)").foregroundColor(.secondary))
        } icon: {
            Image(systemName: isOrchestra ? "checkmark" : OrchestraStyle.symbol)
        }
    }

    // MARK: - Intelligence routing (in-session)

    /// Whether this chat's backend can be routed at all: the matrix only covers the Claude
    /// and Codex lanes — the ACP wrappers' "reasoning" is cosmetic, so they keep menus.
    private var backendSupportsRouting: Bool {
        guard let backend = session?.backend else { return false }
        return !ModelCatalog.autoReasoningBackends.contains(backend)
    }

    /// The Intelligence slider replaces the classic model/reasoning pair in every in-session
    /// composer whose backend has a real routing matrix. Existing pre-routing chats begin at
    /// the user's default level and acquire provenance on their first adjustment.
    private var intelligenceRoutingActive: Bool {
        intelligenceSliderEnabled && backendSupportsRouting
    }

    private var recordedIntelligenceLevel: IntelligenceLevel {
        if isOrchestra { return .max }
        guard let raw = session?.routedLevel,
              let level = IntelligenceLevel(rawValue: raw)
        else { return ModelCatalog.storedDefaultIntelligenceLevel }
        return level
    }

    private var routedPurpose: PromptPurpose {
        session?.routedPurpose.flatMap(PromptPurpose.init(rawValue:)) ?? .general
    }

    private var degradedRoutingProviders: Set<BackendID> {
        var degraded = Set<BackendID>()
        for feed in store.statusFeeds where feed.hasActiveIncident {
            switch feed.provider {
            case .claude: degraded.insert(.claudeCode)
            case .openai: degraded.insert(.codex)
            case .xai: degraded.insert(.grok)
            }
        }
        return degraded
    }

    private func routedResolution(
        for level: IntelligenceLevel
    ) -> IntelligenceRouter.Resolution? {
        guard let backend = session?.backend else { return nil }
        return IntelligenceRouter.route(
            purpose: routedPurpose,
            level: level,
            connected: store.connectedProviders,
            backendLock: backend,
            degraded: degradedRoutingProviders,
            limits: store.intelligenceRoutingLimits,
            codexPro: store.isCodexPro,
            fallback: (
                effectiveModel,
                OrchestrationMode.resolvedEffort(effectiveEffort)
                    ?? ModelCatalog.fallbackEffort))
    }

    private var sessionIntelligenceLevelBinding: Binding<IntelligenceLevel> {
        Binding(
            get: { sliderIntelligenceLevel },
            set: { next in
                sliderIntelligenceLevel = next
                Task { await applySessionIntelligenceLevel(next) }
            })
    }

    private var sessionOrchestraTierBinding: Binding<IntelligenceOrchestraTier?> {
        Binding(
            get: { sliderOrchestraSelected ? .orchestra : nil },
            set: { tier in
                sliderOrchestraSelected = tier != nil
                guard tier != nil else { return }
                sliderIntelligenceLevel = .max
                Task { await activateSessionOrchestra() }
            })
    }

    private func applySessionIntelligenceLevel(_ level: IntelligenceLevel) async {
        guard let resolution = routedResolution(for: level) else { return }
        guard resolution.isAvailable else {
            store.lastError = resolution.reason
            sliderIntelligenceLevel = recordedIntelligenceLevel
            sliderOrchestraSelected = isOrchestra
            return
        }
        await store.setOpenSessionModel(resolution.model)
        await store.setOpenSessionEffort(resolution.effort)
        await store.setOpenSessionRoutingNote(RoutingNote(resolution))
    }

    private func activateSessionOrchestra() async {
        guard isControlProject,
              let resolution = routedResolution(for: .max)
        else { return }
        // Max supplies the supervisor; the resisted overrun adds managed subagents to it.
        // Keep the session's backend lane fixed, just as every other in-session level does.
        await store.setOpenSessionModel(resolution.model)
        await store.setOpenSessionEffort(ModelCatalog.orchestraEffort)
        await store.setOpenSessionRoutingNote(RoutingNote(
            purpose: routedPurpose,
            level: .max,
            reason: "\(routedPurpose.displayName) · Orchestra — Max with managed subagents"))
    }

    /// Whether the chat's own lane is inside a reached quota window right now — the
    /// provider as a whole, or this model's family. Used to hold a send until the user
    /// changes the Intelligence level or the window resets.
    private var sessionQuotaBlocked: Bool {
        let limits = store.intelligenceRoutingLimits
        let lane = session?.backend == .codexExec ? BackendID.codex : session?.backend
        if let lane, limits.providers.contains(lane) { return true }
        return limits.contains(model: effectiveModel)
    }

    /// Compact manual escape hatch beside the in-session slider. A manual pick clears the
    /// routing note and pins that model; moving the bar later resumes level routing.
    private var routedChip: some View {
        // Precomputed: composing these inline in `.help` blows the type-checker budget.
        let routed = "Next turn: \(ModelCatalog.displayName(effectiveModel)) · "
            + "\(ModelCatalog.effortDisplayName(effectiveEffort)). "
        let reason = session?.routedReason.map { "\($0). " } ?? ""
        let helpText = routed + reason
            + "Moving the Intelligence bar re-resolves the next turn. Pick a model here "
            + "to pin it manually; moving the bar later resumes routing."
        return Menu {
            ForEach(ModelCatalog.models(for: session?.backend ?? .claudeCode), id: \.self) { sku in
                Button {
                    Task {
                        await store.setOpenSessionRoutingNote(nil)
                        await store.setOpenSessionModel(sku)
                    }
                } label: {
                    choiceLabel(ModelCatalog.displayName(sku),
                                badge: ModelCatalog.contextBadge(for: sku),
                                isSelected: sku == effectiveModel, isDefault: sku == defaultModel)
                }
            }
        } label: {
            Image(systemName: "slider.horizontal.3")
                .frame(width: 16, height: 16)
        }
        .menuStyle(.borderlessButton)
        .menuIndicator(.hidden)
        .controlSize(.small)
        .fixedSize()
        .disabled(sliderOrchestraSelected)
        .accessibilityLabel("Override routed model")
        .help(helpText)
    }

    private var autoToggle: some View {
        // Nucleic Control chats always run autonomously, so Auto is forced on and the toggle
        // is locked there (nvrsion can't function without it). Off-control it's a normal toggle.
        let locked = isControlProject
        let on = autoOn
        let lockedHelp = "Auto-approve is always on for Nucleic Control chats — they run autonomously."
        return Button {
            Task { await store.setOpenSessionAuto(!on) }
        } label: {
            Label("Auto", systemImage: on ? "bolt.fill" : "bolt.slash")
                .foregroundStyle(on ? composerAccent : Color.secondary)
                .padding(.horizontal, 8)
                .padding(.vertical, 4)
                .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .disabled(locked)
        .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 6))
        .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.quaternary, lineWidth: 1))
        .fixedSize()
        .help(locked
            ? lockedHelp
            : (on
                ? "Auto-approve mode on: Claude auto-approves safe actions; destructive ones still ask."
                : "Manual approvals: every gated tool asks first."))
        // macOS suppresses .help on disabled controls, so overlay a transparent hit area to
        // carry the explanation when Auto is locked on under Nucleic Control.
        .overlay {
            if locked {
                Color.clear
                    .contentShape(Rectangle())
                    .help(lockedHelp)
            }
        }
    }

    /// Combined Merge toggle + destination picker, rendered as one bordered unit so the
    /// "Merge" action and the branch it ships into read as a single control. The left half
    /// toggles autoship; once autoship is on, a divider and the destination menu slide out
    /// within the same border. The destination defaults to the project's autoship branch
    /// (the project root repo) and can be redirected to any branch; "Project default"
    /// clears the override.
    private var shipControl: some View {
        let on = session?.autoShip ?? false
        let project = session.flatMap { store.project($0.projectID) }
        // Autoship is a Nucleic Control capability. Enabling it is blocked off-control; a
        // stale "on" (persisted before this gate, or a project that left control) can still
        // be turned off, so only disable the toggle when off-control AND currently off.
        let controlled = project?.isNucleicControlled == true
        let inherited = project?.resolvedAutoShipBranch.value ?? "main"
        let override = session?.shipBranch
        let current = (override?.isEmpty == false) ? override! : inherited
        return HStack(spacing: 0) {
            Button {
                Task { await store.setOpenSessionAutoShip(!on) }
            } label: {
                Label("Ship", systemImage: on ? "shippingbox.fill" : "shippingbox")
                    .foregroundStyle(on ? composerAccent : Color.secondary)
                    .padding(.horizontal, 8)
                    .padding(.vertical, 4)
                    .contentShape(Rectangle())
            }
            .buttonStyle(.plain)
            .disabled(!controlled && !on)
            .help(!controlled
                ? "Autoship requires Nucleic Control — clone or move this project under Nucleic Control to enable it."
                : (on
                    ? "Autoship on: when the agent finishes, squash-merge this branch into \(current) via the merge queue (keeps Auto on)."
                    : "Autoship off: finished work waits for a manual merge."))
            // macOS suppresses .help tooltips on disabled controls, so overlay an enabled,
            // transparent hit area to carry the explanation when Merge is grayed out off-control.
            .overlay {
                if !controlled && !on {
                    Color.clear
                        .contentShape(Rectangle())
                        .help("Autoship/automerge is only available for Nucleic Control projects — clone or move this project under Nucleic Control to enable it.")
                }
            }

            // The destination is redirectable only on the classic per-session-branch path. Under
            // nvrsion there is a single shared trunk promoted at the project level, so there's no
            // per-session destination to choose — show just the Merge toggle, no branch picker.
            if on && !nvrsionActive {
                Divider().frame(height: 16)
                shipDestinationMenu(inherited: inherited, override: override, current: current)
                    .transition(.move(edge: .leading).combined(with: .opacity))
            }
        }
        .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 6))
        .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.quaternary, lineWidth: 1))
        .fixedSize()
    }

    /// Destination-branch menu nested inside `shipControl`'s shared border.
    private func shipDestinationMenu(inherited: String, override: String?, current: String) -> some View {
        Menu {
            Button {
                Task { await store.setOpenSessionShipBranch(nil) }
            } label: {
                if override?.isEmpty != false {
                    Label("Project default (\(inherited))", systemImage: "checkmark")
                } else {
                    Text("Project default (\(inherited))")
                }
            }
            if !shipBranches.isEmpty { Divider() }
            ForEach(shipBranches, id: \.self) { name in
                Button {
                    Task { await store.setOpenSessionShipBranch(name) }
                } label: {
                    if name == override {
                        Label(name, systemImage: "checkmark")
                    } else {
                        Text(name)
                    }
                }
            }
        } label: {
            HStack(spacing: 4) {
                Image(systemName: "arrow.triangle.merge")
                Text(current)
                Image(systemName: "chevron.down").font(.caption2)
            }
            .padding(.horizontal, 8)
            .padding(.vertical, 4)
            .contentShape(Rectangle())
        }
        .menuStyle(.button)
        .buttonStyle(.plain)
        .help("Destination branch autoship merges this chat into. Defaults to the project's "
            + "autoship branch (the project root repo).")
    }

    /// Live autoship status for the open session (from the merge queue), as a header pill.
    @ViewBuilder private var shipStatusPill: some View {
        if let id = session?.id, let status = store.shipStatuses[id], let (text, icon) = shipStatusLabel(status) {
            // A conflict/failure is shown in the attention color so it reads as "needs
            // attention" — a conflict no longer turns autoship off, so the Merge toggle stays
            // on and this pill is the prominent in-header flag. Other statuses stay muted.
            let needsAttention: Bool = {
                switch status {
                case .conflicted, .failed: return true
                default: return false
                }
            }()
            Label(text, systemImage: icon)
                .font(.caption)
                .foregroundStyle(needsAttention ? palette.attention : .secondary)
                .help("Autoship status")
        }
    }

    /// Shown when this session chose "Wait for Access" and is queued behind another
    /// agent's lock; it clears automatically once the agent is granted and resumes.
    @ViewBuilder private var waitingForAccessPill: some View {
        if let id = session?.id, store.sessionsWaitingForAccess.contains(id) {
            Label("Waiting for access", systemImage: "hourglass")
                .font(.caption)
                .foregroundStyle(.secondary)
                .help("Queued until the conflicting work merges, then resumes automatically.")
        }
    }

    /// Cumulative tokens (input + output) across every completed turn in the open
    /// session — a rough running tally of total token spend. `nil` until a turn
    /// reports usage. Summed from `turnCompleted` only (not `runFinished`, which would
    /// double-count the same turns).
    private var sessionTokens: Int? {
        projectionCache.value.metadata.sessionTokens
    }

    /// Start instant of the current/most-recent turn — the last user message in the
    /// transcript, falling back to the session's last-message timestamp. Anchors the
    /// live elapsed timer while the agent works.
    private var currentTurnStart: Date? {
        projectionCache.value.metadata.currentTurnStart ?? session?.lastUserMessageAt
    }

    /// Wall-clock duration of the most recent finished run, in milliseconds. Shown as a
    /// static elapsed time once the agent is idle.
    private var lastRunDurationMs: Int? {
        projectionCache.value.metadata.lastRunDurationMs
    }

    /// Compact token count, e.g. `1.2k` / `3.4M`.
    private func formatTokens(_ n: Int) -> String {
        if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
        if n >= 1_000 { return String(format: "%.1fk", Double(n) / 1_000) }
        return "\(n)"
    }

    /// `m:ss` (or `h:mm:ss` past an hour) for an elapsed-seconds value.
    private func formatDuration(_ seconds: Int) -> String {
        let s = max(0, seconds)
        if s >= 3600 {
            return String(format: "%d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60)
        }
        return String(format: "%d:%02d", s / 60, s % 60)
    }

    /// Running token tally next to the status word.
    @ViewBuilder private var tokenCounter: some View {
        if let tokens = sessionTokens {
            Text("\(formatTokens(tokens)) tok")
                .font(.caption.monospaced())
                .foregroundStyle(.secondary)
                .help("Total tokens (input + output) used this session")
        }
    }

    /// Elapsed-time readout next to the status word: ticks live while the agent works,
    /// then settles on the last run's total duration once it's idle.
    @ViewBuilder private var turnTimer: some View {
        if isBusy, let start = currentTurnStart {
            TimelineView(.periodic(from: Date(), by: 1)) { context in
                Label(
                    formatDuration(Int(context.date.timeIntervalSince(start))),
                    systemImage: "clock")
                    .font(.caption.monospaced())
                    .foregroundStyle(.secondary)
                    .help("Elapsed time for the running turn")
            }
        } else if let ms = lastRunDurationMs {
            Label(formatDuration(ms / 1000), systemImage: "clock")
                .font(.caption.monospaced())
                .foregroundStyle(.secondary)
                .help("Duration of the last completed run")
        }
    }

    private func shipStatusLabel(_ status: ShipStatus) -> (String, String)? {
        switch status {
        case .queued: return ("Queued to ship", "clock")
        case .merging: return ("Shipping…", "shippingbox")
        case .merged: return ("Shipped", "checkmark.seal")
        case .conflicted: return ("Ship conflict", "exclamationmark.triangle")
        case .failed: return ("Ship failed", "exclamationmark.triangle")
        case .skipped: return nil
        }
    }

    private var targetBranch: String {
        guard let session, let project = store.project(session.projectID) else { return "main" }
        return session.shipDestination(in: project).value
    }

    /// Refresh the branch list backing the autoship destination picker for the open
    /// session's project.
    private func loadShipBranches() async {
        guard let session, let project = store.project(session.projectID) else {
            shipBranches = []
            return
        }
        shipBranches = await store.branches(in: project)
    }

    private var header: some View {
        HStack {
            VStack(alignment: .leading, spacing: 2) {
                Text(session?.title ?? "Session").font(.headline)
                if let s = session {
                    HStack(spacing: 6) {
                        Circle().fill(palette.status(s.status, disposition: s.lastTurnDisposition))
                            .frame(width: 7, height: 7)
                        Text(s.status.label(disposition: s.lastTurnDisposition))
                            .font(.caption).foregroundStyle(.secondary)
                        tokenCounter
                        turnTimer
                        if let diff = s.diffStat, diff.filesChanged > 0 {
                            Text("· \(diff.filesChanged) files +\(diff.added) −\(diff.removed)")
                                .font(.caption.monospaced()).foregroundStyle(.secondary)
                        }
                        shipStatusPill
                        waitingForAccessPill
                    }
                }
            }
            Spacer()
            Button {
                renameDraft = session?.title ?? ""
                renaming = true
            } label: {
                Image(systemName: "pencil")
            }
            .buttonStyle(.plain).help("Rename chat")
        }
        .padding(12)
        .background(DetailSurface(layer: .inner))
    }

    private func summaryCardView() -> some View {
        ConversationSummaryCard(
            text: store.openSummary,
            summarizing: store.summarizing,
            completed: store.openSession?.lastTurnDisposition == .completed,
            working: isBusy || summarySettling,
            isExpanded: $summaryExpanded,
            onRefresh: { store.regenerateOpenSummary() })
    }

    /// The unsettled gap between a turn ending and its disposition being classified: the
    /// session has already dropped to `.awaitingInput` (so `isBusy` is false) but the async
    /// classifier hasn't yet resolved the turn to `.completed` ("Done") or a real question
    /// ("Summary"). Without this the card would flash its neutral "Summary" header for the
    /// fraction of a second it takes classification to land, then snap to "Done" — so hold
    /// the "Working…" state across the gap and let it resolve straight to the final header.
    /// Bounded by `summarizing` (spun up alongside the classifier at turn-end) so a turn that
    /// leaves the disposition `nil` for good — e.g. one that ended with no prose to classify —
    /// still settles onto "Summary" rather than sticking on "Working…". Mirrors the
    /// `ChatCueState.pending` window the sidebar sound cues already defer on.
    private var summarySettling: Bool {
        session?.status == .awaitingInput
            && session?.lastTurnDisposition == nil
            && store.summarizing
    }

    /// The immutable projection most recently delivered by `TranscriptProjectionWorker`.
    ///
    /// Returning this cache is intentionally O(1). In particular, a body evaluation must never
    /// discover a complete startup transcript and synchronously fold it on the main actor: that was
    /// the multi-second barrier after the first tail chunk. The worker task below owns all mutable
    /// projection state and advances this snapshot only after its off-main pass completes.
    private func projectedTranscript() -> TranscriptViewProjection {
        projectionCache.value
    }

    /// Inputs that define one projection result. The store version is monotonic across previews,
    /// authoritative replacement, reverts, and live tail updates; root/filter changes also require
    /// a new immutable render snapshot.
    private var currentProjectionToken: ProjectionToken {
        ProjectionToken(
            session: store.openSessionID,
            version: store.openTranscriptVersion,
            worktree: store.openSession?.worktreePath,
            showDebug: showDebugLines,
            showLock: showLockEvents)
    }

    private var transcriptProjectionWorkToken: ProjectionWorkToken {
        ProjectionWorkToken(
            projection: currentProjectionToken,
            historyAvailable: store.openTranscriptAvailableSessionID)
    }

    /// Project one canonical revision at foreground priority without occupying the main actor.
    /// Loading never parks for a scroll gesture: native-list rows are stable and off-main projection
    /// can keep advancing while AppKit handles momentum. Results from a superseded preview, filter,
    /// or session are discarded. The worker drops canceled calls before they enter the fold, so
    /// rapid preview publications do not leave a queue of obsolete whole-history passes behind.
    private func refreshTranscriptProjection() async {
        let token = currentProjectionToken
        guard let sessionID = token.session,
              store.openTranscriptAvailableSessionID == sessionID
        else { return }
        let events = store.openTranscript
        let worker = projectionCache.worker
        guard let result = await worker.update(
            events: events,
            worktreeRoot: token.worktree,
            showDebug: token.showDebug,
            showLock: token.showLock)
        else { return }
        guard !Task.isCancelled,
              store.openTranscriptAvailableSessionID == sessionID,
              currentProjectionToken == token
        else { return }
        let viewResult = TranscriptViewProjection(
            items: result.items,
            segments: result.segments,
            segmentEstimates: result.segmentEstimates,
            lockLines: result.lockLines,
            metadata: result.metadata,
            benchmarkStatistics: result.benchmarkStatistics,
            revision: TranscriptRenderRevision(
                session: token.session,
                transcriptVersion: token.version,
                firstEventSeq: events.first?.seq,
                renderedItems: result.items))
        var transaction = Transaction(animation: nil)
        transaction.disablesAnimations = true
        withTransaction(transaction) {
            projectionCache.token = token
            projectionCache.value = viewResult
            transcriptProjectionReadyToken = token
        }
    }

    /// A token that changes iff `projectedTranscript`'s inputs change.
    private struct ProjectionToken: Hashable {
        let session: SessionID?
        let version: Int
        let worktree: String?
        let showDebug: Bool
        let showLock: Bool
    }

    private struct ProjectionWorkToken: Hashable {
        let projection: ProjectionToken
        let historyAvailable: SessionID?
    }

    private struct TranscriptViewProjection {
        var items: [TranscriptItem]
        var segments: [TranscriptRenderSegment]
        var segmentEstimates: [Int: TranscriptRenderSegmentEstimate]
        var lockLines: [String: [NoteLock]]
        var metadata: TranscriptProjectionMetadata
        var benchmarkStatistics: TranscriptRenderBenchmarkStatistics?
        /// Changes only when the projection actually rendered by this view changes.
        var revision: TranscriptRenderRevision
    }

    /// Reference box for the memoized projection. A plain (unobserved) class, so mutating it
    /// inside `body` neither triggers a re-render nor trips "modifying state during update";
    /// `@State` just keeps the same instance alive across this view's body re-evaluations.
    @MainActor
    private final class ProjectionCache {
        var token: ProjectionToken?
        var value = TranscriptViewProjection(
            items: [], segments: [], segmentEstimates: [:], lockLines: [:], metadata: .empty,
            benchmarkStatistics: nil,
            revision: TranscriptRenderRevision(
                session: nil, transcriptVersion: 0, renderedItems: []))
        /// Single-owner mutable projection state lives on its own actor. Replacing the worker on a
        /// chat switch lets the new foreground request run immediately even if a canceled giant
        /// projection from the previous chat is still unwinding on another executor thread.
        var worker = TranscriptProjectionWorker()

        private let signposter = OSSignposter(
            subsystem: "com.nucleic", category: "transcript-performance")
        private let logger = Logger(
            subsystem: "com.nucleic", category: "transcript-performance")
        private var firstPaintSession: SessionID?
        private var firstPaintInterval: OSSignpostIntervalState?
        /// Runtime length lookup is intentionally unobserved and dictionary-backed. The former
        /// array scan plus `@State` write for every measured chunk rebuilt the entire List and
        /// approached quadratic work while scrolling through a long transcript.
        private var segmentHeights:
            [TranscriptRenderLengthContext: [TranscriptRenderSegmentIdentity: Double]] = [:]
        private var lengthUpdatedAt: [TranscriptRenderLengthContext: Date] = [:]
        /// Per-session debounce: a streaming tail can report several intermediate heights per
        /// second. Persist only the last settled measurement, and never perform sidecar I/O on the
        /// main actor.
        private var lengthSaveTasks: [SessionID: Task<Void, Never>] = [:]

        func beginFirstPaint(session: SessionID?) {
            if let interval = firstPaintInterval {
                signposter.endInterval("TranscriptFirstPaint", interval)
            }
            firstPaintSession = session
            firstPaintInterval = session == nil
                ? nil
                : signposter.beginInterval("TranscriptFirstPaint")
        }

        func endFirstPaint(session: SessionID?) {
            guard session == firstPaintSession,
                  let interval = firstPaintInterval
            else { return }
            firstPaintInterval = nil
            firstPaintSession = nil
            signposter.endInterval("TranscriptFirstPaint", interval)
            let rows = value.items.count
            let virtualized = TranscriptRenderSegmenter.shouldVirtualize(rowCount: rows)
            let benchmark = TranscriptRenderBenchmarkConfiguration.current
            if benchmark.isEnabled, let stats = value.benchmarkStatistics {
                logger.info(
                    "benchmark_first_paint variant=\(benchmark.variant.rawValue, privacy: .public) projected_rows=\(stats.topLevelRowCount) rendered_rows=\(rows) virtualized=\(virtualized) nested_children=\(stats.nestedChildCount) max_depth=\(stats.maximumNestedDepth) tool_calls=\(stats.toolCallCount) max_group=\(stats.maximumGroupSize) visible_markdown_bytes=\(stats.visibleMarkdownBytes) card_kinds=\(stats.cardKindSummary, privacy: .public)")
            } else {
                logger.info("first_paint rows=\(rows) virtualized=\(virtualized)")
            }
        }

        func resetProjection() {
            token = nil
            value = TranscriptViewProjection(
                items: [], segments: [], segmentEstimates: [:], lockLines: [:], metadata: .empty,
                benchmarkStatistics: nil,
                revision: TranscriptRenderRevision(
                    session: nil, transcriptVersion: 0, renderedItems: []))
            worker = TranscriptProjectionWorker()
        }

        func resetChunkHydration() {
            for task in lengthSaveTasks.values { task.cancel() }
            lengthSaveTasks = [:]
            segmentHeights = [:]
            lengthUpdatedAt = [:]
        }

        func installLengthLookup(_ lookup: TranscriptRenderLengthLookup) {
            segmentHeights = lookup.heights
            lengthUpdatedAt = lookup.updatedAt
        }

        func segmentHeight(
            for segment: TranscriptRenderSegmentIdentity,
            context: TranscriptRenderLengthContext
        ) -> Double? {
            if let exact = segmentHeights[context]?[segment] { return exact }
            let compatible = segmentHeights.keys
                .filter { $0.isCompatible(with: context) }
                .min { abs($0.contentWidth - context.contentWidth)
                    < abs($1.contentWidth - context.contentWidth) }
            return compatible.flatMap { segmentHeights[$0]?[segment] }
        }

        func recordSegmentHeight(
            _ height: Double,
            for segment: TranscriptRenderSegmentIdentity,
            context: TranscriptRenderLengthContext,
            sessionID: SessionID
        ) {
            if let previous = segmentHeights[context]?[segment], abs(previous - height) < 0.5 {
                return
            }
            segmentHeights[context, default: [:]][segment] = height
            lengthUpdatedAt[context] = Date()

            // Keep width-resize churn bounded without touching observable view state.
            while segmentHeights.count > TranscriptRenderLengthCache.maxLayoutsPerSession,
                  let oldest = lengthUpdatedAt.min(by: { $0.value < $1.value })?.key
            {
                segmentHeights[oldest] = nil
                lengthUpdatedAt[oldest] = nil
            }

            lengthSaveTasks[sessionID]?.cancel()
            lengthSaveTasks[sessionID] = Task { [weak self] in
                try? await Task.sleep(for: .milliseconds(450))
                guard !Task.isCancelled,
                      let self,
                      let heights = self.segmentHeights[context]
                else { return }
                let layout = TranscriptRenderLengthLayout(
                    context: context,
                    segments: heights.map {
                        TranscriptRenderSegmentLength(segment: $0.key, height: $0.value)
                    }.sorted { $0.segment.lowerBound < $1.segment.lowerBound },
                    updatedAt: self.lengthUpdatedAt[context] ?? Date())
                await TranscriptRenderLengthCache.shared.save(layout, sessionID: sessionID)
            }
        }
    }

    /// True once the agent's first turn has completed. The summary card stays hidden
    /// until then — before the first response there's nothing to summarize (and the
    /// summary itself is only generated on turn completion).
    private var hasResponse: Bool {
        projectionCache.value.metadata.hasResponse
    }

    /// Whether the floating summary card is shown: once there's a turn to recap, or
    /// while the agent is working (the card then shows live "Working…" progress, which
    /// is worth seeing even during the very first turn).
    private var showSummaryCard: Bool { hasResponse || isBusy }

    /// Whether the summary card should rest expanded for the open chat: once a turn has a
    /// recap to show (idle, awaiting input) or while one is running (live progress). Used to
    /// snap the card into its open-time state without the animated expand that slid the
    /// transcript (see the `openSessionID`/`status` handlers in `body`).
    private var summaryShouldRestExpanded: Bool {
        let status = session?.status
        return status == .awaitingInput || status == .running
    }

    /// True once the selected session has displayable canonical history, that exact revision has
    /// completed off-main projection, and its first Markdown batch is warm. The startup value may
    /// be a bounded tail preview; authoritative replacement follows without hiding it again.
    private var transcriptLoaded: Bool {
        guard let sessionID = store.openSessionID else { return false }
        return store.openTranscriptAvailableSessionID == sessionID
            && transcriptProjectionReadyToken?.session == sessionID
            && markdownReadySessionID == sessionID
    }

    private struct MarkdownPrewarmToken: Hashable {
        let session: SessionID?
        let historyReady: SessionID?
        let transcriptVersion: Int
        let projectionReadyVersion: Int?
        let busy: Bool
    }

    private struct MarkdownSnapshotRefreshToken: Hashable {
        let session: SessionID?
        let historyReady: SessionID?
        let projectionReadyVersion: Int?
        let busy: Bool
        let revealed: Bool
        let segmentsHydrated: TranscriptSegmentHydrationToken?
    }

    private struct TranscriptFirstLayoutToken: Hashable {
        let session: SessionID?
        let loaded: Bool
    }

    private struct TranscriptSegmentHydrationToken: Hashable {
        let session: SessionID?
        let revertEpoch: UInt64
        let segmentCount: Int
        let firstEventSeq: UInt64?
    }

    private struct TranscriptInitialBottomPinToken: Hashable {
        let session: SessionID?
        let tailSegmentID: Int?
    }

    private func transcriptSegmentHydrationToken(
        for projection: TranscriptViewProjection
    ) -> TranscriptSegmentHydrationToken {
        TranscriptSegmentHydrationToken(
            session: projection.revision.session,
            revertEpoch: session?.revertEpoch ?? 0,
            segmentCount: projection.segments.count,
            firstEventSeq: projection.revision.firstEventSeq)
    }

    /// Assistant messages can live at the top level or inside nested subagent cards. Walk the
    /// projected tree so expanding a settled subagent never discovers a cold Markdown body.
    nonisolated private static func assistantMarkdown(in items: [TranscriptItem]) -> [String] {
        var bodies: [String] = []
        func append(from items: [TranscriptItem]) {
            for item in items {
                switch item {
                case .assistant(_, let text, _):
                    bodies.append(text)
                case .tool(_, _, _, _, let children):
                    append(from: children)
                case .toolGroup(_, _, let calls):
                    for call in calls { append(from: call.children) }
                case .event, .thinking, .thinkingTokens:
                    break
                }
            }
        }
        append(from: items)
        return bodies
    }

    /// Markdown that is part of the segment being mounted now. Descendants belong to collapsed
    /// cards and are deliberately excluded from the mount's latency budget.
    nonisolated private static func assistantMarkdownForMount(
        in items: [TranscriptItem]
    ) -> [String] {
        items.compactMap { item in
            guard case .assistant(_, let text, _) = item else { return nil }
            return text
        }
    }

    /// Two utility-QoS lanes keep a huge completed transcript from being serialized through one
    /// CPU core, while remaining bounded so background parsing cannot swamp input/layout work.
    /// Each lane preserves chronological input; `MarkdownText.prewarm` reverses it internally, so
    /// both advance from newer prose toward older prose.
    nonisolated private static func prewarmTopLevelMarkdown(
        _ bodies: [String], sessionID: SessionID
    ) async {
        guard bodies.count >= 24 else {
            _ = await MarkdownText.prewarm(
                bodies, sessionID: sessionID, diskAccess: .memoryOnly)
            return
        }
        var lanes = [[String](), [String]()]
        lanes[0].reserveCapacity((bodies.count + 1) / 2)
        lanes[1].reserveCapacity(bodies.count / 2)
        for (index, body) in bodies.enumerated() {
            lanes[index & 1].append(body)
        }
        await withTaskGroup(of: Void.self) { group in
            for lane in lanes where !lane.isEmpty {
                group.addTask(priority: .utility) {
                    _ = await MarkdownText.prewarm(
                        lane, sessionID: sessionID, diskAccess: .memoryOnly)
                }
            }
        }
    }

    private var transcript: some View {
        // Gate the ScrollView's creation on loaded content, keyed by session id.
        //
        // `.defaultScrollAnchor(.bottom)` only takes effect at the scroll view's *first
        // appearance*. The transcript loads asynchronously — it's empty when a session
        // first opens — so a scroll view created up front first appears empty, anchors
        // to nothing, and then content arrives scrolled to the TOP (which the live-follow
        // handler then visibly scrolls down, landing partway as the layout settles).
        //
        // Keying by `openSessionID` and only building the scroll view once the store marks its
        // authoritative initial history ready means it first appears with the initial transcript
        // present, so the bottom anchor initializes at the true end rather than an empty canvas.
        GeometryReader { geo in
            // On a wide window the card docks in the right margin, clear of the prose;
            // when the margin is too narrow it falls back to floating over the column.
            let cardInMargin = showSummaryCard && summaryCardFitsInMargin(width: geo.size.width)
            let progressivelyHydrates = transcriptLoaded
                && TranscriptRenderSegmenter.shouldVirtualize(
                    rowCount: projectionCache.value.items.count)
            let transcriptVisible = transcriptRevealed || progressivelyHydrates
            ZStack {
                Group {
                    if transcriptLoaded {
                        // The hidden twin only needs to reserve space while the card floats
                        // over the column; a margin-docked card overlaps no content.
                        scrollableTranscript(reserveCardSpace: showSummaryCard && !cardInMargin,
                                             containerWidth: geo.size.width)
                            .id(store.openSessionID)
                    } else {
                        Color.clear
                    }
                }
                .overlay(alignment: .top) {
                    if showSummaryCard {
                        if cardInMargin {
                            // Docked just past the column's trailing edge, in the margin: left
                            // edge pinned a gap beyond the column so it sits next to the prose
                            // and grows rightward into the empty margin (the breakpoint
                            // guarantees the margin can hold it).
                            summaryCardView()
                                .frame(maxWidth: .infinity, alignment: .leading)
                                .padding(.leading, (geo.size.width + contentMaxWidth) / 2 + summaryCardColumnGap)
                                .padding(.top, 14)
                        } else {
                            // Floats in the top-right corner over the transcript, aligned to
                            // the column's trailing edge; the hidden twin reserves its space.
                            summaryCardView()
                                .frame(maxWidth: contentMaxWidth, alignment: .trailing)
                                .padding(.horizontal, transcriptInset)
                                .padding(.top, 14)
                                .frame(maxWidth: .infinity)
                        }
                    }
                }
                // Short eager histories still settle their complete, exact-height stack
                // off-screen. Long histories expose their pre-mounted tail immediately.
                .opacity(transcriptVisible ? 1 : 0)
                .transaction { txn in
                    if !transcriptRevealed, !progressivelyHydrates { txn.animation = nil }
                }

                // Keep a constant-cost stand-in at the bottom until the first real text is ready.
                // This is a structural branch—not an opacity crossfade—so placeholder and focused
                // transcript layers can never be composited in the same frame.
                if !transcriptVisible {
                    TranscriptChunkPlaceholder(
                        seed: TranscriptChunkPlaceholder.seed(
                            for: store.openSessionID?.rawValue),
                        rowCount: TranscriptRenderSegmenter.rowsPerSegment,
                        fontSize: transcriptFontSize)
                        .frame(maxWidth: contentMaxWidth, alignment: .leading)
                        .padding(.horizontal, transcriptInset)
                        .padding(.bottom, transcriptBottomPadding)
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
                }
            }
            // End the first-paint interval only after both independent gates have crossed. This
            // records through the first visible, bottom-pinned text rather than merely through the
            // store's history load or the scroll surface's hidden settling passes.
            .onChange(of: transcriptRevealed) { _, revealed in
                if revealed, transcriptLoaded {
                    projectionCache.endFirstPaint(session: store.openSessionID)
                }
            }
            .onChange(of: transcriptLoaded) { _, loaded in
                if loaded, transcriptRevealed {
                    projectionCache.endFirstPaint(session: store.openSessionID)
                }
            }
        }
        // The selection handler above synchronously resets scroll/reveal state before any async
        // history can arrive. Start the first-paint interval and optional geometry-cache read here;
        // neither operation is allowed to withhold the transcript itself.
        .task(id: store.openSessionID) {
            projectionCache.beginFirstPaint(session: store.openSessionID)

            // This file is intentionally tiny and lives on a private serial executor, but it is
            // only a refinement. Never let derived cache I/O withhold the canonical live tail. If
            // the list has already started mounting by the time it arrives, keep the established
            // geometry instead of applying late estimates underneath visible text.
            guard let sessionID = store.openSessionID else { return }
            let layouts = await TranscriptRenderLengthCache.shared.load(sessionID: sessionID)
            let lookup = await Task.detached(priority: .utility) {
                TranscriptRenderLengthLookup(layouts: layouts)
            }.value
            guard !Task.isCancelled, store.openSessionID == sessionID else { return }
            guard !transcriptRevealed else { return }
            projectionCache.installLengthLookup(lookup)
            // One early invalidation installs every cached height at once. Later measurements stay
            // entirely inside ProjectionCache and never redraw the whole transcript.
            transcriptLengthCacheReadySessionID = sessionID
        }
        // Start the settle budget when this session's rows actually exist, not when selection
        // begins. The old selection-keyed timer could expire during slow I/O and reveal the newly
        // created scroll surface before it had performed even one layout pass.
        .task(id: TranscriptFirstLayoutToken(
            session: store.openSessionID, loaded: transcriptLoaded))
        {
            guard transcriptLoaded else { return }
            if TranscriptRenderSegmenter.shouldVirtualize(
                rowCount: projectedTranscript().items.count)
            {
                // The newest chunk was mounted before this list's first appearance. There is no
                // complete-stack settle to wait for, so make the long-history path interactive on
                // its first frame and let older chunks hydrate independently.
                transcriptSettling = false
                transcriptRevealed = true
                return
            }
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled, transcriptLoaded else { return }
            transcriptSettling = false
            // Fallback reveal: a chat whose height never comes to rest (it opened mid-stream)
            // would otherwise stay hidden. The height-stable reveal in `scrollableTranscript`
            // wins first for any chat that settles; this just caps how long the wait can run.
            try? await Task.sleep(for: .milliseconds(150))
            guard !Task.isCancelled, transcriptLoaded else { return }
            transcriptRevealed = true
        }
    }

    /// A real scroll gesture or its momentum. `.tracking` deliberately does not count: merely
    /// touching a trackpad/mouse can stop deceleration and emit one last stale geometry sample,
    /// but every actual displacement passes through `.interacting`.
    private var isUserScrollingTranscript: Bool {
        transcriptScrollPhase == .interacting || transcriptScrollPhase == .decelerating
    }

    private func scrollableTranscript(reserveCardSpace: Bool, containerWidth: CGFloat) -> some View {
        let projection = projectedTranscript()
        let usesVirtualizedLayout = TranscriptRenderSegmenter.shouldVirtualize(
            rowCount: projection.items.count)
        let tailSegmentID = projection.segments.last?.id
        return ScrollViewReader { proxy in
            Group {
                if usesVirtualizedLayout {
                    virtualizedTranscript(
                        projection: projection,
                        reserveCardSpace: reserveCardSpace,
                        containerWidth: containerWidth)
                } else {
                    ScrollView {
                        // The short-history path keeps exact eager geometry. Its hierarchy is
                        // small enough that realizing every row is cheaper than native row reuse.
                        eagerTranscriptContent(
                            projection: projection,
                            reserveCardSpace: reserveCardSpace,
                            containerWidth: containerWidth)
                    }
                }
            }
            // The single-argument `defaultScrollAnchor(.bottom)` governs BOTH the initial
            // offset AND how the scroll view re-pins on content size changes — it sticks
            // the bottom edge in view as the transcript grows. That native stickiness is
            // what we want while the user is parked at the bottom (the chat opens at the
            // end and follows live output), but it's also what yanked them back down
            // whenever anything changed while they'd scrolled up to read history — it
            // overrides any gating we do in `onChange`.
            //
            // So gate the anchor itself: drop it to `nil` the moment the user scrolls
            // away from the bottom, which lets new content land off-screen below instead
            // of dragging the viewport. It returns to `.bottom` (following resumes) once
            // they're back at the end. `isScrolledToBottom` defaults true, so an opened
            // chat still lands on the true bottom in one pass.
            .defaultScrollAnchor(isScrolledToBottom ? .bottom : nil)
            .scrollContentBackground(.hidden)
            .onScrollPhaseChange { _, newPhase in
                if transcriptScrollPhase != newPhase { transcriptScrollPhase = newPhase }
                // A genuine new drag takes control from an in-flight programmatic jump.
                if newPhase == .interacting, jumpingToTranscriptBottom {
                    jumpingToTranscriptBottom = false
                }
                // Geometry can enter `.away` because content grew while no gesture was active;
                // its Equatable region then stays unchanged as a later drag begins. Apply the
                // held region on the phase edge too, so that drag can still disengage following.
                let phaseIsUserScrolling = newPhase == .interacting || newPhase == .decelerating
                let next = TranscriptScrollFollowPolicy.following(
                    current: isScrolledToBottom,
                    region: transcriptBottomRegion,
                    isSettling: transcriptSettling,
                    isUserScrolling: phaseIsUserScrolling,
                    isJumpingToBottom: jumpingToTranscriptBottom)
                if next != isScrolledToBottom { isScrolledToBottom = next }
            }
            // Track the live scroll position straight from the scroll view's geometry:
            // how much content still sits below the viewport bottom. Re-engagement uses the
            // tight threshold; disengagement requires BOTH the wider hysteresis threshold and
            // a user-driven phase. A programmatic change (streamed text growing the last row,
            // anchor correction, approval-bar resize) may therefore re-engage following but can
            // never disengage it, structurally breaking the geometry→state→layout cycle that
            // caused scroll lag and jitter during rapid Codex deltas. Reduce the continuously
            // changing distance to the three policy regions in the transform: SwiftUI therefore
            // invokes the main-actor action only at a threshold crossing, not once per pixel.
            .onScrollGeometryChange(for: TranscriptScrollFollowPolicy.Region.self) { geo in
                TranscriptScrollFollowPolicy.region(distanceToBottom:
                    geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y)
            } action: { _, region in
                if transcriptBottomRegion != region { transcriptBottomRegion = region }
                let next = TranscriptScrollFollowPolicy.following(
                    current: isScrolledToBottom,
                    region: region,
                    isSettling: transcriptSettling,
                    isUserScrolling: isUserScrollingTranscript,
                    isJumpingToBottom: jumpingToTranscriptBottom)
                // A same-value @State write still dirties the surrounding view graph on some
                // SwiftUI releases. Only meaningful threshold crossings may invalidate it.
                if next != isScrolledToBottom { isScrolledToBottom = next }
                if jumpingToTranscriptBottom, region == .atBottom {
                    jumpingToTranscriptBottom = false
                }
            }
            // Reveal the freshly opened transcript only once its content height has come to
            // rest. On open the initial layout — and the async tool-group summary lines seeded
            // for history — grow the content for a beat, and `.defaultScrollAnchor(.bottom)`
            // re-pins on each growth: that re-pin is the visible on-open scroll/jitter. Track
            // the height, and let `.task(id:)` debounce it — the task restarts on every change
            // and only reaches its body once the height has held steady. Then pin to the true
            // bottom explicitly (un-animated, the way sending a message does — the passive
            // anchor alone lands a hair off on partial layout) and flip the view visible, so a
            // freshly opened chat appears already parked at the tail with no motion at all.
            .onScrollGeometryChange(for: CGFloat.self) { $0.contentSize.height.rounded() } action: { _, height in
                // Only matters until the reveal; freezing it afterward keeps live streaming
                // (which changes the height every frame) from re-arming the debounce task.
                // Rounded so sub-pixel layout noise can't keep it from ever settling.
                if !transcriptRevealed, transcriptContentHeight != height {
                    transcriptContentHeight = height
                }
            }
            .task(id: transcriptContentHeight) {
                guard !transcriptRevealed, transcriptContentHeight > 0 else { return }
                try? await Task.sleep(for: .milliseconds(80))
                // A long virtualized transcript reveals immediately. Its first geometry sample
                // can race this debounce onto the task queue; do not perform a stale delayed
                // scroll after the tail is already visible.
                guard !Task.isCancelled, !transcriptRevealed else { return }
                proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
                // The user can take control as soon as the text becomes visible. Leaving the
                // settling guard raised for the rest of its fallback timer made an immediate
                // trackpad gesture look ignored even though the document had already landed.
                transcriptSettling = false
                transcriptRevealed = true
            }
            // Follow the revision of the *rendered tail*, after SwiftUI has installed that
            // revision's rows. This is intentionally not keyed to a message count: autoship
            // notes and every other standalone transcript/log entry advance `tailItemID` too.
            // The fixed anchor sits after trailing status rows and padding, so "end" always
            // means the bottom of the final entry of any kind. Scrolling up fails only the follow
            // gate, so history reading is never yanked downward while projection and hydration
            // continue independently.
            // Passive follow is deliberately unanimated. The native bottom anchor supplies
            // visual continuity as the last row grows; this call only closes any rounding gap.
            // Animating every ~90 ms display batch started overlapping scroll animations faster
            // than they could finish, competing with trackpad input and keeping text composited.
            .task(id: projection.revision) {
                // Yield once so the anchor has the exact position produced by this revision,
                // rather than resolving against the previous layout in the update transaction.
                await Task.yield()
                guard !Task.isCancelled, isScrolledToBottom, !isUserScrollingTranscript else {
                    return
                }
                scrollToEnd(proxy, animated: false)
            }
            // The tail Markdown is warm before the List exists, so its local row state begins with
            // real content. Yield one native layout turn, then pin to the sentinel below every
            // trailing row and the bottom padding.
            .task(id: TranscriptInitialBottomPinToken(
                session: store.openSessionID,
                tailSegmentID: tailSegmentID))
            {
                guard usesVirtualizedLayout,
                      let sessionID = store.openSessionID,
                      transcriptInitialBottomPinnedSessionID != sessionID,
                      tailSegmentID != nil
                else { return }
                await Task.yield()
                guard !Task.isCancelled, store.openSessionID == sessionID else { return }
                isScrolledToBottom = true
                jumpingToTranscriptBottom = true
                scrollToEnd(proxy, animated: false)
                transcriptInitialBottomPinnedSessionID = sessionID
            }
            .onChange(of: isBusy) { _, busy in
                if busy, isScrolledToBottom, !isUserScrollingTranscript {
                    scrollToEnd(proxy, animated: false)
                }
            }
            // An approval / question pane appearing (or swapping) shrinks the transcript
            // viewport from the bottom; without this the last messages stay scrolled up
            // behind the pane. Re-follow the bottom so the content that prompted the gate
            // is visible above it — but only when the user is already parked there, so
            // scrolling up to read history isn't yanked back down.
            .onChange(of: store.openApprovals.first?.id) { _, id in
                if id != nil, isScrolledToBottom, !isUserScrollingTranscript {
                    scrollToEnd(proxy, animated: false)
                }
            }
            // An explicit jump — sending a message — always wins and immediately hands follow
            // authority back to the bottom anchor. The latch prevents a stale deceleration
            // callback from undoing that decision before the animated jump arrives.
            .onChange(of: scrollToBottomRequest) { _, _ in
                isScrolledToBottom = true
                jumpingToTranscriptBottom = true
                scrollToEnd(proxy)
            }
            // Floating chevron above the chat bar, shown only while scrolled up; tap
            // to jump back to the latest output. The fade in/out is animated on
            // `isScrolledToBottom`, but the animation is scoped to *this overlay* only.
            //
            // A blanket `.animation(value: isScrolledToBottom)` on the whole ScrollView (as
            // this once was) also animates the `.defaultScrollAnchor` re-pin and any layout
            // shift that lands in the same transaction. While a freshly opened chat settles,
            // its first layout passes can flip `isScrolledToBottom` a couple of times before
            // it rests true; the blanket animation smoothed each of those flips into a 150ms
            // eased scroll nudge — the tiny up-and-down jitter before the chat settled at the
            // bottom. Scoping the animation to the button keeps its transition while letting
            // anchor corrections snap instantly, so the chat lands at the bottom in one motion.
            .overlay(alignment: .bottom) {
                ZStack {
                    if !isScrolledToBottom {
                        JumpToBottomButton {
                            isScrolledToBottom = true
                            jumpingToTranscriptBottom = true
                            scrollToEnd(proxy)
                        }
                            .padding(.bottom, 12)
                            .transition(.move(edge: .bottom).combined(with: .opacity))
                    }
                }
                .animation(.easeInOut(duration: 0.15), value: isScrolledToBottom)
            }
        }
    }

    /// Exact eager content for ordinary-sized chats. Keeping this path preserves the zero-estimate
    /// bottom anchor and the already-smooth scrolling behavior of short transcripts.
    private func eagerTranscriptContent(
        projection: TranscriptViewProjection,
        reserveCardSpace: Bool,
        containerWidth: CGFloat
    ) -> some View {
        VStack(alignment: .leading, spacing: 6) {
            transcriptPreamble(reserveCardSpace: reserveCardSpace)
            ForEach(projection.segments) { segment in
                transcriptRows(projection.items[segment.range])
            }
            transcriptTrailingRows
            transcriptBottomAnchor
        }
        .font(.system(size: transcriptFontSize))
        // Hand each tool card the lock lines folded onto it (keyed by tool-call id), so the lock
        // renders as part of the edit rather than as a row that sorts after the call.
        .environment(\.lockLinesByToolCall, projection.lockLines)
        .frame(maxWidth: contentMaxWidth, alignment: .leading)
        .padding(.horizontal, transcriptInset)
        .padding(.top, 14)
        .frame(maxWidth: .infinity)
        // Width-only animation: live transcript growth remains immediate. Suppressed during the
        // initial settle so opening a chat never eases through intermediate wrapping widths.
        .animation(transcriptSettling ? nil : .easeOut(duration: 0.2), value: containerWidth)
    }

    /// AppKit-backed row virtualization for long histories. `List` recycles off-screen batches
    /// through its native table implementation and retains resolved variable row heights; unlike
    /// `LazyVStack`, it does not keep revising one coarse SwiftUI height estimate while momentum is
    /// in flight. Each list row is a bounded segment, limiting mount/diff work without
    /// turning every tiny note into a separate native row.
    private func virtualizedTranscript(
        projection: TranscriptViewProjection,
        reserveCardSpace: Bool,
        containerWidth: CGFloat
    ) -> some View {
        let lengthContext = transcriptLengthContext(containerWidth: containerWidth)
        let renderedSessionID = projection.revision.session
        let hydrationToken = transcriptSegmentHydrationToken(for: projection)
        let tailSegmentID = projection.segments.last?.id
        return List {
            if reserveCardSpace {
                virtualizedTranscriptRow(top: 14) {
                    transcriptPreamble(reserveCardSpace: true)
                }
            }

            ForEach(projection.segments) { segment in
                let isFirst = segment.id == projection.segments.first?.id
                let identity = transcriptSegmentIdentity(segment, in: projection.items)
                let reservedHeight = transcriptSegmentReservedHeight(
                    segment, identity: identity,
                    estimate: projection.segmentEstimates[segment.id],
                    context: lengthContext)
                virtualizedTranscriptRow(
                    top: isFirst && !reserveCardSpace ? 14 : 3
                ) {
                    TranscriptRenderSegmentSlot(
                        seed: segment.id,
                        identity: identity,
                        length: segment.length,
                        fontSize: transcriptFontSize,
                        reservedHeight: reservedHeight,
                        initiallyReady: segment.id == tailSegmentID,
                        onMeasured: { height in
                            recordTranscriptSegmentHeight(
                                height, identity: identity, context: lengthContext,
                                sessionID: renderedSessionID)
                        },
                        onPriorityMount: {
                            await prepareVisibleTranscriptSegment(
                                segment, items: projection.items)
                        }) {
                            transcriptRows(projection.items[segment.range])
                        }
                }
            }

            if hasTranscriptTrailingRows {
                virtualizedTranscriptRow(
                    top: projection.segments.isEmpty && !reserveCardSpace ? 14 : 3
                ) {
                    VStack(alignment: .leading, spacing: 6) {
                        transcriptTrailingRows
                    }
                }
            }

            let anchorNeedsTopInset = !reserveCardSpace
                && projection.segments.isEmpty
                && !hasTranscriptTrailingRows
            virtualizedTranscriptRow(top: anchorNeedsTopInset ? 14 : 3, bottom: 0) {
                Color.clear.frame(height: transcriptBottomPadding)
            }
            .id(Self.bottomAnchorID)
        }
        .listStyle(.plain)
        .contentMargins(.all, 0, for: .scrollContent)
        .font(.system(size: transcriptFontSize))
        .environment(\.lockLinesByToolCall, projection.lockLines)
        .animation(transcriptSettling ? nil : .easeOut(duration: 0.2), value: containerWidth)
        .task(id: hydrationToken, priority: .utility)
        {
            await hydrateTranscriptSegments(
                projection.segments, items: projection.items, token: hydrationToken)
        }
    }

    /// Continuously warm all top-level Markdown newest-to-oldest on two bounded utility lanes. The
    /// shared pass avoids thousands of detached tasks, actor hops, signposts, and main-actor sets.
    /// A visible row may warm the same tiny cache entry concurrently at user-initiated priority;
    /// the background pass is never canceled or restarted merely because the user scrolls.
    private func hydrateTranscriptSegments(
        _ segments: [TranscriptRenderSegment],
        items: [TranscriptItem],
        token: TranscriptSegmentHydrationToken
    ) async {
        guard let sessionID = store.openSessionID, !segments.isEmpty else { return }
        if transcriptMarkdownReadyHydrationToken != token {
            transcriptMarkdownReadyHydrationToken = nil
        }
        let collection = Task.detached(priority: .utility) {
            Self.assistantMarkdownForMount(in: items)
        }
        let bodies = await withTaskCancellationHandler {
            await collection.value
        } onCancel: {
            collection.cancel()
        }
        guard !Task.isCancelled, store.openSessionID == sessionID else { return }
        await Self.prewarmTopLevelMarkdown(bodies, sessionID: sessionID)
        guard !Task.isCancelled,
              store.openSessionID == sessionID,
              transcriptSegmentHydrationToken(for: projectedTranscript()) == token
        else { return }
        transcriptMarkdownReadyHydrationToken = token
    }

    /// Foreground cache fill for one native row. Readiness is returned to that row, which stores it
    /// locally; completing this must never dirty the root transcript view.
    private func prepareVisibleTranscriptSegment(
        _ segment: TranscriptRenderSegment,
        items: [TranscriptItem]
    ) async -> Bool {
        guard let sessionID = store.openSessionID else { return false }
        let segmentItems = Array(items[segment.range])
        let bodies = Self.assistantMarkdownForMount(in: segmentItems)
        guard !bodies.isEmpty else { return true }
        let preparation = Task.detached(priority: .userInitiated) {
            await MarkdownText.prewarm(
                bodies, sessionID: sessionID, diskAccess: .memoryOnly)
        }
        _ = await withTaskCancellationHandler {
            await preparation.value
        } onCancel: {
            preparation.cancel()
        }
        return !Task.isCancelled
            && !preparation.isCancelled
            && store.openSessionID == sessionID
    }

    private func transcriptLengthContext(containerWidth: CGFloat) -> TranscriptRenderLengthContext {
        let available = max(1, containerWidth - transcriptInset * 2)
        return TranscriptRenderLengthContext(
            contentWidth: Int(min(contentMaxWidth, available).rounded()),
            rowsPerSegment: TranscriptRenderSegmenter.rowsPerSegment,
            showDebugLines: showDebugLines,
            showLockEvents: showLockEvents,
            revertEpoch: session?.revertEpoch ?? 0)
    }

    private func transcriptSegmentIdentity(
        _ segment: TranscriptRenderSegment, in items: [TranscriptItem]
    ) -> TranscriptRenderSegmentIdentity {
        let slice = items[segment.range]
        return TranscriptRenderSegmentIdentity(
            lowerBound: segment.range.lowerBound,
            rowCount: segment.length,
            firstItemID: slice.first?.id ?? "",
            lastItemID: slice.last?.id ?? "")
    }

    /// Every segment gets a vertical reservation before its row body mounts. A same-width stored
    /// measurement is exact for settled history; a nearby-width one is the next-best estimate.
    /// The cold-cache fallback is deliberately based only on the segment's `length` metadata, so
    /// constructing the complete canvas never has to visit a transcript row or parse its text.
    private func transcriptSegmentReservedHeight(
        _ segment: TranscriptRenderSegment,
        identity: TranscriptRenderSegmentIdentity,
        estimate: TranscriptRenderSegmentEstimate?,
        context: TranscriptRenderLengthContext
    ) -> CGFloat {
        // Establish the one observable dependency for an asynchronously installed sidecar. The
        // heights themselves stay in ProjectionCache, where both lookup and recording are O(1).
        _ = transcriptLengthCacheReadySessionID
        if let height = projectionCache.segmentHeight(for: identity, context: context),
           height.isFinite, height > 0 {
            return CGFloat(height)
        }
        let internalSpacing = CGFloat(max(0, segment.length - 1)) * 6
        let coldEstimate = estimate?.height(contentWidth: context.contentWidth)
            ?? Double(segment.length * 48)
        return CGFloat(coldEstimate) + internalSpacing
    }

    private func recordTranscriptSegmentHeight(
        _ measuredHeight: CGFloat,
        identity: TranscriptRenderSegmentIdentity,
        context: TranscriptRenderLengthContext,
        sessionID: SessionID?
    ) {
        guard let sessionID,
              store.openSessionID == sessionID,
              measuredHeight.isFinite, measuredHeight > 0
        else { return }
        // Half-point precision ignores harmless subpixel churn while retaining exact-enough native
        // row geometry across reopens.
        let height = Double((measuredHeight * 2).rounded() / 2)
        projectionCache.recordSegmentHeight(
            height, for: identity, context: context, sessionID: sessionID)
    }

    /// Removes every default table inset/background so a virtualized row occupies the exact same
    /// centered reading column as the eager stack. Half the 6pt transcript spacing lives on each
    /// adjoining native row; internal rows retain their normal 6pt `VStack` spacing.
    private func virtualizedTranscriptRow<Content: View>(
        top: CGFloat = 3,
        bottom: CGFloat = 3,
        @ViewBuilder content: () -> Content
    ) -> some View {
        content()
            .frame(maxWidth: contentMaxWidth, alignment: .leading)
            .padding(.horizontal, transcriptInset)
            .frame(maxWidth: .infinity, alignment: .center)
            .listRowInsets(EdgeInsets(top: top, leading: 0, bottom: bottom, trailing: 0))
            .listRowBackground(Color.clear)
            .listRowSeparator(.hidden)
    }

    private var hasTranscriptTrailingRows: Bool {
        if isBusy { return true }
        guard let id = session?.id, store.openHostID == nil else { return false }
        return store.lockWaits[id] != nil
    }

    @ViewBuilder
    private func transcriptPreamble(reserveCardSpace: Bool) -> some View {
        // An invisible twin of the floating summary card: it occupies the card's exact height
        // at the top so the first message is pushed below the visible card rather than covered.
        // Skipped when the card is docked in the margin, where it overlaps no content.
        if reserveCardSpace {
            summaryCardView()
                .hidden()
                .frame(maxWidth: .infinity, alignment: .trailing)
        }
    }

    private func transcriptRows(_ items: ArraySlice<TranscriptItem>) -> some View {
        TranscriptRenderSegmentView(
            items: items,
            // Revert is a transcript rewrite, so it's only offered while idle — never mid-turn
            // (the controller also refuses then) and never in an archived chat.
            onRevert: (isBusy || isArchived) ? nil : { revertSeq = $0 },
            onLogin: startClaudeLogin,
            onOpenVMMonitor: { panels.revealVMMonitor() },
            // Peer-owned execs run on the owner, where there is no remote Skip command yet.
            onSkip: (isArchived || store.openHostID != nil)
                ? nil
                : { call in Task { await store.skipOpenSessionToolCall(call) } },
            // Stall resolution likewise acts only on a local session's in-flight command.
            onResolveStall: (isArchived || store.openHostID != nil)
                ? nil
                : { stallID, kill in
                    Task { await store.resolveOpenSessionStall(stallID, kill: kill) }
                },
            // A plan can dispatch more workers than its card lists inline.
            onOpenSubagents: { panels.revealSubagents() })
            .equatable()
            .frame(maxWidth: .infinity, alignment: .leading)
    }

    @ViewBuilder
    private var transcriptTrailingRows: some View {
        if isBusy {
            WorkingIndicator(text: progressText).id(Self.workingID)
        }
        // A chat parked in the file-lock queue (or caught in a lock deadlock) must explain itself
        // here, where the user is watching it "hang". Local only: peer lock state is not mirrored.
        if let id = session?.id, store.openHostID == nil, let wait = store.lockWaits[id] {
            LockContentionBanner(
                wait: wait,
                deadlocked: store.deadlockedSessions.contains(id),
                onReleaseHolder: { holder in
                    Task { await store.forceReleaseLocks(holder.sessionID) }
                })
        }
    }

    /// A real trailing element and fixed scroll target. Kept as its own final stack child so the
    /// target is unambiguously below messages, notes/log lines, status rows, and bottom padding.
    private var transcriptBottomAnchor: some View {
        Color.clear
            .frame(height: transcriptBottomPadding)
            .id(Self.bottomAnchorID)
    }

    private static let workingID = "nucleic.working-indicator"
    private static let bottomAnchorID = "nucleic.transcript-bottom"

    private func scrollToEnd(_ proxy: ScrollViewProxy, animated: Bool = true) {
        // Always scroll to the trailing spacer, so the resting position shows the
        // final transcript entry of any kind with the full bottom padding beneath it.
        if animated {
            withAnimation {
                proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
            }
        } else {
            proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
        }
    }

    /// A live, human label for what the agent is doing right now, derived from the
    /// most recent transcript event.
    private var progressText: String {
        // Blocked on a human, not the agent: the spinner is a "waiting on you" cue.
        // AskUserQuestion collects answers; ExitPlanMode is a three-way plan review.
        if let approval = store.openApprovals.first {
            switch approval.toolName {
            case AskUserQuestion.toolName: return "Waiting for answers…"
            case ExitPlanMode.toolName: return "Waiting for plan review…"
            default: return "Waiting for approval…"
            }
        }
        for event in store.openTranscript.reversed() {
            // Extended-thinking progress pings (`system/thinking_tokens`) arrive as raw events
            // during the model's reasoning pause — often the long gap right after an
            // AskUserQuestion answer or a tool result, before any visible output. They mean the
            // model is actively thinking, so surface that. Without this the walk skips every raw
            // ping (and the tool result) and lands back on the last tool call, freezing the
            // indicator on e.g. "Running AskUserQuestion…" for the whole (often many-second)
            // thinking pause — which reads as a stall even though the model is working.
            if TranscriptProjection.parseThinkingTokens(event) != nil { return "Thinking…" }
            switch event.kind {
            case .toolCallStarted(let call), .toolCallCompleted(let call):
                return Self.gerund(for: call.name)
            case .thinking:
                return "Thinking…"
            case .assistantText(let chunk) where chunk.isPartial:
                return "Writing…"
            case .toolResult:
                // The tool has finished (its result landed); the model is now working on what
                // comes next. Stop here rather than walking back to the call behind it and
                // reporting a completed tool as still "Running <tool>…".
                return "Working…"
            case .userText:
                // The agent hasn't produced anything since the user's message. If the shared sandbox
                // container is still downloading, that — not a stuck agent — is the delay; name it.
                if let download = store.containerDownload { return Self.downloadText(download) }
                return "Working…"
            default:
                continue
            }
        }
        if let download = store.containerDownload { return Self.downloadText(download) }
        return "Working…"
    }

    /// The working-row label for an in-flight container download (e.g. "Downloading sandbox image
    /// 42%…"), so a first turn waiting on the sandbox reads as "downloading", not a hung agent.
    private static func downloadText(_ download: ContainerDownloadProgress) -> String {
        if let fraction = download.fraction {
            return "\(download.label) \(Int((fraction * 100).rounded()))%…"
        }
        return "\(download.label)…"
    }

    /// Turns a tool name into a present-progressive status line — "Read" → "Reading…",
    /// mirroring the past-tense families in `ConversationIntelligence`. Unknown tools
    /// fall back to "Running <name>…" so we never guess a malformed gerund.
    private static func gerund(for name: String) -> String {
        switch name {
        case "Read":                                 return "Reading…"
        case "Write":                                return "Writing…"
        case "Edit", "MultiEdit", "NotebookEdit":    return "Editing…"
        case "Bash", "BashOutput":                   return "Running…"
        case "Grep", "Glob":                         return "Searching…"
        case "WebFetch":                             return "Fetching…"
        case "WebSearch":                            return "Searching the web…"
        case "Task":                                 return "Delegating…"
        case MCPApprovalServer.qualifiedHostExecToolName:
                                                     return "Running a command on host…"
        // The VM/container tools name where the work is happening ("Running on a macOS VM…")
        // rather than echoing their `mcp__nucleic__…` wire name.
        default:                                     return SandboxToolDisplay.gerund(for: name)
                                                         ?? "Running \(name)…"
        }
    }

    private var isBusy: Bool {
        guard let status = session?.status else { return false }
        return status == .running || status == .awaitingApproval || status == .provisioning
    }

    /// Read-only branch/worktree label, pinned to the upper-right of the composer —
    /// mirroring where the project/git controls sit on the home chat bar.
    private var branchLabel: some View {
        HStack(spacing: 4) {
            Image(systemName: "arrow.triangle.branch")
            Text(session?.branch ?? "—")
        }
        .font(.callout)
        .foregroundStyle(.secondary)
        .help(session?.worktreePath.map { "Worktree: \($0)" } ?? "Branch for this chat")
    }

    /// Replaces `branchLabel` for nvrsion sessions: a small "nvrsion" badge in the same spot,
    /// in the project accent so it reads as an active mode — without surfacing the underlying
    /// shared-workspace branch the way a raw branch name would.
    private var nvrsionLabel: some View {
        HStack(spacing: 4) {
            Image(systemName: "infinity").font(.caption)
            Text("nvrsion")
        }
        .font(.callout)
        .foregroundStyle(composerAccent)
        .help("nvrsion is on for this project — edits version continuously in a shared workspace.")
    }

    /// The branch an nvrsion promote ships into — the project's real default branch (the
    /// promote target), distinct from `targetBranch`, which is the per-session autoship
    /// destination and meaningless under nvrsion's shared trunk.
    private var nvrsionPromoteTarget: String {
        session.flatMap { store.project($0.projectID) }?.defaultBranch.value ?? "main"
    }

    /// Whether the service-status pill shows beside this chat's composer. Always when the
    /// visibility setting is "Everywhere"; under "Only on home view" it still surfaces here when
    /// the chat-incident override is on and this chat's model's provider has an active incident —
    /// so an outage on the model you're actually using never stays hidden in chat.
    private var showsStatusIndicator: Bool {
        if StatusIndicatorVisibility(rawValue: statusVisibilityRaw) == .everywhere { return true }
        guard showStatusInChatOnIncident,
              let provider = StatusProvider.forModel(effectiveModel) else { return false }
        return store.statusFeed(for: provider)?.hasActiveIncident == true
    }

    private var hasSendableContent: Bool {
        !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty
    }

    /// Hold a routed send when its current lane is quota-blocked. Orchestra owns its model
    /// choice and remains sendable, matching the new-chat composer.
    private var routedRouteAvailable: Bool {
        !intelligenceRoutingActive || isOrchestra || !sessionQuotaBlocked
    }

    /// The send button's tooltip while quota-held (mirrors the send guard's message).
    private var quotaHoldNote: String {
        "\(ModelCatalog.displayName(effectiveModel))'s provider has reached its usage "
            + "limit — send again after the window resets, or move the Intelligence bar "
            + "to another level."
    }

    /// Whether there is content and a usable routed destination.
    private var canSend: Bool { hasSendableContent && routedRouteAvailable }

    private var composer: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack(spacing: 8) {
                // A per-session branch name only makes sense on the classic worktree path. nvrsion
                // sessions share one workspace, so instead of leaking that internal name we show a
                // simple "nvrsion is on" badge in the same spot.
                if nvrsionActive {
                    nvrsionLabel
                } else {
                    branchLabel
                }
                Spacer()
                // Provider status + context window + subscription quota, sitting just above
                // the chat box's upper-right corner. Trailing-inset to line the text up with
                // the text field's trailing edge (matching the controls row below the field).
                if showsStatusIndicator {
                    StatusFeedIndicator(palette: palette, monochrome: true)
                }
                QuotaIndicator(
                    palette: palette, contextUsage: contextUsage, backend: quotaBackend,
                    monochrome: true)
            }
            .padding(.trailing, sendButtonWidth + 8)
            // Follow-ups the user submitted while the agent is still working, held to send the
            // instant the current turn finishes. Each is shown as its own pill (with its
            // attachments) and can be cancelled individually; they still send as one combined turn.
            if let queued = session?.queuedMessages, !queued.isEmpty {
                VStack(alignment: .leading, spacing: 6) {
                    ForEach(queued) { message in
                        queuedMessageBar(message)
                    }
                }
                .padding(.trailing, sendButtonWidth + 8)
            }
            // Staged attachments (attach button / paste / drag), shown above the field. Hidden in
            // an archived chat, where the composer is inert.
            if !attachments.isEmpty && !isArchived {
                ComposerAttachmentBar(attachments: $attachments)
                    .padding(.trailing, sendButtonWidth + 8)
            }
            // Someone is typing in this chat on another device: their draft streams in live
            // here while the field below is locked (one typer per session at a time).
            if let typing = remoteTyping {
                remoteTypingBar(typing)
                    .padding(.trailing, sendButtonWidth + 8)
            }
            HStack(alignment: .bottom, spacing: 8) {
                // Conversational sessions can always be resumed by sending — even
                // after an interrupt or an errored turn — so the composer stays
                // usable whenever a session is open. While a turn runs, sending
                // *queues* the message (see `send()`); the trailing button shows Stop
                // when the box is empty and a send/queue arrow once the user types.
                ChatInputField(
                    text: $draft,
                    placeholder: isArchived ? "Unarchive to send messages…"
                        : remoteTyping.map { "\(typerName($0)) is typing…" } ?? "Message the agent…",
                    submitMode: submitMode,
                    isEnabled: session != nil && !isArchived && remoteTyping == nil,
                    height: $composerHeight, onSend: { _ in send() },
                    onAttach: { attachments.append(contentsOf: $0) },
                    onDropTargetedChanged: { composerDropTargeted = $0 })
                    .frame(height: composerHeight)
                    .padding(8)
                    .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 8))
                    // Light up the field's border while a file/image drag hovers it.
                    .overlay {
                        if composerDropTargeted {
                            RoundedRectangle(cornerRadius: 8)
                                .strokeBorder(composerAccent, lineWidth: 2)
                                .allowsHitTesting(false)
                        }
                    }
                    // An archived chat is inert, so don't glow even if its effort was orchestra; the
                    // glow also retires once orchestra has run for a full turn (see
                    // `showsOrchestraGlow`). Cap the pulse below the home screen's full swell so the
                    // glow stays a calm presence while you're reading a conversation.
                    .orchestraGlow(active: showsOrchestraGlow, peakBrightness: 0.6)
                if isArchived {
                    // Read-only chat: the send/cmd-return control is replaced by Unarchive,
                    // the only way to make the composer and messages interactive again. No
                    // stash button either — there's nothing to type, so nothing to set aside.
                    Button(action: unarchive) {
                        Text("Unarchive")
                            .font(.callout.weight(.medium))
                            .fixedSize()
                    }
                    .buttonStyle(.borderedProminent)
                    .help("Unarchive this chat to send messages and interact with it")
                } else {
                    // Whichever trailing control is showing (send, or Stop while a turn runs
                    // with an empty box), then the ⬇︎ that drops the prompt into the stash box
                    // a row below (on the model/effort line, same column).
                    VStack(spacing: 6) {
                        if isBusy && !canSend {
                            Button { Task { await store.interruptOpenSession() } } label: {
                                Image(systemName: "stop.circle.fill").font(.title2)
                            }
                            .buttonStyle(.plain)
                            .help("Interrupt the running turn")
                        } else {
                            Button(action: send) {
                                SubmitKeyIcon(mode: submitMode)
                            }
                            .buttonStyle(.plain)
                            .help(routedRouteAvailable
                                ? (isBusy
                                    ? "Queue this message — it sends when the agent finishes its turn"
                                    : submitMode.detail)
                                : quotaHoldNote)
                            // Locked while another device is typing here — the mesh-wide "one typer
                            // per session" contract, matching the disabled field beside it.
                            .disabled(!canSend || remoteTyping != nil)
                        }
                        PromptStashDropButton(canStash: hasStashableDraft, onStash: stashDraft)
                    }
                    .frame(width: sendButtonWidth)
                }
            }
            HStack(spacing: 8) {
                autoToggle
                // Shipping implies Auto, so the Merge control is only meaningful when Auto
                // is on. It slides out from behind the Auto button when Auto is enabled
                // and tucks back under it when Auto is turned off. Under nvrsion the trunk is
                // promoted at the project level, so a per-chat Merge toggle is meaningless and
                // hidden entirely.
                if autoOn && !nvrsionActive {
                    shipControl
                        .transition(.move(edge: .leading).combined(with: .opacity))
                }
                // Attach files/images. Sits with the left-hand controls (not beside the field) so
                // it never indents the composer. The row's `.disabled(isArchived)` keeps it inert
                // in an archived chat.
                ComposerAttachButton(isEnabled: session != nil) { picked in
                    attachments.append(contentsOf: picked)
                }
                Spacer()
                if intelligenceRoutingActive {
                    IntelligenceSlider(
                        level: sessionIntelligenceLevelBinding,
                        orchestraTier: sessionOrchestraTierBinding,
                        enabled: !isArchived,
                        orchestraAvailable: isControlProject,
                        animationsSettled: !transcriptSettling,
                        stopsShimmerAfterRelease: true,
                        onOrchestraPreviewChanged: { sliderOrchestraPreview = $0 })
                    routedChip
                } else {
                    modelMenu
                    effortMenu
                }
                // The stash box, in the send button's column so the ⬇︎ above drops straight
                // into it. It takes the place of this row's trailing inset rather than adding
                // height beside the text field. Archived chats disable the whole row, the box
                // with it — there's no live composer to pull a prompt into.
                PromptStashBox(accent: composerAccent, onPull: pull)
                    .frame(width: sendButtonWidth)
            }
            .animation(.easeInOut(duration: 0.25), value: autoOn)
            .animation(.easeInOut(duration: 0.25), value: session?.autoShip)
            .task(id: session?.projectID) { await loadShipBranches() }
            // Auto is locked on under Nucleic Control; reconcile any legacy chat that predates
            // that rule so the agent actually runs autonomously, matching the locked-on toggle.
            // (New chats are already created auto-on.) Owner-only: a chat mirrored from a peer
            // Mac is reconciled where it lives — firing setSessionAuto at the owner on every
            // open would bump its updatedAt and reorder Recents mesh-wide just for looking.
            .task(id: session?.id) {
                if store.openHostID == nil, isControlProject, session?.auto == false {
                    await store.setOpenSessionAuto(true)
                }
            }
            .font(.callout)
            // The mode/model controls go inert (and dim) alongside the field in an
            // archived chat — only Unarchive stays live.
            .disabled(isArchived)
        }
        .padding(.top, 22)
        .padding(.bottom, 12)
        // Match the transcript's centered column so the input area and its
        // accessories never extend wider than the chat text above them.
        .chatColumn(maxWidth: contentMaxWidth, inset: transcriptInset, resizeWidth: columnWidth, settling: transcriptSettling)
        // Stream this composer's draft to the mesh (throttled in the store) so every other
        // device viewing this chat sees it live and locks its own composer; an emptied field —
        // including the clear in `send()` — ends the typing and unlocks them.
        .onChange(of: draft) { _, text in
            guard let id = session?.id, !isArchived else { return }
            store.composerDraftChanged(id, text: text)
        }
        // Switching chats (or closing to none) ends any streaming typing in the one we left.
        .onChange(of: session?.id) { old, _ in
            if let old { store.composerDraftEnded(old) }
        }
        .onDisappear {
            if let id = session?.id { store.composerDraftEnded(id) }
        }
    }

    /// The friendly label for a remote typer, never blank.
    private func typerName(_ typing: ComposerTypingState) -> String {
        typing.deviceName.isEmpty ? "Another device" : typing.deviceName
    }

    /// The live view of another device's in-progress draft for this chat, shown where the
    /// queued pills sit while the field below is locked. Head-truncated: the tail is where the
    /// typing is happening, so it's the part that must stay visible.
    private func remoteTypingBar(_ typing: ComposerTypingState) -> some View {
        VStack(alignment: .leading, spacing: 4) {
            HStack(spacing: 6) {
                Image(systemName: "ellipsis.bubble")
                Text("\(typerName(typing)) is typing…")
                Spacer(minLength: 0)
            }
            .font(.callout)
            .foregroundStyle(composerAccent)
            if !typing.text.isEmpty {
                Text(typing.text)
                    .font(.callout)
                    .foregroundStyle(.secondary)
                    .lineLimit(4)
                    .truncationMode(.head)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }
        }
        .padding(.horizontal, 10)
        .padding(.vertical, 6)
        .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 12))
        .help("Someone is composing a message for this chat on another device. The composer unlocks when they send or stop.")
    }

    /// A "message queued" pill shown above the composer while a follow-up waits for the current
    /// turn to finish. Shows the message text and its attachment chips; tapping ✕ cancels just
    /// this message and drops its text (and attachments) back in the composer.
    private func queuedMessageBar(_ message: QueuedMessage) -> some View {
        VStack(alignment: .leading, spacing: 6) {
            HStack(spacing: 6) {
                Image(systemName: "clock")
                    .foregroundStyle(.secondary)
                Text(queuedDisplayText(message))
                    .lineLimit(1)
                    .truncationMode(.tail)
                    .foregroundStyle(.secondary)
                Spacer(minLength: 8)
                Button {
                    Task { await cancelQueued(message) }
                } label: {
                    Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
                }
                .buttonStyle(.plain)
                .help("Cancel this queued message")
            }
            // The queued message's attachments, shown as display-only chips (the whole message is
            // cancelled as a unit via ✕ above, so the chips carry no per-attachment remove).
            if !message.attachments.isEmpty, let worktree = session?.worktreePath {
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: 8) {
                        ForEach(message.attachments) { attachment in
                            ComposerAttachmentChip(
                                attachment: .fromQueued(attachment, worktreeRoot: worktree),
                                onRemove: nil)
                        }
                    }
                    .padding(.vertical, 2)
                }
            }
        }
        .font(.callout)
        .padding(.horizontal, 10)
        .padding(.vertical, 6)
        .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 12))
        .help("This message will send automatically when the agent finishes its current turn.")
    }

    /// The one-line label for a queued pill: the message text, or — for an attachment-only
    /// message — the attachment names (the chips below already show them), so it's never blank.
    private func queuedDisplayText(_ message: QueuedMessage) -> String {
        if !message.text.isEmpty { return message.text }
        return message.attachments.map(\.filename).joined(separator: ", ")
    }

    /// Cancel a single queued message and, unless the user has already started a new message,
    /// refill the composer with its text and restore its attachments as real chips.
    private func cancelQueued(_ message: QueuedMessage) async {
        guard let removed = await store.cancelOpenQueuedMessage(id: message.id) else { return }
        let composerEmpty = draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
            && attachments.isEmpty
        guard composerEmpty else { return }
        draft = removed.text
        if let worktree = session?.worktreePath {
            attachments = removed.attachments.map { .fromQueued($0, worktreeRoot: worktree) }
        }
    }

    /// Whether the ⬇︎ button has anything to drop into the stash — typed text, or staged
    /// attachments on their own (which the stash carries along with the prompt).
    private var hasStashableDraft: Bool {
        !isArchived && hasSendableContent
    }

    /// Set the typed prompt (and anything staged with it) aside and clear the composer, tagged
    /// with this chat's name so the stash still reads sensibly once it holds prompts from
    /// several chats. Clearing the draft also ends the mesh typing indicator, exactly as
    /// sending does.
    private func stashDraft() {
        guard PromptStash.shared.stash(
            draft, attachments: attachments, origin: session?.title, settings: stashedSettings)
        else { return }
        draft = ""
        attachments = []
    }

    /// This chat's setup, captured alongside the prompt. The project and branch are recorded
    /// for the viewer to show — they're fixed for an existing chat, so a pull can't change
    /// them; what a pull actually restores is how hard the prompt was meant to be thought
    /// about (the Intelligence level, or the manual model/effort behind it).
    private var stashedSettings: StashedComposerSettings {
        StashedComposerSettings(
            projectID: session?.projectID.rawValue,
            projectName: session.flatMap { store.project($0.projectID) }?.name,
            branch: nvrsionActive ? nil : session?.branch,
            intelligence: intelligenceRoutingActive && !isOrchestra ? sliderIntelligenceLevel : nil,
            orchestra: isOrchestra ? true : nil,
            model: intelligenceRoutingActive ? nil : effectiveModel,
            effort: intelligenceRoutingActive || isOrchestra ? nil : effectiveEffort,
            auto: autoOn,
            ship: session?.autoShip)
    }

    /// Load a prompt pulled out of the stash back into this composer, media and all, and put
    /// this chat on the intelligence setting the prompt was written for. Project, branch and
    /// worktree are fixed for an existing chat, and Auto/Ship are its own standing policy
    /// rather than one prompt's — so those are shown in the viewer but not applied here.
    private func pull(_ prompt: StashedPrompt) {
        appendPulledPrompt(prompt.text, to: &draft)
        attachments.append(contentsOf: prompt.attachments.map(\.composerAttachment))
        guard let settings = prompt.settings, !isArchived else { return }
        if settings.orchestra == true, isControlProject {
            sliderOrchestraSelected = true
            sliderIntelligenceLevel = .max
            Task { await activateSessionOrchestra() }
        } else if let level = settings.intelligence, intelligenceRoutingActive {
            sliderOrchestraSelected = false
            sliderIntelligenceLevel = level
            Task { await applySessionIntelligenceLevel(level) }
        } else {
            // A manual pick travels as a concrete model/effort; apply it the way the menus do.
            if let model = settings.model { Task { await store.setOpenSessionModel(model) } }
            if let effort = settings.effort { Task { await store.setOpenSessionEffort(effort) } }
        }
    }

    private func send() {
        // Quota hold: refuse the send with its draft untouched until the user moves the
        // Intelligence bar to an available lane or the current window resets.
        guard routedRouteAvailable else {
            store.lastError = quotaHoldNote
            return
        }
        // Locked while another device is typing here (mirrors the disabled field/button —
        // this guards the keyboard path against a lock landing mid-keystroke).
        guard remoteTyping == nil else { return }
        let trimmed = draft.trimmingCharacters(in: .whitespacesAndNewlines)
        // Allow an attachment-only message (no typed text), but never an empty send.
        guard !trimmed.isEmpty || !attachments.isEmpty else { return }
        // Hand the staged attachments to the send Task and clear the composer immediately.
        let staged = attachments
        draft = ""
        attachments = []
        // While the agent is busy this queues the message (the controller holds it and sends
        // it the moment the current turn finishes); otherwise it sends immediately.
        // Sending is a deliberate "show me what happens next" — jump to the bottom even
        // if the user had scrolled up to read history.
        scrollToBottomRequest += 1
        let text = SingularityPreparation.prepare(trimmed)
        Task {
            // Read each attachment's bytes off the main thread — media can be large.
            let pending = await Task.detached { staged.compactMap { try? $0.pending() } }.value
            // The slider resolves on release, not on send. The quota hold at the top of
            // `send()` already refused a limited lane before the draft was cleared.
            await store.sendToOpenSession(text, attachments: pending)
        }
    }

    /// Flip the open chat out of the archive, restoring an interactive composer and
    /// transcript. Wired to the Unarchive button that replaces send while archived.
    /// Routed through the open-session verb so it reaches a peer Mac's chat too — the
    /// id-addressed setter silently no-ops without a local controller.
    private func unarchive() {
        Task { await store.setOpenSessionArchived(false) }
    }

    /// Menu label for an integration strategy, reused in the chat log line.
    private func integrationLabel(_ strategy: IntegrationStrategy) -> String {
        switch strategy {
        case .squash: return "Squash & merge"
        case .merge: return "Merge commit"
        case .rebase: return "Rebase & fast-forward"
        }
    }

    private func integrate(_ strategy: IntegrationStrategy) async {
        integrating = true
        defer { integrating = false }
        let label = integrationLabel(strategy)
        switch await store.integrateOpenSession(strategy: strategy) {
        case .clean(let into, let commit):
            await store.noteToOpenSession(
                "\(label) → \(into.value) · \(commit.prefix(7))", icon: "checkmark.seal")
        case .conflicted(let paths):
            await store.noteToOpenSession(
                "\(label) → \(targetBranch): conflicts in \(paths.count) file\(paths.count == 1 ? "" : "s")",
                icon: "exclamationmark.triangle")
        case .none:
            await store.noteToOpenSession("\(label) → \(targetBranch): failed", icon: "exclamationmark.triangle")
        }
    }

    /// Promote the project's nvrsion trunk into its real default branch and note the
    /// outcome in the chat — the chat-surface equivalent of the settings "Promote trunk"
    /// button, mirroring `integrate()`'s success/failure note pattern.
    private func promoteTrunk() async {
        guard let session, let project = store.project(session.projectID) else { return }
        promoting = true
        defer { promoting = false }
        let base = project.defaultBranch.value
        if await store.promoteNvrsionTrunk(project.id) {
            await store.noteToOpenSession("Integrated trunk → \(base)", icon: "checkmark.seal")
        } else {
            await store.noteToOpenSession(
                store.lastError ?? "Integrate → \(base): failed", icon: "exclamationmark.triangle")
        }
    }

    /// Promote just this chat's landed work into the real default branch (NVRSION §6, per-session
    /// promotion) and note the outcome — the per-chat counterpart of `promoteTrunk()`, so a finished
    /// chat can ship without waiting on a long-running sibling to complete its turn.
    private func promoteSession() async {
        guard let session, let project = store.project(session.projectID) else { return }
        promoting = true
        defer { promoting = false }
        let base = project.defaultBranch.value
        if await store.promoteNvrsionSession(session.id) {
            await store.noteToOpenSession("Integrated this chat → \(base)", icon: "checkmark.seal")
        } else {
            await store.noteToOpenSession(
                store.lastError ?? "Integrate this chat → \(base): failed", icon: "exclamationmark.triangle")
        }
    }

    private func openInTerminal() {
        guard let path = session?.worktreePath else { return }
        let terminal = URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app")
        NSWorkspace.shared.open(
            [URL(fileURLWithPath: path)], withApplicationAt: terminal,
            configuration: NSWorkspace.OpenConfiguration())
        Task { await store.noteToOpenSession("Opened worktree in Terminal", icon: "terminal") }
    }

    private func revealInFinder() {
        guard let path = session?.worktreePath else { return }
        NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
        Task { await store.noteToOpenSession("Revealed worktree in Finder", icon: "folder") }
    }

    /// Start the browser sign-in for this chat's provider through Nucleic's credential broker —
    /// Codex (ChatGPT) for the Codex backends, Claude otherwise. The provider's browser page is
    /// theirs; no terminal or manual CLI command is exposed.
    private func startClaudeLogin() {
        switch session?.backend {
        case .codex, .codexExec:
            Task { await store.loginCodex() }
        default:
            Task { await store.loginClaude() }
        }
    }

    private func copyToClipboard(_ string: String?, note: String) {
        guard let string, !string.isEmpty else { return }
        NSPasteboard.general.clearContents()
        NSPasteboard.general.setString(string, forType: .string)
        Task { await store.noteToOpenSession(note, icon: "doc.on.doc") }
    }

    private func exportSession() async {
        guard let export = await store.exportOpenSession() else { return }
        let panel = NSSavePanel()
        panel.nameFieldStringValue = export.filename
        panel.canCreateDirectories = true
        panel.title = "Export Chat"
        // `begin()`, not `runModal()`: no nested modal run loop on the main thread.
        guard await panel.begin() == .OK, let url = panel.url else { return }
        do {
            try export.contents.write(to: url, atomically: true, encoding: .utf8)
            NSWorkspace.shared.activateFileViewerSelecting([url])
        } catch {
            store.lastError = "Export failed: \(error)"
        }
    }
}

struct TranscriptProjectionMetadata: Sendable {
    let hasResponse: Bool
    let sessionTokens: Int?
    let contextInputTokens: Int?
    let currentTurnStart: Date?
    let lastRunDurationMs: Int?

    static let empty = Self(
        hasResponse: false,
        sessionTokens: nil,
        contextInputTokens: nil,
        currentTurnStart: nil,
        lastRunDurationMs: nil)
}

/// Single-owner projection pipeline for the open transcript. `IncrementalTranscriptProjection`
/// intentionally is not thread-safe; actor ownership preserves its stable-prefix state while all
/// full-fold fallbacks, filters, auth cleanup, and range construction stay off the main actor.
actor TranscriptProjectionWorker {
    struct Output: Sendable {
        let items: [TranscriptItem]
        let segments: [TranscriptRenderSegment]
        let segmentEstimates: [Int: TranscriptRenderSegmentEstimate]
        let lockLines: [String: [NoteLock]]
        let metadata: TranscriptProjectionMetadata
        let benchmarkStatistics: TranscriptRenderBenchmarkStatistics?
    }

    private let projector = IncrementalTranscriptProjection()
    private var segmenter = TranscriptRenderSegmenter()
    private var estimateCache:
        [TranscriptRenderSegmentIdentity: TranscriptRenderSegmentEstimate] = [:]
    private let signposter = OSSignposter(
        subsystem: "com.nucleic", category: "transcript-performance")
    private let logger = Logger(
        subsystem: "com.nucleic", category: "transcript-performance")

    func update(
        events: [AgentEvent],
        worktreeRoot: String?,
        showDebug: Bool,
        showLock: Bool
    ) -> Output? {
        // SwiftUI cancels the prior `.task(id:)` as soon as a newer preview/full-history revision
        // arrives. Actor calls already queued before that cancellation still enter this method;
        // rejecting them here prevents obsolete O(history) folds from serializing the newest one.
        guard !Task.isCancelled else { return nil }
        let started = ContinuousClock.now
        let interval = signposter.beginInterval("TranscriptProjection")
        defer { signposter.endInterval("TranscriptProjection", interval) }
        var result = projector.update(events: events, worktreeRoot: worktreeRoot)
        guard !Task.isCancelled else { return nil }
        if !showDebug { result.items = result.items.filter { !$0.isDebug } }
        if !showLock { result.items = result.items.filter { !$0.isLockEvent } }
        result.items = TranscriptRow.hidingResolvedAuthArtifacts(result.items)
        guard !Task.isCancelled else { return nil }
        // All variants consume this exact projection. Only the text baseline derives a smaller
        // presentation list; production/skeleton/generic retain identical row identities.
        let projectedItems = result.items
        let benchmark = TranscriptRenderBenchmarkConfiguration.current
        if benchmark.variant == .textOnly {
            result.items = projectedItems.filter(TranscriptRow.isTextOnlyBenchmarkItem)
        }
        let segments = segmenter.update(rowCount: result.items.count)
        var liveEstimateIdentities = Set<TranscriptRenderSegmentIdentity>()
        let tailID = segments.last?.id
        var segmentEstimates: [Int: TranscriptRenderSegmentEstimate] = [:]
        segmentEstimates.reserveCapacity(segments.count)
        for (ordinal, segment) in segments.enumerated() {
            if ordinal.isMultiple(of: 64), Task.isCancelled { return nil }
            let slice = result.items[segment.range]
            let identity = TranscriptRenderSegmentIdentity(
                lowerBound: segment.range.lowerBound,
                rowCount: segment.length,
                firstItemID: slice.first?.id ?? "",
                lastItemID: slice.last?.id ?? "")
            liveEstimateIdentities.insert(identity)
            let estimate: TranscriptRenderSegmentEstimate
            if segment.id != tailID, let cached = estimateCache[identity] {
                estimate = cached
            } else {
                estimate = TranscriptRenderSegmentEstimate.measure(slice)
                estimateCache[identity] = estimate
            }
            segmentEstimates[segment.id] = estimate
        }
        estimateCache = estimateCache.filter { liveEstimateIdentities.contains($0.key) }
        guard !Task.isCancelled else { return nil }
        let metadata = Self.metadata(in: events)
        guard !Task.isCancelled else { return nil }

        let parts = (ContinuousClock.now - started).components
        let milliseconds = Double(parts.seconds) * 1_000
            + Double(parts.attoseconds) / 1_000_000_000_000_000
        if milliseconds >= 50 {
            logger.notice(
                "slow_projection events=\(events.count) rows=\(result.items.count) elapsed_ms=\(milliseconds, format: .fixed(precision: 1))")
        } else {
            logger.debug(
                "projection events=\(events.count) rows=\(result.items.count) elapsed_ms=\(milliseconds, format: .fixed(precision: 1))")
        }
        let shapeStarted = ContinuousClock.now
        let benchmarkStatistics = benchmark.isEnabled
            ? TranscriptRenderBenchmarkStatistics.measure(projectedItems)
            : nil
        guard !Task.isCancelled else { return nil }
        if let stats = benchmarkStatistics {
            let shapeParts = (ContinuousClock.now - shapeStarted).components
            let shapeMilliseconds = Double(shapeParts.seconds) * 1_000
                + Double(shapeParts.attoseconds) / 1_000_000_000_000_000
            logger.info(
                "benchmark_shape variant=\(benchmark.variant.rawValue, privacy: .public) events=\(events.count) projected_rows=\(stats.topLevelRowCount) rendered_rows=\(result.items.count) nested_children=\(stats.nestedChildCount) max_depth=\(stats.maximumNestedDepth) tool_calls=\(stats.toolCallCount) max_group=\(stats.maximumGroupSize) visible_markdown_bytes=\(stats.visibleMarkdownBytes) shape_elapsed_ms=\(shapeMilliseconds, format: .fixed(precision: 1)) card_kinds=\(stats.cardKindSummary, privacy: .public)")
        }
        return Output(
            items: result.items,
            segments: segments,
            segmentEstimates: segmentEstimates,
            lockLines: result.lockLines,
            metadata: metadata,
            benchmarkStatistics: benchmarkStatistics)
    }

    /// Header/summary facts formerly rediscovered by several whole-event-array scans on every
    /// SwiftUI body evaluation. Fold them once beside projection so long histories never leak an
    /// O(events) main-actor cost back in through otherwise unrelated header rendering.
    private static func metadata(in events: [AgentEvent]) -> TranscriptProjectionMetadata {
        var hasResponse = false
        var tokenTotal = 0
        var sawTokens = false
        var contextInputTokens: Int?
        var currentTurnStart: Date?
        var lastRunDurationMs: Int?

        for event in events {
            switch event.kind {
            case .turnCompleted(let turn):
                hasResponse = true
                if let usage = turn.usage {
                    tokenTotal += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
                    sawTokens = true
                    if let context = usage.contextInputTokens { contextInputTokens = context }
                }
            case .runFinished(let run):
                hasResponse = true
                if let duration = run.durationMs { lastRunDurationMs = duration }
            case .usage(let usage):
                if let context = usage.contextInputTokens { contextInputTokens = context }
            case .userText:
                currentTurnStart = event.at
            default:
                break
            }
        }
        return TranscriptProjectionMetadata(
            hasResponse: hasResponse,
            sessionTokens: sawTokens ? tokenTotal : nil,
            contextInputTokens: contextInputTokens,
            currentTurnStart: currentTurnStart,
            lastRunDurationMs: lastRunDurationMs)
    }
}

/// Constant-cost reserved canvas for one unloaded native row. It deliberately does not typeset
/// fake prose, mask a gradient, blur layers, or run an infinite animation: all four happened for
/// every transient row during a fling and could cost more than rendering the real transcript.
private struct TranscriptChunkPlaceholder: View {
    let seed: Int
    let rowCount: Int
    let fontSize: CGFloat

    static func seed(for value: String?) -> Int {
        var hash: UInt64 = 14_695_981_039_346_656_037
        for byte in (value ?? "transcript").utf8 {
            hash = (hash ^ UInt64(byte)) &* 1_099_511_628_211
        }
        return Int(truncatingIfNeeded: hash)
    }

    private func trailingInset(row: Int, line: Int) -> CGFloat {
        let mixed = UInt(truncatingIfNeeded: seed &+ row &* 7_919 &+ line &* 1_009)
        return CGFloat(44 + mixed % 164)
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            ForEach(0..<max(1, min(rowCount, 3)), id: \.self) { row in
                VStack(alignment: .leading, spacing: 7) {
                    RoundedRectangle(cornerRadius: 2)
                        .fill(Color.secondary.opacity(0.16))
                        .frame(height: max(5, fontSize * 0.48))
                        .padding(.trailing, trailingInset(row: row, line: 0))
                    RoundedRectangle(cornerRadius: 2)
                        .fill(Color.secondary.opacity(0.10))
                        .frame(height: max(5, fontSize * 0.42))
                        .padding(.trailing, trailingInset(row: row, line: 1) + 70)
                }
            }
        }
        .frame(maxWidth: .infinity, alignment: .topLeading)
        .accessibilityHidden(true)
        .allowsHitTesting(false)
    }
}

/// A stable native-list slot whose readiness belongs to the recycled row, not the transcript root.
/// The placeholder and real content are mutually exclusive branches and swap without animation.
private struct TranscriptRenderSegmentSlot<Content: View>: View {
    let seed: Int
    let identity: TranscriptRenderSegmentIdentity
    let length: Int
    let fontSize: CGFloat
    let reservedHeight: CGFloat
    let onMeasured: (CGFloat) -> Void
    let onPriorityMount: () async -> Bool
    let content: Content

    @State private var readyIdentity: TranscriptRenderSegmentIdentity?

    init(
        seed: Int,
        identity: TranscriptRenderSegmentIdentity,
        length: Int,
        fontSize: CGFloat,
        reservedHeight: CGFloat,
        initiallyReady: Bool,
        onMeasured: @escaping (CGFloat) -> Void,
        onPriorityMount: @escaping () async -> Bool,
        @ViewBuilder content: () -> Content
    ) {
        self.seed = seed
        self.identity = identity
        self.length = length
        self.fontSize = fontSize
        self.reservedHeight = reservedHeight
        self.onMeasured = onMeasured
        self.onPriorityMount = onPriorityMount
        self.content = content()
        _readyIdentity = State(initialValue: initiallyReady ? identity : nil)
    }

    var body: some View {
        Group {
            if readyIdentity == identity {
                content
                    .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
                        onMeasured(height)
                    }
            } else {
                TranscriptChunkPlaceholder(seed: seed, rowCount: length, fontSize: fontSize)
                    .frame(height: max(reservedHeight, CGFloat(length)))
            }
        }
        .task(id: identity) {
            guard readyIdentity != identity else { return }
            guard await onPriorityMount(), !Task.isCancelled else { return }
            var transaction = Transaction(animation: nil)
            transaction.disablesAnimations = true
            withTransaction(transaction) { readyIdentity = identity }
        }
    }
}

/// One bounded batch inside the transcript's render container.
///
/// A short history lays every batch out eagerly; a long history uses each batch as one recyclable
/// native list row. SwiftUI compares at most two items before deciding that a settled segment's
/// row subtree and cached size can be reused. Closure identity is deliberately ignored; only
/// whether each optional control exists affects rendering, matching `TranscriptRow`'s Equatable
/// contract.
private struct TranscriptRenderSegmentView: View, Equatable {
    let items: ArraySlice<TranscriptItem>
    var benchmarkVariant = TranscriptRenderBenchmarkConfiguration.current.variant
    var onRevert: ((UInt64) -> Void)?
    var onLogin: (() -> Void)?
    var onOpenVMMonitor: (() -> Void)?
    var onSkip: ((ToolCall) -> Void)?
    var onResolveStall: ((String, Bool) -> Void)?
    var onOpenSubagents: (() -> Void)?

    nonisolated static func == (
        lhs: TranscriptRenderSegmentView, rhs: TranscriptRenderSegmentView
    ) -> Bool {
        MainActor.assumeIsolated {
            lhs.items.elementsEqual(rhs.items)
                && lhs.benchmarkVariant == rhs.benchmarkVariant
                && (lhs.onRevert == nil) == (rhs.onRevert == nil)
                && (lhs.onLogin == nil) == (rhs.onLogin == nil)
                && (lhs.onOpenVMMonitor == nil) == (rhs.onOpenVMMonitor == nil)
                && (lhs.onSkip == nil) == (rhs.onSkip == nil)
                && (lhs.onResolveStall == nil) == (rhs.onResolveStall == nil)
                && (lhs.onOpenSubagents == nil) == (rhs.onOpenSubagents == nil)
        }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            ForEach(items) { item in
                transcriptRow(item)
            }
        }
    }

    private func transcriptRow(_ item: TranscriptItem) -> some View {
        TranscriptRow(
            item: item,
            benchmarkVariant: benchmarkVariant,
            onRevert: onRevert,
            onLogin: onLogin,
            onOpenVMMonitor: onOpenVMMonitor,
            onSkip: onSkip,
            onResolveStall: onResolveStall,
            onOpenSubagents: onOpenSubagents)
            .equatable()
            .id(item.id)
    }
}

/// The floating "jump to the latest" chevron shown above the chat bar while the user
/// has scrolled up away from the bottom of the transcript.
private struct JumpToBottomButton: View {
    @Environment(\.appPalette) private var palette
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Image(systemName: "chevron.down")
                .font(.system(size: 13, weight: .semibold))
                .foregroundStyle(palette.accent)
                .frame(width: 30, height: 30)
                .background(.regularMaterial, in: .circle)
                .overlay(Circle().strokeBorder(.separator, lineWidth: 0.5))
                .shadow(color: .black.opacity(0.18), radius: 4, y: 1)
        }
        .buttonStyle(.plain)
        .help("Jump to the latest transcript entry")
    }
}

/// A live "the agent is working" row shown at the foot of the transcript while a
/// turn is in flight, so a sent message visibly has somewhere to land.
/// In-transcript banner for a chat parked in the file-lock queue (LOCKING §4): names the
/// contended files and the chat(s) holding them, escalates to the attention style for a genuine
/// deadlock, and offers the manual escape hatch — releasing a holder's locks — right where the
/// user is watching the chat "hang". The queued wait resolves itself when the holder's work
/// lands; this exists so that wait is never mistaken for a frozen app.
struct LockContentionBanner: View {
    @Environment(\.appPalette) private var palette
    let wait: AppStore.LockWaitInfo
    let deadlocked: Bool
    let onReleaseHolder: (AppStore.LockWaitInfo.Holder) -> Void

    private var fileList: String {
        let shown = wait.files.prefix(3).joined(separator: ", ")
        return wait.files.count > 3 ? "\(shown), +\(wait.files.count - 3) more" : shown
    }

    private var holderList: String {
        wait.holders.isEmpty
            ? "another active chat"
            : wait.holders.map { "“\($0.title)”" }.joined(separator: ", ")
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Label(
                deadlocked ? "Deadlocked on file locks" : "Waiting for file access",
                systemImage: deadlocked ? "exclamationmark.triangle.fill" : "hourglass")
                .font(.callout.weight(.semibold))
                .foregroundStyle(deadlocked ? palette.attention : .secondary)
            Text(
                deadlocked
                    ? "This chat and \(holderList) each hold a file the other needs "
                        + "(\(fileList)) — neither can proceed until one releases."
                    : "\(fileList) is held by \(holderList). This chat resumes automatically "
                        + "when that work lands.")
                .font(.caption)
                .foregroundStyle(.secondary)
                .fixedSize(horizontal: false, vertical: true)
            if !wait.holders.isEmpty {
                HStack(spacing: 8) {
                    ForEach(wait.holders) { holder in
                        Button("Release “\(holder.title)”’s locks") { onReleaseHolder(holder) }
                            .buttonStyle(.bordered)
                            .controlSize(.small)
                            .help("Force-release every file lock that chat holds so this one can proceed. Its unmerged work stays intact; the lock re-arms if it keeps editing.")
                    }
                }
            }
        }
        .padding(10)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(
            RoundedRectangle(cornerRadius: 8)
                .fill(deadlocked ? palette.attention.opacity(0.08) : Color.primary.opacity(0.04)))
        .overlay(
            RoundedRectangle(cornerRadius: 8)
                .strokeBorder(
                    deadlocked ? palette.attention.opacity(0.35) : Color.primary.opacity(0.08)))
        .transition(.opacity)
    }
}

struct WorkingIndicator: View {
    let text: String

    /// The label minus a single trailing ellipsis ("Waiting for answers…" → "Waiting
    /// for answers"); the dots are rendered separately so they can animate.
    private var base: String {
        text.hasSuffix("…") ? String(text.dropLast()) : text
    }
    private var hasEllipsis: Bool { text.hasSuffix("…") }

    var body: some View {
        HStack(spacing: 8) {
            ProgressView().controlSize(.small).tint(.secondary)
            HStack(spacing: 0) {
                Text(base)
                if hasEllipsis { AnimatedEllipsis() }
            }
            .font(.callout).foregroundStyle(.secondary)
        }
        .padding(.vertical, 4)
        .transition(.opacity)
    }
}

/// A three-dot ellipsis that cycles ·, ·· , ··· — a "still waiting" pulse. All three
/// dots are always laid out (only their opacity changes) so the label never reflows.
/// Driven by `TimelineView` (pure SwiftUI) so there's no Combine timer to manage.
private struct AnimatedEllipsis: View {
    private static let step = 0.4

    var body: some View {
        TimelineView(.periodic(from: .now, by: Self.step)) { context in
            let phase = Int(context.date.timeIntervalSinceReferenceDate / Self.step) % 4
            HStack(spacing: 0) {
                ForEach(0..<3, id: \.self) { index in
                    Text(".").opacity(index < phase ? 1 : 0.2)
                }
            }
        }
    }
}

struct ApprovalBar: View {
    @Environment(AppStore.self) private var store
    @Environment(\.appPalette) private var palette
    let request: ApprovalRequest
    /// When set, the request belongs to a specific (off-screen) session — the ephemeral popup
    /// window — so answers route there rather than to the single open session. nil = open session.
    var sessionID: SessionID? = nil

    /// The on-device model's cross-check of a host_exec reason against its command, filled in
    /// asynchronously by the `.task` below. Nil while the check is in flight (the card shows
    /// "Checking…") and for any non-host-exec request.
    @State private var reasonAudit: HostExecReasonAudit?

    /// `ExitPlanMode` is a three-way review rather than a generic permission prompt. Choosing
    /// Revise reveals this feedback field; submitting it denies the transition while returning
    /// the requested changes to Claude so it stays in plan mode.
    @State private var revisingPlan = false
    @State private var planRevision = ""

    /// The full, untruncated content being granted (full command / path / URL / input).
    /// `request.title` is only a one-line summary capped at 80 chars; the user must be
    /// able to read everything before allowing, so the detail body is shown verbatim.
    private var detail: String {
        RiskClassifier.detail(toolName: request.toolName, input: request.input)
    }

    /// A git commit pipeline, when this request is one — rendered as a structured card
    /// (subject + body + steps) instead of a raw command blob. Nil for any other command.
    private var gitBlock: GitCommandSummary.Block? {
        guard request.toolName == "Bash",
              let command = request.input["command"]?.stringValue,
              let block = GitCommandSummary.block(for: command), block.isCommit
        else { return nil }
        return block
    }

    /// A non-commit `rm`/`git` pipeline, when this request is one — rendered as a step list
    /// (with destructive deletes flagged) instead of a raw command blob. Nil when a commit
    /// card already covers it, or when there's nothing worth a card: a single non-destructive
    /// op reads fine in the title, so a card is shown only for a multi-step pipeline or any
    /// destructive delete.
    private var shellBlock: ShellCommandSummary.Block? {
        guard gitBlock == nil,
              request.toolName == "Bash",
              let command = request.input["command"]?.stringValue,
              let block = ShellCommandSummary.block(for: command),
              block.steps.count >= 2 || block.steps.contains(where: { $0.destructive })
        else { return nil }
        return block
    }

    /// The shell command of a `host_exec` request, when this is one — rendered as a structured
    /// card (tool identity + pretty command block) instead of the raw `{"command": …}` JSON the
    /// generic detail box would otherwise show. Nil for any other tool. The command escapes the
    /// sandbox onto the host, so naming the tool and showing the command in full both matter.
    /// Nil for a host-command *conflict* prompt — that has its own card (`hostCommandConflict`).
    private var hostExecCommand: String? {
        guard request.toolName == MCPApprovalServer.qualifiedHostExecToolName,
              hostCommandConflict == nil,
              let command = request.input["command"]?.stringValue,
              !command.isEmpty
        else { return nil }
        // Expand `$PWD`-style substitutions so the card shows what the command actually resolves
        // to on the host, rather than an opaque `$PWD` the user can't verify.
        return HostCommandSummary.expand(command, environment: hostExecEnvironment)
    }

    /// The environment used to expand `$PWD`-style substitutions in a `host_exec` command: the host
    /// process environment with `PWD` pinned to the command's working directory the request carries,
    /// since a host_exec command runs with its cwd set to that workdir (so `$PWD` resolves there).
    private var hostExecEnvironment: [String: String] {
        HostCommandSummary.hostDisplayEnvironment(workingDirectory: request.input["cwd"]?.stringValue)
    }

    /// The agent's stated justification for the `host_exec` call, when one is present. Shown on the
    /// card as an *unverified claim* — the command breakdown beside it is the ground truth, so the
    /// user can weigh what the agent says against what the command actually does.
    private var hostExecReason: String? {
        guard let reason = request.input["reason"]?.stringValue,
              !reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        else { return nil }
        return reason
    }

    /// The host-command conflict payload of a `host_exec` override prompt, when this request is one
    /// (HOST_EXEC §concurrency): another session is already running a *similar* command. Drives the
    /// dedicated conflict card + "Run anyway" / "Cancel" buttons. Nil for an ordinary host-exec gate.
    private var hostCommandConflict: HostCommandConflictInfo? {
        guard request.toolName == MCPApprovalServer.qualifiedHostExecToolName,
              let entries = request.input["host_command_conflict"]?["with"]?.arrayValue,
              !entries.isEmpty
        else { return nil }
        let others = entries.compactMap { entry -> HostCommandConflictInfo.Other? in
            guard let session = entry["session"]?.stringValue else { return nil }
            return .init(
                session: session,
                command: entry["command"]?.stringValue ?? "",
                reason: entry["reason"]?.stringValue ?? "")
        }
        guard !others.isEmpty else { return nil }
        return .init(command: request.input["command"]?.stringValue ?? "", others: others)
    }

    /// Whether this is a `mac_vm_request_operator` request — the agent asking the user to operate the
    /// session's macOS VM by hand (CAPTCHA / login). Rendered as its own card with an "Open viewer &
    /// help" action, rather than the raw `{"instructions": …}` JSON the generic detail box would show.
    private var isOperatorAssist: Bool {
        request.toolName == MCPApprovalServer.qualifiedMacVMOperatorToolName
    }

    /// The agent's instructions for an operator-assist request — what the user should do in the VM.
    private var operatorInstructions: String? {
        guard isOperatorAssist,
              let s = request.input["instructions"]?.stringValue,
              !s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        else { return nil }
        return s
    }

    /// The agent's stated reason a human is required, for an operator-assist request (a claim).
    private var operatorReason: String? {
        guard isOperatorAssist,
              let s = request.input["reason"]?.stringValue,
              !s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        else { return nil }
        return s
    }

    /// The plan of an `ExitPlanMode` request, when this is one — the agent presenting the plan it
    /// wrote and asking to leave plan mode. Rendered as the formatted-Markdown plan card instead of
    /// the escaped-JSON blob the generic detail box would show. Nil for any other tool.
    private var planToApprove: ExitPlanMode.Plan? {
        guard request.toolName == ExitPlanMode.toolName else { return nil }
        return ExitPlanMode.plan(from: request.input)
    }

    /// Whether a structured card carries the request's detail — when one does, the title row
    /// just states the gate and the card supplies the specifics.
    private var showsCard: Bool {
        gitBlock != nil || shellBlock != nil || hostExecCommand != nil || hostCommandConflict != nil
            || isOperatorAssist || planToApprove != nil
    }

    /// Kept separate from `planToApprove`: newer Claude builds may omit the Markdown from the
    /// permission payload. The review must still expose only Accept / Deny / Revise and must never
    /// fall back to generic Allow Always behavior just because there is no renderable plan body.
    private var isExitPlanMode: Bool { request.toolName == ExitPlanMode.toolName }

    var body: some View {
        // Title and actions are stacked vertically so the title (often a long file
        // path) can wrap to as many lines as it needs instead of being truncated to
        // share one row with the buttons.
        VStack(alignment: .leading, spacing: 10) {
            HStack(alignment: .top, spacing: 12) {
                Image(systemName: "pause.circle.fill").foregroundStyle(palette.attention).font(.title)
                VStack(alignment: .leading, spacing: 2) {
                    // A commit headline / step list lives in the card below, so when one shows
                    // the title row just states the gate; everything else shows its own
                    // one-line summary.
                    // A plan card keeps the request's own "Ready to code?" title (the card below
                    // is the plan, not a separate gate summary); every other card reads as a gate.
                    Text(showsCard && planToApprove == nil ? "Permission requested" : request.title)
                        .font(.title3).bold()
                        .multilineTextAlignment(.leading)
                        .fixedSize(horizontal: false, vertical: true)
                        .textSelection(.enabled)
                    // The host-exec and conflict cards name the tool in their own headers, so the
                    // generic `mcp__…__host_exec · hostExec` line would only repeat it less legibly;
                    // the plan card likewise reads on its own without an "ExitPlanMode · readOnly" line.
                    if hostExecCommand == nil, hostCommandConflict == nil, !isOperatorAssist,
                       planToApprove == nil {
                        // The gate reads as what it grants — "macOS VM", "linux_container" —
                        // rather than as the `mcp__nucleic__…` wire name. The fully-qualified
                        // name stays one hover away, since this is the surface where knowing
                        // exactly which tool is being allowed matters most.
                        Text("\(SandboxToolDisplay.gateLabel(for: request.toolName)) · \(request.risk.rawValue)")
                            .font(.subheadline).foregroundStyle(.secondary)
                            .help(request.toolName)
                    }
                }
                Spacer(minLength: 0)
            }
            if let gitBlock {
                // The whole git pipeline as a GUI element; the literal command stays one tap
                // away under "Show command" so the user can still verify exactly what's granted.
                GitBlockCard(block: gitBlock, rawCommand: detail, tint: palette.attention)
            } else if let shellBlock {
                // A mixed `rm`/`git` pipeline as a step list, destructive deletes flagged; the
                // literal command stays one tap away under "Show command".
                CommandStepsCard(block: shellBlock, rawCommand: detail, tint: palette.attention)
            } else if let hostCommandConflict {
                // A host-command conflict: another session is already running a similar command.
                // Name which session(s) and why, so the user can spot a true clash — or override a
                // false positive — at a glance (HOST_EXEC §concurrency).
                HostCommandConflictCard(info: hostCommandConflict, tint: palette.attention)
            } else if let hostExecCommand {
                // A `host_exec` call: name the tool being invoked and show the command it wants
                // to run on the host, pretty-formatted, instead of the raw `{"command": …}` JSON.
                HostExecCard(
                    command: hostExecCommand, toolName: request.toolName,
                    reason: hostExecReason, reasonAudit: reasonAudit, tint: palette.attention,
                    projectRoot: store.openSession.flatMap { store.project($0.projectID)?.rootPath },
                    worktreeRoot: store.openSession?.worktreePath)
                .task(id: request.id) {
                    // Cross-check the agent's reason against the command on-device, so a reason
                    // that doesn't match what the command does is flagged before approval. Runs
                    // only when a reason was actually given; the verdict comes from a separate
                    // local model, not the agent, so it can't be spoofed.
                    guard let reason = hostExecReason else { return }
                    reasonAudit = await store.intelligence.auditHostExecReason(
                        command: hostExecCommand, reason: reason)
                }
            } else if isOperatorAssist {
                // A `mac_vm_request_operator` call: the agent needs the user to operate the VM by hand.
                // Show what to do + why, instead of the raw `{"instructions": …}` JSON; the buttons
                // below open the interactive viewer.
                MacVMOperatorRequestCard(
                    instructions: operatorInstructions ?? "",
                    reason: operatorReason, tint: palette.attention)
            } else if let planToApprove {
                // An `ExitPlanMode` call: render the plan the agent presents as formatted Markdown
                // (the same card the transcript shows), instead of the raw `{"plan": …}` JSON.
                ExitPlanModeCard(plan: planToApprove)
            } else if !detail.isEmpty, !request.title.contains(detail) {
                // Full, untruncated content in a monospaced block — the command wraps and
                // the box fits its content, only scrolling vertically once the content is
                // genuinely tall, so the user can always see exactly what they are granting
                // without a giant half-empty box. Shown only when it adds detail beyond the
                // title (a short command the title already shows in full needs no echo).
                ApprovalDetailBox(text: detail)
            }
            if isExitPlanMode, revisingPlan {
                VStack(alignment: .leading, spacing: 6) {
                    Text("What should Claude change?")
                        .font(.subheadline.weight(.semibold))
                    TextField(
                        "Describe the changes you want in the plan…",
                        text: $planRevision,
                        axis: .vertical)
                        .lineLimit(2...6)
                        .textFieldStyle(.roundedBorder)
                }
            }
            HStack(spacing: 8) {
                Spacer()
                if isExitPlanMode {
                    if revisingPlan {
                        Button("Cancel") {
                            planRevision = ""
                            revisingPlan = false
                        }
                        Button("Send Revision") { submitPlanRevision() }
                            .keyboardShortcut(.defaultAction)
                            .buttonStyle(.borderedProminent)
                            .disabled(ExitPlanMode.revisionReason(feedback: planRevision) == nil)
                    } else {
                        Button("Deny") {
                            respond(.deny(reason: ExitPlanMode.rejectionReason))
                        }
                        Button("Revise") { revisingPlan = true }
                        Button("Accept") { respond(.allow()) }
                            .keyboardShortcut(.defaultAction)
                            .buttonStyle(.borderedProminent)
                    }
                } else if hostCommandConflict != nil {
                    // A conflict override is a one-off judgement call, not a trust grant: "Cancel"
                    // (skip this command, tell the agent to retry), "Wait" (queue behind the running
                    // twin and auto-run once it's clear), or "Run anyway" (it's a false positive).
                    // Never a remembered allow (HOST_EXEC §concurrency).
                    Button("Cancel") { respond(.deny(reason: "Skipped to avoid a concurrent host command")) }
                    // "Wait" is a non-deny allow, tagged so the host-exec gate queues it (FIFO) instead
                    // of overriding — see HostCommandConflictSignal.waitInputKey.
                    Button("Wait") {
                        respond(.allow(updatedInput: .object([
                            HostCommandConflictSignal.waitInputKey: .bool(true)
                        ])))
                    }
                    Button("Run anyway") { respond(.allow()) }
                        .keyboardShortcut(.defaultAction)
                        .buttonStyle(.borderedProminent)
                } else if isOperatorAssist {
                    // Operator hand-off: "Decline" tells the agent the user won't help; "Open viewer &
                    // help" hands the user the interactive VM + a walkthrough popup. The popup — NOT
                    // this button — resolves the approval (with {completed, note}) once the user is done,
                    // so opening the viewer leaves the request pending on purpose.
                    Button("Decline") { respond(.deny(reason: "The user declined to help operate the VM.")) }
                    Button("Open viewer & help") {
                        MacVMOperatorAssistController.shared.begin(request: request, store: store)
                    }
                    .keyboardShortcut(.defaultAction)
                    .buttonStyle(.borderedProminent)
                } else {
                    Button("Deny") { respond(.deny(reason: "Denied from Nucleic")) }
                    if request.risk == .hostExec {
                        // Host execution escapes the sandbox: the only "remember" option is a
                        // deliberate, session-scoped grant — never a blanket tool allow (HOST_EXEC).
                        Button("Allow for Session") { respond(.allowAlways(.session)) }
                    } else if request.risk != .destructive {
                        // A destructive action (rm, force-push, reset --hard, …) offers no
                        // remembered allow: every one must be a deliberate, one-off approval,
                        // never granted in a way that lets the next one through unseen.
                        Button("Allow Always") { respond(.allowAlways(.toolName)) }
                    }
                    Button("Allow") { respond(.allow()) }
                        .keyboardShortcut(.defaultAction)
                        .buttonStyle(.borderedProminent)
                }
            }
        }
        .padding(12)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(palette.attention.opacity(0.08), in: .rect(cornerRadius: 10))
        .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(palette.attention.opacity(0.25), lineWidth: 1))
        .onChange(of: request.id) { _, _ in
            revisingPlan = false
            planRevision = ""
        }
    }

    private func respond(_ decision: Decision) {
        let id = request.id
        if let sessionID {
            Task { await store.respondToApproval(id, decision, inSession: sessionID) }
        } else {
            Task { await store.respondToOpenApproval(id, decision) }
        }
    }

    private func submitPlanRevision() {
        guard let reason = ExitPlanMode.revisionReason(feedback: planRevision) else { return }
        respond(.deny(reason: reason))
    }
}

/// The full content of an approval, wrapped and selectable. The box sizes to its
/// content — a short command sits in a snug box — and only begins scrolling once the
/// content exceeds `maxHeight`, so a large paste stays bounded instead of pushing the
/// Allow/Deny buttons off-screen. The text wraps; it never scrolls horizontally.
private struct ApprovalDetailBox: View {
    @Environment(\.appPalette) private var palette
    let text: String
    private let maxHeight: CGFloat = 220
    @State private var contentHeight: CGFloat = 0

    var body: some View {
        ScrollView(.vertical) {
            Text(text)
                .font(.callout.monospaced())
                .textSelection(.enabled)
                .multilineTextAlignment(.leading)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(8)
                .background(GeometryReader { geo in
                    Color.clear.preference(key: ApprovalDetailHeightKey.self, value: geo.size.height)
                })
        }
        // Until the first measurement lands, show the content at full height so it
        // never flashes as a zero-height sliver.
        .frame(height: contentHeight == 0 ? nil : min(contentHeight, maxHeight))
        .onPreferenceChange(ApprovalDetailHeightKey.self) { contentHeight = $0 }
        .background(palette.attention.opacity(0.06), in: .rect(cornerRadius: 8))
        .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(palette.attention.opacity(0.15), lineWidth: 1))
    }
}

private struct ApprovalDetailHeightKey: PreferenceKey {
    static var defaultValue: CGFloat { 0 }
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

/// Answer UI for the built-in `AskUserQuestion` tool. Unlike a permission gate,
/// this collects the user's choice per question and folds the selections into the
/// approval reply's `updatedInput` (see `AskUserQuestion`); replying with a plain
/// allow makes the CLI report "the user did not answer the questions".
///
/// The questions are presented one at a time as a wizard — Back/Next to move
/// between them and Submit on the final step — so a multi-question ask reads as a
/// focused sequence rather than one long stacked form.
struct AskUserQuestionBar: View {
    @Environment(AppStore.self) private var store
    @Environment(\.appPalette) private var palette
    let request: ApprovalRequest
    let questions: [AskUserQuestion.Question]
    /// See `ApprovalBar.sessionID` — routes answers to the ephemeral popup's session. nil = open.
    var sessionID: SessionID? = nil

    /// Selected option labels per question text.
    @State private var selected: [String: Set<String>] = [:]
    /// Whether the free-text "Other" answer is active per question text.
    @State private var otherActive: [String: Bool] = [:]
    /// The free-text "Other" answer per question text.
    @State private var otherText: [String: String] = [:]
    /// Index of the question currently shown (the wizard step).
    @State private var step = 0

    var body: some View {
        VStack(alignment: .leading, spacing: 14) {
            if let question = currentQuestion {
                VStack(alignment: .leading, spacing: 6) {
                    if questions.count > 1 {
                        Text("Question \(step + 1) of \(questions.count)")
                            .font(.caption).foregroundStyle(.secondary)
                    }
                    if let header = question.header, !header.isEmpty {
                        Text(header.uppercased())
                            .font(.caption).bold().foregroundStyle(.secondary)
                    }
                    Text(question.question).font(.title3).bold()
                    ForEach(question.options) { option in
                        optionRow(question, label: option.label, description: option.description)
                    }
                    otherRow(question)
                }
            }
            HStack {
                Button("Deny") { respond(.deny(reason: "Denied from Nucleic")) }
                Spacer()
                if step > 0 {
                    Button("Back") { step -= 1 }
                }
                if isLastStep {
                    Button("Submit") { submit() }
                        .keyboardShortcut(.defaultAction)
                        .buttonStyle(.borderedProminent)
                        .disabled(!allAnswered)
                } else {
                    Button("Next") { step += 1 }
                        .keyboardShortcut(.defaultAction)
                        .buttonStyle(.borderedProminent)
                        .disabled(!currentAnswered)
                }
            }
        }
        .padding(12)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(palette.attention.opacity(0.08), in: .rect(cornerRadius: 10))
        .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(palette.attention.opacity(0.25), lineWidth: 1))
    }

    /// The question for the active step, or `nil` if the index is somehow out of range.
    private var currentQuestion: AskUserQuestion.Question? {
        questions.indices.contains(step) ? questions[step] : nil
    }

    private var isLastStep: Bool { step >= questions.count - 1 }

    /// Whether the question on the current step has at least one answer — gates `Next`.
    private var currentAnswered: Bool {
        guard let currentQuestion else { return false }
        return !answers(for: currentQuestion).isEmpty
    }

    @ViewBuilder
    private func optionRow(_ question: AskUserQuestion.Question, label: String, description: String?)
        -> some View
    {
        let isOn = selected[question.question]?.contains(label) ?? false
        Button { toggle(question, label) } label: {
            HStack(alignment: .top, spacing: 8) {
                Image(systemName: symbol(multiSelect: question.multiSelect, on: isOn))
                    .foregroundStyle(isOn ? palette.attention : .secondary)
                VStack(alignment: .leading, spacing: 2) {
                    Text(label).font(.body)
                        .fixedSize(horizontal: false, vertical: true)
                    if let description, !description.isEmpty {
                        // Let the subtext wrap to as many lines as it needs — inside a Button
                        // label with a trailing Spacer, an unconstrained Text truncates to one
                        // line, hiding the rest of the answer. Matches AskUserQuestionResultCard.
                        Text(description).font(.subheadline).foregroundStyle(.secondary)
                            .fixedSize(horizontal: false, vertical: true)
                    }
                }
                Spacer()
            }
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
    }

    @ViewBuilder
    private func otherRow(_ question: AskUserQuestion.Question) -> some View {
        let isOn = otherActive[question.question] ?? false
        HStack(alignment: .center, spacing: 8) {
            Button { toggleOther(question) } label: {
                Image(systemName: symbol(multiSelect: question.multiSelect, on: isOn))
                    .foregroundStyle(isOn ? palette.attention : .secondary)
            }
            .buttonStyle(.plain)
            TextField(
                "Other…",
                text: Binding(
                    get: { otherText[question.question] ?? "" },
                    set: { newValue in
                        otherText[question.question] = newValue
                        if !newValue.isEmpty { activateOther(question) }
                    })
            )
            .textFieldStyle(.roundedBorder)
        }
    }

    private func symbol(multiSelect: Bool, on: Bool) -> String {
        if multiSelect { return on ? "checkmark.square.fill" : "square" }
        return on ? "largecircle.fill.circle" : "circle"
    }

    private func toggle(_ question: AskUserQuestion.Question, _ label: String) {
        var set = selected[question.question] ?? []
        if question.multiSelect {
            if set.contains(label) { set.remove(label) } else { set.insert(label) }
        } else {
            set = set.contains(label) ? [] : [label]
            otherActive[question.question] = false
        }
        selected[question.question] = set
    }

    private func toggleOther(_ question: AskUserQuestion.Question) {
        let next = !(otherActive[question.question] ?? false)
        otherActive[question.question] = next
        if next, !question.multiSelect { selected[question.question] = [] }
    }

    private func activateOther(_ question: AskUserQuestion.Question) {
        otherActive[question.question] = true
        if !question.multiSelect { selected[question.question] = [] }
    }

    /// Chosen labels for a question: picked options plus any non-empty "Other".
    private func answers(for question: AskUserQuestion.Question) -> [String] {
        var labels = Array(selected[question.question] ?? [])
        if otherActive[question.question] ?? false {
            let custom = (otherText[question.question] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
            if !custom.isEmpty { labels.append(custom) }
        }
        return labels
    }

    private var allAnswered: Bool {
        questions.allSatisfy { !answers(for: $0).isEmpty }
    }

    private func submit() {
        var selections: [String: [String]] = [:]
        for question in questions { selections[question.question] = answers(for: question) }
        let updatedInput = AskUserQuestion.updatedInput(
            original: request.input, selections: selections)
        respond(.allow(updatedInput: updatedInput))
    }

    private func respond(_ decision: Decision) {
        let id = request.id
        if let sessionID {
            Task { await store.respondToApproval(id, decision, inSession: sessionID) }
        } else {
            Task { await store.respondToOpenApproval(id, decision) }
        }
    }
}

private extension View {
    /// Constrains a view to the transcript's centered reading column: capped at
    /// `maxWidth`, padded by `inset`, then centered — so the composer, approval
    /// bars, and their dividers line up with the chat text and never run wider.
    ///
    /// `resizeWidth` is the live column width; keying an animation to it (with the same curve
    /// and timing the transcript uses) eases these elements' width changes in lockstep with the
    /// transcript's prose reflow during a horizontal window resize, so the composer never slides
    /// ahead of the text above it. `settling` suppresses that easing while the chat is opening,
    /// matching the transcript so a freshly opened chat snaps into place in one pass.
    func chatColumn(maxWidth: CGFloat, inset: CGFloat, resizeWidth: CGFloat, settling: Bool) -> some View {
        self
            .frame(maxWidth: maxWidth, alignment: .leading)
            .padding(.horizontal, inset)
            .frame(maxWidth: .infinity)
            .animation(settling ? nil : .easeOut(duration: 0.2), value: resizeWidth)
    }
}
