diff --git a/web/src/components/chat/AssistantMessage.tsx b/web/src/components/chat/AssistantMessage.tsx index 9108da0f..f3cfc995 100644 --- a/web/src/components/chat/AssistantMessage.tsx +++ b/web/src/components/chat/AssistantMessage.tsx @@ -3,6 +3,7 @@ import { useAuiState, } from "@assistant-ui/react"; import { BrainIcon, ChevronDownIcon, RotateCcwIcon } from "lucide-react"; +import { useCallback, useRef } from "react"; import { MarkdownBlock } from "./MarkdownBlock"; import ToolRenderer from "./ToolRenderer"; import { @@ -13,6 +14,7 @@ import { import { useLoopRuntimeExtra, type TurnStats } from "@/useLoopRuntime"; import { cn } from "@/lib/utils"; import ErrorBoundary from "./ErrorBoundary"; +import MessageCopyButton from "./MessageCopyButton"; function extractTime(messageId: string | undefined): string { if (!messageId) return ""; @@ -49,7 +51,7 @@ function JsonBlock({ content }: { content: string }) { const formatted = JSON.stringify(parsed, null, 2); return (
-
+
s.message.status?.type === "running"); const isLast = useAuiState((s) => s.message.isLast); const showRetry = !isRunning && isLast && !textContent.startsWith("```bash"); + const contentRef = useRef(null); + const getPlainText = useCallback(() => textContent, [textContent]); const time = extractTime(messageId); // Per-turn stats render once per turn, on the LAST assistant message of the @@ -185,7 +189,10 @@ export default function AssistantMessage() { onOpenChange={setThinkingOpen} className="group/think my-1 w-full overflow-hidden rounded-md border border-gray-100 bg-gray-50/50" > - + {label} {running && ( @@ -237,17 +244,19 @@ export default function AssistantMessage() { ? Array.from(taskMap.values()).find((t) => t.tool_use_id === toolCallId) : undefined; return ( - - - +
+ + + +
); } default: @@ -281,15 +290,22 @@ export default function AssistantMessage() { /> {/* Content */} -
+
{children}
{/* Footer: time + retry + per-turn stats (stats only on the last assistant message of a live turn; retry only when completed) */} - {(time || showRetry || turnStats) && ( -
+ {(time || showRetry || turnStats || (!isRunning && textContent)) && ( +
{time && {time}} + {!isRunning && textContent && ( + + )} {showRetry && ( + + {label} + + + ); +} diff --git a/web/src/components/chat/UserMessage.tsx b/web/src/components/chat/UserMessage.tsx index 78d94c1a..1d12c2c8 100644 --- a/web/src/components/chat/UserMessage.tsx +++ b/web/src/components/chat/UserMessage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { MessagePrimitive, useAuiState, @@ -6,6 +6,7 @@ import { import { MarkdownBlock } from "./MarkdownBlock"; import { ChevronDownIcon, ChevronUpIcon, FileText, ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; +import MessageCopyButton from "./MessageCopyButton"; function extractTime(messageId: string | undefined): string { if (!messageId) return ""; @@ -54,11 +55,22 @@ function FileCard({ filePath, content }: { filePath: string; content: string }) export default function UserMessage() { const messageId = useAuiState((s) => s.message.id); + const textContent = useAuiState((s) => { + const parts = s.message.content; + if (!Array.isArray(parts)) return ""; + return parts + .filter((p: { type: string }) => p.type === "text") + .map((p: { text?: string }) => p.text ?? "") + .join(""); + }); const time = extractTime(messageId); const [expanded, setExpanded] = useState(false); const [needsTruncation, setNeedsTruncation] = useState(false); + const contentRef = useRef(null); + const getPlainText = useCallback(() => textContent, [textContent]); const measureRef = (el: HTMLDivElement | null) => { + contentRef.current = el; if (!el) return; setNeedsTruncation(el.scrollHeight > 72); }; @@ -96,7 +108,17 @@ export default function UserMessage() { )} - {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, + }; +}