fix(web): preserve transcript scroll across session activation (#3291)
This commit is contained in:
@@ -93,8 +93,14 @@ export const SessionPanelContent = forwardRef<HTMLDivElement, SessionPanelConten
|
||||
if (isHiddenRef.current) {
|
||||
isHiddenRef.current = false;
|
||||
const saved = savedScrollTopRef.current;
|
||||
const scrollTopAtSchedule = el.scrollTop;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = saved;
|
||||
// A transcript can reconcile its position in the expected
|
||||
// restore window. Do not overwrite a newer app or user scroll
|
||||
// with the stale offset captured before the panel was shown.
|
||||
// This guard also protects against a later-than-expected
|
||||
// generic restore delivery.
|
||||
if (el.scrollTop === scrollTopAtSchedule) el.scrollTop = saved;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
shouldCatchUpOnAutoScrollEnable,
|
||||
resolveNativeInitialScrollTop,
|
||||
createFrameCoalescer,
|
||||
scheduleAfterPanelRestore,
|
||||
useActivationPending,
|
||||
} from "./transcript-auto-scroll";
|
||||
import { scheduleClampedScrollRestore } from "./clamped-scroll-restore";
|
||||
import { createDebugLogger, isDebug } from "@/lib/debug/log";
|
||||
@@ -532,19 +534,14 @@ function useRetryPaginationOnUpwardScroll(
|
||||
* Two frames place this after SessionPanelContent's one-frame restore without
|
||||
* treating the initial visible mount as pagination intent. */
|
||||
function useRecheckPaginationOnVisible(isVisible: boolean, recheck: () => void) {
|
||||
const previousVisibleRef = useRef(isVisible);
|
||||
const { isVisibleRef, activationPendingRef } = useActivationPending(isVisible);
|
||||
useEffect(() => {
|
||||
const becameVisible = !previousVisibleRef.current && isVisible;
|
||||
previousVisibleRef.current = isVisible;
|
||||
if (!becameVisible) return;
|
||||
let secondFrame: number | null = null;
|
||||
const firstFrame = requestAnimationFrame(() => {
|
||||
secondFrame = requestAnimationFrame(recheck);
|
||||
if (!isVisible || !activationPendingRef.current) return;
|
||||
return scheduleAfterPanelRestore(() => {
|
||||
if (!isVisibleRef.current) return;
|
||||
activationPendingRef.current = false;
|
||||
recheck();
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(firstFrame);
|
||||
if (secondFrame !== null) cancelAnimationFrame(secondFrame);
|
||||
};
|
||||
}, [isVisible, recheck]);
|
||||
}
|
||||
|
||||
@@ -579,6 +576,7 @@ export function useAutoScroll(params: {
|
||||
enabled: boolean;
|
||||
hasUnreadDivider: boolean;
|
||||
isProgrammaticScrollLocked: () => boolean;
|
||||
isVisible?: boolean;
|
||||
}) {
|
||||
const {
|
||||
scrollRef,
|
||||
@@ -588,29 +586,102 @@ export function useAutoScroll(params: {
|
||||
enabled,
|
||||
hasUnreadDivider,
|
||||
isProgrammaticScrollLocked,
|
||||
isVisible = true,
|
||||
} = params;
|
||||
const storeApi = useAppStoreApi();
|
||||
const isNearBottomRef = useRef(true);
|
||||
const prevIsWorkingRef = useRef(isWorking);
|
||||
const prevEnabledRef = useRef(enabled);
|
||||
const frozenScrollTopRef = useRef<number | null>(null);
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
const enabledRef = useRef(enabled);
|
||||
const hasUnreadDividerRef = useRef(hasUnreadDivider);
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
const isProgrammaticScrollLockedRef = useRef(isProgrammaticScrollLocked);
|
||||
isVisibleRef.current = isVisible;
|
||||
enabledRef.current = enabled;
|
||||
hasUnreadDividerRef.current = hasUnreadDivider;
|
||||
sessionIdRef.current = sessionId;
|
||||
isProgrammaticScrollLockedRef.current = isProgrammaticScrollLocked;
|
||||
|
||||
const resyncIsNearBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (!el || !isVisibleRef.current) return;
|
||||
isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
|
||||
}, [scrollRef]);
|
||||
const markNotNearBottom = useCallback(() => {
|
||||
isNearBottomRef.current = false;
|
||||
}, []);
|
||||
|
||||
usePersistedTranscriptScroll({
|
||||
scrollRef,
|
||||
sessionId,
|
||||
storeApi,
|
||||
resyncIsNearBottom,
|
||||
enabled,
|
||||
isWorking,
|
||||
isVisible,
|
||||
isVisibleRef,
|
||||
messages,
|
||||
});
|
||||
|
||||
useAutoScrollOnContent({
|
||||
scrollRef,
|
||||
messages,
|
||||
isWorking,
|
||||
enabled,
|
||||
hasUnreadDivider,
|
||||
isNearBottomRef,
|
||||
isVisibleRef,
|
||||
isProgrammaticScrollLocked,
|
||||
prevIsWorkingRef,
|
||||
});
|
||||
|
||||
useCatchUpOnReEnable(scrollRef, messages, enabled, isNearBottomRef);
|
||||
useCatchUpOnVisible({
|
||||
scrollRef,
|
||||
isVisible,
|
||||
isNearBottomRef,
|
||||
enabledRef,
|
||||
hasUnreadDividerRef,
|
||||
sessionIdRef,
|
||||
isProgrammaticScrollLockedRef,
|
||||
});
|
||||
|
||||
return { isNearBottomRef, resyncIsNearBottom, markNotNearBottom };
|
||||
}
|
||||
|
||||
function usePersistedTranscriptScroll({
|
||||
scrollRef,
|
||||
sessionId,
|
||||
storeApi,
|
||||
resyncIsNearBottom,
|
||||
enabled,
|
||||
isWorking,
|
||||
isVisible,
|
||||
isVisibleRef,
|
||||
messages,
|
||||
}: {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
sessionId: string | null;
|
||||
storeApi: ReturnType<typeof useAppStoreApi>;
|
||||
resyncIsNearBottom: () => void;
|
||||
enabled: boolean;
|
||||
isWorking: boolean;
|
||||
isVisible: boolean;
|
||||
isVisibleRef: React.RefObject<boolean>;
|
||||
messages: Message[];
|
||||
}) {
|
||||
const frozenScrollTopRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
/** Persists the container's current scrollTop for the session (used when
|
||||
* auto-scroll is disabled). */
|
||||
const captureScrollTop = () => {
|
||||
if (sessionId) storeApi.getState().setTranscriptScrollTop(sessionId, el.scrollTop);
|
||||
if (sessionId && (isVisibleRef.current || frozenScrollTopRef.current !== null)) {
|
||||
storeApi
|
||||
.getState()
|
||||
.setTranscriptScrollTop(sessionId, frozenScrollTopRef.current ?? el.scrollTop);
|
||||
}
|
||||
};
|
||||
// Coalesce persisted writes to at most one per animation frame — native
|
||||
// scroll events can fire far more often than that, and each write is a
|
||||
@@ -619,6 +690,7 @@ export function useAutoScroll(params: {
|
||||
/** Scroll listener: resyncs the near-bottom flag and schedules a
|
||||
* coalesced persistence of the scroll position. */
|
||||
const onScroll = () => {
|
||||
if (!isVisibleRef.current) return;
|
||||
resyncIsNearBottom();
|
||||
if (!enabled) frozenScrollTopRef.current = el.scrollTop;
|
||||
coalescer.schedule();
|
||||
@@ -638,13 +710,12 @@ export function useAutoScroll(params: {
|
||||
// prompt can briefly shrink the scroll range before the new message row is
|
||||
// committed, which makes the browser clamp scrollTop even with
|
||||
// overflow-anchor disabled. Keep the pre-update offset and reapply it after
|
||||
// each message/working-state render so that transient clamp cannot move the
|
||||
// reader. Real user scroll events update the owned offset above.
|
||||
// each message/working-state render so that transient clamp cannot move
|
||||
// the reader. Real user scroll events update the owned offset above.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const wasEnabled = prevEnabledRef.current;
|
||||
prevEnabledRef.current = enabled;
|
||||
if (!el || !isVisibleRef.current) return;
|
||||
const wasEnabled = frozenScrollTopRef.current === null || enabled;
|
||||
if (enabled) {
|
||||
frozenScrollTopRef.current = null;
|
||||
return;
|
||||
@@ -654,14 +725,37 @@ export function useAutoScroll(params: {
|
||||
return;
|
||||
}
|
||||
el.scrollTop = frozenScrollTopRef.current;
|
||||
}, [enabled, isWorking, messages, scrollRef]);
|
||||
}, [enabled, isWorking, isVisible, messages, scrollRef]);
|
||||
}
|
||||
|
||||
function useAutoScrollOnContent({
|
||||
scrollRef,
|
||||
messages,
|
||||
isWorking,
|
||||
enabled,
|
||||
hasUnreadDivider,
|
||||
isNearBottomRef,
|
||||
isVisibleRef,
|
||||
isProgrammaticScrollLocked,
|
||||
prevIsWorkingRef,
|
||||
}: {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
messages: Message[];
|
||||
isWorking: boolean;
|
||||
enabled: boolean;
|
||||
hasUnreadDivider: boolean;
|
||||
isNearBottomRef: React.RefObject<boolean>;
|
||||
isVisibleRef: React.RefObject<boolean>;
|
||||
isProgrammaticScrollLocked: () => boolean;
|
||||
prevIsWorkingRef: React.MutableRefObject<boolean>;
|
||||
}) {
|
||||
// When isWorking transitions to true, force scroll to bottom (unless
|
||||
// disabled, locked, or a layout rebuild scroll restore is pending).
|
||||
useEffect(() => {
|
||||
if (
|
||||
isWorking &&
|
||||
!prevIsWorkingRef.current &&
|
||||
isVisibleRef.current &&
|
||||
shouldAutoScrollToBottom({
|
||||
isNearBottom: !hasUnreadDivider,
|
||||
isProgrammaticScrollLocked: isProgrammaticScrollLocked(),
|
||||
@@ -682,7 +776,7 @@ export function useAutoScroll(params: {
|
||||
// layout rebuild scroll restore is pending).
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (!el || !isVisibleRef.current) return;
|
||||
if (
|
||||
shouldAutoScrollToBottom({
|
||||
isNearBottom: isNearBottomRef.current,
|
||||
@@ -694,10 +788,51 @@ export function useAutoScroll(params: {
|
||||
scrollNativeToBottom(el);
|
||||
}
|
||||
}, [messages, scrollRef, enabled, isProgrammaticScrollLocked]);
|
||||
}
|
||||
|
||||
useCatchUpOnReEnable(scrollRef, messages, enabled, isNearBottomRef);
|
||||
|
||||
return { isNearBottomRef, resyncIsNearBottom, markNotNearBottom };
|
||||
function useCatchUpOnVisible({
|
||||
scrollRef,
|
||||
isVisible,
|
||||
isNearBottomRef,
|
||||
enabledRef,
|
||||
hasUnreadDividerRef,
|
||||
sessionIdRef,
|
||||
isProgrammaticScrollLockedRef,
|
||||
}: {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
isVisible: boolean;
|
||||
isNearBottomRef: React.RefObject<boolean>;
|
||||
enabledRef: React.RefObject<boolean>;
|
||||
hasUnreadDividerRef: React.RefObject<boolean>;
|
||||
sessionIdRef: React.RefObject<string | null>;
|
||||
isProgrammaticScrollLockedRef: React.RefObject<() => boolean>;
|
||||
}) {
|
||||
const { isVisibleRef, activationPendingRef } = useActivationPending(isVisible);
|
||||
useEffect(() => {
|
||||
if (!isVisible || !activationPendingRef.current) return;
|
||||
return scheduleAfterPanelRestore(() => {
|
||||
if (!isVisibleRef.current) return;
|
||||
activationPendingRef.current = false;
|
||||
if (
|
||||
!enabledRef.current ||
|
||||
hasUnreadDividerRef.current ||
|
||||
!isNearBottomRef.current ||
|
||||
(isProgrammaticScrollLockedRef.current?.() ?? false)
|
||||
)
|
||||
return;
|
||||
const dockviewState = useDockviewStore.getState();
|
||||
if (
|
||||
dockviewState.pendingChatScrollTop !== null ||
|
||||
dockviewState.scrollTarget?.sessionId === sessionIdRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
scrollNativeToBottom(el);
|
||||
isNearBottomRef.current = true;
|
||||
});
|
||||
}, [isVisible, scrollRef]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -943,58 +1078,99 @@ export function useScrollToMessage(
|
||||
* The enabled "scroll to bottom" path needs no retry and settles in a single
|
||||
* write.
|
||||
*/
|
||||
function useInitialScrollPosition(
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>,
|
||||
itemCount: number,
|
||||
sessionId: string | null,
|
||||
enabled: boolean,
|
||||
isNearBottomRef: React.RefObject<boolean>,
|
||||
) {
|
||||
function useInitialScrollPosition({
|
||||
scrollRef,
|
||||
itemCount,
|
||||
sessionId,
|
||||
enabled,
|
||||
isNearBottomRef,
|
||||
isVisible,
|
||||
isProgrammaticScrollLocked,
|
||||
}: {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
itemCount: number;
|
||||
sessionId: string | null;
|
||||
enabled: boolean;
|
||||
isNearBottomRef: React.RefObject<boolean>;
|
||||
isVisible: boolean;
|
||||
isProgrammaticScrollLocked: () => boolean;
|
||||
}) {
|
||||
const storeApi = useAppStoreApi();
|
||||
const didInitialScroll = useRef(false);
|
||||
const { isVisibleRef, activationPendingRef } = useActivationPending(isVisible);
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
return;
|
||||
}
|
||||
if (didInitialScroll.current || itemCount === 0) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const hasPendingLayoutRestore = useDockviewStore.getState().pendingChatScrollTop !== null;
|
||||
const savedScrollTop = sessionId
|
||||
? (storeApi.getState().transcriptAutoScroll.scrollTopBySessionId[sessionId] ??
|
||||
getStoredAutoScrollTop(sessionId) ??
|
||||
undefined)
|
||||
: undefined;
|
||||
const scrollTop = resolveNativeInitialScrollTop({
|
||||
enabled,
|
||||
hasPendingLayoutRestore,
|
||||
savedScrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
});
|
||||
didInitialScroll.current = true;
|
||||
if (scrollTop === null) return;
|
||||
|
||||
/** Derives and stores the near-bottom flag from the applied scrollTop
|
||||
* against the container's current dimensions. */
|
||||
const syncNearBottom = () => {
|
||||
isNearBottomRef.current = !hasTranscriptProgressedPastView({
|
||||
scrollTop,
|
||||
const applyInitialScroll = () => {
|
||||
if (!isVisibleRef.current) return;
|
||||
const dockviewState = useDockviewStore.getState();
|
||||
const hasPendingLayoutRestore = dockviewState.pendingChatScrollTop !== null;
|
||||
const hasExplicitScrollTarget =
|
||||
sessionId !== null && dockviewState.scrollTarget?.sessionId === sessionId;
|
||||
if (hasPendingLayoutRestore || hasExplicitScrollTarget || isProgrammaticScrollLocked()) {
|
||||
didInitialScroll.current = true;
|
||||
activationPendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
const savedScrollTop = sessionId
|
||||
? (storeApi.getState().transcriptAutoScroll.scrollTopBySessionId[sessionId] ??
|
||||
getStoredAutoScrollTop(sessionId) ??
|
||||
undefined)
|
||||
: undefined;
|
||||
const scrollTop = resolveNativeInitialScrollTop({
|
||||
enabled,
|
||||
hasPendingLayoutRestore,
|
||||
savedScrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
});
|
||||
didInitialScroll.current = true;
|
||||
activationPendingRef.current = false;
|
||||
if (scrollTop === null) return;
|
||||
|
||||
/** Derives and stores the near-bottom flag from the applied scrollTop
|
||||
* against the container's current dimensions. */
|
||||
const syncNearBottom = () => {
|
||||
isNearBottomRef.current = !hasTranscriptProgressedPastView({
|
||||
scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
});
|
||||
};
|
||||
|
||||
el.scrollTop = scrollTop;
|
||||
syncNearBottom();
|
||||
|
||||
// Only a concrete disabled-restore offset can be clamped by a not-yet-
|
||||
// settled layout; the enabled bottom target lands in one write, and a
|
||||
// write that already reached its target needs no follow-up.
|
||||
if (enabled || scrollTop <= 0 || el.scrollTop >= scrollTop - 1) return;
|
||||
|
||||
scheduleClampedScrollRestore({
|
||||
element: el,
|
||||
targetScrollTop: scrollTop,
|
||||
onApply: syncNearBottom,
|
||||
});
|
||||
};
|
||||
|
||||
el.scrollTop = scrollTop;
|
||||
syncNearBottom();
|
||||
|
||||
// Only a concrete disabled-restore offset can be clamped by a not-yet-
|
||||
// settled layout; the enabled bottom target lands in one write, and a
|
||||
// write that already reached its target needs no follow-up.
|
||||
if (enabled || scrollTop <= 0 || el.scrollTop >= scrollTop - 1) return;
|
||||
|
||||
scheduleClampedScrollRestore({
|
||||
element: el,
|
||||
targetScrollTop: scrollTop,
|
||||
onApply: syncNearBottom,
|
||||
});
|
||||
}, [itemCount, sessionId, enabled, isNearBottomRef, storeApi]);
|
||||
if (activationPendingRef.current) {
|
||||
return scheduleAfterPanelRestore(applyInitialScroll);
|
||||
}
|
||||
applyInitialScroll();
|
||||
}, [
|
||||
itemCount,
|
||||
sessionId,
|
||||
enabled,
|
||||
isNearBottomRef,
|
||||
storeApi,
|
||||
isVisible,
|
||||
isProgrammaticScrollLocked,
|
||||
scrollRef,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1045,6 +1221,7 @@ export function useNativeScrollManagement(params: {
|
||||
enabled,
|
||||
hasUnreadDivider,
|
||||
isProgrammaticScrollLocked,
|
||||
isVisible,
|
||||
});
|
||||
const runGuardedScroll = useProgrammaticScrollGuard(
|
||||
scrollRef,
|
||||
@@ -1079,13 +1256,22 @@ export function useNativeScrollManagement(params: {
|
||||
});
|
||||
useRetryPaginationOnUpwardScroll(scrollRef, onUserGesture, recheck, isProgrammaticScrollLocked);
|
||||
useRecheckPaginationOnVisible(isVisible, recheck);
|
||||
useInitialScrollPosition(scrollRef, items.length, sessionId, enabled, isNearBottomRef);
|
||||
useInitialScrollPosition({
|
||||
scrollRef,
|
||||
itemCount: items.length,
|
||||
sessionId,
|
||||
enabled,
|
||||
isNearBottomRef,
|
||||
isVisible,
|
||||
isProgrammaticScrollLocked,
|
||||
});
|
||||
|
||||
return {
|
||||
handleScrollToMessage,
|
||||
sentinelRef,
|
||||
resyncIsNearBottom,
|
||||
markNotNearBottom,
|
||||
isProgrammaticScrollLocked,
|
||||
retryLoadMore,
|
||||
showRecovery,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,22 @@ const sharedSentinelCalls = vi.hoisted(() => [] as unknown[][]);
|
||||
const sharedSentinelUserGesture = vi.hoisted(() => vi.fn());
|
||||
const sharedSentinelRetry = vi.hoisted(() => vi.fn());
|
||||
const sharedSentinelRecheck = vi.hoisted(() => vi.fn());
|
||||
const transcriptScrollTopBySessionId = vi.hoisted(() => new Map<string, number>());
|
||||
const transcriptScrollTopWrites = vi.hoisted(() =>
|
||||
vi.fn((sessionId: string, scrollTop: number) => {
|
||||
transcriptScrollTopBySessionId.set(sessionId, scrollTop);
|
||||
}),
|
||||
);
|
||||
const mockDockviewState = vi.hoisted(
|
||||
() =>
|
||||
({
|
||||
pendingChatScrollTop: null,
|
||||
scrollTarget: null,
|
||||
}) as {
|
||||
pendingChatScrollTop: number | null;
|
||||
scrollTarget: { sessionId: string } | null;
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("@/hooks/use-lazy-load-sentinel", () => ({
|
||||
useLazyLoadSentinel: (...args: unknown[]) => {
|
||||
@@ -24,15 +40,19 @@ vi.mock("@/hooks/use-lazy-load-sentinel", () => ({
|
||||
|
||||
vi.mock("@/lib/state/dockview-store", () => ({
|
||||
useDockviewStore: Object.assign(
|
||||
(selector: (state: { pendingChatScrollTop: number | null }) => unknown) =>
|
||||
selector({ pendingChatScrollTop: null }),
|
||||
{ getState: () => ({ pendingChatScrollTop: null }) },
|
||||
(selector: (state: typeof mockDockviewState) => unknown) => selector(mockDockviewState),
|
||||
{ getState: () => mockDockviewState },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStoreApi: () => ({
|
||||
getState: () => ({ setTranscriptScrollTop: vi.fn() }),
|
||||
getState: () => ({
|
||||
setTranscriptScrollTop: transcriptScrollTopWrites,
|
||||
transcriptAutoScroll: {
|
||||
scrollTopBySessionId: Object.fromEntries(transcriptScrollTopBySessionId),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -54,6 +74,7 @@ const TARGET_MESSAGE_ID = "target";
|
||||
const HANDLE_RENDER_ERROR = "handle did not render";
|
||||
const HARNESS_RENDER_ERROR = "harness did not render";
|
||||
const NATIVE_SCROLL_MANAGEMENT_TEST_ID = "native-scroll-management-container";
|
||||
const AUTO_SCROLL_CONTAINER_TEST_ID = "auto-scroll-container";
|
||||
const TEST_MESSAGES = [{} as Message];
|
||||
/** Always returns false: the harness never locks programmatic scrolling. */
|
||||
const NEVER_LOCKED = () => false;
|
||||
@@ -72,6 +93,11 @@ function Harness({
|
||||
onDividerScroll,
|
||||
scrollLayoutKey = "initial",
|
||||
dividerDocumentTop = 250,
|
||||
isVisible = true,
|
||||
scrollHeight = 0,
|
||||
enabled = true,
|
||||
sessionId = null,
|
||||
isProgrammaticScrollLocked = NEVER_LOCKED,
|
||||
}: {
|
||||
itemCount: number;
|
||||
anchoredBarOffsetPx: number;
|
||||
@@ -79,11 +105,20 @@ function Harness({
|
||||
onDividerScroll?: () => void;
|
||||
scrollLayoutKey?: string;
|
||||
dividerDocumentTop?: number;
|
||||
isVisible?: boolean;
|
||||
scrollHeight?: number;
|
||||
enabled?: boolean;
|
||||
sessionId?: string | null;
|
||||
isProgrammaticScrollLocked?: () => boolean;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) return;
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: scrollHeight,
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => createRect(100, 400),
|
||||
@@ -95,10 +130,15 @@ function Harness({
|
||||
value: () => createRect(dividerDocumentTop - scrollContainer.scrollTop, 20),
|
||||
});
|
||||
}, [dividerDocumentTop]);
|
||||
useScrollToDividerOrBottom(scrollRef, itemCount, dividerKey, anchoredBarOffsetPx, {
|
||||
const scrollOptions = {
|
||||
onDividerScroll,
|
||||
scrollLayoutKey,
|
||||
});
|
||||
enabled,
|
||||
sessionId,
|
||||
isProgrammaticScrollLocked,
|
||||
isVisible,
|
||||
} as Parameters<typeof useScrollToDividerOrBottom>[4];
|
||||
useScrollToDividerOrBottom(scrollRef, itemCount, dividerKey, anchoredBarOffsetPx, scrollOptions);
|
||||
return (
|
||||
<div ref={scrollRef} data-testid={DIVIDER_SCROLL_CONTAINER_TEST_ID}>
|
||||
<div id="msg-m1" />
|
||||
@@ -129,25 +169,36 @@ function AutoScrollHarness({
|
||||
messages = TEST_MESSAGES,
|
||||
markRef,
|
||||
enabled = true,
|
||||
isVisible = true,
|
||||
sessionId = null,
|
||||
metrics,
|
||||
}: {
|
||||
isWorking: boolean;
|
||||
hasUnreadDivider: boolean;
|
||||
messages?: Message[];
|
||||
markRef?: { current?: () => void };
|
||||
enabled?: boolean;
|
||||
isVisible?: boolean;
|
||||
sessionId?: string | null;
|
||||
metrics?: NativeScrollMetrics;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { markNotNearBottom } = useAutoScroll({
|
||||
const useAutoScrollWithVisibility = useAutoScroll as unknown as (
|
||||
params: Parameters<typeof useAutoScroll>[0] & { isVisible: boolean },
|
||||
) => ReturnType<typeof useAutoScroll>;
|
||||
const { markNotNearBottom } = useAutoScrollWithVisibility({
|
||||
scrollRef,
|
||||
messages,
|
||||
isWorking,
|
||||
sessionId: null,
|
||||
sessionId,
|
||||
enabled,
|
||||
hasUnreadDivider,
|
||||
isProgrammaticScrollLocked: NEVER_LOCKED,
|
||||
isVisible,
|
||||
});
|
||||
useNativeScrollMetrics(scrollRef, metrics);
|
||||
if (markRef) markRef.current = markNotNearBottom;
|
||||
return <div ref={scrollRef} data-testid="auto-scroll-container" />;
|
||||
return <div ref={scrollRef} data-testid={AUTO_SCROLL_CONTAINER_TEST_ID} />;
|
||||
}
|
||||
|
||||
/** Stubs scrollHeight (1000) and clientHeight (400) onto an element. */
|
||||
@@ -158,10 +209,14 @@ function setScrollMetrics(element: HTMLElement) {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockDockviewState.pendingChatScrollTop = null;
|
||||
mockDockviewState.scrollTarget = null;
|
||||
sharedSentinelCalls.length = 0;
|
||||
sharedSentinelUserGesture.mockReset();
|
||||
sharedSentinelRetry.mockReset();
|
||||
sharedSentinelRecheck.mockReset();
|
||||
transcriptScrollTopBySessionId.clear();
|
||||
transcriptScrollTopWrites.mockClear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -208,6 +263,7 @@ function NativeScrollManagementHarness({
|
||||
isLoadingMore = false,
|
||||
recoveryRef,
|
||||
isVisible = true,
|
||||
enabled = false,
|
||||
}: {
|
||||
items: RenderItem[];
|
||||
metrics?: NativeScrollMetrics;
|
||||
@@ -216,15 +272,17 @@ function NativeScrollManagementHarness({
|
||||
isLoadingMore?: boolean;
|
||||
recoveryRef?: { current: boolean };
|
||||
isVisible?: boolean;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useNativeScrollMetrics(scrollRef, metrics);
|
||||
const { sentinelRef, showRecovery } = useNativeScrollManagement({
|
||||
scrollRef,
|
||||
items,
|
||||
messages: [],
|
||||
isWorking: false,
|
||||
sessionId,
|
||||
enabled: false,
|
||||
enabled,
|
||||
hasUnreadDivider: false,
|
||||
messagesLoading: false,
|
||||
hasMore: true,
|
||||
@@ -233,7 +291,6 @@ function NativeScrollManagementHarness({
|
||||
isVisible,
|
||||
});
|
||||
if (recoveryRef) recoveryRef.current = showRecovery;
|
||||
useNativeScrollMetrics(scrollRef, metrics);
|
||||
return (
|
||||
<div data-testid={NATIVE_SCROLL_MANAGEMENT_TEST_ID} ref={scrollRef}>
|
||||
<div ref={sentinelRef} />
|
||||
@@ -280,6 +337,44 @@ describe("isElementInPreloadRegion", () => {
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- pagination invariants share one fixture and lifecycle.
|
||||
describe("useNativeScrollManagement transcript pagination", () => {
|
||||
it("defers the native initial placement until a hidden transcript is active", () => {
|
||||
const frames: Array<FrameRequestCallback> = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
});
|
||||
const metrics = { scrollHeight: 1000, scrollTop: 250, clientHeight: 400 };
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptMessage("initial-message")]}
|
||||
metrics={metrics}
|
||||
enabled
|
||||
isVisible={false}
|
||||
/>,
|
||||
);
|
||||
const scrollContainer = screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID);
|
||||
expect(scrollContainer.scrollTop).toBe(250);
|
||||
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptMessage("initial-message")]}
|
||||
metrics={metrics}
|
||||
enabled
|
||||
isVisible
|
||||
/>,
|
||||
);
|
||||
metrics.scrollTop = 250;
|
||||
act(() => {
|
||||
for (let frame = frames.shift(); frame; frame = frames.shift()) frame(0);
|
||||
});
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(1000);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("rechecks restored geometry once when a hidden transcript becomes visible", () => {
|
||||
const requestAnimationFrame = vi
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
@@ -667,6 +762,302 @@ describe("useNativeScrollManagement transcript pagination", () => {
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- this suite keeps the related scroll invariants together.
|
||||
describe("useScrollToDividerOrBottom — anchored-bar offset", () => {
|
||||
it("waits for an inactive transcript to become visible before placing the initial view", () => {
|
||||
const frames: Array<FrameRequestCallback> = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
});
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={0}
|
||||
dividerKey={null}
|
||||
isVisible={false}
|
||||
scrollHeight={1000}
|
||||
/>,
|
||||
);
|
||||
const scrollContainer = screen.getByTestId(DIVIDER_SCROLL_CONTAINER_TEST_ID) as HTMLElement;
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(0);
|
||||
|
||||
rerender(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={0}
|
||||
dividerKey={null}
|
||||
isVisible
|
||||
scrollHeight={1000}
|
||||
/>,
|
||||
);
|
||||
// Model SessionPanelContent restoring its saved absolute offset after
|
||||
// the panel becomes measurable. The activation placement must happen
|
||||
// after that restore, not before it.
|
||||
scrollContainer.scrollTop = 250;
|
||||
expect(scrollContainer.scrollTop).toBe(250);
|
||||
|
||||
act(() => frames.shift()?.(0));
|
||||
expect(scrollContainer.scrollTop).toBe(250);
|
||||
|
||||
act(() => frames.shift()?.(0));
|
||||
expect(scrollContainer.scrollTop).toBe(1000);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts and bounds the divider settling window at hidden-session activation", () => {
|
||||
vi.useFakeTimers();
|
||||
let currentTime = 0;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => currentTime);
|
||||
const frames: Array<FrameRequestCallback> = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
});
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<Harness itemCount={2} anchoredBarOffsetPx={0} isVisible={false} scrollHeight={1000} />,
|
||||
);
|
||||
const scrollContainer = screen.getByTestId(DIVIDER_SCROLL_CONTAINER_TEST_ID) as HTMLElement;
|
||||
|
||||
vi.advanceTimersByTime(4001);
|
||||
currentTime = 4001;
|
||||
|
||||
rerender(<Harness itemCount={2} anchoredBarOffsetPx={0} isVisible scrollHeight={1000} />);
|
||||
act(() => {
|
||||
let frame = frames.shift();
|
||||
while (frame) {
|
||||
frame(0);
|
||||
frame = frames.shift();
|
||||
}
|
||||
});
|
||||
// The deadline starts at activation, so the divider still wins even
|
||||
// though the persistent hidden portal was mounted for more than 4s.
|
||||
expect(scrollContainer.scrollTop).toBe(150);
|
||||
|
||||
rerender(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={76}
|
||||
isVisible
|
||||
scrollHeight={1000}
|
||||
scrollLayoutKey="activation-window"
|
||||
/>,
|
||||
);
|
||||
expect(scrollContainer.scrollTop).toBe(74);
|
||||
|
||||
vi.advanceTimersByTime(4001);
|
||||
currentTime = 8002;
|
||||
rerender(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={100}
|
||||
isVisible
|
||||
scrollHeight={1000}
|
||||
scrollLayoutKey="settled"
|
||||
/>,
|
||||
);
|
||||
expect(scrollContainer.scrollTop).toBe(74);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not replace a pending layout restore with activation placement", () => {
|
||||
const frames: Array<FrameRequestCallback> = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
});
|
||||
mockDockviewState.pendingChatScrollTop = 42;
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={0}
|
||||
dividerKey={null}
|
||||
isVisible={false}
|
||||
scrollHeight={1000}
|
||||
/>,
|
||||
);
|
||||
const scrollContainer = screen.getByTestId(DIVIDER_SCROLL_CONTAINER_TEST_ID);
|
||||
scrollContainer.scrollTop = 250;
|
||||
|
||||
rerender(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={0}
|
||||
dividerKey={null}
|
||||
isVisible
|
||||
scrollHeight={1000}
|
||||
/>,
|
||||
);
|
||||
act(() => {
|
||||
for (const frame of frames.splice(0)) frame(0);
|
||||
});
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(250);
|
||||
mockDockviewState.pendingChatScrollTop = null;
|
||||
rerender(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={0}
|
||||
dividerKey={null}
|
||||
isVisible
|
||||
scrollHeight={1000}
|
||||
scrollLayoutKey="restored"
|
||||
/>,
|
||||
);
|
||||
expect(scrollContainer.scrollTop).toBe(250);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not replace an explicit message target with activation placement", () => {
|
||||
const frames: Array<FrameRequestCallback> = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
});
|
||||
mockDockviewState.scrollTarget = { sessionId: "session-1" };
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={0}
|
||||
dividerKey={null}
|
||||
sessionId="session-1"
|
||||
isVisible={false}
|
||||
scrollHeight={1000}
|
||||
/>,
|
||||
);
|
||||
const scrollContainer = screen.getByTestId(DIVIDER_SCROLL_CONTAINER_TEST_ID);
|
||||
scrollContainer.scrollTop = 250;
|
||||
|
||||
rerender(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={0}
|
||||
dividerKey={null}
|
||||
sessionId="session-1"
|
||||
isVisible
|
||||
scrollHeight={1000}
|
||||
/>,
|
||||
);
|
||||
act(() => {
|
||||
for (const frame of frames.splice(0)) frame(0);
|
||||
});
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(250);
|
||||
mockDockviewState.scrollTarget = null;
|
||||
rerender(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={0}
|
||||
dividerKey={null}
|
||||
sessionId="session-1"
|
||||
isVisible
|
||||
scrollHeight={1000}
|
||||
scrollLayoutKey="target-consumed"
|
||||
/>,
|
||||
);
|
||||
expect(scrollContainer.scrollTop).toBe(250);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not bottom-place a disabled transcript during activation", () => {
|
||||
const frames: Array<FrameRequestCallback> = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
});
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={0}
|
||||
dividerKey={null}
|
||||
enabled={false}
|
||||
isVisible={false}
|
||||
scrollHeight={1000}
|
||||
/>,
|
||||
);
|
||||
const scrollContainer = screen.getByTestId(DIVIDER_SCROLL_CONTAINER_TEST_ID);
|
||||
scrollContainer.scrollTop = 250;
|
||||
|
||||
rerender(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={0}
|
||||
dividerKey={null}
|
||||
enabled={false}
|
||||
isVisible
|
||||
scrollHeight={1000}
|
||||
/>,
|
||||
);
|
||||
act(() => {
|
||||
for (const frame of frames.splice(0)) frame(0);
|
||||
});
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(250);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels pending activation placement when the transcript becomes inactive again", () => {
|
||||
const frames: Array<FrameRequestCallback> = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
});
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={0}
|
||||
dividerKey={null}
|
||||
isVisible={false}
|
||||
scrollHeight={1000}
|
||||
/>,
|
||||
);
|
||||
const scrollContainer = screen.getByTestId(DIVIDER_SCROLL_CONTAINER_TEST_ID) as HTMLElement;
|
||||
scrollContainer.scrollTop = 250;
|
||||
|
||||
rerender(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={0}
|
||||
dividerKey={null}
|
||||
isVisible
|
||||
scrollHeight={1000}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={0}
|
||||
dividerKey={null}
|
||||
isVisible={false}
|
||||
scrollHeight={1000}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
for (const frame of frames.splice(0)) frame(0);
|
||||
});
|
||||
expect(scrollContainer.scrollTop).toBe(250);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("re-scrolls the divider when the anchored bar's measured height arrives", () => {
|
||||
const { rerender } = render(<Harness itemCount={2} anchoredBarOffsetPx={0} />);
|
||||
const scrollContainer = document.querySelector<HTMLElement>(
|
||||
@@ -713,7 +1104,7 @@ describe("useScrollToDividerOrBottom — anchored-bar offset", () => {
|
||||
it("does not follow the bottom when work starts with an unread divider", () => {
|
||||
const { rerender } = render(<AutoScrollHarness isWorking={false} hasUnreadDivider={true} />);
|
||||
const scrollContainer = document.querySelector<HTMLElement>(
|
||||
'[data-testid="auto-scroll-container"]',
|
||||
`[data-testid="${AUTO_SCROLL_CONTAINER_TEST_ID}"]`,
|
||||
);
|
||||
if (!scrollContainer) throw new Error("auto-scroll container did not render");
|
||||
setScrollMetrics(scrollContainer);
|
||||
@@ -730,7 +1121,7 @@ describe("useScrollToDividerOrBottom — anchored-bar offset", () => {
|
||||
<AutoScrollHarness isWorking={false} hasUnreadDivider={true} markRef={markRef} />,
|
||||
);
|
||||
const scrollContainer = document.querySelector<HTMLElement>(
|
||||
'[data-testid="auto-scroll-container"]',
|
||||
`[data-testid="${AUTO_SCROLL_CONTAINER_TEST_ID}"]`,
|
||||
);
|
||||
if (!scrollContainer) throw new Error("auto-scroll container did not render");
|
||||
setScrollMetrics(scrollContainer);
|
||||
@@ -753,7 +1144,7 @@ describe("useScrollToDividerOrBottom — anchored-bar offset", () => {
|
||||
const { rerender } = render(
|
||||
<AutoScrollHarness isWorking={false} hasUnreadDivider={false} messages={TEST_MESSAGES} />,
|
||||
);
|
||||
const scrollContainer = screen.getByTestId("auto-scroll-container");
|
||||
const scrollContainer = screen.getByTestId(AUTO_SCROLL_CONTAINER_TEST_ID);
|
||||
let scrollTop = 600;
|
||||
let writes = 0;
|
||||
Object.defineProperties(scrollContainer, {
|
||||
@@ -792,7 +1183,7 @@ describe("useScrollToDividerOrBottom — anchored-bar offset", () => {
|
||||
<AutoScrollHarness isWorking={false} hasUnreadDivider={false} enabled />,
|
||||
);
|
||||
const scrollContainer = document.querySelector<HTMLElement>(
|
||||
'[data-testid="auto-scroll-container"]',
|
||||
`[data-testid="${AUTO_SCROLL_CONTAINER_TEST_ID}"]`,
|
||||
);
|
||||
if (!scrollContainer) throw new Error("auto-scroll container did not render");
|
||||
setScrollMetrics(scrollContainer);
|
||||
@@ -814,6 +1205,155 @@ describe("useScrollToDividerOrBottom — anchored-bar offset", () => {
|
||||
expect(scrollContainer.scrollTop).toBe(600);
|
||||
});
|
||||
|
||||
it("persists the last visible disabled offset through hide, unmount, and remount", () => {
|
||||
const metrics = { scrollHeight: 1000, scrollTop: 300, clientHeight: 400 };
|
||||
const sessionId = "session-1";
|
||||
const { rerender, unmount } = render(
|
||||
<AutoScrollHarness
|
||||
isWorking={false}
|
||||
hasUnreadDivider={false}
|
||||
enabled={false}
|
||||
isVisible
|
||||
sessionId={sessionId}
|
||||
metrics={metrics}
|
||||
/>,
|
||||
);
|
||||
const scrollContainer = screen.getByTestId(AUTO_SCROLL_CONTAINER_TEST_ID);
|
||||
metrics.scrollTop = 275;
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
|
||||
rerender(
|
||||
<AutoScrollHarness
|
||||
isWorking={false}
|
||||
hasUnreadDivider={false}
|
||||
enabled={false}
|
||||
isVisible={false}
|
||||
sessionId={sessionId}
|
||||
metrics={metrics}
|
||||
/>,
|
||||
);
|
||||
unmount();
|
||||
|
||||
expect(transcriptScrollTopWrites).toHaveBeenLastCalledWith(sessionId, 275);
|
||||
|
||||
metrics.scrollTop = 0;
|
||||
render(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptMessage("restored-message")]}
|
||||
metrics={metrics}
|
||||
sessionId={sessionId}
|
||||
enabled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(metrics.scrollTop).toBe(275);
|
||||
});
|
||||
|
||||
it("does not write hidden appended content and catches up after activation", () => {
|
||||
const frames: Array<FrameRequestCallback> = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
});
|
||||
const metrics = { scrollHeight: 1000, scrollTop: 600, clientHeight: 400 };
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<AutoScrollHarness isWorking={false} hasUnreadDivider={false} metrics={metrics} />,
|
||||
);
|
||||
const scrollContainer = screen.getByTestId(AUTO_SCROLL_CONTAINER_TEST_ID);
|
||||
metrics.scrollTop = 600;
|
||||
|
||||
rerender(
|
||||
<AutoScrollHarness
|
||||
isWorking={false}
|
||||
hasUnreadDivider={false}
|
||||
messages={[...TEST_MESSAGES, {} as Message]}
|
||||
isVisible={false}
|
||||
metrics={metrics}
|
||||
/>,
|
||||
);
|
||||
expect(scrollContainer.scrollTop).toBe(600);
|
||||
|
||||
rerender(
|
||||
<AutoScrollHarness
|
||||
isWorking={false}
|
||||
hasUnreadDivider={false}
|
||||
messages={[...TEST_MESSAGES, {} as Message]}
|
||||
isVisible
|
||||
metrics={metrics}
|
||||
/>,
|
||||
);
|
||||
metrics.scrollTop = 600;
|
||||
act(() => {
|
||||
for (let frame = frames.shift(); frame; frame = frames.shift()) frame(0);
|
||||
});
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(2_147_483_647);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "disabled", enabled: false },
|
||||
{ label: "manually away while enabled", enabled: true },
|
||||
])("preserves the $label reader position on activation", ({ enabled }) => {
|
||||
const frames: Array<FrameRequestCallback> = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
});
|
||||
const metrics = { scrollHeight: 1000, scrollTop: 300, clientHeight: 400 };
|
||||
try {
|
||||
const markRef: { current?: () => void } = {};
|
||||
const { rerender } = render(
|
||||
<AutoScrollHarness
|
||||
isWorking={false}
|
||||
hasUnreadDivider={false}
|
||||
enabled={enabled}
|
||||
markRef={markRef}
|
||||
metrics={metrics}
|
||||
/>,
|
||||
);
|
||||
const scrollContainer = screen.getByTestId(AUTO_SCROLL_CONTAINER_TEST_ID);
|
||||
metrics.scrollTop = 300;
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
if (!enabled) markRef.current?.();
|
||||
|
||||
rerender(
|
||||
<AutoScrollHarness
|
||||
isWorking={false}
|
||||
hasUnreadDivider={false}
|
||||
enabled={enabled}
|
||||
markRef={markRef}
|
||||
isVisible={false}
|
||||
messages={[...TEST_MESSAGES, {} as Message]}
|
||||
metrics={metrics}
|
||||
/>,
|
||||
);
|
||||
metrics.scrollTop = 300;
|
||||
rerender(
|
||||
<AutoScrollHarness
|
||||
isWorking={false}
|
||||
hasUnreadDivider={false}
|
||||
enabled={enabled}
|
||||
markRef={markRef}
|
||||
isVisible
|
||||
messages={[...TEST_MESSAGES, {} as Message]}
|
||||
metrics={metrics}
|
||||
/>,
|
||||
);
|
||||
metrics.scrollTop = 300;
|
||||
act(() => {
|
||||
for (const frame of frames.splice(0)) frame(0);
|
||||
});
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(300);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("never re-scrolls once the reader has started scrolling, even if the anchored bar's height changes afterward", () => {
|
||||
const { rerender, container } = render(<Harness itemCount={2} anchoredBarOffsetPx={0} />);
|
||||
const scrollContainer = container.querySelector<HTMLElement>(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { OLDER_PAGE_LIMIT, useLazyLoadMessages } from "@/hooks/use-lazy-load-mes
|
||||
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 {
|
||||
@@ -28,6 +29,8 @@ import {
|
||||
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
|
||||
@@ -99,6 +102,10 @@ type NativeMessageListScrollParams = {
|
||||
type ScrollToDividerOptions = {
|
||||
onDividerScroll?: () => void;
|
||||
scrollLayoutKey?: string;
|
||||
enabled?: boolean;
|
||||
sessionId?: string | null;
|
||||
isProgrammaticScrollLocked?: () => boolean;
|
||||
isVisible?: boolean;
|
||||
};
|
||||
|
||||
function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
|
||||
@@ -123,21 +130,27 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
|
||||
scrollLayoutKey,
|
||||
isVisible,
|
||||
} = params;
|
||||
const { handleScrollToMessage, sentinelRef, markNotNearBottom, retryLoadMore, showRecovery } =
|
||||
useNativeScrollManagement({
|
||||
scrollRef,
|
||||
items,
|
||||
messages,
|
||||
isWorking,
|
||||
sessionId,
|
||||
enabled,
|
||||
hasUnreadDivider: Boolean(dividerBeforeItemKey),
|
||||
messagesLoading,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
isVisible,
|
||||
});
|
||||
const {
|
||||
handleScrollToMessage,
|
||||
sentinelRef,
|
||||
markNotNearBottom,
|
||||
isProgrammaticScrollLocked,
|
||||
retryLoadMore,
|
||||
showRecovery,
|
||||
} = useNativeScrollManagement({
|
||||
scrollRef,
|
||||
items,
|
||||
messages,
|
||||
isWorking,
|
||||
sessionId,
|
||||
enabled,
|
||||
hasUnreadDivider: Boolean(dividerBeforeItemKey),
|
||||
messagesLoading,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
isVisible,
|
||||
});
|
||||
const anchoredBarOffsetPx = anchoredBarScrollOffsetPx(anchoredBarHeight);
|
||||
useEffect(() => {
|
||||
scrollRef.current?.style.setProperty("--anchored-bar-h", `${anchoredBarOffsetPx}px`);
|
||||
@@ -145,6 +158,10 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
|
||||
useScrollToDividerOrBottom(scrollRef, items.length, dividerBeforeItemKey, anchoredBarOffsetPx, {
|
||||
onDividerScroll: markNotNearBottom,
|
||||
scrollLayoutKey,
|
||||
enabled,
|
||||
sessionId,
|
||||
isProgrammaticScrollLocked,
|
||||
isVisible,
|
||||
});
|
||||
useImperativeHandle(ref, () => ({ scrollToMessage: handleScrollToMessage }), [
|
||||
handleScrollToMessage,
|
||||
@@ -272,7 +289,7 @@ type NativeMessageListBodyProps = {
|
||||
* 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).
|
||||
* 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
|
||||
@@ -282,10 +299,10 @@ type NativeMessageListBodyProps = {
|
||||
* 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. 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.
|
||||
* 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.
|
||||
*/
|
||||
export function useScrollToDividerOrBottom(
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>,
|
||||
@@ -294,7 +311,115 @@ export function useScrollToDividerOrBottom(
|
||||
anchoredBarOffsetPx: number,
|
||||
options: ScrollToDividerOptions = {},
|
||||
) {
|
||||
const { onDividerScroll, scrollLayoutKey = "" } = options;
|
||||
const {
|
||||
onDividerScroll,
|
||||
scrollLayoutKey = "",
|
||||
enabled = true,
|
||||
sessionId = null,
|
||||
isProgrammaticScrollLocked = () => false,
|
||||
isVisible = true,
|
||||
} = 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 || 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,
|
||||
scrollRef,
|
||||
]);
|
||||
}
|
||||
|
||||
function useDividerUserScrolling(scrollRef: React.RefObject<HTMLDivElement | null>) {
|
||||
const isUserScrollingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -311,57 +436,7 @@ export function useScrollToDividerOrBottom(
|
||||
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) {
|
||||
// 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;
|
||||
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, anchoredBarOffsetPx, onDividerScroll, scrollLayoutKey]);
|
||||
return isUserScrollingRef;
|
||||
}
|
||||
|
||||
/** Sentinel, status/footer, and transcript rows — everything below the
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
hasTranscriptProgressedPastView,
|
||||
@@ -6,8 +7,18 @@ import {
|
||||
resolveNativeInitialScrollTop,
|
||||
isPrependUpdate,
|
||||
createFrameCoalescer,
|
||||
useActivationPending,
|
||||
scheduleAfterPanelRestore,
|
||||
} from "./transcript-auto-scroll";
|
||||
|
||||
function runPendingFrame(pendingOrder: number[], frameCallbacks: Map<number, () => void>) {
|
||||
const id = pendingOrder.shift();
|
||||
if (id === undefined) return;
|
||||
const callback = frameCallbacks.get(id);
|
||||
frameCallbacks.delete(id);
|
||||
callback?.();
|
||||
}
|
||||
|
||||
describe("isPrependUpdate", () => {
|
||||
it("is a prepend when item count grows and the first item's key changed", () => {
|
||||
expect(
|
||||
@@ -323,14 +334,6 @@ describe("createFrameCoalescer", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function runPendingFrame() {
|
||||
const id = pendingOrder.shift();
|
||||
if (id === undefined) return;
|
||||
const cb = frameCallbacks.get(id);
|
||||
frameCallbacks.delete(id);
|
||||
cb?.();
|
||||
}
|
||||
|
||||
it("coalesces multiple schedule() calls within a frame into a single run() call", () => {
|
||||
const run = vi.fn();
|
||||
const coalescer = createFrameCoalescer(run);
|
||||
@@ -338,7 +341,7 @@ describe("createFrameCoalescer", () => {
|
||||
coalescer.schedule();
|
||||
coalescer.schedule();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
runPendingFrame();
|
||||
runPendingFrame(pendingOrder, frameCallbacks);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -346,9 +349,9 @@ describe("createFrameCoalescer", () => {
|
||||
const run = vi.fn();
|
||||
const coalescer = createFrameCoalescer(run);
|
||||
coalescer.schedule();
|
||||
runPendingFrame();
|
||||
runPendingFrame(pendingOrder, frameCallbacks);
|
||||
coalescer.schedule();
|
||||
runPendingFrame();
|
||||
runPendingFrame(pendingOrder, frameCallbacks);
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -358,7 +361,7 @@ describe("createFrameCoalescer", () => {
|
||||
coalescer.schedule();
|
||||
coalescer.flush();
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
runPendingFrame();
|
||||
runPendingFrame(pendingOrder, frameCallbacks);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -375,7 +378,87 @@ describe("createFrameCoalescer", () => {
|
||||
coalescer.schedule();
|
||||
coalescer.flush();
|
||||
coalescer.schedule();
|
||||
runPendingFrame();
|
||||
runPendingFrame(pendingOrder, frameCallbacks);
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scheduleAfterPanelRestore", () => {
|
||||
let frameCallbacks: Map<number, () => void>;
|
||||
let nextFrameId: number;
|
||||
let pendingOrder: number[];
|
||||
|
||||
beforeEach(() => {
|
||||
frameCallbacks = new Map();
|
||||
pendingOrder = [];
|
||||
nextFrameId = 1;
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: () => void) => {
|
||||
const id = nextFrameId++;
|
||||
frameCallbacks.set(id, cb);
|
||||
pendingOrder.push(id);
|
||||
return id;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
|
||||
frameCallbacks.delete(id);
|
||||
pendingOrder = pendingOrder.filter((pendingId) => pendingId !== id);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("runs only after the panel restore frame and a follow-up frame", () => {
|
||||
const run = vi.fn();
|
||||
scheduleAfterPanelRestore(run);
|
||||
|
||||
runPendingFrame(pendingOrder, frameCallbacks);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
|
||||
runPendingFrame(pendingOrder, frameCallbacks);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels both activation frames", () => {
|
||||
const run = vi.fn();
|
||||
const cancel = scheduleAfterPanelRestore(run);
|
||||
runPendingFrame(pendingOrder, frameCallbacks);
|
||||
cancel();
|
||||
|
||||
runPendingFrame(pendingOrder, frameCallbacks);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels before the first activation frame", () => {
|
||||
const run = vi.fn();
|
||||
const cancel = scheduleAfterPanelRestore(run);
|
||||
cancel();
|
||||
|
||||
while (pendingOrder.length > 0) runPendingFrame(pendingOrder, frameCallbacks);
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useActivationPending", () => {
|
||||
it("tracks hidden-to-visible transitions until the activation owner consumes them", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ isVisible }: { isVisible: boolean }) => useActivationPending(isVisible),
|
||||
{ initialProps: { isVisible: false } },
|
||||
);
|
||||
|
||||
expect(result.current.activationPendingRef.current).toBe(false);
|
||||
|
||||
rerender({ isVisible: true });
|
||||
|
||||
expect(result.current.isVisibleRef.current).toBe(true);
|
||||
expect(result.current.activationPendingRef.current).toBe(true);
|
||||
|
||||
result.current.activationPendingRef.current = false;
|
||||
rerender({ isVisible: false });
|
||||
expect(result.current.activationPendingRef.current).toBe(false);
|
||||
|
||||
rerender({ isVisible: true });
|
||||
expect(result.current.activationPendingRef.current).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Pure decision logic for the native transcript auto-scroll toggle. Kept
|
||||
* side-effect free for direct unit testing — the renderer owns all DOM calls
|
||||
* and just consults these functions.
|
||||
* Pure decision logic for the native transcript auto-scroll toggle, plus small
|
||||
* visibility lifecycle primitives. The renderer owns all DOM calls and just
|
||||
* consults these functions or refs.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -155,3 +157,46 @@ export function createFrameCoalescer(run: () => void): {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks a hidden-to-visible transition without consuming it until the caller
|
||||
* completes its activation work. This keeps every activation owner aligned on
|
||||
* the same visibility and pending-transition contract.
|
||||
*/
|
||||
export function useActivationPending(isVisible: boolean) {
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
const previousVisibleRef = useRef(isVisible);
|
||||
const activationPendingRef = useRef(false);
|
||||
isVisibleRef.current = isVisible;
|
||||
|
||||
useEffect(() => {
|
||||
const becameVisible = !previousVisibleRef.current && isVisible;
|
||||
previousVisibleRef.current = isVisible;
|
||||
activationPendingRef.current = isVisible
|
||||
? becameVisible || activationPendingRef.current
|
||||
: false;
|
||||
}, [isVisible]);
|
||||
|
||||
return { isVisibleRef, activationPendingRef };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules work after the generic Dockview panel restore's current one-frame
|
||||
* write. The generic restore also guards stale offsets, so this is a bounded
|
||||
* coordination window rather than the sole correctness mechanism.
|
||||
*/
|
||||
export function scheduleAfterPanelRestore(run: () => void): () => void {
|
||||
let cancelled = false;
|
||||
let secondFrame: number | null = null;
|
||||
const firstFrame = requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
secondFrame = requestAnimationFrame(() => {
|
||||
if (!cancelled) run();
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(firstFrame);
|
||||
if (secondFrame !== null) cancelAnimationFrame(secondFrame);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionPanelContent } from "@kandev/ui/pannel-session";
|
||||
|
||||
let resizeCallback: ResizeObserverCallback | undefined;
|
||||
const frameCallbacks: FrameRequestCallback[] = [];
|
||||
|
||||
class ControllableResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
takeRecords(): ResizeObserverEntry[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function emitResize(width: number, height: number) {
|
||||
if (!resizeCallback) throw new Error("ResizeObserver callback did not register");
|
||||
const entry = {
|
||||
contentRect: { width, height },
|
||||
} as ResizeObserverEntry;
|
||||
act(() => resizeCallback?.([entry], {} as ResizeObserver));
|
||||
}
|
||||
|
||||
function flushFrame() {
|
||||
const callback = frameCallbacks.shift();
|
||||
if (!callback) throw new Error("requestAnimationFrame callback did not register");
|
||||
act(() => callback(0));
|
||||
}
|
||||
|
||||
function renderScrollContainer() {
|
||||
const result = render(
|
||||
<SessionPanelContent>
|
||||
<div />
|
||||
</SessionPanelContent>,
|
||||
);
|
||||
const scrollContainer = result.container.querySelector<HTMLElement>(".overflow-y-auto");
|
||||
if (!scrollContainer) throw new Error("SessionPanelContent scroll container did not render");
|
||||
return { ...result, scrollContainer };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resizeCallback = undefined;
|
||||
frameCallbacks.length = 0;
|
||||
vi.stubGlobal("ResizeObserver", ControllableResizeObserver);
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback);
|
||||
return frameCallbacks.length;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resizeCallback = undefined;
|
||||
frameCallbacks.length = 0;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("SessionPanelContent scroll restoration", () => {
|
||||
it("restores the saved offset when the browser leaves scrollTop unchanged", () => {
|
||||
const { scrollContainer } = renderScrollContainer();
|
||||
scrollContainer.scrollTop = 184;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
|
||||
emitResize(0, 0);
|
||||
scrollContainer.scrollTop = 0;
|
||||
emitResize(100, 100);
|
||||
flushFrame();
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(184);
|
||||
});
|
||||
|
||||
it("does not overwrite a newer app or user scroll before the restore frame", () => {
|
||||
const { scrollContainer } = renderScrollContainer();
|
||||
scrollContainer.scrollTop = 184;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
|
||||
emitResize(0, 0);
|
||||
scrollContainer.scrollTop = 0;
|
||||
emitResize(100, 100);
|
||||
|
||||
// Model a newer synchronous scroll owner. It deliberately does not emit
|
||||
// another event, so the stale-restore guard is the behavior under test.
|
||||
scrollContainer.scrollTop = 263;
|
||||
flushFrame();
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(263);
|
||||
});
|
||||
});
|
||||
@@ -517,6 +517,10 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
groupedItems,
|
||||
isInitialMessagesLoading,
|
||||
);
|
||||
// Kanban previews intentionally pass `isVisible=false` so they do not
|
||||
// advance the read cursor, but their transcript is rendered in a visible
|
||||
// non-Dockview host. Keep read visibility separate from scroll geometry.
|
||||
const transcriptIsVisible = panelId === null || isVisible;
|
||||
const isDockviewJumpLoading = useScrollTargetConsumption({
|
||||
resolvedSessionId,
|
||||
isVisible,
|
||||
@@ -617,6 +621,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
ref={panelRef}
|
||||
data-testid="session-chat"
|
||||
data-panel-kind="session"
|
||||
data-session-id={resolvedSessionId ?? undefined}
|
||||
tabIndex={-1}
|
||||
onMouseDown={handlePanelMouseDown}
|
||||
className="outline-none"
|
||||
@@ -651,7 +656,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
firstMessageId={firstMessageId}
|
||||
onFirstMessageHiddenChange={setIsFirstMessageHidden}
|
||||
anchoredBarHeight={showAnchoredBar && lastPromptMessage ? anchoredBarHeight : 0}
|
||||
isVisible={isVisible}
|
||||
isVisible={transcriptIsVisible}
|
||||
stickyPromptBar={
|
||||
showAnchoredBar && lastPromptMessage ? (
|
||||
<AnchoredLastPromptBar
|
||||
|
||||
@@ -6,6 +6,25 @@ import { waitForSessionDone } from "../../helpers/session";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { dwell } from "../../helpers/causal-waits";
|
||||
import { waitForStableActiveSession } from "../../helpers/session-store";
|
||||
|
||||
type E2EMessageStoreWindow = Window & {
|
||||
__KANDEV_E2E_STORE__?: {
|
||||
getState: () => {
|
||||
messages: { bySession: Record<string, Array<{ content: string }>> };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const INACTIVE_SESSION_MARKER = "INACTIVE-AUTO-SCROLL-CATCH-UP-7M2P";
|
||||
|
||||
function overflowScript(prefix: string, messageCount = 30): string {
|
||||
return Array.from(
|
||||
{ length: messageCount },
|
||||
(_, i) =>
|
||||
`e2e:message("${prefix} ${i + 1} - lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua")`,
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a task whose mock-agent script emits enough distinct messages to
|
||||
@@ -18,11 +37,7 @@ async function seedOverflowingTask(
|
||||
title: string,
|
||||
messageCount = 30,
|
||||
): Promise<SessionPage> {
|
||||
const script = Array.from(
|
||||
{ length: messageCount },
|
||||
(_, i) =>
|
||||
`e2e:message("Filler message ${i + 1} - lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua")`,
|
||||
).join("\n");
|
||||
const script = overflowScript("Filler message", messageCount);
|
||||
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
@@ -53,6 +68,68 @@ async function seedOverflowingTask(
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Seeds two real sessions with overflowing persisted histories. */
|
||||
async function seedTwoSessionOverflowingTask(
|
||||
testPage: Page,
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
): Promise<{
|
||||
session: SessionPage;
|
||||
taskId: string;
|
||||
firstSessionId: string;
|
||||
secondSessionId: string;
|
||||
}> {
|
||||
const firstScript = overflowScript("First session history");
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Inactive session transcript auto-scroll",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: firstScript,
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
|
||||
|
||||
await waitForSessionDone(
|
||||
apiClient,
|
||||
task.id,
|
||||
task.session_id,
|
||||
"first overflowing session should finish before opening the task",
|
||||
);
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await session.openNewSessionDialog();
|
||||
await session.newSessionPromptInput().fill(overflowScript("Second session history"));
|
||||
await session.newSessionStartButton().click();
|
||||
await expect(session.newSessionDialog()).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
let secondSessionId: string | null = null;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
const second = sessions.find((candidate) => candidate.id !== task.session_id);
|
||||
if (!second) return false;
|
||||
secondSessionId = second.id;
|
||||
return ["COMPLETED", "WAITING_FOR_INPUT"].includes(second.state);
|
||||
},
|
||||
{ timeout: 120_000, message: "second overflowing session should finish" },
|
||||
)
|
||||
.toBe(true);
|
||||
if (!secondSessionId) throw new Error("second session was not created");
|
||||
|
||||
return {
|
||||
session,
|
||||
taskId: task.id,
|
||||
firstSessionId: task.session_id,
|
||||
secondSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
function chatList(testPage: Page) {
|
||||
return testPage.locator(".chat-message-list:visible").first();
|
||||
}
|
||||
@@ -69,6 +146,137 @@ async function waitForOverflow(testPage: Page) {
|
||||
}
|
||||
|
||||
test.describe("Transcript auto-scroll toggle", () => {
|
||||
test("inactive session transcript reopens at the bottom after refresh and hidden updates", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const { session, firstSessionId, secondSessionId } = await seedTwoSessionOverflowingTask(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
);
|
||||
await expect(session.sessionTabBySessionId(firstSessionId)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(session.sessionTabBySessionId(secondSessionId)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Leave the first transcript inactive before a refresh. This reproduces
|
||||
// Dockview's persistent portal with populated content and zero geometry.
|
||||
await session.sessionTabBySessionId(secondSessionId).click();
|
||||
await waitForStableActiveSession(testPage, secondSessionId);
|
||||
await testPage.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
const refreshedSession = new SessionPage(testPage);
|
||||
await refreshedSession.waitForLoad();
|
||||
await expect(refreshedSession.sessionTabBySessionId(firstSessionId)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(refreshedSession.sessionTabBySessionId(secondSessionId)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await refreshedSession.sessionTabBySessionId(secondSessionId).click();
|
||||
await waitForStableActiveSession(testPage, secondSessionId);
|
||||
|
||||
// Activating the previously inactive, enabled transcript must place it at
|
||||
// the newest message after SessionPanelContent restores its old offset.
|
||||
await refreshedSession.sessionTabBySessionId(firstSessionId).click();
|
||||
await waitForStableActiveSession(testPage, firstSessionId);
|
||||
const firstChat = testPage.locator(
|
||||
`[data-testid='session-chat'][data-session-id=${JSON.stringify(firstSessionId)}]`,
|
||||
);
|
||||
const firstList = firstChat.locator(".chat-message-list");
|
||||
await expect
|
||||
.poll(async () => firstList.evaluate((el) => el.scrollHeight - el.clientHeight), {
|
||||
timeout: 15_000,
|
||||
message: "first inactive transcript should overflow after activation",
|
||||
})
|
||||
.toBeGreaterThan(200);
|
||||
await expect
|
||||
.poll(
|
||||
async () => firstList.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight),
|
||||
{
|
||||
timeout: 15_000,
|
||||
message: "activating an inactive enabled transcript should restore the bottom",
|
||||
},
|
||||
)
|
||||
.toBeLessThan(10);
|
||||
|
||||
// Deliver new content while that bottom-following transcript is hidden.
|
||||
// Wait for the store event before switching back so the assertion proves
|
||||
// activation reconciliation, not a delayed initial fetch.
|
||||
await refreshedSession.sessionTabBySessionId(secondSessionId).click();
|
||||
await waitForStableActiveSession(testPage, secondSessionId);
|
||||
await apiClient.seedSessionMessage(firstSessionId, {
|
||||
type: "message",
|
||||
content: INACTIVE_SESSION_MARKER,
|
||||
authorType: "agent",
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
testPage.evaluate(
|
||||
({ sid, marker }) => {
|
||||
const store = (window as E2EMessageStoreWindow).__KANDEV_E2E_STORE__;
|
||||
return store
|
||||
?.getState()
|
||||
.messages.bySession[sid]?.some((message) => message.content === marker);
|
||||
},
|
||||
{ sid: firstSessionId, marker: INACTIVE_SESSION_MARKER },
|
||||
),
|
||||
{
|
||||
timeout: 15_000,
|
||||
message: "hidden message should arrive in the inactive transcript cache",
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await refreshedSession.sessionTabBySessionId(firstSessionId).click();
|
||||
await waitForStableActiveSession(testPage, firstSessionId);
|
||||
await expect(
|
||||
refreshedSession.activeChat().getByText(INACTIVE_SESSION_MARKER, { exact: true }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect
|
||||
.poll(
|
||||
async () => firstList.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight),
|
||||
{
|
||||
timeout: 15_000,
|
||||
message: "hidden content should catch up an enabled transcript on activation",
|
||||
},
|
||||
)
|
||||
.toBeLessThan(10);
|
||||
|
||||
// A reader-owned position must survive the same tab round trip. Dispatch
|
||||
// a native scroll event so both the transcript coordinator and the
|
||||
// generic panel restorer capture the exact reader position.
|
||||
const targetScrollTop = await firstList.evaluate((el) => {
|
||||
const target = Math.floor((el.scrollHeight - el.clientHeight) / 2);
|
||||
el.scrollTop = target;
|
||||
el.dispatchEvent(new Event("scroll"));
|
||||
return el.scrollTop;
|
||||
});
|
||||
expect(targetScrollTop).toBeGreaterThan(100);
|
||||
const toggle = firstChat.getByTestId("auto-scroll-toggle-button");
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await refreshedSession.sessionTabBySessionId(secondSessionId).click();
|
||||
await waitForStableActiveSession(testPage, secondSessionId);
|
||||
await refreshedSession.sessionTabBySessionId(firstSessionId).click();
|
||||
await waitForStableActiveSession(testPage, firstSessionId);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
firstList.evaluate((el, expected) => Math.abs(el.scrollTop - expected), targetScrollTop),
|
||||
{
|
||||
timeout: 15_000,
|
||||
message: "disabled reader position should survive session activation",
|
||||
},
|
||||
)
|
||||
.toBeLessThanOrEqual(20);
|
||||
await expect(toggle).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
test("can be hidden without changing the default auto-scroll state", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
---
|
||||
created: 2026-09-02
|
||||
status: done
|
||||
requirements:
|
||||
- REQ-UI-TRANSCRIPT-AUTO-SCROLL-001
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/transcript-auto-scroll.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Restore Session Transcript Scroll on Activation
|
||||
|
||||
## Overview
|
||||
|
||||
Make transcript placement wait for a persistent desktop session panel to be
|
||||
visible and measurable. Preserve bottom-follow intent through inactive tabs,
|
||||
while retaining reader-owned positions when auto-scroll is disabled or the
|
||||
reader has moved away from the newest message.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Defer one-time transcript placement until an inactive Dockview panel becomes
|
||||
visible.
|
||||
- Reconcile bottom-follow intent after `SessionPanelContent` restores its saved
|
||||
absolute offset.
|
||||
- Suppress bottom writes and near-bottom geometry updates while a transcript is
|
||||
hidden.
|
||||
- Preserve unread-divider, explicit navigation, layout-restore, pagination,
|
||||
disabled-position, and manual-reader ownership.
|
||||
- Add focused hook regressions and desktop browser coverage for refresh, session
|
||||
switching, hidden message delivery, and disabled-position preservation.
|
||||
- Run the existing mobile auto-scroll suite against the shared scroll logic.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Changing session tabs, transcript controls, labels, layout, or persistence.
|
||||
- Replacing Dockview persistent portals or the generic panel scroll restorer.
|
||||
- Changing unread-cursor semantics, message pagination, or smooth-navigation
|
||||
behavior.
|
||||
- Adding a mobile persistent-panel lifecycle that the product does not use.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
Dockview panel content is rendered through persistent portals. After a refresh,
|
||||
an inactive session transcript can mount with its full message list while its
|
||||
scroll container has zero geometry. Both `useInitialScrollPosition` and
|
||||
`useScrollToDividerOrBottom` mark their one-time work complete in that state, so
|
||||
activating the tab does not retry placement.
|
||||
|
||||
Live updates have a second failure path. `useAutoScroll` writes a bottom offset
|
||||
while the inactive container is not measurable. When the tab becomes visible,
|
||||
`SessionPanelContent` restores the last visible absolute `scrollTop`, which no
|
||||
longer represents the bottom after hidden content growth.
|
||||
|
||||
The reproduced inactive transcript had 88 rows and a 3,712 px bottom gap after
|
||||
refresh and activation. A transcript that received hidden agent output reopened
|
||||
603 px above the bottom even though auto-scroll remained enabled.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Visibility-aware placement
|
||||
|
||||
- Pass the existing `isVisible` signal through the native initial-placement,
|
||||
divider-placement, and auto-scroll hooks.
|
||||
- Do not consume an initial-placement latch while `isVisible` is false.
|
||||
- When an inactive panel becomes visible, defer placement by two animation
|
||||
frames so the existing one-frame `SessionPanelContent` restore finishes
|
||||
first.
|
||||
- Keep `SessionPanelContent` generic, but apply its queued absolute-offset write
|
||||
only when the panel's `scrollTop` is unchanged since scheduling. A newer
|
||||
transcript or user scroll therefore remains the active owner.
|
||||
- Re-check the live visibility and ownership guards before the deferred write,
|
||||
and cancel the scheduled work if the panel becomes inactive again.
|
||||
|
||||
### Bottom-follow intent
|
||||
|
||||
- Keep the existing near-bottom ref as the transient bottom-follow intent.
|
||||
- Do not write bottom offsets or derive near-bottom state from hidden geometry.
|
||||
- On activation, place an enabled bottom-following transcript at the native
|
||||
maximum offset through `scrollNativeToBottom`.
|
||||
- Leave the restored absolute offset untouched when auto-scroll is disabled or
|
||||
the reader moved away from the bottom before hiding.
|
||||
|
||||
### Competing scroll owners
|
||||
|
||||
- Keep pending Dockview layout restoration and explicit message navigation
|
||||
ahead of automatic placement.
|
||||
- Keep the visit-scoped unread divider ahead of bottom placement and mark that
|
||||
placement as away from the bottom.
|
||||
- Run the existing pagination recheck after visibility restoration without
|
||||
allowing it to consume or override the placement decision.
|
||||
|
||||
## Tests
|
||||
|
||||
| Acceptance criterion | Test evidence |
|
||||
| --- | --- |
|
||||
| `AC-UI-TRANSCRIPT-AUTO-SCROLL-001.8` | A native-scroll hook regression mounts an overflowing transcript hidden, proves no initial latch/write is consumed, then activates it after a simulated panel restore and requires bottom placement. A desktop Playwright scenario refreshes a task with two long session histories, activates the initially inactive tab, and requires a near-zero bottom gap. |
|
||||
| `AC-UI-TRANSCRIPT-AUTO-SCROLL-001.9` | A hook regression appends while hidden and requires one guarded catch-up on activation. The desktop browser scenario seeds a live message into an inactive session, reactivates it, and requires the marker and transcript bottom to be visible. |
|
||||
| `AC-UI-TRANSCRIPT-AUTO-SCROLL-001.10` | Hook tests cover disabled and manually-away states. A desktop browser scenario disables auto-scroll, switches tabs, and requires the prior absolute position after returning. |
|
||||
|
||||
## E2E tests
|
||||
|
||||
- Desktop: extend `apps/web/e2e/tests/chat/auto-scroll-toggle.spec.ts` with a
|
||||
deterministic two-session fixture. Use the existing E2E session/message
|
||||
harness so both histories overflow before refresh and an inactive session can
|
||||
receive a live `message.added` event without a timing delay.
|
||||
- Mobile: no new scenario models the desktop-only persistent hidden portal.
|
||||
Run the complete `mobile-auto-scroll-toggle.spec.ts` suite to prove the shared
|
||||
native coordinator retains visible initial placement, enabled following,
|
||||
disabled freezing, and touch control behavior.
|
||||
|
||||
## Mobile design contract
|
||||
|
||||
- **Desktop outcome:** switching to an enabled, bottom-following session tab
|
||||
shows its newest message after refresh and hidden updates.
|
||||
- **Mobile entry point:** the existing selected Chat panel under
|
||||
`MobileSessionsPicker`.
|
||||
- **Nearest shipped exemplar:** `SessionMobileLayout` renders one selected
|
||||
`TaskChatPanel`; the existing mobile auto-scroll Playwright suite exercises
|
||||
that shared native transcript.
|
||||
- **Hierarchy and presentation:** no controls or composition change. The
|
||||
transcript remains the only vertical scroll owner.
|
||||
- **Shared state and logic:** desktop and mobile keep the same auto-scroll
|
||||
preference, near-bottom intent, and bottom helper. Only desktop supplies an
|
||||
inactive `isVisible` lifecycle.
|
||||
- **Mobile coverage decision:** the defect state is unavailable on mobile
|
||||
because inactive session panels are not kept mounted. The existing
|
||||
mobile-specific Playwright suite is the narrow parity proof for the shared
|
||||
logic changed by this repair.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Reconcile transcript scroll on session activation](task-01-reconcile-transcript-scroll-on-activation.md) (done)
|
||||
|
||||
## Verification strategy
|
||||
|
||||
- TDD regressions were added for hidden activation, hidden updates, competing
|
||||
scroll owners, bounded-frame cancellation, and the activation-scoped
|
||||
unread-divider settling window before the implementation was completed.
|
||||
- A focused `SessionPanelContent` component test controls `ResizeObserver` and
|
||||
`requestAnimationFrame` to prove both guarded restore outcomes: unchanged
|
||||
`scrollTop` is restored, while a newer scroll remains authoritative.
|
||||
- Focused native transcript unit tests, TypeScript typecheck, and targeted
|
||||
ESLint passed.
|
||||
- The desktop multi-session scenario and the existing mobile auto-scroll suite
|
||||
passed with retries disabled.
|
||||
- Specification tests, full specification lint, and diff checks passed.
|
||||
|
||||
## Risks
|
||||
|
||||
- A visibility correction can race the generic panel restore and be overwritten
|
||||
if it runs too early. The two-frame schedule and stale-offset guard bound this
|
||||
race without making the generic panel component chat-aware.
|
||||
- A deferred correction can override an explicit jump, unread divider, or
|
||||
reader-owned position if ownership is not re-checked at execution time.
|
||||
- Hidden message updates can incorrectly turn a manual reading position back
|
||||
into bottom-follow intent if geometry is sampled while inactive.
|
||||
- Animation-frame tests can pass in jsdom while missing Dockview's detached DOM;
|
||||
the real two-session browser regression is required evidence.
|
||||
|
||||
## Decisions
|
||||
|
||||
- No ADR is required. The repair preserves the existing Dockview portal,
|
||||
transcript scroll-owner, and persisted-setting boundaries documented by the
|
||||
current UI system design.
|
||||
- No public documentation change is required because the fix restores existing
|
||||
user-facing behavior and adds no control, setting, or workflow.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented visibility-aware transcript placement, hidden-update catch-up,
|
||||
reader-position preservation, and the cooperative generic panel restore guard.
|
||||
The fixup pass centralized the shared activation-transition lifecycle, retained
|
||||
the last visible disabled-reader offset through hidden cleanup, and covered
|
||||
immediate activation-frame cancellation. The divider settling deadline now
|
||||
starts at first visibility and resets on each hidden-to-visible activation.
|
||||
Focused unit tests, the direct shared-panel restore regression, typecheck,
|
||||
targeted ESLint, desktop and mobile Playwright coverage, specification lint,
|
||||
and diff checks all passed.
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
---
|
||||
id: "01-reconcile-transcript-scroll-on-activation"
|
||||
title: "Reconcile transcript scroll on session activation"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-TRANSCRIPT-AUTO-SCROLL-001
|
||||
acceptance_criteria:
|
||||
- AC-UI-TRANSCRIPT-AUTO-SCROLL-001.8
|
||||
- AC-UI-TRANSCRIPT-AUTO-SCROLL-001.9
|
||||
- AC-UI-TRANSCRIPT-AUTO-SCROLL-001.10
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/transcript-auto-scroll.md
|
||||
---
|
||||
|
||||
# Task 01: Reconcile Transcript Scroll on Session Activation
|
||||
|
||||
## Summary
|
||||
|
||||
Make native transcript placement visibility-aware and restore bottom-follow
|
||||
intent after a persistent Dockview session panel becomes visible. Prove the
|
||||
repair with failing hook regressions and real two-session browser flows.
|
||||
|
||||
## Failing regressions first
|
||||
|
||||
Add focused cases to `message-list-native.test.tsx` before production changes:
|
||||
|
||||
1. Mount populated scroll management with `isVisible=false`, verify that no
|
||||
initial bottom write occurs, simulate the generic one-frame absolute-offset
|
||||
restore, activate the panel, and require the later placement to win.
|
||||
2. Append while hidden and require a bottom-following transcript to catch up
|
||||
only after activation.
|
||||
3. Require disabled and manually-away transcripts to keep their saved offsets
|
||||
through the same inactive-to-active transition.
|
||||
4. Require scheduled activation work to cancel when visibility changes again or
|
||||
when another scroll owner becomes active.
|
||||
5. Mount a hidden transcript, let the original settling interval elapse, then
|
||||
activate it with an unread divider and prove the activation-scoped
|
||||
reassertion window still gives the divider priority.
|
||||
6. Exercise `SessionPanelContent` with controllable `ResizeObserver` and
|
||||
`requestAnimationFrame` delivery to prove unchanged offsets restore and
|
||||
newer scroll positions win.
|
||||
|
||||
The current code consumes the hidden initial latch and writes against hidden
|
||||
geometry, so the first two cases must fail before the repair.
|
||||
|
||||
## In scope
|
||||
|
||||
- Thread `isVisible` through native initial placement, divider placement, and
|
||||
auto-follow behavior.
|
||||
- Add the bounded post-panel-restore activation reconciliation.
|
||||
- Preserve bottom-follow intent without new persisted state.
|
||||
- Preserve disabled, manual, unread-divider, explicit navigation, layout
|
||||
restoration, pagination, and prepend behavior.
|
||||
- Add desktop two-session Playwright regressions for refresh activation, hidden
|
||||
live content, and disabled-position preservation.
|
||||
- Run the existing mobile auto-scroll Playwright suite.
|
||||
- Add direct coverage for the shared `SessionPanelContent` restore guard.
|
||||
- Keep the generic panel restore from overwriting a newer transcript or user
|
||||
scroll scheduled in the same visibility transition.
|
||||
- Update plan/work-order results and statuses after implementation.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing `SessionPanelContent` into a chat-aware component.
|
||||
- Changing the Dockview portal lifecycle or session-tab reconciliation.
|
||||
- New copy, controls, settings, persisted fields, or responsive layout.
|
||||
- New mobile navigation or a mobile hidden-panel model.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- An enabled inactive transcript does not consume initial placement before it
|
||||
is visible and opens at the newest message after refresh.
|
||||
- Hidden live content catches up on activation only when bottom-follow intent
|
||||
remains active.
|
||||
- Disabled and manually-away positions survive a session-tab round trip.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- components/task/chat/message-list-native.test.tsx components/task/chat/transcript-auto-scroll.test.ts
|
||||
cd apps && pnpm --filter @kandev/web run typecheck
|
||||
cd apps/web && pnpm exec eslint components/task/chat/message-list-native-scroll.ts components/task/chat/message-list-native.tsx components/task/chat/message-list-native.test.tsx e2e/tests/chat/auto-scroll-toggle.spec.ts
|
||||
cd apps/web && pnpm e2e:run --host --project chromium tests/chat/auto-scroll-toggle.spec.ts -- --grep "inactive session transcript" --retries=0
|
||||
cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/chat/mobile-auto-scroll-toggle.spec.ts -- --retries=0
|
||||
python3 scripts/lint-spec-files.test.py
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check -- docs/specs docs/plans apps/web/components/task/chat apps/web/e2e/tests/chat
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/chat/message-list-native-scroll.ts`
|
||||
- `apps/web/components/task/chat/message-list-native.tsx`
|
||||
- `apps/web/components/task/chat/message-list-native.test.tsx`
|
||||
- `apps/web/components/task/chat/transcript-auto-scroll.ts`
|
||||
- `apps/web/components/task/chat/transcript-auto-scroll.test.ts`
|
||||
- `apps/web/components/task/task-chat-panel.tsx`
|
||||
- `apps/packages/ui/src/pannel-session.tsx`
|
||||
- `apps/web/components/task/session-panel-content.test.tsx`
|
||||
- `apps/web/e2e/tests/chat/auto-scroll-toggle.spec.ts`
|
||||
- `docs/specs/ui/requirements/transcript-auto-scroll.md`
|
||||
- `docs/specs/ui/system-design/transcript-auto-scroll.md`
|
||||
- `docs/plans/transcript-session-activation-scroll/plan.md`
|
||||
- `docs/plans/transcript-session-activation-scroll/task-01-reconcile-transcript-scroll-on-activation.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- Post-restore placement must run after the generic restore without adding an
|
||||
unbounded observer or timer. The generic restore skips its write when the
|
||||
element changed after scheduling, so the two-frame reconciliation can own the
|
||||
newer position.
|
||||
- The activation path must re-check live guards so stale animation frames cannot
|
||||
override a newer user or layout action.
|
||||
- Browser evidence must use a real inactive Dockview tab; merely hiding a test
|
||||
div does not reproduce the persistent-portal lifecycle.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `REQ-UI-TRANSCRIPT-AUTO-SCROLL-001` acceptance criteria 8 through 10.
|
||||
- `docs/specs/ui/system-design/transcript-auto-scroll.md`.
|
||||
- The existing native scroll-management hook harness.
|
||||
- The existing desktop and mobile transcript auto-scroll Playwright suites.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented visibility-aware native placement, two-frame activation
|
||||
reconciliation, hidden-update catch-up, reader-position preservation, the
|
||||
activation-scoped divider settling window, and the cooperative stale
|
||||
generic-restore guard.
|
||||
|
||||
The PR fixup pass added the shared `useActivationPending` lifecycle helper,
|
||||
preserved the last visible disabled-reader offset through immediate hide and
|
||||
unmount cleanup, and covered cancellation before the first activation frame.
|
||||
|
||||
Checks passed:
|
||||
|
||||
- Focused Vitest tests, including the hidden-activation settling-window
|
||||
regression and direct shared-panel restore coverage.
|
||||
- Web TypeScript typecheck.
|
||||
- Targeted ESLint for changed web files.
|
||||
- Desktop inactive-session Playwright regression, 1 passed.
|
||||
- Mobile auto-scroll Playwright suite, 5 passed.
|
||||
- Specification tests and full specification lint.
|
||||
- Scoped `git diff --check`.
|
||||
@@ -37,7 +37,7 @@ behavior (first visit, pagination, initial scroll) aren't otherwise obvious from
|
||||
- **AC-OFFICE-UNREAD-DIVIDER-001.4:** The divider's position is decided once per **visit** (a transition from hidden/inactive to the active, visible chat panel), not on every render: the hook captures the cursor's value at the instant the session becomes visible (before this visit's live-advance can touch it) and freezes it as the anchor. Re-rendering while still visible never moves the divider; leaving and returning (or switching sessions and back) captures a fresh anchor from whatever the cursor advanced to since.
|
||||
- **AC-OFFICE-UNREAD-DIVIDER-001.5:** A visit that opens at the current transcript tail is **divider-ineligible**: if the captured cursor equals the newest initially-rendered message, the hook discards the anchor for that visible visit. Messages subsequently rendered while the panel remains visible must not make a "New" divider appear. The live cursor still advances normally.
|
||||
- **AC-OFFICE-UNREAD-DIVIDER-001.6:** **First visit**: a session with no persisted cursor (`last_read_message_id` is empty) has no divider — there's nothing to distinguish as "already read." The first visit begins live-advancing the cursor as normal.
|
||||
- **AC-OFFICE-UNREAD-DIVIDER-001.7:** **Initial scroll**: on visit start, if the divider falls on a currently-loaded message that's outside the initial viewport, the message list scrolls to bring it into view (`block: "start"`) instead of the default scroll-to-bottom. This is a one-time positioning decision from the reader's perspective: the transcript's initial data can itself arrive in more than one wave (e.g. a WebSocket-delivered backfill continuing after the first paint), which can shift where the divider actually lands — so the correction re-asserts itself across such changes rather than freezing on a possibly-incomplete first render. It stops permanently the moment either of two things happens, whichever comes first: the reader scrolls (wheel, touch, or a key press), or a short (4s) settling window since mount elapses — bounding how long a live message arriving well after the visit has genuinely settled (with no interaction event to catch, e.g. a scrollbar drag) could otherwise re-trigger it. It does not fight a pending explicit layout scroll restoration (`pendingChatScrollTop`), which always wins when set.
|
||||
- **AC-OFFICE-UNREAD-DIVIDER-001.7:** **Initial scroll**: on visit start, if the divider falls on a currently-loaded message that's outside the initial viewport, the message list scrolls to bring it into view (`block: "start"`) instead of the default scroll-to-bottom. This is a one-time positioning decision from the reader's perspective: the transcript's initial data can itself arrive in more than one wave (e.g. a WebSocket-delivered backfill continuing after the first paint), which can shift where the divider actually lands — so the correction re-asserts itself across such changes rather than freezing on a possibly-incomplete first render. It stops permanently the moment either of two things happens, whichever comes first: the reader scrolls (wheel, touch, or a key press), or a short (4s) settling window since the visit becomes visible elapses. Time spent mounted in an inactive persistent portal does not consume this window. It does not fight a pending explicit layout scroll restoration (`pendingChatScrollTop`), which always wins when set.
|
||||
- **AC-OFFICE-UNREAD-DIVIDER-001.8:** **Pagination**: `findUnreadDividerItemId` looks for the divider's anchor message among the *currently loaded* render items. If the anchor is older than everything loaded so far (paginated out), the whole loaded window is treated as unread and the divider renders before the first loaded item — matching Slack's behavior of showing the marker at the top of what's visible rather than requiring a full backward fetch before showing anything. The synthetic frontend-only `task-description` placeholder (rendered for a session with zero real messages yet) is never a valid anchor or divider boundary — it doesn't exist in the backend messages table.
|
||||
|
||||
## Migrated source detail
|
||||
@@ -101,7 +101,9 @@ aren't otherwise obvious from the UI alone.
|
||||
changes rather than freezing on a possibly-incomplete first render. It
|
||||
stops permanently the moment either of two things happens, whichever
|
||||
comes first: the reader scrolls (wheel, touch, or a key press), or a
|
||||
short (4s) settling window since mount elapses — bounding how long a
|
||||
short (4s) settling window since the visit becomes visible elapses. Time
|
||||
spent mounted in an inactive persistent portal does not consume this window,
|
||||
which bounds how long a
|
||||
live message arriving well after the visit has genuinely settled (with
|
||||
no interaction event to catch, e.g. a scrollbar drag) could otherwise
|
||||
re-trigger it. It does not fight a pending explicit layout scroll
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
status: active
|
||||
system: ui
|
||||
created: 2026-07-30
|
||||
updated: 2026-08-27
|
||||
updated: 2026-09-02
|
||||
owners:
|
||||
- cfl
|
||||
---
|
||||
@@ -11,7 +11,13 @@ owners:
|
||||
|
||||
## Overview
|
||||
|
||||
Users who turn off transcript auto-scroll expect the visible conversation to stay fixed while new content arrives. Chrome can still adjust the view through its native overflow anchoring when the toggle is disabled from the bottom. Separately, the clarification-recovery regression test must reliably observe the asynchronous hand-off it is designed to protect.
|
||||
Users expect an enabled transcript to follow the newest message, including when
|
||||
they activate a long-running session tab that was mounted outside the visible
|
||||
Dockview layout. Users who turn off transcript auto-scroll expect the visible
|
||||
conversation to stay fixed while new content arrives. Chrome can still adjust
|
||||
the view through its native overflow anchoring when the toggle is disabled from
|
||||
the bottom. Separately, the clarification-recovery regression test must reliably
|
||||
observe the asynchronous hand-off it is designed to protect.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -31,6 +37,19 @@ Users who turn off transcript auto-scroll expect the visible conversation to sta
|
||||
pinned to the bottom, **WHEN** streamed content commits, **THEN** the
|
||||
transcript remains pinned without a synchronous content-size read in the
|
||||
message-commit path.
|
||||
- **AC-UI-TRANSCRIPT-AUTO-SCROLL-001.8:** **GIVEN** an enabled, overflowing
|
||||
transcript that was mounted while its desktop session tab was inactive,
|
||||
**WHEN** the user activates that tab after initial load or page refresh,
|
||||
**THEN** the visible transcript settles at its newest message.
|
||||
- **AC-UI-TRANSCRIPT-AUTO-SCROLL-001.9:** **GIVEN** an enabled transcript that
|
||||
was following the newest message before its desktop session tab became
|
||||
inactive, **WHEN** content arrives while the tab is inactive and the user
|
||||
activates it again, **THEN** the transcript catches up to the newest message.
|
||||
- **AC-UI-TRANSCRIPT-AUTO-SCROLL-001.10:** **GIVEN** a transcript whose
|
||||
auto-scroll preference is disabled or whose reader moved away from the newest
|
||||
message, **WHEN** its desktop session tab becomes inactive and visible again,
|
||||
**THEN** the transcript preserves the reader-owned position instead of
|
||||
forcing the newest message into view.
|
||||
|
||||
## Migrated source detail
|
||||
|
||||
@@ -48,6 +67,10 @@ the asynchronous hand-off it is designed to protect.
|
||||
anchoring from moving the transcript when appended content arrives, including
|
||||
when the user disabled it while already at the bottom.
|
||||
- Re-enabling auto-scroll retains the existing catch-up behavior.
|
||||
- Activating an enabled desktop session transcript after initial load, refresh,
|
||||
or hidden message delivery places a bottom-following reader at the newest
|
||||
message after the panel becomes measurable.
|
||||
- Activating a reader-owned transcript position preserves that position.
|
||||
- The clarification-recovery concurrency regression waits for its asynchronous
|
||||
completion signal within a bounded interval instead of treating scheduler
|
||||
timing as a product failure.
|
||||
@@ -63,6 +86,15 @@ the asynchronous hand-off it is designed to protect.
|
||||
- **GIVEN** clarification recovery has accepted its retry prompt,
|
||||
**WHEN** its asynchronous dispatch is scheduled, **THEN** the recovery call
|
||||
completes before the intentionally blocked prompt is released.
|
||||
- **GIVEN** a task with two overflowing session transcripts, **WHEN** the page
|
||||
refreshes with one session inactive and the user activates that session,
|
||||
**THEN** the session opens at the newest message when auto-scroll is enabled.
|
||||
- **GIVEN** a bottom-following session receives content while its desktop tab is
|
||||
inactive, **WHEN** the user activates the session, **THEN** its transcript
|
||||
catches up to the newest message.
|
||||
- **GIVEN** the reader disabled auto-scroll or scrolled away from the newest
|
||||
message before switching desktop session tabs, **WHEN** the reader returns,
|
||||
**THEN** the prior reading position remains visible.
|
||||
|
||||
## Out of scope
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ system: ui
|
||||
requirements:
|
||||
- REQ-UI-TRANSCRIPT-AUTO-SCROLL-001
|
||||
created: 2026-08-27
|
||||
updated: 2026-08-29
|
||||
updated: 2026-09-02
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
@@ -13,16 +13,17 @@ owners:
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design keeps an enabled transcript pinned during streamed updates without
|
||||
forcing browser layout during each React commit. It preserves disabled-state
|
||||
freezing, explicit navigation, prepend restoration, and catch-up after the user
|
||||
enables auto-scroll again.
|
||||
This design keeps an enabled transcript pinned during streamed updates and
|
||||
restores that bottom-follow intent when a persistent desktop session panel
|
||||
becomes visible. It does not force browser layout during each React commit. It
|
||||
preserves disabled-state freezing, reader-owned positions, explicit navigation,
|
||||
prepend restoration, and catch-up after the user enables auto-scroll again.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `REQ-UI-TRANSCRIPT-AUTO-SCROLL-001` | [Bottom placement](#bottom-placement), [Interaction boundaries](#interaction-boundaries), [Responsive behavior](#responsive-behavior) |
|
||||
| `REQ-UI-TRANSCRIPT-AUTO-SCROLL-001` | [Bottom placement](#bottom-placement), [Persistent-panel visibility lifecycle](#persistent-panel-visibility-lifecycle), [Interaction boundaries](#interaction-boundaries), [Responsive behavior](#responsive-behavior) |
|
||||
|
||||
## Bottom placement
|
||||
|
||||
@@ -43,6 +44,53 @@ not add a second animation frame, smooth scrolling, or a resize observer.
|
||||
Tests instrument the common append path. A `scrollHeight` getter fails the test
|
||||
if that path reads it. The scroll setter records the WebKit-safe offset.
|
||||
|
||||
## Persistent-panel visibility lifecycle
|
||||
|
||||
Desktop Dockview renders panel content through persistent portals. An inactive
|
||||
session transcript can therefore be mounted and receive messages while its
|
||||
scroll container has zero or detached geometry. `usePanelActive` supplies the
|
||||
authoritative active-tab signal to `TaskChatPanel`, which passes it to the
|
||||
native transcript scroll coordinator as `isVisible`.
|
||||
|
||||
Geometry-based initialization and divider placement do not consume their
|
||||
one-time completion state while `isVisible` is false. When a transcript first
|
||||
becomes visible after an inactive mount, its placement runs after the generic
|
||||
`SessionPanelContent` absolute-offset restore. The coordinator uses two
|
||||
animation frames because that generic restore uses one frame after its
|
||||
`ResizeObserver` reports non-zero geometry.
|
||||
|
||||
The unread-divider settling deadline starts when the transcript is first
|
||||
visible and resets for each hidden-to-visible activation. Time spent in an
|
||||
inactive persistent portal does not consume the four-second reassertion window.
|
||||
The deadline remains fixed during that visit, so later layout changes can
|
||||
reconcile the divider only while the activation is still settling.
|
||||
|
||||
`SessionPanelContent` remains a generic panel component. Its queued restore
|
||||
captures the element's `scrollTop` when scheduled and applies the saved offset
|
||||
only if that value is unchanged when the frame runs. This cooperative stale-
|
||||
write guard lets a newer transcript or user scroll remain the active owner.
|
||||
|
||||
The post-restore placement follows this ownership order:
|
||||
|
||||
1. A pending Dockview layout restore or explicit message-navigation target
|
||||
retains ownership and suppresses automatic placement.
|
||||
2. A visit-scoped unread divider is placed at its target and marks the reader
|
||||
away from the bottom.
|
||||
3. A disabled auto-scroll preference or an enabled reader who previously moved
|
||||
away from the bottom keeps the saved absolute offset.
|
||||
4. An enabled reader with bottom-follow intent receives the shared write-only
|
||||
bottom placement.
|
||||
|
||||
Hidden message and work-state updates do not write to a zero-geometry scroll
|
||||
container and do not recompute near-bottom state from hidden geometry. If the
|
||||
reader had bottom-follow intent before the panel became inactive, activation
|
||||
performs one guarded catch-up after the panel restore. If the reader had moved
|
||||
away from the bottom, activation leaves the generic absolute-offset restore in
|
||||
place.
|
||||
|
||||
The visibility lifecycle uses component refs only. It does not add persisted
|
||||
scroll state or change the existing per-session disabled-position storage.
|
||||
|
||||
## Interaction boundaries
|
||||
|
||||
The optimization does not replace geometry reads that answer a user-action
|
||||
@@ -55,6 +103,8 @@ The write-only helper runs only when all existing guards allow automatic
|
||||
placement:
|
||||
|
||||
- auto-scroll is enabled.
|
||||
- The transcript is visible, or a visibility-activation catch-up is running
|
||||
after panel restoration.
|
||||
- The reader is near the bottom.
|
||||
- No user programmatic scroll lock is active.
|
||||
- No layout restoration is pending.
|
||||
@@ -69,6 +119,12 @@ Desktop and mobile use the same native transcript, store state, and bottom
|
||||
placement helper. The transcript remains the only vertical scroll owner. No
|
||||
control, copy, layout, or touch target changes.
|
||||
|
||||
The inactive persistent-portal state exists only in the desktop Dockview
|
||||
workbench. Mobile renders one selected `TaskChatPanel` and replaces its session
|
||||
input instead of keeping inactive session panels mounted. Mobile therefore
|
||||
keeps `isVisible` true and retains its current initial, enabled, and disabled
|
||||
scroll behavior through the shared coordinator.
|
||||
|
||||
The nearest mobile exemplar is the current task Chat tab in
|
||||
`apps/web/components/task/task-layout.tsx`. Existing desktop and mobile
|
||||
auto-scroll Playwright scenarios remain the browser contract. Both viewports
|
||||
@@ -78,6 +134,10 @@ change.
|
||||
## Failure modes
|
||||
|
||||
- If the container is not mounted, the helper performs no work.
|
||||
- If a transcript is mounted but inactive, one-time placement remains pending
|
||||
until the panel becomes active and measurable.
|
||||
- If a panel becomes inactive again before the post-restore frames complete,
|
||||
the scheduled placement is cancelled.
|
||||
- Chromium, Gecko, and WebKit clamp the signed 32-bit maximum to the native
|
||||
maximum scroll position.
|
||||
- An offset outside WebKit's safe range can resolve to zero and move the
|
||||
|
||||
Reference in New Issue
Block a user