feat(app): copy buttons for assistant messages and code blocks
This commit is contained in:
18
.opencode/bun.lock
Normal file
18
.opencode/bun.lock
Normal file
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
5
.opencode/package.json
Normal file
5
.opencode/package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@opencode-ai/plugin": "0.0.0-dev-202601211610"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,35 @@
|
|||||||
border: 0.5px solid var(--border-weak-base);
|
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 {
|
pre {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMarked } from "../context/marked"
|
import { useMarked } from "../context/marked"
|
||||||
|
import { useI18n } from "../context/i18n"
|
||||||
import DOMPurify from "dompurify"
|
import DOMPurify from "dompurify"
|
||||||
import { checksum } from "@opencode-ai/util/encode"
|
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"
|
import { isServer } from "solid-js/web"
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
@@ -32,11 +33,120 @@ const config = {
|
|||||||
FORBID_CONTENTS: ["style", "script"],
|
FORBID_CONTENTS: ["style", "script"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const iconPaths = {
|
||||||
|
copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
|
||||||
|
check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
|
||||||
|
}
|
||||||
|
|
||||||
function sanitize(html: string) {
|
function sanitize(html: string) {
|
||||||
if (!DOMPurify.isSupported) return ""
|
if (!DOMPurify.isSupported) return ""
|
||||||
return DOMPurify.sanitize(html, config)
|
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<HTMLButtonElement, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
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) {
|
function touch(key: string, value: Entry) {
|
||||||
cache.delete(key)
|
cache.delete(key)
|
||||||
cache.set(key, value)
|
cache.set(key, value)
|
||||||
@@ -58,6 +168,8 @@ export function Markdown(
|
|||||||
) {
|
) {
|
||||||
const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
|
const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
|
||||||
const marked = useMarked()
|
const marked = useMarked()
|
||||||
|
const i18n = useI18n()
|
||||||
|
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||||
const [html] = createResource(
|
const [html] = createResource(
|
||||||
() => local.text,
|
() => local.text,
|
||||||
async (markdown) => {
|
async (markdown) => {
|
||||||
@@ -81,6 +193,19 @@ export function Markdown(
|
|||||||
},
|
},
|
||||||
{ initialValue: "" },
|
{ 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-component="markdown"
|
data-component="markdown"
|
||||||
@@ -89,6 +214,7 @@ export function Markdown(
|
|||||||
[local.class ?? ""]: !!local.class,
|
[local.class ?? ""]: !!local.class,
|
||||||
}}
|
}}
|
||||||
innerHTML={html.latest}
|
innerHTML={html.latest}
|
||||||
|
ref={setRoot}
|
||||||
{...others}
|
{...others}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -106,8 +106,26 @@
|
|||||||
[data-component="text-part"] {
|
[data-component="text-part"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
[data-component="markdown"] {
|
[data-slot="text-part-body"] {
|
||||||
|
position: relative;
|
||||||
margin-top: 32px;
|
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);
|
font-size: var(--font-size-base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -673,14 +673,40 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
|||||||
|
|
||||||
PART_MAPPING["text"] = function TextPartDisplay(props) {
|
PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||||
const data = useData()
|
const data = useData()
|
||||||
|
const i18n = useI18n()
|
||||||
const part = props.part as TextPart
|
const part = props.part as TextPart
|
||||||
const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory)
|
const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory)
|
||||||
const throttledText = createThrottledValue(displayText)
|
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 (
|
return (
|
||||||
<Show when={throttledText()}>
|
<Show when={throttledText()}>
|
||||||
<div data-component="text-part">
|
<div data-component="text-part">
|
||||||
<Markdown text={throttledText()} cacheKey={part.id} />
|
<div data-slot="text-part-body">
|
||||||
|
<Markdown text={throttledText()} cacheKey={part.id} />
|
||||||
|
<div data-slot="text-part-copy-wrapper">
|
||||||
|
<Tooltip
|
||||||
|
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
||||||
|
placement="top"
|
||||||
|
gutter={8}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon={copied() ? "check" : "copy"}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleCopy}
|
||||||
|
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -209,6 +209,24 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-self: stretch;
|
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 {
|
p {
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
line-height: var(--line-height-x-large);
|
line-height: var(--line-height-x-large);
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ import { Accordion } from "./accordion"
|
|||||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||||
import { FileIcon } from "./file-icon"
|
import { FileIcon } from "./file-icon"
|
||||||
import { Icon } from "./icon"
|
import { Icon } from "./icon"
|
||||||
|
import { IconButton } from "./icon-button"
|
||||||
import { Card } from "./card"
|
import { Card } from "./card"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import { Button } from "./button"
|
import { Button } from "./button"
|
||||||
import { Spinner } from "./spinner"
|
import { Spinner } from "./spinner"
|
||||||
|
import { Tooltip } from "./tooltip"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { DateTime, DurationUnit, Interval } from "luxon"
|
import { DateTime, DurationUnit, Interval } from "luxon"
|
||||||
import { createAutoScroll } from "../hooks"
|
import { createAutoScroll } from "../hooks"
|
||||||
@@ -356,6 +358,16 @@ export function SessionTurn(
|
|||||||
const hasDiffs = createMemo(() => messageDiffs().length > 0)
|
const hasDiffs = createMemo(() => messageDiffs().length > 0)
|
||||||
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
|
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<HTMLDivElement | undefined>()
|
const [rootRef, setRootRef] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [stickyRef, setStickyRef] = createSignal<HTMLDivElement | undefined>()
|
const [stickyRef, setStickyRef] = createSignal<HTMLDivElement | undefined>()
|
||||||
|
|
||||||
@@ -597,12 +609,33 @@ export function SessionTurn(
|
|||||||
<div data-slot="session-turn-summary-section">
|
<div data-slot="session-turn-summary-section">
|
||||||
<div data-slot="session-turn-summary-header">
|
<div data-slot="session-turn-summary-header">
|
||||||
<h2 data-slot="session-turn-summary-title">{i18n.t("ui.sessionTurn.summary.response")}</h2>
|
<h2 data-slot="session-turn-summary-title">{i18n.t("ui.sessionTurn.summary.response")}</h2>
|
||||||
<Markdown
|
<div data-slot="session-turn-response">
|
||||||
data-slot="session-turn-markdown"
|
<Markdown
|
||||||
data-diffs={hasDiffs()}
|
data-slot="session-turn-markdown"
|
||||||
text={response() ?? ""}
|
data-diffs={hasDiffs()}
|
||||||
cacheKey={responsePartId()}
|
text={response() ?? ""}
|
||||||
/>
|
cacheKey={responsePartId()}
|
||||||
|
/>
|
||||||
|
<Show when={response()}>
|
||||||
|
<div data-slot="session-turn-response-copy-wrapper">
|
||||||
|
<Tooltip
|
||||||
|
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
||||||
|
placement="top"
|
||||||
|
gutter={8}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon={copied() ? "check" : "copy"}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
handleCopy()
|
||||||
|
}}
|
||||||
|
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Accordion
|
<Accordion
|
||||||
data-slot="session-turn-accordion"
|
data-slot="session-turn-accordion"
|
||||||
|
|||||||
Reference in New Issue
Block a user