Skip to content
Open
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
19 changes: 18 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});
Expand All @@ -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);
Expand Down
37 changes: 36 additions & 1 deletion apps/web/src/components/chat/timelineScrollTarget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
createComposerScrollGestureState,
recordComposerScrollGestureEvent,
} from "./composerScrollGesture";
import { isTimelineScrollTarget } from "./timelineScrollTarget";
import { createUpwardScrollDetector, isTimelineScrollTarget } from "./timelineScrollTarget";

class ScrollElement extends EventTarget {
scrollTop = 0;
Expand Down Expand Up @@ -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);
});
});
25 changes: 25 additions & 0 deletions apps/web/src/components/chat/timelineScrollTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}
Loading