Skip to content
Merged
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
23 changes: 23 additions & 0 deletions src/renderer/components/composer/AttachmentBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,29 @@ describe("AttachmentBar", () => {
expect(imageUrlForPath).toHaveBeenCalledWith("/Users/host/.poracode/attachments/shot.png");
});

it("prefers the local pasted-bytes preview over the remote URL", () => {
const imageUrlForPath = vi.fn<(path: string) => string>(() => "https://mac.test/remote.png");
render(
<AttachmentBar
attachments={[
{
id: "image-1",
path: "C:\\Users\\host\\.poracode\\attachments\\shot.png",
name: "shot.png",
mimeType: "image/png",
isImage: true,
previewUrl: "blob:app/pasted-1",
},
]}
imagesAsPreview
imageUrlForPath={imageUrlForPath}
/>,
);

expect(screen.getByAltText("shot.png")).toHaveAttribute("src", "blob:app/pasted-1");
expect(imageUrlForPath).not.toHaveBeenCalled();
});

it("renders flush attachment bars for inline message attachments", () => {
const { container } = render(
<AttachmentBar
Expand Down
9 changes: 4 additions & 5 deletions src/renderer/components/composer/AttachmentBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react/macro";
import { isRemoteSession } from "@/renderer/bridge";
import { getEntryIconUrl } from "@/renderer/components/common/fileIcons";
import { resolveLocalImageDisplayUrl } from "@/shared/localImageDisplay";
import { isPdfPath, toLocalFileUrl } from "@/shared/promptContent";
import { isPdfPath } from "@/shared/promptContent";
import type { ComposerMcpServerDescriptor } from "./composerMcpServers";
import type { Attachment } from "./useAttachments";
import { attachmentImageUrl, type Attachment } from "./useAttachments";

/**
* Enabled-MCP indicator, parameterized by a {@link ComposerMcpServerDescriptor}
Expand Down Expand Up @@ -149,7 +148,7 @@ function AttachmentChip(props: {
{att.isImage ? (
<img
className="poracode-attachment-chip__thumb"
src={imageUrlForPath?.(att.path) ?? resolveLocalImageDisplayUrl(toLocalFileUrl(att.path))}
src={attachmentImageUrl(att, imageUrlForPath)}
alt={att.name}
decoding="async"
draggable={false}
Expand Down Expand Up @@ -221,7 +220,7 @@ function ImagePreview(props: {
const { attachment: att, onPreviewImage, imageUrlForPath } = props;
const img = (
<img
src={imageUrlForPath?.(att.path) ?? resolveLocalImageDisplayUrl(toLocalFileUrl(att.path))}
src={attachmentImageUrl(att, imageUrlForPath)}
alt={att.name}
decoding="async"
draggable={false}
Expand Down
6 changes: 2 additions & 4 deletions src/renderer/components/composer/ImageLightbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ import {
import { createPortal } from "react-dom";
import { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut } from "lucide-react";
import { useLingui } from "@lingui/react/macro";
import { resolveLocalImageDisplayUrl } from "@/shared/localImageDisplay";
import { toLocalFileUrl } from "@/shared/promptContent";
import type { Attachment } from "./useAttachments";
import { attachmentImageUrl, type Attachment } from "./useAttachments";

/** A pre-resolved image for the lightbox: a renderable URL plus an accessible label. */
export interface LightboxImage {
Expand Down Expand Up @@ -69,7 +67,7 @@ export function openAttachmentLightbox(
): void {
openImageLightbox(
attachments.map((img) => ({
src: imageUrlForPath?.(img.path) ?? resolveLocalImageDisplayUrl(toLocalFileUrl(img.path)),
src: attachmentImageUrl(img, imageUrlForPath),
alt: img.name,
})),
initialIndex,
Expand Down
55 changes: 54 additions & 1 deletion src/renderer/components/composer/useAttachments.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
// @vitest-environment jsdom

import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAttachments, type SaveClipboardImage } from "./useAttachments";

describe("useAttachments", () => {
// jsdom does not implement object URLs.
const createObjectURL = vi.fn<(source: File) => string>(() => "blob:app/pasted-1");
const revokeObjectURL = vi.fn<(url: string) => void>();

beforeEach(() => {
createObjectURL.mockClear();
revokeObjectURL.mockClear();
Object.assign(URL, { createObjectURL, revokeObjectURL });
});

afterEach(() => {
Reflect.deleteProperty(URL, "createObjectURL");
Reflect.deleteProperty(URL, "revokeObjectURL");
});

it("uses the remote image saver for pasted images", async () => {
const saveImage = vi.fn<SaveClipboardImage>(async () =>
Promise.resolve("/Users/host/.poracode/attachments/draft/image.png"),
Expand All @@ -31,4 +46,42 @@ describe("useAttachments", () => {
},
]);
});

it("previews pasted images from a local object URL and revokes it on removal", async () => {
const saveImage = vi.fn<SaveClipboardImage>(async () =>
Promise.resolve("C:\\Users\\host\\.poracode\\attachments\\draft\\image.png"),
);
const file = new File([new Uint8Array([1, 2, 3])], "clipboard.png", { type: "image/png" });
const { result } = renderHook(() => useAttachments({ saveClipboardImage: saveImage }));

await act(async () => {
await result.current.addClipboardImage(file, "draft:remote-project");
});

const [attachment] = result.current.attachments;
expect(attachment?.previewUrl).toBe("blob:app/pasted-1");
expect(createObjectURL).toHaveBeenCalledWith(file);

act(() => {
result.current.removeAttachment(attachment!.id);
});
expect(revokeObjectURL).toHaveBeenCalledWith("blob:app/pasted-1");
expect(result.current.attachments).toEqual([]);
});

it("revokes pasted-image object URLs on clearAll", async () => {
const saveImage = vi.fn<SaveClipboardImage>(async () => Promise.resolve("/tmp/image.png"));
const file = new File([new Uint8Array([1])], "clipboard.png", { type: "image/png" });
const { result } = renderHook(() => useAttachments({ saveClipboardImage: saveImage }));

await act(async () => {
await result.current.addClipboardImage(file, "thread-1");
});
act(() => {
result.current.clearAll();
});

expect(revokeObjectURL).toHaveBeenCalledWith("blob:app/pasted-1");
expect(result.current.attachments).toEqual([]);
});
});
32 changes: 31 additions & 1 deletion src/renderer/components/composer/useAttachments.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useState } from "react";
import { readBridge } from "@/renderer/bridge";
import type { PromptSegment } from "@/shared/contracts";
import { fileNameFromPath, isImagePath } from "@/shared/promptContent";
import { resolveLocalImageDisplayUrl } from "@/shared/localImageDisplay";
import { fileNameFromPath, isImagePath, toLocalFileUrl } from "@/shared/promptContent";

export interface Attachment {
id: string;
Expand All @@ -13,6 +14,29 @@ export interface Attachment {
selector?: string;
/** Optional source page URL for picker attachments. */
sourceUrl?: string;
/**
* Object URL of the pasted bytes. `path` may live on a remote desktop (the
* paste is uploaded there), so composer previews render from this local copy
* instead of fetching the saved file back.
*/
previewUrl?: string;
}

/**
* Renderable URL for an image attachment. Prefers the local pasted bytes
* (`previewUrl`), then a caller-supplied remote resolver (the paired desktop's
* image endpoint when `path` lives on another machine), then the local-file
* protocol.
*/
export function attachmentImageUrl(
attachment: Pick<Attachment, "path" | "previewUrl">,
imageUrlForPath?: (path: string) => string,
): string {
return (
attachment.previewUrl ??
imageUrlForPath?.(attachment.path) ??
resolveLocalImageDisplayUrl(toLocalFileUrl(attachment.path))
);
}

const MIME_BY_EXT: Record<string, string> = {
Expand Down Expand Up @@ -89,6 +113,7 @@ export function useAttachments(options: { saveClipboardImage?: SaveClipboardImag
name: `Image ${n}.${ext}`,
mimeType: file.type,
isImage: true,
previewUrl: URL.createObjectURL(file),
},
];
});
Expand Down Expand Up @@ -116,10 +141,15 @@ export function useAttachments(options: { saveClipboardImage?: SaveClipboardImag
}

function removeAttachment(id: string) {
const removed = attachments.find((a) => a.id === id);
if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl);
setAttachments((prev) => prev.filter((a) => a.id !== id));
}

function clearAll() {
for (const a of attachments) {
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
}
setAttachments([]);
}

Expand Down
14 changes: 13 additions & 1 deletion src/renderer/components/thread/ThreadComposerSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { isRemoteSession, readBridge } from "@/renderer/bridge";
import { threadProductProperties } from "@/renderer/analytics/posthog";
import { captureProductEvent } from "@/renderer/analytics/productAnalytics";
import { useAppStore } from "@/renderer/state/appStore";
import { useRemoteServersStore } from "@/renderer/state/remoteServersStore";
import { useBrowserAttachInbox } from "@/renderer/state/browserAttachInbox";
import {
useComposerInputInbox,
Expand Down Expand Up @@ -175,6 +176,12 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread
const attachments = useAttachments({
...(props.saveClipboardImage ? { saveClipboardImage: props.saveClipboardImage } : {}),
});
// Remote-thread attachments are stored on the paired desktop; resolve
// previews through its image endpoint instead of the local-file protocol.
const remoteDesktopId = thread.remoteServerId;
const attachmentImageUrlForPath = remoteDesktopId
? (path: string) => useRemoteServersStore.getState().localImageUrl(remoteDesktopId, path)
: undefined;
// Unsent composer content survives leaving this thread. The primary GUI pane
// keeps this section mounted across thread switches, so the thread-keyed
// layout effects below save and restore without exposing another thread's
Expand Down Expand Up @@ -738,9 +745,14 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread
onPreviewImage={(att) => {
const imageAttachments = attachments.attachments.filter((a) => a.isImage);
const idx = imageAttachments.findIndex((a) => a.id === att.id);
if (idx >= 0) openAttachmentLightbox(imageAttachments, idx);
if (idx >= 0) {
openAttachmentLightbox(imageAttachments, idx, attachmentImageUrlForPath);
}
}}
onPreviewPdf={(att) => openPdfPreview(att.path)}
{...(attachmentImageUrlForPath
? { imageUrlForPath: attachmentImageUrlForPath }
: {})}
/>
}
inputContent={
Expand Down
11 changes: 10 additions & 1 deletion src/renderer/components/thread/ThreadDraftComposerArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
type ExperimentDraftCandidate,
} from "@/renderer/components/experiment/ExperimentDraftTargets";
import { useAppStore } from "@/renderer/state/appStore";
import { useRemoteServersStore } from "@/renderer/state/remoteServersStore";
import { useGitStore } from "@/renderer/state/gitStore";
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
import { isDraftContentNonEmpty } from "@/renderer/state/slices/types";
Expand Down Expand Up @@ -307,6 +308,12 @@ export function ThreadDraftComposerArea(props: {
const attachments = useAttachments({
...(props.saveClipboardImage ? { saveClipboardImage: props.saveClipboardImage } : {}),
});
// Remote-project attachments are stored on the paired desktop; resolve
// previews through its image endpoint instead of the local-file protocol.
const remoteDesktopId = props.project.remoteServerId;
const attachmentImageUrlForPath = remoteDesktopId
? (path: string) => useRemoteServersStore.getState().localImageUrl(remoteDesktopId, path)
: undefined;
const inboxKey = props.paneId ?? `draft:${props.project.id}`;
const fallbackInboxKey = `draft:${props.project.id}`;
const pendingPickedAttachments = useBrowserAttachInbox((s) =>
Expand Down Expand Up @@ -967,9 +974,11 @@ export function ThreadDraftComposerArea(props: {
onPreviewImage={(att) => {
const imageAttachments = attachments.attachments.filter((a) => a.isImage);
const idx = imageAttachments.findIndex((a) => a.id === att.id);
if (idx >= 0) openAttachmentLightbox(imageAttachments, idx);
if (idx >= 0)
openAttachmentLightbox(imageAttachments, idx, attachmentImageUrlForPath);
}}
onPreviewPdf={(att) => openPdfPreview(att.path)}
{...(attachmentImageUrlForPath ? { imageUrlForPath: attachmentImageUrlForPath } : {})}
leading={
mentionedMcpServers.length > 0 || showComputerUseChip ? (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useExperimentStore } from "@/renderer/state/experimentStore";
import { remoteOwner } from "@/renderer/state/remoteProjection";
import { useProject, useThread } from "@/renderer/state/useThread";
import { ThreadView } from "@/renderer/components/thread/ThreadView";
import type { SaveClipboardImage } from "@/renderer/components/composer/useAttachments";
import type { RemoteTerminalTransport } from "@/renderer/components/thread/TerminalPane";
import { useDraggable, useDroppable } from "@dnd-kit/react";
import { useIsDraggingPane, usePaneDropIndicatorState, type DragSourceData } from "@/renderer/dnd";
Expand Down Expand Up @@ -109,6 +110,15 @@ export function ThreadPane(props: {
if (!remoteDesktopId || !remoteThreadId) return Promise.resolve(null);
return useRemoteServersStore.getState().pickAndUploadFiles(remoteDesktopId, remoteThreadId);
}
// Pasted images must land on the host desktop — the agent runs there and
// can't read a path saved on this machine.
const saveRemoteClipboardImage: SaveClipboardImage | undefined =
remoteDesktopId && remoteThreadId
? (input) =>
useRemoteServersStore
.getState()
.saveClipboardImage(remoteDesktopId, { ...input, threadId: remoteThreadId })
: undefined;
if (!thread) return null;
if (!project) return null;
if (experiment && !thread.worktreePath) {
Expand Down Expand Up @@ -172,6 +182,7 @@ export function ThreadPane(props: {
pickFiles: pickRemoteFiles,
}
: {})}
{...(saveRemoteClipboardImage ? { saveClipboardImage: saveRemoteClipboardImage } : {})}
onContinueInProvider={
props.onContinueInProvider && !thread.remoteServerId
? (targetKind, tConfig, targetPresentationMode, prompt, segments, closeOrig, ctx) => {
Expand Down