Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
788 lines
26 KiB
TypeScript
788 lines
26 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState
|
|
} from "react";
|
|
import {
|
|
createAgentConversationFollowEndController,
|
|
type AgentConversationFollowEndEvent
|
|
} from "../../../shared/agentConversation/agentConversationFollowEndController";
|
|
import type { AgentTranscriptVirtualScrollController } from "../../../shared/agentConversation/components/AgentTranscriptView";
|
|
import {
|
|
AgentGUIConversationScrollMemory,
|
|
type TimelineScrollAnchor
|
|
} from "./agentGUIScrollMemory";
|
|
import type { AgentGUIDetailScrollInput } from "./agentGUIDetailScrollTypes";
|
|
import {
|
|
hasStaleVirtualScrollController,
|
|
matchingVirtualScrollController,
|
|
readBottomDockSafeArea,
|
|
readTimelineGeometry,
|
|
userScrollBehavior,
|
|
writeBottomDockSafeArea,
|
|
type BottomDockSafeArea
|
|
} from "./agentGUIDetailScrollHelpers";
|
|
import {
|
|
setTimelineScrollTopInstantly,
|
|
setTimelineScrollTopWithUserTransition
|
|
} from "./AgentGUIConversationTimelinePane";
|
|
|
|
const AGENT_GUI_TOP_HISTORY_PREFETCH_THRESHOLD_PX = 240;
|
|
const AGENT_GUI_TOP_MASK_SCROLL_EPSILON_PX = 1;
|
|
const AGENT_GUI_REACHED_END_EPSILON_PX = 1;
|
|
|
|
export function useAgentGUIDetailScroll(input: AgentGUIDetailScrollInput) {
|
|
const {
|
|
actions,
|
|
bottomDockRef,
|
|
bottomDockStoreRevision,
|
|
conversation,
|
|
isVisible,
|
|
pendingPrependScrollAnchorRef,
|
|
showTimelineSkeleton,
|
|
submittedPromptScrollConversationRef,
|
|
timelineConversationId,
|
|
timelineContentRef,
|
|
timelineRef,
|
|
timelineScrollAnchorRef,
|
|
virtualScrollControllerRef,
|
|
viewModel
|
|
} = input;
|
|
const [isTimelineScrolledToTop, setIsTimelineScrolledToTop] = useState(true);
|
|
const followEndControllerRef = useRef(
|
|
createAgentConversationFollowEndController()
|
|
);
|
|
const followEndController = followEndControllerRef.current;
|
|
const conversationScrollMemoryRef = useRef(
|
|
new AgentGUIConversationScrollMemory()
|
|
);
|
|
const [followEndMode, setFollowEndMode] = useState(
|
|
followEndController.getSnapshot
|
|
);
|
|
const dispatchFollowEnd = useCallback(
|
|
(event: AgentConversationFollowEndEvent): void => {
|
|
const nextMode = followEndController.dispatch(event);
|
|
const anchor = timelineScrollAnchorRef.current;
|
|
if (anchor) {
|
|
conversationScrollMemoryRef.current.write(anchor, nextMode);
|
|
}
|
|
setFollowEndMode(nextMode);
|
|
},
|
|
[followEndController, timelineScrollAnchorRef]
|
|
);
|
|
const writeTimelineScrollAnchor = useCallback(
|
|
(anchor: TimelineScrollAnchor): void => {
|
|
timelineScrollAnchorRef.current = anchor;
|
|
conversationScrollMemoryRef.current.write(
|
|
anchor,
|
|
followEndController.getSnapshot()
|
|
);
|
|
},
|
|
[followEndController, timelineScrollAnchorRef]
|
|
);
|
|
const pointerScrollConversationRef = useRef<string | null>(null);
|
|
const userScrollDirectionRef = useRef<"away" | "toward-end" | null>(null);
|
|
const lastShowTimelineSkeletonRef = useRef(showTimelineSkeleton);
|
|
const bottomDockSafeAreaRef = useRef<BottomDockSafeArea | null>(null);
|
|
const [virtualScrollControllerRevision, setVirtualScrollControllerRevision] =
|
|
useState(0);
|
|
const setVirtualScrollController = useCallback(
|
|
(controller: AgentTranscriptVirtualScrollController | null) => {
|
|
if (virtualScrollControllerRef.current === controller) {
|
|
return;
|
|
}
|
|
virtualScrollControllerRef.current = controller;
|
|
if (controller) {
|
|
setVirtualScrollControllerRevision((revision) => revision + 1);
|
|
}
|
|
},
|
|
[virtualScrollControllerRef]
|
|
);
|
|
const lastVirtualScrollControllerRevisionRef = useRef(
|
|
virtualScrollControllerRevision
|
|
);
|
|
useLayoutEffect(() => {
|
|
if (!isVisible) {
|
|
return;
|
|
}
|
|
const timelineSkeletonChanged =
|
|
lastShowTimelineSkeletonRef.current !== showTimelineSkeleton;
|
|
lastShowTimelineSkeletonRef.current = showTimelineSkeleton;
|
|
const timeline = timelineRef.current;
|
|
if (!timeline) {
|
|
return;
|
|
}
|
|
const activeConversationId = timelineConversationId;
|
|
if (!activeConversationId) {
|
|
timelineScrollAnchorRef.current = null;
|
|
pendingPrependScrollAnchorRef.current = null;
|
|
pointerScrollConversationRef.current = null;
|
|
userScrollDirectionRef.current = null;
|
|
submittedPromptScrollConversationRef.current = null;
|
|
setIsTimelineScrolledToTop(true);
|
|
return;
|
|
}
|
|
if (activeConversationId !== viewModel.rail.activeConversationId) {
|
|
return;
|
|
}
|
|
let anchor = timelineScrollAnchorRef.current;
|
|
const conversationChanged =
|
|
!anchor || anchor.conversationId !== activeConversationId;
|
|
if (conversationChanged) {
|
|
if (anchor) {
|
|
conversationScrollMemoryRef.current.write(
|
|
anchor,
|
|
followEndController.getSnapshot()
|
|
);
|
|
}
|
|
const rememberedScroll =
|
|
conversationScrollMemoryRef.current.read(activeConversationId);
|
|
const restoredFollowEndMode =
|
|
rememberedScroll?.followEndMode ?? "following";
|
|
setFollowEndMode(
|
|
followEndController.dispatch(
|
|
restoredFollowEndMode === "detached"
|
|
? "user-scrolled-away"
|
|
: "conversation-changed"
|
|
)
|
|
);
|
|
pointerScrollConversationRef.current = null;
|
|
userScrollDirectionRef.current = null;
|
|
if (showTimelineSkeleton) {
|
|
timelineScrollAnchorRef.current = null;
|
|
setIsTimelineScrolledToTop(true);
|
|
return;
|
|
}
|
|
anchor = rememberedScroll?.anchor ?? {
|
|
clientHeight: 0,
|
|
conversationId: activeConversationId,
|
|
scrollHeight: Number.POSITIVE_INFINITY,
|
|
scrollTop: 0
|
|
};
|
|
timelineScrollAnchorRef.current = anchor;
|
|
conversationScrollMemoryRef.current.write(
|
|
anchor,
|
|
rememberedScroll?.followEndMode ?? "following"
|
|
);
|
|
}
|
|
if (!anchor) {
|
|
return;
|
|
}
|
|
if (
|
|
hasStaleVirtualScrollController(
|
|
virtualScrollControllerRef,
|
|
activeConversationId
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const prependAnchor = pendingPrependScrollAnchorRef.current;
|
|
const shouldScrollSubmittedPromptToBottom =
|
|
submittedPromptScrollConversationRef.current === activeConversationId;
|
|
const shouldRestorePrependAnchor =
|
|
prependAnchor?.conversationId === activeConversationId;
|
|
const virtualScrollControllerChanged =
|
|
lastVirtualScrollControllerRevisionRef.current !==
|
|
virtualScrollControllerRevision;
|
|
lastVirtualScrollControllerRevisionRef.current =
|
|
virtualScrollControllerRevision;
|
|
if (
|
|
!conversationChanged &&
|
|
!shouldScrollSubmittedPromptToBottom &&
|
|
!shouldRestorePrependAnchor &&
|
|
!timelineSkeletonChanged &&
|
|
!virtualScrollControllerChanged
|
|
) {
|
|
return;
|
|
}
|
|
const virtualScrollController = matchingVirtualScrollController(
|
|
virtualScrollControllerRef,
|
|
activeConversationId
|
|
);
|
|
if (virtualScrollController) {
|
|
const followsEnd = followEndController.getSnapshot() === "following";
|
|
if (
|
|
shouldScrollSubmittedPromptToBottom ||
|
|
(followsEnd && (conversationChanged || virtualScrollControllerChanged))
|
|
) {
|
|
if (shouldScrollSubmittedPromptToBottom) {
|
|
dispatchFollowEnd("prompt-submitted");
|
|
}
|
|
pointerScrollConversationRef.current = null;
|
|
userScrollDirectionRef.current = null;
|
|
virtualScrollController.scrollToEnd({ behavior: "auto" });
|
|
submittedPromptScrollConversationRef.current = null;
|
|
if (shouldScrollSubmittedPromptToBottom) {
|
|
pendingPrependScrollAnchorRef.current = null;
|
|
}
|
|
} else if (
|
|
!followsEnd &&
|
|
(conversationChanged ||
|
|
virtualScrollControllerChanged ||
|
|
(timelineSkeletonChanged && !showTimelineSkeleton))
|
|
) {
|
|
virtualScrollController.scrollToOffset(anchor.scrollTop, {
|
|
behavior: "auto"
|
|
});
|
|
}
|
|
if (
|
|
shouldRestorePrependAnchor &&
|
|
!viewModel.detail.isLoadingOlderMessages
|
|
) {
|
|
pendingPrependScrollAnchorRef.current = null;
|
|
}
|
|
const virtualAnchor = anchor;
|
|
writeTimelineScrollAnchor({
|
|
...virtualAnchor,
|
|
conversationId: activeConversationId
|
|
});
|
|
setIsTimelineScrolledToTop(
|
|
virtualAnchor.scrollTop <= AGENT_GUI_TOP_MASK_SCROLL_EPSILON_PX
|
|
);
|
|
return;
|
|
}
|
|
const geometry = readTimelineGeometry(timeline);
|
|
const maxScrollTop = geometry.maxScrollTop;
|
|
let nextScrollTop: number;
|
|
if (conversationChanged || shouldScrollSubmittedPromptToBottom) {
|
|
if (shouldScrollSubmittedPromptToBottom) {
|
|
dispatchFollowEnd("prompt-submitted");
|
|
}
|
|
pointerScrollConversationRef.current = null;
|
|
userScrollDirectionRef.current = null;
|
|
}
|
|
const shouldKeepBottomLocked =
|
|
followEndController.getSnapshot() === "following";
|
|
|
|
if (shouldScrollSubmittedPromptToBottom || shouldKeepBottomLocked) {
|
|
setTimelineScrollTopInstantly(timeline, maxScrollTop);
|
|
nextScrollTop = maxScrollTop;
|
|
submittedPromptScrollConversationRef.current = null;
|
|
if (shouldScrollSubmittedPromptToBottom) {
|
|
pendingPrependScrollAnchorRef.current = null;
|
|
}
|
|
} else if (shouldRestorePrependAnchor && prependAnchor) {
|
|
const nextScrollHeight = geometry.scrollHeight;
|
|
const delta = nextScrollHeight - prependAnchor.scrollHeight;
|
|
nextScrollTop = Math.max(0, prependAnchor.scrollTop + delta);
|
|
timeline.scrollTop = nextScrollTop;
|
|
if (viewModel.detail.isLoadingOlderMessages) {
|
|
pendingPrependScrollAnchorRef.current = {
|
|
conversationId: activeConversationId,
|
|
scrollHeight: nextScrollHeight,
|
|
scrollTop: nextScrollTop
|
|
};
|
|
} else {
|
|
pendingPrependScrollAnchorRef.current = null;
|
|
}
|
|
} else {
|
|
nextScrollTop = Math.min(maxScrollTop, anchor.scrollTop);
|
|
timeline.scrollTop = nextScrollTop;
|
|
}
|
|
|
|
writeTimelineScrollAnchor({
|
|
conversationId: activeConversationId,
|
|
scrollHeight: geometry.scrollHeight,
|
|
scrollTop: nextScrollTop,
|
|
clientHeight: geometry.clientHeight
|
|
});
|
|
setIsTimelineScrolledToTop(
|
|
nextScrollTop <= AGENT_GUI_TOP_MASK_SCROLL_EPSILON_PX
|
|
);
|
|
}, [
|
|
conversation,
|
|
dispatchFollowEnd,
|
|
followEndController,
|
|
isVisible,
|
|
showTimelineSkeleton,
|
|
timelineConversationId,
|
|
virtualScrollControllerRevision,
|
|
viewModel.rail.activeConversationId,
|
|
viewModel.detail.isLoadingOlderMessages,
|
|
writeTimelineScrollAnchor
|
|
]);
|
|
|
|
const hasTimelineConversation = timelineConversationId !== null;
|
|
useLayoutEffect(() => {
|
|
if (!isVisible) {
|
|
return;
|
|
}
|
|
const timeline = timelineRef.current;
|
|
const bottomDock = bottomDockRef.current;
|
|
if (!hasTimelineConversation || !timeline || !bottomDock) {
|
|
return;
|
|
}
|
|
|
|
let animationFrameId: number | null = null;
|
|
const resolveBottomLockConversation = (): string | null => {
|
|
const activeConversationId =
|
|
timelineScrollAnchorRef.current?.conversationId ?? null;
|
|
if (
|
|
!activeConversationId ||
|
|
followEndController.getSnapshot() !== "following"
|
|
) {
|
|
return null;
|
|
}
|
|
const anchor = timelineScrollAnchorRef.current;
|
|
if (!anchor || anchor.conversationId !== activeConversationId) {
|
|
return null;
|
|
}
|
|
return activeConversationId;
|
|
};
|
|
|
|
const syncBottomDockSafeArea = (forceMeasurement: boolean): void => {
|
|
const cachedSafeArea = bottomDockSafeAreaRef.current;
|
|
if (
|
|
!forceMeasurement &&
|
|
cachedSafeArea?.bottomDock === bottomDock &&
|
|
cachedSafeArea.revision === bottomDockStoreRevision
|
|
) {
|
|
writeBottomDockSafeArea(timeline, cachedSafeArea);
|
|
return;
|
|
}
|
|
const measuredSafeArea = readBottomDockSafeArea(bottomDock);
|
|
if (
|
|
forceMeasurement &&
|
|
cachedSafeArea?.bottomDock === bottomDock &&
|
|
cachedSafeArea.timelineOverflowHeight ===
|
|
measuredSafeArea.timelineOverflowHeight &&
|
|
cachedSafeArea.floatingOverflowHeight ===
|
|
measuredSafeArea.floatingOverflowHeight
|
|
) {
|
|
bottomDockSafeAreaRef.current = {
|
|
bottomDock,
|
|
revision: bottomDockStoreRevision,
|
|
...measuredSafeArea
|
|
};
|
|
return;
|
|
}
|
|
const nextSafeArea: BottomDockSafeArea = {
|
|
bottomDock,
|
|
revision: bottomDockStoreRevision,
|
|
...measuredSafeArea
|
|
};
|
|
bottomDockSafeAreaRef.current = nextSafeArea;
|
|
writeBottomDockSafeArea(timeline, nextSafeArea);
|
|
};
|
|
|
|
const syncConversationBottomLock = (): void => {
|
|
const scheduledConversationId = resolveBottomLockConversation();
|
|
if (!scheduledConversationId) {
|
|
return;
|
|
}
|
|
|
|
if (animationFrameId !== null) {
|
|
window.cancelAnimationFrame(animationFrameId);
|
|
}
|
|
animationFrameId = window.requestAnimationFrame(() => {
|
|
animationFrameId = null;
|
|
if (
|
|
resolveBottomLockConversation() !== scheduledConversationId ||
|
|
timelineRef.current !== timeline
|
|
) {
|
|
return;
|
|
}
|
|
if (
|
|
hasStaleVirtualScrollController(
|
|
virtualScrollControllerRef,
|
|
scheduledConversationId
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
const virtualScrollController = matchingVirtualScrollController(
|
|
virtualScrollControllerRef,
|
|
scheduledConversationId
|
|
);
|
|
if (virtualScrollController) {
|
|
virtualScrollController.scrollToEnd({ behavior: "auto" });
|
|
return;
|
|
}
|
|
const geometry = readTimelineGeometry(timeline);
|
|
const maxScrollTop = geometry.maxScrollTop;
|
|
timeline.scrollTop = maxScrollTop;
|
|
writeTimelineScrollAnchor({
|
|
conversationId: scheduledConversationId,
|
|
scrollHeight: geometry.scrollHeight,
|
|
scrollTop: maxScrollTop,
|
|
clientHeight: geometry.clientHeight
|
|
});
|
|
setIsTimelineScrolledToTop(
|
|
maxScrollTop <= AGENT_GUI_TOP_MASK_SCROLL_EPSILON_PX
|
|
);
|
|
});
|
|
};
|
|
|
|
syncBottomDockSafeArea(false);
|
|
syncConversationBottomLock();
|
|
if (typeof ResizeObserver === "undefined") {
|
|
return () => {
|
|
timeline.style.removeProperty("--agent-gui-bottom-dock-safe-area");
|
|
bottomDock.style.removeProperty(
|
|
"--agent-gui-bottom-dock-floating-safe-area"
|
|
);
|
|
if (animationFrameId !== null) {
|
|
window.cancelAnimationFrame(animationFrameId);
|
|
}
|
|
};
|
|
}
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
syncBottomDockSafeArea(true);
|
|
syncConversationBottomLock();
|
|
});
|
|
observer.observe(bottomDock);
|
|
const promptInputArea = bottomDock.querySelector(
|
|
".agent-gui-node__composer-prompt-input-area"
|
|
);
|
|
if (promptInputArea instanceof Element) {
|
|
observer.observe(promptInputArea);
|
|
}
|
|
return () => {
|
|
timeline.style.removeProperty("--agent-gui-bottom-dock-safe-area");
|
|
bottomDock.style.removeProperty(
|
|
"--agent-gui-bottom-dock-floating-safe-area"
|
|
);
|
|
if (animationFrameId !== null) {
|
|
window.cancelAnimationFrame(animationFrameId);
|
|
}
|
|
observer.disconnect();
|
|
};
|
|
}, [
|
|
bottomDockStoreRevision,
|
|
followEndController,
|
|
hasTimelineConversation,
|
|
isVisible,
|
|
writeTimelineScrollAnchor
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!isVisible) {
|
|
return;
|
|
}
|
|
const timeline = timelineRef.current;
|
|
const timelineContent = timelineContentRef.current;
|
|
const activeConversationId = timelineConversationId;
|
|
if (!timeline || !activeConversationId) {
|
|
return;
|
|
}
|
|
|
|
const loadOlderMessagesNearTop = (
|
|
scrollTop: number,
|
|
scrollHeight: number,
|
|
clientHeight: number
|
|
): void => {
|
|
const bottomLocked = followEndController.getSnapshot() === "following";
|
|
const virtualScrollController = matchingVirtualScrollController(
|
|
virtualScrollControllerRef,
|
|
activeConversationId
|
|
);
|
|
const transcriptGeometryIsReady =
|
|
timelineContent?.querySelector("[data-agent-transcript-row]") !== null;
|
|
const needsMoreContentToFillViewport =
|
|
!virtualScrollController &&
|
|
transcriptGeometryIsReady &&
|
|
scrollHeight <= clientHeight;
|
|
if (
|
|
activeConversationId === viewModel.rail.activeConversationId &&
|
|
viewModel.detail.hasOlderMessages &&
|
|
!viewModel.detail.isLoadingOlderMessages &&
|
|
!showTimelineSkeleton &&
|
|
(!bottomLocked || needsMoreContentToFillViewport) &&
|
|
scrollTop <= AGENT_GUI_TOP_HISTORY_PREFETCH_THRESHOLD_PX
|
|
) {
|
|
pendingPrependScrollAnchorRef.current = {
|
|
conversationId: activeConversationId,
|
|
scrollHeight,
|
|
scrollTop
|
|
};
|
|
actions.loadOlderConversationMessages();
|
|
}
|
|
};
|
|
|
|
const captureScrollAnchor = (): void => {
|
|
const previousAnchor = timelineScrollAnchorRef.current;
|
|
if (
|
|
!previousAnchor ||
|
|
previousAnchor.conversationId !== activeConversationId
|
|
) {
|
|
return;
|
|
}
|
|
let scrollTop = timeline.scrollTop;
|
|
const pointerDrivenScrollAway =
|
|
pointerScrollConversationRef.current === activeConversationId &&
|
|
scrollTop < previousAnchor.scrollTop - AGENT_GUI_REACHED_END_EPSILON_PX;
|
|
const pointerDrivenScrollTowardEnd =
|
|
pointerScrollConversationRef.current === activeConversationId &&
|
|
scrollTop > previousAnchor.scrollTop + AGENT_GUI_REACHED_END_EPSILON_PX;
|
|
if (pointerDrivenScrollAway) {
|
|
userScrollDirectionRef.current = "away";
|
|
dispatchFollowEnd("user-scrolled-away");
|
|
} else if (pointerDrivenScrollTowardEnd) {
|
|
userScrollDirectionRef.current = "toward-end";
|
|
}
|
|
const virtualScrollController = matchingVirtualScrollController(
|
|
virtualScrollControllerRef,
|
|
activeConversationId
|
|
);
|
|
if (
|
|
!virtualScrollController &&
|
|
followEndController.getSnapshot() === "following"
|
|
) {
|
|
const anchoredMaxScrollTop = Math.max(
|
|
0,
|
|
previousAnchor.scrollHeight - previousAnchor.clientHeight
|
|
);
|
|
if (
|
|
anchoredMaxScrollTop - scrollTop >
|
|
AGENT_GUI_REACHED_END_EPSILON_PX
|
|
) {
|
|
setTimelineScrollTopInstantly(timeline, anchoredMaxScrollTop);
|
|
scrollTop = anchoredMaxScrollTop;
|
|
}
|
|
}
|
|
if (
|
|
followEndController.getSnapshot() === "detached" &&
|
|
userScrollDirectionRef.current === "toward-end" &&
|
|
(virtualScrollController
|
|
? virtualScrollController.isAtEnd(AGENT_GUI_REACHED_END_EPSILON_PX)
|
|
: previousAnchor.scrollHeight -
|
|
scrollTop -
|
|
previousAnchor.clientHeight <=
|
|
AGENT_GUI_REACHED_END_EPSILON_PX)
|
|
) {
|
|
dispatchFollowEnd("user-reached-end");
|
|
}
|
|
writeTimelineScrollAnchor({
|
|
conversationId: activeConversationId,
|
|
scrollHeight: previousAnchor.scrollHeight,
|
|
scrollTop,
|
|
clientHeight: previousAnchor.clientHeight
|
|
});
|
|
setIsTimelineScrolledToTop(
|
|
scrollTop <= AGENT_GUI_TOP_MASK_SCROLL_EPSILON_PX
|
|
);
|
|
loadOlderMessagesNearTop(
|
|
scrollTop,
|
|
previousAnchor.scrollHeight,
|
|
previousAnchor.clientHeight
|
|
);
|
|
};
|
|
|
|
const syncObservedTimelineGeometry = (
|
|
entries: readonly ResizeObserverEntry[]
|
|
): void => {
|
|
const anchor = timelineScrollAnchorRef.current;
|
|
if (!anchor || anchor.conversationId !== activeConversationId) {
|
|
return;
|
|
}
|
|
|
|
const virtualScrollController = matchingVirtualScrollController(
|
|
virtualScrollControllerRef,
|
|
activeConversationId
|
|
);
|
|
if (virtualScrollController) {
|
|
const scrollTop = timeline.scrollTop;
|
|
const observedClientHeight = entries.find(
|
|
(entry) => entry.target === timeline
|
|
)?.contentRect.height;
|
|
const observedScrollHeight = entries.find(
|
|
(entry) => entry.target === timelineContent
|
|
)?.contentRect.height;
|
|
const clientHeight = observedClientHeight ?? anchor.clientHeight;
|
|
const scrollHeight =
|
|
observedScrollHeight === undefined
|
|
? anchor.scrollHeight
|
|
: Math.max(clientHeight, observedScrollHeight);
|
|
writeTimelineScrollAnchor({
|
|
...anchor,
|
|
clientHeight,
|
|
scrollHeight,
|
|
scrollTop
|
|
});
|
|
setIsTimelineScrolledToTop(
|
|
scrollTop <= AGENT_GUI_TOP_MASK_SCROLL_EPSILON_PX
|
|
);
|
|
loadOlderMessagesNearTop(scrollTop, scrollHeight, clientHeight);
|
|
return;
|
|
}
|
|
const geometry = readTimelineGeometry(timeline);
|
|
const { clientHeight, maxScrollTop, scrollHeight } = geometry;
|
|
const bottomLocked = followEndController.getSnapshot() === "following";
|
|
let scrollTop = Math.min(maxScrollTop, timeline.scrollTop);
|
|
if (bottomLocked) {
|
|
setTimelineScrollTopInstantly(timeline, maxScrollTop);
|
|
scrollTop = maxScrollTop;
|
|
}
|
|
writeTimelineScrollAnchor({
|
|
conversationId: activeConversationId,
|
|
scrollHeight,
|
|
scrollTop,
|
|
clientHeight
|
|
});
|
|
setIsTimelineScrolledToTop(
|
|
scrollTop <= AGENT_GUI_TOP_MASK_SCROLL_EPSILON_PX
|
|
);
|
|
};
|
|
|
|
const captureWheelIntent = (event: WheelEvent): void => {
|
|
if (event.deltaY < 0) {
|
|
userScrollDirectionRef.current = "away";
|
|
dispatchFollowEnd("user-scrolled-away");
|
|
} else if (event.deltaY > 0) {
|
|
userScrollDirectionRef.current = "toward-end";
|
|
}
|
|
};
|
|
const captureKeyboardIntent = (event: KeyboardEvent): void => {
|
|
if (
|
|
event.key === "ArrowUp" ||
|
|
event.key === "Home" ||
|
|
event.key === "PageUp"
|
|
) {
|
|
userScrollDirectionRef.current = "away";
|
|
dispatchFollowEnd("user-scrolled-away");
|
|
} else if (
|
|
event.key === "ArrowDown" ||
|
|
event.key === "End" ||
|
|
event.key === "PageDown" ||
|
|
(event.key === " " && !event.shiftKey)
|
|
) {
|
|
userScrollDirectionRef.current = "toward-end";
|
|
}
|
|
};
|
|
const captureSemanticScrollAwayIntent = (event: MouseEvent): void => {
|
|
if (
|
|
event.target instanceof Element &&
|
|
event.target.closest("[data-agent-transcript-scroll-away-intent]")
|
|
) {
|
|
userScrollDirectionRef.current = "away";
|
|
dispatchFollowEnd("user-scrolled-away");
|
|
}
|
|
};
|
|
const capturePointerIntent = (): void => {
|
|
pointerScrollConversationRef.current = activeConversationId;
|
|
};
|
|
const clearPointerIntent = (): void => {
|
|
if (pointerScrollConversationRef.current === activeConversationId) {
|
|
pointerScrollConversationRef.current = null;
|
|
}
|
|
};
|
|
const clearScrollDirection = (): void => {
|
|
userScrollDirectionRef.current = null;
|
|
};
|
|
|
|
const initialAnchor = timelineScrollAnchorRef.current;
|
|
if (initialAnchor?.conversationId === activeConversationId) {
|
|
loadOlderMessagesNearTop(
|
|
initialAnchor.scrollTop,
|
|
initialAnchor.scrollHeight,
|
|
initialAnchor.clientHeight
|
|
);
|
|
}
|
|
timeline.addEventListener("scroll", captureScrollAnchor, { passive: true });
|
|
timeline.addEventListener("scrollend", clearScrollDirection);
|
|
timeline.addEventListener("wheel", captureWheelIntent, { passive: true });
|
|
timeline.addEventListener("keydown", captureKeyboardIntent);
|
|
timeline.addEventListener("click", captureSemanticScrollAwayIntent);
|
|
timeline.addEventListener("pointerdown", capturePointerIntent, {
|
|
passive: true
|
|
});
|
|
window.addEventListener("pointerup", clearPointerIntent, { passive: true });
|
|
window.addEventListener("pointercancel", clearPointerIntent, {
|
|
passive: true
|
|
});
|
|
const geometryObserver =
|
|
timelineContent && typeof ResizeObserver !== "undefined"
|
|
? new ResizeObserver(syncObservedTimelineGeometry)
|
|
: null;
|
|
geometryObserver?.observe(timeline);
|
|
if (timelineContent) {
|
|
geometryObserver?.observe(timelineContent);
|
|
}
|
|
return () => {
|
|
geometryObserver?.disconnect();
|
|
timeline.removeEventListener("scroll", captureScrollAnchor);
|
|
timeline.removeEventListener("scrollend", clearScrollDirection);
|
|
timeline.removeEventListener("wheel", captureWheelIntent);
|
|
timeline.removeEventListener("keydown", captureKeyboardIntent);
|
|
timeline.removeEventListener("click", captureSemanticScrollAwayIntent);
|
|
timeline.removeEventListener("pointerdown", capturePointerIntent);
|
|
window.removeEventListener("pointerup", clearPointerIntent);
|
|
window.removeEventListener("pointercancel", clearPointerIntent);
|
|
};
|
|
}, [
|
|
actions,
|
|
dispatchFollowEnd,
|
|
followEndController,
|
|
isVisible,
|
|
timelineConversationId,
|
|
showTimelineSkeleton,
|
|
viewModel.rail.activeConversationId,
|
|
viewModel.detail.hasOlderMessages,
|
|
viewModel.detail.isLoadingOlderMessages,
|
|
writeTimelineScrollAnchor
|
|
]);
|
|
|
|
const scrollTimelineToBottom = useCallback(() => {
|
|
const timeline = timelineRef.current;
|
|
const activeConversationId = timelineConversationId;
|
|
if (!isVisible || !timeline || !activeConversationId) {
|
|
return;
|
|
}
|
|
if (activeConversationId !== viewModel.rail.activeConversationId) {
|
|
return;
|
|
}
|
|
if (
|
|
hasStaleVirtualScrollController(
|
|
virtualScrollControllerRef,
|
|
activeConversationId
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const virtualScrollController = matchingVirtualScrollController(
|
|
virtualScrollControllerRef,
|
|
activeConversationId
|
|
);
|
|
dispatchFollowEnd("scroll-to-end-requested");
|
|
userScrollDirectionRef.current = null;
|
|
if (virtualScrollController) {
|
|
virtualScrollController.scrollToEnd({
|
|
behavior: userScrollBehavior()
|
|
});
|
|
return;
|
|
}
|
|
const geometry = readTimelineGeometry(timeline);
|
|
const maxScrollTop = geometry.maxScrollTop;
|
|
setTimelineScrollTopWithUserTransition(timeline, maxScrollTop);
|
|
writeTimelineScrollAnchor({
|
|
conversationId: activeConversationId,
|
|
scrollHeight: geometry.scrollHeight,
|
|
scrollTop: maxScrollTop,
|
|
clientHeight: geometry.clientHeight
|
|
});
|
|
setIsTimelineScrolledToTop(
|
|
maxScrollTop <= AGENT_GUI_TOP_MASK_SCROLL_EPSILON_PX
|
|
);
|
|
}, [
|
|
dispatchFollowEnd,
|
|
isVisible,
|
|
timelineConversationId,
|
|
viewModel.rail.activeConversationId,
|
|
virtualScrollControllerRef,
|
|
writeTimelineScrollAnchor
|
|
]);
|
|
|
|
return {
|
|
followEndMode,
|
|
isTimelineScrolledToBottom: followEndMode === "following",
|
|
isTimelineScrolledToTop,
|
|
setVirtualScrollController,
|
|
scrollTimelineToBottom
|
|
};
|
|
}
|