import SwiftUI
import AppKit
import NucleicCore

/// 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
    /// Panel layout + terminal command channel, used by the in-chat "Log in" button on an
    /// authentication error to open the built-in terminal and run `claude /login` there.
    @Environment(PanelLayoutStore.self) private var panels
    @Environment(TerminalCommandBus.self) private var terminalCommands
    @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?
    /// 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
    /// 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()
    /// 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
    /// Gates the transcript's visibility on open: false from the moment a chat opens until its
    /// transcript has loaded and its eager 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
    /// 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 eager 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
    @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

    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 = 900

    /// 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 }

    // 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)
        // 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 = store.openTranscript.reversed().lazy.compactMap { event -> Int? in
            switch event.kind {
            case .turnCompleted(let turn): return turn.usage?.contextInputTokens
            case .usage(let usage): return usage.contextInputTokens
            default: return nil
            }
        }.first
        guard let used, used > 0 else { return nil }
        return ContextWindowUsage(
            usedTokens: used, windowTokens: ModelCatalog.contextWindow(for: effectiveModel))
    }

    var body: some View {
        VStack(spacing: 0) {
            header
            Divider()
            transcript
            if let approval = store.openApprovals.first {
                Divider().chatColumn(maxWidth: contentMaxWidth, inset: transcriptInset)
                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)
            }
            Divider().chatColumn(maxWidth: contentMaxWidth, inset: transcriptInset)
            composer
        }
        // 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
            summaryStatusSession = id
            summaryExpanded = summaryShouldRestExpanded
        }
        .onChange(of: session?.status) { _, newStatus in
            guard summaryStatusSession == store.openSessionID else { return }
            if newStatus == .awaitingInput || newStatus == .running {
                withAnimation { summaryExpanded = 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()
            }
        }
        .toolbar {
            ToolbarItemGroup {
                if isBusy {
                    Button { Task { await store.interruptOpenSession() } } label: {
                        Label("Stop", systemImage: "stop.circle")
                    }
                    .help("Interrupt the running turn")
                }
                Button { Task { await store.refreshOpenStatus() } } label: {
                    Label("Refresh", systemImage: "arrow.clockwise")
                }
                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 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 open session's project is under Nucleic Control — the gate for Orchestra.
    private var isControlProject: Bool {
        session.flatMap { store.project($0.projectID) }?.isNucleicControlled == true
    }

    private var effortMenu: some View {
        Menu {
            ForEach(ModelCatalog.efforts(for: effectiveModel), id: \.self) { level in
                Button { Task { await store.setOpenSessionEffort(level) } } label: {
                    choiceLabel(ModelCatalog.effortDisplayName(level),
                                isSelected: level == effectiveEffort, isDefault: level == defaultEffort)
                }
                // Orchestra is a one-way latch: once it's on for a chat it can't be turned off, so
                // the ordinary levels are disabled (the host ignores the switch anyway — see
                // `SessionController.setEffort`). Keeps the menu honest rather than a silent no-op.
                .disabled(isOrchestra)
            }
            // 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). It's
            // a Nucleic Control capability, so it's only selectable for a Control project — shown
            // but disabled (with the reason) otherwise so the requirement is discoverable.
            Divider()
            Button { Task { await store.setOpenSessionEffort(ModelCatalog.orchestraEffort) } } label: {
                orchestraMenuItem(available: isControlProject)
            }
            .disabled(!isControlProject)
            .help(isControlProject
                ? (isOrchestra ? ModelCatalog.orchestraLockedHelp : ModelCatalog.orchestraBlurb)
                : ModelCatalog.orchestraRequiresControlHelp)
        } 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)
        }
    }

    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("Merge", 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? {
        var total = 0
        var sawAny = false
        for event in store.openTranscript {
            if case .turnCompleted(let turn) = event.kind, let usage = turn.usage {
                total += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
                sawAny = true
            }
        }
        return sawAny ? total : nil
    }

    /// 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? {
        let fromTranscript = store.openTranscript.last { event in
            if case .userText = event.kind { return true }
            return false
        }?.at
        return fromTranscript ?? 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? {
        store.openTranscript.reversed().lazy.compactMap { event -> Int? in
            if case .runFinished(let run) = event.kind { return run.durationMs }
            return nil
        }.first
    }

    /// 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)
    }

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

    /// The live, coalesced transcript: streaming deltas merged into single rows so
    /// thoughts, tool calls, and assistant text show as they're produced. Lock-lifecycle notes are
    /// folded onto the edit's tool card they bracket (LOCKING §4); `lockLines` carries them keyed by
    /// tool-call id for the rows to render, and a note that matched no card stays a standalone row
    /// (and is still gated by the "Show lock events" filter below).
    private func projectedTranscript() -> (items: [TranscriptItem], lockLines: [String: [NoteLock]]) {
        // The projection is an O(n) partition/fold/coalesce over the whole transcript, and
        // `body` re-evaluates far more often than the transcript actually changes — every
        // scroll moves a preference (viewport height, bottom edge), and hover/selection also
        // re-run it. Memoize on a token that captures exactly the inputs: the open session,
        // its monotonic transcript version (bumps on any content change, see AppStore), the
        // worktree root, and the two display filters. Identical token ⇒ identical projection.
        let token = ProjectionToken(
            session: store.openSessionID,
            version: store.openTranscriptVersion,
            worktree: store.openSession?.worktreePath,
            showDebug: showDebugLines,
            showLock: showLockEvents)
        if projectionCache.token == token { return projectionCache.value }
        var result = TranscriptProjection.items(
            store.openTranscript, worktreeRoot: store.openSession?.worktreePath)
        if !showDebugLines { result.items = result.items.filter { !$0.isDebug } }
        if !showLockEvents { result.items = result.items.filter { !$0.isLockEvent } }
        projectionCache.token = token
        projectionCache.value = result
        return result
    }

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

    /// 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.
    private final class ProjectionCache {
        var token: ProjectionToken?
        var value: (items: [TranscriptItem], lockLines: [String: [NoteLock]]) = ([], [:])
    }

    /// 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 {
        store.openTranscript.contains { event in
            switch event.kind {
            case .turnCompleted, .runFinished: return true
            default: return false
            }
        }
    }

    /// 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 open session's transcript has been loaded (it's cleared
    /// synchronously on open, then filled asynchronously by `reloadOpen`).
    private var transcriptLoaded: Bool { !store.openTranscript.isEmpty }

    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 content exists
        // means it first appears with the full, eagerly-laid-out transcript present, so the
        // bottom anchor initializes at the true end in one layout pass — no scrolling at all.
        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)
            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 {
                    // Brief placeholder while the (fast, local) reload fills the transcript.
                    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)
                    }
                }
            }
            // Hold the whole transcript (prose + summary card) invisible until its eager layout
            // has pinned to the bottom, then reveal it in one shot. The settle happens off-screen,
            // so the chat appears already at the tail — no visible scroll, no prose composited
            // mid-move (the thin/fuzzy look). The flip is set outside any animation, so it snaps
            // rather than fading (a fade would re-introduce the compositing blur it's avoiding).
            .opacity(transcriptRevealed ? 1 : 0)
            // Until the chat is revealed, suppress *every* implicit animation inside the
            // transcript — the tool-summary cross-fades (TranscriptRow) and the scroll-anchor
            // re-pin that fire as the layout settles on open. Animating any of those rasterizes
            // the affected text into an offscreen layer, where macOS drops font-smoothing — the
            // thin, fuzzy, shimmering look — until the animation lands. With them forced off, the
            // settle is instant and the text is drawn straight to the window, so it stays crisp.
            .transaction { txn in
                if !transcriptRevealed { txn.animation = nil }
            }
        }
        // A freshly opened chat must land at the bottom against its OWN geometry, not
        // the previous chat's last-measured position. Reset to the at-bottom default,
        // and hold the settling guard up while the new transcript performs its first
        // layout passes — long enough to cover the open, short enough that subsequent
        // live output animates normally.
        .task(id: store.openSessionID) {
            transcriptSettling = true
            transcriptRevealed = false
            transcriptContentHeight = 0
            isScrolledToBottom = true
            try? await Task.sleep(for: .milliseconds(300))
            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))
            transcriptRevealed = true
        }
    }

    /// How close (pts) the bottom of the content must be to the viewport bottom to
    /// still count as "scrolled to the bottom" — a little slack so sub-pixel rest
    /// positions and the in-flight growth of a streaming line keep following live output.
    private let bottomFollowThreshold: CGFloat = 24

    private func scrollableTranscript(reserveCardSpace: Bool, containerWidth: CGFloat) -> some View {
        let projection = projectedTranscript()
        return ScrollViewReader { proxy in
            ScrollView {
                // Eager VStack (not Lazy): the whole transcript is already in memory,
                // and eager layout gives the ScrollView the true total content height
                // up front. That's what lets `.defaultScrollAnchor(.bottom)` *initialize*
                // at the real bottom in a single pass — no scrolling. A LazyVStack only
                // realizes rows as they're scrolled into view, so the bottom anchor lands
                // on an estimated (partial) height and drifts as rows resolve, which is
                // what made opening land partway down or render blank.
                VStack(alignment: .leading, spacing: 6) {
                    // An invisible twin of the floating summary card: it occupies the
                    // card's exact height at the top so the first message is pushed
                    // down below the (pinned, right-aligned) visible card rather than
                    // being covered by it. Same binding ⇒ identical height. Skipped when
                    // the card is docked in the margin, where it overlaps no content.
                    if reserveCardSpace {
                        summaryCardView()
                            .hidden()
                            .frame(maxWidth: .infinity, alignment: .trailing)
                    }
                    ForEach(projection.items) { item in
                        // 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, where messages are read-only until unarchived.
                        TranscriptRow(
                            item: item,
                            onRevert: (isBusy || isArchived) ? nil : { revertSeq = $0 },
                            onLogin: startClaudeLogin)
                            .id(item.id)
                    }
                    if isBusy {
                        WorkingIndicator(text: progressText).id(Self.workingID)
                    }
                    // The bottom padding is a real trailing element AND the scroll
                    // target, so scrolling to the end reveals the full gap above the
                    // composer — not just the last message's bottom edge (which left
                    // this padding hidden just below the viewport on open).
                    Color.clear
                        .frame(height: transcriptBottomPadding)
                        .id(Self.bottomAnchorID)
                }
                .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)
                // Smooth the prose reflow during a horizontal window resize. Keying the
                // animation to the container width (rather than a blanket `.animation()`)
                // makes SwiftUI re-lay the wrapping text at each *interpolated* width
                // across the easing curve, so words glide across line breaks instead of
                // snapping frame-by-frame on a fast drag. Scoping it to `containerWidth`
                // leaves streaming text and newly appended messages instant — those don't
                // change the width, so this modifier never touches them. Suppressed
                // while the chat is opening: the first layout passes settle the width,
                // and easing those into place is the open-time reflow jitter — let them
                // snap, and animate only genuine resizes once the chat has landed.
                .animation(transcriptSettling ? nil : .easeOut(duration: 0.2), value: 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)
            // Track the live scroll position straight from the scroll view's geometry:
            // how much content still sits below the viewport bottom. Within the slack
            // threshold means the user is parked at the end, so live output keeps
            // following; scrolling up flips this false, which drops the anchor (above)
            // and reveals the jump-to-bottom chevron. (Replaces a coordinate-space /
            // preference measurement that never updated mid-scroll, so `isScrolledToBottom`
            // was stuck true — the chat always re-followed and the chevron never showed.)
            .onScrollGeometryChange(for: Bool.self) { geo in
                geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y
                    <= bottomFollowThreshold
            } action: { _, atBottom in
                // While the chat is opening, the eager layout grows over a few passes and
                // the scroll offset lags each growth by a frame — sampling that frame reads
                // "not at bottom" even though `.defaultScrollAnchor(.bottom)` is about to
                // re-pin. Honoring it drops the anchor to `nil` mid-re-pin and strands the
                // chat slightly above its true bottom (recovering only if the gap happens to
                // fall back under the slack threshold — the "appears higher, then scrolls
                // down, sometimes sticks" open glitch). So during the open window accept only
                // "at bottom" readings: stay pinned until the layout is stable. Once it
                // clears, honor real scroll-ups so history reading and the chevron work.
                if transcriptSettling {
                    if atBottom { isScrolledToBottom = true }
                } else {
                    isScrolledToBottom = atBottom
                }
            }
            // Reveal the freshly opened transcript only once its content height has come to
            // rest. On open the eager 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 }
            }
            .task(id: transcriptContentHeight) {
                guard !transcriptRevealed, transcriptContentHeight > 0 else { return }
                try? await Task.sleep(for: .milliseconds(80))
                if Task.isCancelled { return }
                proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
                transcriptRevealed = true
            }
            // Live output appended during an active session follows the bottom only when
            // the user is already parked there — scrolling up to read history is never
            // yanked back down. (Neither fires on open: this view is created with the
            // transcript already in place, so there's no 0→N change to animate.)
            // During the open-settle window these land the bottom instantly (animated:
            // false) so a chat that's still settling doesn't ease into place; once the
            // window clears, live output follows the bottom with the usual animation.
            .onChange(of: store.openTranscript.count) { _, _ in if isScrolledToBottom { scrollToEnd(proxy, animated: !transcriptSettling) } }
            .onChange(of: isBusy) { _, busy in if busy && isScrolledToBottom { scrollToEnd(proxy, animated: !transcriptSettling) } }
            // 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 { scrollToEnd(proxy, animated: !transcriptSettling) }
            }
            // An explicit jump — the down arrow or sending a message — always wins.
            .onChange(of: scrollToBottomRequest) { _, _ in 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 { scrollToEnd(proxy) }
                            .padding(.bottom, 12)
                            .transition(.move(edge: .bottom).combined(with: .opacity))
                    }
                }
                .animation(.easeInOut(duration: 0.15), value: isScrolledToBottom)
            }
        }
    }

    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
        // last message 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; every other gate is an approval decision.
        if let approval = store.openApprovals.first {
            return approval.toolName == AskUserQuestion.toolName
                ? "Waiting for answers…"
                : "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…"
        default:                                     return "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
    }

    /// Whether there's something to send: typed text, or staged attachments on their own (an
    /// attachment-only message is allowed — the references become the message body).
    private var canSend: Bool {
        !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty
    }

    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, monochrome: true)
            }
            .padding(.trailing, sendButtonWidth + 8)
            // A follow-up the user submitted while the agent is still working, held to send
            // the instant the current turn finishes. Shows the queued text with a cancel ✕.
            if let queued = session?.queuedMessage, !queued.isEmpty {
                queuedMessageBar(queued)
            }
            // 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)
            }
            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…" : "Message the agent…",
                    submitMode: submitMode, isEnabled: session != nil && !isArchived,
                    height: $composerHeight, onSend: 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.
                    // 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: isOrchestra && !isArchived, 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.
                    Button(action: unarchive) {
                        Text("Unarchive")
                            .font(.callout.weight(.medium))
                            .fixedSize()
                    }
                    .buttonStyle(.borderedProminent)
                    .help("Unarchive this chat to send messages and interact with it")
                } else if isBusy && !canSend {
                    Button { Task { await store.interruptOpenSession() } } label: {
                        Image(systemName: "stop.circle.fill").font(.title2)
                    }
                    .buttonStyle(.plain)
                    .frame(width: sendButtonWidth)
                    .help("Interrupt the running turn")
                } else {
                    Button(action: send) {
                        SubmitKeyIcon(mode: submitMode)
                    }
                    .buttonStyle(.plain)
                    .frame(width: sendButtonWidth)
                    .help(isBusy
                        ? "Queue this message — it sends when the agent finishes its turn"
                        : submitMode.detail)
                    .disabled(!canSend)
                }
            }
            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()
                modelMenu
                effortMenu
            }
            .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.)
            .task(id: session?.id) {
                if isControlProject, session?.auto == false {
                    await store.setOpenSessionAuto(true)
                }
            }
            .font(.callout)
            .padding(.trailing, sendButtonWidth + 8)
            // 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)
    }

    /// The "message queued" pill shown above the composer while a follow-up waits for the
    /// current turn to finish. Tapping ✕ cancels it and drops the text back in the composer.
    private func queuedMessageBar(_ text: String) -> some View {
        HStack(spacing: 6) {
            Image(systemName: "clock")
                .foregroundStyle(.secondary)
            Text(text)
                .lineLimit(1)
                .truncationMode(.tail)
                .foregroundStyle(.secondary)
            Spacer(minLength: 8)
            Button {
                Task {
                    if let restored = await store.cancelOpenQueuedMessage() {
                        // Refill the composer for editing, unless the user already typed more.
                        if draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { draft = restored }
                    }
                }
            } label: {
                Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
            }
            .buttonStyle(.plain)
            .help("Cancel the queued message")
        }
        .font(.callout)
        .padding(.horizontal, 10)
        .padding(.vertical, 6)
        .background(.quaternary.opacity(0.5), in: .capsule)
        .help("This message will send automatically when the agent finishes its current turn.")
        .padding(.trailing, sendButtonWidth + 8)
    }

    private func send() {
        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
            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.
    private func unarchive() {
        guard let id = session?.id else { return }
        Task { await store.setSessionArchived(id, 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") }
    }

    /// Open the built-in terminal and run `claude /login` in it, so the in-chat "Log in"
    /// button on an authentication error re-authenticates the host Claude Code login
    /// (which the next turn re-exports into the sandbox) without a separate terminal.
    private func startClaudeLogin() {
        panels.revealTerminal()
        terminalCommands.run("claude /login")
        Task { await store.noteToOpenSession("Started claude /login in the terminal", icon: "key.fill") }
    }

    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"
        guard panel.runModal() == .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)"
        }
    }
}

/// 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 message")
    }
}

/// 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.
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

    /// 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 }
        return command
    }

    /// 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 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
    }

    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.
                    Text(showsCard ? "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.
                    if hostExecCommand == nil, hostCommandConflict == nil {
                        Text("\(request.toolName) · \(request.risk.rawValue)")
                            .font(.subheadline).foregroundStyle(.secondary)
                    }
                }
                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, tint: palette.attention)
            } 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)
            }
            HStack(spacing: 8) {
                Spacer()
                if hostCommandConflict != nil {
                    // A conflict override is a one-off judgement call, not a trust grant: just
                    // "Cancel" (skip this command, let the other session finish) 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")) }
                    Button("Run anyway") { respond(.allow()) }
                        .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 {
                        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))
    }

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

/// 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]

    /// 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)
                    if let description, !description.isEmpty {
                        Text(description).font(.subheadline).foregroundStyle(.secondary)
                    }
                }
                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
        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.
    func chatColumn(maxWidth: CGFloat, inset: CGFloat) -> some View {
        self
            .frame(maxWidth: maxWidth, alignment: .leading)
            .padding(.horizontal, inset)
            .frame(maxWidth: .infinity)
    }
}
