diff --git a/webui/src/components/thread/PromptRail.tsx b/webui/src/components/thread/PromptRail.tsx index 9bee73b20..5d4538a33 100644 --- a/webui/src/components/thread/PromptRail.tsx +++ b/webui/src/components/thread/PromptRail.tsx @@ -1,6 +1,15 @@ -import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Fragment, + type RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; +import { MarkdownText } from "@/components/MarkdownText"; import { cn } from "@/lib/utils"; import type { UIMessage } from "@/lib/types"; import { @@ -155,64 +164,87 @@ export function PromptRail({ > {markers.map((marker, index) => { const active = marker.ids.includes(activePromptId ?? ""); + const previewVisible = focusedMarkerIndex === index; const hoverDistance = focusedMarkerIndex === null ? null : Math.abs(index - focusedMarkerIndex); return ( - +
- - {marker.preview} - - {marker.answerPreview ? ( - - {marker.answerPreview} - + {previewVisible ? ( + <> +
+ {marker.preview} +
+ {marker.answerPreview ? ( +
+ + {marker.answerPreview} + +
+ ) : null} + ) : null} - - +
+ ); })} ); } +function makeInert(node: HTMLDivElement | null): void { + if (node) node.inert = true; +} + function measurePrompts( scrollEl: HTMLElement, anchors: PromptAnchor[], diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index b87417eec..4e5552414 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -157,10 +157,11 @@ function getScroller(container: HTMLElement): HTMLElement { async function renderPromptRailViewport({ scrollTo, + messages: promptMessages = makePromptExchangeMessages(5), }: { scrollTo?: (options?: ScrollToOptions) => void; + messages?: UIMessage[]; } = {}) { - const promptMessages = makePromptExchangeMessages(5); const { container } = render( { expect(railMarkers.every((marker) => marker.style.width === "9px")).toBe(true); const targetPrompt = screen.getByRole("button", { name: "Jump to prompt: message 3" }); - expect(within(targetPrompt).getByText("message 3")).toBeInTheDocument(); - expect(within(targetPrompt).getByText("answer 3")).toBeInTheDocument(); + fireEvent.pointerEnter(targetPrompt); + const preview = screen.getByTestId("prompt-rail-preview"); + expect(within(preview).getByText("message 3")).toBeInTheDocument(); + expect(within(preview).getByText("answer 3")).toBeInTheDocument(); fireEvent.click(targetPrompt); expect(navigateTo).toHaveBeenCalledWith(1064); }); + it("renders markdown in prompt rail previews", async () => { + const promptMessages = makePromptExchangeMessages(5); + const answer = promptMessages.find((message) => message.id === "a3"); + if (!answer) throw new TypeError("prompt answer fixture missing"); + answer.content = "### Confirmed limit\n\nUse the **policy cap**."; + + await renderPromptRailViewport({ messages: promptMessages }); + + const targetPrompt = screen.getByRole("button", { name: "Jump to prompt: message 3" }); + fireEvent.pointerEnter(targetPrompt); + const preview = screen.getByTestId("prompt-rail-preview"); + + await waitFor(() => { + expect(preview.querySelector("h3")).toHaveTextContent("Confirmed limit"); + }); + expect(preview.querySelector("strong")).toHaveTextContent("policy cap"); + expect(preview).not.toHaveTextContent("###"); + expect(preview).not.toHaveTextContent("**"); + }); + it("lets direct paging input interrupt prompt rail navigation", async () => { vi.spyOn(ThreadCameraController.prototype, "navigateTo") .mockReturnValue("started");