From fb007d6bab1258ac21f6d9b7efc4b7149499cf8f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 22 Jan 2026 06:29:33 -0600 Subject: [PATCH] feat(app): copy buttons for assistant messages and code blocks --- .opencode/bun.lock | 18 +++ .opencode/package.json | 5 + packages/ui/src/components/markdown.css | 29 +++++ packages/ui/src/components/markdown.tsx | 128 +++++++++++++++++++- packages/ui/src/components/message-part.css | 20 ++- packages/ui/src/components/message-part.tsx | 28 ++++- packages/ui/src/components/session-turn.css | 18 +++ packages/ui/src/components/session-turn.tsx | 45 ++++++- 8 files changed, 282 insertions(+), 9 deletions(-) create mode 100644 .opencode/bun.lock create mode 100644 .opencode/package.json diff --git a/.opencode/bun.lock b/.opencode/bun.lock new file mode 100644 index 0000000000..e78ccc941b --- /dev/null +++ b/.opencode/bun.lock @@ -0,0 +1,18 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "dependencies": { + "@opencode-ai/plugin": "0.0.0-dev-202601211610", + }, + }, + }, + "packages": { + "@opencode-ai/plugin": ["@opencode-ai/plugin@0.0.0-dev-202601211610", "", { "dependencies": { "@opencode-ai/sdk": "0.0.0-dev-202601211610", "zod": "4.1.8" } }, "sha512-7yBM53Xr7B7fsJlR0kItHi7Rubqyasruj+A167aaXImO3lNczIH9IMizAU+f1O73u0fJYqvs+BGaU/eXOHdaRA=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@0.0.0-dev-202601211610", "", {}, "sha512-p6hg+eZqz+kVIZqOQYhQwnRfW9s0Fojqb9f+i//cZ8a0Vj5RBwcySkQDA8CwSK1gVWuNwHfy8RLrjGxdxAaS5g=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/.opencode/package.json b/.opencode/package.json new file mode 100644 index 0000000000..deaf49716a --- /dev/null +++ b/.opencode/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@opencode-ai/plugin": "0.0.0-dev-202601211610" + } +} \ No newline at end of file diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 1cbcf6f977..a30510a8d1 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -111,6 +111,35 @@ border: 0.5px solid var(--border-weak-base); } + [data-component="markdown-code"] { + position: relative; + } + + [data-slot="markdown-copy-button"] { + position: absolute; + top: 8px; + right: 8px; + opacity: 0; + transition: opacity 0.15s ease; + z-index: 1; + } + + [data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] { + opacity: 1; + } + + [data-slot="markdown-copy-button"] [data-slot="check-icon"] { + display: none; + } + + [data-slot="markdown-copy-button"][data-copied="true"] [data-slot="copy-icon"] { + display: none; + } + + [data-slot="markdown-copy-button"][data-copied="true"] [data-slot="check-icon"] { + display: inline-flex; + } + pre { margin-top: 2rem; margin-bottom: 2rem; diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 3aefe04da3..f7a1ec16f0 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,7 +1,8 @@ import { useMarked } from "../context/marked" +import { useI18n } from "../context/i18n" import DOMPurify from "dompurify" import { checksum } from "@opencode-ai/util/encode" -import { ComponentProps, createResource, splitProps } from "solid-js" +import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js" import { isServer } from "solid-js/web" type Entry = { @@ -32,11 +33,120 @@ const config = { FORBID_CONTENTS: ["style", "script"], } +const iconPaths = { + copy: '', + check: '', +} + function sanitize(html: string) { if (!DOMPurify.isSupported) return "" return DOMPurify.sanitize(html, config) } +type CopyLabels = { + copy: string + copied: string +} + +function createIcon(path: string, slot: string) { + const icon = document.createElement("div") + icon.setAttribute("data-component", "icon") + icon.setAttribute("data-size", "small") + icon.setAttribute("data-slot", slot) + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg") + svg.setAttribute("data-slot", "icon-svg") + svg.setAttribute("fill", "none") + svg.setAttribute("viewBox", "0 0 20 20") + svg.setAttribute("aria-hidden", "true") + svg.innerHTML = path + icon.appendChild(svg) + return icon +} + +function createCopyButton(labels: CopyLabels) { + const button = document.createElement("button") + button.type = "button" + button.setAttribute("data-component", "icon-button") + button.setAttribute("data-variant", "secondary") + button.setAttribute("data-size", "normal") + button.setAttribute("data-slot", "markdown-copy-button") + button.setAttribute("aria-label", labels.copy) + button.setAttribute("title", labels.copy) + button.appendChild(createIcon(iconPaths.copy, "copy-icon")) + button.appendChild(createIcon(iconPaths.check, "check-icon")) + return button +} + +function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) { + if (copied) { + button.setAttribute("data-copied", "true") + button.setAttribute("aria-label", labels.copied) + button.setAttribute("title", labels.copied) + return + } + button.removeAttribute("data-copied") + button.setAttribute("aria-label", labels.copy) + button.setAttribute("title", labels.copy) +} + +function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { + const timeouts = new Map>() + + const updateLabel = (button: HTMLButtonElement) => { + const copied = button.getAttribute("data-copied") === "true" + setCopyState(button, labels, copied) + } + + const ensureWrapper = (block: HTMLPreElement) => { + const parent = block.parentElement + if (!parent) return + const wrapped = parent.getAttribute("data-component") === "markdown-code" + if (wrapped) return + const wrapper = document.createElement("div") + wrapper.setAttribute("data-component", "markdown-code") + parent.replaceChild(wrapper, block) + wrapper.appendChild(block) + wrapper.appendChild(createCopyButton(labels)) + } + + const handleClick = async (event: MouseEvent) => { + const target = event.target + if (!(target instanceof Element)) return + const button = target.closest('[data-slot="markdown-copy-button"]') + if (!(button instanceof HTMLButtonElement)) return + const code = button.closest('[data-component="markdown-code"]')?.querySelector("code") + const content = code?.textContent ?? "" + if (!content) return + const clipboard = navigator?.clipboard + if (!clipboard) return + await clipboard.writeText(content) + setCopyState(button, labels, true) + const existing = timeouts.get(button) + if (existing) clearTimeout(existing) + const timeout = setTimeout(() => setCopyState(button, labels, false), 2000) + timeouts.set(button, timeout) + } + + const blocks = Array.from(root.querySelectorAll("pre")) + for (const block of blocks) { + ensureWrapper(block) + } + + const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]')) + for (const button of buttons) { + if (button instanceof HTMLButtonElement) updateLabel(button) + } + + root.addEventListener("click", handleClick) + + return () => { + root.removeEventListener("click", handleClick) + for (const timeout of timeouts.values()) { + clearTimeout(timeout) + } + } +} + function touch(key: string, value: Entry) { cache.delete(key) cache.set(key, value) @@ -58,6 +168,8 @@ export function Markdown( ) { const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"]) const marked = useMarked() + const i18n = useI18n() + const [root, setRoot] = createSignal() const [html] = createResource( () => local.text, async (markdown) => { @@ -81,6 +193,19 @@ export function Markdown( }, { initialValue: "" }, ) + + createEffect(() => { + const container = root() + const content = html() + if (!container) return + if (!content) return + if (isServer) return + const cleanup = setupCodeCopy(container, { + copy: i18n.t("ui.message.copy"), + copied: i18n.t("ui.message.copied"), + }) + onCleanup(cleanup) + }) return ( ) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 07f9aa3120..d47a3a79b5 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -106,8 +106,26 @@ [data-component="text-part"] { width: 100%; - [data-component="markdown"] { + [data-slot="text-part-body"] { + position: relative; margin-top: 32px; + } + + [data-slot="text-part-copy-wrapper"] { + position: absolute; + top: 8px; + right: 8px; + opacity: 0; + transition: opacity 0.15s ease; + z-index: 1; + } + + [data-slot="text-part-body"]:hover [data-slot="text-part-copy-wrapper"] { + opacity: 1; + } + + [data-component="markdown"] { + margin-top: 0; font-size: var(--font-size-base); } } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index d639c52247..76e88d353d 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -673,14 +673,40 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { PART_MAPPING["text"] = function TextPartDisplay(props) { const data = useData() + const i18n = useI18n() const part = props.part as TextPart const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory) const throttledText = createThrottledValue(displayText) + const [copied, setCopied] = createSignal(false) + + const handleCopy = async () => { + const content = displayText() + if (!content) return + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } return ( - + + + + + + + + ) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 034d302470..8ff6be594b 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -209,6 +209,24 @@ gap: 4px; align-self: stretch; + [data-slot="session-turn-response"] { + position: relative; + width: 100%; + } + + [data-slot="session-turn-response-copy-wrapper"] { + position: absolute; + top: 8px; + right: 8px; + opacity: 0; + transition: opacity 0.15s ease; + z-index: 1; + } + + [data-slot="session-turn-response"]:hover [data-slot="session-turn-response-copy-wrapper"] { + opacity: 1; + } + p { font-size: var(--font-size-base); line-height: var(--line-height-x-large); diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 1cd2104994..a8aa8324be 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -22,10 +22,12 @@ import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { FileIcon } from "./file-icon" import { Icon } from "./icon" +import { IconButton } from "./icon-button" import { Card } from "./card" import { Dynamic } from "solid-js/web" import { Button } from "./button" import { Spinner } from "./spinner" +import { Tooltip } from "./tooltip" import { createStore } from "solid-js/store" import { DateTime, DurationUnit, Interval } from "luxon" import { createAutoScroll } from "../hooks" @@ -356,6 +358,16 @@ export function SessionTurn( const hasDiffs = createMemo(() => messageDiffs().length > 0) const hideResponsePart = createMemo(() => !working() && !!responsePartId()) + const [copied, setCopied] = createSignal(false) + + const handleCopy = async () => { + const content = response() ?? "" + if (!content) return + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + const [rootRef, setRootRef] = createSignal() const [stickyRef, setStickyRef] = createSignal() @@ -597,12 +609,33 @@ export function SessionTurn( {i18n.t("ui.sessionTurn.summary.response")} - + + + + + + { + event.stopPropagation() + handleCopy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} + /> + + + +