Răsfoiți Sursa

feat(deskop): Add Copy to Messages (#7658)

Daniel Polito 1 lună în urmă
părinte
comite
50ed4c6b5d

+ 13 - 0
packages/ui/src/components/message-part.css

@@ -76,12 +76,25 @@
   }
 
   [data-slot="user-message-text"] {
+    position: relative;
     white-space: pre-wrap;
     word-break: break-word;
     overflow: hidden;
     background: var(--surface-base);
     padding: 8px 12px;
     border-radius: 4px;
+
+    [data-slot="user-message-copy-wrapper"] {
+      position: absolute;
+      top: 7px;
+      right: 7px;
+      opacity: 0;
+      transition: opacity 0.15s ease;
+    }
+
+    &:hover [data-slot="user-message-copy-wrapper"] {
+      opacity: 1;
+    }
   }
 
   .text-text-strong {

+ 16 - 0
packages/ui/src/components/message-part.tsx

@@ -38,6 +38,8 @@ import { Markdown } from "./markdown"
 import { ImagePreview } from "./image-preview"
 import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
 import { checksum } from "@opencode-ai/util/encode"
+import { Tooltip } from "./tooltip"
+import { IconButton } from "./icon-button"
 import { createAutoScroll } from "../hooks"
 
 interface Diagnostic {
@@ -278,6 +280,7 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part
 
 export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
   const dialog = useDialog()
+  const [copied, setCopied] = createSignal(false)
 
   const textPart = createMemo(
     () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
@@ -307,6 +310,14 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
     dialog.show(() => <ImagePreview src={url} alt={alt} />)
   }
 
+  const handleCopy = async () => {
+    const content = text()
+    if (!content) return
+    await navigator.clipboard.writeText(content)
+    setCopied(true)
+    setTimeout(() => setCopied(false), 2000)
+  }
+
   return (
     <div data-component="user-message">
       <Show when={attachments().length > 0}>
@@ -341,6 +352,11 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
       <Show when={text()}>
         <div data-slot="user-message-text">
           <HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
+          <div data-slot="user-message-copy-wrapper">
+            <Tooltip value={copied() ? "Copied!" : "Copy"} placement="top" gutter={8}>
+              <IconButton icon={copied() ? "check" : "copy"} variant="secondary" onClick={handleCopy} />
+            </Tooltip>
+          </div>
         </div>
       </Show>
     </div>

+ 16 - 0
packages/ui/src/components/session-turn.css

@@ -225,6 +225,22 @@
     }
   }
 
+  [data-slot="session-turn-summary-section"] {
+    position: relative;
+
+    [data-slot="session-turn-summary-copy"] {
+      position: absolute;
+      top: 0;
+      right: 0;
+      opacity: 0;
+      transition: opacity 0.15s ease;
+    }
+
+    &:hover [data-slot="session-turn-summary-copy"] {
+      opacity: 1;
+    }
+  }
+
   [data-slot="session-turn-accordion"] {
     width: 100%;
   }

+ 21 - 1
packages/ui/src/components/session-turn.tsx

@@ -11,7 +11,7 @@ import { useDiffComponent } from "../context/diff"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 
 import { Binary } from "@opencode-ai/util/binary"
-import { createEffect, createMemo, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
+import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { DiffChanges } from "./diff-changes"
 import { Typewriter } from "./typewriter"
@@ -21,6 +21,8 @@ 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 { Tooltip } from "./tooltip"
 import { Card } from "./card"
 import { Dynamic } from "solid-js/web"
 import { Button } from "./button"
@@ -328,6 +330,15 @@ export function SessionTurn(
   const hasDiffs = createMemo(() => message()?.summary?.diffs?.length)
   const hideResponsePart = createMemo(() => !working() && !!responsePartId())
 
+  const [responseCopied, setResponseCopied] = createSignal(false)
+  const handleCopyResponse = async () => {
+    const content = response()
+    if (!content) return
+    await navigator.clipboard.writeText(content)
+    setResponseCopied(true)
+    setTimeout(() => setResponseCopied(false), 2000)
+  }
+
   function duration() {
     const msg = message()
     if (!msg) return ""
@@ -556,6 +567,15 @@ export function SessionTurn(
                     {/* Response */}
                     <Show when={!working() && (response() || hasDiffs())}>
                       <div data-slot="session-turn-summary-section">
+                        <div data-slot="session-turn-summary-copy">
+                          <Tooltip value={responseCopied() ? "Copied!" : "Copy"} placement="top" gutter={8}>
+                            <IconButton
+                              icon={responseCopied() ? "check" : "copy"}
+                              variant="secondary"
+                              onClick={handleCopyResponse}
+                            />
+                          </Tooltip>
+                        </div>
                         <div data-slot="session-turn-summary-header">
                           <h2 data-slot="session-turn-summary-title">Response</h2>
                           <Markdown