diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 7a749b501bd3e..3755a87ebefb7 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -16,6 +16,7 @@ import type * as AcpSchema from "effect-acp/schema"; import type * as AcpCompat from "effect-acp/compat"; import { beginAcpMockPrompt } from "./acpMockCancellationState.ts"; +import { timelineFollowFixture, waitForTimelineFixtureRelease } from "./timeline-follow-fixture.ts"; const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; @@ -126,7 +127,10 @@ const permissionRequestCount = Math.max( 1, Number(process.env.T3_ACP_PERMISSION_REQUEST_COUNT ?? "1") || 1, ); -const sessionId = "mock-session-1"; +const sessionId = + process.env.T3_ACP_TIMELINE_FIXTURE_DIRECTORY === undefined + ? "mock-session-1" + : `timeline-fixture-${process.pid}`; let currentModeId = antigravityProfile ? "default" : "ask"; let currentModelId = antigravityProfile ? "gemini-test-low" : "default"; @@ -962,6 +966,38 @@ const program = Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); beginAcpMockPrompt(cancelledSessions, requestedSessionId); promptCount += 1; + const timelineFixtureRoot = process.env.T3_ACP_TIMELINE_FIXTURE_DIRECTORY; + if (timelineFixtureRoot !== undefined) { + const timelineFixtureDirectory = `${timelineFixtureRoot}/${process.pid}-${promptCount}`; + const kind = request.prompt.some( + (part) => + part.type === "text" && + typeof part.text === "string" && + part.text.includes("timeline-fixture:short"), + ) + ? "short" + : "long"; + for (const { phase, updates } of timelineFollowFixture(kind)) { + yield* Effect.sync(() => NodeFS.mkdirSync(timelineFixtureDirectory, { recursive: true })); + yield* Effect.sync(() => + NodeFS.writeFileSync( + `${timelineFixtureDirectory}/waiting.json`, + encodeJson({ kind, phase, sessionId: requestedSessionId }), + ), + ); + yield* Effect.promise(() => + waitForTimelineFixtureRelease(timelineFixtureDirectory, phase), + ); + for (const update of updates) { + yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update }); + yield* Effect.sleep("40 millis"); + } + yield* Effect.sync(() => + NodeFS.writeFileSync(`${timelineFixtureDirectory}/${phase}.done`, "done\n"), + ); + } + return yield* finishPrompt(requestedSessionId, "end_turn"); + } if ( process.env.T3_ACP_CRASH_PROMPT === "1" && request.prompt.some((part) => part.type === "text" && part.text === "crash now") diff --git a/apps/server/scripts/timeline-follow-fixture.ts b/apps/server/scripts/timeline-follow-fixture.ts new file mode 100644 index 0000000000000..f0dd0f69e3cbe --- /dev/null +++ b/apps/server/scripts/timeline-follow-fixture.ts @@ -0,0 +1,165 @@ +// @effect-diagnostics nodeBuiltinImport:off - Synthetic capture data and local release barriers. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeURL from "node:url"; + +import type * as AcpSchema from "effect-acp/schema"; + +const timelineFixtureEpoch = "2026-01-01T12:00:00.000Z"; +const timelineFixturePhases = ["entry", "tool-start", "tool-finish", "prose", "resumed"] as const; + +export function timelineFollowFixture(kind: "long" | "short") { + const say = (text: string): AcpSchema.SessionUpdate => ({ + sessionUpdate: "agent_message_chunk", + messageId: "timeline-fixture-answer", + content: { type: "text", text }, + }); + const paragraph = (index: number) => + `Paragraph ${String(index).padStart(3, "0")}. The amber lighthouse overlooks a quiet harbor. ` + + "A keeper records the tide, checks the lantern, and leaves a clear note for the next watch. " + + "This fixed paragraph marks a stable reading position while new output arrives.\n\n"; + const tool = (status: "in_progress" | "completed"): AcpSchema.SessionUpdate => ({ + sessionUpdate: "tool_call_update", + toolCallId: "timeline-fixture-tool", + title: "Check the harbor log", + kind: "execute", + status, + rawInput: { command: "printf 'harbor clear\\n'" }, + ...(status === "completed" + ? { rawOutput: { stdout: "harbor clear\n", stderr: "", exitCode: 0 } } + : {}), + }); + return [ + { + phase: "entry", + updates: + kind === "long" + ? [say(Array.from({ length: 100 }, (_, i) => paragraph(i + 1)).join(""))] + : [say("The harbor is quiet.\n\n")], + }, + { phase: "tool-start", updates: [tool("in_progress")] }, + { phase: "tool-finish", updates: [tool("completed")] }, + { + phase: "prose", + updates: + kind === "long" + ? [say(Array.from({ length: 20 }, (_, i) => paragraph(i + 101)).join(""))] + : [say("The lantern is ready.\n\n")], + }, + { + phase: "resumed", + updates: [say(kind === "long" ? paragraph(121) + paragraph(122) : "All clear.")], + }, + ]; +} + +// The driver creates a release file only after capturing the previous checkpoint. +// Watching the directory before checking the file avoids a lost release race. +export function waitForTimelineFixtureRelease(directory: string, phase: string) { + NodeFS.mkdirSync(directory, { recursive: true }); + const releasePath = NodePath.join(directory, `${phase}.release`); + return new Promise((resolve, reject) => { + const watcher = NodeFS.watch(directory, () => { + if (!NodeFS.existsSync(releasePath)) return; + watcher.close(); + resolve(); + }); + watcher.on("error", (error) => { + watcher.close(); + reject(error); + }); + if (NodeFS.existsSync(releasePath)) { + watcher.close(); + resolve(); + } + }); +} + +if (process.argv[1] && import.meta.url === NodeURL.pathToFileURL(process.argv[1]).href) { + const [command, directory, phase, provider, kind] = process.argv.slice(2); + if (command === "print") { + process.stdout.write( + JSON.stringify( + { + epoch: timelineFixtureEpoch, + long: timelineFollowFixture("long"), + short: timelineFollowFixture("short"), + }, + null, + 2, + ) + "\n", + ); + } else if ( + command === "release" && + directory && + timelineFixturePhases.some((value) => value === phase) + ) { + NodeFS.mkdirSync(directory, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(directory, `${phase}.release`), "release\n"); + } else if (command === "provider" && directory) { + process.stdout.write( + JSON.stringify( + { + operation: "create", + instanceId: "timeline_fixture", + instance: { + driver: "acpRegistry", + displayName: "Timeline fixture", + enabled: true, + config: { + source: "local", + commandPath: process.execPath, + commandArgs: [NodePath.join(import.meta.dirname, "acp-mock-agent.ts")], + }, + environment: [ + { + name: "T3_ACP_TIMELINE_FIXTURE_DIRECTORY", + value: NodePath.resolve(directory), + sensitive: false, + }, + ], + }, + }, + null, + 2, + ) + "\n", + ); + } else if ( + command === "seed" && + directory && + phase && + provider && + (kind === "long" || kind === "short") + ) { + // Launch through the installed app service; do not write projections or credentials. + const result = NodeChildProcess.spawnSync( + process.execPath, + [ + NodePath.join(import.meta.dirname, "../src/bin.ts"), + "thread", + "start", + "--base-dir", + NodePath.resolve(directory), + phase, + `timeline-fixture:${kind}`, + "--provider", + provider, + "--model", + "default", + "--title", + `Timeline fixture ${kind}`, + "--json", + ], + { encoding: "utf8" }, + ); + process.stdout.write(result.stdout ?? ""); + process.stderr.write(result.stderr ?? ""); + process.exitCode = result.status ?? 1; + } else { + process.stderr.write( + "Usage: node apps/server/scripts/timeline-follow-fixture.ts\n print\n provider \n seed \n release \nConfigure the provider JSON through Settings in the isolated environment. Each seed prints actual app thread/run IDs. waiting.json names the next phase; .done marks delivery. Capture before releasing the next phase. Use a fresh root for each capture run.\n", + ); + process.exitCode = 2; + } +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 92707af8ca119..f240859b70e1c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2050,6 +2050,7 @@ export default function ChatView(props: ChatViewProps) { onToolOutputCollapsedAtEndStable, onIsAtEndChangeStable, anchorNewTurn, + setTimelineScrollNode, } = useTimelineFollow({ legendListRef, composerRef, @@ -2057,7 +2058,6 @@ export default function ChatView(props: ChatViewProps) { composerTimelineInset, routeThreadKey, activeThreadKey, - activeThreadId: activeThread?.id, isServerThread, serverProjection, threadStatus, @@ -3313,6 +3313,7 @@ export default function ChatView(props: ChatViewProps) { ) } listRef={legendListRef} + onScrollNodeChange={setTimelineScrollNode} runs={paintOnlyDisplayedTimeline ? [] : (serverProjection?.runs ?? [])} latestRun={paintOnlyDisplayedTimeline ? null : activeActivityRun} runningRunId={paintOnlyDisplayedTimeline ? null : activeRunningTurnId} diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 227a0cda730a2..3a9ad0abe9546 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -1,12 +1,21 @@ import { EnvironmentId, MessageId, RunId, ScheduledTaskId } from "@t3tools/contracts"; import type { LegendListRef } from "@legendapp/list/react"; -import { createRef, Profiler, type ComponentProps, type ReactElement } from "react"; +import { + createRef, + Profiler, + useRef, + useState, + type ComponentProps, + type ReactElement, +} from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import "~/index.css"; -import { act, render } from "../../../test/render"; +import { act, fakeTimers, render } from "../../../test/render"; import { MessagesTimeline } from "./MessagesTimeline"; +import { useTimelineFollow } from "./useTimelineFollow"; +import type { ChatComposerHandle } from "./ChatComposer"; import { remarkThoughtPreview } from "./TimelineWorkEntryRow.logic"; import { parseMarkdownWithMath } from "@t3tools/shared/markdownMath"; import { collectThreadFindRanges } from "./threadFindHighlights"; @@ -121,6 +130,46 @@ async function click(element: HTMLElement) { await act(async () => element.click()); } +function FollowHarness({ entries, loading }: { entries: Entry[]; loading: boolean }) { + const listRef = useRef(null); + const composerRef = useRef(null); + const [routeThreadKey] = useState(() => baseProps().routeThreadKey); + const follow = useTimelineFollow({ + legendListRef: listRef, + composerRef, + composerOverlayElement: null, + composerTimelineInset: 0, + routeThreadKey, + activeThreadKey: routeThreadKey, + isServerThread: false, + serverProjection: null, + threadStatus: "live", + activeActivityRun: null, + activeRunningTurnId: null, + timelineEntries: entries, + timelineAnchorMessageId: null, + setTimelineAnchor: () => {}, + }); + return ( +
+ +
+ ); +} + function setup({ scheduledTasks = [] as ReadonlyArray<{ id: string; title: string }> } = {}) { environmentQuery.data = { tasks: scheduledTasks }; } @@ -220,6 +269,41 @@ describe("MessagesTimeline messages", () => { }); }); +describe("timeline live follow", () => { + it("lets a wheel up break follow when the list mounts after a slow hydration", async () => { + const entries = Array.from({ length: 40 }, (_, index) => + message(`Paragraph ${index}\n\n${"lorem ipsum ".repeat(60)}`, {}, `message-${index}`), + ); + const container = document.createElement("div"); + container.style.cssText = + "height: 1200px; width: 900px; display: flex; flex-direction: column;"; + let screen: Awaited>; + { + // Hydration can outlast any fixed frame budget before the list mounts. + using _timers = fakeTimers(); + screen = await render(, { container }); + expect(container.querySelector(".messages-timeline-scroll")).toBeNull(); + await act(async () => vi.advanceTimersByTime(16 * 20)); + } + await using _screen = screen; + await screen.rerender(); + const scrollNode = container.querySelector(".messages-timeline-scroll"); + if (!scrollNode) throw new Error("Timeline did not mount"); + await expect.poll(() => scrollNode.scrollHeight > scrollNode.clientHeight).toBe(true); + const follow = () => container.firstElementChild?.getAttribute("data-live-follow"); + expect(follow()).toBe("true"); + const row = scrollNode.querySelector("[data-timeline-root]"); + if (!row) throw new Error("No timeline row"); + // LegendList measures its viewport a frame after mount; retry until the gate sees overflow. + await vi.waitFor(async () => { + await act(async () => { + row.dispatchEvent(new WheelEvent("wheel", { deltaY: -120, bubbles: true })); + }); + expect(follow()).toBe("false"); + }); + }); +}); + describe("MessagesTimeline automation prompts", () => { it("folds an automation prompt into a bubble titled by its task", async () => { setup({ scheduledTasks: [{ id: TASK_ID, title: "Nightly triage" }] }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c3e2007c9aad8..0ceef27626b11 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -251,6 +251,8 @@ interface MessagesTimelineProps { onContentOverflowChange?: (overflows: boolean) => void; onToolOutputCollapsedAtEnd?: () => void; onManualNavigation: () => void; + /** Reports the list's scroll node when it mounts, remounts, or unmounts. */ + onScrollNodeChange?: (node: HTMLElement | null) => void; findOpen?: boolean; cancelPositionRestoreRef?: React.RefObject<(() => void) | null> | undefined; hideEmptyPlaceholder?: boolean; @@ -346,6 +348,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ liveFollowEnabled, onToolOutputCollapsedAtEnd, onManualNavigation, + onScrollNodeChange, cancelPositionRestoreRef, hideEmptyPlaceholder = false, emptyPlaceholderInset, @@ -981,10 +984,14 @@ const ConversationTimeline = memo(function ConversationTimeline({ const canvas = useChatCanvas(); const registerTimeline = canvas?.registerTimeline; - const setTimelineList = (list: LegendListRef | null) => { + // A stable ref callback: a fresh identity would make React report null and + // then the node again on every render. + const setTimelineList = useStableCallback((list: LegendListRef | null) => { listRef.current = list; - registerTimeline?.(list?.getScrollableNode() ?? null); - }; + const scrollNode = list?.getScrollableNode() ?? null; + registerTimeline?.(scrollNode); + onScrollNodeChange?.(scrollNode); + }); useThreadFindPositionReader({ findPositionReaderRef, diff --git a/apps/web/src/components/chat/useTimelineFollow.ts b/apps/web/src/components/chat/useTimelineFollow.ts index 191b1d2e8ea48..9f5f6c3d25f87 100644 --- a/apps/web/src/components/chat/useTimelineFollow.ts +++ b/apps/web/src/components/chat/useTimelineFollow.ts @@ -4,7 +4,7 @@ import { TYPE_TO_FOCUS_EDITABLE_SELECTOR, TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR, } from "./composerFocusRedirect"; -import { type MessageId, type ThreadId, type RunId } from "@t3tools/contracts"; +import { type MessageId, type RunId } from "@t3tools/contracts"; import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; import { Debouncer } from "@tanstack/react-pacer"; import { @@ -50,7 +50,6 @@ interface TimelineFollowInput { readonly composerTimelineInset: number; readonly routeThreadKey: string; readonly activeThreadKey: string | null; - readonly activeThreadId: ThreadId | undefined; readonly isServerThread: boolean; readonly serverProjection: ThreadStructure | null; readonly threadStatus: EnvironmentThreadStatus; @@ -74,7 +73,6 @@ export function useTimelineFollow(input: TimelineFollowInput) { composerTimelineInset, routeThreadKey, activeThreadKey, - activeThreadId, isServerThread, serverProjection, threadStatus, @@ -278,192 +276,172 @@ export function useTimelineFollow(input: TimelineFollowInput) { }); }; const scrollToEndStable = useStableCallback(scrollToEnd); + // The list reports its scroll node through a ref callback, so a thread that + // is still hydrating (no list mounted yet) or a remounted list attaches the + // opt-out listeners once the node exists. Polling frames after mount gave up + // before slow hydrations finished, and live-follow then had no way out. + const [timelineScrollNode, setTimelineScrollNode] = useState(null); useEffect(() => { + const scrollNode = timelineScrollNode; + if (!scrollNode) return; const composer = composerRef; - let removeListeners: (() => void) | null = null; - let frame: number | null = null; - const attach = (remainingAttempts: number) => { - frame = requestAnimationFrame(() => { - frame = null; - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - // The list may not have mounted on the first frame after a thread - // switch — without a retry the opt-out listeners never attach and - // live-follow becomes impossible to escape for the whole thread. - if (remainingAttempts > 0) { - attach(remainingAttempts - 1); - } - return; + const handleManualNavigation = () => { + cancelTimelineLiveFollowForUserNavigationRef.current(); + }; + // The gestures below must only break follow when they can actually + // move the viewport away from the live edge (#5566): a spurious break + // while pinned at the end produces no scroll event, never re-arms, + // and streaming silently stops following. Underflowing content can't + // scroll at all, so nothing there should break follow. + const contentScrollsUp = () => timelineRealContentOverflowsViewportStable(); + // The follow re-arm band, not the strict flag: streaming growth makes + // isAtEnd flicker false for a frame before the follow scroll catches + // up, and a gesture landing in that window while still pinned would + // otherwise break follow with no scroll event left to re-arm it. + const viewportIsAwayFromEnd = () => + resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; + const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) return; + if (event.deltaY > 0) { + timelineScrollIntentRef.current = "toward-end"; + if (isAtEndRef.current) { + composer.current?.restoreAfterTimelineReachedEnd(); } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - // The gestures below must only break follow when they can actually - // move the viewport away from the live edge (#5566): a spurious break - // while pinned at the end produces no scroll event, never re-arms, - // and streaming silently stops following. Underflowing content can't - // scroll at all, so nothing there should break follow. - const contentScrollsUp = () => timelineRealContentOverflowsViewportStable(); - // The follow re-arm band, not the strict flag: streaming growth makes - // isAtEnd flicker false for a frame before the follow scroll catches - // up, and a gesture landing in that window while still pinned would - // otherwise break follow with no scroll event left to re-arm it. - const viewportIsAwayFromEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; - const handleWheel = (event: WheelEvent) => { - if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) - return; - if (event.deltaY > 0) { - timelineScrollIntentRef.current = "toward-end"; - if (isAtEndRef.current) { - composer.current?.restoreAfterTimelineReachedEnd(); - } - } else if (event.deltaY < 0) { - timelineScrollIntentRef.current = "away-from-end"; - } - if (event.deltaY < 0 && contentScrollsUp()) { - handleManualNavigation(); - } - }; - // Touch direction isn't observable here (touchmove fires on any - // finger motion, scrolling or not), so break only once the drag has - // actually carried the viewport out of the end band — an upward flick - // gets there within its first few events and later touchmoves break. - const handleTouchMove = () => { - if (viewportIsAwayFromEnd()) { + } else if (event.deltaY < 0) { + timelineScrollIntentRef.current = "away-from-end"; + } + if (event.deltaY < 0 && contentScrollsUp()) { + handleManualNavigation(); + } + }; + // Touch direction isn't observable here (touchmove fires on any + // finger motion, scrolling or not), so break only once the drag has + // actually carried the viewport out of the end band — an upward flick + // gets there within its first few events and later touchmoves break. + const handleTouchMove = () => { + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Scrollbar drags produce no wheel/touch events; they are the only + // pointerdowns whose target is the scroll node itself rather than a + // message row. + const handlePointerDown = (event: PointerEvent) => { + if (event.target === scrollNode) { + if (contentScrollsUp()) { + handleManualNavigation(); + } + return; + } + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and + // pointer events entirely; without this the timeline yanks back to + // the end on the next stream chunk. Clicking message text can leave + // DOM focus on body, so these keys must also be heard at document. + const handleKeyDown = (event: KeyboardEvent) => { + if ( + !(event.target instanceof Node) || + (!scrollNode.contains(event.target) && + event.target !== document.body && + event.target !== document.documentElement) || + event.defaultPrevented || + event.isComposing || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) || + document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR) + ) { + return; + } + if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key)) + return; + const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1; + if ( + scrollNode.contains(event.target) && + !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) + ) + return; + switch (event.key) { + case "PageUp": + case "Home": + case "ArrowUp": + timelineScrollIntentRef.current = "away-from-end"; + if (contentScrollsUp()) { handleManualNavigation(); + composer.current?.collapseForTimelineScrollKey(event.key); } - }; - // Scrollbar drags produce no wheel/touch events; they are the only - // pointerdowns whose target is the scroll node itself rather than a - // message row. - const handlePointerDown = (event: PointerEvent) => { - if (event.target === scrollNode) { - if (contentScrollsUp()) { - handleManualNavigation(); - } - return; - } + break; + case "PageDown": + case "End": + case "ArrowDown": + timelineScrollIntentRef.current = "toward-end"; if (viewportIsAwayFromEnd()) { handleManualNavigation(); } - }; - // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and - // pointer events entirely; without this the timeline yanks back to - // the end on the next stream chunk. Clicking message text can leave - // DOM focus on body, so these keys must also be heard at document. - const handleKeyDown = (event: KeyboardEvent) => { - if ( - !(event.target instanceof Node) || - (!scrollNode.contains(event.target) && - event.target !== document.body && - event.target !== document.documentElement) || - event.defaultPrevented || - event.isComposing || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) || - document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR) - ) { - return; - } - if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key)) - return; - const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1; - if ( - scrollNode.contains(event.target) && - !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) - ) - return; - switch (event.key) { - case "PageUp": - case "Home": - case "ArrowUp": - timelineScrollIntentRef.current = "away-from-end"; - if (contentScrollsUp()) { - handleManualNavigation(); - composer.current?.collapseForTimelineScrollKey(event.key); - } - break; - case "PageDown": - case "End": - case "ArrowDown": - timelineScrollIntentRef.current = "toward-end"; - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - composer.current?.collapseForTimelineScrollKey(event.key); - if (isTimelineAtLogicalEndStable()) { - composer.current?.restoreAfterTimelineReachedEnd(); - } - break; - default: - break; + composer.current?.collapseForTimelineScrollKey(event.key); + if (isTimelineAtLogicalEndStable()) { + composer.current?.restoreAfterTimelineReachedEnd(); } - }; - // Input inside an embedded frame reaches none of the listeners above, - // but its scroll still chains out to the timeline (#17056). - const scrolledUp = createUpwardScrollDetector({ + break; + default: + break; + } + }; + // Input inside an embedded frame reaches none of the listeners above, + // but its scroll still chains out to the timeline (#17056). + const scrolledUp = createUpwardScrollDetector({ + top: scrollNode.scrollTop, + height: scrollNode.scrollHeight, + }); + const handleScroll = () => { + if ( + scrolledUp({ top: scrollNode.scrollTop, height: scrollNode.scrollHeight, - }); - const handleScroll = () => { - if ( - scrolledUp({ - top: scrollNode.scrollTop, - height: scrollNode.scrollHeight, - programmatic: - positionedTimelineAnchorRef.current !== null && - positionedTimelineAnchorRef.current !== settledTimelineAnchorRef.current, - }) && - timelineScrollModeRef.current !== "free-scrolling" && - // LegendList updates its scroll state on the next animation frame. - resolveTimelineIsAtEnd({ - contentLength: scrollNode.scrollHeight, - scroll: scrollNode.scrollTop, - scrollLength: scrollNode.clientHeight, - }) === false - ) { - handleManualNavigation(); - } - }; - scrollNode.addEventListener("scroll", handleScroll, { passive: true }); - scrollNode.addEventListener("wheel", handleWheel, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleTouchMove, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handlePointerDown, { - passive: true, - }); - document.addEventListener("keydown", handleKeyDown); - removeListeners = () => { - scrollNode.removeEventListener("scroll", handleScroll); - scrollNode.removeEventListener("wheel", handleWheel); - scrollNode.removeEventListener("touchmove", handleTouchMove); - scrollNode.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }); + programmatic: + positionedTimelineAnchorRef.current !== null && + positionedTimelineAnchorRef.current !== settledTimelineAnchorRef.current, + }) && + timelineScrollModeRef.current !== "free-scrolling" && + // LegendList updates its scroll state on the next animation frame. + resolveTimelineIsAtEnd({ + contentLength: scrollNode.scrollHeight, + scroll: scrollNode.scrollTop, + scrollLength: scrollNode.clientHeight, + }) === false + ) { + handleManualNavigation(); + } }; - attach(12); - + scrollNode.addEventListener("scroll", handleScroll, { passive: true }); + scrollNode.addEventListener("wheel", handleWheel, { + passive: true, + }); + scrollNode.addEventListener("touchmove", handleTouchMove, { + passive: true, + }); + scrollNode.addEventListener("pointerdown", handlePointerDown, { + passive: true, + }); + document.addEventListener("keydown", handleKeyDown); return () => { - if (frame !== null) { - cancelAnimationFrame(frame); - } - removeListeners?.(); + scrollNode.removeEventListener("scroll", handleScroll); + scrollNode.removeEventListener("wheel", handleWheel); + scrollNode.removeEventListener("touchmove", handleTouchMove); + scrollNode.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); }; }, [ - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A thread switch remounts the list's scroll node, so the listeners reattach. - activeThreadId, composerRef, isTimelineAtLogicalEndStable, legendListRef, timelineRealContentOverflowsViewportStable, + timelineScrollNode, ]); const onTimelineAnchorReady = (messageId: MessageId, anchorIndex: number) => { @@ -670,5 +648,6 @@ export function useTimelineFollow(input: TimelineFollowInput) { onToolOutputCollapsedAtEndStable, onIsAtEndChangeStable, anchorNewTurn, + setTimelineScrollNode, }; }