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
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function mount(
sourceAnchor: AssistantCitationSourceAnchor | undefined,
onSave: () => boolean,
chipCitation: AssistantCitation = citation,
container?: HTMLElement,
) {
function Composer() {
const [open, setOpen] = useState(true);
Expand All @@ -65,7 +66,7 @@ function mount(
/>
);
}
return render(<Composer />);
return render(<Composer />, container ? { container } : {});
}

const textarea = () =>
Expand Down Expand Up @@ -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<HTMLElement>('[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);
});
});
43 changes: 42 additions & 1 deletion apps/web/src/components/chat/AssistantCitationChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, " ");

/**
Expand Down Expand Up @@ -195,6 +221,7 @@ export function AssistantCitationChip({
const sourceLinkProps = useCitationSourceLinkProps(citation);
const commentInputRef = useRef<HTMLTextAreaElement>(null);
const commentPopupRef = useRef<HTMLDivElement>(null);
const [commentTrigger, setCommentTrigger] = useState<HTMLButtonElement | null>(null);
const draftCommentRef = useRef<string | null>(null);
const [voiceSetupOpen, setVoiceSetupOpen] = useState(false);
const [unavailableSourceAnchor, setUnavailableSourceAnchor] =
Expand Down Expand Up @@ -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,
Expand All @@ -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 = (
<Link
Expand Down Expand Up @@ -284,6 +323,7 @@ export function AssistantCitationChip({
}}
>
<PopoverTrigger
ref={setCommentTrigger}
aria-label={citation.comment ? "Edit citation comment" : "Add comment to citation"}
data-citation-comment-trigger="true"
render={<ContextChipAction />}
Expand All @@ -298,6 +338,7 @@ export function AssistantCitationChip({
side={activeSourceAnchor ? "bottom" : "top"}
align="end"
anchor={popupAnchor}
collisionBoundary={popupBoundary}
initialFocus={() => {
const input = commentInputRef.current;
if (input) {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/ui/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function PopoverPopup({
sideOffset = 4,
alignOffset = 0,
collisionAvoidance,
collisionBoundary,
tooltipStyle = false,
keepMounted = false,
anchor,
Expand All @@ -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"];
Expand All @@ -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)",
Expand Down
Loading