fix(chat): restaure le collage des images dans le composer custom CLI (#184)

Le collage d'une image (ex. capture d'écran) échouait en environnement desktop : le presse-papier image n'est souvent pas exposé comme un File synchrone dans clipboardData.files, mais uniquement via l'Async Clipboard API. On ajoute donc un repli sur navigator.clipboard.read() (MIME image/*) quand l'événement de collage ne fournit aucun fichier. Le preventDefault() n'est émis que lorsqu'on a effectivement lu des fichiers issus de l'événement, afin de ne pas bloquer le collage texte quand le repli async ne remonte rien.

- refactor clipboardFiles(event) -> dataTransferFiles(DataTransfer) réutilisable
- dedupeKey robuste pour les images sans nom (mime + taille + contenu base64)
- ReplyChunk::UserPrompt porte désormais les labels d'attachments (DTO + accounting reply_chunk_bytes, skip_serializing_if Vec::is_empty)
- tests ciblés frontend (+219) et backend (chat_bridge/dto/attachments) verts

QA : tests ciblés verts. Réserve : environnement headless, pas de validation manuelle desktop — validation AppImage à confirmer en suivi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 16:49:28 +02:00
parent d779b24916
commit d9f949c0f2
9 changed files with 339 additions and 25 deletions

View File

@ -57,6 +57,50 @@ function addPluginCommand(
}
describe("CustomAgentChatView", () => {
it("repaints attachment labels from user prompt scrollback", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [
{
kind: "userPrompt" as const,
text: "describe this",
attachments: ["clipboard.png"],
},
],
})),
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>,
);
expect(await screen.findByText("describe this")).toBeTruthy();
expect(screen.getByText("Fichier: clipboard.png")).toBeTruthy();
});
it("cancels only the current turn and keeps the structured session alive", async () => {
const agent = {
launchAgentChat: vi.fn(),
@ -1406,6 +1450,181 @@ describe("CustomAgentChatView", () => {
).toBe(true);
});
it("falls back to the async clipboard API when pasted images are absent from DataTransfer", async () => {
const originalClipboard = Object.getOwnPropertyDescriptor(
window.navigator,
"clipboard",
);
const read = vi.fn(async () => [
{
types: ["image/png"],
getType: vi.fn(async () => new Blob([new Uint8Array([1, 2, 3])], {
type: "image/png",
})),
},
]);
Object.defineProperty(window.navigator, "clipboard", {
configurable: true,
value: { read },
});
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 () => {}),
};
try {
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),
),
);
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
clipboardData: {
items: [],
files: [],
getData: () => "",
},
});
await screen.findByText(/Fichier joint: clipboard-image-\d+\.png/);
expect(read).toHaveBeenCalledTimes(1);
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: expect.stringMatching(/^clipboard-image-\d+\.png$/),
contentBase64: "AQID",
mime: "image/png",
sourceKind: "clipboard",
},
],
},
);
} finally {
if (originalClipboard) {
Object.defineProperty(window.navigator, "clipboard", originalClipboard);
} else {
Object.defineProperty(window.navigator, "clipboard", {
configurable: true,
value: undefined,
});
}
}
});
it("shows a non-blocking error when the async clipboard image read fails", async () => {
const originalClipboard = Object.getOwnPropertyDescriptor(
window.navigator,
"clipboard",
);
Object.defineProperty(window.navigator, "clipboard", {
configurable: true,
value: { read: vi.fn(async () => { throw new Error("clipboard denied"); }) },
});
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 () => {}),
};
try {
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),
),
);
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
clipboardData: {
items: [],
files: [],
getData: () => "",
},
});
expect((await screen.findByRole("alert")).textContent).toContain(
"clipboard denied",
);
expect(
(screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement)
.disabled,
).toBe(true);
} finally {
if (originalClipboard) {
Object.defineProperty(window.navigator, "clipboard", originalClipboard);
} else {
Object.defineProperty(window.navigator, "clipboard", {
configurable: true,
value: undefined,
});
}
}
});
it("sends a pasted clipboard image without requiring text", async () => {
const agent = {
launchAgentChat: vi.fn(),