feat(agents): show images in sent messages (#439)

Images sent to an agent were dropped from the conversation: the user message kept only text and file chips. User entries now record their images and show them as thumbnails that open full size; composer thumbnails open the same preview. Editing a sent message keeps its images (removable) and rerunning from it sends them again.
This commit is contained in:
l0ng-ai
2026-10-30 19:48:55 +08:00
committed by GitHub
parent a5b7c154e1
commit 47bd21e315
12 changed files with 211 additions and 14 deletions
+2
View File
@@ -1060,6 +1060,7 @@ impl Task {
self.push_entry(AgentEntry::User {
text: prompt.text,
attachments,
images: prompt.images,
sent_ms: Some(now_ms()),
done_ms: None,
steered: true,
@@ -1080,6 +1081,7 @@ impl Task {
self.push_entry(AgentEntry::User {
text: prompt.text.clone(),
attachments,
images: prompt.images.clone(),
sent_ms: Some(now_ms()),
done_ms: None,
steered: false,
+3
View File
@@ -240,6 +240,9 @@ pub enum AgentEntry {
text: String,
/// Names of the files and images sent with it.
attachments: Vec<String>,
/// The images sent with it, shown in the conversation.
#[serde(default)]
images: Vec<AgentImage>,
/// When it was sent (ms since the epoch); `None` in tasks saved before this was kept.
#[serde(default)]
sent_ms: Option<f64>,
+1
View File
@@ -446,6 +446,7 @@ mod tests {
let user = |sent: f64| AgentEntry::User {
text: "q".into(),
attachments: vec![],
images: vec![],
sent_ms: Some(sent),
done_ms: None,
steered: false,
+2
View File
@@ -1124,6 +1124,8 @@ export type AgentConfigVia =
export type AgentEntry = { type: "user"; text: string;
/** Names of the files and images sent with it. */
attachments: string[];
/** The images sent with it, shown in the conversation. */
images?: AgentImage[];
/** When it was sent (ms since the epoch); `None` in tasks saved before this was kept. */
sentMs?: number | null;
/** When the turn it started ended; `None` while it runs or when it was cut off. */
+9
View File
@@ -72,6 +72,15 @@
box-shadow: inset 0 0 0 1px var(--border);
overflow: hidden;
}
.previewImage {
display: block;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: none;
cursor: zoom-in;
}
.image img {
width: 100%;
height: 100%;
+6 -1
View File
@@ -29,6 +29,7 @@ import {
type FileRef,
} from "./composerModel";
import { composerDraft, updateComposer, useAgents } from "./store";
import { ImagePreview, imageSrc } from "./ImagePreview";
import { useCompletionAnchor } from "./useCompletionAnchor";
import styles from "./Composer.module.css";
import { isImeKey } from "@/platform/ime";
@@ -69,6 +70,7 @@ export function Composer(props: ComposerProps) {
const draft = useAgents((s) => s.composers.get(id));
const text = draft?.text ?? "";
const images = draft?.images ?? [];
const [preview, setPreview] = useState<AgentImage | null>(null);
const ref = useRef<HTMLTextAreaElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const imageInput = useRef<HTMLInputElement>(null);
@@ -361,7 +363,9 @@ export function Composer(props: ComposerProps) {
<div className={styles.images} onClick={(e) => e.stopPropagation()}>
{images.map((img, i) => (
<span key={i} className={styles.image} title={img.name ?? undefined}>
<img src={`data:${img.mimeType};base64,${img.data}`} alt={img.name ?? tx("Image")} />
<button type="button" className={styles.previewImage} onClick={() => setPreview(img)}>
<img src={imageSrc(img)} alt={img.name ?? tx("Image")} />
</button>
<button
type="button"
className={styles.removeImage}
@@ -372,6 +376,7 @@ export function Composer(props: ComposerProps) {
</button>
</span>
))}
<ImagePreview image={preview} onClose={() => setPreview(null)} />
</div>
)}
<textarea
@@ -0,0 +1,7 @@
.full {
display: block;
max-width: 100%;
max-height: 70vh;
margin: 0 auto;
object-fit: contain;
}
+22
View File
@@ -0,0 +1,22 @@
import type { AgentImage } from "@/bindings";
import { useTx } from "@/platform/i18n";
import { Dialog } from "@/ui";
import styles from "./ImagePreview.module.css";
export const imageSrc = (img: AgentImage) => `data:${img.mimeType};base64,${img.data}`;
/** The full image in a dialog; `null` keeps it closed. */
export function ImagePreview({ image, onClose }: { image: AgentImage | null; onClose: () => void }) {
const tx = useTx();
return (
<Dialog
open={image !== null}
onClose={onClose}
title={image?.name ?? tx("Image")}
width="min(90vw, 1000px)"
buttons={[{ label: tx("Close"), role: "cancel" }]}
>
{image && <img className={styles.full} src={imageSrc(image)} alt={image.name ?? tx("Image")} />}
</Dialog>
);
}
+40
View File
@@ -499,6 +499,46 @@
background: var(--fg-muted);
}
.images {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.imageItem {
position: relative;
display: inline-flex;
}
.imageButton {
padding: 0;
border: 0;
background: none;
cursor: zoom-in;
}
.image {
display: block;
max-width: 240px;
max-height: 160px;
object-fit: contain;
border: 1px solid var(--border);
border-radius: var(--radius-small);
}
.removeImage {
position: absolute;
top: 4px;
right: 4px;
display: grid;
place-items: center;
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--bg-popup);
color: var(--fg);
cursor: pointer;
}
.attachments {
display: flex;
flex-wrap: wrap;
+61 -10
View File
@@ -2,7 +2,7 @@
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
import clsx from "clsx";
import { useShallow } from "zustand/react/shallow";
import type { AgentChange, AgentEntry, AgentRetry, AgentTask, AgentUsage } from "@/bindings";
import type { AgentChange, AgentEntry, AgentImage, AgentRetry, AgentTask, AgentUsage } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, commands, onIdeEvent } from "@/platform/ipc";
import { basename, relativeDir, useProject } from "@/platform/project";
@@ -30,6 +30,7 @@ import { Composer } from "./Composer";
import { canSend, emptyDraft, retryParts, usageLabel, workTime } from "./composerModel";
import { ConfigPickers } from "./ConfigPickers";
import { findRanges, formatDuration, formatSentAt } from "./conversation";
import { ImagePreview, imageSrc } from "./ImagePreview";
import { Markdown } from "./Markdown";
import {
branchLabel,
@@ -707,22 +708,27 @@ function UserMessage({
return (
<MessageEditor
initial={entry.text}
initialImages={entry.images ?? []}
later={turn < turnCount - 1}
onCancel={() => setEditing(false)}
onSend={async (text) => {
await rerunFrom(task.id, turn, text);
onSend={async (text, images) => {
await rerunFrom(task.id, turn, text, images);
setEditing(false);
}}
/>
);
}
const images = entry.images ?? [];
// Images are listed after the files in `attachments` and shown as thumbnails instead.
const files = entry.attachments.slice(0, entry.attachments.length - images.length);
return (
<div className={styles.message}>
<div className={styles.user}>
{entry.text}
{entry.attachments.length > 0 && (
<ImageStrip images={images} />
{files.length > 0 && (
<div className={styles.attachments}>
{entry.attachments.map((a, i) => (
{files.map((a, i) => (
<span key={i} className={styles.attachment}>
<Icon name={fileIconName(a)} size={12} />
{a}
@@ -753,7 +759,9 @@ function UserMessage({
title={inPlace ? "Run Again From Here" : "Send Again"}
size="small"
onClick={() =>
void (inPlace ? rerunFrom(task.id, turn, entry.text) : resendMessage(task.id, entry.text)).catch(fail)
void (
inPlace ? rerunFrom(task.id, turn, entry.text, images) : resendMessage(task.id, entry.text)
).catch(fail)
}
/>
)}
@@ -774,28 +782,31 @@ function UserMessage({
/** An earlier message edited where it is: sending runs the conversation again from it. */
function MessageEditor({
initial,
initialImages,
later,
onCancel,
onSend,
}: {
initial: string;
initialImages: AgentImage[];
/** Turns follow it (they are dropped on send). */
later: boolean;
onCancel: () => void;
onSend: (text: string) => Promise<void>;
onSend: (text: string, images: AgentImage[]) => Promise<void>;
}) {
const tx = useTx();
const [text, setText] = useState(initial);
const [images, setImages] = useState(initialImages);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const pending = useRef(false);
const send = async () => {
if (!text.trim() || pending.current) return;
if ((!text.trim() && images.length === 0) || pending.current) return;
pending.current = true;
setSending(true);
setError(null);
try {
await onSend(text.trim());
await onSend(text.trim(), images);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
notifications().notify({ level: "error", title: "Couldn't send the message", body: String(e) });
@@ -827,6 +838,7 @@ function MessageEditor({
}
}}
/>
<ImageStrip images={images} onRemove={sending ? undefined : (i) => setImages(images.filter((_, j) => j !== i))} />
{error && (
<div className={styles.error} role="alert">
<Icon name="error" size={14} />
@@ -845,7 +857,12 @@ function MessageEditor({
<Button size="small" disabled={sending} onClick={onCancel}>
Cancel
</Button>
<Button size="small" variant="primary" disabled={!text.trim() || sending} onClick={() => void send()}>
<Button
size="small"
variant="primary"
disabled={(!text.trim() && images.length === 0) || sending}
onClick={() => void send()}
>
{sending && <Spinner size={12} />}
<T>Send</T>
</Button>
@@ -855,6 +872,40 @@ function MessageEditor({
);
}
/** Image thumbnails; a click opens the full image, `onRemove` adds a remove button to each. */
function ImageStrip({ images, onRemove }: { images: AgentImage[]; onRemove?: (index: number) => void }) {
const tx = useTx();
const [open, setOpen] = useState<AgentImage | null>(null);
if (images.length === 0) return null;
return (
<div className={styles.images}>
{images.map((img, i) => (
<span key={i} className={styles.imageItem}>
<button
type="button"
className={styles.imageButton}
title={img.name ?? undefined}
onClick={() => setOpen(img)}
>
<img className={styles.image} src={imageSrc(img)} alt={img.name ?? tx("Image")} />
</button>
{onRemove && (
<button
type="button"
className={styles.removeImage}
aria-label={tx("Remove Image")}
onClick={() => onRemove(i)}
>
<Icon name="close" size={10} />
</button>
)}
</span>
))}
<ImagePreview image={open} onClose={() => setOpen(null)} />
</div>
);
}
function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry, { type: "tool" }> }) {
const head = (
<>
+55 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { AgentEntry, AgentTask } from "@/bindings";
@@ -64,6 +64,60 @@ async function edit() {
return { user, box, send: screen.getAllByRole("button", { name: "Send" })[0]! };
}
describe("a sent message with images", () => {
afterEach(() => {
entries[0] = { type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false };
});
it("shows the images and lists only the files as chips", async () => {
entries[0] = {
type: "user",
text: "Look",
attachments: ["a.ts", "shot.png"],
images: [{ mimeType: "image/png", data: "AAA", name: "shot.png" }],
sentMs: 1,
doneMs: 2,
steered: false,
};
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
const img = await screen.findByRole("img", { name: "shot.png" });
expect(img).toHaveAttribute("src", "data:image/png;base64,AAA");
expect(screen.getByText("a.ts")).toBeInTheDocument();
expect(screen.queryByText("shot.png")).not.toBeInTheDocument();
entries[0] = { type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false };
});
it("opens an image in a dialog and keeps the images when editing, minus the removed ones", async () => {
entries[0] = {
type: "user",
text: "Look",
attachments: ["a.png", "b.png"],
images: [
{ mimeType: "image/png", data: "AAA", name: "a.png" },
{ mimeType: "image/png", data: "BBB", name: "b.png" },
],
sentMs: 1,
doneMs: 2,
steered: false,
};
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "a.png" }));
expect(await screen.findByRole("dialog")).toBeInTheDocument();
expect(screen.getAllByRole("img", { name: "a.png" })).toHaveLength(2);
await user.click(screen.getAllByRole("button", { name: "Close" })[0]!);
await user.click(screen.getByRole("button", { name: "Edit" }));
expect(screen.getAllByRole("button", { name: "Remove Image" })).toHaveLength(2);
await user.click(screen.getAllByRole("button", { name: "Remove Image" })[0]!);
expect(screen.getAllByRole("button", { name: "Remove Image" })).toHaveLength(1);
expect(screen.queryByRole("img", { name: "a.png" })).not.toBeInTheDocument();
await user.click(screen.getAllByRole("button", { name: "Send" })[0]!);
await waitFor(() => expect(ipc.prompt).toHaveBeenCalledTimes(1));
expect(ipc.prompt.mock.calls[0]![1].images).toEqual([{ mimeType: "image/png", data: "BBB", name: "b.png" }]);
entries[0] = { type: "user", text: "Original message", attachments: [], sentMs: 1, doneMs: 2, steered: false };
});
});
describe("editing a sent agent message", () => {
it("keeps Enter, Shift+Enter and IME confirmation for editing", async () => {
const { box } = await edit();
+3 -2
View File
@@ -7,6 +7,7 @@ import type {
AgentCommand,
AgentEntry,
AgentEvent,
AgentImage,
AgentProvider,
AgentTask,
Isolation,
@@ -410,14 +411,14 @@ export async function forkTask(task: string, turns: number | null): Promise<void
* Runs the conversation again from turn `turn` with `text` as its message: the agent is
* stopped if it works, that turn and the later ones are dropped, then `text` is sent.
*/
export async function rerunFrom(task: string, turn: number, text: string): Promise<void> {
export async function rerunFrom(task: string, turn: number, text: string, images: AgentImage[] = []): Promise<void> {
if (isBusy(useAgents.getState().tasks.get(task)?.status ?? "ready")) {
await cancelTask(task);
await settled(task);
}
await call(commands.agentRollback(task, turn));
try {
await call(commands.agentPrompt(task, { text, files: [], images: [] }, false));
await call(commands.agentPrompt(task, { text, files: [], images }, false));
} catch (e) {
// Rollback removes the inline editor; recover its text in the session composer.
const current = composerDraft(task);