fix(webui): anchor sent prompts during active turns
This commit is contained in:
@@ -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 (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words leading-relaxed text-foreground/92",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{renderedSource}
|
||||
</div>
|
||||
}
|
||||
const plainFallback = (
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words leading-relaxed text-foreground/92",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<MemoizedMarkdownRenderer
|
||||
source={renderedSource}
|
||||
className={className}
|
||||
highlightCode={highlightCode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
</Suspense>
|
||||
{renderedSource}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<MarkdownRendererBoundary fallback={plainFallback}>
|
||||
<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, 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 (
|
||||
<Fragment key={unitKey(unit, index)}>
|
||||
<Fragment key={unitKeys[index]}>
|
||||
<div className={marginTop} data-user-prompt-id={userPromptId}>
|
||||
{unit.type === "activity" ? (
|
||||
<AgentActivityCluster
|
||||
@@ -191,14 +192,40 @@ function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
|
||||
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") {
|
||||
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";
|
||||
|
||||
@@ -284,6 +284,7 @@ export function ThreadShell({
|
||||
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
|
||||
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
|
||||
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
||||
const [scrollToLatestUserPromptSignal, setScrollToLatestUserPromptSignal] = useState(0);
|
||||
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
|
||||
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
|
||||
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||
@@ -300,6 +301,7 @@ export function ThreadShell({
|
||||
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
||||
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
||||
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||
const bottomScrolledChatIdRef = useRef<string | null>(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}
|
||||
|
||||
@@ -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<ThreadViewportHandle, ThreadViewportPro
|
||||
composer,
|
||||
emptyState,
|
||||
scrollToBottomSignal = 0,
|
||||
scrollToLatestUserPromptSignal = 0,
|
||||
conversationKey = null,
|
||||
showScrollToBottomButton = true,
|
||||
cliApps = [],
|
||||
@@ -124,6 +127,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const pendingConversationScrollRef = useRef(true);
|
||||
const pendingPromptJumpRef = useRef<string | null>(null);
|
||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
||||
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<ThreadViewportHandle, ThreadViewportPro
|
||||
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(
|
||||
(smooth = false, frames = 1, options?: { force?: boolean }) => {
|
||||
const force = options?.force ?? false;
|
||||
@@ -297,13 +323,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
};
|
||||
}, [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(() => {
|
||||
if (keyboardInsetBottom > 0) {
|
||||
userReadingHistoryRef.current = false;
|
||||
@@ -335,6 +354,20 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
scrollToBottom(false, 8);
|
||||
}, [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(() => {
|
||||
if (lastConversationKeyRef.current === conversationKey) return;
|
||||
lastConversationKeyRef.current = conversationKey;
|
||||
@@ -390,17 +423,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
|
||||
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(() => {
|
||||
const target = composerDockRef.current;
|
||||
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
|
||||
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]">
|
||||
<ThreadMessages
|
||||
@@ -463,7 +485,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<div
|
||||
ref={composerDockRef}
|
||||
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">
|
||||
{composer}
|
||||
|
||||
Reference in New Issue
Block a user