Files
PaperClipAI/ui/src/components/task-chat/TaskMessageScroller.tsx
T
DottaandPaperclip eb5c24b61c perf(chat): speed up cached returns and preserve bottom follow (#19452)
## 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>
2026-11-06 09:38:40 -06:00

409 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { readThreadScrollAnchor, threadScrollAnchorDelta, type ThreadScrollAnchor } from "./scroll-anchor";
import { cn } from "@/lib/utils";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import { ArrowDown } from "lucide-react";
import { parseCssTimeMs } from "./motion-tokens";
import { taskChatHashAnchor, useTaskChatScrollNavigation } from "./scroll-navigation";
const PIN_THRESHOLD_PX = 48;
/** Visibility lifecycle of the scroll-to-latest pill. */
type PillPhase = "hidden" | "in" | "out";
/**
* True when animations are disabled (prefers-reduced-motion, or environments
* without matchMedia such as jsdom). In that case the pill's exit animation
* never fires animationend, so we must unmount immediately.
*/
function motionDisabled(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export interface ScrollingHistory {
hasMore: boolean;
loading: boolean;
error?: boolean;
load: () => unknown;
}
interface TaskMessageScrollerProps {
scrollingHistory?: ScrollingHistory;
children: ReactNode;
/** Value that changes whenever content that could grow the thread updates. */
contentKey: unknown;
className?: string;
}
/**
* Scroll container that owns the redesign's auto-follow vs. hold-position rule.
*
* Explicit rule: while the viewport is pinned to the bottom (within a small
* threshold) new content auto-follows with INSTANT scroll; the moment the user
* scrolls up we hold their position and surface a scroll-to-latest pill
* instead of yanking them down.
*
* Re-follow is easing-aware: clicking the pill glides down smoothly, and while
* that glide is in flight (`easingRef`) the scroll handler must not unpin —
* the smooth scroll fires intermediate scroll events that would otherwise
* re-show the pill mid-glide (the known stick-to-bottom failure mode). Arrival
* within the pin threshold re-pins and hides the pill; a wheel/touch during
* the glide cancels it and treats the user as unpinned. Content-driven follow
* while pinned stays instant, so no reflow/jump happens during streaming.
*/
export function TaskMessageScroller({ children, contentKey, className, scrollingHistory }: TaskMessageScrollerProps) {
const streamlined = useStreamlinedTaskChatPresentation();
const navigation = useTaskChatScrollNavigation();
const initialPositionApplied = useRef(false);
const appliedNavigation = useRef({ key: navigation.key, hash: navigation.hash });
const pendingHashTarget = useRef(taskChatHashAnchor(navigation.hash ?? ""));
const ref = useRef<HTMLDivElement>(null);
const anchorRef = useRef<ThreadScrollAnchor | null>(null);
const pinnedRef = useRef(true);
const easingRef = useRef(false);
const clientHeightRef = useRef<number | null>(null);
const clientWidthRef = useRef<number | null>(null);
const scrollbarIdleTimerRef = useRef<number | null>(null);
const scrollbarIdleDelayRef = useRef<number | null>(null);
const [pillPhase, setPillPhase] = useState<PillPhase>("hidden");
const previousScrollTop = useRef(0);
const previousScrollHeight = useRef(0);
const touchStartY = useRef<number | null>(null);
const requestingHistory = useRef(false);
const upwardIntent = useRef(false);
const requestEarlierHistory = useCallback(() => {
upwardIntent.current = false;
if (!initialPositionApplied.current || !navigation.ready || !scrollingHistory?.hasMore
|| scrollingHistory.loading || scrollingHistory.error || requestingHistory.current) return;
requestingHistory.current = true;
const el = ref.current;
if (el?.clientHeight) {
const rect = el.getBoundingClientRect();
pinnedRef.current = false;
anchorRef.current = readThreadScrollAnchor(el, rect.top, rect.bottom);
navigation.remember(el.scrollTop, anchorRef.current, false);
}
Promise.resolve().then(() => scrollingHistory.load()).catch(() => {}).finally(() => {
requestingHistory.current = false;
});
}, [navigation.key, navigation.hash, navigation.ready, scrollingHistory]);
const showScrollbarWhileScrolling = useCallback(() => {
const el = ref.current;
if (!el) return;
el.dataset.scrollActive = "true";
if (scrollbarIdleTimerRef.current !== null) {
window.clearTimeout(scrollbarIdleTimerRef.current);
}
const idleDelay = scrollbarIdleDelayRef.current ?? parseCssTimeMs(
getComputedStyle(document.documentElement).getPropertyValue("--motion-scrollbar-idle-delay"),
);
scrollbarIdleDelayRef.current = idleDelay;
scrollbarIdleTimerRef.current = window.setTimeout(() => {
delete el.dataset.scrollActive;
scrollbarIdleTimerRef.current = null;
scrollbarIdleDelayRef.current = null;
}, idleDelay);
}, []);
const showPill = useCallback(() => {
setPillPhase("in");
}, []);
const hidePill = useCallback(() => {
// Keep rendering through the exit animation; unmount on animationend.
// Without animations (reduced motion / no matchMedia) unmount immediately.
setPillPhase((phase) => (phase === "hidden" ? phase : motionDisabled() ? "hidden" : "out"));
}, []);
const isPinned = useCallback(() => {
const el = ref.current;
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_THRESHOLD_PX;
}, []);
const scrollToBottom = useCallback(() => {
const el = ref.current;
if (!el) return;
el.scrollTop = el.scrollHeight; // instant, never smooth
}, []);
const followViewportResize = useCallback(() => {
const el = ref.current;
if (!el) return false;
const previousClientHeight = clientHeightRef.current;
const previousClientWidth = clientWidthRef.current;
const nextClientHeight = el.clientHeight;
clientHeightRef.current = nextClientHeight;
clientWidthRef.current = el.clientWidth;
if (previousClientWidth != null && previousClientWidth !== el.clientWidth && !pinnedRef.current) {
const delta = threadScrollAnchorDelta(el, anchorRef.current, el.getBoundingClientRect().top);
if (delta) el.scrollTop += delta;
return true;
}
if (
previousClientHeight == null ||
previousClientHeight <= 0 ||
previousClientHeight === nextClientHeight ||
!pinnedRef.current
) {
return false;
}
scrollToBottom();
return true;
}, [scrollToBottom]);
const rememberAnchor = useCallback((capture = true) => {
const el = ref.current;
if (!el || el.clientHeight === 0) return;
if (pinnedRef.current) {
// Following the bottom needs only an offset, not a scan of every old row.
anchorRef.current = null;
} else if (capture) {
const rect = el.getBoundingClientRect();
anchorRef.current = readThreadScrollAnchor(el, rect.top, rect.bottom);
}
if (initialPositionApplied.current) navigation.remember(el.scrollTop, anchorRef.current, pinnedRef.current);
}, [navigation.key, navigation.hash, navigation.ready]);
const reconcileContent = useCallback(() => {
const el = ref.current;
if (!el || el.clientHeight === 0) return;
// Clicking latest is an explicit follow intent. If content or the composer
// changes during its glide, finish at the new bottom instead of restoring
// the old reading anchor and cancelling the browser's smooth scroll.
if (easingRef.current) {
easingRef.current = false;
pinnedRef.current = true;
hidePill();
}
if (pinnedRef.current) scrollToBottom();
else {
const delta = threadScrollAnchorDelta(el, anchorRef.current, el.getBoundingClientRect().top);
if (delta) el.scrollTop += delta;
}
// Compensation preserves the existing reading anchor. Recapture only when
// the reader scrolls or navigation selects a different position.
rememberAnchor(false);
previousScrollTop.current = el.scrollTop;
previousScrollHeight.current = el.scrollHeight;
}, [rememberAnchor, scrollToBottom, hidePill]);
const handleScroll = useCallback(() => {
if (!ref.current?.clientHeight) return;
const top = ref.current.scrollTop;
const moved = top !== previousScrollTop.current;
const scrollingUp = top < previousScrollTop.current;
previousScrollTop.current = top;
if (previousScrollHeight.current > 0 && pinnedRef.current && !scrollingUp && !upwardIntent.current &&
ref.current.scrollHeight > previousScrollHeight.current) {
reconcileContent();
hidePill();
return;
}
if ((scrollingUp || upwardIntent.current) && top <= 192 && !easingRef.current) requestEarlierHistory();
showScrollbarWhileScrolling();
// A growing composer shrinks this viewport. Some browsers dispatch the
// resulting scroll event before ResizeObserver, so preserve the previous
// pinned state here instead of mistaking the layout change for a user
// scroll away from the bottom.
if (followViewportResize()) {
rememberAnchor(false);
if (pinnedRef.current) hidePill();
return;
}
const pinned = requestingHistory.current ? false : isPinned();
if (easingRef.current) {
// Smooth re-follow in flight: intermediate scroll events must not
// unpin/re-show the pill. Only act once we arrive within the threshold.
if (pinned) {
easingRef.current = false;
pinnedRef.current = true;
hidePill();
}
rememberAnchor(moved);
return;
}
pinnedRef.current = pinned;
if (pinned) upwardIntent.current = false;
rememberAnchor(moved);
if (pinned) hidePill();
else showPill();
}, [
rememberAnchor,
followViewportResize,
isPinned,
hidePill,
showPill,
showScrollbarWhileScrolling,
requestEarlierHistory,
reconcileContent,
]);
const handleJumpToLatest = useCallback(() => {
pendingHashTarget.current = null;
upwardIntent.current = false;
const el = ref.current;
if (!el) return;
if (isPinned()) {
// Already at (or within threshold of) the bottom: no glide needed.
pinnedRef.current = true;
hidePill();
return;
}
easingRef.current = true;
if (typeof el.scrollTo === "function" && !motionDisabled()) {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
} else {
// Environments without scrollTo (older jsdom): fall back to instant.
el.scrollTop = el.scrollHeight;
easingRef.current = false;
pinnedRef.current = true;
hidePill();
}
}, [isPinned, hidePill]);
// A user gesture during the smooth glide cancels the re-follow: stop
// treating scroll events as easing and consider the user unpinned.
useEffect(() => {
const el = ref.current;
if (!el) return;
const cancelEasing = () => {
if (!easingRef.current) return;
easingRef.current = false;
pinnedRef.current = false;
showPill();
};
el.addEventListener("wheel", cancelEasing, { passive: true });
el.addEventListener("touchstart", cancelEasing, { passive: true });
return () => {
el.removeEventListener("wheel", cancelEasing);
el.removeEventListener("touchstart", cancelEasing);
};
}, [showPill]);
useEffect(() => () => {
if (scrollbarIdleTimerRef.current !== null) {
window.clearTimeout(scrollbarIdleTimerRef.current);
}
}, []);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
clientHeightRef.current = el.clientHeight;
clientWidthRef.current = el.clientWidth;
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
if (followViewportResize() && pinnedRef.current) hidePill();
reconcileContent();
});
observer.observe(el);
// The viewport itself does not resize when an image or historical row
// grows. Observe the content box too, before the browser paints it.
for (const child of el.children) observer.observe(child);
return () => observer.disconnect();
}, [followViewportResize, hidePill, reconcileContent]);
// Follow new content only when already pinned; otherwise hold position.
useLayoutEffect(() => {
const el = ref.current;
if (appliedNavigation.current.key !== navigation.key || appliedNavigation.current.hash !== navigation.hash) {
appliedNavigation.current = { key: navigation.key, hash: navigation.hash };
initialPositionApplied.current = false;
pendingHashTarget.current = taskChatHashAnchor(navigation.hash ?? "");
}
const target = pendingHashTarget.current ? document.getElementById(pendingHashTarget.current) : null;
if (el && el.clientHeight > 0 && navigation.ready &&
(!initialPositionApplied.current || (target && el.contains(target)))) {
const top = navigation.initialPosition(el, el.getBoundingClientRect().top, el.scrollTop, { restore: !initialPositionApplied.current });
if (top !== null) {
el.scrollTop = top;
pinnedRef.current = isPinned();
rememberAnchor();
pendingHashTarget.current = null;
}
initialPositionApplied.current = true;
}
reconcileContent();
}, [contentKey, reconcileContent, navigation.key, navigation.hash, navigation.ready]);
return (
<div className="relative min-h-0 flex-1">
<div
ref={ref}
onScroll={handleScroll}
onPointerDown={() => { pendingHashTarget.current = null; }}
tabIndex={scrollingHistory ? 0 : undefined}
role={scrollingHistory ? "region" : undefined}
aria-label={scrollingHistory ? "Conversation messages" : undefined}
onKeyDown={event => {
if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"].includes(event.key)) pendingHashTarget.current = null;
if (["ArrowUp", "PageUp", "Home"].includes(event.key) && ref.current && ref.current.scrollTop <= 192) requestEarlierHistory();
}}
onTouchStart={event => { pendingHashTarget.current = null; touchStartY.current = event.touches.length === 1 ? event.touches[0]!.clientY : null; }}
onTouchMove={event => {
const start = touchStartY.current;
if (start !== null && event.touches.length === 1 && event.touches[0]!.clientY > start + 16
&& ref.current && ref.current.scrollTop <= 192) {
touchStartY.current = null;
requestEarlierHistory();
}
}}
onTouchEnd={() => { touchStartY.current = null; }}
onWheel={event => {
pendingHashTarget.current = null;
upwardIntent.current = event.deltaY < 0;
if (event.deltaY < 0 && ref.current && ref.current.scrollTop <= 192) requestEarlierHistory();
}}
// Keep the viewport tied to the flex-sized wrapper vertically —
// percentage heights don't reliably resolve against flex-determined
// block heights, which let the thread overflow the page. In the
// streamlined shell, extend only the scroll box through the page's
// right gutter; matching padding preserves the message column while
// placing the scrollbar against the properties-panel boundary.
className={cn(
"task-chat-scroll-viewport scrollbar-while-scrolling absolute inset-y-0 left-0 overflow-y-auto",
streamlined
? "-right-4 overflow-x-hidden pr-4 md:-right-6 md:pr-6"
: "right-0",
className,
)}
data-testid="task-chat-scroller"
>
{scrollingHistory ? <>
{scrollingHistory && (scrollingHistory.hasMore || scrollingHistory.loading || scrollingHistory.error) ? (
<div role="status" className="flex h-8 items-center justify-center gap-2 text-xs text-muted-foreground" data-testid="chat-history-status">
{scrollingHistory.error ? <>
<span>Earlier messages couldn’t load.</span>
<button type="button" className="underline underline-offset-4" onClick={() => scrollingHistory.load()}>Retry</button>
</> : scrollingHistory.loading ? "Loading earlier messages…" : "Scroll up for earlier messages"}
</div>
) : null}
{children}
</> : children}
</div>
{pillPhase !== "hidden" ? (
<button
type="button"
aria-label="Scroll to latest"
onClick={handleJumpToLatest}
onAnimationEnd={() => {
if (pillPhase === "out") setPillPhase("hidden");
}}
// The tc-scroll-pill-* keyframes carry the translate(-50%) X-centering
// (fill: both keeps it after the animation) — no -translate-x-1/2 here.
className={cn(
"absolute left-1/2 flex size-8 items-center justify-center rounded-full border border-border bg-background shadow-sm hover:bg-muted",
streamlined ? "bottom-7" : "bottom-3",
pillPhase === "out" ? "tc-scroll-pill-out" : "tc-scroll-pill-in",
)}
>
<ArrowDown className="h-4 w-4" />
</button>
) : null}
</div>
);
}