import SwiftUI
import AppKit
import Observation
import UniformTypeIdentifiers
import NucleicCore

/// A file or image that was staged in the composer when its prompt was stashed. Persisted
/// by path rather than by value: media the composer held in memory (a pasted screenshot) is
/// copied into the stash's payload directory and *owned* by the stash; a file the composer
/// merely referenced keeps pointing at the original, which is exactly what the composer chip
/// did — the stash shouldn't duplicate a 2 GB video to hold a sentence of text.
struct StashedAttachment: Identifiable, Codable, Equatable {
    var id: UUID
    var filename: String
    /// `UTType.identifier`, so image-ness and the type icon survive a relaunch.
    var typeIdentifier: String?
    var path: String
    /// Whether these bytes are the stash's own copy (and so may be deleted with the entry).
    var owned: Bool

    var utType: UTType? { typeIdentifier.flatMap { UTType($0) } }
    var isImage: Bool { utType?.conforms(to: .image) ?? false }
    var url: URL { URL(fileURLWithPath: path) }

    /// Hand the attachment back to a composer as a live chip.
    var composerAttachment: ComposerAttachment {
        ComposerAttachment(filename: filename, payload: .fileURL(url), utType: utType)
    }
}

/// The composer configuration a prompt was written under — the project and branch it was
/// meant for, the intelligence level (or the manual model/effort) it was to run at, and the
/// Auto/Ship/worktree/Covalence choices around it. A stashed prompt is usually half a decision
/// ("run *this*, on *that* repo, with *that* much thinking"), so restoring the words without
/// the settings would leave the other half to be remembered by hand.
///
/// Every field is optional: each composer records only what it actually governs (a scratch
/// chat has no project or branch; an open chat has no worktree choice), and an entry stashed
/// before this existed decodes with the whole thing absent.
struct StashedComposerSettings: Codable, Equatable {
    var projectID: String?
    /// Stored beside the id so the viewer can still name the project when it no longer
    /// resolves — a peer Mac that's offline, or a project since removed.
    var projectName: String?
    var branch: String?
    var useWorktree: Bool?
    /// The Intelligence slider's stop, when routing was driving the chat. Restored in
    /// preference to a concrete model/effort, which is what routing exists to choose.
    var intelligence: IntelligenceLevel?
    /// Orchestra was the selected mode (the slider's tier above Max).
    var orchestra: Bool?
    /// Manual model/effort picks — recorded only when routing was off (or overridden), so
    /// pulling never silently pins a model the router would have chosen for itself.
    var model: String?
    var effort: String?
    var auto: Bool?
    var ship: Bool?
    /// Covalence: the chat was aimed at the mesh rather than this Mac.
    var runOnMesh: Bool?
    /// The prompt was written for a scratch (out-of-project) chat.
    var ephemeral: Bool?

    var isEmpty: Bool {
        projectID == nil && projectName == nil && branch == nil && useWorktree == nil
            && intelligence == nil && orchestra == nil && model == nil && effort == nil
            && auto == nil && ship == nil && runOnMesh == nil && ephemeral == nil
    }

    var modelDisplay: String? { model.map(ModelCatalog.displayName) }

    var effortDisplay: String? {
        effort.flatMap { ModelCatalog.isOrchestra($0) ? nil : ModelCatalog.effortDisplayName($0) }
    }
}

/// One prompt the user set aside from a composer — its text, whatever was staged alongside it,
/// and the composer settings it was written under. Codable so the whole stash round-trips
/// through `UserDefaults`: a stashed prompt is work you meant to send, so it has to survive a
/// quit as reliably as a draft does.
struct StashedPrompt: Identifiable, Codable, Equatable {
    var id: UUID
    var text: String
    var stashedAt: Date
    /// Where it came from — the project/chat name, or "New chat" — shown as the row's
    /// subtitle so a stash gathered across several chats still tells you what's what.
    var origin: String?
    var attachments: [StashedAttachment]
    /// The composer setup the prompt was written under, restored on a pull.
    var settings: StashedComposerSettings?

    init(id: UUID = UUID(), text: String, stashedAt: Date = Date(), origin: String? = nil,
         attachments: [StashedAttachment] = [], settings: StashedComposerSettings? = nil) {
        self.id = id
        self.text = text
        self.stashedAt = stashedAt
        self.origin = origin
        self.attachments = attachments
        self.settings = settings
    }

    /// Hand-written so an entry written before attachments (or before settings) existed still
    /// decodes — the synthesized initializer would throw on the missing key and drop the
    /// whole stash with it.
    init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(UUID.self, forKey: .id)
        text = try container.decode(String.self, forKey: .text)
        stashedAt = try container.decode(Date.self, forKey: .stashedAt)
        origin = try container.decodeIfPresent(String.self, forKey: .origin)
        attachments = try container.decodeIfPresent([StashedAttachment].self, forKey: .attachments) ?? []
        settings = try container.decodeIfPresent(StashedComposerSettings.self, forKey: .settings)
    }

    /// First non-blank line, for the row's title.
    var headline: String {
        lines.first { !$0.trimmingCharacters(in: .whitespaces).isEmpty } ?? text
    }

    private var lines: [String] {
        text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
    }

    var lineCount: Int { lines.count }

    /// Whether the row has anything the collapsed form doesn't already show — more prose
    /// than the two-line preview, or staged media. Drives the expand chevron.
    var hasDetail: Bool {
        !attachments.isEmpty || lineCount > 1 || text.count > 120
    }

    var images: [StashedAttachment] { attachments.filter(\.isImage) }

    /// One-line size note for the row's metadata ("4 lines · 2 images").
    var sizeNote: String {
        var parts: [String] = []
        if lineCount > 1 {
            parts.append("\(lineCount) lines")
        } else {
            parts.append("\(text.count) characters")
        }
        let imageCount = images.count
        let fileCount = attachments.count - imageCount
        if imageCount > 0 { parts.append("\(imageCount) image\(imageCount == 1 ? "" : "s")") }
        if fileCount > 0 { parts.append("\(fileCount) file\(fileCount == 1 ? "" : "s")") }
        return parts.joined(separator: " · ")
    }
}

/// The prompt stash: prompts the user set aside from a composer (the ⬇︎ button, which drops
/// them into the stash box right below it) to pull back into *any* composer later — the same
/// chat, a different chat, or the ⌘N/⌘⇧N new-chat panel.
///
/// App-wide and persisted to `UserDefaults` as JSON, in the shape of `PanelLayoutStore`;
/// attachment bytes the stash owns live under `<data root>/stash/<entry id>/`. A single
/// shared instance rather than an environment value because the composers that use it live in
/// three different window scenes (main window, floating panel, scratch chat window), only one
/// of which inherits the root view's environment.
@MainActor
@Observable
final class PromptStash {
    static let shared = PromptStash()

    /// Newest first — the order the viewer lists them in.
    private(set) var prompts: [StashedPrompt] = []

    /// Upper bound on how many prompts are kept; stashing past it drops the oldest. A
    /// stash is a short-term holding pen, not an archive.
    static let capacity = 100

    private let defaults: UserDefaults
    private let payloadRoot: URL
    private static let storageKey = "nucleic.composer.stash"

    init(defaults: UserDefaults = .standard,
         payloadRoot: URL = NucleicPaths.dataRoot.appendingPathComponent("stash", isDirectory: true)) {
        self.defaults = defaults
        self.payloadRoot = payloadRoot
        if let data = defaults.data(forKey: Self.storageKey),
           let stored = try? JSONDecoder().decode([StashedPrompt].self, from: data) {
            prompts = stored
        }
        pruneOrphanedPayloads()
    }

    var isEmpty: Bool { prompts.isEmpty }
    var count: Int { prompts.count }

    /// Set a prompt aside, taking the composer's staged attachments and its settings with it.
    /// Blank text with nothing staged is ignored (the button is disabled then anyway), and
    /// re-stashing plain text that's already in the stash moves that entry to the top rather
    /// than piling up duplicates. Returns whether anything was stashed, so the caller only
    /// clears its composer when the text actually landed.
    @discardableResult
    func stash(_ text: String, attachments: [ComposerAttachment] = [], origin: String? = nil,
               settings: StashedComposerSettings? = nil) -> Bool {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty || !attachments.isEmpty else { return false }
        let id = UUID()
        let captured = attachments.compactMap { capture($0, for: id) }
        // Only collapse duplicates when neither side carries media — two prompts with the same
        // words but different images are not the same stashed item, and dropping one would
        // delete media the user still has staged.
        if captured.isEmpty {
            for stale in prompts where stale.text == trimmed && stale.attachments.isEmpty {
                discardPayloads(of: stale)
            }
            prompts.removeAll { $0.text == trimmed && $0.attachments.isEmpty }
        }
        prompts.insert(
            StashedPrompt(
                id: id, text: trimmed, origin: origin, attachments: captured,
                settings: (settings?.isEmpty ?? true) ? nil : settings),
            at: 0)
        while prompts.count > Self.capacity, let dropped = prompts.popLast() {
            discardPayloads(of: dropped)
        }
        persist()
        return true
    }

    /// Take a prompt back out. It leaves the stash — except when it carries attachments the
    /// destination composer can't accept (`takingAttachments: false`, the scratch-chat window),
    /// where only the text is copied out and the entry stays put rather than silently losing
    /// its media.
    @discardableResult
    func pull(_ id: UUID, takingAttachments: Bool = true) -> StashedPrompt? {
        guard let index = prompts.firstIndex(where: { $0.id == id }) else { return nil }
        let prompt = prompts[index]
        guard takingAttachments || prompt.attachments.isEmpty else { return prompt }
        prompts.remove(at: index)
        // The pulled attachments are now referenced by the composer, so their bytes must
        // outlive the entry; the orphan sweep at next launch reclaims them.
        persist()
        return prompt
    }

    func remove(_ id: UUID) {
        guard let index = prompts.firstIndex(where: { $0.id == id }) else { return }
        discardPayloads(of: prompts.remove(at: index))
        persist()
    }

    func clear() {
        prompts.forEach(discardPayloads)
        prompts.removeAll()
        persist()
    }

    // MARK: - Attachment payloads

    /// Take a stashable reference to a staged attachment: media held only in memory (a pasted
    /// screenshot) is written into the stash's own directory so it survives a quit; a file the
    /// composer referenced is kept as a reference. A write failure drops that one attachment
    /// rather than failing the stash — losing the prompt would be the worse outcome.
    private func capture(_ attachment: ComposerAttachment, for entry: UUID) -> StashedAttachment? {
        switch attachment.payload {
        case .fileURL(let url):
            return StashedAttachment(
                id: attachment.id, filename: attachment.filename,
                typeIdentifier: attachment.utType?.identifier, path: url.path, owned: false)
        case .data(let data):
            let directory = payloadRoot.appendingPathComponent(entry.uuidString, isDirectory: true)
            let url = directory.appendingPathComponent(
                "\(attachment.id.uuidString)-\(attachment.filename)")
            do {
                try FileManager.default.createDirectory(
                    at: directory, withIntermediateDirectories: true)
                try data.write(to: url, options: .atomic)
            } catch {
                return nil
            }
            return StashedAttachment(
                id: attachment.id, filename: attachment.filename,
                typeIdentifier: attachment.utType?.identifier, path: url.path, owned: true)
        }
    }

    /// Delete the bytes an entry owned. Referenced (not owned) files are left alone — they're
    /// the user's own files sitting wherever they live.
    private func discardPayloads(of prompt: StashedPrompt) {
        guard prompt.attachments.contains(where: \.owned) else { return }
        try? FileManager.default.removeItem(
            at: payloadRoot.appendingPathComponent(prompt.id.uuidString, isDirectory: true))
    }

    /// Drop payload directories no live entry points at — the residue of prompts pulled into a
    /// composer in an earlier run (their bytes had to outlive the entry then, but no composer
    /// holds them across a relaunch), or of a write that raced a crash.
    private func pruneOrphanedPayloads() {
        let live = Set(prompts.map(\.id.uuidString))
        guard let entries = try? FileManager.default.contentsOfDirectory(
            at: payloadRoot, includingPropertiesForKeys: nil) else { return }
        for entry in entries where !live.contains(entry.lastPathComponent) {
            try? FileManager.default.removeItem(at: entry)
        }
    }

    private func persist() {
        guard let data = try? JSONEncoder().encode(prompts) else { return }
        defaults.set(data, forKey: Self.storageKey)
    }
}

// MARK: - Composer controls

/// The ⬇︎ button that drops the composer's prompt into the stash. Sits directly under the send
/// (⌘Return) button, at the bottom of the composer's trailing column, pointing down at the
/// stash box a row below it on the model/effort line.
struct PromptStashDropButton: View {
    /// Whether the composer has something to set aside right now.
    var canStash: Bool
    /// Stash the composer's contents and clear it. The host owns the draft, so it does both.
    var onStash: () -> Void

    private let stash = PromptStash.shared

    var body: some View {
        Button(action: drop) {
            Image(systemName: "arrow.down")
                .font(.system(size: 12, weight: .semibold))
                .foregroundStyle(.secondary)
                // Bounces downward as the prompt drops into the box below.
                .symbolEffect(.bounce.down, value: stash.count)
                .frame(height: 14)
                .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .disabled(!canStash)
        .help("Stash this prompt (⌘S) — drops it into the stash box below instead of sending it.")
        .keyboardShortcut("s", modifiers: .command)
    }

    private func drop() {
        withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) { onStash() }
    }
}

/// The stash box that catches what the ⬇︎ above drops. Lives on the composer's model/effort
/// row, in the same trailing column as the send button — one line below the arrow, so the
/// prompt reads as falling into it. Clicking it opens the stash viewer.
struct PromptStashBox: View {
    var accent: Color
    /// Whether the host composer can take attachments back. False for the scratch-chat window,
    /// which has no attachment support — prompts with media stay in the stash there.
    var acceptsAttachments = true
    /// A prompt pulled back out, for the host to load into its composer.
    var onPull: (StashedPrompt) -> Void

    @State private var showingViewer = false
    @State private var hovering = false
    private let stash = PromptStash.shared

    var body: some View {
        Button { showingViewer.toggle() } label: {
            // Count *under* the tray rather than beside it: the tray glyph has to stay on the
            // column's centerline — the send button and the ⬇︎ above it are centered there — and
            // an inline count would push the glyph off-center whenever the stash is non-empty
            // (or, pinned to an edge, leave it off-center when the stash is empty).
            VStack(spacing: 0) {
                Image(systemName: stash.isEmpty ? "tray" : "tray.full.fill")
                    .font(.system(size: 13))
                    .contentTransition(.symbolEffect(.replace.downUp))
                    .symbolEffect(.bounce, value: stash.count)
                    .foregroundStyle(stash.isEmpty ? AnyShapeStyle(.tertiary) : AnyShapeStyle(accent))
                if !stash.isEmpty {
                    Text("\(stash.count)")
                        .font(.system(size: 9, weight: .semibold))
                        .monospacedDigit()
                        .contentTransition(.numericText())
                        .foregroundStyle(accent)
                        .transition(.scale.combined(with: .opacity))
                }
            }
            .scaleEffect(hovering ? 1.12 : 1)
            .frame(height: 26)
            .frame(maxWidth: .infinity)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .onHover { hover in
            withAnimation(.spring(response: 0.25, dampingFraction: 0.6)) { hovering = hover }
        }
        .help(stash.isEmpty
            ? "The stash is empty. Press ⬇︎ (⌘S) above to set the current prompt aside."
            : "\(stash.count) stashed prompt\(stash.count == 1 ? "" : "s") — click to pull one back into this composer.")
        // Opens upward off the box: the composer sits at the bottom of its window, so the
        // viewer unfurls over the transcript rather than off the screen edge.
        .popover(isPresented: $showingViewer, arrowEdge: .top) {
            PromptStashViewer(accent: accent, acceptsAttachments: acceptsAttachments) { prompt in
                showingViewer = false
                onPull(prompt)
            }
        }
        // One animation for the box, so the tray glyph and the count move together as prompts
        // come and go.
        .animation(.spring(response: 0.35, dampingFraction: 0.7), value: stash.count)
    }
}

// MARK: - Viewer

/// The stash viewer: the popover the stash box opens. Rows are compact by default and expand
/// in place to the full prompt (scrollable, however long it is) and previews of anything
/// staged with it, so a stash holding a one-liner and a three-page brief with screenshots
/// stays readable either way.
struct PromptStashViewer: View {
    var accent: Color
    var acceptsAttachments: Bool
    var onPull: (StashedPrompt) -> Void

    @Environment(\.colorScheme) private var colorScheme
    @State private var expanded: Set<UUID> = []
    /// Live height of the row list, measured from the stack itself. A `ScrollView` proposes
    /// no ideal height of its own, so left to size itself inside a popover it collapses to a
    /// sliver regardless of how few rows it holds; measuring the content and pinning the
    /// scroll view to `min(content, ceiling)` makes the viewer grow with what's in it and only
    /// start scrolling once it's genuinely tall.
    @State private var listHeight: CGFloat = 0

    private let stash = PromptStash.shared

    /// How tall the list may grow before it scrolls instead — enough for several expanded rows
    /// without the popover running off a laptop screen.
    private let listCeiling: CGFloat = 420

    var body: some View {
        VStack(spacing: 0) {
            header
            Divider()
            if stash.isEmpty {
                emptyState
            } else {
                ScrollView {
                    VStack(spacing: 0) {
                        ForEach(stash.prompts) { prompt in
                            PromptStashRow(
                                prompt: prompt, accent: accent,
                                acceptsAttachments: acceptsAttachments,
                                isExpanded: expanded.contains(prompt.id),
                                onToggle: { toggle(prompt.id) },
                                onPull: { pull(prompt) },
                                onDelete: { delete(prompt) })
                            .transition(.asymmetric(
                                insertion: .push(from: .top).combined(with: .opacity),
                                removal: .scale(scale: 0.92).combined(with: .opacity)))
                            if prompt.id != stash.prompts.last?.id { Divider() }
                        }
                    }
                    .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { listHeight = $0 }
                }
                .frame(height: min(max(listHeight, 1), listCeiling))
            }
        }
        .frame(width: 420)
        // Deepen the popover's glass in dark mode: the system material alone reads washed-out
        // over a dark transcript, and the rows' secondary text with it.
        .background(colorScheme == .dark ? Color.black.opacity(0.32) : .clear)
        .animation(.snappy(duration: 0.28), value: stash.prompts)
        .animation(.snappy(duration: 0.28), value: expanded)
        // The measured height arrives a frame after the content changes, so give it its own
        // animation — otherwise the popover snaps to its new size after the rows have moved.
        .animation(.snappy(duration: 0.28), value: listHeight)
    }

    private var header: some View {
        HStack(spacing: 8) {
            Image(systemName: "tray.full")
                .foregroundStyle(accent)
            Text("Stash").font(.headline)
            Text("\(stash.count)")
                .font(.callout)
                .monospacedDigit()
                .contentTransition(.numericText())
                .foregroundStyle(.secondary)
            Spacer()
            if !stash.isEmpty {
                Button("Clear All") {
                    withAnimation(.snappy) { stash.clear() }
                }
                .buttonStyle(.link)
                .font(.callout)
            }
        }
        .padding(.horizontal, 14)
        .padding(.vertical, 10)
    }

    private var emptyState: some View {
        VStack(spacing: 6) {
            Image(systemName: "tray")
                .font(.title2)
                .foregroundStyle(.tertiary)
            Text("Nothing stashed")
                .font(.callout)
                .foregroundStyle(.secondary)
            // The composer's own `arrow.down` glyph inline, not a Unicode stand-in — the
            // sentence points at a specific button, so it should show that button's symbol.
            Text("Press \(Image(systemName: "arrow.down")) (⌘S) beside the send button to set a prompt aside without sending it.")
                .font(.caption)
                .foregroundStyle(.tertiary)
                .multilineTextAlignment(.center)
                .fixedSize(horizontal: false, vertical: true)
        }
        .frame(maxWidth: .infinity)
        .padding(.horizontal, 20)
        .padding(.vertical, 22)
    }

    private func toggle(_ id: UUID) {
        if expanded.contains(id) { expanded.remove(id) } else { expanded.insert(id) }
    }

    private func pull(_ prompt: StashedPrompt) {
        let takesAttachments = acceptsAttachments || prompt.attachments.isEmpty
        guard let pulled = stash.pull(prompt.id, takingAttachments: takesAttachments) else { return }
        withAnimation(.snappy) { expanded.remove(prompt.id) }
        onPull(pulled)
    }

    private func delete(_ prompt: StashedPrompt) {
        withAnimation(.snappy) {
            expanded.remove(prompt.id)
            stash.remove(prompt.id)
        }
    }
}

/// One row of the stash viewer: a thumbnail of whatever media rode along, the prompt's first
/// line, its metadata, and — once expanded — the whole prompt and its attachment previews.
private struct PromptStashRow: View {
    let prompt: StashedPrompt
    var accent: Color
    var acceptsAttachments: Bool
    var isExpanded: Bool
    var onToggle: () -> Void
    var onPull: () -> Void
    var onDelete: () -> Void

    @State private var hovering = false

    /// Whether pulling this prompt would have to leave its media behind (the scratch-chat
    /// window can't take attachments) — surfaced in the row rather than silently dropped.
    private var mediaStaysBehind: Bool { !acceptsAttachments && !prompt.attachments.isEmpty }

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack(alignment: .top, spacing: 8) {
                if let image = prompt.images.first {
                    StashAttachmentThumbnail(attachment: image, side: 30)
                }
                Button(action: onPull) {
                    VStack(alignment: .leading, spacing: 3) {
                        Text(prompt.headline)
                            .lineLimit(isExpanded ? 1 : 2)
                            .multilineTextAlignment(.leading)
                            .fixedSize(horizontal: false, vertical: true)
                        HStack(spacing: 4) {
                            Text(prompt.stashedAt, format: .relative(presentation: .named))
                            if let origin = prompt.origin, !origin.isEmpty {
                                Text("· \(origin)").lineLimit(1)
                            }
                            Text("· \(prompt.sizeNote)")
                        }
                        .font(.caption)
                        .foregroundStyle(.secondary)
                        if let settings = prompt.settings {
                            settingsChips(settings)
                        }
                    }
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .contentShape(Rectangle())
                }
                .buttonStyle(.plain)
                .help(prompt.settings == nil
                    ? "Pull this prompt into the composer"
                    : "Pull this prompt — and the settings it was written under — into the composer")

                if prompt.hasDetail {
                    Button(action: onToggle) {
                        Image(systemName: "chevron.down")
                            .font(.caption.weight(.semibold))
                            .foregroundStyle(.secondary)
                            .rotationEffect(.degrees(isExpanded ? 180 : 0))
                            .frame(width: 18, height: 18)
                            .contentShape(Rectangle())
                    }
                    .buttonStyle(.plain)
                    .help(isExpanded ? "Collapse" : "Show the whole prompt")
                }
                Button(action: onDelete) {
                    Image(systemName: "xmark.circle.fill")
                        .foregroundStyle(.secondary)
                        .frame(width: 18, height: 18)
                        .contentShape(Rectangle())
                }
                .buttonStyle(.plain)
                .help("Delete this stashed prompt")
                .opacity(hovering ? 1 : 0)
            }
            if isExpanded { detail }
        }
        .padding(.horizontal, 14)
        .padding(.vertical, 10)
        .background(hovering ? accent.opacity(0.10) : .clear)
        .contentShape(Rectangle())
        .onHover { hover in
            withAnimation(.easeOut(duration: 0.15)) { hovering = hover }
        }
        .contextMenu {
            Button("Pull into Composer", action: onPull)
            Button("Copy") {
                NSPasteboard.general.clearContents()
                NSPasteboard.general.setString(prompt.text, forType: .string)
            }
            Divider()
            Button("Delete", role: .destructive, action: onDelete)
        }
    }

    /// The composer setup the prompt was written under, as a row of small chips — what a pull
    /// will put back. Scrolls sideways rather than wrapping, so a fully-specified prompt
    /// (project, branch, level, Auto, Ship, Covalence) never makes the row two lines taller.
    private func settingsChips(_ settings: StashedComposerSettings) -> some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 4) {
                // The origin already names where it came from; only repeat the project when
                // the two differ (a chat's title isn't its project's name).
                if let project = settings.projectName, project != prompt.origin {
                    chip("folder", project)
                }
                if let branch = settings.branch { chip("arrow.triangle.branch", branch) }
                if settings.useWorktree == false { chip("point.3.connected.trianglepath.dotted", "no worktree") }
                if settings.orchestra == true {
                    chip(OrchestraStyle.symbol, "Orchestra")
                } else if let level = settings.intelligence {
                    chip("dial.medium", level.displayName)
                }
                if let model = settings.modelDisplay { chip("cpu", model) }
                if let effort = settings.effortDisplay { chip("gauge.with.dots.needle.50percent", effort) }
                if settings.auto == true { chip("bolt.fill", "Auto") }
                if settings.ship == true { chip("shippingbox.fill", "Ship") }
                if settings.runOnMesh == true { chip("circle.dotted.and.circle", "Covalence") }
                if settings.ephemeral == true { chip("bolt", "Scratch") }
            }
            .padding(.vertical, 1)
        }
    }

    private func chip(_ symbol: String, _ text: String) -> some View {
        HStack(spacing: 3) {
            Image(systemName: symbol)
            Text(text).lineLimit(1)
        }
        .font(.caption2)
        .foregroundStyle(.secondary)
        .padding(.horizontal, 5)
        .padding(.vertical, 2)
        .background(.quaternary.opacity(0.5), in: .capsule)
    }

    /// The expanded body: the full prompt (scrolling once it's taller than the cap, so a very
    /// long one can't run the popover off the screen) and previews of everything staged with it.
    @ViewBuilder
    private var detail: some View {
        VStack(alignment: .leading, spacing: 8) {
            if !prompt.text.isEmpty {
                ScrollView {
                    Text(prompt.text)
                        .font(.callout)
                        .textSelection(.enabled)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .padding(8)
                }
                .frame(maxHeight: 180)
                .fixedSize(horizontal: false, vertical: true)
                .background(.quaternary.opacity(0.4), in: .rect(cornerRadius: 6))
            }
            if !prompt.attachments.isEmpty {
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: 8) {
                        ForEach(prompt.attachments) { attachment in
                            StashAttachmentPreview(attachment: attachment)
                        }
                    }
                    .padding(.vertical, 1)
                }
                if mediaStaysBehind {
                    Label(
                        "This chat can't take attachments — pulling copies the text and leaves the prompt in the stash.",
                        systemImage: "info.circle")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                        .fixedSize(horizontal: false, vertical: true)
                }
            }
        }
        .transition(.opacity.combined(with: .move(edge: .top)))
    }
}

/// Square thumbnail of a stashed image, for the collapsed row.
private struct StashAttachmentThumbnail: View {
    let attachment: StashedAttachment
    var side: CGFloat
    @State private var image: NSImage?

    var body: some View {
        Group {
            if let image {
                Image(nsImage: image).resizable().aspectRatio(contentMode: .fill)
            } else {
                Rectangle().fill(.quaternary)
            }
        }
        .frame(width: side, height: side)
        .clipShape(RoundedRectangle(cornerRadius: 4))
        .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(AppTheme.hairline, lineWidth: 0.5))
        // Decoded once per row (the file is immutable), matching the composer chip's caching.
        .task(id: attachment.id) { image = NSImage(contentsOf: attachment.url) }
    }
}

/// A larger preview in an expanded row: the picture itself for an image, the system type icon
/// for anything else, with its filename beneath.
private struct StashAttachmentPreview: View {
    let attachment: StashedAttachment
    @State private var image: NSImage?

    var body: some View {
        VStack(spacing: 4) {
            Group {
                if let image {
                    Image(nsImage: image).resizable().aspectRatio(contentMode: .fill)
                } else {
                    Image(nsImage: NSWorkspace.shared.icon(for: attachment.utType ?? .data))
                        .resizable().aspectRatio(contentMode: .fit).padding(14)
                }
            }
            .frame(width: 92, height: 66)
            .clipShape(RoundedRectangle(cornerRadius: 6))
            .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(AppTheme.hairline, lineWidth: 0.5))
            Text(attachment.filename)
                .font(.caption2)
                .lineLimit(1)
                .truncationMode(.middle)
                .frame(width: 92)
        }
        .help(attachment.filename)
        .task(id: attachment.id) {
            guard attachment.isImage else { return }
            image = NSImage(contentsOf: attachment.url)
        }
    }
}

/// Append a pulled prompt to a composer's draft. Typed text is never overwritten — a pull
/// into a composer you've already started writing in lands below what's there, separated by
/// a blank line.
@MainActor
func appendPulledPrompt(_ pulled: String, to draft: inout String) {
    let existing = draft.trimmingCharacters(in: .whitespacesAndNewlines)
    let addition = pulled.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !addition.isEmpty else { return }
    draft = existing.isEmpty ? addition : existing + "\n\n" + addition
}
