From 4333d6f10382bf7a86054dc1c432abe65358127a Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 7 Jul 2026 16:31:32 +0800 Subject: [PATCH] fix(webui): keep prompt rail out of narrow chat columns --- webui/src/components/thread/PromptRail.tsx | 54 ++++++++++- .../src/components/thread/ThreadViewport.tsx | 8 +- webui/src/tests/thread-viewport.test.tsx | 92 +++++++++++++++++++ 3 files changed, 148 insertions(+), 6 deletions(-) diff --git a/webui/src/components/thread/PromptRail.tsx b/webui/src/components/thread/PromptRail.tsx index 6714ed2c..52e6cf6f 100644 --- a/webui/src/components/thread/PromptRail.tsx +++ b/webui/src/components/thread/PromptRail.tsx @@ -12,6 +12,7 @@ import { interface PromptRailProps { bottomOffset: number; + contentRef: RefObject; messages: UIMessage[]; scrollRef: RefObject; } @@ -42,9 +43,19 @@ 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) { @@ -53,12 +64,41 @@ export function PromptRail({ const [markers, setMarkers] = useState([]); const [activePromptId, setActivePromptId] = useState(null); const [focusedMarkerIndex, setFocusedMarkerIndex] = useState(null); + const [railLayout, setRailLayout] = useState({ 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 (!scrollEl || promptAnchors.length < MIN_PROMPTS_FOR_RAIL) { + if (!nextRailLayout.visible || !scrollEl || promptAnchors.length < MIN_PROMPTS_FOR_RAIL) { setMarkers([]); setActivePromptId(null); return; @@ -75,7 +115,7 @@ export function PromptRail({ const grouped = groupPromptMarkers(measured, nextRailHeight); setMarkers(distributeMarkerPositions(grouped, nextRailHeight)); setActivePromptId(activePromptForScroll(measured, scrollEl.scrollTop)); - }, [promptAnchors, scrollRef]); + }, [measureRailLayout, promptAnchors, scrollRef]); useEffect(() => { let frame = 0; @@ -116,10 +156,11 @@ 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(); - }, [scrollRef, updateMarkers]); + }, [contentRef, scrollRef, updateMarkers]); - if (markers.length === 0) return null; + if (!railLayout.visible || markers.length === 0) return null; return (
setFocusedMarkerIndex(null)} - style={{ bottom: Math.max(80, bottomOffset) }} + style={{ + bottom: Math.max(80, bottomOffset), + ...(railLayout.left == null ? {} : { left: railLayout.left }), + }} > {markers.map((marker, index) => { const active = marker.ids.includes(activePromptId ?? ""); diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 9646d6bc..48d62818 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -122,6 +122,7 @@ export const ThreadViewport = forwardRef(null); const contentRef = useRef(null); + const messageColumnRef = useRef(null); const composerDockRef = useRef(null); const bottomRef = useRef(null); const lastConversationKeyRef = useRef(conversationKey); @@ -519,7 +520,11 @@ export const ThreadViewport = forwardRef -
+
) : null} diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index e8524fa6..203b59d2 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -132,6 +132,24 @@ 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); +} + function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) { const viewportRef = useRef(null); 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( + } + />, + ); + + 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("[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((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( + } + />, + ); + + 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("[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((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 () => { const promptMessages = makeLongMessages(5); const { container } = render();