diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 0d66f23..6ebc6cd 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -4,7 +4,7 @@ //! [`AppState`], map `Result` to `Result`. 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 serde::{Deserialize, Serialize}; use tauri::ipc::Channel; @@ -2326,22 +2326,17 @@ async fn import_chat_attachments_for_session( for path in legacy_paths { if seen_paths.insert(path.clone()) { items.push(application::ImportChatAttachmentItem { - path, + source: application::ImportChatAttachmentSource::Path(path), mime: None, source_kind: domain::ChatAttachmentSourceKind::LocalFile, }); } } for attachment in attachments { - if seen_paths.insert(attachment.path.clone()) { - items.push(application::ImportChatAttachmentItem { - path: attachment.path, - mime: attachment.mime, - source_kind: attachment - .source_kind - .unwrap_or(domain::ChatAttachmentSourceKind::LocalFile), - }); - } + let Some(item) = chat_attachment_input_to_item(attachment, &mut seen_paths)? else { + continue; + }; + items.push(item); } let output = state @@ -2361,6 +2356,71 @@ async fn import_chat_attachments_for_session( .collect()) } +fn chat_attachment_input_to_item( + attachment: ChatAttachmentInputDto, + seen_paths: &mut std::collections::HashSet, +) -> Result, 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, 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 { if attachments.is_empty() { return prompt.to_owned(); diff --git a/crates/app-tauri/tests/dto_chat.rs b/crates/app-tauri/tests/dto_chat.rs index 6d09abe..ff140cd 100644 --- a/crates/app-tauri/tests/dto_chat.rs +++ b/crates/app-tauri/tests/dto_chat.rs @@ -119,7 +119,26 @@ fn chat_attachment_input_dto_deserialises_camel_case() { })) .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.source_kind, Some(ChatAttachmentSourceKind::Clipboard)); } @@ -154,6 +173,11 @@ fn import_chat_attachments_request_response_use_camel_case() { "path": "/tmp/picked.png", "mime": "image/png", "sourceKind": "dragDrop" + }, { + "filename": "clipboard.png", + "contentBase64": "cG5nIGJ5dGVz", + "mime": "image/png", + "sourceKind": "clipboard" }] })) .unwrap(); @@ -164,6 +188,10 @@ fn import_chat_attachments_request_response_use_camel_case() { request.attachments[0].source_kind, Some(ChatAttachmentSourceKind::DragDrop) ); + assert_eq!( + request.attachments[1].content_base64.as_deref(), + Some("cG5nIGJ5dGVz") + ); let response = ImportChatAttachmentsResponseDto { attachments: vec![ChatAttachmentDto { diff --git a/crates/application/src/chat_attachments.rs b/crates/application/src/chat_attachments.rs index 0c3e78e..7eefbff 100644 --- a/crates/application/src/chat_attachments.rs +++ b/crates/application/src/chat_attachments.rs @@ -30,14 +30,28 @@ pub struct ImportChatAttachmentsInput { /// One source attachment import request. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ImportChatAttachmentItem { - /// Local source path supplied by the driving adapter. - pub path: String, + /// Source payload supplied by the driving adapter. + pub source: ImportChatAttachmentSource, /// Optional MIME type supplied by the driving adapter. pub mime: Option, /// Origin kind. 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, + }, +} + /// Output of [`ImportChatAttachments::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ImportChatAttachmentsOutput { @@ -70,28 +84,44 @@ impl ImportChatAttachments { ) -> Result { let mut imported = Vec::with_capacity(input.attachments.len()); for item in input.attachments { - let filename = filename_from_path(&item.path)?; + let filename = filename_from_source(&item.source)?; validate_attachment_filename(&filename)?; let mime = sanitize_mime(item.mime.as_deref(), &filename)?; let id = ChatAttachmentId::new(self.ids.new_uuid().to_string()) .map_err(|err| AppError::Invalid(err.to_string()))?; - let attachment = self - .store - .import_from_path( - &input.project.root, - input.project.id, - input.agent_id, - input.session_id, - &LocalPath::new(item.path), - ChatAttachmentImport { - id, - filename, - mime, - source_kind: item.source_kind, - created_at: now(&self.clock), - }, - ) - .await?; + let import = ChatAttachmentImport { + id, + filename, + mime, + source_kind: item.source_kind, + 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? + } + 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); } Ok(ImportChatAttachmentsOutput { @@ -112,6 +142,13 @@ fn filename_from_path(path: &str) -> Result { .ok_or_else(|| AppError::Invalid("invalid attachment filename".to_owned())) } +fn filename_from_source(source: &ImportChatAttachmentSource) -> Result { + match source { + ImportChatAttachmentSource::Path(path) => filename_from_path(path), + ImportChatAttachmentSource::Bytes { filename, .. } => Ok(filename.clone()), + } +} + fn validate_attachment_filename(filename: &str) -> Result<(), AppError> { let lowered = filename.to_ascii_lowercase(); let blocked = [ diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 7663ef3..c733ccc 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -72,8 +72,8 @@ pub use background::{ SpawnBackgroundCommandOutput, }; pub use chat_attachments::{ - ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput, - ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES, + ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments, + ImportChatAttachmentsInput, ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES, }; pub use conversation::{ ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn, diff --git a/crates/application/tests/chat_attachments.rs b/crates/application/tests/chat_attachments.rs index 9c440e6..b9a7800 100644 --- a/crates/application/tests/chat_attachments.rs +++ b/crates/application/tests/chat_attachments.rs @@ -10,11 +10,15 @@ use domain::{ }; use uuid::Uuid; -use application::{ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput}; +use application::{ + ImportChatAttachmentItem, ImportChatAttachmentSource, ImportChatAttachments, + ImportChatAttachmentsInput, +}; #[derive(Default)] struct FakeChatAttachments { - calls: Mutex>, + path_calls: Mutex>, + bytes_calls: Mutex, ChatAttachmentImport)>>, } #[async_trait] @@ -28,7 +32,7 @@ impl ChatAttachmentStore for FakeChatAttachments { source: &LocalPath, import: ChatAttachmentImport, ) -> Result { - self.calls + self.path_calls .lock() .unwrap() .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 { + 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( &self, _root: &ProjectPath, @@ -113,7 +156,7 @@ async fn import_chat_attachments_allocates_metadata_and_uses_store_port() { agent_id, session_id, attachments: vec![ImportChatAttachmentItem { - path: "/outside/photo.png".to_owned(), + source: ImportChatAttachmentSource::Path("/outside/photo.png".to_owned()), mime: None, source_kind: ChatAttachmentSourceKind::Clipboard, }], @@ -134,11 +177,49 @@ async fn import_chat_attachments_allocates_metadata_and_uses_store_port() { .readable_path .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[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, + 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] async fn import_chat_attachments_rejects_executable_sources_before_store_call() { 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(), session_id: SessionId::new_random(), attachments: vec![ImportChatAttachmentItem { - path: "/outside/run.sh".to_owned(), + source: ImportChatAttachmentSource::Path("/outside/run.sh".to_owned()), mime: None, source_kind: ChatAttachmentSourceKind::LocalFile, }], @@ -163,5 +244,6 @@ async fn import_chat_attachments_rejects_executable_sources_before_store_call() .unwrap_err(); 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()); } diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 9b2b07c..22e9647 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -2971,7 +2971,14 @@ impl From for TerminalSessionDto { #[serde(rename_all = "camelCase")] pub struct ChatAttachmentInputDto { /// Local source path selected or staged by the driving adapter. - pub path: String, + #[serde(default)] + pub path: Option, + /// Display filename for in-memory content, for example clipboard images. + #[serde(default)] + pub filename: Option, + /// Base64-encoded raw content for in-memory attachment import. + #[serde(default)] + pub content_base64: Option, /// Optional MIME type known by the frontend/OS picker. #[serde(default)] pub mime: Option, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 92a891c..7a8cd94 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1110,6 +1110,17 @@ pub trait ChatAttachmentStore: Send + Sync { import: ChatAttachmentImport, ) -> Result; + /// 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; + /// Lists attachments imported for a structured/chat session. async fn list_for_session( &self, diff --git a/crates/infrastructure/src/chat_attachments.rs b/crates/infrastructure/src/chat_attachments.rs index 1774e90..bdb3a14 100644 --- a/crates/infrastructure/src/chat_attachments.rs +++ b/crates/infrastructure/src/chat_attachments.rs @@ -72,6 +72,52 @@ fn validate_filename(filename: &str) -> Result<(), ChatAttachmentStoreError> { 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( root: &ProjectPath, session_id: SessionId, @@ -110,7 +156,6 @@ impl ChatAttachmentStore for FsChatAttachmentStore { source: &LocalPath, import: ChatAttachmentImport, ) -> Result { - validate_filename(&import.filename)?; let source_path = PathBuf::from(source.as_str()); let meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?; if !meta.is_file() { @@ -118,37 +163,38 @@ impl ChatAttachmentStore for FsChatAttachmentStore { "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); tokio::fs::create_dir_all(&dir).await.map_err(io_error)?; - let stored_name = format!("{}-{}", import.id.as_str(), import.filename); - let dest = dir.join(&stored_name); + let (_, dest, attachment) = + build_attachment(root, project_id, agent_id, session_id, meta.len(), import)?; tokio::fs::copy(&source_path, &dest) .await .map_err(io_error)?; - let storage_path = storage_relative(session_id, &stored_name); - let attachment = ChatAttachment { - id: import.id, + write_metadata(root, session_id, &attachment).await?; + Ok(attachment) + } + + async fn import_from_bytes( + &self, + root: &ProjectPath, + project_id: ProjectId, + agent_id: AgentId, + session_id: SessionId, + bytes: &[u8], + import: ChatAttachmentImport, + ) -> Result { + 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, agent_id, session_id, - filename: import.filename, - mime: import.mime, - size_bytes: meta.len(), - 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()))?; + bytes.len() as u64, + import, + )?; + tokio::fs::write(dest, bytes).await.map_err(io_error)?; write_metadata(root, session_id, &attachment).await?; Ok(attachment) } diff --git a/crates/infrastructure/tests/chat_attachments.rs b/crates/infrastructure/tests/chat_attachments.rs index f534c61..9d2cead 100644 --- a/crates/infrastructure/tests/chat_attachments.rs +++ b/crates/infrastructure/tests/chat_attachments.rs @@ -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; } +#[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] async fn fs_chat_attachment_store_rejects_directories() { 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(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; +} diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index d1ecfb0..216d4b2 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -171,6 +171,38 @@ describe("TauriAgentGateway invoke payloads", () => { 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 () => { invoke.mockResolvedValueOnce({ sessionId: "chat-session-1", diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 361d809..e09dd9b 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -32,6 +32,7 @@ import type { OpenTerminalOptions, ReattachAgentChatResult, ReattachResult, + SendAgentChatOptions, StoppedLiveAgent, TerminalHandle, } from "@/ports"; @@ -261,12 +262,14 @@ export class TauriAgentGateway implements AgentGateway { sessionId: string, prompt: string, onChunk: (chunk: ReplyChunk) => void, + options: SendAgentChatOptions = {}, ): Promise { const channel = new Channel(); channel.onmessage = onChunk; await invoke("agent_send", { sessionId, prompt, + ...(options.attachments ? { attachments: options.attachments } : {}), onReply: channel, }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index d3d8473..ea9b4dc 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -150,6 +150,7 @@ import type { RemoteGateway, ReviewPluginPackageInput, SaveOpenCodeProviderProfileInput, + SendAgentChatOptions, SkillGateway, StoppedLiveAgent, SystemGateway, @@ -826,6 +827,7 @@ export class MockAgentGateway implements AgentGateway { sessionId: string, prompt: string, onChunk: (chunk: ReplyChunk) => void, + _options: SendAgentChatOptions = {}, ): Promise { const chunks = this.chatScrollback.get(sessionId); if (!chunks) { diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 76263b3..dcbb9d1 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -205,6 +205,159 @@ describe("CustomAgentChatView", () => { 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(() => {})), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + 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( + null) }, + } as unknown as Gateways} + > + + , + ); + + 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 () => { const agent = { launchAgentChat: vi.fn(() => new Promise(() => {})), diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 73aa676..2b91b18 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -6,11 +6,19 @@ * 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 { useGateways } from "@/app/di"; import { Button, Spinner, cn } from "@/shared"; +import type { ChatAttachmentInput } from "@/ports"; export interface CustomAgentChatViewProps { projectId: string; @@ -26,13 +34,20 @@ export interface CustomAgentChatViewProps { } type ChatTurn = - | { role: "user"; text: string; attachment?: string } + | { role: "user"; text: string; attachments?: string[] } | { role: "agent"; text: string; pending?: boolean } | { role: "tool"; label: string } | { role: "final"; text: string } | { role: "error"; text: string } | { role: "unknown"; text: string }; +interface AttachmentDraft { + id: string; + label: string; + input: ChatAttachmentInput; + previewUrl?: string; +} + function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); @@ -99,6 +114,56 @@ function appendUserPrompt(turns: ChatTurn[], text: string): ChatTurn[] { 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 { + 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): 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[] { if (!isReplyRecord(raw)) { return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }]; @@ -148,7 +213,7 @@ export function CustomAgentChatView({ const [currentSession, setCurrentSession] = useState(sessionId); const [externalSessionId, setExternalSessionId] = useState(sessionId); const [draft, setDraft] = useState(""); - const [attachment, setAttachment] = useState(null); + const [attachments, setAttachments] = useState([]); const [opening, setOpening] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -412,26 +477,76 @@ export function CustomAgentChatView({ const canSend = useMemo( () => supported && - Boolean(draft.trim()) && + (Boolean(draft.trim()) || attachments.length > 0) && !busy && !opening, - [supported, draft, busy, opening], + [supported, draft, attachments.length, busy, opening], ); async function pickAttachment() { 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, + ) { + 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() { const text = draft.trim(); 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(""); - setAttachment(null); + setAttachments([]); setBusy(true); 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 { const sid = currentSession ?? @@ -439,7 +554,7 @@ export function CustomAgentChatView({ applyScrollback: false, retryAttachNotFound: true, })); - await agent.sendAgentChat(sid, prompt, receive); + await sendTurn(sid); } catch (e) { if (isNotFound(e)) { try { @@ -449,7 +564,7 @@ export function CustomAgentChatView({ applyScrollback: false, retryAttachNotFound: true, }); - await agent.sendAgentChat(recoveredSession, prompt, receive); + await sendTurn(recoveredSession); return; } catch (recoveryError) { setBusy(false); @@ -548,12 +663,36 @@ export function CustomAgentChatView({ data-testid="custom-agent-chat-composer" className="flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2" > - {attachment && ( -
- Fichier joint: {attachment} - + {attachments.length > 0 && ( +
+ {attachments.map((item) => ( +
+ {item.previewUrl && ( + + )} + Fichier joint: {item.label} + +
+ ))}
)}
@@ -568,6 +707,7 @@ export function CustomAgentChatView({ disabled={!supported || opening || busy} placeholder="Message à l'agent…" onChange={(e) => setDraft(e.target.value)} + onPaste={(e) => void pasteClipboardImages(e)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -640,8 +780,14 @@ function ChatBubble({ turn }: { turn: ChatTurn }) { )} >

{turn.text}

- {user && turn.attachment && ( -

Fichier: {turn.attachment}

+ {user && turn.attachments && turn.attachments.length > 0 && ( +
+ {turn.attachments.map((attachment) => ( +

+ Fichier: {attachment} +

+ ))} +
)} {!user && turn.pending && ( diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 0719912..7eb02e6 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -145,6 +145,25 @@ export interface CreateAgentInput { 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 * resume popup. Both fields are optional: a missing inspector or a missing @@ -284,6 +303,7 @@ export interface AgentGateway { sessionId: string, prompt: string, onChunk: (chunk: ReplyChunk) => void, + options?: SendAgentChatOptions, ): Promise; /** Interrupts only the current turn of a live structured session. */ cancelAgentChat?(sessionId: string): Promise;