diff --git a/apps/web/src/components/chat/AssistantCitationSource.tsx b/apps/web/src/components/chat/AssistantCitationSource.tsx index cf2e950e96abd..789ce6ebe4746 100644 --- a/apps/web/src/components/chat/AssistantCitationSource.tsx +++ b/apps/web/src/components/chat/AssistantCitationSource.tsx @@ -338,6 +338,7 @@ export function AssistantCitationSource({ itemKey, request, listRef, + quotable, children, }: { messageId: MessageId; @@ -345,6 +346,7 @@ export function AssistantCitationSource({ itemKey: string; request: AssistantCitationTarget | null; listRef: RefObject; + quotable: boolean; children: ReactNode; }) { const rootRef = useRef(null); @@ -362,6 +364,7 @@ export function AssistantCitationSource({ data-assistant-citation-source={messageId} data-assistant-citation-environment={threadRef?.environmentId} data-assistant-citation-thread={threadRef?.threadId} + data-assistant-citation-quotable={quotable} > {children} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 6c209d78d6ba3..5890330a98b55 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -32,6 +32,7 @@ import { timelineTurnFoldRunIdsByEntryId, liveAnswerPreview, deriveMessagesTimelineRowsWithState, + isQuotableAssistantMessage, resolveAssistantMessageCopyState, type MessagesTimelineRow, workEntryDisplayLabel, @@ -57,6 +58,20 @@ describe("resolveAssistantMessageCopyState", () => { }); }); +describe("isQuotableAssistantMessage", () => { + it("does not offer quotes on Claude thinking shown as a reply", () => { + const ref = (driver: string, nativeId: string) => ({ + driver: ProviderDriverKind.make(driver), + nativeId, + strength: "strong" as const, + }); + expect(isQuotableAssistantMessage(ref("claudeAgent", "msg_01:thinking:1"))).toBe(false); + expect(isQuotableAssistantMessage(ref("claudeAgent", "6b1f0c2e-uuid"))).toBe(true); + expect(isQuotableAssistantMessage(ref("codex", "msg_01:thinking:1"))).toBe(true); + expect(isQuotableAssistantMessage(null)).toBe(true); + }); +}); + describe("deriveMessagesTimelineRows", () => { it.each([ ["html-render", "reply-run", true], diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 9208e409ba205..1789575fa1076 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -57,6 +57,7 @@ import { type OrchestrationV2IdleCompaction, type WorktreeSetupSnapshot, type OrchestrationV2ProjectedTurnItem, + type OrchestrationV2ProviderRef, type RunAttemptId, type RuntimeRequestId, RunId, @@ -708,6 +709,16 @@ export function resolveAssistantMessageCopyState({ }; } +// Claude 5.1+ shows a thinking block before a tool call as a reply. It is still reasoning, +// which no provider lets users quote, and quoting it trips Claude's reasoning_extraction refusal. +export function isQuotableAssistantMessage( + nativeItemRef: OrchestrationV2ProviderRef | null | undefined, +) { + return !( + nativeItemRef?.driver === "claudeAgent" && /:thinking:\d+$/u.test(nativeItemRef.nativeId ?? "") + ); +} + function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray) { const lastAssistantMessageIdByResponseKey = new Map(); let nullTurnResponseIndex = 0; diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index d508e0071dadf..fd7fabd04350e 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -23,7 +23,10 @@ import { } from "../../state/entities"; import { MessageCopyButton } from "./MessageCopyButton"; import { AssistantCitationSource } from "./AssistantCitationSource"; -import { resolveAssistantMessageCopyState } from "./MessagesTimeline.logic"; +import { + isQuotableAssistantMessage, + resolveAssistantMessageCopyState, +} from "./MessagesTimeline.logic"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; @@ -55,6 +58,7 @@ export function AssistantTimelineRow({ row }: { row: Extract ) : null} - {!message.streaming && projectedItem ? ( + {!message.streaming && + projectedItem && + isQuotableAssistantMessage(projectedItem.item.nativeItemRef) ? ( ) : null} {!message.streaming && ( diff --git a/apps/web/src/lib/assistantTextSelection.test.ts b/apps/web/src/lib/assistantTextSelection.test.ts index d06261cb856aa..154d543bebd9d 100644 --- a/apps/web/src/lib/assistantTextSelection.test.ts +++ b/apps/web/src/lib/assistantTextSelection.test.ts @@ -310,6 +310,15 @@ describe("captureAssistantTextSelection", () => { expect(capture(source, nativeSelection([quote, 0], [control, 1]))).toBeNull(); }); + it("does not quote a response marked unquotable", () => { + const quote = textNode("Reasoning shown as a reply."); + const source = new SelectionNode("DIV", "", { + "data-assistant-citation-source": "assistant", + "data-assistant-citation-quotable": "false", + }).append(quote); + expect(capture(source, nativeSelection([quote, 0], [quote, quote.length]))).toBeNull(); + }); + it.each([ new SelectionNode("BUTTON"), new SelectionNode("SPAN", "", { role: "button" }), diff --git a/apps/web/src/lib/assistantTextSelection.ts b/apps/web/src/lib/assistantTextSelection.ts index 9f83cd829eed0..c18a4c89e64b8 100644 --- a/apps/web/src/lib/assistantTextSelection.ts +++ b/apps/web/src/lib/assistantTextSelection.ts @@ -219,7 +219,13 @@ export function captureAssistantTextSelection( const last = selectedTextBoundary(range, range.commonAncestorContainer, true); if (first === null || last === null) return null; const source = first.parentElement?.closest("[data-assistant-citation-source]"); - if (!source || !viewport.contains(source)) return null; + if ( + !source || + !viewport.contains(source) || + source.matches("[data-assistant-citation-quotable=false]") + ) { + return null; + } // Paragraph selection can end at the next block's offset 0 or a parent // boundary. Validate the text actually selected, not that empty endpoint.