fix(webui): keep prompt rail out of narrow chat columns

This commit is contained in:
chengyongru
2026-07-08 12:16:47 +08:00
committed by Xubin Ren
parent 65d32ffd6c
commit 4333d6f103
3 changed files with 148 additions and 6 deletions
+49 -5
View File
@@ -12,6 +12,7 @@ import {
interface PromptRailProps { interface PromptRailProps {
bottomOffset: number; bottomOffset: number;
contentRef: RefObject<HTMLElement>;
messages: UIMessage[]; messages: UIMessage[];
scrollRef: RefObject<HTMLDivElement>; scrollRef: RefObject<HTMLDivElement>;
} }
@@ -42,9 +43,19 @@ const MARKER_STACK_GAP_PX = 16;
const RAIL_FALLBACK_HEIGHT_PX = 300; const RAIL_FALLBACK_HEIGHT_PX = 300;
const MEASURE_RETRY_FRAMES = 4; const MEASURE_RETRY_FRAMES = 4;
const HOVER_MARKER_WIDTHS_PX = [28, 22, 16, 11]; 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({ export function PromptRail({
bottomOffset, bottomOffset,
contentRef,
messages, messages,
scrollRef, scrollRef,
}: PromptRailProps) { }: PromptRailProps) {
@@ -53,12 +64,41 @@ export function PromptRail({
const [markers, setMarkers] = useState<PromptMarker[]>([]); const [markers, setMarkers] = useState<PromptMarker[]>([]);
const [activePromptId, setActivePromptId] = useState<string | null>(null); const [activePromptId, setActivePromptId] = useState<string | null>(null);
const [focusedMarkerIndex, setFocusedMarkerIndex] = useState<number | 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 updateMarkers = useCallback(() => {
const scrollEl = scrollRef.current; const scrollEl = scrollRef.current;
const nextRailHeight = railRef.current?.clientHeight ?? 0; const nextRailHeight = railRef.current?.clientHeight ?? 0;
const nextRailLayout = measureRailLayout();
setRailLayout((current) =>
current.visible === nextRailLayout.visible && current.left === nextRailLayout.left
? current
: nextRailLayout,
);
if (!scrollEl || promptAnchors.length < MIN_PROMPTS_FOR_RAIL) { if (!nextRailLayout.visible || !scrollEl || promptAnchors.length < MIN_PROMPTS_FOR_RAIL) {
setMarkers([]); setMarkers([]);
setActivePromptId(null); setActivePromptId(null);
return; return;
@@ -75,7 +115,7 @@ export function PromptRail({
const grouped = groupPromptMarkers(measured, nextRailHeight); const grouped = groupPromptMarkers(measured, nextRailHeight);
setMarkers(distributeMarkerPositions(grouped, nextRailHeight)); setMarkers(distributeMarkerPositions(grouped, nextRailHeight));
setActivePromptId(activePromptForScroll(measured, scrollEl.scrollTop)); setActivePromptId(activePromptForScroll(measured, scrollEl.scrollTop));
}, [promptAnchors, scrollRef]); }, [measureRailLayout, promptAnchors, scrollRef]);
useEffect(() => { useEffect(() => {
let frame = 0; let frame = 0;
@@ -116,10 +156,11 @@ export function PromptRail({
const observer = new ResizeObserver(() => updateMarkers()); const observer = new ResizeObserver(() => updateMarkers());
observer.observe(scrollEl); observer.observe(scrollEl);
if (scrollEl.firstElementChild) observer.observe(scrollEl.firstElementChild); if (scrollEl.firstElementChild) observer.observe(scrollEl.firstElementChild);
if (contentRef.current) observer.observe(contentRef.current);
return () => observer.disconnect(); return () => observer.disconnect();
}, [scrollRef, updateMarkers]); }, [contentRef, scrollRef, updateMarkers]);
if (markers.length === 0) return null; if (!railLayout.visible || markers.length === 0) return null;
return ( return (
<div <div
@@ -131,7 +172,10 @@ export function PromptRail({
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200", "motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200",
)} )}
onPointerLeave={() => setFocusedMarkerIndex(null)} onPointerLeave={() => setFocusedMarkerIndex(null)}
style={{ bottom: Math.max(80, bottomOffset) }} style={{
bottom: Math.max(80, bottomOffset),
...(railLayout.left == null ? {} : { left: railLayout.left }),
}}
> >
{markers.map((marker, index) => { {markers.map((marker, index) => {
const active = marker.ids.includes(activePromptId ?? ""); const active = marker.ids.includes(activePromptId ?? "");
@@ -122,6 +122,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const { t } = useTranslation(); const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const messageColumnRef = useRef<HTMLDivElement>(null);
const composerDockRef = useRef<HTMLDivElement>(null); const composerDockRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey); const lastConversationKeyRef = useRef<string | null>(conversationKey);
@@ -519,7 +520,11 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
data-testid="thread-message-region" 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" className="flex min-h-0 flex-1 flex-col justify-start px-3 pb-4 pt-4 sm:px-4"
> >
<div className="mx-auto w-full max-w-[49.5rem]"> <div
ref={messageColumnRef}
data-testid="thread-message-column"
className="mx-auto w-full max-w-[49.5rem]"
>
<ThreadMessages <ThreadMessages
messages={visibleMessages} messages={visibleMessages}
isStreaming={isStreaming} isStreaming={isStreaming}
@@ -567,6 +572,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<PromptRail <PromptRail
messages={visibleMessages} messages={visibleMessages}
scrollRef={scrollRef} scrollRef={scrollRef}
contentRef={messageColumnRef}
bottomOffset={scrollButtonBottom} bottomOffset={scrollButtonBottom}
/> />
) : null} ) : null}
+92
View File
@@ -132,6 +132,24 @@ function makePromptExchangeMessages(count: number): UIMessage[] {
])).flat(); ])).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);
}
function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) { function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
const viewportRef = useRef<ThreadViewportHandle | null>(null); const viewportRef = useRef<ThreadViewportHandle | null>(null);
return ( return (
@@ -807,6 +825,80 @@ describe("ThreadViewport", () => {
}); });
}); });
it("positions the prompt rail in the gutter before the message column", async () => {
const promptMessages = makePromptExchangeMessages(5);
const { container } = render(
<ThreadViewport
messages={promptMessages}
isStreaming={false}
composer={<div />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1800 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
});
stubElementRect(scroller, 0, 1200);
stubElementRect(screen.getByTestId("thread-message-column"), 180, 792);
const promptEls = Array.from(
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
);
promptEls.forEach((el, index) => {
Object.defineProperty(el, "offsetTop", {
configurable: true,
value: index * 360,
});
});
await act(async () => {
window.dispatchEvent(new Event("resize"));
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
});
expect(screen.getByLabelText("User prompt navigation")).toHaveStyle({ left: "124px" });
});
it("hides the prompt rail when the message column leaves no side gutter", async () => {
const promptMessages = makePromptExchangeMessages(5);
const { container } = render(
<ThreadViewport
messages={promptMessages}
isStreaming={false}
composer={<div />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1800 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
});
stubElementRect(scroller, 0, 560);
stubElementRect(screen.getByTestId("thread-message-column"), 48, 480);
const promptEls = Array.from(
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
);
promptEls.forEach((el, index) => {
Object.defineProperty(el, "offsetTop", {
configurable: true,
value: index * 360,
});
});
await act(async () => {
window.dispatchEvent(new Event("resize"));
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
});
expect(screen.queryByLabelText("User prompt navigation")).not.toBeInTheDocument();
});
it("opens a prompt navigator list and jumps to a selected prompt", async () => { it("opens a prompt navigator list and jumps to a selected prompt", async () => {
const promptMessages = makeLongMessages(5); const promptMessages = makeLongMessages(5);
const { container } = render(<ViewportWithPromptNavigator messages={promptMessages} />); const { container } = render(<ViewportWithPromptNavigator messages={promptMessages} />);