源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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
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
This commit is contained in:
@@ -0,0 +1,787 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user