diff --git a/ui/goose2/scripts/check-file-sizes.mjs b/ui/goose2/scripts/check-file-sizes.mjs index 1a980cee4b66..0c45b763c29e 100644 --- a/ui/goose2/scripts/check-file-sizes.mjs +++ b/ui/goose2/scripts/check-file-sizes.mjs @@ -65,6 +65,11 @@ const EXCEPTIONS = { justification: "Session prepare/load/list logic, working-dir updates, wait_for_replay_drain helper with iteration cap, and composite prepared-session reuse remain colocated while ACP session ownership stabilizes.", }, + "src-tauri/src/commands/system.rs": { + limit: 640, + justification: + "Desktop system commands still centralize file mentions, attachment inspection, platform-aware path dedupe, guarded image loading, and export helpers in one Tauri command surface.", + }, }; // Directories excluded from size checks (imported library code) diff --git a/ui/goose2/src-tauri/Cargo.lock b/ui/goose2/src-tauri/Cargo.lock index 36dc70ad6bce..ddaef39fde56 100644 --- a/ui/goose2/src-tauri/Cargo.lock +++ b/ui/goose2/src-tauri/Cargo.lock @@ -1754,6 +1754,7 @@ dependencies = [ "acp-client", "agent-client-protocol", "async-trait", + "base64 0.22.1", "chrono", "dirs", "doctor", @@ -1762,6 +1763,7 @@ dependencies = [ "ignore", "keyring", "log", + "mime_guess", "serde", "serde_json", "serde_yaml", @@ -2578,6 +2580,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -5341,6 +5353,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/ui/goose2/src-tauri/Cargo.toml b/ui/goose2/src-tauri/Cargo.toml index 8bc4e5eff515..e2629a7a13d4 100644 --- a/ui/goose2/src-tauri/Cargo.toml +++ b/ui/goose2/src-tauri/Cargo.toml @@ -40,6 +40,8 @@ tokio-tungstenite = "0.21.0" acp-client = { git = "https://github.com/block/builderbot", rev = "db184d20cb48e0c90bbd3fea4a4a871fc9d8a6ad" } doctor = { git = "https://github.com/block/builderbot", rev = "8e1c3ec145edc0df5f04b4427cfd758378036862" } ignore = "0.4.25" +base64 = "0.22" +mime_guess = "2" [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3", features = ["apple-native"] } diff --git a/ui/goose2/src-tauri/src/commands/system.rs b/ui/goose2/src-tauri/src/commands/system.rs index 3791e4da22f4..6dc45adcb0ea 100644 --- a/ui/goose2/src-tauri/src/commands/system.rs +++ b/ui/goose2/src-tauri/src/commands/system.rs @@ -1,3 +1,4 @@ +use base64::Engine; use serde::Serialize; use tauri::Window; use tauri_plugin_dialog::DialogExt; @@ -9,6 +10,7 @@ use std::path::{Path, PathBuf}; const DEFAULT_FILE_MENTION_LIMIT: usize = 1500; const MAX_FILE_MENTION_LIMIT: usize = 5000; const MAX_SCAN_DEPTH: usize = 8; +const MAX_IMAGE_ATTACHMENT_BYTES: u64 = 20 * 1024 * 1024; #[derive(Serialize, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -18,6 +20,23 @@ pub struct FileTreeEntry { pub kind: String, } +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentPathInfo { + pub name: String, + pub path: String, + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, +} + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ImageAttachmentPayload { + pub base64: String, + pub mime_type: String, +} + #[tauri::command] pub fn get_home_dir() -> Result { let home_dir = dirs::home_dir().ok_or("Could not determine home directory")?; @@ -81,31 +100,18 @@ fn read_directory_entries(path: &Path) -> Result, String> { .map_err(|error| format!("Failed to read directory '{}': {}", path.display(), error))?; for entry in reader { - let entry = entry - .map_err(|error| format!("Failed to read directory '{}': {}", path.display(), error))?; + let Ok(entry) = entry else { + continue; + }; let name = entry.file_name().to_string_lossy().into_owned(); if name == ".git" { continue; } + let Some(file_tree_entry) = build_file_tree_entry(entry.path(), name) else { + continue; + }; - let file_type = entry.file_type().map_err(|error| { - format!( - "Failed to inspect directory entry '{}' in '{}': {}", - name, - path.display(), - error - ) - })?; - - entries.push(FileTreeEntry { - name, - path: entry.path().to_string_lossy().into_owned(), - kind: if file_type.is_dir() { - "directory".to_string() - } else { - "file".to_string() - }, - }); + entries.push(file_tree_entry); } entries.sort_by(|a, b| { @@ -120,11 +126,138 @@ fn read_directory_entries(path: &Path) -> Result, String> { Ok(entries) } +fn build_file_tree_entry(path: PathBuf, name: String) -> Option { + let metadata = fs::symlink_metadata(&path).ok()?; + let file_type = metadata.file_type(); + + Some(FileTreeEntry { + name, + path: path.to_string_lossy().into_owned(), + kind: if file_type.is_dir() { + "directory".to_string() + } else { + "file".to_string() + }, + }) +} + #[tauri::command] pub fn list_directory_entries(path: String) -> Result, String> { read_directory_entries(Path::new(&path)) } +fn inspect_attachment_path(path: &Path) -> Result { + if !path.exists() { + return Err(format!( + "Attachment path does not exist: {}", + path.display() + )); + } + + let metadata = fs::metadata(path) + .map_err(|error| format!("Failed to inspect '{}': {}", path.display(), error))?; + let name = path + .file_name() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string_lossy().into_owned()); + + Ok(AttachmentPathInfo { + name, + path: path.to_string_lossy().into_owned(), + kind: if metadata.is_dir() { + "directory".to_string() + } else { + "file".to_string() + }, + mime_type: if metadata.is_file() { + mime_guess::from_path(path) + .first_raw() + .map(std::borrow::ToOwned::to_owned) + } else { + None + }, + }) +} + +fn normalized_path_key(path: &Path) -> String { + if let Ok(canonical) = path.canonicalize() { + return canonical.to_string_lossy().into_owned(); + } + + let raw = path.to_string_lossy().into_owned(); + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + raw.to_lowercase() + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + raw + } +} + +fn normalize_attachment_paths(paths: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut normalized = Vec::new(); + + for raw_path in paths { + let trimmed = raw_path.trim(); + if trimmed.is_empty() { + continue; + } + + let path = PathBuf::from(trimmed); + let key = normalized_path_key(&path); + if seen.insert(key) { + normalized.push(path); + } + } + + normalized +} + +#[tauri::command] +pub fn inspect_attachment_paths(paths: Vec) -> Result, String> { + let mut attachments = Vec::new(); + + for path in normalize_attachment_paths(paths) { + if let Ok(attachment) = inspect_attachment_path(&path) { + attachments.push(attachment); + } + } + + Ok(attachments) +} + +#[tauri::command] +pub fn read_image_attachment(path: String) -> Result { + let attachment = inspect_attachment_path(Path::new(&path))?; + let mime_type = attachment + .mime_type + .ok_or_else(|| format!("Unable to determine image type for '{}'", attachment.path))?; + + if !mime_type.starts_with("image/") { + return Err(format!("Attachment is not an image: {}", attachment.path)); + } + + let metadata = fs::metadata(&attachment.path) + .map_err(|error| format!("Failed to inspect image '{}': {}", attachment.path, error))?; + if metadata.len() > MAX_IMAGE_ATTACHMENT_BYTES { + return Err(format!( + "Image attachment '{}' exceeds the {} MB limit", + attachment.path, + MAX_IMAGE_ATTACHMENT_BYTES / (1024 * 1024) + )); + } + + let bytes = fs::read(&attachment.path) + .map_err(|error| format!("Failed to read image '{}': {}", attachment.path, error))?; + + Ok(ImageAttachmentPayload { + base64: base64::engine::general_purpose::STANDARD.encode(bytes), + mime_type, + }) +} + fn normalize_roots(roots: Vec) -> Vec { let mut dedup = HashSet::new(); let mut normalized = Vec::new(); @@ -134,7 +267,7 @@ fn normalize_roots(roots: Vec) -> Vec { continue; } let path = PathBuf::from(trimmed); - let key = path.to_string_lossy().to_lowercase(); + let key = normalized_path_key(&path); if dedup.insert(key) { normalized.push(path); } @@ -195,7 +328,7 @@ fn scan_files_for_mentions(roots: Vec, max_results: Option) -> Ve continue; } let path_str = entry.path().to_string_lossy().to_string(); - let dedup_key = path_str.to_lowercase(); + let dedup_key = normalized_path_key(entry.path()); if seen.insert(dedup_key) { files.push(path_str); } @@ -217,10 +350,16 @@ pub async fn list_files_for_mentions( #[cfg(test)] mod tests { - use super::{read_directory_entries, scan_files_for_mentions}; + use super::{ + build_file_tree_entry, inspect_attachment_path, inspect_attachment_paths, + normalize_attachment_paths, normalize_roots, read_directory_entries, read_image_attachment, + scan_files_for_mentions, MAX_IMAGE_ATTACHMENT_BYTES, + }; + use base64::Engine; use std::fs; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; use std::process::Command; use tempfile::tempdir; @@ -340,6 +479,16 @@ mod tests { assert!(error.contains("Directory does not exist")); } + #[test] + fn build_file_tree_entry_skips_missing_children() { + let dir = tempdir().expect("tempdir"); + let missing = dir.path().join("missing.ts"); + + let entry = build_file_tree_entry(missing, "missing.ts".into()); + + assert_eq!(entry, None); + } + #[test] #[cfg(unix)] fn list_directory_entries_errors_for_unreadable_directories() { @@ -360,4 +509,118 @@ mod tests { assert!(error.contains("Failed to read directory")); } + + #[test] + fn inspects_file_and_directory_attachments() { + let dir = tempdir().expect("tempdir"); + let root = dir.path(); + let folder = root.join("screenshots"); + let file = root.join("report.txt"); + + fs::create_dir_all(&folder).expect("folder"); + fs::write(&file, "hello").expect("file"); + + let inspected_dir = inspect_attachment_path(&folder).expect("directory"); + let inspected_file = inspect_attachment_path(&file).expect("file"); + + assert_eq!(inspected_dir.kind, "directory"); + assert_eq!(inspected_dir.name, "screenshots"); + assert_eq!(inspected_dir.mime_type, None); + + assert_eq!(inspected_file.kind, "file"); + assert_eq!(inspected_file.name, "report.txt"); + assert_eq!(inspected_file.mime_type.as_deref(), Some("text/plain")); + } + + #[test] + fn reads_image_attachment_payloads() { + let dir = tempdir().expect("tempdir"); + let image = dir.path().join("pixel.png"); + let png_bytes = base64::engine::general_purpose::STANDARD + .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9sU4nS0AAAAASUVORK5CYII=") + .expect("decode png"); + + fs::write(&image, png_bytes).expect("png file"); + + let payload = read_image_attachment(image.to_string_lossy().into_owned()).expect("payload"); + + assert_eq!(payload.mime_type, "image/png"); + assert!(!payload.base64.is_empty()); + } + + #[test] + fn dedupes_attachment_paths_using_platform_path_rules() { + let normalized = normalize_attachment_paths(vec![ + "/tmp/Readme.md".into(), + "/tmp/README.md".into(), + "/tmp/Readme.md".into(), + ]); + + if cfg!(any(target_os = "macos", target_os = "windows")) { + assert_eq!(normalized, vec![PathBuf::from("/tmp/Readme.md")]); + } else { + assert_eq!( + normalized, + vec![ + PathBuf::from("/tmp/Readme.md"), + PathBuf::from("/tmp/README.md") + ] + ); + } + } + + #[test] + fn skips_invalid_attachment_paths_without_dropping_valid_ones() { + let dir = tempdir().expect("tempdir"); + let valid = dir.path().join("report.txt"); + let missing = dir.path().join("missing.txt"); + fs::write(&valid, "hello").expect("file"); + + let attachments = inspect_attachment_paths(vec![ + valid.to_string_lossy().into_owned(), + missing.to_string_lossy().into_owned(), + ]) + .expect("attachments"); + + assert_eq!(attachments.len(), 1); + assert_eq!(attachments[0].name, "report.txt"); + assert_eq!(attachments[0].kind, "file"); + } + + #[test] + fn dedupes_mention_roots_using_platform_path_rules() { + let normalized = normalize_roots(vec![ + "/tmp/Workspace".into(), + "/tmp/workspace".into(), + "/tmp/Workspace".into(), + ]); + + if cfg!(any(target_os = "macos", target_os = "windows")) { + assert_eq!(normalized, vec![PathBuf::from("/tmp/Workspace")]); + } else { + assert_eq!( + normalized, + vec![ + PathBuf::from("/tmp/Workspace"), + PathBuf::from("/tmp/workspace") + ] + ); + } + } + + #[test] + fn rejects_oversized_image_attachment_payloads() { + let dir = tempdir().expect("tempdir"); + let image = dir.path().join("huge.png"); + fs::write( + &image, + vec![0_u8; (MAX_IMAGE_ATTACHMENT_BYTES as usize) + 1], + ) + .expect("oversized image file"); + + let error = + read_image_attachment(image.to_string_lossy().into_owned()).expect_err("size limit"); + + assert!(error.contains("exceeds the 20 MB limit")); + } } diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index 82ae9f3ce211..799d0d32034a 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -101,7 +101,9 @@ pub fn run() { commands::system::save_exported_session_file, commands::system::path_exists, commands::system::list_directory_entries, + commands::system::inspect_attachment_paths, commands::system::list_files_for_mentions, + commands::system::read_image_attachment, ]) .setup(|_app| Ok(())) .build(tauri::generate_context!()) diff --git a/ui/goose2/src-tauri/src/services/acp/manager/session_ops/prompt_ops.rs b/ui/goose2/src-tauri/src/services/acp/manager/session_ops/prompt_ops.rs index f9997844d980..4c2ec2ca9cc8 100644 --- a/ui/goose2/src-tauri/src/services/acp/manager/session_ops/prompt_ops.rs +++ b/ui/goose2/src-tauri/src/services/acp/manager/session_ops/prompt_ops.rs @@ -21,6 +21,21 @@ async fn clear_cancel_requested(state: &Arc>, composite_key: guard.pending_cancels.remove(composite_key); } +pub(super) fn build_content_blocks( + prompt: String, + images: Vec<(String, String)>, +) -> Vec { + let mut content_blocks = Vec::with_capacity(images.len() + 1); + for (data, mime_type) in images { + content_blocks.push(AcpContentBlock::Image(ImageContent::new( + data.as_str(), + mime_type.as_str(), + ))); + } + content_blocks.push(AcpContentBlock::Text(TextContent::new(prompt))); + content_blocks +} + #[allow(clippy::too_many_arguments)] pub(in super::super) async fn send_prompt_inner( connection: &Arc, @@ -77,13 +92,7 @@ pub(in super::super) async fn send_prompt_inner( return Ok(()); } - let mut content_blocks = vec![AcpContentBlock::Text(TextContent::new(prompt))]; - for (data, mime_type) in &images { - content_blocks.push(AcpContentBlock::Image(ImageContent::new( - data.as_str(), - mime_type.as_str(), - ))); - } + let content_blocks = build_content_blocks(prompt, images); let result = connection .prompt(PromptRequest::new(goose_session_id.clone(), content_blocks)) diff --git a/ui/goose2/src-tauri/src/services/acp/manager/session_ops/tests.rs b/ui/goose2/src-tauri/src/services/acp/manager/session_ops/tests.rs index 10e98855a1e8..fbc26327ee2f 100644 --- a/ui/goose2/src-tauri/src/services/acp/manager/session_ops/tests.rs +++ b/ui/goose2/src-tauri/src/services/acp/manager/session_ops/tests.rs @@ -3,9 +3,12 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; +use agent_client_protocol::ContentBlock as AcpContentBlock; + use super::{ - needs_provider_update, prepared_session_for_key, register_prepared_session_keys, - wait_for_replay_drain, ManagerState, PreparedSession, MAX_DRAIN_ITERATIONS, + needs_provider_update, prepared_session_for_key, prompt_ops::build_content_blocks, + register_prepared_session_keys, wait_for_replay_drain, ManagerState, PreparedSession, + MAX_DRAIN_ITERATIONS, }; use crate::services::acp::split_composite_key; @@ -175,3 +178,15 @@ async fn replay_drain_caps_iterations_on_runaway_counter() { assert_eq!(final_count, MAX_DRAIN_ITERATIONS); assert_eq!(poll_count.load(Ordering::SeqCst), MAX_DRAIN_ITERATIONS); } + +#[test] +fn build_content_blocks_places_images_before_prompt_text() { + let blocks = build_content_blocks( + "Please inspect all three attachments".to_string(), + vec![("abc123".to_string(), "image/png".to_string())], + ); + + assert_eq!(blocks.len(), 2); + assert!(matches!(blocks[0], AcpContentBlock::Image(_))); + assert!(matches!(blocks[1], AcpContentBlock::Text(_))); +} diff --git a/ui/goose2/src-tauri/tauri.conf.json b/ui/goose2/src-tauri/tauri.conf.json index f958b67c2ad2..2ea07b5551e7 100644 --- a/ui/goose2/src-tauri/tauri.conf.json +++ b/ui/goose2/src-tauri/tauri.conf.json @@ -22,7 +22,7 @@ "visible": false, "titleBarStyle": "Overlay", "hiddenTitle": true, - "dragDropEnabled": false, + "dragDropEnabled": true, "trafficLightPosition": { "x": 12, "y": 22 } } ], diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index f701b04f750e..b7b9d8ca4a7c 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Sidebar } from "@/features/sidebar/ui/Sidebar"; import { StatusBar } from "@/features/status/ui/StatusBar"; -import type { PastedImage } from "@/shared/types/messages"; +import type { ChatAttachmentDraft } from "@/shared/types/messages"; import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog"; import { archiveProject } from "@/features/projects/api/projects"; import type { ProjectInfo } from "@/features/projects/api/projects"; @@ -135,8 +135,8 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const [pendingInitialMessage, setPendingInitialMessage] = useState< string | undefined >(); - const [pendingInitialImages, setPendingInitialImages] = useState< - PastedImage[] | undefined + const [pendingInitialAttachments, setPendingInitialAttachments] = useState< + ChatAttachmentDraft[] | undefined >(); const [homeSelectedPersonaId, setHomeSelectedPersonaId] = useState< string | undefined @@ -363,12 +363,12 @@ export function AppShell({ children }: { children?: React.ReactNode }) { providerId?: string, personaId?: string, projectId?: string | null, - images?: PastedImage[], + attachments?: ChatAttachmentDraft[], ) => { setHomeSelectedProvider(providerId); setHomeSelectedPersonaId(personaId); setPendingInitialMessage(initialMessage); - setPendingInitialImages(images); + setPendingInitialAttachments(attachments); const selectedProject = projectId != null ? projectStore.projects.find((project) => project.id === projectId) @@ -501,7 +501,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const activeSessionPersonaId = activeSession?.personaId; const handleInitialMessageConsumed = useCallback(() => { setPendingInitialMessage(undefined); - setPendingInitialImages(undefined); + setPendingInitialAttachments(undefined); setHomeSelectedProvider(undefined); setHomeSelectedPersonaId(undefined); }, []); @@ -580,7 +580,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { homeSelectedProvider={homeSelectedProvider} homeSelectedPersonaId={homeSelectedPersonaId} pendingInitialMessage={pendingInitialMessage} - pendingInitialImages={pendingInitialImages} + pendingInitialAttachments={pendingInitialAttachments} onArchiveChat={handleArchiveChat} onCreateProject={openCreateProjectDialog} onHomeStartChat={handleHomeStartChat} diff --git a/ui/goose2/src/app/ui/AppShellContent.tsx b/ui/goose2/src/app/ui/AppShellContent.tsx index b8749baf3808..4100082a687f 100644 --- a/ui/goose2/src/app/ui/AppShellContent.tsx +++ b/ui/goose2/src/app/ui/AppShellContent.tsx @@ -5,7 +5,7 @@ import { AgentsView } from "@/features/agents/ui/AgentsView"; import { ProjectsView } from "@/features/projects/ui/ProjectsView"; import { SessionHistoryView } from "@/features/sessions/ui/SessionHistoryView"; import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; -import type { PastedImage } from "@/shared/types/messages"; +import type { ChatAttachmentDraft } from "@/shared/types/messages"; import type { ProjectInfo } from "@/features/projects/api/projects"; import type { AppView } from "../AppShell"; @@ -16,7 +16,7 @@ interface AppShellContentProps { homeSelectedProvider?: string; homeSelectedPersonaId?: string; pendingInitialMessage?: string; - pendingInitialImages?: PastedImage[]; + pendingInitialAttachments?: ChatAttachmentDraft[]; onArchiveChat: (sessionId: string) => Promise; onCreateProject: (options?: { initialWorkingDir?: string | null; @@ -27,7 +27,7 @@ interface AppShellContentProps { providerId?: string, personaId?: string, projectId?: string | null, - images?: PastedImage[], + attachments?: ChatAttachmentDraft[], ) => void; onInitialMessageConsumed: () => void; onRenameChat: (sessionId: string, nextTitle: string) => void; @@ -47,7 +47,7 @@ export function AppShellContent({ homeSelectedProvider, homeSelectedPersonaId, pendingInitialMessage, - pendingInitialImages, + pendingInitialAttachments, onArchiveChat, onCreateProject, onHomeStartChat, @@ -82,7 +82,7 @@ export function AppShellContent({ initialProvider={homeSelectedProvider} initialPersonaId={activeSessionPersonaId ?? homeSelectedPersonaId} initialMessage={pendingInitialMessage} - initialImages={pendingInitialImages} + initialAttachments={pendingInitialAttachments} onCreateProject={onCreateProject} onInitialMessageConsumed={onInitialMessageConsumed} /> diff --git a/ui/goose2/src/features/chat/hooks/ArtifactPolicyContext.tsx b/ui/goose2/src/features/chat/hooks/ArtifactPolicyContext.tsx index f60ab26216d6..0774a6d3b853 100644 --- a/ui/goose2/src/features/chat/hooks/ArtifactPolicyContext.tsx +++ b/ui/goose2/src/features/chat/hooks/ArtifactPolicyContext.tsx @@ -12,6 +12,7 @@ import { pathExists } from "@/shared/api/system"; import { buildArtifactsIndexForMessages, inferHomeDirFromRoots, + isWriteOrientedTool, resolveMarkdownLocalHref, type ArtifactPathCandidate, } from "@/features/chat/lib/artifactPathPolicy"; @@ -150,6 +151,12 @@ export function ArtifactPolicyProvider({ for (const ranking of artifactsIndex.byMessageId.values()) { if (!ranking.primaryToolCallId || !ranking.primaryCandidate) continue; + if ( + !ranking.primaryCandidate.toolName || + !isWriteOrientedTool(ranking.primaryCandidate.toolName) + ) { + continue; + } displayByToolCallId.set(ranking.primaryToolCallId, { role: "primary_host", primaryCandidate: ranking.primaryCandidate, @@ -232,6 +239,9 @@ export function ArtifactPolicyProvider({ for (const candidates of ranking.candidatesByToolCallId.values()) { for (const candidate of candidates) { if (!candidate.allowed) continue; + if (!candidate.toolName || !isWriteOrientedTool(candidate.toolName)) { + continue; + } const key = candidate.resolvedPath.trim().toLowerCase(); const existing = artifactMap.get(key); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx b/ui/goose2/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx index 06cb8cffa022..93ed16b1c61c 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx +++ b/ui/goose2/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx @@ -72,6 +72,22 @@ function TextFollowupProbe({ ); } +function ReadOnlyProbe({ readArgs }: { readArgs: Record }) { + const { resolveToolCardDisplay, getAllSessionArtifacts } = + useArtifactPolicyContext(); + const display = resolveToolCardDisplay(readArgs, "read_file"); + const artifacts = getAllSessionArtifacts(); + + return ( +
+ {display.role} + + {artifacts.map((artifact) => artifact.resolvedPath).join(",")} + +
+ ); +} + function FallbackProbe({ path = "/Users/test/.goose/projects/sample-project/artifacts/report.md", }: { @@ -171,6 +187,47 @@ describe("ArtifactPolicyContext", () => { expect(screen.getByTestId("cloned-role")).toHaveTextContent("none"); }); + it("does not treat read-only tool paths as session artifacts", () => { + mockPathExists.mockReset(); + mockOpenPath.mockReset(); + const readArgs = { path: "/Users/test/project-a/notes.md" }; + const messages: Message[] = [ + { + id: "assistant-read-only", + role: "assistant", + created: Date.now(), + content: [ + { + type: "toolRequest", + id: "tool-read", + name: "read_file", + arguments: readArgs, + status: "completed", + }, + { + type: "toolResponse", + id: "tool-read", + name: "read_file", + result: "Read /Users/test/project-a/notes.md", + isError: false, + }, + ], + }, + ]; + + render( + + + , + ); + + expect(screen.getByTestId("read-only-role")).toHaveTextContent("none"); + expect(screen.getByTestId("read-only-artifacts")).toHaveTextContent(""); + }); + it("falls back to the home artifacts root when a project artifacts path is missing", async () => { mockPathExists.mockReset(); mockOpenPath.mockReset(); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChat.attachments.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChat.attachments.test.ts new file mode 100644 index 000000000000..975d5bc56451 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChat.attachments.test.ts @@ -0,0 +1,252 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { useChatStore } from "../../stores/chatStore"; +import { useChatSessionStore } from "../../stores/chatSessionStore"; + +const mockAcpSendMessage = vi.fn(); +const mockAcpCancelSession = vi.fn(); +const mockAcpPrepareSession = vi.fn(); +const mockAcpSetModel = vi.fn(); + +vi.mock("@/shared/api/acp", () => ({ + acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args), + acpCancelSession: (...args: unknown[]) => mockAcpCancelSession(...args), + acpPrepareSession: (...args: unknown[]) => mockAcpPrepareSession(...args), + acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args), +})); + +import { useChat } from "../useChat"; + +describe("useChat attachments", () => { + beforeEach(() => { + vi.clearAllMocks(); + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + activeSessionId: null, + isConnected: true, + }); + useChatSessionStore.setState({ + sessions: [], + activeSessionId: null, + isLoading: false, + contextPanelOpenBySession: {}, + activeWorkingContextBySession: {}, + modelsBySession: {}, + modelCacheByProvider: {}, + }); + useAgentStore.setState({ + personas: [], + personasLoading: false, + agents: [], + agentsLoading: false, + activeAgentId: null, + isLoading: false, + personaEditorOpen: false, + editingPersona: null, + }); + mockAcpCancelSession.mockResolvedValue(true); + mockAcpPrepareSession.mockResolvedValue(undefined); + mockAcpSetModel.mockResolvedValue(undefined); + }); + + it("stores non-image attachments in metadata and prepends path references to the prompt", async () => { + const { result } = renderHook(() => useChat("session-1")); + const attachments = [ + { + id: "file-1", + kind: "file" as const, + name: "report.pdf", + path: "/tmp/report.pdf", + mimeType: "application/pdf", + }, + { + id: "dir-1", + kind: "directory" as const, + name: "screenshots", + path: "/tmp/screenshots", + }, + ]; + + await act(async () => { + await result.current.sendMessage( + "Please review these", + undefined, + attachments, + ); + }); + + const message = useChatStore.getState().messagesBySession["session-1"][0]; + + expect(message.metadata?.attachments).toEqual([ + { + type: "file", + name: "report.pdf", + path: "/tmp/report.pdf", + mimeType: "application/pdf", + }, + { + type: "directory", + name: "screenshots", + path: "/tmp/screenshots", + }, + ]); + expect(mockAcpSendMessage).toHaveBeenCalledWith( + "session-1", + "goose", + "Attached items:\n- [file] /tmp/report.pdf\n- [directory] /tmp/screenshots\nPlease review these", + { + systemPrompt: undefined, + workingDir: undefined, + personaId: undefined, + personaName: undefined, + images: undefined, + }, + ); + }); + + it("keeps image attachments in ACP images while preserving path metadata", async () => { + const { result } = renderHook(() => useChat("session-1")); + const attachments = [ + { + id: "image-1", + kind: "image" as const, + name: "diagram.png", + path: "/tmp/diagram.png", + mimeType: "image/png", + base64: "abc123", + previewUrl: "tauri://localhost/tmp/diagram.png", + }, + ]; + + await act(async () => { + await result.current.sendMessage("", undefined, attachments); + }); + + const message = useChatStore.getState().messagesBySession["session-1"][0]; + + expect(message.metadata?.attachments).toEqual([ + { + type: "file", + name: "diagram.png", + path: "/tmp/diagram.png", + mimeType: "image/png", + }, + ]); + expect(message.content).toEqual([ + { type: "text", text: "" }, + { + type: "image", + source: { + type: "base64", + mediaType: "image/png", + data: "abc123", + }, + }, + ]); + expect(mockAcpSendMessage).toHaveBeenCalledWith( + "session-1", + "goose", + "Attached items:\n- [image] diagram.png (image attached)\n ", + { + systemPrompt: undefined, + workingDir: undefined, + personaId: undefined, + personaName: undefined, + images: [["abc123", "image/png"]], + }, + ); + }); + + it("includes image attachments in the prompt summary for mixed sends", async () => { + const { result } = renderHook(() => useChat("session-1")); + const attachments = [ + { + id: "file-1", + kind: "file" as const, + name: "mobile-confirmation.html", + path: "/tmp/mobile-confirmation.html", + mimeType: "text/html", + }, + { + id: "dir-1", + kind: "directory" as const, + name: "neighborhood block", + path: "/tmp/neighborhood block", + }, + { + id: "image-1", + kind: "image" as const, + name: "Screenshot 2026-04-09 at 1.25.32 PM.png", + path: "/tmp/Screenshot.png", + mimeType: "image/png", + base64: "abc123", + previewUrl: "tauri://localhost/tmp/Screenshot.png", + }, + ]; + + await act(async () => { + await result.current.sendMessage( + "can you see the attachments i attached?", + undefined, + attachments, + ); + }); + + expect(mockAcpSendMessage).toHaveBeenCalledWith( + "session-1", + "goose", + "Attached items:\n- [file] /tmp/mobile-confirmation.html\n- [directory] /tmp/neighborhood block\n- [image] Screenshot 2026-04-09 at 1.25.32 PM.png (image attached)\ncan you see the attachments i attached?", + { + systemPrompt: undefined, + workingDir: undefined, + personaId: undefined, + personaName: undefined, + images: [["abc123", "image/png"]], + }, + ); + }); + + it("preserves pathless browser file attachments in sent message metadata", async () => { + const { result } = renderHook(() => useChat("session-1")); + const attachments = [ + { + id: "file-1", + kind: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + }, + ]; + + await act(async () => { + await result.current.sendMessage( + "Please review this", + undefined, + attachments, + ); + }); + + const message = useChatStore.getState().messagesBySession["session-1"][0]; + + expect(message.metadata?.attachments).toEqual([ + { + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + }, + ]); + expect(mockAcpSendMessage).toHaveBeenCalledWith( + "session-1", + "goose", + "Attached items:\n- [file] report.pdf\nPlease review this", + { + systemPrompt: undefined, + workingDir: undefined, + personaId: undefined, + personaName: undefined, + images: undefined, + }, + ); + }); +}); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts index 887ed193262b..141826952fa6 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts @@ -134,6 +134,7 @@ describe("useChat", () => { workingDir: undefined, personaId: "persona-b", personaName: "Persona B", + images: undefined, }, ); expect(mockAcpCancelSession).toHaveBeenCalledWith("session-1", "persona-b"); @@ -322,6 +323,7 @@ describe("useChat", () => { workingDir: undefined, personaId: undefined, personaName: undefined, + images: undefined, }, ); expect(mockAcpSendMessage).toHaveBeenNthCalledWith( @@ -334,6 +336,7 @@ describe("useChat", () => { workingDir: undefined, personaId: undefined, personaName: undefined, + images: undefined, }, ); @@ -380,6 +383,7 @@ describe("useChat", () => { workingDir: undefined, personaId: undefined, personaName: undefined, + images: undefined, }, ); }); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChatInputAttachments.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChatInputAttachments.test.ts new file mode 100644 index 000000000000..8e4c1f027a66 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChatInputAttachments.test.ts @@ -0,0 +1,73 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockResizeImage = vi.fn(); + +vi.mock("../../lib/resizeImage", () => ({ + resizeImage: (...args: unknown[]) => mockResizeImage(...args), +})); + +vi.mock("@/shared/lib/platform", () => ({ + getPlatform: () => "mac", +})); + +vi.mock("@tauri-apps/api/core", () => ({ + convertFileSrc: (path: string) => `asset://${path}`, +})); + +vi.mock("@/shared/api/system", () => ({ + inspectAttachmentPaths: vi.fn(), + readImageAttachment: vi.fn(), +})); + +import { useChatInputAttachments } from "../useChatInputAttachments"; + +describe("useChatInputAttachments", () => { + beforeEach(() => { + mockResizeImage.mockReset(); + }); + + it("keeps valid browser file attachments when an image read fails", async () => { + mockResizeImage.mockRejectedValue(new Error("resize failed")); + + const fileReaderSpy = vi + .spyOn(globalThis, "FileReader") + .mockImplementation(() => { + const fileReader: { + onload: FileReader["onload"]; + onerror: FileReader["onerror"]; + readAsDataURL: FileReader["readAsDataURL"]; + } = { + onload: null, + onerror: null, + readAsDataURL: () => { + fileReader.onerror?.call( + fileReader as unknown as FileReader, + new ProgressEvent("error") as ProgressEvent, + ); + }, + }; + + return fileReader as unknown as FileReader; + }); + + const { result } = renderHook(() => useChatInputAttachments()); + + await act(async () => { + await result.current.addBrowserFiles([ + new File(["bad image"], "broken.png", { type: "image/png" }), + new File(["report"], "report.txt", { type: "text/plain" }), + ]); + }); + + expect(result.current.attachments).toEqual([ + expect.objectContaining({ + kind: "file", + name: "report.txt", + mimeType: "text/plain", + }), + ]); + + fileReaderSpy.mockRestore(); + }); +}); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useMessageQueue.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useMessageQueue.test.ts index f7902070a8d2..99917d59b580 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useMessageQueue.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useMessageQueue.test.ts @@ -100,10 +100,19 @@ describe("useMessageQueue", () => { it("includes images when auto-sending", () => { const sendMessage = vi.fn(); - const images = [{ base64: "abc", mimeType: "image/png" }]; + const attachments = [ + { + id: "image-1", + kind: "image" as const, + name: "image.png", + base64: "abc", + mimeType: "image/png", + previewUrl: "blob:image", + }, + ]; useChatStore.getState().enqueueMessage("s1", { text: "with image", - images, + attachments, }); renderHook( @@ -112,7 +121,11 @@ describe("useMessageQueue", () => { { initialProps: { chatState: "idle" as ChatState } }, ); - expect(sendMessage).toHaveBeenCalledWith("with image", undefined, images); + expect(sendMessage).toHaveBeenCalledWith( + "with image", + undefined, + attachments, + ); }); it("preserves personaId when auto-sending", () => { diff --git a/ui/goose2/src/features/chat/hooks/useAttachmentDropTarget.ts b/ui/goose2/src/features/chat/hooks/useAttachmentDropTarget.ts new file mode 100644 index 000000000000..29814e7301e7 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/useAttachmentDropTarget.ts @@ -0,0 +1,228 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type DragEvent, + type RefObject, +} from "react"; + +interface UseAttachmentDropTargetOptions { + disabled: boolean; + isStreaming: boolean; + targetRef: RefObject; + onDropFiles: (files: File[]) => void; + onDropPaths: (paths: string[]) => void; +} + +function hasDraggedFiles(dataTransfer: DataTransfer) { + return ( + Array.from(dataTransfer.items).some((item) => item.kind === "file") || + Array.from(dataTransfer.types).includes("Files") + ); +} + +function isInTauriEnvironment() { + return typeof window !== "undefined" && Boolean(window.__TAURI_INTERNALS__); +} + +function isPointInsideRect(point: { x: number; y: number }, rect: DOMRect) { + return ( + point.x >= rect.left && + point.x <= rect.right && + point.y >= rect.top && + point.y <= rect.bottom + ); +} + +function getTargetHitTest( + target: HTMLDivElement | null, + position: { x: number; y: number }, +) { + if (!target) { + return { + inside: false, + rawInside: false, + scaledInside: false, + rawElementInside: false, + scaledElementInside: false, + rawPosition: position, + scaledPosition: position, + rect: null, + scale: 1, + }; + } + + const rect = target.getBoundingClientRect(); + const scale = window.devicePixelRatio || 1; + const rawPosition = { x: position.x, y: position.y }; + const scaledPosition = { + x: position.x / scale, + y: position.y / scale, + }; + const rawInside = isPointInsideRect(rawPosition, rect); + const scaledInside = isPointInsideRect(scaledPosition, rect); + const rawElement = document.elementFromPoint(rawPosition.x, rawPosition.y); + const scaledElement = document.elementFromPoint( + scaledPosition.x, + scaledPosition.y, + ); + const rawElementInside = Boolean(rawElement && target.contains(rawElement)); + const scaledElementInside = Boolean( + scaledElement && target.contains(scaledElement), + ); + + return { + inside: + rawInside || scaledInside || rawElementInside || scaledElementInside, + rawInside, + scaledInside, + rawElementInside, + scaledElementInside, + rawPosition, + scaledPosition, + rect: { + left: rect.left, + right: rect.right, + top: rect.top, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }, + scale, + }; +} + +export function useAttachmentDropTarget({ + disabled, + isStreaming, + targetRef, + onDropFiles, + onDropPaths, +}: UseAttachmentDropTargetOptions) { + const [isAttachmentDragOver, setIsAttachmentDragOver] = useState(false); + const dragDepthRef = useRef(0); + const tauriDropHandledAtRef = useRef(0); + + const handleDragEnter = useCallback( + (event: DragEvent) => { + const draggedFiles = hasDraggedFiles(event.dataTransfer); + if (disabled || isStreaming || !draggedFiles) { + return; + } + + event.preventDefault(); + dragDepthRef.current += 1; + setIsAttachmentDragOver(true); + }, + [disabled, isStreaming], + ); + + const handleDragOver = useCallback( + (event: DragEvent) => { + const draggedFiles = hasDraggedFiles(event.dataTransfer); + if (disabled || isStreaming || !draggedFiles) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setIsAttachmentDragOver(true); + }, + [disabled, isStreaming], + ); + + const handleDragLeave = useCallback((event: DragEvent) => { + event.preventDefault(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setIsAttachmentDragOver(false); + } + }, []); + + const handleDrop = useCallback( + (event: DragEvent) => { + const draggedFiles = hasDraggedFiles(event.dataTransfer); + if (disabled || isStreaming || !draggedFiles) { + return; + } + + event.preventDefault(); + dragDepthRef.current = 0; + setIsAttachmentDragOver(false); + + const files = Array.from(event.dataTransfer.files); + if (files.length === 0) { + return; + } + + if (Date.now() - tauriDropHandledAtRef.current < 250) { + return; + } + + onDropFiles(files); + }, + [disabled, isStreaming, onDropFiles], + ); + + useEffect(() => { + if (!isInTauriEnvironment()) { + return; + } + + let disposed = false; + let unlisten: (() => void) | undefined; + + void import("@tauri-apps/api/webview") + .then(({ getCurrentWebview }) => + getCurrentWebview().onDragDropEvent(({ payload }) => { + if (disposed) { + return; + } + + if (payload.type === "leave") { + setIsAttachmentDragOver(false); + return; + } + + const hitTest = getTargetHitTest(targetRef.current, payload.position); + + if (payload.type === "drop") { + setIsAttachmentDragOver(false); + if ( + !hitTest.inside || + disabled || + isStreaming || + payload.paths.length === 0 + ) { + return; + } + tauriDropHandledAtRef.current = Date.now(); + onDropPaths(payload.paths); + return; + } + + setIsAttachmentDragOver(hitTest.inside && !disabled && !isStreaming); + }), + ) + .then((fn) => { + unlisten = fn; + }) + .catch(() => { + setIsAttachmentDragOver(false); + }); + + return () => { + disposed = true; + unlisten?.(); + }; + }, [disabled, isStreaming, onDropPaths, targetRef]); + + return { + isAttachmentDragOver, + handleDragEnter, + handleDragOver, + handleDragLeave, + handleDrop, + }; +} diff --git a/ui/goose2/src/features/chat/hooks/useChat.ts b/ui/goose2/src/features/chat/hooks/useChat.ts index 73ee7947d4cc..cd45ba8a0acc 100644 --- a/ui/goose2/src/features/chat/hooks/useChat.ts +++ b/ui/goose2/src/features/chat/hooks/useChat.ts @@ -2,6 +2,7 @@ import { useCallback, useRef } from "react"; import { useChatStore } from "../stores/chatStore"; import { useChatSessionStore } from "../stores/chatSessionStore"; import { + type ChatAttachmentDraft, createSystemNotificationMessage, createUserMessage, } from "@/shared/types/messages"; @@ -13,8 +14,16 @@ import { acpSetModel, } from "@/shared/api/acp"; import { useAgentStore } from "@/features/agents/stores/agentStore"; -import { isDefaultChatTitle } from "../lib/sessionTitle"; +import { + getSessionTitleFromDraft, + isDefaultChatTitle, +} from "../lib/sessionTitle"; import { findLastIndex } from "@/shared/lib/arrays"; +import { + buildAcpImages, + buildAttachmentPromptPreamble, + buildMessageAttachments, +} from "../lib/attachments"; function getErrorMessage(error: unknown): string { // Tauri command rejections typically arrive as plain strings, so handle @@ -118,10 +127,12 @@ export function useChat( async ( text: string, overridePersona?: { id: string; name?: string }, - images?: { base64: string; mimeType: string }[], + attachments?: ChatAttachmentDraft[], ) => { + const images = buildAcpImages(attachments); + const hasAttachments = (attachments?.length ?? 0) > 0; if ( - (!text.trim() && (!images || images.length === 0)) || + (!text.trim() && !hasAttachments) || chatState === "streaming" || chatState === "thinking" ) @@ -141,7 +152,10 @@ export function useChat( store.setPendingAssistantProvider(sessionId, providerId); // Create and add user message - const userMessage = createUserMessage(text); + const userMessage = createUserMessage( + text, + buildMessageAttachments(attachments), + ); if (effectivePersonaInfo) { userMessage.metadata = { ...userMessage.metadata, @@ -185,7 +199,7 @@ export function useChat( sessionStore.updateSession( sessionId, { - title: text.trim().slice(0, 100), + title: getSessionTitleFromDraft(text, attachments), updatedAt: new Date().toISOString(), }, { localOnly: wasDraft }, @@ -218,7 +232,10 @@ export function useChat( store.setChatState(sessionId, "streaming"); // When images are present with no text, pass a single space so the ACP // driver doesn't send an empty text content block that goose rejects. - const acpPrompt = text.trim() || (images?.length ? " " : text); + const attachmentPromptPreamble = + buildAttachmentPromptPreamble(attachments); + const promptBody = text.trim() || (images?.length ? " " : text); + const acpPrompt = `${attachmentPromptPreamble}${promptBody}`; await acpSendMessage(sessionId, providerId, acpPrompt, { systemPrompt, workingDir: workingDirOverride, diff --git a/ui/goose2/src/features/chat/hooks/useChatInputAttachments.ts b/ui/goose2/src/features/chat/hooks/useChatInputAttachments.ts new file mode 100644 index 000000000000..4b24938ae11f --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/useChatInputAttachments.ts @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { + inspectAttachmentPaths, + readImageAttachment, +} from "@/shared/api/system"; +import type { + ChatAttachmentDraft, + ChatDirectoryAttachmentDraft, + ChatFileAttachmentDraft, + ChatImageAttachmentDraft, +} from "@/shared/types/messages"; +import { getPlatform } from "@/shared/lib/platform"; +import { resizeImage } from "../lib/resizeImage"; + +function isBlobPreview(url: string) { + return url.startsWith("blob:"); +} + +function revokeAttachmentPreview(attachment: ChatAttachmentDraft) { + if (attachment.kind === "image" && isBlobPreview(attachment.previewUrl)) { + URL.revokeObjectURL(attachment.previewUrl); + } +} + +function pathToPreviewUrl(path: string) { + return typeof window !== "undefined" && window.__TAURI_INTERNALS__ + ? convertFileSrc(path) + : path; +} + +function attachmentPathKey(path?: string) { + if (!path) { + return null; + } + + return getPlatform() === "linux" ? path : path.toLowerCase(); +} + +async function createImageAttachmentFromFile( + file: File, +): Promise { + const previewUrl = URL.createObjectURL(file); + + try { + const { base64, mimeType } = await resizeImage(file).catch( + () => + new Promise<{ base64: string; mimeType: string }>((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result as string; + const [header, base64] = dataUrl.split(","); + const mimeType = header.replace("data:", "").replace(";base64", ""); + resolve({ base64, mimeType }); + }; + reader.onerror = () => reject(new Error("Failed to read image")); + reader.readAsDataURL(file); + }), + ); + + return { + id: crypto.randomUUID(), + kind: "image", + name: file.name, + mimeType, + base64, + previewUrl, + }; + } catch (error) { + URL.revokeObjectURL(previewUrl); + throw error; + } +} + +export function normalizeDialogSelection( + selected: string | string[] | null, +): string[] { + if (!selected) { + return []; + } + + return Array.isArray(selected) ? selected : [selected]; +} + +export function useChatInputAttachments() { + const [attachments, setAttachments] = useState([]); + const attachmentsRef = useRef(attachments); + attachmentsRef.current = attachments; + + useEffect( + () => () => { + for (const attachment of attachmentsRef.current) { + revokeAttachmentPreview(attachment); + } + }, + [], + ); + + const appendAttachments = useCallback((incoming: ChatAttachmentDraft[]) => { + if (incoming.length === 0) { + return; + } + + setAttachments((previous) => { + const seenPaths = new Set( + previous + .map((attachment) => attachmentPathKey(attachment.path)) + .filter((value): value is string => Boolean(value)), + ); + const next = [...previous]; + + for (const attachment of incoming) { + const pathKey = attachmentPathKey(attachment.path); + if (pathKey && seenPaths.has(pathKey)) { + revokeAttachmentPreview(attachment); + continue; + } + + if (pathKey) { + seenPaths.add(pathKey); + } + next.push(attachment); + } + + return next; + }); + }, []); + + const addBrowserFiles = useCallback( + async (files: File[]) => { + const nextAttachments = ( + await Promise.allSettled( + files.map(async (file) => { + if (file.type.startsWith("image/")) { + return createImageAttachmentFromFile(file); + } + + return { + id: crypto.randomUUID(), + kind: "file", + name: file.name, + ...(file.type ? { mimeType: file.type } : {}), + } satisfies ChatFileAttachmentDraft; + }), + ) + ).flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + + appendAttachments(nextAttachments); + }, + [appendAttachments], + ); + + const addPathAttachments = useCallback( + async (paths: string[]) => { + if (paths.length === 0) { + return; + } + + const inspectedPaths = await inspectAttachmentPaths(paths); + const nextAttachments = await Promise.all( + inspectedPaths.map(async (attachmentPath) => { + if (attachmentPath.kind === "directory") { + return { + id: crypto.randomUUID(), + kind: "directory", + name: attachmentPath.name, + path: attachmentPath.path, + } satisfies ChatDirectoryAttachmentDraft; + } + + if (attachmentPath.mimeType?.startsWith("image/")) { + try { + const image = await readImageAttachment(attachmentPath.path); + return { + id: crypto.randomUUID(), + kind: "image", + name: attachmentPath.name, + path: attachmentPath.path, + mimeType: image.mimeType, + base64: image.base64, + previewUrl: pathToPreviewUrl(attachmentPath.path), + } satisfies ChatImageAttachmentDraft; + } catch { + // Fall back to a generic file attachment if image loading fails. + } + } + + return { + id: crypto.randomUUID(), + kind: "file", + name: attachmentPath.name, + path: attachmentPath.path, + ...(attachmentPath.mimeType + ? { mimeType: attachmentPath.mimeType } + : {}), + } satisfies ChatFileAttachmentDraft; + }), + ); + + appendAttachments(nextAttachments); + }, + [appendAttachments], + ); + + const removeAttachment = useCallback((id: string) => { + setAttachments((previous) => { + const found = previous.find((attachment) => attachment.id === id); + if (found) { + revokeAttachmentPreview(found); + } + return previous.filter((attachment) => attachment.id !== id); + }); + }, []); + + const clearAttachments = useCallback(() => { + setAttachments((previous) => { + for (const attachment of previous) { + revokeAttachmentPreview(attachment); + } + return []; + }); + }, []); + + return { + attachments, + addBrowserFiles, + addPathAttachments, + removeAttachment, + clearAttachments, + }; +} diff --git a/ui/goose2/src/features/chat/hooks/useImageDropTarget.ts b/ui/goose2/src/features/chat/hooks/useImageDropTarget.ts deleted file mode 100644 index 141d866c2fdd..000000000000 --- a/ui/goose2/src/features/chat/hooks/useImageDropTarget.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { useCallback, useRef, useState, type DragEvent } from "react"; - -interface UseImageDropTargetOptions { - disabled: boolean; - isStreaming: boolean; - onDropFile: (file: File) => void; -} - -function hasDraggedFiles(dataTransfer: DataTransfer) { - return ( - Array.from(dataTransfer.items).some( - (item) => item.kind === "file" || item.type.startsWith("image/"), - ) || Array.from(dataTransfer.types).includes("Files") - ); -} - -export function useImageDropTarget({ - disabled, - isStreaming, - onDropFile, -}: UseImageDropTargetOptions) { - const [isImageDragOver, setIsImageDragOver] = useState(false); - const dragDepthRef = useRef(0); - - const handleDragEnter = useCallback( - (e: DragEvent) => { - if (disabled || isStreaming || !hasDraggedFiles(e.dataTransfer)) { - return; - } - - e.preventDefault(); - dragDepthRef.current += 1; - setIsImageDragOver(true); - }, - [disabled, isStreaming], - ); - - const handleDragOver = useCallback( - (e: DragEvent) => { - if (disabled || isStreaming || !hasDraggedFiles(e.dataTransfer)) { - return; - } - - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - setIsImageDragOver(true); - }, - [disabled, isStreaming], - ); - - const handleDragLeave = useCallback( - (e: DragEvent) => { - e.preventDefault(); - - if (!isImageDragOver) { - return; - } - - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0) { - setIsImageDragOver(false); - } - }, - [isImageDragOver], - ); - - const handleDrop = useCallback( - (e: DragEvent) => { - dragDepthRef.current = 0; - setIsImageDragOver(false); - - if (disabled || isStreaming) { - return; - } - - const files = Array.from(e.dataTransfer.files).filter((file) => - file.type.startsWith("image/"), - ); - if (files.length === 0) { - return; - } - - e.preventDefault(); - for (const file of files) { - onDropFile(file); - } - }, - [disabled, isStreaming, onDropFile], - ); - - return { - isImageDragOver, - handleDragEnter, - handleDragOver, - handleDragLeave, - handleDrop, - }; -} diff --git a/ui/goose2/src/features/chat/hooks/useMessageQueue.ts b/ui/goose2/src/features/chat/hooks/useMessageQueue.ts index 43ee409fd99e..bc5859039d5f 100644 --- a/ui/goose2/src/features/chat/hooks/useMessageQueue.ts +++ b/ui/goose2/src/features/chat/hooks/useMessageQueue.ts @@ -1,5 +1,6 @@ import { useEffect, useCallback } from "react"; import type { ChatState } from "@/shared/types/chat"; +import type { ChatAttachmentDraft } from "@/shared/types/messages"; import { useChatStore } from "../stores/chatStore"; /** @@ -16,7 +17,7 @@ export function useMessageQueue( sendMessage: ( text: string, overridePersona?: { id: string; name?: string }, - images?: { base64: string; mimeType: string }[], + attachments?: ChatAttachmentDraft[], ) => void, ) { const queuedMessage = useChatStore( @@ -25,22 +26,18 @@ export function useMessageQueue( useEffect(() => { if (chatState === "idle" && queuedMessage) { - const { text, personaId, images } = queuedMessage; + const { text, personaId, attachments } = queuedMessage; useChatStore.getState().dismissQueuedMessage(sessionId); - sendMessage(text, personaId ? { id: personaId } : undefined, images); + sendMessage(text, personaId ? { id: personaId } : undefined, attachments); } }, [chatState, queuedMessage, sendMessage, sessionId]); const enqueue = useCallback( - ( - text: string, - personaId?: string, - images?: { base64: string; mimeType: string }[], - ) => { + (text: string, personaId?: string, attachments?: ChatAttachmentDraft[]) => { useChatStore.getState().enqueueMessage(sessionId, { text, personaId, - images, + attachments, }); }, [sessionId], diff --git a/ui/goose2/src/features/chat/lib/artifactPathPolicy.ts b/ui/goose2/src/features/chat/lib/artifactPathPolicy.ts index 934fddab6326..2c3c1252f61e 100644 --- a/ui/goose2/src/features/chat/lib/artifactPathPolicy.ts +++ b/ui/goose2/src/features/chat/lib/artifactPathPolicy.ts @@ -18,6 +18,7 @@ export { extractToolCallCandidates, inferHomeDirFromRoots, isExternalHref, + isWriteOrientedTool, normalizePath, resolveMarkdownLocalHref, resolvePathCandidate, diff --git a/ui/goose2/src/features/chat/lib/attachments.ts b/ui/goose2/src/features/chat/lib/attachments.ts new file mode 100644 index 000000000000..a69a38361c29 --- /dev/null +++ b/ui/goose2/src/features/chat/lib/attachments.ts @@ -0,0 +1,68 @@ +import type { + ChatAttachmentDraft, + MessageAttachment, +} from "@/shared/types/messages"; + +function formatAttachmentReference(attachment: ChatAttachmentDraft): string { + const location = + attachment.kind === "image" + ? `${attachment.name} (image attached)` + : (attachment.path ?? attachment.name); + return `- [${attachment.kind}] ${location}`; +} + +export function buildAttachmentPromptPreamble( + attachments: ChatAttachmentDraft[] | undefined, +): string { + const referencedAttachments = attachments ?? []; + + if (referencedAttachments.length === 0) { + return ""; + } + + return [ + "Attached items:", + ...referencedAttachments.map(formatAttachmentReference), + "", + ].join("\n"); +} + +export function buildMessageAttachments( + attachments: ChatAttachmentDraft[] | undefined, +): MessageAttachment[] | undefined { + const messageAttachments: MessageAttachment[] = []; + + for (const attachment of attachments ?? []) { + if (attachment.kind === "directory") { + messageAttachments.push({ + type: "directory", + name: attachment.name, + path: attachment.path, + }); + continue; + } + + messageAttachments.push({ + type: "file", + name: attachment.name, + ...(attachment.path ? { path: attachment.path } : {}), + ...(attachment.kind === "image" || attachment.mimeType + ? { mimeType: attachment.mimeType } + : {}), + }); + } + + return messageAttachments.length > 0 ? messageAttachments : undefined; +} + +export function buildAcpImages( + attachments: ChatAttachmentDraft[] | undefined, +): { base64: string; mimeType: string }[] | undefined { + const images = (attachments ?? []).flatMap((attachment) => + attachment.kind === "image" + ? [{ base64: attachment.base64, mimeType: attachment.mimeType }] + : [], + ); + + return images.length > 0 ? images : undefined; +} diff --git a/ui/goose2/src/features/chat/lib/sessionTitle.test.ts b/ui/goose2/src/features/chat/lib/sessionTitle.test.ts index b69df784576a..62cbcc546517 100644 --- a/ui/goose2/src/features/chat/lib/sessionTitle.test.ts +++ b/ui/goose2/src/features/chat/lib/sessionTitle.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_CHAT_TITLE, getDisplaySessionTitle, getEditableSessionTitle, + getSessionTitleFromDraft, isSessionTitleUnchanged, } from "./sessionTitle"; @@ -24,4 +25,34 @@ describe("sessionTitle", () => { isSessionTitleUnchanged("Renamed chat", DEFAULT_CHAT_TITLE, "Nuevo chat"), ).toBe(false); }); + + it("falls back to attachment-based titles for attachment-only sends", () => { + expect( + getSessionTitleFromDraft("", [ + { + id: "file-1", + kind: "file", + name: "report.pdf", + path: "/tmp/report.pdf", + }, + ]), + ).toBe("Attached file"); + + expect( + getSessionTitleFromDraft(" ", [ + { + id: "dir-1", + kind: "directory", + name: "screenshots", + path: "/tmp/screenshots", + }, + { + id: "dir-2", + kind: "directory", + name: "receipts", + path: "/tmp/receipts", + }, + ]), + ).toBe("Attached folders"); + }); }); diff --git a/ui/goose2/src/features/chat/lib/sessionTitle.ts b/ui/goose2/src/features/chat/lib/sessionTitle.ts index 283dae2b4cc7..de99e0f0fd63 100644 --- a/ui/goose2/src/features/chat/lib/sessionTitle.ts +++ b/ui/goose2/src/features/chat/lib/sessionTitle.ts @@ -1,9 +1,46 @@ +import type { ChatAttachmentDraft } from "@/shared/types/messages"; + export const DEFAULT_CHAT_TITLE = "New Chat"; export function isDefaultChatTitle(title: string): boolean { return title === DEFAULT_CHAT_TITLE; } +function attachmentKindLabel(kind: ChatAttachmentDraft["kind"], count: number) { + switch (kind) { + case "image": + return count === 1 ? "image" : "images"; + case "directory": + return count === 1 ? "folder" : "folders"; + default: + return count === 1 ? "file" : "files"; + } +} + +export function getSessionTitleFromDraft( + text: string, + attachments?: ChatAttachmentDraft[], +): string { + const trimmed = text.trim(); + if (trimmed.length > 0) { + return trimmed.slice(0, 100); + } + + if (!attachments || attachments.length === 0) { + return DEFAULT_CHAT_TITLE; + } + + const firstKind = attachments[0]?.kind; + const sameKind = attachments.every( + (attachment) => attachment.kind === firstKind, + ); + const kindLabel = sameKind + ? attachmentKindLabel(firstKind, attachments.length) + : "files"; + + return `Attached ${kindLabel}`; +} + export function getDisplaySessionTitle( title: string, defaultTitle: string, diff --git a/ui/goose2/src/features/chat/stores/chatStore.ts b/ui/goose2/src/features/chat/stores/chatStore.ts index fc3273a5f396..f7781b8ef9bb 100644 --- a/ui/goose2/src/features/chat/stores/chatStore.ts +++ b/ui/goose2/src/features/chat/stores/chatStore.ts @@ -1,5 +1,9 @@ import { create } from "zustand"; -import type { Message, MessageContent } from "@/shared/types/messages"; +import type { + ChatAttachmentDraft, + Message, + MessageContent, +} from "@/shared/types/messages"; import { clearReplayBuffer } from "../hooks/replayBuffer"; import type { ChatState, @@ -51,7 +55,7 @@ function createInitialSessionRuntime(): SessionChatRuntime { export interface QueuedMessage { text: string; personaId?: string; - images?: { base64: string; mimeType: string }[]; + attachments?: ChatAttachmentDraft[]; } export interface ScrollTargetMessage { diff --git a/ui/goose2/src/features/chat/ui/ChatInput.tsx b/ui/goose2/src/features/chat/ui/ChatInput.tsx index f7f089d33e52..fdf2bea7d051 100644 --- a/ui/goose2/src/features/chat/ui/ChatInput.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInput.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useCallback, useEffect, useMemo } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; import { X } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { AcpProvider } from "@/shared/api/acp"; @@ -13,11 +14,14 @@ import { ChatInputToolbar } from "./ChatInputToolbar"; import { formatProviderLabel } from "@/shared/ui/icons/ProviderIcons"; import { TooltipProvider } from "@/shared/ui/tooltip"; import { PersonaAvatar } from "./PersonaPicker"; -import { ImageLightbox } from "@/shared/ui/ImageLightbox"; -import type { PastedImage } from "@/shared/types/messages"; -import { resizeImage } from "../lib/resizeImage"; -import { useImageDropTarget } from "../hooks/useImageDropTarget"; +import type { ChatAttachmentDraft } from "@/shared/types/messages"; +import { useAttachmentDropTarget } from "../hooks/useAttachmentDropTarget"; +import { + normalizeDialogSelection, + useChatInputAttachments, +} from "../hooks/useChatInputAttachments"; import type { ModelOption } from "../types"; +import { ChatInputAttachments } from "./ChatInputAttachments"; export interface ProjectOption { id: string; @@ -27,7 +31,11 @@ export interface ProjectOption { } interface ChatInputProps { - onSend: (text: string, personaId?: string, images?: PastedImage[]) => void; + onSend: ( + text: string, + personaId?: string, + attachments?: ChatAttachmentDraft[], + ) => void; onStop?: () => void; isStreaming?: boolean; disabled?: boolean; @@ -36,87 +44,28 @@ interface ChatInputProps { initialValue?: string; onDraftChange?: (text: string) => void; className?: string; - // Personas personas?: Persona[]; selectedPersonaId?: string | null; onPersonaChange?: (personaId: string | null) => void; onCreatePersona?: () => void; - // Provider (secondary -- auto-set by persona but overridable) providers?: AcpProvider[]; providersLoading?: boolean; selectedProvider?: string; onProviderChange?: (providerId: string) => void; - // Model currentModelId?: string | null; currentModel?: string; availableModels?: ModelOption[]; onModelChange?: (modelId: string) => void; - // Project selectedProjectId?: string | null; availableProjects?: ProjectOption[]; onProjectChange?: (projectId: string | null) => void; onCreateProject?: (options?: { onCreated?: (projectId: string) => void; }) => void; - // Context contextTokens?: number; contextLimit?: number; } -// --------------------------------------------------------------------------- -// PastedImageThumb -// --------------------------------------------------------------------------- - -function PastedImageThumb({ - objectUrl, - index, - onRemove, -}: { - objectUrl: string; - index: number; - onRemove: (index: number) => void; -}) { - const [lightboxOpen, setLightboxOpen] = useState(false); - const { t } = useTranslation("chat"); - - return ( - <> -
- - -
- - - ); -} - -// --------------------------------------------------------------------------- -// ChatInput -// --------------------------------------------------------------------------- - export function ChatInput({ onSend, onStop, @@ -155,10 +104,16 @@ export function ChatInput({ }, [onDraftChange], ); - const [images, setImages] = useState([]); const [isCompact, setIsCompact] = useState(false); const textareaRef = useRef(null); const containerRef = useRef(null); + const { + attachments, + addBrowserFiles, + addPathAttachments, + removeAttachment, + clearAttachments, + } = useChatInputAttachments(); const activePersona = useMemo( () => personas.find((persona) => persona.id === selectedPersonaId) ?? null, @@ -174,7 +129,7 @@ export function ChatInput({ const hasQueuedMessage = queuedMessage !== null; const canSend = - (text.trim().length > 0 || images.length > 0) && + (text.trim().length > 0 || attachments.length > 0) && !hasQueuedMessage && !disabled; @@ -200,148 +155,156 @@ export function ChatInput({ }); useEffect(() => { - const el = containerRef.current; - if (!el || typeof ResizeObserver === "undefined") return; + const element = containerRef.current; + if (!element || typeof ResizeObserver === "undefined") { + return; + } + const observer = new ResizeObserver((entries) => { for (const entry of entries) { setIsCompact(entry.contentRect.width < 580); } }); - observer.observe(el); + observer.observe(element); return () => observer.disconnect(); }, []); - // Keep a ref to latest images so the unmount cleanup always sees current state - // without needing images as a dependency (which would revoke still-active URLs - // on every add/remove). - const imagesRef = useRef(images); - imagesRef.current = images; - - useEffect(() => { - return () => { - for (const img of imagesRef.current) { - URL.revokeObjectURL(img.objectUrl); - } - }; - }, []); - // Focus the textarea on mount so the user can type immediately useEffect(() => textareaRef.current?.focus(), []); const handleSend = useCallback(() => { - if (!canSend) return; + if (!canSend) { + return; + } + onSend( text.trim(), selectedPersonaId ?? undefined, - images.length > 0 ? images : undefined, + attachments.length > 0 ? attachments : undefined, ); setText(""); - setImages((prev) => { - for (const img of prev) URL.revokeObjectURL(img.objectUrl); - return []; - }); + clearAttachments(); if (textareaRef.current) { textareaRef.current.style.height = "auto"; } - }, [canSend, text, images, onSend, selectedPersonaId, setText]); + }, [ + attachments, + canSend, + clearAttachments, + onSend, + selectedPersonaId, + setText, + text, + ]); - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (event: React.KeyboardEvent) => { if (mentionOpen) { - if (e.key === "Escape") { - e.preventDefault(); + if (event.key === "Escape") { + event.preventDefault(); closeMention(); return; } - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - e.preventDefault(); - navigateMention(e.key === "ArrowDown" ? "down" : "up"); + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + navigateMention(event.key === "ArrowDown" ? "down" : "up"); return; } - if (e.key === "Enter" || e.key === "Tab") { + if (event.key === "Enter" || event.key === "Tab") { const item = confirmMention(); if (item) { - e.preventDefault(); + event.preventDefault(); handleMentionConfirm(item); return; } } } - if (e.key === "Enter" && !e.shiftKey && !e.altKey) { - e.preventDefault(); + if (event.key === "Enter" && !event.shiftKey && !event.altKey) { + event.preventDefault(); handleSend(); } }; - const handleInput = (e: React.ChangeEvent) => { - const value = e.target.value; + const handleInput = (event: React.ChangeEvent) => { + const value = event.target.value; setText(value); - const cursorPos = e.target.selectionStart ?? value.length; - detectMention(value, cursorPos); - const textarea = e.target; + const cursorPosition = event.target.selectionStart ?? value.length; + detectMention(value, cursorPosition); + const textarea = event.target; textarea.style.height = "auto"; textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; }; - const addImageFile = useCallback((file: File) => { - const objectUrl = URL.createObjectURL(file); - resizeImage(file) - .then(({ base64, mimeType }) => { - setImages((prev) => [...prev, { base64, mimeType, objectUrl }]); - }) - .catch(() => { - const reader = new FileReader(); - reader.onload = () => { - const dataUrl = reader.result as string; - const [header, b64] = dataUrl.split(","); - const mime = header.replace("data:", "").replace(";base64", ""); - setImages((prev) => [ - ...prev, - { base64: b64, mimeType: mime, objectUrl }, - ]); - }; - reader.onerror = () => { - URL.revokeObjectURL(objectUrl); - }; - reader.readAsDataURL(file); - }); - }, []); - const handlePaste = useCallback( - (e: React.ClipboardEvent) => { - const items = Array.from(e.clipboardData.items); - const imageItems = items.filter((item) => item.type.startsWith("image/")); - if (imageItems.length === 0) return; + (event: React.ClipboardEvent) => { + const files = 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)); - e.preventDefault(); - for (const item of imageItems) { - const file = item.getAsFile(); - if (!file) continue; - addImageFile(file); + if (files.length === 0) { + return; } + + event.preventDefault(); + void addBrowserFiles(files); }, - [addImageFile], + [addBrowserFiles], ); const { - isImageDragOver, + isAttachmentDragOver, handleDragEnter, handleDragOver, handleDragLeave, handleDrop, - } = useImageDropTarget({ + } = useAttachmentDropTarget({ disabled, isStreaming, - onDropFile: addImageFile, + targetRef: containerRef, + onDropFiles: (files) => { + void addBrowserFiles(files); + }, + onDropPaths: (paths) => { + void addPathAttachments(paths); + }, }); - const removeImage = useCallback((index: number) => { - setImages((prev) => { - URL.revokeObjectURL(prev[index].objectUrl); - return prev.filter((_, i) => i !== index); - }); - }, []); + const handleAttachFiles = useCallback(async () => { + if (disabled) { + return; + } + + try { + const selected = await open({ + title: t("attachments.chooseFilesDialogTitle"), + multiple: true, + }); + await addPathAttachments(normalizeDialogSelection(selected)); + } catch { + // Dialog plugin may be unavailable in some environments. + } + }, [addPathAttachments, disabled, t]); + + const handleAttachFolders = useCallback(async () => { + if (disabled) { + return; + } + + try { + const selected = await open({ + directory: true, + title: t("attachments.chooseFoldersDialogTitle"), + multiple: true, + }); + await addPathAttachments(normalizeDialogSelection(selected)); + } catch { + // Dialog plugin may be unavailable in some environments. + } + }, [addPathAttachments, disabled, t]); const providerDisplayName = - providers.find((p) => p.id === selectedProvider)?.label ?? + providers.find((provider) => provider.id === selectedProvider)?.label ?? formatProviderLabel(selectedProvider); const agentDisplayName = activePersona?.displayName ?? providerDisplayName; const resolvedCurrentModel = @@ -356,21 +319,22 @@ export function ChatInput({ return ( -
+
- {/* biome-ignore lint/a11y/noStaticElementInteractions: drop zone for image files */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: drop zone for file attachments */}
- {isImageDragOver && ( + {isAttachmentDragOver && (
)} + - {images.length > 0 && ( -
- {images.map((img, i) => ( - - ))} -
- )} + {stickyPersona && (
@@ -475,6 +432,9 @@ export function ChatInput({ canSend={canSend} isStreaming={isStreaming} hasQueuedMessage={hasQueuedMessage} + onAttachFiles={handleAttachFiles} + onAttachFolders={handleAttachFolders} + disabled={disabled} onSend={handleSend} onStop={onStop} isCompact={isCompact} diff --git a/ui/goose2/src/features/chat/ui/ChatInputAttachments.tsx b/ui/goose2/src/features/chat/ui/ChatInputAttachments.tsx new file mode 100644 index 000000000000..416eb128076b --- /dev/null +++ b/ui/goose2/src/features/chat/ui/ChatInputAttachments.tsx @@ -0,0 +1,119 @@ +import { useState } from "react"; +import { FileText, FolderClosed, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { ImageLightbox } from "@/shared/ui/ImageLightbox"; +import type { + ChatAttachmentDraft, + ChatDirectoryAttachmentDraft, + ChatFileAttachmentDraft, + ChatImageAttachmentDraft, +} from "@/shared/types/messages"; + +function DraftImageAttachment({ + attachment, + index, + onRemove, +}: { + attachment: ChatImageAttachmentDraft; + index: number; + onRemove: (id: string) => void; +}) { + const [lightboxOpen, setLightboxOpen] = useState(false); + const { t } = useTranslation("chat"); + + return ( + <> +
+ + +
+ + + ); +} + +function DraftPathAttachment({ + attachment, + onRemove, +}: { + attachment: ChatFileAttachmentDraft | ChatDirectoryAttachmentDraft; + onRemove: (id: string) => void; +}) { + const { t } = useTranslation("chat"); + const Icon = attachment.kind === "directory" ? FolderClosed : FileText; + + return ( +
+ + {attachment.name} + +
+ ); +} + +export function ChatInputAttachments({ + attachments, + onRemove, +}: { + attachments: ChatAttachmentDraft[]; + onRemove: (id: string) => void; +}) { + if (attachments.length === 0) { + return null; + } + + return ( +
+ {attachments.map((attachment, index) => + attachment.kind === "image" ? ( + + ) : ( + + ), + )} +
+ ); +} diff --git a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx index 666f99cbf6b1..85cffd37900c 100644 --- a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx @@ -1,5 +1,12 @@ import { useMemo } from "react"; -import { Mic, ArrowUp, Square } from "lucide-react"; +import { + Mic, + ArrowUp, + Square, + Paperclip, + File, + FolderOpen, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { useLocaleFormatting } from "@/shared/i18n"; import { IconLibraryPlusFilled } from "@tabler/icons-react"; @@ -11,6 +18,12 @@ import { ContextRing } from "./ContextRing"; import { PersonaPicker } from "./PersonaPicker"; import type { ProjectOption } from "./ChatInput"; import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/shared/ui/tooltip"; import { AgentModelPicker } from "./AgentModelPicker"; import type { ModelOption } from "../types"; @@ -69,6 +82,9 @@ interface ChatInputToolbarProps { hasQueuedMessage: boolean; onSend: () => void; onStop?: () => void; + onAttachFiles?: () => void; + onAttachFolders?: () => void; + disabled?: boolean; // Layout isCompact: boolean; } @@ -97,6 +113,9 @@ export function ChatInputToolbar({ hasQueuedMessage, onSend, onStop, + onAttachFiles, + onAttachFolders, + disabled = false, isCompact, }: ChatInputToolbarProps) { const { t } = useTranslation("chat"); @@ -243,6 +262,37 @@ export function ChatInputToolbar({ )} + + + + + + onAttachFiles?.()} + disabled={disabled} + > + + {t("toolbar.attachFile")} + + onAttachFolders?.()} + disabled={disabled} + > + + {t("toolbar.attachFolder")} + + + + diff --git a/ui/goose2/src/features/chat/ui/ChatView.tsx b/ui/goose2/src/features/chat/ui/ChatView.tsx index 7551a53c4a18..0478343dae04 100644 --- a/ui/goose2/src/features/chat/ui/ChatView.tsx +++ b/ui/goose2/src/features/chat/ui/ChatView.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { AnimatePresence } from "motion/react"; import { MessageTimeline } from "./MessageTimeline"; import { ChatInput } from "./ChatInput"; -import type { PastedImage } from "@/shared/types/messages"; +import type { ChatAttachmentDraft } from "@/shared/types/messages"; import { LoadingGoose } from "./LoadingGoose"; import { ChatLoadingSkeleton } from "./ChatLoadingSkeleton"; import { useChat } from "../hooks/useChat"; @@ -33,7 +33,7 @@ interface ChatViewProps { initialProvider?: string; initialPersonaId?: string; initialMessage?: string; - initialImages?: PastedImage[]; + initialAttachments?: ChatAttachmentDraft[]; onInitialMessageConsumed?: () => void; onCreateProject?: (options?: { onCreated?: (projectId: string) => void; @@ -45,7 +45,7 @@ export function ChatView({ initialProvider, initialPersonaId, initialMessage, - initialImages, + initialAttachments, onInitialMessageConsumed, onCreateProject, }: ChatViewProps) { @@ -351,13 +351,14 @@ export function ChatView({ (s.messagesBySession[activeSessionId]?.length ?? 0) === 0, ); - const deferredSend = useRef<{ text: string; images?: PastedImage[] } | null>( - null, - ); + const deferredSend = useRef<{ + text: string; + attachments?: ChatAttachmentDraft[]; + } | null>(null); const queue = useMessageQueue(activeSessionId, chatState, sendMessage); const chatStore = useChatStore(); const handleSend = useCallback( - (text: string, personaId?: string, images?: PastedImage[]) => { + (text: string, personaId?: string, attachments?: ChatAttachmentDraft[]) => { if (personaId && personaId !== selectedPersonaId) { const newPersona = personas.find((p) => p.id === personaId); if (newPersona) { @@ -378,16 +379,16 @@ export function ChatView({ } handlePersonaChange(personaId); // Defer the send until after persona state updates - deferredSend.current = { text, images }; + deferredSend.current = { text, attachments }; return; } // Queue if agent is busy and no message already queued if (chatState !== "idle" && !queue.queuedMessage) { - queue.enqueue(text, personaId, images); + queue.enqueue(text, personaId, attachments); return; } - sendMessage(text, undefined, images); + sendMessage(text, undefined, attachments); }, [ sendMessage, @@ -403,22 +404,27 @@ export function ChatView({ useEffect(() => { if (deferredSend.current && selectedPersona) { - const { text, images } = deferredSend.current; + const { text, attachments } = deferredSend.current; deferredSend.current = null; - sendMessage(text, undefined, images); + sendMessage(text, undefined, attachments); } }, [sendMessage, selectedPersona]); const initialMessageSent = useRef(false); useEffect(() => { if ( - (initialMessage || initialImages?.length) && + (initialMessage || initialAttachments?.length) && !initialMessageSent.current ) { initialMessageSent.current = true; - handleSend(initialMessage ?? "", undefined, initialImages); + handleSend(initialMessage ?? "", undefined, initialAttachments); onInitialMessageConsumed?.(); } - }, [initialMessage, initialImages, handleSend, onInitialMessageConsumed]); + }, [ + initialAttachments, + initialMessage, + handleSend, + onInitialMessageConsumed, + ]); const isStreaming = chatState === "streaming"; const showIndicator = chatState === "thinking" || diff --git a/ui/goose2/src/features/chat/ui/MessageBubble.tsx b/ui/goose2/src/features/chat/ui/MessageBubble.tsx index ee096d2d5157..6363f98c0a6f 100644 --- a/ui/goose2/src/features/chat/ui/MessageBubble.tsx +++ b/ui/goose2/src/features/chat/ui/MessageBubble.tsx @@ -1,7 +1,16 @@ import { useState, memo } from "react"; import { useTranslation } from "react-i18next"; -import { Copy, Check, RotateCcw, Pencil, User } from "lucide-react"; +import { + Copy, + Check, + RotateCcw, + Pencil, + User, + FileText, + FolderClosed, +} from "lucide-react"; import { IconRobot } from "@tabler/icons-react"; +import { openPath } from "@tauri-apps/plugin-opener"; import { cn } from "@/shared/lib/cn"; import { useLocaleFormatting } from "@/shared/i18n"; import { useAgentStore } from "@/features/agents/stores/agentStore"; @@ -26,6 +35,7 @@ import { ClickableImage } from "./ClickableImage"; import { useArtifactLinkHandler } from "@/features/chat/hooks/useArtifactLinkHandler"; import type { Message, + MessageAttachment, MessageContent, TextContent, ImageContent, @@ -35,6 +45,44 @@ import type { SystemNotificationContent, } from "@/shared/types/messages"; +function MessageAttachmentRow({ + attachment, +}: { + attachment: MessageAttachment; +}) { + const { t } = useTranslation("chat"); + const Icon = attachment.type === "directory" ? FolderClosed : FileText; + const canOpen = Boolean(attachment.path); + + return ( + + ); +} + interface MessageBubbleProps { message: Message; isStreaming?: boolean; @@ -307,6 +355,7 @@ export const MessageBubble = memo(function MessageBubble({ !isUser && (assistantDisplayName || personaAvatarUrl || assistantProviderIcon), ); + const messageAttachments = message.metadata?.attachments ?? []; return (
+ {messageAttachments.length > 0 && ( +
+ {messageAttachments.map((attachment) => ( + + ))} +
+ )} {groupContentSections(content).map((section, sectionIdx) => { if (section.type === "toolChain") { const toolItems = section.items as ToolChainItem[]; diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx new file mode 100644 index 000000000000..b99a26b21b37 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createEvent, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ChatInput } from "../ChatInput"; + +vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({ + useAgentProviderStatus: () => ({ + readyAgentIds: new Set(["goose", "claude-acp", "codex-acp"]), + loading: false, + refresh: vi.fn(), + }), +})); + +vi.mock("@/shared/lib/platform", () => ({ + getPlatform: () => "mac", +})); + +const mockListFilesForMentions = vi.fn< + (roots: string[], maxResults?: number) => Promise +>(async () => []); +const mockInspectAttachmentPaths = vi.fn< + (paths: string[]) => Promise< + { + name: string; + path: string; + kind: "file" | "directory"; + mimeType?: string | null; + }[] + > +>(async () => []); +const mockReadImageAttachment = vi.fn< + (path: string) => Promise<{ base64: string; mimeType: string }> +>(async () => ({ base64: "abc", mimeType: "image/png" })); + +vi.mock("@/shared/api/system", () => ({ + listFilesForMentions: (roots: string[], maxResults?: number) => + mockListFilesForMentions(roots, maxResults), + inspectAttachmentPaths: (paths: string[]) => + mockInspectAttachmentPaths(paths), + readImageAttachment: (path: string) => mockReadImageAttachment(path), +})); + +const mockOpenDialog = vi.fn(); +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: (...args: unknown[]) => mockOpenDialog(...args), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + convertFileSrc: (path: string) => `asset://${path}`, +})); + +describe("ChatInput attachments", () => { + beforeEach(() => { + mockListFilesForMentions.mockClear(); + mockListFilesForMentions.mockResolvedValue([]); + mockInspectAttachmentPaths.mockClear(); + mockInspectAttachmentPaths.mockResolvedValue([]); + mockReadImageAttachment.mockClear(); + mockReadImageAttachment.mockResolvedValue({ + base64: "abc", + mimeType: "image/png", + }); + mockOpenDialog.mockClear(); + mockOpenDialog.mockResolvedValue(null); + }); + + it("attaches a file from the toolbar menu and sends it without text", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + mockOpenDialog.mockResolvedValue("/Users/test/report.pdf"); + mockInspectAttachmentPaths.mockResolvedValue([ + { + name: "report.pdf", + path: "/Users/test/report.pdf", + kind: "file", + mimeType: "application/pdf", + }, + ]); + + render(); + + await user.click(screen.getByRole("button", { name: /attach/i })); + await user.click(screen.getByRole("menuitem", { name: /^file$/i })); + + expect(mockOpenDialog).toHaveBeenCalledWith({ + title: "Choose files to attach", + multiple: true, + }); + expect(await screen.findByText("report.pdf")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /send message/i })); + + expect(onSend).toHaveBeenCalledWith( + "", + undefined, + expect.arrayContaining([ + expect.objectContaining({ + kind: "file", + name: "report.pdf", + path: "/Users/test/report.pdf", + }), + ]), + ); + }); + + it("attaches a folder from the toolbar menu", async () => { + const user = userEvent.setup(); + mockOpenDialog.mockResolvedValue("/Users/test/screenshots"); + mockInspectAttachmentPaths.mockResolvedValue([ + { + name: "screenshots", + path: "/Users/test/screenshots", + kind: "directory", + }, + ]); + + render(); + + await user.click(screen.getByRole("button", { name: /attach/i })); + await user.click(screen.getByRole("menuitem", { name: /folder/i })); + + expect(mockOpenDialog).toHaveBeenCalledWith({ + directory: true, + title: "Choose folders to attach", + multiple: true, + }); + expect(await screen.findByText("screenshots")).toBeInTheDocument(); + }); + + it("shows the generic attachment drop overlay for file drags", () => { + render(); + + const textbox = screen.getByRole("textbox"); + const composer = textbox.closest("div.rounded-2xl"); + if (!composer) { + throw new Error("Expected composer container"); + } + const dataTransfer = { + files: [new File(["hello"], "report.txt", { type: "text/plain" })], + items: [{ kind: "file" }], + types: ["Files"], + } as unknown as DataTransfer; + + fireEvent.dragEnter(composer, { dataTransfer }); + fireEvent.dragOver(composer, { dataTransfer }); + + expect( + screen.getByText("Drop files or folders to attach"), + ).toBeInTheDocument(); + }); + + it("does not cancel non-file drops into the composer", () => { + render(); + + const textbox = screen.getByRole("textbox"); + const composer = textbox.closest("div.rounded-2xl"); + if (!composer) { + throw new Error("Expected composer container"); + } + + const dropEvent = createEvent.drop(composer, { + dataTransfer: { + files: [], + items: [{ kind: "string" }], + types: ["text/plain"], + }, + }); + dropEvent.preventDefault = vi.fn(); + + fireEvent(composer, dropEvent); + + expect(dropEvent.preventDefault).not.toHaveBeenCalled(); + }); + + it("renders mixed attachments from a single file picker pass", async () => { + const user = userEvent.setup(); + mockOpenDialog.mockResolvedValue([ + "/Users/test/report.pdf", + "/Users/test/diagram.png", + ]); + mockInspectAttachmentPaths.mockResolvedValue([ + { + name: "report.pdf", + path: "/Users/test/report.pdf", + kind: "file", + mimeType: "application/pdf", + }, + { + name: "diagram.png", + path: "/Users/test/diagram.png", + kind: "file", + mimeType: "image/png", + }, + ]); + + render(); + + await user.click(screen.getByRole("button", { name: /attach/i })); + await user.click(screen.getByRole("menuitem", { name: /^file$/i })); + + expect(await screen.findByText("report.pdf")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByAltText("Attachment 2")).toBeInTheDocument(); + }); + }); + + it("dedupes path attachments that differ only by case on case-insensitive platforms", async () => { + const user = userEvent.setup(); + mockOpenDialog.mockResolvedValue("/Users/test/report.pdf"); + mockInspectAttachmentPaths + .mockResolvedValueOnce([ + { + name: "report.pdf", + path: "/Users/test/report.pdf", + kind: "file", + mimeType: "application/pdf", + }, + ]) + .mockResolvedValueOnce([ + { + name: "report.pdf", + path: "/users/test/REPORT.pdf", + kind: "file", + mimeType: "application/pdf", + }, + ]); + + render(); + + await user.click(screen.getByRole("button", { name: /^attach$/i })); + await user.click(screen.getByRole("menuitem", { name: /^file$/i })); + expect(await screen.findByText("report.pdf")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /^attach$/i })); + await user.click(screen.getByRole("menuitem", { name: /^file$/i })); + + expect(screen.getAllByText("report.pdf")).toHaveLength(1); + }); +}); diff --git a/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx index 83d396a5e98c..55531e8ed5f8 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx @@ -5,6 +5,11 @@ import { MessageBubble } from "../MessageBubble"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import type { Message } from "@/shared/types/messages"; +const mockOpenPath = vi.fn(); +vi.mock("@tauri-apps/plugin-opener", () => ({ + openPath: (path: string) => mockOpenPath(path), +})); + // ── helpers ─────────────────────────────────────────────────────────── function userMessage(text: string, overrides: Partial = {}): Message { @@ -35,6 +40,7 @@ function assistantMessage( describe("MessageBubble", () => { beforeEach(() => { useAgentStore.setState({ personas: [] }); + mockOpenPath.mockClear(); }); it("renders user message with correct alignment", () => { @@ -100,6 +106,39 @@ describe("MessageBubble", () => { expect(screen.getByText("readFile")).toBeInTheDocument(); }); + it("renders metadata attachments and opens them on click", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /open attachment report\.pdf/i }), + ); + expect(mockOpenPath).toHaveBeenCalledWith("/Users/test/report.pdf"); + expect( + screen.getByRole("button", { name: /open attachment screenshots/i }), + ).toBeInTheDocument(); + }); + it("renders standalone tool responses without dropping surrounding text", () => { const msg = assistantMessage([ { type: "text", text: "Working on it." }, diff --git a/ui/goose2/src/features/home/ui/HomeScreen.tsx b/ui/goose2/src/features/home/ui/HomeScreen.tsx index 1f187a59eaef..54d3f8087281 100644 --- a/ui/goose2/src/features/home/ui/HomeScreen.tsx +++ b/ui/goose2/src/features/home/ui/HomeScreen.tsx @@ -7,7 +7,7 @@ import { import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection"; import { ChatInput } from "@/features/chat/ui/ChatInput"; import { useChatStore } from "@/features/chat/stores/chatStore"; -import type { PastedImage } from "@/shared/types/messages"; +import type { ChatAttachmentDraft } from "@/shared/types/messages"; import { useProjectStore } from "@/features/projects/stores/projectStore"; import { useLocaleFormatting } from "@/shared/i18n"; @@ -53,7 +53,7 @@ interface HomeScreenProps { providerId?: string, personaId?: string, projectId?: string | null, - images?: PastedImage[], + attachments?: ChatAttachmentDraft[], ) => void; onCreateProject?: (options?: { onCreated?: (projectId: string) => void; @@ -106,7 +106,11 @@ export function HomeScreen({ onStartChat, onCreateProject }: HomeScreenProps) { }, []); const handleSend = useCallback( - (message: string, personaId?: string, images?: PastedImage[]) => { + ( + message: string, + personaId?: string, + attachments?: ChatAttachmentDraft[], + ) => { const effectivePersonaId = personaId ?? selectedPersonaId ?? undefined; useChatStore.getState().clearDraft(HOME_DRAFT_KEY); @@ -115,7 +119,7 @@ export function HomeScreen({ onStartChat, onCreateProject }: HomeScreenProps) { selectedProvider, effectivePersonaId, selectedProjectId, - images, + attachments, ); }, [onStartChat, selectedPersonaId, selectedProjectId, selectedProvider], diff --git a/ui/goose2/src/shared/api/system.ts b/ui/goose2/src/shared/api/system.ts index 663642b3a961..9ae1e668905f 100644 --- a/ui/goose2/src/shared/api/system.ts +++ b/ui/goose2/src/shared/api/system.ts @@ -6,6 +6,18 @@ export interface FileTreeEntry { kind: "file" | "directory"; } +export interface AttachmentPathInfo { + name: string; + path: string; + kind: "file" | "directory"; + mimeType?: string | null; +} + +export interface ImageAttachmentPayload { + base64: string; + mimeType: string; +} + export async function getHomeDir(): Promise { return invoke("get_home_dir"); } @@ -33,3 +45,15 @@ export async function listDirectoryEntries( ): Promise { return invoke("list_directory_entries", { path }); } + +export async function inspectAttachmentPaths( + paths: string[], +): Promise { + return invoke("inspect_attachment_paths", { paths }); +} + +export async function readImageAttachment( + path: string, +): Promise { + return invoke("read_image_attachment", { path }); +} diff --git a/ui/goose2/src/shared/i18n/locales/en/chat.json b/ui/goose2/src/shared/i18n/locales/en/chat.json index 2fffb22f6331..ee149f346005 100644 --- a/ui/goose2/src/shared/i18n/locales/en/chat.json +++ b/ui/goose2/src/shared/i18n/locales/en/chat.json @@ -1,8 +1,11 @@ { "attachments": { "alt": "Attachment {{index}}", - "dropToAttach": "Drop images to attach", - "remove": "Remove image", + "chooseFilesDialogTitle": "Choose files to attach", + "chooseFoldersDialogTitle": "Choose folders to attach", + "dropToAttach": "Drop files or folders to attach", + "open": "Open attachment {{name}}", + "remove": "Remove attachment", "view": "View attachment {{index}}" }, "context": { @@ -141,6 +144,9 @@ }, "toolbar": { "agent": "Agent", + "attach": "Attach", + "attachFile": "File", + "attachFolder": "Folder", "chooseAgentModel": "Choose agent and model", "chooseProject": "Choose a project", "chooseProvider": "Choose a provider", diff --git a/ui/goose2/src/shared/i18n/locales/es/chat.json b/ui/goose2/src/shared/i18n/locales/es/chat.json index 69f0c5f51c66..63c807e53733 100644 --- a/ui/goose2/src/shared/i18n/locales/es/chat.json +++ b/ui/goose2/src/shared/i18n/locales/es/chat.json @@ -1,8 +1,11 @@ { "attachments": { "alt": "Adjunto {{index}}", - "dropToAttach": "Suelta imágenes para adjuntarlas", - "remove": "Eliminar imagen", + "chooseFilesDialogTitle": "Elegir archivos para adjuntar", + "chooseFoldersDialogTitle": "Elegir carpetas para adjuntar", + "dropToAttach": "Suelta archivos o carpetas para adjuntarlos", + "open": "Abrir adjunto {{name}}", + "remove": "Eliminar adjunto", "view": "Ver adjunto {{index}}" }, "context": { @@ -141,6 +144,9 @@ }, "toolbar": { "agent": "Agente", + "attach": "Adjuntar", + "attachFile": "Archivo", + "attachFolder": "Carpeta", "chooseAgentModel": "Elegir agente y modelo", "chooseProject": "Elegir un proyecto", "chooseProvider": "Elegir un proveedor", diff --git a/ui/goose2/src/shared/types/messages.ts b/ui/goose2/src/shared/types/messages.ts index 42e29cd66531..24b3ed9ee417 100644 --- a/ui/goose2/src/shared/types/messages.ts +++ b/ui/goose2/src/shared/types/messages.ts @@ -1,9 +1,35 @@ -export interface PastedImage { - base64: string; +export type ChatAttachmentKind = "image" | "file" | "directory"; + +export interface ChatImageAttachmentDraft { + id: string; + kind: "image"; + name: string; + path?: string; mimeType: string; - objectUrl: string; + base64: string; + previewUrl: string; +} + +export interface ChatFileAttachmentDraft { + id: string; + kind: "file"; + name: string; + path?: string; + mimeType?: string; +} + +export interface ChatDirectoryAttachmentDraft { + id: string; + kind: "directory"; + name: string; + path: string; } +export type ChatAttachmentDraft = + | ChatImageAttachmentDraft + | ChatFileAttachmentDraft + | ChatDirectoryAttachmentDraft; + // Message roles export type MessageRole = "user" | "assistant" | "system"; @@ -97,6 +123,7 @@ export interface MessageAttachment { name: string; path?: string; url?: string; + mimeType?: string; } export interface MessageChip {