Files
PaperClipAI/ui/src/components/task-chat/TaskMessageScroller.test.tsx
T
Clément df5a78cf91 fix(ui): keep iPhone task chat stable while typing and streaming (#17232)
## Thinking Path

> - Paperclip keeps human-agent conversations attached to tasks.
> - People need to read and reply comfortably from a phone.
> - The mobile task page scrolled the document and positioned its
composer with a sticky navigation offset.
> - Safari's keyboard resize and later focus pan could move that
document again, causing overlap, gaps, and jumps while editing.
> - This change gives mobile and desktop the same message scroller and a
normal flex footer.
> - CSS owns the layout. A small VisualViewport adapter handles the
visible height and Safari's stale document focus scroll.
> - Streaming no longer measures every historical row while following
the bottom.

## Linked Issues or Issue Description

Fixes https://github.com/paperclipai/paperclip/issues/16497

Related: https://github.com/paperclipai/paperclip/pull/15245 also
addresses keyboard positioning. This change additionally removes mobile
document auto-follow and reduces historical-row measurement work.

## What Changed

### One scroll owner and a natural-flow composer

Before:

```text
Document scroll + mobile auto-follow
  └─ Conversation + sticky composer + animated navigation offset
```

After:

```text
Height-constrained conversation shell
  ├─ Header
  ├─ Message scroller — owns follow/reading position
  └─ Composer — normal flex footer
Navigation stays at the layout viewport bottom, behind the keyboard
```

- Reuse TaskMessageScroller on mobile. Delete the separate 136-line
useWindowAutoFollow implementation and its superseded tests.
- Keep the composer in normal flex flow. Remove its sticky bottom
positioning and navigation-offset animation.
- Constrain the mobile conversation with CSS. Classic tasks and other
pages retain their existing layout.
- Add a 47-line useMobileVisualViewport hook. It changes shell height on
resize, not on typing or incoming messages. It never translates the
shell or hides navigation.
- Reject Safari's stale document focus pan only for an unzoomed,
keyboard-open conversation. Inner message and editor scrolling stay
native.
- Keep a saved reading anchor when reading older messages. Skip
historical-row measurements while following the bottom.
- Update tests and the existing mobile streaming fixture. Keep only two
layout-contract sentences in DESIGN.md.

**Scope:** 19 files, +367 / -482 lines: 115 fewer lines overall.
Production UI/CSS alone is +158 / -190 lines. No API, database, or
dependency changes.

## Verification

Bottom-follow is also saved explicitly for Back navigation. A new
regression covers returning after rotation changes message wrapping,
without restoring historical-row scans.

### Physical iPhone acceptance

Tested on an iPhone 15 Pro running iOS 26.7.1. The reporter confirmed:

- Opening the keyboard positions the composer correctly immediately,
before any typing.
- The installed web app works.
- Long drafts, insertion, deletion, and sending are acceptable. The
reporter described long drafts as not perfect but good enough.
- Reading older messages during incoming output preserves position;
returning to the bottom resumes following.
- Rotation, keyboard close/reopen, and attachment/picker transitions
work.

Remote inspection corroborated the corrected open-keyboard state:
visible height 358px, composer bottom 358px, document scrollY 0.
Navigation remained display:block at y=631px, behind the keyboard.

Earlier candidates failed device testing. The decisive trace showed a
correct viewport resize followed about one frame later by a stale 337px
native document focus pan. Typing caused Safari to correct it. Tests now
cover that event ordering, the reverse ordering, and zoom panning.

### Performance evidence

The existing TaskChatPerfHarness supplied 200 historical responses,
4,000 collapsed tools, and 30 updates at 100ms intervals.

| Measurement | Before | After |
| --- | ---: | ---: |
| Historical row geometry reads | 6,000 | 0 |
| Renderer main-thread work | 331.2ms | 273.5ms |
| Largest task | 27.1ms | 12.3ms |

This is one instrumented Chromium development run on the Mac, not a
phone benchmark or a statistical speedup claim. The zero-row-measurement
result also has a regression test that failed with 6,000 reads before
the change.

To reproduce, serve ui/tests/task-chat-perf.html with the UI Vite
server. Record 30 streaming updates at the bottom. Inspect
TaskMessageScroller and row getBoundingClientRect calls. Existing
historical-render tests also confirm that unchanged Markdown does not
reparse on live-tail updates.

### Automated and full-app checks

- [Current-head
CI](https://github.com/paperclipai/paperclip/actions/runs/39862524031)
is green: 54 successful checks and 2 skipped checks. Greptile rates the
current head 5/5, confirms the Back-navigation fix, and has no
unresolved findings.

- **601 focused tests pass** across the task page, layout, composer,
scroller, viewport adapter, and historical-render coverage.
- UI typecheck and production build pass.
- Token gates, Storybook catalog checks, and catalog/map structural
tests pass.
- Independent review of the final source tree passed with no findings.
- The full source app ran on macOS with an isolated database and saved
conversation history. A reply sent through the real UI persisted after
reload. Provider execution was not part of this layout test.
- **Full local repository checks did not pass.** Build/typecheck stop at
missing Rust cargo. The full test run reported 142 failed server tests
and 3 failed suites; missing cargo is confirmed in those three suites.
The other server failures were not diagnosed in this UI task.
Current-head CI now supplies the repository-wide evidence; these local
failures remain disclosed.

## Risks

- Mobile conversations now share the desktop scroll owner. Navigation,
loading, history restoration, and reading-position tests cover that
change.
- The keyboard-state threshold is a 120px viewport reduction.
Accessory-only keyboards retain the normal navigation spacing.
- The document-scroll fallback is scoped to rendered conversations with
an open keyboard at scale 1. It does not handle inner scrolling or
prevent zoom panning.
- Long-draft behavior is accepted, with minor polish still possible.
- The UI change is reversible and requires no data migration.

## Model Used

OpenAI GPT-6 Astra, exact model ID gpt-6-astra, through OpenCode.
Capabilities used: reasoning, code editing, tests, Chromium performance
tracing, and Safari remote inspection. The harness does not expose the
context-window size. An independent general-purpose subagent reviewed
the final tree; its serving model ID was not exposed.

## 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
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run focused tests locally and they pass; repository-wide
failures are disclosed above
- [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
2026-10-26 08:44:39 -07:00

497 lines
17 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 { TaskMessageScroller } from "./TaskMessageScroller";
import { TaskChatScrollNavigation } from "./scroll-navigation";
const PILL_SELECTOR = 'button[aria-label="Scroll to latest"]';
/**
* jsdom has no layout, so scroll geometry is faked per element: scrollHeight /
* clientHeight are pinned and scrollTop is backed by a plain variable.
*/
function fakeGeometry(el: HTMLElement, { scrollHeight = 1000, clientHeight = 400 } = {}) {
let scrollTop = 0;
Object.defineProperty(el, "scrollHeight", { value: scrollHeight, configurable: true });
Object.defineProperty(el, "clientHeight", { value: clientHeight, configurable: true });
Object.defineProperty(el, "scrollTop", {
get: () => scrollTop,
set: (v: number) => {
scrollTop = v;
},
configurable: true,
});
}
function fakeResizableGeometry(
el: HTMLElement,
{ scrollHeight = 1000, clientHeight = 400 } = {},
) {
let currentClientHeight = clientHeight;
let scrollTop = 0;
Object.defineProperty(el, "scrollHeight", {
value: scrollHeight,
configurable: true,
});
Object.defineProperty(el, "clientHeight", {
get: () => currentClientHeight,
configurable: true,
});
Object.defineProperty(el, "scrollTop", {
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
configurable: true,
});
return {
setClientHeight(value: number) {
currentClientHeight = value;
},
};
}
/**
* Scroll/wheel are continuous-priority events, so React flushes the resulting
* state updates asynchronously — wait a macrotask after dispatching.
*/
function flushEvents() {
return new Promise<void>((resolve) => {
setTimeout(resolve);
});
}
describe("TaskMessageScroller", () => {
let container: HTMLDivElement;
let root: Root;
function render(contentKey: unknown = 0) {
flushSync(() => {
root.render(
<TaskMessageScroller contentKey={contentKey}>
<div>messages</div>
</TaskMessageScroller>,
);
});
}
function scroller(): HTMLDivElement {
return container.querySelector<HTMLDivElement>('[data-testid="task-chat-scroller"]')!;
}
function pill(): HTMLButtonElement | null {
return container.querySelector<HTMLButtonElement>(PILL_SELECTOR);
}
/**
* Wait for the pill to reach a state, rather than for a fixed number of turns.
* `flushEvents` yields exactly one macrotask, which is enough on an idle
* machine and not when the suite runs many workers in parallel — React
* flushes continuous-priority updates asynchronously, so the DOM read can
* land before the state does. Sites that dereference `pill()!` turn that into
* a hard failure rather than a retry.
*/
async function waitForPill(present: boolean): Promise<void> {
await vi.waitFor(() =>
present ? expect(pill()).not.toBeNull() : expect(pill()).toBeNull(),
);
}
/** Set scrollTop and fire a scroll event, like a user or the browser would. */
async function scrollTo(el: HTMLElement, top: number) {
el.scrollTop = top;
el.dispatchEvent(new Event("scroll", { bubbles: true }));
await flushEvents();
}
async function dispatch(el: HTMLElement, event: Event) {
el.dispatchEvent(event);
await flushEvents();
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
document.documentElement.style.removeProperty("--motion-scrollbar-idle-delay");
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("does not measure historical rows on each streaming update while following the bottom", () => {
let initialized = false;
let rowMeasurements = 0;
const update = (tick: number) => flushSync(() => root.render(
<TaskMessageScroller contentKey={tick}>
<div ref={(node) => {
if (node && !initialized) {
fakeGeometry(node.parentElement!, { scrollHeight: 20000, clientHeight: 400 });
initialized = true;
}
}}>
{Array.from({ length: 200 }, (_, index) => <div key={index} data-thread-anchor={`history-${index}`} ref={(node) => {
if (node) node.getBoundingClientRect = () => {
rowMeasurements++;
const top = index * 100 - scroller().scrollTop;
return { top, bottom: top + 100, height: 100 } as DOMRect;
};
}}>History {index}</div>)}
<p>Live response {tick}</p>
</div>
</TaskMessageScroller>,
));
update(0);
rowMeasurements = 0;
for (let tick = 1; tick <= 30; tick++) update(tick);
expect(container.textContent).toContain("Live response 30");
expect(scroller().scrollTop).toBe(20000);
expect(rowMeasurements).toBe(0);
});
it("applies same-task hash changes and restores each history entry without remounting", async () => {
let initialized = false;
function navigate(key: string, hash: string, restore = false) {
flushSync(() => root.render(
<TaskChatScrollNavigation.Provider value={{ key, hash, restore }}>
<TaskMessageScroller contentKey="unchanged">
<div ref={(node) => {
if (node && !initialized) {
fakeGeometry(node.parentElement!);
initialized = true;
}
}}>
{[100, 500].map((top, index) => <div key={index} id={`nav-comment-${index}`} data-thread-anchor={`nav-comment-${index}`} ref={(node) => {
if (node) node.getBoundingClientRect = () => ({ top: top - scroller().scrollTop, bottom: top + 100 - scroller().scrollTop, height: 100 } as DOMRect);
}}>Comment {index}</div>)}
</div>
</TaskMessageScroller>
</TaskChatScrollNavigation.Provider>,
));
}
navigate("desktop-entry-one", "#nav-comment-0");
const viewport = scroller();
expect(viewport.scrollTop).toBe(100);
await scrollTo(viewport, 150);
navigate("desktop-entry-two", "#nav-comment-1");
expect(scroller()).toBe(viewport);
expect(viewport.scrollTop).toBe(500);
navigate("desktop-entry-one", "#nav-comment-0", true);
expect(viewport.scrollTop).toBe(150);
navigate("desktop-entry-one", "#nav-comment-1", true);
expect(viewport.scrollTop).toBe(500);
});
it("does not replace a saved reading position with a hidden route's geometry", async () => {
function mount(restore: boolean) {
flushSync(() => root.render(
<TaskChatScrollNavigation.Provider value={{ key: "hidden-route-entry", hash: "", restore }}>
<TaskMessageScroller contentKey="saved-thread">
<div ref={(node) => { if (node) fakeGeometry(node.parentElement!); }}>Saved messages</div>
</TaskMessageScroller>
</TaskChatScrollNavigation.Provider>,
));
}
mount(false);
const viewport = scroller();
await scrollTo(viewport, 150);
Object.defineProperty(viewport, "clientHeight", { value: 0, configurable: true });
await scrollTo(viewport, 0);
flushSync(() => root.render(null));
mount(true);
expect(scroller().scrollTop).toBe(150);
});
it("restores bottom-follow on Back after the viewport and message heights change", () => {
const configured = new WeakSet<HTMLElement>();
function mount(restore: boolean, scrollHeight: number, clientHeight: number) {
flushSync(() => root.render(
<TaskChatScrollNavigation.Provider value={{ key: "rotated-bottom-entry", hash: "", restore }}>
<TaskMessageScroller contentKey={scrollHeight}>
<div ref={(node) => {
const viewport = node?.parentElement;
if (viewport && !configured.has(viewport)) {
fakeGeometry(viewport, { scrollHeight, clientHeight });
let top = 0;
Object.defineProperty(viewport, "scrollTop", {
configurable: true,
get: () => top,
set: (value: number) => {
top = Math.max(0, Math.min(value, viewport.scrollHeight - viewport.clientHeight));
},
});
configured.add(viewport);
}
}}>Latest response</div>
</TaskMessageScroller>
</TaskChatScrollNavigation.Provider>,
));
}
mount(false, 1000, 400);
flushSync(() => root.render(null));
// Rotation changed wrapping and the available message viewport while away.
mount(true, 2000, 500);
expect(scroller().scrollTop).toBe(1500);
expect(pill()).toBeNull();
Object.defineProperty(scroller(), "scrollHeight", { value: 2400, configurable: true });
mount(true, 2400, 500);
expect(scroller().scrollTop).toBe(1900);
});
it("renders children inside the scroll container, pill hidden, scrolled to bottom on mount", () => {
render();
const el = scroller();
expect(el.textContent).toContain("messages");
expect(pill()).toBeNull();
// Mount effect pins to the bottom.
expect(el.scrollTop).toBe(el.scrollHeight);
});
it("extends only the streamlined scroll box through the page gutter", () => {
render();
const el = scroller();
const frame = el.parentElement;
expect(frame?.className).toBe("relative min-h-0 flex-1");
expect(el.classList).toContain("-right-4");
expect(el.classList).toContain("pr-4");
expect(el.classList).toContain("md:-right-6");
expect(el.classList).toContain("md:pr-6");
expect(el.classList).not.toContain("right-0");
});
it("shows the scrollbar only while scroll activity is recent", () => {
vi.useFakeTimers();
document.documentElement.style.setProperty("--motion-scrollbar-idle-delay", "600ms");
render();
const el = scroller();
fakeGeometry(el);
expect(el.className).toContain("scrollbar-while-scrolling");
expect(el.getAttribute("data-scroll-active")).toBeNull();
el.scrollTop = 100;
el.dispatchEvent(new Event("scroll", { bubbles: true }));
expect(el.getAttribute("data-scroll-active")).toBe("true");
vi.advanceTimersByTime(599);
expect(el.getAttribute("data-scroll-active")).toBe("true");
vi.advanceTimersByTime(1);
expect(el.getAttribute("data-scroll-active")).toBeNull();
});
it("contains horizontal overflow so no scrollbar appears above the composer", () => {
render();
expect(scroller().classList).toContain("overflow-x-hidden");
expect(scroller().classList).toContain("overflow-y-auto");
});
it("auto-follows content instantly while pinned", async () => {
render(1);
const el = scroller();
fakeGeometry(el);
await scrollTo(el, 600); // bottom: 1000 - 600 - 400 = 0 → pinned
await waitForPill(false); // pinned state settled before content grows
render(2);
expect(el.scrollTop).toBe(1000);
expect(pill()).toBeNull();
});
it("holds position and shows the icon-only pill when scrolled up", async () => {
render(1);
const el = scroller();
fakeGeometry(el);
await scrollTo(el, 100); // 500px from bottom → unpinned
await waitForPill(true);
const btn = pill();
expect(btn).not.toBeNull();
expect(btn!.className).toContain("tc-scroll-pill-in");
expect(btn!.className).toContain("size-8");
expect(btn!.className).toContain("bottom-7");
expect(btn!.className).not.toContain("bottom-3");
expect(btn!.className).not.toContain("-translate-x-1/2");
// Icon-only: no visible text.
expect(btn!.textContent).toBe("");
// New content must not yank the held position.
render(2);
expect(el.scrollTop).toBe(100);
});
it("follows a shrinking viewport while pinned so a growing composer cannot cover the latest text", async () => {
let triggerResize = () => {};
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
triggerResize = () =>
callback([], this as unknown as ResizeObserver);
}
observe() {}
disconnect() {}
},
);
render();
const el = scroller();
const geometry = fakeResizableGeometry(el);
triggerResize();
await scrollTo(el, 600);
geometry.setClientHeight(200);
triggerResize();
expect(el.scrollTop).toBe(el.scrollHeight);
expect(pill()).toBeNull();
});
it("holds a reader's position when the composer grows while they are scrolled up", async () => {
let triggerResize = () => {};
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
triggerResize = () =>
callback([], this as unknown as ResizeObserver);
}
observe() {}
disconnect() {}
},
);
render();
const el = scroller();
const geometry = fakeResizableGeometry(el);
triggerResize();
await scrollTo(el, 100);
await waitForPill(true);
geometry.setClientHeight(200);
triggerResize();
expect(el.scrollTop).toBe(100);
expect(pill()).not.toBeNull();
});
it("small drifts within the pin threshold do not show the pill", async () => {
render();
const el = scroller();
fakeGeometry(el);
await scrollTo(el, 560); // 40px from bottom, threshold is 48
expect(pill()).toBeNull();
});
it("clicking the pill smooth-scrolls, ignores intermediate scroll events, re-pins on arrival", async () => {
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: false }));
render(1);
const el = scroller();
fakeGeometry(el);
const scrollToSpy = vi.fn();
el.scrollTo = scrollToSpy as unknown as typeof el.scrollTo;
await scrollTo(el, 100);
await waitForPill(true);
const btn = pill()!;
btn.click();
await flushEvents();
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
// Intermediate glide positions fire scroll events; they must not unpin
// (i.e. the easing flag swallows them and the pill state stays put).
await scrollTo(el, 250);
await scrollTo(el, 400);
expect(pill()).not.toBeNull();
// Arrival within the threshold re-pins and hides the pill (immediately
// here: no matchMedia in jsdom → reduced-motion/unmount-now path).
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: true }));
await scrollTo(el, 600);
expect(pill()).toBeNull();
// Re-pinned: content growth follows instantly again.
render(2);
expect(el.scrollTop).toBe(1000);
});
it("finishes following at the new bottom when content changes during the latest glide", async () => {
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: false }));
render(1);
const el = scroller();
fakeGeometry(el);
el.scrollTo = vi.fn() as unknown as typeof el.scrollTo;
await scrollTo(el, 100);
await waitForPill(true);
pill()!.click();
await flushEvents();
await scrollTo(el, 250);
render(2);
expect(el.scrollTop).toBe(1000);
});
it("a wheel gesture during the glide cancels easing and stays unpinned", async () => {
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: false }));
render(1);
const el = scroller();
fakeGeometry(el);
el.scrollTo = vi.fn() as unknown as typeof el.scrollTo;
await scrollTo(el, 100);
await waitForPill(true);
pill()!.click();
await flushEvents();
await dispatch(el, new Event("wheel", { bubbles: true }));
// Easing cancelled → user is unpinned, pill visible, content holds.
expect(pill()).not.toBeNull();
render(2);
expect(el.scrollTop).toBe(100);
// Scroll events far from the bottom now behave normally (still unpinned).
await scrollTo(el, 200);
expect(pill()).not.toBeNull();
});
it("plays the exit animation before unmounting when motion is enabled", async () => {
vi.stubGlobal(
"matchMedia",
vi.fn().mockReturnValue({ matches: false }), // motion allowed
);
render();
const el = scroller();
fakeGeometry(el);
await scrollTo(el, 100);
await vi.waitFor(() => expect(pill()?.className).toContain("tc-scroll-pill-in"));
// Scrolling back to the bottom starts the exit animation but keeps the
// pill mounted until animationend.
await scrollTo(el, 600);
await vi.waitFor(() => expect(pill()?.className).toContain("tc-scroll-pill-out"));
const exiting = pill();
expect(exiting).not.toBeNull();
// jsdom has no window.AnimationEvent, so React's vendor-prefix detection
// maps onAnimationEnd to "webkitAnimationEnd" here (real browsers get the
// unprefixed event).
await dispatch(exiting!, new Event("webkitAnimationEnd", { bubbles: true }));
await waitForPill(false);
});
it("unmounts immediately on hide under prefers-reduced-motion", async () => {
vi.stubGlobal(
"matchMedia",
vi.fn().mockReturnValue({ matches: true }), // reduce
);
render();
const el = scroller();
fakeGeometry(el);
await scrollTo(el, 100);
await waitForPill(true);
await scrollTo(el, 600);
await waitForPill(false); // no animationend needed
});
});