diff --git a/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx b/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx index 790fb481174e5..d5f3d6debba7b 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx @@ -152,3 +152,25 @@ describe("citation comment source disappearance", () => { expect(textarea()).toBeNull(); }); }); + +describe("citation comment popup placement", () => { + it("opens inside the window when the cited line ends in a code block's horizontal overflow", async () => { + const pre = document.createElement("pre"); + pre.style.cssText = "width: 120px; overflow-x: auto; white-space: pre; margin: 0"; + pre.textContent = `${"x".repeat(400)}\ny`; + document.body.append(pre); + const range = document.createRange(); + range.setStart(pre.firstChild!, 0); + range.setEnd(pre.firstChild!, 401); + sourceAnchor = { source: pre, range, viewport: document.body } as AssistantCitationSourceAnchor; + // The anchor is the selection's last rect: a narrow line-end rect past the window edge. + const rects = range.getClientRects(); + expect(rects.item(rects.length - 1)!.left).toBeGreaterThan(window.innerWidth); + + await mount(); + + const popup = document.querySelector('[data-slot="popover-popup"]')!.getBoundingClientRect(); + expect(popup.left).toBeGreaterThanOrEqual(0); + expect(popup.right).toBeLessThanOrEqual(window.innerWidth); + }); +}); diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 741880d411614..6986aaaad593c 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -175,6 +175,10 @@ export function AssistantCitationChip({ {...composerFloatingLayerProps} side={activeSourceAnchor ? "bottom" : "top"} align="end" + // The anchor is the quote's last line rect, which can lie past the window edge + // inside a code block's horizontal overflow. Without sticky, Base UI keeps the + // popup attached to that rect instead of shifting it into view. + sticky anchor={popupAnchor} initialFocus={() => { commentInputRef.current?.focus({ preventScroll: true }); diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 08a10e143a3ff..4d3e7ea2d6263 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, + sticky, 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"]; + sticky?: PopoverPrimitive.Positioner.Props["sticky"]; tooltipStyle?: boolean; keepMounted?: PopoverPrimitive.Portal.Props["keepMounted"]; anchor?: PopoverPrimitive.Positioner.Props["anchor"]; @@ -78,6 +80,7 @@ function PopoverPopup({ alignOffset={alignOffset} anchor={anchor} collisionAvoidance={collisionAvoidance} + sticky={sticky} collisionPadding={variant === "panel" ? 0 : undefined} className={cn( "h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-transform data-instant:transition-none",