Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/AssistantCitationSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -338,13 +338,15 @@ export function AssistantCitationSource({
itemKey,
request,
listRef,
quotable,
children,
}: {
messageId: MessageId;
threadRef?: ScopedThreadRef;
itemKey: string;
request: AssistantCitationTarget | null;
listRef: RefObject<LegendListRef | null>;
quotable: boolean;
children: ReactNode;
}) {
const rootRef = useRef<HTMLDivElement>(null);
Expand All @@ -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}
</div>
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
timelineTurnFoldRunIdsByEntryId,
liveAnswerPreview,
deriveMessagesTimelineRowsWithState,
isQuotableAssistantMessage,
resolveAssistantMessageCopyState,
type MessagesTimelineRow,
workEntryDisplayLabel,
Expand All @@ -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],
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
type OrchestrationV2IdleCompaction,
type WorktreeSetupSnapshot,
type OrchestrationV2ProjectedTurnItem,
type OrchestrationV2ProviderRef,
type RunAttemptId,
type RuntimeRequestId,
RunId,
Expand Down Expand Up @@ -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<TimelineEntry>) {
const lastAssistantMessageIdByResponseKey = new Map<string, string>();
let nullTurnResponseIndex = 0;
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/components/chat/TimelineAssistantRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -55,6 +58,7 @@ export function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind
itemKey={row.id}
request={ctx.citationRequest}
listRef={ctx.listRef}
quotable={isQuotableAssistantMessage(row.projectedItem?.item.nativeItemRef)}
>
<ChatMarkdown
text={messageText}
Expand Down Expand Up @@ -233,7 +237,9 @@ function AssistantMessageMeta({
{projectedItem?.item.type === "assistant_message" ? (
<AssistantForkButton projectedItem={projectedItem} />
) : null}
{!message.streaming && projectedItem ? (
{!message.streaming &&
projectedItem &&
isQuotableAssistantMessage(projectedItem.item.nativeItemRef) ? (
<AssistantReplyButton message={message} itemId={projectedItem.sourceItemId} />
) : null}
{!message.streaming && (
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/lib/assistantTextSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/lib/assistantTextSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>("[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.
Expand Down
Loading