diff --git a/CodegiOS/Features/SessionDetail/Attachment.swift b/CodegiOS/Features/SessionDetail/Attachment.swift index 50f7803..5b4270e 100644 --- a/CodegiOS/Features/SessionDetail/Attachment.swift +++ b/CodegiOS/Features/SessionDetail/Attachment.swift @@ -16,16 +16,37 @@ import UniformTypeIdentifiers /// failure path rather than being blocked up front. Capability gating is a /// follow-up that depends on modeling live session/capability events. struct Attachment: Identifiable, Hashable, Sendable { + enum Kind: String, Sendable { + case image + case file + } + let id: UUID let name: String let mimeType: String let data: Data + let kind: Kind + /// Jail path from `/upload_attachment`. Used for draft sync + hydration. + var uploadPath: String? + /// `file://` uri for workspace files or uploaded images. + var fileURI: String? - init(id: UUID = UUID(), name: String, mimeType: String, data: Data) { + init( + id: UUID = UUID(), + name: String, + mimeType: String, + data: Data, + kind: Kind = .image, + uploadPath: String? = nil, + fileURI: String? = nil + ) { self.id = id self.name = name self.mimeType = mimeType self.data = data + self.kind = kind + self.uploadPath = uploadPath + self.fileURI = fileURI } var byteCount: Int { data.count } @@ -33,13 +54,52 @@ struct Attachment: Identifiable, Hashable, Sendable { /// The wire block sent in `acp_prompt`. var promptInputBlock: PromptInputBlock { - .image(data: base64, mimeType: mimeType, uri: nil) + switch kind { + case .image: + if let fileURI, !fileURI.isEmpty { + // Empty payload + jail uri: the server re-inlines the bytes. + return .image(data: "", mimeType: mimeType, uri: fileURI) + } + return .image(data: base64, mimeType: mimeType, uri: nil) + case .file: + return .resourceLink(uri: fileURI ?? "", name: name, mimeType: mimeType) + } + } + + func asDraftRef() -> ComposerDraftAttachment? { + switch kind { + case .image: + guard let uploadPath, !uploadPath.isEmpty else { return nil } + return ComposerDraftAttachment( + id: "image:\(id.uuidString)", + kind: "image", + name: name, + mime: mimeType, + size: UInt64(data.count), + path: uploadPath, + uri: nil + ) + case .file: + guard let fileURI, !fileURI.isEmpty else { return nil } + return ComposerDraftAttachment( + id: "file:\(fileURI)", + kind: "file", + name: name, + mime: mimeType, + size: UInt64(data.count), + path: nil, + uri: fileURI + ) + } } /// The block used to render this image immediately in the optimistic user /// turn (decoded by `InlineImageView`). var optimisticBlock: ContentBlock { - .image(ImageData(data: base64, mimeType: mimeType, uri: nil)) + if kind == .file { + return .text("[\(name)](\(fileURI ?? name))") + } + return .image(ImageData(data: base64, mimeType: mimeType, uri: nil)) } } diff --git a/CodegiOS/Features/SessionDetail/AttachmentChipsView.swift b/CodegiOS/Features/SessionDetail/AttachmentChipsView.swift index 1ef4228..c5f3c9f 100644 --- a/CodegiOS/Features/SessionDetail/AttachmentChipsView.swift +++ b/CodegiOS/Features/SessionDetail/AttachmentChipsView.swift @@ -48,7 +48,20 @@ private struct AttachmentChip: View { @ViewBuilder private var thumbnail: some View { - if let image = UIImage(data: attachment.data) { + if attachment.kind == .file { + ZStack { + Color.primary.opacity(0.06) + VStack(spacing: 2) { + Image(systemName: "doc") + .font(.system(size: 16)) + Text(attachment.name) + .font(.system(size: 8)) + .lineLimit(1) + } + .foregroundStyle(Theme.textTertiary) + .padding(4) + } + } else if let image = UIImage(data: attachment.data) { Image(uiImage: image) .resizable() .scaledToFill() diff --git a/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift b/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift index 1a23eb3..da713c2 100644 --- a/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift +++ b/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift @@ -114,8 +114,14 @@ final class SessionDetailViewModel { /// True once a draft's first send begins — locks the agent/folder pickers. private(set) var hasStartedFirstSend = false - /// Compose-bar text. - var draft: String = "" + /// Compose-bar text. Changes debounce into `put_composer_draft` so + /// desktop/web see the same unsent text (and the reverse via WS). + var draft: String = "" { + didSet { + guard !applyingRemoteDraft else { return } + scheduleComposerDraftPersist() + } + } /// Images staged for the next prompt (added via the "+" menu). Cleared when /// the optimistic turn is posted; restored if that send is rolled back. @@ -190,6 +196,18 @@ final class SessionDetailViewModel { /// Reset whenever the server confirms a fresh attach (a snapshot/replay /// frame). Past `maxStreamReconnects`, recovery gives up and reconciles. private var streamReconnects = 0 + + // MARK: - Composer draft sync (desktop ↔ this phone) + + private let composerOrigin = ComposerDraftOrigin.id + private var lastPutDraft: String = "" + private var lastPutAttachmentIDs: [String] = [] + private var lastDraftRevision = 0 + private var applyingRemoteDraft = false + private var draftAttachmentsReady = false + private var draftPersistTask: Task? + private var draftListenStream: EventStream? + private var draftListenTask: Task? private static let maxStreamReconnects = 6 private init(client: CodegClient, mode: Mode) { @@ -308,6 +326,7 @@ final class SessionDetailViewModel { phase = .loaded // Initial load lands at the latest message. requestStickToBottom() + startComposerDraftSync() // If a turn is still running on this session (started here earlier, // from codeg web, or before an app relaunch), attach so it streams // live and any pending permission/question card surfaces. @@ -601,7 +620,9 @@ final class SessionDetailViewModel { } attachments.append(attachment) currentBytes += attachment.byteCount + Task { await self.uploadDraftAttachment(attachment.id) } } + scheduleComposerDraftPersist() if droppedForCount { notice = "You can attach up to \(AttachmentPrep.maxCount) images." } else if droppedForSize { @@ -611,6 +632,7 @@ final class SessionDetailViewModel { func removeAttachment(_ id: UUID) { attachments.removeAll { $0.id == id } + scheduleComposerDraftPersist() } // MARK: - Send @@ -657,8 +679,8 @@ final class SessionDetailViewModel { ) pendingUserTurns.append(userTurn) if overrideText == nil { - draft = "" attachments = [] + draft = "" } // 2) Live assistant placeholder. @@ -851,6 +873,7 @@ final class SessionDetailViewModel { ) conversationID = id draftCreatedConversationID = id + startComposerDraftSync() currentBranch = folder?.gitBranch // Refresh this app's own session list so the new row shows there too. notifyConversationsChanged() @@ -962,6 +985,8 @@ final class SessionDetailViewModel { if isCurrent { resumeReady(throwing: nil) } case .pong: break + case .global(let channel, let payload): + if isCurrent { handleComposerDraftNotify(channel: channel, payload: payload) } case .event(let envelope): if isCurrent { handle(event: envelope.event, live: live) } case .detached(let reason): @@ -1134,6 +1159,8 @@ final class SessionDetailViewModel { if let live { for env in events { handle(event: env.event, live: live) } } case .pong: break + case .global(let channel, let payload): + handleComposerDraftNotify(channel: channel, payload: payload) case .event(let envelope): // The attach snapshot always precedes events, so `live` is set by now. if let live { handle(event: envelope.event, live: live) } @@ -1325,6 +1352,7 @@ final class SessionDetailViewModel { private func adoptLinkedConversation(_ id: Int) { guard conversationID == nil else { return } conversationID = id + startComposerDraftSync() Task { [weak self] in guard let self else { return } guard let detail = try? await self.client.conversationDetail(id: id), @@ -1751,9 +1779,189 @@ final class SessionDetailViewModel { consumerTask = nil agentOptions.teardown() insertModel.teardown() + closeComposerDraftSync() closeStream() } + // MARK: - Composer draft sync + + private func startComposerDraftSync() { + guard conversationID != nil else { return } + Task { await pullComposerDraft() } + startDraftListenStream() + } + + private func closeComposerDraftSync() { + draftPersistTask?.cancel() + draftPersistTask = nil + draftListenTask?.cancel() + draftListenTask = nil + draftListenStream?.close() + draftListenStream = nil + } + + private func startDraftListenStream() { + draftListenTask?.cancel() + draftListenStream?.close() + let stream = EventStream(baseURL: client.baseURL, token: client.token) + draftListenStream = stream + draftListenTask = Task { [weak self] in + stream.start() + for await frame in stream.frames { + guard let self else { return } + switch frame { + case .global(let channel, let payload): + self.handleComposerDraftNotify(channel: channel, payload: payload) + case .closed, .detached: + return + default: + break + } + } + } + } + + private func scheduleComposerDraftPersist() { + draftPersistTask?.cancel() + let snapshot = draft + let refs = attachments.compactMap { $0.asDraftRef() } + draftPersistTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(350)) + guard !Task.isCancelled, let self else { return } + await self.pushComposerDraft(snapshot, attachments: refs) + } + } + + private func pushComposerDraft(_ text: String, attachments refs: [ComposerDraftAttachment]) async { + guard let id = conversationID else { return } + let ids = refs.map(\.id) + guard text != lastPutDraft || ids != lastPutAttachmentIDs else { return } + lastPutDraft = text + lastPutAttachmentIDs = ids + do { + let result = try await client.putComposerDraft( + conversationId: id, + text: text, + origin: composerOrigin, + attachments: draftAttachmentsReady ? refs : nil + ) + lastDraftRevision = max(lastDraftRevision, result.revision) + } catch { + // Best-effort. Keep lastPutDraft so we do not tight-loop. + } + } + + private func pullComposerDraft() async { + guard let id = conversationID else { return } + if let remote = try? await client.getComposerDraft(conversationId: id) { + await applyRemoteComposerDraft(remote) + draftAttachmentsReady = true + return + } + draftAttachmentsReady = true + if !draft.isEmpty || !attachments.isEmpty { + await pushComposerDraft(draft, attachments: attachments.compactMap { $0.asDraftRef() }) + } + } + + private func handleComposerDraftNotify(channel: String, payload: Data) { + guard channel == ComposerDraftChanged.channel else { return } + guard let change = try? CodegJSON.decoder.decode(ComposerDraftChanged.self, from: payload) else { return } + guard change.conversationId == conversationID else { return } + if !ComposerDraftPolicy.shouldApply( + remoteRevision: change.revision, + lastAppliedRevision: lastDraftRevision, + remoteOrigin: change.origin, + localOrigin: composerOrigin + ) { + lastDraftRevision = max(lastDraftRevision, change.revision) + return + } + Task { await pullComposerDraft() } + } + + private func applyRemoteComposerDraft(_ remote: ComposerDraft) async { + if !ComposerDraftPolicy.shouldApply( + remoteRevision: remote.revision, + lastAppliedRevision: lastDraftRevision, + remoteOrigin: remote.origin, + localOrigin: composerOrigin + ) { + lastDraftRevision = max(lastDraftRevision, remote.revision) + return + } + lastDraftRevision = remote.revision + lastPutDraft = remote.text + lastPutAttachmentIDs = remote.attachments.map(\.id) + applyingRemoteDraft = true + draft = remote.text + attachments = await hydrateRemoteAttachments(remote.attachments) + applyingRemoteDraft = false + } + + private func hydrateRemoteAttachments(_ refs: [ComposerDraftAttachment]) async -> [Attachment] { + var out: [Attachment] = [] + for ref in refs { + if ref.kind == "image", let path = ref.path, !path.isEmpty { + let data: Data + if let read = try? await client.readUploadAttachment(path: path), + let decoded = Data(base64Encoded: read.data) { + data = decoded + } else { + data = Data() + } + out.append(Attachment( + name: ref.name, + mimeType: ref.mime ?? "image/png", + data: data, + kind: .image, + uploadPath: path, + fileURI: Self.fileURI(fromPath: path) + )) + } else if ref.kind == "file", let uri = ref.uri, !uri.isEmpty { + out.append(Attachment( + name: ref.name, + mimeType: ref.mime ?? "application/octet-stream", + data: Data(), + kind: .file, + uploadPath: nil, + fileURI: uri + )) + } + } + return out + } + + private func uploadDraftAttachment(_ id: UUID) async { + guard let index = attachments.firstIndex(where: { $0.id == id }) else { return } + let current = attachments[index] + guard current.kind == .image, current.uploadPath == nil, !current.data.isEmpty else { return } + do { + let uploaded = try await client.uploadAttachment( + data: current.data, + fileName: current.name, + mimeType: current.mimeType, + sessionId: conversationID.map(String.init) + ) + if let idx = attachments.firstIndex(where: { $0.id == id }) { + attachments[idx].uploadPath = uploaded.path + attachments[idx].fileURI = Self.fileURI(fromPath: uploaded.path) + } + scheduleComposerDraftPersist() + } catch { + notice = "Could not sync the attached image to this chat." + } + } + + private static func fileURI(fromPath path: String) -> String { + let normalized = path.replacingOccurrences(of: "\\", with: "/") + let encoded = normalized + .split(separator: "/", omittingEmptySubsequences: false) + .map { $0.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String($0) } + .joined(separator: "/") + return encoded.hasPrefix("/") ? "file://\(encoded)" : "file:///\(encoded)" + } + // MARK: - Scroll private func requestScrollToBottom() { diff --git a/CodegiOS/Models/ComposerDraft.swift b/CodegiOS/Models/ComposerDraft.swift new file mode 100644 index 0000000..0add39b --- /dev/null +++ b/CodegiOS/Models/ComposerDraft.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Unsent composer text for one persisted conversation. Wire fields are +/// snake_case; `CodegJSON.decoder` converts them. +struct ComposerDraft: Decodable, Sendable { + let conversationId: Int + let text: String + let revision: Int + let origin: String + let attachments: [ComposerDraftAttachment] + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + conversationId = try c.decode(Int.self, forKey: .conversationId) + text = try c.decode(String.self, forKey: .text) + revision = try c.decode(Int.self, forKey: .revision) + origin = try c.decode(String.self, forKey: .origin) + attachments = try c.decodeIfPresent([ComposerDraftAttachment].self, forKey: .attachments) ?? [] + } + + private enum CodingKeys: String, CodingKey { + case conversationId, text, revision, origin, attachments + } +} + +struct ComposerDraftAttachment: Codable, Hashable, Sendable { + let id: String + let kind: String + let name: String + let mime: String? + let size: UInt64? + let path: String? + let uri: String? +} + +struct ComposerDraftPutResult: Decodable, Sendable { + let conversationId: Int + let revision: Int + let origin: String + let cleared: Bool +} + +/// Ids-only WS payload for `composer-draft://changed`. Never carries `text`. +struct ComposerDraftChanged: Decodable, Sendable { + static let channel = "composer-draft://changed" + + let conversationId: Int + let revision: Int + let origin: String + let cleared: Bool +} + +enum ComposerDraftOrigin { + private static let defaultsKey = "codeg.composer-draft.origin" + + /// Stable per-install origin so this phone can ignore its own notify. + static var id: String { + if let existing = UserDefaults.standard.string(forKey: defaultsKey), + !existing.isEmpty, existing.count <= 64 { + return existing + } + let fresh = UUID().uuidString + UserDefaults.standard.set(fresh, forKey: defaultsKey) + return fresh + } +} + +enum ComposerDraftPolicy { + static func shouldApply( + remoteRevision: Int, + lastAppliedRevision: Int, + remoteOrigin: String, + localOrigin: String + ) -> Bool { + if remoteOrigin == localOrigin { return false } + return remoteRevision > lastAppliedRevision + } +} diff --git a/CodegiOS/Networking/CodegClient.swift b/CodegiOS/Networking/CodegClient.swift index c00880e..aa681a3 100644 --- a/CodegiOS/Networking/CodegClient.swift +++ b/CodegiOS/Networking/CodegClient.swift @@ -105,6 +105,81 @@ struct CodegClient: Sendable { try await postJSON("get_folder_conversation", ConversationIdBody(conversationId: id)) } + /// Unsent composer text for a persisted conversation. `nil` when no + /// client has saved a draft yet. Authenticated GET — the WS notify + /// never carries the body. + func getComposerDraft(conversationId: Int) async throws -> ComposerDraft? { + let data = try await send( + "get_composer_draft", + body: ConversationIdBody(conversationId: conversationId) + ) + if Self.isJSONNull(data) { return nil } + do { return try CodegJSON.decoder.decode(ComposerDraft.self, from: data) } + catch { throw APIError.decoding(String(describing: error)) } + } + + @discardableResult + func putComposerDraft( + conversationId: Int, + text: String, + origin: String, + attachments: [ComposerDraftAttachment]? + ) async throws -> ComposerDraftPutResult { + try await postJSON( + "put_composer_draft", + PutComposerDraftBody( + conversationId: conversationId, + text: text, + origin: origin, + attachments: attachments + ) + ) + } + + func readUploadAttachment(path: String) async throws -> ReadUploadAttachmentResult { + try await postJSON("read_upload_attachment", ReadUploadAttachmentBody(path: path)) + } + + func uploadAttachment(data: Data, fileName: String, mimeType: String, sessionId: String?) async throws -> UploadAttachmentResult { + let url = baseURL.appendingPathComponent("api").appendingPathComponent("upload_attachment") + let boundary = "codeg-\(UUID().uuidString)" + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 120 + + var body = Data() + func append(_ string: String) { body.append(Data(string.utf8)) } + append("--\(boundary)\r\n") + append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n") + append("Content-Type: \(mimeType)\r\n\r\n") + body.append(data) + append("\r\n") + if let sessionId, !sessionId.isEmpty { + append("--\(boundary)\r\n") + append("Content-Disposition: form-data; name=\"session_id\"\r\n\r\n") + append("\(sessionId)\r\n") + } + append("--\(boundary)--\r\n") + request.httpBody = body + + let (responseData, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw APIError.transport("Malformed response") + } + guard (200..<300).contains(http.statusCode) else { + if http.statusCode == 401 { throw APIError.unauthorized } + throw APIError.server( + status: http.statusCode, + code: nil, + message: HTTPURLResponse.localizedString(forStatusCode: http.statusCode) + ) + } + do { return try CodegJSON.decoder.decode(UploadAttachmentResult.self, from: responseData) } + catch { throw APIError.decoding(String(describing: error)) } + } + /// Create a conversation row up front (before the first prompt) and return its /// id. The server broadcasts a `conversation_upsert` on creation, so every /// connected client (desktop / web) sees the new session immediately — unlike diff --git a/CodegiOS/Networking/EventStream.swift b/CodegiOS/Networking/EventStream.swift index 0a36fce..7b1d3c9 100644 --- a/CodegiOS/Networking/EventStream.swift +++ b/CodegiOS/Networking/EventStream.swift @@ -83,6 +83,10 @@ final class EventStream: @unchecked Sendable { case detached(reason: String) case pong case closed(reason: String?) + /// Legacy global `{channel, payload}` firehose. `payload` is the + /// raw JSON object (never logged). Used for ids-only notifies such + /// as `composer-draft://changed`. + case global(channel: String, payload: Data) } let frames: AsyncStream @@ -232,7 +236,11 @@ final class EventStream: @unchecked Sendable { if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { if let channel = obj["channel"] as? String { if channel == "__ready__" { continuation.yield(.ready) } - return // ignore other legacy global events in Phase 1 + else { + let payload = obj["payload"].flatMap { try? JSONSerialization.data(withJSONObject: $0) } ?? Data() + continuation.yield(.global(channel: channel, payload: payload)) + } + return } guard obj["type"] is String else { return } } diff --git a/CodegiOS/Networking/WireRequests.swift b/CodegiOS/Networking/WireRequests.swift index 0895dc8..ced5562 100644 --- a/CodegiOS/Networking/WireRequests.swift +++ b/CodegiOS/Networking/WireRequests.swift @@ -6,9 +6,10 @@ import Foundation enum PromptInputBlock: Encodable, Sendable { case text(String) case image(data: String, mimeType: String, uri: String?) + case resourceLink(uri: String, name: String, mimeType: String?) private enum CodingKeys: String, CodingKey { - case type, text, data + case type, text, data, name case mimeType = "mime_type" case uri } @@ -24,6 +25,11 @@ enum PromptInputBlock: Encodable, Sendable { try c.encode(data, forKey: .data) try c.encode(mimeType, forKey: .mimeType) try c.encodeIfPresent(uri, forKey: .uri) + case .resourceLink(let uri, let name, let mimeType): + try c.encode("resource_link", forKey: .type) + try c.encode(uri, forKey: .uri) + try c.encode(name, forKey: .name) + try c.encodeIfPresent(mimeType, forKey: .mimeType) } } } @@ -45,6 +51,31 @@ struct ConversationIdBody: Encodable, Sendable { let conversationId: Int } +struct PutComposerDraftBody: Encodable, Sendable { + let conversationId: Int + let text: String + let origin: String + var attachments: [ComposerDraftAttachment]? +} + +struct ReadUploadAttachmentBody: Encodable, Sendable { + let path: String +} + +struct ReadUploadAttachmentResult: Decodable, Sendable { + let data: String + let name: String + let size: UInt64 + let mimeType: String? +} + +struct UploadAttachmentResult: Decodable, Sendable { + let path: String + let name: String + let size: UInt64 + let mimeType: String? +} + /// Body for `create_conversation` — creates a server-side conversation row in /// `folderId` for `agentType` (optional `title`) BEFORE the first prompt, so the /// server broadcasts a `conversation_upsert` to every client (the desktop/web