Skip to content

Commit 5527b28

Browse files
committed
Fix session switch chat scroll
1 parent ea79bce commit 5527b28

2 files changed

Lines changed: 121 additions & 15 deletions

File tree

apps/web/src/app/data-tasks/__tests__/chat-scroll.test.ts

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,27 @@ function createScrollFixture() {
1111
parentElement: null as HTMLElement | null,
1212
} as unknown as HTMLElement;
1313

14+
const scrollCalls: ScrollToOptions[] = [];
1415
const scroll = {
15-
style: { overflowY: "auto" },
16+
style: { overflowY: "auto", scrollBehavior: "" },
1617
scrollTop: 0,
1718
scrollHeight: 400,
18-
scrollTo({ top }: ScrollToOptions) {
19+
scrollTo(options: ScrollToOptions) {
20+
scrollCalls.push(options);
21+
const { top } = options;
1922
this.scrollTop = top ?? 0;
2023
},
2124
parentElement: null,
2225
} as unknown as HTMLElement;
2326

2427
content.parentElement = scroll;
2528
const root = {
26-
querySelector(selector: string) {
27-
return selector === '[data-testid="copilot-scroll-content"]' ? content : null;
29+
querySelectorAll(selector: string) {
30+
return selector === '[data-testid="copilot-scroll-content"]' ? [content] : [];
2831
},
2932
} as unknown as ParentNode;
3033

31-
return { root, scroll };
34+
return { root, scroll, scrollCalls };
3235
}
3336

3437
describe("chat scroll helpers", () => {
@@ -43,6 +46,67 @@ describe("chat scroll helpers", () => {
4346
expect(scroll.scrollTop).toBe(scroll.scrollHeight);
4447
});
4548

49+
it("uses instant scroll even when the container has smooth scroll behavior", () => {
50+
const { root, scroll, scrollCalls } = createScrollFixture();
51+
scroll.style.scrollBehavior = "smooth";
52+
53+
expect(scrollCopilotChatToBottom(root)).toBe(true);
54+
expect(scroll.scrollTop).toBe(scroll.scrollHeight);
55+
expect(scrollCalls).toEqual([{ top: scroll.scrollHeight, behavior: "auto" }]);
56+
expect(scroll.style.scrollBehavior).toBe("smooth");
57+
});
58+
59+
it("skips hidden chat sessions when finding the active scroll container", () => {
60+
const hiddenContent = {
61+
dataset: { testid: "copilot-scroll-content" },
62+
parentElement: null as HTMLElement | null,
63+
} as unknown as HTMLElement;
64+
const hiddenScroll = {
65+
style: { overflowY: "auto", display: "none" },
66+
parentElement: null,
67+
} as unknown as HTMLElement;
68+
hiddenContent.parentElement = hiddenScroll;
69+
70+
const { scroll, root } = createScrollFixture();
71+
const scopedRoot = {
72+
querySelectorAll(selector: string) {
73+
return selector === '[data-testid="copilot-scroll-content"]'
74+
? [hiddenContent, ...(root.querySelectorAll(selector) as unknown as HTMLElement[])]
75+
: [];
76+
},
77+
} as unknown as ParentNode;
78+
79+
expect(findCopilotChatScrollContainer(scopedRoot)).toBe(scroll);
80+
});
81+
82+
it("prefers the ancestor that actually scrolls over a non-scrolling overflow wrapper", () => {
83+
const content = {
84+
dataset: { testid: "copilot-scroll-content" },
85+
parentElement: null as HTMLElement | null,
86+
} as unknown as HTMLElement;
87+
const innerOverflow = {
88+
style: { overflowY: "auto" },
89+
scrollHeight: 800,
90+
clientHeight: 800,
91+
parentElement: null as HTMLElement | null,
92+
} as unknown as HTMLElement;
93+
const scrollViewport = {
94+
style: { overflowY: "auto" },
95+
scrollHeight: 800,
96+
clientHeight: 300,
97+
parentElement: null,
98+
} as unknown as HTMLElement;
99+
content.parentElement = innerOverflow;
100+
innerOverflow.parentElement = scrollViewport;
101+
const root = {
102+
querySelectorAll(selector: string) {
103+
return selector === '[data-testid="copilot-scroll-content"]' ? [content] : [];
104+
},
105+
} as unknown as ParentNode;
106+
107+
expect(findCopilotChatScrollContainer(root)).toBe(scrollViewport);
108+
});
109+
46110
it("retries scrolling until attempts are exhausted", () => {
47111
const { root, scroll } = createScrollFixture();
48112
const calls: number[] = [];

apps/web/src/app/data-tasks/chat-scroll.ts

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,54 @@ function readOverflowY(element: HTMLElement): string {
1313
return "";
1414
}
1515

16+
function isRendered(element: HTMLElement): boolean {
17+
let current: HTMLElement | null = element;
18+
while (current) {
19+
if (current.style?.display === "none") {
20+
return false;
21+
}
22+
if (typeof getComputedStyle === "function") {
23+
const style = getComputedStyle(current);
24+
if (style.display === "none" || style.visibility === "hidden") {
25+
return false;
26+
}
27+
}
28+
current = current.parentElement;
29+
}
30+
return true;
31+
}
32+
33+
function findScrollableAncestor(element: HTMLElement): HTMLElement | null {
34+
let current = element.parentElement;
35+
let fallback: HTMLElement | null = null;
36+
while (current) {
37+
if (isScrollableOverflow(readOverflowY(current))) {
38+
fallback ??= current;
39+
if (current.scrollHeight > current.clientHeight) {
40+
return current;
41+
}
42+
}
43+
current = current.parentElement;
44+
}
45+
return fallback;
46+
}
47+
1648
export function findCopilotChatScrollContainer(
1749
root: ParentNode = document,
1850
): HTMLElement | null {
19-
const content = root.querySelector('[data-testid="copilot-scroll-content"]');
20-
if (!content || typeof content !== "object" || !("parentElement" in content)) {
21-
return null;
22-
}
23-
24-
let element = (content as HTMLElement).parentElement;
25-
while (element) {
26-
if (isScrollableOverflow(readOverflowY(element))) {
27-
return element;
51+
const contents = root.querySelectorAll('[data-testid="copilot-scroll-content"]');
52+
for (const content of contents) {
53+
if (typeof content !== "object" || !("parentElement" in content)) {
54+
continue;
55+
}
56+
const element = content as HTMLElement;
57+
if (!isRendered(element)) {
58+
continue;
59+
}
60+
const scrollContainer = findScrollableAncestor(element);
61+
if (scrollContainer) {
62+
return scrollContainer;
2863
}
29-
element = element.parentElement;
3064
}
3165

3266
return null;
@@ -41,10 +75,18 @@ export function scrollCopilotChatToBottom(
4175
return false;
4276
}
4377

78+
const previousScrollBehavior = container.style.scrollBehavior;
79+
if (behavior === "auto") {
80+
container.style.scrollBehavior = "auto";
81+
}
82+
container.scrollTop = container.scrollHeight;
4483
container.scrollTo({
4584
top: container.scrollHeight,
4685
behavior,
4786
});
87+
if (behavior === "auto") {
88+
container.style.scrollBehavior = previousScrollBehavior;
89+
}
4890
return true;
4991
}
5092

0 commit comments

Comments
 (0)