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:
@ -171,6 +171,38 @@ describe("TauriAgentGateway invoke payloads", () => {
|
||||
expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything());
|
||||
});
|
||||
|
||||
it("sendAgentChat forwards structured clipboard attachments to agent_send", async () => {
|
||||
await new TauriAgentGateway().sendAgentChat(
|
||||
"chat-session-1",
|
||||
"",
|
||||
vi.fn(),
|
||||
{
|
||||
attachments: [
|
||||
{
|
||||
filename: "clipboard.png",
|
||||
contentBase64: "AQID",
|
||||
mime: "image/png",
|
||||
sourceKind: "clipboard",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("agent_send", {
|
||||
sessionId: "chat-session-1",
|
||||
prompt: "",
|
||||
attachments: [
|
||||
{
|
||||
filename: "clipboard.png",
|
||||
contentBase64: "AQID",
|
||||
mime: "image/png",
|
||||
sourceKind: "clipboard",
|
||||
},
|
||||
],
|
||||
onReply: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it("launchAgentChat returns a handle only when launch_agent confirms cellKind chat", async () => {
|
||||
invoke.mockResolvedValueOnce({
|
||||
sessionId: "chat-session-1",
|
||||
|
||||
@ -32,6 +32,7 @@ import type {
|
||||
OpenTerminalOptions,
|
||||
ReattachAgentChatResult,
|
||||
ReattachResult,
|
||||
SendAgentChatOptions,
|
||||
StoppedLiveAgent,
|
||||
TerminalHandle,
|
||||
} from "@/ports";
|
||||
@ -261,12 +262,14 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
options: SendAgentChatOptions = {},
|
||||
): Promise<void> {
|
||||
const channel = new Channel<ReplyChunk>();
|
||||
channel.onmessage = onChunk;
|
||||
await invoke("agent_send", {
|
||||
sessionId,
|
||||
prompt,
|
||||
...(options.attachments ? { attachments: options.attachments } : {}),
|
||||
onReply: channel,
|
||||
});
|
||||
}
|
||||
|
||||
@ -150,6 +150,7 @@ import type {
|
||||
RemoteGateway,
|
||||
ReviewPluginPackageInput,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
SendAgentChatOptions,
|
||||
SkillGateway,
|
||||
StoppedLiveAgent,
|
||||
SystemGateway,
|
||||
@ -826,6 +827,7 @@ export class MockAgentGateway implements AgentGateway {
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
_options: SendAgentChatOptions = {},
|
||||
): Promise<void> {
|
||||
const chunks = this.chatScrollback.get(sessionId);
|
||||
if (!chunks) {
|
||||
|
||||
@ -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>(() => {})),
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -145,6 +145,25 @@ export interface CreateAgentInput {
|
||||
initialContent?: string;
|
||||
}
|
||||
|
||||
export type ChatAttachmentSourceKind =
|
||||
| "localFile"
|
||||
| "clipboard"
|
||||
| "dragDrop"
|
||||
| "other";
|
||||
|
||||
/** Structured attachment intent accepted by `agent_send`. */
|
||||
export interface ChatAttachmentInput {
|
||||
path?: string;
|
||||
filename?: string;
|
||||
contentBase64?: string;
|
||||
mime?: string;
|
||||
sourceKind?: ChatAttachmentSourceKind;
|
||||
}
|
||||
|
||||
export interface SendAgentChatOptions {
|
||||
attachments?: ChatAttachmentInput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort enriched details about a CLI conversation (T7), used to enrich the
|
||||
* resume popup. Both fields are optional: a missing inspector or a missing
|
||||
@ -284,6 +303,7 @@ export interface AgentGateway {
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
options?: SendAgentChatOptions,
|
||||
): Promise<void>;
|
||||
/** Interrupts only the current turn of a live structured session. */
|
||||
cancelAgentChat?(sessionId: string): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user