Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion apps/web/src/lib/transcript-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { transcriptIsNearEnd } from "./transcript-scroll.js";
import {
transcriptCanSnapAfterFrame,
transcriptIsNearEnd,
transcriptMovedDown,
} from "./transcript-scroll.js";

describe("transcriptIsNearEnd", () => {
it("follows only while the viewport is within 80px of the latest message", () => {
Expand All @@ -11,3 +15,26 @@ describe("transcriptIsNearEnd", () => {
);
});
});

describe("transcriptCanSnapAfterFrame", () => {
it("does not snap after the reader moves while the frame is queued", () => {
const transcript = { scrollTop: 950 };
expect(transcriptCanSnapAfterFrame(transcript, transcript, 950)).toBe(true);
transcript.scrollTop = 920;
expect(transcriptCanSnapAfterFrame(transcript, transcript, 950)).toBe(false);
});

it("does not snap a replacement transcript", () => {
const queuedTranscript = { scrollTop: 950 };
const replacementTranscript = { scrollTop: 950 };
expect(transcriptCanSnapAfterFrame(replacementTranscript, queuedTranscript, 950)).toBe(false);
});
});

describe("transcriptMovedDown", () => {
it("does not treat an uninitialized baseline as downward movement", () => {
expect(transcriptMovedDown(null, 920)).toBe(false);
expect(transcriptMovedDown(950, 920)).toBe(false);
expect(transcriptMovedDown(920, 950)).toBe(true);
});
});
12 changes: 12 additions & 0 deletions apps/web/src/lib/transcript-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,15 @@ export function transcriptIsNearEnd(
): boolean {
return element.scrollHeight - element.scrollTop - element.clientHeight < 80;
}

export function transcriptCanSnapAfterFrame(
element: Pick<HTMLElement, "scrollTop"> | null,
queuedElement: Pick<HTMLElement, "scrollTop">,
queuedScrollTop: number,
): boolean {
return element === queuedElement && queuedElement.scrollTop === queuedScrollTop;
}

export function transcriptMovedDown(previousScrollTop: number | null, scrollTop: number): boolean {
return previousScrollTop !== null && scrollTop >= previousScrollTop;
}
42 changes: 29 additions & 13 deletions apps/web/src/pages/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,11 @@ import {
reduceThreadSnapshot,
userHoldsComputerControl,
} from "../lib/thread-events";
import { transcriptIsNearEnd } from "../lib/transcript-scroll";
import {
transcriptCanSnapAfterFrame,
transcriptIsNearEnd,
transcriptMovedDown,
} from "../lib/transcript-scroll";
import { speaker } from "../lib/tts";
import { ActivityList } from "./ActivityList";
import type { ContextMenuPosition } from "./BotContextMenu";
Expand Down Expand Up @@ -474,6 +478,18 @@ export function ShellPage() {
[navigate],
);

function snapTranscriptToEndAfterFrame() {
const queuedElement = messageScroll.current;
if (!queuedElement) return;
const queuedScrollTop = queuedElement.scrollTop;
window.requestAnimationFrame(() => {
const element = messageScroll.current;
if (transcriptCanSnapAfterFrame(element, queuedElement, queuedScrollTop)) {
queuedElement.scrollTop = queuedElement.scrollHeight;
}
});
}

async function refreshGroupThread(id: string) {
const scrollElement = messageScroll.current;
const stickToEnd = !scrollElement || transcriptIsNearEnd(scrollElement);
Expand All @@ -498,10 +514,7 @@ export function ShellPage() {
(!scrollElement || transcriptIsNearEnd(scrollElement)) &&
expandedHistoryThread.current !== snap.threadId
) {
window.requestAnimationFrame(() => {
const element = messageScroll.current;
if (element) element.scrollTop = element.scrollHeight;
});
snapTranscriptToEndAfterFrame();
}
return snap;
}
Expand Down Expand Up @@ -537,10 +550,7 @@ export function ShellPage() {
(!scrollElement || transcriptIsNearEnd(scrollElement)) &&
expandedHistoryThread.current !== snap.threadId
) {
window.requestAnimationFrame(() => {
const element = messageScroll.current;
if (element) element.scrollTop = element.scrollHeight;
});
snapTranscriptToEndAfterFrame();
}
const [routines, skills] = await Promise.all([
rpc.routines.list({ botId: id }),
Expand Down Expand Up @@ -3103,7 +3113,7 @@ const Transcript = memo(function Transcript({
const [atEnd, setAtEnd] = useState(true);
const following = useRef(true);
const autoScrolling = useRef(false);
const lastScrollTop = useRef(0);
const lastScrollTop = useRef<number | null>(null);
const autoScrollTimer = useRef<number | undefined>(undefined);
const jumpButtonRef = useRef<HTMLButtonElement>(null);
const messageById = useMemo(
Expand Down Expand Up @@ -3185,22 +3195,28 @@ const Transcript = memo(function Transcript({
<div
ref={scrollRef}
data-testid="transcript"
onPointerDown={() => {
onPointerDown={(event) => {
lastScrollTop.current = event.currentTarget.scrollTop;
autoScrolling.current = false;
following.current = false;
}}
onTouchStart={() => {
onTouchStart={(event) => {
lastScrollTop.current = event.currentTarget.scrollTop;
autoScrolling.current = false;
following.current = false;
}}
onWheel={(event) => {
if (event.deltaY < 0) {
lastScrollTop.current = event.currentTarget.scrollTop;
autoScrolling.current = false;
following.current = false;
}
}}
onScroll={(event) => {
const scrolledDown = event.currentTarget.scrollTop >= lastScrollTop.current;
const scrolledDown = transcriptMovedDown(
lastScrollTop.current,
event.currentTarget.scrollTop,
);
lastScrollTop.current = event.currentTarget.scrollTop;
const nearEnd = transcriptIsNearEnd(event.currentTarget);
setAtEnd(nearEnd);
Expand Down
Loading