import SwiftUI
import AppKit
import UniformTypeIdentifiers
import NucleicCore

/// Transcript display preferences.
enum TranscriptDisplay {
    /// When `false` (default), debug-level rows are hidden for a clean transcript;
    /// when `true`, advanced diagnostic detail is shown inline.
    static let showDebugKey = "nucleic.showDebugLines"
    /// When `true` (default), the merged tool-call lines are rephrased by the on-device
    /// model where eligible; when `false`, they stay the deterministic heuristic merge.
    /// (Consecutive calls coalesce into one block and merge by tool either way.)
    static let summarizeToolCallsKey = "nucleic.summarizeToolCalls"
    /// When `true` (default), file-lock lifecycle notes (waiting/acquired/released) show in
    /// the transcript; when `false`, they're hidden to keep the chat focused on the agent's work.
    static let showLockEventsKey = "nucleic.showLockEvents"

    /// Which tool-call families the AI rewrites when summarization is on (Bash only, or
    /// every non-Read family). Read is always a plain deterministic merge.
    static let toolSummaryScopeKey = "nucleic.toolSummaryScope"

    /// The persisted "summarize tool calls" preference, defaulting to `true` when
    /// unset (so the store mirrors `@AppStorage`'s default before Settings is opened).
    static var storedSummarizeToolCalls: Bool {
        UserDefaults.standard.object(forKey: summarizeToolCallsKey) as? Bool ?? true
    }

    /// The persisted tool-summary scope, defaulting to `.bashOnly` when unset (mirrors
    /// the `@AppStorage` default so the store agrees before Settings is opened). Bash-only
    /// is the safe default: the "all tool calls" scope drives the on-device model much
    /// harder and only suits strong chips (see `DeviceCapability`).
    static var storedToolSummaryScope: ToolSummaryScope {
        UserDefaults.standard.string(forKey: toolSummaryScopeKey)
            .flatMap(ToolSummaryScope.init(rawValue:)) ?? .bashOnly
    }
}

private struct TranscriptInSubagentKey: EnvironmentKey {
    static let defaultValue = false
}

private struct LockLinesKey: EnvironmentKey {
    static let defaultValue: [String: [NoteLock]] = [:]
}

extension EnvironmentValues {
    /// True while rendering rows *inside* a subagent's activity stream. Inner tool rows then
    /// show their literal "Read <path>" / "Bash <command>" lines rather than the AFM-rephrased
    /// summaries, which are only computed for the top-level conversation — so a nested Bash
    /// call reads straight away instead of stalling on a loading placeholder that never lands.
    var transcriptInSubagent: Bool {
        get { self[TranscriptInSubagentKey.self] }
        set { self[TranscriptInSubagentKey.self] = newValue }
    }

    /// File-lock lifecycle lines folded onto each tool card, keyed by tool-call id
    /// (`TranscriptProjection.items(_:worktreeRoot:)`). A card renders these as a 🔒 line so the
    /// lock reads as bracketing its edit rather than as a free-floating row after the call.
    var lockLinesByToolCall: [String: [NoteLock]] {
        get { self[LockLinesKey.self] }
        set { self[LockLinesKey.self] = newValue }
    }
}

/// The leading glyph and muted one-liner for a lock line folded onto a tool card.
enum LockLineStyle {
    static func icon(_ state: NoteLock.State) -> String {
        switch state {
        case .waiting: "hourglass"
        case .acquired: "lock.fill"
        case .released: "lock.open"
        }
    }

    static func text(_ lock: NoteLock) -> String {
        let paths = pathList(lock.paths)
        switch lock.state {
        case .waiting: return "Waiting for lock · \(paths)"
        case .acquired: return "Locked · \(paths)"
        case .released: return "Lock released · \(paths)"
        }
    }

    /// Up to two repo-relative paths (basename kept readable), then a "+N more" tail.
    private static func pathList(_ paths: [String]) -> String {
        guard !paths.isEmpty else { return "no files" }
        let shown = paths.prefix(2).joined(separator: ", ")
        let extra = paths.count - min(paths.count, 2)
        return extra > 0 ? "\(shown) +\(extra) more" : shown
    }
}

/// One folded lock line on a tool card: a small muted glyph + status, matching the card's
/// secondary detail styling. Hidden by the caller when the "Show lock events" setting is off.
private struct LockLineRow: View {
    let lock: NoteLock

    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: 6) {
            Image(systemName: LockLineStyle.icon(lock.state))
                .font(.caption2)
                .foregroundStyle(.tertiary)
                .frame(width: 15)
            Text(LockLineStyle.text(lock))
                .font(.caption)
                .foregroundStyle(.secondary)
                .lineLimit(1)
                .truncationMode(.middle)
            Spacer(minLength: 0)
        }
    }
}

/// Constant-height substitute used by the transcript renderer A/B. It deliberately reads no tool
/// input, result, or child collection; the enclosing `ForEach` retains the production row identity.
private struct TranscriptBenchmarkCardSkeleton: View {
    var body: some View {
        HStack(spacing: 8) {
            RoundedRectangle(cornerRadius: 3)
                .fill(Color.secondary.opacity(0.16))
                .frame(width: 15, height: 15)
            RoundedRectangle(cornerRadius: 3)
                .fill(Color.secondary.opacity(0.13))
                .frame(width: 180, height: 11)
            Spacer(minLength: 0)
        }
        .padding(.horizontal, 12)
        .frame(height: 52)
        .background(Color.secondary.opacity(0.04))
        .clipShape(RoundedRectangle(cornerRadius: 8))
        .accessibilityHidden(true)
        .allowsHitTesting(false)
    }
}

/// The tail line under a capped output body (see `TranscriptRow.cappedOutput`): says how
/// much of a huge result was elided from *rendering*, so the cut is honest rather than a
/// silent truncation. The full text remains on the transcript item (and in the session log).
struct TruncatedOutputNote: View {
    /// UTF-8 bytes of output not rendered.
    let elided: Int

    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: 5) {
            Image(systemName: "ellipsis.circle")
                .font(.caption2)
            Text("Long output — showing the first \(TranscriptRow.outputDisplayCap.formatted()) characters (\(Int64(elided).formatted(.byteCount(style: .file))) more not rendered)")
                .font(.caption2)
        }
        .foregroundStyle(.tertiary)
    }
}

/// One transcript item rendered. Collapses tool calls/results to a compact line;
/// assistant text reads as prose; thinking shows muted/italic (UX_MACOS "Session detail").
struct TranscriptRow: View, Equatable {
    @Environment(\.appPalette) private var palette
    @Environment(AppStore.self) private var store
    let item: TranscriptItem
    /// Fixed for the life of a benchmark process. Production launches resolve to `.production`.
    var benchmarkVariant = TranscriptRenderBenchmarkConfiguration.current.variant
    /// Invoked with a user message's canonical `seq` when its "revert to here" arrow is
    /// tapped. `nil` hides the arrow (e.g. while a turn is running). Only user bubbles use it.
    var onRevert: ((UInt64) -> Void)? = nil
    /// Invoked when the "Log in" button on an authentication error is tapped. `nil` hides
    /// the button (falling back to a plain error line). Only the auth-error row uses it.
    var onLogin: (() -> Void)? = nil
    /// Invoked when the "Open VM Monitor" button on a "VM opened" note is tapped. `nil` hides
    /// the button (falling back to a plain note line). Only the VM-opened note uses it.
    var onOpenVMMonitor: (() -> Void)? = nil
    /// Invoked when the user taps **Skip** on a still-running skippable exec call — makes the agent
    /// give up on the (apparently stuck) call. `nil` hides the button (e.g. archived or peer-owned
    /// sessions). Only a running host/VM exec tool row surfaces it.
    var onSkip: ((ToolCall) -> Void)? = nil
    /// Invoked with a stall alert's id and whether to kill (true) or dismiss (false) when the user
    /// acts on a "host command looks hung" alert row. `nil` hides the buttons (archived / peer-owned
    /// sessions). Only the stall-alert note surfaces it.
    var onResolveStall: ((String, Bool) -> Void)? = nil
    /// Invoked when the orchestration card's "view all subagents" button is tapped — reveals the
    /// Subagents panel, where a plan's whole fan-out is listed. `nil` hides the button (a surface
    /// with no panels around it, like the ephemeral chat window). Only the plan card uses it.
    var onOpenSubagents: (() -> Void)? = nil

    /// Row identity for SwiftUI's diff (via `.equatable()` at the transcript's ForEach).
    /// The closure properties make the default structural comparison always-false, so every
    /// streaming update re-rendered *every* row of the transcript. Their behavior depends
    /// only on presence (nil hides the control; the bodies are stable across renders), so
    /// compare that plus the item's content. Environment-driven updates (palette, the
    /// store's summary lines and lock lines) still invalidate a row independently.
    nonisolated static func == (lhs: TranscriptRow, rhs: TranscriptRow) -> Bool {
        // Equatable's `==` is nonisolated, but the compared properties are main-actor
        // isolated (the View is), so reading them here needs the isolation asserted.
        // SwiftUI runs `.equatable()` view diffing on the main actor, so this holds.
        MainActor.assumeIsolated {
            lhs.item == rhs.item
                && lhs.benchmarkVariant == rhs.benchmarkVariant
                && (lhs.onRevert == nil) == (rhs.onRevert == nil)
                && (lhs.onLogin == nil) == (rhs.onLogin == nil)
                && (lhs.onOpenVMMonitor == nil) == (rhs.onOpenVMMonitor == nil)
                && (lhs.onSkip == nil) == (rhs.onSkip == nil)
                && (lhs.onResolveStall == nil) == (rhs.onResolveStall == nil)
                && (lhs.onOpenSubagents == nil) == (rhs.onOpenSubagents == nil)
        }
    }

    var body: some View {
        switch benchmarkVariant {
        case .production:
            productionBody
        case .cardSkeletons:
            if isToolCard {
                TranscriptBenchmarkCardSkeleton().modifier(NonResponsePadding())
            } else {
                productionBody
            }
        case .genericCards:
            genericCardsBody
        case .textOnly:
            // `TranscriptProjectionWorker` filters non-text items after the shared projection but
            // before segmentation, so they leave neither empty native rows nor `VStack` spacing.
            productionBody
        }
    }

    @ViewBuilder
    private var productionBody: some View {
        switch item {
        case .assistant(_, let text, _):
            // Actual agent prose renders tight, with no extra padding — it's the
            // baseline the non-response rows are set apart from. Hovering reveals a
            // copy button that grabs the whole response verbatim.
            MarkdownText(markdown: text)
                .textSelection(.enabled)
                .copyable(text, help: "Copy response")
        case .thinking(_, let text, _):
            thinkingRow(text).modifier(NonResponsePadding())
        case .tool(_, let call, let result, let finished, let children):
            toolRow(call: call, result: result, finished: finished, children: children)
                .modifier(NonResponsePadding())
        case .toolGroup(_, let signature, let calls):
            // A run made entirely of subagent spawns is a multi-agent fan-out — render it
            // as the gold orchestration card (parallel subagents with live status) rather
            // than the generic tool block.
            if calls.allSatisfy({ DelegatedPlan.isPlanCall($0.call) }) {
                // Each plan call is already a whole fan-out, so they stack as their own cards
                // rather than becoming rows of one (a plan carries batches, not a single task).
                VStack(alignment: .leading, spacing: 6) {
                    ForEach(calls, id: \.call.toolCallID) { entry in
                        OrchestrationPlanCard(
                            call: entry.call, result: entry.result, finished: entry.finished,
                            onOpenSubagents: onOpenSubagents)
                    }
                }
                .modifier(NonResponsePadding())
            } else if calls.allSatisfy({ Self.isSubagentCall($0.call) }) {
                OrchestrationCard(calls: calls).modifier(NonResponsePadding())
            } else {
                // The block supplies the single rounded container + padding; its inner
                // rows render chrome-less so they read as one contiguous card. A
                // non-subagent group never carries children, so they're dropped here.
                ToolGroupRow(
                    signature: signature,
                    calls: calls.map { (call: $0.call, result: $0.result, finished: $0.finished) })
                    .modifier(NonResponsePadding())
            }
        case .thinkingTokens(_, let total, let delta, let elapsed):
            thinkingTokensRow(total: total, delta: delta, elapsed: elapsed)
                .modifier(NonResponsePadding())
        case .event(let event):
            rawEventBody(event).modifier(NonResponsePadding())
        }
    }

    @ViewBuilder
    private var genericCardsBody: some View {
        switch item {
        case .tool(_, let call, let result, let finished, _):
            ToolCallRow(
                call: call, result: result, finished: finished,
                worktreeRoot: store.openSession?.worktreePath)
                .modifier(NonResponsePadding())
        case .toolGroup(_, let signature, let calls):
            ToolGroupRow(
                signature: signature,
                calls: calls.map {
                    (call: $0.call, result: $0.result, finished: $0.finished)
                })
                .modifier(NonResponsePadding())
        default:
            productionBody
        }
    }

    private var isToolCard: Bool {
        switch item {
        case .tool, .toolGroup:
            return true
        case .event(let event):
            if case .fileChange = event.kind { return true }
            return false
        case .assistant, .thinking, .thinkingTokens:
            return false
        }
    }

    nonisolated static func isTextOnlyBenchmarkItem(_ item: TranscriptItem) -> Bool {
        switch item {
        case .assistant:
            return true
        case .event(let event):
            switch event.kind {
            case .userText:
                return true
            case .assistantText(let chunk):
                return !chunk.isPartial
            default:
                return false
            }
        case .thinking, .tool, .toolGroup, .thinkingTokens:
            return false
        }
    }

    // MARK: - Tool row

    /// A completed `AskUserQuestion` renders as a pretty Q&A card (the question plus the
    /// option the user picked) rather than the opaque "AskUserQuestion {…}" tool card;
    /// every other tool — and an as-yet-unanswered question — stays the normal collapsible
    /// `ToolCallRow`.
    @ViewBuilder
    private func toolRow(call: ToolCall, result: ToolResult?, finished: Bool,
                         children: [TranscriptItem]) -> some View {
        // A Skip closure bound to this specific call, offered only while it's a running, skippable
        // host/VM exec call (and the host provided an `onSkip` — nil for archived / peer-owned chats).
        let skipThisCall: (() -> Void)? =
            (!finished && MCPApprovalServer.isSkippable(toolName: call.name))
            ? onSkip.map { skip in { skip(call) } }
            : nil
        if let card = answeredQuestionCard(call: call, result: result) {
            card
        } else if let card = planCard(call: call) {
            card
        } else if let card = gitCommitCard(call: call, result: result) {
            card
        } else if let card = hostExecCard(call: call, result: result, finished: finished,
                                          onSkip: skipThisCall) {
            card
        } else if DelegatedPlan.isPlanCall(call) {
            // A delegation plan is a fan-out in a single call: it spawns a worker per task across
            // its batches, so it gets the same "Multiple Subagents" card a Task wave does — built
            // from the plan's dispatch ack plus each worker session's live state.
            OrchestrationPlanCard(
                call: call, result: result, finished: finished, onOpenSubagents: onOpenSubagents)
        } else if Self.isSubagentCall(call) {
            // A lone subagent spawn renders as its own gold subagent card (type, task, and
            // running/done/failed status), making delegated work read distinctly from the
            // agent's own tool calls. Its inner Read/Bash/Edit stream nests inside, on expand.
            SubagentCard(call: call, result: result, finished: finished, children: children)
        } else {
            ToolCallRow(call: call, result: result, finished: finished,
                        worktreeRoot: store.openSession?.worktreePath,
                        onSkip: skipThisCall)
        }
    }

    /// Whether `call` spawns a subagent — Claude Code's `Task` tool (and the `Agent` alias), or
    /// Nucleic's own `nucleic_subagent` Orchestra worker spawn. These all get the dedicated
    /// subagent / orchestration cards instead of a generic tool row. A `nucleic_subagent` worker
    /// runs as its own observed session (surfaced in the Subagents panel), so unlike a `Task` it
    /// carries no nested `children` here — its card shows the final output it returned instead.
    nonisolated static func isSubagentCall(_ call: ToolCall) -> Bool {
        call.name == "Task" || call.name == "Agent"
            || call.name == MCPApprovalServer.qualifiedOrchestraSubagentToolName
    }

    /// A git commit pipeline renders as the same structured card the approval shows — subject,
    /// body, and steps — rather than the raw "Bash <command>" row. The literal command stays
    /// reachable under the card's "Show command", and a trailing `git log` result (the new
    /// hash) shows beneath it. Nil for any non-commit Bash call, which keeps the normal row.
    private func gitCommitCard(call: ToolCall, result: ToolResult?) -> GitBlockCard? {
        guard call.name == "Bash",
              let command = call.input["command"]?.stringValue,
              let block = GitCommandSummary.block(for: command), block.isCommit
        else { return nil }
        let output = result.map { Self.resultText($0.content) }.flatMap { $0.isEmpty ? nil : $0 }
        return GitBlockCard(block: block, rawCommand: command, output: output)
    }

    /// A `mcp__nucleic__host_exec` call renders as a structured host-execution card — a
    /// host-machine glyph, the command pretty-printed with a `$` prompt, and its output revealed
    /// on tap — instead of the generic "mcp__nucleic__host_exec {"command": …}" JSON row the
    /// catch-all tool card would show. Nil for any other tool (which keeps the normal row), and
    /// for a host call with no command (a malformed input falls back to the generic row).
    private func hostExecCard(call: ToolCall, result: ToolResult?, finished: Bool,
                              onSkip: (() -> Void)?) -> HostExecToolCard? {
        guard call.name == MCPApprovalServer.qualifiedHostExecToolName,
              let command = call.input["command"]?.stringValue, !command.isEmpty
        else { return nil }
        // An interrupt-cancelled call shows the factual note, not the CLI's rejection boilerplate.
        let raw = result.map { Self.resultText($0.content) }.flatMap { $0.isEmpty ? nil : $0 }
        let canceled = raw.flatMap { Self.canceledResultNote($0) }
        let output = canceled ?? raw
        // Match the approval card: show what the command actually resolves to on the host — `$PWD`
        // and other substitutions expanded — rather than the literal `$PWD`. `PWD` resolves to the
        // exact directory the command ran in, which the backend records on the call as `cwd`; older
        // transcripts without it fall back to the session's working directory.
        let cwd = call.input["cwd"]?.stringValue ?? store.openSession?.worktreePath
        let resolved = HostCommandSummary.expand(
            command,
            environment: HostCommandSummary.hostDisplayEnvironment(workingDirectory: cwd))
        return HostExecToolCard(
            command: resolved, output: output, finished: finished,
            isError: result?.isError == true && canceled == nil,
            onSkip: onSkip,
            projectRoot: store.openSession.flatMap { store.project($0.projectID)?.rootPath },
            worktreeRoot: store.openSession?.worktreePath)
    }

    /// The pretty card for an `ExitPlanMode` call — the plan the agent presents on its way out
    /// of plan mode, rendered as formatted Markdown rather than the escaped-JSON blob the generic
    /// tool card would show. Nil for any other tool (which keeps the normal row) and for a call
    /// carrying no plan text. The plan is on the call input, so the card shows whether or not the
    /// user has approved it yet.
    private func planCard(call: ToolCall) -> ExitPlanModeCard? {
        guard call.name == ExitPlanMode.toolName,
              let plan = ExitPlanMode.plan(from: call.input)
        else { return nil }
        return ExitPlanModeCard(plan: plan)
    }

    /// The pretty card for a *completed* AskUserQuestion whose result records the user's
    /// choices, or nil when this isn't that (wrong tool, still pending, errored, or an
    /// unexpected result shape) — in which case the row falls back to a normal tool card.
    private func answeredQuestionCard(call: ToolCall, result: ToolResult?) -> AskUserQuestionResultCard? {
        guard call.name == AskUserQuestion.toolName,
              let result, !result.isError,
              let questions = AskUserQuestion.questions(from: call.input)
        else { return nil }
        let answers = AskUserQuestion.answers(in: Self.resultText(result.content), for: questions)
        guard !answers.isEmpty else { return nil }
        return AskUserQuestionResultCard(questions: questions, answers: answers)
    }

    // MARK: - Thinking

    @ViewBuilder
    private func thinkingRow(_ text: String) -> some View {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        if !trimmed.isEmpty {
            HStack(alignment: .top, spacing: 8) {
                Rectangle().fill(.quaternary).frame(width: 2).cornerRadius(1)
                HStack(alignment: .firstTextBaseline, spacing: 6) {
                    Image(systemName: "sparkles").font(.caption2).foregroundStyle(.tertiary)
                    Text(trimmed)
                        .font(.callout).italic().foregroundStyle(.secondary)
                        .lineSpacing(4)
                        .textSelection(.enabled)
                }
            }
            .padding(.vertical, 1)
        }
    }

    // MARK: - Thinking tokens (advanced detail)

    /// The live "thinking tokens" meter: the running token estimate of the model's current
    /// reasoning, the latest per-ping change, and the elapsed time — coalesced by the
    /// projection into one row that updates as it thinks. A debug-level row, so it shows only
    /// when "advanced detail" is on, alongside the other diagnostic tallies.
    private func thinkingTokensRow(total: Int, delta: Int, elapsed: TimeInterval) -> some View {
        label("brain", Self.thinkingTokensText(total: total, delta: delta, elapsed: elapsed), .secondary)
    }

    /// "Thinking · 1.4k tokens (+50) · 12.3s" — the cumulative estimate (compacted past 999
    /// into `n.nk`), the latest delta when it's a real increase, and the elapsed time.
    static func thinkingTokensText(total: Int, delta: Int, elapsed: TimeInterval) -> String {
        var text = "Thinking · \(TokenFormatting.short(total)) tokens"
        if delta > 0 { text += " (+\(TokenFormatting.short(delta)))" }
        return text + " · \(TokenFormatting.elapsed(elapsed))"
    }

    // MARK: - Tool call / result

    /// The Claude CLI closes a still-pending `tool_use` with one canonical boilerplate result when a
    /// call is cancelled out from under the model — chiefly when the user interrupts the turn while
    /// the call is in flight. Shown verbatim it reads as a permission denial that never happened
    /// ("The user doesn't want to proceed with this tool use…") — misleading for a call the user
    /// approved (or that was pre-allowed and never prompted) and then stopped. Map it to a short
    /// factual note; nil for every real result. (Genuine Nucleic denials carry Nucleic's own
    /// messages — "The user denied…", "The user cancelled the run." — and pass through untouched.)
    nonisolated static func canceledResultNote(_ text: String) -> String? {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard trimmed.hasPrefix("The user doesn't want to proceed with this tool use")
            || trimmed.hasPrefix("[Request interrupted by user")
        else { return nil }
        return "Interrupted — the run was stopped while this call was in flight."
    }

    private func toolResultRow(_ result: ToolResult) -> some View {
        let raw = Self.resultText(result.content)
        let canceled = Self.canceledResultNote(raw)
        let isError = result.isError && canceled == nil
        return HStack(alignment: .top, spacing: 8) {
            Image(systemName: canceled != nil
                ? "stop.circle"
                : isError ? "exclamationmark.triangle.fill" : "arrow.turn.down.right")
                .font(.caption2)
                .foregroundStyle(isError ? AnyShapeStyle(palette.danger) : AnyShapeStyle(.tertiary))
                .frame(width: 15)
            Text(canceled ?? raw)
                .font(.system(.caption, design: .monospaced))
                .foregroundStyle(isError ? AnyShapeStyle(palette.danger) : AnyShapeStyle(.secondary))
                .lineLimit(4)
                .textSelection(.enabled)
            Spacer(minLength: 0)
        }
        .padding(.leading, 6)
    }

    /// Renders a one-line tool summary as inline Markdown, so a model-written line can carry
    /// `code` spans — a command, file, or symbol set monospaced — the way assistant prose does.
    /// Only inline constructs apply on a single line (emphasis, links, `code`); a parse failure
    /// falls back to the plain string. Same `.inlineOnlyPreservingWhitespace` path `MarkdownText`
    /// uses, so a backtick span reads identically in a summary line and in chat.
    static func inlineMarkdown(_ string: String) -> AttributedString {
        let key = string as NSString
        if let hit = inlineMarkdownCache.object(forKey: key) { return hit.value }
        let options = AttributedString.MarkdownParsingOptions(
            interpretedSyntax: .inlineOnlyPreservingWhitespace)
        // Fall back to the plain string on parse failure — it renders identically.
        let parsed = (try? AttributedString(markdown: string, options: options)) ?? AttributedString(string)
        inlineMarkdownCache.setObject(InlineMarkdownBox(parsed), forKey: key)
        return parsed
    }

    /// Inline-Markdown cache for tool summary lines. `AttributedString(markdown:)` is a
    /// synchronous parse run once per summary line, and the same lines recur across
    /// re-renders and when a subagent card is (re)expanded — memoize so a large stream of
    /// tool rows doesn't re-parse every line. Independent of font (callers apply it), so the
    /// source string is a complete key. Mirrors `MarkdownText`'s inline cache; bounded, and
    /// `NSCache` also evicts under memory pressure.
    private final class InlineMarkdownBox { let value: AttributedString; init(_ v: AttributedString) { self.value = v } }
    private static let inlineMarkdownCache: NSCache<NSString, InlineMarkdownBox> = {
        let cache = NSCache<NSString, InlineMarkdownBox>()
        cache.countLimit = 16384
        return cache
    }()

    static func toolIcon(_ name: String) -> String {
        switch name {
        case "Bash": "terminal"
        case "Read": "doc.text"
        case "Edit", "MultiEdit", "Write", "NotebookEdit": "pencil"
        case "Grep", "Glob": "magnifyingglass"
        case "Task", "Agent", MCPApprovalServer.qualifiedOrchestraSubagentToolName: "person.2"
        case "WebFetch", "WebSearch": "globe"
        case "TodoWrite", "TaskCreate", "TaskUpdate": "checklist"
        case ExitPlanMode.toolName: "list.bullet.clipboard"
        // A host_exec run on the macOS host, outside the sandbox — the desktop glyph the
        // approval card and the in-chat `HostExecToolCard` use, so it reads alike everywhere.
        case MCPApprovalServer.qualifiedHostExecToolName: "desktopcomputer"
        // The VM/container tools each get their own glyph — a screen for computer-use, a box
        // for a container — so they don't all collapse into the generic wrench.
        default: SandboxToolDisplay.icon(for: name) ?? "wrench.and.screwdriver"
        }
    }

    /// The tool name as it should read in a card headline: the pretty label for Nucleic's own
    /// VM/container tools ("macOS VM" rather than `mcp__nucleic__mac_vm_exec`), and the tool
    /// name unchanged for everything else.
    static func toolLabel(_ name: String) -> String {
        SandboxToolDisplay.label(for: name) ?? name
    }

    /// The single most informative argument of a tool call, for the compact row. File
    /// paths render relative to `root` (the project working directory) when given.
    static func toolDetail(_ call: ToolCall, relativeTo root: String? = nil) -> String? {
        func arg(_ key: String) -> String? { call.input[key]?.stringValue }
        let detail: String? = switch call.name {
        case "Bash": arg("command")
        case "Read", "Edit", "MultiEdit", "Write": arg("file_path").map { HeuristicSummary.displayPath($0, relativeTo: root) }
        case "NotebookEdit": arg("notebook_path").map { HeuristicSummary.displayPath($0, relativeTo: root) }
        case "Grep", "Glob": arg("pattern")
        case "WebFetch": arg("url")
        case "WebSearch": arg("query")
        case "Task", "Agent": arg("description")
        // A `nucleic_subagent` worker's short label, so a per-call worker row in a mixed group
        // reads as its task rather than the raw `{"task": …, "prompt": …}` JSON fallback.
        case MCPApprovalServer.qualifiedOrchestraSubagentToolName: arg("task")
        // The host command itself, so a per-call host_exec row (e.g. inside an expanded group)
        // shows "$ command" rather than the raw `{"command": …}` JSON the fallback would print.
        case MCPApprovalServer.qualifiedHostExecToolName: arg("command")
        // The VM/container tools parse their own arguments — a shell line as `$ command`, a
        // computer-use step as "left click (120, 400)" — instead of the raw JSON fallback.
        default: SandboxToolDisplay.detail(for: call.name, input: call.input)
        }
        // Trim any worktree path mentioned anywhere in the detail (commands, patterns,
        // the JSON fallback), not just the dedicated file-path argument.
        if let detail { return HeuristicSummary.relativizePaths(detail, relativeTo: root) }
        let compacted = compact(call.input)
        return compacted.isEmpty ? nil : HeuristicSummary.relativizePaths(compacted, relativeTo: root)
    }

    /// Human-readable tool-result text: a plain string, the joined text of an MCP
    /// content array, or the canonical JSON as a last resort.
    nonisolated static func resultText(_ value: JSONValue) -> String {
        if let string = value.stringValue { return string }
        if let array = value.arrayValue {
            let texts = array.compactMap { $0["text"]?.stringValue }
            if !texts.isEmpty { return texts.joined(separator: "\n") }
        }
        let canonical = value.canonicalString()
        return canonical == "null" ? "" : canonical
    }

    /// Whether `resultText(value)` would be non-empty, WITHOUT materializing it. `resultText`
    /// allocates the full joined string (or runs a whole JSONEncoder pass for structured
    /// content) — a real cost for a multi-megabyte tool result. Collapsed rows only need
    /// existence (does the chevron show? is the row expandable?), and every row in an
    /// expanded group/subagent stream evaluates that on every render, so this must stay O(1)
    /// in the result's size. Answers exactly like `resultText(...).isEmpty` for any value
    /// decodable from the wire — the rows use it to promise output that `resultText` then
    /// delivers. (Sole divergence: a hand-built NaN/Infinity number, whose encode failure
    /// `canonicalString()` collapses to "null"; JSON can't carry one, and the expanded
    /// branches re-check `resultText` anyway, so a false promise degrades to an empty body.)
    nonisolated static func resultHasText(_ value: JSONValue) -> Bool {
        if let string = value.stringValue { return !string.isEmpty }
        if let array = value.arrayValue {
            let texts = array.compactMap { $0["text"]?.stringValue }
            // joined("\n") is empty only for a single empty text; ≥2 parts always
            // contain at least the separator. No texts falls through to canonical
            // JSON, which for an array ("[…]") is never "null" — so non-empty.
            if !texts.isEmpty { return texts.count > 1 || !texts[0].isEmpty }
        }
        // Everything else prints as canonical JSON: only `null` canonicalizes to "null"
        // (a bare string was already handled above), which `resultText` maps to "".
        return !value.isNull
    }

    /// The most characters of a tool result / subagent report a card lays out when revealed.
    /// A revealed `Text` is laid out synchronously on the main thread even when its surrounding
    /// history is virtualized; at multi-megabyte size that still freezes the visible segment.
    /// 100k characters keeps ordinary output intact (the full text remains on the item).
    static let outputDisplayCap = 100_000

    /// `text` bounded to `outputDisplayCap` characters for rendering, plus how many were
    /// elided (0 when it fit — the common case, which returns the original string untouched).
    /// The size probe uses `utf8.count` (O(1) on native strings) so an un-capped result
    /// costs nothing; only an actually-huge string pays the bounded prefix copy.
    static func cappedOutput(_ text: String) -> (text: String, elided: Int) {
        guard text.utf8.count > outputDisplayCap else { return (text, 0) }
        let shown = String(text.prefix(outputDisplayCap))
        return (shown, text.utf8.count - shown.utf8.count)
    }

    @ViewBuilder
    private func rawEventBody(_ event: AgentEvent) -> some View {
        switch event.kind {
        case .sessionStarted(let s):
            label("play.circle", "Session started · \(s.model)", .secondary)
        case .userText(let chunk):
            // Set the user's message apart from the surrounding turn with extra
            // breathing room above and below the bubble. (NonResponsePadding adds
            // the rest, since the user turn is also non-response text.)
            UserMessageBubble(
                text: chunk.text,
                routing: chunk.routing,
                worktreeRoot: store.openSession?.worktreePath,
                onRevert: onRevert.map { revert in { revert(event.seq) } })
                .padding(.vertical, 4)
        case .assistantText(let chunk) where !chunk.isPartial:
            // Only reached for replayed transcripts not run through the projection.
            MarkdownText(markdown: chunk.text).textSelection(.enabled)
        case .assistantText, .thinking, .toolCallStarted, .toolCallInputDelta, .toolCallCompleted:
            EmptyView()  // handled by the projection
        case .toolResult(let result):
            toolResultRow(result)
        case .fileChange(let change):
            // Codex reports its native writes as standalone `fileChange` events rather than
            // Claude-style Edit/Write tool lifecycles. Present every such write (add, update,
            // delete, or rename) through the same tool card as Claude and normalize its
            // user-facing verb to "Edit"; the canonical event keeps the precise mutation kind.
            ToolCallRow(
                call: ToolCall(
                    toolCallID: change.toolCallID ?? "file-change-\(event.seq)",
                    name: "Edit",
                    input: ["file_path": .string(change.path)]),
                result: nil,
                finished: true,
                worktreeRoot: store.openSession?.worktreePath)
        case .approvalRequested(let request):
            label("pause.circle", "Approval requested: \(request.title)", palette.attention)
        case .approvalResolved(let resolved):
            resolvedByLabel(resolved.decidedBy)
        case .usage(let usage):
            label("chart.bar", usageText(usage), .secondary)
        case .rateLimit(let rl):
            label("hourglass", "Rate limit: \(rl.rateLimitType ?? "?") \(rl.status ?? "")", .secondary)
        case .codexUsage(let usage):
            let peak = usage.peakUtilization.map { "\(Int($0.rounded()))%" } ?? "—"
            label("gauge.with.dots.needle.67percent", "Codex usage: \(peak)", .secondary)
        case .turnCompleted:
            label("circle.dotted", "Turn completed", .secondary)
        case .runFinished(let finished):
            // A run that dead-ends on authentication (a fatal "Not logged in" / expired OAuth)
            // arrives as an errored result carrying the reason in `finalText` — there is no separate
            // `.error` event to catch — so surface the same one-click "Log in" row here rather than a
            // bare "Run errored" line.
            if finished.outcome == .errored,
               let text = finished.finalText, Self.isAuthError(text) {
                AuthErrorRow(rawMessage: text, onLogin: onLogin)
            } else {
                switch finished.outcome {
                case .interrupted:
                    // A deliberate manual stop, not a failure: stop-sign octagon in amber.
                    label("octagon", "Run stopped", palette.attention)
                case .errored:
                    label("exclamationmark.triangle", "Run errored", palette.danger)
                case .completed:
                    label("flag.checkered", "Run completed", palette.success)
                case .maxTurns:
                    label("flag.checkered", "Run maxTurns", palette.success)
                }
            }
        case .error(let error):
            // An authentication failure (an expired/missing Claude Code login, or a bad
            // API key) reads as a friendly "log in" prompt with a one-click button rather
            // than the raw "claude exited… 401…" line.
            if Self.isAuthError(error.message) {
                AuthErrorRow(rawMessage: error.message, onLogin: onLogin)
            } else {
                label("exclamationmark.triangle", error.message, palette.danger)
            }
        case .note(let note) where note.vmMonitor:
            // A "VM opened" note reads as a one-click prompt to surface the monitor panel for this
            // chat, rather than a plain log line — the VM-side sibling of the auth-error "Log in" row.
            VMMonitorNoteRow(text: note.text, icon: note.icon ?? "display", onOpen: onOpenVMMonitor)
        case .note(let note) where note.processStall.map({ $0.resolution == nil }) ?? false:
            // A live "host command looks hung" alert: a distinct amber row with a Kill button (reaps
            // the whole process tree) and a Keep-waiting dismiss — unless it was already resolved (its
            // resolution note flipped `store.resolvedStalls`), in which case it reads as a plain line.
            let stall = note.processStall!
            ProcessStallRow(
                text: note.text,
                pidCount: stall.pidCount,
                resolved: store.resolvedStalls.contains(stall.id),
                onKill: onResolveStall.map { act in { act(stall.id, true) } },
                onDismiss: onResolveStall.map { act in { act(stall.id, false) } })
        case .note(let note):
            label(note.icon ?? "arrow.triangle.branch", note.text, .secondary)
        case .raw(let raw):
            label("questionmark.circle", "[\(event.nativeType ?? "?")] \(Self.compact(raw.native))", .secondary)
        }
    }

    private func label(_ icon: String, _ text: String, _ color: Color) -> some View {
        HStack(alignment: .firstTextBaseline, spacing: 6) {
            Image(systemName: icon).font(.caption).foregroundStyle(color).frame(width: 16)
            Text(text).font(.callout).foregroundStyle(color).lineLimit(3).textSelection(.enabled)
        }
    }

    /// "Resolved by <device> <fingerprint>", with the identity fingerprint in a dimmer
    /// monospaced run so the device name reads first and the key id stays legible but
    /// secondary. A bare sentinel responder ("always-rule", "system") renders name-only.
    private func resolvedByLabel(_ decidedBy: String) -> some View {
        let parts = ResponderLabel.split(decidedBy)
        var text = Text("Resolved by \(parts.name)").foregroundStyle(.secondary)
        if let fingerprint = parts.fingerprint {
            let fp = Text(fingerprint).font(.callout.monospaced()).foregroundStyle(.tertiary)
            text = Text("\(text)  \(fp)")
        }
        return HStack(alignment: .firstTextBaseline, spacing: 6) {
            Image(systemName: "arrow.right.circle").font(.caption)
                .foregroundStyle(Color.secondary).frame(width: 16)
            text.font(.callout).lineLimit(2).textSelection(.enabled)
        }
    }

    static func compact(_ value: JSONValue) -> String {
        let string = value.canonicalString()
        if string == "{}" || string == "null" { return "" }
        return String(string.prefix(160))
    }

    /// Whether an error message looks like an authentication failure — the family of 401s
    /// the user fixes by logging in again (an expired/missing Claude Code OAuth login, or a
    /// bad API key). Matched on the message text since it arrives synthesized from CLI
    /// stderr or an `api_retry` event, not as a structured status code.
    nonisolated static func isAuthError(_ message: String) -> Bool {
        let m = message.lowercased()
        return m.contains("401")
            || m.contains("authentication_error")
            || m.contains("invalid authentication")
            || m.contains("invalid x-api-key")
            || m.contains("invalid bearer token")
            || m.contains("oauth token has expired")
            || m.contains("oauth session expired")
            || m.contains("session expired")
            || m.contains("could not be refreshed")
            || m.contains("failed to authenticate")
            || m.contains("unauthorized")
            || m.contains("not logged in")
            || m.contains("please run /login")
            || m.contains("please run `/login`")
            || m.contains("run /login")
    }

    /// Whether an event, rendered on its own, is an auth-failure "log in" prompt row (either an
    /// `.error` whose message reads as an auth failure, or a run that dead-ended on one).
    nonisolated static func isAuthPromptEvent(_ kind: AgentEvent.Kind) -> Bool {
        switch kind {
        case .error(let error):
            return isAuthError(error.message)
        case .runFinished(let finished):
            return finished.outcome == .errored && (finished.finalText.map(isAuthError) ?? false)
        default:
            return false
        }
    }

    /// Whether a note marks a completed sign-in — the resolution signal that retires earlier auth
    /// artifacts. Covers both the local flow's "… sign-in complete …" note and the remote
    /// (phone-brokered) flow's "<device> signed in to <provider> on this host." audit line, so a
    /// sign-in driven from a paired phone clears the Mac's "Log in" row the same as a local one
    /// (`AppStore.remoteAgentLoginSucceeded` / `loginClaude` / `loginCodex`).
    nonisolated static func isAuthResolvedNote(_ text: String) -> Bool {
        text.range(of: "sign-in complete", options: .caseInsensitive) != nil
            || text.range(of: "signed in to", options: .caseInsensitive) != nil
    }

    /// Drop auth artifacts a later successful sign-in has made stale: the "Unauthenticated — log in
    /// to continue." row (with its Log in button) and the "Opening … sign-in in your browser…"
    /// progress note that precede the newest sign-in-completion note. The completion note itself and
    /// any *newer* auth failure (a sign-in that later lapsed again) are kept, so the affordance still
    /// appears when it's actually needed.
    nonisolated static func hidingResolvedAuthArtifacts(
        _ items: [TranscriptItem]
    ) -> [TranscriptItem] {
        var resolvedSeq: UInt64?
        for item in items {
            guard case .event(let event) = item, case .note(let note) = event.kind,
                  isAuthResolvedNote(note.text)
            else { continue }
            resolvedSeq = max(resolvedSeq ?? UInt64.min, event.seq)
        }
        guard let resolvedSeq else { return items }
        return items.filter { item in
            guard case .event(let event) = item, event.seq < resolvedSeq else { return true }
            if case .note(let note) = event.kind,
               note.text.range(of: "sign-in in your browser", options: .caseInsensitive) != nil {
                return false
            }
            return !isAuthPromptEvent(event.kind)
        }
    }

    private func usageText(_ usage: Usage) -> String {
        var parts: [String] = []
        if let i = usage.inputTokens { parts.append("in \(i)") }
        if let r = usage.cachedInputTokens, r > 0 { parts.append("cache-read \(r)") }
        if let w = usage.cacheCreationInputTokens, w > 0 { parts.append("cache-write \(w)") }
        if let o = usage.outputTokens { parts.append("out \(o)") }
        if let cost = usage.costUSD { parts.append(String(format: "$%.4f", cost)) }
        return parts.joined(separator: " · ")
    }
}

/// The in-chat rendering for an authentication failure (a 401 from an expired/missing
/// Claude Code login or a bad API key). Replaces the raw "claude exited… 401…" line with
/// a friendly prompt and a one-click "Log in" button that asks Nucleic's credential broker to
/// launch the official Claude browser flow, with no terminal or manual CLI command.
/// The underlying error text stays reachable under "Details" for anyone debugging.
private struct AuthErrorRow: View {
    @Environment(\.appPalette) private var palette
    let rawMessage: String
    let onLogin: (() -> Void)?
    @State private var showingDetails = false

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            HStack(alignment: .firstTextBaseline, spacing: 6) {
                Image(systemName: "lock.fill")
                    .font(.caption).foregroundStyle(palette.attention).frame(width: 16)
                Text("Unauthenticated — log in to continue.")
                    .font(.callout).foregroundStyle(palette.attention)
                    .textSelection(.enabled)
            }
            HStack(spacing: 12) {
                if let onLogin {
                    Button(action: onLogin) {
                        Label("Log in", systemImage: "key.fill")
                            .font(.caption.weight(.semibold))
                    }
                    .buttonStyle(.borderedProminent)
                    .controlSize(.small)
                    .tint(palette.accent)
                }
                Button {
                    // Un-animated: a height-changing disclosure inside the transcript
                    // (see ToolCallRow's tap handler for the full rationale).
                    showingDetails.toggle()
                } label: {
                    Text(showingDetails ? "Hide details" : "Details")
                        .font(.caption).foregroundStyle(.secondary)
                }
                .buttonStyle(.plain)
            }
            .padding(.leading, 22)
            if showingDetails {
                Text(rawMessage)
                    .font(.system(.caption, design: .monospaced))
                    .foregroundStyle(.secondary)
                    .lineLimit(4)
                    .textSelection(.enabled)
                    .padding(.leading, 22)
            }
        }
    }
}

/// The in-chat rendering for a "VM opened" note — the one-time line the backend drops when an agent
/// first boots a macOS or Linux guest for this session. Instead of a plain log row it shows the note
/// text with a one-click "Open VM Monitor" button that surfaces the monitor panel for the chat (so the
/// user can watch the guest's screen). The VM-side sibling of `AuthErrorRow`'s "Log in" button; the
/// button is absent (`onOpen == nil`) when no panel host is wired, degrading to a plain line.
private struct VMMonitorNoteRow: View {
    @Environment(\.appPalette) private var palette
    let text: String
    let icon: String
    let onOpen: (() -> Void)?

    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: 6) {
            Image(systemName: icon)
                .font(.caption).foregroundStyle(.secondary).frame(width: 16)
            Text(text)
                .font(.callout).foregroundStyle(.secondary)
                .lineLimit(2).textSelection(.enabled)
            if let onOpen {
                Button(action: onOpen) {
                    Label("Open VM Monitor", systemImage: "display")
                        .font(.caption.weight(.semibold))
                }
                .buttonStyle(.borderedProminent)
                .controlSize(.small)
                .tint(palette.accent)
            }
            Spacer(minLength: 0)
        }
    }
}

/// The in-chat rendering for a live "host command looks hung" alert — the harness raised it when a
/// running `host_exec` command went silent with near-zero CPU (see `ProcessStallMonitor`). It reads
/// as an amber warning row with **Kill** (tears down the command's whole process tree) and **Keep
/// waiting** (dismiss the alert; the command runs on). Once resolved — killed, dismissed, or the
/// command ended on its own — the buttons give way to a muted "resolved" note, and the follow-up
/// resolution line explains how it ended. Buttons are absent (`onKill == nil`) for archived or
/// peer-owned sessions, degrading to a plain warning line.
private struct ProcessStallRow: View {
    @Environment(\.appPalette) private var palette
    let text: String
    let pidCount: Int
    let resolved: Bool
    let onKill: (() -> Void)?
    let onDismiss: (() -> Void)?

    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: 6) {
            Image(systemName: "exclamationmark.triangle.fill")
                .font(.caption).foregroundStyle(palette.attention).frame(width: 16)
            Text(text)
                .font(.callout).foregroundStyle(.primary)
                .fixedSize(horizontal: false, vertical: true).textSelection(.enabled)
            if resolved {
                Text("resolved").font(.caption).foregroundStyle(.secondary)
            } else if let onKill, let onDismiss {
                Button(action: onKill) {
                    Label(
                        pidCount > 1 ? "Kill (\(pidCount) processes)" : "Kill",
                        systemImage: "xmark.octagon")
                        .font(.caption.weight(.semibold))
                }
                .buttonStyle(.borderedProminent)
                .controlSize(.small)
                .tint(palette.danger)
                Button("Keep waiting", action: onDismiss)
                    .buttonStyle(.bordered)
                    .controlSize(.small)
            }
            Spacer(minLength: 0)
        }
    }
}

/// A sent (user) message bubble, right-aligned, with a "revert to here" arrow tucked
/// beneath it that reveals on hover. Tapping the arrow asks to wipe this message and
/// everything after it and drop the text back into the composer to edit (see
/// `AppStore.revertOpenSession`). The arrow is absent (`onRevert == nil`) while a turn runs.
private struct UserMessageBubble: View {
    @Environment(\.appPalette) private var palette
    /// The user's own text, with any attachment reference block split back out (see
    /// `AttachmentMaterializer.split`) so it renders as previews rather than a raw path list.
    let text: String
    /// Immutable purpose/model provenance captured when this turn was sent. Older and manually
    /// routed messages have none and render exactly as they did before.
    let routing: PromptRoutingDecision?
    /// Attachment paths (relative to `worktreeRoot`) pulled out of the raw message, rendered
    /// as inline image previews / file cards beneath the bubble. Empty for an ordinary message.
    let attachmentPaths: [String]
    /// Session worktree the attachment paths resolve against, so the actual image can be shown.
    /// `nil` (or a since-cleaned file) degrades to a name/type card rather than a broken image.
    let worktreeRoot: String?
    let onRevert: (() -> Void)?
    @State private var hovering = false
    @State private var expanded = false

    init(
        text rawText: String, routing: PromptRoutingDecision?,
        worktreeRoot: String?, onRevert: (() -> Void)?
    ) {
        let split = AttachmentMaterializer.split(augmentedMessage: rawText)
        self.text = split.text
        self.routing = routing
        self.attachmentPaths = split.attachmentPaths
        self.worktreeRoot = worktreeRoot
        self.onRevert = onRevert
    }

    /// Collapsed bubbles show at most this many lines before a "Show more". A message
    /// only counts as "long" (and gets the toggle) once it would overflow this.
    private static let collapsedLineLimit = 12
    /// A cheap pre-check so most messages never pay for the measurement probe: only text
    /// past this many characters or newlines can *plausibly* exceed `collapsedLineLimit`
    /// lines. This is necessary but not sufficient — a 700-char message can still wrap to
    /// fewer than 12 lines, so the probe below confirms before showing the toggle.
    private var isLong: Bool {
        text.count > 700 || text.filter(\.isNewline).count >= Self.collapsedLineLimit
    }
    /// Past this size the message can never fit in `collapsedLineLimit` lines (the bubble
    /// wraps well under 300 characters per line), so no measurement is needed — and running
    /// one is what pinned the main thread: the hidden probe typesets the ENTIRE message
    /// unbounded through CoreText, and a pasted-log-sized message froze the UI for seconds
    /// per layout pass (hang-reports 2026-07-21, 100% of samples in `truncationProbe`).
    /// Such messages skip the probe, get the toggle unconditionally, and while collapsed
    /// render only a prefix — the visible text is clipped to 12 lines anyway, so typesetting
    /// megabytes behind the clip buys nothing.
    private static let certainlyLongCharacterCount = 4_000
    private var certainlyLong: Bool {
        text.utf8.count > Self.certainlyLongCharacterCount
            || text.filter(\.isNewline).count >= 100
    }
    /// What the bubble actually typesets: the full message, except while a certainly-long
    /// message is collapsed — then just a comfortably-over-the-clip prefix of it.
    private var displayText: String {
        guard certainlyLong, !expanded else { return text }
        return String(text.prefix(Self.certainlyLongCharacterCount))
    }
    /// Measured heights of the message clipped to `collapsedLineLimit` vs. unbounded. The
    /// limit only hides something — and the toggle is only meaningful — when the unbounded
    /// text is actually taller. Without this the button could appear yet toggle nothing.
    @State private var collapsedHeight: CGFloat = 0
    @State private var fullHeight: CGFloat = 0
    private var isTruncated: Bool { certainlyLong || fullHeight > collapsedHeight + 0.5 }

    var body: some View {
        VStack(alignment: .trailing, spacing: 3) {
            if !attachmentPaths.isEmpty {
                // Attachments read above the caption, mirroring how a screenshot + question is
                // usually posed. Each resolves against the worktree for a live image preview.
                VStack(alignment: .trailing, spacing: 6) {
                    ForEach(attachmentPaths, id: \.self) { path in
                        AttachmentPreview(relativePath: path, worktreeRoot: worktreeRoot)
                    }
                }
                .padding(.bottom, text.isEmpty ? 0 : 3)
            }
            // An attachment-only message (files dropped in with no prose) shows just the
            // previews above — no empty bubble.
            if !text.isEmpty {
                HStack {
                    Spacer(minLength: 40)
                    Text(displayText)
                        .lineSpacing(4)
                        .foregroundStyle(AppTheme.primaryText)
                        .textSelection(.enabled)
                        .lineLimit(isLong && !expanded ? Self.collapsedLineLimit : nil)
                        .padding(.horizontal, 10).padding(.vertical, 6)
                        .background(palette.accent.opacity(0.22), in: .rect(cornerRadius: 10))
                        .background { if isLong && !certainlyLong { truncationProbe } }
                        .frame(alignment: .trailing)
                }
            }
            if let routing {
                promptRoutingNote(routing)
            }
            if isTruncated {
                Button {
                    // Un-animated: a height-changing disclosure inside the transcript
                    // (see ToolCallRow's tap handler for the full rationale).
                    expanded.toggle()
                } label: {
                    Text(expanded ? "Show less" : "Show more")
                        .font(.caption.weight(.medium))
                        .foregroundStyle(palette.accent)
                }
                .buttonStyle(.plain)
                .padding(.trailing, 2)
            }
            if let onRevert {
                Button(action: onRevert) {
                    Image(systemName: "arrow.uturn.backward")
                        .font(.caption2.weight(.semibold))
                        .foregroundStyle(.secondary)
                        .padding(.trailing, 2)
                }
                .buttonStyle(.plain)
                .help("Revert to here: delete this and all later messages and put this message "
                    + "back in the composer to edit. The agent forgets the reverted turns; your files are left as-is.")
                .opacity(hovering ? 1 : 0)
            }
        }
        .onHover { hovering = $0 }
        .animation(.easeInOut(duration: 0.12), value: hovering)
    }

    /// A deliberately quiet audit line under the user's bubble. It stays expanded because the
    /// point is transparency, not another inspector users must discover; ordinary/manual turns
    /// omit the row entirely. The immutable decision came from the transcript event, so the model
    /// and explanation remain those that applied to this turn even if routing changes later.
    private func promptRoutingNote(_ decision: PromptRoutingDecision) -> some View {
        let purpose = PromptPurpose(rawValue: decision.purpose)?.displayName ?? decision.purpose
        let level = IntelligenceLevel(rawValue: decision.level)?.displayName
            ?? String(decision.level)
        let model = ModelCatalog.displayName(decision.model)
        let effort = ModelCatalog.effortDisplayName(decision.effort)
        return VStack(alignment: .trailing, spacing: 1) {
            HStack(alignment: .firstTextBaseline, spacing: 5) {
                Image(systemName: "arrow.triangle.branch")
                    .font(.caption2)
                Text("Purpose: \(purpose) · \(level) → \(model) · \(effort)")
                    .font(.caption.weight(.medium))
            }
            Text("Why: \(decision.reason)")
                .font(.caption2)
                .multilineTextAlignment(.trailing)
                .textSelection(.enabled)
        }
        .foregroundStyle(.secondary)
        .frame(maxWidth: 560, alignment: .trailing)
        .accessibilityElement(children: .combine)
        .accessibilityLabel(
            "Inferred purpose \(purpose). Chose \(model), \(effort), at \(level). \(decision.reason)")
    }

    /// Hidden copies of the message — one clipped to `collapsedLineLimit`, one unbounded —
    /// laid out as a background so they share the bubble's text width and wrap identically.
    /// Their measured heights tell us whether collapsing actually hides any lines.
    private var truncationProbe: some View {
        ZStack {
            probeText(limit: Self.collapsedLineLimit)
                .background(GeometryReader { g in
                    Color.clear.preference(key: CollapsedHeightKey.self, value: g.size.height)
                })
            probeText(limit: nil)
                .background(GeometryReader { g in
                    Color.clear.preference(key: FullHeightKey.self, value: g.size.height)
                })
        }
        .hidden()
        .onPreferenceChange(CollapsedHeightKey.self) { collapsedHeight = $0 }
        .onPreferenceChange(FullHeightKey.self) { fullHeight = $0 }
    }

    /// A measurement-only copy of the bubble text, matching the visible text's spacing and
    /// padding so its wrapped height matches what the user sees.
    ///
    /// `fixedSize(vertical:)` is load-bearing: the probe rides as a `.background` of the *already
    /// line-limited* visible text, so the background is proposed that clamped height. Without this,
    /// the unbounded (`limit == nil`) copy would be squeezed into the clamped height and truncate —
    /// reporting the *collapsed* height as the full height, leaving `isTruncated` permanently false
    /// and the "Show more" button hidden even while the text is clipped. Taking the ideal vertical
    /// size lets it measure the true full height; horizontal stays flexible so it still wraps at the
    /// bubble's width.
    private func probeText(limit: Int?) -> some View {
        Text(text)
            .lineSpacing(4)
            .lineLimit(limit)
            .padding(.horizontal, 10).padding(.vertical, 6)
            .frame(maxWidth: .infinity, alignment: .topLeading)
            .fixedSize(horizontal: false, vertical: true)
    }
}

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

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

/// One attachment shown beneath a user message: an inline thumbnail when it's an image whose
/// bytes are reachable in the session worktree, otherwise a file-type card (icon · name · type).
/// The card doubles as the fallback when an image can't be loaded — the file was cleaned up, or
/// we're viewing without the worktree — so a past message still records what was attached. When
/// the file is on disk the preview opens it in the default app on click.
private struct AttachmentPreview: View {
    /// Path relative to `worktreeRoot`, e.g. `.nucleic/attachments/<token>-shot.png`.
    let relativePath: String
    let worktreeRoot: String?

    /// Longest edge of an image preview; small images render at their natural size (never upscaled).
    private static let maxImageWidth: CGFloat = 260
    private static let maxImageHeight: CGFloat = 200

    @State private var image: NSImage?
    @State private var didAttemptLoad = false

    /// Original file name (materializer collision token stripped) for display.
    private var name: String { AttachmentMaterializer.displayName(forStoredPath: relativePath) }
    private var utType: UTType? { UTType(filenameExtension: (name as NSString).pathExtension) }
    private var isImage: Bool { utType?.conforms(to: .image) ?? false }
    /// Absolute file URL, when we know where the worktree is.
    private var fileURL: URL? {
        worktreeRoot.map { URL(fileURLWithPath: $0).appendingPathComponent(relativePath) }
    }
    private var fileExists: Bool {
        fileURL.map { FileManager.default.fileExists(atPath: $0.path) } ?? false
    }
    /// A friendly type label — "PNG image", "PDF document", … — falling back to the uppercased
    /// extension (or plain "File") when the system has no description for the type.
    private var typeLabel: String {
        if let description = utType?.localizedDescription, !description.isEmpty { return description }
        let ext = (name as NSString).pathExtension
        return ext.isEmpty ? "File" : "\(ext.uppercased()) file"
    }

    var body: some View {
        Group {
            if let image {
                imagePreview(image)
            } else {
                fileCard
            }
        }
        .modifier(OpenAttachmentOnTap(url: fileExists ? fileURL : nil, name: name))
        .onAppear(perform: loadImageIfNeeded)
    }

    /// Load the image once, lazily, when the row scrolls in — and only if it's actually an
    /// image whose bytes are on disk. Anything else stays on the card path.
    private func loadImageIfNeeded() {
        guard !didAttemptLoad else { return }
        didAttemptLoad = true
        guard isImage, fileExists, let fileURL else { return }
        image = NSImage(contentsOf: fileURL)
    }

    private func imagePreview(_ image: NSImage) -> some View {
        let size = displaySize(for: image)
        return VStack(alignment: .leading, spacing: 3) {
            Image(nsImage: image)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: size.width, height: size.height)
                .clipShape(RoundedRectangle(cornerRadius: 10))
                .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(AppTheme.hairline, lineWidth: 1))
            nameAndType.frame(maxWidth: size.width, alignment: .leading)
        }
    }

    private var fileCard: some View {
        HStack(spacing: 9) {
            Image(nsImage: fileIcon)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 30, height: 30)
            nameAndType
        }
        .padding(.horizontal, 11)
        .padding(.vertical, 8)
        .background(AppTheme.surface, in: .rect(cornerRadius: 10))
        .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(AppTheme.hairline, lineWidth: 1))
    }

    /// File name over a smaller, dimmer type line — the "name and type in subtext" pairing used
    /// by both the card and the image caption.
    private var nameAndType: some View {
        VStack(alignment: .leading, spacing: 1) {
            Text(name)
                .font(.caption.weight(.medium))
                .foregroundStyle(AppTheme.primaryText)
                .lineLimit(1)
                .truncationMode(.middle)
            Text(typeLabel)
                .font(.caption2)
                .foregroundStyle(.secondary)
                .lineLimit(1)
        }
        .frame(maxWidth: 190, alignment: .leading)
    }

    /// The icon for a non-image file: the real Finder icon when the file is on disk, else a
    /// generic icon derived from the file's type.
    private var fileIcon: NSImage {
        if let fileURL, fileExists { return NSWorkspace.shared.icon(forFile: fileURL.path) }
        return NSWorkspace.shared.icon(for: utType ?? .data)
    }

    /// Fit the image within the preview bounds, preserving aspect ratio and never upscaling past
    /// its natural size, so the frame hugs the picture instead of letterboxing it.
    private func displaySize(for image: NSImage) -> CGSize {
        let natural = image.size
        guard natural.width > 0, natural.height > 0 else {
            return CGSize(width: Self.maxImageWidth, height: Self.maxImageHeight)
        }
        let scale = min(Self.maxImageWidth / natural.width, Self.maxImageHeight / natural.height, 1)
        return CGSize(width: natural.width * scale, height: natural.height * scale)
    }
}

/// Makes an attachment preview open its file in the default app on click — with a link cursor
/// and an "Open …" tooltip — but only when the file is actually reachable; a stale reference
/// stays inert (and just shows its name) rather than offering a dead click.
private struct OpenAttachmentOnTap: ViewModifier {
    let url: URL?
    let name: String

    func body(content: Content) -> some View {
        if let url {
            content
                .contentShape(Rectangle())
                .onTapGesture { NSWorkspace.shared.open(url) }
                .pointerStyle(.link)
                .help("Open \(name)")
        } else {
            content.help(name)
        }
    }
}

/// A tool call rendered as a self-contained rounded "card". The header (icon,
/// tool name, and the most informative argument) is always visible; the tool's
/// output is tucked inside and revealed on tap. Collapsed by default to keep the
/// transcript uncluttered — the output is opt-in detail, not baseline reading.
/// The compact **Skip** control shown on a still-running host/VM exec card (``ToolCallRow`` and
/// ``HostExecToolCard``). Tapping it abandons the call — the agent gets a "skipped" result and stops
/// blocking on what looks like a stuck command (see `AppStore.skipOpenSessionToolCall`). It's a quiet
/// bordered pill in the attention tint, an escape hatch beside the running spinner rather than a
/// primary action, and it latches to "Skipping…" on tap so the intent registers immediately even
/// though the row keeps spinning until the abandoned result actually lands.
struct SkipToolCallButton: View {
    @Environment(\.appPalette) private var palette
    let action: () -> Void
    @State private var skipped = false

    var body: some View {
        Button {
            guard !skipped else { return }
            skipped = true
            action()
        } label: {
            Label(skipped ? "Skipping…" : "Skip", systemImage: "forward.end.alt.fill")
                .font(.caption2.weight(.semibold))
                .labelStyle(.titleAndIcon)
                .foregroundStyle(skipped ? AnyShapeStyle(.tertiary) : AnyShapeStyle(palette.attention))
                .padding(.horizontal, 7).padding(.vertical, 3)
                .background((skipped ? Color.secondary : palette.attention).opacity(0.14), in: Capsule())
        }
        .buttonStyle(.plain)
        .disabled(skipped)
        .help("Give up on this call — the agent stops waiting and moves on. Use if the process looks stuck.")
    }
}

private struct ToolCallRow: View {
    @Environment(\.appPalette) private var palette
    @Environment(AppStore.self) private var store
    @Environment(\.transcriptInSubagent) private var inSubagent
    @Environment(\.lockLinesByToolCall) private var lockLinesByToolCall
    @AppStorage(TranscriptDisplay.summarizeToolCallsKey) private var summarizeToolCalls = true
    @AppStorage(TranscriptDisplay.showLockEventsKey) private var showLockEvents = true
    @AppStorage(TranscriptDisplay.toolSummaryScopeKey)
    private var toolSummaryScopeRaw = ToolSummaryScope.bashOnly.rawValue
    let call: ToolCall
    let result: ToolResult?
    let finished: Bool
    /// The project working directory, so the header's file path renders relative to it.
    var worktreeRoot: String? = nil
    /// When true, the row renders flush (no card background/border) because an
    /// enclosing `ToolGroupRow` supplies the single contiguous container.
    var inGroup: Bool = false
    /// Invoked when the user taps **Skip** on this still-running call — abandons the (apparently
    /// stuck) call so the agent stops blocking on it. `nil` hides the button; set only for the
    /// skippable host/VM exec tools while they're in flight.
    var onSkip: (() -> Void)? = nil
    @State private var expanded = false

    private var scope: ToolSummaryScope { ToolSummaryScope(rawValue: toolSummaryScopeRaw) ?? .bashOnly }

    /// For a lone, AI-eligible call (Bash always; others only under the "all" scope, and
    /// then only with more than one call to merge — so in practice Bash) the model's rewrite
    /// once it lands, or a loading placeholder until then — exactly as a one-family group
    /// card shows. A lone call summarizes identically to a one-call group; it renders in its
    /// own card only for display. Nil for grouped per-call rows (they keep their literal
    /// detail), and when summarization is off or the family isn't eligible — those keep the
    /// existing "ToolName detail" / `searchSummary` look.
    private var aiSummaryLine: SummaryLine? {
        // Nested subagent rows keep their literal detail — their AFM lines are never computed
        // (only the top-level conversation is summarized), so a placeholder would never resolve.
        guard !inGroup, !inSubagent, summarizeToolCalls,
              let family = HeuristicSummary.families([call]).first,
              family.aiEligible(scope: scope) else { return nil }
        if family.isBash {
            // The block line is keyed by the block's stable id — for a lone call, its own id.
            return store.bashBlockLine(call.toolCallID).map(SummaryLine.ready) ?? .pending(verb: family.verb)
        }
        let signature = TranscriptProjection.toolGroupSignature([call])
        if let line = store.toolFamilyLines(signature)?.first { return .ready(line) }
        return .pending(verb: family.verb)
    }

    /// For a lone `Grep` rendered on its own (not inside a group card), the same
    /// "Searched for <pattern>" line the group headline uses — so a standalone search reads
    /// in plain language, with a regex alternation naturalized into "a, b, or c", rather
    /// than the raw "Grep <pattern>" tool label. Nil for other tools and for the raw per-call
    /// rows under an expanded group, which intentionally keep their literal detail.
    private var searchSummary: String? {
        guard !inGroup, call.name == "Grep" else { return nil }
        return HeuristicSummary.toolGroupLines([call], relativeTo: worktreeRoot)
            .first.map(HeuristicSummary.terminated)
    }

    /// Whether there's output worth revealing (a non-empty result, or any error) — the
    /// row's expandability, and the chevron's visibility. Deliberately does NOT build the
    /// output string: this runs for every row on every render (including the hundreds of
    /// collapsed rows a group expansion reveals), so it must stay O(1) in the result's
    /// size — `outputText` is only materialized once this row itself is expanded.
    private var hasOutput: Bool {
        guard let result else { return false }
        return result.isError || TranscriptRow.resultHasText(result.content)
    }

    /// The output text to reveal, or `nil` when there's nothing worth showing
    /// (no result yet, or an empty non-error result) — in which case the row
    /// isn't expandable and shows no disclosure chevron. Materializes the full
    /// result string; only call on the expanded path (`hasOutput` gates cheaply).
    private var outputText: String? {
        guard let result else { return nil }
        let text = TranscriptRow.resultText(result.content)
        if text.isEmpty && !result.isError { return nil }
        return text
    }

    /// The lock-lifecycle lines folded onto this call (LOCKING §4), newest last, or empty when the
    /// "Show lock events" setting is off — so the lock reads as part of the edit instead of as a
    /// separate row that sorts after the call.
    private var lockLines: [NoteLock] {
        showLockEvents ? (lockLinesByToolCall[call.toolCallID] ?? []) : []
    }

    var body: some View {
        let expandable = hasOutput
        let locks = lockLines
        let content = VStack(alignment: .leading, spacing: 0) {
            header(hasOutput: expandable)
                .contentShape(Rectangle())
                .onTapGesture {
                    guard expandable else { return }
                    // Deliberately NOT animated (here and in every transcript disclosure).
                    // Long histories are now segmented, but a height animation still changes the
                    // lazy document estimate once per frame and an expanded group can contain many
                    // eager children. That layout/commit storm stalled for seconds in the Jul 12
                    // spin report and was implicated in a permanent Jul 14 main-thread wedge.
                    // One un-animated pass is the cheap, safe shape.
                    expanded.toggle()
                }
            if !locks.isEmpty {
                VStack(alignment: .leading, spacing: 2) {
                    ForEach(Array(locks.enumerated()), id: \.offset) { _, lock in
                        LockLineRow(lock: lock)
                    }
                }
                .padding(.horizontal, 10)
                .padding(.bottom, 6)
            }
            // The full result string is built only here, for the one row actually opened.
            if expanded, let output = outputText {
                Divider().overlay(palette.accent.opacity(0.14))
                resultBody(output)
            }
        }
        if inGroup {
            content  // the block draws the card; rows stay flush so they read as one
        } else {
            content
                .background(palette.accent.opacity(0.06))
                .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(palette.accent.opacity(0.14), lineWidth: 1))
                .clipShape(RoundedRectangle(cornerRadius: 8))
        }
    }

    private func header(hasOutput: Bool) -> some View {
        HStack(spacing: 8) {
            Image(systemName: TranscriptRow.toolIcon(call.name))
                .font(.caption).foregroundStyle(palette.accent).frame(width: 15)
            if let aiSummaryLine {
                // An AI-eligible lone call (Bash) reads as its model rewrite once it lands,
                // matching the group card — with a loading placeholder until then so the
                // literal "Bash <command>" never flashes in for a line about to be rewritten.
                switch aiSummaryLine {
                case .ready(let text):
                    // A model rewrite may carry `code` spans (a command or symbol); render them
                    // as Markdown, matching the group card's summary lines.
                    Text(TranscriptRow.inlineMarkdown(text))
                        .font(.callout)
                        .foregroundStyle(AppTheme.primaryText)
                        .lineLimit(1).truncationMode(.middle)
                case .pending:
                    pendingPlaceholder
                }
            } else if let searchSummary {
                // A standalone search reads as the plain "Searched for …" line rather than
                // the "Grep <pattern>" tool label, matching the group card's phrasing.
                Text(searchSummary)
                    .font(.callout)
                    .foregroundStyle(AppTheme.primaryText)
                    .lineLimit(1).truncationMode(.middle)
            } else {
                Text(TranscriptRow.toolLabel(call.name))
                    .font(.callout.weight(.medium)).foregroundStyle(AppTheme.primaryText)
                if let detail = TranscriptRow.toolDetail(call, relativeTo: worktreeRoot) {
                    Text(detail)
                        .font(.system(.caption, design: .monospaced))
                        .foregroundStyle(.secondary)
                        .lineLimit(1).truncationMode(.middle)
                }
            }
            Spacer(minLength: 0)
            if !finished, let onSkip {
                SkipToolCallButton(action: onSkip)
            }
            if !finished {
                ProgressView().controlSize(.small).scaleEffect(0.7)
            }
            if hasOutput {
                Image(systemName: "chevron.right")
                    .font(.caption2.weight(.semibold))
                    .foregroundStyle(.tertiary)
                    .rotationEffect(.degrees(expanded ? 90 : 0))
            }
        }
        .padding(.horizontal, 10).padding(.vertical, 6)
        // Cross-fade the headline when the heuristic placeholder is replaced by the AI line.
        .animation(.easeInOut(duration: 0.2), value: aiSummaryLine)
    }

    /// The loading stand-in for a lone summarized line whose AI text is still being
    /// generated: a muted bar (where the prose will land) plus a small spinner — the same
    /// look the group card uses, so a lone Bash call reads identically while it summarizes.
    private var pendingPlaceholder: some View {
        HStack(spacing: 7) {
            // Anchor the bar to a hidden line of the prose it stands in for: an empty
            // `.callout` glyph gives the placeholder the exact line height, leading, and
            // baseline of the real text, so the transcript doesn't jump vertically (or
            // re-align against the baseline) when the AI line replaces it. The visible bar
            // stays 10pt tall, centered in that text-sized box.
            Text(verbatim: " ")
                .font(.callout)
                .frame(width: 150)
                .overlay(
                    RoundedRectangle(cornerRadius: 4)
                        .fill(.tertiary)
                        .frame(height: 10)
                        .opacity(0.45)
                )
            ProgressView().controlSize(.small).scaleEffect(0.6)
        }
    }

    @ViewBuilder
    private func resultBody(_ output: String) -> some View {
        // An interrupt-cancelled call's boilerplate result renders as a calm factual note, not as
        // the error-red "user doesn't want to proceed" scolding (see `canceledResultNote`).
        let canceled = TranscriptRow.canceledResultNote(output)
        let isError = result?.isError == true && canceled == nil
        // A huge output is laid out bounded (`outputDisplayCap`) — this visible segment lays the
        // Text synchronously, so an uncapped multi-MB result still freezes the app. The Markdown
        // sniff also runs on the capped text, not the whole string.
        let (shown, elided) = TranscriptRow.cappedOutput(canceled ?? output)
        HStack(alignment: .top, spacing: 8) {
            Image(systemName: canceled != nil
                ? "stop.circle"
                : isError ? "exclamationmark.triangle.fill" : "arrow.turn.down.right")
                .font(.caption2)
                .foregroundStyle(isError ? AnyShapeStyle(palette.danger) : AnyShapeStyle(.tertiary))
                .frame(width: 15)
            VStack(alignment: .leading, spacing: 6) {
                if !isError, shown.contains("```") {
                    // Tool output that comes back as Markdown (fenced code blocks, tables)
                    // renders formatted rather than as one flat monospaced blob.
                    MarkdownText(markdown: shown, bodySize: 12)
                } else {
                    Text(shown)
                        .font(.system(.caption, design: .monospaced))
                        .foregroundStyle(isError ? AnyShapeStyle(palette.danger) : AnyShapeStyle(.secondary))
                        .textSelection(.enabled)
                }
                if elided > 0 { TruncatedOutputNote(elided: elided) }
            }
            Spacer(minLength: 0)
        }
        .padding(.horizontal, 10).padding(.vertical, 6)
    }
}

/// Pretty rendering of a *completed* `AskUserQuestion` tool call. Once the user has
/// answered, the raw "AskUserQuestion {…}" card reads as opaque JSON; instead we show each
/// question with the option the user chose (and that option's description when the answer
/// matches a known label — free-text "Other" answers won't, so they show none). The answers
/// are recovered from the persisted tool result (`AskUserQuestion.answers(in:for:)`), so the
/// card survives transcript replay across app restarts. Mirrors `AskUserQuestionBar`'s look —
/// the question prompt set quiet, the chosen answer emphasized — so the answered card reads as
/// the settled form of the picker the user filled in.
private struct AskUserQuestionResultCard: View {
    @Environment(\.appPalette) private var palette
    let questions: [AskUserQuestion.Question]
    /// Chosen option labels keyed by question text (more than one for a multi-select).
    let answers: [String: [String]]

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack(spacing: 8) {
                Image(systemName: "questionmark.bubble")
                    .font(.caption).foregroundStyle(palette.accent).frame(width: 15)
                Text("You answered")
                    .font(.callout.weight(.medium)).foregroundStyle(AppTheme.primaryText)
                Spacer(minLength: 0)
            }
            ForEach(Array(questions.enumerated()), id: \.offset) { index, question in
                if index > 0 { Divider().overlay(palette.accent.opacity(0.12)) }
                questionRow(question)
            }
        }
        .padding(.horizontal, 12).padding(.vertical, 10)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(palette.accent.opacity(0.06))
        .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(palette.accent.opacity(0.14), lineWidth: 1))
        .clipShape(RoundedRectangle(cornerRadius: 8))
    }

    @ViewBuilder
    private func questionRow(_ question: AskUserQuestion.Question) -> some View {
        VStack(alignment: .leading, spacing: 5) {
            if let header = question.header, !header.isEmpty {
                Text(header.uppercased())
                    .font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
            }
            Text(question.question)
                .font(.callout).foregroundStyle(.secondary)
                .fixedSize(horizontal: false, vertical: true)
            if let chosen = answers[question.question], !chosen.isEmpty {
                ForEach(Array(chosen.enumerated()), id: \.offset) { _, label in
                    answerRow(label, description: description(of: label, in: question))
                }
            } else {
                // An answered card only shows for questions that parsed an answer; this is the
                // honest fallback if one question in a multi-question ask somehow didn't.
                Text("No answer recorded")
                    .font(.callout).italic().foregroundStyle(.tertiary)
            }
        }
    }

    private func answerRow(_ label: String, description: String?) -> some View {
        HStack(alignment: .top, spacing: 7) {
            Image(systemName: "checkmark.circle.fill")
                .font(.caption).foregroundStyle(palette.accent)
            VStack(alignment: .leading, spacing: 2) {
                Text(label)
                    .font(.callout.weight(.semibold)).foregroundStyle(AppTheme.primaryText)
                    .fixedSize(horizontal: false, vertical: true)
                    .textSelection(.enabled)
                if let description, !description.isEmpty {
                    Text(description)
                        .font(.caption).foregroundStyle(.secondary)
                        .fixedSize(horizontal: false, vertical: true)
                }
            }
            Spacer(minLength: 0)
        }
    }

    /// The chosen option's description, when the answer matches a known option label.
    private func description(of label: String, in question: AskUserQuestion.Question) -> String? {
        question.options.first { $0.label == label }?.description
    }
}

/// One line in a tool-call block's collapsed headline: a finished line — the deterministic
/// merge for a non-summarized family, or the model's rewrite once it lands — or a placeholder
/// shown while a summarized family's AI line is still being generated, so the heuristic never
/// flashes in for a call that's about to be rewritten.
private enum SummaryLine: Equatable {
    case ready(String)
    case pending(verb: String)
}

/// A run of consecutive tool calls rendered as ONE contiguous rounded card. Same-tool
/// calls are always folded into one line ("Read a, b from dir/"), whatever the AI toggle;
/// with "Summarize tool calls with AI" on, eligible lines are rephrased by the on-device
/// model once they land. Each line carries a glyph for its tool and sits on its own row
/// so the block reads cleanly. A long block shows the first five lines with a "See more";
/// tapping the block (or "See more") reveals every individual call, each with its output.
private struct ToolGroupRow: View {
    @Environment(\.appPalette) private var palette
    @Environment(AppStore.self) private var store
    @Environment(\.transcriptInSubagent) private var inSubagent
    @Environment(\.lockLinesByToolCall) private var lockLinesByToolCall
    @AppStorage(TranscriptDisplay.summarizeToolCallsKey) private var summarizeToolCalls = true
    @AppStorage(TranscriptDisplay.showLockEventsKey) private var showLockEvents = true
    @AppStorage(TranscriptDisplay.toolSummaryScopeKey)
    private var toolSummaryScopeRaw = ToolSummaryScope.bashOnly.rawValue
    let signature: Int
    let calls: [(call: ToolCall, result: ToolResult?, finished: Bool)]
    @State private var blockExpanded = false

    /// Expanded per-call rows reveal in bounded batches, exactly like
    /// `SubagentActivityStream.stepBatch`: this card's expanded children lay out eagerly, and
    /// `coalesceToolRuns` puts no upper bound on a run's length — a subagent's long tool
    /// loop arrives here as ONE group (bypassing the stream's per-child batching), and a
    /// single tap that laid out hundreds of rows at once stalled the main thread and froze
    /// the app. First `rowBatch` on expand, then `rowBatch` more per tap.
    private static let rowBatch = 40
    @State private var shownRows = Self.rowBatch

    /// Which families the AI rewrites — mirrors the store's scope so the view knows which
    /// lines are summarized (and thus shown as a loading placeholder until their AI line
    /// lands) versus a plain deterministic merge shown straight away.
    private var scope: ToolSummaryScope { ToolSummaryScope(rawValue: toolSummaryScopeRaw) ?? .bashOnly }

    /// A collapsed block shows at most this many summary lines before a "See more".
    private static let collapsedLineLimit = 5

    private var allFinished: Bool { calls.allSatisfy(\.finished) }
    private var worktreeRoot: String? { store.openSession?.worktreePath }

    /// The block's headline lines. With AI off, the deterministic heuristic merge of the
    /// whole run (read-then-edit folds included). With AI on, one entry per tool family in
    /// run order: a non-summarized family (Read, or anything outside the scope) shows its
    /// plain merge straight away, while a summarized family (Bash always; others under the
    /// "all" scope) shows its model line once it lands — and a loading placeholder until
    /// then, so the heuristic never flashes in for a call that's about to be rewritten.
    private var displayLines: [SummaryLine] {
        let rawCalls = calls.map(\.call)
        // Off, or nested inside a subagent (whose inner calls are never AFM-summarized): show
        // the deterministic heuristic merge straight away, with no pending placeholders.
        guard summarizeToolCalls, !inSubagent else {
            return HeuristicSummary.toolGroupLines(rawCalls, relativeTo: worktreeRoot).map { .ready($0) }
        }
        var out: [SummaryLine] = []
        for family in HeuristicSummary.families(rawCalls) {
            if !family.aiEligible(scope: scope) {
                out.append(.ready(HeuristicSummary.line(for: family, relativeTo: worktreeRoot)))
            } else if family.isBash {
                // Bash's live line is keyed by the block's stable id (its first call's id).
                if let first = family.calls.first, let line = store.bashBlockLine(first.toolCallID) {
                    out.append(.ready(line))
                } else {
                    out.append(.pending(verb: family.verb))
                }
            } else {
                let familySignature = TranscriptProjection.toolGroupSignature(family.calls)
                if let lines = store.toolFamilyLines(familySignature), !lines.isEmpty {
                    out.append(contentsOf: lines.map { SummaryLine.ready($0) })
                } else {
                    out.append(.pending(verb: family.verb))
                }
            }
        }
        return out
    }

    /// When the block *leads* with a sub-agent spawn (`Task`/`Agent`), the short
    /// `description` the parent gave it. Surfaced as an emphasized headline so a
    /// collapsed agent block says what the subagent was sent to do rather than a
    /// generic "Ran a subagent".
    private var agentPurpose: String? {
        guard let first = calls.first?.call, first.name == "Task" || first.name == "Agent"
        else { return nil }
        let purpose = (first.input["description"]?.stringValue ?? "")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return purpose.isEmpty ? nil : purpose
    }

    /// Summary lines beneath the headline. When an agent-purpose headline is shown it
    /// replaces the generic sub-agent line, so drop that from the body to avoid a
    /// redundant "Ran a subagent" right under the purpose.
    private var bodyLines: [SummaryLine] {
        guard agentPurpose != nil else { return displayLines }
        return displayLines.filter { line in
            if case .ready(let text) = line, text == HeuristicSummary.subagentVerb { return false }
            return true
        }
    }

    /// Lines actually shown: capped to the collapsed limit either way. Collapsed, the
    /// remainder is reached via "See more" (which expands the block); expanded, the
    /// per-call rows below carry the full detail — so a giant run's headline never lays
    /// out hundreds of summary lines on top of its (batched) call rows.
    private var displayedLines: [SummaryLine] {
        let lines = bodyLines
        guard lines.count > Self.collapsedLineLimit else { return lines }
        return Array(lines.prefix(Self.collapsedLineLimit))
    }

    private var hiddenLineCount: Int { max(0, bodyLines.count - Self.collapsedLineLimit) }

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            header
                .contentShape(Rectangle())
                .onTapGesture {
                    // Un-animated: see ToolCallRow's tap handler for the rationale.
                    blockExpanded.toggle()
                }
            if blockExpanded {
                let visible = min(shownRows, calls.count)
                ForEach(Array(calls.prefix(visible).enumerated()), id: \.element.call.toolCallID) { _, entry in
                    Divider().overlay(palette.accent.opacity(0.14))
                    ToolCallRow(call: entry.call, result: entry.result,
                                finished: entry.finished, worktreeRoot: worktreeRoot, inGroup: true)
                }
                if visible < calls.count {
                    Divider().overlay(palette.accent.opacity(0.14))
                    revealMoreRows(remaining: calls.count - visible)
                }
                // A footer collapse control so a long, scrolled-through block can be folded
                // back without hunting for the header at the top.
                Divider().overlay(palette.accent.opacity(0.14))
                collapseFooter
            }
        }
        .background(palette.accent.opacity(0.06))
        .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(palette.accent.opacity(0.14), lineWidth: 1))
        .clipShape(RoundedRectangle(cornerRadius: 8))
    }

    private var header: some View {
        VStack(alignment: .leading, spacing: 7) {
            // A block led by a sub-agent reads as "Agent: <purpose>", emphasized and a
            // touch larger than the activity lines, even collapsed.
            if let agentPurpose {
                HStack(alignment: .firstTextBaseline, spacing: 10) {
                    Image(systemName: TranscriptRow.toolIcon(calls[0].call.name))
                        .font(.body)
                        .foregroundStyle(palette.accent)
                        .frame(width: 18)
                    Text("Agent: \(agentPurpose)")
                        .font(.body.weight(.semibold))
                        .foregroundStyle(AppTheme.primaryText)
                        .lineLimit(2)
                        .truncationMode(.tail)
                        .fixedSize(horizontal: false, vertical: true)
                    Spacer(minLength: 6)
                    trailingControls
                }
            }
            ForEach(Array(displayedLines.enumerated()), id: \.offset) { index, line in
                summaryLineRow(line, index: index)
            }
            if !blockExpanded, hiddenLineCount > 0 { seeMore }
        }
        .padding(.horizontal, 12).padding(.vertical, 10)
        // Cross-fade the headline when the heuristic lines are replaced by the AFM summary.
        .animation(.easeInOut(duration: 0.2), value: displayedLines)
    }

    /// One headline row: a finished line (glyph + prose) or, for a summarized family whose
    /// AI line hasn't landed yet, a subtle loading placeholder (glyph + shimmer bar + spinner)
    /// so the heuristic never flashes in first. The count + chevron pin to the first row.
    @ViewBuilder
    private func summaryLineRow(_ line: SummaryLine, index: Int) -> some View {
        HStack(alignment: .firstTextBaseline, spacing: 9) {
            switch line {
            case .ready(let text):
                Image(systemName: Self.icon(for: text))
                    .font(.caption)
                    .foregroundStyle(palette.accent)
                    .frame(width: 16)
                Text(Self.styledLine(text))
                    .font(.callout)
                    .foregroundStyle(AppTheme.primaryText)
                    .lineLimit(2)
                    .truncationMode(.middle)
                    .fixedSize(horizontal: false, vertical: true)
            case .pending(let verb):
                Image(systemName: Self.icon(for: verb))
                    .font(.caption)
                    .foregroundStyle(palette.accent)
                    .frame(width: 16)
                pendingPlaceholder
            }
            Spacer(minLength: 6)
            // Pin the count + chevron to the headline: the purpose row carries them
            // when present, otherwise the first summary line does.
            if agentPurpose == nil, index == 0 { trailingControls }
        }
    }

    /// The loading stand-in for a summarized line whose AI text is still being generated:
    /// a muted bar (where the prose will land) plus a small spinner.
    private var pendingPlaceholder: some View {
        HStack(spacing: 7) {
            // Anchor the bar to a hidden line of the prose it stands in for: an empty
            // `.callout` glyph gives the placeholder the exact line height, leading, and
            // baseline of the real text, so the transcript doesn't jump vertically (or
            // re-align against the baseline) when the AI line replaces it. The visible bar
            // stays 10pt tall, centered in that text-sized box.
            Text(verbatim: " ")
                .font(.callout)
                .frame(width: 150)
                .overlay(
                    RoundedRectangle(cornerRadius: 4)
                        .fill(.tertiary)
                        .frame(height: 10)
                        .opacity(0.45)
                )
            ProgressView().controlSize(.small).scaleEffect(0.6)
        }
    }

    /// The lock state of any call in the block that is currently waiting/held (newest wins), so a
    /// collapsed block can flag at a glance that it holds a file lock. `nil` when nothing is locked,
    /// every lock has been released, or the "Show lock events" setting is off — expand to see which
    /// calls and paths (each per-call row carries its own lock line).
    private var blockLockState: NoteLock.State? {
        guard showLockEvents else { return nil }
        for entry in calls.reversed() {
            if let state = lockLinesByToolCall[entry.call.toolCallID]?.last?.state, state != .released {
                return state
            }
        }
        return nil
    }

    private var trailingControls: some View {
        HStack(spacing: 6) {
            // A held/waiting lock anywhere in the block shows as a small glyph, collapsed.
            if let lockState = blockLockState {
                Image(systemName: LockLineStyle.icon(lockState))
                    .font(.caption2)
                    .foregroundStyle(.tertiary)
            }
            // The call count makes the block's collapsed scope obvious at a glance.
            Text("\(calls.count)")
                .font(.caption2.weight(.semibold).monospacedDigit())
                .foregroundStyle(.secondary)
            if !allFinished {
                ProgressView().controlSize(.small).scaleEffect(0.7)
            }
            Image(systemName: "chevron.right")
                .font(.caption2.weight(.semibold))
                .foregroundStyle(.tertiary)
                .rotationEffect(.degrees(blockExpanded ? 90 : 0))
        }
    }

    private var collapseFooter: some View {
        Button {
            blockExpanded = false  // un-animated: see ToolCallRow's tap handler
        } label: {
            HStack(spacing: 6) {
                Image(systemName: "chevron.up")
                    .font(.caption2.weight(.semibold))
                Text("Collapse")
                    .font(.caption.weight(.medium))
                Spacer(minLength: 0)
            }
            .foregroundStyle(palette.accent)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .padding(.horizontal, 12).padding(.vertical, 4)
    }

    private var seeMore: some View {
        Button {
            blockExpanded = true  // un-animated: see ToolCallRow's tap handler
        } label: {
            Text("See \(hiddenLineCount) more…")
                .font(.caption.weight(.medium))
                .foregroundStyle(palette.accent)
        }
        .buttonStyle(.plain)
        .padding(.leading, 25)  // align with the text column, past the icon gutter
    }

    /// The "Show N more calls" reveal at the tail of a truncated expanded block — each tap
    /// lays out one more bounded batch (mirrors `SubagentActivityStream.revealMore`), keeping
    /// every call reachable without a single unbounded render.
    private func revealMoreRows(remaining: Int) -> some View {
        let next = min(Self.rowBatch, remaining)
        return Button {
            shownRows += Self.rowBatch  // un-animated: see ToolCallRow's tap handler
        } label: {
            HStack(spacing: 6) {
                Image(systemName: "chevron.down")
                Text("Show \(next) more call\(next == 1 ? "" : "s")")
                Text("· \(remaining) remaining").foregroundStyle(.secondary)
            }
            .font(.caption.weight(.medium))
            .foregroundStyle(palette.accent)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .padding(.horizontal, 12).padding(.vertical, 8)
        .frame(maxWidth: .infinity, alignment: .leading)
    }

    /// A merged headline as styled text. For a file-family line (Read/Edited/Wrote …) the
    /// path portion is set in the same monospaced, secondary style the per-call rows give
    /// "Read <path>", so the path stands apart from the verb at a glance. Lines without a
    /// file path — Ran/Searched/Fetched and any AI-rewritten prose — render plain.
    ///
    /// Only the filenames, paths, and directories themselves take the monospaced run; the
    /// connective punctuation the merge inserts between them — the commas, the "; " between
    /// directory groups, the " from " before a shared directory, and the terminating period
    /// — stays in the row's regular prose font so it doesn't read as part of a filename.
    private static func styledLine(_ line: String) -> AttributedString {
        guard let (verb, path) = HeuristicSummary.filePathBody(of: line) else {
            // Ran/Searched/Fetched and any model-rewritten prose: no path to set apart, but the
            // line may carry `code` spans (a command, flag, or symbol) — render them as Markdown.
            return TranscriptRow.inlineMarkdown(HeuristicSummary.terminated(line))
        }
        var result = AttributedString(verb + " ")
        result.append(styledPathBody(path))
        // The terminating period rides outside the monospaced run, as plain punctuation.
        if let last = path.last, !".!?…".contains(last) {
            result.append(AttributedString("."))
        }
        return result
    }

    /// Styles a merged file-path body so only the paths render monospaced/secondary while
    /// the connective tissue the merge inserts stays in the regular prose font: groups are
    /// joined by "; ", filenames within a group by ", ", a shared directory is introduced by
    /// " from ", and an overflow count trails as " +N more". See `mergeFilePaths`.
    private static func styledPathBody(_ body: String) -> AttributedString {
        // Peel off a trailing "+N more" overflow tag — it's a count, not a path.
        var core = body
        var overflow = ""
        if let range = core.range(of: #" \+\d+ more$"#, options: .regularExpression) {
            overflow = String(core[range])
            core = String(core[..<range.lowerBound])
        }
        var result = AttributedString()
        for (groupIndex, group) in core.components(separatedBy: "; ").enumerated() {
            if groupIndex > 0 { result.append(AttributedString("; ")) }
            for (itemIndex, item) in group.components(separatedBy: ", ").enumerated() {
                if itemIndex > 0 { result.append(AttributedString(", ")) }
                // The last filename of a multi-file group may carry a " from dir/" tail;
                // monospace the filename and the directory, but not the " from " between.
                if let from = item.range(of: " from ") {
                    result.append(monospacedPath(String(item[..<from.lowerBound])))
                    result.append(AttributedString(" from "))
                    result.append(monospacedPath(String(item[from.upperBound...])))
                } else {
                    result.append(monospacedPath(item))
                }
            }
        }
        if !overflow.isEmpty { result.append(AttributedString(overflow)) }
        return result
    }

    /// A path token in the monospaced, secondary style the per-call rows use for "Read <path>".
    private static func monospacedPath(_ path: String) -> AttributedString {
        var run = AttributedString(path)
        run.font = .system(.caption, design: .monospaced)
        run.foregroundColor = .secondary
        return run
    }

    /// A per-line glyph from the line's leading verb (Read / Edited / Ran / Searched …),
    /// so each merged line is identifiable at a glance. Works for heuristic and AI lines
    /// alike — the model is told to lead with the same verbs.
    private static func icon(for line: String) -> String {
        // A combined "Read, Edited …" / "Read, Wrote …" line is a modification at heart —
        // show the edit glyph rather than the leading Read's.
        if line.hasPrefix("Read, ") { return "pencil" }
        let verb = line.split(separator: " ").first.map { $0.lowercased() } ?? ""
        switch verb {
        case "read": return "doc.text"
        case "edited", "wrote", "created": return "pencil"
        case "ran": return "terminal"
        case "searched", "listed", "grepped", "found": return "magnifyingglass"
        case "fetched": return "globe"
        default: return "square.stack.3d.up"
        }
    }
}

/// Extra vertical breathing room around non-response rows (tool calls, thinking,
/// and pass-through events). It sets these apart from the agent's actual prose,
/// which renders tight, so real responses are visually obvious in the transcript.
private struct NonResponsePadding: ViewModifier {
    func body(content: Content) -> some View {
        content.padding(.vertical, 8)
    }
}

// MARK: - Subagent & multi-agent cards
//
// Claude Code spawns subagents through the `Task` tool. Nucleic surfaces that delegated
// work in the orchestra gold so it reads distinctly from the agent's own tool calls: a
// lone `Task` becomes a `SubagentCard`, and a parallel wave of them becomes an
// `OrchestrationCard`. Both build entirely from the existing tool-call/result events
// (`ToolCall.parentToolCallID` already links a subagent's inner calls to its parent), so
// no protocol or projection changes are needed.

/// The lifecycle state of a spawned subagent, read from its `Task` tool result: no result
/// yet → still working; an error result → failed; otherwise → done.
private enum SubagentRunState {
    case running, done, failed

    init(result: ToolResult?) {
        guard let result else { self = .running; return }
        // A `Task` subagent surfaces failure as an error result. A `nucleic_subagent` worker
        // instead returns a *non-error* result whose JSON envelope carries `{"ok": false}` (or
        // `{"denied": true}`) in-band, so read that too — otherwise a failed worker would read
        // as "Done".
        self = (result.isError || Subagent.workerFailed(result)) ? .failed : .done
    }

    var label: String {
        switch self {
        case .running: "Working"
        case .done: "Done"
        case .failed: "Failed"
        }
    }

    /// Status tint: done/failed reuse the shared palette so they match the rest of the app;
    /// a still-running subagent glows in the orchestra gold.
    func color(_ palette: AppPalette) -> Color {
        switch self {
        case .running: AppTheme.orchestra
        case .done: palette.success
        case .failed: palette.danger
        }
    }
}

/// Pulls the human-facing bits out of a `Task` tool call so the subagent cards agree on what
/// to show.
private enum Subagent {
    /// The subagent flavor (`subagent_type`: "Explore", "general-purpose", …), or nil.
    static func type(_ call: ToolCall) -> String? {
        let raw = call.input["subagent_type"]?.stringValue?
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return (raw?.isEmpty == false) ? raw : nil
    }

    /// The one-line task the parent handed the subagent: a `nucleic_subagent` worker's `task`
    /// label, or a `Task` subagent's `description`, falling back to the full `prompt`, then a
    /// generic label.
    static func taskLabel(_ call: ToolCall) -> String {
        if let task = call.input["task"]?.stringValue?
            .trimmingCharacters(in: .whitespacesAndNewlines), !task.isEmpty {
            return task
        }
        if let description = call.input["description"]?.stringValue?
            .trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty {
            return description
        }
        if let prompt = call.input["prompt"]?.stringValue?
            .trimmingCharacters(in: .whitespacesAndNewlines), !prompt.isEmpty {
            return prompt
        }
        return "Subagent task"
    }

    /// "Explore — find the composer", joining the flavor and task for a compact one-liner.
    static func headline(_ call: ToolCall) -> String {
        let label = taskLabel(call)
        if let type = type(call) { return "\(type) — \(label)" }
        return label
    }

    /// The subagent's returned report, or nil while it's still working / reported nothing.
    /// Materializes the full report string — gate on `hasReport` and only call this on the
    /// expanded path (the collapsed card just needs to know whether a report exists).
    static func report(_ result: ToolResult?) -> String? {
        guard let result else { return nil }
        let text = TranscriptRow.resultText(result.content)
        // A `nucleic_subagent` worker returns a JSON envelope, not prose — surface the worker's
        // actual `output` (or its failure `message`) rather than the raw `{ok,session_id,…}`
        // blob. A `Task` subagent returns prose, which stays untouched. Both keep the
        // `hasReport ⇔ report != nil` invariant: whenever the result has text, this is non-nil.
        if let worker = workerText(text), !worker.isEmpty { return worker }
        return text.isEmpty ? nil : text
    }

    /// Whether `report(result)` would be non-nil, without building the string — the
    /// collapsed cards evaluate expandability on every render (every agent row of a
    /// fan-out, on every streaming update), so it must stay O(1) in the report's size.
    static func hasReport(_ result: ToolResult?) -> Bool {
        guard let result else { return false }
        return TranscriptRow.resultHasText(result.content)
    }

    /// Whether a `nucleic_subagent` worker result reports failure — a `{"ok": false}` completion
    /// or a `{"denied": true}` refusal, both delivered as non-error tool results. False for any
    /// other tool (a `Task` subagent, whose failure is an error result handled separately).
    static func workerFailed(_ result: ToolResult) -> Bool {
        guard let envelope = workerEnvelope(TranscriptRow.resultText(result.content)) else {
            return false
        }
        if let denied = envelope["denied"] as? Bool, denied { return true }
        if let ok = envelope["ok"] as? Bool { return !ok }
        return false
    }

    /// The human-facing text of a `nucleic_subagent` worker envelope — the worker's `output` on
    /// success, or its `message` on failure/denial — or nil when `text` isn't such an envelope
    /// (so a `Task` subagent's prose report falls through unchanged).
    private static func workerText(_ text: String) -> String? {
        guard let envelope = workerEnvelope(text) else { return nil }
        if let denied = envelope["denied"] as? Bool, denied {
            return envelope["message"] as? String ?? ""
        }
        guard let ok = envelope["ok"] as? Bool else { return nil }
        return ok ? (envelope["output"] as? String ?? "")
                  : (envelope["message"] as? String ?? "")
    }

    /// Parses a `nucleic_subagent` result string into its JSON envelope, gated on the envelope's
    /// signature keys (`ok` / `denied`) so an arbitrary JSON-shaped tool result isn't mistaken
    /// for one. Nil for anything that isn't a worker envelope.
    private static func workerEnvelope(_ text: String) -> [String: Any]? {
        guard let data = text.data(using: .utf8),
              let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              object["ok"] != nil || object["denied"] != nil
        else { return nil }
        return object
    }
}

/// One worker dispatched by a `nucleic_delegate_plan` call, read out of the plan's result envelope.
///
/// A plan spawns its workers as their own Nucleic sessions and returns only their ids — unlike a
/// `Task` subagent, nothing about how a worker *fares* comes back through this tool call (results
/// reach the supervisor later, through `nucleic_supervise`). So the card resolves each worker's live
/// state from the store by session id; `sessionID` is what makes that lookup possible.
private struct PlanWorker: Identifiable {
    let sessionID: SessionID
    let task: String
    var id: String { sessionID.rawValue }
}

/// One batch of a dispatched plan: its name, what it was for, and the workers it spawned. `rejected`
/// carries tasks Nucleic could not spawn, so a partially-dispatched plan doesn't read as complete.
private struct PlanBatch: Identifiable {
    let name: String
    let purpose: String?
    let workers: [PlanWorker]
    let rejected: [String]
    var id: String { name }
}

/// Reads the `nucleic_delegate_plan` call + result into the batches the card renders.
private enum DelegatedPlan {
    static func isPlanCall(_ call: ToolCall) -> Bool {
        call.name == MCPApprovalServer.qualifiedOrchestraPlanToolName
    }

    /// The orchestrator's prose plan, from the call's input.
    static func planText(_ call: ToolCall) -> String? {
        let raw = call.input["plan"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
        return (raw?.isEmpty == false) ? raw : nil
    }

    /// Each batch's stated purpose, keyed by batch name — it lives in the *call* (what was asked for),
    /// while the workers live in the *result* (what was actually spawned).
    static func purposes(_ call: ToolCall) -> [String: String] {
        var out: [String: String] = [:]
        for batch in call.input["batches"]?.arrayValue ?? [] {
            guard let name = batch["name"]?.stringValue,
                let purpose = batch["purpose"]?.stringValue?
                    .trimmingCharacters(in: .whitespacesAndNewlines),
                !purpose.isEmpty
            else { continue }
            out[name] = purpose
        }
        return out
    }

    /// The dispatched batches, or nil while the call is still in flight / the result isn't a plan
    /// ack. Nil is also what a `{denied:true}` refusal yields — see ``denial(_:)``.
    static func batches(call: ToolCall, result: ToolResult?) -> [PlanBatch]? {
        guard let result, let envelope = envelope(result) else { return nil }
        guard let rawBatches = envelope["batches"] as? [[String: Any]] else { return nil }
        let purposes = purposes(call)
        let batches = rawBatches.compactMap { raw -> PlanBatch? in
            guard let name = raw["batch"] as? String else { return nil }
            let workers = (raw["workers"] as? [[String: Any]] ?? []).compactMap {
                worker -> PlanWorker? in
                guard let id = worker["worker_id"] as? String, !id.isEmpty else { return nil }
                let task = (worker["task"] as? String).flatMap { $0.isEmpty ? nil : $0 }
                return PlanWorker(
                    sessionID: SessionID(rawValue: id), task: task ?? "Delegated task")
            }
            let rejected = (raw["rejected"] as? [[String: Any]] ?? []).compactMap {
                $0["task"] as? String
            }
            guard !workers.isEmpty || !rejected.isEmpty else { return nil }
            return PlanBatch(
                name: name, purpose: purposes[name], workers: workers, rejected: rejected)
        }
        return batches.isEmpty ? nil : batches
    }

    /// The refusal message when the plan was denied outright (no worker spawned), else nil.
    static func denial(_ result: ToolResult?) -> String? {
        guard let result, let envelope = envelope(result) else { return nil }
        if let denied = envelope["denied"] as? Bool, denied {
            return envelope["message"] as? String ?? "The plan was not dispatched."
        }
        return nil
    }

    private static func envelope(_ result: ToolResult) -> [String: Any]? {
        let text = TranscriptRow.resultText(result.content)
        guard let data = text.data(using: .utf8),
            let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
            object["ok"] != nil || object["denied"] != nil
        else { return nil }
        return object
    }
}

/// A small leading status glyph for a subagent — a gold spinner while it works, a green
/// check or red cross once it settles.
private struct SubagentStatusGlyph: View {
    @Environment(\.appPalette) private var palette
    let state: SubagentRunState

    var body: some View {
        Group {
            switch state {
            case .running: ProgressView().controlSize(.mini).tint(AppTheme.orchestra)
            case .done: Image(systemName: "checkmark.circle.fill").foregroundStyle(palette.success)
            case .failed: Image(systemName: "xmark.octagon.fill").foregroundStyle(palette.danger)
            }
        }
        .font(.caption)
        .frame(width: 16)
    }
}

/// A status pill (spinner / check / cross + word) for the header of a subagent card.
private struct SubagentStatusChip: View {
    @Environment(\.appPalette) private var palette
    let state: SubagentRunState

    var body: some View {
        HStack(spacing: 4) {
            SubagentStatusGlyph(state: state).frame(width: 14)
            Text(state.label)
        }
        .font(.caption2.weight(.medium))
        .foregroundStyle(state.color(palette))
        .padding(.horizontal, 7).padding(.vertical, 3)
        .background(state.color(palette).opacity(0.14), in: Capsule())
    }
}

/// One delegated subagent (`Task`), as a gold card: its type and the task it was given, a
/// live Working / Done / Failed status, and — once it reports back — its findings, collapsed
/// by default. Mirrors the other transcript cards (GitBlockCard et al.) in the orchestra hue.
private struct SubagentCard: View {
    @Environment(\.appPalette) private var palette
    let call: ToolCall
    let result: ToolResult?
    let finished: Bool
    /// The subagent's own activity — its inner Read/Bash/Edit/thinking/prose — nested here so
    /// the card can show what it actually did, on expand.
    let children: [TranscriptItem]
    @State private var expanded = false

    private let accent = AppTheme.orchestra
    private var state: SubagentRunState { SubagentRunState(result: result) }
    private var report: String? { Subagent.report(result) }
    /// Expandable once there's something to reveal: a captured activity stream, or (for a
    /// replayed/legacy transcript with none) the subagent's returned report. Uses the O(1)
    /// `hasReport` probe — this runs on every render, and `report` builds the whole string.
    private var hasDetail: Bool { !children.isEmpty || Subagent.hasReport(result) }

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            header
                .contentShape(Rectangle())
                .onTapGesture {
                    guard hasDetail else { return }
                    expanded.toggle()  // un-animated: see ToolCallRow's tap handler
                }
            if expanded, hasDetail {
                Divider().overlay(accent.opacity(0.2))
                SubagentActivityStream(children: children, fallbackReport: report)
            }
        }
        .background(accent.opacity(0.08))
        .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.22), lineWidth: 1))
        .clipShape(RoundedRectangle(cornerRadius: 8))
    }

    private var header: some View {
        VStack(alignment: .leading, spacing: 6) {
            HStack(alignment: .firstTextBaseline, spacing: 10) {
                Image(systemName: "person.2.fill")
                    .font(.body)
                    .foregroundStyle(accent)
                    .frame(width: 18)
                (Text("Subagent")
                    + (Subagent.type(call).map { Text(" · \($0)").foregroundColor(.secondary) } ?? Text("")))
                    .font(.body.weight(.semibold))
                    .foregroundStyle(AppTheme.primaryText)
                Spacer(minLength: 6)
                SubagentStatusChip(state: state)
                if hasDetail {
                    Image(systemName: expanded ? "chevron.up" : "chevron.down")
                        .font(.caption2).foregroundStyle(.secondary)
                }
            }
            Text(Subagent.taskLabel(call))
                .font(.callout)
                .foregroundStyle(AppTheme.primaryText)
                .lineLimit(expanded ? nil : 2)
                .truncationMode(.tail)
                .fixedSize(horizontal: false, vertical: true)
        }
        .padding(.horizontal, 12).padding(.vertical, 10)
    }
}

/// A subagent's nested activity — the calls, thinking, and prose it produced — rendered with
/// the same row views the main transcript uses, but flagged `transcriptInSubagent` so inner
/// tool lines stay literal (the AFM summaries are only computed for the top-level conversation,
/// so a nested Bash line shows its command straight away rather than a spinner that never
/// resolves). Nested subagents recurse into their own cards.
private struct SubagentActivityStream: View {
    let children: [TranscriptItem]
    /// Shown when there's no captured inner activity (a replayed/legacy transcript, or an agent
    /// that hasn't streamed anything yet) but the subagent returned a final report.
    var fallbackReport: String? = nil

    /// Nested rows inside this one card lay out eagerly even though long top-level history is
    /// virtualized, so revealing hundreds of steps at once stalls the main thread. Reveal them
    /// in bounded batches instead: the first `stepBatch` when the card expands, then `stepBatch`
    /// more per tap — so no single interaction ever lays out an unbounded tree.
    private static let stepBatch = 40
    @State private var shown = stepBatch

    var body: some View {
        if !children.isEmpty {
            let visible = min(shown, children.count)
            VStack(alignment: .leading, spacing: 2) {
                ForEach(children.prefix(visible)) { child in
                    TranscriptRow(item: child)
                }
                if visible < children.count {
                    revealMore(remaining: children.count - visible)
                }
            }
            .environment(\.transcriptInSubagent, true)
            .padding(.horizontal, 12).padding(.vertical, 6)
            .frame(maxWidth: .infinity, alignment: .leading)
        } else if let fallbackReport {
            // Bounded like a tool result body: a subagent can return a huge report, and an
            // uncapped Text would stall its visible segment when revealed.
            let (shown, elided) = TranscriptRow.cappedOutput(fallbackReport)
            VStack(alignment: .leading, spacing: 6) {
                Text(shown)
                    .font(.callout)
                    .foregroundStyle(AppTheme.primaryText)
                    .textSelection(.enabled)
                if elided > 0 { TruncatedOutputNote(elided: elided) }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(.horizontal, 12).padding(.vertical, 10)
        }
    }

    /// The "Show N more steps" reveal at the tail of a truncated stream — each tap lays out one
    /// more bounded batch, keeping every step reachable without a single unbounded render.
    private func revealMore(remaining: Int) -> some View {
        let next = min(Self.stepBatch, remaining)
        return Button {
            shown += Self.stepBatch  // un-animated: see ToolCallRow's tap handler
        } label: {
            HStack(spacing: 6) {
                Image(systemName: "chevron.down")
                Text("Show \(next) more step\(next == 1 ? "" : "s")")
                Text("· \(remaining) remaining").foregroundStyle(.secondary)
            }
            .font(.caption.weight(.medium))
            .foregroundStyle(AppTheme.orchestra)
        }
        .buttonStyle(.plain)
        .padding(.top, 4)
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

/// A multi-agent fan-out: a wave of two or more subagents (`Task` calls) spawned together,
/// the visual centerpiece of orchestra's orchestration. The header sums up the wave (count +
/// overall status); each subagent is its own row — a one-line status that expands
/// independently to reveal that agent's live activity stream and report. Gold throughout to
/// mark it as orchestration work.
private struct OrchestrationCard: View {
    @Environment(\.appPalette) private var palette
    let calls: [ToolEntry]

    private let accent = AppTheme.orchestra

    /// Overall wave status: failed if any subagent failed, else running if any is still
    /// working, else done.
    private var overall: SubagentRunState {
        let states = calls.map { SubagentRunState(result: $0.result) }
        if states.contains(where: { if case .failed = $0 { true } else { false } }) { return .failed }
        if states.contains(where: { if case .running = $0 { true } else { false } }) { return .running }
        return .done
    }

    private var doneCount: Int { calls.filter { $0.result != nil }.count }

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            header
            ForEach(Array(calls.enumerated()), id: \.element.call.toolCallID) { _, entry in
                Divider().overlay(accent.opacity(0.18))
                OrchestrationAgentRow(entry: entry)
            }
        }
        .background(accent.opacity(0.08))
        .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.22), lineWidth: 1))
        .clipShape(RoundedRectangle(cornerRadius: 8))
    }

    private var header: some View {
        HStack(alignment: .firstTextBaseline, spacing: 10) {
            Image(systemName: "rectangle.3.group.fill")
                .font(.body)
                .foregroundStyle(accent)
                .frame(width: 18)
            VStack(alignment: .leading, spacing: 1) {
                Text("Multiple Subagents")
                    .font(.body.weight(.semibold))
                    .foregroundStyle(AppTheme.primaryText)
                Text("\(calls.count) subagents · \(doneCount) done")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            Spacer(minLength: 6)
            SubagentStatusChip(state: overall)
        }
        .padding(.horizontal, 12).padding(.vertical, 10)
    }
}

/// One subagent within a fan-out: a one-line status header (glyph + "Type — task") that
/// expands on tap to reveal that agent's own activity stream — the Read/Bash/Edit/thinking it
/// produced — and, failing that, its returned report. Each row tracks its own expansion so
/// agents open independently.
private struct OrchestrationAgentRow: View {
    let entry: ToolEntry
    @State private var expanded = false

    private let accent = AppTheme.orchestra
    private var state: SubagentRunState { SubagentRunState(result: entry.result) }
    private var report: String? { Subagent.report(entry.result) }
    /// O(1) probe (see `SubagentCard.hasDetail`): every agent row of a fan-out evaluates
    /// this on every streaming update, so it must not materialize the report string.
    private var hasDetail: Bool { !entry.children.isEmpty || Subagent.hasReport(entry.result) }

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            header
                .contentShape(Rectangle())
                .onTapGesture {
                    guard hasDetail else { return }
                    expanded.toggle()  // un-animated: see ToolCallRow's tap handler
                }
            if expanded, hasDetail {
                SubagentActivityStream(children: entry.children, fallbackReport: report)
            }
        }
    }

    private var header: some View {
        HStack(alignment: .firstTextBaseline, spacing: 9) {
            SubagentStatusGlyph(state: state)
            Text(Subagent.headline(entry.call))
                .font(.callout)
                .foregroundStyle(AppTheme.primaryText)
                .lineLimit(1)
                .truncationMode(.tail)
            Spacer(minLength: 6)
            if hasDetail {
                Image(systemName: expanded ? "chevron.up" : "chevron.down")
                    .font(.caption2).foregroundStyle(.secondary)
            }
        }
        .padding(.horizontal, 12).padding(.vertical, 8)
    }
}

/// The multi-agent card for a **plan-dispatched** fan-out: one `nucleic_delegate_plan` call that
/// spawned a whole batched task list at once. Same gold "Multiple Subagents" language as the
/// `Task`-wave `OrchestrationCard`, but built from a different source — and that difference is the
/// whole design:
///
/// - A `Task` wave is N tool calls, so each agent's status and report live in its own tool result.
///   A plan is ONE call returning only worker *ids*; results come back later through
///   `nucleic_supervise`. So each row resolves its worker's **live session state** from the store
///   instead, which also means the card keeps updating after the call itself has settled.
/// - Rows are grouped by batch (with its stated purpose), because a batch is the unit the
///   supervisor acts on — the card should show the same shape the orchestrator planned in.
///
/// A plan can dispatch far more workers than belong inline, so the list is capped and the remainder
/// is handed to the Subagents panel, which is built for exactly this (full session rows, live status,
/// scrollable). Tapping any row opens that worker's session.
private struct OrchestrationPlanCard: View {
    @Environment(\.appPalette) private var palette
    @Environment(AppStore.self) private var store
    let call: ToolCall
    let result: ToolResult?
    let finished: Bool
    /// Opens the Subagents panel; nil hides the "view all" affordance (see `TranscriptRow`'s
    /// `onOpenSubagents` — the ephemeral chat window has no panels to reveal).
    var onOpenSubagents: (() -> Void)?

    /// How many worker rows the card lists before deferring to the panel. Sized to stay a transcript
    /// card rather than a scrolling list of its own. Its rows are eager within the card, so an
    /// unbounded fan-out would still stall the main thread the way `SubagentActivityStream` guards
    /// against.
    private static let inlineLimit = 8

    private let accent = AppTheme.orchestra
    @State private var expanded = true

    private var batches: [PlanBatch]? { DelegatedPlan.batches(call: call, result: result) }
    private var denial: String? { DelegatedPlan.denial(result) }
    private var workers: [PlanWorker] { (batches ?? []).flatMap(\.workers) }

    /// Live state per worker, resolved from the store — a plan's tool result never carries it.
    ///
    /// Built ONCE per render in `body` and threaded down, never re-derived per row: it scans every
    /// session summary, so evaluating it inside a row (or worse, inside a filter closure) would cost
    /// O(workers x summaries) on every streaming update. Same discipline as `Subagent.hasReport`'s
    /// O(1) probe — these cards re-render constantly while a fan-out runs.
    private var workerStates: [SessionID: SubagentRunState] {
        let wanted = Set(workers.map(\.sessionID))
        guard !wanted.isEmpty else { return [:] }
        var out: [SessionID: SubagentRunState] = [:]
        for summary in store.summaries where wanted.contains(summary.id) {
            out[summary.id] = Self.state(of: summary)
        }
        return out
    }

    /// A worker session's lifecycle mapped onto the card's three states. A worker is archived the
    /// moment it finishes, so "done" has to be read from the run's outcome rather than from the
    /// chat still being active.
    private static func state(of summary: SessionSummary) -> SubagentRunState {
        switch summary.status {
        case .error, .interrupted: return .failed
        case .finished: return .done
        case .awaitingInput: return summary.isCompleted ? .done : .running
        default: return .running
        }
    }

    /// Overall status: a denial or any failed/unspawnable worker fails the card; any worker still
    /// running keeps it working; otherwise done. A dispatch whose result hasn't landed yet is
    /// working — the plan is in flight.
    private func overall(_ states: [SessionID: SubagentRunState]) -> SubagentRunState {
        if denial != nil { return .failed }
        guard let batches else { return finished ? .failed : .running }
        if batches.contains(where: { !$0.rejected.isEmpty }) { return .failed }
        let resolved = workers.map { states[$0.sessionID] ?? .running }
        if resolved.contains(where: { if case .failed = $0 { true } else { false } }) { return .failed }
        if resolved.contains(where: { if case .running = $0 { true } else { false } }) { return .running }
        return resolved.isEmpty ? .running : .done
    }

    private func doneCount(
        _ workers: [PlanWorker], _ states: [SessionID: SubagentRunState]
    ) -> Int {
        workers.filter {
            if case .done = states[$0.sessionID] ?? .running { return true } else { return false }
        }.count
    }

    var body: some View {
        // Resolved once here and threaded down — see `workerStates`.
        let states = workerStates
        VStack(alignment: .leading, spacing: 0) {
            header(states)
                .contentShape(Rectangle())
                .onTapGesture { expanded.toggle() }  // un-animated: see ToolCallRow's tap handler
            if expanded {
                if let denial {
                    Divider().overlay(accent.opacity(0.18))
                    Text(denial)
                        .font(.callout)
                        .foregroundStyle(AppTheme.primaryText)
                        .textSelection(.enabled)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .padding(.horizontal, 12).padding(.vertical, 10)
                } else if let batches {
                    batchList(batches, states: states)
                }
            }
        }
        .background(accent.opacity(0.08))
        .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.22), lineWidth: 1))
        .clipShape(RoundedRectangle(cornerRadius: 8))
    }

    /// The batches, in plan order, with worker rows capped across the whole card (not per batch) so a
    /// plan whose first batch is huge still shows something of the later ones' existence.
    @ViewBuilder
    private func batchList(
        _ batches: [PlanBatch], states: [SessionID: SubagentRunState]
    ) -> some View {
        let total = workers.count
        var budget = Self.inlineLimit
        // Walk the batches once, handing each its share of what's left of the inline budget.
        let slices: [(batch: PlanBatch, shown: Int)] = batches.map { batch in
            let shown = min(batch.workers.count, max(0, budget))
            budget -= shown
            return (batch, shown)
        }
        let hidden = total - slices.reduce(0) { $0 + $1.shown }

        ForEach(slices, id: \.batch.id) { slice in
            Divider().overlay(accent.opacity(0.18))
            batchHeader(slice.batch, states: states, showsCount: batches.count > 1)
            ForEach(slice.batch.workers.prefix(slice.shown)) { worker in
                workerRow(worker, state: states[worker.sessionID] ?? .running)
            }
            if !slice.batch.rejected.isEmpty {
                rejectedNote(slice.batch.rejected)
            }
        }
        if hidden > 0 || onOpenSubagents != nil {
            Divider().overlay(accent.opacity(0.18))
            viewAll(hidden: hidden, total: total)
        }
    }

    /// A batch's own line: name, purpose, and its progress — the group the supervisor waits on.
    private func batchHeader(
        _ batch: PlanBatch, states: [SessionID: SubagentRunState], showsCount: Bool
    ) -> some View {
        let done = doneCount(batch.workers, states)
        return HStack(alignment: .firstTextBaseline, spacing: 6) {
            Text(batch.name.uppercased())
                .font(.caption2.weight(.semibold))
                .foregroundStyle(accent)
                .lineLimit(1)
            if let purpose = batch.purpose {
                Text("· \(purpose)")
                    .font(.caption2)
                    .foregroundStyle(.secondary)
                    .lineLimit(1)
            }
            Spacer(minLength: 6)
            if showsCount, !batch.workers.isEmpty {
                Text("\(done)/\(batch.workers.count)")
                    .font(.caption2.monospacedDigit())
                    .foregroundStyle(.secondary)
            }
        }
        .padding(.horizontal, 12).padding(.top, 8).padding(.bottom, 2)
    }

    /// One dispatched worker: live status glyph + its task label. Tapping opens that worker's own
    /// session — the only place its work is visible, since a plan worker reports to the supervisor
    /// rather than back through this call.
    private func workerRow(_ worker: PlanWorker, state: SubagentRunState) -> some View {
        HStack(alignment: .firstTextBaseline, spacing: 9) {
            SubagentStatusGlyph(state: state)
            Text(worker.task)
                .font(.callout)
                .foregroundStyle(AppTheme.primaryText)
                .lineLimit(1)
                .truncationMode(.tail)
            Spacer(minLength: 6)
        }
        .padding(.horizontal, 12).padding(.vertical, 6)
        .contentShape(Rectangle())
        .onTapGesture {
            guard let summary = store.summaries.first(where: { $0.id == worker.sessionID }) else {
                return
            }
            store.openSession(summary)
        }
        .help("Open this subagent's session")
    }

    /// Tasks Nucleic could not spawn — surfaced so a short plan doesn't silently read as complete.
    private func rejectedNote(_ rejected: [String]) -> some View {
        HStack(alignment: .firstTextBaseline, spacing: 9) {
            Image(systemName: "exclamationmark.triangle.fill")
                .font(.caption)
                .foregroundStyle(palette.danger)
                .frame(width: 16)
            Text(
                rejected.count == 1
                    ? "1 task could not be dispatched: \(rejected[0])"
                    : "\(rejected.count) tasks could not be dispatched")
                .font(.caption)
                .foregroundStyle(.secondary)
                .lineLimit(2)
            Spacer(minLength: 6)
        }
        .padding(.horizontal, 12).padding(.vertical, 6)
    }

    /// The overflow affordance: a plan can spawn far more workers than belong in a transcript card,
    /// so the rest live in the Subagents panel, which this opens.
    private func viewAll(hidden: Int, total: Int) -> some View {
        Button {
            onOpenSubagents?()
        } label: {
            HStack(spacing: 6) {
                Image(systemName: "person.2.fill")
                Text(hidden > 0
                    ? "View \(hidden) more in Subagents panel"
                    : "View all \(total) in Subagents panel")
                Spacer(minLength: 0)
                Image(systemName: "chevron.right").font(.caption2)
            }
            .font(.caption.weight(.medium))
            .foregroundStyle(accent)
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(.horizontal, 12).padding(.vertical, 8)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .disabled(onOpenSubagents == nil)
    }

    private func header(_ states: [SessionID: SubagentRunState]) -> some View {
        HStack(alignment: .firstTextBaseline, spacing: 10) {
            Image(systemName: "rectangle.3.group.fill")
                .font(.body)
                .foregroundStyle(accent)
                .frame(width: 18)
            VStack(alignment: .leading, spacing: 1) {
                Text("Multiple Subagents")
                    .font(.body.weight(.semibold))
                    .foregroundStyle(AppTheme.primaryText)
                Text(subtitle(states))
                    .font(.caption)
                    .foregroundStyle(.secondary)
                    .lineLimit(1)
            }
            Spacer(minLength: 6)
            SubagentStatusChip(state: overall(states))
            Image(systemName: expanded ? "chevron.up" : "chevron.down")
                .font(.caption2).foregroundStyle(.secondary)
        }
        .padding(.horizontal, 12).padding(.vertical, 10)
    }

    /// "7 subagents · 3 done · 2 batches" — the plan's shape at a glance. Falls back to the plan
    /// prose while the dispatch is still in flight, so the card says something useful immediately.
    private func subtitle(_ states: [SessionID: SubagentRunState]) -> String {
        guard let batches, !workers.isEmpty else {
            if denial != nil { return "Plan not dispatched" }
            return DelegatedPlan.planText(call) ?? "Dispatching plan…"
        }
        var parts = [
            "\(workers.count) subagent\(workers.count == 1 ? "" : "s")",
            "\(doneCount(workers, states)) done",
        ]
        if batches.count > 1 { parts.append("\(batches.count) batches") }
        return parts.joined(separator: " · ")
    }
}
