Camera-roll strip on the note-capture composer
Status#
Done — three rounds, CI green on 39b123b. Round three fixed the device-caught agent-path bug; see the bottom. Misha ran round one on a real phone and sent four pieces of feedback; all four are in (see "Round two" at the bottom). Round one is below and unchanged.
Round one: done. The strip ships, the browser spec drives it end to end (tap →
attach → note on /notes carrying the photo), and every lane is green:
pnpm typecheck, lint, knip, format, pnpm --dir apps/mobile test,
pnpm spec --project=mobile (both the new spec and the existing notes one),
plus expo export --platform ios and an expo config --type introspect
showing the new NSPhotoLibraryUsageDescription.
The one thing left for a human: this needs a native rebuild to reach a phone (see "Cost" below). Nothing about that is broken — it is the documented path and CI triggers the preview build — but nobody has installed it yet.
What#
The global note-capture composer (NoteCaptureOverlay,
apps/mobile/src/components/note-composer.tsx — the docked sheet that is the
reason the app exists when you open it) currently offers exactly one way to
attach a photo: the + button, which opens the full-screen system picker.
That is two taps and a modal for the thing you most often want — the photo you
took thirty seconds ago.
Add a single-row, horizontally scrollable strip of the most recent camera
roll items directly above the text field. Tap a tile to attach it; tap again
to un-attach. The + stays where it is for the full-screen picker.
Decisions#
-
D1 — Tile size 100×100. As asked. One row,
horizontalScrollView,showsHorizontalScrollIndicator={false}. Total added height ≈ 108px including the gap. The strip only renders when there is something to show, so a composer with no library access looks exactly as it does today. -
D2 —
+stays in the composer row. It is the full-screen escape hatch and it already reads as one. The strip is purely additive; nothing moves. -
D3 — Never prompt for photo permission on composer open. The composer auto-expands on cold start and on foreground-after-a-while; a permission dialog on app open would be obnoxious. So: read permission with the non-prompting
getPermissionsAsync(). Granted (allorlimited) → load and show the strip. Otherwise → show ONE tile, "Recent photos / Allow", and the prompt fires on tap. Denied-after-asking → the strip disappears entirely (iOS will not re-prompt; the+picker still works because PHPicker needs no permission). -
D4 — 24 most recent, photos only. Newest first, all albums (not just screenshots — that is the sync engine's job). 24 is ~3 screens of sideways scroll at 100px; more is a scroll nobody does inside a composer.
-
D5 — Bytes are read at TAP time, not at send time. The tile shows a spinner while reading, so a tap always resolves to a real attachment thumbnail in the existing attachment strip (which already exists and already handles remove). Reading at send time would mean a send that can fail for a reason you cannot see.
-
D6 — Transcode to JPEG on read (
expo-image-manipulator). The+picker path gets this for free: PHPicker'spreferredAssetRepresentationMode: Compatibletranscodes HEIC camera photos to JPEG at pick time, which is whypickImagesproduces analyzable bytes. Reading aMediaLibraryasset gives you the original, which on a default iPhone ("High Efficiency") is HEIC — andunsupportedImageReason()exists precisely because the server'stoMarkdownhas no HEIC converter. So a strip that attached raw asset bytes would produce un-analyzable notes for most real camera photos.expo-image-manipulatorre-encodes to JPEG atcompress: 0.8— the same quality the picker uses — and also keeps 12MP originals from becoming 10MB websocket frames.This is a native dependency: existing builds will not receive this feature over the air. The fingerprint policy means CI notices the new fingerprint and triggers a preview build automatically; a dev client needs
pnpm --dir apps/mobile build:development:ios. See "Cost" below. -
D7 — Update the photo-permission string.
app.json'sphotosPermissioncurrently says "Iterate syncs the screenshots you choose into your project". The strip reads the whole roll to display it, so the string has to say so. This alone would bump the fingerprint even without D6. -
D8 — cap attachments at 4.Dropped while building it: the+path has no total cap today (each pick appends), so a cap enforced only on the strip would be an inconsistency the user feels rather than a limit that protects anything. Attached tiles do show a check, and tapping a checked tile removes it — the toggle is the whole interaction. -
D9 — Selection identity is the asset id, so the strip and the existing attachment strip stay in sync when you remove a thumbnail from either side. The
PickedImagegains an optionalassetIdfor exactly this.
Cost / what a reviewer should know#
Merging this makes the next mobile bundle require a new native build
(one new native module + an Info.plist string). That is the documented
normal path (apps/mobile/README.md "Dev ↔ preview"), and CI triggers the
preview build itself, but it means this feature is not instantly OTA-visible
on an installed app. Reverting D6+D7 would restore OTA delivery at the cost
of most camera photos being un-analyzable.
Checklist#
-
apps/mobile/src/lib/recent-photos-core.ts— pure tell me what this permission answer means —photoLibraryAccessFrom— plus the tile count. The strip-model derivation the spec imagined turned out to be one.some()call over the attachments, so it stayed inlined in the component where a reader can see it. -
apps/mobile/src/lib/recent-photos.ts— Expo-welded: non-prompting permission read, request-on-tap, recent-asset listing, and asset →PickedImage(JPEG transcode) -
— the web seam lives at the bottom ofrecent-photos.web.tsrecent-photos.tsinstead. Metro only substitutes a.web.tssibling for extensionless imports (resolveSourceFiletries the exact path before any platform variant), and this repo writes every relative import with its.tsextension — so a.web.tsfile would have been silently dead code. -
apps/mobile/src/components/recent-photos-strip.tsx— the row - Wire it into
note-composer.tsxabove the text field, sharing selection state with the existing attachment strip -
expo-image-manipulatordependency +app.jsonpermission string (verified landing in the Info.plist viaexpo config --type introspect) -
apps/mobile/src/lib/recent-photos-core.test.ts -
specs/mobile/note-composer-camera-roll.spec.ts— browser spec: tiles render, tap attaches, tap again removes, and the note on /notes carries the photo under its library filename - README verification-table / layout-table rows (plus the native-rebuild paragraph, which now names this strip as the second such module)
Implementation log#
PickedImage gained assetId. The strip needs to know which tiles are
already in the note, and asset id is the only identity a camera-roll photo
and a composer attachment share. ImagePicker reports the same id, so a
photo added through the + button also shows up checked in the strip —
one model, not two.
Bytes are read on tap, not on send. getAssetInfoAsync can trigger an
iCloud download, so the tile spins until real bytes exist and only then joins
the attachment row. Reading at send time would have made a send fail for a
reason that happened minutes earlier and nowhere the user was looking.
readPhotoAsAttachment still sniffs the result's magic bytes even though
saveAsync({format: JPEG}) promises JPEG. Same scar tissue as
lib/attachments.ts: the label picks the uploaded file's extension, and the
server's converter picks by extension.
Round two (device feedback)#
Screenshot from a real phone, four items:
- More space above the text input.
paddingBottom: spacing.smon the strip's row — 12px total with the sheet's own gap. - A way into the full picker from the end of the strip. A trailing
"+ All photos" tile. It calls the composer's own
pickMore, so the tile and the+button are one handler, not two that can drift. - The keyboard's rounded top corners expose a different colour. The
sheet now hangs a 24px skirt of its own background below its content
(
marginBottom: -24plus matchingpaddingBottom), which is what the keyboard's corners reveal. Nothing moves; only the background reaches lower. - A message-icon button on
/notes. 💬 Chat, first in the expanded row's actions. Design in D10 below.
D10 — how the note-chat works#
A per-note chat at a deterministic path
(/agents/mobile/note-<the note file's stem>), not a fresh chat per tap.
Tapping 💬 twice lands you back in the same conversation instead of littering
the chat list, and because the chat list is the unfiltered /agents
catalogue, that conversation is reachable later like any other.
The pointer is typed into the composer, not auto-sent. Auto-sending "about this note:" with no question spends a model turn to be asked "what about it?". Prefilling means the one thing only the human knows — the actual question — is what starts the conversation.
The seed carries the note's text as well as its path. The path alone assumes the agent will glob the notes repo; the text makes the first message self-contained, and the path is still there for follow-up.
The prefill is skipped when the thread already has events — a conversation in
progress IS the context, and re-pasting the note on top of it is noise. The
button resolves that with one getEvents call before it navigates, which is
why it has a pending state like the other row actions.
Gotcha found while building it: "has this thread got anything in it?"
cannot be getEvents({}).length === 0. Merely READING a stream lazily
initializes it, so an untouched note-chat comes back holding infrastructure
events ("Stream durable object woke") and the seed silently never appeared —
caught by the browser spec, which is exactly what it is for. The check asks
for USER_MESSAGE_TYPE/ASSISTANT_MESSAGE_TYPE specifically.
Second gotcha, in the spec: the /notes screen stays mounted underneath the
pushed chat screen, so an expanded row is still expanded after goBack() —
"re-opening" it there actually closes it.
Third, caught by CI and not by the laptop: the seed originally quoted the
row's item, which comes from the file-derived list query. Tap 💬 straight
after saving an edit and the list may not have refetched yet, so the agent
would be handed the note's PREVIOUS text. Locally the refetch always won the
race; against a preview deployment it did not. The seed now reads the note
file itself (in parallel with the has-anyone-spoken check) and falls back to
the row only for a note that has since gone.
Round three (device bug)#
Misha opened a note-chat on his phone and got a red zod blob under his
message: agent path must be canonical: "/agents/" followed by lowercase [a-z0-9_-] segments.
noteChatPath derived the thread from the note's filename, and a note
filename is an ISO stamp — 2026-08-26T09-26-45-481Z-ejdd06 — whose T and
Z are uppercase. AgentPath (apps/os/src/domains/agents/agent-presence.ts)
is deliberately a parser, not a repair step, so it rejects the path outright.
The message still lands, but the agent-presence projection errors and the
failure renders in the feed.
- Lowercase and scrub the derived stem to
[a-z0-9_-]. - Pin it with a unit test that asserts against the platform's own regex,
hand-mirrored with a pointer to the source — the same convention
notes.tsalready uses for the server's frontmatter helpers. - Make
specs/mobile/notes.spec.tsactually SEND the seeded message, so the spec exercisescreate()+message()rather than stopping at a pre-filled composer.
What actually catches this, honestly#
The unit test, not the spec. Reverting the fix and re-running the browser spec still passes: the message lands regardless, and the rejection surfaces asynchronously from the presence projection, after the spec has moved on. The send step is still worth having — it exercises the create/message path a pre-filled composer never reached — but it is not the regression test.
The blind spot underneath it#
apps/mobile sets data-type="error" nowhere, so middlewright's
ui-error-reporter (specs/AGENTS.md) cannot see app errors at all — a spec can
walk past a screen full of red and report green, which is exactly what
happened here. Not fixed in this PR: react-native-web's dataSet prop is
absent from react-native's TypeScript types, so it needs either a small
ErrorText component or a module augmentation, and it wants applying across
every error surface rather than one screen. Filed as its own task.