refactor(webui): use container query for prompt rail layout

This commit is contained in:
chengyongru
2026-07-08 12:16:47 +08:00
committed by Xubin Ren
parent 815f15993c
commit c188e96f4e
4 changed files with 34 additions and 117 deletions
+6 -50
View File
@@ -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}