mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = (
|
||||
<>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user