Skip to content

Commit eb6343d

Browse files
authored
Add copy button to assistant responses (#329)
- Show a response copy action beside assistant messages - Improve copy button labeling for accessibility - Update timeline test coverage for the new control
1 parent 722a1d1 commit eb6343d

3 files changed

Lines changed: 120 additions & 93 deletions

File tree

apps/web/src/components/chat/MessageCopyButton.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,29 @@ import { memo } from "react";
22
import { CopyIcon, CheckIcon } from "lucide-react";
33
import { Button } from "../ui/button";
44
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
5+
import { cn } from "~/lib/utils";
56

6-
export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) {
7+
export const MessageCopyButton = memo(function MessageCopyButton({
8+
text,
9+
label = "message",
10+
className,
11+
}: {
12+
text: string;
13+
label?: string;
14+
className?: string;
15+
}) {
716
const { copyToClipboard, isCopied } = useCopyToClipboard();
17+
const title = isCopied ? "Copied" : `Copy ${label}`;
818

919
return (
1020
<Button
1121
type="button"
1222
size="xs"
1323
variant="outline"
24+
className={cn("gap-1.5", className)}
1425
onClick={() => copyToClipboard(text)}
15-
title="Copy message"
26+
title={title}
27+
aria-label={title}
1628
>
1729
{isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
1830
</Button>

apps/web/src/components/chat/MessagesTimeline.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
66
import { buildChatShortcutGuides } from "~/lib/chatShortcutGuidance";
77
import { I18nProvider } from "~/i18n/I18nProvider";
88

9+
vi.mock("~/hooks/useFileViewNavigation", () => ({
10+
useFileViewNavigation: () => () => {},
11+
}));
12+
913
function matchMedia() {
1014
return {
1115
matches: false,
@@ -263,6 +267,7 @@ describe("MessagesTimeline", () => {
263267

264268
expect(markup).toContain("Open diff");
265269
expect(markup).toContain("Changed files (1)");
270+
expect(markup).toContain("Copy response");
266271
});
267272

268273
it("renders an open diff action when a turn diff exists but the file summary is empty", async () => {

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 101 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
542542
row.message.role === "assistant" &&
543543
(() => {
544544
const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
545+
const copyText = row.message.text.trim().length > 0 ? row.message.text : null;
545546
return (
546547
<>
547548
{row.showCompletionDivider && (
@@ -554,106 +555,115 @@ export const MessagesTimeline = memo(function MessagesTimeline({
554555
</div>
555556
)}
556557
<div className="min-w-0 px-1 py-0.5">
557-
<ChatMarkdown
558-
text={messageText}
559-
cwd={markdownCwd}
560-
isStreaming={Boolean(row.message.streaming)}
561-
/>
562-
{(() => {
563-
const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id);
564-
if (!turnSummary) return null;
565-
const checkpointFiles = turnSummary.files;
566-
const summaryStat = summarizeTurnDiffStats(checkpointFiles);
567-
const changedFileCountLabel = String(checkpointFiles.length);
568-
const allDirectoriesExpanded =
569-
allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true;
570-
const isFileSectionCollapsed =
571-
collapsedFileSectionsByTurnId[turnSummary.turnId] ?? false;
572-
return (
573-
<div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
574-
<div className="flex items-center justify-between gap-2">
575-
{checkpointFiles.length > 0 ? (
576-
<button
577-
type="button"
578-
className="group flex items-center gap-1.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65 transition-colors duration-150 hover:text-muted-foreground/90"
579-
onClick={() => onToggleFileSection(turnSummary.turnId)}
580-
>
581-
<ChevronRightIcon
582-
aria-hidden="true"
583-
className={cn(
584-
"size-3 shrink-0 transition-transform duration-150",
585-
!isFileSectionCollapsed && "rotate-90",
586-
)}
587-
/>
588-
<span>Changed files ({changedFileCountLabel})</span>
589-
{hasNonZeroStat(summaryStat) && (
590-
<>
591-
<span className="mx-1"></span>
592-
<DiffStatLabel
593-
additions={summaryStat.additions}
594-
deletions={summaryStat.deletions}
558+
<div className="flex items-start gap-2">
559+
<div className="min-w-0 flex-1">
560+
<ChatMarkdown
561+
text={messageText}
562+
cwd={markdownCwd}
563+
isStreaming={Boolean(row.message.streaming)}
564+
/>
565+
{(() => {
566+
const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id);
567+
if (!turnSummary) return null;
568+
const checkpointFiles = turnSummary.files;
569+
const summaryStat = summarizeTurnDiffStats(checkpointFiles);
570+
const changedFileCountLabel = String(checkpointFiles.length);
571+
const allDirectoriesExpanded =
572+
allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true;
573+
const isFileSectionCollapsed =
574+
collapsedFileSectionsByTurnId[turnSummary.turnId] ?? false;
575+
return (
576+
<div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
577+
<div className="flex items-center justify-between gap-2">
578+
{checkpointFiles.length > 0 ? (
579+
<button
580+
type="button"
581+
className="group flex items-center gap-1.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65 transition-colors duration-150 hover:text-muted-foreground/90"
582+
onClick={() => onToggleFileSection(turnSummary.turnId)}
583+
>
584+
<ChevronRightIcon
585+
aria-hidden="true"
586+
className={cn(
587+
"size-3 shrink-0 transition-transform duration-150",
588+
!isFileSectionCollapsed && "rotate-90",
589+
)}
595590
/>
596-
</>
591+
<span>Changed files ({changedFileCountLabel})</span>
592+
{hasNonZeroStat(summaryStat) && (
593+
<>
594+
<span className="mx-1"></span>
595+
<DiffStatLabel
596+
additions={summaryStat.additions}
597+
deletions={summaryStat.deletions}
598+
/>
599+
</>
600+
)}
601+
</button>
602+
) : (
603+
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
604+
<EyeIcon className="size-3 shrink-0" />
605+
<span>Diff available</span>
606+
</div>
597607
)}
598-
</button>
599-
) : (
600-
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
601-
<EyeIcon className="size-3 shrink-0" />
602-
<span>Diff available</span>
608+
<div className="flex items-center gap-1.5">
609+
<Button
610+
type="button"
611+
size="xs"
612+
variant="outline"
613+
onClick={() => onOpenTurnDiff(turnSummary.turnId)}
614+
>
615+
Open diff
616+
</Button>
617+
{checkpointFiles.length > 0 && !isFileSectionCollapsed && (
618+
<Button
619+
type="button"
620+
size="xs"
621+
variant="outline"
622+
onClick={() => onToggleAllDirectories(turnSummary.turnId)}
623+
>
624+
{allDirectoriesExpanded ? "Collapse all" : "Expand all"}
625+
</Button>
626+
)}
627+
</div>
603628
</div>
604-
)}
605-
<div className="flex items-center gap-1.5">
606-
<Button
607-
type="button"
608-
size="xs"
609-
variant="outline"
610-
onClick={() => onOpenTurnDiff(turnSummary.turnId)}
611-
>
612-
Open diff
613-
</Button>
614629
{checkpointFiles.length > 0 && !isFileSectionCollapsed && (
615-
<Button
616-
type="button"
617-
size="xs"
618-
variant="outline"
619-
onClick={() => onToggleAllDirectories(turnSummary.turnId)}
620-
>
621-
{allDirectoriesExpanded ? "Collapse all" : "Expand all"}
622-
</Button>
630+
<div className="mt-1.5">
631+
<ChangedFilesTree
632+
key={`changed-files-tree:${turnSummary.turnId}`}
633+
turnId={turnSummary.turnId}
634+
files={checkpointFiles}
635+
allDirectoriesExpanded={allDirectoriesExpanded}
636+
resolvedTheme={resolvedTheme}
637+
cwd={markdownCwd}
638+
onOpenTurnDiff={onOpenTurnDiff}
639+
/>
640+
</div>
641+
)}
642+
{checkpointFiles.length === 0 && (
643+
<p className="mt-1.5 text-xs text-muted-foreground/75">
644+
Open the diff to inspect changes when the file summary is unavailable.
645+
</p>
623646
)}
624647
</div>
625-
</div>
626-
{checkpointFiles.length > 0 && !isFileSectionCollapsed && (
627-
<div className="mt-1.5">
628-
<ChangedFilesTree
629-
key={`changed-files-tree:${turnSummary.turnId}`}
630-
turnId={turnSummary.turnId}
631-
files={checkpointFiles}
632-
allDirectoriesExpanded={allDirectoriesExpanded}
633-
resolvedTheme={resolvedTheme}
634-
cwd={markdownCwd}
635-
onOpenTurnDiff={onOpenTurnDiff}
636-
/>
637-
</div>
638-
)}
639-
{checkpointFiles.length === 0 && (
640-
<p className="mt-1.5 text-xs text-muted-foreground/75">
641-
Open the diff to inspect changes when the file summary is unavailable.
642-
</p>
648+
);
649+
})()}
650+
<p className="mt-1.5 text-[10px] text-muted-foreground/30">
651+
{formatMessageMeta(
652+
row.message.createdAt,
653+
row.message.streaming
654+
? formatElapsed(row.durationStart, nowIso)
655+
: formatElapsed(row.durationStart, row.message.completedAt),
656+
timestampFormat,
657+
resolvedLocale,
643658
)}
659+
</p>
660+
</div>
661+
{copyText && (
662+
<div className="flex shrink-0 items-start pt-0.5">
663+
<MessageCopyButton text={copyText} label="response" />
644664
</div>
645-
);
646-
})()}
647-
<p className="mt-1.5 text-[10px] text-muted-foreground/30">
648-
{formatMessageMeta(
649-
row.message.createdAt,
650-
row.message.streaming
651-
? formatElapsed(row.durationStart, nowIso)
652-
: formatElapsed(row.durationStart, row.message.completedAt),
653-
timestampFormat,
654-
resolvedLocale,
655665
)}
656-
</p>
666+
</div>
657667
</div>
658668
</>
659669
);

0 commit comments

Comments
 (0)