refactor(webui): use container query for prompt rail layout
This commit is contained in:
@@ -12,7 +12,6 @@ import {
|
||||
|
||||
interface PromptRailProps {
|
||||
bottomOffset: number;
|
||||
contentRef: RefObject<HTMLElement>;
|
||||
messages: UIMessage[];
|
||||
scrollRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
@@ -43,19 +42,9 @@ const MARKER_STACK_GAP_PX = 16;
|
||||
const RAIL_FALLBACK_HEIGHT_PX = 300;
|
||||
const MEASURE_RETRY_FRAMES = 4;
|
||||
const HOVER_MARKER_WIDTHS_PX = [28, 22, 16, 11];
|
||||
const RAIL_WIDTH_PX = 36;
|
||||
const RAIL_MIN_EDGE_GAP_PX = 12;
|
||||
const RAIL_COLUMN_GAP_PX = 20;
|
||||
const RAIL_REQUIRED_GUTTER_PX = RAIL_MIN_EDGE_GAP_PX + RAIL_WIDTH_PX + RAIL_COLUMN_GAP_PX;
|
||||
|
||||
interface RailLayout {
|
||||
visible: boolean;
|
||||
left: number | null;
|
||||
}
|
||||
|
||||
export function PromptRail({
|
||||
bottomOffset,
|
||||
contentRef,
|
||||
messages,
|
||||
scrollRef,
|
||||
}: PromptRailProps) {
|
||||
@@ -64,41 +53,12 @@ export function PromptRail({
|
||||
const [markers, setMarkers] = useState<PromptMarker[]>([]);
|
||||
const [activePromptId, setActivePromptId] = useState<string | null>(null);
|
||||
const [focusedMarkerIndex, setFocusedMarkerIndex] = useState<number | null>(null);
|
||||
const [railLayout, setRailLayout] = useState<RailLayout>({ visible: true, left: null });
|
||||
|
||||
const measureRailLayout = useCallback((): RailLayout => {
|
||||
const scrollEl = scrollRef.current;
|
||||
const contentEl = contentRef.current;
|
||||
if (!scrollEl || !contentEl) return { visible: true, left: null };
|
||||
|
||||
const scrollRect = scrollEl.getBoundingClientRect();
|
||||
const contentRect = contentEl.getBoundingClientRect();
|
||||
if (scrollRect.width <= 0 || contentRect.width <= 0) {
|
||||
return { visible: true, left: null };
|
||||
}
|
||||
|
||||
const leftGutter = contentRect.left - scrollRect.left;
|
||||
if (leftGutter < RAIL_REQUIRED_GUTTER_PX) {
|
||||
return { visible: false, left: null };
|
||||
}
|
||||
|
||||
return {
|
||||
visible: true,
|
||||
left: Math.round(leftGutter - RAIL_WIDTH_PX - RAIL_COLUMN_GAP_PX),
|
||||
};
|
||||
}, [contentRef, scrollRef]);
|
||||
|
||||
const updateMarkers = useCallback(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
const nextRailHeight = railRef.current?.clientHeight ?? 0;
|
||||
const nextRailLayout = measureRailLayout();
|
||||
setRailLayout((current) =>
|
||||
current.visible === nextRailLayout.visible && current.left === nextRailLayout.left
|
||||
? current
|
||||
: nextRailLayout,
|
||||
);
|
||||
|
||||
if (!nextRailLayout.visible || !scrollEl || promptAnchors.length < MIN_PROMPTS_FOR_RAIL) {
|
||||
if (!scrollEl || promptAnchors.length < MIN_PROMPTS_FOR_RAIL) {
|
||||
setMarkers([]);
|
||||
setActivePromptId(null);
|
||||
return;
|
||||
@@ -115,7 +75,7 @@ export function PromptRail({
|
||||
const grouped = groupPromptMarkers(measured, nextRailHeight);
|
||||
setMarkers(distributeMarkerPositions(grouped, nextRailHeight));
|
||||
setActivePromptId(activePromptForScroll(measured, scrollEl.scrollTop));
|
||||
}, [measureRailLayout, promptAnchors, scrollRef]);
|
||||
}, [promptAnchors, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
@@ -156,26 +116,22 @@ export function PromptRail({
|
||||
const observer = new ResizeObserver(() => updateMarkers());
|
||||
observer.observe(scrollEl);
|
||||
if (scrollEl.firstElementChild) observer.observe(scrollEl.firstElementChild);
|
||||
if (contentRef.current) observer.observe(contentRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [contentRef, scrollRef, updateMarkers]);
|
||||
}, [scrollRef, updateMarkers]);
|
||||
|
||||
if (!railLayout.visible || markers.length === 0) return null;
|
||||
if (markers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={railRef}
|
||||
aria-label="User prompt navigation"
|
||||
className={cn(
|
||||
"group pointer-events-auto absolute left-7 top-3 z-20 hidden w-9 opacity-100 md:block",
|
||||
"thread-prompt-rail group pointer-events-auto absolute top-3 z-20 w-9 opacity-100",
|
||||
"transition-opacity duration-200",
|
||||
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200",
|
||||
)}
|
||||
onPointerLeave={() => setFocusedMarkerIndex(null)}
|
||||
style={{
|
||||
bottom: Math.max(80, bottomOffset),
|
||||
...(railLayout.left == null ? {} : { left: railLayout.left }),
|
||||
}}
|
||||
style={{ bottom: Math.max(80, bottomOffset) }}
|
||||
>
|
||||
{markers.map((marker, index) => {
|
||||
const active = marker.ids.includes(activePromptId ?? "");
|
||||
|
||||
@@ -122,7 +122,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const messageColumnRef = useRef<HTMLDivElement>(null);
|
||||
const composerDockRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||
@@ -502,7 +501,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
}, [maybeLoadEarlierFromScroll]);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
@@ -520,11 +519,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
data-testid="thread-message-region"
|
||||
className="flex min-h-0 flex-1 flex-col justify-start px-3 pb-4 pt-4 sm:px-4"
|
||||
>
|
||||
<div
|
||||
ref={messageColumnRef}
|
||||
data-testid="thread-message-column"
|
||||
className="mx-auto w-full max-w-[49.5rem]"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
||||
<ThreadMessages
|
||||
messages={visibleMessages}
|
||||
isStreaming={isStreaming}
|
||||
@@ -572,7 +567,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<PromptRail
|
||||
messages={visibleMessages}
|
||||
scrollRef={scrollRef}
|
||||
contentRef={messageColumnRef}
|
||||
bottomOffset={scrollButtonBottom}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -568,4 +568,30 @@
|
||||
.thread-viewport-scrollbar {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.thread-viewport {
|
||||
--thread-message-column-half: 24.75rem;
|
||||
--thread-prompt-rail-offset: 3.5rem;
|
||||
container-name: thread-viewport;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.thread-prompt-rail {
|
||||
display: none;
|
||||
left: calc(
|
||||
50% - var(--thread-message-column-half) - var(--thread-prompt-rail-offset)
|
||||
);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.thread-prompt-rail {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@container thread-viewport (max-width: 58rem) {
|
||||
.thread-prompt-rail {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,35 +132,9 @@ function makePromptExchangeMessages(count: number): UIMessage[] {
|
||||
])).flat();
|
||||
}
|
||||
|
||||
function elementRect(left: number, width: number, height = 600): DOMRect {
|
||||
return {
|
||||
x: left,
|
||||
y: 0,
|
||||
left,
|
||||
top: 0,
|
||||
right: left + width,
|
||||
bottom: height,
|
||||
width,
|
||||
height,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
function stubElementRect(element: HTMLElement, left: number, width: number, height = 600) {
|
||||
element.getBoundingClientRect = () => elementRect(left, width, height);
|
||||
}
|
||||
|
||||
interface PromptRailLayoutStub {
|
||||
scrollerWidth: number;
|
||||
messageColumnLeft: number;
|
||||
messageColumnWidth: number;
|
||||
}
|
||||
|
||||
async function renderPromptRailViewport({
|
||||
layout,
|
||||
scrollTo,
|
||||
}: {
|
||||
layout?: PromptRailLayoutStub;
|
||||
scrollTo?: (options?: ScrollToOptions) => void;
|
||||
} = {}) {
|
||||
const promptMessages = makePromptExchangeMessages(5);
|
||||
@@ -180,15 +154,6 @@ async function renderPromptRailViewport({
|
||||
...(scrollTo ? { scrollTo: { configurable: true, value: scrollTo } } : {}),
|
||||
});
|
||||
|
||||
if (layout) {
|
||||
stubElementRect(scroller, 0, layout.scrollerWidth);
|
||||
stubElementRect(
|
||||
screen.getByTestId("thread-message-column"),
|
||||
layout.messageColumnLeft,
|
||||
layout.messageColumnWidth,
|
||||
);
|
||||
}
|
||||
|
||||
const promptEls = Array.from(
|
||||
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
|
||||
);
|
||||
@@ -853,30 +818,6 @@ describe("ThreadViewport", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("positions the prompt rail in the gutter before the message column", async () => {
|
||||
await renderPromptRailViewport({
|
||||
layout: {
|
||||
scrollerWidth: 1200,
|
||||
messageColumnLeft: 180,
|
||||
messageColumnWidth: 792,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("User prompt navigation")).toHaveStyle({ left: "124px" });
|
||||
});
|
||||
|
||||
it("hides the prompt rail when the message column leaves no side gutter", async () => {
|
||||
await renderPromptRailViewport({
|
||||
layout: {
|
||||
scrollerWidth: 560,
|
||||
messageColumnLeft: 48,
|
||||
messageColumnWidth: 480,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText("User prompt navigation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a prompt navigator list and jumps to a selected prompt", async () => {
|
||||
const promptMessages = makeLongMessages(5);
|
||||
const { container } = render(<ViewportWithPromptNavigator messages={promptMessages} />);
|
||||
|
||||
Reference in New Issue
Block a user