import ImageIO
import Observation
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
import UIKit

enum SybilWorkspaceNavigationLeadingControl: Equatable {
    case back
    case hidden
    case showSidebar
}

struct SybilWorkspaceView: View {
    @Bindable var viewModel: SybilViewModel
    var composerFocusRequest: Int = 0
    var usesCustomWorkspaceNavigation: Bool = true
    var navigationLeadingControl: SybilWorkspaceNavigationLeadingControl = .back
    var onShowSidebar: (() -> Void)? = nil
    var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
    var onRequestNewChat: (() -> Void)? = nil
    var onOpenForkedChat: ((String) -> Void)? = nil
    @FocusState private var composerFocused: Bool
    @Environment(\.dismiss) private var dismiss
    @State private var isShowingAttachmentOptions = false
    @State private var isShowingFileImporter = false
    @State private var isShowingPhotoPicker = false
    @State private var photoPickerItems: [PhotosPickerItem] = []
    @State private var isComposerDropTargeted = false
    @State private var newChatSwipeOffset: CGFloat = 0
    @State private var newChatSwipeCompletionOffset: CGFloat = 0
    @State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
    @State private var newChatSwipeIsActive = false
    @State private var newChatSwipeIsCompleting = false
    @State private var newChatSwipeHasLatched = false
    @State private var newChatSwipeDidTriggerHaptic = false
    @State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?

    private let customWorkspaceNavigationContentInset: CGFloat = 96
    private let composerOverlayContentInset: CGFloat = 112

    private var isSettingsSelected: Bool {
        if case .settings = viewModel.selectedItem {
            return true
        }
        return false
    }

    private var showsHeader: Bool {
        viewModel.errorMessage != nil
    }

    private var showsCustomWorkspaceNavigation: Bool {
        usesCustomWorkspaceNavigation && (!isSettingsSelected || navigationLeadingControl != .hidden)
    }

    private var transcriptScrollContextID: String {
        if viewModel.draftKind == .chat {
            return "draft-chat"
        }
        if case let .chat(chatID) = viewModel.selectedItem {
            return "chat:\(chatID)"
        }
        return "chat:none"
    }

    private var shouldAutoFocusComposer: Bool {
        viewModel.draftKind == .chat && viewModel.displayedMessages.isEmpty
    }

    private var composerFocusPolicyID: String {
        "\(transcriptScrollContextID):\(composerFocusRequest):\(shouldAutoFocusComposer)"
    }

    private var canSwipeToCreateChat: Bool {
        guard onRequestNewChat != nil else {
            return false
        }
        guard !viewModel.isActiveSelectionSending, viewModel.draftKind == nil else {
            return false
        }
        guard case .chat = viewModel.selectedItem else {
            return false
        }
        return true
    }

    private var canRecognizeNewChatSwipe: Bool {
        canSwipeToCreateChat && !newChatSwipeIsCompleting
    }

    private var showsNewChatSwipeBackdrop: Bool {
        canSwipeToCreateChat || newChatSwipeIsCompleting
    }

    private var workspaceSwipeOffset: CGFloat {
        newChatSwipeOffset
    }

    private var workspaceCompletionOffset: CGFloat {
        newChatSwipeCompletionOffset
    }

    private var workspaceSwipeBlurRadius: CGFloat {
        NewChatSwipeMetrics.blurRadius(for: newChatSwipeOffset, width: newChatSwipeContainerWidth)
    }

    var body: some View {
        ZStack {
            if showsNewChatSwipeBackdrop {
                NewChatSwipeBackdrop(
                    progress: NewChatSwipeMetrics.progress(for: newChatSwipeOffset, width: newChatSwipeContainerWidth),
                    hasLatched: newChatSwipeHasLatched
                )
                .padding(.trailing, 18)
                .padding(.vertical, 20)
                .allowsHitTesting(false)
            }

            workspaceContent
                .compositingGroup()
                .offset(x: workspaceSwipeOffset)
                .blur(radius: workspaceSwipeBlurRadius)
        }
        .offset(x: workspaceCompletionOffset)
        .background(SybilTheme.background)
        .navigationTitle(showsCustomWorkspaceNavigation ? "" : viewModel.selectedTitle)
        .navigationBarTitleDisplayMode(.inline)
        .toolbarRole(.editor)
        .toolbar(showsCustomWorkspaceNavigation ? .hidden : .visible, for: .navigationBar)
        .toolbar {
            if !isSettingsSelected && !showsCustomWorkspaceNavigation {
                ToolbarItem(placement: .topBarTrailing) {
                    if viewModel.isSearchMode {
                        searchModeChip
                    } else {
                        providerModelToolbarMenu
                    }
                }
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
        .onChange(of: canSwipeToCreateChat) { _, isEnabled in
            guard !isEnabled else {
                return
            }
            resetNewChatSwipe(animated: false)
        }
        .task(id: composerFocusPolicyID) {
            await applyComposerFocusPolicy()
        }
    }

    private var workspaceContent: some View {
        ZStack(alignment: .top) {
            workspaceContentStack

            if showsCustomWorkspaceNavigation {
                SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isActiveSelectionSending)
                    .allowsHitTesting(false)
                customWorkspaceNavigationBar
            }
        }
    }

    private var workspaceContentStack: some View {
        VStack(spacing: 0) {
            if showsHeader {
                header

                Divider()
                    .overlay(SybilTheme.border)
            }

            ZStack(alignment: .bottom) {
                if isSettingsSelected {
                    SybilSettingsView(viewModel: viewModel)
                        .padding(.top, showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
                } else if viewModel.isSearchMode {
                    SybilSearchResultsView(
                        search: viewModel.displayedSearch,
                        isLoading: viewModel.isLoadingSelection,
                        isRunning: viewModel.isRunningVisibleSearch,
                        isStartingChat: viewModel.isCreatingSearchChat,
                        topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
                        bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
                    ) {
                        Task {
                            await viewModel.startChatFromSelectedSearch()
                        }
                    }
                } else {
                    SybilChatTranscriptView(
                        messages: viewModel.displayedMessages,
                        isLoading: viewModel.isLoadingSelection,
                        isSending: viewModel.isSendingVisibleChat,
                        streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
                        topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
                        bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
                        bottomPinRequestID: viewModel.chatBottomPinRequestID,
                        isForking: viewModel.isForkingChat,
                        onForkResponse: forkResponse
                    )
                    .id(transcriptScrollContextID)
                }

                if viewModel.showsComposer {
                    composerBar
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background {
                WorkspaceSwipePanInstaller(
                    direction: .left,
                    isEnabled: canRecognizeNewChatSwipe,
                    onBegan: { width in
                        beginNewChatSwipe(containerWidth: width)
                    },
                    onChanged: { translationX, width in
                        updateNewChatSwipe(with: translationX, containerWidth: width)
                    },
                    onEnded: { translationX, width, velocityX, didFinish in
                        finishNewChatSwipe(
                            translationX: translationX,
                            containerWidth: width,
                            velocityX: velocityX,
                            didFinish: didFinish
                        )
                    }
                )
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
        }
    }

    private func forkResponse(messageID: String) {
        guard viewModel.draftKind == nil,
              case let .chat(chatID) = viewModel.selectedItem else {
            return
        }

        let selectionAtStart = viewModel.selectedItem
        Task {
            guard let forkedChat = await viewModel.forkChat(chatID: chatID, messageID: messageID),
                  viewModel.selectedItem == selectionAtStart,
                  viewModel.draftKind == nil else {
                return
            }

            if let onOpenForkedChat {
                onOpenForkedChat(forkedChat.id)
            } else {
                viewModel.select(.chat(forkedChat.id))
            }
        }
    }

    private var customWorkspaceNavigationBar: some View {
        HStack(spacing: 14) {
            workspaceNavigationLeadingControl

            customWorkspaceNavigationTitle

            workspaceNavigationTrailingControl
        }
        .padding(.horizontal, 16)
        .padding(.top, 10)
        .padding(.bottom, 34)
        .background(alignment: .top) {
            SybilNavigationFadeBackground()
                .allowsHitTesting(false)
        }
    }

    private var selectedProviderModelSubtitle: String {
        let selectedModel = viewModel.model.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !selectedModel.isEmpty else {
            return viewModel.provider.displayName
        }
        return "\(viewModel.provider.displayName) • \(selectedModel)"
    }

    private var customWorkspaceNavigationTitle: some View {
        VStack(alignment: .leading, spacing: 2) {
            Text(viewModel.selectedTitle)
                .font(.sybil(size: 16, weight: .semibold))
                .foregroundStyle(SybilTheme.text)
                .lineLimit(1)
                .minimumScaleFactor(0.78)

            Text(selectedProviderModelSubtitle)
                .font(.sybil(size: 10, weight: .medium))
                .foregroundStyle(SybilTheme.textMuted)
                .lineLimit(1)
                .minimumScaleFactor(0.82)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .multilineTextAlignment(.leading)
    }

    @ViewBuilder
    private var workspaceNavigationLeadingControl: some View {
        switch navigationLeadingControl {
        case .back:
            Button {
                requestBack()
            } label: {
                SybilNavigationIcon(systemImage: "chevron.left")
            }
            .buttonStyle(.plain)
            .accessibilityLabel("Back")

        case .showSidebar:
            Button {
                onShowSidebar?()
            } label: {
                SybilNavigationIcon(systemImage: "sidebar.left")
            }
            .buttonStyle(.plain)
            .accessibilityLabel("Show sidebar")

        case .hidden:
            EmptyView()
        }
    }

    private func requestBack(animateNavigation: Bool = true) {
        if let onRequestBack {
            onRequestBack(animateNavigation)
        } else {
            dismiss()
        }
    }

    private func beginNewChatSwipe(containerWidth: CGFloat) {
        let update = {
            newChatSwipeContainerWidth = max(containerWidth, 1)
            newChatSwipeIsActive = true
            newChatSwipeHasLatched = false
            newChatSwipeDidTriggerHaptic = false
        }

        var transaction = Transaction()
        transaction.disablesAnimations = true
        withTransaction(transaction, update)

        if newChatSwipeFeedbackGenerator == nil {
            newChatSwipeFeedbackGenerator = UIImpactFeedbackGenerator(style: .rigid)
        }
        newChatSwipeFeedbackGenerator?.prepare()
    }

    private func updateNewChatSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
        let nextOffset = NewChatSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
        let wasLatched = newChatSwipeHasLatched
        let nextLatched = NewChatSwipeMetrics.isLatched(
            offset: nextOffset,
            width: containerWidth,
            isCurrentlyLatched: newChatSwipeHasLatched
        )

        var transaction = Transaction()
        transaction.disablesAnimations = true
        withTransaction(transaction) {
            newChatSwipeContainerWidth = max(containerWidth, 1)
            newChatSwipeOffset = nextOffset
            newChatSwipeHasLatched = nextLatched
        }

        if nextLatched && !wasLatched && !newChatSwipeDidTriggerHaptic {
            newChatSwipeFeedbackGenerator?.impactOccurred(intensity: 0.95)
            newChatSwipeDidTriggerHaptic = true
        }
    }

    private func finishNewChatSwipe(
        translationX: CGFloat,
        containerWidth: CGFloat,
        velocityX: CGFloat,
        didFinish: Bool
    ) {
        guard newChatSwipeIsActive else {
            resetNewChatSwipe(animated: false)
            return
        }

        let finalOffset = NewChatSwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
        let finalLatched = NewChatSwipeMetrics.isLatched(
            offset: finalOffset,
            width: containerWidth,
            isCurrentlyLatched: newChatSwipeHasLatched
        )
        updateNewChatSwipe(with: translationX, containerWidth: containerWidth)

        if didFinish && NewChatSwipeMetrics.shouldComplete(
            offset: finalOffset,
            velocityX: velocityX,
            width: containerWidth,
            isLatched: finalLatched
        ) {
            Task {
                await completeNewChatSwipe(
                    containerWidth: containerWidth,
                    releaseVelocityX: velocityX
                )
            }
            return
        }

        resetNewChatSwipe(animated: true, velocityX: velocityX)
    }

    @MainActor
    private func completeNewChatSwipe(containerWidth: CGFloat, releaseVelocityX: CGFloat) async {
        newChatSwipeIsCompleting = true
        let targetOffset = NewChatSwipeMetrics.completionTargetOffset(for: containerWidth)

        withAnimation(
            NewChatSwipeMetrics.springAnimation(
                currentOffset: newChatSwipeOffset,
                targetOffset: targetOffset,
                velocityX: releaseVelocityX
            )
        ) {
            newChatSwipeCompletionOffset = targetOffset - newChatSwipeOffset
        }

        try? await Task.sleep(for: .milliseconds(NewChatSwipeMetrics.completionAnimationDelayMs))
        onRequestNewChat?()
        resetNewChatSwipe(animated: false)
    }

    private func resetNewChatSwipe(animated: Bool, velocityX: CGFloat = 0) {
        let currentOffset = newChatSwipeOffset + newChatSwipeCompletionOffset
        let reset = {
            newChatSwipeOffset = 0
            newChatSwipeCompletionOffset = 0
            newChatSwipeIsActive = false
            newChatSwipeIsCompleting = false
            newChatSwipeHasLatched = false
            newChatSwipeDidTriggerHaptic = false
        }

        if animated {
            withAnimation(
                NewChatSwipeMetrics.springAnimation(
                    currentOffset: currentOffset,
                    targetOffset: 0,
                    velocityX: velocityX
                )
            ) {
                reset()
            }
        } else {
            reset()
        }

        newChatSwipeFeedbackGenerator = nil
    }

    @MainActor
    private func applyComposerFocusPolicy() async {
        guard shouldAutoFocusComposer else {
            composerFocused = false
            return
        }

        guard shouldAutoFocusComposer, viewModel.showsComposer else {
            return
        }
        composerFocused = true
    }

    private var header: some View {
        VStack(alignment: .leading, spacing: 12) {
            if let error = viewModel.errorMessage {
                Text(error)
                    .font(.sybil(.footnote))
                    .foregroundStyle(SybilTheme.danger)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 12)
        .background(SybilTheme.panelGradient.opacity(0.58))
    }

    private var providerModelToolbarMenu: some View {
        providerModelMenu {
            Image(systemName: "ellipsis")
                .font(.system(size: 18, weight: .semibold))
                .foregroundStyle(SybilTheme.text)
                .frame(width: 34, height: 34)
                .background(
                    Circle()
                        .fill(SybilTheme.surface.opacity(0.78))
                )
                .overlay(
                    Circle()
                        .stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
                )
        }
    }

    private var providerModelNavigationMenu: some View {
        providerModelMenu {
            SybilNavigationIcon(systemImage: "ellipsis")
        }
    }

    @ViewBuilder
    private var workspaceNavigationTrailingControl: some View {
        if isSettingsSelected {
            EmptyView()
        } else if viewModel.isSearchMode {
            searchModeNavigationLabel
        } else {
            providerModelNavigationMenu
        }
    }

    private var searchModeNavigationLabel: some View {
        Label("Search", systemImage: "globe")
            .font(.sybil(.caption, weight: .medium))
            .foregroundStyle(SybilTheme.accent)
            .lineLimit(1)
    }

    private func providerModelMenu<Label: View>(@ViewBuilder label: @escaping () -> Label) -> some View {
        Menu {
            providerModelMenuItems
        } label: {
            label()
        }
        .accessibilityLabel("Provider and model")
    }

    @ViewBuilder
    private var providerModelMenuItems: some View {
        Text("\(viewModel.provider.displayName) • \(viewModel.model)")
            .font(.sybil(.caption))

        Divider()

        ForEach(viewModel.providerOptions, id: \.self) { candidate in
            Menu(candidate.displayName) {
                let models = viewModel.modelOptions(for: candidate)
                if models.isEmpty {
                    Text("No models")
                } else {
                    ForEach(models, id: \.self) { candidateModel in
                        Button {
                            viewModel.setProvider(candidate, model: candidateModel)
                        } label: {
                            if viewModel.provider == candidate && viewModel.model == candidateModel {
                                Label(candidateModel, systemImage: "checkmark")
                            } else {
                                Text(candidateModel)
                            }
                        }
                    }
                }
            }
        }
    }

    private var searchModeChip: some View {
        Label("Search", systemImage: "globe")
            .font(.sybil(.caption, weight: .medium))
            .foregroundStyle(SybilTheme.accent)
            .padding(.horizontal, 10)
            .padding(.vertical, 7)
            .background(
                Capsule()
                    .fill(SybilTheme.accent.opacity(0.10))
                    .overlay(
                        Capsule()
                            .stroke(SybilTheme.accent.opacity(0.24), lineWidth: 1)
                    )
            )
    }

    private var composerBar: some View {
        VStack(alignment: .leading, spacing: 10) {
            if !viewModel.isSearchMode && !viewModel.composerAttachments.isEmpty {
                SybilAttachmentListView(
                    attachments: viewModel.composerAttachments,
                    tone: .composer
                ) { attachmentID in
                    viewModel.removeComposerAttachment(id: attachmentID)
                }
            }

            HStack(alignment: .bottom, spacing: 10) {
                if !viewModel.isSearchMode {
                    Button {
                        isShowingAttachmentOptions = true
                    } label: {
                        Image(systemName: "paperclip")
                            .font(.system(size: 17, weight: .semibold))
                            .frame(width: 40, height: 40)
                            .background(
                                Circle()
                                    .fill(SybilTheme.surface)
                            )
                            .overlay(
                                Circle()
                                    .stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
                            )
                            .foregroundStyle(viewModel.isActiveSelectionSending ? SybilTheme.textMuted : SybilTheme.text)
                    }
                    .buttonStyle(.plain)
                    .disabled(viewModel.isActiveSelectionSending)
                    .accessibilityLabel("Attach file")
                }

                TextField(
                    viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
                    text: $viewModel.composer,
                    axis: .vertical
                )
                .focused($composerFocused)
                .textInputAutocapitalization(.sentences)
                .autocorrectionDisabled(false)
                .lineLimit(1 ... 6)
                .submitLabel(.send)
                .onSubmit {
                    submitComposer()
                }
                .submitOnMacReturn(submitComposer)
                .padding(.horizontal, 12)
                .padding(.vertical, 10)
                .background(
                    RoundedRectangle(cornerRadius: 12)
                        .fill(SybilTheme.composerGradient)
                        .opacity(0.98)
                )
                .foregroundStyle(SybilTheme.text)

                Button {
                    submitComposer()
                } label: {
                    Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
                        .font(.system(size: 17, weight: .semibold))
                        .frame(width: 40, height: 40)
                        .background(
                            Circle()
                                .fill(
                                    viewModel.canSendComposer
                                        ? AnyShapeStyle(SybilTheme.primaryGradient)
                                        : AnyShapeStyle(SybilTheme.surface)
                                )
                        )
                        .foregroundStyle(viewModel.canSendComposer ? SybilTheme.text : SybilTheme.textMuted)
                }
                .buttonStyle(.plain)
                .disabled(!viewModel.canSendComposer)
            }
        }
        .padding(.horizontal, 14)
        .padding(.top, 64)
        .padding(.bottom, 12)
        .background(alignment: .bottom) {
            SybilComposerFadeBackground()
                .allowsHitTesting(false)
        }
        .overlay {
            if isComposerDropTargeted && !viewModel.isSearchMode {
                RoundedRectangle(cornerRadius: 18)
                    .stroke(SybilTheme.accent.opacity(0.78), style: StrokeStyle(lineWidth: 1.5, dash: [7, 5]))
                    .padding(.horizontal, 14)
                    .padding(.top, 32)
                    .padding(.bottom, 10)
            }
        }
        .onDrop(of: [UTType.fileURL.identifier, UTType.image.identifier], isTargeted: $isComposerDropTargeted) { providers in
            if viewModel.isSearchMode || viewModel.isActiveSelectionSending {
                return false
            }

            Task {
                await importAttachmentsFromItemProviders(providers)
            }
            return true
        }
        .confirmationDialog("Add attachment", isPresented: $isShowingAttachmentOptions, titleVisibility: .visible) {
            Button("Photo Library") {
                isShowingPhotoPicker = true
            }
            Button("Files") {
                isShowingFileImporter = true
            }
            Button("Paste from Clipboard") {
                Task {
                    await pasteAttachmentsFromClipboard()
                }
            }
            Button("Cancel", role: .cancel) {}
        }
        .photosPicker(
            isPresented: $isShowingPhotoPicker,
            selection: $photoPickerItems,
            maxSelectionCount: max(1, SybilChatAttachmentSupport.maxAttachmentsPerMessage - viewModel.composerAttachments.count),
            matching: .images
        )
        .fileImporter(
            isPresented: $isShowingFileImporter,
            allowedContentTypes: [.item],
            allowsMultipleSelection: true
        ) { result in
            Task {
                do {
                    let urls = try result.get()
                    let attachments = try SybilChatAttachmentSupport.buildAttachments(from: urls)
                    try await MainActor.run {
                        try viewModel.appendComposerAttachments(attachments)
                    }
                    composerFocused = true
                } catch {
                    await MainActor.run {
                        viewModel.errorMessage = error.localizedDescription
                    }
                    SybilLog.error(SybilLog.ui, "File import failed", error: error)
                }
            }
        }
        .onChange(of: photoPickerItems) { _, items in
            guard !items.isEmpty else { return }
            Task {
                do {
                    let attachments = try await loadAttachmentsFromPhotoPickerItems(items)
                    try await MainActor.run {
                        try viewModel.appendComposerAttachments(attachments)
                        photoPickerItems = []
                    }
                    composerFocused = true
                } catch {
                    await MainActor.run {
                        viewModel.errorMessage = error.localizedDescription
                        photoPickerItems = []
                    }
                    SybilLog.error(SybilLog.ui, "Photo import failed", error: error)
                }
            }
        }
    }

    private func submitComposer() {
        guard viewModel.canSendComposer else {
            return
        }

        #if !targetEnvironment(macCatalyst)
        composerFocused = false
        #endif

        Task {
            await viewModel.sendComposer()
        }
    }

    @MainActor
    private func importAttachmentsFromItemProviders(_ providers: [NSItemProvider]) async {
        do {
            let attachments = try await SybilChatAttachmentSupport.buildAttachments(from: providers)
            try viewModel.appendComposerAttachments(attachments)
            composerFocused = true
        } catch {
            viewModel.errorMessage = error.localizedDescription
            SybilLog.error(SybilLog.ui, "Clipboard/drop attachment import failed", error: error)
        }
    }

    private func loadAttachmentsFromPhotoPickerItems(_ items: [PhotosPickerItem]) async throws -> [ChatAttachment] {
        var attachments: [ChatAttachment] = []

        for item in items {
            guard let data = try await item.loadTransferable(type: Data.self) else {
                continue
            }

            let contentType = item.supportedContentTypes.first(where: { $0.conforms(to: .image) })
            let filename = contentType?.preferredFilenameExtension.map { "photo.\($0)" } ?? "photo.jpg"
            attachments.append(try SybilChatAttachmentSupport.buildImageAttachment(data: data, filename: filename, contentType: contentType))
        }

        return attachments
    }

    @MainActor
    private func pasteAttachmentsFromClipboard() async {
        do {
            let pasteboard = UIPasteboard.general
            var attachments: [ChatAttachment] = []

            if let image = pasteboard.image {
                attachments.append(try SybilChatAttachmentSupport.buildImageAttachment(image: image))
            }

            if let url = pasteboard.url, url.isFileURL {
                attachments.append(contentsOf: try SybilChatAttachmentSupport.buildAttachments(from: [url]))
            } else if let text = pasteboard.string?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
                attachments.append(try SybilChatAttachmentSupport.buildTextAttachment(text: text))
            }

            guard !attachments.isEmpty else {
                viewModel.errorMessage = "Clipboard does not contain a supported attachment."
                return
            }

            try viewModel.appendComposerAttachments(attachments)
            composerFocused = true
        } catch {
            viewModel.errorMessage = error.localizedDescription
            SybilLog.error(SybilLog.ui, "Clipboard attachment import failed", error: error)
        }
    }
}

enum NewChatSwipeMetrics {
    static let referenceWidth: CGFloat = 390
    static let horizontalActivationDistance: CGFloat = 18
    static let directionDominanceRatio: CGFloat = 1.22
    static let minimumLeftwardVelocity: CGFloat = 55
    static let latchHysteresis: CGFloat = 32
    static let completionOvershoot = WorkspaceSwipePhysics.completionOvershoot
    static let completionAnimationDelayMs = WorkspaceSwipePhysics.completionAnimationDelayMs

    static func maxTravel(for width: CGFloat) -> CGFloat {
        min(max(width * 0.46, 156), 240)
    }

    static func latchDistance(for width: CGFloat) -> CGFloat {
        min(max(width * 0.28, 112), 152)
    }

    static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
        max(min(rawTranslation, 0), -maxTravel(for: width))
    }

    static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
        let travel = maxTravel(for: width)
        guard travel > 0 else {
            return 0
        }
        return min(max(abs(offset) / travel, 0), 1)
    }

    static func blurRadius(for offset: CGFloat, width: CGFloat) -> CGFloat {
        progress(for: offset, width: width) * 10
    }

    static func shouldBeginPan(
        leftwardTravel: CGFloat,
        verticalTravel: CGFloat,
        leftwardVelocity: CGFloat,
        verticalVelocity: CGFloat
    ) -> Bool {
        guard leftwardTravel > 0 || leftwardVelocity > 0 else {
            return false
        }

        if leftwardTravel >= horizontalActivationDistance,
           leftwardTravel >= verticalTravel * directionDominanceRatio {
            return true
        }

        return leftwardVelocity >= minimumLeftwardVelocity &&
            leftwardVelocity >= verticalVelocity * directionDominanceRatio
    }

    static func latchReleaseDistance(for width: CGFloat) -> CGFloat {
        max(latchDistance(for: width) - latchHysteresis, horizontalActivationDistance)
    }

    static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
        let distance = abs(offset)
        if isCurrentlyLatched {
            return distance >= latchReleaseDistance(for: width)
        }
        return distance >= latchDistance(for: width)
    }

    static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool {
        WorkspaceSwipePhysics.shouldComplete(
            offset: offset,
            velocityX: velocityX,
            width: width,
            directionSign: -1,
            isLatched: isLatched,
            latchDistance: latchDistance(for: width)
        )
    }

    static func completionTargetOffset(for width: CGFloat) -> CGFloat {
        WorkspaceSwipePhysics.completionTargetOffset(for: width, directionSign: -1)
    }

    static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
        WorkspaceSwipePhysics.springAnimation(
            currentOffset: currentOffset,
            targetOffset: targetOffset,
            velocityX: velocityX
        )
    }
}

enum BackSwipeMetrics {
    static let referenceWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
    static let horizontalActivationDistance: CGFloat = NewChatSwipeMetrics.horizontalActivationDistance
    static let directionDominanceRatio: CGFloat = NewChatSwipeMetrics.directionDominanceRatio
    static let minimumRightwardVelocity: CGFloat = NewChatSwipeMetrics.minimumLeftwardVelocity
    static let latchHysteresis: CGFloat = NewChatSwipeMetrics.latchHysteresis
    static let completionOvershoot: CGFloat = NewChatSwipeMetrics.completionOvershoot
    static let completionAnimationDelayMs = NewChatSwipeMetrics.completionAnimationDelayMs

    static func maxTravel(for width: CGFloat) -> CGFloat {
        NewChatSwipeMetrics.maxTravel(for: width)
    }

    static func latchDistance(for width: CGFloat) -> CGFloat {
        NewChatSwipeMetrics.latchDistance(for: width)
    }

    static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
        min(max(rawTranslation, 0), maxTravel(for: width))
    }

    static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
        NewChatSwipeMetrics.progress(for: offset, width: width)
    }

    static func blurRadius(for offset: CGFloat, width: CGFloat) -> CGFloat {
        NewChatSwipeMetrics.blurRadius(for: offset, width: width)
    }

    static func shouldBeginPan(
        rightwardTravel: CGFloat,
        verticalTravel: CGFloat,
        rightwardVelocity: CGFloat,
        verticalVelocity: CGFloat
    ) -> Bool {
        guard rightwardTravel > 0 || rightwardVelocity > 0 else {
            return false
        }

        if rightwardTravel >= horizontalActivationDistance,
           rightwardTravel >= verticalTravel * directionDominanceRatio {
            return true
        }

        return rightwardVelocity >= minimumRightwardVelocity &&
            rightwardVelocity >= verticalVelocity * directionDominanceRatio
    }

    static func latchReleaseDistance(for width: CGFloat) -> CGFloat {
        NewChatSwipeMetrics.latchReleaseDistance(for: width)
    }

    static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
        NewChatSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched)
    }

    static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool {
        WorkspaceSwipePhysics.shouldComplete(
            offset: offset,
            velocityX: velocityX,
            width: width,
            directionSign: 1,
            isLatched: isLatched,
            latchDistance: latchDistance(for: width)
        )
    }

    static func completionTargetOffset(for width: CGFloat) -> CGFloat {
        WorkspaceSwipePhysics.completionTargetOffset(for: width, directionSign: 1)
    }

    static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
        WorkspaceSwipePhysics.springAnimation(
            currentOffset: currentOffset,
            targetOffset: targetOffset,
            velocityX: velocityX
        )
    }
}

enum WorkspaceSwipePhysics {
    static let velocityProjectionDuration: CGFloat = 0.18
    static let completionVelocityThreshold: CGFloat = 620
    static let completionOvershoot: CGFloat = 180
    static let completionAnimationDelayMs: UInt64 = 320

    private static let springMass: Double = 1
    private static let springStiffness: Double = 300
    private static let springDamping: Double = 34
    private static let maximumInitialVelocity: CGFloat = 10

    static func shouldComplete(
        offset: CGFloat,
        velocityX: CGFloat,
        width: CGFloat,
        directionSign: CGFloat,
        isLatched: Bool,
        latchDistance: CGFloat
    ) -> Bool {
        let directionalOffset = offset * directionSign
        let directionalVelocity = velocityX * directionSign

        if directionalVelocity <= -completionVelocityThreshold {
            return false
        }

        if directionalVelocity >= completionVelocityThreshold {
            return true
        }

        let projectedOffset = directionalOffset + directionalVelocity * velocityProjectionDuration
        return isLatched || projectedOffset >= latchDistance
    }

    static func completionTargetOffset(for width: CGFloat, directionSign: CGFloat) -> CGFloat {
        directionSign * (max(width, 1) + completionOvershoot)
    }

    static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
        .interpolatingSpring(
            mass: springMass,
            stiffness: springStiffness,
            damping: springDamping,
            initialVelocity: springInitialVelocity(
                currentOffset: currentOffset,
                targetOffset: targetOffset,
                velocityX: velocityX
            )
        )
    }

    static func springInitialVelocity(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Double {
        let distance = targetOffset - currentOffset
        guard abs(distance) > 1 else {
            return 0
        }

        let normalizedVelocity = velocityX / distance
        let clampedVelocity = min(max(normalizedVelocity, -maximumInitialVelocity), maximumInitialVelocity)
        return Double(clampedVelocity)
    }
}

enum WorkspaceSwipeDirection {
    case left
    case right
}

struct WorkspaceSwipePanInstaller: UIViewRepresentable {
    var direction: WorkspaceSwipeDirection
    var isEnabled: Bool
    var onBegan: (CGFloat) -> Void
    var onChanged: (CGFloat, CGFloat) -> Void
    var onEnded: (CGFloat, CGFloat, CGFloat, Bool) -> Void

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    func makeUIView(context: Context) -> InstallerView {
        let view = InstallerView()
        view.isUserInteractionEnabled = false
        view.coordinator = context.coordinator
        context.coordinator.markerView = view
        return view
    }

    func updateUIView(_ uiView: InstallerView, context: Context) {
        context.coordinator.update(
            direction: direction,
            isEnabled: isEnabled,
            onBegan: onBegan,
            onChanged: onChanged,
            onEnded: onEnded
        )
        context.coordinator.markerView = uiView
        context.coordinator.installIfPossible()
    }

    static func dismantleUIView(_ uiView: InstallerView, coordinator: Coordinator) {
        coordinator.detach()
    }

    final class InstallerView: UIView {
        weak var coordinator: Coordinator?

        override func didMoveToWindow() {
            super.didMoveToWindow()
            coordinator?.markerView = self
            coordinator?.installIfPossible()
        }

        override func layoutSubviews() {
            super.layoutSubviews()
            coordinator?.configureScrollViewFailureRequirements()
        }
    }

    final class Coordinator: NSObject, UIGestureRecognizerDelegate {
        weak var markerView: UIView?
        private weak var installedWindow: UIWindow?
        private let panGesture = UIPanGestureRecognizer()
        private var preparedScrollRecognizers: Set<ObjectIdentifier> = []

        private var direction: WorkspaceSwipeDirection = .left
        private var isEnabled = false
        private var onBegan: (CGFloat) -> Void = { _ in }
        private var onChanged: (CGFloat, CGFloat) -> Void = { _, _ in }
        private var onEnded: (CGFloat, CGFloat, CGFloat, Bool) -> Void = { _, _, _, _ in }

        override init() {
            super.init()
            panGesture.addTarget(self, action: #selector(handlePan(_:)))
            panGesture.cancelsTouchesInView = true
            panGesture.delaysTouchesBegan = false
            panGesture.delaysTouchesEnded = false
            panGesture.delegate = self
        }

        func update(
            direction: WorkspaceSwipeDirection,
            isEnabled: Bool,
            onBegan: @escaping (CGFloat) -> Void,
            onChanged: @escaping (CGFloat, CGFloat) -> Void,
            onEnded: @escaping (CGFloat, CGFloat, CGFloat, Bool) -> Void
        ) {
            self.direction = direction
            self.isEnabled = isEnabled
            self.onBegan = onBegan
            self.onChanged = onChanged
            self.onEnded = onEnded
            panGesture.isEnabled = isEnabled
            configureScrollViewFailureRequirements()
        }

        func installIfPossible() {
            guard let window = markerView?.window else {
                detach()
                return
            }

            guard installedWindow !== window else {
                configureScrollViewFailureRequirements()
                return
            }

            installedWindow?.removeGestureRecognizer(panGesture)
            window.addGestureRecognizer(panGesture)
            installedWindow = window
            configureScrollViewFailureRequirements()
        }

        func detach() {
            installedWindow?.removeGestureRecognizer(panGesture)
            installedWindow = nil
            preparedScrollRecognizers = []
        }

        func configureScrollViewFailureRequirements() {
            guard isEnabled, let markerView, let window = markerView.window else {
                return
            }

            let markerFrame = markerView.convert(markerView.bounds, to: window)
            for scrollView in window.sybilDescendantScrollViews {
                let recognizerID = ObjectIdentifier(scrollView.panGestureRecognizer)
                guard !preparedScrollRecognizers.contains(recognizerID) else {
                    continue
                }

                let scrollFrame = scrollView.convert(scrollView.bounds, to: window)
                if scrollFrame.intersects(markerFrame) {
                    scrollView.panGestureRecognizer.require(toFail: panGesture)
                    preparedScrollRecognizers.insert(recognizerID)
                }
            }
        }

        @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
            guard let markerView else {
                return
            }

            let width = max(markerView.bounds.width, 1)
            let translationX = recognizer.translation(in: markerView).x
            let velocityX = recognizer.velocity(in: markerView).x

            switch recognizer.state {
            case .began:
                onBegan(width)
                onChanged(translationX, width)

            case .changed:
                onChanged(translationX, width)

            case .ended:
                onEnded(translationX, width, velocityX, true)

            case .cancelled, .failed:
                onEnded(translationX, width, velocityX, false)

            case .possible:
                break

            @unknown default:
                onEnded(translationX, width, velocityX, false)
            }
        }

        func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
            guard isEnabled, gestureRecognizer === panGesture, let markerView else {
                return false
            }

            return markerView.bounds.contains(touch.location(in: markerView))
        }

        func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
            guard isEnabled, gestureRecognizer === panGesture, let markerView else {
                return false
            }

            let translation = panGesture.translation(in: markerView)
            let velocity = panGesture.velocity(in: markerView)
            switch direction {
            case .left:
                return NewChatSwipeMetrics.shouldBeginPan(
                    leftwardTravel: max(-translation.x, 0),
                    verticalTravel: abs(translation.y),
                    leftwardVelocity: max(-velocity.x, 0),
                    verticalVelocity: abs(velocity.y)
                )
            case .right:
                return BackSwipeMetrics.shouldBeginPan(
                    rightwardTravel: max(translation.x, 0),
                    verticalTravel: abs(translation.y),
                    rightwardVelocity: max(velocity.x, 0),
                    verticalVelocity: abs(velocity.y)
                )
            }
        }

        func gestureRecognizer(
            _ gestureRecognizer: UIGestureRecognizer,
            shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
        ) -> Bool {
            false
        }
    }
}

private extension UIView {
    var sybilDescendantScrollViews: [UIScrollView] {
        var scrollViews: [UIScrollView] = []
        collectSybilScrollViews(into: &scrollViews)
        return scrollViews
    }

    func collectSybilScrollViews(into scrollViews: inout [UIScrollView]) {
        if let scrollView = self as? UIScrollView {
            scrollViews.append(scrollView)
        }

        for subview in subviews {
            subview.collectSybilScrollViews(into: &scrollViews)
        }
    }
}

private struct SybilComposerFadeBackground: View {
    var body: some View {
        ZStack(alignment: .bottomLeading) {
            LinearGradient(
                colors: [
                    Color.clear,
                    SybilTheme.background.opacity(0.30),
                    SybilTheme.background.opacity(0.86),
                    SybilTheme.background.opacity(0.86),
                    SybilTheme.background.opacity(0.98)
                ],
                startPoint: .top,
                endPoint: .bottom
            )

            LinearGradient(
                colors: [
                    SybilTheme.primary.opacity(0.18),
                    SybilTheme.surface.opacity(0.16),
                    SybilTheme.accent.opacity(0.08)
                ],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .mask(
                LinearGradient(
                    colors: [
                        Color.clear,
                        Color.black.opacity(0.42),
                        Color.black
                    ],
                    startPoint: .top,
                    endPoint: .bottom
                )
            )
            .blendMode(.screen)

            RadialGradient(
                colors: [
                    SybilTheme.primary.opacity(0.28),
                    SybilTheme.primary.opacity(0.08),
                    Color.clear
                ],
                center: .bottomLeading,
                startRadius: 8,
                endRadius: 80
            )
            .blendMode(.screen)
            .offset(y: 42)
        }
        .ignoresSafeArea(edges: .bottom)
    }
}

private struct SybilNavigationIcon: View {
    var systemImage: String

    var body: some View {
        Image(systemName: systemImage)
            .font(.system(size: 21, weight: .semibold, design: .rounded))
            .foregroundStyle(SybilTheme.text)
            .frame(width: 46, height: 46)
            .contentShape(Rectangle())
            .shadow(color: SybilTheme.primary.opacity(0.34), radius: 12, x: 0, y: 0)
    }
}

private struct SybilNavigationFadeBackground: View {
    var body: some View {
        ZStack(alignment: .topLeading) {
            LinearGradient(
                colors: [
                    SybilTheme.background.opacity(1.0),
                    SybilTheme.background.opacity(0.90),
                    SybilTheme.background.opacity(0.80),
                    SybilTheme.background.opacity(0.80),
                    SybilTheme.background.opacity(0.28),
                    Color.clear
                ],
                startPoint: .top,
                endPoint: .bottom
            )

            RadialGradient(
                colors: [
                    SybilTheme.primary.opacity(0.36),
                    SybilTheme.primary.opacity(0.10),
                    Color.clear
                ],
                center: .topLeading,
                startRadius: 6,
                endRadius: 210
            )
            .blendMode(.screen)
            .offset(y: -46)
        }
        .frame(height: 200.0)
        .ignoresSafeArea(edges: .top)
    }
}

private struct SybilWorkspaceCharacterBackdrop: View {
    var isBusy: Bool

    var body: some View {
        ZStack(alignment: .topTrailing) {
            RadialGradient(
                colors: [
                    SybilTheme.primary.opacity(0.36),
                    SybilTheme.primary.opacity(0.13),
                    Color.clear
                ],
                center: .center,
                startRadius: 10,
                endRadius: 150
            )
            .frame(width: 136, height: 118)
            .blur(radius: 7)
            .offset(x: 28, y: -24)

            SybilAnimatedGIFView(resourceName: isBusy ? "character-busy" : "character-idle")
                .frame(width: 172, height: 172)
                .opacity(0.92)
                .mask {
                    LinearGradient(
                        colors: [
                            Color.black,
                            Color.black,
                            Color.black.opacity(0)
                        ],
                        startPoint: .top,
                        endPoint: .bottom
                    )
                }
                .mask {
                    LinearGradient(
                        colors: [
                            Color.clear,
                            Color.black.opacity(0.98),
                            Color.black
                        ],
                        startPoint: .leading,
                        endPoint: .trailing
                    )
                }
                .offset(x: 8, y: 4)
        }
        .frame(maxWidth: .infinity, minHeight: 156, maxHeight: 156, alignment: .topTrailing)
        .clipped()
        .ignoresSafeArea(edges: .top)
        .accessibilityHidden(true)
    }
}

private struct SybilAnimatedGIFView: UIViewRepresentable {
    var resourceName: String

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    func makeUIView(context: Context) -> UIImageView {
        let imageView = SybilAnimatedUIImageView()
        imageView.backgroundColor = .clear
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = false
        imageView.isOpaque = false
        imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
        imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
        imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
        return imageView
    }

    func updateUIView(_ imageView: UIImageView, context: Context) {
        guard context.coordinator.resourceName != resourceName else {
            if !imageView.isAnimating {
                imageView.startAnimating()
            }
            return
        }

        context.coordinator.resourceName = resourceName
        imageView.image = SybilAnimatedGIFCache.image(named: resourceName)
        imageView.startAnimating()
    }

    final class Coordinator {
        var resourceName: String?
    }
}

private final class SybilAnimatedUIImageView: UIImageView {
    override var intrinsicContentSize: CGSize {
        CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
    }
}

@MainActor
private enum SybilAnimatedGIFCache {
    private static var images: [String: UIImage] = [:]

    static func image(named name: String) -> UIImage? {
        if let cached = images[name] {
            return cached
        }

        guard let image = loadImage(named: name) else {
            return nil
        }

        images[name] = image

        return image
    }

    private static func loadImage(named name: String) -> UIImage? {
        let url = Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: "Character") ??
            Bundle.main.url(forResource: name, withExtension: "gif")

        guard let url,
              let data = try? Data(contentsOf: url),
              let source = CGImageSourceCreateWithData(data as CFData, nil)
        else {
            return nil
        }

        let frameCount = CGImageSourceGetCount(source)
        guard frameCount > 0 else {
            return nil
        }

        var frames: [UIImage] = []
        frames.reserveCapacity(frameCount)
        var duration: TimeInterval = 0

        for index in 0 ..< frameCount {
            guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else {
                continue
            }
            frames.append(UIImage(cgImage: cgImage))
            duration += frameDuration(at: index, source: source)
        }

        guard !frames.isEmpty else {
            return nil
        }

        if frames.count == 1 {
            return frames[0]
        }

        return UIImage.animatedImage(with: frames, duration: max(duration, 0.1))
    }

    private static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
        guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
              let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
        else {
            return 0.08
        }

        let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
        let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval ?? 0.08
        return max(delay, 0.03)
    }
}

private struct NewChatSwipeBackdrop: View {
    var progress: CGFloat
    var hasLatched: Bool

    private var clampedProgress: CGFloat {
        min(max(progress, 0), 1)
    }

    var body: some View {
        ZStack(alignment: .trailing) {
            Circle()
                .fill((hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.16 + (0.18 * clampedProgress)))
                .frame(width: 176, height: 176)
                .blur(radius: 44)
                .offset(x: 38, y: 18)

            ZStack {
                Circle()
                    .fill(
                        RadialGradient(
                            colors: [
                                (hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.28),
                                SybilTheme.surface.opacity(0.78)
                            ],
                            center: .topLeading,
                            startRadius: 8,
                            endRadius: 58
                        )
                    )
                    .overlay(
                        Circle()
                            .stroke(
                                (hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.44 + (0.24 * clampedProgress)),
                                lineWidth: 1
                            )
                    )
                    .shadow(
                        color: (hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.24 + (0.20 * clampedProgress)),
                        radius: 24,
                        x: 0,
                        y: 0
                    )

                Circle()
                    .fill(
                        AngularGradient(
                            colors: [
                                (hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.20),
                                Color.clear,
                                (hasLatched ? SybilTheme.accent : SybilTheme.primary).opacity(0.34)
                            ],
                            center: .center
                        )
                    )
                    .frame(width: 72, height: 72)
                    .blur(radius: 10)

                Image(systemName: "plus")
                    .font(.system(size: 31, weight: .bold, design: .rounded))
                    .foregroundStyle(SybilTheme.text)
                    .symbolEffect(.bounce, value: hasLatched)
            }
            .frame(width: 92, height: 92)
            .background(
                Circle()
                    .fill(SybilTheme.surface.opacity(0.42))
                    .blur(radius: 16)
            )
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
        .opacity(clampedProgress)
        .offset(x: (1 - clampedProgress) * 28)
        .animation(.easeOut(duration: 0.16), value: hasLatched)
        .accessibilityHidden(true)
    }
}
