fix(webui): render markdown in prompt rail previews (#5244)

This commit is contained in:
chengyongru 2026-08-04 18:24:54 +08:00 committed by GitHub
parent 29fdb7d628
commit 5770329542
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 99 additions and 44 deletions

View File

@ -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 (
<button
key={marker.ids.join("|")}
type="button"
aria-label={t("thread.promptNavigator.jumpTo", { label: marker.label })}
onClick={() => onJumpToPrompt(marker.ids[marker.ids.length - 1])}
onBlur={() => setFocusedMarkerIndex(null)}
onFocus={() => setFocusedMarkerIndex(index)}
onPointerEnter={() => setFocusedMarkerIndex(index)}
onPointerLeave={() => setFocusedMarkerIndex(null)}
className={cn(
"group/marker absolute left-0 h-4 w-9 -translate-y-1/2 overflow-visible rounded-sm",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60",
)}
style={{ top: `${marker.topPercent}%` }}
>
<span
aria-hidden
data-testid="prompt-rail-marker"
<Fragment key={marker.ids.join("|")}>
<button
type="button"
aria-label={t("thread.promptNavigator.jumpTo", { label: marker.label })}
onClick={() => onJumpToPrompt(marker.ids[marker.ids.length - 1])}
onBlur={() => setFocusedMarkerIndex(null)}
onFocus={() => setFocusedMarkerIndex(index)}
onPointerEnter={() => setFocusedMarkerIndex(index)}
onPointerLeave={() => setFocusedMarkerIndex(null)}
className={cn(
"absolute left-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full",
"transition-[width,background-color,opacity,height] duration-150",
railMarkerTone(hoverDistance, active),
"absolute left-0 h-4 w-9 -translate-y-1/2 overflow-visible rounded-sm",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60",
)}
style={{
height: markerHeight(hoverDistance),
width: markerWidth(hoverDistance),
}}
/>
<span
style={{ top: `${marker.topPercent}%` }}
>
<span
aria-hidden
data-testid="prompt-rail-marker"
className={cn(
"absolute left-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full",
"transition-[width,background-color,opacity,height] duration-150",
railMarkerTone(hoverDistance, active),
)}
style={{
height: markerHeight(hoverDistance),
width: markerWidth(hoverDistance),
}}
/>
</button>
<div
ref={makeInert}
aria-hidden
data-testid={previewVisible ? "prompt-rail-preview" : undefined}
className={cn(
"pointer-events-none absolute left-10 top-1/2 z-30 w-[34rem] max-w-[calc(100vw-4rem)] -translate-y-1/2 rounded-[20px] px-4 py-3 text-left",
"pointer-events-none absolute left-10 z-30 w-[34rem] max-w-[calc(100vw-4rem)] -translate-y-1/2 rounded-[20px] px-4 py-3 text-left",
"bg-popover/95 text-popover-foreground shadow-[0_18px_45px_rgba(0,0,0,0.12)] backdrop-blur-xl",
"dark:shadow-[0_18px_45px_rgba(0,0,0,0.45)]",
"-translate-x-2 scale-[0.98] opacity-0 transition-[opacity,transform] duration-150",
"group-hover/marker:translate-x-0 group-hover/marker:scale-100 group-hover/marker:opacity-100",
"group-focus-visible/marker:translate-x-0 group-focus-visible/marker:scale-100 group-focus-visible/marker:opacity-100",
"transition-[opacity,transform] duration-150",
previewVisible
? "translate-x-0 scale-100 opacity-100"
: "-translate-x-2 scale-[0.98] opacity-0",
)}
style={{ top: `${marker.topPercent}%` }}
>
<span className="line-clamp-2 whitespace-pre-wrap break-words text-[15px] font-semibold leading-6">
{marker.preview}
</span>
{marker.answerPreview ? (
<span className="mt-1.5 line-clamp-3 whitespace-pre-wrap break-words text-[14px] leading-6 text-muted-foreground dark:text-white/55">
{marker.answerPreview}
</span>
{previewVisible ? (
<>
<div className="line-clamp-2 whitespace-pre-wrap break-words text-[15px] font-semibold leading-6">
{marker.preview}
</div>
{marker.answerPreview ? (
<div className="mt-1.5 max-h-[4.5rem] overflow-hidden break-words text-[14px] leading-6 text-muted-foreground dark:text-white/55">
<MarkdownText
className={cn(
"max-w-none text-[14px] leading-6 text-inherit",
"[--tw-prose-body:currentColor] [--tw-prose-headings:currentColor] [--tw-prose-bold:currentColor]",
"prose-headings:my-0 prose-h1:text-[14px] prose-h2:text-[14px] prose-h3:text-[14px] prose-h4:text-[14px]",
"prose-p:my-0 prose-ul:my-0 prose-ol:my-0 prose-li:my-0",
)}
>
{marker.answerPreview}
</MarkdownText>
</div>
) : null}
</>
) : null}
</span>
</button>
</div>
</Fragment>
);
})}
</div>
);
}
function makeInert(node: HTMLDivElement | null): void {
if (node) node.inert = true;
}
function measurePrompts(
scrollEl: HTMLElement,
anchors: PromptAnchor[],

View File

@ -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(
<ThreadViewport
messages={promptMessages}
@ -1506,14 +1507,36 @@ describe("ThreadViewport", () => {
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");