feat: add native clipboard support for screenshot paste
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 59s
CI / Lint & Test (pull_request) Failing after 6m1s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped

- Add Tauri clipboard-manager plugin for native clipboard access
- Fall back to native clipboard when WebView clipboard API returns empty
- Convert RGBA image data to PNG via canvas for saving
- Allow sending messages with attachments only (no text required)
- Log attached files to output with 📎 emoji
- Update send button to enable when attachments exist

Co-Authored-By: Naomi Carrigan <commits@nhcarrigan.com>
This commit is contained in:
2026-01-24 16:10:32 -08:00
parent a183a7ef47
commit 07f9cf8c30
7 changed files with 534 additions and 42 deletions
+168 -36
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { readImage } from "@tauri-apps/plugin-clipboard-manager";
import { get } from "svelte/store";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
@@ -165,10 +166,13 @@
event.preventDefault();
const message = inputValue.trim();
if (!message || isSubmitting) return;
const hasAttachments = attachments.length > 0;
// Need either a message or attachments to submit
if ((!message && !hasAttachments) || isSubmitting) return;
// Check for slash commands first (these work even when disconnected)
if (isSlashCommand(message)) {
if (message && isSlashCommand(message)) {
// Add slash commands to history too
addToHistory(message);
historyIndex = -1;
@@ -189,8 +193,10 @@
// Regular messages require connection
if (!isConnected) return;
// Add to history before clearing
addToHistory(message);
// Add to history before clearing (only if there's text)
if (message) {
addToHistory(message);
}
historyIndex = -1;
tempInput = "";
userHasTyped = false;
@@ -198,12 +204,33 @@
isSubmitting = true;
inputValue = "";
// Apply mode prefix if needed
// Capture attachments before clearing
const currentAttachments = [...attachments];
// Apply mode prefix if needed (only if there's a message)
const currentMode = getCurrentMode();
const formattedMessage = formatMessageWithMode(message, currentMode);
const formattedMessage = message ? formatMessageWithMode(message, currentMode) : "";
// Build message with attachments
let messageWithAttachments = formattedMessage;
if (currentAttachments.length > 0) {
const attachmentPaths = currentAttachments.map((a) => a.path).join("\n");
const attachmentPrefix = formattedMessage
? `${formattedMessage}\n\n`
: "";
messageWithAttachments = `${attachmentPrefix}[Attached files - please read these files to see their contents:]\n${attachmentPaths}`;
// Log attached files to the output
for (const attachment of currentAttachments) {
claudeStore.addLine("system", `📎 Attached: ${attachment.filename} (${attachment.path})`);
}
}
// Clear attachments after capturing them
claudeStore.clearAttachments();
// Check if we need to restore conversation history
let messageToSend = formattedMessage;
let messageToSend = messageWithAttachments;
if (getShouldRestoreHistory()) {
const savedHistory = getSavedHistory();
@@ -341,7 +368,7 @@ User: ${formattedMessage}`;
}
}
function handleDrop(event: DragEvent) {
async function handleDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
isDragging = false;
@@ -349,6 +376,8 @@ User: ${formattedMessage}`;
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
const conversationId = get(claudeStore.activeConversationId);
for (const file of files) {
const filename = file.name;
const extension = filename.split(".").pop()?.toLowerCase() || "";
@@ -361,10 +390,29 @@ User: ${formattedMessage}`;
previewUrl = URL.createObjectURL(file);
}
// Check if the file has a native path (from Tauri's file drop)
// If not, we need to save it to a temp file
let savedPath = (file as File & { path?: string }).path;
if (!savedPath && conversationId) {
try {
const arrayBuffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
savedPath = await invoke<string>("save_temp_file", {
conversationId,
filename,
data: bytes,
});
} catch (error) {
console.error("Failed to save dropped file to temp:", error);
savedPath = file.name;
}
}
const attachment: Attachment = {
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
filename,
path: (file as File & { path?: string }).path || file.name,
path: savedPath || file.name,
size: file.size,
type: fileType,
mimeType: file.type,
@@ -441,40 +489,124 @@ User: ${formattedMessage}`;
}
}
function handlePaste(event: ClipboardEvent) {
async function handlePaste(event: ClipboardEvent) {
// First, try the web clipboard API for files
const items = event.clipboardData?.items;
if (!items) return;
let handledFile = false;
for (const item of items) {
// Check if the item is a file (image or other)
if (item.kind === "file") {
const file = item.getAsFile();
if (!file) continue;
if (items && items.length > 0) {
for (const item of items) {
if (item.kind === "file") {
const file = item.getAsFile();
if (!file) continue;
// Prevent default for file pastes so we handle it
event.preventDefault();
event.preventDefault();
handledFile = true;
const filename = file.name || `pasted-${Date.now()}.${file.type.split("/")[1] || "png"}`;
const extension = filename.split(".").pop()?.toLowerCase() || "";
const fileType = getFileTypeFromExtension(extension);
const filename = file.name || `pasted-${Date.now()}.${file.type.split("/")[1] || "png"}`;
const extension = filename.split(".").pop()?.toLowerCase() || "";
const fileType = getFileTypeFromExtension(extension);
// Create preview URL for images
let previewUrl: string | undefined;
if (fileType === "image" || file.type.startsWith("image/")) {
previewUrl = URL.createObjectURL(file);
let previewUrl: string | undefined;
if (fileType === "image" || file.type.startsWith("image/")) {
previewUrl = URL.createObjectURL(file);
}
const conversationId = get(claudeStore.activeConversationId);
let savedPath = filename;
if (conversationId) {
try {
const arrayBuffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
savedPath = await invoke<string>("save_temp_file", {
conversationId,
filename,
data: bytes,
});
} catch (error) {
console.error("Failed to save pasted file to temp:", error);
}
}
const attachment: Attachment = {
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
filename,
path: savedPath,
size: file.size,
type: file.type.startsWith("image/") ? "image" : fileType,
mimeType: file.type,
previewUrl,
};
claudeStore.addAttachment(attachment);
}
}
}
const attachment: Attachment = {
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
filename,
path: filename,
size: file.size,
type: file.type.startsWith("image/") ? "image" : fileType,
mimeType: file.type,
previewUrl,
};
// If web clipboard didn't have files, try Tauri's native clipboard for images
if (!handledFile) {
try {
const image = await readImage();
const rgba = await image.rgba();
const size = await image.size();
claudeStore.addAttachment(attachment);
if (rgba && rgba.length > 0) {
event.preventDefault();
const conversationId = get(claudeStore.activeConversationId);
const filename = `screenshot-${Date.now()}.png`;
// Convert RGBA to PNG using canvas
const canvas = document.createElement("canvas");
canvas.width = size.width;
canvas.height = size.height;
const ctx = canvas.getContext("2d");
if (ctx) {
const imgData = ctx.createImageData(size.width, size.height);
imgData.data.set(new Uint8ClampedArray(rgba));
ctx.putImageData(imgData, 0, 0);
// Create preview URL from canvas
const previewUrl = canvas.toDataURL("image/png");
// Convert to blob for saving
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png")
);
let savedPath = filename;
if (blob && conversationId) {
try {
const arrayBuffer = await blob.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
savedPath = await invoke<string>("save_temp_file", {
conversationId,
filename,
data: bytes,
});
} catch (error) {
console.error("Failed to save clipboard image to temp:", error);
}
}
const attachment: Attachment = {
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
filename,
path: savedPath,
size: blob?.size || 0,
type: "image",
mimeType: "image/png",
previewUrl,
};
claudeStore.addAttachment(attachment);
}
}
} catch (error) {
// No image in clipboard or clipboard read failed - that's fine, just ignore
console.log("No image in native clipboard:", error);
}
}
}
@@ -619,7 +751,7 @@ User: ${formattedMessage}`;
{:else}
<button
type="submit"
disabled={!isConnected || isSubmitting || !inputValue.trim()}
disabled={!isConnected || isSubmitting || (!inputValue.trim() && attachments.length === 0)}
class="send-button bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
disabled:opacity-50 disabled:cursor-not-allowed"
>