diff --git a/web/src/components/timeline/ActivityStream.tsx b/web/src/components/timeline/ActivityStream.tsx
index 9aceda227..704be1081 100644
--- a/web/src/components/timeline/ActivityStream.tsx
+++ b/web/src/components/timeline/ActivityStream.tsx
@@ -3,6 +3,8 @@ import { ObjectLifecycleSequence } from "@/types/timeline";
import { LifecycleIcon } from "@/components/overlay/detail/ObjectLifecycle";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { useActivityStream } from "@/contexts/ActivityStreamContext";
+import scrollIntoView from "scroll-into-view-if-needed";
+import useUserInteraction from "@/hooks/use-user-interaction";
type ActivityStreamProps = {
timelineData: ObjectLifecycleSequence[];
@@ -20,6 +22,11 @@ export default function ActivityStream({
const effectiveTime = currentTime + annotationOffset;
+ // Track user interaction and adjust scrolling behavior
+ const { userInteracting, setProgrammaticScroll } = useUserInteraction({
+ elementRef: scrollRef,
+ });
+
// group activities by timestamp (within 1 second resolution window)
const groupedActivities = useMemo(() => {
const groups: { [key: number]: ObjectLifecycleSequence[] } = {};
@@ -63,7 +70,7 @@ export default function ActivityStream({
// Auto-scroll to current time
useEffect(() => {
- if (!scrollRef.current) return;
+ if (!scrollRef.current || userInteracting) return;
// Find the last group where effectiveTimestamp <= currentTime + annotationOffset
let currentGroupIndex = -1;
@@ -75,17 +82,24 @@ export default function ActivityStream({
}
if (currentGroupIndex !== -1) {
- const element = scrollRef.current.children[
- currentGroupIndex
- ] as HTMLElement;
+ const element = scrollRef.current.querySelector(
+ `[data-timestamp="${filteredGroups[currentGroupIndex].timestamp}"]`,
+ ) as HTMLElement;
if (element) {
- element.scrollIntoView({
+ setProgrammaticScroll();
+ scrollIntoView(element, {
+ scrollMode: "if-needed",
behavior: "smooth",
- block: "center",
});
}
}
- }, [filteredGroups, effectiveTime, annotationOffset]);
+ }, [
+ filteredGroups,
+ effectiveTime,
+ annotationOffset,
+ userInteracting,
+ setProgrammaticScroll,
+ ]);
return (
;
@@ -71,9 +72,9 @@ function useDraggableElement({
// track user interaction and adjust scrolling behavior
- const [userInteracting, setUserInteracting] = useState(false);
- const interactionTimeout = useRef();
- const isProgrammaticScroll = useRef(false);
+ const { userInteracting } = useUserInteraction({
+ elementRef: timelineRef,
+ });
const draggingAtTopEdge = useMemo(() => {
if (clientYPosition && timelineRef.current && scrollEdgeSize) {
@@ -507,47 +508,6 @@ function useDraggableElement({
}
}, [timelineRef, segmentsRef, segments]);
- useEffect(() => {
- const handleUserInteraction = () => {
- if (!isProgrammaticScroll.current) {
- setUserInteracting(true);
-
- if (interactionTimeout.current) {
- clearTimeout(interactionTimeout.current);
- }
-
- interactionTimeout.current = setTimeout(() => {
- setUserInteracting(false);
- }, 3000);
- } else {
- isProgrammaticScroll.current = false;
- }
- };
-
- const timelineElement = timelineRef.current;
-
- if (timelineElement) {
- timelineElement.addEventListener("scroll", handleUserInteraction);
- timelineElement.addEventListener("mousedown", handleUserInteraction);
- timelineElement.addEventListener("mouseup", handleUserInteraction);
- timelineElement.addEventListener("touchstart", handleUserInteraction);
- timelineElement.addEventListener("touchmove", handleUserInteraction);
- timelineElement.addEventListener("touchend", handleUserInteraction);
-
- return () => {
- timelineElement.removeEventListener("scroll", handleUserInteraction);
- timelineElement.removeEventListener("mousedown", handleUserInteraction);
- timelineElement.removeEventListener("mouseup", handleUserInteraction);
- timelineElement.removeEventListener(
- "touchstart",
- handleUserInteraction,
- );
- timelineElement.removeEventListener("touchmove", handleUserInteraction);
- timelineElement.removeEventListener("touchend", handleUserInteraction);
- };
- }
- }, [timelineRef]);
-
return { handleMouseDown, handleMouseUp, handleMouseMove };
}
diff --git a/web/src/hooks/use-user-interaction.ts b/web/src/hooks/use-user-interaction.ts
new file mode 100644
index 000000000..2b59a6d49
--- /dev/null
+++ b/web/src/hooks/use-user-interaction.ts
@@ -0,0 +1,57 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+type UseUserInteractionProps = {
+ elementRef: React.RefObject;
+};
+
+function useUserInteraction({ elementRef }: UseUserInteractionProps) {
+ const [userInteracting, setUserInteracting] = useState(false);
+ const interactionTimeout = useRef();
+ const isProgrammaticScroll = useRef(false);
+
+ const setProgrammaticScroll = useCallback(() => {
+ isProgrammaticScroll.current = true;
+ }, []);
+
+ useEffect(() => {
+ const handleUserInteraction = () => {
+ if (!isProgrammaticScroll.current) {
+ setUserInteracting(true);
+
+ if (interactionTimeout.current) {
+ clearTimeout(interactionTimeout.current);
+ }
+
+ interactionTimeout.current = setTimeout(() => {
+ setUserInteracting(false);
+ }, 3000);
+ } else {
+ isProgrammaticScroll.current = false;
+ }
+ };
+
+ const element = elementRef.current;
+
+ if (element) {
+ element.addEventListener("scroll", handleUserInteraction);
+ element.addEventListener("mousedown", handleUserInteraction);
+ element.addEventListener("mouseup", handleUserInteraction);
+ element.addEventListener("touchstart", handleUserInteraction);
+ element.addEventListener("touchmove", handleUserInteraction);
+ element.addEventListener("touchend", handleUserInteraction);
+
+ return () => {
+ element.removeEventListener("scroll", handleUserInteraction);
+ element.removeEventListener("mousedown", handleUserInteraction);
+ element.removeEventListener("mouseup", handleUserInteraction);
+ element.removeEventListener("touchstart", handleUserInteraction);
+ element.removeEventListener("touchmove", handleUserInteraction);
+ element.removeEventListener("touchend", handleUserInteraction);
+ };
+ }
+ }, [elementRef]);
+
+ return { userInteracting, setProgrammaticScroll };
+}
+
+export default useUserInteraction;