diff --git a/CHANGELOG.md b/CHANGELOG.md index 809f0f7..7429497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Fixed + +- **Images you upload reach Claude Code**: its chats read the history without the image bytes, so the + files it was pointed to were empty. Images without data are no longer written as empty files. + ## [v202611-00039] – 2026-11-15 ### Changed diff --git a/server/src/claude_code.rs b/server/src/claude_code.rs index 15efdb5..fadb98c 100644 --- a/server/src/claude_code.rs +++ b/server/src/claude_code.rs @@ -361,7 +361,12 @@ fn save_images(cwd: &str, parts: &[Part]) -> Vec { let mut out = Vec::new(); for (i, p) in parts.iter().enumerate() { let Part::Image { image } = p else { continue }; + // An image without bytes (an unresolved reference) would become an empty file. let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) else { continue }; + if bytes.is_empty() { + tracing::warn!("claude code: image {i} of the message has no data – not saved"); + continue; + } let ext = image.media_type.split('/').nth(1).unwrap_or("png").split('+').next().unwrap_or("png"); let path = dir.join(format!("chat-{}-{i}.{ext}", now_ms())); if std::fs::create_dir_all(&dir).is_ok() { @@ -379,7 +384,8 @@ pub async fn run(app: Arc, session_id: &str, cancel: CancellationToken) -> let session = app.session(session_id)?; let owner = session.owner_id.clone(); let model_id = session.model.strip_prefix("claude-code/").unwrap_or("default").to_string(); - let history = crate::runner::active_history(app.store.messages(session_id)?); + // With the bytes of the images filled in (they are stored as references). + let history = crate::runner::active_history(app.store.messages_for_model(session_id)?); let Some(last_user) = history.iter().rposition(|m| m.role == Role::User && m.parts.iter().any(|p| matches!(p, Part::Text { .. } | Part::Image { .. }))) else { bail!("nothing to do: no user message"); }; @@ -956,6 +962,21 @@ pub async fn update() -> Result { #[cfg(test)] mod tests { + #[test] + fn uploaded_images_are_saved_with_their_bytes_only() { + let dir = std::env::temp_dir().join(format!("cc-uploads-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let parts = vec![ + Part::Image { image: ImageData { media_type: "image/png".into(), data: "QUJD".into(), blob: None, size: None } }, + // A reference whose bytes were not filled in: must not become an empty file. + Part::Image { image: ImageData { media_type: "image/png".into(), data: String::new(), blob: Some("x".into()), size: None } }, + ]; + let saved = save_images(&dir.display().to_string(), &parts); + assert_eq!(saved.len(), 1); + assert_eq!(std::fs::read(&saved[0]).unwrap(), b"ABC"); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn questions_roundtrip_with_the_cli_format() { let input = json!({"questions": [ diff --git a/web/src/whatsnew.ts b/web/src/whatsnew.ts index fd60745..1f8c691 100644 --- a/web/src/whatsnew.ts +++ b/web/src/whatsnew.ts @@ -3,6 +3,10 @@ * under "unreleased"; scripts/release.sh renames the key to the release version. */ export const WHATS_NEW: Record = { + unreleased: { + en: ["Images you upload are now visible to Claude Code (they arrived empty before)"], + de: ["Hochgeladene Bilder sind für Claude Code jetzt sichtbar (sie kamen vorher leer an)"], + }, "202611-00039": { en: ["The chat colour no longer tints the chat background", "Claude Code can ask you questions again (question card in the chat)"], de: ["Die Chat-Farbe färbt den Hintergrund des Chats nicht mehr ein", "Claude Code kann dir wieder Fragen stellen (Fragenkarte im Chat)"],