mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent Chat shows saved messages and native run output in one conversation. > - Reopening a chat rebuilt terminal transcripts and waited for network hydration. > - A queued scroll event could also disable bottom-follow after a cached editor increased the content height. > - This pull request reuses terminal history and preserves bottom-follow through that event order. > - The benefit is a faster cached return that opens at the latest reply. ## Linked Issues or Issue Description **What happened?** Returning to an agent chat could leave its latest reply below the viewport. Saved native transcripts also held the first reveal behind repeated network reads. **Expected behavior** A new chat navigation opens at the latest reply. A reader who scrolls up keeps their position. Back restores the earlier reading anchor. Saved history can render while the normal catch-up read runs. **Steps to reproduce** 1. Open an agent chat with saved native runs and task question cards. 2. Open Tasks, then reopen the same agent from Recents. 3. Observe the wait and the distance from the latest reply. **Paperclip version or commit** Reproduced on `523818ea9bf81f96abf4e6b73e81c2ce87518c07`. Rebased onto master `2f9d3d43c3`. **Deployment mode** Built from source with production UI assets and an isolated synthetic database. Related work: #18358 added recent-window chat loading. #18461 deferred completed transcripts for compact task opens. Open PR #14667 changes the reveal gate for task threads. This PR keeps that gate and caches hydrated terminal native history across visits. ## What Changed - Retain successful terminal native history in the application query cache. Keys include company, run, status and projection. - Render matching saved history immediately and continue the normal cursor-based catch-up read. - Preserve bottom-follow when content grows before a queued scroll event is delivered. Upward gestures and Back anchors retain priority. - Add regression coverage for remounts, catch-up, cache clearing, projection changes, cached question editors and scroll event ordering. - Repair two browser assertions: use a final 422 failure for explicit history retry, and accept saved task-view query parameters. - Document the local before/after comparison, including unchanged paths. ## Verification - Focused transcript and scroll suites: 51 tests passed after the review fixes, including a 300-run cache stability case. - Full UI suite on the rebased head: 896 files passed; 11,257 tests passed and one skipped. - Final review-fixed production UI: all 24 desktop/mobile chat journeys passed. - Final head: `pnpm -r typecheck`, `pnpm build` and `pnpm check:token-gates` passed. The complete GitHub CI test matrix, E2E, build and release checks passed for `4429a9aab4`. - The local repo-wide test command was stopped before performance sampling. Local acceptance used the full UI suite and the chat browser rig; CI completed the full repository matrix. - A parallel local build initially read a partially regenerated runner module while typecheck was emitting files. After typecheck finished, the serial full build passed. No runner source change was required. - The first browser run exposed the retry fixture's synthetic 500 being retried by the shared query policy. A final 422 response now verifies explicit retry. The Back assertion also now accepts the task collection's saved view query. - Re-measured the final reviewed build against the original baseline in five alternating pairs. The same retained 600-message/300-run fixture, Chromium at 1440×900 and 150 ms simulated latency produced cached-return medians of 580 ms (568–594) before and 155 ms (130–161) after. Median paired saving: 433 ms. All five pairs improved. A separate loopback asset host kept the manual test-drive build unchanged. - The initial full comparison found no reliable change to loopback cached returns, cold opens or uncached in-app entry. These are local results, not deployed staging measurements. - Greptile identified cache-expiry row loss and missing older output after a cached projection switch. Regression cases reproduce those paths. Mounted history now retains restored rows, and each projection retains its confirmed snapshot. The fresh review on commit `4429a9aab4` is 5/5 with no open findings. - One browser run overlapped a local rebuild and received a root-page 500. The server log records the missing generated index at that time. The run also timed out waiting for wheel acknowledgement after older rows had loaded; that input-acknowledgement cause was not established. After the build completed, the full serial run passed all 24 cases. - Run the opt-in browser rig with `tests/perf/agent-chat/playwright.config.ts` against a disposable loopback instance. See `tests/perf/agent-chat/README.md`. ## Risks - Cached history must not cross company, status or projection boundaries. Regression tests cover those cache misses and cache clearing. - Terminal output can receive late events. Cached history still performs catch-up reads, and retry, reconnect and mode changes retain their reads. - Programmatic scroll events can race with a reader gesture. Tests cover upward intent, delayed media and Back restoration. - This change does not improve cold-start latency. No schema, server authorization or exported data changes. ## Model Used OpenAI Codex using GPT-6. The runtime does not expose a more specific model ID or context-window size. Used reasoning, shell execution, repository editing, tests and browser profiling. ## 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>
687 lines
26 KiB
TypeScript
687 lines
26 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, clamp = false } = {}) {
|
||
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 = clamp ? Math.max(0, Math.min(v, el.scrollHeight - el.clientHeight)) : 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("loads one older page on upward scroll, keeps the status slot, and pauses after failure", async () => {
|
||
let release!: () => void;
|
||
const load = vi.fn(() => new Promise<void>(resolve => { release = resolve; }));
|
||
const history = { hasMore: true, loading: false, error: false, load };
|
||
const update = (tick: number) => flushSync(() => root.render(
|
||
<TaskMessageScroller contentKey={tick} scrollingHistory={{ ...history }}><div>saved messages</div></TaskMessageScroller>,
|
||
));
|
||
update(0);
|
||
fakeGeometry(scroller());
|
||
update(1);
|
||
expect(load).not.toHaveBeenCalled();
|
||
await scrollTo(scroller(), 400);
|
||
expect(load).not.toHaveBeenCalled();
|
||
await scrollTo(scroller(), 150);
|
||
expect(load).toHaveBeenCalledTimes(1);
|
||
await scrollTo(scroller(), 100);
|
||
scroller().dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 }));
|
||
await flushEvents();
|
||
expect(load).toHaveBeenCalledTimes(1);
|
||
const status = container.querySelector('[data-testid="chat-history-status"]');
|
||
history.loading = true;
|
||
update(2);
|
||
expect(container.querySelector('[data-testid="chat-history-status"]')).toBe(status);
|
||
expect(status?.textContent).toBe("Loading earlier messages…");
|
||
release();
|
||
await flushEvents();
|
||
history.loading = false;
|
||
history.error = true;
|
||
update(3);
|
||
await scrollTo(scroller(), 50);
|
||
expect(load).toHaveBeenCalledTimes(1);
|
||
expect(status?.textContent).toContain("Earlier messages couldn’t load.");
|
||
(status?.querySelector('button') as HTMLButtonElement).click();
|
||
expect(load).toHaveBeenCalledTimes(2);
|
||
release();
|
||
await flushEvents();
|
||
history.error = false;
|
||
history.hasMore = false;
|
||
update(4);
|
||
await scrollTo(scroller(), 0);
|
||
expect(load).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it("follows a late image resize in the message body after the history status", () => {
|
||
const observers: { targets: Set<Element>; resize: () => void }[] = [];
|
||
vi.stubGlobal("ResizeObserver", class {
|
||
targets = new Set<Element>();
|
||
constructor(callback: ResizeObserverCallback) { observers.push({ targets: this.targets, resize: () => callback([], this as unknown as ResizeObserver) }); }
|
||
observe(target: Element) { this.targets.add(target); }
|
||
disconnect() {}
|
||
});
|
||
const update = (tick: number) => flushSync(() => root.render(
|
||
<TaskMessageScroller contentKey={tick} scrollingHistory={{ hasMore: true, loading: false, load: vi.fn() }}><div data-testid="image-body">image content</div></TaskMessageScroller>,
|
||
));
|
||
update(0); fakeGeometry(scroller(), { clamp: true }); update(1);
|
||
const body = container.querySelector('[data-testid="image-body"]')!;
|
||
Object.defineProperty(scroller(), "scrollHeight", { value: 1400, configurable: true });
|
||
for (const observer of observers) if (observer.targets.has(body)) observer.resize();
|
||
expect(scroller().scrollTop).toBe(1000);
|
||
});
|
||
|
||
it("keeps following when content grows before the previous scroll event is delivered", async () => {
|
||
render(0);
|
||
fakeGeometry(scroller(), { clamp: true });
|
||
render(1);
|
||
expect(scroller().scrollTop).toBe(600);
|
||
Object.defineProperty(scroller(), "scrollHeight", { value: 1400, configurable: true });
|
||
await dispatch(scroller(), new Event("scroll", { bubbles: true }));
|
||
expect(scroller().scrollTop).toBe(1000);
|
||
expect(pill()).toBeNull();
|
||
scroller().dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 }));
|
||
Object.defineProperty(scroller(), "scrollHeight", { value: 1600, configurable: true });
|
||
await scrollTo(scroller(), 900);
|
||
render(2);
|
||
expect(scroller().scrollTop).toBe(900);
|
||
await waitForPill(true);
|
||
});
|
||
|
||
it("retains the reading anchor when a resize scroll event precedes the next width reflow", async () => {
|
||
const resize: (() => void)[] = [];
|
||
vi.stubGlobal("ResizeObserver", class {
|
||
constructor(callback: ResizeObserverCallback) { resize.push(() => callback([], this as unknown as ResizeObserver)); }
|
||
observe() {}
|
||
disconnect() {}
|
||
});
|
||
let width = 400, rowTop = 100;
|
||
flushSync(() => root.render(<TaskMessageScroller contentKey="width-transition">
|
||
<div ref={node => {
|
||
if (!node) return;
|
||
const viewport = node.parentElement!;
|
||
fakeGeometry(viewport, { clamp: true });
|
||
Object.defineProperty(viewport, "clientWidth", { get: () => width, configurable: true });
|
||
viewport.getBoundingClientRect = () => ({ top: 0, bottom: 400 } as DOMRect);
|
||
}}>
|
||
<div data-thread-anchor="reading-row" ref={node => {
|
||
if (node) node.getBoundingClientRect = () => ({ top: rowTop - scroller().scrollTop, bottom: rowTop + 100 - scroller().scrollTop, height: 100 } as DOMRect);
|
||
}}>Saved reply</div>
|
||
</div>
|
||
</TaskMessageScroller>));
|
||
await scrollTo(scroller(), 150);
|
||
width = 500; rowTop = 80;
|
||
for (const callback of resize) callback();
|
||
expect(scroller().scrollTop).toBe(130);
|
||
rowTop = 70;
|
||
await dispatch(scroller(), new Event("scroll", { bubbles: true }));
|
||
for (const callback of resize) callback();
|
||
expect(scroller().scrollTop).toBe(120);
|
||
width = 600; rowTop = 60;
|
||
await dispatch(scroller(), new Event("scroll", { bubbles: true }));
|
||
for (const callback of resize) callback();
|
||
expect(rowTop - scroller().scrollTop).toBe(-50);
|
||
expect(pill()).not.toBeNull();
|
||
});
|
||
|
||
it("retains upward intent when content reconciles before the scroll event", async () => {
|
||
const load = vi.fn();
|
||
const update = (tick: number) => flushSync(() => root.render(
|
||
<TaskMessageScroller contentKey={tick} scrollingHistory={{ hasMore: true, loading: false, load }}><div>saved messages</div></TaskMessageScroller>,
|
||
));
|
||
update(0); fakeGeometry(scroller()); update(1);
|
||
await scrollTo(scroller(), 400);
|
||
scroller().dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 }));
|
||
scroller().scrollTop = 0;
|
||
update(2);
|
||
await dispatch(scroller(), new Event("scroll", { bubbles: true }));
|
||
expect(load).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("holds a short conversation when an upward wheel gesture loads history", async () => {
|
||
const load = vi.fn().mockResolvedValue(undefined);
|
||
const update = (tick: number) => flushSync(() => root.render(
|
||
<TaskMessageScroller contentKey={tick} scrollingHistory={{ hasMore: true, loading: false, load }}><div>short conversation</div></TaskMessageScroller>,
|
||
));
|
||
update(0);
|
||
fakeGeometry(scroller(), { scrollHeight: 200, clientHeight: 400, clamp: true });
|
||
update(1);
|
||
await scrollTo(scroller(), 0);
|
||
scroller().dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 }));
|
||
await flushEvents();
|
||
expect(load).toHaveBeenCalledTimes(1);
|
||
Object.defineProperty(scroller(), "scrollHeight", { value: 1200, configurable: true });
|
||
update(2);
|
||
expect(scroller().scrollTop).toBe(0);
|
||
});
|
||
|
||
it("pages short conversations with touch and keyboard gestures", async () => {
|
||
const load = vi.fn().mockResolvedValue(undefined);
|
||
const update = (tick: number) => flushSync(() => root.render(
|
||
<TaskMessageScroller contentKey={tick} scrollingHistory={{ hasMore: true, loading: false, load }}><div>short conversation</div></TaskMessageScroller>,
|
||
));
|
||
update(0); fakeGeometry(scroller(), { scrollHeight: 200, clientHeight: 400, clamp: true }); update(1);
|
||
await scrollTo(scroller(), 0);
|
||
const touch = (type: string, y: number) => {
|
||
const event = new Event(type, { bubbles: true });
|
||
Object.defineProperty(event, "touches", { value: [{ clientY: y }] });
|
||
scroller().dispatchEvent(event);
|
||
};
|
||
touch("touchstart", 100); touch("touchmove", 200); touch("touchmove", 250);
|
||
await flushEvents();
|
||
expect(load).toHaveBeenCalledTimes(1);
|
||
scroller().dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Home" }));
|
||
await flushEvents();
|
||
expect(load).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
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.each([[false, "none"], [false, "wheel"], [false, "pointer"], [true, "none"], [true, "wheel"], [true, "pointer"]] as const)("positions a late hash target unless the reader moved on (restore=%s, readerMoved=%s)", async (restore, readerMoved) => {
|
||
let initialized = false;
|
||
const show = (targetReady: boolean) => flushSync(() => root.render(
|
||
<TaskChatScrollNavigation.Provider value={{ key: `late-target-${restore}-${readerMoved}`, hash: "#late-target", restore }}>
|
||
<TaskMessageScroller contentKey={targetReady}>
|
||
<div ref={node => {
|
||
if (node && !initialized) { fakeGeometry(node.parentElement!, { clamp: true }); initialized = true; }
|
||
}}>
|
||
Saved conversation
|
||
{targetReady ? <div id="late-target" data-thread-anchor="late-target" ref={node => {
|
||
if (node) node.getBoundingClientRect = () => ({ top: 100 - scroller().scrollTop, bottom: 200 - scroller().scrollTop, height: 100 } as DOMRect);
|
||
}}>Linked comment</div> : null}
|
||
</div>
|
||
</TaskMessageScroller>
|
||
</TaskChatScrollNavigation.Provider>,
|
||
));
|
||
show(false);
|
||
if (readerMoved !== "none") {
|
||
await scrollTo(scroller(), 300);
|
||
await dispatch(scroller(), readerMoved === "wheel" ? new WheelEvent("wheel", { bubbles: true, deltaY: -100 }) : new Event("pointerdown", { bubbles: true }));
|
||
}
|
||
show(true);
|
||
expect(scroller().scrollTop).toBe(readerMoved === "none" ? 100 : 300);
|
||
});
|
||
|
||
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
|
||
});
|
||
});
|