mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 05:00:20 +00:00
Add transcript virtualization to Foundry UI
This commit is contained in:
parent
5ea9ec5e2f
commit
070a5f1cd6
15 changed files with 780 additions and 576 deletions
|
|
@ -28,6 +28,7 @@
|
|||
"sandbox-agent": "^0.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.22",
|
||||
"ghostty-web": "^0.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import { AgentTranscript, type AgentTranscriptClassNames, type AgentTranscriptProps, type TranscriptEntry } from "./AgentTranscript.tsx";
|
||||
import { ChatComposer, type ChatComposerClassNames, type ChatComposerProps } from "./ChatComposer.tsx";
|
||||
|
||||
|
|
@ -18,9 +18,10 @@ export interface AgentConversationProps {
|
|||
emptyState?: ReactNode;
|
||||
transcriptClassName?: string;
|
||||
transcriptClassNames?: Partial<AgentTranscriptClassNames>;
|
||||
scrollRef?: RefObject<HTMLDivElement>;
|
||||
composerClassName?: string;
|
||||
composerClassNames?: Partial<ChatComposerClassNames>;
|
||||
transcriptProps?: Omit<AgentTranscriptProps, "entries" | "className" | "classNames">;
|
||||
transcriptProps?: Omit<AgentTranscriptProps, "entries" | "className" | "classNames" | "scrollRef">;
|
||||
composerProps?: Omit<ChatComposerProps, "className" | "classNames">;
|
||||
}
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ export const AgentConversation = ({
|
|||
emptyState,
|
||||
transcriptClassName,
|
||||
transcriptClassNames,
|
||||
scrollRef,
|
||||
composerClassName,
|
||||
composerClassNames,
|
||||
transcriptProps,
|
||||
|
|
@ -58,12 +60,18 @@ export const AgentConversation = ({
|
|||
return (
|
||||
<div className={cx(resolvedClassNames.root, className)} data-slot="root">
|
||||
{hasTranscriptContent ? (
|
||||
<AgentTranscript
|
||||
entries={entries}
|
||||
className={cx(resolvedClassNames.transcript, transcriptClassName)}
|
||||
classNames={transcriptClassNames}
|
||||
{...transcriptProps}
|
||||
/>
|
||||
scrollRef ? (
|
||||
<div className={cx(resolvedClassNames.transcript, transcriptClassName)} data-slot="transcript" ref={scrollRef}>
|
||||
<AgentTranscript entries={entries} classNames={transcriptClassNames} {...transcriptProps} />
|
||||
</div>
|
||||
) : (
|
||||
<AgentTranscript
|
||||
entries={entries}
|
||||
className={cx(resolvedClassNames.transcript, transcriptClassName)}
|
||||
classNames={transcriptClassNames}
|
||||
{...transcriptProps}
|
||||
/>
|
||||
)
|
||||
) : emptyState ? (
|
||||
<div className={resolvedClassNames.emptyState} data-slot="empty-state">
|
||||
{emptyState}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranscriptVirtualizer } from "./useTranscriptVirtualizer.ts";
|
||||
|
||||
export type PermissionReply = "once" | "always" | "reject";
|
||||
|
||||
|
|
@ -98,10 +99,14 @@ export interface AgentTranscriptProps {
|
|||
className?: string;
|
||||
classNames?: Partial<AgentTranscriptClassNames>;
|
||||
endRef?: RefObject<HTMLDivElement>;
|
||||
scrollRef?: RefObject<HTMLDivElement>;
|
||||
scrollToEntryId?: string | null;
|
||||
sessionError?: string | null;
|
||||
eventError?: string | null;
|
||||
isThinking?: boolean;
|
||||
agentId?: string;
|
||||
virtualize?: boolean;
|
||||
onAtBottomChange?: (atBottom: boolean) => void;
|
||||
onEventClick?: (eventId: string) => void;
|
||||
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
|
||||
isDividerEntry?: (entry: TranscriptEntry) => boolean;
|
||||
|
|
@ -124,6 +129,8 @@ type GroupedEntries =
|
|||
| { type: "divider"; entries: TranscriptEntry[] }
|
||||
| { type: "permission"; entries: TranscriptEntry[] };
|
||||
|
||||
const VIRTUAL_GROUP_GAP_PX = 12;
|
||||
|
||||
const DEFAULT_CLASS_NAMES: AgentTranscriptClassNames = {
|
||||
root: "sa-agent-transcript",
|
||||
divider: "sa-agent-transcript-divider",
|
||||
|
|
@ -324,9 +331,21 @@ const buildGroupedEntries = (entries: TranscriptEntry[], isDividerEntry: (entry:
|
|||
return groupedEntries;
|
||||
};
|
||||
|
||||
const getGroupedEntryKey = (group: GroupedEntries, index: number): string => {
|
||||
const firstEntry = group.entries[0];
|
||||
|
||||
if (group.type === "tool-group") {
|
||||
return `tool-group:${firstEntry?.id ?? index}`;
|
||||
}
|
||||
|
||||
return firstEntry?.id ?? `${group.type}:${index}`;
|
||||
};
|
||||
|
||||
const ToolItem = ({
|
||||
entry,
|
||||
isLast,
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
classNames,
|
||||
onEventClick,
|
||||
canOpenEvent,
|
||||
|
|
@ -337,6 +356,8 @@ const ToolItem = ({
|
|||
}: {
|
||||
entry: TranscriptEntry;
|
||||
isLast: boolean;
|
||||
expanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
classNames: AgentTranscriptClassNames;
|
||||
onEventClick?: (eventId: string) => void;
|
||||
canOpenEvent: (entry: TranscriptEntry) => boolean;
|
||||
|
|
@ -345,7 +366,6 @@ const ToolItem = ({
|
|||
renderChevron: (expanded: boolean) => ReactNode;
|
||||
renderEventLinkContent: (entry: TranscriptEntry) => ReactNode;
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isTool = entry.kind === "tool";
|
||||
const isReasoning = entry.kind === "reasoning";
|
||||
const isMeta = entry.kind === "meta";
|
||||
|
|
@ -382,7 +402,7 @@ const ToolItem = ({
|
|||
disabled={!hasContent}
|
||||
onClick={() => {
|
||||
if (hasContent) {
|
||||
setExpanded((value) => !value);
|
||||
onExpandedChange(!expanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
@ -469,6 +489,10 @@ const ToolItem = ({
|
|||
|
||||
const ToolGroup = ({
|
||||
entries,
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
expandedItemIds,
|
||||
onToolItemExpandedChange,
|
||||
classNames,
|
||||
onEventClick,
|
||||
canOpenEvent,
|
||||
|
|
@ -480,6 +504,10 @@ const ToolGroup = ({
|
|||
renderEventLinkContent,
|
||||
}: {
|
||||
entries: TranscriptEntry[];
|
||||
expanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
expandedItemIds: Record<string, boolean>;
|
||||
onToolItemExpandedChange: (entryId: string, expanded: boolean) => void;
|
||||
classNames: AgentTranscriptClassNames;
|
||||
onEventClick?: (eventId: string) => void;
|
||||
canOpenEvent: (entry: TranscriptEntry) => boolean;
|
||||
|
|
@ -490,7 +518,6 @@ const ToolGroup = ({
|
|||
renderChevron: (expanded: boolean) => ReactNode;
|
||||
renderEventLinkContent: (entry: TranscriptEntry) => ReactNode;
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasFailed = entries.some((entry) => entry.kind === "tool" && entry.toolStatus === "failed");
|
||||
|
||||
if (entries.length === 1) {
|
||||
|
|
@ -499,6 +526,8 @@ const ToolGroup = ({
|
|||
<ToolItem
|
||||
entry={entries[0]}
|
||||
isLast={true}
|
||||
expanded={Boolean(expandedItemIds[entries[0]!.id])}
|
||||
onExpandedChange={(nextExpanded) => onToolItemExpandedChange(entries[0]!.id, nextExpanded)}
|
||||
classNames={classNames}
|
||||
onEventClick={onEventClick}
|
||||
canOpenEvent={canOpenEvent}
|
||||
|
|
@ -518,7 +547,7 @@ const ToolGroup = ({
|
|||
className={cx(classNames.toolGroupHeader, expanded && "expanded")}
|
||||
data-slot="tool-group-header"
|
||||
data-expanded={expanded ? "true" : undefined}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
>
|
||||
<span className={classNames.toolGroupIcon} data-slot="tool-group-icon">
|
||||
{renderToolGroupIcon(entries, expanded)}
|
||||
|
|
@ -537,6 +566,8 @@ const ToolGroup = ({
|
|||
key={entry.id}
|
||||
entry={entry}
|
||||
isLast={index === entries.length - 1}
|
||||
expanded={Boolean(expandedItemIds[entry.id])}
|
||||
onExpandedChange={(nextExpanded) => onToolItemExpandedChange(entry.id, nextExpanded)}
|
||||
classNames={classNames}
|
||||
onEventClick={onEventClick}
|
||||
canOpenEvent={canOpenEvent}
|
||||
|
|
@ -636,10 +667,14 @@ export const AgentTranscript = ({
|
|||
className,
|
||||
classNames: classNameOverrides,
|
||||
endRef,
|
||||
scrollRef,
|
||||
scrollToEntryId,
|
||||
sessionError,
|
||||
eventError,
|
||||
isThinking,
|
||||
agentId,
|
||||
virtualize = false,
|
||||
onAtBottomChange,
|
||||
onEventClick,
|
||||
onPermissionReply,
|
||||
isDividerEntry = defaultIsDividerEntry,
|
||||
|
|
@ -657,83 +692,199 @@ export const AgentTranscript = ({
|
|||
}: AgentTranscriptProps) => {
|
||||
const resolvedClassNames = useMemo(() => mergeClassNames(DEFAULT_CLASS_NAMES, classNameOverrides), [classNameOverrides]);
|
||||
const groupedEntries = useMemo(() => buildGroupedEntries(entries, isDividerEntry), [entries, isDividerEntry]);
|
||||
const [expandedToolGroups, setExpandedToolGroups] = useState<Record<string, boolean>>({});
|
||||
const [expandedToolItems, setExpandedToolItems] = useState<Record<string, boolean>>({});
|
||||
const lastScrollTargetRef = useRef<string | null>(null);
|
||||
const isVirtualized = virtualize && Boolean(scrollRef);
|
||||
const { virtualizer, isFollowingRef } = useTranscriptVirtualizer(groupedEntries, isVirtualized ? scrollRef : undefined, onAtBottomChange);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollToEntryId) {
|
||||
lastScrollTargetRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isVirtualized || scrollToEntryId === lastScrollTargetRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndex = groupedEntries.findIndex((group) => group.entries.some((entry) => entry.id === scrollToEntryId));
|
||||
if (targetIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastScrollTargetRef.current = scrollToEntryId;
|
||||
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
virtualizer.scrollToIndex(targetIndex, {
|
||||
align: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [groupedEntries, isVirtualized, scrollToEntryId, virtualizer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVirtualized || !scrollRef?.current || !isFollowingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollElement = scrollRef.current;
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
scrollElement.scrollTo({ top: scrollElement.scrollHeight });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [eventError, isFollowingRef, isThinking, isVirtualized, scrollRef, sessionError]);
|
||||
|
||||
const setToolGroupExpanded = (groupKey: string, expanded: boolean) => {
|
||||
setExpandedToolGroups((current) => {
|
||||
if (current[groupKey] === expanded) {
|
||||
return current;
|
||||
}
|
||||
return { ...current, [groupKey]: expanded };
|
||||
});
|
||||
};
|
||||
|
||||
const setToolItemExpanded = (entryId: string, expanded: boolean) => {
|
||||
setExpandedToolItems((current) => {
|
||||
if (current[entryId] === expanded) {
|
||||
return current;
|
||||
}
|
||||
return { ...current, [entryId]: expanded };
|
||||
});
|
||||
};
|
||||
|
||||
const renderGroup = (group: GroupedEntries, index: number) => {
|
||||
if (group.type === "divider") {
|
||||
const entry = group.entries[0];
|
||||
const title = entry.meta?.title ?? "Status";
|
||||
return (
|
||||
<div key={entry.id} className={resolvedClassNames.divider} data-slot="divider">
|
||||
<div className={resolvedClassNames.dividerLine} data-slot="divider-line" />
|
||||
<span className={resolvedClassNames.dividerText} data-slot="divider-text">
|
||||
{title}
|
||||
</span>
|
||||
<div className={resolvedClassNames.dividerLine} data-slot="divider-line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.type === "tool-group") {
|
||||
const groupKey = getGroupedEntryKey(group, index);
|
||||
|
||||
return (
|
||||
<ToolGroup
|
||||
key={groupKey}
|
||||
entries={group.entries}
|
||||
expanded={Boolean(expandedToolGroups[groupKey])}
|
||||
onExpandedChange={(expanded) => setToolGroupExpanded(groupKey, expanded)}
|
||||
expandedItemIds={expandedToolItems}
|
||||
onToolItemExpandedChange={setToolItemExpanded}
|
||||
classNames={resolvedClassNames}
|
||||
onEventClick={onEventClick}
|
||||
canOpenEvent={canOpenEvent}
|
||||
getToolGroupSummary={getToolGroupSummary}
|
||||
renderInlinePendingIndicator={renderInlinePendingIndicator}
|
||||
renderToolItemIcon={renderToolItemIcon}
|
||||
renderToolGroupIcon={renderToolGroupIcon}
|
||||
renderChevron={renderChevron}
|
||||
renderEventLinkContent={renderEventLinkContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.type === "permission") {
|
||||
const entry = group.entries[0];
|
||||
return (
|
||||
<PermissionPrompt
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
classNames={resolvedClassNames}
|
||||
onPermissionReply={onPermissionReply}
|
||||
renderPermissionIcon={renderPermissionIcon}
|
||||
renderPermissionOptionContent={renderPermissionOptionContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const entry = group.entries[0];
|
||||
const messageVariant = getMessageVariant(entry);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={cx(resolvedClassNames.message, messageVariant, "no-avatar")}
|
||||
data-slot="message"
|
||||
data-kind={entry.kind}
|
||||
data-role={entry.role}
|
||||
data-variant={messageVariant}
|
||||
data-severity={entry.meta?.severity}
|
||||
>
|
||||
<div className={resolvedClassNames.messageContent} data-slot="message-content">
|
||||
{entry.text ? (
|
||||
<div className={resolvedClassNames.messageText} data-slot="message-text">
|
||||
{renderMessageText(entry)}
|
||||
</div>
|
||||
) : (
|
||||
<span className={resolvedClassNames.thinkingIndicator} data-slot="thinking-indicator">
|
||||
{renderInlinePendingIndicator()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(resolvedClassNames.root, className)} data-slot="root">
|
||||
{groupedEntries.map((group, index) => {
|
||||
if (group.type === "divider") {
|
||||
const entry = group.entries[0];
|
||||
const title = entry.meta?.title ?? "Status";
|
||||
return (
|
||||
<div key={entry.id} className={resolvedClassNames.divider} data-slot="divider">
|
||||
<div className={resolvedClassNames.dividerLine} data-slot="divider-line" />
|
||||
<span className={resolvedClassNames.dividerText} data-slot="divider-text">
|
||||
{title}
|
||||
</span>
|
||||
<div className={resolvedClassNames.dividerLine} data-slot="divider-line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div className={cx(resolvedClassNames.root, className)} data-slot="root" data-virtualized={isVirtualized ? "true" : undefined}>
|
||||
{isVirtualized ? (
|
||||
<div
|
||||
data-slot="virtual-list"
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const group = groupedEntries[virtualItem.index];
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (group.type === "tool-group") {
|
||||
return (
|
||||
<ToolGroup
|
||||
key={`tool-group-${index}`}
|
||||
entries={group.entries}
|
||||
classNames={resolvedClassNames}
|
||||
onEventClick={onEventClick}
|
||||
canOpenEvent={canOpenEvent}
|
||||
getToolGroupSummary={getToolGroupSummary}
|
||||
renderInlinePendingIndicator={renderInlinePendingIndicator}
|
||||
renderToolItemIcon={renderToolItemIcon}
|
||||
renderToolGroupIcon={renderToolGroupIcon}
|
||||
renderChevron={renderChevron}
|
||||
renderEventLinkContent={renderEventLinkContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.type === "permission") {
|
||||
const entry = group.entries[0];
|
||||
return (
|
||||
<PermissionPrompt
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
classNames={resolvedClassNames}
|
||||
onPermissionReply={onPermissionReply}
|
||||
renderPermissionIcon={renderPermissionIcon}
|
||||
renderPermissionOptionContent={renderPermissionOptionContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const entry = group.entries[0];
|
||||
const messageVariant = getMessageVariant(entry);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={cx(resolvedClassNames.message, messageVariant, "no-avatar")}
|
||||
data-slot="message"
|
||||
data-kind={entry.kind}
|
||||
data-role={entry.role}
|
||||
data-variant={messageVariant}
|
||||
data-severity={entry.meta?.severity}
|
||||
>
|
||||
<div className={resolvedClassNames.messageContent} data-slot="message-content">
|
||||
{entry.text ? (
|
||||
<div className={resolvedClassNames.messageText} data-slot="message-text">
|
||||
{renderMessageText(entry)}
|
||||
return (
|
||||
<div
|
||||
key={getGroupedEntryKey(group, virtualItem.index)}
|
||||
data-index={virtualItem.index}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
virtualizer.measureElement(node);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
left: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div style={{ paddingBottom: virtualItem.index === groupedEntries.length - 1 ? 0 : `${VIRTUAL_GROUP_GAP_PX}px` }}>
|
||||
{renderGroup(group, virtualItem.index)}
|
||||
</div>
|
||||
) : (
|
||||
<span className={resolvedClassNames.thinkingIndicator} data-slot="thinking-indicator">
|
||||
{renderInlinePendingIndicator()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
groupedEntries.map((group, index) => renderGroup(group, index))
|
||||
)}
|
||||
{sessionError ? (
|
||||
<div className={resolvedClassNames.error} data-slot="error" data-source="session">
|
||||
{sessionError}
|
||||
|
|
@ -753,7 +904,7 @@ export const AgentTranscript = ({
|
|||
</div>
|
||||
))
|
||||
: null}
|
||||
<div ref={endRef} className={resolvedClassNames.endAnchor} data-slot="end-anchor" />
|
||||
{!isVirtualized ? <div ref={endRef} className={resolvedClassNames.endAnchor} data-slot="end-anchor" /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export { AgentConversation } from "./AgentConversation.tsx";
|
|||
export { AgentTranscript } from "./AgentTranscript.tsx";
|
||||
export { ChatComposer } from "./ChatComposer.tsx";
|
||||
export { ProcessTerminal } from "./ProcessTerminal.tsx";
|
||||
export { useTranscriptVirtualizer } from "./useTranscriptVirtualizer.ts";
|
||||
|
||||
export type {
|
||||
AgentConversationClassNames,
|
||||
|
|
|
|||
58
sdks/react/src/useTranscriptVirtualizer.ts
Normal file
58
sdks/react/src/useTranscriptVirtualizer.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
export function useTranscriptVirtualizer<T>(items: T[], scrollElementRef?: RefObject<HTMLDivElement>, onAtBottomChange?: (atBottom: boolean) => void) {
|
||||
const isFollowingRef = useRef(true);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => scrollElementRef?.current ?? null,
|
||||
estimateSize: () => 80,
|
||||
measureElement: (element) => element.getBoundingClientRect().height,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => isFollowingRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollElementRef?.current;
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateFollowState = () => {
|
||||
const atBottom = scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight < 50;
|
||||
isFollowingRef.current = atBottom;
|
||||
onAtBottomChange?.(atBottom);
|
||||
};
|
||||
|
||||
updateFollowState();
|
||||
scrollElement.addEventListener("scroll", updateFollowState, { passive: true });
|
||||
|
||||
return () => {
|
||||
scrollElement.removeEventListener("scroll", updateFollowState);
|
||||
};
|
||||
}, [onAtBottomChange, scrollElementRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFollowingRef.current || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
virtualizer.scrollToIndex(items.length - 1, {
|
||||
align: "end",
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [items.length, virtualizer]);
|
||||
|
||||
return { virtualizer, isFollowingRef };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue