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
5 changes: 5 additions & 0 deletions src/main/attachments/localFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export function saveClipboardImageFile(
return filePath;
}

/** Write raw image bytes to a user-chosen absolute path (download "Save as…"). */
export function writeImageFile(filePath: string, data: Uint8Array): void {
writeFileSync(filePath, Buffer.from(data));
}

export function saveHandoffContextFile(
paths: LightcodePaths,
payload: { threadId: string; content: string },
Expand Down
23 changes: 23 additions & 0 deletions src/main/ipc/localHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
resolveProjectFsPath,
saveClipboardImageFile,
saveHandoffContextFile,
writeImageFile,
} from "../attachments/localFiles";
import { createProjectDirectory } from "../projectDirectory";
import {
Expand Down Expand Up @@ -132,6 +133,28 @@ export function createLocalIpcHandlers(
saveClipboardImageFile(options.requireLightcodePaths(), payload),
saveHandoffContext: (payload) =>
saveHandoffContextFile(options.requireLightcodePaths(), payload),
saveImageFile: async ({ data, suggestedName }) => {
const win = options.getMainWindow();
const result = await dialog.showSaveDialog(win!, {
title: "Save image",
defaultPath: suggestedName,
filters: [
{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] },
],
});
if (result.canceled || !result.filePath) return null;
writeImageFile(result.filePath, data);
return result.filePath;
},
copyImageToClipboard: ({ data }) => {
// `nativeImage.createFromBuffer` only decodes PNG/JPEG; the renderer
// converts other formats to PNG first. Report whether anything landed on
// the clipboard so the UI doesn't claim success on an empty image.
const image = nativeImage.createFromBuffer(Buffer.from(data));
if (image.isEmpty()) return false;
clipboard.writeImage(image);
return true;
},
createProjectDirectory: (payload) => createProjectDirectory(payload),
openExternal: async (url) => {
const safeUrl = assertSafeExternalUrl(url);
Expand Down
41 changes: 37 additions & 4 deletions src/renderer/components/composer/ImageLightbox.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,46 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import { toLocalFileUrl } from "@/shared/promptContent";
import type { Attachment } from "./useAttachments";

/** A pre-resolved image for the lightbox: a renderable URL plus an accessible label. */
export interface LightboxImage {
/** Renderable image URL — a `data:`, `lightcode-local://`, or remote URL. */
src: string;
/** Accessible label / alt text. */
alt?: string;
}

/**
* Attachment-backed lightbox used by the composer surfaces. Resolves each
* attachment's local path to a renderable URL and defers to
* {@link ImageLightboxView}.
*/
export function ImageLightbox(props: {
images: Attachment[];
initialIndex: number;
onClose: () => void;
}) {
const images = useMemo<LightboxImage[]>(
() => props.images.map((img) => ({ src: toLocalFileUrl(img.path), alt: img.name })),
[props.images],
);
return (
<ImageLightboxView images={images} initialIndex={props.initialIndex} onClose={props.onClose} />
);
}

/**
* Source-agnostic fullscreen image viewer. Accepts already-resolved image URLs
* (`data:`, `lightcode-local://`, remote) so it can be reused for chat-generated
* images as well as composer attachments. Supports keyboard nav and prev/next
* chrome for multi-image galleries; a single image renders without that chrome.
*/
export function ImageLightboxView(props: {
images: LightboxImage[];
initialIndex: number;
onClose: () => void;
}) {
const { images, initialIndex, onClose } = props;
const [index, setIndex] = useState(initialIndex);
Expand Down Expand Up @@ -40,7 +73,7 @@ export function ImageLightbox(props: {
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={current.name ?? "Image preview"}
aria-label={current.alt ?? "Image preview"}
>
<button
type="button"
Expand Down Expand Up @@ -68,8 +101,8 @@ export function ImageLightbox(props: {
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -- stopPropagation prevents backdrop close */}
<img
className="lightcode-image-lightbox__image"
src={toLocalFileUrl(current.path)}
alt={current.name ?? ""}
src={current.src}
alt={current.alt ?? ""}
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/composer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ export { MentionInput, type MentionInputHandle } from "./MentionInput";
export { AttachmentBar, BrowserChip } from "./AttachmentBar";
export { ComposerAddMenu } from "./ComposerAddMenu";
export { VoiceInputButton } from "./VoiceInputButton";
export { ImageLightbox } from "./ImageLightbox";
export { ImageLightbox, ImageLightboxView, type LightboxImage } from "./ImageLightbox";
export { useAttachments, type Attachment } from "./useAttachments";
export { toLocalFileUrl } from "@/shared/promptContent";
15 changes: 15 additions & 0 deletions src/renderer/components/thread/ChatPane/chatPaneSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
import type { AppStoreState } from "@/renderer/state/slices/shared";
import type { ToolCallPayload } from "@/shared/contracts";
import { canShareRuntimeToolGroup } from "@/renderer/state/runtimeToolGrouping";
import { imageViewRendersInline } from "./parts/items/imageViewSource";
import { isContextCompactionToolCall } from "./parts/items/ContextCompaction";
import { isPlanProposalToolCall } from "./parts/items/PlanProposal";
import { isSubAgentTool, isWorkflowTool } from "./parts/items/toolDisplay";
Expand Down Expand Up @@ -173,6 +174,20 @@ function isToolGroupItem(item: RuntimeChatItem): boolean {
if (item.type === "tool_call" && isSubAgentTool(item.payload as ToolCallPayload | undefined)) {
return false;
}
// Any tool-like row that renders as a standalone inline image card
// (ImageView) is never folded into a "Ran N tools" group. Rows that fall back
// to the generic accordion (still running, errored, or non-image) keep the
// default grouping — `imageViewRendersInline` mirrors ImageView's render
// decision so the two never disagree.
if (
(item.type === "tool_call" ||
item.type === "mcp_tool_call" ||
item.type === "image_view" ||
item.type === "dynamic_tool_call") &&
imageViewRendersInline(item.payload)
) {
return false;
}
return (
item.type === "tool_call" ||
item.type === "mcp_tool_call" ||
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AppProvider } from "@/renderer/components/ui/provider";
import type { RuntimeChatItem } from "@/renderer/state/slices/runtimeEventSlice";
import { AssistantMessage } from "./AssistantMessage";

const PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";

describe("AssistantMessage", () => {
it("renders embedded image content blocks inline alongside text", () => {
const item: RuntimeChatItem = {
id: "asst_1",
type: "assistant_message",
state: "completed",
payload: {
content: [
{ kind: "text", text: "Here is your image:" },
{
kind: "image",
mimeType: "image/png",
dataUrl: `data:image/png;base64,${PNG_BASE64}`,
name: "result",
},
],
},
streams: {},
};

render(
<AppProvider>
<AssistantMessage item={item} />
</AppProvider>,
);

const img = screen.getByAltText("result") as HTMLImageElement;
expect(img.getAttribute("src")).toBe(`data:image/png;base64,${PNG_BASE64}`);
expect(screen.getByText("Here is your image:")).toBeTruthy();
});

it("ignores non-image content blocks", () => {
const item: RuntimeChatItem = {
id: "asst_2",
type: "assistant_message",
state: "completed",
payload: { content: [{ kind: "text", text: "Just text." }] },
streams: {},
};

render(
<AppProvider>
<AssistantMessage item={item} />
</AppProvider>,
);

expect(screen.queryByRole("img")).toBeNull();
expect(screen.getByText("Just text.")).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useDeferredValue } from "react";
import { memo, useDeferredValue, useMemo } from "react";
import { Surface } from "@heroui/react";
import type { MessageItemPayload } from "@/shared/contracts";
import { PixelLoader } from "@/renderer/components/common";
Expand All @@ -7,6 +7,8 @@ import {
type RuntimeChatItem,
} from "@/renderer/state/slices/runtimeEventSlice";
import { chatMessageSurfaceClass } from "./chatMessageSurface";
import { ImageCard } from "./ImageView";
import { imageViewSourceFromImageBlock } from "./imageViewSource";
import { ItemMarkdown } from "./ItemMarkdown";

interface AssistantMessageProps {
Expand All @@ -26,11 +28,28 @@ export const AssistantMessage = memo(function AssistantMessage({ item }: Assista
const deferredText = useDeferredValue(rawText);
const text = item.state === "completed" ? rawText : deferredText;
const isStreaming = item.state !== "completed";
// Agents (e.g. ACP providers) can embed images directly in a message as image
// content blocks; render them inline beneath any text.
const imageSources = useMemo(
() =>
(payload?.content ?? [])
.filter((b) => b.kind === "image")
.map((b) => imageViewSourceFromImageBlock(b))
.filter((s): s is NonNullable<typeof s> => s !== null),
[payload?.content],
);
return (
<Surface variant="transparent" className={chatMessageSurfaceClass}>
<div className="min-w-0 leading-snug">
{rawText.length > 0 ? <ItemMarkdown text={text} /> : null}
{isStreaming && rawText.length === 0 ? (
{imageSources.length > 0 ? (
<div className="mt-1 flex flex-col gap-2">
{imageSources.map((source, index) => (
<ImageCard key={`${source.src.slice(0, 64)}:${index}`} source={source} />
))}
</div>
) : null}
{isStreaming && rawText.length === 0 && imageSources.length === 0 ? (
<div className="text-foreground-muted">
<PixelLoader size="xxs" />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import {
import { AssistantMessage } from "./AssistantMessage";
import { CommandExecution } from "./CommandExecution";
import { FileChange } from "./FileChange";
import { ImageView } from "./ImageView";
import { PlanItem } from "./PlanItem";
import { QuestionAnswer } from "./QuestionAnswer";
import { Reasoning } from "./Reasoning";
import { SubAgentToolCall } from "./SubAgentToolCall";
import { ToolCall } from "./ToolCall";
import { ToolCallGroup } from "./ToolCallGroup";
import { UserMessage } from "./UserMessage";
import { WebSearchItem } from "./WebSearchItem";
Expand Down Expand Up @@ -103,11 +103,14 @@ function renderItem(item: RuntimeChatItem, checkpointRevertControl: ReactNode |
return <CommandExecution item={item} />;
case "file_change":
return <FileChange item={item} />;
// Any tool-like row may carry a generated image (Codex `imageGeneration`,
// ACP/Claude image tools). ImageView renders the inline image card and
// falls back to the standard ToolCall accordion when there's no image.
case "image_view":
case "tool_call":
case "mcp_tool_call":
case "image_view":
case "dynamic_tool_call":
return <ToolCall item={item} />;
return <ImageView item={item} />;
case "web_search":
return <WebSearchItem item={item} />;
case "error":
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AppProvider } from "@/renderer/components/ui/provider";
import type { RuntimeChatItem } from "@/renderer/state/slices/runtimeEventSlice";
import { ImageView } from "./ImageView";

const PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";

function imageItem(payload: Record<string, unknown>): RuntimeChatItem {
return {
id: "image_1",
type: "image_view",
state: "completed",
payload,
streams: {},
};
}

describe("ImageView", () => {
it("renders an inline <img> with the generated image and a caption", () => {
render(
<AppProvider>
<ImageView
item={imageItem({
name: "imageGeneration",
status: "success",
result: PNG_BASE64,
args: { prompt: "A red square" },
})}
/>
</AppProvider>,
);

const img = screen.getByAltText("A red square") as HTMLImageElement;
expect(img.tagName).toBe("IMG");
expect(img.getAttribute("src")).toBe(`data:image/png;base64,${PNG_BASE64}`);
expect(screen.getByText("A red square")).toBeTruthy();
expect(screen.getByRole("button", { name: "Copy image" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Download image" })).toBeTruthy();
});

it("opens a lightbox when the image is clicked", () => {
render(
<AppProvider>
<ImageView item={imageItem({ name: "imageGeneration", result: PNG_BASE64 })} />
</AppProvider>,
);

expect(screen.queryByRole("dialog")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Open image preview" }));
expect(screen.getByRole("dialog")).toBeTruthy();
});

it("falls back to the tool-call row when the result is not an image", () => {
render(
<AppProvider>
<ImageView
item={imageItem({
name: "imageGeneration",
status: "success",
result: "Sorry, image generation failed.",
})}
/>
</AppProvider>,
);

// No inline image card; the generic tool-call accordion is shown instead.
expect(screen.queryByRole("img")).toBeNull();
expect(screen.getByText(/imageGeneration/i)).toBeTruthy();
});
});
Loading