Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 44 additions & 16 deletions apps/swift-ios/App/NativeFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1479,7 +1479,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
worktreePath: String?,
startFromOrigin: Bool,
attachments: [FeatureUploadAttachment],
identity: FeatureSubmissionIdentity
identity: FeatureSubmissionIdentity,
context: OrchestrationMessageContext? = nil
) async throws -> FeatureThread {
try await createThreadAndSendResolved(
projectID: projectID,
Expand All @@ -1492,7 +1493,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
worktreePath: worktreePath,
startFromOrigin: startFromOrigin,
attachments: attachments,
submissionIdentity: identity
submissionIdentity: identity,
context: context
)
}

Expand All @@ -1507,7 +1509,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
worktreePath: String?,
startFromOrigin: Bool,
attachments: [FeatureUploadAttachment],
submissionIdentity: FeatureSubmissionIdentity?
submissionIdentity: FeatureSubmissionIdentity?,
context: OrchestrationMessageContext? = nil
) async throws -> FeatureThread {
let route = try projectRoute(for: projectID)
let client = route.client
Expand Down Expand Up @@ -1540,7 +1543,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
branch: branch,
worktreePath: worktreePath,
startFromOrigin: startFromOrigin,
attachments: attachments
attachments: attachments,
context: context
)
let pending: PendingBootstrapSubmission
let explicitIdentity = submissionIdentity.map { commandIdentity($0) }
Expand Down Expand Up @@ -1590,6 +1594,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
}
},
attachments: uploads,
context: context,
commandID: pending.identity.commandID,
messageID: pending.identity.messageID,
createdAt: pending.identity.createdAt
Expand All @@ -1607,7 +1612,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
model: model,
runtimeMode: runtime,
interactionMode: interaction,
attachments: uploads
attachments: uploads,
context: context
)
guard recovered else {
await resetFailedBootstrapIfConfirmed(
Expand Down Expand Up @@ -1669,7 +1675,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
model: ModelSelection,
runtimeMode: RuntimeMode,
interactionMode: InteractionMode,
attachments: [UploadChatImageAttachment]
attachments: [UploadChatImageAttachment],
context: OrchestrationMessageContext? = nil
) async throws -> Bool {
guard let snapshot = try? await client.threadSnapshot(id: pending.threadID) else {
return false
Expand All @@ -1693,6 +1700,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
interactionMode: interactionMode,
model: model,
attachments: attachments,
context: context,
commandID: pending.identity.commandID,
messageID: pending.identity.messageID,
createdAt: pending.identity.createdAt
Expand Down Expand Up @@ -2184,15 +2192,17 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
selection: FeatureSelection?,
runtimeMode: FeatureRuntimeMode,
attachments: [FeatureUploadAttachment],
identity: FeatureSubmissionIdentity
identity: FeatureSubmissionIdentity,
context: OrchestrationMessageContext? = nil
) async throws {
try await sendMessageResolved(
threadID: threadID,
text: text,
selection: selection,
runtimeMode: runtimeMode,
attachments: attachments,
submissionIdentity: identity
submissionIdentity: identity,
context: context
)
}

Expand All @@ -2202,7 +2212,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
selection: FeatureSelection?,
runtimeMode requestedRuntimeMode: FeatureRuntimeMode?,
attachments: [FeatureUploadAttachment],
submissionIdentity: FeatureSubmissionIdentity?
submissionIdentity: FeatureSubmissionIdentity?,
context: OrchestrationMessageContext? = nil
) async throws {
let route = try threadRoute(for: threadID)
let client = route.client
Expand All @@ -2224,7 +2235,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
model: model,
runtimeMode: runtimeMode,
interactionMode: interactionMode,
attachments: attachments
attachments: attachments,
context: context
)
let pending: PendingTurnSubmission
let explicitIdentity = submissionIdentity.map { commandIdentity($0) }
Expand Down Expand Up @@ -2252,6 +2264,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
interactionMode: interactionMode,
model: model,
attachments: uploads,
context: context,
commandID: pending.identity.commandID,
messageID: pending.identity.messageID,
createdAt: pending.identity.createdAt
Expand Down Expand Up @@ -6255,9 +6268,11 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
name: $0.name,
mimeType: $0.mimeType,
sizeBytes: $0.sizeBytes,
url: cachedAttachmentURL(for: $0.id, environmentID: environmentID)
url: cachedAttachmentURL(for: $0.id, environmentID: environmentID),
source: $0.source.flatMap { try? $0.decode(PastedTextAttachmentSource.self) }
)
}
},
context: message.context
)
}

Expand Down Expand Up @@ -7030,15 +7045,17 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
name: $0.name,
mimeType: $0.mimeType,
sizeBytes: ownedFile.byteCount,
uploadedReference: reference
uploadedReference: reference,
contextSource: $0.source
)
}
return try UploadChatAttachment(
id: $0.id,
data: $0.data,
name: $0.name,
mimeType: $0.mimeType,
uploadedReference: reference
uploadedReference: reference,
contextSource: $0.source
)
}
}
Expand Down Expand Up @@ -7753,6 +7770,13 @@ enum NativeThreadDetailReducer {
return .refresh
}
let turnID = payload["turnId"]?.stringValue
let context: OrchestrationMessageContext?
if let rawContext = payload["context"], rawContext != .null {
guard let decoded = try? rawContext.decode(OrchestrationMessageContext.self) else { return .refresh }
context = decoded
} else {
context = nil
}
let attachments: [ChatAttachment]?
if let rawAttachments = payload["attachments"], rawAttachments != .null {
guard let decoded = try? rawAttachments.decode([ChatAttachment].self) else {
Expand All @@ -7777,7 +7801,8 @@ enum NativeThreadDetailReducer {
turnId: turnID,
streaming: streaming,
createdAt: existing.createdAt,
updatedAt: streaming ? existing.updatedAt : updatedAt
updatedAt: streaming ? existing.updatedAt : updatedAt,
context: context ?? existing.context
)
renderMutation = .message(messages[index])
} else {
Expand All @@ -7789,7 +7814,8 @@ enum NativeThreadDetailReducer {
turnId: turnID,
streaming: streaming,
createdAt: createdAt,
updatedAt: updatedAt
updatedAt: updatedAt,
context: context
)
messages.append(message)
renderMutation = .message(message)
Expand Down Expand Up @@ -8213,6 +8239,7 @@ private struct BootstrapSubmissionSignature: Equatable {
let worktreePath: String?
let startFromOrigin: Bool
let attachments: [FeatureUploadAttachment]
var context: OrchestrationMessageContext? = nil
}

private struct PendingBootstrapSubmission {
Expand All @@ -8239,6 +8266,7 @@ private struct TurnSubmissionSignature: Equatable {
let runtimeMode: RuntimeMode
let interactionMode: InteractionMode
let attachments: [FeatureUploadAttachment]
var context: OrchestrationMessageContext? = nil
}

private struct PendingTurnSubmission {
Expand Down
42 changes: 36 additions & 6 deletions apps/swift-ios/Core/Attachments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,14 @@ public struct UploadedAttachmentReference: Codable, Equatable, Sendable {
}

/// A validated turn attachment. Images can remain inline for older servers.
/// Generic files always stay file-backed and require the upload capability.
/// Generic files require the upload capability. Clipboard text can stay in memory.
public struct UploadChatAttachment: Equatable, Sendable {
public static let maximumBytes = 10 * 1024 * 1024
public static let maximumFileBytes = 50 * 1024 * 1024

enum Source: Equatable, Sendable {
case imageData(Data)
case fileData(Data)
case file(URL)
}

Expand All @@ -67,15 +68,37 @@ public struct UploadChatAttachment: Equatable, Sendable {
public let mimeType: String
public let sizeBytes: Int
public let uploadedReference: UploadedAttachmentReference?
public let contextSource: PastedTextAttachmentSource?
let source: Source

public init(
id: UUID = UUID(),
data: Data,
name: String,
mimeType: String,
uploadedReference: UploadedAttachmentReference? = nil
uploadedReference: UploadedAttachmentReference? = nil,
contextSource: PastedTextAttachmentSource? = nil
) throws {
let normalizedMIME = mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if !normalizedMIME.hasPrefix("image/") {
guard !data.isEmpty else { throw FileAttachmentError.empty }
guard data.count <= Self.maximumFileBytes else {
throw FileAttachmentError.tooLarge(actualBytes: data.count, maximumBytes: Self.maximumFileBytes)
}
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedName.isEmpty, normalizedName.count <= 255 else { throw FileAttachmentError.invalidName }
guard !normalizedMIME.isEmpty, normalizedMIME.count <= 100,
!normalizedMIME.contains(where: { $0.isWhitespace || $0.isNewline }) else { throw FileAttachmentError.invalidMIMEType }
self.id = id
type = "file"
self.name = normalizedName
self.mimeType = normalizedMIME
sizeBytes = data.count
self.uploadedReference = uploadedReference
self.contextSource = contextSource
source = .fileData(data)
return
}
guard !data.isEmpty else { throw ImageAttachmentError.empty }
guard data.count <= Self.maximumBytes else {
throw ImageAttachmentError.tooLarge(
Expand All @@ -87,7 +110,6 @@ public struct UploadChatAttachment: Equatable, Sendable {
guard !normalizedName.isEmpty, normalizedName.count <= 255 else {
throw ImageAttachmentError.invalidName
}
let normalizedMIME = mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard normalizedMIME.hasPrefix("image/"), normalizedMIME.count <= 100 else {
throw ImageAttachmentError.invalidMIMEType
}
Expand All @@ -97,6 +119,7 @@ public struct UploadChatAttachment: Equatable, Sendable {
self.mimeType = normalizedMIME
sizeBytes = data.count
self.uploadedReference = uploadedReference
self.contextSource = nil
source = .imageData(data)
}

Expand All @@ -106,7 +129,8 @@ public struct UploadChatAttachment: Equatable, Sendable {
name: String,
mimeType: String,
sizeBytes: Int,
uploadedReference: UploadedAttachmentReference? = nil
uploadedReference: UploadedAttachmentReference? = nil,
contextSource: PastedTextAttachmentSource? = nil
) throws {
guard fileURL.isFileURL else { throw FileAttachmentError.invalidFileURL }
guard sizeBytes > 0 else { throw FileAttachmentError.empty }
Expand All @@ -131,6 +155,7 @@ public struct UploadChatAttachment: Equatable, Sendable {
self.mimeType = normalizedMIME
self.sizeBytes = sizeBytes
self.uploadedReference = uploadedReference
self.contextSource = contextSource
source = .file(fileURL)
}

Expand All @@ -142,6 +167,7 @@ public struct UploadChatAttachment: Equatable, Sendable {
"sizeBytes": .number(Double(sizeBytes)),
]
if case let .imageData(data) = source {
value["id"] = .string(id.uuidString)
value["dataUrl"] = .string(
"data:\(mimeType);base64,\(data.base64EncodedString())"
)
Expand All @@ -150,13 +176,17 @@ public struct UploadChatAttachment: Equatable, Sendable {
}

func uploadedJSONValue(id: String) -> JSONValue {
.object([
var value: [String: JSONValue] = [
"type": .string(type),
"id": .string(id),
"name": .string(name),
"mimeType": .string(mimeType),
"sizeBytes": .number(Double(sizeBytes)),
])
]
if let contextSource {
value["source"] = .object(["_tag": .string(contextSource.rawValue)])
}
return .object(value)
}
}

Expand Down
Loading
Loading