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
19 changes: 12 additions & 7 deletions apps/code/src/renderer/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,20 +557,25 @@ export class PostHogAPIClient {
async runTaskInCloud(
taskId: string,
branch?: string | null,
resumeOptions?: { resumeFromRunId: string; pendingUserMessage: string },
sandboxEnvironmentId?: string,
options?: {
resumeFromRunId?: string;
pendingUserMessage?: string;
sandboxEnvironmentId?: string;
},
): Promise<Task> {
const teamId = await this.getTeamId();
const body: Record<string, unknown> = { mode: "interactive" };
if (branch) {
body.branch = branch;
}
if (resumeOptions) {
body.resume_from_run_id = resumeOptions.resumeFromRunId;
body.pending_user_message = resumeOptions.pendingUserMessage;
if (options?.resumeFromRunId) {
body.resume_from_run_id = options.resumeFromRunId;
}
if (options?.pendingUserMessage) {
body.pending_user_message = options.pendingUserMessage;
}
if (sandboxEnvironmentId) {
body.sandbox_environment_id = sandboxEnvironmentId;
if (options?.sandboxEnvironmentId) {
body.sandbox_environment_id = options.sandboxEnvironmentId;
}

const data = await this.api.post(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Theme } from "@radix-ui/themes";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mockSelectFiles = vi.hoisted(() => vi.fn());

vi.mock("@renderer/trpc/client", () => ({
trpcClient: {
os: {
selectFiles: {
query: mockSelectFiles,
},
},
},
useTRPC: () => ({
git: {
getGhStatus: {
queryOptions: () => ({}),
},
},
}),
}));

vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined }),
}));

vi.mock("@renderer/utils/toast", () => ({
toast: {
error: vi.fn(),
},
}));

import { AttachmentMenu } from "./AttachmentMenu";

describe("AttachmentMenu", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("adds attachments using absolute file paths from the OS picker", async () => {
const user = userEvent.setup();
const onAddAttachment = vi.fn();

mockSelectFiles.mockResolvedValue(["/tmp/demo/test.txt"]);

render(
<Theme>
<AttachmentMenu
onAddAttachment={onAddAttachment}
onInsertChip={vi.fn()}
/>
</Theme>,
);

await user.click(screen.getByRole("button"));
await user.click(await screen.findByText("Add file"));

expect(mockSelectFiles).toHaveBeenCalledOnce();
expect(onAddAttachment).toHaveBeenCalledWith({
id: "/tmp/demo/test.txt",
label: "test.txt",
});
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import "./AttachmentMenu.css";
import { Tooltip } from "@components/ui/Tooltip";
import { File, GithubLogo, Paperclip } from "@phosphor-icons/react";
import { IconButton, Popover } from "@radix-ui/themes";
import { useTRPC } from "@renderer/trpc/client";
import { trpcClient, useTRPC } from "@renderer/trpc/client";
import { toast } from "@renderer/utils/toast";
import { useQuery } from "@tanstack/react-query";
import { getFileName } from "@utils/path";
import { useRef, useState } from "react";
import type { FileAttachment, MentionChip } from "../utils/content";
import { persistBrowserFile } from "../utils/persistFile";
import { IssuePicker } from "./IssuePicker";

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

const issueDisabledReason = getIssueDisabledReason(ghStatus, repoPath);

const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const fileArray = Array.from(files);
for (const file of fileArray) {
const filePath =
(file as globalThis.File & { path?: string }).path || file.name;
onAddAttachment({ id: filePath, label: file.name });
}
onAttachFiles?.(fileArray);
}
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? Array.from(e.target.files) : [];
if (fileInputRef.current) {
fileInputRef.current.value = "";
}

if (files.length === 0) {
return;
}

try {
const attachments = await Promise.all(
files.map(async (file) => {
const filePath = (file as globalThis.File & { path?: string }).path;
if (filePath) {
return { id: filePath, label: file.name } satisfies FileAttachment;
}

return await persistBrowserFile(file);
}),
);

for (const attachment of attachments) {
if (attachment) {
onAddAttachment(attachment);
}
}

onAttachFiles?.(files);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Unable to attach selected files from this picker",
);
}
};

const handleOpenChange = (isOpen: boolean) => {
Expand All @@ -77,8 +101,21 @@ export function AttachmentMenu({
}
};

const handleAddFile = () => {
const handleAddFile = async () => {
setOpen(false);

try {
const filePaths = await trpcClient.os.selectFiles.query();
if (filePaths.length > 0) {
for (const filePath of filePaths) {
onAddAttachment({ id: filePath, label: getFileName(filePath) });
}
}
return;
} catch {
// Fall back to the input element for non-Electron environments.
}

fileInputRef.current?.click();
};

Expand Down Expand Up @@ -112,18 +149,17 @@ export function AttachmentMenu({
style={{ display: "none" }}
/>
<Popover.Root open={open} onOpenChange={handleOpenChange}>
<Tooltip content={attachTooltip}>
<Popover.Trigger>
<IconButton
size="1"
variant="ghost"
color="gray"
disabled={disabled}
>
<Paperclip size={iconSize} weight="bold" />
</IconButton>
</Popover.Trigger>
</Tooltip>
<Popover.Trigger>
<IconButton
size="1"
variant="ghost"
color="gray"
disabled={disabled}
title={attachTooltip}
>
<Paperclip size={iconSize} weight="bold" />
</IconButton>
</Popover.Trigger>
<Popover.Content side="top" align="start" style={{ padding: 0 }}>
{view === "menu" ? (
<div className="attachment-menu">
Expand All @@ -138,9 +174,7 @@ export function AttachmentMenu({
<span>Add file</span>
</button>
{issueDisabledReason ? (
<Tooltip content={issueDisabledReason} side="right">
<span>{issueButton}</span>
</Tooltip>
<span title={issueDisabledReason}>{issueButton}</span>
) : (
issueButton
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { act, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@utils/electronStorage", () => ({
electronStorage: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
},
}));

import { useDraftStore } from "../stores/draftStore";
import { useDraftSync } from "./useDraftSync";

function DraftAttachmentsProbe({ sessionId }: { sessionId: string }) {
const { restoredAttachments } = useDraftSync(null, sessionId);
return (
<div>
{restoredAttachments.map((att) => att.label).join(",") || "empty"}
</div>
);
}

describe("useDraftSync", () => {
beforeEach(() => {
vi.clearAllMocks();
useDraftStore.setState((state) => ({
...state,
drafts: {},
contexts: {},
commands: {},
focusRequested: {},
pendingContent: {},
_hasHydrated: true,
}));
});

it("clears restored attachments when a draft no longer has attachments", () => {
const { rerender } = render(
<DraftAttachmentsProbe sessionId="session-1" />,
);

act(() => {
useDraftStore.getState().actions.setDraft("session-1", {
segments: [{ type: "text", text: "hello" }],
attachments: [{ id: "/tmp/file.txt", label: "file.txt" }],
});
});

expect(screen.getByText("file.txt")).toBeInTheDocument();

act(() => {
useDraftStore.getState().actions.setDraft("session-1", {
segments: [{ type: "text", text: "hello" }],
});
});

expect(screen.getByText("empty")).toBeInTheDocument();

rerender(<DraftAttachmentsProbe sessionId="session-2" />);
expect(screen.getByText("empty")).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,12 @@ export function useDraftSync(
>([]);
useLayoutEffect(() => {
if (!draft || typeof draft === "string") return;
if (draft.attachments && draft.attachments.length > 0) {
setRestoredAttachments(draft.attachments);
}
const incoming = draft.attachments ?? [];
// Short-circuit the common empty→empty case to avoid creating a new array
// reference that would trigger unnecessary re-renders.
setRestoredAttachments((prev) =>
prev.length === 0 && incoming.length === 0 ? prev : incoming,
);
}, [draft]);

const attachmentsRef = useRef<FileAttachment[]>([]);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { sessionStoreSetters } from "@features/sessions/stores/sessionStore";
import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/stores/settingsStore";
import { trpcClient } from "@renderer/trpc/client";
import { toast } from "@renderer/utils/toast";
import { useSettingsStore } from "@stores/settingsStore";
import type { EditorView } from "@tiptap/pm/view";
Expand All @@ -10,6 +9,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { usePromptHistoryStore } from "../stores/promptHistoryStore";
import type { FileAttachment, MentionChip } from "../utils/content";
import { contentToXml, isContentEmpty } from "../utils/content";
import { persistImageFile, persistTextContent } from "../utils/persistFile";
import { getEditorExtensions } from "./extensions";
import { type DraftContext, useDraftSync } from "./useDraftSync";

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

try {
const arrayBuffer = await file.arrayBuffer();
const base64 = btoa(
new Uint8Array(arrayBuffer).reduce(
(data, byte) => data + String.fromCharCode(byte),
"",
),
);

const result = await trpcClient.os.saveClipboardImage.mutate({
base64Data: base64,
mimeType: file.type,
originalName: file.name,
});
const result = await persistImageFile(file);

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

// Restore attachments from draft on mount
useEffect(() => {
if (draft.restoredAttachments.length > 0) {
setAttachments(draft.restoredAttachments);
}
setAttachments(draft.restoredAttachments);
// Only run on mount / session change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft.restoredAttachments]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export function ConversationView({
return (
<UserMessage
content={item.content}
attachments={item.attachments}
timestamp={item.timestamp}
sourceUrl={
slackThreadUrl && item.id === firstUserMessageId
Expand Down
Loading
Loading