fix(chat): restaure le collage des images dans le composer custom CLI (#184)

Le collage d'une image (ex. capture d'écran) échouait en environnement desktop : le presse-papier image n'est souvent pas exposé comme un File synchrone dans clipboardData.files, mais uniquement via l'Async Clipboard API. On ajoute donc un repli sur navigator.clipboard.read() (MIME image/*) quand l'événement de collage ne fournit aucun fichier. Le preventDefault() n'est émis que lorsqu'on a effectivement lu des fichiers issus de l'événement, afin de ne pas bloquer le collage texte quand le repli async ne remonte rien.

- refactor clipboardFiles(event) -> dataTransferFiles(DataTransfer) réutilisable
- dedupeKey robuste pour les images sans nom (mime + taille + contenu base64)
- ReplyChunk::UserPrompt porte désormais les labels d'attachments (DTO + accounting reply_chunk_bytes, skip_serializing_if Vec::is_empty)
- tests ciblés frontend (+219) et backend (chat_bridge/dto/attachments) verts

QA : tests ciblés verts. Réserve : environnement headless, pas de validation manuelle desktop — validation AppImage à confirmer en suivi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 16:49:28 +02:00
parent d779b24916
commit d9f949c0f2
9 changed files with 339 additions and 25 deletions

View File

@ -145,7 +145,9 @@ impl ChatBridge {
fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize {
match chunk {
ReplyChunk::UserPrompt { text } => text.len(),
ReplyChunk::UserPrompt { text, attachments } => {
text.len() + attachments.iter().map(String::len).sum::<usize>()
}
ReplyChunk::TextDelta { text } => text.len(),
ReplyChunk::ToolActivity { label } => label.len(),
ReplyChunk::Progress { progress } => {

View File

@ -2356,6 +2356,10 @@ pub async fn agent_send(
)
.await?;
let prompt_for_model = prompt_with_imported_attachments(&prompt, &imported_attachments);
let attachment_labels = imported_attachments
.iter()
.map(|attachment| attachment.filename.clone())
.collect();
// Retain the human submit in the same live scrollback as model chunks so
// `reattach_agent_chat` can repaint the whole in-flight conversation.
@ -2363,6 +2367,7 @@ pub async fn agent_send(
&sid,
ReplyChunk::UserPrompt {
text: prompt.clone(),
attachments: attachment_labels,
},
);

View File

@ -61,7 +61,9 @@ fn error_chunk(s: &str) -> ReplyChunk {
fn chunk_bytes(chunk: &ReplyChunk) -> usize {
match chunk {
ReplyChunk::UserPrompt { text } => text.len(),
ReplyChunk::UserPrompt { text, attachments } => {
text.len() + attachments.iter().map(String::len).sum::<usize>()
}
ReplyChunk::TextDelta { text } => text.len(),
ReplyChunk::ToolActivity { label } => label.len(),
ReplyChunk::Progress { progress } => {

View File

@ -27,11 +27,29 @@ use uuid::Uuid;
fn reply_chunk_user_prompt_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::UserPrompt {
text: "run tests".into(),
attachments: Vec::new(),
})
.unwrap();
assert_eq!(v, json!({ "kind": "userPrompt", "text": "run tests" }));
}
#[test]
fn reply_chunk_user_prompt_serialises_attachment_labels() {
let v = serde_json::to_value(ReplyChunk::UserPrompt {
text: "describe this".into(),
attachments: vec!["clipboard.png".into()],
})
.unwrap();
assert_eq!(
v,
json!({
"kind": "userPrompt",
"text": "describe this",
"attachments": ["clipboard.png"]
})
);
}
#[test]
fn reply_chunk_text_delta_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::TextDelta {
@ -105,7 +123,10 @@ fn reply_chunk_error_serialises_exact_camel_case() {
#[test]
fn reply_chunk_round_trips_through_json_for_every_variant() {
for chunk in [
ReplyChunk::UserPrompt { text: "u".into() },
ReplyChunk::UserPrompt {
text: "u".into(),
attachments: vec!["clipboard.png".into()],
},
ReplyChunk::TextDelta { text: "x".into() },
ReplyChunk::ToolActivity {
label: "runs".into(),
@ -140,7 +161,13 @@ fn reply_chunk_deserialises_from_camel_case_wire_payload() {
// The shape the frontend (or a mock gateway) emits.
let back: ReplyChunk =
serde_json::from_value(json!({ "kind": "userPrompt", "text": "hi" })).unwrap();
assert_eq!(back, ReplyChunk::UserPrompt { text: "hi".into() });
assert_eq!(
back,
ReplyChunk::UserPrompt {
text: "hi".into(),
attachments: Vec::new()
}
);
}
#[test]
@ -310,7 +337,10 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
let dto = ReattachChatDto {
session_id: "sess-1".into(),
scrollback: vec![
ReplyChunk::UserPrompt { text: "Hi".into() },
ReplyChunk::UserPrompt {
text: "Hi".into(),
attachments: Vec::new(),
},
ReplyChunk::TextDelta { text: "Hi".into() },
ReplyChunk::Final {
content: "Hi".into(),

View File

@ -318,7 +318,9 @@ async fn import_chat_attachments_rejects_empty_clipboard_bytes_before_store_call
.unwrap_err();
assert_eq!(err.code(), "INVALID");
assert!(err.to_string().contains("chat attachment must not be empty"));
assert!(err
.to_string()
.contains("chat attachment must not be empty"));
assert!(store.path_calls.lock().unwrap().is_empty());
assert!(store.bytes_calls.lock().unwrap().is_empty());
}

View File

@ -3347,6 +3347,9 @@ pub enum ReplyChunk {
UserPrompt {
/// Submitted prompt text.
text: String,
/// Display labels for attachments submitted with the prompt.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<String>,
},
/// An assistant text fragment (incremental chat rendering).
#[serde(rename_all = "camelCase")]