Răsfoiți Sursa

feat(app): copy buttons for assistant messages and code blocks

Adam 1 lună în urmă
părinte
comite
fb007d6bab

+ 18 - 0
.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/[email protected]", "", { "dependencies": { "@opencode-ai/sdk": "0.0.0-dev-202601211610", "zod": "4.1.8" } }, "sha512-7yBM53Xr7B7fsJlR0kItHi7Rubqyasruj+A167aaXImO3lNczIH9IMizAU+f1O73u0fJYqvs+BGaU/eXOHdaRA=="],
+
+    "@opencode-ai/sdk": ["@opencode-ai/[email protected]", "", {}, "sha512-p6hg+eZqz+kVIZqOQYhQwnRfW9s0Fojqb9f+i//cZ8a0Vj5RBwcySkQDA8CwSK1gVWuNwHfy8RLrjGxdxAaS5g=="],
+
+    "zod": ["[email protected]", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
+  }
+}

+ 5 - 0
.opencode/package.json

@@ -0,0 +1,5 @@
+{
+  "dependencies": {
+    "@opencode-ai/plugin": "0.0.0-dev-202601211610"
+  }
+}

+ 29 - 0
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;

+ 127 - 1
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: '<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) {
   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<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) {
   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<HTMLDivElement>()
   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 (
     <div
       data-component="markdown"
@@ -89,6 +214,7 @@ export function Markdown(
         [local.class ?? ""]: !!local.class,
       }}
       innerHTML={html.latest}
+      ref={setRoot}
       {...others}
     />
   )

+ 19 - 1
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);
   }
 }

+ 27 - 1
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 (
     <Show when={throttledText()}>
       <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>
     </Show>
   )

+ 18 - 0
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);

+ 39 - 6
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<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-header">
                           <h2 data-slot="session-turn-summary-title">{i18n.t("ui.sessionTurn.summary.response")}</h2>
-                          <Markdown
-                            data-slot="session-turn-markdown"
-                            data-diffs={hasDiffs()}
-                            text={response() ?? ""}
-                            cacheKey={responsePartId()}
-                          />
+                          <div data-slot="session-turn-response">
+                            <Markdown
+                              data-slot="session-turn-markdown"
+                              data-diffs={hasDiffs()}
+                              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>
                         <Accordion
                           data-slot="session-turn-accordion"