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)",