mirror of
https://github.com/block/goose.git
synced 2026-04-28 03:29:36 +00:00
port goose2 chat attachments into goose (#8534)
Signed-off-by: tulsi <tulsi@block.xyz>
This commit is contained in:
parent
8e04d7c8be
commit
210ef52d81
38 changed files with 2142 additions and 356 deletions
|
|
@ -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)
|
||||
|
|
|
|||
18
ui/goose2/src-tauri/Cargo.lock
generated
18
ui/goose2/src-tauri/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String, String> {
|
||||
let home_dir = dirs::home_dir().ok_or("Could not determine home directory")?;
|
||||
|
|
@ -81,31 +100,18 @@ fn read_directory_entries(path: &Path) -> Result<Vec<FileTreeEntry>, 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<Vec<FileTreeEntry>, String> {
|
|||
Ok(entries)
|
||||
}
|
||||
|
||||
fn build_file_tree_entry(path: PathBuf, name: String) -> Option<FileTreeEntry> {
|
||||
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<Vec<FileTreeEntry>, String> {
|
||||
read_directory_entries(Path::new(&path))
|
||||
}
|
||||
|
||||
fn inspect_attachment_path(path: &Path) -> Result<AttachmentPathInfo, String> {
|
||||
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<String>) -> Vec<PathBuf> {
|
||||
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<String>) -> Result<Vec<AttachmentPathInfo>, 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<ImageAttachmentPayload, String> {
|
||||
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<String>) -> Vec<PathBuf> {
|
||||
let mut dedup = HashSet::new();
|
||||
let mut normalized = Vec::new();
|
||||
|
|
@ -134,7 +267,7 @@ fn normalize_roots(roots: Vec<String>) -> Vec<PathBuf> {
|
|||
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<String>, max_results: Option<usize>) -> 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!())
|
||||
|
|
|
|||
|
|
@ -21,6 +21,21 @@ async fn clear_cancel_requested(state: &Arc<Mutex<ManagerState>>, composite_key:
|
|||
guard.pending_cancels.remove(composite_key);
|
||||
}
|
||||
|
||||
pub(super) fn build_content_blocks(
|
||||
prompt: String,
|
||||
images: Vec<(String, String)>,
|
||||
) -> Vec<AcpContentBlock> {
|
||||
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<ClientSideConnection>,
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(_)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
"visible": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"dragDropEnabled": false,
|
||||
"dragDropEnabled": true,
|
||||
"trafficLightPosition": { "x": 12, "y": 22 }
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,22 @@ function TextFollowupProbe({
|
|||
);
|
||||
}
|
||||
|
||||
function ReadOnlyProbe({ readArgs }: { readArgs: Record<string, unknown> }) {
|
||||
const { resolveToolCardDisplay, getAllSessionArtifacts } =
|
||||
useArtifactPolicyContext();
|
||||
const display = resolveToolCardDisplay(readArgs, "read_file");
|
||||
const artifacts = getAllSessionArtifacts();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="read-only-role">{display.role}</span>
|
||||
<span data-testid="read-only-artifacts">
|
||||
{artifacts.map((artifact) => artifact.resolvedPath).join(",")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<ArtifactPolicyProvider
|
||||
messages={messages}
|
||||
allowedRoots={["/Users/test/project-a", "/Users/test/.goose/artifacts"]}
|
||||
>
|
||||
<ReadOnlyProbe readArgs={readArgs} />
|
||||
</ArtifactPolicyProvider>,
|
||||
);
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<FileReader>,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
228
ui/goose2/src/features/chat/hooks/useAttachmentDropTarget.ts
Normal file
228
ui/goose2/src/features/chat/hooks/useAttachmentDropTarget.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
|
||||
interface UseAttachmentDropTargetOptions {
|
||||
disabled: boolean;
|
||||
isStreaming: boolean;
|
||||
targetRef: RefObject<HTMLDivElement | null>;
|
||||
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<HTMLDivElement>) => {
|
||||
const draggedFiles = hasDraggedFiles(event.dataTransfer);
|
||||
if (disabled || isStreaming || !draggedFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setIsAttachmentDragOver(true);
|
||||
},
|
||||
[disabled, isStreaming],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(event: DragEvent<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) {
|
||||
setIsAttachmentDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: DragEvent<HTMLDivElement>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
233
ui/goose2/src/features/chat/hooks/useChatInputAttachments.ts
Normal file
233
ui/goose2/src/features/chat/hooks/useChatInputAttachments.ts
Normal file
|
|
@ -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<ChatImageAttachmentDraft> {
|
||||
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<ChatAttachmentDraft[]>([]);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>) => {
|
||||
if (disabled || isStreaming || !hasDraggedFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setIsImageDragOver(true);
|
||||
},
|
||||
[disabled, isStreaming],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: DragEvent<HTMLDivElement>) => {
|
||||
if (disabled || isStreaming || !hasDraggedFiles(e.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsImageDragOver(true);
|
||||
},
|
||||
[disabled, isStreaming],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(
|
||||
(e: DragEvent<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export {
|
|||
extractToolCallCandidates,
|
||||
inferHomeDirFromRoots,
|
||||
isExternalHref,
|
||||
isWriteOrientedTool,
|
||||
normalizePath,
|
||||
resolveMarkdownLocalHref,
|
||||
resolvePathCandidate,
|
||||
|
|
|
|||
68
ui/goose2/src/features/chat/lib/attachments.ts
Normal file
68
ui/goose2/src/features/chat/lib/attachments.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<div className="group relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
className="block cursor-pointer rounded-lg"
|
||||
aria-label={t("attachments.view", { index: index + 1 })}
|
||||
>
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={t("attachments.alt", { index: index + 1 })}
|
||||
className="h-16 w-16 rounded-lg object-cover border border-border"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-foreground text-background opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||
aria-label={t("attachments.remove")}
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ImageLightbox
|
||||
src={objectUrl}
|
||||
alt={t("attachments.alt", { index: index + 1 })}
|
||||
open={lightboxOpen}
|
||||
onOpenChange={setLightboxOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatInput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
onStop,
|
||||
|
|
@ -155,10 +104,16 @@ export function ChatInput({
|
|||
},
|
||||
[onDraftChange],
|
||||
);
|
||||
const [images, setImages] = useState<PastedImage[]>([]);
|
||||
const [isCompact, setIsCompact] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
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<HTMLTextAreaElement>) => {
|
||||
const items = Array.from(e.clipboardData.items);
|
||||
const imageItems = items.filter((item) => item.type.startsWith("image/"));
|
||||
if (imageItems.length === 0) return;
|
||||
(event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className={cn("px-4 pb-6 pt-2", className)} ref={containerRef}>
|
||||
<div className={cn("px-4 pb-6 pt-2", className)}>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Popover open={mentionOpen}>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: drop zone for image files */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: drop zone for file attachments */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative rounded-2xl border border-border bg-background px-4 pb-3 pt-4 transition-colors",
|
||||
isImageDragOver && "bg-muted/20",
|
||||
isAttachmentDragOver && "bg-muted/20",
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{isImageDragOver && (
|
||||
{isAttachmentDragOver && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-2xl border border-dashed border-border bg-background/70">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
|
|
@ -380,6 +344,7 @@ export function ChatInput({
|
|||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MentionAutocomplete
|
||||
filteredPersonas={filteredPersonas}
|
||||
filteredFiles={filteredFiles}
|
||||
|
|
@ -390,18 +355,10 @@ export function ChatInput({
|
|||
selectedIndex={mentionSelectedIndex}
|
||||
/>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{images.map((img, i) => (
|
||||
<PastedImageThumb
|
||||
key={img.objectUrl}
|
||||
objectUrl={img.objectUrl}
|
||||
index={i}
|
||||
onRemove={removeImage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChatInputAttachments
|
||||
attachments={attachments}
|
||||
onRemove={removeAttachment}
|
||||
/>
|
||||
|
||||
{stickyPersona && (
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
119
ui/goose2/src/features/chat/ui/ChatInputAttachments.tsx
Normal file
119
ui/goose2/src/features/chat/ui/ChatInputAttachments.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<div className="group relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
className="block cursor-pointer rounded-lg"
|
||||
aria-label={t("attachments.view", { index: index + 1 })}
|
||||
title={attachment.path ?? attachment.name}
|
||||
>
|
||||
<img
|
||||
src={attachment.previewUrl}
|
||||
alt={t("attachments.alt", { index: index + 1 })}
|
||||
className="h-16 w-16 rounded-lg border border-border object-cover"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-foreground text-background opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
||||
aria-label={t("attachments.remove")}
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ImageLightbox
|
||||
src={attachment.previewUrl}
|
||||
alt={t("attachments.alt", { index: index + 1 })}
|
||||
open={lightboxOpen}
|
||||
onOpenChange={setLightboxOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftPathAttachment({
|
||||
attachment,
|
||||
onRemove,
|
||||
}: {
|
||||
attachment: ChatFileAttachmentDraft | ChatDirectoryAttachmentDraft;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("chat");
|
||||
const Icon = attachment.kind === "directory" ? FolderClosed : FileText;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex items-center gap-2 rounded-full border border-border bg-muted/40 px-3 py-1.5 pr-8 text-xs text-foreground"
|
||||
title={attachment.path ?? attachment.name}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-44 truncate">{attachment.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
className="absolute right-2 flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:text-foreground"
|
||||
aria-label={t("attachments.remove")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatInputAttachments({
|
||||
attachments,
|
||||
onRemove,
|
||||
}: {
|
||||
attachments: ChatAttachmentDraft[];
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{attachments.map((attachment, index) =>
|
||||
attachment.kind === "image" ? (
|
||||
<DraftImageAttachment
|
||||
key={attachment.id}
|
||||
attachment={attachment}
|
||||
index={index}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
) : (
|
||||
<DraftPathAttachment
|
||||
key={attachment.id}
|
||||
attachment={attachment}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
|||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={disabled}
|
||||
aria-label={t("toolbar.attach")}
|
||||
title={t("toolbar.attach")}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onAttachFiles?.()}
|
||||
disabled={disabled}
|
||||
>
|
||||
<File className="mr-2 h-4 w-4" />
|
||||
{t("toolbar.attachFile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onAttachFolders?.()}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
{t("toolbar.attachFolder")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!attachment.path) {
|
||||
return;
|
||||
}
|
||||
void openPath(attachment.path);
|
||||
}}
|
||||
disabled={!canOpen}
|
||||
className={cn(
|
||||
"flex max-w-full items-center gap-2 rounded-full border border-border bg-muted/40 px-3 py-1.5 text-xs text-foreground",
|
||||
canOpen
|
||||
? "cursor-pointer hover:bg-muted/70"
|
||||
: "cursor-default opacity-80",
|
||||
)}
|
||||
aria-label={
|
||||
canOpen
|
||||
? t("attachments.open", { name: attachment.name })
|
||||
: attachment.name
|
||||
}
|
||||
title={attachment.path ?? attachment.name}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{attachment.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
|
|
@ -360,6 +409,16 @@ export const MessageBubble = memo(function MessageBubble({
|
|||
className="w-full min-w-0 text-[13px] leading-relaxed"
|
||||
onClick={handleContentClick}
|
||||
>
|
||||
{messageAttachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{messageAttachments.map((attachment) => (
|
||||
<MessageAttachmentRow
|
||||
key={`${attachment.type}-${attachment.path ?? attachment.name}`}
|
||||
attachment={attachment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{groupContentSections(content).map((section, sectionIdx) => {
|
||||
if (section.type === "toolChain") {
|
||||
const toolItems = section.items as ToolChainItem[];
|
||||
|
|
|
|||
|
|
@ -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<string[]>
|
||||
>(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(<ChatInput onSend={onSend} />);
|
||||
|
||||
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(<ChatInput onSend={vi.fn()} />);
|
||||
|
||||
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(<ChatInput onSend={vi.fn()} />);
|
||||
|
||||
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(<ChatInput onSend={vi.fn()} />);
|
||||
|
||||
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(<ChatInput onSend={vi.fn()} />);
|
||||
|
||||
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(<ChatInput onSend={vi.fn()} />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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> = {}): 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(
|
||||
<MessageBubble
|
||||
message={userMessage("See attached", {
|
||||
metadata: {
|
||||
attachments: [
|
||||
{
|
||||
type: "file",
|
||||
name: "report.pdf",
|
||||
path: "/Users/test/report.pdf",
|
||||
},
|
||||
{
|
||||
type: "directory",
|
||||
name: "screenshots",
|
||||
path: "/Users/test/screenshots",
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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." },
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
return invoke("get_home_dir");
|
||||
}
|
||||
|
|
@ -33,3 +45,15 @@ export async function listDirectoryEntries(
|
|||
): Promise<FileTreeEntry[]> {
|
||||
return invoke("list_directory_entries", { path });
|
||||
}
|
||||
|
||||
export async function inspectAttachmentPaths(
|
||||
paths: string[],
|
||||
): Promise<AttachmentPathInfo[]> {
|
||||
return invoke("inspect_attachment_paths", { paths });
|
||||
}
|
||||
|
||||
export async function readImageAttachment(
|
||||
path: string,
|
||||
): Promise<ImageAttachmentPayload> {
|
||||
return invoke("read_image_attachment", { path });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue