Files
PaperClipAI/ui/src/components/task-chat/useWindowAutoFollow.test.tsx
T
DottaandPaperclip ad0c4f0767 fix(ui): keep mobile task output above the composer (#15282)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People read agent output in the task conversation.
> - Mobile tasks scroll the document and keep the composer above the
footer.
> - The document body keeps a fixed height while the thread grows.
> - The old observer misses late thread growth, and a layout scroll
event can cancel follow mode.
> - This pull request observes the thread and preserves follow mode
until the reader scrolls up.
> - The benefit is visible new output without repeated manual scrolling.

## Linked Issues or Issue Description

**What happened?**

New task output can move behind the mobile composer while the reader is
already at the bottom. Late content layout does not resize the
fixed-height body. A scroll event after layout growth can also clear
follow mode before the resize observer runs.

**Expected behavior**

Keep new output visible above the composer while the reader follows the
bottom. Hold the reading position after an upward scroll. Resume
following when the reader returns to the bottom.

**Steps to reproduce**

1. Open a long task conversation on mobile and scroll to the bottom.
2. Let a streamed row or delayed content grow after the parent renders.
3. Check whether the latest output stays above the composer.
4. Scroll up to read earlier output, then return to the bottom.

**Paperclip version or commit**

Reproduced by regression tests against master at 6c36c07a4.

**Deployment mode**

Built from source. This is a mobile UI bug and does not depend on an
adapter or database.

Related work: #15228, #13229, and #13095. The search found no duplicate
fix for mobile document auto-follow.

## What Changed

- Observe the conversation box as well as the body for late layout
growth.
- Preserve bottom-follow intent when a layout scroll event arrives
without an upward scroll.
- Reconcile document scrolling when the viewport resizes.
- Add regressions for late growth, event ordering, reading position,
return to bottom, and viewport changes.
- Add a bounded Storybook streaming fixture with the production thread,
composer, and mobile footer.

## Verification

- 244 focused tests pass across `useWindowAutoFollow`,
`TaskMessageScroller`, and `TaskChatThread`. Three new regressions fail
before the fix.
- All 7,439 UI tests pass across 683 files (`pnpm exec vitest run
--project @paperclipai/ui`).
- `pnpm check:token-gates`, `pnpm -r typecheck`, and `pnpm build` pass.
- The full repository test shards and browser E2E checks pass in CI on
commit 43354eb38. The duplicate local `pnpm test:run` was stopped after
37 minutes once those CI test shards passed; that local run did not
complete.
- Browser check at 402 by 874: controlled streamed output stayed at
document bottom, with the newest paragraph above the composer. Scrolling
up held position as output grew. Returning to bottom resumed following.
- Open Storybook's **Task chat / Mobile streaming follow / Streaming**
story to repeat the check. The fixture makes no agent or provider calls.
- Greptile is 5/5 on commit 43354eb38. All review threads are resolved.
- All CI checks pass on the final commit, including the canary packaging
gate.

## Risks

- Scroll event order differs between browsers. The regression tests
cover a scroll event before resize reconciliation and deliberate upward
scrolling during growth.
- The change affects the mobile document scroll owner. Desktop scrolling
stays covered by its existing tests.
- Existing same-task targets and browser history restoration still pass.

## Model Used

OpenAI GPT-6 through Codex. The exact serving variant and context window
are not exposed in this session. Capabilities used: reasoning, code
editing, tool execution, and browser verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-05 17:33:29 -05:00

264 lines
8.6 KiB
TypeScript

// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useWindowAutoFollow } from "./useWindowAutoFollow";
import { TaskChatScrollNavigation } from "./scroll-navigation";
function Host({ contentKey, enabled }: { contentKey: unknown; enabled: boolean }) {
useWindowAutoFollow(contentKey, enabled);
return <div data-testid="task-chat-thread">thread</div>;
}
/**
* jsdom has no layout: document scroll geometry is faked on the scrolling
* element, window.scrollTo is stubbed to record calls and update scrollY.
*/
describe("useWindowAutoFollow", () => {
let container: HTMLDivElement;
let root: Root;
let scrollToCalls: number[];
function fakeWindowGeometry({ scrollHeight = 2000, innerHeight = 800 } = {}) {
const el = document.scrollingElement ?? document.documentElement;
let currentScrollHeight = scrollHeight;
Object.defineProperty(el, "scrollHeight", {
get: () => currentScrollHeight,
configurable: true,
});
Object.defineProperty(window, "innerHeight", { value: innerHeight, configurable: true });
return {
setScrollHeight(value: number) {
currentScrollHeight = value;
},
};
}
function setWindowScrollY(y: number) {
Object.defineProperty(window, "scrollY", { value: y, configurable: true });
}
function render(contentKey: unknown, enabled = true) {
flushSync(() => {
root.render(<Host contentKey={contentKey} enabled={enabled} />);
});
}
/** Fire a window scroll event and flush the continuous-priority update. */
function scrollWindowTo(y: number) {
setWindowScrollY(y);
window.dispatchEvent(new Event("scroll"));
return new Promise<void>((resolve) => {
setTimeout(resolve);
});
}
/** Wait out the mount effect's rAF re-follow so it can't leak into asserts. */
function flushRaf() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => setTimeout(resolve));
});
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
scrollToCalls = [];
fakeWindowGeometry();
setWindowScrollY(0);
vi.stubGlobal("scrollTo", (options?: ScrollToOptions | number) => {
const top = typeof options === "number" ? options : (options?.top ?? 0);
scrollToCalls.push(top);
// Mirror the browser: a follow to the bottom leaves the window pinned.
setWindowScrollY(Math.max(0, top - window.innerHeight));
});
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it("reapplies same-task targets and POP positions on mobile without remounting", async () => {
vi.stubGlobal("scrollTo", (options: ScrollToOptions) => setWindowScrollY(Math.min(1200, options.top ?? 0)));
function MobileThread() {
useWindowAutoFollow("unchanged", true);
return <div data-testid="task-chat-thread">{[100, 500].map((top, index) => (
<div key={index} id={`mobile-comment-${index}`} data-thread-anchor={`mobile-comment-${index}`} ref={(node) => {
if (node) node.getBoundingClientRect = () => ({ top: top - window.scrollY, bottom: top + 100 - window.scrollY, height: 100 } as DOMRect);
}}>Comment {index}</div>
))}</div>;
}
const navigate = (key: string, hash: string, restore = false) => flushSync(() => root.render(
<TaskChatScrollNavigation.Provider value={{ key, hash, restore }}><MobileThread /></TaskChatScrollNavigation.Provider>,
));
navigate("mobile-entry-one", "#mobile-comment-0");
const thread = container.firstElementChild;
expect(window.scrollY).toBe(100);
await scrollWindowTo(150);
navigate("mobile-entry-two", "#mobile-comment-1");
expect(container.firstElementChild).toBe(thread);
expect(window.scrollY).toBe(500);
navigate("mobile-entry-one", "#mobile-comment-0", true);
expect(window.scrollY).toBe(150);
navigate("mobile-entry-one", "#mobile-comment-1", true);
expect(window.scrollY).toBe(500);
});
it("owns browser restoration only while the mobile thread is enabled", () => {
window.history.scrollRestoration = "auto";
render(0);
expect(window.history.scrollRestoration).toBe("manual");
render(0, false);
expect(window.history.scrollRestoration).toBe("auto");
});
it("scrolls the window to the bottom on mount", () => {
render(0);
expect(scrollToCalls).toContain(2000);
});
it("follows content growth while pinned to the bottom", async () => {
render(0);
await flushRaf();
await scrollWindowTo(1200); // pinned: 2000 - 1200 - 800 = 0
scrollToCalls = [];
render(1);
expect(scrollToCalls).toContain(2000);
});
it("holds position when the user has scrolled up", async () => {
render(0);
await flushRaf();
await scrollWindowTo(100); // far from the bottom
scrollToCalls = [];
render(1);
expect(scrollToCalls).toHaveLength(0);
});
it("follows document growth while pinned on mobile", async () => {
let triggerResize = () => {};
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
triggerResize = () =>
callback([], this as unknown as ResizeObserver);
}
observe() {}
disconnect() {}
},
);
const geometry = fakeWindowGeometry();
render(0);
await flushRaf();
await scrollWindowTo(1200);
scrollToCalls = [];
geometry.setScrollHeight(2300);
triggerResize();
expect(scrollToCalls).toContain(2300);
});
it("holds document position through mobile composer growth when scrolled up", async () => {
let triggerResize = () => {};
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
triggerResize = () =>
callback([], this as unknown as ResizeObserver);
}
observe() {}
disconnect() {}
},
);
const geometry = fakeWindowGeometry();
render(0);
await flushRaf();
await scrollWindowTo(100);
scrollToCalls = [];
geometry.setScrollHeight(2300);
triggerResize();
expect(scrollToCalls).toHaveLength(0);
});
it("follows a late thread resize without a React content update or body resize", async () => {
const observed = new Set<Element>();
let resizeThread = () => {};
vi.stubGlobal("ResizeObserver", class {
constructor(callback: ResizeObserverCallback) {
resizeThread = () => {
const thread = container.querySelector('[data-testid="task-chat-thread"]')!;
if (observed.has(thread)) callback([{ target: thread } as ResizeObserverEntry], this as unknown as ResizeObserver);
};
}
observe(element: Element) { observed.add(element); }
disconnect() { observed.clear(); }
});
const geometry = fakeWindowGeometry();
render(0);
scrollToCalls = [];
geometry.setScrollHeight(2400);
resizeThread();
expect(scrollToCalls).toContain(2400);
expect(window.scrollY).toBe(1600);
});
it("keeps following when a scroll event arrives after growth before reconciliation", async () => {
const geometry = fakeWindowGeometry();
render(0);
geometry.setScrollHeight(2400);
await scrollWindowTo(1200); // Still at the old bottom; the user did not move up.
scrollToCalls = [];
render(1);
expect(scrollToCalls).toContain(2400);
});
it("holds an upward user scroll during growth and resumes after returning to the bottom", async () => {
const geometry = fakeWindowGeometry();
render(0);
geometry.setScrollHeight(2400);
await scrollWindowTo(900);
scrollToCalls = [];
render(1);
expect(scrollToCalls).toHaveLength(0);
await scrollWindowTo(1600);
geometry.setScrollHeight(2700);
render(2);
expect(scrollToCalls).toContain(2700);
});
it("follows viewport resizing while pinned and holds it while reading older output", async () => {
render(0);
Object.defineProperty(window, "innerHeight", { value: 600, configurable: true });
window.dispatchEvent(new Event("resize"));
expect(window.scrollY).toBe(1400);
await scrollWindowTo(500);
scrollToCalls = [];
Object.defineProperty(window, "innerHeight", { value: 700, configurable: true });
window.dispatchEvent(new Event("resize"));
expect(scrollToCalls).toHaveLength(0);
});
it("does nothing when disabled", () => {
render(0, false);
expect(scrollToCalls).toHaveLength(0);
render(1, false);
expect(scrollToCalls).toHaveLength(0);
});
});