diff --git a/apps/web/src/domains/chat/components/chat-route-content.tsx b/apps/web/src/domains/chat/components/chat-route-content.tsx index 62eefd4c766..ca1be1edece 100644 --- a/apps/web/src/domains/chat/components/chat-route-content.tsx +++ b/apps/web/src/domains/chat/components/chat-route-content.tsx @@ -1124,6 +1124,7 @@ export function ChatRouteContent({ const chatTranscriptProps: TranscriptProps = { items: transcriptItems, + conversationId: activeConversationId, assistantDisplayName: assistantIdentity?.name?.trim() || undefined, expandedToolCallIds: expandedToolCallIdsRef.current, onOpenRuleEditor: handleOpenRuleEditorForToolCall, diff --git a/apps/web/src/domains/chat/transcript/transcript-scroll-flag.ts b/apps/web/src/domains/chat/transcript/transcript-scroll-flag.ts index a0a4242e745..9384ce68848 100644 --- a/apps/web/src/domains/chat/transcript/transcript-scroll-flag.ts +++ b/apps/web/src/domains/chat/transcript/transcript-scroll-flag.ts @@ -1,5 +1,11 @@ // 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 @@ -7,16 +13,9 @@ // 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: // diff --git a/apps/web/src/domains/chat/transcript/transcript-scroll.test.ts b/apps/web/src/domains/chat/transcript/transcript-scroll.test.ts new file mode 100644 index 00000000000..c9a844a1af1 --- /dev/null +++ b/apps/web/src/domains/chat/transcript/transcript-scroll.test.ts @@ -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>(); + 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(); + }); +}); diff --git a/apps/web/src/domains/chat/transcript/transcript-scroll.ts b/apps/web/src/domains/chat/transcript/transcript-scroll.ts new file mode 100644 index 00000000000..6ad6eb24fee --- /dev/null +++ b/apps/web/src/domains/chat/transcript/transcript-scroll.ts @@ -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; + contentRef: MutableRefObject; +}): { + 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; +} diff --git a/apps/web/src/domains/chat/transcript/transcript-subagent-inline.test.tsx b/apps/web/src/domains/chat/transcript/transcript-subagent-inline.test.tsx index 154e7eb9fdf..2f4c3241fc7 100644 --- a/apps/web/src/domains/chat/transcript/transcript-subagent-inline.test.tsx +++ b/apps/web/src/domains/chat/transcript/transcript-subagent-inline.test.tsx @@ -176,6 +176,7 @@ describe("Transcript — inline subagent rendering (PR 8)", () => { const { getAllByTestId } = render( { const { queryAllByTestId } = render( { const { container, getByTestId } = render( { const { getAllByTestId } = render( { const { getAllByTestId } = render( { const { getAllByTestId } = render( { const { queryAllByTestId } = render( { const { getAllByTestId } = render( { const html = renderToStaticMarkup( { const html = renderToStaticMarkup( { const html = renderToStaticMarkup( { const html = renderToStaticMarkup( { const html = renderToStaticMarkup( void; onConfirmationDecision: (requestId: string, decision: string) => void; @@ -150,9 +152,15 @@ export interface TranscriptHandle { export const Transcript = forwardRef( function Transcript(props, ref) { - const { items, onPullRefresh, pullRefreshEnabled, ...rest } = props; + const { items, conversationId, onPullRefresh, pullRefreshEnabled, ...rest } = + props; const scrollRef = useRef(null); const contentRef = useRef(null); + const { scrollContainerCallbackRef, contentCallbackRef } = + useTranscriptScrollOnAttach({ + scrollContainerRef: scrollRef, + contentRef, + }); const viewportMinHeight = useViewportMinHeight(scrollRef); const pullEnabled = !!pullRefreshEnabled && !!onPullRefresh; @@ -249,7 +257,8 @@ export const Transcript = forwardRef( return (
@@ -258,7 +267,7 @@ export const Transcript = forwardRef( * height changes (async min-height settle, late image loads, * streaming growth). Wrapping all rows in a single observed * element is cheaper than observing each row individually. */} -
+
{/* History items in chronological order — oldest at top. */} {partition.historyItems.map((item) => (