Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 03b7bf9a92 fix(app): keep delayed navigation behind user scroll intent 2026-11-15 18:56:43 +01:00
Mohamed Boudra a01098ed31 fix(app): preserve submit scroll through existing momentum
A prompt sent during an active gesture requested the bottom, but the older gesture cancelled that request when momentum ended away. Honor the still-sticky pending request on release while allowing a newer gesture to take ownership.
2026-11-15 18:45:57 +01:00
3 changed files with 62 additions and 5 deletions
+4 -2
View File
@@ -156,8 +156,10 @@ keeps following.
Following resumes only for a gesture that ends within 1 px of the end, either when the finger lifts
or when momentum stops. A release past the end counts: iOS bounces back to the end, and lines that
land during the bounce must keep scrolling in. A release anywhere short of the end stays detached
with the scroll-to-bottom pill, so the rows the user let go of never move.
land during the bounce must keep scrolling in. A release short of the end stays detached with the scroll-to-bottom pill unless you submitted
another prompt during that gesture. That submit requests the end after the gesture finishes;
a new drag after submitting takes ownership again. Rewind and tail-fetch completions do not
override a scroll away because they can arrive after the action that requested them.
### Draw distance
@@ -394,6 +394,56 @@ describe("bottom anchor controller driver", () => {
expect(harness.scrollAttempts).toHaveLength(0);
});
it("honors a submit made during a gesture when that gesture ends away", () => {
const harness = createDriverHarness({ isNearBottom: false });
harness.driver.beginUserScroll();
harness.driver.requestLocalAnchor({ agentId: "agent-1", reason: "message-sent" });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toEqual([]);
harness.driver.endUserScroll({ isAtEnd: false });
harness.scheduler.flushAll();
expect(harness.context.measurementState.offsetY).toBe(
harness.context.measurementState.contentHeight -
harness.context.measurementState.viewportHeight,
);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
});
});
it.each(["rewind", "jump-to-bottom"] as const)(
"lets scrolling away supersede a late %s request",
(reason) => {
const harness = createDriverHarness({ isNearBottom: false });
harness.driver.beginUserScroll();
harness.driver.requestLocalAnchor({ agentId: "agent-1", reason });
harness.driver.endUserScroll({ isAtEnd: false });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toEqual([]);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "detached",
pendingRequest: null,
});
},
);
it("lets a new gesture supersede a submit requested during the previous gesture", () => {
const harness = createDriverHarness({ isNearBottom: false });
harness.driver.beginUserScroll();
harness.driver.requestLocalAnchor({ agentId: "agent-1", reason: "message-sent" });
harness.driver.endUserScroll({ isAtEnd: false });
harness.driver.beginUserScroll();
harness.driver.endUserScroll({ isAtEnd: false });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toEqual([]);
expect(harness.driver.getSnapshot()).toMatchObject({ mode: "detached", pendingRequest: null });
});
it("resumes following when a user scroll ends at the end", () => {
const harness = createDriverHarness({
transportBehavior: {
@@ -499,11 +499,16 @@ function createBottomAnchorControllerDriver(
cancelPendingAttempt();
setModeInternal("detached");
},
// Following resumes only for a gesture that ends at the end. Resuming from short of it would
// scroll the rows the finger just let go of, so a release above the end, or with output that
// arrived below it during the gesture, stays detached with the pill.
// A gesture ending short stays detached unless a newer prompt submission requested the end.
// Passive output must not move the rows the finger just let go of.
endUserScroll(params) {
isUserScrollActive = false;
// Submit arrives with the user's action; rewind and tail jumps can arrive after a fetch.
// A newer gesture would have detached again, so only a still-sticky submit owns release.
if (pendingRequest?.reason === "message-sent" && mode === "sticky-bottom") {
evaluate(false, "user_scroll_end");
return;
}
if (params.isAtEnd) {
if (mode === "detached") {
setModeInternal("sticky-bottom");