Skip to content

Commit 8327674

Browse files
committed
feat(cloud-agent): cloud file attachments
chore: cleaner chore: cleaner
1 parent bc8fae9 commit 8327674

42 files changed

Lines changed: 1870 additions & 202 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const expandHomePath = (searchPath: string): string =>
3939

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

4344
interface DownscaledImage {
4445
buffer: Buffer;
@@ -88,6 +89,17 @@ function downscaleImage(raw: Buffer, mimeType: string): DownscaledImage {
8889
};
8990
}
9091

92+
async function createClipboardTempFilePath(
93+
displayName: string,
94+
): Promise<string> {
95+
const safeName = path.basename(displayName) || "attachment";
96+
await fsPromises.mkdir(CLIPBOARD_TEMP_DIR, { recursive: true });
97+
const tempDir = await fsPromises.mkdtemp(
98+
path.join(CLIPBOARD_TEMP_DIR, "attachment-"),
99+
);
100+
return path.join(tempDir, safeName);
101+
}
102+
91103
const claudeSettingsPath = path.join(os.homedir(), ".claude", "settings.json");
92104

93105
export const osRouter = router({
@@ -136,6 +148,25 @@ export const osRouter = router({
136148
return result.filePaths[0];
137149
}),
138150

151+
/**
152+
* Show file picker dialog
153+
*/
154+
selectFiles: publicProcedure.output(z.array(z.string())).query(async () => {
155+
const win = getMainWindow();
156+
if (!win) return [];
157+
158+
const result = await dialog.showOpenDialog(win, {
159+
title: "Select files",
160+
properties: ["openFile", "multiSelections", "treatPackageAsDirectory"],
161+
});
162+
163+
if (result.canceled || !result.filePaths?.length) {
164+
return [];
165+
}
166+
167+
return result.filePaths;
168+
}),
169+
139170
/**
140171
* Check if a directory has write access
141172
*/
@@ -277,18 +308,18 @@ export const osRouter = router({
277308
.input(
278309
z.object({
279310
text: z.string(),
311+
originalName: z.string().optional(),
280312
}),
281313
)
282314
.mutation(async ({ input }) => {
283-
const filename = `pasted-text-${Date.now()}.txt`;
284-
const tempDir = path.join(os.tmpdir(), "posthog-code-clipboard");
285-
286-
await fsPromises.mkdir(tempDir, { recursive: true });
287-
const filePath = path.join(tempDir, filename);
315+
const displayName = path.basename(
316+
input.originalName ?? "pasted-text.txt",
317+
);
318+
const filePath = await createClipboardTempFilePath(displayName);
288319

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

291-
return { path: filePath, name: "pasted-text.txt" };
322+
return { path: filePath, name: displayName };
292323
}),
293324

294325
/**
@@ -321,12 +352,7 @@ export const osRouter = router({
321352
/\.[^.]+$/,
322353
`.${extension}`,
323354
);
324-
const baseName = displayName.replace(/\.[^.]+$/, "");
325-
const filename = `${baseName}-${Date.now()}.${extension}`;
326-
const tempDir = path.join(os.tmpdir(), "posthog-code-clipboard");
327-
328-
await fsPromises.mkdir(tempDir, { recursive: true });
329-
const filePath = path.join(tempDir, filename);
355+
const filePath = await createClipboardTempFilePath(displayName);
330356

331357
await fsPromises.writeFile(filePath, buffer);
332358

apps/code/src/renderer/api/posthogClient.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -557,20 +557,25 @@ export class PostHogAPIClient {
557557
async runTaskInCloud(
558558
taskId: string,
559559
branch?: string | null,
560-
resumeOptions?: { resumeFromRunId: string; pendingUserMessage: string },
561-
sandboxEnvironmentId?: string,
560+
options?: {
561+
resumeFromRunId?: string;
562+
pendingUserMessage?: string;
563+
sandboxEnvironmentId?: string;
564+
},
562565
): Promise<Task> {
563566
const teamId = await this.getTeamId();
564567
const body: Record<string, unknown> = { mode: "interactive" };
565568
if (branch) {
566569
body.branch = branch;
567570
}
568-
if (resumeOptions) {
569-
body.resume_from_run_id = resumeOptions.resumeFromRunId;
570-
body.pending_user_message = resumeOptions.pendingUserMessage;
571+
if (options?.resumeFromRunId) {
572+
body.resume_from_run_id = options.resumeFromRunId;
573+
}
574+
if (options?.pendingUserMessage) {
575+
body.pending_user_message = options.pendingUserMessage;
571576
}
572-
if (sandboxEnvironmentId) {
573-
body.sandbox_environment_id = sandboxEnvironmentId;
577+
if (options?.sandboxEnvironmentId) {
578+
body.sandbox_environment_id = options.sandboxEnvironmentId;
574579
}
575580

576581
const data = await this.api.post(

apps/code/src/renderer/features/code-review/hooks/useReviewComment.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,10 @@ import { DEFAULT_TAB_IDS } from "@features/panels/constants/panelConstants";
22
import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore";
33
import { findTabInTree } from "@features/panels/store/panelTree";
44
import { getSessionService } from "@features/sessions/service/service";
5+
import { escapeXmlAttr } from "@utils/xml";
56
import { useCallback } from "react";
67
import type { OnCommentCallback } from "../types";
78

8-
function escapeXmlAttr(value: string): string {
9-
return value
10-
.replace(/&/g, "&amp;")
11-
.replace(/</g, "&lt;")
12-
.replace(/>/g, "&gt;")
13-
.replace(/"/g, "&quot;")
14-
.replace(/'/g, "&apos;");
15-
}
16-
179
export function useReviewComment(taskId: string): OnCommentCallback {
1810
return useCallback(
1911
(filePath, startLine, endLine, side, comment) => {
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mockFs = vi.hoisted(() => ({
4+
readAbsoluteFile: { query: vi.fn() },
5+
readFileAsBase64: { query: vi.fn() },
6+
}));
7+
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+
}));
26+
27+
vi.mock("@renderer/trpc/client", () => ({
28+
trpcClient: {
29+
fs: mockFs,
30+
},
31+
}));
32+
33+
import { parseAttachmentUri } from "@utils/promptContent";
34+
import {
35+
buildCloudPromptBlocks,
36+
buildCloudTaskDescription,
37+
serializeCloudPrompt,
38+
stripAbsoluteFileTags,
39+
} from "./cloud-prompt";
40+
41+
describe("cloud-prompt", () => {
42+
beforeEach(() => {
43+
vi.clearAllMocks();
44+
});
45+
46+
it("strips absolute file tags but keeps repo file tags", () => {
47+
const prompt =
48+
'review <file path="src/index.ts" /> and <file path="/tmp/test.txt" />';
49+
50+
expect(stripAbsoluteFileTags(prompt)).toBe(
51+
'review <file path="src/index.ts" /> and',
52+
);
53+
});
54+
55+
it("builds a safe cloud task description for local attachments", () => {
56+
const description = buildCloudTaskDescription(
57+
'review <file path="src/index.ts" /> and <file path="/tmp/test.txt" />',
58+
);
59+
60+
expect(description).toBe(
61+
'review <file path="src/index.ts" /> and\n\nAttached files: test.txt',
62+
);
63+
});
64+
65+
it("embeds text attachments as ACP resources", async () => {
66+
mockFs.readAbsoluteFile.query.mockResolvedValue("hello from file");
67+
68+
const blocks = await buildCloudPromptBlocks(
69+
'read this <file path="/tmp/test.txt" />',
70+
);
71+
72+
expect(blocks).toEqual([
73+
{ type: "text", text: "read this" },
74+
expect.objectContaining({
75+
type: "resource",
76+
resource: expect.objectContaining({
77+
text: "hello from file",
78+
mimeType: "text/plain",
79+
}),
80+
}),
81+
]);
82+
83+
const attachmentBlock = blocks[1];
84+
expect(attachmentBlock.type).toBe("resource");
85+
if (attachmentBlock.type !== "resource") {
86+
throw new Error("Expected a resource attachment block");
87+
}
88+
89+
expect(parseAttachmentUri(attachmentBlock.resource.uri)).toEqual({
90+
id: attachmentBlock.resource.uri,
91+
label: "test.txt",
92+
});
93+
});
94+
95+
it("embeds image attachments as ACP image blocks", async () => {
96+
const fakeBase64 = btoa("tiny-image-data");
97+
mockFs.readFileAsBase64.query.mockResolvedValue(fakeBase64);
98+
99+
const blocks = await buildCloudPromptBlocks(
100+
'check <file path="/tmp/screenshot.png" />',
101+
);
102+
103+
expect(blocks).toHaveLength(2);
104+
expect(blocks[0]).toEqual({ type: "text", text: "check" });
105+
expect(blocks[1]).toMatchObject({
106+
type: "image",
107+
data: fakeBase64,
108+
mimeType: "image/png",
109+
});
110+
});
111+
112+
it("rejects images over 5 MB", async () => {
113+
// 5 MB in base64 is ~6.67M chars; generate slightly over
114+
const oversize = "A".repeat(7_000_000);
115+
mockFs.readFileAsBase64.query.mockResolvedValue(oversize);
116+
117+
await expect(
118+
buildCloudPromptBlocks('see <file path="/tmp/huge.png" />'),
119+
).rejects.toThrow(/too large/);
120+
});
121+
122+
it("rejects unsupported image formats", async () => {
123+
await expect(
124+
buildCloudPromptBlocks('see <file path="/tmp/photo.bmp" />'),
125+
).rejects.toThrow(/Unsupported image/);
126+
});
127+
128+
it("throws when readAbsoluteFile returns null", async () => {
129+
mockFs.readAbsoluteFile.query.mockResolvedValue(null);
130+
131+
await expect(
132+
buildCloudPromptBlocks('read <file path="/tmp/missing.txt" />'),
133+
).rejects.toThrow(/Unable to read/);
134+
});
135+
136+
it("throws when readFileAsBase64 returns falsy for images", async () => {
137+
mockFs.readFileAsBase64.query.mockResolvedValue(null);
138+
139+
await expect(
140+
buildCloudPromptBlocks('see <file path="/tmp/broken.png" />'),
141+
).rejects.toThrow(/Unable to read/);
142+
});
143+
144+
it("throws on empty prompt with no attachments", async () => {
145+
await expect(buildCloudPromptBlocks("")).rejects.toThrow(/cannot be empty/);
146+
});
147+
148+
it("serializes structured prompts for pending cloud messages", () => {
149+
const serialized = serializeCloudPrompt([
150+
{ type: "text", text: "read this" },
151+
{
152+
type: "resource",
153+
resource: {
154+
uri: "attachment://test.txt",
155+
text: "hello from file",
156+
mimeType: "text/plain",
157+
},
158+
},
159+
]);
160+
161+
expect(serialized).toContain("__twig_cloud_prompt_v1__:");
162+
expect(serialized).toContain('"type":"resource"');
163+
});
164+
});

0 commit comments

Comments
 (0)