Skip to content
Closed
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
3 changes: 3 additions & 0 deletions apps/web/src/domains/chat/components/chat-route-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,9 @@ export function ChatRouteContent({
const chatTranscriptProps: TranscriptProps = {
items: transcriptItems,
conversationId: activeConversationId,
hasMore: transcriptPagination.hasMore,
isLoadingOlder: transcriptPagination.isLoadingOlder,
onLoadOlder: loadOlder,
assistantDisplayName: assistantName?.trim() || undefined,
expandedToolCallIds: expandedToolCallIdsRef.current,
onOpenRuleEditor: handleOpenRuleEditorForToolCall,
Expand Down
110 changes: 109 additions & 1 deletion apps/web/src/domains/chat/transcript/transcript-scroll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@

import { describe, expect, test } from "bun:test";

import { attachSnapToLatest } from "@/domains/chat/transcript/transcript-scroll";
import {
attachLoadOlderOnTop,
attachSnapToLatest,
} from "@/domains/chat/transcript/transcript-scroll";

type Listener = (...args: unknown[]) => void;

Expand Down Expand Up @@ -213,3 +216,108 @@ describe("attachSnapToLatest", () => {
stop();
});
});

// ---- attachLoadOlderOnTop --------------------------------------------------

describe("attachLoadOlderOnTop", () => {
test("fires onLoadOlder on the initial ResizeObserver tick when scrolled to top", () => {
installFakeResizeObserver();
const container = createFakeElement(5000);
const content = createFakeElement(0);
container.scrollTop = 0;
let calls = 0;

attachLoadOlderOnTop({
container: container as unknown as HTMLElement,
content: content as unknown as HTMLElement,
onLoadOlder: () => {
calls += 1;
},
});
FakeResizeObserver.instances[0].fire();

expect(calls).toBe(1);
uninstallFakeResizeObserver();
});

test("does NOT fire when scrolled past the 200px threshold", () => {
installFakeResizeObserver();
const container = createFakeElement(5000);
const content = createFakeElement(0);
container.scrollTop = 1000;
let calls = 0;

attachLoadOlderOnTop({
container: container as unknown as HTMLElement,
content: content as unknown as HTMLElement,
onLoadOlder: () => {
calls += 1;
},
});
FakeResizeObserver.instances[0].fire();

expect(calls).toBe(0);
uninstallFakeResizeObserver();
});

test("fires on every ResizeObserver tick while near the top (streaming, chain-load)", () => {
installFakeResizeObserver();
const container = createFakeElement(5000);
const content = createFakeElement(0);
container.scrollTop = 100;
let calls = 0;

attachLoadOlderOnTop({
container: container as unknown as HTMLElement,
content: content as unknown as HTMLElement,
onLoadOlder: () => {
calls += 1;
},
});

// Each RO tick (streaming chunk arriving, image load, etc.)
// triggers another check. The hook gates re-entry by tearing
// this down when isLoadingOlder flips true, so the attachable
// itself doesn't need to throttle.
FakeResizeObserver.instances[0].fire();
FakeResizeObserver.instances[0].fire();
FakeResizeObserver.instances[0].fire();
expect(calls).toBe(3);

uninstallFakeResizeObserver();
});

test("teardown disconnects the observer", () => {
installFakeResizeObserver();
const container = createFakeElement(5000);
const content = createFakeElement(0);

const stop = attachLoadOlderOnTop({
container: container as unknown as HTMLElement,
content: content as unknown as HTMLElement,
onLoadOlder: () => {},
});
stop();

expect(FakeResizeObserver.instances[0].disconnected).toBe(true);
uninstallFakeResizeObserver();
});

test("returns an inert teardown when ResizeObserver is unavailable", () => {
// No installFakeResizeObserver — global is undefined.
const container = createFakeElement(5000);
const content = createFakeElement(0);
let calls = 0;

const stop = attachLoadOlderOnTop({
container: container as unknown as HTMLElement,
content: content as unknown as HTMLElement,
onLoadOlder: () => {
calls += 1;
},
});
expect(calls).toBe(0);
// Should not throw.
stop();
});
});
109 changes: 106 additions & 3 deletions apps/web/src/domains/chat/transcript/transcript-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,21 @@
// each utility's body so component files import them without
// branching on the flag themselves.

import { useCallback, useRef, type MutableRefObject } from "react";

import { TRANSCRIPT_SCROLL_CONTROLLER_ENABLED } from "@/domains/chat/transcript/transcript-scroll-flag";
import {
useCallback,
useEffect,
useRef,
type MutableRefObject,
} from "react";

import {
TRANSCRIPT_SCROLL_CONTROLLER_ENABLED,
getTranscriptScrollControllerEnabled,
} from "@/domains/chat/transcript/transcript-scroll-flag";

/** Pixel threshold for "near the top" — matches the value the
* deprecated hook used so user-facing behavior is preserved. */
const NEAR_TOP_LOAD_OLDER_PX = 200;

/**
* Wire the transcript scroll container + content callback refs.
Expand Down Expand Up @@ -40,6 +52,17 @@ import { TRANSCRIPT_SCROLL_CONTROLLER_ENABLED } from "@/domains/chat/transcript/
export function useTranscriptScrollOnAttach(args: {
scrollContainerRef: MutableRefObject<HTMLDivElement | null>;
contentRef: MutableRefObject<HTMLDivElement | null>;
/** Whether more older history is available. Used by the load-older
* effect to decide whether to attach a `ResizeObserver`. */
hasMore?: boolean;
/** Whether an older-page fetch is currently in flight. When true,
* the effect tears down its `ResizeObserver`; when false again,
* the effect re-runs and a fresh observer's initial tick covers
* the chain-load case. */
isLoadingOlder?: boolean;
/** Callback fired when the `ResizeObserver` detects the scroll
* position is near the top. */
onLoadOlder?: () => void;
}): {
scrollContainerCallbackRef: (el: HTMLDivElement | null) => void;
contentCallbackRef: (el: HTMLDivElement | null) => void;
Expand Down Expand Up @@ -81,6 +104,38 @@ export function useTranscriptScrollOnAttach(args: {
[args.scrollContainerRef, args.contentRef, teardown],
);

// Load-older wiring. A `useEffect` (not a state-mirror ref) is the
// right shape here: when `hasMore`, `isLoadingOlder`, or
// `onLoadOlder` change, the effect tears down and re-attaches with
// fresh closures — no `latestRef` pattern, no stale-snapshot bug
// shape from the deprecated hook.
//
// While `isLoadingOlder` is true the observer is intentionally
// detached: once it flips back to false the effect re-runs, a fresh
// observer's initial tick measures the post-prepend layout, and
// chain-loads continue automatically when the viewport is still
// underfilled.
const { hasMore, isLoadingOlder, onLoadOlder } = args;
useEffect(() => {
// Read the flag at effect-run time (not module-load). The effect
// is an early-return guard, not a hook dispatch site, so there's
// no rules-of-hooks concern with a dynamic check here. Side
// benefit: integration tests can flip the flag via `localStorage`
// without fighting module-import ordering.
if (!getTranscriptScrollControllerEnabled()) return;
if (!hasMore || isLoadingOlder || !onLoadOlder) return;
const container = args.scrollContainerRef.current;
const content = args.contentRef.current;
if (!container || !content) return;
return attachLoadOlderOnTop({ container, content, onLoadOlder });
}, [
args.scrollContainerRef,
args.contentRef,
hasMore,
isLoadingOlder,
onLoadOlder,
]);

return { scrollContainerCallbackRef, contentCallbackRef };
}

Expand Down Expand Up @@ -134,3 +189,51 @@ export function attachSnapToLatest(args: {

return stop;
}

/**
* Trigger `onLoadOlder()` whenever a `ResizeObserver` tick on the
* content reports the scroll container is within
* `NEAR_TOP_LOAD_OLDER_PX` of the top.
*
* Pure imperative function — no React, no saved state, no anchor.
* The hook owns when to attach (only when `hasMore && !isLoadingOlder`)
* and tears down on prop change, so this function only needs to act
* on every observed tick.
*
* Covers, by construction:
* • **Initial chain-load** — `observe()` fires once with current
* measurements; if the freshly attached transcript is already
* near the top (typically because it's underfilled), older
* history is requested.
* • **Repeat chain-load** — when an older page lands, the parent
* flips `isLoadingOlder` false, the hook re-runs the effect, a
* fresh observer's initial tick measures the new layout, and the
* loop continues until the viewport is full or `hasMore` flips.
* • **Streaming-triggered detection** — any content height change
* while the user is near the top fires the observer.
*
* Does NOT cover, intentionally:
* • User scrolling up to the top with no other content change.
* Scroll events do not fire a `ResizeObserver`. Adding a scroll
* listener belongs to a separate PR (Trigger A in the migration
* spec).
*/
export function attachLoadOlderOnTop(args: {
container: HTMLElement;
content: HTMLElement;
onLoadOlder: () => void;
}): () => void {
const { container, content, onLoadOlder } = args;

if (typeof ResizeObserver === "undefined") {
return () => {};
}

const observer = new ResizeObserver(() => {
if (container.scrollTop > NEAR_TOP_LOAD_OLDER_PX) return;
onLoadOlder();
});
observer.observe(content);

return () => observer.disconnect();
}
21 changes: 19 additions & 2 deletions apps/web/src/domains/chat/transcript/transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export type RefreshOutcome =
export interface TranscriptProps {
items: TranscriptItem[];
conversationId: string | null;
/** Pagination state driving the imperative load-older + chain-load
* utilities. When the controller flag is OFF the deprecated hook
* still owns this; when ON, these props feed the attachables. */
hasMore?: boolean;
isLoadingOlder?: boolean;
onLoadOlder?: () => void;
assistantDisplayName?: string | null;
onSecretSubmit: (requestId: string, value: string) => void;
onConfirmationDecision: (requestId: string, decision: string) => void;
Expand Down Expand Up @@ -152,14 +158,25 @@ export interface TranscriptHandle {

export const Transcript = forwardRef<TranscriptHandle, TranscriptProps>(
function Transcript(props, ref) {
const { items, conversationId, onPullRefresh, pullRefreshEnabled, ...rest } =
props;
const {
items,
conversationId,
hasMore,
isLoadingOlder,
onLoadOlder,
onPullRefresh,
pullRefreshEnabled,
...rest
} = props;
const scrollRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const { scrollContainerCallbackRef, contentCallbackRef } =
useTranscriptScrollOnAttach({
scrollContainerRef: scrollRef,
contentRef,
hasMore,
isLoadingOlder,
onLoadOlder,
});
const viewportMinHeight = useViewportMinHeight(scrollRef);

Expand Down
Loading