From 1c990eff46ed8538640ae056f39dca9c2f13f754 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 2 May 2026 22:24:21 -0700 Subject: [PATCH 1/9] Downscale large images before attaching --- apps/code/src/main/trpc/routers/os.ts | 43 ++++++++++++----- .../components/AttachmentMenu.tsx | 25 +++++++--- .../message-editor/tiptap/useTiptapEditor.ts | 47 +++++++++++++++---- .../message-editor/utils/persistFile.ts | 28 +++++++++++ .../sessions/components/SessionView.tsx | 19 +++----- .../task-detail/components/TaskInput.tsx | 19 +++----- 6 files changed, 128 insertions(+), 53 deletions(-) diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index 630cf5599..39302efb6 100644 --- a/apps/code/src/main/trpc/routers/os.ts +++ b/apps/code/src/main/trpc/routers/os.ts @@ -52,6 +52,24 @@ const MAX_IMAGE_DIMENSION = 1568; const JPEG_QUALITY = 85; const CLIPBOARD_TEMP_DIR = path.join(os.tmpdir(), "posthog-code-clipboard"); +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 }; +} + async function createClipboardTempFilePath( displayName: string, ): Promise { @@ -331,11 +349,6 @@ 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 || @@ -343,16 +356,20 @@ export const osRouter = router({ 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"); - await fsPromises.writeFile(filePath, Buffer.from(buffer)); + return downscaleAndPersist(raw, input.mimeType, displayName); + }), + + downscaleImageFile: publicProcedure + .input(z.object({ filePath: z.string().min(1) })) + .mutation(async ({ input }) => { + const raw = new Uint8Array(await fsPromises.readFile(input.filePath)); + const ext = path.extname(input.filePath).replace(".", "").toLowerCase(); + const inputMime = IMAGE_MIME_MAP[ext] ?? "image/png"; - return { path: filePath, name: displayName, mimeType }; + return downscaleAndPersist(raw, inputMime, path.basename(input.filePath)); }), /** 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..ae8b3a693 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx @@ -21,7 +21,8 @@ import { type FileAttachment, type MentionChip, } from "../utils/content"; -import { persistBrowserFile } from "../utils/persistFile"; +import { isImageFile } from "../utils/imageUtils"; +import { persistBrowserFile, persistImageFilePath } from "../utils/persistFile"; import { IssuePicker } from "./IssuePicker"; interface AttachmentMenuProps { @@ -115,11 +116,23 @@ 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, + deriveFileLabel(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/tiptap/useTiptapEditor.ts b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts index f25613416..7e7619ef5 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -20,7 +20,12 @@ import { type ParsedGithubIssueUrl, parseGithubIssueUrl, } from "../utils/githubIssueUrl"; -import { persistImageFile, persistTextContent } from "../utils/persistFile"; +import { isImageFile } from "../utils/imageUtils"; +import { + persistImageFile, + persistImageFilePath, + persistTextContent, +} from "../utils/persistFile"; import { getEditorExtensions } from "./extensions"; import { type DraftContext, useDraftSync } from "./useDraftSync"; @@ -367,26 +372,48 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const files = event.dataTransfer?.files; if (!files || files.length === 0) return false; - const newAttachments: FileAttachment[] = []; + const entries: { path: string; name: string }[] = []; 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 }); + const filePath = getFilePath(file); + if (filePath) { + entries.push({ path: filePath, name: file.name }); } } - if (newAttachments.length > 0) { - event.preventDefault(); + if (entries.length === 0) return false; + + event.preventDefault(); + + const nonImages = entries.filter((e) => !isImageFile(e.name)); + if (nonImages.length > 0) { setAttachments((prev) => { const existing = new Set(prev.map((a) => a.id)); - const unique = newAttachments.filter((a) => !existing.has(a.id)); + const unique = nonImages + .filter((e) => !existing.has(e.path)) + .map((e) => ({ id: e.path, label: e.name })); return unique.length > 0 ? [...prev, ...unique] : prev; }); - return true; } - return false; + const images = entries.filter((e) => isImageFile(e.name)); + if (images.length > 0) { + (async () => { + for (const img of images) { + try { + const result = await persistImageFilePath(img.path, img.name); + setAttachments((prev) => { + if (prev.some((a) => a.id === result.id)) return prev; + return [...prev, result]; + }); + } catch (_error) { + toast.error("Failed to attach image"); + } + } + })(); + } + + return true; }, handlePaste: (view, event) => { const { from, to } = view.state.selection; 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..605e8372e 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 { getFilePath } from "@utils/getFilePath"; +import type { FileAttachment } from "./content"; +import { isImageFile } from "./imageUtils"; const CHUNK_SIZE = 8192; @@ -58,6 +61,31 @@ export async function persistGenericFile(file: File): Promise { }; } +export async function persistImageFilePath( + filePath: string, + fileName: string, +): Promise<{ id: string; label: string }> { + const result = await trpcClient.os.downscaleImageFile.mutate({ filePath }); + return { id: result.path, label: fileName }; +} + +export async function resolveDroppedFile( + file: File, +): Promise { + const filePath = getFilePath(file); + if (!filePath) return null; + + if (isImageFile(file.name)) { + try { + return await persistImageFilePath(filePath, file.name); + } catch { + return null; + } + } + + return { id: filePath, label: file.name }; +} + export async function persistBrowserFile( file: File, ): Promise<{ id: string; label: string }> { diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index d195c5ce5..06e2ff354 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 { resolveDroppedFile } from "@features/message-editor/utils/persistFile"; import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants"; import { useSessionForTask } from "@features/sessions/hooks/useSession"; import { @@ -25,7 +26,6 @@ import { 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 +372,13 @@ 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, - }); + (async () => { + for (let i = 0; i < files.length; i++) { + const attachment = await resolveDroppedFile(files[i]); + if (attachment) editorRef.current?.addAttachment(attachment); } - } - - editorRef.current?.focus(); + editorRef.current?.focus(); + })(); }, []); 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..67787e970 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 { resolveDroppedFile } 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,13 @@ 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, - }); + (async () => { + for (let i = 0; i < files.length; i++) { + const attachment = await resolveDroppedFile(files[i]); + if (attachment) editorRef.current?.addAttachment(attachment); } - } - - editorRef.current?.focus(); + editorRef.current?.focus(); + })(); }, []); const handleContainerClick = useCallback((e: React.MouseEvent) => { From fcf999dc6f7fe92dd84061698ccb62912ab1f1f6 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 01:36:22 -0700 Subject: [PATCH 2/9] Add size guard to downscaleImageFile and tests --- apps/code/src/main/trpc/routers/os.ts | 17 +++- .../message-editor/utils/persistFile.test.ts | 97 +++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index 39302efb6..1f6ab3ec2 100644 --- a/apps/code/src/main/trpc/routers/os.ts +++ b/apps/code/src/main/trpc/routers/os.ts @@ -349,7 +349,6 @@ export const osRouter = router({ ) .mutation(async ({ input }) => { const raw = new Uint8Array(Buffer.from(input.base64Data, "base64")); - const isGenericName = !input.originalName || input.originalName === "image.png" || @@ -365,9 +364,21 @@ export const osRouter = router({ downscaleImageFile: publicProcedure .input(z.object({ filePath: z.string().min(1) })) .mutation(async ({ input }) => { - const raw = new Uint8Array(await fsPromises.readFile(input.filePath)); const ext = path.extname(input.filePath).replace(".", "").toLowerCase(); - const inputMime = IMAGE_MIME_MAP[ext] ?? "image/png"; + if (!IMAGE_MIME_MAP[ext]) { + throw new Error(`Unsupported image type: .${ext}`); + } + + const stat = await fsPromises.stat(input.filePath); + const MAX_FILE_SIZE = 50 * 1024 * 1024; + if (stat.size > MAX_FILE_SIZE) { + throw new Error( + `Image too large (${Math.round(stat.size / 1024 / 1024)}MB). Max is 50MB.`, + ); + } + + const raw = new Uint8Array(await fsPromises.readFile(input.filePath)); + const inputMime = IMAGE_MIME_MAP[ext]; return downscaleAndPersist(raw, inputMime, path.basename(input.filePath)); }), 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..fbea8114f 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,6 +18,9 @@ vi.mock("@renderer/trpc/client", () => ({ saveClipboardFile: { mutate: mockSaveClipboardFile, }, + downscaleImageFile: { + mutate: mockDownscaleImageFile, + }, }, }, })); @@ -24,10 +29,16 @@ vi.mock("@features/code-editor/utils/imageUtils", () => ({ getImageMimeType: () => "image/png", })); +vi.mock("@utils/getFilePath", () => ({ + getFilePath: mockGetFilePath, +})); + import { persistBrowserFile, persistImageFile, + persistImageFilePath, persistTextContent, + resolveDroppedFile, } from "./persistFile"; describe("persistFile", () => { @@ -147,3 +158,89 @@ 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", + "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.png", + }); + }); + + it("propagates errors from downscaleImageFile", async () => { + mockDownscaleImageFile.mockRejectedValue(new Error("Image too large")); + + await expect( + persistImageFilePath("/big/image.png", "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.png", + }); + }); + + it("returns null 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)).toBeNull(); + }); +}); From 757074c42827c5a5dd4ea1c265bb43b61c9f65a8 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 01:41:19 -0700 Subject: [PATCH 3/9] Fall back to original path on downscale failure and add tests --- apps/code/src/main/trpc/routers/os.ts | 24 +++++----- .../components/AttachmentMenu.test.tsx | 44 +++++++++++++++++++ .../message-editor/utils/persistFile.test.ts | 7 ++- .../message-editor/utils/persistFile.ts | 2 +- 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index 1f6ab3ec2..bc4dd5d45 100644 --- a/apps/code/src/main/trpc/routers/os.ts +++ b/apps/code/src/main/trpc/routers/os.ts @@ -50,8 +50,20 @@ 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( + displayName: string, +): Promise { + const safeName = path.basename(displayName) || "attachment"; + await fsPromises.mkdir(CLIPBOARD_TEMP_DIR, { recursive: true }); + const tempDir = await fsPromises.mkdtemp( + path.join(CLIPBOARD_TEMP_DIR, "attachment-"), + ); + return path.join(tempDir, safeName); +} + async function downscaleAndPersist( raw: Uint8Array, inputMime: string, @@ -70,17 +82,6 @@ async function downscaleAndPersist( return { path: filePath, name: finalName, mimeType }; } -async function createClipboardTempFilePath( - displayName: string, -): Promise { - const safeName = path.basename(displayName) || "attachment"; - await fsPromises.mkdir(CLIPBOARD_TEMP_DIR, { recursive: true }); - const tempDir = await fsPromises.mkdtemp( - path.join(CLIPBOARD_TEMP_DIR, "attachment-"), - ); - return path.join(tempDir, safeName); -} - const claudeSettingsPath = path.join(os.homedir(), ".claude", "settings.json"); export const osRouter = router({ @@ -370,7 +371,6 @@ export const osRouter = router({ } const stat = await fsPromises.stat(input.filePath); - const MAX_FILE_SIZE = 50 * 1024 * 1024; if (stat.size > MAX_FILE_SIZE) { throw new Error( `Image too large (${Math.round(stat.size / 1024 / 1024)}MB). Max is 50MB.`, 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..30efc136d 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: "demo/photo.png", + }); + expect(onInsertChip).toHaveBeenCalledWith({ + type: "file", + id: "/tmp/demo/readme.md", + label: "demo/readme.md", + }); + }); }); 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 fbea8114f..bff6ec984 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 @@ -236,11 +236,14 @@ describe("resolveDroppedFile", () => { }); }); - it("returns null when image downscaling fails", async () => { + it("falls back to original path 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)).toBeNull(); + expect(await resolveDroppedFile(file)).toEqual({ + id: "/Users/me/corrupt.png", + label: "corrupt.png", + }); }); }); 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 605e8372e..d8bf026b8 100644 --- a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts +++ b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts @@ -79,7 +79,7 @@ export async function resolveDroppedFile( try { return await persistImageFilePath(filePath, file.name); } catch { - return null; + return { id: filePath, label: file.name }; } } From 48248467a661df79e98a42378a8edf5673dcce05 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 01:49:16 -0700 Subject: [PATCH 4/9] Consolidate image constants and unify drop handler logic --- apps/code/src/main/trpc/routers/os.ts | 20 ++----- .../components/CodeEditorPanel.tsx | 3 +- .../editor/utils/cloud-prompt.test.ts | 37 ++++++------- .../features/editor/utils/cloud-prompt.ts | 3 +- .../components/AttachmentMenu.tsx | 2 +- .../components/AttachmentsBar.tsx | 2 +- .../message-editor/tiptap/useTiptapEditor.ts | 52 ++++--------------- .../message-editor/utils/imageUtils.ts | 21 -------- .../message-editor/utils/persistFile.test.ts | 9 ++-- .../message-editor/utils/persistFile.ts | 3 +- .../constants/image.ts} | 11 ++++ 11 files changed, 56 insertions(+), 107 deletions(-) delete mode 100644 apps/code/src/renderer/features/message-editor/utils/imageUtils.ts rename apps/code/src/{renderer/features/code-editor/utils/imageUtils.ts => shared/constants/image.ts} (56%) diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index bc4dd5d45..a8a4645e5 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(), @@ -305,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")}`; @@ -366,7 +354,7 @@ export const osRouter = router({ .input(z.object({ filePath: z.string().min(1) })) .mutation(async ({ input }) => { const ext = path.extname(input.filePath).replace(".", "").toLowerCase(); - if (!IMAGE_MIME_MAP[ext]) { + if (!IMAGE_MIME_TYPES[ext]) { throw new Error(`Unsupported image type: .${ext}`); } @@ -378,7 +366,7 @@ export const osRouter = router({ } const raw = new Uint8Array(await fsPromises.readFile(input.filePath)); - const inputMime = IMAGE_MIME_MAP[ext]; + const inputMime = IMAGE_MIME_TYPES[ext]; 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/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.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx index ae8b3a693..c49ced16d 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx @@ -13,6 +13,7 @@ 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"; @@ -21,7 +22,6 @@ import { type FileAttachment, type MentionChip, } from "../utils/content"; -import { isImageFile } from "../utils/imageUtils"; import { persistBrowserFile, persistImageFilePath } from "../utils/persistFile"; import { IssuePicker } from "./IssuePicker"; 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 7e7619ef5..d35357f43 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,11 +19,10 @@ import { type ParsedGithubIssueUrl, parseGithubIssueUrl, } from "../utils/githubIssueUrl"; -import { isImageFile } from "../utils/imageUtils"; import { persistImageFile, - persistImageFilePath, persistTextContent, + resolveDroppedFile, } from "../utils/persistFile"; import { getEditorExtensions } from "./extensions"; import { type DraftContext, useDraftSync } from "./useDraftSync"; @@ -372,46 +370,18 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const files = event.dataTransfer?.files; if (!files || files.length === 0) return false; - const entries: { path: string; name: string }[] = []; - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const filePath = getFilePath(file); - if (filePath) { - entries.push({ path: filePath, name: file.name }); - } - } - - if (entries.length === 0) return false; - event.preventDefault(); - const nonImages = entries.filter((e) => !isImageFile(e.name)); - if (nonImages.length > 0) { - setAttachments((prev) => { - const existing = new Set(prev.map((a) => a.id)); - const unique = nonImages - .filter((e) => !existing.has(e.path)) - .map((e) => ({ id: e.path, label: e.name })); - return unique.length > 0 ? [...prev, ...unique] : prev; - }); - } - - const images = entries.filter((e) => isImageFile(e.name)); - if (images.length > 0) { - (async () => { - for (const img of images) { - try { - const result = await persistImageFilePath(img.path, img.name); - setAttachments((prev) => { - if (prev.some((a) => a.id === result.id)) return prev; - return [...prev, result]; - }); - } catch (_error) { - toast.error("Failed to attach image"); - } - } - })(); - } + (async () => { + for (let i = 0; i < files.length; i++) { + const result = await resolveDroppedFile(files[i]); + if (!result) continue; + setAttachments((prev) => { + if (prev.some((a) => a.id === result.id)) return prev; + return [...prev, result]; + }); + } + })(); return true; }, 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 bff6ec984..510851271 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 @@ -25,9 +25,12 @@ vi.mock("@renderer/trpc/client", () => ({ }, })); -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, 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 d8bf026b8..b27eec472 100644 --- a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts +++ b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts @@ -1,8 +1,7 @@ -import { getImageMimeType } from "@features/code-editor/utils/imageUtils"; import { trpcClient } from "@renderer/trpc/client"; +import { getImageMimeType, isImageFile } from "@shared/constants/image"; import { getFilePath } from "@utils/getFilePath"; import type { FileAttachment } from "./content"; -import { isImageFile } from "./imageUtils"; const CHUNK_SIZE = 8192; diff --git a/apps/code/src/renderer/features/code-editor/utils/imageUtils.ts b/apps/code/src/shared/constants/image.ts similarity index 56% rename from apps/code/src/renderer/features/code-editor/utils/imageUtils.ts rename to apps/code/src/shared/constants/image.ts index 196007c4f..21dbf48b4 100644 --- a/apps/code/src/renderer/features/code-editor/utils/imageUtils.ts +++ b/apps/code/src/shared/constants/image.ts @@ -11,6 +11,17 @@ export const IMAGE_MIME_TYPES: Record = { 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] ?? "image/png"; From 2750e017f98c06172ede645c57c05ca7d1b5e1bb Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 16:12:59 -0700 Subject: [PATCH 5/9] Extract shared drop-attach helper and add downscale warning toast --- .../message-editor/utils/persistFile.test.ts | 45 ++++++++++++++++++- .../message-editor/utils/persistFile.ts | 14 ++++++ .../sessions/components/SessionView.tsx | 12 ++--- .../task-detail/components/TaskInput.tsx | 12 ++--- 4 files changed, 66 insertions(+), 17 deletions(-) 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 510851271..04397e026 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 @@ -36,11 +36,17 @@ 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"; @@ -239,7 +245,7 @@ describe("resolveDroppedFile", () => { }); }); - it("falls back to original path when image downscaling fails", async () => { + 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")); @@ -248,5 +254,42 @@ describe("resolveDroppedFile", () => { 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 b27eec472..497dfdbd3 100644 --- a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts +++ b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts @@ -1,4 +1,5 @@ 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"; @@ -78,6 +79,9 @@ export async function resolveDroppedFile( try { return await persistImageFilePath(filePath, file.name); } catch { + toast.warning("Image could not be downscaled", { + description: "Attaching original file instead", + }); return { id: filePath, label: file.name }; } } @@ -85,6 +89,16 @@ export async function resolveDroppedFile( 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 }> { diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index 06e2ff354..042dad41b 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -5,7 +5,7 @@ import { type EditorHandle as PromptInputHandle, } from "@features/message-editor/components/PromptInput"; import { useDraftStore } from "@features/message-editor/stores/draftStore"; -import { resolveDroppedFile } from "@features/message-editor/utils/persistFile"; +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 { @@ -372,13 +372,9 @@ export function SessionView({ const files = e.dataTransfer.files; if (!files || files.length === 0) return; - (async () => { - for (let i = 0; i < files.length; i++) { - const attachment = await resolveDroppedFile(files[i]); - if (attachment) editorRef.current?.addAttachment(attachment); - } - editorRef.current?.focus(); - })(); + resolveAndAttachDroppedFiles(files, (a) => + editorRef.current?.addAttachment(a), + ).then(() => editorRef.current?.focus()); }, []); 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 67787e970..f8f6871f2 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -15,7 +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 { resolveDroppedFile } from "@features/message-editor/utils/persistFile"; +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"; @@ -545,13 +545,9 @@ export function TaskInput({ const files = e.dataTransfer.files; if (!files || files.length === 0) return; - (async () => { - for (let i = 0; i < files.length; i++) { - const attachment = await resolveDroppedFile(files[i]); - if (attachment) editorRef.current?.addAttachment(attachment); - } - editorRef.current?.focus(); - })(); + resolveAndAttachDroppedFiles(files, (a) => + editorRef.current?.addAttachment(a), + ).then(() => editorRef.current?.focus()); }, []); const handleContainerClick = useCallback((e: React.MouseEvent) => { From becdb4b455eff98eb11c3129571a637e5dc40c02 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 18:31:08 -0700 Subject: [PATCH 6/9] Dedupe drop handlers and add missing error handling --- .../components/AttachmentMenu.tsx | 13 +++++++------ .../message-editor/tiptap/useTiptapEditor.ts | 18 +++++++----------- .../sessions/components/SessionView.tsx | 5 ++++- .../task-detail/components/TaskInput.tsx | 4 +++- 4 files changed, 21 insertions(+), 19 deletions(-) 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 c49ced16d..216bb080c 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx @@ -15,14 +15,17 @@ 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, persistImageFilePath } from "../utils/persistFile"; +import { + persistBrowserFile, + persistImageFilePath, + resolveDroppedFile, +} from "../utils/persistFile"; import { IssuePicker } from "./IssuePicker"; interface AttachmentMenuProps { @@ -83,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); }), 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 d35357f43..9dbe099ac 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -22,7 +22,7 @@ import { import { persistImageFile, persistTextContent, - resolveDroppedFile, + resolveAndAttachDroppedFiles, } from "../utils/persistFile"; import { getEditorExtensions } from "./extensions"; import { type DraftContext, useDraftSync } from "./useDraftSync"; @@ -372,16 +372,12 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { event.preventDefault(); - (async () => { - for (let i = 0; i < files.length; i++) { - const result = await resolveDroppedFile(files[i]); - if (!result) continue; - setAttachments((prev) => { - if (prev.some((a) => a.id === result.id)) return prev; - return [...prev, result]; - }); - } - })(); + resolveAndAttachDroppedFiles(files, (a) => { + setAttachments((prev) => { + if (prev.some((existing) => existing.id === a.id)) return prev; + return [...prev, a]; + }); + }).catch(() => toast.error("Failed to attach files")); return true; }, diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index 042dad41b..def15befe 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -20,6 +20,7 @@ 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, @@ -374,7 +375,9 @@ export function SessionView({ resolveAndAttachDroppedFiles(files, (a) => editorRef.current?.addAttachment(a), - ).then(() => editorRef.current?.focus()); + ) + .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 f8f6871f2..7f7713805 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -547,7 +547,9 @@ export function TaskInput({ resolveAndAttachDroppedFiles(files, (a) => editorRef.current?.addAttachment(a), - ).then(() => editorRef.current?.focus()); + ) + .then(() => editorRef.current?.focus()) + .catch(() => toast.error("Failed to attach files")); }, []); const handleContainerClick = useCallback((e: React.MouseEvent) => { From 352f21ae5cba92a768fb984b42bc16b6ac4094e2 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 18:46:08 -0700 Subject: [PATCH 7/9] Use downscaled filename as label and fix minor inconsistencies --- apps/code/src/main/trpc/routers/os.ts | 2 +- .../components/AttachmentMenu.test.tsx | 2 +- .../message-editor/components/AttachmentMenu.tsx | 5 +---- .../message-editor/utils/persistFile.test.ts | 15 ++++++--------- .../features/message-editor/utils/persistFile.ts | 5 ++--- apps/code/src/shared/constants/image.ts | 2 +- 6 files changed, 12 insertions(+), 19 deletions(-) diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index a8a4645e5..046fadd00 100644 --- a/apps/code/src/main/trpc/routers/os.ts +++ b/apps/code/src/main/trpc/routers/os.ts @@ -353,7 +353,7 @@ export const osRouter = router({ downscaleImageFile: publicProcedure .input(z.object({ filePath: z.string().min(1) })) .mutation(async ({ input }) => { - const ext = path.extname(input.filePath).replace(".", "").toLowerCase(); + const ext = path.extname(input.filePath).toLowerCase().slice(1); if (!IMAGE_MIME_TYPES[ext]) { throw new Error(`Unsupported image type: .${ext}`); } 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 30efc136d..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 @@ -149,7 +149,7 @@ describe("AttachmentMenu", () => { }); expect(onAddAttachment).toHaveBeenCalledWith({ id: "/tmp/posthog-code-clipboard/attachment-xyz/photo.jpg", - label: "demo/photo.png", + label: "photo.jpg", }); expect(onInsertChip).toHaveBeenCalledWith({ type: "file", 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 216bb080c..8549a9c90 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx @@ -119,10 +119,7 @@ export function AttachmentMenu({ for (const { path: filePath, kind } of results) { if (kind === "file" && isImageFile(filePath)) { try { - const attachment = await persistImageFilePath( - filePath, - deriveFileLabel(filePath), - ); + const attachment = await persistImageFilePath(filePath); onAddAttachment(attachment); } catch { toast.error("Failed to attach image"); 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 04397e026..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 @@ -180,26 +180,23 @@ describe("persistImageFilePath", () => { mimeType: "image/jpeg", }); - const result = await persistImageFilePath( - "/Users/me/Desktop/photo.png", - "photo.png", - ); + 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.png", + label: "photo.jpg", }); }); it("propagates errors from downscaleImageFile", async () => { mockDownscaleImageFile.mockRejectedValue(new Error("Image too large")); - await expect( - persistImageFilePath("/big/image.png", "image.png"), - ).rejects.toThrow("Image too large"); + await expect(persistImageFilePath("/big/image.png")).rejects.toThrow( + "Image too large", + ); }); }); @@ -241,7 +238,7 @@ describe("resolveDroppedFile", () => { }); expect(result).toEqual({ id: "/tmp/posthog-code-clipboard/attachment-bbb/photo.jpg", - label: "photo.png", + label: "photo.jpg", }); }); 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 497dfdbd3..1644d93ab 100644 --- a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts +++ b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts @@ -63,10 +63,9 @@ export async function persistGenericFile(file: File): Promise { export async function persistImageFilePath( filePath: string, - fileName: string, ): Promise<{ id: string; label: string }> { const result = await trpcClient.os.downscaleImageFile.mutate({ filePath }); - return { id: result.path, label: fileName }; + return { id: result.path, label: result.name }; } export async function resolveDroppedFile( @@ -77,7 +76,7 @@ export async function resolveDroppedFile( if (isImageFile(file.name)) { try { - return await persistImageFilePath(filePath, file.name); + return await persistImageFilePath(filePath); } catch { toast.warning("Image could not be downscaled", { description: "Attaching original file instead", diff --git a/apps/code/src/shared/constants/image.ts b/apps/code/src/shared/constants/image.ts index 21dbf48b4..a201592ad 100644 --- a/apps/code/src/shared/constants/image.ts +++ b/apps/code/src/shared/constants/image.ts @@ -24,5 +24,5 @@ export function isGifFile(filename: string): boolean { export function getImageMimeType(filePath: string): string { const ext = filePath.split(".").pop()?.toLowerCase() ?? ""; - return IMAGE_MIME_TYPES[ext] ?? "image/png"; + return IMAGE_MIME_TYPES[ext] ?? "application/octet-stream"; } From ff5cb03a74740c25a562c6e9ed4a9a051ef054c9 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 18:49:34 -0700 Subject: [PATCH 8/9] Use downscaled name as label in persistBrowserFile --- .../src/renderer/features/message-editor/utils/persistFile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1644d93ab..8ae3c4240 100644 --- a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts +++ b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts @@ -103,7 +103,7 @@ export async function persistBrowserFile( ): 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); From 813e5c98a50e30c1f3180b2792275579f2d5e68b Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 4 May 2026 07:45:18 -0700 Subject: [PATCH 9/9] Add service and component extraction guidelines to CLAUDE.md --- CLAUDE.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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.