diff --git a/CLAUDE.md b/CLAUDE.md index 791972bd3..cac4d3f89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,14 @@ - TypeScript strict mode enabled - Tailwind CSS classes should be sorted (biome `useSortedClasses` rule) +### Services Over Hooks for Business Logic + +Put data-fetching logic and derivation in main process services, not renderer hooks. Hooks should be thin wrappers around a single tRPC query. If a hook orchestrates multiple queries and derives a result, that logic belongs in a service exposed via tRPC so it can be reused from both the main process and the renderer. + +### Small Focused Components + +Extract distinct UI concerns into their own components instead of building long inline ternary chains or conditional blocks. If a section of JSX handles its own logic (e.g. icon selection based on state), pull it into a named component next to where it's used. Keep render functions short and scannable. + ### Async Cleanup Ordering When tearing down async operations that use an AbortController, always abort the controller **before** awaiting any cleanup that depends on it. Otherwise you get a deadlock: the cleanup waits for the operation to stop, but the operation won't stop until the abort signal fires. diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index 630cf5599..046fadd00 100644 --- a/apps/code/src/main/trpc/routers/os.ts +++ b/apps/code/src/main/trpc/routers/os.ts @@ -5,6 +5,7 @@ import type { IAppMeta } from "@posthog/platform/app-meta"; import type { DialogSeverity, IDialog } from "@posthog/platform/dialog"; import type { IImageProcessor } from "@posthog/platform/image-processor"; import type { IUrlLauncher } from "@posthog/platform/url-launcher"; +import { IMAGE_MIME_TYPES } from "@shared/constants/image"; import { z } from "zod"; import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; @@ -20,19 +21,6 @@ const getAppMeta = () => container.get(MAIN_TOKENS.AppMeta); const getImageProcessor = () => container.get(MAIN_TOKENS.ImageProcessor); -const IMAGE_MIME_MAP: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - bmp: "image/bmp", - ico: "image/x-icon", - svg: "image/svg+xml", - tiff: "image/tiff", - tif: "image/tiff", -}; - const messageBoxOptionsSchema = z.object({ type: z.enum(["none", "info", "error", "question", "warning"]).optional(), title: z.string().optional(), @@ -50,6 +38,7 @@ const expandHomePath = (searchPath: string): string => const MAX_IMAGE_DIMENSION = 1568; const JPEG_QUALITY = 85; +const MAX_FILE_SIZE = 50 * 1024 * 1024; const CLIPBOARD_TEMP_DIR = path.join(os.tmpdir(), "posthog-code-clipboard"); async function createClipboardTempFilePath( @@ -63,6 +52,24 @@ async function createClipboardTempFilePath( return path.join(tempDir, safeName); } +async function downscaleAndPersist( + raw: Uint8Array, + inputMime: string, + displayName: string, +): Promise<{ path: string; name: string; mimeType: string }> { + const { buffer, mimeType, extension } = getImageProcessor().downscale( + raw, + inputMime, + { maxDimension: MAX_IMAGE_DIMENSION, jpegQuality: JPEG_QUALITY }, + ); + + const finalName = displayName.replace(/\.[^.]+$/, `.${extension}`); + const filePath = await createClipboardTempFilePath(finalName); + await fsPromises.writeFile(filePath, Buffer.from(buffer)); + + return { path: filePath, name: finalName, mimeType }; +} + const claudeSettingsPath = path.join(os.homedir(), ".claude", "settings.json"); export const osRouter = router({ @@ -286,7 +293,7 @@ export const osRouter = router({ if (stat.size > input.maxSizeBytes) return null; const ext = path.extname(input.filePath).toLowerCase().slice(1); - const mime = IMAGE_MIME_MAP[ext] ?? "application/octet-stream"; + const mime = IMAGE_MIME_TYPES[ext] ?? "application/octet-stream"; const buffer = await fsPromises.readFile(input.filePath); return `data:${mime};base64,${buffer.toString("base64")}`; @@ -331,28 +338,37 @@ export const osRouter = router({ ) .mutation(async ({ input }) => { const raw = new Uint8Array(Buffer.from(input.base64Data, "base64")); - const { buffer, mimeType, extension } = getImageProcessor().downscale( - raw, - input.mimeType, - { maxDimension: MAX_IMAGE_DIMENSION, jpegQuality: JPEG_QUALITY }, - ); - const isGenericName = !input.originalName || input.originalName === "image.png" || input.originalName === "image.jpeg" || input.originalName === "image.jpg"; const displayName = isGenericName - ? `clipboard.${extension}` - : (input.originalName ?? "clipboard").replace( - /\.[^.]+$/, - `.${extension}`, - ); - const filePath = await createClipboardTempFilePath(displayName); + ? "clipboard.png" + : (input.originalName ?? "clipboard.png"); + + return downscaleAndPersist(raw, input.mimeType, displayName); + }), + + downscaleImageFile: publicProcedure + .input(z.object({ filePath: z.string().min(1) })) + .mutation(async ({ input }) => { + const ext = path.extname(input.filePath).toLowerCase().slice(1); + if (!IMAGE_MIME_TYPES[ext]) { + throw new Error(`Unsupported image type: .${ext}`); + } + + const stat = await fsPromises.stat(input.filePath); + if (stat.size > MAX_FILE_SIZE) { + throw new Error( + `Image too large (${Math.round(stat.size / 1024 / 1024)}MB). Max is 50MB.`, + ); + } - await fsPromises.writeFile(filePath, Buffer.from(buffer)); + const raw = new Uint8Array(await fsPromises.readFile(input.filePath)); + const inputMime = IMAGE_MIME_TYPES[ext]; - return { path: filePath, name: displayName, mimeType }; + return downscaleAndPersist(raw, inputMime, path.basename(input.filePath)); }), /** diff --git a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx b/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx index 102ad7f74..45bbb5090 100644 --- a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx +++ b/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx @@ -5,10 +5,8 @@ import { EnrichmentPopover } from "@features/code-editor/components/EnrichmentPo import { useCloudFileContent } from "@features/code-editor/hooks/useCloudFileContent"; import { useFileEnrichment } from "@features/code-editor/hooks/useFileEnrichment"; import { useMarkdownViewerStore } from "@features/code-editor/stores/markdownViewerStore"; -import { getImageMimeType } from "@features/code-editor/utils/imageUtils"; import { isMarkdownFile } from "@features/code-editor/utils/markdownUtils"; import { getRelativePath } from "@features/code-editor/utils/pathUtils"; -import { isImageFile } from "@features/message-editor/utils/imageUtils"; import { usePanelLayoutStore } from "@features/panels"; import { useFileTreeStore } from "@features/right-sidebar/stores/fileTreeStore"; import { useCwd } from "@features/sidebar/hooks/useCwd"; @@ -16,6 +14,7 @@ import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace"; import { Check, Code, Copy, Eye } from "@phosphor-icons/react"; import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { getImageMimeType, isImageFile } from "@shared/constants/image"; import type { Task } from "@shared/types"; import { useQuery } from "@tanstack/react-query"; diff --git a/apps/code/src/renderer/features/code-editor/utils/imageUtils.ts b/apps/code/src/renderer/features/code-editor/utils/imageUtils.ts deleted file mode 100644 index 196007c4f..000000000 --- a/apps/code/src/renderer/features/code-editor/utils/imageUtils.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const IMAGE_MIME_TYPES: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - bmp: "image/bmp", - ico: "image/x-icon", - svg: "image/svg+xml", - tiff: "image/tiff", - tif: "image/tiff", -}; - -export function getImageMimeType(filePath: string): string { - const ext = filePath.split(".").pop()?.toLowerCase() ?? ""; - return IMAGE_MIME_TYPES[ext] ?? "image/png"; -} diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts b/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts index 0d29c5460..b152dbeca 100644 --- a/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts @@ -5,24 +5,25 @@ const mockFs = vi.hoisted(() => ({ readFileAsBase64: { query: vi.fn() }, })); -vi.mock("@features/message-editor/utils/imageUtils", () => ({ - isImageFile: (name: string) => - /\.(png|jpe?g|gif|webp|bmp|svg|ico|tiff?)$/i.test(name), -})); - -vi.mock("@features/code-editor/utils/imageUtils", () => ({ - getImageMimeType: (name: string) => { - const ext = name.split(".").pop()?.toLowerCase(); - const map: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - }; - return map[ext ?? ""] ?? "image/png"; - }, -})); +vi.mock("@shared/constants/image", async () => { + const actual = await vi.importActual< + typeof import("@shared/constants/image") + >("@shared/constants/image"); + return { + ...actual, + getImageMimeType: (name: string) => { + const ext = name.split(".").pop()?.toLowerCase(); + const map: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + }; + return map[ext ?? ""] ?? "image/png"; + }, + }; +}); vi.mock("@renderer/trpc/client", () => ({ trpcClient: { diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts index fb36d94eb..98dfa457c 100644 --- a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts @@ -1,8 +1,7 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; -import { getImageMimeType } from "@features/code-editor/utils/imageUtils"; -import { isImageFile } from "@features/message-editor/utils/imageUtils"; import { CLOUD_PROMPT_PREFIX, serializeCloudPrompt } from "@posthog/shared"; import { trpcClient } from "@renderer/trpc/client"; +import { getImageMimeType, isImageFile } from "@shared/constants/image"; import { getFileExtension, getFileName, isAbsolutePath } from "@utils/path"; import { makeAttachmentUri } from "@utils/promptContent"; import { unescapeXmlAttr } from "@utils/xml"; diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx index 4919c5bda..a4a557059 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx @@ -5,6 +5,7 @@ import type React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockSelectAttachments = vi.hoisted(() => vi.fn()); +const mockDownscaleImageFile = vi.hoisted(() => vi.fn()); vi.mock("@posthog/quill", () => ({ Button: ({ @@ -52,6 +53,9 @@ vi.mock("@renderer/trpc/client", () => ({ selectAttachments: { query: mockSelectAttachments, }, + downscaleImageFile: { + mutate: mockDownscaleImageFile, + }, }, }, useTRPC: () => ({ @@ -113,4 +117,44 @@ describe("AttachmentMenu", () => { label: "demo/src", }); }); + + it("downscales image files from the OS picker and adds as attachment", async () => { + const user = userEvent.setup(); + const onAddAttachment = vi.fn(); + const onInsertChip = vi.fn(); + + mockSelectAttachments.mockResolvedValue([ + { path: "/tmp/demo/photo.png", kind: "file" }, + { path: "/tmp/demo/readme.md", kind: "file" }, + ]); + mockDownscaleImageFile.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-xyz/photo.jpg", + name: "photo.jpg", + mimeType: "image/jpeg", + }); + + render( + + + , + ); + + await user.click(screen.getByText("Add file or folder")); + + expect(mockDownscaleImageFile).toHaveBeenCalledWith({ + filePath: "/tmp/demo/photo.png", + }); + expect(onAddAttachment).toHaveBeenCalledWith({ + id: "/tmp/posthog-code-clipboard/attachment-xyz/photo.jpg", + label: "photo.jpg", + }); + expect(onInsertChip).toHaveBeenCalledWith({ + type: "file", + id: "/tmp/demo/readme.md", + label: "demo/readme.md", + }); + }); }); diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx index 27c265205..8549a9c90 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx @@ -13,15 +13,19 @@ import { } from "@posthog/quill"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; +import { isImageFile } from "@shared/constants/image"; import { useQuery } from "@tanstack/react-query"; -import { getFilePath } from "@utils/getFilePath"; import { useRef, useState } from "react"; import { deriveFileLabel, type FileAttachment, type MentionChip, } from "../utils/content"; -import { persistBrowserFile } from "../utils/persistFile"; +import { + persistBrowserFile, + persistImageFilePath, + resolveDroppedFile, +} from "../utils/persistFile"; import { IssuePicker } from "./IssuePicker"; interface AttachmentMenuProps { @@ -82,10 +86,8 @@ export function AttachmentMenu({ try { const attachments = await Promise.all( files.map(async (file) => { - const filePath = getFilePath(file); - if (filePath) { - return { id: filePath, label: file.name } satisfies FileAttachment; - } + const resolved = await resolveDroppedFile(file); + if (resolved) return resolved; return await persistBrowserFile(file); }), @@ -115,11 +117,20 @@ export function AttachmentMenu({ try { const results = await trpcClient.os.selectAttachments.query({ mode }); for (const { path: filePath, kind } of results) { - onInsertChip({ - type: kind === "directory" ? "folder" : "file", - id: filePath, - label: deriveFileLabel(filePath), - }); + if (kind === "file" && isImageFile(filePath)) { + try { + const attachment = await persistImageFilePath(filePath); + onAddAttachment(attachment); + } catch { + toast.error("Failed to attach image"); + } + } else { + onInsertChip({ + type: kind === "directory" ? "folder" : "file", + id: filePath, + label: deriveFileLabel(filePath), + }); + } } return; } catch { diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx index 570d57152..5c4408100 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx @@ -1,10 +1,10 @@ import { File, X } from "@phosphor-icons/react"; import { Dialog, Flex, IconButton, Text } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc/client"; +import { isGifFile, isImageFile } from "@shared/constants/image"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; import type { FileAttachment } from "../utils/content"; -import { isGifFile, isImageFile } from "../utils/imageUtils"; function FrozenGifThumbnail({ src, alt }: { src: string; alt: string }) { const canvasRef = useRef(null); diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts index f25613416..9dbe099ac 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -4,7 +4,6 @@ import { trpc } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; import type { EditorView } from "@tiptap/pm/view"; import { useEditor } from "@tiptap/react"; -import { getFilePath } from "@utils/getFilePath"; import { queryClient } from "@utils/queryClient"; import { isSendMessageSubmitKey } from "@utils/sendMessageKey"; import type React from "react"; @@ -20,7 +19,11 @@ import { type ParsedGithubIssueUrl, parseGithubIssueUrl, } from "../utils/githubIssueUrl"; -import { persistImageFile, persistTextContent } from "../utils/persistFile"; +import { + persistImageFile, + persistTextContent, + resolveAndAttachDroppedFiles, +} from "../utils/persistFile"; import { getEditorExtensions } from "./extensions"; import { type DraftContext, useDraftSync } from "./useDraftSync"; @@ -367,26 +370,16 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const files = event.dataTransfer?.files; if (!files || files.length === 0) return false; - const newAttachments: FileAttachment[] = []; - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const path = getFilePath(file); - if (path) { - newAttachments.push({ id: path, label: file.name }); - } - } + event.preventDefault(); - if (newAttachments.length > 0) { - event.preventDefault(); + resolveAndAttachDroppedFiles(files, (a) => { setAttachments((prev) => { - const existing = new Set(prev.map((a) => a.id)); - const unique = newAttachments.filter((a) => !existing.has(a.id)); - return unique.length > 0 ? [...prev, ...unique] : prev; + if (prev.some((existing) => existing.id === a.id)) return prev; + return [...prev, a]; }); - return true; - } + }).catch(() => toast.error("Failed to attach files")); - return false; + return true; }, handlePaste: (view, event) => { const { from, to } = view.state.selection; diff --git a/apps/code/src/renderer/features/message-editor/utils/imageUtils.ts b/apps/code/src/renderer/features/message-editor/utils/imageUtils.ts deleted file mode 100644 index 540d7f700..000000000 --- a/apps/code/src/renderer/features/message-editor/utils/imageUtils.ts +++ /dev/null @@ -1,21 +0,0 @@ -const IMAGE_EXTENSIONS = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "webp", - "bmp", - "ico", - "svg", - "tiff", - "tif", -]); - -export function isImageFile(filename: string): boolean { - const ext = filename.split(".").pop()?.toLowerCase(); - return !!ext && IMAGE_EXTENSIONS.has(ext); -} - -export function isGifFile(filename: string): boolean { - return filename.split(".").pop()?.toLowerCase() === "gif"; -} diff --git a/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts b/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts index 8dffe8be0..79fa90d0c 100644 --- a/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts +++ b/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockSaveClipboardImage = vi.hoisted(() => vi.fn()); const mockSaveClipboardText = vi.hoisted(() => vi.fn()); const mockSaveClipboardFile = vi.hoisted(() => vi.fn()); +const mockDownscaleImageFile = vi.hoisted(() => vi.fn()); +const mockGetFilePath = vi.hoisted(() => vi.fn()); vi.mock("@renderer/trpc/client", () => ({ trpcClient: { @@ -16,18 +18,36 @@ vi.mock("@renderer/trpc/client", () => ({ saveClipboardFile: { mutate: mockSaveClipboardFile, }, + downscaleImageFile: { + mutate: mockDownscaleImageFile, + }, }, }, })); -vi.mock("@features/code-editor/utils/imageUtils", () => ({ - getImageMimeType: () => "image/png", +vi.mock("@shared/constants/image", async () => { + const actual = await vi.importActual< + typeof import("@shared/constants/image") + >("@shared/constants/image"); + return { ...actual, getImageMimeType: () => "image/png" }; +}); + +vi.mock("@utils/getFilePath", () => ({ + getFilePath: mockGetFilePath, +})); + +const mockToastWarning = vi.hoisted(() => vi.fn()); +vi.mock("@renderer/utils/toast", () => ({ + toast: { warning: mockToastWarning }, })); import { persistBrowserFile, persistImageFile, + persistImageFilePath, persistTextContent, + resolveAndAttachDroppedFiles, + resolveDroppedFile, } from "./persistFile"; describe("persistFile", () => { @@ -147,3 +167,126 @@ describe("persistFile", () => { }); }); }); + +describe("persistImageFilePath", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls downscaleImageFile and returns { id, label }", async () => { + mockDownscaleImageFile.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-aaa/photo.jpg", + name: "photo.jpg", + mimeType: "image/jpeg", + }); + + const result = await persistImageFilePath("/Users/me/Desktop/photo.png"); + + expect(mockDownscaleImageFile).toHaveBeenCalledWith({ + filePath: "/Users/me/Desktop/photo.png", + }); + expect(result).toEqual({ + id: "/tmp/posthog-code-clipboard/attachment-aaa/photo.jpg", + label: "photo.jpg", + }); + }); + + it("propagates errors from downscaleImageFile", async () => { + mockDownscaleImageFile.mockRejectedValue(new Error("Image too large")); + + await expect(persistImageFilePath("/big/image.png")).rejects.toThrow( + "Image too large", + ); + }); +}); + +describe("resolveDroppedFile", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when getFilePath returns empty string", async () => { + mockGetFilePath.mockReturnValue(""); + + const file = { name: "test.txt" } as File; + expect(await resolveDroppedFile(file)).toBeNull(); + }); + + it("returns file attachment directly for non-image files", async () => { + mockGetFilePath.mockReturnValue("/Users/me/doc.pdf"); + + const file = { name: "doc.pdf" } as File; + const result = await resolveDroppedFile(file); + + expect(result).toEqual({ id: "/Users/me/doc.pdf", label: "doc.pdf" }); + expect(mockDownscaleImageFile).not.toHaveBeenCalled(); + }); + + it("routes image files through downscaleImageFile", async () => { + mockGetFilePath.mockReturnValue("/Users/me/photo.png"); + mockDownscaleImageFile.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-bbb/photo.jpg", + name: "photo.jpg", + mimeType: "image/jpeg", + }); + + const file = { name: "photo.png" } as File; + const result = await resolveDroppedFile(file); + + expect(mockDownscaleImageFile).toHaveBeenCalledWith({ + filePath: "/Users/me/photo.png", + }); + expect(result).toEqual({ + id: "/tmp/posthog-code-clipboard/attachment-bbb/photo.jpg", + label: "photo.jpg", + }); + }); + + it("falls back to original path and shows warning toast when image downscaling fails", async () => { + mockGetFilePath.mockReturnValue("/Users/me/corrupt.png"); + mockDownscaleImageFile.mockRejectedValue(new Error("decode failed")); + + const file = { name: "corrupt.png" } as File; + expect(await resolveDroppedFile(file)).toEqual({ + id: "/Users/me/corrupt.png", + label: "corrupt.png", + }); + expect(mockToastWarning).toHaveBeenCalledWith( + "Image could not be downscaled", + { description: "Attaching original file instead" }, + ); + }); +}); + +describe("resolveAndAttachDroppedFiles", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls addAttachment for each resolved file", async () => { + mockGetFilePath + .mockReturnValueOnce("/Users/me/a.txt") + .mockReturnValueOnce("") + .mockReturnValueOnce("/Users/me/b.txt"); + + const files = [ + { name: "a.txt" }, + { name: "skip.txt" }, + { name: "b.txt" }, + ] as unknown as FileList; + Object.defineProperty(files, "length", { value: 3 }); + + const addAttachment = vi.fn(); + await resolveAndAttachDroppedFiles(files, addAttachment); + + expect(addAttachment).toHaveBeenCalledTimes(2); + expect(addAttachment).toHaveBeenCalledWith({ + id: "/Users/me/a.txt", + label: "a.txt", + }); + expect(addAttachment).toHaveBeenCalledWith({ + id: "/Users/me/b.txt", + label: "b.txt", + }); + }); +}); diff --git a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts index c82612a25..8ae3c4240 100644 --- a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts +++ b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts @@ -1,5 +1,8 @@ -import { getImageMimeType } from "@features/code-editor/utils/imageUtils"; import { trpcClient } from "@renderer/trpc/client"; +import { toast } from "@renderer/utils/toast"; +import { getImageMimeType, isImageFile } from "@shared/constants/image"; +import { getFilePath } from "@utils/getFilePath"; +import type { FileAttachment } from "./content"; const CHUNK_SIZE = 8192; @@ -58,12 +61,49 @@ export async function persistGenericFile(file: File): Promise { }; } +export async function persistImageFilePath( + filePath: string, +): Promise<{ id: string; label: string }> { + const result = await trpcClient.os.downscaleImageFile.mutate({ filePath }); + return { id: result.path, label: result.name }; +} + +export async function resolveDroppedFile( + file: File, +): Promise { + const filePath = getFilePath(file); + if (!filePath) return null; + + if (isImageFile(file.name)) { + try { + return await persistImageFilePath(filePath); + } catch { + toast.warning("Image could not be downscaled", { + description: "Attaching original file instead", + }); + return { id: filePath, label: file.name }; + } + } + + return { id: filePath, label: file.name }; +} + +export async function resolveAndAttachDroppedFiles( + files: FileList, + addAttachment: (attachment: FileAttachment) => void, +): Promise { + for (let i = 0; i < files.length; i++) { + const attachment = await resolveDroppedFile(files[i]); + if (attachment) addAttachment(attachment); + } +} + export async function persistBrowserFile( file: File, ): Promise<{ id: string; label: string }> { if (file.type.startsWith("image/")) { const result = await persistImageFile(file); - return { id: result.path, label: file.name }; + return { id: result.path, label: result.name }; } const result = await persistGenericFile(file); diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index d195c5ce5..def15befe 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -5,6 +5,7 @@ import { type EditorHandle as PromptInputHandle, } from "@features/message-editor/components/PromptInput"; import { useDraftStore } from "@features/message-editor/stores/draftStore"; +import { resolveAndAttachDroppedFiles } from "@features/message-editor/utils/persistFile"; import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants"; import { useSessionForTask } from "@features/sessions/hooks/useSession"; import { @@ -19,13 +20,13 @@ import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace"; import { useAutoFocusOnTyping } from "@hooks/useAutoFocusOnTyping"; import { Pause, Spinner, Warning } from "@phosphor-icons/react"; import { Box, Button, ContextMenu, Flex, Text } from "@radix-ui/themes"; +import { toast } from "@renderer/utils/toast"; import type { TaskRunStatus } from "@shared/types"; import { type AcpMessage, isJsonRpcNotification, isJsonRpcResponse, } from "@shared/types/session-events"; -import { getFilePath } from "@utils/getFilePath"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getSessionService } from "../service/service"; import { flattenSelectOptions } from "../stores/sessionStore"; @@ -372,18 +373,11 @@ export function SessionView({ const files = e.dataTransfer.files; if (!files || files.length === 0) return; - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const filePath = getFilePath(file); - if (filePath) { - editorRef.current?.addAttachment({ - id: filePath, - label: file.name, - }); - } - } - - editorRef.current?.focus(); + resolveAndAttachDroppedFiles(files, (a) => + editorRef.current?.addAttachment(a), + ) + .then(() => editorRef.current?.focus()) + .catch(() => toast.error("Failed to attach files")); }, []); const handlePaneClick = useCallback((e: React.MouseEvent) => { diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 1ebc10ec4..7f7713805 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -15,6 +15,7 @@ import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReport import { PromptInput } from "@features/message-editor/components/PromptInput"; import { useTaskInputHistoryStore } from "@features/message-editor/stores/taskInputHistoryStore"; import type { EditorHandle } from "@features/message-editor/types"; +import { resolveAndAttachDroppedFiles } from "@features/message-editor/utils/persistFile"; import { DropZoneOverlay } from "@features/sessions/components/DropZoneOverlay"; import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningLevelSelector"; import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModelSelector"; @@ -40,7 +41,6 @@ import { useNavigationStore, } from "@stores/navigationStore"; import { useQuery } from "@tanstack/react-query"; -import { getFilePath } from "@utils/getFilePath"; import { FOCUSABLE_SELECTOR } from "@utils/overlay"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePreviewConfig } from "../hooks/usePreviewConfig"; @@ -545,18 +545,11 @@ export function TaskInput({ const files = e.dataTransfer.files; if (!files || files.length === 0) return; - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const filePath = getFilePath(file); - if (filePath) { - editorRef.current?.addAttachment({ - id: filePath, - label: file.name, - }); - } - } - - editorRef.current?.focus(); + resolveAndAttachDroppedFiles(files, (a) => + editorRef.current?.addAttachment(a), + ) + .then(() => editorRef.current?.focus()) + .catch(() => toast.error("Failed to attach files")); }, []); const handleContainerClick = useCallback((e: React.MouseEvent) => { diff --git a/apps/code/src/shared/constants/image.ts b/apps/code/src/shared/constants/image.ts new file mode 100644 index 000000000..a201592ad --- /dev/null +++ b/apps/code/src/shared/constants/image.ts @@ -0,0 +1,28 @@ +export const IMAGE_MIME_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + bmp: "image/bmp", + ico: "image/x-icon", + svg: "image/svg+xml", + tiff: "image/tiff", + tif: "image/tiff", +}; + +const IMAGE_EXTENSIONS = new Set(Object.keys(IMAGE_MIME_TYPES)); + +export function isImageFile(filename: string): boolean { + const ext = filename.split(".").pop()?.toLowerCase(); + return !!ext && IMAGE_EXTENSIONS.has(ext); +} + +export function isGifFile(filename: string): boolean { + return filename.split(".").pop()?.toLowerCase() === "gif"; +} + +export function getImageMimeType(filePath: string): string { + const ext = filePath.split(".").pop()?.toLowerCase() ?? ""; + return IMAGE_MIME_TYPES[ext] ?? "application/octet-stream"; +}