diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6a021ef7e5e2..d5e9b29ba19c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -433,7 +433,7 @@ import { import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; -import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; +import { createUpwardScrollDetector, isTimelineScrollTarget } from "./chat/timelineScrollTarget"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -6621,6 +6621,22 @@ export default function ChatView(props: ChatViewProps) { 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 }) && + timelineScrollModeRef.current !== "free-scrolling" && + viewportIsAwayFromEnd() + ) { + handleManualNavigation(); + } + }; + scrollNode.addEventListener("scroll", handleScroll, { passive: true }); scrollNode.addEventListener("wheel", handleWheel, { passive: true, }); @@ -6632,6 +6648,7 @@ export default function ChatView(props: ChatViewProps) { }); document.addEventListener("keydown", handleKeyDown); removeListeners = () => { + scrollNode.removeEventListener("scroll", handleScroll); scrollNode.removeEventListener("wheel", handleWheel); scrollNode.removeEventListener("touchmove", handleTouchMove); scrollNode.removeEventListener("pointerdown", handlePointerDown); diff --git a/apps/web/src/components/chat/timelineScrollTarget.test.ts b/apps/web/src/components/chat/timelineScrollTarget.test.ts index 051f7785f5a3..b3c3a2c1f9fc 100644 --- a/apps/web/src/components/chat/timelineScrollTarget.test.ts +++ b/apps/web/src/components/chat/timelineScrollTarget.test.ts @@ -4,7 +4,7 @@ import { createComposerScrollGestureState, recordComposerScrollGestureEvent, } from "./composerScrollGesture"; -import { isTimelineScrollTarget } from "./timelineScrollTarget"; +import { createUpwardScrollDetector, isTimelineScrollTarget } from "./timelineScrollTarget"; class ScrollElement extends EventTarget { scrollTop = 0; @@ -140,3 +140,38 @@ describe("timeline scroll targets", () => { expect(record(content, 60, 14)).toBe(true); }); }); + +describe("createUpwardScrollDetector", () => { + it("reports a reader scrolling up, such as a scroll chained out of an embedded frame", () => { + const scrolledUp = createUpwardScrollDetector({ top: 6621, height: 7421 }); + expect(scrolledUp({ top: 6471, height: 7421 })).toBe(true); + expect(scrolledUp({ top: 6321, height: 7421 })).toBe(true); + }); + + it("ignores moves toward the end and content growing while it follows", () => { + const scrolledUp = createUpwardScrollDetector({ top: 1000, height: 2000 }); + expect(scrolledUp({ top: 1000, height: 2400 })).toBe(false); + expect(scrolledUp({ top: 1400, height: 2400 })).toBe(false); + }); + + it("ignores the offset clamping up when content above the end shrinks", () => { + const scrolledUp = createUpwardScrollDetector({ top: 1400, height: 2400 }); + expect(scrolledUp({ top: 1100, height: 2100 })).toBe(false); + // The next upward move is measured from the clamped position. + expect(scrolledUp({ top: 900, height: 2100 })).toBe(true); + }); + + it("ignores subpixel jitter", () => { + const scrolledUp = createUpwardScrollDetector({ top: 500.5, height: 2000 }); + expect(scrolledUp({ top: 500, height: 2000 })).toBe(false); + expect(scrolledUp({ top: 500.5, height: 2000 })).toBe(false); + expect(scrolledUp({ top: 500, height: 2000 })).toBe(false); + }); + + it("adds up a slow scroll that moves under a pixel per event", () => { + const scrolledUp = createUpwardScrollDetector({ top: 500, height: 2000 }); + expect(scrolledUp({ top: 499.5, height: 2000 })).toBe(false); + expect(scrolledUp({ top: 499, height: 2000 })).toBe(false); + expect(scrolledUp({ top: 498.5, height: 2000 })).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/timelineScrollTarget.ts b/apps/web/src/components/chat/timelineScrollTarget.ts index da87e9a7036d..0c00fda095a5 100644 --- a/apps/web/src/components/chat/timelineScrollTarget.ts +++ b/apps/web/src/components/chat/timelineScrollTarget.ts @@ -29,3 +29,28 @@ export function isTimelineScrollTarget( } return true; } + +/** + * Reports, for each timeline scroll, whether the reader moved it up. A scroll + * that chains out of an embedded frame (an HTML render or MCP app) reaches the + * timeline with no wheel, touch, or pointer event it can see. Following only + * moves toward the end, and shrinking content can only pull the offset up by + * clamping, so an upward move while the content kept its height is the reader. + */ +export function createUpwardScrollDetector(initial: { top: number; height: number }) { + let baseline = initial; + return (next: { top: number; height: number }) => { + // Shrinking content or a move toward the end starts a new measurement. + if (next.height < baseline.height || next.top > baseline.top) { + baseline = next; + return false; + } + if (next.top < baseline.top - 1) { + baseline = next; + return true; + } + // A slow scroll can move under a pixel per event; let those moves add up. + baseline = { top: baseline.top, height: next.height }; + return false; + }; +}