diff --git a/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx b/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx
index 667c19fe4718c..5540f8eafa1fd 100644
--- a/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx
+++ b/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx
@@ -55,6 +55,7 @@ function mount(
sourceAnchor: AssistantCitationSourceAnchor | undefined,
onSave: () => boolean,
chipCitation: AssistantCitation = citation,
+ container?: HTMLElement,
) {
function Composer() {
const [open, setOpen] = useState(true);
@@ -65,7 +66,7 @@ function mount(
/>
);
}
- return render();
+ return render(, container ? { container } : {});
}
const textarea = () =>
@@ -217,4 +218,67 @@ describe("citation comment popup position", () => {
expect(rect.left).toBeGreaterThanOrEqual(0);
expect(rect.right).toBeLessThanOrEqual(window.innerWidth);
});
+
+ it("pins to the timeline edge the quote scrolled past, and typing scrolls nothing", async () => {
+ using source = setup();
+ const spacer = () => {
+ const element = document.createElement("div");
+ element.style.height = `${window.innerHeight * 2}px`;
+ return element;
+ };
+ const timeline = document.createElement("div");
+ timeline.style.cssText = "position: fixed; inset: 0; overflow-y: auto;";
+ timeline.append(spacer(), source.sourceAnchor.source, spacer());
+ document.body.append(timeline);
+ using _timeline = { [Symbol.dispose]: () => timeline.remove() };
+ // Moving the source collapsed the range.
+ source.sourceAnchor.range.selectNodeContents(source.sourceAnchor.source);
+ const quoteAt = (top: number) => {
+ timeline.scrollTop += source.sourceAnchor.source.getBoundingClientRect().top - top;
+ };
+ quoteAt(100);
+ // The composer floats over the bottom of the timeline.
+ const composer = document.createElement("div");
+ composer.dataset.chatComposerStack = "true";
+ composer.style.cssText = "position: fixed; inset-inline: 0; bottom: 0; height: 120px;";
+ const composerTop = window.innerHeight - 120;
+
+ await using _screen = await mount(
+ { ...source.sourceAnchor, viewport: timeline },
+ vi.fn(() => true),
+ citation,
+ composer,
+ );
+ const popup = () => document.querySelector('[data-slot="popover-popup"]')!;
+ await vi.waitFor(() => {
+ const quote = source.sourceAnchor.source.getBoundingClientRect();
+ expect(popup().getBoundingClientRect().top).toBeGreaterThanOrEqual(quote.bottom);
+ });
+
+ // Scrolled up, the quote goes under the composer and the popup stops just above it.
+ quoteAt(window.innerHeight);
+ const scrolledUp = timeline.scrollTop;
+ await vi.waitFor(() => {
+ const rect = popup().getBoundingClientRect();
+ expect(rect.bottom).toBeLessThanOrEqual(composerTop);
+ expect(rect.bottom).toBeGreaterThan(composerTop - 20);
+ });
+ await userEvent.keyboard("note");
+ expect(textarea()?.value).toBe("note");
+ expect(document.scrollingElement?.scrollTop).toBe(0);
+ expect(timeline.scrollTop).toBe(scrolledUp);
+
+ // Scrolled down past the quote, the popup stops at the top of the timeline.
+ quoteAt(-200);
+ const scrolledDown = timeline.scrollTop;
+ await vi.waitFor(() => {
+ const rect = popup().getBoundingClientRect();
+ expect(rect.top).toBeGreaterThanOrEqual(0);
+ expect(rect.top).toBeLessThan(20);
+ });
+ await userEvent.keyboard("!");
+ expect(textarea()?.value).toBe("note!");
+ expect(document.scrollingElement?.scrollTop).toBe(0);
+ expect(timeline.scrollTop).toBe(scrolledDown);
+ });
});
diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx
index 5a86f65399fcd..3acb9d712ef25 100644
--- a/apps/web/src/components/chat/AssistantCitationChip.tsx
+++ b/apps/web/src/components/chat/AssistantCitationChip.tsx
@@ -50,6 +50,32 @@ function useCitationSourceLinkProps(citation: AssistantCitation) {
};
}
+/** The part of the timeline left visible by the composer, which floats over its bottom. */
+function visibleTimelineRect(viewport: HTMLElement, composer: Element | null | undefined) {
+ const bounds = viewport.getBoundingClientRect();
+ const composerTop = composer?.getBoundingClientRect().top ?? bounds.bottom;
+ const bottom = Math.max(bounds.top, Math.min(bounds.bottom, composerTop));
+ return new DOMRect(bounds.left, bounds.top, bounds.width, bottom - bounds.top);
+}
+
+/** Floating UI reads a rect boundary on every position update, so getters keep it current. */
+function liveBoundary(read: () => DOMRect) {
+ return {
+ get x() {
+ return read().x;
+ },
+ get y() {
+ return read().y;
+ },
+ get width() {
+ return read().width;
+ },
+ get height() {
+ return read().height;
+ },
+ };
+}
+
const flat = (text: string) => text.replace(/\s+/g, " ");
/**
@@ -195,6 +221,7 @@ export function AssistantCitationChip({
const sourceLinkProps = useCitationSourceLinkProps(citation);
const commentInputRef = useRef(null);
const commentPopupRef = useRef(null);
+ const [commentTrigger, setCommentTrigger] = useState(null);
const draftCommentRef = useRef(null);
const [voiceSetupOpen, setVoiceSetupOpen] = useState(false);
const [unavailableSourceAnchor, setUnavailableSourceAnchor] =
@@ -234,6 +261,11 @@ export function AssistantCitationChip({
onUnavailable: onSourceUnavailable,
});
}, [citation, commentOpen, sourceAnchor, unavailableSourceAnchor]);
+ const visibleTimeline = (anchor: AssistantCitationSourceAnchor) =>
+ visibleTimelineRect(
+ anchor.viewport,
+ commentTrigger?.closest('[data-chat-composer-stack="true"]'),
+ );
const popupAnchor = activeSourceAnchor
? {
contextElement: activeSourceAnchor.source,
@@ -247,10 +279,17 @@ export function AssistantCitationChip({
const bounds = activeSourceAnchor.source.getBoundingClientRect();
const left = Math.min(Math.max(rect.left, bounds.left), bounds.right);
const right = Math.max(Math.min(rect.right, bounds.right), left);
- return new DOMRect(left, rect.top, right - left, rect.height);
+ // A quote scrolled out of view pins the popup to the timeline edge it left by.
+ const timeline = visibleTimeline(activeSourceAnchor);
+ const top = Math.min(Math.max(rect.top, timeline.top), timeline.bottom);
+ const bottom = Math.min(Math.max(rect.bottom, top), timeline.bottom);
+ return new DOMRect(left, top, right - left, bottom - top);
},
}
: undefined;
+ const popupBoundary = activeSourceAnchor
+ ? liveBoundary(() => visibleTimeline(activeSourceAnchor))
+ : undefined;
const label = assistantCitationLabel(citation);
const composerSourceLink = (
}
@@ -298,6 +338,7 @@ export function AssistantCitationChip({
side={activeSourceAnchor ? "bottom" : "top"}
align="end"
anchor={popupAnchor}
+ collisionBoundary={popupBoundary}
initialFocus={() => {
const input = commentInputRef.current;
if (input) {
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx
index ecbf7a6667062..3f87af7fff635 100644
--- a/apps/web/src/components/ui/popover.tsx
+++ b/apps/web/src/components/ui/popover.tsx
@@ -40,6 +40,7 @@ function PopoverPopup({
sideOffset = 4,
alignOffset = 0,
collisionAvoidance,
+ collisionBoundary,
tooltipStyle = false,
keepMounted = false,
anchor,
@@ -53,6 +54,7 @@ function PopoverPopup({
sideOffset?: PopoverPrimitive.Positioner.Props["sideOffset"];
alignOffset?: PopoverPrimitive.Positioner.Props["alignOffset"];
collisionAvoidance?: PopoverPrimitive.Positioner.Props["collisionAvoidance"];
+ collisionBoundary?: PopoverPrimitive.Positioner.Props["collisionBoundary"];
tooltipStyle?: boolean;
keepMounted?: PopoverPrimitive.Portal.Props["keepMounted"];
anchor?: PopoverPrimitive.Positioner.Props["anchor"];
@@ -76,6 +78,7 @@ function PopoverPopup({
alignOffset={alignOffset}
anchor={anchor}
collisionAvoidance={collisionAvoidance}
+ collisionBoundary={collisionBoundary}
collisionPadding={variant === "panel" ? 0 : undefined}
className={cn(
"h-(--positioner-height) max-h-(--available-height) w-(--positioner-width) max-w-(--available-width)",