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

+39 -5
View File
@@ -94,6 +94,11 @@ impl LiveSession {
/// driver -- which queues it for injection mid-run rather than at the
/// end of the turn (the point of the whole app).
pub fn send_message(&self, text: String, images: Vec<ImageRef>) {
// Attachments render in the transcript like any produced image --
// the files route serves uploads by the same ref.
for image in &images {
let _ = self.sink.send(Event::Image { image: image.clone() });
}
let _ = self.sink.send(Event::UserMessage { text: text.clone() });
self.driver.send_user_message(text, images);
}
@@ -122,6 +127,30 @@ impl LiveSession {
&self.transcript_path
}
/// The session's directory (attachments in, produced files out live in
/// `attachments/` and `files/` under it).
pub fn dir(&self) -> &Path {
self.transcript_path.parent().expect("transcript lives in the session dir")
}
/// Stores one uploaded attachment, returning the id `POST /message`
/// references it by. Removed with the session directory on delete --
/// the same path out as everything else in it.
pub fn save_attachment(&self, bytes: &[u8], content_type: &str) -> Result<String> {
let extension = match content_type {
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "jpg",
};
let name = format!("{}.{extension}", random_hex());
let dir = self.dir().join("attachments");
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
std::fs::write(dir.join(&name), bytes)
.with_context(|| format!("write attachment {name}"))?;
Ok(name)
}
fn info(&self) -> SessionInfo {
SessionInfo {
id: self.meta.id.clone(),
@@ -313,13 +342,18 @@ fn default_title(kind: SessionKind) -> String {
}
/// 8 random bytes, hex -- short enough for a URL, unique enough forever at
/// this scale. Still checked against the existing list out of caution.
fn unique_id(config: &Config) -> String {
/// this scale.
pub fn random_hex() -> String {
use rand::Rng;
let mut bytes = [0u8; 8];
rand::rng().fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// A [`random_hex`] id not already taken -- checked out of caution.
fn unique_id(config: &Config) -> String {
loop {
let mut bytes = [0u8; 8];
rand::rng().fill_bytes(&mut bytes);
let id: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
let id = random_hex();
if !config.sessions.iter().any(|meta| meta.id == id) {
return id;
}