Skip to content
This repository was archived by the owner on Apr 14, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ const EXCEPTIONS = {
"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: 540,
limit: 620,
justification:
"Desktop system commands still centralize file mentions, attachment inspection, image loading, and export helpers in one Tauri command surface.",
"Desktop system commands still centralize file mentions, attachment inspection, platform-aware path dedupe, guarded image loading, and export helpers in one Tauri command surface.",
},
};

Expand Down
115 changes: 105 additions & 10 deletions src-tauri/src/commands/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,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")]
Expand Down Expand Up @@ -178,10 +179,25 @@ fn inspect_attachment_path(path: &Path) -> Result<AttachmentPathInfo, String> {
})
}

#[tauri::command]
pub fn inspect_attachment_paths(paths: Vec<String>) -> Result<Vec<AttachmentPathInfo>, String> {
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 attachments = Vec::new();
let mut normalized = Vec::new();

for raw_path in paths {
let trimmed = raw_path.trim();
Expand All @@ -190,11 +206,20 @@ pub fn inspect_attachment_paths(paths: Vec<String>) -> Result<Vec<AttachmentPath
}

let path = PathBuf::from(trimmed);
let key = path.to_string_lossy().to_lowercase();
if !seen.insert(key) {
continue;
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) {
attachments.push(inspect_attachment_path(&path)?);
}

Expand All @@ -212,6 +237,16 @@ pub fn read_image_attachment(path: String) -> Result<ImageAttachmentPayload, Str
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))?;

Expand All @@ -230,7 +265,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) {
Comment thread
tulsi-builder marked this conversation as resolved.
normalized.push(path);
}
Expand Down Expand Up @@ -291,7 +326,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);
}
Expand All @@ -314,13 +349,15 @@ pub async fn list_files_for_mentions(
#[cfg(test)]
mod tests {
use super::{
build_file_tree_entry, inspect_attachment_path, read_directory_entries,
read_image_attachment, scan_files_for_mentions,
build_file_tree_entry, inspect_attachment_path, 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;

Expand Down Expand Up @@ -508,4 +545,62 @@ mod tests {
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 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"));
}
}
13 changes: 11 additions & 2 deletions src/features/chat/hooks/useChatInputAttachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ChatFileAttachmentDraft,
ChatImageAttachmentDraft,
} from "@/shared/types/messages";
import { getPlatform } from "@/shared/lib/platform";
import { resizeImage } from "../lib/resizeImage";

function isBlobPreview(url: string) {
Expand All @@ -28,6 +29,14 @@ function pathToPreviewUrl(path: string) {
: path;
}

function attachmentPathKey(path?: string) {
if (!path) {
return null;
}

return getPlatform() === "linux" ? path : path.toLowerCase();
}

async function createImageAttachmentFromFile(
file: File,
): Promise<ChatImageAttachmentDraft> {
Expand Down Expand Up @@ -95,13 +104,13 @@ export function useChatInputAttachments() {
setAttachments((previous) => {
const seenPaths = new Set(
previous
.map((attachment) => attachment.path?.toLowerCase())
.map((attachment) => attachmentPathKey(attachment.path))
.filter((value): value is string => Boolean(value)),
);
const next = [...previous];

for (const attachment of incoming) {
const pathKey = attachment.path?.toLowerCase();
const pathKey = attachmentPathKey(attachment.path);
if (pathKey && seenPaths.has(pathKey)) {
Comment thread
tulsi-builder marked this conversation as resolved.
revokeAttachmentPreview(attachment);
continue;
Expand Down
4 changes: 2 additions & 2 deletions src/features/chat/ui/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ export function ChatInput({
title: t("attachments.chooseFilesDialogTitle"),
multiple: true,
});
void addPathAttachments(normalizeDialogSelection(selected));
await addPathAttachments(normalizeDialogSelection(selected));
} catch {
// Dialog plugin may be unavailable in some environments.
}
Expand All @@ -297,7 +297,7 @@ export function ChatInput({
title: t("attachments.chooseFoldersDialogTitle"),
multiple: true,
});
void addPathAttachments(normalizeDialogSelection(selected));
await addPathAttachments(normalizeDialogSelection(selected));
} catch {
// Dialog plugin may be unavailable in some environments.
}
Expand Down
37 changes: 37 additions & 0 deletions src/features/chat/ui/__tests__/ChatInput.attachments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({
}),
}));

vi.mock("@/shared/lib/platform", () => ({
getPlatform: () => "mac",
}));

const mockListFilesForMentions = vi.fn<
(roots: string[], maxResults?: number) => Promise<string[]>
>(async () => []);
Expand Down Expand Up @@ -176,4 +180,37 @@ describe("ChatInput attachments", () => {
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);
});
});
Loading