summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorDaniel Polito <[email protected]>2026-01-10 19:01:23 -0300
committerGitHub <[email protected]>2026-01-10 16:01:23 -0600
commit50ed4c6b5de8aa0e5d0739f933f7afd2ecaa95f4 (patch)
treee7a6e63d030e0d14eece9603e3017ba2e6bb1d9b /packages
parentf882cca98a2b245b695880499dfc4101d4ed924c (diff)
downloadopencode-50ed4c6b5de8aa0e5d0739f933f7afd2ecaa95f4.tar.gz
opencode-50ed4c6b5de8aa0e5d0739f933f7afd2ecaa95f4.zip
feat(deskop): Add Copy to Messages (#7658)
Diffstat (limited to 'packages')
-rw-r--r--packages/ui/src/components/message-part.css13
-rw-r--r--packages/ui/src/components/message-part.tsx16
-rw-r--r--packages/ui/src/components/session-turn.css16
-rw-r--r--packages/ui/src/components/session-turn.tsx22
4 files changed, 66 insertions, 1 deletions
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index fba36dc27..3cdd93cb9 100644
--- a/packages/ui/src/components/message-part.css
+++ b/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 {
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index d59f5cfa3..644690ed2 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/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>
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index 9b7aa7364..581935b3e 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/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%;
}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index f69d414be..075da218b 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/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