mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
fix(webui): anchor sent prompts during active turns
This commit is contained in:
parent
fbaa85117b
commit
7170761e47
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Component,
|
||||||
Suspense,
|
Suspense,
|
||||||
lazy,
|
lazy,
|
||||||
memo,
|
memo,
|
||||||
@ -8,6 +9,7 @@ import {
|
|||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -49,6 +51,21 @@ const MEDIUM_STREAM_COMMIT_MS = 140;
|
|||||||
const LONG_STREAM_COMMIT_MS = 220;
|
const LONG_STREAM_COMMIT_MS = 220;
|
||||||
const STREAMING_HIGHLIGHT_CHAR_LIMIT = 16_000;
|
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 {
|
export function preloadMarkdownText(): void {
|
||||||
void loadMarkdownRenderer();
|
void loadMarkdownRenderer();
|
||||||
}
|
}
|
||||||
@ -73,26 +90,28 @@ export function MarkdownText({
|
|||||||
if (streaming) preloadMarkdownText();
|
if (streaming) preloadMarkdownText();
|
||||||
}, [streaming]);
|
}, [streaming]);
|
||||||
|
|
||||||
return (
|
const plainFallback = (
|
||||||
<Suspense
|
<div
|
||||||
fallback={
|
className={cn(
|
||||||
<div
|
"whitespace-pre-wrap break-words leading-relaxed text-foreground/92",
|
||||||
className={cn(
|
className,
|
||||||
"whitespace-pre-wrap break-words leading-relaxed text-foreground/92",
|
)}
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{renderedSource}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<MemoizedMarkdownRenderer
|
{renderedSource}
|
||||||
source={renderedSource}
|
</div>
|
||||||
className={className}
|
);
|
||||||
highlightCode={highlightCode}
|
|
||||||
onOpenFilePreview={onOpenFilePreview}
|
return (
|
||||||
/>
|
<MarkdownRendererBoundary fallback={plainFallback}>
|
||||||
</Suspense>
|
<Suspense fallback={plainFallback}>
|
||||||
|
<MemoizedMarkdownRenderer
|
||||||
|
source={renderedSource}
|
||||||
|
className={className}
|
||||||
|
highlightCode={highlightCode}
|
||||||
|
onOpenFilePreview={onOpenFilePreview}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</MarkdownRendererBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -81,6 +81,7 @@ export function ThreadMessages({
|
|||||||
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
||||||
[isStreaming, units],
|
[isStreaming, units],
|
||||||
);
|
);
|
||||||
|
const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]);
|
||||||
let nextUserIndex = hiddenUserMessageCount;
|
let nextUserIndex = hiddenUserMessageCount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -108,7 +109,7 @@ export function ThreadMessages({
|
|||||||
if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1;
|
if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={unitKey(unit, index)}>
|
<Fragment key={unitKeys[index]}>
|
||||||
<div className={marginTop} data-user-prompt-id={userPromptId}>
|
<div className={marginTop} data-user-prompt-id={userPromptId}>
|
||||||
{unit.type === "activity" ? (
|
{unit.type === "activity" ? (
|
||||||
<AgentActivityCluster
|
<AgentActivityCluster
|
||||||
@ -191,14 +192,40 @@ function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
|
|||||||
return indices;
|
return indices;
|
||||||
}
|
}
|
||||||
|
|
||||||
function unitKey(unit: DisplayUnit, index: number): string {
|
export function unitKeysForDisplay(units: DisplayUnit[]): string[] {
|
||||||
|
const occurrences = new Map<string, number>();
|
||||||
|
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") {
|
if (unit.type === "activity") {
|
||||||
const anchor = unit.messages[0]?.id;
|
const anchor = unit.messages[0];
|
||||||
return anchor != null ? `activity-${anchor}` : `activity-idx-${index}`;
|
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;
|
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 {
|
function marginAfterPrevUnit(prev: DisplayUnit): string {
|
||||||
if (prev.type === "activity") {
|
if (prev.type === "activity") {
|
||||||
return "mt-4";
|
return "mt-4";
|
||||||
|
|||||||
@ -284,6 +284,7 @@ export function ThreadShell({
|
|||||||
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
|
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
|
||||||
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
|
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
|
||||||
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
||||||
|
const [scrollToLatestUserPromptSignal, setScrollToLatestUserPromptSignal] = useState(0);
|
||||||
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
|
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
|
||||||
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
|
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
|
||||||
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
|
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||||
@ -300,6 +301,7 @@ export function ThreadShell({
|
|||||||
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
||||||
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
||||||
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||||
|
const bottomScrolledChatIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const initial = useMemo(() => {
|
const initial = useMemo(() => {
|
||||||
if (!chatId) return historical;
|
if (!chatId) return historical;
|
||||||
@ -454,9 +456,14 @@ export function ThreadShell({
|
|||||||
}, [chatId, client, refreshHistory]);
|
}, [chatId, client, refreshHistory]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId || loading) return;
|
if (!chatId) {
|
||||||
|
bottomScrolledChatIdRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loading || bottomScrolledChatIdRef.current === chatId) return;
|
||||||
|
bottomScrolledChatIdRef.current = chatId;
|
||||||
setScrollToBottomSignal((value) => value + 1);
|
setScrollToBottomSignal((value) => value + 1);
|
||||||
}, [chatId, loading, historical]);
|
}, [chatId, loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chatId) return;
|
if (chatId) return;
|
||||||
@ -505,7 +512,7 @@ export function ThreadShell({
|
|||||||
const pending = pendingFirstRef.current;
|
const pending = pendingFirstRef.current;
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
pendingFirstRef.current = null;
|
pendingFirstRef.current = null;
|
||||||
setScrollToBottomSignal((value) => value + 1);
|
setScrollToLatestUserPromptSignal((value) => value + 1);
|
||||||
send(pending.content, pending.images, pending.options);
|
send(pending.content, pending.images, pending.options);
|
||||||
setBooting(false);
|
setBooting(false);
|
||||||
}, [chatId, send]);
|
}, [chatId, send]);
|
||||||
@ -541,7 +548,7 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const handleThreadSend = useCallback(
|
const handleThreadSend = useCallback(
|
||||||
(content: string, images?: SendImage[], options?: SendOptions) => {
|
(content: string, images?: SendImage[], options?: SendOptions) => {
|
||||||
setScrollToBottomSignal((value) => value + 1);
|
setScrollToLatestUserPromptSignal((value) => value + 1);
|
||||||
send(content, images, withWorkspaceScope(options));
|
send(content, images, withWorkspaceScope(options));
|
||||||
},
|
},
|
||||||
[send, withWorkspaceScope],
|
[send, withWorkspaceScope],
|
||||||
@ -764,6 +771,7 @@ export function ThreadShell({
|
|||||||
emptyState={emptyState}
|
emptyState={emptyState}
|
||||||
composer={composer}
|
composer={composer}
|
||||||
scrollToBottomSignal={scrollToBottomSignal}
|
scrollToBottomSignal={scrollToBottomSignal}
|
||||||
|
scrollToLatestUserPromptSignal={scrollToLatestUserPromptSignal}
|
||||||
conversationKey={historyKey}
|
conversationKey={historyKey}
|
||||||
showScrollToBottomButton={!!session}
|
showScrollToBottomButton={!!session}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import {
|
import {
|
||||||
findPromptElement,
|
findPromptElement,
|
||||||
jumpToPrompt,
|
jumpToPrompt,
|
||||||
|
promptTop,
|
||||||
} from "@/components/thread/promptNavigation";
|
} from "@/components/thread/promptNavigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||||
@ -33,6 +34,7 @@ interface ThreadViewportProps {
|
|||||||
composer: ReactNode;
|
composer: ReactNode;
|
||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
scrollToBottomSignal?: number;
|
scrollToBottomSignal?: number;
|
||||||
|
scrollToLatestUserPromptSignal?: number;
|
||||||
conversationKey?: string | null;
|
conversationKey?: string | null;
|
||||||
showScrollToBottomButton?: boolean;
|
showScrollToBottomButton?: boolean;
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
@ -103,6 +105,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
composer,
|
composer,
|
||||||
emptyState,
|
emptyState,
|
||||||
scrollToBottomSignal = 0,
|
scrollToBottomSignal = 0,
|
||||||
|
scrollToLatestUserPromptSignal = 0,
|
||||||
conversationKey = null,
|
conversationKey = null,
|
||||||
showScrollToBottomButton = true,
|
showScrollToBottomButton = true,
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
@ -124,6 +127,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const pendingConversationScrollRef = useRef(true);
|
const pendingConversationScrollRef = useRef(true);
|
||||||
const pendingPromptJumpRef = useRef<string | null>(null);
|
const pendingPromptJumpRef = useRef<string | null>(null);
|
||||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
const scrollFrameIdsRef = useRef<number[]>([]);
|
||||||
|
const handledLatestPromptSignalRef = useRef(0);
|
||||||
const restoreScrollAfterPrependRef =
|
const restoreScrollAfterPrependRef =
|
||||||
useRef<{ height: number; top: number } | null>(null);
|
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). */
|
/** 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<ThreadViewportHandle, ThreadViewportPro
|
|||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const scrollToPromptTopNow = useCallback((promptId: string) => {
|
||||||
|
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(
|
const scrollToBottom = useCallback(
|
||||||
(smooth = false, frames = 1, options?: { force?: boolean }) => {
|
(smooth = false, frames = 1, options?: { force?: boolean }) => {
|
||||||
const force = options?.force ?? false;
|
const force = options?.force ?? false;
|
||||||
@ -297,13 +323,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
};
|
};
|
||||||
}, [hasMessages, scrollToBottom]);
|
}, [hasMessages, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (keyboardInsetBottom > 0) {
|
if (keyboardInsetBottom > 0) {
|
||||||
userReadingHistoryRef.current = false;
|
userReadingHistoryRef.current = false;
|
||||||
@ -335,6 +354,20 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
scrollToBottom(false, 8);
|
scrollToBottom(false, 8);
|
||||||
}, [scrollToBottomSignal, scrollToBottom]);
|
}, [scrollToBottomSignal, scrollToBottom]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (lastConversationKeyRef.current === conversationKey) return;
|
if (lastConversationKeyRef.current === conversationKey) return;
|
||||||
lastConversationKeyRef.current = conversationKey;
|
lastConversationKeyRef.current = conversationKey;
|
||||||
@ -390,17 +423,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
|
|
||||||
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
|
useEffect(() => 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(() => {
|
useEffect(() => {
|
||||||
const target = composerDockRef.current;
|
const target = composerDockRef.current;
|
||||||
if (!target || typeof ResizeObserver === "undefined") return;
|
if (!target || typeof ResizeObserver === "undefined") return;
|
||||||
@ -444,7 +466,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
|
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
|
||||||
<div
|
<div
|
||||||
data-testid="thread-message-region"
|
data-testid="thread-message-region"
|
||||||
className="flex min-h-0 flex-1 flex-col justify-end px-3 pb-4 pt-4 sm:px-4"
|
className="flex min-h-0 flex-1 flex-col justify-start px-3 pb-4 pt-4 sm:px-4"
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
<div className="mx-auto w-full max-w-[49.5rem]">
|
||||||
<ThreadMessages
|
<ThreadMessages
|
||||||
@ -463,7 +485,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
<div
|
<div
|
||||||
ref={composerDockRef}
|
ref={composerDockRef}
|
||||||
data-testid="thread-composer-dock"
|
data-testid="thread-composer-dock"
|
||||||
className="sticky bottom-0 z-10 mt-auto bg-background"
|
className="sticky bottom-0 z-10 bg-background"
|
||||||
>
|
>
|
||||||
<div className="px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
|
<div className="px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
|
||||||
{composer}
|
{composer}
|
||||||
|
|||||||
25
webui/src/tests/markdown-text-lazy-failure.test.tsx
Normal file
25
webui/src/tests/markdown-text-lazy-failure.test.tsx
Normal file
@ -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(<MarkdownText>hello **markdown**</MarkdownText>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("hello **markdown**")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
consoleError.mockRestore();
|
||||||
|
vi.doUnmock("@/components/MarkdownTextRenderer");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -5,6 +5,7 @@ import {
|
|||||||
assistantCopyFlags,
|
assistantCopyFlags,
|
||||||
buildDisplayUnits,
|
buildDisplayUnits,
|
||||||
ThreadMessages,
|
ThreadMessages,
|
||||||
|
unitKeysForDisplay,
|
||||||
} from "@/components/thread/ThreadMessages";
|
} from "@/components/thread/ThreadMessages";
|
||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
@ -72,6 +73,42 @@ describe("ThreadMessages", () => {
|
|||||||
expect(screen.getByText("Forked from history")).toBeInTheDocument();
|
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", () => {
|
it("keeps file edits as their own activity row inside a turn", () => {
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1035,11 +1035,79 @@ describe("ThreadShell", () => {
|
|||||||
expect(historyCalls).toBe(1);
|
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,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("chat-a")}
|
||||||
|
title="Chat chat-a"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
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<void>((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 () => {
|
it("scrolls to the bottom after loading a session from the blank new-chat page", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const scrollIntoView = vi.fn();
|
const scrollIntoView = vi.fn();
|
||||||
|
const scrollTo = vi.fn();
|
||||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||||
|
const originalScrollTo = HTMLElement.prototype.scrollTo;
|
||||||
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||||
|
HTMLElement.prototype.scrollTo = scrollTo;
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (input: RequestInfo | URL) => {
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
@ -1092,13 +1160,14 @@ describe("ThreadShell", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("loaded answer")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("loaded answer")).toBeInTheDocument());
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
expect(scrollTo).toHaveBeenCalledWith({
|
||||||
block: "end",
|
top: 0,
|
||||||
behavior: "auto",
|
behavior: "auto",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||||
|
HTMLElement.prototype.scrollTo = originalScrollTo;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -110,7 +110,7 @@ function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ThreadViewport", () => {
|
describe("ThreadViewport", () => {
|
||||||
it("bottom-aligns short history near the composer", () => {
|
it("top-aligns short threads in the message rendering area", () => {
|
||||||
render(
|
render(
|
||||||
<ThreadViewport
|
<ThreadViewport
|
||||||
messages={messages}
|
messages={messages}
|
||||||
@ -120,11 +120,76 @@ describe("ThreadViewport", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const messageRegion = screen.getByTestId("thread-message-region");
|
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).toHaveClass("pb-4");
|
||||||
expect(messageRegion.className).not.toContain("5rem");
|
expect(messageRegion.className).not.toContain("5rem");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("top-aligns a short active turn while the agent is responding", () => {
|
||||||
|
render(
|
||||||
|
<ThreadViewport
|
||||||
|
messages={messages}
|
||||||
|
isStreaming
|
||||||
|
composer={<div>composer</div>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ThreadViewport
|
||||||
|
messages={threaded}
|
||||||
|
isStreaming
|
||||||
|
composer={<div>composer</div>}
|
||||||
|
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<HTMLElement>('[data-user-prompt-id="u2"]');
|
||||||
|
expect(prompt).not.toBeNull();
|
||||||
|
Object.defineProperty(prompt, "offsetTop", {
|
||||||
|
configurable: true,
|
||||||
|
value: 420,
|
||||||
|
});
|
||||||
|
scrollTo.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rerender(
|
||||||
|
<ThreadViewport
|
||||||
|
messages={threaded}
|
||||||
|
isStreaming
|
||||||
|
composer={<div>composer</div>}
|
||||||
|
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", () => {
|
it("keeps the scroll-to-bottom button above a growing composer", () => {
|
||||||
const originalResizeObserver = globalThis.ResizeObserver;
|
const originalResizeObserver = globalThis.ResizeObserver;
|
||||||
const resizeObservers: ResizeObserverInstance[] = [];
|
const resizeObservers: ResizeObserverInstance[] = [];
|
||||||
|
|||||||
@ -15,11 +15,16 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
// Radix dialog was introduced mid-session for the mobile sidebar sheet.
|
// Keep dev reloads stable for dependencies that can rewrite generated
|
||||||
// When Vite re-optimizes it on a running dev server, the browser can race
|
// optimizer chunk filenames while a browser tab is still running. Do not
|
||||||
// and request stale chunk paths from `.vite/deps`. Excluding it keeps dev
|
// exclude the markdown/remark/rehype chain: Vite's pre-bundling is needed
|
||||||
// reloads stable instead of rewriting those chunk filenames under us.
|
// there for CommonJS interop such as style-to-js.
|
||||||
exclude: ["@radix-ui/react-dialog"],
|
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: {
|
build: {
|
||||||
outDir: path.resolve(__dirname, "../nanobot/web/dist"),
|
outDir: path.resolve(__dirname, "../nanobot/web/dist"),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user