Skip to content
160 changes: 160 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { create, type ReactTestRenderer } from "react-test-renderer";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react";
import { shouldUseRestingComposerLayout } from "../composerFooterLayout";
import { rememberTimelinePosition } from "./timelineScrollAnchoring";
import { useComposerFocusState } from "./useComposerFocusState";

vi.mock("@legendapp/list/react", async () => {
Expand Down Expand Up @@ -1067,6 +1068,165 @@ describe("MessagesTimeline", () => {
}
});

it("waits for the saved anchor's rows during a position restore, then falls back at its deadline", async () => {
vi.useFakeTimers();
const frames = new Map<number, FrameRequestCallback>();
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
const frame = frames.size + 1;
frames.set(frame, callback);
return frame;
});
vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame));
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const createListRef = (calls: {
scrollToIndex: ReturnType<typeof vi.fn>;
scrollToOffset: ReturnType<typeof vi.fn>;
scrollToEnd: ReturnType<typeof vi.fn>;
}) => {
const ref = createRef<LegendListRef | null>();
ref.current = {
getState: () => ({ indexByKey: () => undefined }),
getScrollableNode: () => null,
scrollToIndex: calls.scrollToIndex,
scrollToOffset: calls.scrollToOffset,
scrollToEnd: calls.scrollToEnd,
} as unknown as LegendListRef;
return ref;
};
const savedPosition = {
rowId: "entry-anchor",
offsetWithinRow: 12,
scrollOffset: 420,
atEnd: false,
};
const staleEntries = [buildUserTimelineEntry("Another thread's rows")];
const anchoredEntries = [
buildUserTimelineEntry("Earlier message"),
{ ...buildUserTimelineEntry("Saved anchor"), id: "entry-anchor" },
];
let renderer: ReactTestRenderer | undefined;
try {
// Switching back paints rows before the saved anchor exists among them.
// The restore must not clamp a fallback scroll against those rows.
rememberTimelinePosition("environment-local:thread-restore-wait", savedPosition);
const waitCalls = {
scrollToIndex: vi.fn(() => Promise.resolve()),
scrollToOffset: vi.fn(() => Promise.resolve()),
scrollToEnd: vi.fn(() => Promise.resolve()),
};
const waitProps = {
...buildProps(),
routeThreadKey: "environment-local:thread-restore-wait",
listRef: createListRef(waitCalls),
};
act(() => {
renderer = create(<MessagesTimeline {...waitProps} timelineEntries={staleEntries} />);
});
expect(waitCalls.scrollToIndex).not.toHaveBeenCalled();
expect(waitCalls.scrollToOffset).not.toHaveBeenCalled();
expect(waitCalls.scrollToEnd).not.toHaveBeenCalled();

// Once rows carrying the anchor arrive, the restore targets the anchor.
act(() => {
renderer!.update(<MessagesTimeline {...waitProps} timelineEntries={anchoredEntries} />);
});
expect(waitCalls.scrollToIndex).toHaveBeenCalledWith(
expect.objectContaining({ index: 1, animated: false, viewPosition: 0, viewOffset: -12 }),
);
expect(waitCalls.scrollToOffset).not.toHaveBeenCalled();
act(() => renderer?.unmount());
renderer = undefined;

// If the anchor never appears, the bounded wait ends and the restore
// completes against the saved offset instead of hanging.
rememberTimelinePosition("environment-local:thread-restore-fallback", savedPosition);
const fallbackCalls = {
scrollToIndex: vi.fn(() => Promise.resolve()),
scrollToOffset: vi.fn(() => Promise.resolve()),
scrollToEnd: vi.fn(() => Promise.resolve()),
};
const fallbackProps = {
...buildProps(),
routeThreadKey: "environment-local:thread-restore-fallback",
listRef: createListRef(fallbackCalls),
};
act(() => {
renderer = create(<MessagesTimeline {...fallbackProps} timelineEntries={staleEntries} />);
});
expect(fallbackCalls.scrollToOffset).not.toHaveBeenCalled();
await act(() => vi.advanceTimersByTimeAsync(2_100));
expect(fallbackCalls.scrollToOffset).toHaveBeenCalledWith({ offset: 420, animated: false });
expect(fallbackCalls.scrollToIndex).not.toHaveBeenCalled();
} finally {
act(() => renderer?.unmount());
vi.useRealTimers();
vi.unstubAllGlobals();
}
});

it("keeps falling back immediately after the deadline instead of restarting the wait", async () => {
vi.useFakeTimers();
vi.stubGlobal("requestAnimationFrame", () => 0);
vi.stubGlobal("cancelAnimationFrame", () => {});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const savedPosition = {
rowId: "entry-anchor",
offsetWithinRow: 12,
scrollOffset: 420,
atEnd: false,
};
const staleEntries = [buildUserTimelineEntry("Another thread's rows")];
// A later row not carrying the anchor either: a streamed update that
// arrives after the deadline expires, before the fallback scroll settles.
const stillStaleEntries = [...staleEntries, buildUserTimelineEntry("More streamed content")];
let renderer: ReactTestRenderer | undefined;
try {
rememberTimelinePosition("environment-local:thread-restore-no-restart", savedPosition);
// The fallback scroll never resolves in this test, so a second rerender
// observes the effect before setPositionedThreadKey ever runs.
const calls = {
scrollToIndex: vi.fn(() => Promise.resolve()),
scrollToOffset: vi.fn(() => new Promise<void>(() => {})),
scrollToEnd: vi.fn(() => Promise.resolve()),
};
const ref = createRef<LegendListRef | null>();
ref.current = {
getState: () => ({ indexByKey: () => undefined }),
getScrollableNode: () => null,
scrollToIndex: calls.scrollToIndex,
scrollToOffset: calls.scrollToOffset,
scrollToEnd: calls.scrollToEnd,
} as unknown as LegendListRef;
const props = {
...buildProps(),
routeThreadKey: "environment-local:thread-restore-no-restart",
listRef: ref,
};

act(() => {
renderer = create(<MessagesTimeline {...props} timelineEntries={staleEntries} />);
});
expect(calls.scrollToOffset).not.toHaveBeenCalled();

// The bounded wait expires and the effect falls back once.
await act(() => vi.advanceTimersByTimeAsync(2_100));
expect(calls.scrollToOffset).toHaveBeenCalledTimes(1);

// A streamed update changes the rows again while the fallback scroll
// is still pending. Since the anchor is still missing, the effect
// reruns; it must fall back again immediately rather than starting
// another 2s wait.
act(() => {
renderer!.update(<MessagesTimeline {...props} timelineEntries={stillStaleEntries} />);
});
expect(calls.scrollToOffset).toHaveBeenCalledTimes(2);
} finally {
act(() => renderer?.unmount());
vi.useRealTimers();
vi.unstubAllGlobals();
}
});

it("keeps reserved end space when tool work starts while reading history", () => {
const turnId = TurnId.make("turn-with-active-tool");
const firstEntry = buildUserTimelineEntry("Run the command.");
Expand Down
56 changes: 49 additions & 7 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
const listIdentityRef = useRef(listIdentityKey);
const previousLatestTurnRef = useRef(latestTurn);
// Bounds how long a position restore waits for rows that contain the saved
// anchor; switched threads can paint another thread's rows for a frame.
const restoreDeadlineRef = useRef<number | null>(null);
// The list stays mounted across thread switches. Its first end pins on the
// new thread must snap, not glide, even if that thread is mid-turn.
const [settlingListIdentity, setSettlingListIdentity] = useState<string | null>(null);
Expand All @@ -561,6 +564,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
if (listIdentityRef.current !== listIdentityKey) {
listIdentityRef.current = listIdentityKey;
setPositionedThreadKey(null);
restoreDeadlineRef.current = null;
previousLatestTurnRef.current = latestTurn;
setSettlingListIdentity(listIdentityKey);
paintedExpandedTurnIds = rememberedPosition?.disclosures?.turns ?? new Set();
Expand Down Expand Up @@ -821,6 +825,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
restoringThreadPosition && restoreRowIndex >= 0 ? { indices: [restoreRowIndex] } : undefined,
[restoreRowIndex, restoringThreadPosition],
);
// Bumped when a restore waiting for the saved anchor's rows reaches its
// deadline, so the restore completes even if no row change re-runs the effect.
const [restoreWaitTick, setRestoreWaitTick] = useState(0);
useLayoutEffect(() => {
if (!restoringThreadPosition || rows.length === 0) return;
const list = listRef.current;
Expand All @@ -831,13 +838,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({
}
let cancelled = false;
let settleFrame: number | null = null;
let waitTimer: ReturnType<typeof setTimeout> | null = null;
const viewport: HTMLElement | null = list.getScrollableNode();
const cancelRestoration = () => {
if (cancelled) return;
cancelled = true;
if (settleFrame !== null) cancelAnimationFrame(settleFrame);
// Supersede any pending estimated-index scroll before the browser applies the gesture.
if (viewport) void list.scrollToOffset({ offset: viewport.scrollTop, animated: false });
restoreDeadlineRef.current = null;
setPositionedThreadKey(listIdentityKey);
};
const cancelForNavigation = () => {
Expand All @@ -858,10 +867,46 @@ export const MessagesTimeline = memo(function MessagesTimeline({
viewport?.addEventListener("touchmove", cancelForNavigation, { passive: true });
viewport?.addEventListener("pointerdown", cancelForNavigation, { passive: true });
viewport?.ownerDocument.addEventListener("keydown", onScrollKey);
const removeCancelListeners = () => {
viewport?.removeEventListener("wheel", cancelForNavigation);
viewport?.removeEventListener("touchmove", cancelForNavigation);
viewport?.removeEventListener("pointerdown", cancelForNavigation);
viewport?.ownerDocument.removeEventListener("keydown", onScrollKey);
};
const detach = () => {
if (cancelPositionRestoreRef?.current === cancelRestoration) {
cancelPositionRestoreRef.current = null;
}
if (waitTimer !== null) clearTimeout(waitTimer);
removeCancelListeners();
};
const position = rememberedPosition;
const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1;
if (position?.atEnd === false) onManualNavigation();
if (cancelPositionRestoreRef) cancelPositionRestoreRef.current = cancelRestoration;
if (position?.atEnd === false && index < 0) {
// The displayed rows may briefly be a paint-only projection of another
// thread. Restoring against those would clamp the offset and mark the
// restoration done before the real rows arrive, so wait for rows that
// contain the saved anchor (bounded) before falling back. Gestures keep
// cancelling through the wait, and the wait always terminates.
if (restoreDeadlineRef.current === null) {
restoreDeadlineRef.current = Date.now() + 2_000;
}
if (Date.now() < restoreDeadlineRef.current) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
waitTimer = setTimeout(
() => {
if (!cancelled) setRestoreWaitTick((tick) => tick + 1);
},
restoreDeadlineRef.current - Date.now() + 25,
);
return detach;
}
// Leave the deadline set: the fallback below is async (scrollToOffset
// resolves before setPositionedThreadKey runs), so a rows change that
// reruns this effect before that completes must see the deadline as
// already expired, not start a fresh 2s wait.
}
const scrolling =
position?.atEnd === false
? index >= 0
Expand All @@ -876,6 +921,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
void Promise.resolve(scrolling).then(() => {
if (cancelled) return;
if (position?.atEnd !== false || index < 0) {
restoreDeadlineRef.current = null;
setPositionedThreadKey(listIdentityKey);
return;
}
Expand Down Expand Up @@ -907,6 +953,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
return;
}
if (++stableFrames >= 2) {
restoreDeadlineRef.current = null;
setPositionedThreadKey(listIdentityKey);
} else {
settleFrame = requestAnimationFrame(reconcile);
Expand All @@ -916,14 +963,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
});
return () => {
cancelled = true;
if (cancelPositionRestoreRef?.current === cancelRestoration) {
cancelPositionRestoreRef.current = null;
}
if (settleFrame !== null) cancelAnimationFrame(settleFrame);
viewport?.removeEventListener("wheel", cancelForNavigation);
viewport?.removeEventListener("touchmove", cancelForNavigation);
viewport?.removeEventListener("pointerdown", cancelForNavigation);
viewport?.ownerDocument.removeEventListener("keydown", onScrollKey);
detach();
};
}, [
citationRequest,
Expand All @@ -932,6 +973,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
listRef,
onManualNavigation,
rememberedPosition,
restoreWaitTick,
restoringThreadPosition,
rows,
]);
Expand Down
Loading