diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 981decc3116d..e835e340630f 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -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, @@ -1492,7 +1493,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, worktreePath: worktreePath, startFromOrigin: startFromOrigin, attachments: attachments, - submissionIdentity: identity + submissionIdentity: identity, + context: context ) } @@ -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 @@ -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) } @@ -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 @@ -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( @@ -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 @@ -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 @@ -2184,7 +2192,8 @@ 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, @@ -2192,7 +2201,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, selection: selection, runtimeMode: runtimeMode, attachments: attachments, - submissionIdentity: identity + submissionIdentity: identity, + context: context ) } @@ -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 @@ -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) } @@ -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 @@ -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 ) } @@ -7030,7 +7045,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, name: $0.name, mimeType: $0.mimeType, sizeBytes: ownedFile.byteCount, - uploadedReference: reference + uploadedReference: reference, + contextSource: $0.source ) } return try UploadChatAttachment( @@ -7038,7 +7054,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, data: $0.data, name: $0.name, mimeType: $0.mimeType, - uploadedReference: reference + uploadedReference: reference, + contextSource: $0.source ) } } @@ -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 { @@ -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 { @@ -7789,7 +7814,8 @@ enum NativeThreadDetailReducer { turnId: turnID, streaming: streaming, createdAt: createdAt, - updatedAt: updatedAt + updatedAt: updatedAt, + context: context ) messages.append(message) renderMutation = .message(message) @@ -8213,6 +8239,7 @@ private struct BootstrapSubmissionSignature: Equatable { let worktreePath: String? let startFromOrigin: Bool let attachments: [FeatureUploadAttachment] + var context: OrchestrationMessageContext? = nil } private struct PendingBootstrapSubmission { @@ -8239,6 +8266,7 @@ private struct TurnSubmissionSignature: Equatable { let runtimeMode: RuntimeMode let interactionMode: InteractionMode let attachments: [FeatureUploadAttachment] + var context: OrchestrationMessageContext? = nil } private struct PendingTurnSubmission { diff --git a/apps/swift-ios/Core/Attachments.swift b/apps/swift-ios/Core/Attachments.swift index c6795b4267bf..ecb94dd5bf9b 100644 --- a/apps/swift-ios/Core/Attachments.swift +++ b/apps/swift-ios/Core/Attachments.swift @@ -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) } @@ -67,6 +68,7 @@ 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( @@ -74,8 +76,29 @@ public struct UploadChatAttachment: Equatable, Sendable { 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( @@ -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 } @@ -97,6 +119,7 @@ public struct UploadChatAttachment: Equatable, Sendable { self.mimeType = normalizedMIME sizeBytes = data.count self.uploadedReference = uploadedReference + self.contextSource = nil source = .imageData(data) } @@ -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 } @@ -131,6 +155,7 @@ public struct UploadChatAttachment: Equatable, Sendable { self.mimeType = normalizedMIME self.sizeBytes = sizeBytes self.uploadedReference = uploadedReference + self.contextSource = contextSource source = .file(fileURL) } @@ -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())" ) @@ -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) } } diff --git a/apps/swift-ios/Core/ComposerContext.swift b/apps/swift-ios/Core/ComposerContext.swift new file mode 100644 index 000000000000..f7386c412afd --- /dev/null +++ b/apps/swift-ios/Core/ComposerContext.swift @@ -0,0 +1,287 @@ +import Foundation + +/// The link owns position. A record owns the captured data behind that link. +public struct ComposerContextRecord: Codable, Equatable, Hashable, Sendable, Identifiable { + public var id: String { contextId } + public let version: Int + public let contextId: String + public let label: String + public var payload: Payload + + public enum Payload: Equatable, Hashable, Sendable { + case image(Attachment) + case file(Attachment) + case terminal(Terminal) + case element(Element) + case previewAnnotation(PreviewAnnotation) + case reviewComment(ReviewComment) + case mention(Mention) + case skill(Skill) + case unknown(kind: String, payload: JSONValue) + } + + public struct Attachment: Codable, Equatable, Hashable, Sendable { + public var attachmentId: String + public let name: String + public let mimeType: String + public let sizeBytes: Int + } + + public struct Terminal: Codable, Equatable, Hashable, Sendable { + public let terminalId: String + public let terminalLabel: String + public let lineStart: Int + public let lineEnd: Int + public let text: String + } + + public struct Element: Codable, Equatable, Hashable, Sendable { + public struct Source: Codable, Equatable, Hashable, Sendable { + public let functionName: String? + public let fileName: String? + public let lineNumber: Int? + public let columnNumber: Int? + + private enum CodingKeys: String, CodingKey { case functionName, fileName, lineNumber, columnNumber } + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(functionName, forKey: .functionName) + try values.encode(fileName, forKey: .fileName) + try values.encode(lineNumber, forKey: .lineNumber) + try values.encode(columnNumber, forKey: .columnNumber) + } + } + public let pageUrl: String + public let pageTitle: String? + public let tagName: String + public let selector: String? + public let htmlPreview: String + public let componentName: String? + public let source: Source? + public let styles: String + + private enum CodingKeys: String, CodingKey { case pageUrl, pageTitle, tagName, selector, htmlPreview, componentName, source, styles } + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(pageUrl, forKey: .pageUrl) + try values.encode(pageTitle, forKey: .pageTitle) + try values.encode(tagName, forKey: .tagName) + try values.encode(selector, forKey: .selector) + try values.encode(htmlPreview, forKey: .htmlPreview) + try values.encode(componentName, forKey: .componentName) + try values.encode(source, forKey: .source) + try values.encode(styles, forKey: .styles) + } + } + + public struct PreviewAnnotation: Codable, Equatable, Hashable, Sendable { + public struct StyleChange: Codable, Equatable, Hashable, Sendable { + public let targetId: String + public let selector: String? + public let property: String + public let previousValue: String + public let value: String + + private enum CodingKeys: String, CodingKey { case targetId, selector, property, previousValue, value } + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(targetId, forKey: .targetId) + try values.encode(selector, forKey: .selector) + try values.encode(property, forKey: .property) + try values.encode(previousValue, forKey: .previousValue) + try values.encode(value, forKey: .value) + } + } + public let annotationId: String + public let pageUrl: String + public let pageTitle: String? + public let comment: String + public let targetSummary: String + public let styleChanges: [String] + public let elements: [Element]? + public let elementIds: [String]? + public let regionCount: Int? + public let strokeCount: Int? + public let styleChangeDetails: [StyleChange]? + public let screenshotContextId: String? + + private enum CodingKeys: String, CodingKey { + case annotationId, pageUrl, pageTitle, comment, targetSummary, styleChanges + case elements, elementIds, regionCount, strokeCount, styleChangeDetails, screenshotContextId + } + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(annotationId, forKey: .annotationId) + try values.encode(pageUrl, forKey: .pageUrl) + try values.encode(pageTitle, forKey: .pageTitle) + try values.encode(comment, forKey: .comment) + try values.encode(targetSummary, forKey: .targetSummary) + try values.encode(styleChanges, forKey: .styleChanges) + try values.encodeIfPresent(elements, forKey: .elements) + try values.encodeIfPresent(elementIds, forKey: .elementIds) + try values.encodeIfPresent(regionCount, forKey: .regionCount) + try values.encodeIfPresent(strokeCount, forKey: .strokeCount) + try values.encodeIfPresent(styleChangeDetails, forKey: .styleChangeDetails) + try values.encodeIfPresent(screenshotContextId, forKey: .screenshotContextId) + } + } + + public struct ReviewComment: Codable, Equatable, Hashable, Sendable { + public struct PullRequest: Codable, Equatable, Hashable, Sendable { + public let number: Int + public let title: String + public let url: String + public let headBranch: String + public let baseBranch: String + public let state: String + public let isDraft: Bool + } + public let sectionId: String + public let sectionTitle: String + public let filePath: String + public let startIndex: Int + public let endIndex: Int + public let rangeLabel: String + public let text: String + public let diff: String + public let fenceLanguage: String? + public let pullRequest: PullRequest? + } + + public struct Mention: Codable, Equatable, Hashable, Sendable { + public let path: String + } + + public struct Skill: Codable, Equatable, Hashable, Sendable { + public let name: String + } + + public var kind: String { + switch payload { + case .image: "image" + case .file: "file" + case .terminal: "terminal" + case .element: "element" + case .previewAnnotation: "preview-annotation" + case .reviewComment: "review-comment" + case .mention: "mention" + case .skill: "skill" + case let .unknown(kind, _): kind + } + } + + public init(contextId: String = UUID().uuidString, label: String, payload: Payload) { + version = 1 + self.contextId = contextId + self.label = ComposerContextReferences.sanitizeLabel(label, kind: "context") + self.payload = payload + } + + private enum CodingKeys: String, CodingKey { + case version, contextId, label, kind, payload + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + version = try values.decode(Int.self, forKey: .version) + contextId = try values.decode(String.self, forKey: .contextId) + label = try values.decode(String.self, forKey: .label) + let kind = try values.decode(String.self, forKey: .kind) + guard version == 1, label.utf16.count <= 200, + ComposerContextReferences.parseHref("t3-context://v1/\(kind)/\(contextId)") != nil else { + throw DecodingError.dataCorruptedError(forKey: .kind, in: values, debugDescription: "Invalid context identity") + } + switch kind { + case "image": payload = .image(try Attachment(from: decoder)) + case "file": payload = .file(try Attachment(from: decoder)) + case "terminal": + let value = try Terminal(from: decoder) + guard value.lineStart >= 0, value.lineEnd >= value.lineStart, value.text.utf16.count <= 64_000 else { + throw DecodingError.dataCorruptedError(forKey: .kind, in: values, debugDescription: "Invalid terminal context") + } + payload = .terminal(value) + case "element": payload = .element(try Element(from: decoder)) + case "preview-annotation": payload = .previewAnnotation(try PreviewAnnotation(from: decoder)) + case "review-comment": + let value = try ReviewComment(from: decoder) + guard value.startIndex >= 0, value.endIndex >= value.startIndex, + value.text.utf16.count <= 16_000, value.diff.utf16.count <= 32_000 else { + throw DecodingError.dataCorruptedError(forKey: .kind, in: values, debugDescription: "Invalid review context") + } + payload = .reviewComment(value) + case "mention": payload = .mention(try Mention(from: decoder)) + case "skill": payload = .skill(try Skill(from: decoder)) + default: payload = .unknown(kind: kind, payload: try values.decode(JSONValue.self, forKey: .payload)) + } + } + + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(version, forKey: .version) + try values.encode(contextId, forKey: .contextId) + try values.encode(label, forKey: .label) + try values.encode(kind, forKey: .kind) + switch payload { + case let .image(value), let .file(value): try value.encode(to: encoder) + case let .terminal(value): try value.encode(to: encoder) + case let .element(value): try value.encode(to: encoder) + case let .previewAnnotation(value): try value.encode(to: encoder) + case let .reviewComment(value): try value.encode(to: encoder) + case let .mention(value): try value.encode(to: encoder) + case let .skill(value): try value.encode(to: encoder) + case let .unknown(_, value): try values.encode(value, forKey: .payload) + } + } + + public var attachment: Attachment? { + switch payload { + case let .file(value), let .image(value): value + default: nil + } + } +} + +public struct OrchestrationMessageContext: Codable, Equatable, Hashable, Sendable { + public let version: Int + public var records: [ComposerContextRecord] + + public init(records: [ComposerContextRecord]) { + version = 1 + self.records = records + } + + private enum CodingKeys: String, CodingKey { case version, records } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + version = try values.decode(Int.self, forKey: .version) + let raw = try values.decode([JSONValue].self, forKey: .records) + guard version == 1, raw.count <= 200 else { + throw DecodingError.dataCorruptedError(forKey: .records, in: values, debugDescription: "Invalid message context") + } + records = raw.compactMap { try? $0.decode(ComposerContextRecord.self) } + guard Set(records.map(\.contextId)).count == records.count else { + throw DecodingError.dataCorruptedError(forKey: .records, in: values, debugDescription: "Duplicate context identity") + } + } +} + +public enum PastedTextAttachmentSource: String, Codable, Equatable, Hashable, Sendable { + case pastedText = "pasted-text" + + private enum CodingKeys: String, CodingKey { case tag = "_tag" } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + let tag = try values.decode(String.self, forKey: .tag) + guard let source = Self(rawValue: tag) else { + throw DecodingError.dataCorruptedError(forKey: .tag, in: values, debugDescription: "Unknown file source") + } + self = source + } + + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(rawValue, forKey: .tag) + } +} diff --git a/apps/swift-ios/Core/ComposerContextReferences.swift b/apps/swift-ios/Core/ComposerContextReferences.swift new file mode 100644 index 000000000000..b2b5b141d8cd --- /dev/null +++ b/apps/swift-ios/Core/ComposerContextReferences.swift @@ -0,0 +1,193 @@ +import Foundation + +/// Matches packages/shared/src/composerContextReferences.ts. Labels are never identities. +public enum ComposerContextReferences { + public struct Reference: Equatable, Sendable { + public let kind: String + public let contextId: String + public let label: String + public let image: Bool + public let range: NSRange + } + + private static let expression = try! NSRegularExpression( + pattern: #"(!?)\[([^\]\n]{0,512})\]\((t3-context://v1/[^\s)]{1,200})\)"# + ) + + public static func parseHref(_ href: String) -> (kind: String, contextId: String)? { + guard href.hasPrefix("t3-context://v1/") else { return nil } + let parts = href.dropFirst("t3-context://v1/".count).split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 2, + parts[0].range(of: #"^[a-z][a-z0-9-]{0,39}$"#, options: .regularExpression) != nil, + parts[1].range(of: #"^[a-zA-Z0-9_-]{1,128}$"#, options: .regularExpression) != nil else { return nil } + return (String(parts[0]), String(parts[1])) + } + + public static func sanitizeLabel(_ label: String, kind: String) -> String { + let clean = label.replacingOccurrences(of: #"[\[\]\\\r\n]"#, with: " ", options: .regularExpression) + .split(whereSeparator: \.isWhitespace).joined(separator: " ") + let bounded = boundedPrefix(clean, maximumUTF16: 200) + return bounded.isEmpty ? kind : bounded + } + + public static func boundedPrefix(_ text: String, maximumUTF16: Int) -> String { + let units = text.utf16.prefix(max(0, maximumUTF16)) + return String(decoding: units.last.map { (0xD800...0xDBFF).contains($0) } == true ? units.dropLast() : units, as: UTF16.self) + } + + public static func format(_ record: ComposerContextRecord) -> String { + "\(record.kind == "image" ? "!" : "")[\(sanitizeLabel(record.label, kind: record.kind))](t3-context://v1/\(record.kind)/\(record.contextId))" + } + + public static func collect(_ text: String) -> [Reference] { + guard text.contains("](t3-context:") else { return [] } + let source = text as NSString + return expression.matches(in: text, range: NSRange(location: 0, length: source.length)).compactMap { match in + guard let parsed = parseHref(source.substring(with: match.range(at: 3))) else { return nil } + return Reference( + kind: parsed.kind, contextId: parsed.contextId, + label: sanitizeLabel(source.substring(with: match.range(at: 2)), kind: parsed.kind), + image: match.range(at: 1).length > 0, range: match.range + ) + } + } + + public static func replace(_ text: String, with replacement: (Reference) -> String) -> String { + let result = NSMutableString(string: text) + for reference in collect(text).reversed() { + result.replaceCharacters(in: reference.range, with: replacement(reference)) + } + return result as String + } + + public static func displayText(_ text: String) -> String { + replace(text) { $0.label } + } + + public static func ensureReferences(_ text: String, records: [ComposerContextRecord]) -> String { + var referenced = Set(collect(text).map(\.contextId)) + var result = text + for record in records where referenced.insert(record.contextId).inserted { + if let last = result.last, !last.isWhitespace { result += " " } + result += format(record) + " " + } + return result + } + + /// Keep linked records and annotation screenshots, including unknown kinds. + public static func referenced(_ context: OrchestrationMessageContext?, text: String) -> OrchestrationMessageContext? { + guard let context else { return nil } + var ids = Set(collect(text).map(\.contextId)) + for record in context.records where ids.contains(record.contextId) { + if case let .previewAnnotation(value) = record.payload, let screenshot = value.screenshotContextId { + ids.insert(screenshot) + } + } + let records = context.records.filter { ids.contains($0.contextId) } + return records.isEmpty ? nil : OrchestrationMessageContext(records: records) + } + + /// Attachment uploads replace client ids. Rebind payloads without changing link identity. + public static func rebind( + _ context: OrchestrationMessageContext?, attachmentIDs: [String: String] + ) -> OrchestrationMessageContext? { + guard var context else { return nil } + context.records = context.records.map { record in + var result = record + guard var attachment = record.attachment, + let id = attachmentIDs[attachment.attachmentId] else { return result } + attachment.attachmentId = id + result.payload = record.kind == "image" ? .image(attachment) : .file(attachment) + return result + } + return context + } + + /// Older servers need readable text because they discard the context field. + public static func providerProjection(_ text: String, context: OrchestrationMessageContext?) -> String { + let references = collect(text) + guard !references.isEmpty else { return text } + let grouped = Dictionary(grouping: context?.records ?? [], by: \.contextId) + let byID = grouped.compactMapValues { $0.count == 1 ? $0.first : nil } + let body = replace(text) { reference in + let kind = byID[reference.contextId]?.kind ?? reference.kind + let spaced = kind.replacingOccurrences(of: "-", with: " ") + let name = spaced.prefix(1).uppercased() + spaced.dropFirst() + let label = reference.label.replacingOccurrences(of: #"[\r\n;\]]"#, with: " ", options: .regularExpression) + .split(whereSeparator: \.isWhitespace).joined(separator: " ") + return "[\(name): \(escapePayload(label)); ref=\(reference.contextId)]" + } + var seen = Set() + let entries = references.filter { seen.insert($0.contextId).inserted }.map { reference in + let record = byID[reference.contextId] + let open = "" } + return open + ">\n" + escapePayload(providerPayload(record)) + "\n" + } + return body + "\n\n\n" + entries.joined(separator: "\n") + "\n" + } + + private static func escapePayload(_ text: String) -> String { + text.replacingOccurrences(of: #"<(?=/?(?:t3_context|context)\b)"#, with: "<", options: [.regularExpression, .caseInsensitive]) + } + + private static func indent(_ text: String) -> String { + text.components(separatedBy: "\n").map { " " + $0 }.joined(separator: "\n") + } + + private static func elementLines(_ element: ComposerContextRecord.Element) -> [String] { + var lines = ["url: \(element.pageUrl)", "tag: \(element.tagName)"] + if let title = element.pageTitle, !title.isEmpty { lines.append("title: \(title)") } + if let selector = element.selector, !selector.isEmpty { lines.append("selector: \(selector)") } + if let component = element.componentName, !component.isEmpty { lines.append("component: \(component)") } + if let source = element.source, let file = source.fileName, !file.isEmpty { + var location = file + if let line = source.lineNumber { + location += ":\(line)" + if let column = source.columnNumber { location += ":\(column)" } + } + lines.append("source: \(location)") + } + let html = element.htmlPreview.trimmingCharacters(in: .whitespacesAndNewlines) + let styles = element.styles.trimmingCharacters(in: .whitespacesAndNewlines) + if !html.isEmpty { lines += ["html:", indent(html)] } + if !styles.isEmpty { lines += ["styles:", indent(styles)] } + return lines + } + + public static func providerPayload(_ record: ComposerContextRecord) -> String { + switch record.payload { + case let .image(value), let .file(value): + return "name: \(value.name)\nmimeType: \(value.mimeType)\nsizeBytes: \(value.sizeBytes)\nattachmentId: \(value.attachmentId)" + case let .terminal(value): + let lines = value.text.components(separatedBy: "\n").prefix(min(value.lineEnd - value.lineStart, 64_000) + 1) + .enumerated().map { "\(value.lineStart + $0.offset) | \($0.element)" } + return (["terminal: \(value.terminalLabel)"] + lines).joined(separator: "\n") + case let .mention(value): return "path: \(value.path)" + case let .skill(value): return "name: \(value.name)" + case let .reviewComment(value): + var lines = ["file: \(value.filePath)", "range: \(value.rangeLabel) (\(value.startIndex)-\(value.endIndex))", "section: \(value.sectionTitle)"] + let text = value.text.trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { lines += ["comment:", indent(text)] } + if !value.diff.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + lines += ["\(value.fenceLanguage ?? "diff"):", indent(value.diff.replacingOccurrences(of: #"\s+$"#, with: "", options: .regularExpression))] + } + return lines.joined(separator: "\n") + case let .element(value): return elementLines(value).joined(separator: "\n") + case let .previewAnnotation(value): + let title = value.pageTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + var lines = ["page: \(title.isEmpty ? value.pageUrl : title)", "url: \(value.pageUrl)"] + let comment = value.comment.trimmingCharacters(in: .whitespacesAndNewlines) + if !comment.isEmpty { lines.append("comment: \(comment)") } + if !value.targetSummary.isEmpty { lines.append("targets: \(value.targetSummary)") } + if !value.styleChanges.isEmpty { lines += ["requested visual changes:"] + value.styleChanges.map { "- " + $0 } } + if let id = value.screenshotContextId { lines.append("screenshot: ref=\(id)") } + for (index, element) in (value.elements ?? []).enumerated() { + lines += ["element \(index + 1):", indent(elementLines(element).joined(separator: "\n"))] + } + return lines.joined(separator: "\n") + case let .unknown(_, value): + return (try? JSONEncoder.t3.encode(value)).map { String(decoding: $0, as: UTF8.self) } ?? "null" + } + } +} diff --git a/apps/swift-ios/Core/JSONValue.swift b/apps/swift-ios/Core/JSONValue.swift index 24434eaef31d..e2bddea8e72e 100644 --- a/apps/swift-ios/Core/JSONValue.swift +++ b/apps/swift-ios/Core/JSONValue.swift @@ -2,7 +2,7 @@ import Foundation /// A lossless, Sendable JSON representation used at protocol boundaries that /// intentionally carry provider-defined payloads. -public enum JSONValue: Codable, Equatable, Sendable { +public enum JSONValue: Codable, Equatable, Hashable, Sendable { case null case bool(Bool) case integer(Int64) diff --git a/apps/swift-ios/Core/Models.swift b/apps/swift-ios/Core/Models.swift index 5a62deba4e34..7c49b9f619db 100644 --- a/apps/swift-ios/Core/Models.swift +++ b/apps/swift-ios/Core/Models.swift @@ -124,6 +124,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { public var usageLimitSources: Bool? = nil public var questionAttachments: Bool? = nil public var projectSettingsOverrides: Bool? = nil + public var inlineMessageContext: Bool? = nil private enum CodingKeys: String, CodingKey { case repositoryIdentity @@ -148,6 +149,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { case usageLimitSources case questionAttachments case projectSettingsOverrides + case inlineMessageContext } public init(from decoder: any Decoder) throws { @@ -156,6 +158,7 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { usageLimitSources = try container.decodeIfPresent(Bool.self, forKey: .usageLimitSources) questionAttachments = try container.decodeIfPresent(Bool.self, forKey: .questionAttachments) projectSettingsOverrides = try container.decodeIfPresent(Bool.self, forKey: .projectSettingsOverrides) + inlineMessageContext = try container.decodeIfPresent(Bool.self, forKey: .inlineMessageContext) repositoryIdentity = try container.decodeIfPresent(Bool.self, forKey: .repositoryIdentity) ?? false connectionProbe = try container.decodeIfPresent(Bool.self, forKey: .connectionProbe) @@ -521,6 +524,7 @@ public struct OrchestrationMessage: Codable, Identifiable, Equatable, Sendable { public let streaming: Bool public let createdAt: String public let updatedAt: String + public var context: OrchestrationMessageContext? = nil } public struct ChatAttachment: Codable, Identifiable, Equatable, Sendable { @@ -529,6 +533,7 @@ public struct ChatAttachment: Codable, Identifiable, Equatable, Sendable { public let name: String public let mimeType: String public let sizeBytes: Int + public var source: JSONValue? = nil } public struct OrchestrationActivity: Codable, Identifiable, Equatable, Sendable { diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift index e1c8f086ff32..235302e9d3d9 100644 --- a/apps/swift-ios/Core/T3Client.swift +++ b/apps/swift-ios/Core/T3Client.swift @@ -678,20 +678,26 @@ public actor T3Client { interactionMode: InteractionMode = .default, model: ModelSelection? = nil, attachments: [UploadChatImageAttachment] = [], + context: OrchestrationMessageContext? = nil, commandID: String = UUID().uuidString, messageID: String = UUID().uuidString, createdAt: String = OrchestrationCommands.now() ) async throws -> DispatchResult { let uploadedAttachments = try await prepareTurnAttachments(attachments) + let preparedMessage = Self.prepareMessageContext( + text: text, context: context, attachments: attachments, uploadedAttachments: uploadedAttachments, + supportsContext: (latestServerEnvironment ?? environment.descriptor)?.capabilities.inlineMessageContext == true + ) return try await dispatch( try OrchestrationCommands.sendTurn( threadID: threadID, - text: text, + text: preparedMessage.text, runtimeMode: runtimeMode, interactionMode: interactionMode, model: model, attachments: attachments, uploadedAttachments: uploadedAttachments, + context: preparedMessage.context, commandID: commandID, messageID: messageID, createdAt: createdAt @@ -739,17 +745,22 @@ public actor T3Client { worktreePath: String? = nil, worktreePreparation: ThreadWorktreePreparation? = nil, attachments: [UploadChatImageAttachment] = [], + context: OrchestrationMessageContext? = nil, commandID: String = UUID().uuidString, messageID: String = UUID().uuidString, createdAt: String = OrchestrationCommands.now() ) async throws -> DispatchResult { let uploadedAttachments = try await prepareTurnAttachments(attachments) + let preparedMessage = Self.prepareMessageContext( + text: text, context: context, attachments: attachments, uploadedAttachments: uploadedAttachments, + supportsContext: (latestServerEnvironment ?? environment.descriptor)?.capabilities.inlineMessageContext == true + ) return try await dispatchOverWebSocket( try OrchestrationCommands.createThreadAndSend( threadID: threadID, projectID: projectID, title: title, - text: text, + text: preparedMessage.text, model: model, runtimeMode: runtimeMode, interactionMode: interactionMode, @@ -758,6 +769,7 @@ public actor T3Client { worktreePreparation: worktreePreparation, attachments: attachments, uploadedAttachments: uploadedAttachments, + context: preparedMessage.context, commandID: commandID, messageID: messageID, createdAt: createdAt @@ -1063,6 +1075,39 @@ public actor T3Client { ) } + static func prepareMessageContext( + text: String, + context: OrchestrationMessageContext?, + attachments: [UploadChatAttachment], + uploadedAttachments: [JSONValue]?, + supportsContext: Bool + ) -> (text: String, context: OrchestrationMessageContext?) { + var records = ComposerContextReferences.referenced(context, text: text)?.records ?? [] + var prompt = text + if supportsContext { + for attachment in attachments where records.count < 200 { + guard !records.contains(where: { $0.attachment?.attachmentId == attachment.id.uuidString }) else { continue } + let binding = ComposerContextRecord.Attachment( + attachmentId: attachment.id.uuidString, name: attachment.name, + mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes + ) + let record = ComposerContextRecord( + contextId: "\(attachment.type)_\(attachment.id.uuidString)", label: attachment.name, + payload: attachment.type == "image" ? .image(binding) : .file(binding) + ) + records.append(record) + prompt = ComposerContextReferences.ensureReferences(prompt, records: [record]) + } + } + let ids = Dictionary(zip(attachments, uploadedAttachments ?? []).compactMap { attachment, uploaded in + uploaded["id"]?.stringValue.map { (attachment.id.uuidString, $0) } + }, uniquingKeysWith: { first, _ in first }) + let rebound = ComposerContextReferences.rebind(records.isEmpty ? nil : OrchestrationMessageContext(records: records), attachmentIDs: ids) + return supportsContext + ? (prompt, rebound) + : (ComposerContextReferences.providerProjection(prompt, context: rebound), nil) + } + private func prepareTurnAttachments( _ attachments: [UploadChatImageAttachment] ) async throws -> [JSONValue]? { @@ -1159,7 +1204,7 @@ public actor T3Client { throw RPCError.protocolViolation("The attachment upload URL is invalid.") } switch attachment.source { - case let .imageData(data): + case let .imageData(data), let .fileData(data): try await api.uploadAttachment(data, mimeType: attachment.mimeType, to: url) case let .file(fileURL): guard let actualBytes = try? fileURL.resourceValues( @@ -2081,20 +2126,21 @@ public enum OrchestrationCommands { model: ModelSelection? = nil, attachments: [UploadChatImageAttachment] = [], uploadedAttachments: [JSONValue]? = nil, + context: OrchestrationMessageContext? = nil, commandID: String = UUID().uuidString, messageID: String = UUID().uuidString, createdAt: String = now() ) throws -> JSONValue { + var message: [String: JSONValue] = [ + "messageId": .string(messageID), "role": .string("user"), "text": .string(text), + "attachments": .array(uploadedAttachments ?? attachments.map(\.jsonValue)), + ] + if let context { message["context"] = try .encode(context) } var command: [String: JSONValue] = [ "type": .string("thread.turn.start"), "commandId": .string(commandID), "threadId": .string(threadID), - "message": .object([ - "messageId": .string(messageID), - "role": .string("user"), - "text": .string(text), - "attachments": .array(uploadedAttachments ?? attachments.map(\.jsonValue)), - ]), + "message": .object(message), "runtimeMode": .string(runtimeMode.rawValue), "interactionMode": .string(interactionMode.rawValue), "createdAt": .string(createdAt), @@ -2118,6 +2164,7 @@ public enum OrchestrationCommands { worktreePreparation: ThreadWorktreePreparation? = nil, attachments: [UploadChatImageAttachment] = [], uploadedAttachments: [JSONValue]? = nil, + context: OrchestrationMessageContext? = nil, commandID: String = UUID().uuidString, messageID: String = UUID().uuidString, createdAt: String = now() @@ -2146,16 +2193,16 @@ public enum OrchestrationCommands { bootstrap["prepareWorktree"] = .object(prepareWorktree) bootstrap["runSetupScript"] = .bool(true) } + var message: [String: JSONValue] = [ + "messageId": .string(messageID), "role": .string("user"), "text": .string(text), + "attachments": .array(uploadedAttachments ?? attachments.map(\.jsonValue)), + ] + if let context { message["context"] = try .encode(context) } return .object([ "type": .string("thread.turn.start"), "commandId": .string(commandID), "threadId": .string(threadID), - "message": .object([ - "messageId": .string(messageID), - "role": .string("user"), - "text": .string(text), - "attachments": .array(uploadedAttachments ?? attachments.map(\.jsonValue)), - ]), + "message": .object(message), "modelSelection": try .encode(model), "titleSeed": .string(title), "runtimeMode": .string(runtimeMode.rawValue), diff --git a/apps/swift-ios/Features/Chat/FeatureComposerContext.swift b/apps/swift-ios/Features/Chat/FeatureComposerContext.swift new file mode 100644 index 000000000000..88a527786629 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerContext.swift @@ -0,0 +1,29 @@ +import Foundation + +enum FeatureComposerContext { + enum MergeError: LocalizedError { + case tooManyRecords + var errorDescription: String? { "A message can include up to 200 context items. Remove an item before adding another." } + } + + static func terminalRecord(text: String, terminalID: String, label: String) -> ComposerContextRecord { + // Keep the newest output and its original line numbers within the wire limit. + let suffix = String(decoding: text.utf16.suffix(64_000).drop(while: { (0xDC00...0xDFFF).contains($0) }), as: UTF16.self) + let end = text.components(separatedBy: "\n").count - 1 + let start = max(0, end - suffix.components(separatedBy: "\n").count + 1) + return ComposerContextRecord(label: "\(label) lines \(start)-\(end)", payload: .terminal(.init( + terminalId: terminalID, terminalLabel: label, lineStart: start, lineEnd: end, text: suffix + ))) + } + + static func merge( + _ first: OrchestrationMessageContext?, _ second: OrchestrationMessageContext? + ) throws -> OrchestrationMessageContext? { + var seen = Set() + let records = ((first?.records ?? []) + (second?.records ?? [])).filter { + seen.insert($0.contextId).inserted + } + guard records.count <= 200 else { throw MergeError.tooManyRecords } + return records.isEmpty ? nil : OrchestrationMessageContext(records: records) + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerPowerFeatures.swift b/apps/swift-ios/Features/Chat/FeatureComposerPowerFeatures.swift index 0c113252ff92..c882e2973cea 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerPowerFeatures.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerPowerFeatures.swift @@ -280,6 +280,12 @@ enum FeatureComposerTriggerParser { } enum FeatureComposerFileLinkSerializer { + static func url(for path: String) -> URL? { + // Keep a Windows drive followed by a slash so the workspace router + // recognizes it as a file path instead of an external URL scheme. + URL(string: encodeDestination(path.replacingOccurrences(of: "\\", with: "/"))) + } + static func markdownLink(for path: String) -> String { let normalized = path.replacingOccurrences(of: "\\", with: "/") let basename = normalized.split(separator: "/", omittingEmptySubsequences: true) diff --git a/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift b/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift index a7c67360d44a..71b5f46efd65 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift @@ -22,6 +22,10 @@ struct FeatureComposerTextInput: UIViewRepresentable { let onSelectionChange: (NSRange) -> Void let onPasteImages: ([NSItemProvider]) -> Void let onDismissKeyboard: (() -> Void)? + var maximumPastedTextBytes: Int? = nil + var onPasteTextAttachment: ((String, @escaping @MainActor () -> Bool) -> Void)? = nil + var onPasteTextError: ((String) -> Void)? = nil + var draftOwnerID: String = "" func makeCoordinator() -> Coordinator { Coordinator(self) @@ -34,6 +38,10 @@ struct FeatureComposerTextInput: UIViewRepresentable { textView.isReadOnly = isReadOnly textView.onPasteImages = onPasteImages textView.onDismissKeyboard = onDismissKeyboard + textView.maximumPastedTextBytes = maximumPastedTextBytes + textView.onPasteTextAttachment = onPasteTextAttachment + textView.draftOwnerID = draftOwnerID + textView.onPasteTextError = onPasteTextError if onDismissKeyboard != nil { textView.installDismissPanRecognizer() } @@ -64,6 +72,10 @@ struct FeatureComposerTextInput: UIViewRepresentable { textView.acceptsImages = acceptsImages textView.onPasteImages = onPasteImages textView.onDismissKeyboard = onDismissKeyboard + textView.maximumPastedTextBytes = maximumPastedTextBytes + textView.onPasteTextAttachment = onPasteTextAttachment + textView.draftOwnerID = draftOwnerID + textView.onPasteTextError = onPasteTextError textView.isReadOnly = isReadOnly let previousAttributedText = textView.attributedText ?? NSAttributedString() @@ -295,6 +307,21 @@ struct FeatureComposerTextInput: UIViewRepresentable { parent.onSelectionChange(selection) } + func textView( + _ textView: UITextView, + editMenuForTextIn range: NSRange, + suggestedActions: [UIMenuElement] + ) -> UIMenu? { + guard !parent.isReadOnly, UIPasteboard.general.hasStrings, + let composerTextView = textView as? FeatureComposerUITextView else { + return UIMenu(children: suggestedActions) + } + let pasteAsText = UIAction(title: "Paste as Text") { [weak composerTextView] _ in + composerTextView?.pasteAsText(nil) + } + return UIMenu(children: suggestedActions + [pasteAsText]) + } + func textViewDidBeginEditing(_ textView: UITextView) { lastAppliedFocus = true if !parent.focused { @@ -380,8 +407,8 @@ struct FeatureComposerTextInput: UIViewRepresentable { } } -/// Advertises image support to the paste menu and routes image pastes out to -/// the attachment pipeline. Text-only pastes fall through to UIKit untouched. +/// Routes images and large text pastes to attachments. Ordinary text keeps +/// UIKit's native paste behavior. final class FeatureComposerUITextView: FeatureInlineSkillTextView { private static let bottomEditingInset: CGFloat = 10 private var lastLaidOutBoundsSize = CGSize.zero @@ -456,8 +483,24 @@ final class FeatureComposerUITextView: FeatureInlineSkillTextView { } var onPasteImages: (([NSItemProvider]) -> Void)? var onDismissKeyboard: (() -> Void)? + var maximumPastedTextBytes: Int? + var onPasteTextAttachment: ((String, @escaping @MainActor () -> Bool) -> Void)? + var draftOwnerID = "" + private var pastedTextRequestID = UUID() + var onPasteTextError: ((String) -> Void)? private var wantsFirstResponderOnAttach = false + override var keyCommands: [UIKeyCommand]? { + let pasteAsTextCommand = UIKeyCommand( + input: "v", + modifierFlags: [.command, .shift], + action: #selector(pasteAsText(_:)) + ) + pasteAsTextCommand.discoverabilityTitle = "Paste as Text" + pasteAsTextCommand.wantsPriorityOverSystemBehavior = true + return (super.keyCommands ?? []) + [pasteAsTextCommand] + } + /// Programmatic focus can arrive before the view joins a window (a host /// refocusing right as the composer expands); retry once attached. The /// pending request is cancelled if focus clears again before the view @@ -548,6 +591,9 @@ final class FeatureComposerUITextView: FeatureInlineSkillTextView { } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(pasteAsText(_:)) { + return !isReadOnly && UIPasteboard.general.hasStrings + } if isReadOnly, action == #selector(paste(_:)) || action == #selector(cut(_:)) { return false } @@ -579,18 +625,74 @@ final class FeatureComposerUITextView: FeatureInlineSkillTextView { // attached screenshot reads as a bug. override func paste(_ sender: Any?) { guard !isReadOnly else { return } - guard acceptsImages else { - super.paste(sender) + if acceptsImages { + let imageProviders = UIPasteboard.general.itemProviders.filter { + $0.hasItemConformingToTypeIdentifier(UTType.image.identifier) + } + if !imageProviders.isEmpty { + onPasteImages?(imageProviders) + return + } + } + if let pastedText = UIPasteboard.general.string, foldPastedText(pastedText) { return } - let imageProviders = UIPasteboard.general.itemProviders.filter { - $0.hasItemConformingToTypeIdentifier(UTType.image.identifier) + super.paste(sender) + } + + var readClipboardText: () -> String? = { UIPasteboard.general.string } + + @objc func pasteAsText(_ sender: Any?) { + guard !isReadOnly, let pastedText = readClipboardText() else { return } + if !foldPastedText(pastedText, bypassAutoAttachment: true) { + insertText(pastedText) } - guard !imageProviders.isEmpty else { - super.paste(sender) - return + } + + /// Returning false leaves the paste to UIKit. A rejected fold must not + /// replace selected draft text with an empty string. + func foldPastedText(_ pastedText: String, bypassAutoAttachment: Bool = false) -> Bool { + guard !isReadOnly else { return true } + let source = FeatureInlineSkillProjection.plainText(from: attributedText) + let selection = FeatureInlineSkillProjection.plainRange( + for: selectedRange, + in: attributedText + ) + switch FeaturePastedText.disposition( + text: pastedText, + currentTextLength: source.utf16.count, + selection: selection, + maximumAttachmentBytes: maximumPastedTextBytes, + bypassAutoAttachment: bypassAutoAttachment + ) { + case .inline: + return false + case .rejected: + onPasteTextError?( + "Pasted text is too large for this message. Remove some text or an attachment, then paste again." + ) + case .attachment: + guard markedTextRange == nil, let onPasteTextAttachment else { + onPasteTextError?("Could not attach pasted text. Your draft has not changed.") + return true + } + let requestID = UUID() + pastedTextRequestID = requestID + let ownerID = draftOwnerID + onPasteTextAttachment(pastedText) { [weak self] in + guard let self, self.pastedTextRequestID == requestID, + self.draftOwnerID == ownerID, !self.isReadOnly, self.markedTextRange == nil, + FeatureInlineSkillProjection.plainText(from: self.attributedText) == source, + FeatureInlineSkillProjection.plainRange(for: self.selectedRange, in: self.attributedText) == selection else { + return false + } + if self.selectedRange.length > 0 { self.insertText("") } + self.scrollSelectionIntoView() + self.pastedTextRequestID = UUID() + return true + } } - onPasteImages?(imageProviders) + return true } } diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift index 0f9f25d45427..4319f9cf3f8d 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerView.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -40,12 +40,16 @@ struct FeatureComposerView: View { @State private var pathSearchError: String? @State private var textSelectionRequest: FeatureComposerTextSelectionRequest? @State private var imageIntakeErrorMessage: String? + @State private var pastedTextErrorMessage: String? + @State private var pastedTextTask: Task? + @State private var pastedTextGeneration = UUID() @State private var textRevision: UInt64 = 0 @State private var textObservation = FeatureComposerTextObservation() @State private var voiceInputController = FeatureVoiceInputController() @Binding private var text: String @Binding private var selection: FeatureSelection? @Binding private var attachments: [FeatureDraftAttachment] + @Binding private var context: OrchestrationMessageContext? private let providers: [FeatureProvider] private let draftOwnerID: String @@ -107,11 +111,13 @@ struct FeatureComposerView: View { onUserInputDismiss: ((String) async -> Void)? = nil, onRefreshModels: (() async throws -> Void)? = nil, draftSaveError: String? = nil, - onRetryDraftSave: (() -> Void)? = nil + onRetryDraftSave: (() -> Void)? = nil, + context: Binding = .constant(nil) ) { _text = text _selection = selection _attachments = attachments + _context = context self.draftOwnerID = draftOwnerID self.environmentID = environmentID self.draftStorageKey = draftStorageKey @@ -202,6 +208,8 @@ struct FeatureComposerView: View { } .onDisappear { voiceInputController.cancel() + pastedTextTask?.cancel() + pastedTextGeneration = UUID() } .onChange(of: text) { textRevision &+= 1 @@ -209,6 +217,8 @@ struct FeatureComposerView: View { } .onChange(of: draftOwnerID) { synchronizeVoiceDraft(ownerChanged: true) + pastedTextTask?.cancel() + pastedTextGeneration = UUID() } .onChange(of: voiceInputController.pendingCommit?.id) { applyPendingVoiceCommit() @@ -237,6 +247,17 @@ struct FeatureComposerView: View { } message: { Text(imageIntakeErrorMessage ?? "") } + .alert( + "Could not paste text", + isPresented: Binding( + get: { pastedTextErrorMessage != nil }, + set: { if !$0 { pastedTextErrorMessage = nil } } + ) + ) { + Button("OK") { pastedTextErrorMessage = nil } + } message: { + Text(pastedTextErrorMessage ?? "") + } } private var composerSurface: some View { @@ -349,7 +370,11 @@ struct FeatureComposerView: View { selectionRequest: textSelectionRequest, onSelectionChange: handleTextSelectionChange, onPasteImages: attachImageProviders, - onDismissKeyboard: onDismissKeyboard + onDismissKeyboard: onDismissKeyboard, + maximumPastedTextBytes: maximumPastedTextBytes, + onPasteTextAttachment: attachPastedText, + onPasteTextError: { pastedTextErrorMessage = $0 }, + draftOwnerID: draftOwnerID ) .padding(.horizontal, 16) .padding(.top, 14) @@ -871,21 +896,36 @@ struct FeatureComposerView: View { } } + private func contextReference(label: String, payload: ComposerContextRecord.Payload) throws -> String { + let record = ComposerContextRecord(label: label, payload: payload) + context = try FeatureComposerContext.merge(ComposerContextReferences.referenced(context, text: text), .init(records: [record])) + return ComposerContextReferences.format(record) + " " + } + private func selectCommandItem(_ item: FeatureComposerMenuItem) { guard let trigger = composerTrigger else { return } let replacement: String - switch item { - case .modelCommand: - replacement = "/model " - case let .model(nextSelection, _, _): - selection = nextSelection - replacement = "" - case let .providerCommand(command): - replacement = "/\(command.name) " - case let .skill(skill): - replacement = skill.invocation - case let .path(entry): - replacement = FeatureComposerFileLinkSerializer.markdownLink(for: entry.path) + " " + do { + switch item { + case .modelCommand: + replacement = "/model " + case let .model(nextSelection, _, _): + selection = nextSelection + replacement = "" + case let .providerCommand(command): + replacement = "/\(command.name) " + case let .skill(skill): + if skill.userInvocationOnly == true { + replacement = skill.invocation + } else { + replacement = try contextReference(label: skill.invocationDisplayName, payload: .skill(.init(name: skill.name))) + } + case let .path(entry): + replacement = try contextReference(label: entry.name, payload: .mention(.init(path: entry.path))) + } + } catch { + pathSearchError = error.localizedDescription + return } let nextCursorLocation = FeatureComposerTextSelectionPolicy.cursorLocation( afterReplacing: trigger.range, @@ -1022,6 +1062,58 @@ struct FeatureComposerView: View { } } + private var maximumPastedTextBytes: Int? { + FeaturePastedText.maximumAttachmentBytes( + advertisedMaximum: attachmentPreferences.maxFileAttachmentBytes, + attachmentCount: attachments.count, + pendingCount: attachmentPreparation.pendingItemCount + ) + } + + private func attachPastedText(_ pastedText: String, commitSelection: @escaping @MainActor () -> Bool) { + guard !voiceInputController.isBusy, !pastedText.isEmpty, + let maximumPastedTextBytes, + pastedText.utf8.count <= maximumPastedTextBytes else { + pastedTextErrorMessage = "Could not attach pasted text. Your draft has not changed." + return + } + pastedTextTask?.cancel() + let generation = UUID() + pastedTextGeneration = generation + let fileName = FeaturePastedText.nextFileName(existingNames: attachments.map(\.filename)) + let fileStore = ManagedAttachmentFileStore() + let operation = attachmentPreparation.begin(itemCount: 1) + pastedTextTask = Task { @MainActor in + defer { + attachmentPreparation.finish(operation) + if pastedTextGeneration == generation { pastedTextTask = nil } + } + do { + try Task.checkCancellation() + let attachment = try await Task.detached(priority: .userInitiated) { + try FeaturePastedText.attachment(text: pastedText, fileName: fileName, maximumBytes: maximumPastedTextBytes, fileStore: fileStore) + }.value + var adopted = false + defer { + if !adopted, let file = attachment.ownedFile { + try? fileStore.removeOwnedFile(fileName: file.fileName) + } + } + guard !Task.isCancelled, pastedTextGeneration == generation else { return } + guard attachments.count + attachmentPreparation.pendingItemCount <= FeatureImageAttachmentLimits.maximumCount, + commitSelection() else { + pastedTextErrorMessage = "The draft changed while the file was prepared. Paste again to add it." + return + } + attachments.append(attachment) + adopted = true + } catch { + guard !Task.isCancelled, pastedTextGeneration == generation else { return } + pastedTextErrorMessage = error.localizedDescription + } + } + } + /// 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 { diff --git a/apps/swift-ios/Features/Chat/FeatureInlineSkillPill.swift b/apps/swift-ios/Features/Chat/FeatureInlineSkillPill.swift index 8be5b706f698..f581dac7408b 100644 --- a/apps/swift-ios/Features/Chat/FeatureInlineSkillPill.swift +++ b/apps/swift-ios/Features/Chat/FeatureInlineSkillPill.swift @@ -34,15 +34,24 @@ enum FeatureInlineSkillParser { allowsEndBoundary: Bool, preservingTrailing preserved: FeatureInlineSkillDescriptor? = nil ) -> [FeatureInlineSkillDescriptor] { - guard !text.isEmpty, !skills.isEmpty else { return [] } + guard !text.isEmpty else { return [] } + let references = ComposerContextReferences.collect(text) + let contextDescriptors = references.map { reference in + FeatureInlineSkillDescriptor( + rawText: (text as NSString).substring(with: reference.range), + displayName: reference.label, + range: reference.range + ) + } let skillsByName = Dictionary(skills.map { ($0.name, $0) }) { first, _ in first } let source = text as NSString - return tokenExpression.matches( + let skillDescriptors = tokenExpression.matches( in: text, range: NSRange(location: 0, length: source.length) - ).compactMap { match in + ).compactMap { match -> FeatureInlineSkillDescriptor? in let range = match.range(at: 0) + guard !references.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) else { return nil } let hasEndBoundary = NSMaxRange(range) == source.length let preservesThisTrailingToken = hasEndBoundary && preserved?.range == range @@ -59,6 +68,7 @@ enum FeatureInlineSkillParser { range: range ) } + return (skillDescriptors + contextDescriptors).sorted { $0.range.location < $1.range.location } } } diff --git a/apps/swift-ios/Features/Chat/FeaturePastedText.swift b/apps/swift-ios/Features/Chat/FeaturePastedText.swift new file mode 100644 index 000000000000..28df9162ddb1 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeaturePastedText.swift @@ -0,0 +1,71 @@ +import Foundation + +enum FeaturePastedText { + // Keep these limits in sync with client-runtime/textPaste.ts and contracts/orchestration.ts. + static let attachmentThresholdBytes = 32 * 1024 + static let maximumInputCharacters = 120_000 + + enum Disposition: Equatable { + case inline + case attachment + case rejected + } + + /// Call off the main actor. Drafts and outbox entries retain the owned file, + /// so a large clipboard value is never copied into their JSON documents. + static func attachment( + text: String, fileName: String, maximumBytes: Int, + fileStore: ManagedAttachmentFileStore = .init() + ) throws -> FeatureDraftAttachment { + let id = UUID() + let file = try fileStore.writeOwnedFile( + data: Data(text.utf8), attachmentID: id, originalFileName: fileName, maximumBytes: maximumBytes + ) + return FeatureDraftAttachment(id: id, ownedFile: file, filename: fileName, mimeType: "text/plain", source: .pastedText) + } + + static func maximumAttachmentBytes( + advertisedMaximum: Int?, + attachmentCount: Int, + pendingCount: Int + ) -> Int? { + guard let advertisedMaximum, advertisedMaximum > 0, + attachmentCount + pendingCount < FeatureImageAttachmentLimits.maximumCount else { + return nil + } + return min(advertisedMaximum, ManagedAttachmentFileStore.maximumBytes) + } + + /// Input limits use UTF-16 offsets, as on web. Attachment limits use UTF-8 bytes. + static func disposition( + text: String, + currentTextLength: Int, + selection: NSRange, + maximumAttachmentBytes: Int?, + bypassAutoAttachment: Bool = false + ) -> Disposition { + guard !bypassAutoAttachment, !text.isEmpty else { return .inline } + let start = min(max(0, selection.location), currentTextLength) + let selectedLength = min(max(0, selection.length), currentTextLength - start) + let wouldExceedInputLimit = currentTextLength - selectedLength + text.utf16.count + > maximumInputCharacters + let byteCount = text.utf8.count + guard wouldExceedInputLimit || byteCount >= attachmentThresholdBytes else { + return .inline + } + if let maximumAttachmentBytes, byteCount <= maximumAttachmentBytes { + return .attachment + } + return wouldExceedInputLimit ? .rejected : .inline + } + + static func nextFileName(existingNames: [String]) -> String { + let names = Set(existingNames.map { $0.lowercased() }) + guard names.contains("pasted-text.txt") else { return "pasted-text.txt" } + var sequence = 2 + while names.contains("pasted-text-\(sequence).txt") { + sequence += 1 + } + return "pasted-text-\(sequence).txt" + } +} diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 79f70f53da1b..15cb7eaaf74f 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -16,6 +16,7 @@ public struct ThreadDetailView: View { private let draftStore: FeatureComposerDraftStore @State private var draft = "" + @State private var composerContext: OrchestrationMessageContext? @State private var selection: FeatureSelection? @State private var attachments: [FeatureDraftAttachment] = [] @State private var isSending = false @@ -162,7 +163,14 @@ public struct ThreadDetailView: View { case .sourceControl: FeatureSourceControlView(client: model.client, threadID: thread.id) case .terminal: - FeatureTerminalView(client: model.client, threadID: thread.id) + FeatureTerminalView(client: model.client, threadID: thread.id) { record in + composerContext = try FeatureComposerContext.merge( + ComposerContextReferences.referenced(composerContext, text: draft), .init(records: [record]) + ) + draft = ComposerContextReferences.ensureReferences(draft, records: [record]) + persistDraftImmediately() + composerFocused = true + } } } .toolbar { @@ -751,7 +759,8 @@ public struct ThreadDetailView: View { }, onRefreshModels: refreshThreadEnvironmentModels, draftSaveError: draftSaveError, - onRetryDraftSave: persistDraftImmediately + onRetryDraftSave: persistDraftImmediately, + context: contextBinding ) } .background(T3Colors.background) @@ -915,6 +924,7 @@ public struct ThreadDetailView: View { private func send() { let message = draft + let pendingContext = composerContext let pendingAttachments = currentThread.environmentID.map { model.attachmentUploads.attachmentsForSend( draftKey: draftKey, @@ -945,6 +955,7 @@ public struct ThreadDetailView: View { ) draft = "" attachments = [] + composerContext = nil composerFocused = false Task { await pendingDraftSave?.value @@ -953,7 +964,8 @@ public struct ThreadDetailView: View { threadID: thread.id, text: message, selection: selection, - attachments: pendingAttachments + attachments: pendingAttachments, + context: pendingContext ) ) if sent { @@ -977,11 +989,19 @@ public struct ThreadDetailView: View { } } else { let currentDraft = draft - let restoredMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) + let restoredMessage: String + do { + composerContext = try FeatureComposerContext.merge(pendingContext, composerContext) + restoredMessage = message + } catch { + // The failed turn and the new draft can each contain 200 items. + // Retain the failed turn as readable text if their records cannot fit together. + restoredMessage = ComposerContextReferences.providerProjection(message, context: pendingContext) + } if currentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - draft = message + draft = restoredMessage } else if !restoredMessage.isEmpty { - draft = "\(message)\n\(currentDraft)" + draft = "\(restoredMessage)\n\(currentDraft)" } let pendingIDs = Set(pendingAttachments.map(\.id)) attachments = pendingAttachments + attachments.filter { @@ -1069,6 +1089,13 @@ public struct ThreadDetailView: View { FeatureComposerDraftStore.threadKey(currentThread) } + private var contextBinding: Binding { + Binding(get: { composerContext }, set: { value in + composerContext = value + scheduleDraftSave() + }) + } + private var attachmentBinding: Binding<[FeatureDraftAttachment]> { Binding( get: { attachments }, @@ -1098,6 +1125,7 @@ public struct ThreadDetailView: View { providers: threadProviders ) draft = restored.text + composerContext = restored.context attachments = restored.attachments selection = restored.selection didRestoreDraft = true @@ -1238,7 +1266,8 @@ public struct ThreadDetailView: View { FeatureComposerDraft( text: draft, attachments: attachments, - selection: selection + selection: selection, + context: composerContext ) } @@ -1379,7 +1408,8 @@ enum FeatureComposerDraftRestoration { saved: saved?.workspace ?? fallbackWorkspace, baseline: baseline.workspace, current: current.workspace - ) + ), + context: current.context == baseline.context ? saved?.context : current.context ) } @@ -2574,8 +2604,76 @@ struct FeatureMessageView: View { var imageContext: MarkdownImageContext? = nil var attachmentContext: FeatureAttachmentContext? = nil var skills: [FeatureProviderSkill] = [] + @SwiftUI.Environment(\.openURL) private var openURL + @State private var previewedContext: ComposerContextRecord? + @State private var contextUnavailable = false var body: some View { + messageBody + .environment(\.openURL, OpenURLAction { url in + guard let reference = ComposerContextReferences.parseHref(url.absoluteString) else { + openURL(url) + return .handled + } + guard let record = message.context?.records.first(where: { $0.contextId == reference.contextId }) else { + contextUnavailable = true + return .handled + } + if case let .mention(value) = record.payload, let path = FeatureComposerFileLinkSerializer.url(for: value.path) { + openURL(path) + } else { + previewedContext = record + } + return .handled + }) + .sheet(item: $previewedContext) { record in + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if let binding = record.attachment, + let attachment = message.attachments.first(where: { $0.id == binding.attachmentId }) { + FeatureMessageAttachmentsView(attachments: [attachment], context: attachmentContext) + } else { + Text(ComposerContextReferences.providerPayload(record)) + .font(T3Typography.tool) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + if case let .reviewComment(value) = record.payload, + let request = value.pullRequest, + let url = URL(string: request.url), ["https", "http"].contains(url.scheme?.lowercased() ?? "") { + Link("Open pull request #\(request.number)", destination: url) + } + } + .padding() + } + .background(T3Colors.background) + .navigationTitle(record.label) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { previewedContext = nil } + } + } + .t3NavigationChrome() + } + .preferredColorScheme(.dark) + } + .alert("Context unavailable", isPresented: $contextUnavailable) { + Button("OK", role: .cancel) {} + } message: { + Text("This message has a context link without its saved record.") + } + } + + private var renderedText: String { + // Images use the existing attachment preview. Do not ask Markdown to fetch t3-context URLs. + ComposerContextReferences.replace(message.text) { + "[\($0.label)](t3-context://v1/\($0.kind)/\($0.contextId))" + } + } + + @ViewBuilder private var messageBody: some View { switch message.role { case .user: HStack { @@ -2584,7 +2682,7 @@ struct FeatureMessageView: View { FeatureMessageAttachmentsView(attachments: message.attachments, context: attachmentContext) if !message.text.isEmpty { MarkdownMessageView( - message.text, + renderedText, isStreaming: message.state == .streaming, imageContext: imageContext, skills: skills @@ -2621,7 +2719,7 @@ struct FeatureMessageView: View { FeatureMessageAttachmentsView(attachments: message.attachments, context: attachmentContext) if !message.text.isEmpty { MarkdownMessageView( - message.text, + renderedText, isStreaming: message.state == .streaming, imageContext: imageContext, skills: skills diff --git a/apps/swift-ios/Features/Review/FeatureReviewView.swift b/apps/swift-ios/Features/Review/FeatureReviewView.swift index c00290e51cca..0cf291b32555 100644 --- a/apps/swift-ios/Features/Review/FeatureReviewView.swift +++ b/apps/swift-ios/Features/Review/FeatureReviewView.swift @@ -422,12 +422,13 @@ private struct FeatureDiffView: View { private func sendComment() { guard !trimmedComment.isEmpty, !isSending else { return } - let prompt = reviewDraft.prompt + let record = reviewDraft.contextRecord(lines: renderedLines) + let prompt = reviewDraft.submissionText(contextRecord: record) isSending = true commentError = nil Task { let sent = await sendMessage( - FeatureMessageSubmission(threadID: threadID, text: prompt, selection: nil) + FeatureMessageSubmission(threadID: threadID, text: prompt, selection: nil, context: .init(records: [record])) ) if sent { comment = "" diff --git a/apps/swift-ios/Features/Root/FeatureRootModel.swift b/apps/swift-ios/Features/Root/FeatureRootModel.swift index 1ed729d71183..ddcf34a91acd 100644 --- a/apps/swift-ios/Features/Root/FeatureRootModel.swift +++ b/apps/swift-ios/Features/Root/FeatureRootModel.swift @@ -406,7 +406,8 @@ public final class FeatureRootModel { branch: request.branch, worktreePath: request.worktreePath, startFromOrigin: request.startFromOrigin - ) + ), + context: request.context ) guard await enqueue(queued) else { return nil } installPendingCreation(queued, project: project) @@ -425,7 +426,8 @@ public final class FeatureRootModel { worktreePath: request.worktreePath, startFromOrigin: request.startFromOrigin, attachments: uploads, - identity: identity + identity: identity, + context: request.context ) if !(await completeQueuedSubmission(queued)) { scheduleOutboxRetry() @@ -815,7 +817,8 @@ public final class FeatureRootModel { selection: submission.selection, runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, - attachments: uploads + attachments: uploads, + context: submission.context ) guard await enqueue(queued) else { return false } @@ -831,9 +834,11 @@ public final class FeatureRootModel { name: $0.filename, mimeType: $0.mimeType, sizeBytes: $0.byteCount, - previewData: $0.thumbnailData + previewData: $0.thumbnailData, + source: $0.source ) - } + }, + context: submission.context ) mutateDetail( id: submission.threadID, @@ -854,7 +859,8 @@ public final class FeatureRootModel { selection: submission.selection, runtimeMode: queued.runtimeMode, attachments: uploads, - identity: identity + identity: identity, + context: submission.context ) if !(await completeQueuedSubmission(queued)) { scheduleOutboxRetry() @@ -1590,14 +1596,16 @@ public final class FeatureRootModel { text: submission.text, createdAt: submission.identity.createdAt, state: .queued, - attachments: submission.attachments.enumerated().map { index, attachment in + attachments: submission.attachments.map { attachment in FeatureMessageAttachment( - id: "\(submission.id)-attachment-\(index)", + id: attachment.id.uuidString, name: attachment.name, mimeType: attachment.mimeType, - sizeBytes: attachment.byteCount ?? attachment.data?.count ?? 0 + sizeBytes: attachment.byteCount ?? attachment.data?.count ?? 0, + source: attachment.source ) - } + }, + context: submission.context ) } @@ -1868,7 +1876,8 @@ public final class FeatureRootModel { worktreePath: creation.worktreePath, startFromOrigin: creation.startFromOrigin, attachments: submission.uploads, - identity: submission.identity + identity: submission.identity, + context: submission.context ) guard !Task.isCancelled, outboxGeneration == generation else { return false } @@ -1887,7 +1896,8 @@ public final class FeatureRootModel { selection: submission.selection, runtimeMode: submission.runtimeMode, attachments: submission.uploads, - identity: submission.identity + identity: submission.identity, + context: submission.context ) guard !Task.isCancelled, outboxGeneration == generation else { return false } diff --git a/apps/swift-ios/Features/Shared/FeatureClient.swift b/apps/swift-ios/Features/Shared/FeatureClient.swift index 817fbfcb048f..d938c1f47f09 100644 --- a/apps/swift-ios/Features/Shared/FeatureClient.swift +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -37,7 +37,8 @@ public protocol FeatureClient: AnyObject { worktreePath: String?, startFromOrigin: Bool, attachments: [FeatureUploadAttachment], - identity: FeatureSubmissionIdentity + identity: FeatureSubmissionIdentity, + context: OrchestrationMessageContext? ) async throws -> FeatureThread func listWorkspaceBranches( projectID: String, @@ -79,7 +80,8 @@ public protocol FeatureClient: AnyObject { selection: FeatureSelection?, runtimeMode: FeatureRuntimeMode, attachments: [FeatureUploadAttachment], - identity: FeatureSubmissionIdentity + identity: FeatureSubmissionIdentity, + context: OrchestrationMessageContext? ) async throws func cancelTurn(threadID: String) async throws func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws diff --git a/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift b/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift index e715c4ae270a..413a179591d5 100644 --- a/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift +++ b/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift @@ -1,6 +1,7 @@ import Foundation public struct FeatureComposerDraft: Sendable, Equatable { + public var context: OrchestrationMessageContext? public var text: String public var attachments: [FeatureDraftAttachment] public var selection: FeatureSelection? @@ -10,12 +11,14 @@ public struct FeatureComposerDraft: Sendable, Equatable { text: String = "", attachments: [FeatureDraftAttachment] = [], selection: FeatureSelection? = nil, - workspace: FeatureComposerWorkspaceDraft? = nil + workspace: FeatureComposerWorkspaceDraft? = nil, + context: OrchestrationMessageContext? = nil ) { self.text = text self.attachments = attachments self.selection = selection self.workspace = workspace + self.context = context } public var isEmpty: Bool { @@ -89,6 +92,7 @@ public actor FeatureComposerDraftStore { } private struct PersistedDraft: Codable { + var context: OrchestrationMessageContext? var text: String var attachments: [PersistedAttachment] var selection: FeatureSelection? @@ -97,6 +101,7 @@ public actor FeatureComposerDraftStore { init(_ draft: FeatureComposerDraft) { text = draft.text + context = draft.context attachments = draft.attachments.map(PersistedAttachment.init) selection = draft.selection workspace = draft.workspace.map(PersistedWorkspace.init) @@ -108,7 +113,8 @@ public actor FeatureComposerDraftStore { text: text, attachments: attachments.compactMap { $0.featureValue(fileStore: fileStore) }, selection: selection, - workspace: workspace?.featureValue + workspace: workspace?.featureValue, + context: context ) } } @@ -137,6 +143,7 @@ public actor FeatureComposerDraftStore { } private struct PersistedAttachment: Codable { + var source: PastedTextAttachmentSource? var id: UUID var data: Data? var ownedFileName: String? @@ -148,6 +155,7 @@ public actor FeatureComposerDraftStore { init(_ attachment: FeatureDraftAttachment) { id = attachment.id + source = attachment.source data = attachment.ownedFile == nil ? attachment.data : nil ownedFileName = attachment.ownedFile?.fileName byteCount = attachment.byteCount @@ -169,7 +177,8 @@ public actor FeatureComposerDraftStore { thumbnailData: thumbnailData, filename: filename, mimeType: mimeType, - uploadedReference: uploadedReference + uploadedReference: uploadedReference, + source: source ) } guard let data else { return nil } @@ -179,7 +188,8 @@ public actor FeatureComposerDraftStore { thumbnailData: thumbnailData, filename: filename, mimeType: mimeType, - uploadedReference: uploadedReference + uploadedReference: uploadedReference, + source: source ) } @@ -187,6 +197,7 @@ public actor FeatureComposerDraftStore { guard id == attachment.id, filename == attachment.filename, mimeType == attachment.mimeType, + source == attachment.source, (byteCount ?? data?.count ?? 0) == attachment.byteCount else { return false } if let ownedFileName { return ownedFileName == attachment.ownedFile?.fileName diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift index db6322d2ec2c..930ce76dfe65 100644 --- a/apps/swift-ios/Features/Shared/FeatureModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -508,6 +508,7 @@ public enum FeatureMessageState: String, Sendable, Codable { } public struct FeatureMessageAttachment: Identifiable, Sendable, Equatable, Hashable, Codable { + public var source: PastedTextAttachmentSource? public let id: String public var name: String public var mimeType: String @@ -523,7 +524,8 @@ public struct FeatureMessageAttachment: Identifiable, Sendable, Equatable, Hasha mimeType: String, sizeBytes: Int, url: URL? = nil, - previewData: Data? = nil + previewData: Data? = nil, + source: PastedTextAttachmentSource? = nil ) { self.id = id self.name = name @@ -531,10 +533,12 @@ public struct FeatureMessageAttachment: Identifiable, Sendable, Equatable, Hasha self.sizeBytes = sizeBytes self.url = url self.previewData = previewData + self.source = source } } public struct FeatureUploadAttachment: Sendable, Equatable { + public var source: PastedTextAttachmentSource? public let id: UUID private var inlineData: Data? public var ownedFile: FeatureOwnedAttachmentFile? @@ -547,7 +551,8 @@ public struct FeatureUploadAttachment: Sendable, Equatable { data: Data, name: String, mimeType: String, - uploadedReference: FeatureUploadedAttachmentReference? = nil + uploadedReference: FeatureUploadedAttachmentReference? = nil, + source: PastedTextAttachmentSource? = nil ) { self.id = id inlineData = data @@ -555,6 +560,7 @@ public struct FeatureUploadAttachment: Sendable, Equatable { self.name = name self.mimeType = mimeType self.uploadedReference = uploadedReference + self.source = source } public init( @@ -562,7 +568,8 @@ public struct FeatureUploadAttachment: Sendable, Equatable { ownedFile: FeatureOwnedAttachmentFile, name: String, mimeType: String, - uploadedReference: FeatureUploadedAttachmentReference? = nil + uploadedReference: FeatureUploadedAttachmentReference? = nil, + source: PastedTextAttachmentSource? = nil ) { self.id = id inlineData = nil @@ -570,6 +577,7 @@ public struct FeatureUploadAttachment: Sendable, Equatable { self.name = name self.mimeType = mimeType self.uploadedReference = uploadedReference + self.source = source } public init(_ draft: FeatureDraftAttachment) { @@ -579,6 +587,7 @@ public struct FeatureUploadAttachment: Sendable, Equatable { name = draft.filename mimeType = draft.mimeType uploadedReference = draft.uploadedReference + source = draft.source } public var data: Data { @@ -595,6 +604,7 @@ public struct FeatureUploadAttachment: Sendable, Equatable { } public struct FeatureMessage: Identifiable, Sendable, Equatable, Hashable, Codable { + public var context: OrchestrationMessageContext? public let id: String public var role: FeatureMessageRole public var text: String @@ -615,7 +625,8 @@ public struct FeatureMessage: Identifiable, Sendable, Equatable, Hashable, Codab toolName: String? = nil, attachments: [FeatureMessageAttachment] = [], workLogImagePaths: [String]? = nil, - activeWorkLabel: String? = nil + activeWorkLabel: String? = nil, + context: OrchestrationMessageContext? = nil ) { self.id = id self.role = role @@ -626,6 +637,7 @@ public struct FeatureMessage: Identifiable, Sendable, Equatable, Hashable, Codab self.attachments = attachments self.workLogImagePaths = workLogImagePaths self.activeWorkLabel = activeWorkLabel + self.context = context } } diff --git a/apps/swift-ios/Features/Shared/FeatureOutboxStore.swift b/apps/swift-ios/Features/Shared/FeatureOutboxStore.swift index 0f82357e2aca..11f125460a90 100644 --- a/apps/swift-ios/Features/Shared/FeatureOutboxStore.swift +++ b/apps/swift-ios/Features/Shared/FeatureOutboxStore.swift @@ -23,6 +23,7 @@ public struct FeatureSubmissionIdentity: Sendable, Equatable, Hashable, Codable } public struct FeatureQueuedAttachment: Sendable, Equatable, Codable { + public var source: PastedTextAttachmentSource? public var id: UUID public var data: Data? public var ownedFileName: String? @@ -37,7 +38,8 @@ public struct FeatureQueuedAttachment: Sendable, Equatable, Codable { data: Data, name: String, mimeType: String, - uploadedReference: FeatureUploadedAttachmentReference? = nil + uploadedReference: FeatureUploadedAttachmentReference? = nil, + source: PastedTextAttachmentSource? = nil ) { self.id = id self.data = data @@ -46,11 +48,13 @@ public struct FeatureQueuedAttachment: Sendable, Equatable, Codable { self.name = name self.mimeType = mimeType self.uploadedReference = uploadedReference + self.source = source resolvedOwnedFile = nil } init(_ attachment: FeatureUploadAttachment) { id = attachment.id + source = attachment.source data = attachment.ownedFile == nil ? attachment.data : nil ownedFileName = attachment.ownedFile?.fileName byteCount = attachment.byteCount @@ -62,6 +66,7 @@ public struct FeatureQueuedAttachment: Sendable, Equatable, Codable { private enum CodingKeys: String, CodingKey { case id, data, ownedFileName, byteCount, name, mimeType, uploadedReference + case source } public init(from decoder: any Decoder) throws { @@ -72,6 +77,7 @@ public struct FeatureQueuedAttachment: Sendable, Equatable, Codable { byteCount = try container.decodeIfPresent(Int.self, forKey: .byteCount) ?? data?.count name = try container.decode(String.self, forKey: .name) mimeType = try container.decode(String.self, forKey: .mimeType) + source = try container.decodeIfPresent(PastedTextAttachmentSource.self, forKey: .source) uploadedReference = try container.decodeIfPresent( FeatureUploadedAttachmentReference.self, forKey: .uploadedReference @@ -87,6 +93,7 @@ public struct FeatureQueuedAttachment: Sendable, Equatable, Codable { try container.encodeIfPresent(byteCount, forKey: .byteCount) try container.encode(name, forKey: .name) try container.encode(mimeType, forKey: .mimeType) + try container.encodeIfPresent(source, forKey: .source) try container.encodeIfPresent(uploadedReference, forKey: .uploadedReference) } @@ -105,7 +112,8 @@ public struct FeatureQueuedAttachment: Sendable, Equatable, Codable { ownedFile: resolvedOwnedFile, name: name, mimeType: mimeType, - uploadedReference: uploadedReference + uploadedReference: uploadedReference, + source: source ) } guard let data else { return nil } @@ -114,7 +122,8 @@ public struct FeatureQueuedAttachment: Sendable, Equatable, Codable { data: data, name: name, mimeType: mimeType, - uploadedReference: uploadedReference + uploadedReference: uploadedReference, + source: source ) } } @@ -145,6 +154,7 @@ public struct FeatureQueuedCreation: Sendable, Equatable, Codable { } public struct FeatureQueuedSubmission: Identifiable, Sendable, Equatable, Codable { + public var context: OrchestrationMessageContext? public let id: String public var environmentID: String public var identity: FeatureSubmissionIdentity @@ -166,7 +176,8 @@ public struct FeatureQueuedSubmission: Identifiable, Sendable, Equatable, Codabl runtimeMode: FeatureRuntimeMode, interactionMode: FeatureInteractionMode, attachments: [FeatureUploadAttachment], - creation: FeatureQueuedCreation? = nil + creation: FeatureQueuedCreation? = nil, + context: OrchestrationMessageContext? = nil ) { self.id = id ?? identity.messageID self.environmentID = environmentID @@ -178,6 +189,7 @@ public struct FeatureQueuedSubmission: Identifiable, Sendable, Equatable, Codabl self.interactionMode = interactionMode.mobileNormalized self.attachments = attachments.map(FeatureQueuedAttachment.init) self.creation = creation + self.context = context } public var uploads: [FeatureUploadAttachment] { diff --git a/apps/swift-ios/Features/Shared/FeatureToolModels.swift b/apps/swift-ios/Features/Shared/FeatureToolModels.swift index fd49602d4bb0..e7b9d68bac00 100644 --- a/apps/swift-ios/Features/Shared/FeatureToolModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureToolModels.swift @@ -610,6 +610,61 @@ public struct FeatureReviewCommentDraft: Sendable, Equatable, Hashable { Inspect the surrounding code, make the smallest correct change, and report what changed. """ } + + public func submissionText(contextRecord: ComposerContextRecord) -> String { + let reference = ComposerContextReferences.format(contextRecord) + // The record has a smaller bound than a message. Keep the full comment + // in ordinary text when it does not fit instead of discarding instructions. + return body.utf16.count > 16_000 + ? prompt + "\n\n" + reference + : "Address this review comment: " + reference + } + + public func contextRecord(lines: [FeatureDiffLine]) -> ComposerContextRecord { + let range = line.map { "\($0.side.rawValue) line \($0.line)" } ?? "File" + let selectedIndex = line.flatMap { selected in + lines.firstIndex { + selected.side == .new ? $0.newLine == selected.line : $0.oldLine == selected.line + } + } ?? 0 + func formatted(_ index: Int) -> String { + let line = lines[index] + let prefix = switch line.kind { + case .addition: "+" + case .deletion: "-" + case .context: " " + case .hunk: "" + } + return prefix + line.text + } + // Build outward from the selected row so a large file never clips away + // the code the comment is about. Indices still refer to the full diff. + var lower = min(selectedIndex, max(0, lines.count - 1)) + var upper = min(lines.count, lower + 1) + let selectedText = lines.isEmpty ? "" : ComposerContextReferences.boundedPrefix(formatted(lower), maximumUTF16: 32_000) + var remaining = 32_000 - selectedText.utf16.count + while remaining > 0, lower > 0 || upper < lines.count { + var added = false + if lower > 0, formatted(lower - 1).utf16.count + 1 <= remaining { + lower -= 1 + remaining -= formatted(lower).utf16.count + 1 + added = true + } + if upper < lines.count, formatted(upper).utf16.count + 1 <= remaining { + remaining -= formatted(upper).utf16.count + 1 + upper += 1 + added = true + } + if !added { break } + } + return ComposerContextRecord(label: "\(filePath) \(range)", payload: .reviewComment(.init( + sectionId: "working-tree", sectionTitle: "Working changes", filePath: filePath, + startIndex: selectedIndex, endIndex: selectedIndex, + rangeLabel: range, text: ComposerContextReferences.boundedPrefix(body, maximumUTF16: 16_000), + diff: (lower.. FeatureOwnedAttachmentFile { + guard !data.isEmpty else { throw ManagedAttachmentFileError.empty } + let limit = min(Self.maximumBytes, max(0, maximumBytes)) + guard data.count <= limit else { + throw ManagedAttachmentFileError.tooLarge(actualBytes: data.count, maximumBytes: limit) + } + let fileName = try Self.ownedFileName(attachmentID: attachmentID, originalFileName: originalFileName) + let destination = try resolvedFile(fileName: fileName, byteCount: data.count).url + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + guard !FileManager.default.fileExists(atPath: destination.path) else { + throw ManagedAttachmentFileError.alreadyExists + } + try data.write(to: destination, options: .atomic) + return FeatureOwnedAttachmentFile(fileName: fileName, url: destination, byteCount: data.count) + } + public func copyOwnedFile( from sourceURL: URL, attachmentID: UUID, diff --git a/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift b/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift index c643b4c8ccb9..d3070a6f34d0 100644 --- a/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift +++ b/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift @@ -153,6 +153,7 @@ final class TerminalInputSession { public struct FeatureTerminalView: View { let client: any FeatureClient let threadID: String + let onAttachContext: ((ComposerContextRecord) throws -> Void)? @SwiftUI.Environment(\.dismiss) private var dismiss @AppStorage("terminalFontSize") private var storedFontSize = TerminalFontSize.defaultValue @@ -168,9 +169,10 @@ public struct FeatureTerminalView: View { @State private var errorMessage: String? @State private var inputSession = TerminalInputSession() - public init(client: any FeatureClient, threadID: String) { + public init(client: any FeatureClient, threadID: String, onAttachContext: ((ComposerContextRecord) throws -> Void)? = nil) { self.client = client self.threadID = threadID + self.onAttachContext = onAttachContext } public var body: some View { @@ -201,6 +203,20 @@ public struct FeatureTerminalView: View { }, onFontSizeStep: { direction in stepFontSize(direction) + }, + onAttachOutput: onAttachContext.map { attach in + { output in + guard !output.isEmpty else { return } + do { + try attach(FeatureComposerContext.terminalRecord( + text: output, terminalID: activeTerminalID, + label: terminal.map(TerminalSessionList.displayTitle) ?? "Terminal" + )) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } } ) .id(terminalTaskID) diff --git a/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift b/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift index be8e8d6f83db..48f850fcf1a7 100644 --- a/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift +++ b/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift @@ -17,6 +17,7 @@ struct GhosttyTerminalSurface: UIViewRepresentable { let onResize: (Int, Int) -> Void let onClear: () -> Void let onFontSizeStep: (Int) -> Void + var onAttachOutput: ((String) -> Void)? = nil func makeUIView(context _: Context) -> GhosttyTerminalView { let view = GhosttyTerminalView() @@ -39,6 +40,7 @@ struct GhosttyTerminalSurface: UIViewRepresentable { view.onResize = onResize view.onClear = onClear view.onFontSizeStep = onFontSizeStep + view.onAttachOutput = onAttachOutput view.terminalKey = terminalKey view.lifecycleVersion = lifecycleVersion view.fontSize = fontSize @@ -657,6 +659,7 @@ final class GhosttyTerminalView: UIView, UITextFieldDelegate, UIContextMenuInter var onResize: ((Int, Int) -> Void)? var onClear: (() -> Void)? var onFontSizeStep: ((Int) -> Void)? + var onAttachOutput: ((String) -> Void)? var isDarkMode = true { didSet { @@ -896,7 +899,14 @@ final class GhosttyTerminalView: UIView, UITextFieldDelegate, UIContextMenuInter let clear = UIAction(title: "Clear", image: UIImage(systemName: "eraser")) { [weak self] _ in self?.onClear?() } - return UIMenu(children: [copy, paste, clear]) + var actions = [copy, paste, clear] + if self.onAttachOutput != nil { + actions.insert(UIAction(title: "Add output to message", image: UIImage(systemName: "text.bubble")) { [weak self] _ in + guard let self else { return } + self.onAttachOutput?(TerminalText.plainText(from: self.buffer)) + }, at: 1) + } + return UIMenu(children: actions) } } @@ -1012,6 +1022,7 @@ final class GhosttyTerminalView: UIView, UITextFieldDelegate, UIContextMenuInter onResize = nil onClear = nil onFontSizeStep = nil + onAttachOutput = nil destroySurface() } diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift index df39c1971504..b874b7a4fca8 100644 --- a/apps/swift-ios/Features/Workspace/DailyUXModels.swift +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -8,6 +8,7 @@ public struct FeatureDraftAttachment: Identifiable, Sendable, Equatable { public var filename: String public var mimeType: String public var uploadedReference: FeatureUploadedAttachmentReference? + public var source: PastedTextAttachmentSource? public init( id: UUID = UUID(), @@ -15,7 +16,8 @@ public struct FeatureDraftAttachment: Identifiable, Sendable, Equatable { thumbnailData: Data? = nil, filename: String, mimeType: String, - uploadedReference: FeatureUploadedAttachmentReference? = nil + uploadedReference: FeatureUploadedAttachmentReference? = nil, + source: PastedTextAttachmentSource? = nil ) { self.id = id inlineData = data @@ -24,6 +26,7 @@ public struct FeatureDraftAttachment: Identifiable, Sendable, Equatable { self.filename = filename self.mimeType = mimeType self.uploadedReference = uploadedReference + self.source = source } public init( @@ -32,7 +35,8 @@ public struct FeatureDraftAttachment: Identifiable, Sendable, Equatable { thumbnailData: Data? = nil, filename: String, mimeType: String, - uploadedReference: FeatureUploadedAttachmentReference? = nil + uploadedReference: FeatureUploadedAttachmentReference? = nil, + source: PastedTextAttachmentSource? = nil ) { self.id = id inlineData = nil @@ -41,6 +45,7 @@ public struct FeatureDraftAttachment: Identifiable, Sendable, Equatable { self.filename = filename self.mimeType = mimeType self.uploadedReference = uploadedReference + self.source = source } /// Kept for image-only callers. File-backed attachments return empty data @@ -59,6 +64,7 @@ public struct FeatureDraftAttachment: Identifiable, Sendable, Equatable { } public struct NewTaskRequest: Sendable, Equatable { + public var context: OrchestrationMessageContext? public var projectID: String public var prompt: String public var selection: FeatureSelection? @@ -80,7 +86,8 @@ public struct NewTaskRequest: Sendable, Equatable { branch: String? = nil, worktreePath: String? = nil, startFromOrigin: Bool = true, - attachments: [FeatureDraftAttachment] = [] + attachments: [FeatureDraftAttachment] = [], + context: OrchestrationMessageContext? = nil ) { self.projectID = projectID self.prompt = prompt @@ -92,6 +99,7 @@ public struct NewTaskRequest: Sendable, Equatable { self.worktreePath = workspaceMode == .local ? Self.nonEmpty(worktreePath) : nil self.startFromOrigin = workspaceMode == .worktree && startFromOrigin self.attachments = attachments + self.context = context } public var trimmedPrompt: String { @@ -108,6 +116,7 @@ public struct NewTaskRequest: Sendable, Equatable { } public struct FeatureMessageSubmission: Sendable, Equatable { + public var context: OrchestrationMessageContext? public var threadID: String public var text: String public var selection: FeatureSelection? @@ -117,12 +126,14 @@ public struct FeatureMessageSubmission: Sendable, Equatable { threadID: String, text: String, selection: FeatureSelection?, - attachments: [FeatureDraftAttachment] = [] + attachments: [FeatureDraftAttachment] = [], + context: OrchestrationMessageContext? = nil ) { self.threadID = threadID self.text = text self.selection = selection self.attachments = attachments + self.context = context } } diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift index 83ddfc44a205..0e623267290e 100644 --- a/apps/swift-ios/Features/Workspace/NewThreadView.swift +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -18,6 +18,7 @@ public struct NewThreadView: View { @State private var selectionIsExplicit = false @State private var preferredSelection: FeatureSelection? @State private var attachments: [FeatureDraftAttachment] = [] + @State private var composerContext: OrchestrationMessageContext? @State private var workspaceMode: FeatureWorkspaceMode = .local @State private var workspaceSelectionIsExplicit = false @State private var branches: [FeatureWorkspaceBranch] = [] @@ -120,7 +121,8 @@ public struct NewThreadView: View { onDismissKeyboard: { promptFocused = false }, onRefreshModels: refreshSelectedEnvironmentModels, draftSaveError: draftSaveError, - onRetryDraftSave: persistCurrentDraftImmediately + onRetryDraftSave: persistCurrentDraftImmediately, + context: contextBinding ) } .background(T3Colors.background) @@ -837,7 +839,8 @@ public struct NewThreadView: View { draftKey: draftKey ?? FeatureComposerDraftStore.newTaskKey(project: project), environmentID: project.environmentID, attachments: attachments - ) + ), + context: composerContext ) Task { @MainActor in @@ -953,6 +956,7 @@ public struct NewThreadView: View { draftSaveTask = nil prompt = carryingContent?.text ?? "" attachments = carryingContent?.attachments ?? [] + composerContext = carryingContent?.context selectionIsExplicit = false workspaceSelectionIsExplicit = false branches = [] @@ -1109,6 +1113,7 @@ public struct NewThreadView: View { ) prompt = restored.text attachments = restored.attachments + composerContext = restored.context selection = DailyUXModelOptions.validated(restored.selection, in: creationProviders) ?? initialSelection selectionIsExplicit = liveSelectionIsExplicit || saved?.selection != nil @@ -1149,6 +1154,13 @@ public struct NewThreadView: View { return draftKey(for: project) } + private var contextBinding: Binding { + Binding(get: { composerContext }, set: { value in + composerContext = value + scheduleDraftSave() + }) + } + private var attachmentBinding: Binding<[FeatureDraftAttachment]> { Binding( get: { attachments }, @@ -1182,7 +1194,8 @@ public struct NewThreadView: View { : nil, startFromOrigin: startFromOrigin ) - : nil + : nil, + context: composerContext ) } @@ -1339,7 +1352,8 @@ struct NewTaskDraftRestoreContext: Equatable { attachment.uploadedReference = nil } return attachment - } + }, + context: draft.context ) } @@ -1364,6 +1378,7 @@ struct NewTaskDraftRestoreContext: Equatable { target = saved ?? FeatureComposerDraft() target?.text = baseline.text target?.attachments = baseline.attachments + target?.context = baseline.context } var restored = FeatureComposerDraftRestoration.merge( saved: target, diff --git a/apps/swift-ios/Tests/CoreTests/ComposerContextContractTests.swift b/apps/swift-ios/Tests/CoreTests/ComposerContextContractTests.swift new file mode 100644 index 000000000000..1ae44d3ebb5f --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/ComposerContextContractTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Composer context contracts") +struct ComposerContextContractTests { + @Test func knownRecordsAndFuturePayloadsRoundTrip() throws { + let data = Data(#"{"version":1,"records":[{"version":1,"contextId":"file_1","kind":"file","label":"log","attachmentId":"a1","name":"log.txt","mimeType":"text/plain","sizeBytes":7},{"version":1,"contextId":"terminal_1","kind":"terminal","label":"output","terminalId":"default","terminalLabel":"Shell","lineStart":4,"lineEnd":5,"text":"one\ntwo"},{"version":1,"contextId":"element_1","kind":"element","label":"button","pageUrl":"https://example.com","pageTitle":null,"tagName":"button","selector":null,"htmlPreview":"