diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index 6009ce76b..903597c6e 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -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", )} > {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", diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index 523154590..8e003ca67 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -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]", )} > diff --git a/webui/src/components/thread/PromptRail.tsx b/webui/src/components/thread/PromptRail.tsx new file mode 100644 index 000000000..415893b8c --- /dev/null +++ b/webui/src/components/thread/PromptRail.tsx @@ -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; +} + +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(null); + const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]); + const [markers, setMarkers] = useState([]); + const [activePromptId, setActivePromptId] = useState(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 ( + + ); +} + +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("[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)); +} diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index 1f5b2de9c..b548ede26 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -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 ( -
+
{unit.type === "activity" ? ( + {hasMessages ? ( + + ) : null} + {showScrollToBottomButton && !atBottom && (