mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 01:48:53 +00:00
fix(webui): render markdown in prompt rail previews (#5244)
This commit is contained in:
parent
29fdb7d628
commit
5770329542
@ -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[],
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user