mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 22:34:06 +00:00
feat(webui): add prompt rail navigation
This commit is contained in:
parent
e8d4aff5be
commit
1af2bc513f
@ -272,7 +272,7 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||
aria-label={`Open link: ${label}`}
|
||||
className={cn(
|
||||
"not-prose inline-flex max-w-full items-center gap-2 align-baseline",
|
||||
"text-blue-600 no-underline underline-offset-2 hover:underline dark:text-blue-300",
|
||||
"text-blue-500 no-underline underline-offset-2 hover:underline dark:text-blue-300",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@ -410,7 +410,7 @@ export default function MarkdownTextRenderer({
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-blue-600 underline underline-offset-2 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200"
|
||||
className="text-blue-500 underline underline-offset-2 hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-200"
|
||||
{...props}
|
||||
>
|
||||
{markdownChildren}
|
||||
@ -508,7 +508,7 @@ export default function MarkdownTextRenderer({
|
||||
"prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5",
|
||||
"prose-blockquote:my-3 prose-blockquote:border-l-2 prose-blockquote:font-normal",
|
||||
"prose-blockquote:not-italic prose-blockquote:text-foreground/80",
|
||||
"prose-a:text-blue-600 prose-a:underline-offset-2 hover:prose-a:text-blue-700 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
|
||||
"prose-a:text-blue-500 prose-a:underline-offset-2 hover:prose-a:text-blue-600 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
|
||||
"prose-hr:my-6",
|
||||
"prose-pre:my-0 prose-pre:bg-transparent prose-pre:p-0",
|
||||
"prose-code:before:content-none prose-code:after:content-none prose-code:font-normal",
|
||||
|
||||
@ -573,7 +573,7 @@ export function ReasoningBubble({
|
||||
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
|
||||
"prose-headings:text-muted-foreground/92 prose-strong:text-muted-foreground",
|
||||
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
|
||||
"prose-a:text-blue-600 prose-a:underline hover:prose-a:text-blue-700 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
|
||||
"prose-a:text-blue-500 prose-a:underline hover:prose-a:text-blue-600 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
|
||||
"prose-code:text-[0.92em]",
|
||||
)}
|
||||
>
|
||||
|
||||
242
webui/src/components/thread/PromptRail.tsx
Normal file
242
webui/src/components/thread/PromptRail.tsx
Normal file
@ -0,0 +1,242 @@
|
||||
import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
interface PromptRailProps {
|
||||
bottomOffset: number;
|
||||
messages: UIMessage[];
|
||||
scrollRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
interface PromptAnchor {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MeasuredPrompt extends PromptAnchor {
|
||||
top: number;
|
||||
topPercent: number;
|
||||
}
|
||||
|
||||
interface PromptMarker {
|
||||
ids: string[];
|
||||
label: string;
|
||||
topPercent: number;
|
||||
}
|
||||
|
||||
const MIN_PROMPTS_FOR_RAIL = 3;
|
||||
const RAIL_MIN_SCROLL_RANGE_PX = 240;
|
||||
const MARKER_MIN_GAP_PX = 9;
|
||||
|
||||
export function PromptRail({
|
||||
bottomOffset,
|
||||
messages,
|
||||
scrollRef,
|
||||
}: PromptRailProps) {
|
||||
const railRef = useRef<HTMLDivElement>(null);
|
||||
const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]);
|
||||
const [markers, setMarkers] = useState<PromptMarker[]>([]);
|
||||
const [activePromptId, setActivePromptId] = useState<string | null>(null);
|
||||
|
||||
const updateMarkers = useCallback(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl || promptAnchors.length < MIN_PROMPTS_FOR_RAIL) {
|
||||
setMarkers([]);
|
||||
setActivePromptId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollRange = scrollEl.scrollHeight - scrollEl.clientHeight;
|
||||
if (scrollRange < RAIL_MIN_SCROLL_RANGE_PX) {
|
||||
setMarkers([]);
|
||||
setActivePromptId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const measured = measurePrompts(scrollEl, promptAnchors, scrollRange);
|
||||
setMarkers(groupPromptMarkers(measured, railRef.current?.clientHeight ?? 0));
|
||||
setActivePromptId(activePromptForScroll(measured, scrollEl.scrollTop));
|
||||
}, [promptAnchors, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
updateMarkers();
|
||||
}, [updateMarkers]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return undefined;
|
||||
|
||||
let frame = 0;
|
||||
const schedule = () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = window.requestAnimationFrame(updateMarkers);
|
||||
};
|
||||
|
||||
scrollEl.addEventListener("scroll", schedule, { passive: true });
|
||||
window.addEventListener("resize", schedule);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
scrollEl.removeEventListener("scroll", schedule);
|
||||
window.removeEventListener("resize", schedule);
|
||||
};
|
||||
}, [scrollRef, updateMarkers]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl || typeof ResizeObserver === "undefined") return undefined;
|
||||
const observer = new ResizeObserver(() => updateMarkers());
|
||||
observer.observe(scrollEl);
|
||||
if (scrollEl.firstElementChild) observer.observe(scrollEl.firstElementChild);
|
||||
return () => observer.disconnect();
|
||||
}, [scrollRef, updateMarkers]);
|
||||
|
||||
if (markers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={railRef}
|
||||
aria-label="User prompt navigation"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-2 top-12 z-20 hidden w-10 md:block",
|
||||
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200",
|
||||
)}
|
||||
style={{ bottom: Math.max(80, bottomOffset) }}
|
||||
>
|
||||
{markers.map((marker) => {
|
||||
const active = marker.ids.includes(activePromptId ?? "");
|
||||
return (
|
||||
<button
|
||||
key={marker.ids.join("|")}
|
||||
type="button"
|
||||
title={marker.label}
|
||||
aria-label={`Jump to prompt: ${marker.label}`}
|
||||
onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute right-1 h-1.5 w-7 -translate-y-1/2 rounded-full",
|
||||
"bg-muted-foreground/30 transition-all duration-150",
|
||||
"hover:w-9 hover:bg-blue-500/80 focus-visible:w-9 focus-visible:bg-blue-500",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60",
|
||||
active && "w-9 bg-foreground shadow-sm",
|
||||
)}
|
||||
style={{ top: `${marker.topPercent}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
|
||||
return messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message, index) => ({
|
||||
id: message.id,
|
||||
label: promptLabel(message.content, index),
|
||||
}));
|
||||
}
|
||||
|
||||
function promptLabel(content: string, index: number): string {
|
||||
const text = content.replace(/\s+/g, " ").trim();
|
||||
if (!text) return `Prompt ${index + 1}`;
|
||||
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
|
||||
}
|
||||
|
||||
function measurePrompts(
|
||||
scrollEl: HTMLElement,
|
||||
anchors: PromptAnchor[],
|
||||
scrollRange: number,
|
||||
): MeasuredPrompt[] {
|
||||
return anchors.flatMap((anchor) => {
|
||||
const target = findPromptElement(scrollEl, anchor.id);
|
||||
if (!target) return [];
|
||||
const top = Math.max(0, Math.min(scrollRange, promptTop(scrollEl, target) - 16));
|
||||
return [{
|
||||
...anchor,
|
||||
top,
|
||||
topPercent: clamp((top / scrollRange) * 100, 2, 98),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function groupPromptMarkers(
|
||||
measured: MeasuredPrompt[],
|
||||
railHeight: number,
|
||||
): PromptMarker[] {
|
||||
if (measured.length === 0) return [];
|
||||
const minGapPercent = railHeight > 0
|
||||
? (MARKER_MIN_GAP_PX / railHeight) * 100
|
||||
: 2;
|
||||
const groups: PromptMarker[] = [];
|
||||
|
||||
for (const prompt of measured) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && prompt.topPercent - last.topPercent < minGapPercent) {
|
||||
last.ids.push(prompt.id);
|
||||
last.label = `${last.ids.length} prompts, latest: ${prompt.label}`;
|
||||
continue;
|
||||
}
|
||||
groups.push({
|
||||
ids: [prompt.id],
|
||||
label: prompt.label,
|
||||
topPercent: prompt.topPercent,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function activePromptForScroll(
|
||||
measured: MeasuredPrompt[],
|
||||
scrollTop: number,
|
||||
): string | null {
|
||||
if (measured.length === 0) return null;
|
||||
let active = measured[0];
|
||||
const cursor = scrollTop + 96;
|
||||
for (const prompt of measured) {
|
||||
if (prompt.top <= cursor) {
|
||||
active = prompt;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return active.id;
|
||||
}
|
||||
|
||||
function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
|
||||
if (!scrollEl || !promptId) return;
|
||||
const target = findPromptElement(scrollEl, promptId);
|
||||
if (!target) return;
|
||||
scrollEl.scrollTo({
|
||||
top: Math.max(0, promptTop(scrollEl, target) - 16),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
|
||||
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
|
||||
return Array.from(candidates).find(
|
||||
(candidate) => candidate.dataset.userPromptId === promptId,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function promptTop(scrollEl: HTMLElement, target: HTMLElement): number {
|
||||
const scrollRect = scrollEl.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const hasLayoutRect = scrollRect.top !== 0 || targetRect.top !== 0;
|
||||
if (hasLayoutRect) {
|
||||
return targetRect.top - scrollRect.top + scrollEl.scrollTop;
|
||||
}
|
||||
return target.offsetTop;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
@ -98,8 +98,17 @@ export function ThreadMessages({
|
||||
&& next?.type === "message"
|
||||
&& next.message.role === "assistant";
|
||||
|
||||
const userPromptId =
|
||||
unit.type === "message" && unit.message.role === "user"
|
||||
? unit.message.id
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div key={unitKey(unit, index)} className={marginTop}>
|
||||
<div
|
||||
key={unitKey(unit, index)}
|
||||
className={marginTop}
|
||||
data-user-prompt-id={userPromptId}
|
||||
>
|
||||
{unit.type === "activity" ? (
|
||||
<AgentActivityCluster
|
||||
messages={unit.messages}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PromptRail } from "@/components/thread/PromptRail";
|
||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -289,6 +290,14 @@ export function ThreadViewport({
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
|
||||
/>
|
||||
|
||||
{hasMessages ? (
|
||||
<PromptRail
|
||||
messages={visibleMessages}
|
||||
scrollRef={scrollRef}
|
||||
bottomOffset={scrollButtonBottom}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showScrollToBottomButton && !atBottom && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@ -36,7 +36,7 @@ export function ReasoningRow({
|
||||
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
|
||||
"prose-headings:text-muted-foreground/88 prose-strong:text-muted-foreground",
|
||||
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
|
||||
"prose-a:text-blue-600 prose-a:underline hover:prose-a:text-blue-700 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
|
||||
"prose-a:text-blue-500 prose-a:underline hover:prose-a:text-blue-600 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
|
||||
"prose-code:text-[0.92em]",
|
||||
)}
|
||||
>
|
||||
|
||||
@ -9,7 +9,7 @@ describe("MarkdownTextRenderer", () => {
|
||||
|
||||
const link = screen.getByRole("link", { name: "local server" });
|
||||
expect(link).toHaveAttribute("href", "http://127.0.0.1:7891/");
|
||||
expect(link).toHaveClass("text-blue-600", "dark:text-blue-300");
|
||||
expect(link).toHaveClass("text-blue-500", "dark:text-blue-300");
|
||||
});
|
||||
|
||||
it("does not wrap complete fenced code blocks in an extra pre", () => {
|
||||
|
||||
@ -170,6 +170,51 @@ describe("ThreadViewport", () => {
|
||||
expect(screen.getByText("message 299")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a prompt rail that jumps to user messages", async () => {
|
||||
const promptMessages = makeLongMessages(5);
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={promptMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
const scrollTo = vi.fn();
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 1800 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
});
|
||||
|
||||
const promptEls = Array.from(
|
||||
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
|
||||
);
|
||||
expect(promptEls).toHaveLength(5);
|
||||
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")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Jump to prompt: message 3" }));
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 1064,
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
|
||||
it("expands the window start to avoid cutting an agent activity cluster", () => {
|
||||
const clustered = makeLongMessages(200);
|
||||
clustered.splice(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user