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
2 changes: 1 addition & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,7 @@ export default function ChatView(props: ChatViewProps) {
const branchToolbarRef = useRef<BranchToolbarHandle>(null);
const citeAssistantText = (
citation: AssistantCitation,
sourceAnchor: AssistantCitationSourceAnchor,
sourceAnchor: AssistantCitationSourceAnchor | undefined,
) => {
const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false;
if (!inserted) {
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,15 +208,15 @@ export type ComposerCitationCommentRequest = {
previousValue: string;
value: string;
citationStart: number;
sourceAnchor: AssistantCitationSourceAnchor;
sourceAnchor: AssistantCitationSourceAnchor | undefined;
insertedSpaces: CitationInsertedSpaces;
};

type CitationInsertedSpaces = { before: boolean; after: boolean };

type OpenCitationComment = {
key: string;
sourceAnchor?: AssistantCitationSourceAnchor;
sourceAnchor?: AssistantCitationSourceAnchor | undefined;
removeOnCancel?: CitationInsertedSpaces;
};

Expand Down
13 changes: 9 additions & 4 deletions apps/web/src/components/chat/AssistantCitationSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,13 @@ function observeAssistantCitationSource({
itemKey,
request,
list,
opaque,
}: {
root: HTMLElement;
itemKey: string;
request: AssistantCitationTarget;
list: LegendListRef;
opaque: boolean;
}) {
const activation = request.activationRef.current;
if (activation.dismissed) return;
Expand Down Expand Up @@ -163,7 +165,7 @@ function observeAssistantCitationSource({
const state = list.getState();
const index = state.indexByKey(itemKey);
if (index === undefined || !(state.sizeAtIndex(index) > 0)) return;
const range = resolveAssistantCitationRange(root, request.citation);
const range = opaque ? null : resolveAssistantCitationRange(root, request.citation);
const rect = (range ?? root).getBoundingClientRect();
if (rect.height <= 0 || scrollNode.clientHeight <= 0) {
clear();
Expand Down Expand Up @@ -204,7 +206,7 @@ function observeAssistantCitationSource({
if (rect.bottom <= scrollRect.top || rect.top >= scrollRect.bottom) return;
activation.scrolled = true;
request.onComplete();
if (!range) {
if (!range && !opaque) {
toastManager.add({
type: "warning",
title: "The quoted text has changed",
Expand Down Expand Up @@ -338,13 +340,16 @@ export function AssistantCitationSource({
itemKey,
request,
listRef,
opaque = false,
children,
}: {
messageId: MessageId;
threadRef?: ScopedThreadRef;
itemKey: string;
request: AssistantCitationTarget | null;
listRef: RefObject<LegendListRef | null>;
/** The source's text sits in a sandboxed frame, so navigation reveals the row without a range. */
opaque?: boolean;
children: ReactNode;
}) {
const rootRef = useRef<HTMLDivElement>(null);
Expand All @@ -353,8 +358,8 @@ export function AssistantCitationSource({
const root = rootRef.current;
const list = listRef.current;
if (!root || !list || !request || request.citation.messageId !== messageId) return;
return observeAssistantCitationSource({ root, itemKey, request, list });
}, [itemKey, listRef, messageId, request]);
return observeAssistantCitationSource({ root, itemKey, request, list, opaque });
}, [itemKey, listRef, messageId, opaque, request]);

return (
<div
Expand Down
76 changes: 74 additions & 2 deletions apps/web/src/components/chat/AssistantSelectionToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import {
type AssistantCitation,
type ScopedThreadRef,
} from "@t3tools/contracts";
import type { HtmlRenderBox, HtmlRenderSelection } from "@t3tools/shared/htmlRender";
import { QuoteIcon } from "lucide-react";
import { use, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
captureAssistantTextSelection,
createAssistantTextSelector,
type AssistantCitationSourceAnchor,
} from "~/lib/assistantTextSelection";
import {
Expand All @@ -17,6 +19,7 @@ import {
resolveSelectionActionPosition,
type SelectionActionPoint,
} from "~/lib/selectionActions";
import { HTML_RENDER_SELECTION_EVENT } from "../files/BrowserDocumentFrame";
import { Button } from "../ui/button";
import { BackgroundThreadContext } from "./backgroundThread";

Expand All @@ -27,14 +30,21 @@ export function AssistantSelectionToolbar({
}: {
viewport: HTMLElement | null;
threadRef: ScopedThreadRef;
onCite: (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => boolean;
onCite: (
citation: AssistantCitation,
sourceAnchor: AssistantCitationSourceAnchor | undefined,
) => boolean;
}) {
const [selection, setSelection] = useState<{
citation: AssistantCitation;
position: SelectionActionPoint;
sourceAnchor: AssistantCitationSourceAnchor;
sourceAnchor: AssistantCitationSourceAnchor | undefined;
} | null>(null);
const toolbarRef = useRef<HTMLButtonElement>(null);
const frameSelectionRef = useRef<{
frame: HTMLIFrameElement;
selection: HtmlRenderSelection;
} | null>(null);
const actionsRef = useRef<ReturnType<typeof observeSelectionActions> | null>(null);
const backgroundThread = use(BackgroundThreadContext);

Expand All @@ -51,7 +61,53 @@ export function AssistantSelectionToolbar({
useEffect(() => {
if (!viewport) return;
const clear = () => setSelection(null);
// A sandboxed frame reports its own selection. The focused document owns the selection,
// so that report yields to the host's once focus leaves the frame.
const updateFromFrame = () => {
const reported = frameSelectionRef.current;
if (!reported) return false;
const { frame, selection: frameSelection } = reported;
const source = frame.closest<HTMLElement>("[data-assistant-citation-source]");
const messageId = source?.dataset.assistantCitationSource;
if (document.activeElement !== frame || !source || !viewport.contains(source) || !messageId) {
frameSelectionRef.current = null;
return false;
}
const frameRect = frame.getBoundingClientRect();
const inHost = (box: HtmlRenderBox) => ({
left: frameRect.left + box.left,
top: frameRect.top + box.top,
right: frameRect.left + box.right,
bottom: frameRect.top + box.bottom,
});
const selectionRect = inHost(frameSelection.rect);
const viewportRect = viewport.getBoundingClientRect();
const selector = createAssistantTextSelector(
frameSelection.text,
0,
frameSelection.text.length,
);
if (
!selector ||
!isSelectionRectVisible(inHost(frameSelection.bounds), [frameRect, viewportRect])
) {
clear();
return true;
}
setSelection({
sourceAnchor: undefined,
citation: { version: 1, ...threadRef, messageId: MessageId.make(messageId), ...selector },
position: resolveSelectionActionPosition({
bounds: viewportRect,
selectionRect,
pointer: null,
viewport: { width: window.innerWidth, height: window.innerHeight },
}),
});
return true;
};
const update = (pointer: SelectionActionPoint | null) => {
if (updateFromFrame()) return;
const nativeSelection = window.getSelection();
const captured = captureAssistantTextSelection(viewport, nativeSelection);
const messageId = captured?.source.dataset.assistantCitationSource;
Expand Down Expand Up @@ -145,6 +201,19 @@ export function AssistantSelectionToolbar({
onDismiss: clear,
});
actionsRef.current = actions;
const onFrameSelection = (event: Event) => {
const frame = event.target;
if (!(frame instanceof HTMLIFrameElement)) return;
const reported = (event as CustomEvent<HtmlRenderSelection | null>).detail;
if (reported) {
frameSelectionRef.current = { frame, selection: reported };
actions.selectionReported();
} else if (frameSelectionRef.current?.frame === frame) {
frameSelectionRef.current = null;
clear();
}
};
viewport.addEventListener(HTML_RENDER_SELECTION_EVENT, onFrameSelection);
const focusActions = (event: KeyboardEvent) => {
const toolbar = toolbarRef.current;
if (
Expand All @@ -169,17 +238,20 @@ export function AssistantSelectionToolbar({
if (!backgroundThread) document.addEventListener("keydown", focusActions, true);
document.addEventListener("selectionchange", actions.selectionChanged);
return () => {
viewport.removeEventListener(HTML_RENDER_SELECTION_EVENT, onFrameSelection);
document.removeEventListener("keydown", focusActions, true);
document.removeEventListener("selectionchange", actions.selectionChanged);
actions.dispose();
actionsRef.current = null;
frameSelectionRef.current = null;
};
}, [backgroundThread, threadRef, viewport]);

if (!selection) return null;
const tooLong = selection.citation.text.length > ASSISTANT_CITATION_MAX_TEXT_LENGTH;
const dismiss = () => {
actionsRef.current?.cancel();
frameSelectionRef.current = null;
setSelection(null);
};
const cite = () => {
Expand Down
14 changes: 7 additions & 7 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ export interface ChatComposerHandle {
pasteTextAtEnd: (text: string, options?: { bypassAutoAttachment?: boolean }) => boolean;
citeAssistantText: (
citation: AssistantCitation,
sourceAnchor: AssistantCitationSourceAnchor,
sourceAnchor: AssistantCitationSourceAnchor | undefined,
) => boolean;
replyToResponse: (response: ResponseContextRecord) => boolean;
openModelPicker: () => void;
Expand Down Expand Up @@ -1718,7 +1718,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
focusEditorAfterReplace?: boolean;
citationComment?: {
start: number;
sourceAnchor: AssistantCitationSourceAnchor;
sourceAnchor: AssistantCitationSourceAnchor | undefined;
insertedSpaces: ComposerCitationCommentRequest["insertedSpaces"];
};
},
Expand Down Expand Up @@ -2553,7 +2553,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
position: "cursor" | "end",
options?: {
ensureLeadingBoundary?: boolean;
citationCommentAnchor?: AssistantCitationSourceAnchor;
citationComment?: { sourceAnchor: AssistantCitationSourceAnchor | undefined };
clipboardData?: DataTransfer;
},
): boolean => {
Expand All @@ -2562,7 +2562,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
activePendingIsResponding ||
(pendingUserInputs.length > 0 && !activePendingProgress?.activeQuestion) ||
projectSelectionRequired ||
(options?.citationCommentAnchor && !composerEditorRef.current)
(options?.citationComment && !composerEditorRef.current)
) {
return false;
}
Expand All @@ -2580,11 +2580,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
cursor,
rangeEnd,
needsLeadingSpace ? ` ${text}` : text,
options?.citationCommentAnchor
options?.citationComment
? {
citationComment: {
start: cursor + (needsLeadingSpace ? 1 : 0),
sourceAnchor: options.citationCommentAnchor,
sourceAnchor: options.citationComment.sourceAnchor,
insertedSpaces: { before: needsLeadingSpace, after: rangeEnd === cursor },
},
focusEditorAfterReplace: false,
Expand Down Expand Up @@ -3037,7 +3037,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
insertComposerTextStable(
formatAssistantCitationForComposer(citation, citation.comment),
"cursor",
{ ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor },
{ ensureLeadingBoundary: true, citationComment: { sourceAnchor } },
),
replyToResponse: (response) => {
// An open question owns the composer, and its answer would not carry the reply.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/HtmlRenderFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function HtmlRenderFrame(props: {
) : null}
</>
) : failed ? (
<p className="flex size-full items-center justify-center text-muted-foreground text-xs">
<p className="flex size-full select-none items-center justify-center text-muted-foreground text-xs">
Unable to load {title}
</p>
) : null}
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { injectHtmlRenderBootstrap } from "@t3tools/shared/htmlRender";
import { HtmlRenderDocument } from "../files/BrowserDocumentFrame";
import { flushSync } from "react-dom";
import { AudioPreview } from "../files/AudioPreview";
import { HTML_RENDER_SELECTION_EVENT } from "../files/BrowserDocumentFrame";
import { cdp, page, userEvent } from "vite-plus/test/browser";
import {
ComposerContextId,
Expand Down Expand Up @@ -93,6 +94,14 @@ vi.mock(import("~/assets/assetUrls"), async (original) => {
};
});

vi.mock(import("~/assets/longLivedAssetUrls"), () => ({
useLongLivedAssetUrl: () => ({
_tag: "Success",
url: "about:blank",
expiresAt: Date.now() + 60 * 60_000,
}),
}));

const ENVIRONMENT_ID = EnvironmentId.make("environment-local");
const CREATED_AT = "2026-03-17T19:12:28.000Z";

Expand Down Expand Up @@ -1314,6 +1323,57 @@ describe("MessagesTimeline citations", () => {
await expect.element(screen.getByText("the cited answer")).toBeVisible();
expect(toast).not.toHaveBeenCalled();
});

it("cites the selection an HTML render reports from its frame", async () => {
const onCiteAssistantText = vi.fn(() => true);
const listRef = createRef<LegendListRef | null>();
const htmlRender: Entry = {
id: "render",
kind: "html-render",
createdAt: CREATED_AT,
runId: null,
htmlRender: { attachmentId: "page.html", title: "Report", height: 2000 },
};
await using screen = await renderTimeline(
[message("Show a report", {}, "question"), htmlRender],
{ onCiteAssistantText, listRef, liveFollowEnabled: false },
);
const frame = (await screen.getByTitle("Report").findElement()) as HTMLIFrameElement;
const viewport = frame.closest<HTMLElement>("[data-assistant-citation-viewport]")!;
await listRef.current?.scrollToOffset({ offset: 0, animated: false });
await vi.waitFor(() =>
expect(frame.getBoundingClientRect().top).toBeGreaterThan(
viewport.getBoundingClientRect().top,
),
);
// The selection starts in view, but its last line sits below the chat viewport.
const below = viewport.getBoundingClientRect().bottom - frame.getBoundingClientRect().top + 100;

frame.focus();
frame.dispatchEvent(
new CustomEvent(HTML_RENDER_SELECTION_EVENT, {
bubbles: true,
detail: {
text: "Quoted words",
rect: { left: 8, top: below, right: 120, bottom: below + 20 },
bounds: { left: 8, top: 8, right: 300, bottom: below + 20 },
},
}),
);
await userEvent.click(page.getByRole("button", { name: "Cite selection in composer" }));

expect(onCiteAssistantText).toHaveBeenCalledWith(
expect.objectContaining({
messageId: "render",
text: "Quoted words",
start: 0,
end: "Quoted words".length,
prefix: "",
suffix: "",
}),
undefined,
);
});
});

describe("MessagesTimeline automation prompts", () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ interface MessagesTimelineProps {
citationHistory?: CitedHistory;
onCiteAssistantText?: (
citation: AssistantCitation,
sourceAnchor: AssistantCitationSourceAnchor,
sourceAnchor: AssistantCitationSourceAnchor | undefined,
) => boolean;
onReplyToResponse?: TimelineRowSharedState["onReplyToResponse"];
isWorking: boolean;
Expand Down
Loading
Loading