Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7bfabe4
Refine assistant message copy button state and UX
shivamhwp Mar 19, 2026
8d776f7
Harden theme and terminal state storage for non-browser envs
shivamhwp Mar 19, 2026
741eb41
fix: resolve rebase fallout on assistant copy state
shivamhwp Apr 4, 2026
08dd481
fix: simplify message copy callbacks
shivamhwp Apr 4, 2026
92905a4
fix: stabilize assistant markdown height estimates
shivamhwp Apr 4, 2026
250be7a
Merge branch 'main' into feature/assistant-copy-state
shivamhwp Apr 5, 2026
fd955a3
Merge branch 'main' into feature/assistant-copy-state
shivamhwp Apr 5, 2026
f9a53ab
Simplify message copy button copy state
shivamhwp Apr 5, 2026
e8504de
Show copy only on terminal assistant messages
shivamhwp Apr 6, 2026
3da865a
Move message meta before copy controls
shivamhwp Apr 6, 2026
0d4beff
Remove redundant assistant copy disabled state
shivamhwp Apr 7, 2026
6c0a24d
Remove disabled copy button state
shivamhwp Apr 7, 2026
4994126
Merge branch 'main' into feature/assistant-copy-state
shivamhwp Apr 7, 2026
d19e592
Merge branch 'main' into feature/assistant-copy-state
shivamhwp Apr 8, 2026
1d34730
Merge upstream/main into feature/assistant-copy-state
shivamhwp Apr 10, 2026
a7a902c
Merge branch 'main' into feature/assistant-copy-state
shivamhwp Apr 10, 2026
ee61e27
Merge branch 'main' into feature/assistant-copy-state
shivamhwp Apr 11, 2026
a4e170b
Show disabled assistant copy button while streaming
shivamhwp Apr 11, 2026
31cbcd8
Hide assistant copy button until streaming completes
shivamhwp Apr 11, 2026
5945b3e
Hide assistant copy button until turn settles
shivamhwp Apr 11, 2026
afa41df
Show assistant copy button on row hover
juliusmarminge Apr 11, 2026
40aa530
Remove focus reveal from assistant copy controls
juliusmarminge Apr 11, 2026
5ee8129
anchored toast
juliusmarminge Apr 11, 2026
0b8b360
kewl
juliusmarminge Apr 11, 2026
90a1b7a
fuck tests
juliusmarminge Apr 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3347,6 +3347,7 @@ export default function ChatView(props: ChatViewProps) {
hasMessages={timelineEntries.length > 0}
isWorking={isWorking}
activeTurnInProgress={isWorking || !latestTurnSettled}
activeTurnId={activeLatestTurn?.turnId ?? null}
activeTurnStartedAt={activeWorkStartedAt}
scrollContainer={messagesScrollElement}
timelineEntries={timelineEntries}
Expand Down
86 changes: 74 additions & 12 deletions apps/web/src/components/chat/MessageCopyButton.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,82 @@
import { memo } from "react";
import { memo, useRef } from "react";
import { CopyIcon, CheckIcon } from "lucide-react";
import { Button } from "../ui/button";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { cn } from "~/lib/utils";
import { anchoredToastManager } from "../ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";

export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) {
const { copyToClipboard, isCopied } = useCopyToClipboard();
const ANCHORED_TOAST_TIMEOUT_MS = 1000;
const onCopy = (ref: React.RefObject<HTMLButtonElement | null>) => {
if (ref.current) {
anchoredToastManager.add({
data: {
tooltipStyle: true,
},
positionerProps: {
anchor: ref.current,
},
timeout: ANCHORED_TOAST_TIMEOUT_MS,
title: "Copied!",
});
}
};

const onCopyError = (ref: React.RefObject<HTMLButtonElement | null>, error: Error) => {
if (ref.current) {
anchoredToastManager.add({
data: {
tooltipStyle: true,
},
positionerProps: {
anchor: ref.current,
},
timeout: ANCHORED_TOAST_TIMEOUT_MS,
title: "Failed to copy",
description: error.message,
});
}
};

export const MessageCopyButton = memo(function MessageCopyButton({
text,
size = "xs",
variant = "outline",
className,
}: {
text: string;
size?: "xs" | "icon-xs";
variant?: "outline" | "ghost";
className?: string;
}) {
const ref = useRef<HTMLButtonElement>(null);
const { copyToClipboard, isCopied } = useCopyToClipboard<void>({
onCopy: () => onCopy(ref),
onError: (error: Error) => onCopyError(ref, error),
timeout: ANCHORED_TOAST_TIMEOUT_MS,
});
Comment thread
cursor[bot] marked this conversation as resolved.

return (
<Button
type="button"
size="xs"
variant="outline"
onClick={() => copyToClipboard(text)}
title="Copy message"
>
{isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
</Button>
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="Copy link"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect aria-label says "Copy link" instead of message

Medium Severity

The aria-label on the copy button is "Copy link" but the button actually copies message text to the clipboard. The old code correctly used title="Copy message". This mislabels the action for screen reader users and is inconsistent with the tooltip text "Copy to clipboard".

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 90a1b7a. Configure here.

disabled={isCopied}
onClick={() => copyToClipboard(text)}
ref={ref}
type="button"
size={size}
variant={variant}
className={cn(className)}
/>
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
>
{isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
</TooltipTrigger>
<TooltipPopup>
<p>Copy to clipboard</p>
</TooltipPopup>
</Tooltip>
);
});
124 changes: 123 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
import {
computeMessageDurationStart,
deriveMessagesTimelineRows,
normalizeCompactToolLabel,
resolveAssistantMessageCopyState,
} from "./MessagesTimeline.logic";

describe("computeMessageDurationStart", () => {
it("returns message createdAt when there is no preceding user message", () => {
Expand Down Expand Up @@ -143,3 +148,120 @@ describe("normalizeCompactToolLabel", () => {
expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file");
});
});

describe("resolveAssistantMessageCopyState", () => {
it("returns enabled copy state for completed assistant messages", () => {
expect(
resolveAssistantMessageCopyState({
showCopyButton: true,
text: "Ship it",
streaming: false,
}),
).toEqual({
text: "Ship it",
visible: true,
});
});

it("hides copy while an assistant message is still streaming", () => {
expect(
resolveAssistantMessageCopyState({
showCopyButton: true,
text: "Still streaming",
streaming: true,
}),
).toEqual({
text: "Still streaming",
visible: false,
});
});

it("hides copy for empty completed assistant messages", () => {
expect(
resolveAssistantMessageCopyState({
showCopyButton: true,
text: " ",
streaming: false,
}),
).toEqual({
text: null,
visible: false,
});
});

it("hides copy for non-terminal assistant messages", () => {
expect(
resolveAssistantMessageCopyState({
showCopyButton: false,
text: "Interim thought",
streaming: false,
}),
).toEqual({
text: "Interim thought",
visible: false,
});
});
});

describe("deriveMessagesTimelineRows", () => {
it("only enables assistant copy for the terminal assistant message in a turn", () => {
const rows = deriveMessagesTimelineRows({
timelineEntries: [
{
id: "user-1-entry",
kind: "message",
createdAt: "2026-01-01T00:00:00Z",
message: {
id: "user-1" as never,
role: "user",
text: "Write a poem",
turnId: null,
createdAt: "2026-01-01T00:00:00Z",
streaming: false,
},
},
{
id: "assistant-thought-entry",
kind: "message",
createdAt: "2026-01-01T00:00:10Z",
message: {
id: "assistant-thought" as never,
role: "assistant",
text: "I should ground this first.",
turnId: "turn-1" as never,
createdAt: "2026-01-01T00:00:10Z",
completedAt: "2026-01-01T00:00:11Z",
streaming: false,
},
},
{
id: "assistant-final-entry",
kind: "message",
createdAt: "2026-01-01T00:00:20Z",
message: {
id: "assistant-final" as never,
role: "assistant",
text: "Here is the poem.",
turnId: "turn-1" as never,
createdAt: "2026-01-01T00:00:20Z",
completedAt: "2026-01-01T00:00:30Z",
streaming: false,
},
},
],
completionDividerBeforeEntryId: "assistant-final-entry",
isWorking: false,
activeTurnStartedAt: null,
});

const assistantRows = rows.filter(
(row): row is Extract<(typeof rows)[number], { kind: "message" }> =>
row.kind === "message" && row.message.role === "assistant",
);

expect(assistantRows).toHaveLength(2);
expect(assistantRows[0]?.showAssistantCopyButton).toBe(false);
expect(assistantRows[1]?.showAssistantCopyButton).toBe(true);
expect(assistantRows[1]?.showCompletionDivider).toBe(true);
});
});
47 changes: 47 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type MessagesTimelineRow =
message: ChatMessage;
durationStart: string;
showCompletionDivider: boolean;
showAssistantCopyButton: boolean;
}
| {
kind: "proposed-plan";
Expand Down Expand Up @@ -59,6 +60,48 @@ export function normalizeCompactToolLabel(value: string): string {
return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
}

export function resolveAssistantMessageCopyState({
text,
showCopyButton,
streaming,
}: {
text: string | null;
showCopyButton: boolean;
streaming: boolean;
}) {
const hasText = text !== null && text.trim().length > 0;
return {
text: hasText ? text : null,
visible: showCopyButton && hasText && !streaming,
};
Comment thread
cursor[bot] marked this conversation as resolved.
}

function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<TimelineEntry>) {
const lastAssistantMessageIdByResponseKey = new Map<string, string>();
let nullTurnResponseIndex = 0;

for (const timelineEntry of timelineEntries) {
if (timelineEntry.kind !== "message") {
continue;
}
const { message } = timelineEntry;
if (message.role === "user") {
nullTurnResponseIndex += 1;
continue;
}
if (message.role !== "assistant") {
continue;
}

const responseKey = message.turnId
? `turn:${message.turnId}`
: `unkeyed:${nullTurnResponseIndex}`;
lastAssistantMessageIdByResponseKey.set(responseKey, message.id);
}

return new Set(lastAssistantMessageIdByResponseKey.values());
}

export function deriveMessagesTimelineRows(input: {
timelineEntries: ReadonlyArray<TimelineEntry>;
completionDividerBeforeEntryId: string | null;
Expand All @@ -69,6 +112,7 @@ export function deriveMessagesTimelineRows(input: {
const durationStartByMessageId = computeMessageDurationStart(
input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
);
const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries);

for (let index = 0; index < input.timelineEntries.length; index += 1) {
const timelineEntry = input.timelineEntries[index];
Expand Down Expand Up @@ -115,6 +159,9 @@ export function deriveMessagesTimelineRows(input: {
showCompletionDivider:
timelineEntry.message.role === "assistant" &&
input.completionDividerBeforeEntryId === timelineEntry.id,
showAssistantCopyButton:
timelineEntry.message.role === "assistant" &&
terminalAssistantMessageIds.has(timelineEntry.message.id),
});
}

Expand Down
Loading
Loading