Skip to content
Merged
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
72 changes: 44 additions & 28 deletions apps/code/src/main/trpc/routers/os.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -20,19 +21,6 @@ const getAppMeta = () => container.get<IAppMeta>(MAIN_TOKENS.AppMeta);
const getImageProcessor = () =>
container.get<IImageProcessor>(MAIN_TOKENS.ImageProcessor);

const IMAGE_MIME_MAP: Record<string, string> = {
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(),
Expand All @@ -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(
Expand All @@ -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({
Expand Down Expand Up @@ -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")}`;
Expand Down Expand Up @@ -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));
}),

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,16 @@ 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";
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";
Expand Down
17 changes: 0 additions & 17 deletions apps/code/src/renderer/features/code-editor/utils/imageUtils.ts

This file was deleted.

37 changes: 19 additions & 18 deletions apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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<string, string> = {
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: {
Expand Down
3 changes: 1 addition & 2 deletions apps/code/src/renderer/features/editor/utils/cloud-prompt.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: ({
Expand Down Expand Up @@ -52,6 +53,9 @@ vi.mock("@renderer/trpc/client", () => ({
selectAttachments: {
query: mockSelectAttachments,
},
downscaleImageFile: {
mutate: mockDownscaleImageFile,
},
},
},
useTRPC: () => ({
Expand Down Expand Up @@ -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(
<Theme>
<AttachmentMenu
onAddAttachment={onAddAttachment}
onInsertChip={onInsertChip}
/>
</Theme>,
);

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",
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}),
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLCanvasElement>(null);
Expand Down
Loading
Loading