feat(chat): #155 paste image depuis presse-papier dans le composer custom (QA verte)

- frontend: onPaste sur CustomAgentChatView, détection MIME image, chip/preview d'attachment, envoi via le contrat #154 (adapters/agent, ports)

- backend: import d'image par bytes dans le store attachments (commands, chat_attachments app+infra, dto, ports) + tests
This commit is contained in:
2026-08-06 11:01:50 +02:00
parent 88dfbfe471
commit 790c4755be
15 changed files with 791 additions and 84 deletions

View File

@ -205,6 +205,159 @@ describe("CustomAgentChatView", () => {
expect(screen.getAllByText("hello agent")).toHaveLength(1);
});
it("pastes a clipboard image as a removable preview chip", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const file = new File(["ignored"], "clipboard.png", { type: "image/png" });
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
});
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
clipboardData: {
items: [
{
kind: "file",
type: "image/png",
getAsFile: () => file,
},
],
getData: () => "",
},
});
expect(await screen.findByText("Fichier joint: clipboard.png")).toBeTruthy();
expect(screen.getByTestId("attachment-preview-clipboard.png")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "retirer clipboard.png" }));
await waitFor(() =>
expect(screen.queryByText("Fichier joint: clipboard.png")).toBeNull(),
);
expect(
(screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it("sends a pasted clipboard image without requiring text", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const file = new File(["ignored"], "paste-image.png", { type: "image/png" });
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
});
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
clipboardData: {
items: [
{
kind: "file",
type: "image/png",
getAsFile: () => file,
},
],
getData: () => "",
},
});
await screen.findByText("Fichier joint: paste-image.png");
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"",
expect.any(Function),
{
attachments: [
{
filename: "paste-image.png",
contentBase64: "AQID",
mime: "image/png",
sourceKind: "clipboard",
},
],
},
);
expect(screen.getByText("Pièce jointe")).toBeTruthy();
expect(screen.getByText("Fichier: paste-image.png")).toBeTruthy();
});
it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => {
const agent = {
launchAgentChat: vi.fn(() => new Promise<never>(() => {})),

View File

@ -6,11 +6,19 @@
* deliberately does not try to parse PTY bytes.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ClipboardEvent,
} from "react";
import type { AgentProfile, GatewayError, ReplyChunk } from "@/domain";
import { useGateways } from "@/app/di";
import { Button, Spinner, cn } from "@/shared";
import type { ChatAttachmentInput } from "@/ports";
export interface CustomAgentChatViewProps {
projectId: string;
@ -26,13 +34,20 @@ export interface CustomAgentChatViewProps {
}
type ChatTurn =
| { role: "user"; text: string; attachment?: string }
| { role: "user"; text: string; attachments?: string[] }
| { role: "agent"; text: string; pending?: boolean }
| { role: "tool"; label: string }
| { role: "final"; text: string }
| { role: "error"; text: string }
| { role: "unknown"; text: string };
interface AttachmentDraft {
id: string;
label: string;
input: ChatAttachmentInput;
previewUrl?: string;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
@ -99,6 +114,56 @@ function appendUserPrompt(turns: ChatTurn[], text: string): ChatTurn[] {
return [...turns, { role: "user", text }];
}
function fileExtension(mime: string): string {
if (mime === "image/png") return "png";
if (mime === "image/jpeg") return "jpg";
if (mime === "image/gif") return "gif";
if (mime === "image/webp") return "webp";
return "img";
}
function pathBasename(path: string): string {
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
async function clipboardImageToAttachment(file: File): Promise<AttachmentDraft> {
const mime = file.type || "application/octet-stream";
const filename =
file.name ||
`clipboard-image-${Date.now()}.${fileExtension(mime)}`;
const contentBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
return {
id: `clipboard-${Date.now()}-${Math.random().toString(36).slice(2)}`,
label: filename,
previewUrl: mime.startsWith("image/")
? `data:${mime};base64,${contentBase64}`
: undefined,
input: {
filename,
contentBase64,
mime,
sourceKind: "clipboard",
},
};
}
function clipboardImageFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
return Array.from(event.clipboardData.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
}
function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
if (!isReplyRecord(raw)) {
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
@ -148,7 +213,7 @@ export function CustomAgentChatView({
const [currentSession, setCurrentSession] = useState(sessionId);
const [externalSessionId, setExternalSessionId] = useState(sessionId);
const [draft, setDraft] = useState("");
const [attachment, setAttachment] = useState<string | null>(null);
const [attachments, setAttachments] = useState<AttachmentDraft[]>([]);
const [opening, setOpening] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -412,26 +477,76 @@ export function CustomAgentChatView({
const canSend = useMemo(
() =>
supported &&
Boolean(draft.trim()) &&
(Boolean(draft.trim()) || attachments.length > 0) &&
!busy &&
!opening,
[supported, draft, busy, opening],
[supported, draft, attachments.length, busy, opening],
);
async function pickAttachment() {
const path = await system.pickFile();
if (path) setAttachment(path);
if (path) {
setAttachments((prev) => [
...prev,
{
id: `path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
label: pathBasename(path),
input: { path, sourceKind: "localFile" },
},
]);
}
}
async function pasteClipboardImages(
event: ClipboardEvent<HTMLTextAreaElement>,
) {
const files = clipboardImageFiles(event);
if (files.length === 0) return;
event.preventDefault();
const pastedText = event.clipboardData.getData("text/plain");
const selectionStart = event.currentTarget.selectionStart;
const selectionEnd = event.currentTarget.selectionEnd;
try {
const nextAttachments = await Promise.all(
files.map(clipboardImageToAttachment),
);
setAttachments((prev) => [...prev, ...nextAttachments]);
if (pastedText) {
setDraft((prev) =>
prev.slice(0, selectionStart) +
pastedText +
prev.slice(selectionEnd),
);
}
} catch (e) {
setError(describe(e));
}
}
async function send() {
const text = draft.trim();
if (!canSend || !agent.sendAgentChat) return;
const prompt = attachment ? `${text}\n\n[Fichier joint: ${attachment}]` : text;
const outgoingAttachments = attachments;
const attachmentInputs = outgoingAttachments.map((item) => item.input);
const attachmentLabels = outgoingAttachments.map((item) => item.label);
const displayText = text || "Pièce jointe";
setDraft("");
setAttachment(null);
setAttachments([]);
setBusy(true);
setError(null);
setTurns((prev) => [...prev, { role: "user", text, attachment: attachment ?? undefined }]);
setTurns((prev) => [
...prev,
{
role: "user",
text: displayText,
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
},
]);
const sendTurn = (sid: string) =>
attachmentInputs.length > 0
? agent.sendAgentChat!(sid, text, receive, { attachments: attachmentInputs })
: agent.sendAgentChat!(sid, text, receive);
try {
const sid =
currentSession ??
@ -439,7 +554,7 @@ export function CustomAgentChatView({
applyScrollback: false,
retryAttachNotFound: true,
}));
await agent.sendAgentChat(sid, prompt, receive);
await sendTurn(sid);
} catch (e) {
if (isNotFound(e)) {
try {
@ -449,7 +564,7 @@ export function CustomAgentChatView({
applyScrollback: false,
retryAttachNotFound: true,
});
await agent.sendAgentChat(recoveredSession, prompt, receive);
await sendTurn(recoveredSession);
return;
} catch (recoveryError) {
setBusy(false);
@ -548,12 +663,36 @@ export function CustomAgentChatView({
data-testid="custom-agent-chat-composer"
className="flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2"
>
{attachment && (
<div className="flex items-center justify-between gap-2 rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted">
<span className="truncate">Fichier joint: {attachment}</span>
<button type="button" className="text-content" onClick={() => setAttachment(null)}>
Retirer
</button>
{attachments.length > 0 && (
<div className="flex min-w-0 flex-wrap gap-2">
{attachments.map((item) => (
<div
key={item.id}
className="flex max-w-full items-center gap-2 rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted"
>
{item.previewUrl && (
<img
src={item.previewUrl}
alt=""
data-testid={`attachment-preview-${item.label}`}
className="h-8 w-8 shrink-0 rounded border border-border object-cover"
/>
)}
<span className="min-w-0 truncate">Fichier joint: {item.label}</span>
<button
type="button"
aria-label={`retirer ${item.label}`}
className="shrink-0 text-content"
onClick={() =>
setAttachments((prev) =>
prev.filter((candidate) => candidate.id !== item.id),
)
}
>
Retirer
</button>
</div>
))}
</div>
)}
<div className="flex min-w-0 items-end gap-2">
@ -568,6 +707,7 @@ export function CustomAgentChatView({
disabled={!supported || opening || busy}
placeholder="Message à l'agent…"
onChange={(e) => setDraft(e.target.value)}
onPaste={(e) => void pasteClipboardImages(e)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@ -640,8 +780,14 @@ function ChatBubble({ turn }: { turn: ChatTurn }) {
)}
>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
{user && turn.attachment && (
<p className="mt-1 truncate text-xs text-muted">Fichier: {turn.attachment}</p>
{user && turn.attachments && turn.attachments.length > 0 && (
<div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
{turn.attachments.map((attachment) => (
<p key={attachment} className="truncate">
Fichier: {attachment}
</p>
))}
</div>
)}
{!user && turn.pending && (
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted">