Skip to content

Commit 5adce84

Browse files
committed
feat(cloud-agent): wire cloud attachments into task creation and session display
1 parent b551471 commit 5adce84

File tree

18 files changed

+624
-130
lines changed

18 files changed

+624
-130
lines changed

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -728,20 +728,25 @@ export class PostHogAPIClient {
728728
async runTaskInCloud(
729729
taskId: string,
730730
branch?: string | null,
731-
resumeOptions?: { resumeFromRunId: string; pendingUserMessage: string },
732-
sandboxEnvironmentId?: string,
731+
options?: {
732+
resumeFromRunId?: string;
733+
pendingUserMessage?: string;
734+
sandboxEnvironmentId?: string;
735+
},
733736
): Promise<Task> {
734737
const teamId = await this.getTeamId();
735738
const body: Record<string, unknown> = { mode: "interactive" };
736739
if (branch) {
737740
body.branch = branch;
738741
}
739-
if (resumeOptions) {
740-
body.resume_from_run_id = resumeOptions.resumeFromRunId;
741-
body.pending_user_message = resumeOptions.pendingUserMessage;
742+
if (options?.resumeFromRunId) {
743+
body.resume_from_run_id = options.resumeFromRunId;
744+
}
745+
if (options?.pendingUserMessage) {
746+
body.pending_user_message = options.pendingUserMessage;
742747
}
743-
if (sandboxEnvironmentId) {
744-
body.sandbox_environment_id = sandboxEnvironmentId;
748+
if (options?.sandboxEnvironmentId) {
749+
body.sandbox_environment_id = options.sandboxEnvironmentId;
745750
}
746751

747752
const data = await this.api.post(
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Theme } from "@radix-ui/themes";
2+
import { render, screen } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
6+
const mockSelectFiles = vi.hoisted(() => vi.fn());
7+
8+
vi.mock("@renderer/trpc/client", () => ({
9+
trpcClient: {
10+
os: {
11+
selectFiles: {
12+
query: mockSelectFiles,
13+
},
14+
},
15+
},
16+
useTRPC: () => ({
17+
git: {
18+
getGhStatus: {
19+
queryOptions: () => ({}),
20+
},
21+
},
22+
}),
23+
}));
24+
25+
vi.mock("@tanstack/react-query", () => ({
26+
useQuery: () => ({ data: undefined }),
27+
}));
28+
29+
vi.mock("@renderer/utils/toast", () => ({
30+
toast: {
31+
error: vi.fn(),
32+
},
33+
}));
34+
35+
import { AttachmentMenu } from "./AttachmentMenu";
36+
37+
describe("AttachmentMenu", () => {
38+
beforeEach(() => {
39+
vi.clearAllMocks();
40+
});
41+
42+
it("adds attachments using absolute file paths from the OS picker", async () => {
43+
const user = userEvent.setup();
44+
const onAddAttachment = vi.fn();
45+
46+
mockSelectFiles.mockResolvedValue(["/tmp/demo/test.txt"]);
47+
48+
render(
49+
<Theme>
50+
<AttachmentMenu
51+
onAddAttachment={onAddAttachment}
52+
onInsertChip={vi.fn()}
53+
/>
54+
</Theme>,
55+
);
56+
57+
await user.click(screen.getByRole("button"));
58+
await user.click(await screen.findByText("Add file"));
59+
60+
expect(mockSelectFiles).toHaveBeenCalledOnce();
61+
expect(onAddAttachment).toHaveBeenCalledWith({
62+
id: "/tmp/demo/test.txt",
63+
label: "test.txt",
64+
});
65+
});
66+
});

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

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import "./AttachmentMenu.css";
2-
import { Tooltip } from "@components/ui/Tooltip";
32
import { File, GithubLogo, Paperclip } from "@phosphor-icons/react";
43
import { IconButton, Popover } from "@radix-ui/themes";
5-
import { useTRPC } from "@renderer/trpc/client";
4+
import { trpcClient, useTRPC } from "@renderer/trpc/client";
5+
import { toast } from "@renderer/utils/toast";
66
import { useQuery } from "@tanstack/react-query";
7+
import { getFileName } from "@utils/path";
78
import { useRef, useState } from "react";
89
import type { FileAttachment, MentionChip } from "../utils/content";
10+
import { persistBrowserFile } from "../utils/persistFile";
911
import { IssuePicker } from "./IssuePicker";
1012

1113
type View = "menu" | "issues";
@@ -54,20 +56,42 @@ export function AttachmentMenu({
5456

5557
const issueDisabledReason = getIssueDisabledReason(ghStatus, repoPath);
5658

57-
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
58-
const files = e.target.files;
59-
if (files && files.length > 0) {
60-
const fileArray = Array.from(files);
61-
for (const file of fileArray) {
62-
const filePath =
63-
(file as globalThis.File & { path?: string }).path || file.name;
64-
onAddAttachment({ id: filePath, label: file.name });
65-
}
66-
onAttachFiles?.(fileArray);
67-
}
59+
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
60+
const files = e.target.files ? Array.from(e.target.files) : [];
6861
if (fileInputRef.current) {
6962
fileInputRef.current.value = "";
7063
}
64+
65+
if (files.length === 0) {
66+
return;
67+
}
68+
69+
try {
70+
const attachments = await Promise.all(
71+
files.map(async (file) => {
72+
const filePath = (file as globalThis.File & { path?: string }).path;
73+
if (filePath) {
74+
return { id: filePath, label: file.name } satisfies FileAttachment;
75+
}
76+
77+
return await persistBrowserFile(file);
78+
}),
79+
);
80+
81+
for (const attachment of attachments) {
82+
if (attachment) {
83+
onAddAttachment(attachment);
84+
}
85+
}
86+
87+
onAttachFiles?.(files);
88+
} catch (error) {
89+
toast.error(
90+
error instanceof Error
91+
? error.message
92+
: "Unable to attach selected files from this picker",
93+
);
94+
}
7195
};
7296

7397
const handleOpenChange = (isOpen: boolean) => {
@@ -77,8 +101,21 @@ export function AttachmentMenu({
77101
}
78102
};
79103

80-
const handleAddFile = () => {
104+
const handleAddFile = async () => {
81105
setOpen(false);
106+
107+
try {
108+
const filePaths = await trpcClient.os.selectFiles.query();
109+
if (filePaths.length > 0) {
110+
for (const filePath of filePaths) {
111+
onAddAttachment({ id: filePath, label: getFileName(filePath) });
112+
}
113+
}
114+
return;
115+
} catch {
116+
// Fall back to the input element for non-Electron environments.
117+
}
118+
82119
fileInputRef.current?.click();
83120
};
84121

@@ -112,18 +149,17 @@ export function AttachmentMenu({
112149
style={{ display: "none" }}
113150
/>
114151
<Popover.Root open={open} onOpenChange={handleOpenChange}>
115-
<Tooltip content={attachTooltip}>
116-
<Popover.Trigger>
117-
<IconButton
118-
size="1"
119-
variant="ghost"
120-
color="gray"
121-
disabled={disabled}
122-
>
123-
<Paperclip size={iconSize} weight="bold" />
124-
</IconButton>
125-
</Popover.Trigger>
126-
</Tooltip>
152+
<Popover.Trigger>
153+
<IconButton
154+
size="1"
155+
variant="ghost"
156+
color="gray"
157+
disabled={disabled}
158+
title={attachTooltip}
159+
>
160+
<Paperclip size={iconSize} weight="bold" />
161+
</IconButton>
162+
</Popover.Trigger>
127163
<Popover.Content side="top" align="start" style={{ padding: 0 }}>
128164
{view === "menu" ? (
129165
<div className="attachment-menu">
@@ -138,9 +174,7 @@ export function AttachmentMenu({
138174
<span>Add file</span>
139175
</button>
140176
{issueDisabledReason ? (
141-
<Tooltip content={issueDisabledReason} side="right">
142-
<span>{issueButton}</span>
143-
</Tooltip>
177+
<span title={issueDisabledReason}>{issueButton}</span>
144178
) : (
145179
issueButton
146180
)}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { act, render, screen } from "@testing-library/react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
vi.mock("@utils/electronStorage", () => ({
5+
electronStorage: {
6+
getItem: () => null,
7+
setItem: () => {},
8+
removeItem: () => {},
9+
},
10+
}));
11+
12+
import { useDraftStore } from "../stores/draftStore";
13+
import { useDraftSync } from "./useDraftSync";
14+
15+
function DraftAttachmentsProbe({ sessionId }: { sessionId: string }) {
16+
const { restoredAttachments } = useDraftSync(null, sessionId);
17+
return (
18+
<div>
19+
{restoredAttachments.map((att) => att.label).join(",") || "empty"}
20+
</div>
21+
);
22+
}
23+
24+
describe("useDraftSync", () => {
25+
beforeEach(() => {
26+
vi.clearAllMocks();
27+
useDraftStore.setState((state) => ({
28+
...state,
29+
drafts: {},
30+
contexts: {},
31+
commands: {},
32+
focusRequested: {},
33+
pendingContent: {},
34+
_hasHydrated: true,
35+
}));
36+
});
37+
38+
it("clears restored attachments when a draft no longer has attachments", () => {
39+
const { rerender } = render(
40+
<DraftAttachmentsProbe sessionId="session-1" />,
41+
);
42+
43+
act(() => {
44+
useDraftStore.getState().actions.setDraft("session-1", {
45+
segments: [{ type: "text", text: "hello" }],
46+
attachments: [{ id: "/tmp/file.txt", label: "file.txt" }],
47+
});
48+
});
49+
50+
expect(screen.getByText("file.txt")).toBeInTheDocument();
51+
52+
act(() => {
53+
useDraftStore.getState().actions.setDraft("session-1", {
54+
segments: [{ type: "text", text: "hello" }],
55+
});
56+
});
57+
58+
expect(screen.getByText("empty")).toBeInTheDocument();
59+
60+
rerender(<DraftAttachmentsProbe sessionId="session-2" />);
61+
expect(screen.getByText("empty")).toBeInTheDocument();
62+
});
63+
});

apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,12 @@ export function useDraftSync(
171171
>([]);
172172
useLayoutEffect(() => {
173173
if (!draft || typeof draft === "string") return;
174-
if (draft.attachments && draft.attachments.length > 0) {
175-
setRestoredAttachments(draft.attachments);
176-
}
174+
const incoming = draft.attachments ?? [];
175+
// Short-circuit the common empty→empty case to avoid creating a new array
176+
// reference that would trigger unnecessary re-renders.
177+
setRestoredAttachments((prev) =>
178+
prev.length === 0 && incoming.length === 0 ? prev : incoming,
179+
);
177180
}, [draft]);
178181

179182
const attachmentsRef = useRef<FileAttachment[]>([]);

apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { sessionStoreSetters } from "@features/sessions/stores/sessionStore";
22
import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/stores/settingsStore";
3-
import { trpcClient } from "@renderer/trpc/client";
43
import { toast } from "@renderer/utils/toast";
54
import { useSettingsStore } from "@stores/settingsStore";
65
import type { EditorView } from "@tiptap/pm/view";
@@ -10,6 +9,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
109
import { usePromptHistoryStore } from "../stores/promptHistoryStore";
1110
import type { FileAttachment, MentionChip } from "../utils/content";
1211
import { contentToXml, isContentEmpty } from "../utils/content";
12+
import { persistImageFile, persistTextContent } from "../utils/persistFile";
1313
import { getEditorExtensions } from "./extensions";
1414
import { type DraftContext, useDraftSync } from "./useDraftSync";
1515

@@ -45,7 +45,7 @@ async function pasteTextAsFile(
4545
text: string,
4646
pasteCountRef: React.MutableRefObject<number>,
4747
): Promise<void> {
48-
const result = await trpcClient.os.saveClipboardText.mutate({ text });
48+
const result = await persistTextContent(text);
4949
pasteCountRef.current += 1;
5050
const lineCount = text.split("\n").length;
5151
const label = `Pasted text #${pasteCountRef.current} (${lineCount} lines)`;
@@ -331,19 +331,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
331331
if (!file) continue;
332332

333333
try {
334-
const arrayBuffer = await file.arrayBuffer();
335-
const base64 = btoa(
336-
new Uint8Array(arrayBuffer).reduce(
337-
(data, byte) => data + String.fromCharCode(byte),
338-
"",
339-
),
340-
);
341-
342-
const result = await trpcClient.os.saveClipboardImage.mutate({
343-
base64Data: base64,
344-
mimeType: file.type,
345-
originalName: file.name,
346-
});
334+
const result = await persistImageFile(file);
347335

348336
setAttachments((prev) => {
349337
if (prev.some((a) => a.id === result.path)) return prev;
@@ -448,9 +436,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
448436

449437
// Restore attachments from draft on mount
450438
useEffect(() => {
451-
if (draft.restoredAttachments.length > 0) {
452-
setAttachments(draft.restoredAttachments);
453-
}
439+
setAttachments(draft.restoredAttachments);
454440
// Only run on mount / session change
455441
// eslint-disable-next-line react-hooks/exhaustive-deps
456442
}, [draft.restoredAttachments]);

apps/code/src/renderer/features/sessions/components/ConversationView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export function ConversationView({
146146
return (
147147
<UserMessage
148148
content={item.content}
149+
attachments={item.attachments}
149150
timestamp={item.timestamp}
150151
sourceUrl={
151152
slackThreadUrl && item.id === firstUserMessageId

0 commit comments

Comments
 (0)