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 @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 == .back) } 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.isSending, 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.isSending) .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, topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0, bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0 ) .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 var customWorkspaceNavigationBar: some View { HStack(spacing: 14) { workspaceNavigationLeadingControl Text(viewModel.selectedTitle) .font(.sybil(size: 16, weight: .semibold)) .foregroundStyle(SybilTheme.text) .lineLimit(1) .minimumScaleFactor(0.78) .frame(maxWidth: .infinity, alignment: .leading) .multilineTextAlignment(.leading) workspaceNavigationTrailingControl } .padding(.horizontal, 16) .padding(.top, 10) .padding(.bottom, 34) .background(alignment: .top) { SybilNavigationFadeBackground() .allowsHitTesting(false) } } @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(@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(Provider.allCases, 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.isSending ? SybilTheme.textMuted : SybilTheme.text) } .buttonStyle(.plain) .disabled(viewModel.isSending) .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() } .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.isSending { 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) if !viewModel.isSearchMode { 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 = [] 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) } }