merge(chat): intègre feature/155-clipboard-paste-image — paste image clipboard dans le composer (#155, QA verte)

This commit is contained in:
2026-08-06 11:01:50 +02:00
15 changed files with 791 additions and 84 deletions

View File

@ -4,7 +4,7 @@
//! [`AppState`], map `Result<Output, AppError>` to `Result<ResponseDto, //! [`AppState`], map `Result<Output, AppError>` to `Result<ResponseDto,
//! ErrorDto>`. No business logic lives here. //! ErrorDto>`. No business logic lives here.
use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
use base64::Engine; use base64::Engine;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::ipc::Channel; use tauri::ipc::Channel;
@ -2326,22 +2326,17 @@ async fn import_chat_attachments_for_session(
for path in legacy_paths { for path in legacy_paths {
if seen_paths.insert(path.clone()) { if seen_paths.insert(path.clone()) {
items.push(application::ImportChatAttachmentItem { items.push(application::ImportChatAttachmentItem {
path, source: application::ImportChatAttachmentSource::Path(path),
mime: None, mime: None,
source_kind: domain::ChatAttachmentSourceKind::LocalFile, source_kind: domain::ChatAttachmentSourceKind::LocalFile,
}); });
} }
} }
for attachment in attachments { for attachment in attachments {
if seen_paths.insert(attachment.path.clone()) { let Some(item) = chat_attachment_input_to_item(attachment, &mut seen_paths)? else {
items.push(application::ImportChatAttachmentItem { continue;
path: attachment.path, };
mime: attachment.mime, items.push(item);
source_kind: attachment
.source_kind
.unwrap_or(domain::ChatAttachmentSourceKind::LocalFile),
});
}
} }
let output = state let output = state
@ -2361,6 +2356,71 @@ async fn import_chat_attachments_for_session(
.collect()) .collect())
} }
fn chat_attachment_input_to_item(
attachment: ChatAttachmentInputDto,
seen_paths: &mut std::collections::HashSet<String>,
) -> Result<Option<application::ImportChatAttachmentItem>, ErrorDto> {
let has_path = attachment
.path
.as_deref()
.is_some_and(|path| !path.is_empty());
let has_content = attachment
.content_base64
.as_deref()
.is_some_and(|content| !content.is_empty());
if has_path && has_content {
return Err(ErrorDto::from(AppError::Invalid(
"chat attachment must provide either path or contentBase64, not both".to_owned(),
)));
}
if let Some(path) = attachment.path.filter(|path| !path.is_empty()) {
if !seen_paths.insert(path.clone()) {
return Ok(None);
}
return Ok(Some(application::ImportChatAttachmentItem {
source: application::ImportChatAttachmentSource::Path(path),
mime: attachment.mime,
source_kind: attachment
.source_kind
.unwrap_or(domain::ChatAttachmentSourceKind::LocalFile),
}));
}
if let Some(content_base64) = attachment
.content_base64
.filter(|content| !content.is_empty())
{
let filename = attachment.filename.ok_or_else(|| {
ErrorDto::from(AppError::Invalid(
"chat attachment bytes require filename".to_owned(),
))
})?;
let bytes = decode_attachment_content_base64(&content_base64)?;
return Ok(Some(application::ImportChatAttachmentItem {
source: application::ImportChatAttachmentSource::Bytes { filename, bytes },
mime: attachment.mime,
source_kind: attachment
.source_kind
.unwrap_or(domain::ChatAttachmentSourceKind::Clipboard),
}));
}
Err(ErrorDto::from(AppError::Invalid(
"chat attachment must provide path or contentBase64".to_owned(),
)))
}
fn decode_attachment_content_base64(raw: &str) -> Result<Vec<u8>, ErrorDto> {
STANDARD
.decode(raw)
.or_else(|_| STANDARD_NO_PAD.decode(raw))
.or_else(|_| URL_SAFE.decode(raw))
.or_else(|_| URL_SAFE_NO_PAD.decode(raw))
.map_err(|_| {
ErrorDto::from(AppError::Invalid(
"invalid chat attachment contentBase64".to_owned(),
))
})
}
fn prompt_with_imported_attachments(prompt: &str, attachments: &[ChatAttachmentDto]) -> String { fn prompt_with_imported_attachments(prompt: &str, attachments: &[ChatAttachmentDto]) -> String {
if attachments.is_empty() { if attachments.is_empty() {
return prompt.to_owned(); return prompt.to_owned();

View File

@ -119,7 +119,26 @@ fn chat_attachment_input_dto_deserialises_camel_case() {
})) }))
.unwrap(); .unwrap();
assert_eq!(dto.path, "/tmp/picked.png"); assert_eq!(dto.path.as_deref(), Some("/tmp/picked.png"));
assert_eq!(dto.filename, None);
assert_eq!(dto.content_base64, None);
assert_eq!(dto.mime.as_deref(), Some("image/png"));
assert_eq!(dto.source_kind, Some(ChatAttachmentSourceKind::Clipboard));
}
#[test]
fn chat_attachment_input_dto_deserialises_clipboard_bytes_camel_case() {
let dto: ChatAttachmentInputDto = serde_json::from_value(json!({
"filename": "clipboard.png",
"contentBase64": "cG5nIGJ5dGVz",
"mime": "image/png",
"sourceKind": "clipboard"
}))
.unwrap();
assert_eq!(dto.path, None);
assert_eq!(dto.filename.as_deref(), Some("clipboard.png"));
assert_eq!(dto.content_base64.as_deref(), Some("cG5nIGJ5dGVz"));
assert_eq!(dto.mime.as_deref(), Some("image/png")); assert_eq!(dto.mime.as_deref(), Some("image/png"));
assert_eq!(dto.source_kind, Some(ChatAttachmentSourceKind::Clipboard)); assert_eq!(dto.source_kind, Some(ChatAttachmentSourceKind::Clipboard));
} }
@ -154,6 +173,11 @@ fn import_chat_attachments_request_response_use_camel_case() {
"path": "/tmp/picked.png", "path": "/tmp/picked.png",
"mime": "image/png", "mime": "image/png",
"sourceKind": "dragDrop" "sourceKind": "dragDrop"
}, {
"filename": "clipboard.png",
"contentBase64": "cG5nIGJ5dGVz",
"mime": "image/png",
"sourceKind": "clipboard"
}] }]
})) }))
.unwrap(); .unwrap();
@ -164,6 +188,10 @@ fn import_chat_attachments_request_response_use_camel_case() {
request.attachments[0].source_kind, request.attachments[0].source_kind,
Some(ChatAttachmentSourceKind::DragDrop) Some(ChatAttachmentSourceKind::DragDrop)
); );
assert_eq!(
request.attachments[1].content_base64.as_deref(),
Some("cG5nIGJ5dGVz")
);
let response = ImportChatAttachmentsResponseDto { let response = ImportChatAttachmentsResponseDto {
attachments: vec![ChatAttachmentDto { attachments: vec![ChatAttachmentDto {

View File

@ -30,14 +30,28 @@ pub struct ImportChatAttachmentsInput {
/// One source attachment import request. /// One source attachment import request.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportChatAttachmentItem { pub struct ImportChatAttachmentItem {
/// Local source path supplied by the driving adapter. /// Source payload supplied by the driving adapter.
pub path: String, pub source: ImportChatAttachmentSource,
/// Optional MIME type supplied by the driving adapter. /// Optional MIME type supplied by the driving adapter.
pub mime: Option<String>, pub mime: Option<String>,
/// Origin kind. /// Origin kind.
pub source_kind: ChatAttachmentSourceKind, pub source_kind: ChatAttachmentSourceKind,
} }
/// Source payload for a chat attachment import.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImportChatAttachmentSource {
/// Local source path supplied by the driving adapter.
Path(String),
/// In-memory bytes supplied by the driving adapter.
Bytes {
/// Display filename to persist in metadata and storage name.
filename: String,
/// Raw content bytes.
bytes: Vec<u8>,
},
}
/// Output of [`ImportChatAttachments::execute`]. /// Output of [`ImportChatAttachments::execute`].
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportChatAttachmentsOutput { pub struct ImportChatAttachmentsOutput {
@ -70,28 +84,44 @@ impl ImportChatAttachments {
) -> Result<ImportChatAttachmentsOutput, AppError> { ) -> Result<ImportChatAttachmentsOutput, AppError> {
let mut imported = Vec::with_capacity(input.attachments.len()); let mut imported = Vec::with_capacity(input.attachments.len());
for item in input.attachments { for item in input.attachments {
let filename = filename_from_path(&item.path)?; let filename = filename_from_source(&item.source)?;
validate_attachment_filename(&filename)?; validate_attachment_filename(&filename)?;
let mime = sanitize_mime(item.mime.as_deref(), &filename)?; let mime = sanitize_mime(item.mime.as_deref(), &filename)?;
let id = ChatAttachmentId::new(self.ids.new_uuid().to_string()) let id = ChatAttachmentId::new(self.ids.new_uuid().to_string())
.map_err(|err| AppError::Invalid(err.to_string()))?; .map_err(|err| AppError::Invalid(err.to_string()))?;
let attachment = self let import = ChatAttachmentImport {
.store
.import_from_path(
&input.project.root,
input.project.id,
input.agent_id,
input.session_id,
&LocalPath::new(item.path),
ChatAttachmentImport {
id, id,
filename, filename,
mime, mime,
source_kind: item.source_kind, source_kind: item.source_kind,
created_at: now(&self.clock), created_at: now(&self.clock),
}, };
let attachment = match item.source {
ImportChatAttachmentSource::Path(path) => {
self.store
.import_from_path(
&input.project.root,
input.project.id,
input.agent_id,
input.session_id,
&LocalPath::new(path),
import,
) )
.await?; .await?
}
ImportChatAttachmentSource::Bytes { bytes, .. } => {
self.store
.import_from_bytes(
&input.project.root,
input.project.id,
input.agent_id,
input.session_id,
&bytes,
import,
)
.await?
}
};
imported.push(attachment); imported.push(attachment);
} }
Ok(ImportChatAttachmentsOutput { Ok(ImportChatAttachmentsOutput {
@ -112,6 +142,13 @@ fn filename_from_path(path: &str) -> Result<String, AppError> {
.ok_or_else(|| AppError::Invalid("invalid attachment filename".to_owned())) .ok_or_else(|| AppError::Invalid("invalid attachment filename".to_owned()))
} }
fn filename_from_source(source: &ImportChatAttachmentSource) -> Result<String, AppError> {
match source {
ImportChatAttachmentSource::Path(path) => filename_from_path(path),
ImportChatAttachmentSource::Bytes { filename, .. } => Ok(filename.clone()),
}
}
fn validate_attachment_filename(filename: &str) -> Result<(), AppError> { fn validate_attachment_filename(filename: &str) -> Result<(), AppError> {
let lowered = filename.to_ascii_lowercase(); let lowered = filename.to_ascii_lowercase();
let blocked = [ let blocked = [

View File

@ -72,8 +72,8 @@ pub use background::{
SpawnBackgroundCommandOutput, SpawnBackgroundCommandOutput,
}; };
pub use chat_attachments::{ pub use chat_attachments::{
ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput, ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments,
ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES, ImportChatAttachmentsInput, ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES,
}; };
pub use conversation::{ pub use conversation::{
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn, ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,

View File

@ -10,11 +10,15 @@ use domain::{
}; };
use uuid::Uuid; use uuid::Uuid;
use application::{ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput}; use application::{
ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments,
ImportChatAttachmentsInput,
};
#[derive(Default)] #[derive(Default)]
struct FakeChatAttachments { struct FakeChatAttachments {
calls: Mutex<Vec<(LocalPath, ChatAttachmentImport)>>, path_calls: Mutex<Vec<(LocalPath, ChatAttachmentImport)>>,
bytes_calls: Mutex<Vec<(Vec<u8>, ChatAttachmentImport)>>,
} }
#[async_trait] #[async_trait]
@ -28,7 +32,7 @@ impl ChatAttachmentStore for FakeChatAttachments {
source: &LocalPath, source: &LocalPath,
import: ChatAttachmentImport, import: ChatAttachmentImport,
) -> Result<ChatAttachment, ChatAttachmentStoreError> { ) -> Result<ChatAttachment, ChatAttachmentStoreError> {
self.calls self.path_calls
.lock() .lock()
.unwrap() .unwrap()
.push((source.clone(), import.clone())); .push((source.clone(), import.clone()));
@ -58,6 +62,45 @@ impl ChatAttachmentStore for FakeChatAttachments {
}) })
} }
async fn import_from_bytes(
&self,
root: &ProjectPath,
project_id: ProjectId,
agent_id: AgentId,
session_id: SessionId,
bytes: &[u8],
import: ChatAttachmentImport,
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
self.bytes_calls
.lock()
.unwrap()
.push((bytes.to_vec(), import.clone()));
let storage_path = format!(
"agent-chat/{session_id}/{}-{}",
import.id.as_str(),
import.filename
);
let readable_path = format!(
"{}/.ideai/attachments/agent-chat/{session_id}/{}-{}",
root.as_str(),
import.id.as_str(),
import.filename
);
Ok(ChatAttachment {
id: import.id,
project_id,
agent_id,
session_id,
filename: import.filename.clone(),
mime: import.mime.clone(),
size_bytes: bytes.len() as u64,
source_kind: import.source_kind,
storage_path,
readable_path,
created_at: import.created_at,
})
}
async fn list_for_session( async fn list_for_session(
&self, &self,
_root: &ProjectPath, _root: &ProjectPath,
@ -113,7 +156,7 @@ async fn import_chat_attachments_allocates_metadata_and_uses_store_port() {
agent_id, agent_id,
session_id, session_id,
attachments: vec![ImportChatAttachmentItem { attachments: vec![ImportChatAttachmentItem {
path: "/outside/photo.png".to_owned(), source: ImportChatAttachmentSource::Path("/outside/photo.png".to_owned()),
mime: None, mime: None,
source_kind: ChatAttachmentSourceKind::Clipboard, source_kind: ChatAttachmentSourceKind::Clipboard,
}], }],
@ -134,11 +177,49 @@ async fn import_chat_attachments_allocates_metadata_and_uses_store_port() {
.readable_path .readable_path
.contains("/.ideai/attachments/agent-chat/")); .contains("/.ideai/attachments/agent-chat/"));
let calls = store.calls.lock().unwrap(); let calls = store.path_calls.lock().unwrap();
assert_eq!(calls.len(), 1); assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0.as_str(), "/outside/photo.png"); assert_eq!(calls[0].0.as_str(), "/outside/photo.png");
} }
#[tokio::test]
async fn import_chat_attachments_routes_clipboard_bytes_to_store_port() {
let store = Arc::new(FakeChatAttachments::default());
let usecase = ImportChatAttachments::new(
Arc::clone(&store) as Arc<dyn ChatAttachmentStore>,
Arc::new(FixedIds),
Arc::new(FixedClock),
);
let output = usecase
.execute(ImportChatAttachmentsInput {
project: project(),
agent_id: AgentId::new_random(),
session_id: SessionId::new_random(),
attachments: vec![ImportChatAttachmentItem {
source: ImportChatAttachmentSource::Bytes {
filename: "clipboard.png".to_owned(),
bytes: b"png bytes".to_vec(),
},
mime: Some("image/png".to_owned()),
source_kind: ChatAttachmentSourceKind::Clipboard,
}],
})
.await
.unwrap();
assert_eq!(output.attachments[0].filename, "clipboard.png");
assert_eq!(output.attachments[0].mime, "image/png");
assert_eq!(
output.attachments[0].source_kind,
ChatAttachmentSourceKind::Clipboard
);
let calls = store.bytes_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, b"png bytes");
assert_eq!(calls[0].1.filename, "clipboard.png");
}
#[tokio::test] #[tokio::test]
async fn import_chat_attachments_rejects_executable_sources_before_store_call() { async fn import_chat_attachments_rejects_executable_sources_before_store_call() {
let store = Arc::new(FakeChatAttachments::default()); let store = Arc::new(FakeChatAttachments::default());
@ -154,7 +235,7 @@ async fn import_chat_attachments_rejects_executable_sources_before_store_call()
agent_id: AgentId::new_random(), agent_id: AgentId::new_random(),
session_id: SessionId::new_random(), session_id: SessionId::new_random(),
attachments: vec![ImportChatAttachmentItem { attachments: vec![ImportChatAttachmentItem {
path: "/outside/run.sh".to_owned(), source: ImportChatAttachmentSource::Path("/outside/run.sh".to_owned()),
mime: None, mime: None,
source_kind: ChatAttachmentSourceKind::LocalFile, source_kind: ChatAttachmentSourceKind::LocalFile,
}], }],
@ -163,5 +244,6 @@ async fn import_chat_attachments_rejects_executable_sources_before_store_call()
.unwrap_err(); .unwrap_err();
assert_eq!(err.code(), "INVALID"); assert_eq!(err.code(), "INVALID");
assert!(store.calls.lock().unwrap().is_empty()); assert!(store.path_calls.lock().unwrap().is_empty());
assert!(store.bytes_calls.lock().unwrap().is_empty());
} }

View File

@ -2971,7 +2971,14 @@ impl From<TerminalSession> for TerminalSessionDto {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ChatAttachmentInputDto { pub struct ChatAttachmentInputDto {
/// Local source path selected or staged by the driving adapter. /// Local source path selected or staged by the driving adapter.
pub path: String, #[serde(default)]
pub path: Option<String>,
/// Display filename for in-memory content, for example clipboard images.
#[serde(default)]
pub filename: Option<String>,
/// Base64-encoded raw content for in-memory attachment import.
#[serde(default)]
pub content_base64: Option<String>,
/// Optional MIME type known by the frontend/OS picker. /// Optional MIME type known by the frontend/OS picker.
#[serde(default)] #[serde(default)]
pub mime: Option<String>, pub mime: Option<String>,

View File

@ -1110,6 +1110,17 @@ pub trait ChatAttachmentStore: Send + Sync {
import: ChatAttachmentImport, import: ChatAttachmentImport,
) -> Result<ChatAttachment, ChatAttachmentStoreError>; ) -> Result<ChatAttachment, ChatAttachmentStoreError>;
/// Writes in-memory bytes into IdeA-managed durable attachment storage.
async fn import_from_bytes(
&self,
root: &ProjectPath,
project_id: ProjectId,
agent_id: AgentId,
session_id: SessionId,
bytes: &[u8],
import: ChatAttachmentImport,
) -> Result<ChatAttachment, ChatAttachmentStoreError>;
/// Lists attachments imported for a structured/chat session. /// Lists attachments imported for a structured/chat session.
async fn list_for_session( async fn list_for_session(
&self, &self,

View File

@ -72,6 +72,52 @@ fn validate_filename(filename: &str) -> Result<(), ChatAttachmentStoreError> {
Ok(()) Ok(())
} }
fn validate_size(size_bytes: u64) -> Result<(), ChatAttachmentStoreError> {
if size_bytes == 0 {
return Err(ChatAttachmentStoreError::Invalid(
"chat attachment must not be empty".to_owned(),
));
}
if size_bytes > CHAT_ATTACHMENT_MAX_BYTES {
return Err(ChatAttachmentStoreError::Invalid(format!(
"chat attachment exceeds {CHAT_ATTACHMENT_MAX_BYTES} bytes"
)));
}
Ok(())
}
fn build_attachment(
root: &ProjectPath,
project_id: ProjectId,
agent_id: AgentId,
session_id: SessionId,
size_bytes: u64,
import: ChatAttachmentImport,
) -> Result<(String, PathBuf, ChatAttachment), ChatAttachmentStoreError> {
validate_filename(&import.filename)?;
validate_size(size_bytes)?;
let stored_name = format!("{}-{}", import.id.as_str(), import.filename);
let dest = session_dir(root, session_id).join(&stored_name);
let storage_path = storage_relative(session_id, &stored_name);
let attachment = ChatAttachment {
id: import.id,
project_id,
agent_id,
session_id,
filename: import.filename,
mime: import.mime,
size_bytes,
source_kind: import.source_kind,
readable_path: readable_path(root, &storage_path),
storage_path,
created_at: import.created_at,
};
attachment
.validate()
.map_err(|err| ChatAttachmentStoreError::Invalid(err.to_string()))?;
Ok((stored_name, dest, attachment))
}
async fn write_metadata( async fn write_metadata(
root: &ProjectPath, root: &ProjectPath,
session_id: SessionId, session_id: SessionId,
@ -110,7 +156,6 @@ impl ChatAttachmentStore for FsChatAttachmentStore {
source: &LocalPath, source: &LocalPath,
import: ChatAttachmentImport, import: ChatAttachmentImport,
) -> Result<ChatAttachment, ChatAttachmentStoreError> { ) -> Result<ChatAttachment, ChatAttachmentStoreError> {
validate_filename(&import.filename)?;
let source_path = PathBuf::from(source.as_str()); let source_path = PathBuf::from(source.as_str());
let meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?; let meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?;
if !meta.is_file() { if !meta.is_file() {
@ -118,37 +163,38 @@ impl ChatAttachmentStore for FsChatAttachmentStore {
"chat attachment source must be a file".to_owned(), "chat attachment source must be a file".to_owned(),
)); ));
} }
if meta.len() > CHAT_ATTACHMENT_MAX_BYTES {
return Err(ChatAttachmentStoreError::Invalid(format!(
"chat attachment exceeds {CHAT_ATTACHMENT_MAX_BYTES} bytes"
)));
}
let dir = session_dir(root, session_id); let dir = session_dir(root, session_id);
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?; tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
let stored_name = format!("{}-{}", import.id.as_str(), import.filename); let (_, dest, attachment) =
let dest = dir.join(&stored_name); build_attachment(root, project_id, agent_id, session_id, meta.len(), import)?;
tokio::fs::copy(&source_path, &dest) tokio::fs::copy(&source_path, &dest)
.await .await
.map_err(io_error)?; .map_err(io_error)?;
let storage_path = storage_relative(session_id, &stored_name); write_metadata(root, session_id, &attachment).await?;
let attachment = ChatAttachment { Ok(attachment)
id: import.id, }
async fn import_from_bytes(
&self,
root: &ProjectPath,
project_id: ProjectId,
agent_id: AgentId,
session_id: SessionId,
bytes: &[u8],
import: ChatAttachmentImport,
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
let dir = session_dir(root, session_id);
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
let (_, dest, attachment) = build_attachment(
root,
project_id, project_id,
agent_id, agent_id,
session_id, session_id,
filename: import.filename, bytes.len() as u64,
mime: import.mime, import,
size_bytes: meta.len(), )?;
source_kind: import.source_kind, tokio::fs::write(dest, bytes).await.map_err(io_error)?;
readable_path: readable_path(root, &storage_path),
storage_path,
created_at: import.created_at,
};
attachment
.validate()
.map_err(|err| ChatAttachmentStoreError::Invalid(err.to_string()))?;
write_metadata(root, session_id, &attachment).await?; write_metadata(root, session_id, &attachment).await?;
Ok(attachment) Ok(attachment)
} }

View File

@ -72,6 +72,57 @@ async fn fs_chat_attachment_store_imports_outside_file_to_project_managed_path()
let _ = tokio::fs::remove_dir_all(outside_root).await; let _ = tokio::fs::remove_dir_all(outside_root).await;
} }
#[tokio::test]
async fn fs_chat_attachment_store_imports_bytes_to_project_managed_path() {
let project_root = temp_dir("project-bytes");
tokio::fs::create_dir_all(&project_root).await.unwrap();
let store = FsChatAttachmentStore::new();
let project_path = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap();
let project_id = ProjectId::new_random();
let agent_id = AgentId::new_random();
let session_id = SessionId::new_random();
let attachment_id = ChatAttachmentId::new("attach-bytes").unwrap();
let attachment = store
.import_from_bytes(
&project_path,
project_id,
agent_id,
session_id,
b"clipboard png bytes",
ChatAttachmentImport {
id: attachment_id,
filename: "clipboard.png".to_owned(),
mime: "image/png".to_owned(),
source_kind: ChatAttachmentSourceKind::Clipboard,
created_at: 43,
},
)
.await
.unwrap();
assert_eq!(attachment.project_id, project_id);
assert_eq!(attachment.agent_id, agent_id);
assert_eq!(attachment.session_id, session_id);
assert_eq!(attachment.filename, "clipboard.png");
assert_eq!(attachment.size_bytes, 19);
assert_eq!(attachment.source_kind, ChatAttachmentSourceKind::Clipboard);
assert!(attachment
.readable_path
.starts_with(&format!("{}/.ideai/attachments/", project_root.display())));
let stored_bytes = tokio::fs::read(&attachment.readable_path).await.unwrap();
assert_eq!(stored_bytes, b"clipboard png bytes");
let listed = store
.list_for_session(&project_path, session_id)
.await
.unwrap();
assert_eq!(listed, vec![attachment]);
let _ = tokio::fs::remove_dir_all(project_root).await;
}
#[tokio::test] #[tokio::test]
async fn fs_chat_attachment_store_rejects_directories() { async fn fs_chat_attachment_store_rejects_directories() {
let project_root = temp_dir("project-dir"); let project_root = temp_dir("project-dir");
@ -103,3 +154,32 @@ async fn fs_chat_attachment_store_rejects_directories() {
let _ = tokio::fs::remove_dir_all(project_root).await; let _ = tokio::fs::remove_dir_all(project_root).await;
let _ = tokio::fs::remove_dir_all(outside_root).await; let _ = tokio::fs::remove_dir_all(outside_root).await;
} }
#[tokio::test]
async fn fs_chat_attachment_store_rejects_empty_bytes() {
let project_root = temp_dir("project-empty-bytes");
tokio::fs::create_dir_all(&project_root).await.unwrap();
let store = FsChatAttachmentStore::new();
let err = store
.import_from_bytes(
&ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(),
ProjectId::new_random(),
AgentId::new_random(),
SessionId::new_random(),
&[],
ChatAttachmentImport {
id: ChatAttachmentId::new("attach-empty").unwrap(),
filename: "empty.png".to_owned(),
mime: "image/png".to_owned(),
source_kind: ChatAttachmentSourceKind::Clipboard,
created_at: 42,
},
)
.await
.unwrap_err();
assert!(matches!(err, ChatAttachmentStoreError::Invalid(_)));
let _ = tokio::fs::remove_dir_all(project_root).await;
}

View File

@ -171,6 +171,38 @@ describe("TauriAgentGateway invoke payloads", () => {
expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything()); expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything());
}); });
it("sendAgentChat forwards structured clipboard attachments to agent_send", async () => {
await new TauriAgentGateway().sendAgentChat(
"chat-session-1",
"",
vi.fn(),
{
attachments: [
{
filename: "clipboard.png",
contentBase64: "AQID",
mime: "image/png",
sourceKind: "clipboard",
},
],
},
);
expect(invoke).toHaveBeenCalledWith("agent_send", {
sessionId: "chat-session-1",
prompt: "",
attachments: [
{
filename: "clipboard.png",
contentBase64: "AQID",
mime: "image/png",
sourceKind: "clipboard",
},
],
onReply: expect.anything(),
});
});
it("launchAgentChat returns a handle only when launch_agent confirms cellKind chat", async () => { it("launchAgentChat returns a handle only when launch_agent confirms cellKind chat", async () => {
invoke.mockResolvedValueOnce({ invoke.mockResolvedValueOnce({
sessionId: "chat-session-1", sessionId: "chat-session-1",

View File

@ -32,6 +32,7 @@ import type {
OpenTerminalOptions, OpenTerminalOptions,
ReattachAgentChatResult, ReattachAgentChatResult,
ReattachResult, ReattachResult,
SendAgentChatOptions,
StoppedLiveAgent, StoppedLiveAgent,
TerminalHandle, TerminalHandle,
} from "@/ports"; } from "@/ports";
@ -261,12 +262,14 @@ export class TauriAgentGateway implements AgentGateway {
sessionId: string, sessionId: string,
prompt: string, prompt: string,
onChunk: (chunk: ReplyChunk) => void, onChunk: (chunk: ReplyChunk) => void,
options: SendAgentChatOptions = {},
): Promise<void> { ): Promise<void> {
const channel = new Channel<ReplyChunk>(); const channel = new Channel<ReplyChunk>();
channel.onmessage = onChunk; channel.onmessage = onChunk;
await invoke("agent_send", { await invoke("agent_send", {
sessionId, sessionId,
prompt, prompt,
...(options.attachments ? { attachments: options.attachments } : {}),
onReply: channel, onReply: channel,
}); });
} }

View File

@ -150,6 +150,7 @@ import type {
RemoteGateway, RemoteGateway,
ReviewPluginPackageInput, ReviewPluginPackageInput,
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileInput,
SendAgentChatOptions,
SkillGateway, SkillGateway,
StoppedLiveAgent, StoppedLiveAgent,
SystemGateway, SystemGateway,
@ -826,6 +827,7 @@ export class MockAgentGateway implements AgentGateway {
sessionId: string, sessionId: string,
prompt: string, prompt: string,
onChunk: (chunk: ReplyChunk) => void, onChunk: (chunk: ReplyChunk) => void,
_options: SendAgentChatOptions = {},
): Promise<void> { ): Promise<void> {
const chunks = this.chatScrollback.get(sessionId); const chunks = this.chatScrollback.get(sessionId);
if (!chunks) { if (!chunks) {

View File

@ -205,6 +205,159 @@ describe("CustomAgentChatView", () => {
expect(screen.getAllByText("hello agent")).toHaveLength(1); expect(screen.getAllByText("hello agent")).toHaveLength(1);
}); });
it("pastes a clipboard image as a removable preview chip", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const file = new File(["ignored"], "clipboard.png", { type: "image/png" });
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
});
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
clipboardData: {
items: [
{
kind: "file",
type: "image/png",
getAsFile: () => file,
},
],
getData: () => "",
},
});
expect(await screen.findByText("Fichier joint: clipboard.png")).toBeTruthy();
expect(screen.getByTestId("attachment-preview-clipboard.png")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "retirer clipboard.png" }));
await waitFor(() =>
expect(screen.queryByText("Fichier joint: clipboard.png")).toBeNull(),
);
expect(
(screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it("sends a pasted clipboard image without requiring text", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const file = new File(["ignored"], "paste-image.png", { type: "image/png" });
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
});
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
clipboardData: {
items: [
{
kind: "file",
type: "image/png",
getAsFile: () => file,
},
],
getData: () => "",
},
});
await screen.findByText("Fichier joint: paste-image.png");
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"",
expect.any(Function),
{
attachments: [
{
filename: "paste-image.png",
contentBase64: "AQID",
mime: "image/png",
sourceKind: "clipboard",
},
],
},
);
expect(screen.getByText("Pièce jointe")).toBeTruthy();
expect(screen.getByText("Fichier: paste-image.png")).toBeTruthy();
});
it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => { it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => {
const agent = { const agent = {
launchAgentChat: vi.fn(() => new Promise<never>(() => {})), launchAgentChat: vi.fn(() => new Promise<never>(() => {})),

View File

@ -6,11 +6,19 @@
* deliberately does not try to parse PTY bytes. * deliberately does not try to parse PTY bytes.
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ClipboardEvent,
} from "react";
import type { AgentProfile, GatewayError, ReplyChunk } from "@/domain"; import type { AgentProfile, GatewayError, ReplyChunk } from "@/domain";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import { Button, Spinner, cn } from "@/shared"; import { Button, Spinner, cn } from "@/shared";
import type { ChatAttachmentInput } from "@/ports";
export interface CustomAgentChatViewProps { export interface CustomAgentChatViewProps {
projectId: string; projectId: string;
@ -26,13 +34,20 @@ export interface CustomAgentChatViewProps {
} }
type ChatTurn = type ChatTurn =
| { role: "user"; text: string; attachment?: string } | { role: "user"; text: string; attachments?: string[] }
| { role: "agent"; text: string; pending?: boolean } | { role: "agent"; text: string; pending?: boolean }
| { role: "tool"; label: string } | { role: "tool"; label: string }
| { role: "final"; text: string } | { role: "final"; text: string }
| { role: "error"; text: string } | { role: "error"; text: string }
| { role: "unknown"; text: string }; | { role: "unknown"; text: string };
interface AttachmentDraft {
id: string;
label: string;
input: ChatAttachmentInput;
previewUrl?: string;
}
function describe(e: unknown): string { function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) { if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message); return String((e as GatewayError).message);
@ -99,6 +114,56 @@ function appendUserPrompt(turns: ChatTurn[], text: string): ChatTurn[] {
return [...turns, { role: "user", text }]; return [...turns, { role: "user", text }];
} }
function fileExtension(mime: string): string {
if (mime === "image/png") return "png";
if (mime === "image/jpeg") return "jpg";
if (mime === "image/gif") return "gif";
if (mime === "image/webp") return "webp";
return "img";
}
function pathBasename(path: string): string {
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
async function clipboardImageToAttachment(file: File): Promise<AttachmentDraft> {
const mime = file.type || "application/octet-stream";
const filename =
file.name ||
`clipboard-image-${Date.now()}.${fileExtension(mime)}`;
const contentBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
return {
id: `clipboard-${Date.now()}-${Math.random().toString(36).slice(2)}`,
label: filename,
previewUrl: mime.startsWith("image/")
? `data:${mime};base64,${contentBase64}`
: undefined,
input: {
filename,
contentBase64,
mime,
sourceKind: "clipboard",
},
};
}
function clipboardImageFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
return Array.from(event.clipboardData.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
}
function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] { function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
if (!isReplyRecord(raw)) { if (!isReplyRecord(raw)) {
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }]; return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
@ -148,7 +213,7 @@ export function CustomAgentChatView({
const [currentSession, setCurrentSession] = useState(sessionId); const [currentSession, setCurrentSession] = useState(sessionId);
const [externalSessionId, setExternalSessionId] = useState(sessionId); const [externalSessionId, setExternalSessionId] = useState(sessionId);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [attachment, setAttachment] = useState<string | null>(null); const [attachments, setAttachments] = useState<AttachmentDraft[]>([]);
const [opening, setOpening] = useState(false); const [opening, setOpening] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -412,26 +477,76 @@ export function CustomAgentChatView({
const canSend = useMemo( const canSend = useMemo(
() => () =>
supported && supported &&
Boolean(draft.trim()) && (Boolean(draft.trim()) || attachments.length > 0) &&
!busy && !busy &&
!opening, !opening,
[supported, draft, busy, opening], [supported, draft, attachments.length, busy, opening],
); );
async function pickAttachment() { async function pickAttachment() {
const path = await system.pickFile(); const path = await system.pickFile();
if (path) setAttachment(path); if (path) {
setAttachments((prev) => [
...prev,
{
id: `path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
label: pathBasename(path),
input: { path, sourceKind: "localFile" },
},
]);
}
}
async function pasteClipboardImages(
event: ClipboardEvent<HTMLTextAreaElement>,
) {
const files = clipboardImageFiles(event);
if (files.length === 0) return;
event.preventDefault();
const pastedText = event.clipboardData.getData("text/plain");
const selectionStart = event.currentTarget.selectionStart;
const selectionEnd = event.currentTarget.selectionEnd;
try {
const nextAttachments = await Promise.all(
files.map(clipboardImageToAttachment),
);
setAttachments((prev) => [...prev, ...nextAttachments]);
if (pastedText) {
setDraft((prev) =>
prev.slice(0, selectionStart) +
pastedText +
prev.slice(selectionEnd),
);
}
} catch (e) {
setError(describe(e));
}
} }
async function send() { async function send() {
const text = draft.trim(); const text = draft.trim();
if (!canSend || !agent.sendAgentChat) return; if (!canSend || !agent.sendAgentChat) return;
const prompt = attachment ? `${text}\n\n[Fichier joint: ${attachment}]` : text; const outgoingAttachments = attachments;
const attachmentInputs = outgoingAttachments.map((item) => item.input);
const attachmentLabels = outgoingAttachments.map((item) => item.label);
const displayText = text || "Pièce jointe";
setDraft(""); setDraft("");
setAttachment(null); setAttachments([]);
setBusy(true); setBusy(true);
setError(null); setError(null);
setTurns((prev) => [...prev, { role: "user", text, attachment: attachment ?? undefined }]); setTurns((prev) => [
...prev,
{
role: "user",
text: displayText,
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
},
]);
const sendTurn = (sid: string) =>
attachmentInputs.length > 0
? agent.sendAgentChat!(sid, text, receive, { attachments: attachmentInputs })
: agent.sendAgentChat!(sid, text, receive);
try { try {
const sid = const sid =
currentSession ?? currentSession ??
@ -439,7 +554,7 @@ export function CustomAgentChatView({
applyScrollback: false, applyScrollback: false,
retryAttachNotFound: true, retryAttachNotFound: true,
})); }));
await agent.sendAgentChat(sid, prompt, receive); await sendTurn(sid);
} catch (e) { } catch (e) {
if (isNotFound(e)) { if (isNotFound(e)) {
try { try {
@ -449,7 +564,7 @@ export function CustomAgentChatView({
applyScrollback: false, applyScrollback: false,
retryAttachNotFound: true, retryAttachNotFound: true,
}); });
await agent.sendAgentChat(recoveredSession, prompt, receive); await sendTurn(recoveredSession);
return; return;
} catch (recoveryError) { } catch (recoveryError) {
setBusy(false); setBusy(false);
@ -548,13 +663,37 @@ export function CustomAgentChatView({
data-testid="custom-agent-chat-composer" data-testid="custom-agent-chat-composer"
className="flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2" className="flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2"
> >
{attachment && ( {attachments.length > 0 && (
<div className="flex items-center justify-between gap-2 rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted"> <div className="flex min-w-0 flex-wrap gap-2">
<span className="truncate">Fichier joint: {attachment}</span> {attachments.map((item) => (
<button type="button" className="text-content" onClick={() => setAttachment(null)}> <div
key={item.id}
className="flex max-w-full items-center gap-2 rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted"
>
{item.previewUrl && (
<img
src={item.previewUrl}
alt=""
data-testid={`attachment-preview-${item.label}`}
className="h-8 w-8 shrink-0 rounded border border-border object-cover"
/>
)}
<span className="min-w-0 truncate">Fichier joint: {item.label}</span>
<button
type="button"
aria-label={`retirer ${item.label}`}
className="shrink-0 text-content"
onClick={() =>
setAttachments((prev) =>
prev.filter((candidate) => candidate.id !== item.id),
)
}
>
Retirer Retirer
</button> </button>
</div> </div>
))}
</div>
)} )}
<div className="flex min-w-0 items-end gap-2"> <div className="flex min-w-0 items-end gap-2">
<textarea <textarea
@ -568,6 +707,7 @@ export function CustomAgentChatView({
disabled={!supported || opening || busy} disabled={!supported || opening || busy}
placeholder="Message à l'agent…" placeholder="Message à l'agent…"
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onPaste={(e) => void pasteClipboardImages(e)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@ -640,8 +780,14 @@ function ChatBubble({ turn }: { turn: ChatTurn }) {
)} )}
> >
<p className="whitespace-pre-wrap break-words">{turn.text}</p> <p className="whitespace-pre-wrap break-words">{turn.text}</p>
{user && turn.attachment && ( {user && turn.attachments && turn.attachments.length > 0 && (
<p className="mt-1 truncate text-xs text-muted">Fichier: {turn.attachment}</p> <div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
{turn.attachments.map((attachment) => (
<p key={attachment} className="truncate">
Fichier: {attachment}
</p>
))}
</div>
)} )}
{!user && turn.pending && ( {!user && turn.pending && (
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted"> <span className="mt-1 inline-flex items-center gap-1 text-xs text-muted">

View File

@ -145,6 +145,25 @@ export interface CreateAgentInput {
initialContent?: string; initialContent?: string;
} }
export type ChatAttachmentSourceKind =
| "localFile"
| "clipboard"
| "dragDrop"
| "other";
/** Structured attachment intent accepted by `agent_send`. */
export interface ChatAttachmentInput {
path?: string;
filename?: string;
contentBase64?: string;
mime?: string;
sourceKind?: ChatAttachmentSourceKind;
}
export interface SendAgentChatOptions {
attachments?: ChatAttachmentInput[];
}
/** /**
* Best-effort enriched details about a CLI conversation (T7), used to enrich the * Best-effort enriched details about a CLI conversation (T7), used to enrich the
* resume popup. Both fields are optional: a missing inspector or a missing * resume popup. Both fields are optional: a missing inspector or a missing
@ -284,6 +303,7 @@ export interface AgentGateway {
sessionId: string, sessionId: string,
prompt: string, prompt: string,
onChunk: (chunk: ReplyChunk) => void, onChunk: (chunk: ReplyChunk) => void,
options?: SendAgentChatOptions,
): Promise<void>; ): Promise<void>;
/** Interrupts only the current turn of a live structured session. */ /** Interrupts only the current turn of a live structured session. */
cancelAgentChat?(sessionId: string): Promise<void>; cancelAgentChat?(sessionId: string): Promise<void>;