Skip to content

Commit a6d9cd7

Browse files
authored
feat(chat): add copy controls to chat messages (#296)
1 parent be34ffe commit a6d9cd7

19 files changed

Lines changed: 231 additions & 84 deletions

src/renderer/components/thread/ChatPane/parts/items/AssistantMessage.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ describe("AssistantMessage", () => {
2929

3030
render(
3131
<AppProvider>
32-
<AssistantMessage item={item} />
32+
<AssistantMessage threadId="thread-1" item={item} />
3333
</AppProvider>,
3434
);
3535

@@ -49,7 +49,7 @@ describe("AssistantMessage", () => {
4949

5050
render(
5151
<AppProvider>
52-
<AssistantMessage item={item} />
52+
<AssistantMessage threadId="thread-1" item={item} />
5353
</AppProvider>,
5454
);
5555

src/renderer/components/thread/ChatPane/parts/items/AssistantMessage.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,49 @@
11
import { memo, useDeferredValue, useMemo } from "react";
22
import { Surface } from "@heroui/react";
3+
import { useLingui } from "@lingui/react/macro";
34
import type { MessageItemPayload } from "@/shared/contracts";
45
import { PixelLoader } from "@/renderer/components/common/PixelLoader";
6+
import { useAppStore } from "@/renderer/state/appStore";
57
import {
68
getRuntimeItemPayload,
79
type RuntimeChatItem,
810
} from "@/renderer/state/slices/runtimeEventSlice";
911
import { chatMessageSurfaceClass } from "./chatMessageSurface";
12+
import { CopyTextButton } from "./CopyTextButton";
1013
import { ImageCard } from "./ImageView";
1114
import { imageViewSourceFromImageBlock } from "./imageViewSource";
1215
import { ItemMarkdown } from "./ItemMarkdown";
1316

1417
interface AssistantMessageProps {
18+
threadId: string;
1519
item: RuntimeChatItem;
1620
}
1721

18-
export const AssistantMessage = memo(function AssistantMessage({ item }: AssistantMessageProps) {
22+
export const AssistantMessage = memo(function AssistantMessage({
23+
threadId,
24+
item,
25+
}: AssistantMessageProps) {
26+
const { t } = useLingui();
27+
// Matching Codex: the copy action only appears under a turn's *final* answer,
28+
// i.e. the last assistant message before the next user message (or the end of
29+
// the thread). Every turn keeps its button, not just the most recent one.
30+
// Sub-agent messages (those nested under a tool call) are ignored so they
31+
// neither qualify nor cancel a top-level answer's terminal status.
32+
const isFinalAnswer = useAppStore((state) => {
33+
if (item.parentItemId) return false;
34+
const ids = state.runtimeItemIdsByThread[threadId];
35+
const byId = state.runtimeItemsByIdByThread[threadId];
36+
if (!ids || !byId) return false;
37+
const index = ids.indexOf(item.id);
38+
if (index < 0) return false;
39+
for (let i = index + 1; i < ids.length; i += 1) {
40+
const next = byId[ids[i]!];
41+
if (!next || next.parentItemId) continue;
42+
if (next.type === "user_message") return true;
43+
if (next.type === "assistant_message") return false;
44+
}
45+
return true;
46+
});
1947
const stream = item.streams.assistant_text ?? "";
2048
const payload = getRuntimeItemPayload<MessageItemPayload>(item, "assistant_message");
2149
const rawText =
@@ -38,6 +66,7 @@ export const AssistantMessage = memo(function AssistantMessage({ item }: Assista
3866
.filter((s): s is NonNullable<typeof s> => s !== null),
3967
[payload?.content],
4068
);
69+
const showCopyButton = isFinalAnswer && !isStreaming && rawText.length > 0;
4170
return (
4271
<Surface variant="transparent" className={chatMessageSurfaceClass}>
4372
<div className="min-w-0 leading-snug">
@@ -55,6 +84,11 @@ export const AssistantMessage = memo(function AssistantMessage({ item }: Assista
5584
</div>
5685
) : null}
5786
</div>
87+
{showCopyButton ? (
88+
<div className="poracode-message-action-strip mt-1 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/checkpoint:opacity-100 focus-within:opacity-100">
89+
<CopyTextButton text={rawText} label={t`Copy message`} />
90+
</div>
91+
) : null}
5892
</Surface>
5993
);
6094
});

src/renderer/components/thread/ChatPane/parts/items/ChatItemRow.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,17 +76,21 @@ const SingleChatItemRow = memo(function SingleChatItemRow({
7676
return <SubAgentToolCall threadId={threadId} item={item} />;
7777
}
7878
}
79-
return renderItem(item, checkpointRevert);
79+
return renderItem(threadId, item, checkpointRevert);
8080
});
8181

82-
function renderItem(item: RuntimeChatItem, checkpointRevert: CheckpointRevertRequest | null) {
82+
function renderItem(
83+
threadId: string,
84+
item: RuntimeChatItem,
85+
checkpointRevert: CheckpointRevertRequest | null,
86+
) {
8387
switch (item.type) {
8488
case "user_message":
8589
return <UserMessage item={item} checkpointRevert={checkpointRevert} />;
8690
case "question_answer":
8791
return <QuestionAnswer item={item} checkpointRevert={checkpointRevert} />;
8892
case "assistant_message":
89-
return <AssistantMessage item={item} />;
93+
return <AssistantMessage threadId={threadId} item={item} />;
9094
case "reasoning":
9195
return <Reasoning item={item} />;
9296
case "plan":
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { useLayoutEffect, useRef, useState } from "react";
2+
import { Tooltip, toast } from "@heroui/react";
3+
import { useLingui } from "@lingui/react/macro";
4+
import { Copy } from "lucide-react";
5+
import { friendlyError } from "@/shared/messages";
6+
7+
interface CopyTextButtonProps {
8+
text: string;
9+
/** Idle tooltip / aria-label (already translated), e.g. t`Copy message`. */
10+
label: string;
11+
}
12+
13+
/**
14+
* Small hover-strip icon button that copies `text` to the clipboard and flips
15+
* its tooltip to "Copied" for a moment. Shared by the user/assistant message
16+
* action strips and markdown code blocks.
17+
*/
18+
export function CopyTextButton({ text, label }: CopyTextButtonProps) {
19+
const { t } = useLingui();
20+
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
21+
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
22+
const resetTimerRef = useRef<number | null>(null);
23+
const labelResetTimerRef = useRef<number | null>(null);
24+
25+
useLayoutEffect(
26+
() => () => {
27+
if (resetTimerRef.current !== null) window.clearTimeout(resetTimerRef.current);
28+
if (labelResetTimerRef.current !== null) window.clearTimeout(labelResetTimerRef.current);
29+
},
30+
[],
31+
);
32+
33+
return (
34+
<Tooltip delay={300} isOpen={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
35+
<Tooltip.Trigger>
36+
<button
37+
type="button"
38+
aria-label={copyState === "copied" ? t`Copied` : label}
39+
className="flex size-5 items-center justify-center rounded text-muted/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
40+
onClick={(event) => {
41+
event.stopPropagation();
42+
navigator.clipboard
43+
.writeText(text)
44+
.then(() => {
45+
setCopyState("copied");
46+
setIsTooltipOpen(true);
47+
if (resetTimerRef.current !== null) {
48+
window.clearTimeout(resetTimerRef.current);
49+
}
50+
if (labelResetTimerRef.current !== null) {
51+
window.clearTimeout(labelResetTimerRef.current);
52+
}
53+
resetTimerRef.current = window.setTimeout(() => {
54+
setIsTooltipOpen(false);
55+
resetTimerRef.current = null;
56+
labelResetTimerRef.current = window.setTimeout(() => {
57+
setCopyState("idle");
58+
labelResetTimerRef.current = null;
59+
}, 200);
60+
}, 1200);
61+
})
62+
.catch((error: unknown) => {
63+
toast.danger(friendlyError(error));
64+
});
65+
}}
66+
>
67+
<Copy className="size-3" />
68+
</button>
69+
</Tooltip.Trigger>
70+
<Tooltip.Content placement="top">
71+
{copyState === "copied" ? t`Copied` : label}
72+
</Tooltip.Content>
73+
</Tooltip>
74+
);
75+
}

src/renderer/components/thread/ChatPane/parts/items/ItemMarkdownInner.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Link } from "@heroui/react";
2+
import { useLingui } from "@lingui/react/macro";
23
import { ExternalLink } from "lucide-react";
34
import {
45
Children,
@@ -19,6 +20,7 @@ import { openExternalWithFeedback } from "@/renderer/utils/openExternal";
1920
import { useChatPaneActions } from "../../chatPaneActionsContext";
2021
import { normalizeChatProjectPath } from "../../chatPathUtils";
2122
import { CodeBlock } from "./CodeBlock";
23+
import { CopyTextButton } from "./CopyTextButton";
2224
import { InlineFilePathChip } from "./InlineFilePathChip";
2325
import { InlineFolderPathChip } from "./InlineFolderPathChip";
2426
import { LC_SELECTOR_LANG, tryParseSelectorPayload } from "./SelectorBadge";
@@ -114,10 +116,18 @@ const MD_COMPONENTS: StreamdownComponents = {
114116
const language = normalizeHighlightLanguage(codeProps?.className);
115117
if (language) {
116118
const text = flattenMdChildren(codeProps?.children).replace(/\r?\n$/, "");
117-
return <CodeBlock text={text} lang={language} className={markdownCodeBlockClass} />;
119+
return (
120+
<MdCodeBlockFrame text={text}>
121+
<CodeBlock text={text} lang={language} className={markdownCodeBlockClass} />
122+
</MdCodeBlockFrame>
123+
);
118124
}
119125
}
120-
return <pre>{markCodeChildAsBlock(children)}</pre>;
126+
return (
127+
<MdCodeBlockFrame text={flattenMdChildren(children).replace(/\r?\n$/, "")}>
128+
<pre>{markCodeChildAsBlock(children)}</pre>
129+
</MdCodeBlockFrame>
130+
);
121131
},
122132
code({ className, children, ...rest }) {
123133
const isBlock =
@@ -183,6 +193,24 @@ const markdownCodeBlockClass =
183193
"not-prose my-2 min-w-0 overflow-x-hidden rounded bg-foreground/10 px-[0.5em] py-[0.25em] font-mono text-[0.875em] leading-snug text-foreground";
184194
const markdownImageClass = `not-prose my-2 rounded-lg border border-[color:var(--border)] bg-[var(--composer-surface)] ${chatInlineImageClass}`;
185195

196+
/**
197+
* Wraps a fenced code block with a copy button that reveals on hover of the
198+
* code block itself (`group/codeblock`). Kept outside `CodeBlock` so
199+
* command-output viewports (which reuse `CodeBlock`) are not affected.
200+
*/
201+
function MdCodeBlockFrame({ text, children }: { text: string; children?: ReactNode }) {
202+
const { t } = useLingui();
203+
if (text.length === 0) return <>{children}</>;
204+
return (
205+
<div className="group/codeblock relative">
206+
{children}
207+
<div className="absolute right-1 top-1 z-10 opacity-0 transition-opacity focus-within:opacity-100 group-hover/codeblock:opacity-100">
208+
<CopyTextButton text={text} label={t`Copy code`} />
209+
</div>
210+
</div>
211+
);
212+
}
213+
186214
function MdCode(props: { className: string; isBlock?: boolean; children?: ReactNode }) {
187215
const actions = useChatPaneActions();
188216
const isBlock =

src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx

Lines changed: 4 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { memo, type ReactNode, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
2-
import { Link, Surface, Tooltip, toast } from "@heroui/react";
2+
import { Link, Surface, Tooltip } from "@heroui/react";
33
import { useLingui } from "@lingui/react/macro";
4-
import { ChevronDown, ChevronUp, Copy, Sparkles } from "lucide-react";
4+
import { ChevronDown, ChevronUp, Sparkles } from "lucide-react";
55
import type { CanonicalContentBlock, MessageItemPayload } from "@/shared/contracts";
6-
import { friendlyError } from "@/shared/messages";
76
import { AttachmentBar } from "@/renderer/components/composer/AttachmentBar";
87
import { openAttachmentLightbox } from "@/renderer/components/composer/ImageLightbox";
98
import type { Attachment } from "@/renderer/components/composer/useAttachments";
@@ -20,6 +19,7 @@ import { normalizeChatProjectPath } from "../../chatPathUtils";
2019
import { openUserMessageActions } from "../../userMessageActions";
2120
import { CheckpointRevertButton, type CheckpointRevertRequest } from "../CheckpointRevertControls";
2221
import { chatPromptSurfaceClass } from "./chatMessageSurface";
22+
import { CopyTextButton } from "./CopyTextButton";
2323
import { InlineFilePathChip } from "./InlineFilePathChip";
2424
import { ItemMarkdown } from "./ItemMarkdown";
2525
import { extractSelectorPayloads } from "./SelectorBadge";
@@ -270,72 +270,13 @@ export const UserMessage = memo(function UserMessage({ item, checkpointRevert }:
270270
onRequestRevert={checkpointRevert.onRequestRevert}
271271
/>
272272
) : null}
273-
<CopyUserMessageButton text={rawText} />
273+
<CopyTextButton text={rawText} label={t`Copy message`} />
274274
</div>
275275
) : null}
276276
</Surface>
277277
);
278278
});
279279

280-
function CopyUserMessageButton({ text }: { text: string }) {
281-
const { t } = useLingui();
282-
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
283-
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
284-
const resetTimerRef = useRef<number | null>(null);
285-
const labelResetTimerRef = useRef<number | null>(null);
286-
287-
useLayoutEffect(
288-
() => () => {
289-
if (resetTimerRef.current !== null) window.clearTimeout(resetTimerRef.current);
290-
if (labelResetTimerRef.current !== null) window.clearTimeout(labelResetTimerRef.current);
291-
},
292-
[],
293-
);
294-
295-
return (
296-
<Tooltip delay={300} isOpen={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
297-
<Tooltip.Trigger>
298-
<button
299-
type="button"
300-
aria-label={copyState === "copied" ? t`Copied` : t`Copy message`}
301-
className="flex size-5 items-center justify-center rounded text-muted/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
302-
onClick={(event) => {
303-
event.stopPropagation();
304-
navigator.clipboard
305-
.writeText(text)
306-
.then(() => {
307-
setCopyState("copied");
308-
setIsTooltipOpen(true);
309-
if (resetTimerRef.current !== null) {
310-
window.clearTimeout(resetTimerRef.current);
311-
}
312-
if (labelResetTimerRef.current !== null) {
313-
window.clearTimeout(labelResetTimerRef.current);
314-
}
315-
resetTimerRef.current = window.setTimeout(() => {
316-
setIsTooltipOpen(false);
317-
resetTimerRef.current = null;
318-
labelResetTimerRef.current = window.setTimeout(() => {
319-
setCopyState("idle");
320-
labelResetTimerRef.current = null;
321-
}, 200);
322-
}, 1200);
323-
})
324-
.catch((error: unknown) => {
325-
toast.danger(friendlyError(error));
326-
});
327-
}}
328-
>
329-
<Copy className="size-3" />
330-
</button>
331-
</Tooltip.Trigger>
332-
<Tooltip.Content placement="top">
333-
{copyState === "copied" ? t`Copied` : t`Copy message`}
334-
</Tooltip.Content>
335-
</Tooltip>
336-
);
337-
}
338-
339280
const LEADING_SLASH_COMMAND_RE = /^\/([A-Za-z][A-Za-z0-9_-]*)(\s+|$)/;
340281

341282
function extractLeadingSlashCommand(text: string): { slashCommand: string | null; body: string } {

src/renderer/locales/de/messages.po

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2370,8 +2370,8 @@ msgstr "Gespräch"
23702370

23712371
#: src/mobile/UserMessageActionsSheet.tsx
23722372
#: src/mobile/views/PortsView.tsx
2373+
#: src/renderer/components/thread/ChatPane/parts/items/CopyTextButton.tsx
23732374
#: src/renderer/components/thread/ChatPane/parts/items/ImageView.tsx
2374-
#: src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx
23752375
#: src/renderer/RendererCrashScreen.tsx
23762376
msgid "Copied"
23772377
msgstr "Kopiert"
@@ -2399,6 +2399,10 @@ msgstr "{0} Gerätecode {1} kopieren"
23992399
msgid "Copy all Lightcode data into Poracode. Poracode restarts and keeps a complete backup of its current data."
24002400
msgstr "Alle Lightcode-Daten in Poracode kopieren. Poracode wird neu gestartet und behält eine vollständige Sicherung seiner aktuellen Daten."
24012401

2402+
#: src/renderer/components/thread/ChatPane/parts/items/ItemMarkdownInner.tsx
2403+
msgid "Copy code"
2404+
msgstr "Code kopieren"
2405+
24022406
#: src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/parts/BrowserToolbar.tsx
24032407
msgid "Copy Current URL"
24042408
msgstr "Aktuelle URL kopieren"
@@ -2429,6 +2433,7 @@ msgid "Copy image"
24292433
msgstr "Bild kopieren"
24302434

24312435
#: src/mobile/UserMessageActionsSheet.tsx
2436+
#: src/renderer/components/thread/ChatPane/parts/items/AssistantMessage.tsx
24322437
#: src/renderer/components/thread/ChatPane/parts/items/UserMessage.tsx
24332438
msgid "Copy message"
24342439
msgstr "Nachricht kopieren"

0 commit comments

Comments
 (0)