Files
kandev/apps/web/components/task/chat/message-list-native.tsx
T
2026-09-13 21:57:10 +01:00

751 lines
26 KiB
TypeScript

"use client";
/* eslint-disable max-lines -- native transcript composition owns scrolling. */
import {
useEffect,
useMemo,
useRef,
memo,
forwardRef,
useImperativeHandle,
type ReactNode,
} from "react";
import { SessionPanelContent } from "@kandev/ui/pannel-session";
import type { Message, TaskSessionState } from "@/lib/types/http";
import type { RenderItem } from "@/hooks/use-processed-messages";
import { OLDER_PAGE_LIMIT, useLazyLoadMessages } from "@/hooks/use-lazy-load-messages";
import { useSessionTurn } from "@/hooks/domains/session/use-session-turn";
import { MessageListFooter } from "./message-list-footer";
import { useNativeScrollManagement } from "./message-list-native-scroll";
import { scheduleAfterPanelRestore, useActivationPending } from "./transcript-auto-scroll";
import { useTranscriptAutoScrollEnabled } from "./use-transcript-auto-scroll-enabled";
import { useDockviewStore } from "@/lib/state/dockview-store";
import {
type MessageListProps,
type MessageListHandle,
type LastPromptEdge,
MessageListStatus,
MessageItem,
UnreadDivider,
anchoredBarScrollOffsetPx,
getItemKey,
getConversationLoadingState,
getEffectiveActiveTurnId,
getLastTurnGroupId,
getStreamingAgentMessageId,
canReassertDividerScroll,
filterLaunchErrorItems,
filterLaunchErrorMessages,
resolveLastPromptEdge,
isElementFullyVisible,
} from "./message-list-shared";
const DIVIDER_SETTLING_WINDOW_MS = 4000;
/** Notifies `onLastPromptEdgeChange`/`onFirstMessageHiddenChange` whenever the
* last-prompt or first message crosses the container's viewport edges, so
* the composer's scroll buttons and the anchored-bar affordance know when to
* show themselves. A single scroll/resize listener drives both checks. */
function useTranscriptEdgeTracking(
scrollRef: React.RefObject<HTMLDivElement | null>,
lastPromptMessageId: string | null | undefined,
onLastPromptEdgeChange: ((edge: LastPromptEdge) => void) | undefined,
firstMessageId: string | null | undefined,
onFirstMessageHiddenChange: ((isHidden: boolean) => void) | undefined,
) {
useEffect(() => {
const container = scrollRef.current;
const lastTarget = lastPromptMessageId
? document.getElementById(`msg-${lastPromptMessageId}`)
: null;
const firstTarget = firstMessageId ? document.getElementById(`msg-${firstMessageId}`) : null;
if (!container || !lastTarget) onLastPromptEdgeChange?.("visible");
if (!container || !firstTarget) onFirstMessageHiddenChange?.(false);
if (!container) return;
const update = () => {
if (lastTarget) onLastPromptEdgeChange?.(resolveLastPromptEdge(container, lastTarget));
if (firstTarget) onFirstMessageHiddenChange?.(!isElementFullyVisible(container, firstTarget));
};
update();
container.addEventListener("scroll", update, { passive: true });
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(container);
if (lastTarget) resizeObserver.observe(lastTarget);
if (firstTarget && firstTarget !== lastTarget) resizeObserver.observe(firstTarget);
return () => {
container.removeEventListener("scroll", update);
resizeObserver.disconnect();
};
}, [
scrollRef,
lastPromptMessageId,
onLastPromptEdgeChange,
firstMessageId,
onFirstMessageHiddenChange,
]);
}
type NativeMessageListScrollParams = {
scrollRef: React.RefObject<HTMLDivElement | null>;
ref: React.ForwardedRef<MessageListHandle>;
items: RenderItem[];
messages: Message[];
isWorking: boolean;
sessionId: string | null;
enabled: boolean;
dividerBeforeItemKey?: string | null;
anchoredBarHeight?: number;
/** Initial/refetch loading: the sentinel's hard block. */
messagesLoading: boolean;
historyRefreshPending: boolean;
hasMore: boolean;
isLoadingMore: boolean;
loadMore: () => Promise<number>;
lastPromptMessageId: string | null | undefined;
onLastPromptEdgeChange: ((edge: LastPromptEdge) => void) | undefined;
firstMessageId: string | null | undefined;
onFirstMessageHiddenChange: ((isHidden: boolean) => void) | undefined;
/** Changes when transcript status rows can add/remove space above messages. */
scrollLayoutKey: string;
isVisible: boolean;
recoveryRevealKey?: string | null;
};
type ScrollToDividerOptions = {
onDividerScroll?: () => void;
scrollLayoutKey?: string;
enabled?: boolean;
sessionId?: string | null;
isProgrammaticScrollLocked?: () => boolean;
isVisible?: boolean;
historyRefreshPending?: boolean;
recoveryRevealKey?: string | null;
};
function consumeRecoveryRevealForDivider(
recoveryRevealKey: string | null,
didInitialScroll: React.RefObject<boolean>,
activationPendingRef: React.RefObject<boolean>,
): boolean {
if (recoveryRevealKey === null) return false;
// The native initial-position hook owns the recovery reveal. Mark this
// hook consumed so its normal bottom/divider placement cannot override it.
didInitialScroll.current = true;
activationPendingRef.current = false;
return true;
}
function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
const {
scrollRef,
ref,
items,
messages,
isWorking,
sessionId,
enabled,
dividerBeforeItemKey,
anchoredBarHeight,
messagesLoading,
historyRefreshPending,
hasMore,
isLoadingMore,
loadMore,
lastPromptMessageId,
onLastPromptEdgeChange,
firstMessageId,
onFirstMessageHiddenChange,
scrollLayoutKey,
isVisible,
recoveryRevealKey,
} = params;
const {
handleScrollToMessage,
sentinelRef,
markNotNearBottom,
isProgrammaticScrollLocked,
retryLoadMore,
showRecovery,
} = useNativeScrollManagement({
scrollRef,
items,
messages,
isWorking,
sessionId,
enabled,
hasUnreadDivider: Boolean(dividerBeforeItemKey),
messagesLoading,
historyRefreshPending,
hasMore,
isLoadingMore,
loadMore,
isVisible,
recoveryRevealKey,
});
const anchoredBarOffsetPx = anchoredBarScrollOffsetPx(anchoredBarHeight);
useEffect(() => {
scrollRef.current?.style.setProperty("--anchored-bar-h", `${anchoredBarOffsetPx}px`);
}, [anchoredBarOffsetPx]);
useScrollToDividerOrBottom(scrollRef, items.length, dividerBeforeItemKey, anchoredBarOffsetPx, {
onDividerScroll: markNotNearBottom,
scrollLayoutKey,
enabled,
sessionId,
isProgrammaticScrollLocked,
isVisible,
historyRefreshPending,
recoveryRevealKey,
});
useImperativeHandle(ref, () => ({ scrollToMessage: handleScrollToMessage }), [
handleScrollToMessage,
]);
useTranscriptEdgeTracking(
scrollRef,
lastPromptMessageId,
onLastPromptEdgeChange,
firstMessageId,
onFirstMessageHiddenChange,
);
return { handleScrollToMessage, sentinelRef, retryLoadMore, showRecovery };
}
type MessageRowProps = {
item: RenderItem;
sessionId: string | null;
permissionsByToolCallId: Map<string, Message>;
childrenByParentToolCallId: Map<string, Message[]>;
taskId?: string;
worktreePath?: string;
onOpenFile?: (path: string, repo?: string) => void;
isLastGroup: boolean;
activeTurnId: string | null;
streamingMessageId: string | null;
onScrollToMessage: (messageId: string, options?: { align?: "start" | "center" }) => void;
dividerBeforeItemKey?: string | null;
};
function getItemTurnId(item: RenderItem): string | undefined {
if (item.type === "turn_group") return item.turnId ?? undefined;
if (item.type === "message") return item.message.turn_id ?? undefined;
return undefined;
}
/** One transcript row, keyed and DOM-id'd by `getItemKey` so the scroll
* affordances (and `scrollToMessage`) can locate it directly. */
function MessageRow({
item,
sessionId,
permissionsByToolCallId,
childrenByParentToolCallId,
taskId,
worktreePath,
onOpenFile,
isLastGroup,
activeTurnId,
streamingMessageId,
onScrollToMessage,
dividerBeforeItemKey,
}: MessageRowProps) {
const key = getItemKey(item);
return (
<div
id={`msg-${key}`}
data-turn-id={getItemTurnId(item)}
tabIndex={-1}
className="pb-2 scroll-mt-[calc(4rem+env(safe-area-inset-top))] md:scroll-mt-[var(--anchored-bar-h,0px)]"
style={{ overflowAnchor: "none" }}
>
{dividerBeforeItemKey === key && <UnreadDivider />}
<MessageItem
item={item}
sessionId={sessionId}
permissionsByToolCallId={permissionsByToolCallId}
childrenByParentToolCallId={childrenByParentToolCallId}
taskId={taskId}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
isLastGroup={isLastGroup}
activeTurnId={activeTurnId}
streamingMessageId={streamingMessageId}
onScrollToMessage={onScrollToMessage}
/>
</div>
);
}
type NativeMessageListBodyProps = {
items: RenderItem[];
messages: Message[];
footerActionMessages?: Message[];
permissionsByToolCallId: Map<string, Message>;
childrenByParentToolCallId: Map<string, Message[]>;
taskId?: string;
sessionId: string | null;
isWorking: boolean;
messagesLoading: boolean;
sessionState?: TaskSessionState;
worktreePath?: string;
onOpenFile?: (path: string, repo?: string) => void;
hasMore: boolean;
isLoadingMore: boolean;
isInitialLoading: boolean;
showLoadingState: boolean;
historyStatus: MessageListProps["historyStatus"];
historyError: MessageListProps["historyError"];
onRetryHistory: MessageListProps["onRetryHistory"];
retryLoadMore: () => void;
showRecovery: boolean;
sentinelRef: (node: HTMLDivElement | null) => void;
lastTurnGroupId: string | null;
activeTurnId: string | null;
streamingMessageId: string | null;
onScrollToMessage: (messageId: string, options?: { align?: "start" | "center" }) => void;
autoScrollEnabled: boolean;
dividerBeforeItemKey?: string | null;
prependContent?: ReactNode;
launchErrorOwned: boolean;
launchErrorStamp?: string;
launchErrorOccurredAt?: string;
};
/**
* Scroll to bottom on initial load — or, if this visit's Slack-style "New"
* boundary lands on a currently-loaded item, straight to the divider
* instead (mirrors Slack drawing the line where you left off rather than
* always jumping to the newest message).
*
* - dividerBeforeItemKey is derived from usePanelActive (Dockview's
* active-tab signal), backed by useSyncExternalStore, which only
* resolves true on a render *after* this component's own mount — so on
* the very first run here it's still null even for a session that does
* have an unread divider.
* - The initial messages fetch can itself arrive in more than one wave
* (e.g. a WebSocket-delivered backfill continuing after this
* component's first commit, unrelated to user-triggered pagination),
* which can also retroactively shift where useScrollPositionOnPrepend
* lands the scroll. Rather than trying to classify every wave as
* "prepend" or "append" up front, the correction below keeps
* re-asserting the divider's position on every relevant change, bounded
* by BOTH of: the reader hasn't started scrolling yet (isUserScrolling
* — wheel/touchstart/keydown, since a plain 'scroll' event can't tell
* user intent apart from our own programmatic writes), AND still being
* within a short settling window since activation (isWithinSettlingWindow).
* The window exists so a live message arriving long after the visit has
* genuinely settled — with no wheel/touch/key event to catch, e.g. a
* scrollbar drag — can never re-trigger a correction; once either gate
* trips, it's the user's scroll position to own, same as Slack never
* re-snapping you to the unread line once you've started reading.
* - didScrollToDivider and didInitialScroll are separate latches so the
* bottom-fallback firing first (before dividerBeforeItemKey resolves)
* doesn't block the divider correction from still applying once it
* does. The caller also supplies a layout key so a loading-state transition
* with an unchanged item count can re-assert the target. Disabled initial
* placement is owned by the native initial-position hook, while previews
* keep their existing visible-host behavior without participating in read
* tracking.
*/
// eslint-disable-next-line max-lines-per-function -- divider and recovery placement share one scroll lifecycle.
export function useScrollToDividerOrBottom(
scrollRef: React.RefObject<HTMLDivElement | null>,
itemCount: number,
dividerBeforeItemKey: string | null | undefined,
anchoredBarOffsetPx: number,
options: ScrollToDividerOptions = {},
) {
const {
onDividerScroll,
scrollLayoutKey = "",
enabled = true,
sessionId = null,
isProgrammaticScrollLocked = () => false,
isVisible = true,
historyRefreshPending = false,
recoveryRevealKey = null,
} = options;
const { isVisibleRef, activationPendingRef } = useActivationPending(isVisible);
const isUserScrollingRef = useDividerUserScrolling(scrollRef);
// Bounds how long the divider correction below can keep re-asserting
// itself after activation, independent of user interaction: a scrollbar drag
// (no wheel/touch/key event) or a live message arriving long after the
// visit has settled must never be able to re-trigger it. 4s comfortably
// covers the slowest observed multi-wave initial load (WS backfill
// continuing after the REST fetch) without lingering into the range
// where the user has plausibly started reading and scrolling normally.
const previousVisibleForSettlingRef = useRef(isVisible);
const settlingDeadlineRef = useRef<number | null>(null);
const isWithinSettlingWindow = () =>
settlingDeadlineRef.current !== null && Date.now() < settlingDeadlineRef.current;
const didInitialScroll = useRef(false);
const didScrollToDivider = useRef(false);
useEffect(() => {
const becameVisible = isVisible && !previousVisibleForSettlingRef.current;
previousVisibleForSettlingRef.current = isVisible;
if (!isVisible) {
settlingDeadlineRef.current = null;
return;
}
if (settlingDeadlineRef.current === null || becameVisible) {
settlingDeadlineRef.current = Date.now() + DIVIDER_SETTLING_WINDOW_MS;
}
const el = scrollRef.current;
if (!el || historyRefreshPending) return;
if (consumeRecoveryRevealForDivider(recoveryRevealKey, didInitialScroll, activationPendingRef))
return;
if (itemCount === 0) return;
const placeInitialPosition = () => {
if (!isVisibleRef.current) return;
const dockviewState = useDockviewStore.getState();
const hasPendingLayoutRestore = dockviewState.pendingChatScrollTop !== null;
const hasExplicitScrollTarget =
sessionId !== null && dockviewState.scrollTarget?.sessionId === sessionId;
const hasProgrammaticOwner = isProgrammaticScrollLocked();
if (hasPendingLayoutRestore || hasExplicitScrollTarget || hasProgrammaticOwner) {
didInitialScroll.current = true;
activationPendingRef.current = false;
if (hasExplicitScrollTarget || hasProgrammaticOwner) {
isUserScrollingRef.current = true;
}
return;
}
const canReassertDivider = canReassertDividerScroll({
hasDividerTarget: Boolean(dividerBeforeItemKey),
didScrollToDivider: didScrollToDivider.current,
isUserScrolling: isUserScrollingRef.current,
isWithinSettlingWindow: isWithinSettlingWindow(),
});
if (canReassertDivider) {
const dividerEl = el.querySelector<HTMLElement>(`[id="msg-${dividerBeforeItemKey}"]`);
if (dividerEl) {
// scrollIntoView aligns against the viewport, which puts the target
// behind the fixed mobile session header instead of inside this
// nested scroll container. Move by the relative geometry instead;
// the desktop anchored prompt bar still reserves its measured height.
const containerRect = el.getBoundingClientRect();
const dividerRect = dividerEl.getBoundingClientRect();
el.scrollTop += dividerRect.top - containerRect.top - anchoredBarOffsetPx;
onDividerScroll?.();
didScrollToDivider.current = true;
didInitialScroll.current = true;
activationPendingRef.current = false;
return;
}
}
if (didInitialScroll.current) return;
// Disabled initial placement is owned by useInitialScrollPosition,
// which restores the persisted reader offset (or its bottom fallback).
// This divider hook only owns the enabled bottom fallback.
if (!enabled) {
didInitialScroll.current = true;
activationPendingRef.current = false;
return;
}
el.scrollTop = el.scrollHeight;
didInitialScroll.current = true;
activationPendingRef.current = false;
};
if (activationPendingRef.current) {
return scheduleAfterPanelRestore(placeInitialPosition);
}
placeInitialPosition();
}, [
itemCount,
dividerBeforeItemKey,
anchoredBarOffsetPx,
onDividerScroll,
scrollLayoutKey,
enabled,
sessionId,
isProgrammaticScrollLocked,
isVisible,
historyRefreshPending,
recoveryRevealKey,
scrollRef,
]);
}
function useDividerUserScrolling(scrollRef: React.RefObject<HTMLDivElement | null>) {
const isUserScrollingRef = useRef(false);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const markUserScrolling = () => {
isUserScrollingRef.current = true;
};
el.addEventListener("wheel", markUserScrolling, { passive: true });
el.addEventListener("touchstart", markUserScrolling, { passive: true });
el.addEventListener("keydown", markUserScrolling);
return () => {
el.removeEventListener("wheel", markUserScrolling);
el.removeEventListener("touchstart", markUserScrolling);
el.removeEventListener("keydown", markUserScrolling);
};
}, [scrollRef]);
return isUserScrollingRef;
}
/** Sentinel, status/footer, and transcript rows — everything below the
* (optional) anchored prompt bar inside the scroll container. */
function NativeMessageListBody({
items,
messages,
footerActionMessages,
permissionsByToolCallId,
childrenByParentToolCallId,
taskId,
sessionId,
isWorking,
messagesLoading,
sessionState,
worktreePath,
onOpenFile,
hasMore,
isLoadingMore,
isInitialLoading,
showLoadingState,
historyStatus,
historyError,
onRetryHistory,
retryLoadMore,
showRecovery,
sentinelRef,
lastTurnGroupId,
activeTurnId,
streamingMessageId,
onScrollToMessage,
autoScrollEnabled,
dividerBeforeItemKey,
prependContent,
launchErrorOwned,
launchErrorStamp,
launchErrorOccurredAt,
}: NativeMessageListBodyProps) {
return (
<div className="p-4">
{prependContent ? <div className="mb-4">{prependContent}</div> : null}
{/* Sentinel for lazy loading older messages */}
{hasMore && <div ref={sentinelRef} className="h-px" />}
<MessageListStatus
isLoadingMore={isLoadingMore}
hasMore={hasMore}
showLoadingState={showLoadingState}
messagesLoading={messagesLoading}
isInitialLoading={isInitialLoading}
messagesCount={messages.length}
sessionId={sessionId}
sessionState={sessionState}
historyStatus={historyStatus}
historyError={historyError}
onRetryHistory={onRetryHistory}
onLoadMore={retryLoadMore}
showRecovery={showRecovery}
/>
{items.map((item) => (
<MessageRow
key={getItemKey(item)}
item={item}
sessionId={sessionId}
permissionsByToolCallId={permissionsByToolCallId}
childrenByParentToolCallId={childrenByParentToolCallId}
taskId={taskId}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
isLastGroup={item.type === "turn_group" && item.id === lastTurnGroupId}
activeTurnId={activeTurnId}
streamingMessageId={streamingMessageId}
onScrollToMessage={onScrollToMessage}
dividerBeforeItemKey={dividerBeforeItemKey}
/>
))}
<MessageListFooter
sessionState={sessionState}
sessionId={sessionId}
messages={messages}
isWorking={isWorking}
footerActionMessages={footerActionMessages}
launchErrorOwned={launchErrorOwned}
launchErrorStamp={launchErrorStamp}
launchErrorOccurredAt={launchErrorOccurredAt}
/>
{/* Bottom anchor keeps the view pinned while auto-scroll is enabled.
The scroll container disables anchoring entirely while it is off,
so status/footer updates cannot choose a different anchor and move
the frozen transcript. */}
<div style={{ overflowAnchor: autoScrollEnabled ? "auto" : "none", height: 1 }} />
</div>
);
}
/**
* Renders the transcript as plain DOM nodes with `overflow-anchor` for
* scroll pinning. Wires together lazy-loading of older messages, the
* scroll-position-on-prepend fix-up, last-prompt/first-message edge
* tracking, and the session's auto-scroll toggle (freeze/resume/catch-up)
* via {@link useNativeScrollManagement}.
*/
export const NativeMessageList = memo(
// eslint-disable-next-line max-lines-per-function -- native transcript composition owns scrolling.
forwardRef<MessageListHandle, MessageListProps>(function NativeMessageList(
{
items,
messages,
footerActionMessages,
permissionsByToolCallId,
childrenByParentToolCallId,
taskId,
sessionId,
messagesLoading,
historyRefreshPending = false,
historyStatus = "ready",
historyError = null,
onRetryHistory,
isWorking,
sessionState,
worktreePath,
onOpenFile,
lastPromptMessageId,
onLastPromptEdgeChange,
firstMessageId,
onFirstMessageHiddenChange,
stickyPromptBar,
dividerBeforeItemKey,
anchoredBarHeight,
isVisible = true,
launchErrorOwned = false,
launchErrorStamp,
launchErrorOccurredAt,
prependContent,
recoveryRevealKey,
}: MessageListProps,
ref,
) {
const scrollRef = useRef<HTMLDivElement>(null);
const visibleItems = useMemo(
() =>
filterLaunchErrorItems(items, launchErrorOwned, launchErrorStamp, launchErrorOccurredAt),
[items, launchErrorOwned, launchErrorStamp, launchErrorOccurredAt],
);
const visibleMessages = useMemo(
() =>
filterLaunchErrorMessages(
messages,
launchErrorOwned,
launchErrorStamp,
launchErrorOccurredAt,
),
[messages, launchErrorOwned, launchErrorStamp, launchErrorOccurredAt],
);
const visibleFooterActionMessages = useMemo(
() =>
filterLaunchErrorMessages(
footerActionMessages ?? [],
launchErrorOwned,
launchErrorStamp,
launchErrorOccurredAt,
),
[footerActionMessages, launchErrorOwned, launchErrorStamp, launchErrorOccurredAt],
);
const { isInitialLoading, showLoadingState } = getConversationLoadingState({
messagesLoading,
messagesCount: visibleMessages.length,
isWorking,
sessionState,
});
const { loadMore, hasMore, isLoadingMore } = useLazyLoadMessages(sessionId, {
minTextPartsPerLoad: OLDER_PAGE_LIMIT,
});
const { activeTurnId } = useSessionTurn(sessionId);
const effectiveActiveTurnId = getEffectiveActiveTurnId(activeTurnId, isWorking);
const streamingMessageId = getStreamingAgentMessageId(visibleMessages);
const lastTurnGroupId = useMemo(() => getLastTurnGroupId(visibleItems), [visibleItems]);
const autoScrollEnabled = useTranscriptAutoScrollEnabled(sessionId);
const { handleScrollToMessage, sentinelRef, retryLoadMore, showRecovery } =
useNativeMessageListScroll({
scrollRef,
ref,
items: visibleItems,
messages: visibleMessages,
isWorking,
sessionId,
enabled: autoScrollEnabled,
dividerBeforeItemKey,
anchoredBarHeight,
messagesLoading,
historyRefreshPending,
hasMore,
isLoadingMore,
loadMore,
lastPromptMessageId,
onLastPromptEdgeChange,
firstMessageId,
onFirstMessageHiddenChange,
scrollLayoutKey: [
messagesLoading,
isInitialLoading,
showLoadingState,
isLoadingMore,
hasMore,
isWorking,
].join(":"),
isVisible,
recoveryRevealKey,
});
return (
<SessionPanelContent
ref={scrollRef}
className={`relative chat-message-list p-0 ${
autoScrollEnabled ? "[overflow-anchor:auto]" : "[overflow-anchor:none]"
}`}
>
{stickyPromptBar}
<NativeMessageListBody
items={visibleItems}
messages={visibleMessages}
footerActionMessages={visibleFooterActionMessages}
permissionsByToolCallId={permissionsByToolCallId}
childrenByParentToolCallId={childrenByParentToolCallId}
taskId={taskId}
sessionId={sessionId}
isWorking={isWorking}
messagesLoading={messagesLoading}
sessionState={sessionState}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
hasMore={hasMore}
isLoadingMore={isLoadingMore}
isInitialLoading={isInitialLoading}
showLoadingState={showLoadingState}
historyStatus={historyStatus}
historyError={historyError}
onRetryHistory={onRetryHistory}
retryLoadMore={retryLoadMore}
showRecovery={showRecovery}
sentinelRef={sentinelRef}
lastTurnGroupId={lastTurnGroupId}
activeTurnId={effectiveActiveTurnId}
streamingMessageId={streamingMessageId}
onScrollToMessage={handleScrollToMessage}
autoScrollEnabled={autoScrollEnabled}
dividerBeforeItemKey={dividerBeforeItemKey}
prependContent={prependContent}
launchErrorOwned={launchErrorOwned}
launchErrorStamp={launchErrorStamp}
launchErrorOccurredAt={launchErrorOccurredAt}
/>
</SessionPanelContent>
);
}),
);