Skip to content

iOS composer: image attachments, attach button, autocorrect, padding - #6102

Merged
lawrencecchen merged 12 commits into
mainfrom
feat-ios-composer-attach
Jun 15, 2026
Merged

lawrencecchen merged 12 commits into
mainfrom
feat-ios-composer-attach

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 14, 2026 •

Copy link
Copy Markdown
Contributor

What changed

Adds iMessage-style image attachments to the iOS mobile composer and fixes a few composer ergonomics issues. Reuses the existing terminal.paste_image transport (no new wire protocol).

Store (CmuxMobileShell)

  • New MobilePendingAttachment value type (image data + lowercase format hint + stable id), host-testable (no UIKit).
  • Per-terminal pending-attachment state keyed by terminal id (like the text draft), with add/remove/clear/read methods.
  • composerCanSend(forTerminalID:): true when text is non-empty OR at least one attachment is staged (images-only send allowed).
  • submitComposer(): sends staged images in pick order (awaited, so the injected file paths land first), then submits the text, then clears the staged set for the submitted terminal.

Composer UI (TerminalComposerView)

  • Replaced the chevron.down (hide-composer) button 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 (ComposerDockIntent.closeComposer -> dismissComposer()), so no dismiss path is lost.
  • Picked images are encoded the same way the clipboard paste path encodes them (PNG, JPEG fallback over ~8MB) and staged.
  • Staged attachments render as a horizontal row of removable thumbnail chips above the text field.
  • Send enabled on text OR attachments; routes through submitComposer() and re-measures the band height.
  • Composer now uses normal iOS text assistance (autocorrect on, sentence-case) since it is natural language to an agent. The raw terminal input field (TerminalInputTextView) is unchanged and keeps autocorrect off.
  • Reduced top padding above the field (was 8pt vertical, now 2pt top / 8pt bottom).

Entitlements / localization

  • Added NSPhotoLibraryUsageDescription to ios/Config/Info.plist.
  • Added en/ja strings for the attach and remove-attachment labels.

How to test

  • Host unit tests: cd Packages/CmuxMobileShell && swift test --filter ComposerPendingAttachmentTests (10 tests, all green).
  • On device: open a terminal, tap the paperclip, pick one or more photos, confirm chips appear above the field and each has a working x. Send with text and with empty text (images only); confirm images arrive before the message and chips clear after send.

Caveats

  • The SwiftUI view + PhotosPicker cannot be host-compiled (CmuxMobileShellUI depends on the GhosttyKit xcframework, iOS-only); the final Xcode build validates it. API usage was parse-checked against the iOS SDK.
  • mobile.composer.close localization key is now unused (the chevron it labeled was removed) but left in place to avoid extraction churn.

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Large in-memory attachment state and async multi-RPC submit interact with account switch, reconnect, and terminal topology; mitigated by caps, pruning, identity guards, and broad unit/routing tests, but still user-data and sync-sensitive.

Overview
Adds iMessage-style photo attachments to the iOS terminal composer: pick from the library, stage per-terminal chips, then send images first (existing terminal.paste_image) followed by the composed text via new submitComposer().

MobileShellComposite gains per-terminal MobilePendingAttachment staging with atomic per-terminal and global count/byte caps, topology validation/pruning on workspace sync, sign-out wipe, and session-generation guards for in-flight picks. Submit paths capture workspace/terminal/text up front, block double-send, abort on sign-out or connection swap mid-flight, and keep staged content on RPC failure (bool results from paste-image).

TerminalComposerView swaps the band chevron for a paperclip + PhotosPicker, chip row, Send gated on text or attachments, file-backed import with ImageIO downsampling and cancellable staging tasks, plus composer autocorrect/sentence case and tighter top padding. Adds photo-library usage strings and updates UI tests to dismiss via the toolbar compose toggle.

Reviewed by Cursor Bugbot for commit 02e276a. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Adds iMessage-style photo attachments to the iOS composer with a paperclip picker, per-terminal staging chips, and images-first sending via submitComposer(). Reliability is tightened across routing, memory, cancellation, and now aborts mid-send if the session or connection changes.

  • New Features

    • Pick photos (multi-select) via PhotosUI; stage per terminal as removable chips; Send works with text or attachments (images first via submitComposer()).
    • Store: MobilePendingAttachment, composerCanSend, whole-submit re-entrancy guard, and targeted RPCs that capture workspace/terminal/text at start for correct routing.
    • UI: replace chevron with attach; enable autocorrect and sentence case; tighter top padding; add NSPhotoLibraryUsageDescription and localized attach/remove labels.
  • Bug Fixes

    • Memory/perf: file-backed import with on-disk size gate; bounded ImageIO downsample/re-encode; cached thumbnails; structured cancellation with propagation; cancel on re-pick, terminal switch, and view disappear; caps enforced atomically (per terminal: 10 items, 8 MB per image, 32 MB total; global: 20 items, 64 MB).
    • Topology/privacy: validate terminal before staging; prune attachments when terminals disappear; clear staged attachments on sign-out; drop picks for stale sessions or missing terminals.
    • Reliability: snapshot target terminal and text at submit start; abort the submit if sign-in or connection changes mid-send (keep attachments and text for retry); skip mid-send deletions; remove after ack; on image failure, stop and keep remaining attachments and text.

Written for commit 02e276a. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

Release Notes

  • New Features

    • Added photo attachment support to the terminal composer with a photo picker, localized attach/remove strings, and iOS photo library permission.
    • Enable images-only sending with removable thumbnail chips, attachment count/size limits, and send button gating.
  • Bug Fixes

    • Improved reliability with per-terminal staged attachments, send-time terminal targeting, images-then-text submission, and ack-based failure handling that preserves unacknowledged attachments.
    • Prevented cross-account/session carryover by clearing staged attachments on sign-out and guarding in-flight operations.
  • Tests

    • Added unit and end-to-end coverage for per-terminal staging, session behavior, re-entrancy, and routing snapshots.

cmux-lawrence and others added 2 commits June 14, 2026 00:04
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 15, 2026 10:18pm
cmux-staging Building Building Preview, Comment Jun 15, 2026 10:18pm

@coderabbitai

coderabbitai Bot commented Jun 14, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds end-to-end photo attachment support to the iOS mobile composer. A new MobilePendingAttachment model stores encoded images per terminal in MobileShellComposite. Public store APIs provide CRUD operations, per-terminal scoping, session-generation guards for account-switch races, and composerCanSend to gate on text or images. The new submitComposer() async entry point captures the terminal once, sends staged attachments in order (each returning Bool for ack), removes cleared ones, then submits text to the captured terminal. TerminalComposerView is reworked with PhotosUI picker, removable chips, PNG/JPEG encoding with budgets, and main-actor thumbnail caching. Info.plist, localization strings, and UI tests are updated for permissions, labels, and new composer UI structure.

Changes

Photo Attachment Feature

Layer / File(s) Summary
MobilePendingAttachment model, permissions, and localization
Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePendingAttachment.swift, ios/Config/Info.plist, ios/cmux/Resources/Localizable.xcstrings, ios/cmux/Resources/InfoPlist.xcstrings
New Identifiable, Equatable, Sendable struct with stable UUID, encoded image data, and format string. Adds photo library permission description to Info.plist and dual-language permission/action localization strings for attach button and remove-attachment action.
Per-terminal attachment store state, cleanup, and session generation
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
Private pendingAttachmentsByTerminalID dictionary, isSubmittingComposer re-entrancy guard, and signInGeneration token for account-switch detection. Sign-out clears all pending attachments and increments generation to prevent cross-account carryover. Exposes currentSessionGeneration for guarded staging.
Per-terminal attachment CRUD APIs and composerCanSend gating
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
Public methods pendingAttachments(forTerminalID:), addPendingAttachment(_:format:forTerminalID:) (including generation-guarded overload), removePendingAttachment(id:forTerminalID:), clearPendingAttachments(forTerminalID:), and composerCanSend(forTerminalID:) that enable images-only sends (text OR attachment present). Per-terminal scoping with default to selected terminal.
Image send refactoring: Bool return for ack-based control
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
submitTerminalPasteImage(_:format:) now returns Bool indicating ack success. Internal overload validates against captured workspace/terminal IDs for routing immutability during async waits. Underlying sendRemoteTerminalPasteImage returns Bool, treating successful RPC acks as true even when bookkeeping is superseded, and RPC/auth failures as false while preserving existing error-handling paths.
submitComposer() async flow and submitComposerInput() refactor
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
New submitComposer() captures workspace/terminal once, sends staged attachments in pick order (awaiting acks), removes cleared ones (stops on failure, keeps remaining staged), then submits text snapshot for captured terminal. submitComposerInput() refactored to use captured IDs, enforcing re-entrancy and reconciling draft clearing against captured terminal to prevent mid-flight rerouting.
TerminalComposerView UI foundation: imports, picker state, thumbnail cache, and constraints
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
Adds PhotosUI/ImageIO imports. Adds picker state (pickerSelection, isPickerPresented) and main-actor AttachmentThumbnailCache. Introduces derived properties (canSend, pendingAttachments, trimmedIsEmpty) and static constraints (per-image 8MB, max attachment count, total bytes, thumbnail size). Enables sentence autocapitalization and autocorrection.
Composer bar UI rebuild, send workflow, image encoding, and staging pipeline
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
Removes chevron dismiss, adds paperclip attach button wired to constrained photosPicker. send() guards on canSend, focuses field, awaits store.submitComposer(), retains thumbnails for pending attachments, re-measures height. stagePickedItems(_:) loads/encodes images (PNG ≤8MB or JPEG 0.8 with PNG fallback) within budgets, stages via store, caches thumbnails, clears picker, re-measures. Off-main pipeline: PreparedAttachment, prepare(_:) with Task.detached, encode(_:) format selection, downsampledThumbnailData(from:) via ImageIO. AttachmentThumbnailCache manages thumbnails; AttachmentChip renders thumbnail with remove and placeholder fallback.
ComposerPendingAttachmentTests suite
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift
@MainActor @Suite`` with two preview terminals. 14 tests covering add/remove/clear per-terminal, empty-data ignore, per-terminal keying, default-to-selected-terminal, composerCanSend gating (images-only, text-only, whitespace), sign-out clearing, generation-guarded add (drops after sign-out or missing terminal), and async submit retention on failure.
Routing test infrastructure
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift
RoutingTestRuntime provides in-memory MobileSync fixtures. RoutingHostRouter actor records paste/paste_image requests with configurable rejection and response-hold. RoutingTransport decodes frames, routes asynchronously, delivers responses via continuations with orderly shutdown. makeRoutingConnectedStore(router:) helper builds two-terminal composite with loopback RPC wiring and stack-auth fallback.
ComposerSubmitRoutingTests end-to-end suite
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTests.swift
Async suite (7 tests) validating submit routing over RPC: terminal capture at submit time, mid-flight selection switches don't reroute later images or text, text snapshots survive field edits and terminal switches, concurrent submits rejected, rejected images stop flow and preserve attachments, partial failures clear only acked attachments.
UI test accessibility and composer dismissal
ios/cmuxUITests/cmuxUITests.swift
Removes bandClose accessibility identifier. Updates testComposerSurvivesRepeatedOpenCloseCycles baseline to use toolbar compose button for reveal/focus/dismiss instead of chevron, adjusting dock probe assertions.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant TerminalComposerView
  participant MobileShellComposite
  participant TerminalSession
  
  rect rgba(173, 216, 230, 0.5)
    Note over User,TerminalComposerView: submitComposer() captures terminal once
  end
  User->>TerminalComposerView: tap send
  TerminalComposerView->>MobileShellComposite: submitComposer()
  MobileShellComposite->>MobileShellComposite: snapshot currentWorkspace + selectedTerminal
  loop Each staged MobilePendingAttachment (pick order)
    MobileShellComposite->>TerminalSession: submitTerminalPasteImage(data, format)
    TerminalSession-->>MobileShellComposite: true (acked)
    MobileShellComposite->>MobileShellComposite: removePendingAttachment(id)
  end
  MobileShellComposite->>MobileShellComposite: submitComposerInput() for captured terminal
  MobileShellComposite-->>TerminalComposerView: complete
  TerminalComposerView->>TerminalComposerView: requestHeightRemeasure()
Loading
sequenceDiagram
  participant User
  participant TerminalComposerView
  participant PhotosPicker
  participant MobileShellComposite
  
  rect rgba(255, 228, 196, 0.5)
    Note over User,TerminalComposerView: User picks images for staging
  end
  User->>TerminalComposerView: tap paperclip
  TerminalComposerView->>PhotosPicker: present (images only)
  PhotosPicker-->>TerminalComposerView: pickerSelection changed
  TerminalComposerView->>TerminalComposerView: stagePickedItems(_:)
  par Async per-image
    TerminalComposerView->>TerminalComposerView: Task.detached encode()<br/>(PNG ≤8MB else JPEG 0.8)
    TerminalComposerView->>TerminalComposerView: downsampledThumbnailData(from:)<br/>via ImageIO
  end
  TerminalComposerView->>MobileShellComposite: addPendingAttachment(data, format)
  MobileShellComposite-->>TerminalComposerView: attachment id
  TerminalComposerView->>TerminalComposerView: cache thumbnail<br/>clear picker<br/>requestHeightRemeasure()
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • manaflow-ai/cmux#5546: Both PRs touch MobileShellComposite.submitTerminalPasteImage and the terminal.paste_image RPC flow—this PR refactors it to support staged attachments with ack-based control, while #5546 introduces the initial paste-image RPC dispatch.
  • manaflow-ai/cmux#5876: Both PRs extend the iOS terminal composer implementation in MobileShellComposite.swift—#5876 refactors submitComposerInput() text-send with per-terminal draft handling, while this PR extends that pipeline to stage/ack images and coordinate submission via captured terminal IDs.

Poem

🐇 A paperclip hops through the composer bar,
Picking up photos from near and afar.
Each chip in a row, a thumbnail so bright,
PNG or JPEG—whichever feels right.
With one tap on send, the images fly,
Then text follows after—a bunny's goodbye! 📸


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Algorithmic Complexity ❌ Error Line 2785 in MobileShellComposite.swift contains a nested double-scan (workspaces.contains with nested terminals.contains) called once per picked image in stagePickedItems loop (up to 10), violatin... Validate terminal existence once before the batch loop, or keep a terminal-ID set/index in the store to enable O(1) lookups instead of O(W×T) per image.
Cmux Swift File And Package Boundaries ❌ Error PR adds 295 lines to MobileShellComposite (5385 total, >800 lines), exceeding the 250-line threshold for oversized files. No extraction exception met: file didn't shrink 200+ lines, and this is a n... Extract attachment staging logic (~295 lines: pendingAttachmentsByTerminalID, add/remove/clear, composerCanSend, submitComposer) into a new ComposerAttachmentStaging package with MobilePendingAttachment (already in model package) as its...
Docstring Coverage ⚠️ Warning Docstring coverage is 51.52% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (18 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: iOS composer now supports image attachments via a paperclip button, includes autocorrect, and adjusts padding.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed PR introduces proper Swift 6 actor isolation: MobilePendingAttachment is Sendable with immutable fields; MobileShellComposite public methods run on @MainActor; TerminalComposerView uses explicit @M...
Cmux Swift Blocking Runtime ✅ Passed No blocking/timing-based synchronization introduced. New functions use simple boolean re-entrancy guards, proper async/await for RPC coordination, and Task.detached for off-thread image prep—no sem...
Cmux Expensive Synchronous Load ✅ Passed The workspace.contains traversal is a synchronous in-memory array check, not the disk/syscall-based load pattern (350ms+) specified in swift-expensive-sync-load.md. Rule targets RestorableAgentSess...
Cmux Cache Substitution Correctness ✅ Passed AttachmentThumbnailCache is transient UI-only state with graceful cold-cache handling (placeholder rendering). pendingAttachmentsByTerminalID is authoritative in-memory state, not persisted. No aut...
Cmux No Hacky Sleeps ✅ Passed PR contains only Swift code and configuration files; rule scope excludes Swift (covered by separate rule) and only applies to TypeScript, JavaScript, shell, or build/runtime scripts.
Cmux Swift Concurrency ✅ Passed PR uses modern async/await for all new public APIs and employs fire-and-forget Tasks only within SwiftUI callback boundaries (button actions, lifecycle hooks) for UI isolation, which is explicitly...
Cmux Swift @Concurrent ✅ Passed PR complies with Swift @concurrent rules: TerminalComposerView.prepare() has explicit Task.detached() actor hop, and all MobileShellComposite async methods inherit @MainActor isolation from the class.
Cmux Swift Logging ✅ Passed No print, debugPrint, dump, NSLog, or other logging violations found in any production code changes; existing Logger already properly used; no new logging statements added.
Cmux User-Facing Error Privacy ✅ Passed PR adds iMessage-style photo attachments with only three user-facing strings: "Attach Photo", "Remove Attachment" (UI labels), and "Attach photos to send to your terminal agent." (iOS permission pr...
Cmux Full Internationalization ✅ Passed All user-facing Swift strings use L10n.string() API with proper localization keys, and all new entries in Localizable.xcstrings and InfoPlist.xcstrings include complete translations for both existi...
Cmux Swiftui State Layout ✅ Passed TerminalComposerView follows all SwiftUI state layout rules: uses modern @State (not @Published/@StateObject), has no GeometryReader, passes immutable snapshots + closures to AttachmentChip rows (n...
Cmux Architecture Rethink ✅ Passed PR adds attachment staging with clear single ownership (store, view), proper value snapshots for async boundaries, no timing/dispatch repairs, and named invariants. No architectural violations per...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds PhotosPicker (system sheet modal) to iOS composer; no NSWindow, NSPanel, NSWindowController, or cmux-owned SwiftUI Window/WindowGroup declarations added. Sheets and popovers are explicitly...
Cmux Source Artifacts ✅ Passed All 10 files in the PR are hand-written source code, test files, config files, or localization catalogs. No generated logs, build artifacts, caches, DerivedData, temp files, or other artifacts were...
Description check ✅ Passed The PR description covers all required template sections: what changed, why, testing approach with command examples, and a comprehensive checklist of validation steps.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-composer-attach

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Outdated
Comment thread Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 20a18767a3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve staged photos when image send fails

When the Mac is disconnected or terminal.paste_image rejects a photo (for example because the JPEG still exceeds the Mac-side limit or the queue is full), submitTerminalPasteImage returns after recording the error but does not report failure. This block still clears every staged attachment, so the user loses the only retryable copy while nothing was delivered; mirror the text path by clearing only after acknowledged image sends.

Useful? React with 👍 / 👎.

// 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Target the submitted terminal for every attachment send

If a user taps Send with multiple photos and switches terminals while the first await is in flight, the next iteration calls submitTerminalPasteImage, which re-reads selectedWorkspace/selectedTerminalID instead of using submittedTerminalID. That sends later photos to the newly selected terminal while clearing the original terminal’s staged chips, so a single composed message can be split across terminals.

Useful? React with 👍 / 👎.

/// 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard attachment submits against double taps

The existing re-entrancy guard is inside submitComposerInput, so it never protects the image loop, and images-only sends never enter it at all. With a photo staged, a second tap while the first image RPC is awaiting starts another submitComposer() that captures the same attachments before they are cleared, causing duplicate image paths to be injected into the terminal.

Useful? React with 👍 / 👎.

Comment thread ios/Config/Info.plist
<key>NSLocalNetworkUsageDescription</key>
<string>Connect to your Mac on the local network for cmux mobile pairing and terminal sync.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Attach photos to send to your terminal agent.</string>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Localize the new photo-library privacy string

The repo’s /workspace/cmux/AGENTS.md requires all user-facing strings to be localized, and this target already localizes Info.plist privacy prompts in ios/cmux/Resources/InfoPlist.xcstrings. Adding NSPhotoLibraryUsageDescription only as an English plist value means any iOS photo-library permission prompt/settings text falls back to English for Japanese users; add the key to InfoPlist.xcstrings with both locales.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jun 14, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds iMessage-style photo attachment support to the iOS terminal composer: a paperclip button opens PhotosUI, picked images are encoded via ImageIO (file-backed, off-main-thread), staged per-terminal as removable chips, and submitted images-first ahead of the text via submitComposer().

  • Store (MobileShellComposite): Adds MobilePendingAttachment value type, per-terminal attachment state, composerCanSend, whole-submit re-entrancy guard (isSubmittingComposer), session/connection generation guards for mid-send sign-out and Mac-switch safety, topology pruning, and global/per-terminal byte+count caps enforced atomically on @MainActor.
  • Composer UI (TerminalComposerView): Replaces the chevron with a paperclip attach button, adds the chip row above the field, corrects the send-gating to allow images-only sends, switches the text field to sentence-case/autocorrect-on for natural-language agent input, and uses nonisolated static func prepare(url:) (child task group at .background priority) so CPU-heavy ImageIO work never runs on the main actor.
  • Tests: New ComposerPendingAttachmentTests (staging caps, topology pruning, session guards) and ComposerSubmitRoutingTests (mid-send switch, text snapshot, double-submit re-entrancy, sign-out abort, connection-swap abort) over a real RPC wire backed by a scripted recording host.

Confidence Score: 5/5

Safe to merge — all three previously flagged concerns (re-entrancy gap, main-actor encoding, missing xcstrings entry) are addressed in this diff, and no new correctness issues were found.

The store-side logic is carefully designed: caps are enforced atomically on @mainactor, session and connection generations are both checked before each image send, the text snapshot is captured before any await, and the per-terminal/global dual-cap model is thoroughly exercised by the new tests. The view's ImageIO path correctly uses nonisolated static func prepare(url:) in a background-priority child task group so no CPU-heavy work runs on the main actor. The routing tests exercise the full mid-send terminal switch, field edit, double-submit, sign-out abort, and connection-swap abort scenarios over a real RPC wire. The only observation is a minor UX mismatch in the picker's maxSelectionCount, which does not affect data integrity.

No files require special attention.

Important Files Changed

Filename Overview
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Adds per-terminal pending-attachment state, whole-submit re-entrancy guard, session/connection-generation identity guards, topology pruning, global+per-terminal caps, submitComposer(), and a refactored submitComposerInput(workspaceID:terminalID:capturedText:) — all correctly isolated on @mainactor with detailed inline documentation.
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift Replaces chevron with paperclip/PhotosPicker, adds chip row, encodes via nonisolated static func prepare(url:) (background child task group — correctly off main actor), caches thumbnails per attachment id. One minor P2: photosPicker maxSelectionCount doesn't account for already-staged items.
Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePendingAttachment.swift New value type: Identifiable, Equatable, Sendable — correct design for UIKit-free host testability. UUID stable id avoids byte-equality identity.
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTests.swift End-to-end routing tests over a real RPC wire (recording host): mid-send terminal switch, text snapshot survival, double-submit guard, partial failure keep-on-retry, sign-out mid-send abort, connection-swap abort — exhaustive coverage.
ios/cmux/Resources/InfoPlist.xcstrings Adds NSPhotoLibraryUsageDescription with both en and ja entries — correct and consistent with existing NSCameraUsageDescription/NSLocalNetworkUsageDescription pattern.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant View as TerminalComposerView
    participant Store as MobileShellComposite
    participant Host as Mac Host (RPC)

    User->>View: tap Send
    View->>View: guard canSend
    View->>Store: submitComposer()
    Store->>Store: "guard !isSubmittingComposer / isSubmittingComposer = true"
    Store->>Store: snapshot workspaceID, terminalID, text, attachments, signInGeneration, connectionGeneration

    loop For each staged attachment (in pick order)
        Store->>Store: guard isComposerSubmitIdentityCurrent()
        Store->>Store: guard attachment still staged
        Store->>Host: await terminal.paste_image(data, format)
        Host-->>Store: ack
        Store->>Store: removePendingAttachment(id)
    end

    Store->>Store: guard isComposerSubmitIdentityCurrent()
    Store->>Store: submitComposerInput(workspaceID, terminalID, capturedText)
    Store->>Host: await terminal.paste(text)
    Host-->>Store: ack
    Store->>Store: reconcileComposerDraftAfterSend()
    Store->>Store: "isSubmittingComposer = false"
    Store-->>View: return
    View->>View: thumbnailCache.retain(remaining ids)
    View->>View: requestHeightRemeasure()
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant View as TerminalComposerView
    participant Store as MobileShellComposite
    participant Host as Mac Host (RPC)

    User->>View: tap Send
    View->>View: guard canSend
    View->>Store: submitComposer()
    Store->>Store: "guard !isSubmittingComposer / isSubmittingComposer = true"
    Store->>Store: snapshot workspaceID, terminalID, text, attachments, signInGeneration, connectionGeneration

    loop For each staged attachment (in pick order)
        Store->>Store: guard isComposerSubmitIdentityCurrent()
        Store->>Store: guard attachment still staged
        Store->>Host: await terminal.paste_image(data, format)
        Host-->>Store: ack
        Store->>Store: removePendingAttachment(id)
    end

    Store->>Store: guard isComposerSubmitIdentityCurrent()
    Store->>Store: submitComposerInput(workspaceID, terminalID, capturedText)
    Store->>Host: await terminal.paste(text)
    Host-->>Store: ack
    Store->>Store: reconcileComposerDraftAfterSend()
    Store->>Store: "isSubmittingComposer = false"
    Store-->>View: return
    View->>View: thumbnailCache.retain(remaining ids)
    View->>View: requestHeightRemeasure()
Loading

Reviews (8): Last reviewed commit: "iOS composer: abort submit on session/co..." | Re-trigger Greptile

Comment thread ios/Config/Info.plist
Comment on lines +58 to +59
<key>NSPhotoLibraryUsageDescription</key>
<string>Attach photos to send to your terminal agent.</string>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing NSPhotoLibraryUsageDescription entry in InfoPlist.xcstrings

InfoPlist.xcstrings already holds NSCameraUsageDescription and NSLocalNetworkUsageDescription each with en + ja entries — that file is the authoritative source for localized Info.plist strings. Adding NSPhotoLibraryUsageDescription only to Info.plist as a hardcoded English string means iOS will always show English to Japanese users instead of pulling the catalog translation. The fix is to add this key (with both locales) to InfoPlist.xcstrings instead; the raw Info.plist entry should then also be removed, since the catalog value takes precedence when both exist.

Rule Used: Flag production user-facing text that is not fully... (source)

Comment on lines +2795 to +2810
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()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 submitComposer() has no re-entrancy guard

submitComposerInput() uses isSubmittingComposerInput specifically to prevent a double-tap from sending the same text twice. submitComposer() has no equivalent guard. If the user double-taps Send while images are still being delivered (between await submitTerminalPasteImage calls), a second invocation captures the same attachments snapshot before clearPendingAttachments runs and sends every image a second time. The view's send() function creates a new Task { @MainActor in ... } each call and only checks canSend—which is still true until the clear finishes—so both tasks proceed. Extending the isSubmittingComposerInput flag (or an equivalent dedicated flag) to gate the full submitComposer() body would close this window.

Comment on lines +330 to +336
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 pngData() called twice in the over-size fallback path

When PNG exceeds the size cap and JPEG encoding also fails (rare but possible), image.pngData() is called again and the oversized PNG is staged anyway. This means the expensive PNG render runs twice in the worst case—first for the size check, then for the fallback—doubling the main-actor block time. Cache the first result to avoid the redundant encode.

Suggested change
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)
}
let png = image.pngData()
if let png, 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 {
store.addPendingAttachment(png, format: "png", forTerminalID: terminalID)
}

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ios/Config/Info.plist`:
- Around line 58-59: Add the NSPhotoLibraryUsageDescription localization entry
to ios/cmux/Resources/InfoPlist.xcstrings to match the permission description
added to ios/Config/Info.plist. Follow the existing pattern used for
NSCameraUsageDescription, creating an entry with the key
NSPhotoLibraryUsageDescription that includes both English and Japanese
localizations. The English value should be "Attach photos to send to your
terminal agent." and you must provide the appropriate Japanese translation for
the "ja" localization. Ensure the entry uses the same structure with
extractionState set to "manual" and state set to "translated" for both language
variants.

In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 2795-2810: The submitComposer() method captures
submittedTerminalID but then calls submitTerminalPasteImage(...) and
submitComposerInput() without passing this captured value, causing them to
resolve selectedTerminalID at execution time instead. If the user changes the
selected terminal during the awaits, the attachments and text will be sent to
the wrong terminal. Fix this by modifying both submitTerminalPasteImage(...) and
submitComposerInput() to accept the captured submittedTerminalID as a parameter
and use it directly, ensuring messages are always sent to the terminal that was
selected when the user clicked submit.
- Around line 313-317: The pendingAttachmentsByTerminalID dictionary stores
unsent user content but is not cleared during sign out in the signOut() method,
which can leak one user's staged images to the next session on a shared device.
Add a line in the signOut() method to clear this dictionary by setting it to an
empty dictionary, ensuring all pending attachments are removed when the user
signs out.
- Around line 2801-2807: The clearPendingAttachments call at line 2806 executes
unconditionally after submitTerminalPasteImage, which is fire-and-forget with a
Void return type. This means attachments are cleared even if the image RPC
fails, losing user data with no retry path. Modify submitTerminalPasteImage to
return a Result type or Bool indicating success/failure, or wrap the attachment
clearing logic with error handling so that clearPendingAttachments is only
called after confirming that all image sends completed successfully. If any
attachment fails to send, retain it in the staged set so the user can retry.

In
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift`:
- Around line 28-123: Add three new edge case test methods to the
ComposerPendingAttachmentTests class: First, add a test that calls
removePendingAttachment with a UUID that does not correspond to any attachment
and verify it is a no-op without crashing. Second, add a test that calls
clearPendingAttachments with a terminal ID that does not exist and verify it is
a no-op. Third, add a test that sets both terminalInputText to a non-empty value
and adds a pending attachment, then calls composerCanSend to verify it returns
true, explicitly testing the combined OR logic end-to-end. Each test should
follow the same setup pattern as the existing tests using Self.makeComposite()
and should verify the expected behavior using `#expect` assertions.

In
`@Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift`:
- Around line 325-341: The stagePickedItems function currently runs all photo
loading and transcoding operations on the main actor, which blocks the UI thread
while decoding and re-encoding large photos. Remove the `@MainActor` annotation
from the Task so the item.loadTransferable, UIImage initialization, and
pngData/jpegData calls execute off-main. Then wrap only the
store.addPendingAttachment, pickerSelection assignment, and
requestHeightRemeasure calls in a separate `@MainActor-isolated` block to ensure
those UI-related operations run on the main thread.
- Around line 267-272: The photosPicker configuration sets maxSelectionCount to
nil, allowing unlimited image selection in a single batch, and the code does not
validate the encoded byte size before calling addPendingAttachment, which can
cause memory spikes and stage payloads exceeding the transport limit. Set a
reasonable maxSelectionCount value in the photosPicker call (around lines
267-272), and add size validation logic in the image encoding/attachment staging
flow (also around lines 330-335) to check the encoded attachment size against
the transport limit before adding it as a pending attachment; reject or
downscale any attachment that exceeds this limit to prevent oversized payloads
from being staged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2c1fce16-a566-4172-b80c-0ed831df7e51

📥 Commits

Reviewing files that changed from the base of the PR and between 8bb9b69 and 20a1876.

📒 Files selected for processing (6)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift
  • Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePendingAttachment.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
  • ios/Config/Info.plist
  • ios/cmux/Resources/Localizable.xcstrings

Comment thread ios/Config/Info.plist
Comment thread Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Outdated
Comment on lines +28 to +123
@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)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Optional: Consider adding edge case tests for defensive coverage.

The current test suite has solid coverage of the core behavior. For even more defensive coverage, consider adding:

  1. Remove with non-existent ID: Verify that removePendingAttachment(id: UUID(), forTerminalID: "term-a") is a no-op and doesn't crash.
  2. Clear with non-existent terminal: Verify that clearPendingAttachments(forTerminalID: "non-existent") is a no-op.
  3. Send gating with both text AND attachment: Explicitly test composerCanSend returns true when both conditions are satisfied (to verify the OR logic end-to-end, though the individual branches are already covered).

These are nice-to-haves; the current coverage is comprehensive for the feature scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift`
around lines 28 - 123, Add three new edge case test methods to the
ComposerPendingAttachmentTests class: First, add a test that calls
removePendingAttachment with a UUID that does not correspond to any attachment
and verify it is a no-op without crashing. Second, add a test that calls
clearPendingAttachments with a terminal ID that does not exist and verify it is
a no-op. Third, add a test that sets both terminalInputText to a non-empty value
and adds a pending attachment, then calls composerCanSend to verify it returns
true, explicitly testing the combined OR logic end-to-end. Each test should
follow the same setup pattern as the existing tests using Self.makeComposite()
and should verify the expected behavior using `#expect` assertions.

The touched file is already well over any reasonable size; splitting it is a
separate refactor and stored properties cannot move to an extension. Accept the
incremental growth as known debt so the budget guard reflects reality.
Three P1 autoreview fixes for the iOS composer image attachments:

1. Capture target terminal once in submitComposer. The image and text
   sends now thread an explicit workspace + terminal id captured at submit
   time, so a terminal switch while an awaited image send is in flight can
   no longer reroute later images or the text to whatever is selected at
   that moment. Adds internal targeted variants:
   submitTerminalPasteImage(_:format:workspaceID:terminalID:) and
   submitComposerInput(workspaceID:terminalID:); the public selection-based
   overloads call them, preserving the clipboard-paste path.

2. Keep attachments on a failed send. The image send path now returns Bool
   (threaded through sendRemoteTerminalPasteImage). submitComposer removes
   each attachment only after its send is acknowledged; on a failure it
   stops, keeps the remaining and failed attachments staged, and does not
   submit the text, matching the text-keep-on-failure semantics.

3. Bound staging and stop per-render decodes. The picker is capped at 10
   and a 32 MB total byte budget is enforced when staging. Each attachment
   gets a small downsampled thumbnail built once off the main thread (via
   ImageIO) and cached by id; the chip renders the cached thumbnail instead
   of decoding the full Data in the view body on every keystroke.

Tests: ComposerPendingAttachmentTests updated for keep-on-failure; new
ComposerSubmitRoutingTests drives submitComposer end to end over the real
paste RPC frames (captured-terminal routing, mid-send switch, full and
partial image-send failures). Budget TSV refreshed for the two grown files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Addressed the three P1 autoreview findings (commit 4ba9f3f):

1. Wrong-terminal routing after a mid-send switch. submitComposer() now captures the target workspace + terminal id once and threads them through both the image sends and the text send, so a terminal switch while an awaited image send is in flight can no longer reroute later images or the text. New internal targeted variants submitTerminalPasteImage(_:format:workspaceID:terminalID:) and submitComposerInput(workspaceID:terminalID:); the public selection-based overloads delegate to them, preserving the clipboard-paste path.

2. Failed sends no longer lose photos. The image send path returns Bool (threaded through sendRemoteTerminalPasteImage). submitComposer() removes each attachment only after its send is acknowledged; on a failure it stops, keeps the remaining and failed attachments staged, and does not submit the text, matching the existing text-keep-on-failure semantics.

3. Bounded staging, no per-render full-image decode. Picker capped at maxSelectionCount: 10 plus a 32 MB total byte budget enforced at stage time. Each attachment gets a small downsampled thumbnail built once off the main thread (ImageIO CGImageSourceCreateThumbnailAtIndex) and cached by id in a side cache; the chip renders the cached thumbnail instead of decoding the full Data in the view body on every keystroke.

Final API shape (image/text send):

  • submitTerminalPasteImage(_:format:) async -> Bool (selection) -> submitTerminalPasteImage(_:format:workspaceID:terminalID:) async -> Bool (captured) -> sendRemoteTerminalPasteImage(...) async -> Bool
  • submitComposerInput() async (selection) -> submitComposerInput(workspaceID:terminalID:) async -> Bool (captured) -> sendRemoteTerminalPaste(...) async -> Bool
  • addPendingAttachment(...) now returns the new attachment's id so the UI can key the thumbnail cache.

Tests: ComposerPendingAttachmentTests updated (failed send keeps attachments). New ComposerSubmitRoutingTests drives submitComposer() end to end over the real terminal.paste / terminal.paste_image RPC frames: captured-terminal routing, mid-send switch does not reroute, full image-send rejection keeps everything, and a partial failure clears only the acknowledged attachment. All composer store tests pass on host. Swift file-length budget guard is green (TSV refreshed for the two grown files; the iOS-only view cannot be host-compiled, so CI ios-simulator / Xcode is the final compile gate).

Comment thread Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4ba9f3f9e2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
// Submit the text to the captured terminal (a no-op when empty, e.g. an
// images-only send). All images acked by here, so the text follows.
await submitComposerInput(workspaceID: workspaceID, terminalID: submittedTerminalID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Capture the submitted text before image awaits

When a send includes attachments, the text is not captured at tap time; after each awaited image upload this calls submitComposerInput, which re-reads the live terminalInputText. If the user starts typing the next message while a large photo is still uploading, that newer draft is sent with the old images and can then be cleared by the post-ack reconciliation. Capture the text before the image loop and pass that snapshot to the text-send path.

Useful? React with 👍 / 👎.

Comment on lines +411 to +412
if let jpeg = image.jpegData(compressionQuality: 0.8) {
return (jpeg, "jpg")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the Mac image size cap on JPEG fallback

For picked photos whose PNG exceeds the cap, this returns the 0.8-quality JPEG without checking its size, even though the Mac-side saveImageData rejects payloads over the 10 MB image limit. A high-resolution photo whose JPEG is still larger than that limit can be staged under the 32 MB total budget, but every send attempt will fail and leave the attachment stuck until the user removes it; keep compressing or skip the pick when the encoded bytes are still too large.

Useful? React with 👍 / 👎.

# Conflicts:
#	.github/swift-file-length-budget.tsv

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 2836-2861: The submitComposer() function is vulnerable to
concurrent calls that can both snapshot and send the same staged attachments
before either removes them after acknowledgment, causing duplicate image sends
on double-tap. Add a new stored property isSubmittingComposer (similar to the
existing isSubmittingComposerInput flag) alongside other composer submit state,
then guard the entire submitComposer() function with this flag at the beginning
to prevent concurrent execution. This prevents both attachment snapshots and
sends from being duplicated, not just the text input portion.
- Around line 2787-2795: The submitComposerInput method reads the live
terminalInputText field, but since submitComposer awaits image uploads first,
the user could edit the input or switch terminals during that await, causing the
wrong text to be submitted. Modify the submitComposerInput method signature to
accept a submittedText parameter instead of reading terminalInputText directly
from the live field. Then in the caller (submitComposer method), capture the
text value by snapshotting terminalInputText before any awaits on image uploads
occur, and pass this captured text as the submittedText argument when calling
submitComposerInput.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6886c6f3-bb46-44c8-a38c-42f3a0619a7b

📥 Commits

Reviewing files that changed from the base of the PR and between 20a1876 and 4ba9f3f.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (5)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTests.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f9b9f284e9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +303 to +304
ForEach(pendingAttachments) { attachment in
AttachmentChip(thumbnail: thumbnailCache.image(for: attachment.id)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebuild thumbnails when revisiting staged attachments

When a user stages a photo, switches terminals, and then returns, WorkspaceDetailView keys the terminal surface by terminalID, so this composer view and its @State AttachmentThumbnailCache are recreated empty while the store still restores the per-terminal pendingAttachments. Every retained chip then gets nil here and renders only the generic placeholder, leaving the user unable to identify which photo will be sent; rebuild the cache from the stored attachment data on mount or keep the thumbnail with the attachment state.

Useful? React with 👍 / 👎.

1. Swift 6 Sendable: PreparedAttachment no longer carries a non-Sendable
   UIImage across the detached thumbnail-prep task. The off-main path keeps
   ImageIO downsampling but returns the thumbnail as PNG Data; the UIImage is
   built on the main actor when populating the chip cache.
2. Privacy: clear pendingAttachmentsByTerminalID in signOut(), alongside the
   text-draft wipe, so a previous account's staged photo bytes cannot resurface
   on a reused terminal id.
3. Re-entrancy: add isSubmittingComposer guard around the whole submitComposer()
   (images + text), so a double tap on Send cannot re-upload the still-staged
   attachments while the first RPC awaits. Failure still keeps attachments.
4. UI test: testComposerSurvivesRepeatedOpenCloseCycles closes the composer via
   the accessory compose toggle instead of the removed MobileComposerClose
   chevron; drop the now-unused bandClose constant.
5. Localize NSPhotoLibraryUsageDescription in InfoPlist.xcstrings (en + ja).

Add host tests for sign-out clearing (finding 2) and double-submit guard
(finding 3). Refresh the Swift file-length budget for the two touched files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Round-2 autoreview fixes (96dcff1):

  1. P1 Swift 6 Sendable: PreparedAttachment no longer stores a UIImage across the detached thumbnail-prep task. The off-main path keeps the ImageIO downsampling but hands back the thumbnail as PNG Data (Sendable); the UIImage is built on the main actor when populating the chip cache. No UIKit reference crosses the concurrency boundary.
  2. P1 Privacy: pendingAttachmentsByTerminalID is now cleared in signOut(), right beside the text-draft wipe, so a previous account's staged photo bytes cannot resurface on a reused terminal id. (Drafts have no per-terminal-disappear prune hook, so sign-out is the matching account-bound reset.)
  3. P2 Double-submit: added an isSubmittingComposer re-entrancy guard around the whole submitComposer() (images + text), set at entry and cleared in a defer. A double tap on Send no longer re-uploads the still-staged attachments while the first RPC awaits; failure still keeps attachments for retry.
  4. P2 UI test: testComposerSurvivesRepeatedOpenCloseCycles now closes the composer via the accessory compose toggle instead of the removed MobileComposerClose chevron; dropped the unused bandClose constant.
  5. P3 Localization: added NSPhotoLibraryUsageDescription (en + ja) to InfoPlist.xcstrings.

Tests: added host tests for sign-out clearing and the double-submit guard; swift test --filter "ComposerPendingAttachmentTests|ComposerSubmitRoutingTests" is 16/16 green. File-length budget guard is green (refreshed the two touched files).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 96dcff1f6a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
// Submit the text to the captured terminal (a no-op when empty, e.g. an
// images-only send). All images acked by here, so the text follows.
await submitComposerInput(workspaceID: workspaceID, terminalID: submittedTerminalID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Submit image-only composer sends

When the user taps Send with only attachments, the image RPCs only inject escaped file paths into the terminal (TerminalController.v2MobileTerminalPasteImage calls sendInputResult(escapedPath) and does not press Return), and this final text submit is a no-op because submitComposerInput returns success for empty text. The chips are then removed even though the agent prompt is left with unsubmitted paths, so an images-only composer send does not actually submit anything; send a submit key/Return after successful images when the text snapshot is empty.

Useful? React with 👍 / 👎.

/// Encode a picked image the way the clipboard paste path does: PNG when it
/// fits the per-image cap, otherwise JPEG, falling back to PNG.
private static func encode(_ image: UIImage) -> (data: Data, format: String)? {
if let png = image.pngData(), png.count <= maxImageBytes {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cap staged PNGs below the RPC frame limit

This accepts PNG attachments up to 8 MiB, but the mobile RPC request wraps the bytes as base64 inside JSON before MobileCoreRPCSession calls MobileSyncFrameCodec.encodeFrame, whose default frame limit is also 8 MiB. A selected PNG around 6.5–8 MiB passes this check, then expands past the frame limit and fails locally on every send attempt while remaining staged; use a lower limit based on the framed/base64 payload or compress before staging.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (1)

2769-2772: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope the text half of composerCanSend to the requested terminal.

terminalID scopes the attachment lookup, but terminalInputText is always the selected terminal’s draft. A caller checking a non-selected terminal can get Send enabled because another terminal has text.

Suggested fix
 public func composerCanSend(forTerminalID terminalID: String? = nil) -> Bool {
-    let textNonEmpty = !terminalInputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
-    return textNonEmpty || !pendingAttachments(forTerminalID: terminalID).isEmpty
+    let key = terminalID ?? selectedTerminalID?.rawValue
+    let textNonEmpty = key == selectedTerminalID?.rawValue
+        && !terminalInputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+    return textNonEmpty || !pendingAttachments(forTerminalID: key).isEmpty
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 2769 - 2772, The composerCanSend method accepts a terminalID
parameter to scope checks to a specific terminal, but the textNonEmpty check
uses terminalInputText which always refers to the selected terminal's draft,
causing incorrect Send enablement for non-selected terminals. Modify the method
to retrieve and check the input text for the specific terminal identified by the
terminalID parameter, rather than always checking the selected terminal's
terminalInputText. This ensures both the text and attachment checks are properly
scoped to the requested terminal.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 2769-2772: The composerCanSend method accepts a terminalID
parameter to scope checks to a specific terminal, but the textNonEmpty check
uses terminalInputText which always refers to the selected terminal's draft,
causing incorrect Send enablement for non-selected terminals. Modify the method
to retrieve and check the input text for the specific terminal identified by the
terminalID parameter, rather than always checking the selected terminal's
terminalInputText. This ensures both the text and attachment checks are properly
scoped to the requested terminal.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ecad115b-bfb0-451f-a56e-6d7fad1aa15b

📥 Commits

Reviewing files that changed from the base of the PR and between f9b9f28 and 96dcff1.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (6)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTests.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
  • ios/cmux/Resources/InfoPlist.xcstrings
  • ios/cmuxUITests/cmuxUITests.swift

Two async/privacy bugs from the codex autoreview:

1. submitComposer captured the target terminal but re-read the live
   terminalInputText after the awaited image paste_image RPCs. A terminal
   switch (draft swap) or a field edit during those awaits could skip the
   composed text or paste a different terminal's draft. Snapshot the text at
   the very start of submitComposer (before any await) and thread it through
   submitComposerInput via a new capturedText param; the post-send reconcile
   already keys on that same sent text, so a mid-send edit is preserved
   (cleared only when the field still equals the snapshot). Text-only entry
   points keep nil (no prior await, no drift). Images-only sends snapshot
   empty text and no-op the text submit.

2. The photo picker started an unstructured Task that awaited load+encode then
   unconditionally re-staged bytes via addPendingAttachment. A sign-out in
   flight cleared pending attachments but the continuation re-added the
   previous user's photo under an explicit terminal id. Add a signInGeneration
   token bumped by signOut, captured before staging, and re-checked in a new
   guarded addPendingAttachment(...ifSessionGeneration:) store path that drops
   the result when the token moved or the target terminal no longer exists.

Tests: ComposerSubmitRoutingTests gains text-snapshot-survives-edit and
text-snapshot-survives-switch; ComposerPendingAttachmentTests gains
guarded-add dropped-after-signout, dropped-when-terminal-gone, and
succeeds-when-unchanged. Budget actuals refreshed for both touched files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Round-3 autoreview fixes (7853a83):

Finding 1 (text not captured): submitComposer() now snapshots terminalInputText before any await and threads it through submitComposerInput(...capturedText:). The text send and the post-send draft reconcile both key on that snapshot, so a terminal switch or field edit during the image paste_image awaits can no longer skip the composed text or paste a different terminal's draft. A mid-send edit is preserved (the field clears only when it still equals the sent snapshot). Text-only paths pass nil (no prior await, no drift); images-only sends snapshot empty text and no-op the text submit.

Finding 2 (re-stage after sign-out): Added a signInGeneration token bumped in signOut(). The picker captures it before its off-main load+encode and the store re-checks it in a new addPendingAttachment(...ifSessionGeneration:) path that drops the result if the token moved (sign-out) or the target terminal no longer exists. The recheck lives in the store mutation path so it holds even if the picker view is gone.

Tests (ComposerSubmitRoutingTests | ComposerPendingAttachmentTests): 21 pass, including 5 new ones covering snapshot-survives-edit, snapshot-survives-switch, guarded-add dropped-after-signout / dropped-when-terminal-gone / succeeds-when-unchanged. File-length budget green (actuals refreshed for both touched files).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (1)

321-334: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Don’t grow the 5k-line composite with another store responsibility.

MobileShellComposite.swift is already 5386 lines and mixes connection lifecycle, workspace/terminal state, drafts, transport, recovery, and UI-facing composer state. Please move the attachment queue/session-generation state and CRUD/send orchestration behind a small composer/attachment collaborator, with this facade delegating to it. As per coding guidelines, production Swift files over 800 lines must be flagged, especially when mixed responsibilities keep expanding.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 321 - 334, The MobileShellComposite class is exceeding recommended
size and mixing too many responsibilities. Extract the attachment-related state
(pendingAttachmentsByTerminalID and signInGeneration) and their associated
CRUD/send orchestration logic into a separate small collaborator class dedicated
to composer and attachment concerns. Have MobileShellComposite delegate
attachment operations to this new facade rather than managing them directly,
keeping the main composite focused on connection lifecycle, workspace/terminal
state, and core transport concerns.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 2784-2786: The nested workspaces.contains and terminals.contains
check is being executed repeatedly for each staged photo in the batch operation
called by TerminalComposerView.stagePickedItems, causing unnecessary topology
scans. Instead of validating the terminalID existence on each item individually,
maintain a terminal-id index in the store or perform the terminal validation
once before processing the entire batch of attachments, then proceed with
appending them without repeating the contains checks for each item.

---

Outside diff comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 321-334: The MobileShellComposite class is exceeding recommended
size and mixing too many responsibilities. Extract the attachment-related state
(pendingAttachmentsByTerminalID and signInGeneration) and their associated
CRUD/send orchestration logic into a separate small collaborator class dedicated
to composer and attachment concerns. Have MobileShellComposite delegate
attachment operations to this new facade rather than managing them directly,
keeping the main composite focused on connection lifecycle, workspace/terminal
state, and core transport concerns.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 18df0b6e-e3a8-4848-be85-ee6357b7f946

📥 Commits

Reviewing files that changed from the base of the PR and between 96dcff1 and 7853a83.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (4)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTests.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift

Comment on lines +2784 to +2786
if let terminalID,
!workspaces.contains(where: { $0.terminals.contains(where: { $0.id.rawValue == terminalID }) }) {
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Avoid rescanning every workspace/terminal per staged photo.

TerminalComposerView.stagePickedItems calls this guarded add once per picked image, so this nested workspaces.contains { terminals.contains { ... } } repeats a full topology scan up to the picker cap. Keep a terminal-id index in the store, or validate the target once for the whole batch before appending attachments. As per coding guidelines, attachment staging/picker paths should not repeatedly rescan scalable workspace/terminal collections.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 2784 - 2786, The nested workspaces.contains and terminals.contains
check is being executed repeatedly for each staged photo in the batch operation
called by TerminalComposerView.stagePickedItems, causing unnecessary topology
scans. Instead of validating the terminalID existence on each item individually,
maintain a terminal-id index in the store or perform the terminal validation
once before processing the entire batch of attachments, then proceed with
appending them without repeating the contains checks for each item.

Source: Coding guidelines

Fix three autoreview findings in the iOS composer image-attachment
staging and send path.

Finding 1 (OOM): replace the full-raster pngData() encode with a bounded
ImageIO path. prepare() now downsamples the picked item via
CGImageSourceCreateThumbnailAtIndex (send payload capped at 2048 px
longest edge, thumbnail at 168 px) and re-encodes the bounded CGImage
through CGImageDestination, so a large HEIC/JPEG/panorama is never
materialized as a full-resolution raster. The send payload tries PNG at
the send size, then JPEG at decreasing quality, then smaller dimensions,
all enforced under the 8 MB per-image cap. Returns Data only across the
concurrency boundary (Sendable-safe).

Finding 2 (caps bypass): enforce the count cap (10), total-byte budget
(32 MB), and per-image cap (8 MB) atomically inside the store's
addPendingAttachment, computed against the current staged set at mutation
time on the MainActor. Two racing picker batches can no longer both append
past the cap. The view now tracks the staging Task and cancels the prior
one when a new batch starts, and treats the store as the authoritative cap
(its own checks are only a pre-filter). Sign-in generation guard kept.

Finding 3 (removed-but-uploaded): submitComposer re-checks each attachment
is still staged for the captured terminal before uploading it, so a chip
the user deletes mid-send is skipped instead of uploaded from the local
snapshot. Failure-keeps-attachments and re-entrancy behavior unchanged.

Tests: store-enforced count and byte caps (including racing adds against
the same starting budget) and a mid-send removal skipping that attachment.
Budget TSV bumped for the two touched files.
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Round-3 hardening (autoreview findings):

  1. Full-size PNG OOM — TerminalComposerView.prepare no longer calls pngData() on a full-res UIImage. It now downsamples the picked item via CGImageSourceCreateThumbnailAtIndex (send payload capped at 2048 px longest edge, thumbnail at 168 px) and re-encodes the bounded CGImage through CGImageDestination. The send payload tries PNG at the send size, then JPEG at decreasing quality, then smaller dimensions, all under the 8 MB per-image cap. A large HEIC/panorama is never materialized as a full raster. Returns Data only across the task boundary (Sendable-safe).

  2. Concurrent picker batches bypass caps — the count (10), total-byte (32 MB), and per-image (8 MB) caps are now enforced atomically inside MobileShellComposite.addPendingAttachment, computed against the current staged set at mutation time on the @MainActor. Two racing batches can no longer both append past the cap. The view tracks the staging Task and cancels the prior one on a new batch; it treats the store as the authoritative cap (its own checks are a pre-filter only). Sign-in generation guard kept.

  3. Removed attachment still uploaded mid-send — submitComposer re-checks each attachment is still present in pendingAttachmentsByTerminalID for the captured terminal before uploading, so a chip deleted mid-send is skipped. Failure-keeps-attachments and re-entrancy behavior unchanged.

Tests: added store-enforced count/byte cap coverage (including racing adds against the same starting budget) in ComposerPendingAttachmentTests and a mid-send-removal-skips-upload test in ComposerSubmitRoutingTests. swift test --filter "ComposerPendingAttachmentTests|ComposerSubmitRoutingTests": 28/28 pass. File-length budget green.

Comment thread Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7e60e7a9e5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/// 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]] = [:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prune attachments when terminals disappear

When a workspace or terminal disappears after a remote workspace-list refresh/close, nothing removes its entry from this new per-terminal map (the workspaces setter only bumps workspaceTopologyVersion). That leaves up to 32 MiB of Data per closed terminal retained but no longer reachable by any composer chip or submit path, and because the cap is per terminal a user can repeatedly stage photos and close workspaces to grow memory unbounded until sign-out. Prune pendingAttachmentsByTerminalID against the current terminal IDs whenever workspace topology is replaced or merged.

Useful? React with 👍 / 👎.

…ker load

Finding 1: the store could retain orphaned photo Data for stale terminal ids.
The base addPendingAttachment now validates the target id exists in the current
topology (shared terminalExistsInTopology check, used by both add paths so no
unchecked entry point remains), and the workspaces didSet prunes
pendingAttachmentsByTerminalID for any terminal that disappears from topology so
multi-MB staged bytes are released on a sync rather than held until sign-out.
Sign-out clear is unchanged. Added tests: base-path add for a missing id is
rejected; a topology update dropping a terminal prunes its attachments while a
surviving (and a moved-elsewhere) terminal keeps its.

Finding 2: the picker loaded the full original asset into memory before any cap.
Replaced loadTransferable(Data) with a file-backed ImportedImageFile Transferable
(FileRepresentation(contentType: .image)) that copies the import to a temp URL,
so a huge ProRAW/DNG/panorama never enters memory as Data. The picker now
size-gates the file on disk (60 MB raw bound, above the 8 MB per-image cap since
HEIC decodes larger) before reading, downsamples straight from the URL via
CGImageSourceCreateWithURL, and deletes the temp file after encoding. Per-image
and total caps, atomic store enforcement, the session-generation guard, and the
staging Task cancellation are all preserved. Only Data/String/URL cross
concurrency boundaries.

Refreshed the swift file-length budget for the two touched files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Addressed the two remaining memory-safety findings (commit 42847cf).

Finding 1 (orphaned bytes for stale terminal ids): The base addPendingAttachment now validates the target id is in the current topology via a shared terminalExistsInTopology check, so both add paths (base and session-guarded) funnel through one existence gate and there is no unchecked entry point. Added prunePendingAttachmentsForMissingTerminals(), called from the workspaces didSet (the single topology funnel: every sync/merge/replace goes through it), which drops dictionary entries whose terminal id is no longer present, releasing multi-MB staged bytes on a topology change instead of holding them until sign-out. Sign-out clear unchanged. New host tests: base-path add for a missing id is rejected; a topology update that drops a terminal prunes its attachments while a surviving terminal (and one moved to another workspace) keeps its.

Finding 2 (picker slurped full original asset): Replaced loadTransferable(Data.self) with a file-backed ImportedImageFile Transferable (FileRepresentation(contentType: .image) copying the import to a temp URL), so a huge ProRAW/DNG/panorama never enters memory as Data. The picker size-gates the file on disk (60 MB raw bound, above the 8 MB per-image cap since HEIC decodes larger) before reading any bytes, downsamples directly from the URL via CGImageSourceCreateWithURL (full-res raster never materialized), and deletes the temp file after encoding. Per-image/total caps, atomic store enforcement, the session-generation guard, and staging Task cancellation are all preserved; only Data/String/URL cross concurrency boundaries.

Tests: ComposerPendingAttachmentTests|ComposerSubmitRoutingTests -> 31/31 pass. File-length budget respected (refreshed actuals for the two touched files).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 42847cf998

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +3015 to +3019
let sent = await submitTerminalPasteImage(
attachment.data,
format: attachment.format,
workspaceID: workspaceID,
terminalID: submittedTerminalID

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Insert separators between pasted image paths and captions

When a composer submit includes two attachments, or an attachment plus caption that does not start with whitespace, each successful terminal.paste_image is followed immediately by the next image/text paste. The Mac handler (checked Sources/TerminalController.swift:14329) injects only the shell-escaped path with no trailing delimiter, so the terminal receives /tmp/a.png/tmp/b.png or /tmp/a.pngplease...; agents won't parse the intended file path. Add a boundary space/newline after each image before continuing.

Useful? React with 👍 / 👎.

Finding 1: pending attachment bytes were capped per terminal but unbounded
across all terminals, so staging photos across many terminals/workspaces grew
linearly with terminal count and could OOM. Add global all-terminals caps
(maxPendingAttachmentTotalBytesAllTerminals = 64 MB,
maxPendingAttachmentCountAllTerminals = 20) enforced atomically in
addPendingAttachment after the per-terminal checks, as a hard reject. The sum
across all keys is consistent because the add runs on @mainactor. Per-terminal
caps stay. Host tests cover the multi-terminal case (global byte and count
budgets bind while each terminal is under its own cap; per-terminal cap still
binds under global headroom).

Finding 2: the picker's ImageIO decode/encode ran in an unstructured
Task.detached that did not inherit cancellation, so cancelling the staging task
(re-pick, terminal switch, view disappear) left the decode running and fanning
out temp files. Run prepare() in a structured background-priority child task
group so cancellation propagates; check Task.isCancelled before launching and
before the heavy CGImageSourceCreateWithURL and the thumbnail encode. Cancel the
staging task on composer .onDisappear and on a terminalID change, in addition to
the existing cancel-on-new-batch. Temp files are still cleaned on the
cancellation path via the existing per-iteration defer. Only Sendable Data
crosses concurrency boundaries.

Bump swift-file-length-budget.tsv for the two touched files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Addressed the two remaining autoreview findings (commit a8461d0).

Finding 1 (global byte cap): added maxPendingAttachmentTotalBytesAllTerminals (64 MB) and maxPendingAttachmentCountAllTerminals (20), enforced atomically in addPendingAttachment after the per-terminal checks as a hard reject. The sum across all terminal keys is consistent because the add path runs on @mainactor. Per-terminal caps remain. New host tests cover the multi-terminal case: global byte and count budgets bind while each terminal stays under its own cap, and the per-terminal cap still binds under global headroom. swift test --filter "ComposerPendingAttachmentTests|ComposerSubmitRoutingTests" is green (34 tests).

Finding 2 (cancelled picker batches): replaced the fire-and-forget Task.detached in prepare with a structured background-priority child task group so cancellation propagates from the staging task into the ImageIO decode. Added Task.isCancelled checks before launching, before the heavy CGImageSourceCreateWithURL, and before the thumbnail encode. The staging task is now cancelled on composer .onDisappear and on a terminalID change, in addition to the existing cancel-on-new-batch. Temp files are still cleaned on the cancellation path via the existing per-iteration defer. Only Sendable Data/URL cross concurrency boundaries; UIImage is still built on the main actor.

File-length budget guard green (bumped the two touched files to their new actuals).

# Conflicts:
#	.github/swift-file-length-budget.tsv
submitComposer() snapshots attachments and text up front but awaits a
paste_image RPC per image. sendRemoteTerminalPasteImage returns true even
when a superseded connection answered, so a sign-out, account switch, Mac
switch, or reconnect that landed during an image await let the loop keep
going: the next staged image and then the captured text were sent through
whatever remoteClient is now current, leaking the previous user's or
previous Mac's unsent content into a different session.

Capture signInGeneration (sign-out / account switch) and connectionGeneration
(Mac switch / reconnect / disconnect) at the start of the run and re-check
both before every image send and before the text send via
isComposerSubmitIdentityCurrent. On mismatch, abort the whole submit (stop
the loop, do not send the text) and leave attachments and text staged for a
retry. Both generations already existed and are bumped on those edges.

Add host-testable coverage in ComposerSubmitRoutingTests: a sign-out and a
connection swap between the first and second image send each abort the
submit so no further image and no text reach the new session, and the
connection-swap case keeps the unsent attachment and text staged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Fixed the P1 stale-session continuation finding in submitComposer().

The submit loop awaited a paste_image RPC per image, and sendRemoteTerminalPasteImage returns true even when a superseded connection answered, so a sign-out / account switch / Mac switch / reconnect that landed mid-flight let the loop send the next staged image and then the captured text through whatever remoteClient is now current, leaking the previous user's or previous Mac's unsent content into a different session.

Fix: capture a submit-time identity once at the start of the run and re-check it before every image send and before the text send via isComposerSubmitIdentityCurrent(signIn:connection:). Identity is signInGeneration (sign-out / account switch, bumped in signOut()) plus connectionGeneration (Mac switch / reconnect / disconnect, bumped wherever the remote client/transport is replaced). Both already existed. On mismatch the whole submit aborts (loop stops, text is not sent) and attachments and text stay staged for a retry; the abort itself clears nothing.

Tests (ComposerSubmitRoutingTests, over the real RPC wire): a sign-out and a connection swap each between the first and second image send abort the submit so no further image and no text reach the new session, and the connection-swap case asserts the unsent attachment and text stay staged. Verified red without the guard (text reaches the new session, staged content lost), green with it. 36 tests pass; file-length budget respected.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 02e276a. Configure here.

terminalID: submittedTerminalID
)
guard sent else { return }
removePendingAttachment(id: attachment.id, forTerminalID: submittedTerminalID.rawValue)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed chip still uploads image

Medium Severity

Removing a staged attachment chip only prevents uploads that have not started yet. If the user taps remove while that image’s paste_image RPC is already in flight, the send still completes and the Mac receives the image even though the chip is gone from the composer.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 02e276a. Configure here.

@lawrencecchen
lawrencecchen merged commit 50ae246 into main Jun 15, 2026
24 of 28 checks passed
lawrencecchen added a commit that referenced this pull request Jun 16, 2026
…6199)

The Release/archive build (Swift 6 language mode, strict isolation) failed to
compile TerminalComposerView: the nonisolated detached prepare() task calls the
image-encode helpers and reads the bounding constants, but boundedSendPayload /
downsampledImageData and the maxImageBytes / thumbnailMaxPixelSize / sendMaxPixelSize
constants inherited the View's @mainactor isolation, so they were not accessible
from the nonisolated context. Mark the pure ImageIO helpers and the constants they
read nonisolated (and the store's maxPendingAttachmentImageBytes they mirror). Pure
functions over CGImageSource/Data plus immutable Int constants, so nonisolated is
correct. Verified with a local Swift 6 simulator build (BUILD SUCCEEDED).

This unblocks the iOS Release archive / TestFlight build, which the timed-out
ios-simulator CI never confirmed before #6102 merged.

Co-authored-by: cmux-lawrence <cmux-lawrence@cmux-lawrences-Mac-mini.local>

This branch was successfully deployed

1 active deployment
Preview – cmux — 02e276a1 Deployed Jun 15, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant