Skip to content
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
6 changes: 3 additions & 3 deletions apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -970,7 +970,7 @@ async function getTurnFileDiffFromGit(
};
}

function saveAgentChatTempAttachment(projectRoot: string, arg: { data?: string; filename?: string }): { path: string } {
async function saveAgentChatTempAttachment(projectRoot: string, arg: { data?: string; filename?: string }): Promise<{ path: string }> {
const maxEncodedLength = Math.ceil(MAX_TEMP_ATTACHMENT_BYTES / 3) * 4;
if (typeof arg.data !== "string") {
throw new Error("Temporary attachment data is required.");
Expand All @@ -983,11 +983,11 @@ function saveAgentChatTempAttachment(projectRoot: string, arg: { data?: string;
throw new Error("Temporary attachments must be 10 MB or smaller.");
}
const baseDir = path.join(projectRoot, ".ade", "attachments");
fs.mkdirSync(baseDir, { recursive: true });
await fs.promises.mkdir(baseDir, { recursive: true });
const filename = typeof arg.filename === "string" ? arg.filename : "";
const ext = path.extname(filename) || ".png";
const destPath = path.join(baseDir, `${randomUUID()}${ext}`);
fs.writeFileSync(destPath, content);
await fs.promises.writeFile(destPath, content);
return { path: destPath };
}

Expand Down
59 changes: 41 additions & 18 deletions apps/desktop/src/main/services/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3398,6 +3398,7 @@ export function registerIpc({
};

const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
const MAX_TEMP_ATTACHMENT_BYTES = 10 * 1024 * 1024;

/**
* Read an allow-listed image file from disk after a stat-based size check,
Expand All @@ -3421,6 +3422,26 @@ export function registerIpc({
return { data, mimeType };
};

const saveAgentChatTempAttachmentBuffer = async (
content: Buffer,
filename: string,
): Promise<{ path: string }> => {
if (content.byteLength > MAX_TEMP_ATTACHMENT_BYTES) {
throw new Error("Temporary attachments must be 10 MB or smaller.");
}
const ctx = getCtx();
// Save within the project's .ade directory so CLI subprocesses have
// filesystem access. Fall back to system temp if no project is open.
const baseDir = ctx.project?.rootPath
? path.join(ctx.project.rootPath, ".ade", "attachments")
: path.join(app.getPath("temp"), "ade-attachments");
await fs.promises.mkdir(baseDir, { recursive: true });
const ext = path.extname(filename) || ".png";
const destPath = path.join(baseDir, `${randomUUID()}${ext}`);
await fs.promises.writeFile(destPath, content);
return { path: destPath };
};

ipcMain.handle(IPC.appRevealPath, async (_event, arg: { path: string }): Promise<void> => {
const raw = typeof arg?.path === "string" ? arg.path.trim() : "";
if (!raw) return;
Expand Down Expand Up @@ -3476,12 +3497,11 @@ export function registerIpc({
});

ipcMain.handle(IPC.appReadClipboardImage, async (): Promise<{ data: string; filename: string; mimeType: string } | null> => {
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
const image = clipboard.readImage();
if (image.isEmpty()) return null;
const png = image.toPNG();
if (!png.byteLength) return null;
if (png.byteLength > MAX_ATTACHMENT_BYTES) {
if (png.byteLength > MAX_TEMP_ATTACHMENT_BYTES) {
throw new Error("Clipboard image must be 10 MB or smaller.");
}
return {
Expand All @@ -3491,6 +3511,23 @@ export function registerIpc({
};
});

ipcMain.handle(IPC.appSaveClipboardImageAttachment, async (): Promise<{ path: string; mimeType: string; previewDataUrl: string | null } | null> => {
const image = clipboard.readImage();
if (image.isEmpty()) return null;
const png = image.toPNG();
if (!png.byteLength) return null;
if (png.byteLength > MAX_TEMP_ATTACHMENT_BYTES) {
throw new Error("Clipboard image must be 10 MB or smaller.");
}
const saved = await saveAgentChatTempAttachmentBuffer(png, "clipboard.png");
const previewImage = image.resize({ width: 96, height: 96, quality: "best" });
return {
path: saved.path,
mimeType: "image/png",
previewDataUrl: previewImage.isEmpty() ? null : previewImage.toDataURL(),
};
});

ipcMain.handle(IPC.appGetImageDataUrl, async (_event, arg: { path: string }): Promise<{ dataUrl: string }> => {
const filePath = resolveAllowedRendererPath(arg?.path);
// Use async fs APIs and a size pre-check so a 10 MB image read never
Expand Down Expand Up @@ -6670,26 +6707,12 @@ export function registerIpc({
});

ipcMain.handle(IPC.agentChatSaveTempAttachment, async (_event, arg: { data: string; filename: string }): Promise<{ path: string }> => {
const ctx = getCtx();
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
const maxEncodedLength = Math.ceil(MAX_ATTACHMENT_BYTES / 3) * 4;
const maxEncodedLength = Math.ceil(MAX_TEMP_ATTACHMENT_BYTES / 3) * 4;
if (typeof arg.data === "string" && arg.data.length > maxEncodedLength) {
throw new Error("Temporary attachments must be 10 MB or smaller.");
}
const content = Buffer.from(arg.data, "base64");
if (content.byteLength > MAX_ATTACHMENT_BYTES) {
throw new Error("Temporary attachments must be 10 MB or smaller.");
}
// Save within the project's .ade directory so CLI subprocesses (Claude Code)
// have filesystem access. Fall back to system temp if no project is open.
const baseDir = ctx.project?.rootPath
? path.join(ctx.project.rootPath, ".ade", "attachments")
: path.join(app.getPath("temp"), "ade-attachments");
fs.mkdirSync(baseDir, { recursive: true });
const ext = path.extname(arg.filename) || ".png";
const destPath = path.join(baseDir, `${randomUUID()}${ext}`);
fs.writeFileSync(destPath, content);
return { path: destPath };
return saveAgentChatTempAttachmentBuffer(content, arg.filename);
});

ipcMain.handle(IPC.agentChatGetTurnFileDiff, async (_event, arg: AgentChatGetTurnFileDiffArgs) => {
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/preload/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,11 @@ declare global {
filename: string;
mimeType: string;
} | null>;
saveClipboardImageAttachment: () => Promise<{
path: string;
mimeType: string;
previewDataUrl: string | null;
} | null>;
getImageDataUrl: (path: string) => Promise<{ dataUrl: string }>;
writeClipboardImage: (path: string) => Promise<void>;
openPathInEditor: (args: {
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2549,6 +2549,11 @@ contextBridge.exposeInMainWorld("ade", {
filename: string;
mimeType: string;
} | null> => ipcRenderer.invoke(IPC.appReadClipboardImage),
saveClipboardImageAttachment: async (): Promise<{
path: string;
mimeType: string;
previewDataUrl: string | null;
} | null> => ipcRenderer.invoke(IPC.appSaveClipboardImageAttachment),
getImageDataUrl: async (path: string): Promise<{ dataUrl: string }> =>
imageDataUrlCache.get(path),
writeClipboardImage: async (path: string): Promise<void> =>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/browserMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2833,6 +2833,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) {
writeClipboardText: resolvedArg(undefined),
hasClipboardImage: resolved(false),
readClipboardImage: resolved(null),
saveClipboardImageAttachment: resolved(null),
getImageDataUrl: resolvedArg({ dataUrl: "" }),
writeClipboardImage: resolvedArg(undefined),
openPath: resolvedArg(undefined),
Expand Down
106 changes: 106 additions & 0 deletions apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,112 @@ describe("AgentChatComposer", () => {
}
});

it("uses the native clipboard attachment IPC for macOS Cmd+V fallback when available", async () => {
const originalPlatform = navigator.platform;
Object.defineProperty(navigator, "platform", {
configurable: true,
value: "MacIntel",
});
const saveClipboardImageAttachment = vi.fn().mockResolvedValue({
path: "/tmp/ade-native-clipboard.png",
mimeType: "image/png",
previewDataUrl: "data:image/png;base64,preview",
});
const readClipboardImage = vi.fn();
const saveTempAttachment = vi.fn();
(window as any).ade = {
app: { saveClipboardImageAttachment, readClipboardImage },
agentChat: { saveTempAttachment },
};

try {
const props = renderComposer({
turnActive: false,
draft: "",
});

fireEvent.keyDown(screen.getByPlaceholderText("Type to vibecode..."), {
key: "v",
metaKey: true,
});

await waitFor(() => expect(saveClipboardImageAttachment).toHaveBeenCalledTimes(1));
expect(readClipboardImage).not.toHaveBeenCalled();
expect(saveTempAttachment).not.toHaveBeenCalled();
expect(props.onAddAttachment).toHaveBeenCalledWith({
path: "/tmp/ade-native-clipboard.png",
type: "image",
});
} finally {
Object.defineProperty(navigator, "platform", {
configurable: true,
value: originalPlatform,
});
}
});

it("shows a pasted image preview while the temp attachment is still saving", async () => {
const createObjectURL = vi.fn().mockReturnValue("blob:ade-paste-preview");
const revokeObjectURL = vi.fn();
const previousCreateObjectURL = URL.createObjectURL;
const previousRevokeObjectURL = URL.revokeObjectURL;
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: createObjectURL,
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: revokeObjectURL,
});

let resolveSave: (value: { path: string }) => void = () => {};
const saveTempAttachment = vi.fn(() => new Promise<{ path: string }>((resolve) => {
resolveSave = resolve;
}));
(window as any).ade = {
app: {},
agentChat: { saveTempAttachment },
};

try {
const props = renderComposer({
turnActive: false,
draft: "",
});
const file = new File([new Uint8Array([1, 2, 3])], "paste.png", { type: "image/png" });
Object.defineProperty(file, "arrayBuffer", {
configurable: true,
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
});
const clipboardData = {
files: [file],
items: [],
getData: vi.fn(() => ""),
};

fireEvent.paste(screen.getByPlaceholderText("Type to vibecode..."), { clipboardData });

expect(await screen.findByRole("status", { name: "Attaching paste.png" })).toBeTruthy();
expect(screen.getByAltText("paste.png preview").getAttribute("src")).toBe("blob:ade-paste-preview");
expect(createObjectURL).toHaveBeenCalledWith(file);

resolveSave({ path: "/tmp/ade-paste.png" });
await waitFor(() => expect(props.onAddAttachment).toHaveBeenCalledWith({
path: "/tmp/ade-paste.png",
type: "image",
}));
} finally {
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: previousCreateObjectURL,
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: previousRevokeObjectURL,
});
}
});

it("clears the drop highlight when a URL drop is rejected", async () => {
const props = renderComposer({
turnActive: false,
Expand Down
Loading
Loading