Repository: gradio-app/gradio
Pull request: Add copy all messages button to chatbot
## Description

Adds a copy all button (via a `show_copy_all_button` param) similar to gr.JSON and other components. I'm curious to know if we think this should look more unique to the other copy buttons in Chatbot 🤔 

~Having other messages with copy buttons will look confusing, and scrolling will look terrible if users have copy buttons as well (which I've asked about in Slack)~

We could use a clipboard icon: 

<img width="100" alt="Screenshot 2024-08-06 at 11 03 35" src="https://github.com/user-attachments/assets/67b7b810-b341-47aa-928a-551b0ef1c29c">

but then we're losing out on consistency between other components. 

Also, should default be true or false?

<img width="333" alt="Screenshot 2024-08-06 at 10 59 47" src="https://github.com/user-attachments/assets/32203dd9-fdb4-4d77-a1f9-8a06b3927a3c">


Test:

```
import gradio as gr

with gr.Blocks() as demo:
    gr.Chatbot(show_copy_all_button=True, show_copy_button=True, value=[["Hello World","Hey Gradio!"],["❤️","😍"],["🔥","🤗"]])

demo.launch()
```

Clipboard output:

```
user: Hello World

assistant: Hey Gradio!

user: ❤️

assistant: 😍

user: 🔥

assistant: 🤗
```

Closes: #7446 

## 🎯 PRs Should Target Issues

Before your create a PR, please check to see if there is [an existing issue](https://github.com/gradio-app/gradio/issues) for this change. If not, please create an issue before you create this PR, unless the fix is very small. 

Not adhering to this guideline will result in the PR being closed. 

## Tests

1. PRs will only be merged if tests pass on CI. To run the tests locally, please set up [your Gradio environment locally](https://github.com/gradio-app/gradio/blob/main/CONTRIBUTING.md) and run the tests: `bash scripts/run_all_tests.sh`

2. You may need to run the linters: `bash scripts/format_backend.sh` and `bash scripts/format_frontend.sh`
  


Problem statement:
Chatbot copy all messages
for gradio.Chatbot(...) create a 'show_copy_all_messages_button' parameter. It would copy the entire chat history onto clipboard.  This is a useful feature because copying all the messages manually can be difficult given the scrolling nature of the interface. Currently there are two parameters 'show_share_button' and 'show_copy_button' that are close but not quite right. Copying a single message is trivial and omits context, the majority of the time a user wants to share multiple messages to include context. The 'show_share_button' has the right idea and placement, but simply copying to clipboard would be superior to sharing to HugginFaces space. Thank you.

Hints from issue discussion:
Thanks @mberco-quandl for creating this issue. Yes we've heard versions of this issue quite a bit, and I think what you're describing makes sense. cc @dawoodkhan82!
I would love to see this enhancement added as well.

Commit message: Merge branch 'main' into chatbot-copy-msgs

Patch to review:
diff --git a/gradio/components/chatbot.py b/gradio/components/chatbot.py
index fd629c6cd5..74caf6ae3d 100644
--- a/gradio/components/chatbot.py
+++ b/gradio/components/chatbot.py
@@ -172,6 +172,7 @@ def __init__(
         likeable: bool = False,
         layout: Literal["panel", "bubble"] | None = None,
         placeholder: str | None = None,
+        show_copy_all_button=False,
     ):
         """
         Parameters:
@@ -202,6 +203,7 @@ def __init__(
             likeable: Whether the chat messages display a like or dislike button. Set automatically by the .like method but has to be present in the signature for it to show up in the config.
             layout: If "panel", will display the chatbot in a llm style layout. If "bubble", will display the chatbot with message bubbles, with the user and bot messages on alterating sides. Will default to "bubble".
             placeholder: a placeholder message to display in the chatbot when it is empty. Centered vertically and horizontally in the Chatbot. Supports Markdown and HTML. If None, no placeholder is displayed.
+            show_copy_all_button: If True, will show a copy all button that copies all chatbot messages to the clipboard.
         """
         self.likeable = likeable
         if type not in ["messages", "tuples"]:
@@ -227,6 +229,7 @@ def __init__(
         self.bubble_full_width = bubble_full_width
         self.line_breaks = line_breaks
         self.layout = layout
+        self.show_copy_all_button = show_copy_all_button
         super().__init__(
             label=label,
             every=every,
diff --git a/js/chatbot/Chatbot.test.ts b/js/chatbot/Chatbot.test.ts
index d803f6a552..20644faf61 100644
--- a/js/chatbot/Chatbot.test.ts
+++ b/js/chatbot/Chatbot.test.ts
@@ -1,5 +1,5 @@
-import { test, describe, assert, afterEach } from "vitest";
-import { cleanup, render } from "@gradio/tootils";
+import { test, describe, assert, afterEach, vi } from "vitest";
+import { cleanup, render, fireEvent } from "@gradio/tootils";
 import Chatbot from "./Index.svelte";
 import type { LoadingStatus } from "@gradio/statustracker";
 import type { FileData } from "@gradio/client";
@@ -204,4 +204,33 @@ describe("Chatbot", () => {
 		assert.isTrue(file_link[0].href.includes("titanic.csv"));
 		assert.isTrue(file_link[0].href.includes("titanic.csv"));
 	});
+
+	test("renders copy all messages button and copies all messages to clipboard", async () => {
+		// mock the clipboard API
+		const clipboard_write_text_mock = vi.fn().mockResolvedValue(undefined);
+
+		Object.defineProperty(navigator, "clipboard", {
+			value: { writeText: clipboard_write_text_mock },
+			configurable: true,
+			writable: true
+		});
+
+		const { getByLabelText } = await render(Chatbot, {
+			loading_status,
+			label: "chatbot",
+			value: [["user message one", "bot message one"]],
+			show_copy_all_button: true
+		});
+
+		const copy_button = getByLabelText("Copy conversation");
+
+		fireEvent.click(copy_button);
+
+		expect(clipboard_write_text_mock).toHaveBeenCalledWith(
+			expect.stringContaining("user: user message one")
+		);
+		expect(clipboard_write_text_mock).toHaveBeenCalledWith(
+			expect.stringContaining("assistant: bot message one")
+		);
+	});
 });
diff --git a/js/chatbot/Index.svelte b/js/chatbot/Index.svelte
index 5c146be761..85d7070727 100644
--- a/js/chatbot/Index.svelte
+++ b/js/chatbot/Index.svelte
@@ -11,12 +11,7 @@
 	import { Chat } from "@gradio/icons";
 	import type { FileData } from "@gradio/client";
 	import { StatusTracker } from "@gradio/statustracker";
-	import type {
-		Message,
-		TupleFormat,
-		MessageRole,
-		NormalisedMessage
-	} from "./types";
+	import type { Message, TupleFormat, NormalisedMessage } from "./types";
 
 	import { normalise_tuples, normalise_messages } from "./shared/utils";
 
@@ -34,6 +29,7 @@
 	export let show_share_button = false;
 	export let rtl = false;
 	export let show_copy_button = true;
+	export let show_copy_all_button = false;
 	export let sanitize_html = true;
 	export let bubble_full_width = true;
 	export let layout: "bubble" | "panel" = "bubble";
@@ -103,6 +99,7 @@
 			selectable={_selectable}
 			{likeable}
 			{show_share_button}
+			{show_copy_all_button}
 			value={_value}
 			{latex_delimiters}
 			{render_markdown}
diff --git a/js/chatbot/shared/ChatBot.svelte b/js/chatbot/shared/ChatBot.svelte
index 38a8d89f59..7de0341828 100644
--- a/js/chatbot/shared/ChatBot.svelte
+++ b/js/chatbot/shared/ChatBot.svelte
@@ -30,6 +30,7 @@
 	import Component from "./Component.svelte";
 	import LikeButtons from "./ButtonPanel.svelte";
 	import type { LoadedComponent } from "../../app/src/types";
+	import CopyAll from "./CopyAll.svelte";
 
 	export let _fetch: typeof fetch;
 	export let load_component: Gradio["load_component"];
@@ -80,6 +81,7 @@
 	export let selectable = false;
 	export let likeable = false;
 	export let show_share_button = false;
+	export let show_copy_all_button = false;
 	export let rtl = false;
 	export let show_copy_button = false;
 	export let avatar_images: [FileData | null, FileData | null] = [null, null];
@@ -275,6 +277,10 @@
 	</div>
 {/if}
 
+{#if show_copy_all_button}
+	<CopyAll {value} />
+{/if}
+
 <div
 	class={layout === "bubble" ? "bubble-wrap" : "panel-wrap"}
 	class:placeholder-container={value === null || value.length === 0}
diff --git a/js/chatbot/shared/CopyAll.svelte b/js/chatbot/shared/CopyAll.svelte
new file mode 100644
index 0000000000..d9e360013d
--- /dev/null
+++ b/js/chatbot/shared/CopyAll.svelte
@@ -0,0 +1,78 @@
+<script lang="ts">
+	import { onDestroy } from "svelte";
+	import { Copy, Check } from "@gradio/icons";
+	import type { NormalisedMessage } from "../types";
+
+	let copied = false;
+	export let value: NormalisedMessage[] | null;
+
+	let timer: NodeJS.Timeout;
+
+	function copy_feedback(): void {
+		copied = true;
+		if (timer) clearTimeout(timer);
+		timer = setTimeout(() => {
+			copied = false;
+		}, 1000);
+	}
+
+	const copy_conversation = (): void => {
+		if (value) {
+			const conversation_text = value
+				.map((message) => `${message.role}: ${message.content}`)
+				.join("\n\n");
+			navigator.clipboard.writeText(conversation_text).catch((err) => {
+				console.error("Failed to copy conversation: ", err);
+			});
+		}
+	};
+
+	async function handle_copy(): Promise<void> {
+		if ("clipboard" in navigator) {
+			copy_conversation();
+			copy_feedback();
+		}
+	}
+
+	onDestroy(() => {
+		if (timer) clearTimeout(timer);
+	});
+</script>
+
+<button
+	on:click={handle_copy}
+	title="Copy conversation"
+	class={copied ? "copied" : "copy-text"}
+	aria-label={copied ? "Copied conversation" : "Copy conversation"}
+>
+	{#if copied}
+		<Check />
+	{:else}
+		<Copy />
+	{/if}
+</button>
+
+<style>
+	button {
+		display: flex;
+		position: absolute;
+		top: var(--block-label-margin);
+		right: var(--block-label-margin);
+		align-items: center;
+		box-shadow: var(--shadow-drop);
+		border: 1px solid var(--border-color-primary);
+		border-top: none;
+		border-right: none;
+		border-radius: var(--block-label-right-radius);
+		background: var(--block-label-background-fill);
+		padding: var(--spacing-sm);
+		width: var(--size-6);
+		height: var(--size-6);
+		overflow: hidden;
+		color: var(--block-label-text-color);
+	}
+
+	button:hover {
+		color: var(--body-text-color);
+	}
+</style>
diff --git a/js/code/shared/Copy.svelte b/js/code/shared/Copy.svelte
index 04e32c6579..e8909fc1c5 100644
--- a/js/code/shared/Copy.svelte
+++ b/js/code/shared/Copy.svelte
@@ -29,9 +29,9 @@
 
 <button
 	on:click={handle_copy}
-	title="copy"
+	title="Copy message"
 	class:copied
-	aria-label={copied ? "Value copied" : "Copy value"}
+	aria-label={copied ? "Message copied" : "Copy Message"}
 >
 	{#if !copied}
 		<Copy />
diff --git a/test/components/test_chatbot.py b/test/components/test_chatbot.py
index 90b912c8c7..aee9241b67 100644
--- a/test/components/test_chatbot.py
+++ b/test/components/test_chatbot.py
@@ -58,6 +58,7 @@ def test_component_functions(self):
             "bubble_full_width": True,
             "line_breaks": True,
             "layout": None,
+            "show_copy_all_button": False,
         }
 
     def test_avatar_images_are_moved_to_cache(self):


Code review comments:
diff_hunk: @@ -0,0 +1,78 @@
+<script lang="ts">
+	import { onDestroy } from "svelte";
+	import { Copy, Check } from "@gradio/icons";
+	import type { NormalisedMessage } from "../types";
+
+	let copied = false;
+	export let value: NormalisedMessage[] | null;
+
+	let timer: NodeJS.Timeout;
+
+	function copy_feedback(): void {
+		copied = true;
+		if (timer) clearTimeout(timer);
+		timer = setTimeout(() => {
+			copied = false;
+		}, 1000);
+	}
+
+	const copy_conversation = (): void => {
+		if (value) {
+			const conversation_text = value
+				.map((message) => `${message.role}: ${message.content}`)
path: js/chatbot/shared/CopyAll.svelte
text: @user1:
This is going to render images/videos as `[Object Object]`. I think the file url should be used instead. Or maybe some placeholder like `Generated File`