Skip to content

Commit 53cc2db

Browse files
authored
fix: Downscale large images before attaching (#1987)
## Problem Large images (>2000px) fail with a dimension limit error when attached via file picker or drag-and-drop. Cmd+V paste already worked because it pre-downscales through ElectronImageProcessor. Closes https://github.com/PostHog/code/issues/1889<!-- Who is this for and what problem does it solve? --> <!-- Closes #ISSUE_ID --> ## Changes 1. Add downscaleImageFile tRPC endpoint that reads an on-disk image and downscales via existing ElectronImageProcessor 2. Extract shared downscaleAndPersist helper to deduplicate saveClipboardImage and the new endpoint 3. Route image files in all attachment paths through the downscale pipeline 4. Extract resolveDroppedFile utility to deduplicate drop handler logic across components <!-- What did you change and why? --> <!-- If there are frontend changes, include screenshots. --> ## How did you test this? Manually <!-- For features only --> <!-- If publishing, you must provide changelog details in the #changelog Slack channel. You will receive a follow-up PR comment or notification. --> <!-- If not, write "no" or "do not publish to changelog" to explicitly opt-out of posting to #changelog. Removing this entire section will not prevent posting. -->
1 parent 0587776 commit 53cc2db

16 files changed

Lines changed: 379 additions & 148 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@
5656
- TypeScript strict mode enabled
5757
- Tailwind CSS classes should be sorted (biome `useSortedClasses` rule)
5858

59+
### Services Over Hooks for Business Logic
60+
61+
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.
62+
63+
### Small Focused Components
64+
65+
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.
66+
5967
### Async Cleanup Ordering
6068

6169
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.

apps/code/src/main/trpc/routers/os.ts

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { IAppMeta } from "@posthog/platform/app-meta";
55
import type { DialogSeverity, IDialog } from "@posthog/platform/dialog";
66
import type { IImageProcessor } from "@posthog/platform/image-processor";
77
import type { IUrlLauncher } from "@posthog/platform/url-launcher";
8+
import { IMAGE_MIME_TYPES } from "@shared/constants/image";
89
import { z } from "zod";
910
import { container } from "../../di/container";
1011
import { MAIN_TOKENS } from "../../di/tokens";
@@ -20,19 +21,6 @@ const getAppMeta = () => container.get<IAppMeta>(MAIN_TOKENS.AppMeta);
2021
const getImageProcessor = () =>
2122
container.get<IImageProcessor>(MAIN_TOKENS.ImageProcessor);
2223

23-
const IMAGE_MIME_MAP: Record<string, string> = {
24-
png: "image/png",
25-
jpg: "image/jpeg",
26-
jpeg: "image/jpeg",
27-
gif: "image/gif",
28-
webp: "image/webp",
29-
bmp: "image/bmp",
30-
ico: "image/x-icon",
31-
svg: "image/svg+xml",
32-
tiff: "image/tiff",
33-
tif: "image/tiff",
34-
};
35-
3624
const messageBoxOptionsSchema = z.object({
3725
type: z.enum(["none", "info", "error", "question", "warning"]).optional(),
3826
title: z.string().optional(),
@@ -50,6 +38,7 @@ const expandHomePath = (searchPath: string): string =>
5038

5139
const MAX_IMAGE_DIMENSION = 1568;
5240
const JPEG_QUALITY = 85;
41+
const MAX_FILE_SIZE = 50 * 1024 * 1024;
5342
const CLIPBOARD_TEMP_DIR = path.join(os.tmpdir(), "posthog-code-clipboard");
5443

5544
async function createClipboardTempFilePath(
@@ -63,6 +52,24 @@ async function createClipboardTempFilePath(
6352
return path.join(tempDir, safeName);
6453
}
6554

55+
async function downscaleAndPersist(
56+
raw: Uint8Array,
57+
inputMime: string,
58+
displayName: string,
59+
): Promise<{ path: string; name: string; mimeType: string }> {
60+
const { buffer, mimeType, extension } = getImageProcessor().downscale(
61+
raw,
62+
inputMime,
63+
{ maxDimension: MAX_IMAGE_DIMENSION, jpegQuality: JPEG_QUALITY },
64+
);
65+
66+
const finalName = displayName.replace(/\.[^.]+$/, `.${extension}`);
67+
const filePath = await createClipboardTempFilePath(finalName);
68+
await fsPromises.writeFile(filePath, Buffer.from(buffer));
69+
70+
return { path: filePath, name: finalName, mimeType };
71+
}
72+
6673
const claudeSettingsPath = path.join(os.homedir(), ".claude", "settings.json");
6774

6875
export const osRouter = router({
@@ -286,7 +293,7 @@ export const osRouter = router({
286293
if (stat.size > input.maxSizeBytes) return null;
287294

288295
const ext = path.extname(input.filePath).toLowerCase().slice(1);
289-
const mime = IMAGE_MIME_MAP[ext] ?? "application/octet-stream";
296+
const mime = IMAGE_MIME_TYPES[ext] ?? "application/octet-stream";
290297

291298
const buffer = await fsPromises.readFile(input.filePath);
292299
return `data:${mime};base64,${buffer.toString("base64")}`;
@@ -331,28 +338,37 @@ export const osRouter = router({
331338
)
332339
.mutation(async ({ input }) => {
333340
const raw = new Uint8Array(Buffer.from(input.base64Data, "base64"));
334-
const { buffer, mimeType, extension } = getImageProcessor().downscale(
335-
raw,
336-
input.mimeType,
337-
{ maxDimension: MAX_IMAGE_DIMENSION, jpegQuality: JPEG_QUALITY },
338-
);
339-
340341
const isGenericName =
341342
!input.originalName ||
342343
input.originalName === "image.png" ||
343344
input.originalName === "image.jpeg" ||
344345
input.originalName === "image.jpg";
345346
const displayName = isGenericName
346-
? `clipboard.${extension}`
347-
: (input.originalName ?? "clipboard").replace(
348-
/\.[^.]+$/,
349-
`.${extension}`,
350-
);
351-
const filePath = await createClipboardTempFilePath(displayName);
347+
? "clipboard.png"
348+
: (input.originalName ?? "clipboard.png");
349+
350+
return downscaleAndPersist(raw, input.mimeType, displayName);
351+
}),
352+
353+
downscaleImageFile: publicProcedure
354+
.input(z.object({ filePath: z.string().min(1) }))
355+
.mutation(async ({ input }) => {
356+
const ext = path.extname(input.filePath).toLowerCase().slice(1);
357+
if (!IMAGE_MIME_TYPES[ext]) {
358+
throw new Error(`Unsupported image type: .${ext}`);
359+
}
360+
361+
const stat = await fsPromises.stat(input.filePath);
362+
if (stat.size > MAX_FILE_SIZE) {
363+
throw new Error(
364+
`Image too large (${Math.round(stat.size / 1024 / 1024)}MB). Max is 50MB.`,
365+
);
366+
}
352367

353-
await fsPromises.writeFile(filePath, Buffer.from(buffer));
368+
const raw = new Uint8Array(await fsPromises.readFile(input.filePath));
369+
const inputMime = IMAGE_MIME_TYPES[ext];
354370

355-
return { path: filePath, name: displayName, mimeType };
371+
return downscaleAndPersist(raw, inputMime, path.basename(input.filePath));
356372
}),
357373

358374
/**

apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,16 @@ import { EnrichmentPopover } from "@features/code-editor/components/EnrichmentPo
55
import { useCloudFileContent } from "@features/code-editor/hooks/useCloudFileContent";
66
import { useFileEnrichment } from "@features/code-editor/hooks/useFileEnrichment";
77
import { useMarkdownViewerStore } from "@features/code-editor/stores/markdownViewerStore";
8-
import { getImageMimeType } from "@features/code-editor/utils/imageUtils";
98
import { isMarkdownFile } from "@features/code-editor/utils/markdownUtils";
109
import { getRelativePath } from "@features/code-editor/utils/pathUtils";
11-
import { isImageFile } from "@features/message-editor/utils/imageUtils";
1210
import { usePanelLayoutStore } from "@features/panels";
1311
import { useFileTreeStore } from "@features/right-sidebar/stores/fileTreeStore";
1412
import { useCwd } from "@features/sidebar/hooks/useCwd";
1513
import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace";
1614
import { Check, Code, Copy, Eye } from "@phosphor-icons/react";
1715
import { Box, Flex, IconButton, Text } from "@radix-ui/themes";
1816
import { trpcClient, useTRPC } from "@renderer/trpc/client";
17+
import { getImageMimeType, isImageFile } from "@shared/constants/image";
1918
import type { Task } from "@shared/types";
2019

2120
import { useQuery } from "@tanstack/react-query";

apps/code/src/renderer/features/code-editor/utils/imageUtils.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.

apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,25 @@ const mockFs = vi.hoisted(() => ({
55
readFileAsBase64: { query: vi.fn() },
66
}));
77

8-
vi.mock("@features/message-editor/utils/imageUtils", () => ({
9-
isImageFile: (name: string) =>
10-
/\.(png|jpe?g|gif|webp|bmp|svg|ico|tiff?)$/i.test(name),
11-
}));
12-
13-
vi.mock("@features/code-editor/utils/imageUtils", () => ({
14-
getImageMimeType: (name: string) => {
15-
const ext = name.split(".").pop()?.toLowerCase();
16-
const map: Record<string, string> = {
17-
png: "image/png",
18-
jpg: "image/jpeg",
19-
jpeg: "image/jpeg",
20-
gif: "image/gif",
21-
webp: "image/webp",
22-
};
23-
return map[ext ?? ""] ?? "image/png";
24-
},
25-
}));
8+
vi.mock("@shared/constants/image", async () => {
9+
const actual = await vi.importActual<
10+
typeof import("@shared/constants/image")
11+
>("@shared/constants/image");
12+
return {
13+
...actual,
14+
getImageMimeType: (name: string) => {
15+
const ext = name.split(".").pop()?.toLowerCase();
16+
const map: Record<string, string> = {
17+
png: "image/png",
18+
jpg: "image/jpeg",
19+
jpeg: "image/jpeg",
20+
gif: "image/gif",
21+
webp: "image/webp",
22+
};
23+
return map[ext ?? ""] ?? "image/png";
24+
},
25+
};
26+
});
2627

2728
vi.mock("@renderer/trpc/client", () => ({
2829
trpcClient: {

apps/code/src/renderer/features/editor/utils/cloud-prompt.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import type { ContentBlock } from "@agentclientprotocol/sdk";
2-
import { getImageMimeType } from "@features/code-editor/utils/imageUtils";
3-
import { isImageFile } from "@features/message-editor/utils/imageUtils";
42
import { CLOUD_PROMPT_PREFIX, serializeCloudPrompt } from "@posthog/shared";
53
import { trpcClient } from "@renderer/trpc/client";
4+
import { getImageMimeType, isImageFile } from "@shared/constants/image";
65
import { getFileExtension, getFileName, isAbsolutePath } from "@utils/path";
76
import { makeAttachmentUri } from "@utils/promptContent";
87
import { unescapeXmlAttr } from "@utils/xml";

apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type React from "react";
55
import { beforeEach, describe, expect, it, vi } from "vitest";
66

77
const mockSelectAttachments = vi.hoisted(() => vi.fn());
8+
const mockDownscaleImageFile = vi.hoisted(() => vi.fn());
89

910
vi.mock("@posthog/quill", () => ({
1011
Button: ({
@@ -52,6 +53,9 @@ vi.mock("@renderer/trpc/client", () => ({
5253
selectAttachments: {
5354
query: mockSelectAttachments,
5455
},
56+
downscaleImageFile: {
57+
mutate: mockDownscaleImageFile,
58+
},
5559
},
5660
},
5761
useTRPC: () => ({
@@ -113,4 +117,44 @@ describe("AttachmentMenu", () => {
113117
label: "demo/src",
114118
});
115119
});
120+
121+
it("downscales image files from the OS picker and adds as attachment", async () => {
122+
const user = userEvent.setup();
123+
const onAddAttachment = vi.fn();
124+
const onInsertChip = vi.fn();
125+
126+
mockSelectAttachments.mockResolvedValue([
127+
{ path: "/tmp/demo/photo.png", kind: "file" },
128+
{ path: "/tmp/demo/readme.md", kind: "file" },
129+
]);
130+
mockDownscaleImageFile.mockResolvedValue({
131+
path: "/tmp/posthog-code-clipboard/attachment-xyz/photo.jpg",
132+
name: "photo.jpg",
133+
mimeType: "image/jpeg",
134+
});
135+
136+
render(
137+
<Theme>
138+
<AttachmentMenu
139+
onAddAttachment={onAddAttachment}
140+
onInsertChip={onInsertChip}
141+
/>
142+
</Theme>,
143+
);
144+
145+
await user.click(screen.getByText("Add file or folder"));
146+
147+
expect(mockDownscaleImageFile).toHaveBeenCalledWith({
148+
filePath: "/tmp/demo/photo.png",
149+
});
150+
expect(onAddAttachment).toHaveBeenCalledWith({
151+
id: "/tmp/posthog-code-clipboard/attachment-xyz/photo.jpg",
152+
label: "photo.jpg",
153+
});
154+
expect(onInsertChip).toHaveBeenCalledWith({
155+
type: "file",
156+
id: "/tmp/demo/readme.md",
157+
label: "demo/readme.md",
158+
});
159+
});
116160
});

apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,19 @@ import {
1313
} from "@posthog/quill";
1414
import { trpcClient, useTRPC } from "@renderer/trpc/client";
1515
import { toast } from "@renderer/utils/toast";
16+
import { isImageFile } from "@shared/constants/image";
1617
import { useQuery } from "@tanstack/react-query";
17-
import { getFilePath } from "@utils/getFilePath";
1818
import { useRef, useState } from "react";
1919
import {
2020
deriveFileLabel,
2121
type FileAttachment,
2222
type MentionChip,
2323
} from "../utils/content";
24-
import { persistBrowserFile } from "../utils/persistFile";
24+
import {
25+
persistBrowserFile,
26+
persistImageFilePath,
27+
resolveDroppedFile,
28+
} from "../utils/persistFile";
2529
import { IssuePicker } from "./IssuePicker";
2630

2731
interface AttachmentMenuProps {
@@ -82,10 +86,8 @@ export function AttachmentMenu({
8286
try {
8387
const attachments = await Promise.all(
8488
files.map(async (file) => {
85-
const filePath = getFilePath(file);
86-
if (filePath) {
87-
return { id: filePath, label: file.name } satisfies FileAttachment;
88-
}
89+
const resolved = await resolveDroppedFile(file);
90+
if (resolved) return resolved;
8991

9092
return await persistBrowserFile(file);
9193
}),
@@ -115,11 +117,20 @@ export function AttachmentMenu({
115117
try {
116118
const results = await trpcClient.os.selectAttachments.query({ mode });
117119
for (const { path: filePath, kind } of results) {
118-
onInsertChip({
119-
type: kind === "directory" ? "folder" : "file",
120-
id: filePath,
121-
label: deriveFileLabel(filePath),
122-
});
120+
if (kind === "file" && isImageFile(filePath)) {
121+
try {
122+
const attachment = await persistImageFilePath(filePath);
123+
onAddAttachment(attachment);
124+
} catch {
125+
toast.error("Failed to attach image");
126+
}
127+
} else {
128+
onInsertChip({
129+
type: kind === "directory" ? "folder" : "file",
130+
id: filePath,
131+
label: deriveFileLabel(filePath),
132+
});
133+
}
123134
}
124135
return;
125136
} catch {

apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { File, X } from "@phosphor-icons/react";
22
import { Dialog, Flex, IconButton, Text } from "@radix-ui/themes";
33
import { useTRPC } from "@renderer/trpc/client";
4+
import { isGifFile, isImageFile } from "@shared/constants/image";
45
import { useQuery } from "@tanstack/react-query";
56
import { useEffect, useRef } from "react";
67
import type { FileAttachment } from "../utils/content";
7-
import { isGifFile, isImageFile } from "../utils/imageUtils";
88

99
function FrozenGifThumbnail({ src, alt }: { src: string; alt: string }) {
1010
const canvasRef = useRef<HTMLCanvasElement>(null);

0 commit comments

Comments
 (0)