mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task conversations must show the saved instruction and recent replies quickly. > - Compact task reads already limit the saved message window. > - The conversation still waits for completed run transcripts even when a saved reply contains the result. > - Removing that wait alone can insert activity after the reader starts reading. > - This pull request defers that output until the reader opens history and keeps required output in the initial reveal. > - The benefit is a useful conversation sooner, with a stable first layout and a working composer. ## Linked Issues or Issue Description Refs #17593, #18092, #18312, #18358, #18422. **What happened?** Opening a task with a long saved investigation can wait for native events or legacy logs before showing its existing replies. A slow transcript request can hold the conversation behind the loading screen until the 15-second fallback. **Expected behavior** Show the bounded saved conversation without reading completed output that already has a usable saved reply. Keep that output available through the existing history controls. **Steps to reproduce** 1. Create a task with many completed runs and long saved agent replies. 2. Delay its run events and logs. 3. Open the compact task directly. Before this change, the transcript read blocks the saved replies and composer. ## What Changed - Defer completed successful run transcripts on compact task opens when a usable saved reply is present. Load that output through the existing history controls. - Keep required output on the initial path: live and failed runs, yielded runs, pending interactions, placeholder/progress-only replies, explicit anchors, chats, and full history. Retain output already selected during this visit when its run completes. - Keep saved plans visible when their owning run output is deferred or still loading. - Keep the same composer and draft through history expansion. Offer **More run output** when a single saved run has deferred output. - Position same-task comment links after their target arrives. Wait only for a missing target, with a 15-second limit. Unrelated stalled metadata cannot block an available target after the reveal deadline. - Preserve the reading anchor when a queued scroll event arrives during a properties-pane width transition. - Add a 200-reply/200-run PostgreSQL browser fixture with a saved plan, covering both task views on desktop and mobile. Correct stale browser setup for current routes and the shared mobile scroller. - Remove a random company-prefix collision in the isolated privacy test fixture that failed during CI. ## Verification - Final revision `fba3f2fcbb`: 386 focused thread/scroller unit cases and all 32 production-browser journeys passed. The browser cases cover both task views on desktop/mobile, saved plans, delayed transcripts, history, comment links, Back, drafts, retry, avatar continuity, media and pane resizing. The 25 privacy fixture cases also passed against PostgreSQL. - Workspace typecheck and full build passed with Node 24. A broader local UI run passed 10,220 cases across 849 files before the final navigation/plan review fixes; the changed components were then rechecked in the 386-case focused run. - Five alternating pairs compared master `12b0cc6304` with this revision, using 200 long saved replies and 200 completed native runs. Both production UI builds used the same loopback API/database, Chromium 151.0.7922.34, 1440 × 900, fresh browser contexts, service workers blocked, no CPU throttle, and a controlled 3-second events/log delay. Builds and other suites were stopped during measurement. No page errors occurred. | Useful conversation + editable composer | Baseline median (range) | Fixed median (range) | | --- | --- | --- | | Cold direct open | 3.570s (3.538–3.616s) | 1.056s (1.022–1.141s) | | Task-list navigation | 3.373s (3.342–3.394s) | 0.899s (0.885–0.915s) | | Back | 22ms (22–25ms) | 25ms (25–28ms) | - Median paired savings: 2.519s cold, 2.470s from the list. Completed-output requests: two per baseline sample, zero per fixed sample. Recent-comments API control medians were 13.6ms for both, with ranges 12.1–18.7ms and 11.6–50.8ms. No Back improvement is claimed; an earlier revision's run also included one 1-second Back outlier, retained in the local evidence. - The full local default test command did **not** pass: it stopped on the existing chat test line-selection error. After restoring embedded PostgreSQL library symlinks omitted by an install with scripts disabled, Node 24 passed 672 cases in the first two chat shards, then failed to select the third. This is separate from the passing focused suites and remote CI. - Earlier CI failures were addressed: the privacy prefix collision is fixed; the delegated task-completion reply case passed locally after merging master's reply-recovery fix (#18465). Browser fixture corrections retain the original behavior assertions. Red tests reproduced the loading, navigation, resize and saved-plan regressions before their fixes. - Map, module-boundary, token-gate and added-line token checks passed. The full-tree token scan still finds pre-existing active-username fixtures; this PR adds zero forbidden token lines. - Greptile: **5/5**, final revision, all four review threads resolved. The final-revision PR CI workflow passed, including build, typecheck, all server/chat/workspace test shards, all eight E2E shards, and every Runner lane. GitHub reports 55 successful checks and three skipped checks. The separate **Paperclip Review** check remains queued with **Waiting for the assigned agent**; it has not produced an assessment and has not been bypassed. The branch has no merge conflicts. ## Risks - The default compact task omits completed transcript details when the saved result is sufficient. The reader must open history to inspect them. - Required structural data remains inside the initial reveal boundary. Slow server responses for that data remain visible latency. - Link positioning falls back after 15 seconds when its target never arrives. The conversation remains readable. - The measurements use a controlled local delay. They do not establish a deployed staging speedup. - No API, schema, authorization, or migration changes. ## Model Used OpenAI Codex, GPT-6, with tool use and code execution. ## 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 - [ ] I have run tests locally and they pass (focused suites pass; the full default wrapper has the existing selection error documented 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 - [ ] All Paperclip CI gates are green (code CI passed; the separate assigned-agent Paperclip Review remains queued) - [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>
666 lines
25 KiB
TypeScript
666 lines
25 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("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);
|
||
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
|
||
});
|
||
});
|