feat(chat): colle les fichiers du presse-papiers via Ctrl+V dans le composer (#181)
This commit is contained in:
@ -1562,6 +1562,162 @@ describe("CustomAgentChatView", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps pasted text insertion when clipboard files are attached", 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 composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||
fireEvent.change(composer, { target: { value: "prefix suffix" } });
|
||||
composer.setSelectionRange(7, 13);
|
||||
|
||||
const file = new File(["ignored"], "mixed.txt", { type: "text/plain" });
|
||||
Object.defineProperty(file, "arrayBuffer", {
|
||||
value: vi.fn(async () => new Uint8Array([7, 8, 9]).buffer),
|
||||
});
|
||||
fireEvent.paste(composer, {
|
||||
clipboardData: {
|
||||
items: [
|
||||
{
|
||||
kind: "file",
|
||||
type: "text/plain",
|
||||
getAsFile: () => file,
|
||||
},
|
||||
],
|
||||
getData: () => "inserted",
|
||||
},
|
||||
});
|
||||
|
||||
await screen.findByText("Fichier joint: mixed.txt");
|
||||
await waitFor(() => expect(composer.value).toBe("prefix inserted"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
|
||||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
|
||||
expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
||||
"chat-session-1",
|
||||
"prefix inserted",
|
||||
expect.any(Function),
|
||||
{
|
||||
attachments: [
|
||||
{
|
||||
filename: "mixed.txt",
|
||||
contentBase64: "BwgJ",
|
||||
mime: "text/plain",
|
||||
sourceKind: "clipboard",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(screen.getByText("prefix inserted")).toBeTruthy();
|
||||
expect(screen.getByText("Fichier: mixed.txt")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("deduplicates clipboard files by metadata across clipboard sources and repeated paste", 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"], "duplicate.pdf", {
|
||||
type: "application/pdf",
|
||||
lastModified: 123,
|
||||
});
|
||||
Object.defineProperty(file, "arrayBuffer", {
|
||||
value: vi.fn(async () => new Uint8Array([10, 11, 12]).buffer),
|
||||
});
|
||||
const clipboardData = {
|
||||
items: [
|
||||
{
|
||||
kind: "file",
|
||||
type: "application/pdf",
|
||||
getAsFile: () => file,
|
||||
},
|
||||
],
|
||||
files: [file],
|
||||
getData: () => "",
|
||||
};
|
||||
const composer = screen.getByLabelText(/message CLI custom/);
|
||||
|
||||
fireEvent.paste(composer, { clipboardData });
|
||||
await screen.findByText("Fichier joint: duplicate.pdf");
|
||||
fireEvent.paste(composer, { clipboardData });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText("Fichier joint: duplicate.pdf")).toHaveLength(1),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(() => new Promise<never>(() => {})),
|
||||
|
||||
@ -57,6 +57,7 @@ interface AttachmentDraft {
|
||||
label: string;
|
||||
input: ChatAttachmentInput;
|
||||
previewUrl?: string;
|
||||
dedupeKey?: string;
|
||||
}
|
||||
|
||||
interface ProfileCommandDialogState {
|
||||
@ -351,6 +352,7 @@ async function clipboardFileToAttachment(file: File): Promise<AttachmentDraft> {
|
||||
previewUrl: mime.startsWith("image/")
|
||||
? `data:${mime};base64,${contentBase64}`
|
||||
: undefined,
|
||||
dedupeKey: fileMetadataKey(file),
|
||||
input: {
|
||||
filename,
|
||||
contentBase64,
|
||||
@ -360,6 +362,10 @@ async function clipboardFileToAttachment(file: File): Promise<AttachmentDraft> {
|
||||
};
|
||||
}
|
||||
|
||||
function fileMetadataKey(file: File): string {
|
||||
return `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`;
|
||||
}
|
||||
|
||||
function clipboardFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
|
||||
const clipboardItems = event.clipboardData.items
|
||||
? Array.from(event.clipboardData.items)
|
||||
@ -373,7 +379,7 @@ function clipboardFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
|
||||
.filter((file): file is File => Boolean(file));
|
||||
const seen = new Set<string>();
|
||||
return [...fromItems, ...clipboardFiles].filter((file) => {
|
||||
const key = `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`;
|
||||
const key = fileMetadataKey(file);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
@ -958,7 +964,20 @@ export function CustomAgentChatView({
|
||||
const nextAttachments = await Promise.all(
|
||||
files.map(clipboardFileToAttachment),
|
||||
);
|
||||
setAttachments((prev) => [...prev, ...nextAttachments]);
|
||||
setAttachments((prev) => {
|
||||
const known = new Set(
|
||||
prev
|
||||
.map((attachment) => attachment.dedupeKey)
|
||||
.filter((key): key is string => Boolean(key)),
|
||||
);
|
||||
const unique = nextAttachments.filter((attachment) => {
|
||||
if (!attachment.dedupeKey) return true;
|
||||
if (known.has(attachment.dedupeKey)) return false;
|
||||
known.add(attachment.dedupeKey);
|
||||
return true;
|
||||
});
|
||||
return unique.length > 0 ? [...prev, ...unique] : prev;
|
||||
});
|
||||
if (pastedText) {
|
||||
setDraft((prev) =>
|
||||
prev.slice(0, selectionStart) +
|
||||
|
||||
Reference in New Issue
Block a user