From af2e68748dff397649d03d8c54fd4ab4b15452b6 Mon Sep 17 00:00:00 2001 From: cmux-lawrence Date: Sun, 14 Jun 2026 00:04:05 -0700 Subject: [PATCH 01/10] iOS composer: pending image attachments in store + send orchestration Add per-terminal pending-attachment state to the mobile shell store so picked images can be staged as drafts (keyed by terminal id, like the text draft) and sent on the next composer submit, reusing the existing terminal.paste_image transport. - New MobilePendingAttachment value type (data + lowercase format + stable id), host-testable (no UIKit). - Store add/remove/clear/read methods plus composerCanSend (text non-empty OR attachments present, so an images-only send is allowed). - submitComposer() sends staged images in pick order (awaited) then the text, then clears the staged set for the submitted terminal. - Unit tests for add/remove/clear, per-terminal keying, send gating, and clear-after-send. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MobileShellComposite.swift | 89 +++++++++++++ .../ComposerPendingAttachmentTests.swift | 123 ++++++++++++++++++ .../MobilePendingAttachment.swift | 32 +++++ 3 files changed, 244 insertions(+) create mode 100644 Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift create mode 100644 Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePendingAttachment.swift diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index daa82110e792..adec3cdb2e8f 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -310,6 +310,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// (the field is cleared only on ack), pasting the message to the agent /// twice. Not observed: it gates an async flow, not view state. @ObservationIgnored private var isSubmittingComposerInput = false + /// Pending image attachments per terminal, keyed by terminal id so switching + /// terminals keeps each draft's own attachments (mirroring how the text draft + /// is keyed). Observed so the composer's chip row re-renders on add/remove. + /// Sent in order on the next submit and then cleared for that terminal. + private var pendingAttachmentsByTerminalID: [String: [MobilePendingAttachment]] = [:] public var selectedWorkspaceID: MobileWorkspacePreview.ID? { didSet { syncSelectedTerminalForWorkspace() @@ -2690,6 +2695,64 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { #endif } + /// The pending image attachments for a terminal, in pick order. Empty when + /// none are staged. Drives the composer's chip row. + /// - Parameter terminalID: The terminal whose attachments to read; `nil` + /// falls back to the selected terminal. + public func pendingAttachments(forTerminalID terminalID: String? = nil) -> [MobilePendingAttachment] { + guard let key = terminalID ?? selectedTerminalID?.rawValue else { return [] } + return pendingAttachmentsByTerminalID[key] ?? [] + } + + /// Stage a picked image as a pending attachment for a terminal, appended in + /// pick order so it sends after earlier picks. A no-op when the bytes are + /// empty. + /// - Parameters: + /// - data: The encoded image bytes (PNG/JPEG), already under the size cap. + /// - format: A lowercase format hint (`"png"`/`"jpg"`). + /// - terminalID: The terminal to stage under; `nil` falls back to the + /// selected terminal. + public func addPendingAttachment(_ data: Data, format: String, forTerminalID terminalID: String? = nil) { + guard !data.isEmpty, let key = terminalID ?? selectedTerminalID?.rawValue else { return } + pendingAttachmentsByTerminalID[key, default: []].append( + MobilePendingAttachment(data: data, format: format) + ) + } + + /// Remove one staged attachment by id. A no-op when the id is not staged. + /// - Parameters: + /// - id: The attachment's stable id. + /// - terminalID: The terminal it is staged under; `nil` falls back to the + /// selected terminal. + public func removePendingAttachment(id: MobilePendingAttachment.ID, forTerminalID terminalID: String? = nil) { + guard let key = terminalID ?? selectedTerminalID?.rawValue, + var list = pendingAttachmentsByTerminalID[key] else { return } + list.removeAll { $0.id == id } + if list.isEmpty { + pendingAttachmentsByTerminalID[key] = nil + } else { + pendingAttachmentsByTerminalID[key] = list + } + } + + /// Drop every staged attachment for a terminal (used after a successful send). + /// - Parameter terminalID: The terminal to clear; `nil` falls back to the + /// selected terminal. + public func clearPendingAttachments(forTerminalID terminalID: String? = nil) { + guard let key = terminalID ?? selectedTerminalID?.rawValue else { return } + pendingAttachmentsByTerminalID[key] = nil + } + + /// Whether the composer's Send should be enabled: text is non-empty OR at + /// least one attachment is staged. An attachments-only send (empty text) is + /// allowed, so the gating cannot key on text alone. + /// - Parameter terminalID: The terminal whose composer to gate; `nil` falls + /// back to the selected terminal. + public func composerCanSend(forTerminalID terminalID: String? = nil) -> Bool { + let textNonEmpty = !terminalInputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return textNonEmpty || !pendingAttachments(forTerminalID: terminalID).isEmpty + } + /// Submit the composer's text to the selected terminal as a bracketed paste /// plus a single Return, then clear the field while keeping the composer /// open. Unlike ``submitTerminalInput()``, this delivers a multi-line block @@ -2720,6 +2783,32 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { await reconcileComposerDraftAfterSend(sentText: text, submittedTerminalID: submittedTerminalID) } + /// Send the composer's staged attachments then its text, iMessage-style: the + /// images are delivered first (in pick order) so their injected file paths + /// land before the message that references them, then the text is submitted. + /// Attachments for the submitted terminal are cleared once they have all been + /// sent. + /// + /// Allowed with empty text as long as at least one attachment is staged; an + /// images-only send skips the (no-op) text submit. Captures the submitted + /// terminal up front so a mid-flight terminal switch clears the right key. + public func submitComposer() async { + let submittedTerminalID = selectedTerminalID + let attachments = pendingAttachments(forTerminalID: submittedTerminalID?.rawValue) + // Deliver each image first and await it, so the agent's terminal has the + // file paths before the text arrives. + for attachment in attachments { + await submitTerminalPasteImage(attachment.data, format: attachment.format) + } + // Clear what we sent (only this terminal's staged set) before the text + // submit so the chip row empties immediately. + if !attachments.isEmpty { + clearPendingAttachments(forTerminalID: submittedTerminalID?.rawValue) + } + // Submit the text (a no-op when empty, e.g. an images-only send). + await submitComposerInput() + } + /// Clear the sent text from wherever it now lives after a successful /// composer send: the visible field when the submitted terminal is still /// selected, or the submitted terminal's STORED draft when the user switched diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift new file mode 100644 index 000000000000..657f5947a538 --- /dev/null +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift @@ -0,0 +1,123 @@ +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxMobileShell + +/// Behavior tests for the composer's pending-attachment store logic +/// (add/remove/clear, per-terminal keying, send-enabled gating). The actual +/// image RPC and the post-ack text reconciliation are covered elsewhere; these +/// drive the staging state directly, which is what the composer's chip row and +/// Send gating read. +@MainActor +@Suite struct ComposerPendingAttachmentTests { + private static let terminalA = MobileTerminalPreview(id: "term-a", name: "a") + private static let terminalB = MobileTerminalPreview(id: "term-b", name: "b") + + /// A composite selected on `term-a`. Selection is set by `init` (no `didSet` + /// draft swap fires), so the store contents stay exactly what each test seeds. + private static func makeComposite() -> MobileShellComposite { + MobileShellComposite( + workspaces: [ + MobileWorkspacePreview(id: "ws-1", name: "ws", terminals: [terminalA, terminalB]), + ] + ) + } + + private static func bytes(_ s: String) -> Data { Data(s.utf8) } + + @Test func addAppendsInPickOrder() { + let composite = Self.makeComposite() + composite.addPendingAttachment(Self.bytes("one"), format: "png", forTerminalID: "term-a") + composite.addPendingAttachment(Self.bytes("two"), format: "jpg", forTerminalID: "term-a") + + let staged = composite.pendingAttachments(forTerminalID: "term-a") + #expect(staged.count == 2) + #expect(staged[0].data == Self.bytes("one")) + #expect(staged[0].format == "png") + #expect(staged[1].data == Self.bytes("two")) + #expect(staged[1].format == "jpg") + } + + @Test func addIgnoresEmptyData() { + let composite = Self.makeComposite() + composite.addPendingAttachment(Data(), format: "png", forTerminalID: "term-a") + #expect(composite.pendingAttachments(forTerminalID: "term-a").isEmpty) + } + + @Test func removeDropsOnlyTheTargetedAttachment() { + let composite = Self.makeComposite() + composite.addPendingAttachment(Self.bytes("one"), format: "png", forTerminalID: "term-a") + composite.addPendingAttachment(Self.bytes("two"), format: "png", forTerminalID: "term-a") + let toRemove = composite.pendingAttachments(forTerminalID: "term-a")[0].id + + composite.removePendingAttachment(id: toRemove, forTerminalID: "term-a") + + let staged = composite.pendingAttachments(forTerminalID: "term-a") + #expect(staged.count == 1) + #expect(staged[0].data == Self.bytes("two")) + } + + @Test func clearEmptiesOnlyTheGivenTerminal() { + let composite = Self.makeComposite() + composite.addPendingAttachment(Self.bytes("a1"), format: "png", forTerminalID: "term-a") + composite.addPendingAttachment(Self.bytes("b1"), format: "png", forTerminalID: "term-b") + + composite.clearPendingAttachments(forTerminalID: "term-a") + + #expect(composite.pendingAttachments(forTerminalID: "term-a").isEmpty) + #expect(composite.pendingAttachments(forTerminalID: "term-b").count == 1) + } + + @Test func attachmentsAreKeyedPerTerminal() { + let composite = Self.makeComposite() + composite.addPendingAttachment(Self.bytes("a1"), format: "png", forTerminalID: "term-a") + composite.addPendingAttachment(Self.bytes("b1"), format: "png", forTerminalID: "term-b") + composite.addPendingAttachment(Self.bytes("b2"), format: "png", forTerminalID: "term-b") + + #expect(composite.pendingAttachments(forTerminalID: "term-a").count == 1) + #expect(composite.pendingAttachments(forTerminalID: "term-b").count == 2) + } + + @Test func defaultsToSelectedTerminalWhenIDOmitted() { + let composite = Self.makeComposite() + // Selected terminal is term-a (set at init). + composite.addPendingAttachment(Self.bytes("sel"), format: "png") + #expect(composite.pendingAttachments().count == 1) + #expect(composite.pendingAttachments(forTerminalID: "term-a").count == 1) + #expect(composite.pendingAttachments(forTerminalID: "term-b").isEmpty) + } + + @Test func canSendWhenTextEmptyButAttachmentPresent() { + let composite = Self.makeComposite() + composite.terminalInputText = "" + #expect(composite.composerCanSend(forTerminalID: "term-a") == false) + + composite.addPendingAttachment(Self.bytes("img"), format: "png", forTerminalID: "term-a") + #expect(composite.composerCanSend(forTerminalID: "term-a") == true) + } + + @Test func canSendWhenTextPresentButNoAttachment() { + let composite = Self.makeComposite() + composite.terminalInputText = "hello" + #expect(composite.composerCanSend(forTerminalID: "term-a") == true) + } + + @Test func cannotSendWhenTextWhitespaceAndNoAttachment() { + let composite = Self.makeComposite() + composite.terminalInputText = " \n " + #expect(composite.composerCanSend(forTerminalID: "term-a") == false) + } + + @Test func submitClearsAttachmentsForSubmittedTerminal() async { + let composite = Self.makeComposite() + // No remoteClient is wired, so the image/text RPCs are no-ops, but the + // clear-after-send of the staged set still runs (it does not depend on + // the wire). + composite.addPendingAttachment(Self.bytes("img"), format: "png", forTerminalID: "term-a") + composite.terminalInputText = "" + + await composite.submitComposer() + + #expect(composite.pendingAttachments(forTerminalID: "term-a").isEmpty) + } +} diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePendingAttachment.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePendingAttachment.swift new file mode 100644 index 000000000000..866164961c25 --- /dev/null +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePendingAttachment.swift @@ -0,0 +1,32 @@ +public import Foundation + +/// A picked image held in the composer as a pending attachment, sent to the +/// terminal agent on the next composer submit (iMessage-style: pick now, send +/// with the message). +/// +/// Value type so the store logic (add/remove/clear, per-terminal keying) is +/// host-testable without UIKit. The bytes are already encoded the same way the +/// clipboard paste path encodes them (PNG, or JPEG when over the size cap); the +/// composer view builds the thumbnail from ``data`` at render time. +public struct MobilePendingAttachment: Identifiable, Equatable, Sendable { + /// Stable identity so the chip row can diff and the remove action can target + /// one attachment without relying on byte equality. + public let id: UUID + /// The encoded image bytes (PNG/JPEG), ready to hand to + /// `submitTerminalPasteImage(_:format:)` as-is. + public let data: Data + /// A lowercase file-extension hint (e.g. `"png"`/`"jpg"`) for the Mac side, + /// matching the clipboard paste path's format argument. + public let format: String + + /// Creates a pending attachment. + /// - Parameters: + /// - id: Stable identity; defaults to a fresh `UUID`. + /// - data: The encoded image bytes. + /// - format: A lowercase format hint (`"png"`/`"jpg"`). + public init(id: UUID = UUID(), data: Data, format: String) { + self.id = id + self.data = data + self.format = format + } +} From 20a18767a39273849a3972324a04b63f61c96980 Mon Sep 17 00:00:00 2001 From: cmux-lawrence Date: Sun, 14 Jun 2026 00:04:18 -0700 Subject: [PATCH 02/10] iOS composer UI: attach button, attachment chips, autocorrect, padding - Replace the chevron.down (hide-composer) button to the left of the field with a paperclip attach button that opens a PhotosUI photo picker (multi-select). Composer dismissal still lives on the accessory toolbar's compose toggle. - Picked images are encoded the same way the clipboard paste path encodes them (PNG, JPEG fallback over ~8MB) and staged as pending attachments. - Render staged attachments as a horizontal row of removable thumbnail chips above the text field (iMessage style). - Send is enabled when text is non-empty OR attachments are staged; send routes through store.submitComposer() (images first, then text) and re-measures the band height. - Composer now uses normal text assistance (autocorrect on, sentence-case) since it is natural language to an agent; the raw terminal input is unchanged. - Reduce the top padding above the field (was 8pt vertical, now 2pt top / 8pt bottom) so the composer sits tighter; band measurement still driven by content + padding. - Add NSPhotoLibraryUsageDescription to Info.plist and en/ja strings for the attach and remove-attachment labels. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TerminalComposerView.swift | 274 ++++++++++++++---- ios/Config/Info.plist | 2 + ios/cmux/Resources/Localizable.xcstrings | 34 +++ 3 files changed, 247 insertions(+), 63 deletions(-) diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift index ef3318d64790..00d4256f23b4 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift @@ -1,9 +1,12 @@ #if os(iOS) import CMUXMobileCore import CmuxMobileShell +import CmuxMobileShellModel import CmuxMobileSupport import CmuxMobileTerminal +import PhotosUI import SwiftUI +import UIKit /// iMessage-style composer hosted in the terminal surface's composer band. /// @@ -17,8 +20,9 @@ import SwiftUI /// Open by default per terminal (like iMessage's always-present input bar), and /// presented does NOT mean focused: the field appears with the keyboard down and /// takes focus only on a user tap or an explicit focus request from the store -/// (an explicit open/reveal, or a terminal switch mid-compose). The chevron -/// dismisses it for that terminal. +/// (an explicit open/reveal, or a terminal switch mid-compose). The button to +/// the left of the field opens the photo picker for image attachments; the +/// composer is dismissed from the accessory toolbar's compose toggle. /// /// The bottom dock (terminal grid / composer band / accessory toolbar / keyboard) /// is owned entirely by `GhosttySurfaceView` in one coordinate system. This view is @@ -41,6 +45,11 @@ struct TerminalComposerView: View { /// the host measures the ideal height via `sizeThatFits` and animates the band. let requestHeightRemeasure: () -> Void @FocusState private var isFieldFocused: Bool + /// Photo-picker selection bound to the system `PhotosPicker`. Cleared after + /// each batch is encoded and staged so re-picking the same image fires again. + @State private var pickerSelection: [PhotosPickerItem] = [] + /// Drives the photo picker's presentation from the attach button. + @State private var isPickerPresented = false init(store: CMUXMobileShellStore, terminalID: String, requestHeightRemeasure: @escaping () -> Void) { self.store = store @@ -48,7 +57,7 @@ struct TerminalComposerView: View { self.requestHeightRemeasure = requestHeightRemeasure } - /// Single-line height of the round close button beside the field. It stays + /// Single-line height of the round attach button beside the field. It stays /// pinned to the bottom edge of the (taller) field via the outer `HStack`'s /// `.bottom` alignment. private let controlHeight: CGFloat = 40 @@ -69,10 +78,29 @@ struct TerminalComposerView: View { /// Minimum height of the compose field, matching the one-line baseline. private let composerFieldMinHeight: CGFloat = 40 + /// Whether the field's text alone is empty. Drives only secondary visuals; + /// the Send affordance keys on ``canSend`` so an images-only message (empty + /// text, attachments staged) is still sendable. private var trimmedIsEmpty: Bool { store.terminalInputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + /// Send is enabled when the text is non-empty OR at least one attachment is + /// staged for this terminal (iMessage-style images-only send). + private var canSend: Bool { + store.composerCanSend(forTerminalID: terminalID) + } + + /// This terminal's staged image attachments, shown as the chip row above the + /// field and sent (in order) ahead of the text on submit. + private var pendingAttachments: [MobilePendingAttachment] { + store.pendingAttachments(forTerminalID: terminalID) + } + + /// The Mac decodes the image to a temp file with a 10 MB cap; mirror the + /// clipboard paste path and keep PNG under ~8 MB, otherwise fall back to JPEG. + private static let maxImageBytes = 8 * 1024 * 1024 + var body: some View { composerSurface // The field is pinned edge-to-edge inside the surface's composer band, so its @@ -152,73 +180,117 @@ struct TerminalComposerView: View { } private var composerBar: some View { - HStack(alignment: .bottom, spacing: 8) { - Button { - store.toggleComposer(forTerminalID: terminalID) - } label: { - Image(systemName: "chevron.down") - .font(.system(size: 15, weight: .semibold)) - .frame(width: controlHeight, height: controlHeight) + VStack(alignment: .leading, spacing: 6) { + // iMessage-style chip row of staged image attachments, ABOVE the + // field. Shown only when something is staged so the empty composer + // keeps its compact one-line height (and the host's measurement). + if !pendingAttachments.isEmpty { + attachmentChipRow } - .buttonStyle(.plain) - .foregroundStyle(TerminalPalette.foreground.opacity(0.7)) - .mobileGlassCircle() - .accessibilityIdentifier("MobileComposerClose") - .accessibilityLabel(L10n.string("mobile.composer.close", defaultValue: "Hide Composer")) - - // The field and its send button share ONE rounded glass container — - // iMessage's layout, where the circular up-arrow lives INSIDE the - // field at the trailing edge. `.bottom` alignment pins the button to - // the field's last line as it grows, so a multi-line draft keeps the - // send affordance at the natural "end of message" spot. - HStack(alignment: .bottom, spacing: 8) { - TextField( - L10n.string("mobile.composer.placeholder", defaultValue: "Message"), - text: $store.terminalInputText, - axis: .vertical - ) - // Opens at a single line and grows up to 14 lines so a long message has - // room. Each added line grows this view, which the host reserves above the - // always-visible toolbar; the toolbar and keyboard never move. - .lineLimit(composerLineLimit) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .focused($isFieldFocused) - .foregroundStyle(TerminalPalette.foreground) - // 6pt container padding + 3pt here keeps the text's 9pt inset - // from the round-7 layout, and bottom-aligns the single-line text - // with the inline button's circle. - .padding(.vertical, 3) - .accessibilityIdentifier("MobileComposerField") + HStack(alignment: .bottom, spacing: 8) { Button { - send() + isPickerPresented = true } label: { - Image(systemName: "arrow.up") - .font(.system(size: 15, weight: .bold)) - .foregroundStyle(trimmedIsEmpty ? TerminalPalette.foreground.opacity(0.35) : .white) - .frame(width: inlineSendDiameter, height: inlineSendDiameter) - .background( - Circle().fill( - trimmedIsEmpty - ? AnyShapeStyle(TerminalPalette.foreground.opacity(0.12)) - : AnyShapeStyle(Color.accentColor) - ) - ) + Image(systemName: "paperclip") + .font(.system(size: 15, weight: .semibold)) + .frame(width: controlHeight, height: controlHeight) } .buttonStyle(.plain) - .disabled(trimmedIsEmpty) - .accessibilityIdentifier("MobileComposerSend") - .accessibilityLabel(L10n.string("mobile.composer.send", defaultValue: "Send")) + .foregroundStyle(TerminalPalette.foreground.opacity(0.7)) + .mobileGlassCircle() + .accessibilityIdentifier("MobileComposerAttach") + .accessibilityLabel(L10n.string("mobile.composer.attach", defaultValue: "Attach Photo")) + + // The field and its send button share ONE rounded glass container — + // iMessage's layout, where the circular up-arrow lives INSIDE the + // field at the trailing edge. `.bottom` alignment pins the button to + // the field's last line as it grows, so a multi-line draft keeps the + // send affordance at the natural "end of message" spot. + HStack(alignment: .bottom, spacing: 8) { + TextField( + L10n.string("mobile.composer.placeholder", defaultValue: "Message"), + text: $store.terminalInputText, + axis: .vertical + ) + // Opens at a single line and grows up to 14 lines so a long message has + // room. Each added line grows this view, which the host reserves above the + // always-visible toolbar; the toolbar and keyboard never move. + .lineLimit(composerLineLimit) + // Natural-language to an agent, so normal iOS text assistance + // is on (autocorrect, sentence-case, spell check). The raw + // terminal input field keeps these OFF; only the composer + // enables them. + .textInputAutocapitalization(.sentences) + .autocorrectionDisabled(false) + .focused($isFieldFocused) + .foregroundStyle(TerminalPalette.foreground) + // 6pt container padding + 3pt here keeps the text's 9pt inset + // from the round-7 layout, and bottom-aligns the single-line text + // with the inline button's circle. + .padding(.vertical, 3) + .accessibilityIdentifier("MobileComposerField") + + Button { + send() + } label: { + Image(systemName: "arrow.up") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(canSend ? .white : TerminalPalette.foreground.opacity(0.35)) + .frame(width: inlineSendDiameter, height: inlineSendDiameter) + .background( + Circle().fill( + canSend + ? AnyShapeStyle(Color.accentColor) + : AnyShapeStyle(TerminalPalette.foreground.opacity(0.12)) + ) + ) + } + .buttonStyle(.plain) + .disabled(!canSend) + .accessibilityIdentifier("MobileComposerSend") + .accessibilityLabel(L10n.string("mobile.composer.send", defaultValue: "Send")) + } + .padding(.leading, 14) + .padding(.trailing, 6) + .padding(.vertical, 6) + .frame(minHeight: composerFieldMinHeight, alignment: .top) + .mobileGlassField(cornerRadius: 20) } - .padding(.leading, 14) - .padding(.trailing, 6) - .padding(.vertical, 6) - .frame(minHeight: composerFieldMinHeight, alignment: .top) - .mobileGlassField(cornerRadius: 20) } .padding(.horizontal, 12) - .padding(.vertical, 8) + // Tighter above the field than below (the user reported too much top + // padding); the band height is still driven by content + this padding, + // so the host's re-measure stays correct. + .padding(.top, 2) + .padding(.bottom, 8) + .photosPicker( + isPresented: $isPickerPresented, + selection: $pickerSelection, + maxSelectionCount: nil, + matching: .images + ) + .onChange(of: pickerSelection) { _, items in + guard !items.isEmpty else { return } + stagePickedItems(items) + } + } + + /// Horizontal, removable thumbnail chips for the staged attachments. Each + /// chip shows the picked image with an x to remove it. + private var attachmentChipRow: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(pendingAttachments) { attachment in + AttachmentChip(attachment: attachment) { + store.removePendingAttachment(id: attachment.id, forTerminalID: terminalID) + requestHeightRemeasure() + } + } + } + .padding(.leading, controlHeight + 8) + .padding(.trailing, 12) + } } /// Focus the field one runloop after appearing. Setting `@FocusState` inline @@ -233,10 +305,86 @@ struct TerminalComposerView: View { } private func send() { - guard !trimmedIsEmpty else { return } + // Allowed with empty text as long as an attachment is staged. + guard canSend else { return } isFieldFocused = true Task { @MainActor in - await store.submitComposerInput() + // Sends staged images first (in order), then the text, then clears + // the staged set for this terminal. + await store.submitComposer() + // The chip row emptied as part of the send; re-measure so the band + // shrinks back to the one-line height. + requestHeightRemeasure() + } + } + + /// Encode each picked photo the same way the clipboard paste path does (PNG, + /// falling back to JPEG when over the ~8 MB cap) and stage it as a pending + /// attachment for this terminal. Runs off the picker callback; the selection + /// is cleared so re-picking the same asset fires again. + private func stagePickedItems(_ items: [PhotosPickerItem]) { + Task { @MainActor in + for item in items { + guard let raw = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: raw) else { continue } + if let png = image.pngData(), png.count <= Self.maxImageBytes { + store.addPendingAttachment(png, format: "png", forTerminalID: terminalID) + } else if let jpeg = image.jpegData(compressionQuality: 0.8) { + store.addPendingAttachment(jpeg, format: "jpg", forTerminalID: terminalID) + } else if let png = image.pngData() { + store.addPendingAttachment(png, format: "png", forTerminalID: terminalID) + } + } + pickerSelection = [] + // A new chip grows the band; ask the host to re-measure. + requestHeightRemeasure() + } + } +} + +/// A removable thumbnail chip for one staged image attachment. +private struct AttachmentChip: View { + let attachment: MobilePendingAttachment + let onRemove: () -> Void + + private let side: CGFloat = 56 + + var body: some View { + ZStack(alignment: .topTrailing) { + thumbnail + .frame(width: side, height: side) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(TerminalPalette.foreground.opacity(0.15), lineWidth: 1) + ) + + Button(action: onRemove) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18, weight: .bold)) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, Color.black.opacity(0.55)) + } + .buttonStyle(.plain) + .padding(2) + .accessibilityIdentifier("MobileComposerAttachmentRemove") + .accessibilityLabel(L10n.string("mobile.composer.attachment.remove", defaultValue: "Remove Attachment")) + } + } + + @ViewBuilder + private var thumbnail: some View { + if let uiImage = UIImage(data: attachment.data) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + } else { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(TerminalPalette.foreground.opacity(0.12)) + .overlay( + Image(systemName: "photo") + .foregroundStyle(TerminalPalette.foreground.opacity(0.5)) + ) } } } diff --git a/ios/Config/Info.plist b/ios/Config/Info.plist index 4543be5a47bb..91fba6a06371 100644 --- a/ios/Config/Info.plist +++ b/ios/Config/Info.plist @@ -55,6 +55,8 @@ Scan cmux pairing QR codes from your Mac terminal. NSLocalNetworkUsageDescription Connect to your Mac on the local network for cmux mobile pairing and terminal sync. + NSPhotoLibraryUsageDescription + Attach photos to send to your terminal agent. NSAppTransportSecurity