feat(cli): références d'attachments [file n]/[image n] dans le transcript + zone chat redimensionnable — #188 (QA verte)

- Attachments: projection canonique ChatPromptAttachmentDto (référence [file n]/[image n] par compteur de type, ordre d'import) insérée dans le prompt modèle et transportée par ReplyChunk::UserPrompt (attachmentRefs) ; rendu compact côté CustomAgentChatView.
- Resize: la zone chat (LeafView/LayoutGrid) est redimensionnable, taille actuelle conservée par défaut, préférence locale persistée.
- Tests: commands (projection des refs), dto_chat, chat_bridge, CustomAgentChatView, LayoutGrid.chat.
This commit is contained in:
2026-08-13 13:01:03 +02:00
parent 8b8468dacd
commit 4f38e1c506
10 changed files with 513 additions and 42 deletions

View File

@ -256,8 +256,22 @@ fn progress_entity_key(progress: &ReplyProgressDto) -> Option<String> {
fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize {
match chunk {
ReplyChunk::UserPrompt { text, attachments } => {
text.len() + attachments.iter().map(String::len).sum::<usize>()
ReplyChunk::UserPrompt {
text,
attachments,
attachment_refs,
} => {
text.len()
+ attachments.iter().map(String::len).sum::<usize>()
+ attachment_refs
.iter()
.map(|attachment| {
attachment.reference.len()
+ attachment.filename.len()
+ attachment.mime.len()
+ attachment.readable_path.len()
})
.sum::<usize>()
}
ReplyChunk::TextDelta { text } => text.len(),
ReplyChunk::ToolActivity { label } => label.len(),

View File

@ -48,7 +48,7 @@ use crate::dto::{
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto,
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CellKind,
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ChatAttachmentDto, ChatAttachmentInputDto,
ChatDraftScopeRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
ChatDraftScopeRequestDto, ChatPromptAttachmentDto, CloneOpenCodeProfileFromSeedRequestDto,
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
@ -2356,7 +2356,9 @@ pub async fn agent_send(
attachments.unwrap_or_default(),
)
.await?;
let prompt_for_model = prompt_with_imported_attachments(&prompt, &imported_attachments);
let attachment_refs = chat_prompt_attachment_refs(&imported_attachments);
let prompt_for_model =
prompt_with_imported_attachments(&prompt, &imported_attachments, &attachment_refs);
let attachment_labels = imported_attachments
.iter()
.map(|attachment| attachment.filename.clone())
@ -2369,6 +2371,7 @@ pub async fn agent_send(
ReplyChunk::UserPrompt {
text: prompt.clone(),
attachments: attachment_labels,
attachment_refs,
},
);
@ -2629,15 +2632,21 @@ fn decode_attachment_content_base64(raw: &str) -> Result<Vec<u8>, ErrorDto> {
})
}
fn prompt_with_imported_attachments(prompt: &str, attachments: &[ChatAttachmentDto]) -> String {
fn prompt_with_imported_attachments(
prompt: &str,
attachments: &[ChatAttachmentDto],
refs: &[ChatPromptAttachmentDto],
) -> String {
if attachments.is_empty() {
return prompt.to_owned();
}
let mut out = String::with_capacity(prompt.len() + attachments.len() * 160);
out.push_str(prompt);
out.push_str("\n\nAttachments imported by IdeA and readable from the agent sandbox:\n");
for attachment in attachments {
out.push_str("- id: ");
for (attachment, attachment_ref) in attachments.iter().zip(refs.iter()) {
out.push_str("- ");
out.push_str(&attachment_ref.reference);
out.push_str(" id: ");
out.push_str(&attachment.id);
out.push_str("; file: ");
out.push_str(&attachment.filename);
@ -2652,6 +2661,81 @@ fn prompt_with_imported_attachments(prompt: &str, attachments: &[ChatAttachmentD
out
}
fn chat_prompt_attachment_refs(attachments: &[ChatAttachmentDto]) -> Vec<ChatPromptAttachmentDto> {
let mut image_count = 0usize;
let mut file_count = 0usize;
attachments
.iter()
.map(|attachment| {
let reference = if attachment.mime.starts_with("image/") {
image_count += 1;
format!("[image {image_count}]")
} else {
file_count += 1;
format!("[file {file_count}]")
};
ChatPromptAttachmentDto {
reference,
filename: attachment.filename.clone(),
mime: attachment.mime.clone(),
size_bytes: attachment.size_bytes,
readable_path: attachment.readable_path.clone(),
}
})
.collect()
}
#[cfg(test)]
mod chat_prompt_attachment_projection_tests {
use super::*;
fn attachment(filename: &str, mime: &str, size_bytes: u64) -> ChatAttachmentDto {
ChatAttachmentDto {
id: format!("id-{filename}"),
filename: filename.to_owned(),
mime: mime.to_owned(),
size_bytes,
source_kind: domain::ChatAttachmentSourceKind::Clipboard,
storage_path: format!("agent-chat/session/id-{filename}-{filename}"),
readable_path: format!("/project/.ideai/attachments/agent-chat/session/{filename}"),
created_at: size_bytes,
}
}
#[test]
fn chat_prompt_attachment_refs_follow_import_order_and_kind_counters() {
let attachments = vec![
attachment("first.png", "image/png", 10),
attachment("notes.pdf", "application/pdf", 20),
attachment("second.jpg", "image/jpeg", 30),
];
let refs = chat_prompt_attachment_refs(&attachments);
assert_eq!(refs[0].reference, "[image 1]");
assert_eq!(refs[0].filename, "first.png");
assert_eq!(refs[1].reference, "[file 1]");
assert_eq!(refs[1].filename, "notes.pdf");
assert_eq!(refs[2].reference, "[image 2]");
assert_eq!(refs[2].filename, "second.jpg");
}
#[test]
fn prompt_with_imported_attachments_uses_canonical_refs() {
let attachments = vec![
attachment("first.png", "image/png", 10),
attachment("notes.pdf", "application/pdf", 20),
];
let refs = chat_prompt_attachment_refs(&attachments);
let prompt = prompt_with_imported_attachments("describe", &attachments, &refs);
assert!(prompt.contains("- [image 1] id: id-first.png; file: first.png"));
assert!(prompt.contains("- [file 1] id: id-notes.pdf; file: notes.pdf"));
assert!(prompt.contains("; path: /project/.ideai/attachments/agent-chat/session/notes.pdf"));
}
}
/// `cancel_resume` — annule la **reprise automatique** armée pour un agent limité
/// (ARCHITECTURE §21.1-4, fenêtre annulable).
///

View File

@ -106,8 +106,22 @@ fn progress_chunk_with_id(
fn chunk_bytes(chunk: &ReplyChunk) -> usize {
match chunk {
ReplyChunk::UserPrompt { text, attachments } => {
text.len() + attachments.iter().map(String::len).sum::<usize>()
ReplyChunk::UserPrompt {
text,
attachments,
attachment_refs,
} => {
text.len()
+ attachments.iter().map(String::len).sum::<usize>()
+ attachment_refs
.iter()
.map(|attachment| {
attachment.reference.len()
+ attachment.filename.len()
+ attachment.mime.len()
+ attachment.readable_path.len()
})
.sum::<usize>()
}
ReplyChunk::TextDelta { text } => text.len(),
ReplyChunk::ToolActivity { label } => label.len(),

View File

@ -8,9 +8,9 @@
use app_tauri_lib::dto::{
CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto,
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, ReadChatDraftResponseDto,
ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressSourceDto,
ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto,
ChatPromptAttachmentDto, ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
ReadChatDraftResponseDto, ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto,
ReplyProgressSourceDto, ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto,
};
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
use domain::project::ProjectPath;
@ -28,6 +28,7 @@ fn reply_chunk_user_prompt_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::UserPrompt {
text: "run tests".into(),
attachments: Vec::new(),
attachment_refs: Vec::new(),
})
.unwrap();
assert_eq!(v, json!({ "kind": "userPrompt", "text": "run tests" }));
@ -38,6 +39,13 @@ 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()],
attachment_refs: vec![ChatPromptAttachmentDto {
reference: "[image 1]".into(),
filename: "clipboard.png".into(),
mime: "image/png".into(),
size_bytes: 9,
readable_path: "/project/.ideai/attachments/agent-chat/s/attach-1-clipboard.png".into(),
}],
})
.unwrap();
assert_eq!(
@ -45,7 +53,14 @@ fn reply_chunk_user_prompt_serialises_attachment_labels() {
json!({
"kind": "userPrompt",
"text": "describe this",
"attachments": ["clipboard.png"]
"attachments": ["clipboard.png"],
"attachmentRefs": [{
"reference": "[image 1]",
"filename": "clipboard.png",
"mime": "image/png",
"sizeBytes": 9,
"readablePath": "/project/.ideai/attachments/agent-chat/s/attach-1-clipboard.png"
}]
})
);
}
@ -128,6 +143,14 @@ fn reply_chunk_round_trips_through_json_for_every_variant() {
ReplyChunk::UserPrompt {
text: "u".into(),
attachments: vec!["clipboard.png".into()],
attachment_refs: vec![ChatPromptAttachmentDto {
reference: "[image 1]".into(),
filename: "clipboard.png".into(),
mime: "image/png".into(),
size_bytes: 9,
readable_path: "/project/.ideai/attachments/agent-chat/s/attach-1-clipboard.png"
.into(),
}],
},
ReplyChunk::TextDelta { text: "x".into() },
ReplyChunk::ToolActivity {
@ -168,7 +191,8 @@ fn reply_chunk_deserialises_from_camel_case_wire_payload() {
back,
ReplyChunk::UserPrompt {
text: "hi".into(),
attachments: Vec::new()
attachments: Vec::new(),
attachment_refs: Vec::new()
}
);
}
@ -343,6 +367,7 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
ReplyChunk::UserPrompt {
text: "Hi".into(),
attachments: Vec::new(),
attachment_refs: Vec::new(),
},
ReplyChunk::TextDelta { text: "Hi".into() },
ReplyChunk::Final {

View File

@ -3206,6 +3206,22 @@ pub struct ChatAttachmentDto {
pub created_at: u64,
}
/// Canonical attachment projection attached to a streamed user prompt.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatPromptAttachmentDto {
/// Stable textual reference inserted in the model prompt, e.g. `[file 1]`.
pub reference: String,
/// Original display filename.
pub filename: String,
/// MIME type.
pub mime: String,
/// Size in bytes.
pub size_bytes: u64,
/// Absolute managed path readable by the target agent sandbox.
pub readable_path: String,
}
impl From<domain::ChatAttachment> for ChatAttachmentDto {
fn from(value: domain::ChatAttachment) -> Self {
Self {
@ -3359,6 +3375,9 @@ pub enum ReplyChunk {
/// Display labels for attachments submitted with the prompt.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<String>,
/// Canonical attachment references for this prompt, in backend import order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachment_refs: Vec<ChatPromptAttachmentDto>,
},
/// An assistant text fragment (incremental chat rendering).
#[serde(rename_all = "camelCase")]