Files
PaperClipAI/ui/src/components/task-chat/TaskMessageScroller.tsx
T
scotttongandClaude Fable 5 d270ee47fd feat(ui): task-chat redesign baseline behind enableTaskChatRedesign
New ui/src/components/task-chat/ surface (model, TASK_CHAT_STATES inventory,
fixtures, adapter, Bubble/Marker/StatusPill/ToolCard/ThinkingBlock/UsageReadout/
Scroller/PlanView/ThreadView, TweakPanel), motion tokens in index.css :root,
useTaskChatRedesignEnabled hook + TaskChatRedesignGate, one-line ThreadComponent
seam in IssueDetail.tsx, flag-scoped tabs in IssueProperties.tsx, dev harness
/dev/task-chat-lab, gate script scripts/check-task-chat-motion.mjs.

Flag wired through the 3 lockstep shared files (types/validators/feature-catalog),
preference tier, default off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:55:20 -07:00

84 lines
2.7 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { ArrowDown } from "lucide-react";
const PIN_THRESHOLD_PX = 48;
interface TaskMessageScrollerProps {
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; the moment the user scrolls up we hold
* their position and surface a "Jump to latest" affordance instead of yanking
* them down. Scrolling is INSTANT (not smooth) so a scroll listener cannot
* unpin mid-animation — a known stick-to-bottom failure mode. No reflow/jump is
* introduced during streaming because we only ever scroll when already pinned.
*/
export function TaskMessageScroller({ children, contentKey, className }: TaskMessageScrollerProps) {
const ref = useRef<HTMLDivElement>(null);
const pinnedRef = useRef(true);
const [showJump, setShowJump] = useState(false);
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 handleScroll = useCallback(() => {
const pinned = isPinned();
pinnedRef.current = pinned;
setShowJump(!pinned);
}, [isPinned]);
// Follow new content only when already pinned; otherwise hold position.
useLayoutEffect(() => {
if (pinnedRef.current) scrollToBottom();
}, [contentKey, scrollToBottom]);
useEffect(() => {
scrollToBottom();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="relative min-h-0 flex-1">
<div
ref={ref}
onScroll={handleScroll}
className={cn("h-full overflow-y-auto", className)}
data-testid="task-chat-scroller"
>
{children}
</div>
{showJump ? (
<button
type="button"
onClick={() => {
scrollToBottom();
pinnedRef.current = true;
setShowJump(false);
}}
className="tc-enter-status absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card px-3 py-1 text-xs shadow-sm hover:bg-accent/50"
>
<ArrowDown className="h-3.5 w-3.5" />
Jump to latest
</button>
) : null}
</div>
);
}