diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index ee687d86d006..aaba055877a7 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -25,7 +25,7 @@ private struct T3ConnectManagedCleanupError: LocalizedError { @MainActor final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, FeatureProjectCreationClient, FeatureWorkspaceAssetResolving, FeatureAttachmentAssetResolving, - FeatureFeedbackSubmitting, T3ConnectCapable + FeatureFeedbackSubmitting, FeatureContextAttachmentResolving, T3ConnectCapable { private static let maximumRetainedThreadDetails = 6 private static let t3ConnectLogger = Logger( @@ -1529,7 +1529,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, shell: shellsByEnvironmentID[environment.id] ) let title = Self.title(from: prompt, hasAttachments: !attachments.isEmpty) - let uploads = try makeUploadAttachments(attachments) + let uploads = try await makeUploadAttachments(attachments) if !uploads.isEmpty { _ = try await client.serverConfig() } let runtime = coreRuntimeMode(runtimeMode) let interaction = coreInteractionMode(interactionMode) @@ -2208,7 +2208,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, throw NativeFeatureClientError.threadNotFound } let model = selection.map(coreModelSelection) - let uploads = try makeUploadAttachments(attachments) + let uploads = try await makeUploadAttachments(attachments) if !uploads.isEmpty { _ = try await client.serverConfig() } let runtimeMode = coreRuntimeMode( requestedRuntimeMode ?? mapRuntimeMode(shellThread.runtimeMode) @@ -2474,11 +2474,15 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, throw NativeFeatureClientError.inputRequestNotFound } let route = try threadRoute(for: request.threadID) + var uploads: [String: [UploadChatAttachment]] = [:] + for (questionID, attachments) in attachmentsByQuestionID { + uploads[questionID] = try await makeUploadAttachments(attachments) + } _ = try await route.client.respondToUserInput( threadID: route.wireID, requestID: request.wireID, answers: answers.mapValues(\.jsonValue), - attachmentsByQuestionID: try attachmentsByQuestionID.mapValues(makeUploadAttachments) + attachmentsByQuestionID: uploads ) inputRoutes[id] = nil removeCachedInput(id: id, threadID: route.uiID) @@ -3433,9 +3437,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, ) async throws -> FeatureUploadedAttachmentReference? { let client = try await projectCreationClient(environmentID: environmentID) _ = try await client.serverConfig() - let prepared = try await client.prepareAttachment( - makeUploadAttachments([attachment])[0] - ) + let uploads = try await makeUploadAttachments([attachment]) + let prepared = try await client.prepareAttachment(uploads[0]) return prepared.map { FeatureUploadedAttachmentReference( environmentID: $0.environmentID, @@ -7097,6 +7100,25 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, return resolved.url } + func contextAttachmentAssetURL( + environmentID: String, attachment: ComposerContextRecord.Attachment + ) async throws -> URL { + try Task.checkCancellation() + guard try await runtime.environments().contains(where: { $0.id == environmentID && $0.isEnabled }) else { + throw ComposerContextClipboardError.sourceUnavailable + } + let client = try await projectCreationClient(environmentID: environmentID) + let generation = environmentGeneration + let resolved = try await client.resolvedAsset(resource: .attachment( + id: attachment.attachmentId, fileName: attachment.name, mimeType: attachment.mimeType + )) + try Task.checkCancellation() + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + throw CancellationError() + } + return resolved.url + } + private func lastActivityDate( latestUserMessageAt: String?, latestTurn: OrchestrationLatestTurn? @@ -7161,37 +7183,47 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, private func makeUploadAttachments( _ attachments: [FeatureUploadAttachment] - ) throws -> [UploadChatAttachment] { + ) async throws -> [UploadChatAttachment] { guard attachments.count <= 8 else { throw NativeFeatureClientError.tooManyAttachments } - return try attachments.map { - let reference = $0.uploadedReference.map { - UploadedAttachmentReference( - environmentID: $0.environmentID, - attachmentID: $0.attachmentID - ) - } - if let ownedFile = $0.ownedFile { + let uploads = try await Task.detached(priority: .userInitiated) { + try attachments.map { + let reference = $0.uploadedReference.map { + UploadedAttachmentReference( + environmentID: $0.environmentID, + attachmentID: $0.attachmentID + ) + } + if let ownedFile = $0.ownedFile { + if $0.mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().hasPrefix("image/") { + return try UploadChatAttachment( + id: $0.id, data: Data(contentsOf: ownedFile.url), + name: $0.name, mimeType: $0.mimeType, uploadedReference: reference + ) + } + return try UploadChatAttachment( + id: $0.id, + fileURL: ownedFile.url, + name: $0.name, + mimeType: $0.mimeType, + sizeBytes: ownedFile.byteCount, + uploadedReference: reference, + contextSource: $0.source + ) + } return try UploadChatAttachment( id: $0.id, - fileURL: ownedFile.url, + data: $0.data, name: $0.name, mimeType: $0.mimeType, - sizeBytes: ownedFile.byteCount, uploadedReference: reference, contextSource: $0.source ) } - return try UploadChatAttachment( - id: $0.id, - data: $0.data, - name: $0.name, - mimeType: $0.mimeType, - uploadedReference: reference, - contextSource: $0.source - ) - } + }.value + try Task.checkCancellation() + return uploads } private func requireScope(_ scope: String, client: T3Client) async throws { diff --git a/apps/swift-ios/Core/ComposerContext.swift b/apps/swift-ios/Core/ComposerContext.swift index f7386c412afd..53386c77d0cc 100644 --- a/apps/swift-ios/Core/ComposerContext.swift +++ b/apps/swift-ios/Core/ComposerContext.swift @@ -4,7 +4,7 @@ import Foundation public struct ComposerContextRecord: Codable, Equatable, Hashable, Sendable, Identifiable { public var id: String { contextId } public let version: Int - public let contextId: String + public var contextId: String public let label: String public var payload: Payload @@ -103,7 +103,7 @@ public struct ComposerContextRecord: Codable, Equatable, Hashable, Sendable, Ide public let regionCount: Int? public let strokeCount: Int? public let styleChangeDetails: [StyleChange]? - public let screenshotContextId: String? + public var screenshotContextId: String? private enum CodingKeys: String, CodingKey { case annotationId, pageUrl, pageTitle, comment, targetSummary, styleChanges diff --git a/apps/swift-ios/Core/ComposerContextClipboard.swift b/apps/swift-ios/Core/ComposerContextClipboard.swift new file mode 100644 index 000000000000..b5c3a3efd49a --- /dev/null +++ b/apps/swift-ios/Core/ComposerContextClipboard.swift @@ -0,0 +1,154 @@ +import Foundation + +public struct ComposerContextClipboardFragment: Codable, Equatable, Sendable { + public struct Source: Codable, Equatable, Sendable { + public let environmentId: String + public var threadId: String? + public var messageId: String? + + public init(environmentId: String, threadId: String? = nil, messageId: String? = nil) { + self.environmentId = environmentId + self.threadId = threadId + self.messageId = messageId + } + } + + public let version: Int + public let source: Source + public let records: [ComposerContextRecord] + + public init(source: Source, records: [ComposerContextRecord]) { + version = 1 + self.source = source + self.records = records + } +} + +public enum ComposerContextClipboardError: LocalizedError, Equatable { + case invalidFragment + case tooLarge + case missingRecord + case contextLimit + case sourceUnavailable + case draftChanged + + public var errorDescription: String? { + switch self { + case .invalidFragment: "The clipboard context is invalid. Copy it again from the source." + case .tooLarge: "This context is too large to copy. Select fewer items." + case .missingRecord: "A copied context item is missing its saved data. Copy it again from the source." + case .contextLimit: "A message can have up to 200 context items. Remove some items and paste again." + case .sourceUnavailable: "Reconnect to the source environment to paste its attachments." + case .draftChanged: "The draft changed while the paste was loading. Paste again." + } + } +} + +/// Matches the web/native clipboard contract. Binary data and resolved asset URLs stay out of this payload. +public enum ComposerContextClipboard { + public static let mimeType = "web application/x-t3-context-fragment+json" + public static let maximumCharacters = 16_000_000 + public static let maximumRecords = 200 + + public struct Content: Equatable, Sendable { + public let text: String + public let fragment: ComposerContextClipboardFragment + } + + public static func encode(_ fragment: ComposerContextClipboardFragment) throws -> String { + try validate(fragment) + let data = try JSONEncoder.t3.encode(fragment) + let text = String(decoding: data, as: UTF8.self) + guard text.utf16.count <= maximumCharacters else { throw ComposerContextClipboardError.tooLarge } + return text + } + + public static func decode(_ text: String) throws -> ComposerContextClipboardFragment { + guard text.utf16.count <= maximumCharacters else { throw ComposerContextClipboardError.tooLarge } + let fragment: ComposerContextClipboardFragment + do { + // Decode every record. An invalid record must not turn into a broken pasted link. + fragment = try JSONDecoder.t3.decode(ComposerContextClipboardFragment.self, from: Data(text.utf8)) + } catch { + throw ComposerContextClipboardError.invalidFragment + } + try validate(fragment) + return fragment + } + + private static func validate(_ fragment: ComposerContextClipboardFragment) throws { + guard fragment.version == 1, !fragment.source.environmentId.isEmpty, + Set(fragment.records.map(\.contextId)).count == fragment.records.count else { + throw ComposerContextClipboardError.invalidFragment + } + guard fragment.records.count <= maximumRecords else { throw ComposerContextClipboardError.contextLimit } + guard fragment.records.allSatisfy({ record in + record.label.utf16.count <= 200 + && ComposerContextReferences.parseHref("t3-context://v1/\(record.kind)/\(record.contextId)") != nil + }) else { throw ComposerContextClipboardError.invalidFragment } + } + + public static func html(text: String, fragment: String) -> String { + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~") + let encoded = fragment.addingPercentEncoding(withAllowedCharacters: allowed) ?? "" + let escaped = text.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<").replacingOccurrences(of: ">", with: ">") + return "
\(escaped)
" + } + + public static func decodeHTML(_ html: String) throws -> ComposerContextClipboardFragment? { + guard html.utf16.count <= maximumCharacters * 9 + 4_096 else { + throw ComposerContextClipboardError.tooLarge + } + let expression = try NSRegularExpression(pattern: #"data-t3-context-fragment=["']([^"']+)["']"#) + guard let match = expression.firstMatch(in: html, range: NSRange(location: 0, length: html.utf16.count)) else { + return nil + } + guard let decoded = (html as NSString).substring(with: match.range(at: 1)).removingPercentEncoding else { + throw ComposerContextClipboardError.invalidFragment + } + return try decode(decoded) + } + + /// Include only selected records and the screenshots required by selected annotations. + public static func selected(text: String, fragment: ComposerContextClipboardFragment) throws -> Content { + try validate(fragment) + let references = ComposerContextReferences.collect(text) + let byID = Dictionary(uniqueKeysWithValues: fragment.records.map { ($0.contextId, $0) }) + var ids = Set() + for reference in references { + guard let record = byID[reference.contextId], record.kind == reference.kind else { + throw ComposerContextClipboardError.missingRecord + } + ids.insert(record.contextId) + if case let .previewAnnotation(annotation) = record.payload, let screenshot = annotation.screenshotContextId { + guard byID[screenshot]?.kind == "image" else { throw ComposerContextClipboardError.missingRecord } + ids.insert(screenshot) + } + } + return Content(text: text, fragment: .init(source: fragment.source, records: fragment.records.filter { ids.contains($0.contextId) })) + } + + public static func reidentified(_ content: Content, createID: () -> String = { UUID().uuidString }) throws -> Content { + let selected = try selected(text: content.text, fragment: content.fragment) + let ids = Dictionary(uniqueKeysWithValues: selected.fragment.records.map { ($0.contextId, createID()) }) + guard Set(ids.values).count == ids.count else { throw ComposerContextClipboardError.invalidFragment } + let text = ComposerContextReferences.replace(selected.text) { reference in + let original = (selected.text as NSString).substring(with: reference.range) + return original.replacingOccurrences( + of: "t3-context://v1/\(reference.kind)/\(reference.contextId)", + with: "t3-context://v1/\(reference.kind)/\(ids[reference.contextId]!)" + ) + } + let records = selected.fragment.records.map { record in + var result = record + result.contextId = ids[record.contextId]! + if case var .previewAnnotation(annotation) = record.payload, let screenshot = annotation.screenshotContextId { + annotation.screenshotContextId = ids[screenshot]! + result.payload = .previewAnnotation(annotation) + } + return result + } + return Content(text: text, fragment: .init(source: selected.fragment.source, records: records)) + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift b/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift index 71b5f46efd65..7b0ce5c45926 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift @@ -26,6 +26,8 @@ struct FeatureComposerTextInput: UIViewRepresentable { var onPasteTextAttachment: ((String, @escaping @MainActor () -> Bool) -> Void)? = nil var onPasteTextError: ((String) -> Void)? = nil var draftOwnerID: String = "" + var onCopyContext: ((String) throws -> Bool)? = nil + var onPasteContext: ((ComposerContextClipboard.Content, String, NSRange) -> Void)? = nil func makeCoordinator() -> Coordinator { Coordinator(self) @@ -42,6 +44,11 @@ struct FeatureComposerTextInput: UIViewRepresentable { textView.onPasteTextAttachment = onPasteTextAttachment textView.draftOwnerID = draftOwnerID textView.onPasteTextError = onPasteTextError + textView.onCopySelection = { selected in + try onCopyContext?(FeatureInlineSkillProjection.plainText(from: selected)) ?? false + } + textView.onCopyError = onPasteTextError + textView.onPasteContext = onPasteContext if onDismissKeyboard != nil { textView.installDismissPanRecognizer() } @@ -76,6 +83,11 @@ struct FeatureComposerTextInput: UIViewRepresentable { textView.onPasteTextAttachment = onPasteTextAttachment textView.draftOwnerID = draftOwnerID textView.onPasteTextError = onPasteTextError + textView.onCopySelection = { selected in + try onCopyContext?(FeatureInlineSkillProjection.plainText(from: selected)) ?? false + } + textView.onCopyError = onPasteTextError + textView.onPasteContext = onPasteContext textView.isReadOnly = isReadOnly let previousAttributedText = textView.attributedText ?? NSAttributedString() @@ -488,6 +500,7 @@ final class FeatureComposerUITextView: FeatureInlineSkillTextView { var draftOwnerID = "" private var pastedTextRequestID = UUID() var onPasteTextError: ((String) -> Void)? + var onPasteContext: ((ComposerContextClipboard.Content, String, NSRange) -> Void)? private var wantsFirstResponderOnAttach = false override var keyCommands: [UIKeyCommand]? { @@ -625,6 +638,22 @@ final class FeatureComposerUITextView: FeatureInlineSkillTextView { // attached screenshot reads as a bug. override func paste(_ sender: Any?) { guard !isReadOnly else { return } + do { + if let content = try FeatureContextClipboard.read() { + guard let onPasteContext, markedTextRange == nil else { + throw ComposerContextClipboardError.draftChanged + } + onPasteContext( + content, + FeatureInlineSkillProjection.plainText(from: attributedText), + FeatureInlineSkillProjection.plainRange(for: selectedRange, in: attributedText) + ) + return + } + } catch { + onPasteTextError?(error.localizedDescription) + return + } if acceptsImages { let imageProviders = UIPasteboard.general.itemProviders.filter { $0.hasItemConformingToTypeIdentifier(UTType.image.identifier) diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift index 389e1974641d..ba17e50d2652 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerView.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -44,6 +44,8 @@ struct FeatureComposerView: View { @State private var pastedTextErrorMessage: String? @State private var pastedTextTask: Task? @State private var pastedTextGeneration = UUID() + @State private var contextImportTask: Task? + @State private var contextImportID: UUID? @State private var textRevision: UInt64 = 0 @State private var textObservation = FeatureComposerTextObservation() @State private var voiceInputController = FeatureVoiceInputController() @@ -57,6 +59,7 @@ struct FeatureComposerView: View { private let environmentID: String? private let draftStorageKey: String? private let environmentIsConnected: Bool + private let contextAttachmentResolver: (any FeatureContextAttachmentResolving)? private let attachmentUploads: FeatureAttachmentUploadCoordinator private let attachmentPreferences: FeatureEnvironmentPreferences private let onRefreshModels: (() async throws -> Void)? @@ -115,12 +118,14 @@ struct FeatureComposerView: View { draftSaveError: String? = nil, onRetryDraftSave: (() -> Void)? = nil, context: Binding = .constant(nil), - onInputPreparationChange: ((Bool) -> Void)? = nil + onInputPreparationChange: ((Bool) -> Void)? = nil, + contextAttachmentResolver: (any FeatureContextAttachmentResolving)? = nil ) { _text = text _selection = selection _attachments = attachments _context = context + self.contextAttachmentResolver = contextAttachmentResolver self.draftOwnerID = draftOwnerID self.environmentID = environmentID self.draftStorageKey = draftStorageKey @@ -214,15 +219,26 @@ struct FeatureComposerView: View { voiceInputController.cancel() pastedTextTask?.cancel() pastedTextGeneration = UUID() + contextImportTask?.cancel() } - .onChange(of: text) { + .onChange(of: text) { previous, _ in textRevision &+= 1 synchronizeVoiceDraft(ownerChanged: false) + removeUnlinkedContextAttachments(previousText: previous) + } + .onChange(of: attachments) { previous, _ in + removeDeletedAttachmentReferences(previousAttachments: previous) } .onChange(of: draftOwnerID) { synchronizeVoiceDraft(ownerChanged: true) pastedTextTask?.cancel() pastedTextGeneration = UUID() + contextImportTask?.cancel() + } + .onChange(of: environmentID) { + contextImportTask?.cancel() + pastedTextTask?.cancel() + pastedTextGeneration = UUID() } .onChange(of: voiceInputController.pendingCommit?.id) { applyPendingVoiceCommit() @@ -252,7 +268,7 @@ struct FeatureComposerView: View { Text(imageIntakeErrorMessage ?? "") } .alert( - "Could not paste text", + "Clipboard error", isPresented: Binding( get: { pastedTextErrorMessage != nil }, set: { if !$0 { pastedTextErrorMessage = nil } } @@ -306,12 +322,12 @@ struct FeatureComposerView: View { .stroke(T3Colors.inputBorder, lineWidth: 1) } .clipShape(composerShape) - .onChange(of: attachmentPreparation.isPreparing || isAttachmentFlowActive || voiceInputController.isBusy, initial: true) { _, busy in + .onChange(of: attachmentPreparation.isPreparing || isAttachmentFlowActive || voiceInputController.isBusy || contextImportID != nil, initial: true) { _, busy in onInputPreparationChange?(busy) } .modifier( FeatureComposerImageDrop( - isEnabled: imagesAllowed && !voiceInputController.isBusy, + isEnabled: imagesAllowed && !voiceInputController.isBusy && contextImportID == nil, shape: composerShape, onDropImages: attachDroppedImages ) @@ -372,7 +388,7 @@ struct FeatureComposerView: View { focused: $focused, placeholder: composerPlaceholder, acceptsImages: imagesAllowed, - isReadOnly: voiceInputController.isBusy || !isEnabled, + isReadOnly: voiceInputController.isBusy || !isEnabled || contextImportID != nil, skills: powerFeatures.enabledSkills, selectionRequest: textSelectionRequest, onSelectionChange: handleTextSelectionChange, @@ -381,7 +397,9 @@ struct FeatureComposerView: View { maximumPastedTextBytes: maximumPastedTextBytes, onPasteTextAttachment: attachPastedText, onPasteTextError: { pastedTextErrorMessage = $0 }, - draftOwnerID: draftOwnerID + draftOwnerID: draftOwnerID, + onCopyContext: copyContext, + onPasteContext: pasteContext ) .padding(.horizontal, 16) .padding(.top, 14) @@ -420,6 +438,17 @@ struct FeatureComposerView: View { .accessibilityIdentifier("attachment-preparing") } + if contextImportID != nil { + HStack { + Text("Importing context") + Spacer() + Button("Cancel paste") { contextImportTask?.cancel() } + } + .font(T3Typography.supporting) + .padding(.horizontal, 15) + .padding(.bottom, 4) + } + if let draftSaveError { HStack(spacing: 8) { Text(draftSaveError).lineLimit(3) @@ -442,6 +471,7 @@ struct FeatureComposerView: View { } composerFooter + .disabled(contextImportID != nil) .fixedSize(horizontal: false, vertical: true) .layoutPriority(1) } @@ -1027,7 +1057,7 @@ struct FeatureComposerView: View { /// from another app through the same preparation pipeline the attachment /// picker uses, so sending stays blocked until every image is processed. private func attachImageProviders(_ providers: [NSItemProvider]) { - guard imagesAllowed, !providers.isEmpty else { return } + guard imagesAllowed, !providers.isEmpty, contextImportID == nil else { return } guard let plan = FeatureComposerImageIntakePlan.forProviders( providerCount: providers.count, @@ -1077,8 +1107,114 @@ struct FeatureComposerView: View { ) } + private func copyContext(_ selectedText: String) throws -> Bool { + try FeatureContextClipboard.write( + text: selectedText, + source: environmentID.map { .init(environmentId: $0) }, + context: context, attachments: attachments + ) + } + + private func pasteContext(_ content: ComposerContextClipboard.Content, _ originalText: String, _ range: NSRange) { + guard contextImportID == nil, !voiceInputController.isBusy else { return } + pastedTextTask?.cancel() + pastedTextGeneration = UUID() + let originalContext = context + let originalAttachments = attachments + let owner = draftOwnerID + let environment = environmentID + let importID = UUID() + contextImportID = importID + let importer = FeatureContextClipboardImporter(resolver: contextAttachmentResolver) + contextImportTask = Task { @MainActor in + defer { + if contextImportID == importID { + contextImportID = nil + contextImportTask = nil + } + } + do { + guard range.location != NSNotFound, NSMaxRange(range) <= originalText.utf16.count else { + throw ComposerContextClipboardError.draftChanged + } + let remainingText = (originalText as NSString).replacingCharacters(in: range, with: "") + let remainingContext = ComposerContextReferences.referenced(originalContext, text: remainingText) + let removedIDs = FeatureContextClipboardEdit.unlinkedAttachmentIDs( + context: originalContext, previousText: originalText, text: remainingText + ) + let remainingAttachmentCount = originalAttachments.filter { + !removedIDs.contains($0.id.uuidString.lowercased()) + }.count + let result = try await importer.importContent( + content, attachmentCount: remainingAttachmentCount + attachmentPreparation.pendingItemCount, + contextCount: remainingContext?.records.count ?? 0, + imagesAllowed: imagesAllowed, + maximumFileBytes: attachmentPreferences.maxFileAttachmentBytes + ) + var committed = false + defer { if !committed { result.discardFiles(using: importer.fileStore) } } + try Task.checkCancellation() + guard owner == draftOwnerID, environment == environmentID, text == originalText, + context == originalContext, attachments == originalAttachments, + textObservation.selection == range else { throw ComposerContextClipboardError.draftChanged } + for attachment in result.attachments { + if attachment.mimeType.hasPrefix("image/") { + guard imagesAllowed else { throw FeatureAttachmentIntakeError.imagesUnsupported } + } else { + guard let maximum = attachmentPreferences.maxFileAttachmentBytes else { throw FileAttachmentError.unsupported } + guard attachment.byteCount <= maximum else { + throw FileAttachmentError.tooLarge(actualBytes: attachment.byteCount, maximumBytes: maximum) + } + } + } + let edit = try FeatureContextClipboardEdit.apply( + text: originalText, selection: range, context: originalContext, + attachments: originalAttachments, imported: result + ) + context = edit.context + text = edit.text + textSelectionRequest = FeatureComposerTextSelectionRequest(location: edit.cursor) + attachments = edit.attachments + committed = true + } catch { + if owner == draftOwnerID, environment == environmentID { + pastedTextErrorMessage = error is CancellationError + ? "Paste cancelled. Your draft has not changed." + : error.localizedDescription + } + } + } + } + + private func removeUnlinkedContextAttachments(previousText: String) { + let removedIDs = FeatureContextClipboardEdit.unlinkedAttachmentIDs( + context: context, previousText: previousText, text: text + ) + guard !removedIDs.isEmpty else { return } + attachments.removeAll { removedIDs.contains($0.id.uuidString.lowercased()) } + } + + private func removeDeletedAttachmentReferences(previousAttachments: [FeatureDraftAttachment]) { + let removedIDs = Set(previousAttachments.map { $0.id.uuidString.lowercased() }) + .subtracting(attachments.map { $0.id.uuidString.lowercased() }) + guard !removedIDs.isEmpty else { return } + var removedContextIDs = Set((context?.records ?? []).filter { + $0.attachment.map { removedIDs.contains($0.attachmentId.lowercased()) } ?? false + }.map(\.contextId)) + for record in context?.records ?? [] { + if case let .previewAnnotation(annotation) = record.payload, + let screenshot = annotation.screenshotContextId, removedContextIDs.contains(screenshot) { + removedContextIDs.insert(record.contextId) + } + } + guard !removedContextIDs.isEmpty else { return } + text = ComposerContextReferences.replace(text) { reference in + removedContextIDs.contains(reference.contextId) ? "" : (text as NSString).substring(with: reference.range) + } + } + private func attachPastedText(_ pastedText: String, commitSelection: @escaping @MainActor () -> Bool) { - guard !voiceInputController.isBusy, !pastedText.isEmpty, + guard !voiceInputController.isBusy, contextImportID == nil, !pastedText.isEmpty, let maximumPastedTextBytes, pastedText.utf8.count <= maximumPastedTextBytes else { pastedTextErrorMessage = "Could not attach pasted text. Your draft has not changed." @@ -1124,7 +1260,7 @@ struct FeatureComposerView: View { /// A drop is refused outright when images are not accepted, so the drag /// session shows the system's "not allowed" badge instead of a dead drop. private func attachDroppedImages(_ providers: [NSItemProvider]) -> Bool { - guard imagesAllowed, !providers.isEmpty else { return false } + guard imagesAllowed, !providers.isEmpty, contextImportID == nil else { return false } attachImageProviders(providers) return true } diff --git a/apps/swift-ios/Features/Chat/FeatureContextClipboardEdit.swift b/apps/swift-ios/Features/Chat/FeatureContextClipboardEdit.swift new file mode 100644 index 000000000000..3e29df5c772d --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureContextClipboardEdit.swift @@ -0,0 +1,51 @@ +import Foundation + +@MainActor +enum FeatureContextClipboardEdit { + struct Result { + let text: String + let context: OrchestrationMessageContext? + let attachments: [FeatureDraftAttachment] + let cursor: Int + } + + static func unlinkedAttachmentIDs( + context: OrchestrationMessageContext?, previousText: String, text: String + ) -> Set { + let previous = ComposerContextReferences.referenced(context, text: previousText)?.records ?? [] + let remaining = ComposerContextReferences.referenced(context, text: text)?.records ?? [] + let liveIDs = Set(remaining.compactMap { $0.attachment?.attachmentId.lowercased() }) + return Set(previous.compactMap { $0.attachment?.attachmentId.lowercased() }).subtracting(liveIDs) + } + + static func apply( + text: String, selection: NSRange, context: OrchestrationMessageContext?, + attachments: [FeatureDraftAttachment], imported: FeatureContextClipboardImporter.Result + ) throws -> Result { + guard selection.location != NSNotFound, selection.location >= 0, + selection.length >= 0, selection.location <= text.utf16.count, + selection.length <= text.utf16.count - selection.location else { + throw ComposerContextClipboardError.draftChanged + } + let remainingText = (text as NSString).replacingCharacters(in: selection, with: "") + let updatedText = (text as NSString).replacingCharacters(in: selection, with: imported.text) + guard updatedText.utf16.count <= FeaturePastedText.maximumInputCharacters else { + throw ComposerContextClipboardError.tooLarge + } + let remainingContext = ComposerContextReferences.referenced(context, text: remainingText) + let merged = try FeatureComposerContext.merge(remainingContext, imported.context) + if let merged { + let encoded = try JSONEncoder.t3.encode(merged) + guard String(decoding: encoded, as: UTF8.self).utf16.count <= ComposerContextClipboard.maximumCharacters else { + throw ComposerContextClipboardError.tooLarge + } + } + let removedIDs = unlinkedAttachmentIDs(context: context, previousText: text, text: remainingText) + let updatedAttachments = attachments.filter { !removedIDs.contains($0.id.uuidString.lowercased()) } + imported.attachments + guard updatedAttachments.count <= FeatureImageAttachmentLimits.maximumCount else { + throw FileAttachmentError.tooMany(maximum: FeatureImageAttachmentLimits.maximumCount) + } + return Result(text: updatedText, context: merged, attachments: updatedAttachments, + cursor: selection.location + imported.text.utf16.count) + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureInlineSkillPill.swift b/apps/swift-ios/Features/Chat/FeatureInlineSkillPill.swift index f581dac7408b..66a3e1d6c25a 100644 --- a/apps/swift-ios/Features/Chat/FeatureInlineSkillPill.swift +++ b/apps/swift-ios/Features/Chat/FeatureInlineSkillPill.swift @@ -389,7 +389,11 @@ enum FeatureInlineSkillPillRenderer { /// Makes selected pill text portable. UIKit otherwise copies an attachment as /// rich image data or the object-replacement character instead of `$skill`. class FeatureInlineSkillTextView: UITextView { + var onCopySelection: ((NSAttributedString) throws -> Bool)? + var onCopyError: ((String) -> Void)? + override func copy(_ sender: Any?) { + if copyContextSelection() != nil { return } guard let selectedPlainText else { super.copy(sender) return @@ -398,6 +402,10 @@ class FeatureInlineSkillTextView: UITextView { } override func cut(_ sender: Any?) { + if let copied = copyContextSelection() { + if copied { insertText("") } + return + } guard let selectedPlainText else { super.cut(sender) return @@ -406,6 +414,18 @@ class FeatureInlineSkillTextView: UITextView { UIPasteboard.general.string = selectedPlainText } + /// A failed rich copy claims the action but must not delete the selected text during Cut. + private func copyContextSelection() -> Bool? { + guard let onCopySelection, selectedRange.location != NSNotFound, + selectedRange.length > 0, NSMaxRange(selectedRange) <= attributedText.length else { return nil } + do { + return try onCopySelection(attributedText.attributedSubstring(from: selectedRange)) ? true : nil + } catch { + onCopyError?(error.localizedDescription) + return false + } + } + private var selectedPlainText: String? { let range = selectedRange guard range.location != NSNotFound, diff --git a/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift index 66d405f643f5..97b896a69d0e 100644 --- a/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift +++ b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift @@ -715,6 +715,16 @@ enum FeatureImageProcessor { private static let maximumDimension: CGFloat = 2_048 private static let maximumEncodedBytes = 10 * 1_024 * 1_024 + static func thumbnail(fileURL: URL) -> Data? { + guard let source = CGImageSourceCreateWithURL(fileURL as CFURL, nil), + let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 160, + ] as CFDictionary) else { return nil } + return UIImage(cgImage: image).jpegData(compressionQuality: 0.72) + } + static func attachment( from sourceData: Data, ordinal: Int diff --git a/apps/swift-ios/Features/Chat/MarkdownMessageView.swift b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift index 481b8308d788..eb22dd58f003 100644 --- a/apps/swift-ios/Features/Chat/MarkdownMessageView.swift +++ b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift @@ -28,6 +28,10 @@ struct MarkdownMessageView: View { private let copyActionTitle: String private let imageContext: MarkdownImageContext? private let skills: [FeatureProviderSkill] + private let clipboardSource: ComposerContextClipboardFragment.Source? + private let messageContext: OrchestrationMessageContext? + private let copyText: String? + @State private var copyError: String? @State private var selectionSource: MarkdownSelectionSource @State private var renderedDocument: MarkdownRenderedDocument? @State private var streamingRenderer = StreamingMarkdownRenderer() @@ -37,13 +41,19 @@ struct MarkdownMessageView: View { isStreaming: Bool = false, copyActionTitle: String = "Copy message", imageContext: MarkdownImageContext? = nil, - skills: [FeatureProviderSkill] = [] + skills: [FeatureProviderSkill] = [], + clipboardSource: ComposerContextClipboardFragment.Source? = nil, + messageContext: OrchestrationMessageContext? = nil, + copyText: String? = nil ) { self.source = source self.isStreaming = isStreaming self.copyActionTitle = copyActionTitle self.imageContext = imageContext self.skills = skills + self.clipboardSource = clipboardSource + self.messageContext = messageContext + self.copyText = copyText _selectionSource = State(initialValue: MarkdownSelectionSource(source)) let revision = MarkdownContentRevision(source) self.revision = revision @@ -76,8 +86,13 @@ struct MarkdownMessageView: View { } } .accessibilityAction(named: copyActionTitle) { - UIPasteboard.general.string = source + selectionSource.copyMessage() } + .alert("Could not copy context", isPresented: Binding( + get: { copyError != nil }, set: { if !$0 { copyError = nil } } + )) { + Button("OK") { copyError = nil } + } message: { Text(copyError ?? "") } .task(id: RenderRequest(revision: revision, isStreaming: isStreaming)) { if !isStreaming { streamingRenderer.cancel() @@ -130,6 +145,10 @@ struct MarkdownMessageView: View { private var selectionContext: MarkdownSelectionContext { selectionSource.text = source + selectionSource.originalText = copyText ?? source + selectionSource.clipboardSource = clipboardSource + selectionSource.messageContext = messageContext + selectionSource.onCopyError = { copyError = $0 } return MarkdownSelectionContext( source: selectionSource, copyActionTitle: copyActionTitle, @@ -205,9 +224,22 @@ private final class StreamingMarkdownRenderer { private final class MarkdownSelectionSource: @unchecked Sendable { var text: String + var originalText: String + var clipboardSource: ComposerContextClipboardFragment.Source? + var messageContext: OrchestrationMessageContext? + var onCopyError: ((String) -> Void)? init(_ text: String) { self.text = text + originalText = text + } + + @MainActor func copyMessage() { + do { + if try !FeatureContextClipboard.write(text: originalText, source: clipboardSource, context: messageContext) { + UIPasteboard.general.string = originalText + } + } catch { onCopyError?(error.localizedDescription) } } } @@ -851,6 +883,16 @@ private struct MarkdownInlineText: UIViewRepresentable { context.coordinator.didApply(attributedText) } context.coordinator.selectionContext = selectionContext + if let textView = textView as? FeatureInlineSkillTextView { + let source = selectionContext.source + textView.onCopySelection = { selected in + try FeatureContextClipboard.write( + text: FeatureContextClipboard.selectionText(selected, originalSource: source.originalText), + source: source.clipboardSource, context: source.messageContext + ) + } + textView.onCopyError = source.onCopyError + } context.coordinator.onOpenURL = { url in openURL(url) } @@ -1056,7 +1098,7 @@ private struct MarkdownInlineText: UIViewRepresentable { } private func copyMessage() { - UIPasteboard.general.string = selectionContext.source.text + selectionContext.source.copyMessage() } } } diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 14c6973d4959..d6f86371d5f8 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -31,6 +31,8 @@ public struct ThreadDetailView: View { @State private var feedbackAlertMessage: String? @State private var feedbackIdentifier: String? @State private var didRestoreDraft = false + @State private var draftRestoreBaseline: FeatureComposerDraft? + @State private var missingFileRecoverySnapshot: FeatureComposerDraft? @State private var draftSaveTask: Task? @State private var draftSaveError: String? @State private var toolSurface: FeatureThreadToolSurface? @@ -773,7 +775,11 @@ public struct ThreadDetailView: View { openURL: transcriptOpenURL, imageContext: markdownImageContext, attachmentContext: (model.client as? any FeatureAttachmentAssetResolving).map { - FeatureAttachmentContext(threadID: thread.id, resolver: $0) + FeatureAttachmentContext( + threadID: thread.id, resolver: $0, + environmentID: currentThread.environmentID, + wireThreadID: currentThread.wireID ?? currentThread.id + ) }, skills: threadProviderSkills, renderUpdate: timelineRenderUpdate, @@ -837,7 +843,7 @@ public struct ThreadDetailView: View { providers: threadProviders, threadSelection: currentSelection, materializesDefaultSelection: false, - isSending: isSending || isRewinding, + isSending: isSending || isRewinding || !didRestoreDraft, isWorking: detail.thread.state == .working || detail.thread.state == .queued || isCompacting, focused: $composerFocused, @@ -862,9 +868,16 @@ public struct ThreadDetailView: View { }, onRefreshModels: refreshThreadEnvironmentModels, draftSaveError: draftSaveError, - onRetryDraftSave: persistDraftImmediately, + onRetryDraftSave: missingFileRecoverySnapshot == nil ? { + if didRestoreDraft { + persistDraftImmediately() + } else { + Task { await restoreDraft(from: draftRestoreBaseline ?? composerDraft, key: draftKey) } + } + } : nil, context: contextBinding, - onInputPreparationChange: { isPreparingInput = $0 } + onInputPreparationChange: { isPreparingInput = $0 }, + contextAttachmentResolver: model.client as? any FeatureContextAttachmentResolving ) .disabled(isRewinding) } @@ -1028,7 +1041,7 @@ public struct ThreadDetailView: View { } private func send() { - guard !isRewinding else { return } + guard !isRewinding, didRestoreDraft else { return } let message = draft let pendingContext = composerContext let pendingAttachments = currentThread.environmentID.map { @@ -1220,6 +1233,7 @@ public struct ThreadDetailView: View { restoreRewindDraft() return } + draftRestoreBaseline = baseline let saved = try? await draftStore.draft(for: key) guard !Task.isCancelled else { return } if model.recoveredRewindDrafts[thread.id] != nil { @@ -1228,11 +1242,15 @@ public struct ThreadDetailView: View { } let liveDraft = composerDraft - var restored = FeatureComposerDraftRestoration.merge( - saved: saved, - baseline: baseline, - current: liveDraft - ) + var restored: FeatureComposerDraft + var recoveredMissingFiles = false + do { + restored = try FeatureComposerDraftRestoration.merge(saved: saved, baseline: baseline, current: liveDraft, + onMissingAttachments: { recoveredMissingFiles = true }) + } catch { + draftSaveError = error.localizedDescription + return + } restored.selection = ThreadComposerModelSelectionPolicy.explicitSelection( restored.selection, inherited: currentSelection, @@ -1243,6 +1261,8 @@ public struct ThreadDetailView: View { attachments = restored.attachments selection = restored.selection didRestoreDraft = true + missingFileRecoverySnapshot = recoveredMissingFiles ? composerDraft : nil + draftSaveError = recoveredMissingFiles ? FeatureComposerDraftRestoration.missingFilesWarning : nil // Changes made while the file read or thread refresh was in flight did // not pass the didRestoreDraft gate, so enqueue their first save now. @@ -1259,6 +1279,8 @@ public struct ThreadDetailView: View { private func scheduleDraftSave() { guard didRestoreDraft, !isRewinding else { return } + guard !FeatureComposerDraftRestoration.keepsSavedRecovery(missingFileRecoverySnapshot, current: composerDraft) else { return } + missingFileRecoverySnapshot = nil let previousSave = draftSaveTask previousSave?.cancel() let snapshot = composerDraft @@ -1290,6 +1312,8 @@ public struct ThreadDetailView: View { private func persistDraftImmediately() { guard didRestoreDraft, !isRewinding else { return } + guard !FeatureComposerDraftRestoration.keepsSavedRecovery(missingFileRecoverySnapshot, current: composerDraft) else { return } + missingFileRecoverySnapshot = nil let previousSave = draftSaveTask previousSave?.cancel() let snapshot = composerDraft @@ -1497,18 +1521,43 @@ struct ThreadPullRequestDestination: Equatable { } } -/// Merges a stored draft with edits made while that draft was loading. Each -/// field is restored only if its live value still matches the value captured -/// before the asynchronous read began. +/// Keep live edits, but restore file context only with the attachments it needs. enum FeatureComposerDraftRestoration { + static let missingFilesWarning = "Some saved files are missing. Their metadata is shown in the draft. The saved draft stays unchanged until you edit or send." + + static func keepsSavedRecovery(_ snapshot: FeatureComposerDraft?, current: FeatureComposerDraft) -> Bool { + guard let snapshot else { return false } + return snapshot.text == current.text && snapshot.context == current.context + && snapshot.attachments.count == current.attachments.count + && zip(snapshot.attachments, current.attachments).allSatisfy { saved, live in + var saved = saved + var live = live + saved.uploadedReference = nil + live.uploadedReference = nil + return saved == live + } + } + + enum RestorationError: LocalizedError { + case attachmentLimit + + var errorDescription: String? { + switch self { + case .attachmentLimit: + "Remove an attachment, then retry restoring the draft. The saved draft has not changed." + } + } + } + static func merge( saved: FeatureComposerDraft?, baseline: FeatureComposerDraft, current: FeatureComposerDraft, fallbackSelection: FeatureSelection? = nil, - fallbackWorkspace: FeatureComposerWorkspaceDraft? = nil - ) -> FeatureComposerDraft { - FeatureComposerDraft( + fallbackWorkspace: FeatureComposerWorkspaceDraft? = nil, + onMissingAttachments: () -> Void = {} + ) throws -> FeatureComposerDraft { + var restored = FeatureComposerDraft( text: current.text == baseline.text ? saved?.text ?? "" : current.text, @@ -1525,6 +1574,57 @@ enum FeatureComposerDraftRestoration { ), context: current.context == baseline.context ? saved?.context : current.context ) + restored.context = ComposerContextReferences.referenced(restored.context, text: restored.text) + var missing: [ComposerContextRecord] = [] + if let context = restored.context { + func matches(_ attachment: FeatureDraftAttachment, id: String) -> Bool { + attachment.id.uuidString.caseInsensitiveCompare(id) == .orderedSame + || attachment.uploadedReference?.attachmentID == id + } + for record in context.records { + guard let binding = record.attachment, + !restored.attachments.contains(where: { matches($0, id: binding.attachmentId) }) else { continue } + guard let attachment = saved?.attachments.first(where: { matches($0, id: binding.attachmentId) }) else { + missing.append(record) + continue + } + guard restored.attachments.count < FeatureImageAttachmentLimits.maximumCount else { + throw RestorationError.attachmentLimit + } + restored.attachments.append(attachment) + } + } + let missingIDs = Set(missing.map(\.contextId)) + let directIDs = Set(ComposerContextReferences.collect(restored.text).map(\.contextId)) + func missingText(_ record: ComposerContextRecord) -> String { + "[Missing attachment: \(record.label)]\n" + ComposerContextReferences.providerPayload(record) + } + let originalText = restored.text + restored.text = ComposerContextReferences.replace(originalText) { reference in + missing.first(where: { $0.contextId == reference.contextId }).map(missingText) + ?? (originalText as NSString).substring(with: reference.range) + } + for record in missing where !directIDs.contains(record.contextId) { + restored.text += "\n\n" + missingText(record) + } + var repairedScreenshot = false + let records = (restored.context?.records ?? []).filter { !missingIDs.contains($0.contextId) }.map { record in + var record = record + if case var .previewAnnotation(annotation) = record.payload, + let screenshot = annotation.screenshotContextId, + missingIDs.contains(screenshot) || !(restored.context?.records.contains { $0.contextId == screenshot && $0.kind == "image" } ?? false) { + if !missingIDs.contains(screenshot) { + restored.text += "\n\n[Missing screenshot: \(record.label)]\ncontextId: \(screenshot)" + } + annotation.screenshotContextId = nil + record.payload = .previewAnnotation(annotation) + repairedScreenshot = true + } + return record + } + restored.context = records.isEmpty ? nil : .init(records: records) + if !missing.isEmpty || repairedScreenshot { onMissingAttachments() } + return restored } private static func mergeWorkspace( @@ -2811,6 +2911,12 @@ struct FeatureMessageView: View { } } + private var clipboardSource: ComposerContextClipboardFragment.Source? { + attachmentContext?.environmentID.map { + .init(environmentId: $0, threadId: attachmentContext?.wireThreadID, messageId: message.id) + } + } + @ViewBuilder private var messageBody: some View { switch message.role { case .user: @@ -2823,7 +2929,10 @@ struct FeatureMessageView: View { renderedText, isStreaming: message.state == .streaming, imageContext: imageContext, - skills: skills + skills: skills, + clipboardSource: clipboardSource, + messageContext: message.context, + copyText: message.text ) } if message.state == .queued { @@ -2860,7 +2969,10 @@ struct FeatureMessageView: View { renderedText, isStreaming: message.state == .streaming, imageContext: imageContext, - skills: skills + skills: skills, + clipboardSource: clipboardSource, + messageContext: message.context, + copyText: message.text ) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/swift-ios/Features/Shared/FeatureAttachmentAssetResolving.swift b/apps/swift-ios/Features/Shared/FeatureAttachmentAssetResolving.swift index aab2cab89900..a38ac4df470b 100644 --- a/apps/swift-ios/Features/Shared/FeatureAttachmentAssetResolving.swift +++ b/apps/swift-ios/Features/Shared/FeatureAttachmentAssetResolving.swift @@ -13,8 +13,11 @@ public protocol FeatureAttachmentAssetResolving: AnyObject { struct FeatureAttachmentContext: Equatable { let threadID: String let resolver: any FeatureAttachmentAssetResolving + var environmentID: String? = nil + var wireThreadID: String? = nil static func == (lhs: Self, rhs: Self) -> Bool { lhs.threadID == rhs.threadID && lhs.resolver === rhs.resolver + && lhs.environmentID == rhs.environmentID && lhs.wireThreadID == rhs.wireThreadID } } diff --git a/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift b/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift index 5998a7a2599a..45a23577a04f 100644 --- a/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift +++ b/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift @@ -231,6 +231,23 @@ public actor FeatureComposerDraftStore { return draft } + public func clipboardAttachment(environmentID: String, attachmentID: String) throws -> FeatureDraftAttachment? { + for (key, draft) in try loadIfNeeded() { + for attachment in draft.attachments { + // Logical-project drafts move between environments. Their local UUID identifies + // the bytes. Server attachment IDs still require the matching upload environment. + let localMatch = (key.hasPrefix("environment:\(environmentID):") + || key.hasPrefix(Self.rewindRecoveryKey(for: "environment:\(environmentID):")) + || key.hasPrefix("logical-project:")) + && attachment.id.uuidString.caseInsensitiveCompare(attachmentID) == .orderedSame + let uploadedMatch = attachment.uploadedReference?.environmentID == environmentID + && attachment.uploadedReference?.attachmentID == attachmentID + if localMatch || uploadedMatch { return attachment.featureValue(fileStore: attachmentFileStore) } + } + } + return nil + } + public func setDraft(_ draft: FeatureComposerDraft, for key: String) throws { var drafts = try loadIfNeeded() let existingReferences = Dictionary( diff --git a/apps/swift-ios/Features/Shared/FeatureContextClipboard.swift b/apps/swift-ios/Features/Shared/FeatureContextClipboard.swift new file mode 100644 index 000000000000..3310c981a28a --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureContextClipboard.swift @@ -0,0 +1,94 @@ +import Foundation +import UIKit +import UniformTypeIdentifiers + +@MainActor +enum FeatureContextClipboard { + private static var retainedEnvironmentID: String? + private static var retainedAttachments: [FeatureDraftAttachment] = [] + + /// Keep local bytes in the app, not on the system clipboard. This also keeps cut attachments available. + static func write( + text: String, + source: ComposerContextClipboardFragment.Source?, + context: OrchestrationMessageContext?, + attachments: [FeatureDraftAttachment] = [], + pasteboard: UIPasteboard = .general + ) throws -> Bool { + guard !ComposerContextReferences.collect(text).isEmpty else { return false } + guard let source else { return false } + let content = try ComposerContextClipboard.selected( + text: text, fragment: .init(source: source, records: context?.records ?? []) + ) + let encoded = try ComposerContextClipboard.encode(content.fragment) + let ids = Set(content.fragment.records.compactMap { $0.attachment?.attachmentId.lowercased() }) + retainedEnvironmentID = source.environmentId + retainedAttachments = attachments.filter { + ids.contains($0.id.uuidString.lowercased()) + || ($0.uploadedReference?.environmentID == source.environmentId + && ids.contains($0.uploadedReference?.attachmentID.lowercased() ?? "")) + } + pasteboard.items = [[ + UTType.utf8PlainText.identifier: text, + ComposerContextClipboard.mimeType: Data(encoded.utf8), + UTType.html.identifier: Data(ComposerContextClipboard.html(text: text, fragment: encoded).utf8), + ]] + return true + } + + static func read(from pasteboard: UIPasteboard = .general) throws -> ComposerContextClipboard.Content? { + func string(for type: String) -> String? { + if let data = pasteboard.data(forPasteboardType: type) { return String(data: data, encoding: .utf8) } + return pasteboard.value(forPasteboardType: type) as? String + } + var fragment: ComposerContextClipboardFragment? + var decodingError: (any Error)? + if let raw = string(for: ComposerContextClipboard.mimeType) { + do { fragment = try ComposerContextClipboard.decode(raw) } + catch { decodingError = error } + } + if fragment == nil, let html = string(for: UTType.html.identifier) { + do { fragment = try ComposerContextClipboard.decodeHTML(html) } + catch { decodingError = error } + } + guard let fragment else { + if let decodingError { throw decodingError } + return nil + } + guard let text = pasteboard.string else { throw ComposerContextClipboardError.invalidFragment } + let content = try ComposerContextClipboard.selected(text: text, fragment: fragment) + return content.fragment.records.isEmpty ? nil : content + } + + static func retainedAttachment(environmentID: String, attachmentID: String) -> FeatureDraftAttachment? { + guard retainedEnvironmentID == environmentID else { return nil } + return retainedAttachments.first { + $0.id.uuidString.caseInsensitiveCompare(attachmentID) == .orderedSame + || ($0.uploadedReference?.environmentID == environmentID + && $0.uploadedReference?.attachmentID == attachmentID) + } + } + + /// UIKit history renders links as labels. Rebuild only the selected link spans without changing the selection. + static func selectionText(_ selected: NSAttributedString, originalSource: String) -> String { + let result = NSMutableString() + let references = ComposerContextReferences.collect(originalSource) + selected.enumerateAttribute(.link, in: NSRange(location: 0, length: selected.length)) { value, range, _ in + let part = selected.attributedSubstring(from: range) + let text = FeatureInlineSkillProjection.plainText(from: part) + let href = (value as? URL)?.absoluteString ?? value as? String + guard let href, let identity = ComposerContextReferences.parseHref(href) else { + result.append(text) + return + } + if let original = references.first(where: { + $0.contextId == identity.contextId && $0.kind == identity.kind && $0.label == text + }) { + result.append((originalSource as NSString).substring(with: original.range)) + } else { + result.append("[\(ComposerContextReferences.sanitizeLabel(text, kind: identity.kind))](\(href))") + } + } + return result as String + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureContextClipboardImporter.swift b/apps/swift-ios/Features/Shared/FeatureContextClipboardImporter.swift new file mode 100644 index 000000000000..34122aea6912 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureContextClipboardImporter.swift @@ -0,0 +1,172 @@ +import Foundation + +/// Resolve in the source environment. Its attachment IDs never enter the destination upload queue. +@MainActor +public protocol FeatureContextAttachmentResolving: AnyObject { + func contextAttachmentAssetURL( + environmentID: String, + attachment: ComposerContextRecord.Attachment + ) async throws -> URL +} + +@MainActor +struct FeatureContextClipboardImporter { + struct Result { + let text: String + let context: OrchestrationMessageContext + let attachments: [FeatureDraftAttachment] + + func discardFiles(using store: ManagedAttachmentFileStore) { + for attachment in attachments { + if let file = attachment.ownedFile { try? store.removeOwnedFile(fileName: file.fileName) } + } + } + } + + var fileStore = ManagedAttachmentFileStore() + var draftStore = FeatureComposerDraftStore.shared + var outboxStore = FeatureOutboxStore.shared + var resolver: (any FeatureContextAttachmentResolving)? + var download: (URL) async throws -> (URL, URLResponse) = { + try await URLSession.shared.download(for: URLRequest(url: $0, timeoutInterval: 30)) + } + + func importContent( + _ content: ComposerContextClipboard.Content, + attachmentCount: Int, + contextCount: Int, + imagesAllowed: Bool, + maximumFileBytes: Int? + ) async throws -> Result { + let content = try ComposerContextClipboard.reidentified(content) + guard contextCount + content.fragment.records.count <= ComposerContextClipboard.maximumRecords else { + throw ComposerContextClipboardError.contextLimit + } + let bindings = content.fragment.records.compactMap(\.attachment) + guard attachmentCount + Set(bindings.map(\.attachmentId)).count <= FeatureImageAttachmentLimits.maximumCount else { + throw FileAttachmentError.tooMany(maximum: FeatureImageAttachmentLimits.maximumCount) + } + var imported: [String: FeatureDraftAttachment] = [:] + var ordered: [FeatureDraftAttachment] = [] + do { + for record in content.fragment.records { + try Task.checkCancellation() + guard let binding = record.attachment else { continue } + let maximum: Int + if record.kind == "image" { + guard imagesAllowed else { throw ImageAttachmentError.invalidMIMEType } + maximum = UploadChatAttachment.maximumBytes + } else { + guard let maximumFileBytes, maximumFileBytes > 0 else { throw FileAttachmentError.unsupported } + maximum = min(maximumFileBytes, ManagedAttachmentFileStore.maximumBytes) + } + if let previous = imported[binding.attachmentId] { + guard previous.filename == binding.name, + previous.mimeType == binding.mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + throw ComposerContextClipboardError.invalidFragment + } + continue + } + guard binding.sizeBytes >= 0, binding.sizeBytes <= maximum else { + throw FileAttachmentError.tooLarge(actualBytes: binding.sizeBytes, maximumBytes: maximum) + } + let attachment = try await importAttachment( + binding, environmentID: content.fragment.source.environmentId, maximumBytes: maximum + ) + imported[binding.attachmentId] = attachment + ordered.append(attachment) + } + try Task.checkCancellation() + let rebound = ComposerContextReferences.rebind( + .init(records: content.fragment.records), + attachmentIDs: imported.mapValues { $0.id.uuidString } + )! + return Result(text: content.text, context: rebound, attachments: ordered) + } catch { + Result(text: "", context: .init(records: []), attachments: ordered).discardFiles(using: fileStore) + throw error + } + } + + private func importAttachment( + _ binding: ComposerContextRecord.Attachment, + environmentID: String, + maximumBytes: Int + ) async throws -> FeatureDraftAttachment { + let binding = ComposerContextRecord.Attachment( + attachmentId: binding.attachmentId, name: binding.name, + mimeType: binding.mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), sizeBytes: binding.sizeBytes + ) + let retained = FeatureContextClipboard.retainedAttachment(environmentID: environmentID, attachmentID: binding.attachmentId) + var local = if let retained { retained } else { + try await draftStore.clipboardAttachment(environmentID: environmentID, attachmentID: binding.attachmentId) + } + if local == nil { + let queued = try await outboxStore.submissions().filter { $0.environmentID == environmentID } + .flatMap(\.uploads).first { + $0.id.uuidString.caseInsensitiveCompare(binding.attachmentId) == .orderedSame + || ($0.uploadedReference?.environmentID == environmentID + && $0.uploadedReference?.attachmentID == binding.attachmentId) + } + if let queued { + if let file = queued.ownedFile { + local = FeatureDraftAttachment(id: queued.id, ownedFile: file, filename: queued.name, mimeType: queued.mimeType, source: queued.source) + } else { + local = FeatureDraftAttachment(id: queued.id, data: queued.data, filename: queued.name, mimeType: queued.mimeType, source: queued.source) + } + } + } + try Task.checkCancellation() + if let local { + guard local.filename == binding.name, + local.mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == binding.mimeType else { + throw ComposerContextClipboardError.invalidFragment + } + if let file = local.ownedFile, FileManager.default.fileExists(atPath: file.url.path) { + return try await copyFile(file.url, binding: binding, maximumBytes: maximumBytes, source: local.source) + } + if local.ownedFile == nil, !local.data.isEmpty { + guard local.byteCount <= maximumBytes else { + throw FileAttachmentError.tooLarge(actualBytes: local.byteCount, maximumBytes: maximumBytes) + } + if !binding.mimeType.hasPrefix("image/") { + let id = UUID() + let store = fileStore + let data = local.data + let file = try await Task.detached(priority: .userInitiated) { + try store.writeOwnedFile(data: data, attachmentID: id, originalFileName: binding.name, maximumBytes: maximumBytes) + }.value + return FeatureDraftAttachment(id: id, ownedFile: file, filename: binding.name, mimeType: binding.mimeType, source: local.source) + } + return FeatureDraftAttachment( + data: local.data, thumbnailData: local.thumbnailData, + filename: binding.name, mimeType: binding.mimeType, source: local.source + ) + } + } + guard let resolver else { throw ComposerContextClipboardError.sourceUnavailable } + let url = try await resolver.contextAttachmentAssetURL(environmentID: environmentID, attachment: binding) + try Task.checkCancellation() + let (temporary, response) = try await download(url) + defer { try? FileManager.default.removeItem(at: temporary) } + try Task.checkCancellation() + guard let response = response as? HTTPURLResponse else { throw FeatureMediaPreviewError.invalidResponse } + guard (200 ... 299).contains(response.statusCode) else { throw FeatureMediaPreviewError.httpStatus(response.statusCode) } + return try await copyFile(temporary, binding: binding, maximumBytes: maximumBytes, source: local?.source) + } + + private func copyFile( + _ url: URL, binding: ComposerContextRecord.Attachment, + maximumBytes: Int, source: PastedTextAttachmentSource? + ) async throws -> FeatureDraftAttachment { + let id = UUID() + let store = fileStore + let (file, thumbnail) = try await Task.detached(priority: .userInitiated) { + let file = try store.copyOwnedFile(from: url, attachmentID: id, originalFileName: binding.name, maximumBytes: maximumBytes) + let thumbnail = binding.mimeType.hasPrefix("image/") ? FeatureImageProcessor.thumbnail(fileURL: file.url) : nil + return (file, thumbnail) + }.value + // Ownership passes to the caller even after cancellation, so its rollback removes this copy. + return FeatureDraftAttachment(id: id, ownedFile: file, thumbnailData: thumbnail, filename: binding.name, mimeType: binding.mimeType, source: source) + } +} diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift index 0e623267290e..d94fd16338c5 100644 --- a/apps/swift-ios/Features/Workspace/NewThreadView.swift +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -35,6 +35,7 @@ public struct NewThreadView: View { @State private var draftRestoreContext: NewTaskDraftRestoreContext? @State private var draftSaveTask: Task? @State private var draftSaveError: String? + @State private var missingFileRecoverySnapshot: FeatureComposerDraft? @State private var immediateDraftSaveTasks: [String: Task] = [:] @State private var submittedSuccessfully = false @State private var restoresPromptAfterPickerDismissal = false @@ -121,8 +122,15 @@ public struct NewThreadView: View { onDismissKeyboard: { promptFocused = false }, onRefreshModels: refreshSelectedEnvironmentModels, draftSaveError: draftSaveError, - onRetryDraftSave: persistCurrentDraftImmediately, - context: contextBinding + onRetryDraftSave: missingFileRecoverySnapshot == nil ? { + if restoredDraftProjectID == projectID { + persistCurrentDraftImmediately() + } else { + Task { await restoreDraftAndLoadBranches() } + } + } : nil, + context: contextBinding, + contextAttachmentResolver: model.client as? any FeatureContextAttachmentResolving ) } .background(T3Colors.background) @@ -556,6 +564,7 @@ public struct NewThreadView: View { if workspaceMode == .worktree { Button { workspaceSelectionIsExplicit = true + missingFileRecoverySnapshot = nil startFromOrigin.toggle() } label: { Label( @@ -687,6 +696,7 @@ public struct NewThreadView: View { && value == initialSelection selection = value guard !materializesProjectDefault else { return } + missingFileRecoverySnapshot = nil selectionIsExplicit = true preferredSelection = value } @@ -952,6 +962,7 @@ public struct NewThreadView: View { restoredDraftProjectID = nil draftSaveError = nil + missingFileRecoverySnapshot = nil draftSaveTask?.cancel() draftSaveTask = nil prompt = carryingContent?.text ?? "" @@ -999,6 +1010,7 @@ public struct NewThreadView: View { } private func setWorkspaceMode(_ mode: FeatureWorkspaceMode) { + missingFileRecoverySnapshot = nil workspaceSelectionIsExplicit = true workspaceMode = mode selectedBranch = switch mode { @@ -1020,6 +1032,7 @@ public struct NewThreadView: View { projectID: requestedProjectID, branch: branch, mode: requestedMode ) guard projectID == requestedProjectID, workspaceMode == requestedMode else { return } + missingFileRecoverySnapshot = nil workspaceSelectionIsExplicit = true selectedBranch = selected // A checkout can change a remote ref into a local one. @@ -1100,17 +1113,25 @@ public struct NewThreadView: View { let liveDraft = composerDraft let liveSelectionIsExplicit = selectionIsExplicit let liveWorkspaceSelectionIsExplicit = workspaceSelectionIsExplicit - let restored = context.merging( - saved: saved, - current: liveDraft, - fallbackSelection: initialSelection, - fallbackWorkspace: FeatureComposerWorkspaceDraft( - mode: environmentPreferences.defaultWorkspaceMode, - branch: nil, - worktreePath: nil, - startFromOrigin: environmentPreferences.newWorktreesStartFromOrigin + let restored: FeatureComposerDraft + var recoveredMissingFiles = false + do { + restored = try context.merging( + saved: saved, + current: liveDraft, + fallbackSelection: initialSelection, + fallbackWorkspace: FeatureComposerWorkspaceDraft( + mode: environmentPreferences.defaultWorkspaceMode, + branch: nil, + worktreePath: nil, + startFromOrigin: environmentPreferences.newWorktreesStartFromOrigin + ), + onMissingAttachments: { recoveredMissingFiles = true } ) - ) + } catch { + draftSaveError = error.localizedDescription + return + } prompt = restored.text attachments = restored.attachments composerContext = restored.context @@ -1133,6 +1154,10 @@ public struct NewThreadView: View { workspaceSelectionIsExplicit = liveWorkspaceSelectionIsExplicit || saved?.workspace != nil restoredDraftProjectID = requestedProjectID + missingFileRecoverySnapshot = context.recoverySnapshot( + restored: composerDraft, saved: saved, hasMissingFiles: recoveredMissingFiles + ) + draftSaveError = recoveredMissingFiles ? FeatureComposerDraftRestoration.missingFilesWarning : nil if context.shouldCarryContent(into: saved) { persistCurrentDraftImmediately() } else if liveDraft != context.baseline { @@ -1206,6 +1231,8 @@ public struct NewThreadView: View { let key = currentDraftKey else { return } + guard !FeatureComposerDraftRestoration.keepsSavedRecovery(missingFileRecoverySnapshot, current: composerDraft) else { return } + missingFileRecoverySnapshot = nil let pendingDraftSaveTask = draftSaveTask pendingDraftSaveTask?.cancel() draftSaveTask = nil @@ -1242,6 +1269,8 @@ public struct NewThreadView: View { let key = currentDraftKey else { return } + guard !FeatureComposerDraftRestoration.keepsSavedRecovery(missingFileRecoverySnapshot, current: composerDraft) else { return } + missingFileRecoverySnapshot = nil let pendingDraftSaveTask = draftSaveTask pendingDraftSaveTask?.cancel() draftSaveTask = nil @@ -1261,8 +1290,14 @@ public struct NewThreadView: View { restoreContext.projectID == draftProjectID { let saved = try? await draftStore.draft(for: key) guard !Task.isCancelled else { return } - let merged = restoreContext.merging(saved: saved, current: snapshot) do { + var hasMissingAttachments = false + let merged = try restoreContext.merging(saved: saved, current: snapshot, + onMissingAttachments: { hasMissingAttachments = true }) + guard !hasMissingAttachments else { + if currentDraftKey == key { draftSaveError = FeatureComposerDraftRestoration.missingFilesWarning } + return + } try await draftStore.setDraft(merged, for: key) guard !Task.isCancelled else { return } if currentDraftKey == key { draftSaveError = nil } @@ -1367,12 +1402,20 @@ struct NewTaskDraftRestoreContext: Equatable { ) } + /// A carry writes to the new target, not the source draft that still owns the recovery copy. + func recoverySnapshot( + restored: FeatureComposerDraft, saved: FeatureComposerDraft?, hasMissingFiles: Bool + ) -> FeatureComposerDraft? { + hasMissingFiles && !shouldCarryContent(into: saved) ? restored : nil + } + func merging( saved: FeatureComposerDraft?, current: FeatureComposerDraft, fallbackSelection: FeatureSelection? = nil, - fallbackWorkspace: FeatureComposerWorkspaceDraft? = nil - ) -> FeatureComposerDraft { + fallbackWorkspace: FeatureComposerWorkspaceDraft? = nil, + onMissingAttachments: () -> Void = {} + ) throws -> FeatureComposerDraft { var target = saved if shouldCarryContent(into: saved) { target = saved ?? FeatureComposerDraft() @@ -1380,12 +1423,13 @@ struct NewTaskDraftRestoreContext: Equatable { target?.attachments = baseline.attachments target?.context = baseline.context } - var restored = FeatureComposerDraftRestoration.merge( + var restored = try FeatureComposerDraftRestoration.merge( saved: target, baseline: baseline, current: current, fallbackSelection: fallbackSelection, - fallbackWorkspace: fallbackWorkspace + fallbackWorkspace: fallbackWorkspace, + onMissingAttachments: onMissingAttachments ) if let environmentID { restored.attachments = Self.content( diff --git a/apps/swift-ios/Tests/CoreTests/ComposerContextClipboardTests.swift b/apps/swift-ios/Tests/CoreTests/ComposerContextClipboardTests.swift new file mode 100644 index 000000000000..0524ca9bcb9a --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/ComposerContextClipboardTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Composer context clipboard contract") +struct ComposerContextClipboardTests { + @Test func portableHTMLAndMIMERoundTripUnknownPayloads() throws { + let raw = #"{"version":1,"source":{"environmentId":"source","threadId":"thread","messageId":"message"},"records":[{"version":1,"contextId":"future","kind":"future-kind","label":"future","payload":{"integer":9007199254740993,"nested":[null,true,"日本語 & "]}}]}"# + let fragment = try ComposerContextClipboard.decode(raw) + let text = "Keep [future](t3-context://v1/future-kind/future)\n" + let encoded = try ComposerContextClipboard.encode(fragment) + let html = ComposerContextClipboard.html(text: text, fragment: encoded) + #expect(html.contains("<plain & text>")) + #expect(try ComposerContextClipboard.decode(encoded) == fragment) + #expect(try ComposerContextClipboard.decodeHTML(html) == fragment) + #expect(try ComposerContextClipboard.decodeHTML("
copied
") == fragment) + #expect(encoded.contains("9007199254740993")) + } + + @Test func selectionKeepsItsScreenshotButNotUnselectedRecords() throws { + let fragment = try fixture() + let text = "Look [chosen label](t3-context://v1/preview-annotation/annotation) here" + let selected = try ComposerContextClipboard.selected(text: text, fragment: fragment) + #expect(selected.text == text) + #expect(selected.fragment.records.map(\.contextId) == ["annotation", "shot"]) + } + + @Test func repeatedLinksAndScreenshotDependenciesReceiveFreshIdentities() throws { + let text = "🐱 [chosen label](t3-context://v1/preview-annotation/annotation)\n![custom shot](t3-context://v1/image/shot) [again](t3-context://v1/preview-annotation/annotation)" + let original = try ComposerContextClipboard.selected(text: text, fragment: fixture()) + var ordinal = 0 + let imported = try ComposerContextClipboard.reidentified(original) { ordinal += 1; return "new_\(ordinal)" } + #expect(imported.text == "🐱 [chosen label](t3-context://v1/preview-annotation/new_1)\n![custom shot](t3-context://v1/image/new_2) [again](t3-context://v1/preview-annotation/new_1)") + guard case let .previewAnnotation(annotation) = imported.fragment.records[0].payload else { + Issue.record("Expected annotation"); return + } + #expect(annotation.screenshotContextId == "new_2") + #expect(imported.fragment.records[1].attachment?.attachmentId == "source-attachment") + #expect(original.fragment.records.map(\.contextId) == ["annotation", "shot"]) + } + + @Test func missingRecordsAndScreenshotsRejectTheWholePaste() throws { + let fragment = try fixture() + #expect(throws: ComposerContextClipboardError.missingRecord) { + try ComposerContextClipboard.selected(text: "[missing](t3-context://v1/mention/missing)", fragment: fragment) + } + let missingScreenshot = ComposerContextClipboardFragment(source: fragment.source, records: [fragment.records[0]]) + #expect(throws: ComposerContextClipboardError.missingRecord) { + try ComposerContextClipboard.selected(text: "[annotation](t3-context://v1/preview-annotation/annotation)", fragment: missingScreenshot) + } + } + + @Test func recordLimitsAndMalformedKnownKindsAreNotSilentlyTrimmed() throws { + let records = (0 ... 200).map { ComposerContextRecord(contextId: "id_\($0)", label: "file", payload: .mention(.init(path: "file.swift"))) } + #expect(throws: ComposerContextClipboardError.contextLimit) { + try ComposerContextClipboard.encode(.init(source: .init(environmentId: "source"), records: records)) + } + let malformed = #"{"version":1,"source":{"environmentId":"source"},"records":[{"version":1,"contextId":"bad","kind":"terminal","label":"bad","payload":{"keep":"this"}}]}"# + #expect(throws: ComposerContextClipboardError.invalidFragment) { try ComposerContextClipboard.decode(malformed) } + #expect(throws: ComposerContextClipboardError.invalidFragment) { + try ComposerContextClipboard.encode(.init(source: .init(environmentId: "source"), records: [records[0], records[0]])) + } + } + + @Test func encodingRejectsIdentitiesThatTheDecoderCannotRead() { + let badID = ComposerContextRecord(contextId: "bad id", label: "bad", payload: .mention(.init(path: "file.swift"))) + let badKind = ComposerContextRecord(contextId: "valid_id", label: "bad", payload: .unknown(kind: "Bad Kind", payload: .null)) + for record in [badID, badKind] { + #expect(throws: ComposerContextClipboardError.invalidFragment) { + try ComposerContextClipboard.encode(.init(source: .init(environmentId: "source"), records: [record])) + } + } + } + + private func fixture() throws -> ComposerContextClipboardFragment { + try ComposerContextClipboard.decode(#"{"version":1,"source":{"environmentId":"source"},"records":[{"version":1,"kind":"preview-annotation","contextId":"annotation","label":"Annotation","annotationId":"original","pageUrl":"https://example.com","pageTitle":null,"comment":"Keep this exact comment","targetSummary":"one target","styleChanges":[],"screenshotContextId":"shot"},{"version":1,"kind":"image","contextId":"shot","label":"Screenshot","attachmentId":"source-attachment","name":"shot.png","mimeType":"image/png","sizeBytes":4},{"version":1,"kind":"mention","contextId":"not-selected","label":"not selected","path":"src/unselected.swift"}]}"#) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ComposerContextPersistenceTests.swift b/apps/swift-ios/Tests/FeatureTests/ComposerContextPersistenceTests.swift index e18e612dd443..7a48b7b14632 100644 --- a/apps/swift-ios/Tests/FeatureTests/ComposerContextPersistenceTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/ComposerContextPersistenceTests.swift @@ -37,11 +37,11 @@ struct ComposerContextPersistenceTests { #expect(attachment.upload?.data == Data("paste".utf8)) } - @Test func draftRestoreDoesNotReplaceContextAddedDuringRead() { + @Test func draftRestoreDoesNotReplaceContextAddedDuringRead() throws { let savedRecord = ComposerContextRecord(label: "old", payload: .skill(.init(name: "old"))) let newRecord = ComposerContextRecord(label: "new", payload: .skill(.init(name: "new"))) let current = FeatureComposerDraft(text: ComposerContextReferences.format(newRecord), context: .init(records: [newRecord])) - let restored = FeatureComposerDraftRestoration.merge( + let restored = try FeatureComposerDraftRestoration.merge( saved: .init(text: ComposerContextReferences.format(savedRecord), context: .init(records: [savedRecord])), baseline: .init(), current: current ) diff --git a/apps/swift-ios/Tests/FeatureTests/ComposerDraftStoreTests.swift b/apps/swift-ios/Tests/FeatureTests/ComposerDraftStoreTests.swift index 260e8bb296ba..a1ab5b7af798 100644 --- a/apps/swift-ios/Tests/FeatureTests/ComposerDraftStoreTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/ComposerDraftStoreTests.swift @@ -349,7 +349,7 @@ struct ComposerDraftStoreTests { #expect(persisted?["version"] as? Int == 2) } - @Test func restorationPreservesLiveEditsAndRestoresUntouchedFields() { + @Test func restorationPreservesLiveEditsAndRestoresUntouchedFields() throws { let baseline = FeatureComposerDraft( selection: FeatureSelection(providerID: "openai", modelID: "gpt-default"), workspace: FeatureComposerWorkspaceDraft( @@ -387,7 +387,7 @@ struct ComposerDraftStoreTests { ) ) - let merged = FeatureComposerDraftRestoration.merge( + let merged = try FeatureComposerDraftRestoration.merge( saved: saved, baseline: baseline, current: current @@ -402,7 +402,7 @@ struct ComposerDraftStoreTests { #expect(merged.workspace?.startFromOrigin == false) } - @Test func restorationUsesFallbacksWithoutOverwritingLiveChoices() { + @Test func restorationUsesFallbacksWithoutOverwritingLiveChoices() throws { let baseline = FeatureComposerDraft() let liveSelection = FeatureSelection(providerID: "anthropic", modelID: "claude-sonnet") let current = FeatureComposerDraft(selection: liveSelection) @@ -414,7 +414,7 @@ struct ComposerDraftStoreTests { startFromOrigin: true ) - let merged = FeatureComposerDraftRestoration.merge( + let merged = try FeatureComposerDraftRestoration.merge( saved: nil, baseline: baseline, current: current, diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift index 6d913e47f15c..4b72d4c5a97b 100644 --- a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift @@ -446,7 +446,7 @@ struct DailyUXNewTaskTests { } @Test - func projectDraftRestoreNeverOverwritesTypingMadeWhileLoading() { + func projectDraftRestoreNeverOverwritesTypingMadeWhileLoading() throws { let savedAttachment = FeatureDraftAttachment( data: Data([0x01]), filename: "saved.png", @@ -457,7 +457,7 @@ struct DailyUXNewTaskTests { baseline: FeatureComposerDraft() ) - let merged = context.merging( + let merged = try context.merging( saved: FeatureComposerDraft( text: "Old saved prompt", attachments: [savedAttachment] @@ -471,7 +471,7 @@ struct DailyUXNewTaskTests { } @Test - func computerSwitchCarriesLocalContentAndDropsAnotherServersUpload() { + func computerSwitchCarriesLocalContentAndDropsAnotherServersUpload() throws { let oldUpload = FeatureUploadedAttachmentReference( environmentID: "source", attachmentID: "old-upload" ) @@ -508,7 +508,7 @@ struct DailyUXNewTaskTests { let targetWorkspace = FeatureComposerWorkspaceDraft( mode: .local, branch: nil, worktreePath: nil, startFromOrigin: true ) - let restored = context.merging( + let restored = try context.merging( saved: FeatureComposerDraft(selection: targetSelection, workspace: targetWorkspace), current: content ) @@ -526,7 +526,7 @@ struct DailyUXNewTaskTests { } @Test - func computerSwitchKeepsExistingTargetDraftAndLiveEdits() { + func computerSwitchKeepsExistingTargetDraftAndLiveEdits() throws { let content = FeatureComposerDraft(text: "Prompt from the first computer") let context = NewTaskDraftRestoreContext(projectID: "target-project", baseline: content) let targetAttachment = FeatureDraftAttachment( @@ -537,22 +537,22 @@ struct DailyUXNewTaskTests { ) #expect(!context.shouldCarryContent(into: saved)) - #expect(context.merging(saved: saved, current: content) == saved) + #expect(try context.merging(saved: saved, current: content) == saved) #expect(!context.shouldCarryContent(into: FeatureComposerDraft(attachments: [targetAttachment]))) - let edited = context.merging( + let edited = try context.merging( saved: saved, current: FeatureComposerDraft(text: "Typed while the target draft loaded") ) #expect(edited.text == "Typed while the target draft loaded") #expect(edited.attachments == [targetAttachment]) - let cleared = context.merging(saved: nil, current: FeatureComposerDraft()) + let cleared = try context.merging(saved: nil, current: FeatureComposerDraft()) #expect(cleared.text.isEmpty) } @Test - func sharedProjectDraftRestorationDoesNotReuseAnotherEnvironmentsUpload() { + func sharedProjectDraftRestorationDoesNotReuseAnotherEnvironmentsUpload() throws { let source = FeatureComposerDraft( text: "Shared repo draft", attachments: [FeatureDraftAttachment( @@ -566,7 +566,7 @@ struct DailyUXNewTaskTests { let context = NewTaskDraftRestoreContext( projectID: "target-project", baseline: content, environmentID: "target" ) - let restored = context.merging(saved: source, current: content) + let restored = try context.merging(saved: source, current: content) #expect(restored.text == source.text) #expect(restored.attachments[0].id == source.attachments[0].id) diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureContextClipboardTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureContextClipboardTests.swift new file mode 100644 index 000000000000..fa4d3876b56e --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureContextClipboardTests.swift @@ -0,0 +1,390 @@ +import Foundation +import Testing +import UIKit +import UniformTypeIdentifiers +@testable import T3Code + +@Suite("Context clipboard", .serialized) +@MainActor +struct FeatureContextClipboardTests { + @Test func nativeCopyKeepsCanonicalSelectedTextAndAllPortableFlavors() throws { + let pasteboard = UIPasteboard.withUniqueName() + let record = mention("selected") + let original = "Before \(ComposerContextReferences.format(record)) after" + let editor = FeatureComposerUITextView() + let selected = ComposerContextReferences.format(record) + editor.text = original + editor.selectedRange = NSRange(location: 7, length: selected.utf16.count) + let selection = editor.selectedRange + editor.onCopySelection = { attributed in + try FeatureContextClipboard.write( + text: FeatureInlineSkillProjection.plainText(from: attributed), + source: .init(environmentId: "source"), context: .init(records: [record, mention("other")]), + pasteboard: pasteboard + ) + } + + editor.copy(nil) + + #expect(editor.text == original) + #expect(editor.selectedRange == selection) + #expect(pasteboard.string == selected) + #expect(try FeatureContextClipboard.read(from: pasteboard)?.fragment.records == [record]) + let html = try #require(pasteboard.data(forPasteboardType: UTType.html.identifier)) + #expect(try ComposerContextClipboard.decodeHTML(String(decoding: html, as: UTF8.self))?.records == [record]) + } + + @Test func failedRichCutDoesNotDeleteTheSelectionOrReplaceTheClipboard() { + let editor = FeatureComposerUITextView() + editor.text = "Before selected after" + editor.selectedRange = NSRange(location: 7, length: 8) + editor.onCopySelection = { _ in throw ComposerContextClipboardError.missingRecord } + var error: String? + editor.onCopyError = { error = $0 } + + editor.cut(nil) + + #expect(editor.text == "Before selected after") + #expect(editor.selectedRange == NSRange(location: 7, length: 8)) + #expect(error != nil) + } + + @Test func HTMLFallbackRecoversWhenTheCustomMIMEIsUnavailableOrInvalid() throws { + let pasteboard = UIPasteboard.withUniqueName() + let record = mention("selected") + let text = ComposerContextReferences.format(record) + let fragment = ComposerContextClipboardFragment(source: .init(environmentId: "source"), records: [record]) + let html = ComposerContextClipboard.html(text: text, fragment: try ComposerContextClipboard.encode(fragment)) + pasteboard.items = [[UTType.utf8PlainText.identifier: text, UTType.html.identifier: Data(html.utf8)]] + #expect(try FeatureContextClipboard.read(from: pasteboard)?.fragment == fragment) + pasteboard.items = [[ + UTType.utf8PlainText.identifier: text, UTType.html.identifier: Data(html.utf8), + ComposerContextClipboard.mimeType: Data("invalid JSON".utf8), + ]] + #expect(try FeatureContextClipboard.read(from: pasteboard)?.fragment == fragment) + } + + @Test func aMarkdownViewWithoutAnEnvironmentKeepsPlainCopyAvailable() throws { + let pasteboard = UIPasteboard.withUniqueName() + let record = mention("source") + let text = ComposerContextReferences.format(record) + #expect(try !FeatureContextClipboard.write(text: text, source: nil, context: nil, pasteboard: pasteboard)) + #expect(pasteboard.data(forPasteboardType: ComposerContextClipboard.mimeType) == nil) + } + + @Test func historySelectionUsesTheOriginalImageReferenceAndKeepsPartialLabels() { + let source = "Before ![screenshot](t3-context://v1/image/shot) after" + let selected = NSAttributedString(string: "screenshot", attributes: [.link: URL(string: "t3-context://v1/image/shot")!]) + #expect(FeatureContextClipboard.selectionText(selected, originalSource: source) == "![screenshot](t3-context://v1/image/shot)") + #expect(FeatureContextClipboard.selectionText(selected.attributedSubstring(from: NSRange(location: 0, length: 6)), originalSource: source) + == "[screen](t3-context://v1/image/shot)") + } + + @Test(arguments: ["environment:local-source:thread:one", "logical-project:repo:new-task", "rewind-recovery:environment:local-source:thread:one"]) + func localDraftBytesAreCopiedWithNewIDsAndPastedTextSource(key: String) async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore(fileURL: directory.appendingPathComponent("drafts.json")) + let local = FeatureDraftAttachment(data: Data("retained paste bytes".utf8), filename: "file.txt", mimeType: "text/plain", source: .pastedText) + let record = file("source-record", attachmentID: local.id.uuidString, size: local.byteCount) + try await store.setDraft(.init(attachments: [local]), for: key) + let reloaded = FeatureComposerDraftStore(fileURL: directory.appendingPathComponent("drafts.json")) + let importer = importer(directory: directory, draftStore: reloaded) + + let result = try await importer.importContent(content(record, environment: "local-source"), attachmentCount: 0, contextCount: 0, imagesAllowed: true, maximumFileBytes: 1_000) + let attachment = try #require(result.attachments.first) + + #expect(attachment.id != local.id) + #expect(attachment.uploadedReference == nil) + let ownedFile = try #require(attachment.ownedFile) + #expect(try Data(contentsOf: ownedFile.url) == local.data) + #expect(attachment.data.isEmpty) + #expect(attachment.source == .pastedText) + #expect(result.context.records.first?.attachment?.attachmentId == attachment.id.uuidString) + #expect(result.context.records.first?.contextId != record.contextId) + #expect(try await reloaded.draft(for: key)?.attachments == [local]) + } + + @Test func remoteImportResolvesTheSourceAndMakesAnOwnedDestinationFile() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let resolver = Resolver() + var importer = importer(directory: directory) + importer.resolver = resolver + importer.download = { url in + let temporary = directory.appendingPathComponent(UUID().uuidString) + try Data("remote file".utf8).write(to: temporary) + return (temporary, HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + let record = file("source-record", attachmentID: "source-server-id", size: 11) + let result = try await importer.importContent(content(record), attachmentCount: 0, contextCount: 0, imagesAllowed: true, maximumFileBytes: 1_000) + let attachment = try #require(result.attachments.first) + let owned = try #require(attachment.ownedFile) + + #expect(resolver.requests.map(\.environmentID) == ["remote-source"]) + #expect(resolver.requests.map(\.attachmentID) == ["source-server-id"]) + #expect(try Data(contentsOf: owned.url) == Data("remote file".utf8)) + #expect(attachment.uploadedReference == nil) + #expect(result.context.records.first?.attachment?.attachmentId == attachment.id.uuidString) + #expect(result.context.records.first?.attachment?.attachmentId != "source-server-id") + } + + @Test func mismatchedLocalMetadataDoesNotReuseTheAttachmentBytes() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore(fileURL: directory.appendingPathComponent("drafts.json")) + let local = FeatureDraftAttachment(data: Data("original".utf8), filename: "original.txt", mimeType: "text/plain") + try await store.setDraft(.init(attachments: [local]), for: "environment:local-source:thread:one") + let importer = importer(directory: directory, draftStore: store) + let clipboard = content(file("record", attachmentID: local.id.uuidString, size: local.byteCount), environment: "local-source") + await #expect(throws: ComposerContextClipboardError.invalidFragment) { + try await importer.importContent(clipboard, attachmentCount: 0, contextCount: 0, imagesAllowed: true, maximumFileBytes: 1_000) + } + #expect(try await store.draft(for: "environment:local-source:thread:one")?.attachments == [local]) + } + + @Test func imageMIMENormalizationDoesNotRequireFileAttachmentSupport() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + var importer = importer(directory: directory) + importer.resolver = Resolver() + importer.download = { url in + let temporary = directory.appendingPathComponent(UUID().uuidString) + try Data([1, 2, 3, 4]).write(to: temporary) + return (temporary, HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + let image = ComposerContextRecord(contextId: "image", label: "Image", payload: .image(.init( + attachmentId: "source-image", name: "image.png", mimeType: " IMAGE/PNG ", sizeBytes: 4 + ))) + let imported = try await importer.importContent(content(image), attachmentCount: 0, contextCount: 0, imagesAllowed: true, maximumFileBytes: nil) + #expect(imported.attachments.first?.mimeType == "image/png") + #expect(imported.attachments.first?.ownedFile != nil) + } + + @Test(arguments: [false, true]) + func failedOrCancelledImportsRollBackEarlierFiles(cancelled: Bool) async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let resolver = Resolver(failAfterFirst: true, cancelled: cancelled) + var importer = importer(directory: directory) + importer.resolver = resolver + importer.download = { url in + let temporary = directory.appendingPathComponent(UUID().uuidString) + try Data("file".utf8).write(to: temporary) + return (temporary, HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + let records = [file("first", attachmentID: "first-file", size: 4), file("second", attachmentID: "second-file", size: 4)] + let clipboard = ComposerContextClipboard.Content(text: records.map(ComposerContextReferences.format).joined(separator: " "), fragment: .init(source: .init(environmentId: "remote-source"), records: records)) + await #expect(throws: (any Error).self) { + try await importer.importContent(clipboard, attachmentCount: 0, contextCount: 0, imagesAllowed: true, maximumFileBytes: 1_000) + } + #expect(try FileManager.default.contentsOfDirectory(atPath: importer.fileStore.rootURL.path).isEmpty) + } + + @Test func limitsRejectTheImportBeforeAnyDownload() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let resolver = Resolver() + var importer = importer(directory: directory) + importer.resolver = resolver + let clipboard = content(file("file", attachmentID: "server-file", size: 1)) + await #expect(throws: ComposerContextClipboardError.contextLimit) { + try await importer.importContent(clipboard, attachmentCount: 0, contextCount: 200, imagesAllowed: true, maximumFileBytes: 1_000) + } + await #expect(throws: FileAttachmentError.tooMany(maximum: 8)) { + try await importer.importContent(clipboard, attachmentCount: 8, contextCount: 0, imagesAllowed: true, maximumFileBytes: 1_000) + } + await #expect(throws: FileAttachmentError.unsupported) { + try await importer.importContent(clipboard, attachmentCount: 0, contextCount: 0, imagesAllowed: true, maximumFileBytes: nil) + } + #expect(resolver.requests.isEmpty) + } + + @Test func replacingTheFinalAttachmentLinkRemovesOnlyItsOwnedAttachment() throws { + let linked = FeatureDraftAttachment(data: Data("linked".utf8), filename: "linked.txt", mimeType: "text/plain") + let independent = FeatureDraftAttachment(data: Data("strip".utf8), filename: "strip.txt", mimeType: "text/plain") + let oldRecord = file("old", attachmentID: linked.id.uuidString, size: linked.byteCount) + let newRecord = mention("new") + let link = ComposerContextReferences.format(oldRecord) + let incoming = FeatureContextClipboardImporter.Result(text: ComposerContextReferences.format(newRecord), context: .init(records: [newRecord]), attachments: []) + let edit = try FeatureContextClipboardEdit.apply( + text: "Before \(link) after", selection: NSRange(location: 7, length: link.utf16.count), + context: .init(records: [oldRecord]), attachments: [linked, independent], imported: incoming + ) + #expect(edit.text == "Before \(incoming.text) after") + #expect(edit.cursor == 7 + incoming.text.utf16.count) + #expect(edit.attachments == [independent]) + #expect(edit.context?.records == [newRecord]) + #expect(FeatureContextClipboardEdit.unlinkedAttachmentIDs(context: .init(records: [oldRecord]), previousText: link + link, text: link).isEmpty) + } + + @Test func replacingContextAtTheLimitUsesOnlyTheRemainingRecords() throws { + let records = (0 ..< 200).map { mention("item_\($0)") } + let text = records.map(ComposerContextReferences.format).joined(separator: " ") + let incoming = mention("incoming") + let imported = FeatureContextClipboardImporter.Result(text: ComposerContextReferences.format(incoming), context: .init(records: [incoming]), attachments: []) + let replaced = try FeatureContextClipboardEdit.apply( + text: text, selection: NSRange(location: 0, length: ComposerContextReferences.format(records[0]).utf16.count), + context: .init(records: records), attachments: [], imported: imported + ) + #expect(replaced.context?.records.count == 200) + #expect(replaced.context?.records.contains(records[0]) == false) + #expect(replaced.context?.records.contains(incoming) == true) + #expect(throws: FeatureComposerContext.MergeError.self) { + try FeatureContextClipboardEdit.apply(text: text, selection: NSRange(location: text.utf16.count, length: 0), context: .init(records: records), attachments: [], imported: imported) + } + } + + @Test(arguments: [1, 8]) + func attachmentEditsDuringDraftReadRestoreContextTogetherOrKeepTheSavedDraft(liveAttachmentCount: Int) async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore(fileURL: directory.appendingPathComponent("drafts.json")) + let attachment = FeatureDraftAttachment(data: Data("saved".utf8), filename: "file.txt", mimeType: "text/plain") + let record = file("saved", attachmentID: attachment.id.uuidString, size: attachment.byteCount) + let saved = FeatureComposerDraft(text: "Review \(ComposerContextReferences.format(record))", attachments: [attachment], context: .init(records: [record])) + let key = "environment:source:thread:one" + try await store.setDraft(saved, for: key) + let baseline = FeatureComposerDraft() + var live = baseline + let readStarted = AsyncStream.makeStream() + let resumeRead = AsyncStream.makeStream() + let restoration = Task { @MainActor in + defer { readStarted.continuation.finish() } + let loaded = try await store.draft(for: key) + readStarted.continuation.yield(()) + readStarted.continuation.finish() + for await _ in resumeRead.stream { break } + return try FeatureComposerDraftRestoration.merge(saved: loaded, baseline: baseline, current: live) + } + for await _ in readStarted.stream { break } + live.attachments = (0 ..< liveAttachmentCount).map { + FeatureDraftAttachment(data: Data("live \($0)".utf8), filename: "live-\($0).txt", mimeType: "text/plain") + } + let edited = live + resumeRead.continuation.yield(()) + resumeRead.continuation.finish() + + if liveAttachmentCount == 8 { + await #expect(throws: FeatureComposerDraftRestoration.RestorationError.self) { try await restoration.value } + #expect(live == edited) + #expect(try await store.draft(for: key) == saved) + live.attachments.removeLast() + let savedForRetry = try await store.draft(for: key) + let retried = try FeatureComposerDraftRestoration.merge(saved: savedForRetry, baseline: baseline, current: live) + #expect(retried.attachments == live.attachments + [attachment]) + #expect(retried.context == saved.context) + #expect(retried.text == saved.text) + } else { + let restored = try await restoration.value + #expect(restored.text == saved.text) + #expect(restored.context == saved.context) + #expect(restored.attachments == edited.attachments + [attachment]) + #expect(live == edited) + #expect(try await store.draft(for: key) == saved) + } + } + + @Test func missingSavedFilesBecomeVisibleTextWithoutDroppingInstructionsOrOverwritingRecovery() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore(fileURL: directory.appendingPathComponent("drafts.json")) + let missing = file("missing", attachmentID: UUID().uuidString, size: 123) + let terminal = ComposerContextRecord(contextId: "terminal", label: "Logs", payload: .terminal(.init( + terminalId: "shell", terminalLabel: "Shell", lineStart: 0, lineEnd: 0, text: "Keep these exact captured instructions." + ))) + let saved = FeatureComposerDraft( + text: "Keep my instructions. \(ComposerContextReferences.format(missing)) Then read \(ComposerContextReferences.format(terminal)).", + context: .init(records: [missing, terminal]) + ) + let key = "environment:source:thread:one" + try await store.setDraft(saved, for: key) + var warned = false + let restored = try FeatureComposerDraftRestoration.merge(saved: saved, baseline: .init(), current: .init(), onMissingAttachments: { warned = true }) + #expect(warned) + #expect(restored.text.contains("[Missing attachment: File]")) + #expect(restored.text.contains("Keep my instructions.")) + #expect(restored.text.contains("attachmentId: \(missing.attachment!.attachmentId)")) + #expect(restored.context?.records == [terminal]) + #expect(ComposerContextReferences.collect(restored.text).map(\.contextId) == [terminal.contextId]) + #expect(FeatureComposerDraftRestoration.keepsSavedRecovery(restored, current: restored)) + var modelRefresh = restored + modelRefresh.selection = .init(providerID: "test", modelID: "updated") + #expect(FeatureComposerDraftRestoration.keepsSavedRecovery(restored, current: modelRefresh)) + var edited = restored + edited.text += " I will send without the missing file." + #expect(!FeatureComposerDraftRestoration.keepsSavedRecovery(restored, current: edited)) + #expect(try await store.draft(for: key) == saved) + } + + @Test func carryingMissingFileRecoverySavesTheNewTargetAndKeepsTheSourceDraft() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore(fileURL: directory.appendingPathComponent("drafts.json")) + let record = file("missing", attachmentID: UUID().uuidString, size: 123) + let source = FeatureComposerDraft(text: "Keep this task. \(ComposerContextReferences.format(record))", context: .init(records: [record])) + let sourceKey = "environment:source:new-task:source" + let targetKey = "environment:target:new-task:target" + try await store.setDraft(source, for: sourceKey) + let restore = NewTaskDraftRestoreContext(projectID: "target", baseline: source, environmentID: "target") + let savedTarget = try await store.draft(for: targetKey) + var hasMissingFiles = false + let recovered = try restore.merging(saved: savedTarget, current: source, onMissingAttachments: { hasMissingFiles = true }) + let snapshot = restore.recoverySnapshot(restored: recovered, saved: savedTarget, hasMissingFiles: hasMissingFiles) + #expect(snapshot == nil) + #expect(!FeatureComposerDraftRestoration.keepsSavedRecovery(snapshot, current: recovered)) + try await store.setDraft(recovered, for: targetKey) + let reloaded = FeatureComposerDraftStore(fileURL: directory.appendingPathComponent("drafts.json")) + #expect(try await reloaded.draft(for: sourceKey) == source) + #expect(try await reloaded.draft(for: targetKey) == recovered) + #expect(recovered.text.contains("[Missing attachment: File]")) + #expect(recovered.context == nil) + } + + private func mention(_ id: String) -> ComposerContextRecord { + .init(contextId: id, label: id, payload: .mention(.init(path: "src/\(id).swift"))) + } + + private func file(_ id: String, attachmentID: String, size: Int) -> ComposerContextRecord { + .init(contextId: id, label: "File", payload: .file(.init(attachmentId: attachmentID, name: "file.txt", mimeType: "text/plain", sizeBytes: size))) + } + + private func content(_ record: ComposerContextRecord, environment: String = "remote-source") -> ComposerContextClipboard.Content { + .init(text: ComposerContextReferences.format(record), fragment: .init(source: .init(environmentId: environment), records: [record])) + } + + private func temporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func importer(directory: URL, draftStore: FeatureComposerDraftStore? = nil) -> FeatureContextClipboardImporter { + FeatureContextClipboardImporter( + fileStore: .init(rootURL: directory.appendingPathComponent("owned")), + draftStore: draftStore ?? .init(fileURL: directory.appendingPathComponent("drafts.json")), + outboxStore: .init(fileURL: directory.appendingPathComponent("outbox.json")) + ) + } + + private final class Resolver: FeatureContextAttachmentResolving { + struct Request { let environmentID: String; let attachmentID: String } + var requests: [Request] = [] + let failAfterFirst: Bool + let cancelled: Bool + + init(failAfterFirst: Bool = false, cancelled: Bool = false) { + self.failAfterFirst = failAfterFirst + self.cancelled = cancelled + } + + func contextAttachmentAssetURL(environmentID: String, attachment: ComposerContextRecord.Attachment) async throws -> URL { + requests.append(Request(environmentID: environmentID, attachmentID: attachment.attachmentId)) + if failAfterFirst, requests.count > 1 { + if cancelled { throw CancellationError() } + throw ComposerContextClipboardError.sourceUnavailable + } + return URL(string: "https://source.invalid/asset?signature=private")! + } + } +}