Shrink a photo to what the provider takes, and put it in its own bubble

Sending an image was broken in the way that is hardest to see from the
phone: a camera photo is twelve megapixels and several megabytes, the Claude
API resizes anything past 1568px on its long edge before looking at it and
refuses far larger outright, so the picture was uploaded whole over the
tunnel to be thrown away or rejected at the other end.

Shrunk on the phone, to a limit the server states. Which number it is comes
from the provider's *kind* -- `DriverKind::max_image_edge`, reported on the
session row -- because that is where a provider's requirements are known,
and a phone carrying its own copy of them would be a second place to update
when one changes. `None` where nothing cares, rather than a large number:
"no limit" and "a limit that happens to be big" are different answers and
only one of them stays true. Doing it before the upload rather than after is
the point -- the expensive part on a phone is the tunnel, not the decode --
and an image already inside the limit is uploaded byte for byte rather than
being round-tripped through JPEG for nothing.

EXIF orientation is applied while scaling. The camera writes which way up
the picture is into a tag rather than into the pixels, and re-encoding drops
it, so a portrait photo would have arrived at the model on its side with
nothing anywhere saying so.

**What is attached is now visible before it is sent**, in a row directly
above the box it will be sent from: the count on the "+" button said how
many and never which, so the only way to find out what you had picked was to
send it. It scrolls sideways rather than shrinking, and tapping one takes it
back off -- an image picked by mistake could otherwise only be dealt with by
sending it. The tile is outlined as well as filled, because most of what
gets attached here is a screenshot of a dark app and a cropped one is
near-black: without an edge the only thing on screen saying an image was
attached was the cross drawn on top of nothing.

**And the picture is inside the bubble that sent it.** Attachments used to
be their own `Image` events emitted just before the message, which drew
somebody's screenshot as a row floating above the bubble and left the phone
deciding from adjacency alone which message an image belonged to -- a thing
the sender knew and could simply say. `UserMessage`, `MessageQueued` and
`MessageTaken` carry the refs now, so a waiting message keeps its picture
for as long as the turn runs, and a replay puts it back in the same place.

Verified on a real claude-cli session rather than an echo one, since the
limit only exists for that kind: a 3000x4000 image arrived as 1176x1568
JPEG -- long edge exactly the limit, aspect ratio intact -- and haiku
answered "AI Sessions displays idle Photo", which is what the picture was.
No error, and the transcript records the message with `images` on it.
This commit is contained in:
iris committed 2026-08-30 00:47:16 -04:00
1 parent 5d47a1ec89
commit b0629f77ca
16 files changed
+529 -70

No files matched your search

@@ -29,6 +29,15 @@ sealed class SessionEvent {
* bubbles and clearing whichever one matched first would leave the wrong one on screen.
*/
val id: String?,
/**
* What was attached to it, by the ref the files route serves.
*
* On the message rather than beside it: these arrived as separate image events until
* 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent
* it, and left this app deciding from adjacency alone which message an image went with --
* something the sender knew and could simply have said.
*/
val images: List<String>,
) : SessionEvent()
/**
@@ -41,7 +50,8 @@ sealed class SessionEvent {
* Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by
* [CommandSent].
*/
data class MessageQueued(val id: String, val text: String) : SessionEvent()
data class MessageQueued(val id: String, val text: String, val images: List<String>) :
SessionEvent()
data class AssistantText(val delta: String) : SessionEvent()
@@ -144,6 +154,17 @@ sealed class SessionEvent {
data class Unknown(val type: String) : SessionEvent()
}
/**
* A JSON array of strings under [name], empty when the field is absent.
*
* Absent is the ordinary case -- most messages carry no attachment, and the server omits the field
* rather than sending an empty list -- so this is the shape every caller wants.
*/
private fun JSONObject.stringList(name: String): List<String> {
val array = optJSONArray(name) ?: return emptyList()
return (0 until array.length()).map { array.getString(it) }
}
fun parseSeqEvent(json: String): SeqEvent {
val body = JSONObject(json)
val event =
@@ -152,9 +173,14 @@ fun parseSeqEvent(json: String): SeqEvent {
SessionEvent.UserMessage(
body.getString("text"),
body.optString("id").ifEmpty { null },
body.stringList("images"),
)
"messageQueued" ->
SessionEvent.MessageQueued(body.getString("id"), body.getString("text"))
SessionEvent.MessageQueued(
body.getString("id"),
body.getString("text"),
body.stringList("images"),
)
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
"toolStart" ->
SessionEvent.ToolStart(