diff --git a/webui/src/components/MarkdownText.tsx b/webui/src/components/MarkdownText.tsx index 875f5303..8e629310 100644 --- a/webui/src/components/MarkdownText.tsx +++ b/webui/src/components/MarkdownText.tsx @@ -1,4 +1,5 @@ import { + Component, Suspense, lazy, memo, @@ -8,6 +9,7 @@ import { useLayoutEffect, useRef, useState, + type ReactNode, } from "react"; import { cn } from "@/lib/utils"; @@ -49,6 +51,21 @@ const MEDIUM_STREAM_COMMIT_MS = 140; const LONG_STREAM_COMMIT_MS = 220; const STREAMING_HIGHLIGHT_CHAR_LIMIT = 16_000; +class MarkdownRendererBoundary extends Component< + { children: ReactNode; fallback: ReactNode }, + { failed: boolean } +> { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + render() { + return this.state.failed ? this.props.fallback : this.props.children; + } +} + export function preloadMarkdownText(): void { void loadMarkdownRenderer(); } @@ -73,26 +90,28 @@ export function MarkdownText({ if (streaming) preloadMarkdownText(); }, [streaming]); - return ( - - {renderedSource} - - } + const plainFallback = ( +
- - + {renderedSource} +
+ ); + + return ( + + + + + ); } diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index b75460a6..45f0f5ff 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -81,6 +81,7 @@ export function ThreadMessages({ () => isStreaming ? currentActivityClusterIndices(units) : new Set(), [isStreaming, units], ); + const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]); let nextUserIndex = hiddenUserMessageCount; return ( @@ -108,7 +109,7 @@ export function ThreadMessages({ if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1; return ( - +
{unit.type === "activity" ? ( { return indices; } -function unitKey(unit: DisplayUnit, index: number): string { +export function unitKeysForDisplay(units: DisplayUnit[]): string[] { + const occurrences = new Map(); + return units.map((unit, index) => { + const base = unitKeyBase(unit, index); + if (!base.startsWith("turn-") || base.endsWith("-user")) return base; + const next = (occurrences.get(base) ?? 0) + 1; + occurrences.set(base, next); + return `${base}-${next}`; + }); +} + +function unitKeyBase(unit: DisplayUnit, index: number): string { if (unit.type === "activity") { - const anchor = unit.messages[0]?.id; - return anchor != null ? `activity-${anchor}` : `activity-idx-${index}`; + const anchor = unit.messages[0]; + const turnKey = stableTurnMessageKey(anchor, "activity"); + if (turnKey) return turnKey; + const anchorId = anchor?.id; + return anchorId != null ? `activity-${anchorId}` : `activity-idx-${index}`; } + const turnKey = stableTurnMessageKey(unit.message); + if (turnKey) return turnKey; return unit.message.id; } +function stableTurnMessageKey(message: UIMessage | undefined, fallbackPhase?: string): string | null { + if (!message?.turnId) return null; + const phase = message.turnPhase ?? fallbackPhase ?? message.kind ?? message.role; + if (message.role === "user") return `turn-${message.turnId}-user`; + if (message.kind === "trace") { + return `turn-${message.turnId}-${phase}-${message.activitySegmentId ?? "activity"}`; + } + return `turn-${message.turnId}-${phase}`; +} + function marginAfterPrevUnit(prev: DisplayUnit): string { if (prev.type === "activity") { return "mt-4"; diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index f9ca7be8..7ac58ce3 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -284,6 +284,7 @@ export function ThreadShell({ const [settings, setSettings] = useState(settingsSnapshot); const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey); const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0); + const [scrollToLatestUserPromptSignal, setScrollToLatestUserPromptSignal] = useState(0); const [filePreviewPath, setFilePreviewPath] = useState(null); const [filePreviewClosing, setFilePreviewClosing] = useState(false); const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH); @@ -300,6 +301,7 @@ export function ThreadShell({ const appliedHistoryVersionRef = useRef>(new Map()); const pendingCanonicalHydrateRef = useRef>(new Set()); const sessionKeyByChatIdRef = useRef>(new Map()); + const bottomScrolledChatIdRef = useRef(null); const initial = useMemo(() => { if (!chatId) return historical; @@ -454,9 +456,14 @@ export function ThreadShell({ }, [chatId, client, refreshHistory]); useEffect(() => { - if (!chatId || loading) return; + if (!chatId) { + bottomScrolledChatIdRef.current = null; + return; + } + if (loading || bottomScrolledChatIdRef.current === chatId) return; + bottomScrolledChatIdRef.current = chatId; setScrollToBottomSignal((value) => value + 1); - }, [chatId, loading, historical]); + }, [chatId, loading]); useEffect(() => { if (chatId) return; @@ -505,7 +512,7 @@ export function ThreadShell({ const pending = pendingFirstRef.current; if (!pending) return; pendingFirstRef.current = null; - setScrollToBottomSignal((value) => value + 1); + setScrollToLatestUserPromptSignal((value) => value + 1); send(pending.content, pending.images, pending.options); setBooting(false); }, [chatId, send]); @@ -541,7 +548,7 @@ export function ThreadShell({ const handleThreadSend = useCallback( (content: string, images?: SendImage[], options?: SendOptions) => { - setScrollToBottomSignal((value) => value + 1); + setScrollToLatestUserPromptSignal((value) => value + 1); send(content, images, withWorkspaceScope(options)); }, [send, withWorkspaceScope], @@ -764,6 +771,7 @@ export function ThreadShell({ emptyState={emptyState} composer={composer} scrollToBottomSignal={scrollToBottomSignal} + scrollToLatestUserPromptSignal={scrollToLatestUserPromptSignal} conversationKey={historyKey} showScrollToBottomButton={!!session} cliApps={cliApps} diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index bfd9bf81..a621e040 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button"; import { findPromptElement, jumpToPrompt, + promptTop, } from "@/components/thread/promptNavigation"; import { cn } from "@/lib/utils"; import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types"; @@ -33,6 +34,7 @@ interface ThreadViewportProps { composer: ReactNode; emptyState?: ReactNode; scrollToBottomSignal?: number; + scrollToLatestUserPromptSignal?: number; conversationKey?: string | null; showScrollToBottomButton?: boolean; cliApps?: CliAppInfo[]; @@ -103,6 +105,7 @@ export const ThreadViewport = forwardRef(null); const scrollFrameIdsRef = useRef([]); + const handledLatestPromptSignalRef = useRef(0); const restoreScrollAfterPrependRef = useRef<{ height: number; top: number } | null>(null); /** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */ @@ -186,6 +190,28 @@ export const ThreadViewport = forwardRef { + const el = scrollRef.current; + if (!el) return false; + const target = findPromptElement(el, promptId); + if (!target) return false; + const top = Math.max(0, promptTop(el, target) - 16); + try { + el.scrollTo?.({ top, behavior: "auto" }); + el.scrollTop = top; + } catch { + try { + el.scrollTop = top; + } catch { + // Test DOMs can expose read-only scrollTop; browsers keep this writable. + } + } + const near = el.scrollHeight - top - el.clientHeight < NEAR_BOTTOM_PX; + userReadingHistoryRef.current = !near; + setAtBottom(near); + return true; + }, []); + const scrollToBottom = useCallback( (smooth = false, frames = 1, options?: { force?: boolean }) => { const force = options?.force ?? false; @@ -297,13 +323,6 @@ export const ThreadViewport = forwardRef { - if (!atBottom) return; - // Instant jump: CSS scroll-smooth + behavior "auto" still animates in some - // browsers; session switches and history hydration should never slide from top. - scrollToBottom(false); - }, [messages, atBottom, scrollToBottom]); - useLayoutEffect(() => { if (keyboardInsetBottom > 0) { userReadingHistoryRef.current = false; @@ -335,6 +354,20 @@ export const ThreadViewport = forwardRef { + if (scrollToLatestUserPromptSignal <= handledLatestPromptSignalRef.current) return; + const latest = messages[messages.length - 1]; + if (!latest || latest.role !== "user") return; + handledLatestPromptSignalRef.current = scrollToLatestUserPromptSignal; + cancelScheduledBottomScroll(); + scrollToPromptTopNow(latest.id); + }, [ + cancelScheduledBottomScroll, + messages, + scrollToLatestUserPromptSignal, + scrollToPromptTopNow, + ]); + useLayoutEffect(() => { if (lastConversationKeyRef.current === conversationKey) return; lastConversationKeyRef.current = conversationKey; @@ -390,17 +423,6 @@ export const ThreadViewport = forwardRef cancelScheduledBottomScroll, [cancelScheduledBottomScroll]); - useEffect(() => { - const target = contentRef.current; - if (!target || typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(() => { - if (userReadingHistoryRef.current) return; - scrollToBottom(false, 4); - }); - observer.observe(target); - return () => observer.disconnect(); - }, [hasMessages, scrollToBottom]); - useEffect(() => { const target = composerDockRef.current; if (!target || typeof ResizeObserver === "undefined") return; @@ -444,7 +466,7 @@ export const ThreadViewport = forwardRef
{composer} diff --git a/webui/src/tests/markdown-text-lazy-failure.test.tsx b/webui/src/tests/markdown-text-lazy-failure.test.tsx new file mode 100644 index 00000000..034bf401 --- /dev/null +++ b/webui/src/tests/markdown-text-lazy-failure.test.tsx @@ -0,0 +1,25 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +describe("MarkdownText lazy renderer failure", () => { + it("keeps rendering plain text if the markdown renderer chunk fails to load", async () => { + vi.resetModules(); + vi.doMock("@/components/MarkdownTextRenderer", () => { + throw new Error("markdown renderer failed to load"); + }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const { MarkdownText } = await import("@/components/MarkdownText"); + + render(hello **markdown**); + + await waitFor(() => { + expect(screen.getByText("hello **markdown**")).toBeInTheDocument(); + }); + } finally { + consoleError.mockRestore(); + vi.doUnmock("@/components/MarkdownTextRenderer"); + } + }); +}); diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx index debf73c1..c84dc6bf 100644 --- a/webui/src/tests/thread-messages.test.tsx +++ b/webui/src/tests/thread-messages.test.tsx @@ -5,6 +5,7 @@ import { assistantCopyFlags, buildDisplayUnits, ThreadMessages, + unitKeysForDisplay, } from "@/components/thread/ThreadMessages"; import type { UIMessage } from "@/lib/types"; @@ -72,6 +73,42 @@ describe("ThreadMessages", () => { expect(screen.getByText("Forked from history")).toBeInTheDocument(); }); + it("keeps turn unit keys stable across replayed ids and mutable turn sequence", () => { + const liveUnits = buildDisplayUnits([ + { id: "optimistic-user", role: "user", content: "go", turnId: "turn-1", turnPhase: "user", turnSeq: 0, createdAt: 1 }, + { + id: "live-a1", + role: "assistant", + content: "first answer slice", + turnId: "turn-1", + turnPhase: "answer", + turnSeq: 2, + createdAt: 2, + }, + { + id: "live-a2", + role: "assistant", + content: "second answer slice", + turnId: "turn-1", + turnPhase: "answer", + turnSeq: 20, + createdAt: 3, + }, + ]); + const replayUnits = buildDisplayUnits([ + { id: "replayed-user", role: "user", content: "go", turnId: "turn-1", turnPhase: "user", turnSeq: 10, createdAt: 10 }, + { id: "replayed-a1", role: "assistant", content: "first answer slice", turnId: "turn-1", turnPhase: "answer", turnSeq: 11, createdAt: 11 }, + { id: "replayed-a2", role: "assistant", content: "second answer slice", turnId: "turn-1", turnPhase: "answer", turnSeq: 99, createdAt: 12 }, + ]); + + expect(unitKeysForDisplay(liveUnits)).toEqual(unitKeysForDisplay(replayUnits)); + expect(unitKeysForDisplay(liveUnits)).toEqual([ + "turn-turn-1-user", + "turn-turn-1-answer-1", + "turn-turn-1-answer-2", + ]); + }); + it("keeps file edits as their own activity row inside a turn", () => { const messages: UIMessage[] = [ { diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 700818f6..2de4b7b5 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -1035,11 +1035,79 @@ describe("ThreadShell", () => { expect(historyCalls).toBe(1); }); + it("does not scroll again when canonical history refreshes after a session update", async () => { + const client = makeClient(); + const scrollTo = vi.fn(); + const originalScrollTo = HTMLElement.prototype.scrollTo; + HTMLElement.prototype.scrollTo = scrollTo; + let historyCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Achat-a/webui-thread")) { + historyCalls += 1; + return httpJson( + transcriptFromSimpleMessages( + historyCalls === 1 + ? [{ role: "user", content: "question" }] + : [ + { role: "user", content: "question" }, + { role: "assistant", content: "canonical answer" }, + ], + ), + ); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + try { + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument()); + await waitFor(() => expect(scrollTo).toHaveBeenCalled()); + await act(async () => { + for (let i = 0; i < 8; i += 1) { + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + } + }); + scrollTo.mockClear(); + + await act(async () => { + client._emitSessionUpdate("chat-a"); + }); + + await waitFor(() => expect(historyCalls).toBe(2)); + await waitFor(() => expect(screen.getByText("canonical answer")).toBeInTheDocument()); + expect(scrollTo).not.toHaveBeenCalled(); + } finally { + HTMLElement.prototype.scrollTo = originalScrollTo; + } + }); + it("scrolls to the bottom after loading a session from the blank new-chat page", async () => { const client = makeClient(); const scrollIntoView = vi.fn(); + const scrollTo = vi.fn(); const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + const originalScrollTo = HTMLElement.prototype.scrollTo; HTMLElement.prototype.scrollIntoView = scrollIntoView; + HTMLElement.prototype.scrollTo = scrollTo; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { @@ -1092,13 +1160,14 @@ describe("ThreadShell", () => { await waitFor(() => expect(screen.getByText("loaded answer")).toBeInTheDocument()); await waitFor(() => - expect(scrollIntoView).toHaveBeenCalledWith({ - block: "end", + expect(scrollTo).toHaveBeenCalledWith({ + top: 0, behavior: "auto", }), ); } finally { HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + HTMLElement.prototype.scrollTo = originalScrollTo; } }); diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index 03108939..7c806a9b 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -110,7 +110,7 @@ function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) { } describe("ThreadViewport", () => { - it("bottom-aligns short history near the composer", () => { + it("top-aligns short threads in the message rendering area", () => { render( { ); const messageRegion = screen.getByTestId("thread-message-region"); - expect(messageRegion).toHaveClass("justify-end"); + expect(messageRegion).toHaveClass("justify-start"); + expect(messageRegion).not.toHaveClass("justify-end"); expect(messageRegion).toHaveClass("pb-4"); expect(messageRegion.className).not.toContain("5rem"); }); + it("top-aligns a short active turn while the agent is responding", () => { + render( + composer
} + />, + ); + + const messageRegion = screen.getByTestId("thread-message-region"); + expect(messageRegion).toHaveClass("justify-start"); + expect(messageRegion).not.toHaveClass("justify-end"); + expect(screen.getByTestId("thread-composer-dock")).not.toHaveClass("mt-auto"); + }); + + it("anchors the latest user prompt after sending instead of scrolling to the bottom", async () => { + const threaded: UIMessage[] = [ + { id: "u1", role: "user", content: "old question", createdAt: 1 }, + { id: "a1", role: "assistant", content: "old answer", createdAt: 2 }, + { id: "u2", role: "user", content: "new question", createdAt: 3 }, + ]; + const scrollTo = vi.fn(); + const { container, rerender } = render( + composer
} + scrollToLatestUserPromptSignal={0} + />, + ); + + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 1200 }, + clientHeight: { configurable: true, value: 500 }, + scrollTop: { configurable: true, writable: true, value: 700 }, + scrollTo: { configurable: true, value: scrollTo }, + }); + const prompt = container.querySelector('[data-user-prompt-id="u2"]'); + expect(prompt).not.toBeNull(); + Object.defineProperty(prompt, "offsetTop", { + configurable: true, + value: 420, + }); + scrollTo.mockClear(); + + await act(async () => { + rerender( + composer
} + scrollToLatestUserPromptSignal={1} + />, + ); + }); + + expect(scrollTo).toHaveBeenCalledWith({ + top: 404, + behavior: "auto", + }); + expect(screen.getByTestId("thread-message-region")).toHaveClass("justify-start"); + }); + it("keeps the scroll-to-bottom button above a growing composer", () => { const originalResizeObserver = globalThis.ResizeObserver; const resizeObservers: ResizeObserverInstance[] = []; diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 188c3beb..28729732 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -15,11 +15,16 @@ export default defineConfig(({ mode }) => { }, }, optimizeDeps: { - // Radix dialog was introduced mid-session for the mobile sidebar sheet. - // When Vite re-optimizes it on a running dev server, the browser can race - // and request stale chunk paths from `.vite/deps`. Excluding it keeps dev - // reloads stable instead of rewriting those chunk filenames under us. - exclude: ["@radix-ui/react-dialog"], + // Keep dev reloads stable for dependencies that can rewrite generated + // optimizer chunk filenames while a browser tab is still running. Do not + // exclude the markdown/remark/rehype chain: Vite's pre-bundling is needed + // there for CommonJS interop such as style-to-js. + exclude: [ + "@radix-ui/react-dialog", + "react-syntax-highlighter/dist/esm/prism-async-light", + "react-syntax-highlighter/dist/esm/styles/prism/one-dark", + "react-syntax-highlighter/dist/esm/styles/prism/one-light", + ], }, build: { outDir: path.resolve(__dirname, "../nanobot/web/dist"),