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 [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 [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  after"
+ let selected = NSAttributedString(string: "screenshot", attributes: [.link: URL(string: "t3-context://v1/image/shot")!])
+ #expect(FeatureContextClipboard.selectionText(selected, originalSource: source) == "")
+ #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")!
+ }
+ }
+}