442 lines
16 KiB
TypeScript
442 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, memo, forwardRef, useImperativeHandle } 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 { 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 { useTranscriptAutoScrollEnabled } from "./use-transcript-auto-scroll-enabled";
|
|
import { useDockviewStore } from "@/lib/state/dockview-store";
|
|
import {
|
|
type MessageListProps,
|
|
type MessageListHandle,
|
|
type LastPromptEdge,
|
|
MessageListStatus,
|
|
MessageItem,
|
|
UnreadDivider,
|
|
getItemKey,
|
|
getConversationLoadingState,
|
|
getEffectiveActiveTurnId,
|
|
getLastTurnGroupId,
|
|
getStreamingAgentMessageId,
|
|
canReassertDividerScroll,
|
|
resolveLastPromptEdge,
|
|
isElementFullyVisible,
|
|
} from "./message-list-shared";
|
|
|
|
/** 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 MessageRowProps = {
|
|
item: RenderItem;
|
|
sessionId: string | null;
|
|
permissionsByToolCallId: Map<string, Message>;
|
|
childrenByParentToolCallId: Map<string, Message[]>;
|
|
taskId?: string;
|
|
worktreePath?: string;
|
|
onOpenFile?: (path: string) => void;
|
|
isLastGroup: boolean;
|
|
activeTurnId: string | null;
|
|
streamingMessageId: string | null;
|
|
onScrollToMessage: (messageId: string, options?: { align?: "start" | "center" }) => void;
|
|
dividerBeforeItemKey?: string | null;
|
|
};
|
|
|
|
/** 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}`}
|
|
className="pb-2 scroll-mt-[calc(4rem+env(safe-area-inset-top))] sm:scroll-mt-0"
|
|
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) => void;
|
|
hasMore: boolean;
|
|
isLoadingMore: boolean;
|
|
isInitialLoading: boolean;
|
|
showLoadingState: boolean;
|
|
loadMore: () => Promise<number>;
|
|
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;
|
|
};
|
|
|
|
/**
|
|
* 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 mount (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. Embedded, always-invisible previews (isVisible hardcoded false,
|
|
* see TaskChatPanel) never resolve a divider, so they keep the
|
|
* original, unconditional scroll-to-bottom-on-mount behavior untouched.
|
|
*/
|
|
function useScrollToDividerOrBottom(
|
|
scrollRef: React.RefObject<HTMLDivElement | null>,
|
|
itemCount: number,
|
|
dividerBeforeItemKey: string | null | undefined,
|
|
) {
|
|
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]);
|
|
|
|
// Bounds how long the divider correction below can keep re-asserting
|
|
// itself after mount, 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 mountedAtRef = useRef<number | null>(null);
|
|
if (mountedAtRef.current === null) mountedAtRef.current = Date.now();
|
|
const isWithinSettlingWindow = () => Date.now() - (mountedAtRef.current ?? 0) < 4000;
|
|
|
|
const didInitialScroll = useRef(false);
|
|
const didScrollToDivider = useRef(false);
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (!el || itemCount === 0) return;
|
|
const canReassertDivider = canReassertDividerScroll({
|
|
hasDividerTarget: Boolean(dividerBeforeItemKey),
|
|
didScrollToDivider: didScrollToDivider.current,
|
|
isUserScrolling: isUserScrollingRef.current,
|
|
isWithinSettlingWindow: isWithinSettlingWindow(),
|
|
});
|
|
if (canReassertDivider) {
|
|
if (useDockviewStore.getState().pendingChatScrollTop === null) {
|
|
const dividerEl = el.querySelector<HTMLElement>(`[id="msg-${dividerBeforeItemKey}"]`);
|
|
if (dividerEl) {
|
|
dividerEl.scrollIntoView({ block: "start" });
|
|
didScrollToDivider.current = true;
|
|
didInitialScroll.current = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
if (didInitialScroll.current) return;
|
|
// If a layout rebuild scroll restore is pending, skip initial scroll
|
|
// (the restore handler will set the correct position)
|
|
if (useDockviewStore.getState().pendingChatScrollTop !== null) {
|
|
didInitialScroll.current = true;
|
|
return;
|
|
}
|
|
el.scrollTop = el.scrollHeight;
|
|
didInitialScroll.current = true;
|
|
}, [itemCount, dividerBeforeItemKey]);
|
|
}
|
|
|
|
/** 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,
|
|
loadMore,
|
|
sentinelRef,
|
|
lastTurnGroupId,
|
|
activeTurnId,
|
|
streamingMessageId,
|
|
onScrollToMessage,
|
|
autoScrollEnabled,
|
|
dividerBeforeItemKey,
|
|
}: NativeMessageListBodyProps) {
|
|
return (
|
|
<div className="p-4">
|
|
{/* 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}
|
|
onLoadMore={loadMore}
|
|
/>
|
|
|
|
{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}
|
|
/>
|
|
|
|
{/* 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(
|
|
forwardRef<MessageListHandle, MessageListProps>(function NativeMessageList(
|
|
{
|
|
items,
|
|
messages,
|
|
footerActionMessages,
|
|
permissionsByToolCallId,
|
|
childrenByParentToolCallId,
|
|
taskId,
|
|
sessionId,
|
|
messagesLoading,
|
|
isWorking,
|
|
sessionState,
|
|
worktreePath,
|
|
onOpenFile,
|
|
lastPromptMessageId,
|
|
onLastPromptEdgeChange,
|
|
firstMessageId,
|
|
onFirstMessageHiddenChange,
|
|
stickyPromptBar,
|
|
dividerBeforeItemKey,
|
|
}: MessageListProps,
|
|
ref,
|
|
) {
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
const { isInitialLoading, showLoadingState } = getConversationLoadingState({
|
|
messagesLoading,
|
|
messagesCount: messages.length,
|
|
isWorking,
|
|
sessionState,
|
|
});
|
|
const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(sessionId);
|
|
const { activeTurnId } = useSessionTurn(sessionId);
|
|
const effectiveActiveTurnId = getEffectiveActiveTurnId(activeTurnId, isWorking);
|
|
const streamingMessageId = getStreamingAgentMessageId(messages);
|
|
const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]);
|
|
const autoScrollEnabled = useTranscriptAutoScrollEnabled(sessionId);
|
|
const { handleScrollToMessage, sentinelRef } = useNativeScrollManagement({
|
|
scrollRef,
|
|
items,
|
|
messages,
|
|
isWorking,
|
|
sessionId,
|
|
enabled: autoScrollEnabled,
|
|
hasMore,
|
|
isLoadingMore,
|
|
loadMore,
|
|
});
|
|
useScrollToDividerOrBottom(scrollRef, items.length, dividerBeforeItemKey);
|
|
useImperativeHandle(ref, () => ({ scrollToMessage: handleScrollToMessage }), [
|
|
handleScrollToMessage,
|
|
]);
|
|
useTranscriptEdgeTracking(
|
|
scrollRef,
|
|
lastPromptMessageId,
|
|
onLastPromptEdgeChange,
|
|
firstMessageId,
|
|
onFirstMessageHiddenChange,
|
|
);
|
|
|
|
return (
|
|
<SessionPanelContent
|
|
ref={scrollRef}
|
|
className={`relative chat-message-list p-0 ${
|
|
autoScrollEnabled ? "[overflow-anchor:auto]" : "[overflow-anchor:none]"
|
|
}`}
|
|
>
|
|
{stickyPromptBar}
|
|
<NativeMessageListBody
|
|
items={items}
|
|
messages={messages}
|
|
footerActionMessages={footerActionMessages}
|
|
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}
|
|
loadMore={loadMore}
|
|
sentinelRef={sentinelRef}
|
|
lastTurnGroupId={lastTurnGroupId}
|
|
activeTurnId={effectiveActiveTurnId}
|
|
streamingMessageId={streamingMessageId}
|
|
onScrollToMessage={handleScrollToMessage}
|
|
autoScrollEnabled={autoScrollEnabled}
|
|
dividerBeforeItemKey={dividerBeforeItemKey}
|
|
/>
|
|
</SessionPanelContent>
|
|
);
|
|
}),
|
|
);
|