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 { useTranslation } from "react-i18next";
import { MarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types"; import type { UIMessage } from "@/lib/types";
import { import {
@ -155,64 +164,87 @@ export function PromptRail({
> >
{markers.map((marker, index) => { {markers.map((marker, index) => {
const active = marker.ids.includes(activePromptId ?? ""); const active = marker.ids.includes(activePromptId ?? "");
const previewVisible = focusedMarkerIndex === index;
const hoverDistance = const hoverDistance =
focusedMarkerIndex === null ? null : Math.abs(index - focusedMarkerIndex); focusedMarkerIndex === null ? null : Math.abs(index - focusedMarkerIndex);
return ( return (
<button <Fragment key={marker.ids.join("|")}>
key={marker.ids.join("|")} <button
type="button" type="button"
aria-label={t("thread.promptNavigator.jumpTo", { label: marker.label })} aria-label={t("thread.promptNavigator.jumpTo", { label: marker.label })}
onClick={() => onJumpToPrompt(marker.ids[marker.ids.length - 1])} onClick={() => onJumpToPrompt(marker.ids[marker.ids.length - 1])}
onBlur={() => setFocusedMarkerIndex(null)} onBlur={() => setFocusedMarkerIndex(null)}
onFocus={() => setFocusedMarkerIndex(index)} onFocus={() => setFocusedMarkerIndex(index)}
onPointerEnter={() => setFocusedMarkerIndex(index)} onPointerEnter={() => setFocusedMarkerIndex(index)}
onPointerLeave={() => setFocusedMarkerIndex(null)} 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"
className={cn( className={cn(
"absolute left-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full", "absolute left-0 h-4 w-9 -translate-y-1/2 overflow-visible rounded-sm",
"transition-[width,background-color,opacity,height] duration-150", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60",
railMarkerTone(hoverDistance, active),
)} )}
style={{ style={{ top: `${marker.topPercent}%` }}
height: markerHeight(hoverDistance), >
width: markerWidth(hoverDistance), <span
}} aria-hidden
/> data-testid="prompt-rail-marker"
<span 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 aria-hidden
data-testid={previewVisible ? "prompt-rail-preview" : undefined}
className={cn( 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", "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)]", "dark:shadow-[0_18px_45px_rgba(0,0,0,0.45)]",
"-translate-x-2 scale-[0.98] opacity-0 transition-[opacity,transform] duration-150", "transition-[opacity,transform] duration-150",
"group-hover/marker:translate-x-0 group-hover/marker:scale-100 group-hover/marker:opacity-100", previewVisible
"group-focus-visible/marker:translate-x-0 group-focus-visible/marker:scale-100 group-focus-visible/marker:opacity-100", ? "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"> {previewVisible ? (
{marker.preview} <>
</span> <div className="line-clamp-2 whitespace-pre-wrap break-words text-[15px] font-semibold leading-6">
{marker.answerPreview ? ( {marker.preview}
<span className="mt-1.5 line-clamp-3 whitespace-pre-wrap break-words text-[14px] leading-6 text-muted-foreground dark:text-white/55"> </div>
{marker.answerPreview} {marker.answerPreview ? (
</span> <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} ) : null}
</span> </div>
</button> </Fragment>
); );
})} })}
</div> </div>
); );
} }
function makeInert(node: HTMLDivElement | null): void {
if (node) node.inert = true;
}
function measurePrompts( function measurePrompts(
scrollEl: HTMLElement, scrollEl: HTMLElement,
anchors: PromptAnchor[], anchors: PromptAnchor[],

View File

@ -157,10 +157,11 @@ function getScroller(container: HTMLElement): HTMLElement {
async function renderPromptRailViewport({ async function renderPromptRailViewport({
scrollTo, scrollTo,
messages: promptMessages = makePromptExchangeMessages(5),
}: { }: {
scrollTo?: (options?: ScrollToOptions) => void; scrollTo?: (options?: ScrollToOptions) => void;
messages?: UIMessage[];
} = {}) { } = {}) {
const promptMessages = makePromptExchangeMessages(5);
const { container } = render( const { container } = render(
<ThreadViewport <ThreadViewport
messages={promptMessages} messages={promptMessages}
@ -1506,14 +1507,36 @@ describe("ThreadViewport", () => {
expect(railMarkers.every((marker) => marker.style.width === "9px")).toBe(true); expect(railMarkers.every((marker) => marker.style.width === "9px")).toBe(true);
const targetPrompt = screen.getByRole("button", { name: "Jump to prompt: message 3" }); const targetPrompt = screen.getByRole("button", { name: "Jump to prompt: message 3" });
expect(within(targetPrompt).getByText("message 3")).toBeInTheDocument(); fireEvent.pointerEnter(targetPrompt);
expect(within(targetPrompt).getByText("answer 3")).toBeInTheDocument(); const preview = screen.getByTestId("prompt-rail-preview");
expect(within(preview).getByText("message 3")).toBeInTheDocument();
expect(within(preview).getByText("answer 3")).toBeInTheDocument();
fireEvent.click(targetPrompt); fireEvent.click(targetPrompt);
expect(navigateTo).toHaveBeenCalledWith(1064); 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 () => { it("lets direct paging input interrupt prompt rail navigation", async () => {
vi.spyOn(ThreadCameraController.prototype, "navigateTo") vi.spyOn(ThreadCameraController.prototype, "navigateTo")
.mockReturnValue("started"); .mockReturnValue("started");