diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 3df9002..3f2419f 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -1575,10 +1575,12 @@ final class RemoteStore: ObservableObject { openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms } - func sendInput(_ text: String, to sessionID: SessionID) { + func sendInput(_ text: String, attachments: [WireAttachment] = [], to sessionID: SessionID) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - send(.sendInput(sessionID, AgentInput(text: trimmed))) + // An attachment-only follow-up (files, no typed text) is a valid turn — the host folds the + // file references in as the message body. + guard !trimmed.isEmpty || !attachments.isEmpty else { return } + send(.sendInput(sessionID, AgentInput(text: trimmed, attachments: attachments))) } func refreshSessions() { send(.listSessions); send(.listDashboard) } @@ -1587,12 +1589,15 @@ final class RemoteStore: ObservableObject { func startChat(in projectID: ProjectID, message: String, model: String? = nil, effort: String? = nil, baseBranch: String? = nil, - useWorktree: Bool = true, auto: Bool? = nil) { + useWorktree: Bool = true, auto: Bool? = nil, + attachments: [WireAttachment] = []) { let text = message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } + // An attachment-only opening message is allowed — the host materializes the files into the + // new working tree and uses their references as the first prompt. + guard !text.isEmpty || !attachments.isEmpty else { return } send(.startChat(StartChatRequest( projectID: projectID, message: text, model: model, effort: effort, - baseBranch: baseBranch, useWorktree: useWorktree, auto: auto))) + baseBranch: baseBranch, useWorktree: useWorktree, auto: auto, attachments: attachments))) } func captureTodo(_ text: String, projectID: ProjectID?) { diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index 843dbef..aae3fcd 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -25,6 +25,10 @@ struct StartChatComposer: View { @State private var showOptions = false @State private var baseBranch = "" @State private var useWorktree = true + // Files/images attached to the opening message (see `ComposerAttachments`) — shipped as bytes + // and materialized into the new session's working tree host-side. + @State private var attachments: [StagedAttachment] = [] + @State private var attachmentsOverflowed = false private var projects: [WireProject] { store.dashboard.projects } /// The picker defaults to "No project" (nil) rather than auto-selecting the first project, so a @@ -106,7 +110,18 @@ struct StartChatComposer: View { } .transition(.move(edge: .top).combined(with: .opacity)) } + // Staged attachments ride above the field, matching the in-session composer. + if !attachments.isEmpty { + StagedAttachmentBar(attachments: $attachments) + } + if attachmentsOverflowed { + Text("Some files were too large to attach.") + .font(.caption2).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } HStack(alignment: .bottom, spacing: 8) { + // Attach photos/files to the opening message. + AttachMenuButton(attachments: $attachments, overflowed: $attachmentsOverflowed) // Plain, borderless field — matching the open session's chat bar rather than a // boxed form control. TextField("Describe a task…", text: $draft, axis: .vertical) @@ -121,14 +136,18 @@ struct StartChatComposer: View { store.startChat( in: project.id, message: draft, model: model, effort: effort, baseBranch: branch.isEmpty ? nil : branch, - useWorktree: useWorktree, auto: effectiveAuto) + useWorktree: useWorktree, auto: effectiveAuto, + attachments: attachments.wireAttachments) draft = "" + attachments = [] + attachmentsOverflowed = false onStart?() } } label: { Image(systemName: "arrow.up.circle.fill").font(.title) } - .disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) + // An attachment-only opening message is allowed (a project must still be chosen). + .disabled(selected == nil || (draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty) || !store.canControl) .keyboardShortcut(.return, modifiers: .command) } if !store.canControl { diff --git a/NucleicRemote/NucleicRemote/Views/ComposerAttachments.swift b/NucleicRemote/NucleicRemote/Views/ComposerAttachments.swift new file mode 100644 index 0000000..4a50880 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/ComposerAttachments.swift @@ -0,0 +1,213 @@ +import SwiftUI +import PhotosUI +import UniformTypeIdentifiers +import NucleicProtocol + +/// A file or image the user has staged in a composer but not yet sent. Holds the (already +/// compressed, for images) bytes plus a display name and, when it's an image, a small preview for +/// the chip. Converted to a `WireAttachment` at send time and shipped to the host, which +/// materializes it into the session's working tree (see `AttachmentMaterializer`). The phone can't +/// reach the host's tree, so it carries the bytes itself. +struct StagedAttachment: Identifiable, Equatable { + let id = UUID() + var filename: String + var data: Data + /// A downscaled preview for the chip; `nil` for non-image files (they show a doc glyph). + var thumbnail: UIImage? + + var wire: WireAttachment { WireAttachment(filename: filename, data: data) } + + static func == (lhs: StagedAttachment, rhs: StagedAttachment) -> Bool { lhs.id == rhs.id } +} + +extension Array where Element == StagedAttachment { + /// The wire form to hand to `RemoteStore.sendInput` / `startChat`. + var wireAttachments: [WireAttachment] { map(\.wire) } + var totalBytes: Int { reduce(0) { $0 + $1.data.count } } +} + +/// Staging helpers: turn picked photos / documents into `StagedAttachment`s, compressing images so +/// a phone photo (often 5–15 MB) rides the wire comfortably under the 16 MB frame cap. +enum ComposerAttachmentLoader { + /// Longest edge an attached image is scaled down to before sending — keeps big camera-roll + /// shots small while staying legible when the agent reads them. + static let maxImageDimension: CGFloat = 2048 + /// A soft ceiling on the combined size of one message's attachments, leaving headroom under the + /// wire frame limit (`WireFraming.maxFrameSize`, 16 MB) for the rest of the envelope. + static let maxTotalBytes = 12 * 1024 * 1024 + + /// Load and compress picked photo-library items into staged image attachments. Runs off the main + /// actor (decoding/encoding is heavy); returns in pick order, skipping any that fail to load. + static func stage(photoItems items: [PhotosPickerItem]) async -> [StagedAttachment] { + var staged: [StagedAttachment] = [] + for (index, item) in items.enumerated() { + guard let data = try? await item.loadTransferable(type: Data.self), + let attachment = stageImageData(data, index: index) else { continue } + staged.append(attachment) + } + return staged + } + + /// Load picked documents into staged attachments (images are compressed like photos; other file + /// types pass through verbatim). Reads each URL under its security scope. + static func stage(fileURLs urls: [URL]) -> [StagedAttachment] { + var staged: [StagedAttachment] = [] + for (index, url) in urls.enumerated() { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + guard let data = try? Data(contentsOf: url) else { continue } + let name = url.lastPathComponent + if let image = stageImageData(data, index: index, preferredName: name) { + staged.append(image) + } else { + staged.append(StagedAttachment(filename: name, data: data, thumbnail: nil)) + } + } + return staged + } + + /// Compress image bytes (downscale to `maxImageDimension`, re-encode as JPEG) into a staged + /// attachment with a thumbnail. Returns `nil` if the bytes aren't a decodable image, so callers + /// can fall back to shipping the file verbatim. + private static func stageImageData( + _ data: Data, index: Int, preferredName: String? = nil + ) -> StagedAttachment? { + guard let image = UIImage(data: data) else { return nil } + let scaled = downscale(image, maxDimension: maxImageDimension) + guard let jpeg = scaled.jpegData(compressionQuality: 0.8) else { return nil } + // Keep the picked file's name when we have one (retargeted to .jpg since we re-encoded); + // photo-library items carry no filename, so synthesize a stable, unique one. + let base = preferredName.map { ($0 as NSString).deletingPathExtension } ?? "image-\(shortToken())" + return StagedAttachment(filename: "\(base).jpg", data: jpeg, thumbnail: scaled) + } + + /// Scale `image` down so its longest edge is at most `maxDimension`, preserving aspect ratio. + /// Returns the image untouched if it's already small enough. + private static func downscale(_ image: UIImage, maxDimension: CGFloat) -> UIImage { + let longest = max(image.size.width, image.size.height) + guard longest > maxDimension, longest > 0 else { return image } + let scale = maxDimension / longest + let target = CGSize(width: image.size.width * scale, height: image.size.height * scale) + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 // target is already in pixels; don't multiply by screen scale + let renderer = UIGraphicsImageRenderer(size: target, format: format) + return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: target)) } + } + + private static func shortToken() -> String { String(UUID().uuidString.prefix(8)).lowercased() } +} + +/// The paperclip button: a menu offering Photos (PhotosPicker) or Files (document picker), each +/// appending compressed/verbatim attachments to `attachments`. Shows a spinner while staging. +struct AttachMenuButton: View { + @Binding var attachments: [StagedAttachment] + /// Surfaced to the parent so it can show a note when an add is refused for exceeding the cap. + @Binding var overflowed: Bool + var tint: Color = Palette.accent + + @State private var photoItems: [PhotosPickerItem] = [] + @State private var showPhotos = false + @State private var showFiles = false + @State private var staging = false + + var body: some View { + Menu { + Button { showPhotos = true } label: { Label("Photos", systemImage: "photo") } + Button { showFiles = true } label: { Label("Files", systemImage: "folder") } + } label: { + if staging { + ProgressView().controlSize(.small).frame(width: 24, height: 24) + } else { + Image(systemName: "paperclip") + .font(.title3) + .foregroundStyle(tint) + .frame(width: 24, height: 24) + } + } + .disabled(staging) + .accessibilityLabel("Attach") + .photosPicker( + isPresented: $showPhotos, selection: $photoItems, + maxSelectionCount: 10, matching: .images) + .fileImporter( + isPresented: $showFiles, allowedContentTypes: [.item], allowsMultipleSelection: true + ) { result in + guard case .success(let urls) = result else { return } + append(ComposerAttachmentLoader.stage(fileURLs: urls)) + } + .onChange(of: photoItems) { _, items in + guard !items.isEmpty else { return } + staging = true + Task { + let staged = await ComposerAttachmentLoader.stage(photoItems: items) + await MainActor.run { + append(staged) + photoItems = [] + staging = false + } + } + } + } + + /// Append newly staged items, dropping any that would push the message over the total-size cap + /// (and flagging `overflowed` so the composer can say so). + private func append(_ staged: [StagedAttachment]) { + var running = attachments.totalBytes + for item in staged { + if running + item.data.count > ComposerAttachmentLoader.maxTotalBytes { + overflowed = true + continue + } + running += item.data.count + attachments.append(item) + } + } +} + +/// The horizontal strip of staged-attachment chips shown above a composer's text field. Image +/// attachments show a thumbnail; other files show a doc glyph. Each chip has a remove button. +struct StagedAttachmentBar: View { + @Binding var attachments: [StagedAttachment] + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(attachments) { attachment in + chip(attachment) + } + } + .padding(.vertical, 2) + } + } + + @ViewBuilder + private func chip(_ attachment: StagedAttachment) -> some View { + HStack(spacing: 6) { + if let thumbnail = attachment.thumbnail { + Image(uiImage: thumbnail) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 28, height: 28) + .clipShape(.rect(cornerRadius: 5)) + } else { + Image(systemName: "doc") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(width: 28, height: 28) + } + Text(attachment.filename) + .font(.caption2) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 120) + Button { + attachments.removeAll { $0.id == attachment.id } + } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.leading, 4).padding(.trailing, 6).padding(.vertical, 4) + .background(.quaternary.opacity(0.5), in: .capsule) + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index abc7b88..a621241 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -11,6 +11,11 @@ struct SessionDetailView: View { // matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION. @State private var showDiff = ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] == "1" @State private var draft = "" + // Files/images staged for the next follow-up (see `ComposerAttachments`); shipped as bytes and + // materialized into the session's working tree host-side. `attachmentsOverflowed` flags a pick + // refused for exceeding the per-message size cap. + @State private var attachments: [StagedAttachment] = [] + @State private var attachmentsOverflowed = false @State private var showRename = false @State private var renameDraft = "" @State private var showIntegrate = false @@ -398,7 +403,24 @@ struct SessionDetailView: View { } if store.canControl, let summary { controlRow(summary) } if canCompose { + // Staged attachments ride above the field, matching the queued-message + // chips and the Mac composer. + if !attachments.isEmpty { + StagedAttachmentBar(attachments: $attachments) + } + if attachmentsOverflowed { + Text("Some files were too large to attach.") + .font(.caption2).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } HStack(alignment: .bottom, spacing: 8) { + // Attach photos/files to the next turn (control scope only — a + // view-only device can't send). + if store.canControl { + AttachMenuButton( + attachments: $attachments, overflowed: $attachmentsOverflowed) + .disabled(!store.connectivity.isLive) + } // No keyboard-accessory Done button here (it floats awkwardly // over the glass bar on iOS 26) — a drag on the transcript // dismisses the keyboard instead (`scrollDismissesKeyboard`). @@ -420,14 +442,18 @@ struct SessionDetailView: View { .accessibilityLabel("Stop") } Button { - store.sendInput(draft, to: sessionID) + store.sendInput(draft, attachments: attachments.wireAttachments, + to: sessionID) draft = "" + attachments = [] + attachmentsOverflowed = false scrollToBottomRequest += 1 } label: { Image(systemName: "arrow.up.circle.fill") .font(.title2) } - .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive) + // An attachment-only follow-up (files, no typed text) is sendable. + .disabled((draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty) || !store.connectivity.isLive) // Hardware-keyboard send (Magic Keyboard on iPad), mirroring the // Mac — plain Return stays newline in the multiline field. .keyboardShortcut(.return, modifiers: .command)