Skip to content

Commit e7ea9db

Browse files
fix(mothership): detach auto-scroll on stop and dead-band the sizer floor
1 parent fff6119 commit e7ea9db

2 files changed

Lines changed: 33 additions & 28 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -299,24 +299,22 @@ export function MothershipChat({
299299
const messages = useDeferredValue(messagesProp)
300300
const [lastRowAnimating, setLastRowAnimating] = useState(false)
301301
const scrollElementRef = useRef<HTMLDivElement | null>(null)
302+
const { ref: autoScrollRef, detach: detachAutoScroll } = useAutoScroll(
303+
isStreamActive || lastRowAnimating
304+
)
302305
/**
303-
* True from the stop button until the next stream: a stopped turn must not
304-
* settle-scroll — the user asked everything to freeze, and following the
305-
* stopped-row/actions mount nudges the transcript up right after they did.
306+
* Stop means freeze: detach auto-scroll exactly like a user scroll-away, so
307+
* every chase path — mutation kicks while the reveal drains, animation
308+
* follows, the settle window — parks instead of nudging the transcript the
309+
* user just halted. The next stream re-seeds stickiness from position.
306310
*/
307-
const stopRequestedRef = useRef(false)
308311
const handleStopGeneration = useCallback(() => {
309-
stopRequestedRef.current = true
312+
detachAutoScroll()
310313
onStopGeneration()
311-
}, [onStopGeneration])
312-
useEffect(() => {
313-
if (isStreamActive) stopRequestedRef.current = false
314-
}, [isStreamActive])
315-
const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating, {
316-
shouldFollowSettle: () => !stopRequestedRef.current,
317-
})
314+
}, [detachAutoScroll, onStopGeneration])
318315
const sizerRef = useRef<HTMLDivElement | null>(null)
319316
const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null)
317+
const sizerFloorAppliedRef = useRef(0)
320318

321319
/**
322320
* Sizer floor while streaming: `scrollHeight` must never dip below the
@@ -340,6 +338,7 @@ export function MothershipChat({
340338
const el = scrollElementRef.current
341339
if (!sizer || !el) return
342340
if (!floorActive) {
341+
sizerFloorAppliedRef.current = 0
343342
sizer.style.minHeight = ''
344343
return
345344
}
@@ -359,6 +358,13 @@ export function MothershipChat({
359358
0,
360359
Math.floor(el.scrollTop + el.clientHeight - padding.top - padding.bottom)
361360
)
361+
// Dead-band: the floor feeds back into its own inputs (a floored value can
362+
// land a fraction BELOW the extent, the browser clamps scrollTop, and the
363+
// next commit re-derives from the clamped position — a visible ~1px×N
364+
// downward cascade on fractional-scrollTop displays). Sub-pixel deltas are
365+
// rounding noise from that loop, never real growth; only apply real moves.
366+
if (Math.abs(floor - sizerFloorAppliedRef.current) <= 1) return
367+
sizerFloorAppliedRef.current = floor
362368
sizer.style.minHeight = `${floor}px`
363369
})
364370
const setScrollElement = useCallback(

apps/sim/hooks/use-auto-scroll.ts

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,6 @@ const POST_STREAM_SETTLE_WINDOW = 300
3939

4040
interface UseAutoScrollOptions {
4141
scrollOnMount?: boolean
42-
/**
43-
* Consulted at stream teardown; return false to skip the post-stream settle
44-
* follow. A user-initiated stop means "freeze" — chasing the stopped-row and
45-
* actions mount would visibly nudge the transcript the user just halted.
46-
*/
47-
shouldFollowSettle?: () => boolean
4842
}
4943

5044
/**
@@ -56,17 +50,18 @@ interface UseAutoScrollOptions {
5650
* of the bottom to re-engage. Each streaming start re-seeds stickiness from the
5751
* current scroll position, so a user who scrolled up beforehand stays put.
5852
*
59-
* Returns `ref` (callback ref for the scroll container) and `scrollToBottom`
60-
* for imperative use after layout-changing events like panel expansion.
53+
* Returns `ref` (callback ref for the scroll container), `scrollToBottom` for
54+
* imperative use after layout-changing events like panel expansion, and
55+
* `detach` for programmatic freezes (a user stop) — it parks every chase path
56+
* exactly like a user scroll-away, until the user scrolls back to the bottom
57+
* or the next stream re-seeds stickiness.
6158
*/
6259
export function useAutoScroll(
6360
isStreaming: boolean,
64-
{ scrollOnMount = false, shouldFollowSettle }: UseAutoScrollOptions = {}
61+
{ scrollOnMount = false }: UseAutoScrollOptions = {}
6562
) {
6663
const containerRef = useRef<HTMLDivElement>(null)
6764
const stickyRef = useRef(true)
68-
const shouldFollowSettleRef = useRef(shouldFollowSettle)
69-
shouldFollowSettleRef.current = shouldFollowSettle
7065
const userDetachedRef = useRef(false)
7166
const prevScrollTopRef = useRef(0)
7267
const prevScrollHeightRef = useRef(0)
@@ -91,6 +86,11 @@ export function useAutoScroll(
9186
el.scrollTop = el.scrollHeight
9287
}, [])
9388

89+
const detach = useCallback(() => {
90+
stickyRef.current = false
91+
userDetachedRef.current = true
92+
}, [])
93+
9494
const callbackRef = useCallback((el: HTMLDivElement | null) => {
9595
containerRef.current = el
9696
if (el && scrollOnMountRef.current) el.scrollTop = el.scrollHeight
@@ -244,12 +244,11 @@ export function useAutoScroll(
244244
lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY
245245
// End-of-turn content mounts just after teardown; follow it briefly. The
246246
// chase's own upward-move interrupt still protects a real user scroll
247-
// even with the gesture listeners gone.
248-
if (shouldFollowSettleRef.current?.() !== false) {
249-
chase.kickUntil(POST_STREAM_SETTLE_WINDOW)
250-
}
247+
// even with the gesture listeners gone, and the per-frame sticky check
248+
// makes this a no-op after a detach().
249+
chase.kickUntil(POST_STREAM_SETTLE_WINDOW)
251250
}
252251
}, [isStreaming, scrollToBottom])
253252

254-
return { ref: callbackRef, scrollToBottom }
253+
return { ref: callbackRef, scrollToBottom, detach }
255254
}

0 commit comments

Comments
 (0)