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
2 changes: 2 additions & 0 deletions src/features/messages/Messages.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,8 @@ html[data-keyboard-navigation] .attachmentImage:focus-visible {
overflow: auto;
padding: 0 var(--space-panel-inset);
padding-bottom: var(--space-panel-inset);
/* Older-history corrections hide overflow for a task (scroll-correction.ts). */
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--text) 20%, transparent) transparent;
}
Expand Down
8 changes: 6 additions & 2 deletions src/features/messages/ThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type { PageNavigation } from "../navigation/service";
import { messageViewKey } from "./view-key";
import { useKnownAgentPubkeys } from "../agents/use-known";
import { JumpToLatestButton } from "./JumpToLatestButton";
import { correctScrollTop } from "./scroll-correction";

export type ThreadPanelProps = {
extensions?: ConversationExtensions | undefined;
Expand Down Expand Up @@ -584,14 +585,17 @@ function ThreadMessages({
...element.querySelectorAll<HTMLElement>("[data-message-id]"),
].find((row) => row.dataset.messageId === anchor.id);
if (row)
element.scrollTop += row.getBoundingClientRect().top - anchor.top;
correctScrollTop(
element,
row.getBoundingClientRect().top - anchor.top,
);
if (snapshot.status !== "loading") olderAnchor.current = undefined;
}
}
if (targetAnchor.current !== undefined) {
const offset = selectedOffset();
if (offset !== undefined) {
element.scrollTop += offset - targetAnchor.current;
correctScrollTop(element, offset - targetAnchor.current);
targetAnchor.current = offset;
follow.current = false;
}
Expand Down
91 changes: 91 additions & 0 deletions src/features/messages/scroll-correction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { correctScrollTop } from "./scroll-correction";

const stubNavigator = (platform: string, vendor: string, maxTouchPoints = 0) =>
vi.stubGlobal("navigator", { platform, vendor, maxTouchPoints });

function scroller() {
const element = document.createElement("section");
let top = 100;
Object.defineProperty(element, "scrollTop", {
get: () => top,
set: (value: number) => {
top = value;
},
});
return element;
}

const overflow = (element: HTMLElement) => [
element.style.getPropertyValue("overflow-y"),
element.style.getPropertyPriority("overflow-y"),
];

beforeEach(() => {
vi.useFakeTimers();
stubNavigator("MacIntel", "Apple Computer, Inc.");
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it("interrupts Mac WebKit momentum for one task around the correction", () => {
const element = scroller();
element.style.setProperty("overflow-y", "scroll", "important");

correctScrollTop(element, 40);

expect(element.scrollTop).toBe(140);
expect(overflow(element)).toEqual(["hidden", "important"]);
vi.runAllTimers();
expect(overflow(element)).toEqual(["scroll", "important"]);
});

it("restores the original declaration across overlapping corrections", () => {
const element = scroller();

correctScrollTop(element, 40);
correctScrollTop(element, -10);

expect(element.scrollTop).toBe(130);
expect(overflow(element)).toEqual(["hidden", "important"]);
vi.runAllTimers();
expect(element.getAttribute("style")).toBe("");
});

it("keeps a declaration written after the correction", () => {
const element = scroller();

correctScrollTop(element, 40);
element.style.overflowY = "auto";
vi.runAllTimers();

expect(element.style.getPropertyValue("overflow-y")).toBe("auto");
});

it.each([
["Chromium", "MacIntel", "Google Inc.", 0],
["desktop-mode iPad", "MacIntel", "Apple Computer, Inc.", 5],
["non-Mac WebKit", "Linux x86_64", "Apple Computer, Inc.", 0],
])("leaves %s momentum alone", (_, platform, vendor, touchPoints) => {
stubNavigator(platform, vendor, touchPoints);
const element = scroller();

correctScrollTop(element, 40);

expect(element.scrollTop).toBe(140);
expect(element.getAttribute("style")).toBeNull();
expect(vi.getTimerCount()).toBe(0);
});

it("ignores a zero correction", () => {
const element = scroller();

correctScrollTop(element, 0);

expect(element.scrollTop).toBe(100);
expect(element.getAttribute("style")).toBeNull();
});
37 changes: 37 additions & 0 deletions src/features/messages/scroll-correction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const restores = new WeakMap<HTMLElement, () => void>();
const macWebKit = () =>
navigator.platform === "MacIntel" &&
!navigator.maxTouchPoints && // desktop-mode iPad keeps its momentum policy
navigator.vendor === "Apple Computer, Inc.";

/**
* Applies an automatic scroll correction. Mac WebKit can leave the corrected
* viewport unpainted while native momentum continues, so stop the momentum the
* same way the Virtua patch does (patches/README.md): hide vertical overflow for
* one task. The scroller must keep its inline size (`scrollbar-gutter: stable`).
*/
export function correctScrollTop(element: HTMLElement, delta: number) {
if (!delta) return;
if (macWebKit()) {
// Corrections in one task share the original declaration.
restores.get(element)?.();
const { style } = element;
const value = style.getPropertyValue("overflow-y");
const priority = style.getPropertyPriority("overflow-y");
style.setProperty("overflow-y", "hidden", "important");
const restore = () => {
clearTimeout(timer);
restores.delete(element);
if (
style.getPropertyValue("overflow-y") === "hidden" &&
style.getPropertyPriority("overflow-y") === "important"
)
value
? style.setProperty("overflow-y", value, priority)
: style.removeProperty("overflow-y");
};
const timer = setTimeout(restore);
restores.set(element, restore);
}
element.scrollTop += delta;
}
Loading