Skip to content

Commit fb007d6

Browse files
committed
feat(app): copy buttons for assistant messages and code blocks
1 parent 4ca088e commit fb007d6

File tree

8 files changed

+282
-9
lines changed

8 files changed

+282
-9
lines changed

.opencode/bun.lock

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.opencode/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"dependencies": {
3+
"@opencode-ai/plugin": "0.0.0-dev-202601211610"
4+
}
5+
}

packages/ui/src/components/markdown.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,35 @@
111111
border: 0.5px solid var(--border-weak-base);
112112
}
113113

114+
[data-component="markdown-code"] {
115+
position: relative;
116+
}
117+
118+
[data-slot="markdown-copy-button"] {
119+
position: absolute;
120+
top: 8px;
121+
right: 8px;
122+
opacity: 0;
123+
transition: opacity 0.15s ease;
124+
z-index: 1;
125+
}
126+
127+
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {
128+
opacity: 1;
129+
}
130+
131+
[data-slot="markdown-copy-button"] [data-slot="check-icon"] {
132+
display: none;
133+
}
134+
135+
[data-slot="markdown-copy-button"][data-copied="true"] [data-slot="copy-icon"] {
136+
display: none;
137+
}
138+
139+
[data-slot="markdown-copy-button"][data-copied="true"] [data-slot="check-icon"] {
140+
display: inline-flex;
141+
}
142+
114143
pre {
115144
margin-top: 2rem;
116145
margin-bottom: 2rem;

packages/ui/src/components/markdown.tsx

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { useMarked } from "../context/marked"
2+
import { useI18n } from "../context/i18n"
23
import DOMPurify from "dompurify"
34
import { checksum } from "@opencode-ai/util/encode"
4-
import { ComponentProps, createResource, splitProps } from "solid-js"
5+
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
56
import { isServer } from "solid-js/web"
67

78
type Entry = {
@@ -32,11 +33,120 @@ const config = {
3233
FORBID_CONTENTS: ["style", "script"],
3334
}
3435

36+
const iconPaths = {
37+
copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
38+
check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
39+
}
40+
3541
function sanitize(html: string) {
3642
if (!DOMPurify.isSupported) return ""
3743
return DOMPurify.sanitize(html, config)
3844
}
3945

46+
type CopyLabels = {
47+
copy: string
48+
copied: string
49+
}
50+
51+
function createIcon(path: string, slot: string) {
52+
const icon = document.createElement("div")
53+
icon.setAttribute("data-component", "icon")
54+
icon.setAttribute("data-size", "small")
55+
icon.setAttribute("data-slot", slot)
56+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
57+
svg.setAttribute("data-slot", "icon-svg")
58+
svg.setAttribute("fill", "none")
59+
svg.setAttribute("viewBox", "0 0 20 20")
60+
svg.setAttribute("aria-hidden", "true")
61+
svg.innerHTML = path
62+
icon.appendChild(svg)
63+
return icon
64+
}
65+
66+
function createCopyButton(labels: CopyLabels) {
67+
const button = document.createElement("button")
68+
button.type = "button"
69+
button.setAttribute("data-component", "icon-button")
70+
button.setAttribute("data-variant", "secondary")
71+
button.setAttribute("data-size", "normal")
72+
button.setAttribute("data-slot", "markdown-copy-button")
73+
button.setAttribute("aria-label", labels.copy)
74+
button.setAttribute("title", labels.copy)
75+
button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
76+
button.appendChild(createIcon(iconPaths.check, "check-icon"))
77+
return button
78+
}
79+
80+
function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) {
81+
if (copied) {
82+
button.setAttribute("data-copied", "true")
83+
button.setAttribute("aria-label", labels.copied)
84+
button.setAttribute("title", labels.copied)
85+
return
86+
}
87+
button.removeAttribute("data-copied")
88+
button.setAttribute("aria-label", labels.copy)
89+
button.setAttribute("title", labels.copy)
90+
}
91+
92+
function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
93+
const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
94+
95+
const updateLabel = (button: HTMLButtonElement) => {
96+
const copied = button.getAttribute("data-copied") === "true"
97+
setCopyState(button, labels, copied)
98+
}
99+
100+
const ensureWrapper = (block: HTMLPreElement) => {
101+
const parent = block.parentElement
102+
if (!parent) return
103+
const wrapped = parent.getAttribute("data-component") === "markdown-code"
104+
if (wrapped) return
105+
const wrapper = document.createElement("div")
106+
wrapper.setAttribute("data-component", "markdown-code")
107+
parent.replaceChild(wrapper, block)
108+
wrapper.appendChild(block)
109+
wrapper.appendChild(createCopyButton(labels))
110+
}
111+
112+
const handleClick = async (event: MouseEvent) => {
113+
const target = event.target
114+
if (!(target instanceof Element)) return
115+
const button = target.closest('[data-slot="markdown-copy-button"]')
116+
if (!(button instanceof HTMLButtonElement)) return
117+
const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
118+
const content = code?.textContent ?? ""
119+
if (!content) return
120+
const clipboard = navigator?.clipboard
121+
if (!clipboard) return
122+
await clipboard.writeText(content)
123+
setCopyState(button, labels, true)
124+
const existing = timeouts.get(button)
125+
if (existing) clearTimeout(existing)
126+
const timeout = setTimeout(() => setCopyState(button, labels, false), 2000)
127+
timeouts.set(button, timeout)
128+
}
129+
130+
const blocks = Array.from(root.querySelectorAll("pre"))
131+
for (const block of blocks) {
132+
ensureWrapper(block)
133+
}
134+
135+
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
136+
for (const button of buttons) {
137+
if (button instanceof HTMLButtonElement) updateLabel(button)
138+
}
139+
140+
root.addEventListener("click", handleClick)
141+
142+
return () => {
143+
root.removeEventListener("click", handleClick)
144+
for (const timeout of timeouts.values()) {
145+
clearTimeout(timeout)
146+
}
147+
}
148+
}
149+
40150
function touch(key: string, value: Entry) {
41151
cache.delete(key)
42152
cache.set(key, value)
@@ -58,6 +168,8 @@ export function Markdown(
58168
) {
59169
const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
60170
const marked = useMarked()
171+
const i18n = useI18n()
172+
const [root, setRoot] = createSignal<HTMLDivElement>()
61173
const [html] = createResource(
62174
() => local.text,
63175
async (markdown) => {
@@ -81,6 +193,19 @@ export function Markdown(
81193
},
82194
{ initialValue: "" },
83195
)
196+
197+
createEffect(() => {
198+
const container = root()
199+
const content = html()
200+
if (!container) return
201+
if (!content) return
202+
if (isServer) return
203+
const cleanup = setupCodeCopy(container, {
204+
copy: i18n.t("ui.message.copy"),
205+
copied: i18n.t("ui.message.copied"),
206+
})
207+
onCleanup(cleanup)
208+
})
84209
return (
85210
<div
86211
data-component="markdown"
@@ -89,6 +214,7 @@ export function Markdown(
89214
[local.class ?? ""]: !!local.class,
90215
}}
91216
innerHTML={html.latest}
217+
ref={setRoot}
92218
{...others}
93219
/>
94220
)

packages/ui/src/components/message-part.css

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,26 @@
106106
[data-component="text-part"] {
107107
width: 100%;
108108

109-
[data-component="markdown"] {
109+
[data-slot="text-part-body"] {
110+
position: relative;
110111
margin-top: 32px;
112+
}
113+
114+
[data-slot="text-part-copy-wrapper"] {
115+
position: absolute;
116+
top: 8px;
117+
right: 8px;
118+
opacity: 0;
119+
transition: opacity 0.15s ease;
120+
z-index: 1;
121+
}
122+
123+
[data-slot="text-part-body"]:hover [data-slot="text-part-copy-wrapper"] {
124+
opacity: 1;
125+
}
126+
127+
[data-component="markdown"] {
128+
margin-top: 0;
111129
font-size: var(--font-size-base);
112130
}
113131
}

packages/ui/src/components/message-part.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -673,14 +673,40 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
673673

674674
PART_MAPPING["text"] = function TextPartDisplay(props) {
675675
const data = useData()
676+
const i18n = useI18n()
676677
const part = props.part as TextPart
677678
const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory)
678679
const throttledText = createThrottledValue(displayText)
680+
const [copied, setCopied] = createSignal(false)
681+
682+
const handleCopy = async () => {
683+
const content = displayText()
684+
if (!content) return
685+
await navigator.clipboard.writeText(content)
686+
setCopied(true)
687+
setTimeout(() => setCopied(false), 2000)
688+
}
679689

680690
return (
681691
<Show when={throttledText()}>
682692
<div data-component="text-part">
683-
<Markdown text={throttledText()} cacheKey={part.id} />
693+
<div data-slot="text-part-body">
694+
<Markdown text={throttledText()} cacheKey={part.id} />
695+
<div data-slot="text-part-copy-wrapper">
696+
<Tooltip
697+
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
698+
placement="top"
699+
gutter={8}
700+
>
701+
<IconButton
702+
icon={copied() ? "check" : "copy"}
703+
variant="secondary"
704+
onClick={handleCopy}
705+
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
706+
/>
707+
</Tooltip>
708+
</div>
709+
</div>
684710
</div>
685711
</Show>
686712
)

packages/ui/src/components/session-turn.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,24 @@
209209
gap: 4px;
210210
align-self: stretch;
211211

212+
[data-slot="session-turn-response"] {
213+
position: relative;
214+
width: 100%;
215+
}
216+
217+
[data-slot="session-turn-response-copy-wrapper"] {
218+
position: absolute;
219+
top: 8px;
220+
right: 8px;
221+
opacity: 0;
222+
transition: opacity 0.15s ease;
223+
z-index: 1;
224+
}
225+
226+
[data-slot="session-turn-response"]:hover [data-slot="session-turn-response-copy-wrapper"] {
227+
opacity: 1;
228+
}
229+
212230
p {
213231
font-size: var(--font-size-base);
214232
line-height: var(--line-height-x-large);

packages/ui/src/components/session-turn.tsx

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ import { Accordion } from "./accordion"
2222
import { StickyAccordionHeader } from "./sticky-accordion-header"
2323
import { FileIcon } from "./file-icon"
2424
import { Icon } from "./icon"
25+
import { IconButton } from "./icon-button"
2526
import { Card } from "./card"
2627
import { Dynamic } from "solid-js/web"
2728
import { Button } from "./button"
2829
import { Spinner } from "./spinner"
30+
import { Tooltip } from "./tooltip"
2931
import { createStore } from "solid-js/store"
3032
import { DateTime, DurationUnit, Interval } from "luxon"
3133
import { createAutoScroll } from "../hooks"
@@ -356,6 +358,16 @@ export function SessionTurn(
356358
const hasDiffs = createMemo(() => messageDiffs().length > 0)
357359
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
358360

361+
const [copied, setCopied] = createSignal(false)
362+
363+
const handleCopy = async () => {
364+
const content = response() ?? ""
365+
if (!content) return
366+
await navigator.clipboard.writeText(content)
367+
setCopied(true)
368+
setTimeout(() => setCopied(false), 2000)
369+
}
370+
359371
const [rootRef, setRootRef] = createSignal<HTMLDivElement | undefined>()
360372
const [stickyRef, setStickyRef] = createSignal<HTMLDivElement | undefined>()
361373

@@ -597,12 +609,33 @@ export function SessionTurn(
597609
<div data-slot="session-turn-summary-section">
598610
<div data-slot="session-turn-summary-header">
599611
<h2 data-slot="session-turn-summary-title">{i18n.t("ui.sessionTurn.summary.response")}</h2>
600-
<Markdown
601-
data-slot="session-turn-markdown"
602-
data-diffs={hasDiffs()}
603-
text={response() ?? ""}
604-
cacheKey={responsePartId()}
605-
/>
612+
<div data-slot="session-turn-response">
613+
<Markdown
614+
data-slot="session-turn-markdown"
615+
data-diffs={hasDiffs()}
616+
text={response() ?? ""}
617+
cacheKey={responsePartId()}
618+
/>
619+
<Show when={response()}>
620+
<div data-slot="session-turn-response-copy-wrapper">
621+
<Tooltip
622+
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
623+
placement="top"
624+
gutter={8}
625+
>
626+
<IconButton
627+
icon={copied() ? "check" : "copy"}
628+
variant="secondary"
629+
onClick={(event) => {
630+
event.stopPropagation()
631+
handleCopy()
632+
}}
633+
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
634+
/>
635+
</Tooltip>
636+
</div>
637+
</Show>
638+
</div>
606639
</div>
607640
<Accordion
608641
data-slot="session-turn-accordion"

0 commit comments

Comments
 (0)