Phase 2 complete: images both ways

Inbound: POST /sessions/{id}/attachments stores a picked photo under the
session; message attachmentIds become base64 image blocks in the
stream-json user message (verified live: an uploaded red PNG answered
"Red."). Outbound: image parts in tool results are decoded into the
session's files/ dir and referenced by Image events -- the transcript
stays lean -- and GET /sessions/{id}/files/{ref} serves them (verified
via the Read tool round-tripping the same PNG). The app grows an attach
button (system photo picker, upload-on-pick) and renders Image events
inline with an authenticated pinned fetch. Sent attachments are echoed
into the transcript as Image events so every device shows them.

Attachments and files are addressed under their session (a deviation
from PLAN.md's original bare /attachments -- recorded there) so their
lifecycle is the session directory's: deleting the session is still the
complete path out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-24 21:40:25 -04:00
1 parent 95d389e2b8
commit f2430671a2
7 files changed
+389 -55

No files matched your search

@@ -30,6 +30,8 @@ fun <T> requestFromServer(
path: String,
method: String = "GET",
jsonBody: String? = null,
/** Raw request body as content-type to bytes -- the upload path. */
binaryBody: Pair<String, ByteArray>? = null,
readTimeoutMs: Int = 5000,
readBody: (HttpURLConnection) -> T,
): T {
@@ -44,6 +46,10 @@ fun <T> requestFromServer(
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.outputStream.use { it.write(jsonBody.encodeToByteArray()) }
} else if (binaryBody != null) {
connection.doOutput = true
connection.setRequestProperty("Content-Type", binaryBody.first)
connection.outputStream.use { it.write(binaryBody.second) }
}
if (connection.responseCode !in 200..299) {
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
@@ -141,15 +147,54 @@ fun spawnSession(
)
}
fun sendMessage(settings: ServerSettings, sessionId: String, text: String) {
fun sendMessage(
settings: ServerSettings,
sessionId: String,
text: String,
attachmentIds: List<String> = emptyList(),
) {
requestFromServer(
settings,
"/sessions/$sessionId/message",
method = "POST",
jsonBody = JSONObject().put("text", text).toString(),
jsonBody = JSONObject()
.put("text", text)
.put("attachmentIds", JSONArray(attachmentIds))
.toString(),
) {}
}
/** Uploads one picked image; the returned id goes into [sendMessage]. */
fun uploadAttachment(
settings: ServerSettings,
sessionId: String,
bytes: ByteArray,
mime: String,
): String {
val boundary = "----aiapp-${System.currentTimeMillis()}"
val head = (
"--$boundary\r\n" +
"Content-Disposition: form-data; name=\"file\"; filename=\"image\"\r\n" +
"Content-Type: $mime\r\n\r\n"
).encodeToByteArray()
val tail = "\r\n--$boundary--\r\n".encodeToByteArray()
return requestFromServer(
settings,
"/sessions/$sessionId/attachments",
method = "POST",
binaryBody = "multipart/form-data; boundary=$boundary" to (head + bytes + tail),
readTimeoutMs = 60000,
) { connection ->
JSONObject(connection.inputStream.bufferedReader().readText()).getString("id")
}
}
/** Fetches an image the transcript references (produced or uploaded). */
fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray =
requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) {
it.inputStream.readBytes()
}
fun answerQuestion(settings: ServerSettings, sessionId: String, questionId: String, answer: String) {
requestFromServer(
settings,
@@ -33,6 +33,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -66,7 +67,9 @@ sealed class TranscriptItem {
val answer: String?,
) : TranscriptItem()
data class ErrorMsg(val message: String) : TranscriptItem()
/** Placeholder row for events this build can't render (images, newer kinds). */
/** An image by server-side ref, fetched from the session's files route. */
data class ImageItem(val ref: String) : TranscriptItem()
/** Placeholder row for events this build can't render (newer kinds). */
data class Note(val text: String) : TranscriptItem()
}
@@ -94,7 +97,7 @@ fun foldEvent(items: List<TranscriptItem>, event: SessionEvent): List<Transcript
if (it is TranscriptItem.QuestionCard && it.id == event.id) it.copy(answer = event.answer) else it
}
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(event.message)
is SessionEvent.Image -> items + TranscriptItem.Note("[image ${event.ref}]")
is SessionEvent.Image -> items + TranscriptItem.ImageItem(event.ref)
is SessionEvent.Unknown -> items + TranscriptItem.Note("[${event.type}]")
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.Status, is SessionEvent.UsageDelta -> items
@@ -118,6 +121,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
var actionError by remember { mutableStateOf<String?>(null) }
var input by remember { mutableStateOf("") }
var expandedTools by remember { mutableStateOf(setOf<String>()) }
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
val context = androidx.compose.ui.platform.LocalContext.current
// The resume cursor, written from the stream's IO thread.
val lastSeq = remember { AtomicLong(0) }
val activeStream = remember { AtomicReference<EventStream?>(null) }
@@ -177,9 +183,35 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
fun send() {
val text = input.trim()
if (text.isEmpty()) return
val attachments = pendingAttachments
if (text.isEmpty() && attachments.isEmpty()) return
input = ""
act { sendMessage(settings, summary.id, text) }
pendingAttachments = emptyList()
act { sendMessage(settings, summary.id, text, attachments) }
}
// The system photo picker; the image uploads as soon as it's chosen,
// so Send only has ids to reference.
val pickImage = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia(),
) { uri ->
if (uri != null) {
scope.launch {
try {
val id = withContext(Dispatchers.IO) {
val bytes = context.contentResolver.openInputStream(uri)
?.use { it.readBytes() }
?: throw ApiException("couldn't read the picked image")
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
uploadAttachment(settings, summary.id, bytes, mime)
}
pendingAttachments = pendingAttachments + id
actionError = null
} catch (e: ApiException) {
actionError = e.message
}
}
}
}
Column(Modifier.fillMaxSize()) {
@@ -239,6 +271,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
is TranscriptItem.ImageItem -> SessionImage(settings, summary.id, item.ref)
is TranscriptItem.Note -> Text(
item.text,
style = MaterialTheme.typography.bodySmall,
@@ -255,11 +288,21 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(8.dp),
) {
TextButton(onClick = {
pickImage.launch(
androidx.activity.result.PickVisualMediaRequest(
androidx.activity.result.contract.ActivityResultContracts
.PickVisualMedia.ImageOnly,
),
)
}) {
Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}")
}
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Message") },
placeholder = { Text(if (pendingAttachments.isEmpty()) "Message" else "Message (+image)") },
maxLines = 4,
)
Spacer(Modifier.width(8.dp))
@@ -274,6 +317,41 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
}
}
/**
* An inline transcript image, fetched (authenticated, pinned) from the
* session's files route. The bitmap is remembered per ref, so scrolling
* doesn't refetch.
*/
@Composable
private fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) {
var bitmap by remember(ref) {
mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null)
}
var failed by remember(ref) { mutableStateOf(false) }
LaunchedEffect(ref) {
try {
val bytes = withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) }
bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?.asImageBitmap()
failed = bitmap == null
} catch (e: ApiException) {
failed = true
}
}
when (val image = bitmap) {
null -> Text(
if (failed) "[image $ref unavailable]" else "[loading image…]",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> androidx.compose.foundation.Image(
bitmap = image,
contentDescription = "session image",
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun UserBubble(text: String) {
Box(Modifier.fillMaxWidth()) {