fix(webui): keep prompt rail out of narrow chat columns
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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} />);
|
||||||
|
|||||||
Reference in New Issue
Block a user