Files
PaperClipAI/ui/src/components/task-chat/TaskMessageScroller.tsx
T
DottaandPaperclip 91fdf02e95 perf(tasks): defer completed transcripts on compact opens (#18461)
## 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>
2026-10-31 12:56:06 -05:00

397 lines
16 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 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;
}, [rememberAnchor, scrollToBottom, hidePill]);
const handleScroll = useCallback(() => {
if (!ref.current?.clientHeight) return;
const top = ref.current.scrollTop;
const scrollingUp = top < previousScrollTop.current;
previousScrollTop.current = top;
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();
return;
}
pinnedRef.current = pinned;
rememberAnchor();
if (pinned) hidePill();
else showPill();
}, [
rememberAnchor,
followViewportResize,
isPinned,
hidePill,
showPill,
showScrollbarWhileScrolling,
requestEarlierHistory,
]);
const handleJumpToLatest = useCallback(() => {
pendingHashTarget.current = null;
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>
);
}