Skip to content

Commit 5057c0c

Browse files
committed
Anchor manual scroll compensation to in-message descendants
The Safari manual-anchoring fallback tracked only top-level message wrappers. With the viewport top inside a long assistant message, a thinking block collapsing (or an image loading) above the reading position within that same message leaves the wrapper's top unchanged — delta reads zero and the jump survives. Descend from the straddling wrapper to the deepest element at the viewport top (linear scans below the top level, where children are few and can sit out of flow), which is what native scroll anchoring does. Regression test covers the in-message collapse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148VmxBDazfhczvfE122XVW
1 parent cf5bde5 commit 5057c0c

2 files changed

Lines changed: 89 additions & 4 deletions

File tree

src/lib/utils/scroll/__tests__/chatScroll.svelte.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,69 @@ describe("thinking-block collapse (layout-shift regressions)", () => {
443443
const after = reading.getBoundingClientRect().top - containerTop;
444444
expect(Math.abs(after - before)).toBeLessThanOrEqual(2);
445445
});
446+
447+
it("manual anchoring tracks a descendant INSIDE a long message, not just the wrapper", async () => {
448+
// Reading the middle of one long assistant message whose own thinking
449+
// block collapses above the reading position: the wrapper's top never
450+
// moves, so a wrapper-level anchor would read delta 0 and let the text
451+
// jump — the anchor must be the descendant at the viewport top.
452+
const style = document.createElement("style");
453+
style.textContent = ".sim-safari { overflow-anchor: none !important; }";
454+
document.head.appendChild(style);
455+
456+
const fixture = createFixture({ viewportHeight: 400, blocks: [] });
457+
fixture.container.classList.add("sim-safari");
458+
const chat = createChatScroll({ forceManualAnchoring: true });
459+
460+
const mkInner = (height: number) => {
461+
const el = document.createElement("div");
462+
el.style.cssText = `height: ${height}px; flex-shrink: 0; background: #eef;`;
463+
return el;
464+
};
465+
const message = document.createElement("div");
466+
message.style.cssText = "display: flex; flex-direction: column; flex-shrink: 0;";
467+
message.dataset.messageId = "long-message";
468+
const thinking = mkInner(400);
469+
const reading = mkInner(300);
470+
message.append(thinking, reading, mkInner(900));
471+
fixture.content.appendChild(message);
472+
473+
const spacerAction = chat.attachSpacer(fixture.spacer);
474+
const containerAction = chat.attach(fixture.container, { content: () => fixture.content });
475+
chat.sync({ conversationKey: "c1", messages: [], lastMessageEmpty: false });
476+
active.push({
477+
fixture,
478+
chat,
479+
messages: [],
480+
sync: () => {},
481+
mountPair: () => ({
482+
user: document.createElement("div"),
483+
assistant: document.createElement("div"),
484+
}),
485+
swapAssistant: () => document.createElement("div"),
486+
viewportTop: () => fixture.container.getBoundingClientRect().top,
487+
destroy() {
488+
containerAction.destroy();
489+
spacerAction.destroy();
490+
fixture.destroy();
491+
style.remove();
492+
},
493+
});
494+
await waitFor(() => fixture.distance() <= ARRIVED, { label: "settle" });
495+
await nextTask();
496+
497+
const containerTop = fixture.container.getBoundingClientRect().top;
498+
const target = reading.getBoundingClientRect().top - containerTop + fixture.container.scrollTop;
499+
dragScrollbarTo(fixture.container, target);
500+
await frames(2);
501+
expect(chat.state.pinned).toBe(false);
502+
const before = reading.getBoundingClientRect().top - containerTop;
503+
504+
thinking.style.height = "50px"; // collapse INSIDE the same message
505+
await frames(3);
506+
const after = reading.getBoundingClientRect().top - containerTop;
507+
expect(Math.abs(after - before)).toBeLessThanOrEqual(2);
508+
});
446509
});
447510

448511
describe("composer clearance", () => {

src/lib/utils/scroll/chatScroll.svelte.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,10 @@ export class ChatScroll {
174174
};
175175
};
176176

177-
/** Record the message element at the viewport top (binary search over the
178-
* content children: they are in document order). Only while detached —
179-
* pinned following owns the bottom edge instead. */
177+
/** Record the element at the viewport top (binary search over the content
178+
* children — they are in document order — then a descent into the
179+
* straddling message). Only while detached — pinned following owns the
180+
* bottom edge instead. */
180181
private trackReadAnchor = () => {
181182
if (!this.manualAnchoring) return;
182183
if (this.state.pinned || !this.container) {
@@ -202,10 +203,31 @@ export class ChatScroll {
202203
lo = mid + 1;
203204
}
204205
}
205-
const el = children[found];
206+
// Descend into the straddling message: anchoring the outer wrapper
207+
// misses changes INSIDE it — a thinking block collapsing above the
208+
// reading position within the same long message leaves the wrapper's
209+
// top unchanged while the visible paragraph moves. Native anchoring
210+
// anchors deep descendants; do the same. Linear scans here (unlike the
211+
// top level) because in-message children are few and can be positioned
212+
// out of flow, which breaks the binary search's ordering assumption.
213+
let el: Element = children[found];
214+
for (let depth = 0; depth < 8; depth++) {
215+
const inner = this.firstChildBelow(el, containerTop);
216+
if (!inner) break;
217+
el = inner;
218+
}
206219
this.readAnchor = { el, offset: el.getBoundingClientRect().top - containerTop };
207220
};
208221

222+
private firstChildBelow(parent: Element, containerTop: number): Element | null {
223+
const kids = parent.children;
224+
for (let i = 0; i < kids.length; i++) {
225+
const rect = kids[i].getBoundingClientRect();
226+
if (rect.height > 0 && rect.bottom > containerTop + 1) return kids[i];
227+
}
228+
return null;
229+
}
230+
209231
/** After a content resize while detached, restore the tracked element's
210232
* viewport position — Safari's stand-in for native scroll anchoring. The
211233
* adjustment goes through the controller, so attribution stays sound (it

0 commit comments

Comments
 (0)