)}
- {time &&
{time}
}
+ {(time || textContent) && (
+
+ {textContent && (
+
+ )}
+ {time && {time}}
+
+ )}
);
}
diff --git a/web/src/index.css b/web/src/index.css
index 335ca75d..a0d085de 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -453,3 +453,8 @@ html, body {
font: inherit;
}
}
+
+/* Keep the latest assistant message copy action visible without affecting older messages. */
+[data-role="assistant"]:not(:has(~ [data-role="assistant"])) .aui-msg-copy {
+ opacity: 1;
+}
diff --git a/web/src/lib/clipboard.ts b/web/src/lib/clipboard.ts
new file mode 100644
index 00000000..b712e0dd
--- /dev/null
+++ b/web/src/lib/clipboard.ts
@@ -0,0 +1,79 @@
+export type ClipboardWriteMode = "rich" | "plain";
+
+export function buildClipboardHtml(root: HTMLElement): string {
+ const clone = root.cloneNode(true) as HTMLElement;
+ clone.querySelectorAll("[data-copy-ignore]").forEach((node) => node.remove());
+ return clone.innerHTML;
+}
+
+function execCommandCopy(text: string): boolean {
+ if (typeof document === "undefined" || !document.body) return false;
+
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.setAttribute("readonly", "");
+ textarea.style.position = "fixed";
+ textarea.style.left = "-9999px";
+ textarea.style.top = "-9999px";
+ textarea.style.opacity = "0";
+
+ document.body.appendChild(textarea);
+ textarea.select();
+ textarea.setSelectionRange(0, textarea.value.length);
+
+ try {
+ return document.execCommand("copy");
+ } catch {
+ return false;
+ } finally {
+ textarea.remove();
+ }
+}
+
+export async function writePlainTextClipboard(text: string): Promise
{
+ const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
+
+ if (clipboard?.writeText) {
+ try {
+ await clipboard.writeText(text);
+ return;
+ } catch {
+ // Fall back to execCommand for insecure HTTP contexts and denied writes.
+ }
+ }
+
+ if (!execCommandCopy(text)) {
+ throw new Error("Unable to write text to clipboard");
+ }
+}
+
+export async function writeRichClipboard({
+ plainText,
+ html,
+}: {
+ plainText: string;
+ html: string;
+}): Promise {
+ const clipboard = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
+
+ if (
+ html &&
+ typeof ClipboardItem !== "undefined" &&
+ clipboard?.write
+ ) {
+ try {
+ await clipboard.write([
+ new ClipboardItem({
+ "text/plain": new Blob([plainText], { type: "text/plain" }),
+ "text/html": new Blob([html], { type: "text/html" }),
+ }),
+ ]);
+ return "rich";
+ } catch {
+ // Fall back to the original markdown/plain text.
+ }
+ }
+
+ await writePlainTextClipboard(plainText);
+ return "plain";
+}
diff --git a/web/src/lib/useClipboard.ts b/web/src/lib/useClipboard.ts
new file mode 100644
index 00000000..39016a00
--- /dev/null
+++ b/web/src/lib/useClipboard.ts
@@ -0,0 +1,25 @@
+import { useCallback, useState } from "react";
+import { writePlainTextClipboard } from "./clipboard";
+
+export function useClipboard(duration = 1500) {
+ const [copied, setCopied] = useState(false);
+
+ const copy = useCallback(async (text: string) => {
+ if (!text || copied) return false;
+ try {
+ await writePlainTextClipboard(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), duration);
+ return true;
+ } catch {
+ return false;
+ }
+ }, [copied, duration]);
+
+ return {
+ copied,
+ isCopied: copied,
+ copy,
+ copyToClipboard: copy,
+ };
+}