Files
nanobot/webui/src/components/thread/ThreadViewport.tsx
T

733 lines
25 KiB
TypeScript

import {
forwardRef,
type ReactNode,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
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 { ThreadCameraController } from "@/components/thread/thread-camera";
import {
ThreadMotionCoordinator,
type ThreadMotionGeometry,
} from "@/components/thread/thread-motion";
import { Button } from "@/components/ui/button";
import {
findPromptElement,
promptTop,
} from "@/components/thread/promptNavigation";
import { cn } from "@/lib/utils";
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
export interface ThreadViewportHandle {
jumpToUserPrompt: (promptId: string) => void;
cancelAutoScroll: () => void;
}
interface ThreadViewportProps {
messages: UIMessage[];
isStreaming: boolean;
composer: ReactNode;
emptyState?: ReactNode;
scrollToBottomSignal?: number;
activeTurnId?: string | null;
activeTurnStartedHere?: boolean;
conversationKey?: string | null;
conversationReady?: boolean;
showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[];
forkBoundaryMessageCount?: number | null;
hasMoreBefore?: boolean;
loadingOlder?: boolean;
userMessageOffset?: number;
onLoadOlder?: () => Promise<void> | void;
onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void;
onQuoteSelection?: (text: string) => void;
}
const NEAR_BOTTOM_PX = 48;
const NEAR_TOP_PX = 96;
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120;
export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
if (messages.length <= visibleCount) return messages;
let start = Math.max(0, messages.length - visibleCount);
while (
start > 0
&& isAgentActivityMember(messages[start])
&& isAgentActivityMember(messages[start - 1])
) {
start -= 1;
}
return messages.slice(start);
}
function isKeyboardEditableElement(element: Element | null): element is HTMLElement {
if (!(element instanceof HTMLElement)) return false;
if (element.isContentEditable) return true;
if (element instanceof HTMLTextAreaElement) return true;
if (!(element instanceof HTMLInputElement)) return false;
return ![
"button",
"checkbox",
"color",
"file",
"hidden",
"image",
"radio",
"range",
"reset",
"submit",
].includes(element.type);
}
type ThreadScrollDirection = "backward" | "forward";
const KEYBOARD_SCROLL_DIRECTIONS: Readonly<
Partial<Record<string, ThreadScrollDirection>>
> = {
ArrowUp: "backward",
PageUp: "backward",
Home: "backward",
ArrowDown: "forward",
PageDown: "forward",
End: "forward",
};
function directionFromDelta(deltaY: number): ThreadScrollDirection | null {
return deltaY < 0 ? "backward" : deltaY > 0 ? "forward" : null;
}
function keyboardScrollDirection(
event: KeyboardEvent,
): ThreadScrollDirection | null {
if (event.key === " ") {
return event.shiftKey ? "backward" : "forward";
}
return KEYBOARD_SCROLL_DIRECTIONS[event.key] ?? null;
}
function canScrollInDirection(
element: HTMLElement,
direction: ThreadScrollDirection | null,
): boolean {
switch (direction) {
case "backward":
return element.scrollTop > 0;
case "forward":
return (
element.scrollTop
< Math.max(0, element.scrollHeight - element.clientHeight)
);
default:
return false;
}
}
function readSoftKeyboardInsetBottom(container: HTMLElement | null): number {
const viewport = window.visualViewport;
if (!viewport) return 0;
const active = document.activeElement;
if (!isKeyboardEditableElement(active) || !container?.contains(active)) return 0;
const layoutHeight = window.innerHeight || document.documentElement.clientHeight;
const inset = layoutHeight - viewport.height - viewport.offsetTop;
return inset >= SOFT_KEYBOARD_MIN_INSET_PX ? Math.ceil(inset) : 0;
}
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
messages,
isStreaming,
composer,
emptyState,
scrollToBottomSignal = 0,
activeTurnId = null,
activeTurnStartedHere = false,
conversationKey = null,
conversationReady = true,
showScrollToBottomButton = true,
cliApps = [],
mcpPresets = [],
slashCommands = [],
forkBoundaryMessageCount = null,
hasMoreBefore = false,
loadingOlder = false,
userMessageOffset = 0,
onLoadOlder,
onOpenFilePreview,
onForkFromMessage,
onQuoteSelection,
}, ref) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const messageRegionRef = useRef<HTMLDivElement>(null);
const messageContentRef = useRef<HTMLDivElement>(null);
const composerDockRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey);
const pendingConversationScrollRef = useRef(true);
const pendingPromptJumpRef = useRef<string | null>(null);
const restoreScrollAfterPrependRef =
useRef<{ height: number; top: number } | null>(null);
const composerDockHeightRef = useRef(0);
const [atBottom, setAtBottom] = useState(true);
const [composerDockHeight, setComposerDockHeight] = useState(0);
const [keyboardInsetBottom, setKeyboardInsetBottom] = useState(0);
const [hasVerticalOverflow, setHasVerticalOverflow] = useState(false);
const [visibleMessageCount, setVisibleMessageCount] =
useState(INITIAL_HISTORY_WINDOW);
const threadMotionRef = useRef<ThreadMotionCoordinator | null>(null);
if (threadMotionRef.current === null) {
const camera = new ThreadCameraController(() => scrollRef.current);
threadMotionRef.current = new ThreadMotionCoordinator({
camera,
measure: (promptId) => {
const scrollEl = scrollRef.current;
const composerDock = composerDockRef.current;
if (!scrollEl) return null;
const composerHeight = composerDock
? composerDock.getBoundingClientRect().height || composerDock.offsetHeight
: 0;
const prompt = promptId ? findPromptElement(scrollEl, promptId) : null;
const scrollHeight = scrollEl.scrollHeight;
const clientHeight = scrollEl.clientHeight;
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
return {
scrollTop: scrollEl.scrollTop,
scrollHeight,
clientHeight,
maxScrollTop,
composerHeight,
promptTop: prompt
? Math.min(
maxScrollTop,
Math.max(0, promptTop(scrollEl, prompt) - 16),
)
: null,
};
},
onGeometry: (geometry: ThreadMotionGeometry) => {
if (Math.abs(composerDockHeightRef.current - geometry.composerHeight) >= 1) {
composerDockHeightRef.current = geometry.composerHeight;
setComposerDockHeight(geometry.composerHeight);
}
const nextOverflow = geometry.scrollHeight > geometry.clientHeight + 1;
setHasVerticalOverflow((current) =>
current === nextOverflow ? current : nextOverflow,
);
},
onAutoFollow: () => setAtBottom(true),
});
}
const hasMessages = messages.length > 0;
const visibleMessages = useMemo(
() => windowMessages(messages, visibleMessageCount),
[messages, visibleMessageCount],
);
const hiddenMessageCount = messages.length - visibleMessages.length;
const hiddenUserMessageCount =
userMessageOffset
+ (hiddenMessageCount > 0
? messages.slice(0, hiddenMessageCount).filter((message) => message.role === "user").length
: 0);
const visibleForkBoundaryMessageCount =
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
? forkBoundaryMessageCount - hiddenMessageCount
: null;
const scrollButtonBottom =
keyboardInsetBottom
+ (composerDockHeight > 0
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
const scrollViewportStyle =
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
const yieldCameraToUser = useCallback(() => {
threadMotionRef.current?.takeUserControl();
}, []);
const scrollToBottomNow = useCallback((smooth = false) => {
const el = scrollRef.current;
const marker = bottomRef.current;
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
if (el) {
const top = Math.max(0, el.scrollHeight - el.clientHeight);
if (smooth) {
threadMotionRef.current?.animateTo(top);
} else {
threadMotionRef.current?.jumpTo(top);
}
} else if (marker) {
marker.scrollIntoView({ block: "end", behavior });
}
setAtBottom(true);
}, []);
const scrollToBottom = useCallback(
(smooth = false, options?: { force?: boolean }) => {
const force = options?.force ?? false;
if (!force && threadMotionRef.current?.isAutoFollowPaused()) return;
threadMotionRef.current?.resumeAutoFollow();
scrollToBottomNow(smooth);
},
[scrollToBottomNow],
);
const loadEarlierMessages = useCallback(() => {
const el = scrollRef.current;
if (el) {
restoreScrollAfterPrependRef.current = {
height: el.scrollHeight,
top: el.scrollTop,
};
}
threadMotionRef.current?.takeUserControl();
setAtBottom(false);
if (hiddenMessageCount > 0) {
setVisibleMessageCount((count) =>
Math.min(messages.length, count + HISTORY_WINDOW_INCREMENT),
);
return;
}
if (hasMoreBefore && onLoadOlder && !loadingOlder) {
setVisibleMessageCount((count) => count + HISTORY_WINDOW_INCREMENT);
void onLoadOlder();
}
}, [hasMoreBefore, hiddenMessageCount, loadingOlder, messages.length, onLoadOlder]);
const maybeLoadEarlierFromScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || !hasMessages || pendingConversationScrollRef.current) return;
if (!threadMotionRef.current?.isBrowsingHistory()) return;
if (el.scrollTop > NEAR_TOP_PX) return;
if (hiddenMessageCount <= 0 && !hasMoreBefore) return;
loadEarlierMessages();
}, [hasMessages, hasMoreBefore, hiddenMessageCount, loadEarlierMessages]);
const navigateToVisiblePrompt = useCallback((promptId: string) => {
const scrollEl = scrollRef.current;
const prompt = scrollEl ? findPromptElement(scrollEl, promptId) : null;
if (!scrollEl || !prompt) return false;
setAtBottom(false);
const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
threadMotionRef.current?.navigateHistoryTo(
Math.min(
maxScrollTop,
Math.max(0, promptTop(scrollEl, prompt) - 16),
),
);
return true;
}, []);
const jumpToUserPrompt = useCallback((promptId: string) => {
if (navigateToVisiblePrompt(promptId)) return;
const index = messages.findIndex((message) => message.id === promptId);
if (index < 0) return;
threadMotionRef.current?.takeUserControl();
pendingPromptJumpRef.current = promptId;
setAtBottom(false);
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
}, [messages, navigateToVisiblePrompt]);
const cancelAutoScroll = useCallback(() => {
threadMotionRef.current?.takeUserControl();
}, []);
useImperativeHandle(
ref,
() => ({
jumpToUserPrompt,
cancelAutoScroll,
}),
[cancelAutoScroll, jumpToUserPrompt],
);
useLayoutEffect(() => {
const updateKeyboardInset = () => {
const scrollEl = scrollRef.current;
const next = readSoftKeyboardInsetBottom(scrollEl);
const active = document.activeElement;
const composerFocused =
hasMessages
&& isKeyboardEditableElement(active)
&& Boolean(scrollEl?.contains(active));
setKeyboardInsetBottom((current) =>
Math.abs(current - next) < 1 ? current : next,
);
if (composerFocused) {
// Focusing the composer establishes a new reference frame at the
// latest message. This is one immediate positioning command; viewport
// events may issue a fresh command, but no command survives into a
// later render as a train of retry frames.
scrollToBottom(false, { force: true });
}
};
updateKeyboardInset();
const viewport = window.visualViewport;
viewport?.addEventListener("resize", updateKeyboardInset);
viewport?.addEventListener("scroll", updateKeyboardInset);
window.addEventListener("resize", updateKeyboardInset);
document.addEventListener("focusin", updateKeyboardInset);
document.addEventListener("focusout", updateKeyboardInset);
return () => {
viewport?.removeEventListener("resize", updateKeyboardInset);
viewport?.removeEventListener("scroll", updateKeyboardInset);
window.removeEventListener("resize", updateKeyboardInset);
document.removeEventListener("focusin", updateKeyboardInset);
document.removeEventListener("focusout", updateKeyboardInset);
};
}, [hasMessages, scrollToBottom]);
useEffect(() => {
if (scrollToBottomSignal <= 0) return;
scrollToBottom(false, { force: true });
}, [scrollToBottomSignal, scrollToBottom]);
useLayoutEffect(() => {
if (lastConversationKeyRef.current === conversationKey) return;
lastConversationKeyRef.current = conversationKey;
pendingConversationScrollRef.current = true;
threadMotionRef.current?.reset();
setAtBottom(true);
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
}, [conversationKey]);
useLayoutEffect(() => {
if (!conversationReady) {
threadMotionRef.current?.reset();
return;
}
if (!activeTurnId) {
threadMotionRef.current?.completeTurn();
return;
}
const promptIndex = messages.findIndex(
(message) => message.role === "user" && message.turnId === activeTurnId,
);
if (
activeTurnStartedHere
&& promptIndex >= 0
&& threadMotionRef.current?.snapshot().turnId !== activeTurnId
) {
// A turn submitted in this mounted viewport establishes a new prompt
// origin. A restored turn must first complete open-at-bottom instead.
pendingConversationScrollRef.current = false;
}
const prompt = promptIndex >= 0 ? messages[promptIndex] : null;
const hasOutput = promptIndex >= 0
? messages
.slice(promptIndex + 1)
.some(
(message) =>
message.role !== "user"
&& (!message.turnId || message.turnId === activeTurnId),
)
: !activeTurnStartedHere && messages.some(
(message) =>
message.role !== "user" && message.turnId === activeTurnId,
);
threadMotionRef.current?.updateTurn({
id: activeTurnId,
promptId: prompt?.id ?? null,
hasOutput,
entry: activeTurnStartedHere ? "submitted" : "restored",
});
}, [activeTurnId, activeTurnStartedHere, conversationReady, messages]);
useLayoutEffect(() => {
const pending = restoreScrollAfterPrependRef.current;
if (!pending) return;
const el = scrollRef.current;
restoreScrollAfterPrependRef.current = null;
if (!el) return;
const delta = el.scrollHeight - pending.height;
const nextTop = Math.min(
Math.max(0, el.scrollHeight - el.clientHeight),
Math.max(0, pending.top + delta),
);
threadMotionRef.current?.jumpTo(nextTop);
}, [visibleMessages.length, messages.length]);
useLayoutEffect(() => {
const promptId = pendingPromptJumpRef.current;
const scrollEl = scrollRef.current;
if (!promptId || !scrollEl || !findPromptElement(scrollEl, promptId)) return;
pendingPromptJumpRef.current = null;
const frame = window.requestAnimationFrame(() => navigateToVisiblePrompt(promptId));
return () => window.cancelAnimationFrame(frame);
}, [navigateToVisiblePrompt, visibleMessages.length]);
useLayoutEffect(() => {
if (!pendingConversationScrollRef.current) return;
if (!conversationReady) return;
if (!conversationKey) {
pendingConversationScrollRef.current = false;
scrollToBottom(false, { force: true });
return;
}
scrollToBottom(false, { force: true });
if (!hasMessages) return;
pendingConversationScrollRef.current = false;
}, [
conversationKey,
conversationReady,
hasMessages,
messages,
scrollToBottom,
]);
useLayoutEffect(() => {
threadMotionRef.current?.invalidateGeometry();
}, [composer, hasMessages, visibleMessages.length]);
useEffect(() => () => threadMotionRef.current?.dispose(), []);
useLayoutEffect(() => {
const el = scrollRef.current;
const content = contentRef.current;
const messageRegion = messageRegionRef.current;
const messageContent = messageContentRef.current;
const composerDock = composerDockRef.current;
if (!el) return;
const invalidateGeometry = () => {
threadMotionRef.current?.invalidateGeometry();
};
invalidateGeometry();
const observer = typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(invalidateGeometry);
observer?.observe(el);
if (content) observer?.observe(content);
if (messageRegion) observer?.observe(messageRegion);
if (messageContent) observer?.observe(messageContent);
if (composerDock) observer?.observe(composerDock);
window.addEventListener("resize", invalidateGeometry);
return () => {
observer?.disconnect();
window.removeEventListener("resize", invalidateGeometry);
};
}, [hasMessages]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = (allowHistoryLoad = true) => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
const near = distance < NEAR_BOTTOM_PX;
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
const logicallyAtBottom = owner === "automatic" || near;
setAtBottom((current) =>
current === logicallyAtBottom ? current : logicallyAtBottom,
);
if (allowHistoryLoad && owner === "user") maybeLoadEarlierFromScroll();
};
onScroll(false);
const handleScroll = () => onScroll(true);
const handleDirectionalInput = (
direction: ThreadScrollDirection | null,
) => {
if (!direction) return;
threadMotionRef.current?.handleUserScrollIntent(
canScrollInDirection(el, direction),
);
};
const handleWheel = (event: WheelEvent) => {
if (
event.defaultPrevented
|| event.ctrlKey
|| Math.abs(event.deltaY) <= Math.abs(event.deltaX)
) {
return;
}
handleDirectionalInput(directionFromDelta(event.deltaY));
};
const handlePointerDown = (event: PointerEvent) => {
if (event.button === 0 && event.target === el) yieldCameraToUser();
};
let touchStartY: number | null = null;
const handleTouchStart = (event: TouchEvent) => {
touchStartY = event.touches[0]?.clientY ?? null;
};
const handleTouchMove = (event: TouchEvent) => {
const currentY = event.touches[0]?.clientY;
const scrollDeltaY =
touchStartY !== null && currentY !== undefined
? touchStartY - currentY
: 0;
handleDirectionalInput(directionFromDelta(scrollDeltaY));
};
const handleTouchEnd = () => {
touchStartY = null;
};
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.defaultPrevented
|| event.altKey
|| event.ctrlKey
|| event.metaKey
|| isKeyboardEditableElement(event.target as Element | null)
) {
return;
}
handleDirectionalInput(keyboardScrollDirection(event));
};
el.addEventListener("scroll", handleScroll, { passive: true });
el.addEventListener("wheel", handleWheel, { passive: true });
el.addEventListener("touchstart", handleTouchStart, { passive: true });
el.addEventListener("touchmove", handleTouchMove, { passive: true });
el.addEventListener("touchend", handleTouchEnd, { passive: true });
el.addEventListener("touchcancel", handleTouchEnd, { passive: true });
el.addEventListener("pointerdown", handlePointerDown);
el.addEventListener("keydown", handleKeyDown);
return () => {
el.removeEventListener("scroll", handleScroll);
el.removeEventListener("wheel", handleWheel);
el.removeEventListener("touchstart", handleTouchStart);
el.removeEventListener("touchmove", handleTouchMove);
el.removeEventListener("touchend", handleTouchEnd);
el.removeEventListener("touchcancel", handleTouchEnd);
el.removeEventListener("pointerdown", handlePointerDown);
el.removeEventListener("keydown", handleKeyDown);
};
}, [maybeLoadEarlierFromScroll, yieldCameraToUser]);
return (
<div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden">
<div
ref={scrollRef}
className={cn(
"thread-viewport-scrollbar absolute inset-0 scroll-auto",
"[overflow-anchor:none] [scrollbar-width:none]",
"[&::-webkit-scrollbar]:hidden",
hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden",
)}
style={scrollViewportStyle}
>
<div
ref={contentRef}
data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
data-layout={hasMessages ? "thread" : "hero"}
className={cn(
"thread-layout mx-auto grid min-h-full w-full",
hasMessages
? "max-w-[64rem]"
: "max-w-[72rem] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-6 sm:px-4 sm:py-12",
)}
>
{hasMessages ? (
<div
ref={messageRegionRef}
data-testid="thread-message-region"
className="row-start-1 flex min-h-0 min-w-0 flex-col justify-start px-3 pb-4 pt-4 sm:px-4"
>
<div ref={messageContentRef} className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages
messages={visibleMessages}
isStreaming={isStreaming}
hiddenUserMessageCount={hiddenUserMessageCount}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
forkBoundaryMessageCount={visibleForkBoundaryMessageCount}
onOpenFilePreview={onOpenFilePreview}
onForkFromMessage={onForkFromMessage}
onQuoteSelection={onQuoteSelection}
/>
</div>
</div>
) : (
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8">
{emptyState}
</div>
)}
<div
ref={composerDockRef}
data-testid="thread-composer-dock"
className={cn(
"row-start-2 z-10 w-full",
hasMessages ? "sticky bottom-0 bg-background" : "relative self-center",
)}
>
<div
className={cn(
hasMessages
? "px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4"
: "",
)}
>
<div
data-testid="thread-composer-motion"
className="mx-auto w-full max-w-[58rem]"
>
{composer}
</div>
</div>
</div>
<div
aria-hidden
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
/>
</div>
<div ref={bottomRef} aria-hidden className="h-px" />
</div>
<div
aria-hidden
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}
onJumpToPrompt={navigateToVisiblePrompt}
/>
) : null}
{showScrollToBottomButton && !atBottom && (
<div
className="absolute left-1/2 z-20 -translate-x-1/2"
style={{ bottom: scrollButtonBottom }}
>
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true, { force: true })}
className={cn(
"h-8 w-8 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
aria-label={t("thread.scrollToBottom")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
});