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>
397 lines
16 KiB
TypeScript
397 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|