port goose2 chat attachments into goose (#8534)

Signed-off-by: tulsi <tulsi@block.xyz>
This commit is contained in:
tulsi 2026-04-14 14:26:43 -07:00 committed by GitHub
parent 8e04d7c8be
commit 210ef52d81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2142 additions and 356 deletions

View file

@ -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)

View file

@ -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"

View file

@ -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"] }

View file

@ -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"));
}
}

View file

@ -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!())

View file

@ -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))

View file

@ -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(_)));
}

View file

@ -22,7 +22,7 @@
"visible": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"dragDropEnabled": false,
"dragDropEnabled": true,
"trafficLightPosition": { "x": 12, "y": 22 }
}
],

View file

@ -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}

View file

@ -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}
/>

View file

@ -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);

View file

@ -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();

View file

@ -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,
},
);
});
});

View file

@ -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,
},
);
});

View file

@ -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();
});
});

View file

@ -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", () => {

View 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,
};
}

View file

@ -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,

View 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,
};
}

View file

@ -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,
};
}

View file

@ -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],

View file

@ -18,6 +18,7 @@ export {
extractToolCallCandidates,
inferHomeDirFromRoots,
isExternalHref,
isWriteOrientedTool,
normalizePath,
resolveMarkdownLocalHref,
resolvePathCandidate,

View 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;
}

View file

@ -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");
});
});

View file

@ -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,

View file

@ -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 {

View file

@ -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}

View 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>
);
}

View file

@ -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>

View file

@ -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" ||

View file

@ -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[];

View file

@ -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);
});
});

View file

@ -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." },

View file

@ -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],

View file

@ -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 });
}

View file

@ -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",

View file

@ -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",

View file

@ -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 {