Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 38 additions & 12 deletions apps/code/src/main/trpc/routers/os.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const expandHomePath = (searchPath: string): string =>

const MAX_IMAGE_DIMENSION = 1568;
const JPEG_QUALITY = 85;
const CLIPBOARD_TEMP_DIR = path.join(os.tmpdir(), "posthog-code-clipboard");

interface DownscaledImage {
buffer: Buffer;
Expand Down Expand Up @@ -88,6 +89,17 @@ function downscaleImage(raw: Buffer, mimeType: string): DownscaledImage {
};
}

async function createClipboardTempFilePath(
displayName: string,
): Promise<string> {
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({
Expand Down Expand Up @@ -136,6 +148,25 @@ export const osRouter = router({
return result.filePaths[0];
}),

/**
* Show file picker dialog
*/
selectFiles: publicProcedure.output(z.array(z.string())).query(async () => {
const win = getMainWindow();
if (!win) return [];

const result = await dialog.showOpenDialog(win, {
title: "Select files",
properties: ["openFile", "multiSelections", "treatPackageAsDirectory"],
});

if (result.canceled || !result.filePaths?.length) {
return [];
}

return result.filePaths;
}),

/**
* Check if a directory has write access
*/
Expand Down Expand Up @@ -277,18 +308,18 @@ export const osRouter = router({
.input(
z.object({
text: z.string(),
originalName: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
const filename = `pasted-text-${Date.now()}.txt`;
const tempDir = path.join(os.tmpdir(), "posthog-code-clipboard");

await fsPromises.mkdir(tempDir, { recursive: true });
const filePath = path.join(tempDir, filename);
const displayName = path.basename(
input.originalName ?? "pasted-text.txt",
);
const filePath = await createClipboardTempFilePath(displayName);

await fsPromises.writeFile(filePath, input.text, "utf-8");

return { path: filePath, name: "pasted-text.txt" };
return { path: filePath, name: displayName };
}),

/**
Expand Down Expand Up @@ -321,12 +352,7 @@ export const osRouter = router({
/\.[^.]+$/,
`.${extension}`,
);
const baseName = displayName.replace(/\.[^.]+$/, "");
const filename = `${baseName}-${Date.now()}.${extension}`;
const tempDir = path.join(os.tmpdir(), "posthog-code-clipboard");

await fsPromises.mkdir(tempDir, { recursive: true });
const filePath = path.join(tempDir, filename);
const filePath = await createClipboardTempFilePath(displayName);

await fsPromises.writeFile(filePath, buffer);

Expand Down
164 changes: 164 additions & 0 deletions apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockFs = vi.hoisted(() => ({
readAbsoluteFile: { query: vi.fn() },
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("@renderer/trpc/client", () => ({
trpcClient: {
fs: mockFs,
},
}));

import { parseAttachmentUri } from "@utils/promptContent";
import {
buildCloudPromptBlocks,
buildCloudTaskDescription,
serializeCloudPrompt,
stripAbsoluteFileTags,
} from "./cloud-prompt";

describe("cloud-prompt", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("strips absolute file tags but keeps repo file tags", () => {
const prompt =
'review <file path="src/index.ts" /> and <file path="/tmp/test.txt" />';

expect(stripAbsoluteFileTags(prompt)).toBe(
'review <file path="src/index.ts" /> and',
);
});

it("builds a safe cloud task description for local attachments", () => {
const description = buildCloudTaskDescription(
'review <file path="src/index.ts" /> and <file path="/tmp/test.txt" />',
);

expect(description).toBe(
'review <file path="src/index.ts" /> and\n\nAttached files: test.txt',
);
});

it("embeds text attachments as ACP resources", async () => {
mockFs.readAbsoluteFile.query.mockResolvedValue("hello from file");

const blocks = await buildCloudPromptBlocks(
'read this <file path="/tmp/test.txt" />',
);

expect(blocks).toEqual([
{ type: "text", text: "read this" },
expect.objectContaining({
type: "resource",
resource: expect.objectContaining({
text: "hello from file",
mimeType: "text/plain",
}),
}),
]);

const attachmentBlock = blocks[1];
expect(attachmentBlock.type).toBe("resource");
if (attachmentBlock.type !== "resource") {
throw new Error("Expected a resource attachment block");
}

expect(parseAttachmentUri(attachmentBlock.resource.uri)).toEqual({
id: attachmentBlock.resource.uri,
label: "test.txt",
});
});

it("embeds image attachments as ACP image blocks", async () => {
const fakeBase64 = btoa("tiny-image-data");
mockFs.readFileAsBase64.query.mockResolvedValue(fakeBase64);

const blocks = await buildCloudPromptBlocks(
'check <file path="/tmp/screenshot.png" />',
);

expect(blocks).toHaveLength(2);
expect(blocks[0]).toEqual({ type: "text", text: "check" });
expect(blocks[1]).toMatchObject({
type: "image",
data: fakeBase64,
mimeType: "image/png",
});
});

it("rejects images over 5 MB", async () => {
// 5 MB in base64 is ~6.67M chars; generate slightly over
const oversize = "A".repeat(7_000_000);
mockFs.readFileAsBase64.query.mockResolvedValue(oversize);

await expect(
buildCloudPromptBlocks('see <file path="/tmp/huge.png" />'),
).rejects.toThrow(/too large/);
});

it("rejects unsupported image formats", async () => {
await expect(
buildCloudPromptBlocks('see <file path="/tmp/photo.bmp" />'),
).rejects.toThrow(/Unsupported image/);
});

it("throws when readAbsoluteFile returns null", async () => {
mockFs.readAbsoluteFile.query.mockResolvedValue(null);

await expect(
buildCloudPromptBlocks('read <file path="/tmp/missing.txt" />'),
).rejects.toThrow(/Unable to read/);
});

it("throws when readFileAsBase64 returns falsy for images", async () => {
mockFs.readFileAsBase64.query.mockResolvedValue(null);

await expect(
buildCloudPromptBlocks('see <file path="/tmp/broken.png" />'),
).rejects.toThrow(/Unable to read/);
});

it("throws on empty prompt with no attachments", async () => {
await expect(buildCloudPromptBlocks("")).rejects.toThrow(/cannot be empty/);
});

it("serializes structured prompts for pending cloud messages", () => {
const serialized = serializeCloudPrompt([
{ type: "text", text: "read this" },
{
type: "resource",
resource: {
uri: "attachment://test.txt",
text: "hello from file",
mimeType: "text/plain",
},
},
]);

expect(serialized).toContain("__twig_cloud_prompt_v1__:");
expect(serialized).toContain('"type":"resource"');
});
});
Loading
Loading