Scroll behavior: hysteresis collapse, expand-scrolls-to-bottom, streaming follow
- Oscillation fix: collapsing the dock frees ~Δpx of flow height; within Δ of the bottom the browser clamps scrollTop to the new max, landing the learner AT the bottom, which re-expanded the dock → loop. Now a hysteresis state machine on the scroller's scroll events: collapse only when distance-from-bottom > Δ+64 (strictly clamp-proof), expand only within 16px of the bottom. ~370px dead band; exactly one transition per direction verified live. Heights measured live per question. - Reaching the bottom re-expands the dock AND scrolls to reveal it fully (previously it grew over the content and left you short of the bottom). - Streaming follow fixed: ConversationContent had h-full, so the observed content box never grew and use-stick-to-bottom's ResizeObserver never fired — no auto-scroll during streaming. min-h-full restores growth. Verified: distance-from-bottom ≤70px transient during a 25s stream, settles at 1px.
This commit is contained in:
+57
-14
@@ -96,7 +96,7 @@ export function Chat({
|
|||||||
|
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
<Conversation className="h-full">
|
<Conversation className="h-full">
|
||||||
<ConversationContent className="h-full gap-0 p-0">
|
<ConversationContent className="min-h-full gap-0 p-0">
|
||||||
<div className={`${COLUMN} flex flex-1 flex-col gap-4 pt-4`}>
|
<div className={`${COLUMN} flex flex-1 flex-col gap-4 pt-4`}>
|
||||||
{messages.length === 0 && !busy && (
|
{messages.length === 0 && !busy && (
|
||||||
<ConversationEmptyState
|
<ConversationEmptyState
|
||||||
@@ -204,23 +204,61 @@ function DockOverlay({
|
|||||||
composer: React.ReactNode;
|
composer: React.ReactNode;
|
||||||
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
||||||
}) {
|
}) {
|
||||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
|
||||||
const [forceOpen, setForceOpen] = useState(false);
|
const [expanded, setExpanded] = useState(true);
|
||||||
useEffect(() => {
|
|
||||||
if (!isAtBottom) setForceOpen(false);
|
|
||||||
}, [isAtBottom]);
|
|
||||||
const expanded = isAtBottom || forceOpen;
|
|
||||||
|
|
||||||
const mode = pending ? `question:${pending.toolCallId}` : "chat";
|
|
||||||
|
|
||||||
// A new question while the learner is at the bottom: scroll so the dock
|
|
||||||
// never covers the transcript tail. (Scrolled up → collapsed bar, no yank.)
|
|
||||||
const pendingId = pending?.toolCallId ?? null;
|
const pendingId = pending?.toolCallId ?? null;
|
||||||
|
|
||||||
|
// New question: open expanded if the learner is following at the bottom
|
||||||
|
// (scroll to reveal it); if they're reading history, arrive as the slim bar.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingId && isAtBottom) void scrollToBottom();
|
if (pendingId) {
|
||||||
|
setExpanded(isAtBottom);
|
||||||
|
if (isAtBottom) void scrollToBottom();
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- fire only when a NEW question lands
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- fire only when a NEW question lands
|
||||||
}, [pendingId]);
|
}, [pendingId]);
|
||||||
|
|
||||||
|
// ── Collapse state machine with hysteresis ──
|
||||||
|
// Collapsing frees ~Δpx of flow height. If it happened while the learner is
|
||||||
|
// within Δ of the bottom, the browser clamps scrollTop to the new max —
|
||||||
|
// landing them AT the bottom, which re-expands the dock, which re-collapses…
|
||||||
|
// the oscillation. So: collapse only once scrolled a full dock-height past
|
||||||
|
// the bottom (clamp-proof), expand only within 16px of the bottom, and leave
|
||||||
|
// a wide dead band between the two thresholds. Heights measured live.
|
||||||
|
const expandedRef = useRef(expanded);
|
||||||
|
expandedRef.current = expanded;
|
||||||
|
const pendingRef = useRef(!!pending);
|
||||||
|
pendingRef.current = !!pending;
|
||||||
|
const expandedHRef = useRef(0);
|
||||||
|
const collapsedHRef = useRef(42);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!pendingRef.current) return;
|
||||||
|
const d = el.scrollHeight - el.clientHeight - el.scrollTop;
|
||||||
|
const delta = Math.max(expandedHRef.current - collapsedHRef.current, 240);
|
||||||
|
if (expandedRef.current) {
|
||||||
|
if (d > delta + 64) setExpanded(false);
|
||||||
|
} else if (d < 16) {
|
||||||
|
setExpanded(true);
|
||||||
|
void scrollToBottom();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
el.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => el.removeEventListener("scroll", onScroll);
|
||||||
|
}, [scrollRef, scrollToBottom]);
|
||||||
|
|
||||||
|
const recordHeight = (h: number) => {
|
||||||
|
if (!pendingRef.current) return;
|
||||||
|
if (expandedRef.current) expandedHRef.current = h;
|
||||||
|
else collapsedHRef.current = h;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mode = pending ? `question:${pending.toolCallId}` : "chat";
|
||||||
|
|
||||||
// Height morph: animate the shell from its previous height to its new one
|
// Height morph: animate the shell from its previous height to its new one
|
||||||
// whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing
|
// whenever the variant (chat ↔ question ↔ collapsed bar) changes. Typing
|
||||||
// growth keeps the same stateKey and is NOT animated.
|
// growth keeps the same stateKey and is NOT animated.
|
||||||
@@ -235,11 +273,16 @@ function DockOverlay({
|
|||||||
// Same variant: keep the settled height fresh, but never measure while
|
// Same variant: keep the settled height fresh, but never measure while
|
||||||
// a morph is in flight — getBoundingClientRect would read the ANIMATED
|
// a morph is in flight — getBoundingClientRect would read the ANIMATED
|
||||||
// value and corrupt the start height of the next morph.
|
// value and corrupt the start height of the next morph.
|
||||||
if (el.getAnimations().length === 0) p.height = el.getBoundingClientRect().height;
|
if (el.getAnimations().length === 0) {
|
||||||
|
const h = el.getBoundingClientRect().height;
|
||||||
|
p.height = h;
|
||||||
|
recordHeight(h);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.getAnimations().forEach((a) => a.cancel()); // flush to natural height before measuring
|
el.getAnimations().forEach((a) => a.cancel()); // flush to natural height before measuring
|
||||||
const h = el.getBoundingClientRect().height;
|
const h = el.getBoundingClientRect().height;
|
||||||
|
recordHeight(h);
|
||||||
if (p && Math.abs(p.height - h) > 1) {
|
if (p && Math.abs(p.height - h) > 1) {
|
||||||
el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], {
|
el.animate([{ height: `${p.height}px` }, { height: `${h}px` }], {
|
||||||
duration: 180,
|
duration: 180,
|
||||||
@@ -263,7 +306,7 @@ function DockOverlay({
|
|||||||
input={pending.input as never}
|
input={pending.input as never}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onExpand={() => {
|
onExpand={() => {
|
||||||
setForceOpen(true);
|
setExpanded(true);
|
||||||
void scrollToBottom();
|
void scrollToBottom();
|
||||||
}}
|
}}
|
||||||
onSubmit={onSubmitAnswer}
|
onSubmit={onSubmitAnswer}
|
||||||
|
|||||||
Reference in New Issue
Block a user