Mobile: richer chat attachments (photos, video, files, audio, location)
Status summary#
Done: shipped through nine on-device feedback rounds, review bots handled, ready to merge (PR #2554). Follow-ups live in their own task files (codemode-tag-message-rendering; addFiles-by-reference eager upload was discussed in-PR).
- Done: attachment model + chips + remove dialog, + sheet (carousel, camera
tile, All photos/Files/Audio/Location rows), hold-to-record gesture machine
- record button (mic + circular video), mosaic bubbles, unit tests, two browser specs (mosaic, sheet)
- Missing: on-phone verification of camera/audio/location (needs new EAS dev client), audio recorder UI in the sheet (row currently picks audio files; the mic button records), locked-mode pause, map rendering for locations
Ask (verbatim gist)#
The "+" button in chats only selects photos. It should open a Telegram-style attachment sheet: a horizontally scrollable carousel of the last ~10 camera roll items (photos OR videos), whose first tile is a live camera feed with a box-camera icon. Below the carousel, a row of other sendable stuff:
- All photos (native photo picker)
- Files (pdfs etc.)
- Audio recordings
- Location (attach as html/xml in the message; maybe a map renderer later)
Also: when there's no text, the disabled send button becomes a microphone for hold-to-record voice clips. Tapping it once switches it to a box-camera icon (with a transient tooltip) for recording video instead. Hold-to-record follows Telegram: release to finish, slide left to cancel, slide up to lock into hands-free recording mode (with stop/cancel controls).
Attachments appear as thumbnails above the input — never auto-sent. Tapping a thumbnail asks "Remove attachment?" OK|Cancel.
Attachments send as xml/html parts pointing at the thing; audio stays local and uploads lazily.
Decisions & assumptions (made while Misha was AFK)#
- Scope: the chat composer (
src/app/project/[projectId]/chat.tsx). The note composer keeps its current strip/+ but shares the new lib code where free. - Telegram screenshots arrived mid-flight (a follow-up message). Details to mimic: tooltip text "Hold to record video. Tap to switch to audio." (mirrored for the other direction); while holding — red dot + timer + "‹ Slide to cancel" bar, lock icon with chevron above the button; locked mode — bar becomes red dot + timer + a Cancel text button, and the button becomes send/stop. Video records into a circular viewport overlaid on the screen with camera-flip + flash controls. Telegram's locked-mode pause control is a follow-up, not v1.
- Release-to-finish attaches, doesn't send. Telegram sends on release; Misha's blanket rule here is "don't automatically send", so a finished recording becomes an attachment chip like everything else.
- Transport follows the platform grain (per push-back memory): anything
with bytes (photo, video, file, audio) goes through the existing
agent.addFileslane — one event, signed URLs, web + LLM rendering for free. "Lazily" = bytes are read/uploaded at send time, not at attach time (attach just holds a local uri). Only location — which has no bytes — is sent as an inline XML part appended to the message text:<user-location latitude=".." longitude=".." accuracy-meters=".." captured-at=".."/>. If Misha really wants file-attachments-as-xml-parts instead of addFiles, that's a follow-up conversation — the addFiles lane is strictly more capable today. - New native modules (
expo-camera,expo-audio,expo-location,expo-document-picker) mean a new dev-client build (EAS cloud; no local Xcode needed). Until then the new UI degrades: modules are loaded lazily and missing ones hide their tiles/rows (README precedent: the camera-roll strip is "simply absent" on older clients). - Caps: video records at 720p max 60s; library videos > ~32MB are refused with a friendly alert (websocket frame sanity). Audio records AAC/m4a, no hard cap (voice clips are small).
- Map renderer: skipped for v1 (follow-up below). Location renders as its XML → the agent sees coordinates; mobile/web render is the raw text for now.
- Web build (where browser specs run): camera/audio/location tiles hide;
the carousel uses the existing
__ITERATE_WEB_PHOTO_LIBRARY__boundary so specs can still drive attach flows.
Checklist#
-
ComposerAttachmentunion (photo | video | file | audio | location) in a newlib/composer-attachments.ts, replacingPickedImage[]state in chat; bytes read lazily at send - Attachment chips above input: thumbnail per kind (image preview, video preview + ▶, 📎 name, 🎤 duration, 📍); tap → "Remove attachment?" OK|Cancel
- "+" opens attachment sheet (not the picker directly)
- Sheet carousel: last ~10 camera-roll photos+videos, horizontal; extends
recent-photoslib to videos - Carousel tile 1: live
expo-camerapreview with box-camera icon → tap opens full-screen capture (photo snap + video record) - Sheet rows: All photos (picker with videos enabled), Files (document picker), Audio (recorder modal), Location (one-tap attach)
- Mic button replaces dimmed ↑ when composer is empty; tap toggles mic ↔ box-camera with transient tooltip
- Hold-to-record gesture: pure state machine in
lib/record-gesture.ts(+ unit tests) — hold records, release finishes→attach, slide left cancels, slide up locks - Recording UI: red dot + elapsed timer + "slide to cancel"; locked mode with Cancel / stop button
- Video record mode: front camera, Telegram-style circular viewport while recording
- Location XML part composing + send-path merge into message text
-
addFilessend path handles the new kinds (contentType/filename per kind); video/file size guard - app.json: camera/mic/location/photo permission strings + plugins
- Graceful degradation when native module missing (old dev client, web)
- Masonry/mosaic layout for multi-photo MESSAGE BUBBLES (scope added
mid-flight): today multiple photos stack full-width on top of each
other; instead lay them out Telegram-style. Pure justified-rows
algorithm (flickr/justified-layout-inspired, attributed) in
lib/mosaic-layout.ts+ unit tests; bubble component uses cached image sizes and falls back to squares while they load - Unit tests: gesture machine, attachment model, location xml, size guard
- typecheck + lint + knip + format + test green
Follow-ups (explicitly out of scope)#
- Map renderer for location parts + audio/video players on the WEB dashboard (mobile has them now)
- Note composer adopting the same sheet
- True background/lazy upload with retry (upload-at-send is v1)
Implementation log#
- Pure cores first, all unit-tested:
lib/composer-attachments.ts(model, lazy uploads, location XML),lib/record-gesture.ts(hold/slide/lock machine),lib/mosaic-layout.ts(justified rows, flickr/justified-layout-inspired). lib/native-modules.tsguards requires of expo-camera/audio/location/ document-picker/file-system so old dev clients hide features instead of crashing (native-markdown precedent).- Components:
attachment-chips.tsx(remove dialog; window.confirm on web),attachment-sheet.tsx(carousel + rows),camera-capture.tsx(full-screen photo/video),record-controls.tsx(mic/video hold-to-record; expo-audio hook used via a component only mounted when the module loads). - Audio row = document picker filtered to audio/* (recording lives on the mic button); revisit if a dedicated recorder modal is wanted there.
- Timer displays use refetchInterval queries, not effect hooks.
- The chips flow + carousel toggle + mosaic verified end-to-end by browser
specs
specs/mobile/chat-attachment-sheet.spec.tsand the updatedspecs/mobile/chat-photos.spec.ts(mosaic assertions; solo-photo message keeps the blurred-backdrop coverage). - Feedback round 1 (Misha, in-chat): sheet moved ABOVE the input row so the
composer never shifts; action rows became a horizontal Telegram-style
icon bar; the "Camera is not ready" crash on mic→video switch fixed by
pre-warming an invisible CameraView the moment video mode is armed
(recordAsync only ever runs after onCameraReady); photo/video pixel
dimensions now ride the message as
<attachment filename w h />XML parts so mosaics lay out exactly right on first paint (parts are stripped from the visible caption);<user-location/>renders as a real map card — OSM raster tiles stitched by pure Web Mercator math (lib/location-map.ts, no native module, no API key), pin overlay, tap → Apple Maps | Google Maps chooser. - Feedback round 2: inline PLAYERS. Audio attachments (m4a/mp3/wav) render a play/pause + scrubbable waveform row (deterministic bars — lib/waveform.ts; expo-audio playback; fixed height, full media width) with the length underneath. Videos join the photo mosaic as first-frame thumbnails (expo-video-thumbnails) with a play badge → full-screen expo-video player. Tapping photos now opens the existing MediaViewer (pinch/zoom/swipe-dismiss) on the cached uri — instant — instead of the slow in-app browser page. Two more native modules: expo-video, expo-video-thumbnails (same new-build boat).
- Feedback round 3 (on-device testing): ripped out ALL old-client degradation (guarded loaders, availability checks, fallback branches) — the fingerprint runtime policy already keeps this JS off old binaries, and every module ships a web implementation, so the guards protected nobody but an outdated Metro dev client (crash-and-rebuild is the repo's precedent). Waveform recolored to the neutral theme palette (tone-aware per bubble). ROOT CAUSE of dead m4a playback / missing duration / broken scrub / blank video thumbnails / black fullscreen video: the file-serving plane ignored HTTP Range headers, which iOS AVPlayer requires — fixed server-side (parseRangeHeader + 206/416 in serveProjectFileRequest, unit tested + live 206 asserted in the chat-photos spec). Image viewer opens with animationType none (no more black-fade flash). The 10MB-PDF "RPC session was shut down" was Cloudflare's ~1MiB websocket message cap: attachments over 512KB now ride as chunked ReadableStreams (capnweb multiplexes them with flow control; web-streams-polyfill fills Hermes's gap).
- Optimistic sends (feedback item a): tapping ↑ renders the predicted bubble immediately from phone-local data — the SAME MessageBubble the echo will render, fed local uris (previews, mosaic, players, location cards all work locally) — dimmed with "sending…" while the upload runs, hidden when its event offset echoes back over the live connection. Failures keep the bubble with Retry / Edit instead of dumping the draft back. Sends can queue (the button no longer locks while one is in flight). Eager upload (item b) stays a follow-up: needs an addFiles-by-reference platform seam.
- Feedback round 4: "sending…" under a pending bubble now renders in the
SAME WorkingCard box that replaces it — no layout jump. Voice notes record
as 16kHz mono LPCM WAV (transcription models' native diet; the AAC m4a
came back "no recognizable speech" from the platform transcriber) and
announce themselves with a
<voice-note filename duration-seconds />part — the agent's cue to transcribe, stripped from the visible caption along with the server's default "[Files attached: …]" note, so a voice-only message renders as just the player. On-device transcription (expo-speech-recognition) noted as a possible follow-up. - Feedback round 5: ON-DEVICE TRANSCRIPTION. expo-speech-recognition
(SFSpeechRecognizer) transcribes a recorded clip starting the moment the
recording lands; the send path waits up to 4s for a straggler and stamps
the result as
<voice-note transcript="…" />. Best-effort: permission refused / no speech / slow → attribute simply absent, and the agent can still run a model transcription. New native module → another build. - Feedback round 6 (UI niggles): the + sheet dismisses on any tap outside it (transparent backdrop over the conversation — drawer semantics); carousel now 50 items with ❤️ badges (per-asset info lookups, no-network); carousel tiles and pending-attachment thumbnails sit flush with a 1px background line between them; file attachments render as a media row matching the voice note's geometry (glyph / filename / size + type; page count would need PDF parsing — skipped); the All photos|Files|Audio|Location bar is centered. EAS plan upgraded, so this push's workflow run should produce the transcription build.
- Feedback round 7: chips render ABOVE the attachment sheet (chat and note composer), so attaching never shifts the sheet or input. The ambient note composer now uses the SAME AttachmentSheet + AttachmentChips as chat — full surface (camera tile, 50-item carousel, files, audio, location), destination /notes; locations fold into the note text as xml lines, byte attachments convert to inline base64 (pendingNoteAttachments) so the offline pending-note store keeps its never-lose-data guarantee with an UNCHANGED schema; the /media analysis double-append is now images-only. recent-photos-strip.tsx deleted (readRecentPhotos/RECENT_PHOTOS_LIMIT pruned with it); its spec now drives the shared sheet.
- Feedback round 8: chips grew to 84px flush tiles with a corner ✕
(removal, still behind the confirm dialog) — tapping the tile previews it
full screen via the SAME MediaViewer sent photos use (pinch/zoom,
swipe-down dismiss; markup tools can hang off it one day); videos preview
in the shared fullscreen player. The note composer's empty-state send slot
now shows the hold-to-record mic/video button (was a dimmed ↑), and a
spoken note's on-device transcript rides into the note text as a
line — for a note, the words are the point. - Feedback round 9: the note composer's ✕ (clashing with the chips' remove badges) became a drawer-style double-chevron tab perched above the sheet; successful on-device transcripts now render in italics under the audio player's waveform — collapsed to two lines, tap to expand/collapse (parseVoiceNoteTranscripts reads them back out of the message part).
- Gotcha found while running specs: wrapping
dev.tsin an outerdoppler runexports DOPPLER_PROJECT/DOPPLER_CONFIG, which the INNERdoppler run(apps/os scope) honors over doppler.yaml — the dev server then reads the wrong project's secrets and /api/health 500s. Runnode ./apps/os/scripts/dev.ts restart --detachbare.