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
Original file line number Diff line number Diff line change
Expand Up @@ -1124,6 +1124,7 @@ export function ChatRouteContent({

const chatTranscriptProps: TranscriptProps = {
items: transcriptItems,
conversationId: activeConversationId,
assistantDisplayName: assistantIdentity?.name?.trim() || undefined,
expandedToolCallIds: expandedToolCallIdsRef.current,
onOpenRuleEditor: handleOpenRuleEditorForToolCall,
Expand Down
19 changes: 9 additions & 10 deletions apps/web/src/domains/chat/transcript/transcript-scroll-flag.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
// Runtime flag that turns OFF the deprecated transcript scroll hook.
//
// This file is intentionally isolated from the rest of the scroll
// utilities so it can be deleted in one move when the migration is
// complete — no other file in `transcript/` depends on it except
// `use-deprecated-transcript-scroll.ts` and `transcript-scroll.ts`,
// both of which will be deleted/refactored at that point.
//
// In the current shape of this work-in-progress:
//
// • Flag OFF (default) — the orchestrator runs the deprecated hook
// `useDeprecatedTranscriptScroll`, which is the existing
// production scroll-coordination logic. Production users land
// here. We keep this in the tree so we don't regress shipping
// behavior while we redesign the replacement.
// • Flag ON — the orchestrator runs neither the deprecated hook
// nor a replacement. The transcript scrolls natively with no
// JavaScript coordination at all. No auto-pin, no anchor
// correction, no chain-load, no "Go to Newest" pill. This is the
// baseline against which the eventual controller will be built.
//
// The eventual `TranscriptScrollController` (not yet introduced) will
// land behind this same flag — flipping it on will route to the new
// path. The name `toggleTranscriptScrollController` reflects the
// destination, not the current intermediate state.
// • Flag ON — the deprecated hook returns its no-op result. The
// replacement utilities in `transcript-scroll.ts` take over
// piecewise as features migrate.
//
// Why module-load read + reload-on-toggle:
//
Expand Down
215 changes: 215 additions & 0 deletions apps/web/src/domains/chat/transcript/transcript-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/**
* Tests for the imperative transcript scroll utilities.
*
* `bun:test` runs without a real DOM, so we exercise `attachSnapToLatest`
* — the pure imperative function the hook delegates to — against
* a minimal fake element shape. The hook itself is thin glue around
* this function; testing the function covers the load-bearing
* behavior (initial snap, re-snap on content resize, gesture disengage).
*/

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

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

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

type FakeElement = {
scrollTop: number;
scrollHeight: number;
addEventListener(event: string, listener: Listener): void;
removeEventListener(event: string, listener: Listener): void;
fire(event: string): void;
listenerCount(event: string): number;
};

function createFakeElement(scrollHeight: number): FakeElement {
const listeners = new Map<string, Set<Listener>>();
return {
scrollTop: 0,
scrollHeight,
addEventListener(event, listener) {
let set = listeners.get(event);
if (!set) {
set = new Set();
listeners.set(event, set);
}
set.add(listener);
},
removeEventListener(event, listener) {
listeners.get(event)?.delete(listener);
},
fire(event) {
listeners.get(event)?.forEach((l) => l());
},
listenerCount(event) {
return listeners.get(event)?.size ?? 0;
},
};
}

class FakeResizeObserver {
static instances: FakeResizeObserver[] = [];
callback: ResizeObserverCallback;
observed: Element[] = [];
disconnected = false;
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
FakeResizeObserver.instances.push(this);
}
observe(el: Element): void {
this.observed.push(el);
}
unobserve(): void {}
disconnect(): void {
this.disconnected = true;
}
fire(): void {
this.callback([], this as unknown as ResizeObserver);
}
}

function installFakeResizeObserver(): void {
FakeResizeObserver.instances = [];
(globalThis as { ResizeObserver?: unknown }).ResizeObserver =
FakeResizeObserver;
}

function uninstallFakeResizeObserver(): void {
delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver;
}

describe("attachSnapToLatest", () => {
test("snaps to bottom on initial attach", () => {
installFakeResizeObserver();
const container = createFakeElement(1000);
const content = createFakeElement(1000);
container.scrollTop = 0;

const stop = attachSnapToLatest({
container: container as unknown as HTMLElement,
content: content as unknown as HTMLElement,
});

expect(container.scrollTop).toBe(1000);

stop();
uninstallFakeResizeObserver();
});

test("re-snaps on content ResizeObserver fire (covers seed-then-grow race)", () => {
installFakeResizeObserver();
const container = createFakeElement(500);
const content = createFakeElement(500);

attachSnapToLatest({
container: container as unknown as HTMLElement,
content: content as unknown as HTMLElement,
});

expect(container.scrollTop).toBe(500);

// Simulate `useViewportMinHeight` seeding LatestTurnRow's minHeight:
// content grows, scrollHeight grows.
container.scrollHeight = 1500;
FakeResizeObserver.instances[0].fire();

expect(container.scrollTop).toBe(1500);

uninstallFakeResizeObserver();
});

test("stops re-snapping after user wheel gesture", () => {
installFakeResizeObserver();
const container = createFakeElement(500);
const content = createFakeElement(500);

attachSnapToLatest({
container: container as unknown as HTMLElement,
content: content as unknown as HTMLElement,
});

// User scrolls up.
container.scrollTop = 100;
container.fire("wheel");

// ResizeObserver fires after disengage — should NOT re-snap.
container.scrollHeight = 1500;
FakeResizeObserver.instances[0].fire();

expect(container.scrollTop).toBe(100);
expect(FakeResizeObserver.instances[0].disconnected).toBe(true);

uninstallFakeResizeObserver();
});

test("stops re-snapping after touchmove and keydown gestures too", () => {
installFakeResizeObserver();
const container1 = createFakeElement(500);
const content1 = createFakeElement(500);
attachSnapToLatest({
container: container1 as unknown as HTMLElement,
content: content1 as unknown as HTMLElement,
});
container1.scrollTop = 100;
container1.fire("touchmove");
container1.scrollHeight = 1500;
FakeResizeObserver.instances[0].fire();
expect(container1.scrollTop).toBe(100);

const container2 = createFakeElement(500);
const content2 = createFakeElement(500);
attachSnapToLatest({
container: container2 as unknown as HTMLElement,
content: content2 as unknown as HTMLElement,
});
container2.scrollTop = 100;
container2.fire("keydown");
container2.scrollHeight = 1500;
FakeResizeObserver.instances[1].fire();
expect(container2.scrollTop).toBe(100);

uninstallFakeResizeObserver();
});

test("teardown removes listeners and disconnects observer", () => {
installFakeResizeObserver();
const container = createFakeElement(500);
const content = createFakeElement(500);

const stop = attachSnapToLatest({
container: container as unknown as HTMLElement,
content: content as unknown as HTMLElement,
});

expect(container.listenerCount("wheel")).toBe(1);
expect(container.listenerCount("touchmove")).toBe(1);
expect(container.listenerCount("keydown")).toBe(1);

stop();

expect(container.listenerCount("wheel")).toBe(0);
expect(container.listenerCount("touchmove")).toBe(0);
expect(container.listenerCount("keydown")).toBe(0);
expect(FakeResizeObserver.instances[0].disconnected).toBe(true);

uninstallFakeResizeObserver();
});

test("no ResizeObserver available — snaps once and returns inert teardown", () => {
// SSR / older test environments.
const container = createFakeElement(800);
const content = createFakeElement(800);

const stop = attachSnapToLatest({
container: container as unknown as HTMLElement,
content: content as unknown as HTMLElement,
});

expect(container.scrollTop).toBe(800);
expect(container.listenerCount("wheel")).toBe(0);

// Should not throw.
stop();
});
});
136 changes: 136 additions & 0 deletions apps/web/src/domains/chat/transcript/transcript-scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Transcript scroll utilities — the imperative replacement for
// `useDeprecatedTranscriptScroll`. Listens to DOM lifecycle events
// (element attached, content resized, user gesture) rather than
// reacting to React state changes. Full spec lives at
// `/workspace/scratch/scroll-imperative-spec.md`.
//
// Gating against `TRANSCRIPT_SCROLL_CONTROLLER_ENABLED` lives inside
// 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";

/**
* Wire the transcript scroll container + content callback refs.
*
* Behavior when the controller flag is ON:
* 1. On scroll container DOM attach (which fires on conversation
* switch via `key={conversationId}` in `transcript.tsx`, and on
* fresh detail-page loads), the container is snapped to bottom.
* 2. A `ResizeObserver` watches the content wrapper. Until the user
* interacts, every content height change re-snaps to bottom.
* This covers the seed-then-grow race where `useViewportMinHeight`
* seeds `LatestTurnRow`'s `minHeight` in a post-paint effect,
* growing `scrollHeight` after the initial attach snap.
* 3. The first `wheel`/`touchmove`/`keydown` on the container
* disengages the observer — the user is now in control.
*
* When the controller flag is OFF, both callbacks forward to the
* passed-in refs without observing or scrolling — the deprecated hook
* still owns scroll coordination in that path.
*
* Both callbacks are returned as memoized identities so React doesn't
* tear them down between renders. The internal state (observer +
* gesture listeners) is keyed off element identity, not callback
* identity, so the wiring survives parent re-renders and tears down
* exactly when the underlying element changes.
*/
export function useTranscriptScrollOnAttach(args: {
scrollContainerRef: MutableRefObject<HTMLDivElement | null>;
contentRef: MutableRefObject<HTMLDivElement | null>;
}): {
scrollContainerCallbackRef: (el: HTMLDivElement | null) => void;
contentCallbackRef: (el: HTMLDivElement | null) => void;
} {
// Refs for tearing down the previous attach when the element
// changes (e.g. conversation switch with `key={conversationId}`).
const teardownRef = useRef<(() => void) | null>(null);

const teardown = useCallback(() => {
teardownRef.current?.();
teardownRef.current = null;
}, []);

const scrollContainerCallbackRef = useCallback(
(el: HTMLDivElement | null) => {
args.scrollContainerRef.current = el;
// No work here — all setup waits for the content ref so we
// can attach the ResizeObserver to the inner wrapper. The
// content ref is guaranteed to fire on the same commit since
// it's a child of this element.
},
[args.scrollContainerRef],
);

const contentCallbackRef = useCallback(
(el: HTMLDivElement | null) => {
args.contentRef.current = el;
if (!TRANSCRIPT_SCROLL_CONTROLLER_ENABLED) return;

// Tear down whatever was wired to the previous content/container.
teardown();

if (!el) return;
const container = args.scrollContainerRef.current;
if (!container) return;

teardownRef.current = attachSnapToLatest({ container, content: el });
},
[args.scrollContainerRef, args.contentRef, teardown],
);

return { scrollContainerCallbackRef, contentCallbackRef };
}

/**
* Snap the scroll container to bottom and keep it there through async
* layout settling. Returns a teardown function that disconnects the
* `ResizeObserver` and removes the gesture listeners.
*
* Pure imperative function — takes plain DOM elements, no React. The
* React wiring lives in `useTranscriptScrollOnAttach`; this function
* is independently testable with a fake `HTMLElement`.
*/
export function attachSnapToLatest(args: {
container: HTMLElement;
content: HTMLElement;
}): () => void {
const { container, content } = args;

// Initial attach snap — synchronous, during commit, before paint.
container.scrollTop = container.scrollHeight;

// Browsers without `ResizeObserver` get the initial snap only.
// Modern browsers (and the iOS WKWebView) all have it; this guard
// is for SSR / test environments.
if (typeof ResizeObserver === "undefined") {
return () => {};
}

let active = true;

const stop = (): void => {
if (!active) return;
active = false;
observer.disconnect();
container.removeEventListener("wheel", stop);
container.removeEventListener("touchmove", stop);
container.removeEventListener("keydown", stop);
};

const observer = new ResizeObserver(() => {
if (!active) return;
container.scrollTop = container.scrollHeight;
});
observer.observe(content);

// User-gesture disengage. The user touching the scroll container in
// any way means they're driving — stop fighting them.
container.addEventListener("wheel", stop, { passive: true });
container.addEventListener("touchmove", stop, { passive: true });
container.addEventListener("keydown", stop);

return stop;
}
Loading