mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(mothership): stop-button transitions freeze in place (#5838)
* fix(mothership): freeze the transcript on user stop instead of settle-scrolling * fix(mothership): swap stopped row into the shimmer slot and floor the sizer min-height * fix(mothership): detach auto-scroll on stop and dead-band the sizer floor * improvement(mothership): net-zero settle — equal tail regions, drained floor, one growth signal * fix(mothership): follow the post-stop drain to the end instead of freezing * fix(mothership): single stopped tail region and drain cleanup * fix(mothership): clamp-aware chase interrupt so the floor drain can't park the settle follow * fix(mothership): park the chase only on real upward top moves, not growth * improvement(mothership): stack the stopped status above the actions row * improvement(mothership): compact stopped-turn tail with the original 10px rhythm * fix(mothership): single drain cadence and a settle-window gesture kill switch * fix(mothership): debt-aware drain fast-path and settle-window handle retention
This commit is contained in:
+43
-17
@@ -30,6 +30,14 @@ import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils'
|
||||
const FILE_SUBAGENT_ID = 'file'
|
||||
/** Quiet period before the shimmer takes the slot back from streamed output. */
|
||||
const STREAM_IDLE_DELAY_MS = 1_500
|
||||
/**
|
||||
* The vertical extent (10px gap + 36px row) shared by the shimmer slot and the
|
||||
* actions row that replaces it at settle. The swap is only jump-free because
|
||||
* these are equal; changing one side without the other reintroduces a scroll
|
||||
* clamp at end of turn. (A stopped turn's stacked rows are exempt — their
|
||||
* extra height is glided-in growth, not a swap.)
|
||||
*/
|
||||
const TAIL_REGION_CLASSES = 'mt-[10px] flex h-[36px] items-center'
|
||||
|
||||
interface TextSegment {
|
||||
type: 'text'
|
||||
@@ -853,8 +861,16 @@ function MessageContentInner({
|
||||
}, [visibleStreamActivityKey, isStreaming])
|
||||
|
||||
const lastSegment = segments[segments.length - 1]
|
||||
const hasTrailingTextSegment = lastSegment?.type === 'text'
|
||||
const isRevealing = hasTrailingTextSegment && trailingRevealing
|
||||
// The reveal tail is the last TEXT segment — a stopped block appends AFTER
|
||||
// the text that is still visibly draining, and treating the turn as settled
|
||||
// the moment it lands tears down the scroll machinery mid-reveal.
|
||||
const revealTailIndex =
|
||||
lastSegment?.type === 'stopped' && segments[segments.length - 2]?.type === 'text'
|
||||
? segments.length - 2
|
||||
: lastSegment?.type === 'text'
|
||||
? segments.length - 1
|
||||
: -1
|
||||
const isRevealing = revealTailIndex >= 0 && trailingRevealing
|
||||
const phase = deriveMessagePhase({ isStreaming, isRevealing })
|
||||
|
||||
const onPhaseChangeRef = useRef(onPhaseChange)
|
||||
@@ -905,13 +921,13 @@ function MessageContentInner({
|
||||
onQuestionDismiss={onQuestionDismiss}
|
||||
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
|
||||
onRevealStateChange={
|
||||
i === segments.length - 1 ? handleTrailingRevealChange : undefined
|
||||
i === revealTailIndex ? handleTrailingRevealChange : undefined
|
||||
}
|
||||
onStreamActivityChange={
|
||||
i === segments.length - 1 ? handleTrailingStreamActivityChange : undefined
|
||||
i === revealTailIndex ? handleTrailingStreamActivityChange : undefined
|
||||
}
|
||||
onPendingTagChange={
|
||||
i === segments.length - 1 ? handleTrailingPendingTagChange : undefined
|
||||
i === revealTailIndex ? handleTrailingPendingTagChange : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -943,13 +959,12 @@ function MessageContentInner({
|
||||
<Options items={segment.items} onSelect={onOptionSelect} />
|
||||
</div>
|
||||
)
|
||||
// The stopped row renders in the tail region below, in the
|
||||
// shimmer's place — a stop while the shimmer is visible must read
|
||||
// as an in-place replacement, not the shimmer vanishing from the
|
||||
// tail while a row mounts up here.
|
||||
case 'stopped':
|
||||
return (
|
||||
<div key={`stopped-${i}`} className='flex items-center gap-[8px]'>
|
||||
<CircleStop className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='text-[14px] text-[var(--text-body)]'>Stopped by user</span>
|
||||
</div>
|
||||
)
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
@@ -957,8 +972,8 @@ function MessageContentInner({
|
||||
// Fixed-height placeholder for the NEXT piece of output: the shimmer
|
||||
// and arriving output trade places via opacity only, so mid-turn swaps
|
||||
// can't move layout. A sibling of the space-y stack (not a child), so
|
||||
// it carries no stray sibling margin — pt-[10px] is its own gap.
|
||||
<div aria-hidden={!showShimmer} className='pt-[10px]'>
|
||||
// it carries no stray sibling margin.
|
||||
<div aria-hidden={!showShimmer} className={TAIL_REGION_CLASSES}>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-opacity duration-200 ease-out',
|
||||
@@ -968,11 +983,22 @@ function MessageContentInner({
|
||||
<PendingTagIndicator label={thinkingLabel ?? 'Thinking…'} />
|
||||
</div>
|
||||
</div>
|
||||
) : // The settled tail takes the slot's place in the SAME render and at the
|
||||
// SAME extent (TAIL_REGION_CLASSES), so the swap is height-neutral by
|
||||
// construction — no reflow for the pinned scroller to absorb. A stopped
|
||||
// turn instead stacks compact natural rows (10px gaps, no 36px boxes):
|
||||
// its extra height is glided-in growth either way, so only the
|
||||
// shimmer-swap occupant needs the fixed extent.
|
||||
lastSegment?.type === 'stopped' ? (
|
||||
<>
|
||||
<div className='mt-[10px] flex items-center gap-[8px]'>
|
||||
<CircleStop className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='text-[14px] text-[var(--text-body)]'>Stopped by user</span>
|
||||
</div>
|
||||
{actions && <div className='mt-[10px]'>{actions}</div>}
|
||||
</>
|
||||
) : (
|
||||
// The actions row takes the slot's place in the SAME render — a single
|
||||
// ~10px reflow instead of a collapse the buttons would ride upward or a
|
||||
// late mount the chase would visibly scroll to.
|
||||
actions && <div className='mt-2.5'>{actions}</div>
|
||||
actions && <div className={TAIL_REGION_CLASSES}>{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
+64
-4
@@ -12,6 +12,7 @@ import {
|
||||
} from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase'
|
||||
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
|
||||
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
|
||||
import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
|
||||
@@ -109,7 +110,7 @@ const UNSCROLLED = Symbol('unscrolled')
|
||||
const LAYOUT_STYLES = {
|
||||
'mothership-view': {
|
||||
scrollContainer:
|
||||
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-2 [scrollbar-gutter:stable_both-edges]',
|
||||
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-2 [overflow-anchor:none] [scrollbar-gutter:stable_both-edges]',
|
||||
sizer: 'relative mx-auto w-full max-w-[48rem]',
|
||||
rowGap: 'pb-6',
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-3',
|
||||
@@ -120,7 +121,8 @@ const LAYOUT_STYLES = {
|
||||
footerInner: 'mx-auto max-w-[48rem]',
|
||||
},
|
||||
'copilot-view': {
|
||||
scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4',
|
||||
scrollContainer:
|
||||
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4 [overflow-anchor:none]',
|
||||
sizer: 'relative w-full',
|
||||
rowGap: 'pb-4',
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-2',
|
||||
@@ -302,6 +304,9 @@ export function MothershipChat({
|
||||
const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating)
|
||||
const sizerRef = useRef<HTMLDivElement | null>(null)
|
||||
const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null)
|
||||
const sizerFloorAppliedRef = useRef(0)
|
||||
const floorDrainRafRef = useRef(0)
|
||||
useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), [])
|
||||
|
||||
/**
|
||||
* Sizer floor while streaming: `scrollHeight` must never dip below the
|
||||
@@ -318,6 +323,15 @@ export function MothershipChat({
|
||||
* Active on the same signal as auto-scroll: the reveal keeps re-parsing
|
||||
* markdown (and shrinking) after the network stream closes, so the floor
|
||||
* must hold through `lastRowAnimating` too.
|
||||
*
|
||||
* Release is DRAINED, not cliffed: while active the floor forbids
|
||||
* scrollHeight from dropping, so permanent shrinks over the turn accrue as
|
||||
* phantom space (debt). Clearing min-height in one commit released that
|
||||
* whole debt as a single clamp — the end-of-turn downward jump. Instead the
|
||||
* floor glides down to the natural size at the chase's rate; the browser's
|
||||
* clamp follows a few px per frame, which reads as the same eased settle as
|
||||
* the rest of the stream. Instant-clears when the debt is sub-pixel or the
|
||||
* user isn't pinned (shrinking below-viewport space is invisible then).
|
||||
*/
|
||||
const floorActive = isStreamActive || lastRowAnimating
|
||||
useLayoutEffect(() => {
|
||||
@@ -325,9 +339,41 @@ export function MothershipChat({
|
||||
const el = scrollElementRef.current
|
||||
if (!sizer || !el) return
|
||||
if (!floorActive) {
|
||||
sizer.style.minHeight = ''
|
||||
if (sizerFloorAppliedRef.current === 0) return
|
||||
// A drain already in flight keeps its own rAF cadence — settle-burst
|
||||
// commits re-enter this branch and must not add extra steps in layout,
|
||||
// which would accelerate the release past the eased rate.
|
||||
if (floorDrainRafRef.current !== 0) return
|
||||
scrollerPaddingRef.current = null
|
||||
const drain = () => {
|
||||
const target = virtualizer.getTotalSize()
|
||||
const current = sizerFloorAppliedRef.current
|
||||
if (current === 0) {
|
||||
floorDrainRafRef.current = 0
|
||||
return
|
||||
}
|
||||
// Instant-clear only when the whole remaining debt sits BELOW the
|
||||
// viewport (debt ≤ distance-from-bottom) — then the shrink is
|
||||
// invisible. A merely-unpinned viewport with debt larger than its
|
||||
// slack would still clamp, so it keeps the eased drain instead.
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
|
||||
const debt = current - target
|
||||
if (debt <= 1 || debt <= distance) {
|
||||
sizerFloorAppliedRef.current = 0
|
||||
floorDrainRafRef.current = 0
|
||||
sizer.style.minHeight = ''
|
||||
return
|
||||
}
|
||||
const next = Math.floor(current - Math.max(1, debt * SMOOTH_CHASE_RATE))
|
||||
sizerFloorAppliedRef.current = next
|
||||
sizer.style.minHeight = `${next}px`
|
||||
floorDrainRafRef.current = requestAnimationFrame(drain)
|
||||
}
|
||||
floorDrainRafRef.current = requestAnimationFrame(drain)
|
||||
return
|
||||
}
|
||||
cancelAnimationFrame(floorDrainRafRef.current)
|
||||
floorDrainRafRef.current = 0
|
||||
if (!scrollerPaddingRef.current) {
|
||||
const style = getComputedStyle(el)
|
||||
scrollerPaddingRef.current = {
|
||||
@@ -336,7 +382,21 @@ export function MothershipChat({
|
||||
}
|
||||
}
|
||||
const padding = scrollerPaddingRef.current
|
||||
const floor = Math.max(0, el.scrollTop + el.clientHeight - padding.top - padding.bottom)
|
||||
// Math.floor, not the raw float: a fractional min-height can round
|
||||
// scrollHeight 1px ABOVE the scrolled-to extent, and that phantom 1px gap
|
||||
// re-derives 1px higher after every chase step — a visible 1px/frame
|
||||
// upward creep whenever the floor is what's holding scrollHeight.
|
||||
const floor = Math.max(
|
||||
0,
|
||||
Math.floor(el.scrollTop + el.clientHeight - padding.top - padding.bottom)
|
||||
)
|
||||
// Dead-band: the floor feeds back into its own inputs (a floored value can
|
||||
// land a fraction BELOW the extent, the browser clamps scrollTop, and the
|
||||
// next commit re-derives from the clamped position — a visible ~1px×N
|
||||
// downward cascade on fractional-scrollTop displays). Sub-pixel deltas are
|
||||
// rounding noise from that loop, never real growth; only apply real moves.
|
||||
if (Math.abs(floor - sizerFloorAppliedRef.current) <= 1) return
|
||||
sizerFloorAppliedRef.current = floor
|
||||
sizer.style.minHeight = `${floor}px`
|
||||
})
|
||||
const setScrollElement = useCallback(
|
||||
|
||||
@@ -25,22 +25,12 @@ const USER_GESTURE_WINDOW = 250
|
||||
* in the listener) is the other upward shortcut; plain `Space` pages down.
|
||||
*/
|
||||
const SCROLL_UP_KEYS = new Set(['ArrowUp', 'PageUp', 'Home'])
|
||||
/** How long to keep chasing the bottom while a CSS height animation plays. */
|
||||
const ANIMATION_FOLLOW_WINDOW = 500
|
||||
/**
|
||||
* How long to keep chasing the bottom after streaming stops. End-of-turn content
|
||||
* mounts just after `isStreaming` flips false — the suggested-follow-up options,
|
||||
* the actions row (swapped into the thinking slot's place), and the
|
||||
* virtualizer's re-measure of the grown row — so a single final scroll fires
|
||||
* before it lays out and leaves it clipped behind the input. Following for a
|
||||
* short window pulls it into view.
|
||||
* How long the chase idle-follows after stream teardown. Covers content that
|
||||
* mounts with the observers already gone — a stop's stopped-row and actions
|
||||
* append only after the abort round-trips.
|
||||
*/
|
||||
const POST_STREAM_SETTLE_WINDOW = 300
|
||||
|
||||
interface UseAutoScrollOptions {
|
||||
scrollOnMount?: boolean
|
||||
}
|
||||
|
||||
const POST_STOP_SETTLE_WINDOW = 800
|
||||
/**
|
||||
* Manages sticky auto-scroll for a streaming chat container.
|
||||
*
|
||||
@@ -50,20 +40,15 @@ interface UseAutoScrollOptions {
|
||||
* of the bottom to re-engage. Each streaming start re-seeds stickiness from the
|
||||
* current scroll position, so a user who scrolled up beforehand stays put.
|
||||
*
|
||||
* Returns `ref` (callback ref for the scroll container) and `scrollToBottom`
|
||||
* for imperative use after layout-changing events like panel expansion.
|
||||
* Returns `ref`, the callback ref for the scroll container.
|
||||
*/
|
||||
export function useAutoScroll(
|
||||
isStreaming: boolean,
|
||||
{ scrollOnMount = false }: UseAutoScrollOptions = {}
|
||||
) {
|
||||
export function useAutoScroll(isStreaming: boolean) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const stickyRef = useRef(true)
|
||||
const userDetachedRef = useRef(false)
|
||||
const prevScrollTopRef = useRef(0)
|
||||
const prevScrollHeightRef = useRef(0)
|
||||
const touchStartYRef = useRef(0)
|
||||
const scrollOnMountRef = useRef(scrollOnMount)
|
||||
/**
|
||||
* Whether the user is actively dragging the scrollbar — a pointer press on the
|
||||
* container itself rather than its content. Reset on teardown so a pointer held
|
||||
@@ -77,21 +62,25 @@ export function useAutoScroll(
|
||||
*/
|
||||
const lastUserGestureAtRef = useRef(Number.NEGATIVE_INFINITY)
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
el.scrollTop = el.scrollHeight
|
||||
}, [])
|
||||
|
||||
const callbackRef = useCallback((el: HTMLDivElement | null) => {
|
||||
containerRef.current = el
|
||||
if (el && scrollOnMountRef.current) el.scrollTop = el.scrollHeight
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Cancels the previous teardown's settle window (chase, temp listeners,
|
||||
* removal timer). Invoked when a new stream starts — a stop-then-resend must
|
||||
* not leave the old settle chase writing beside the new stream's chase — and
|
||||
* on unmount.
|
||||
*/
|
||||
const settleCleanupRef = useRef<(() => void) | null>(null)
|
||||
useEffect(() => () => settleCleanupRef.current?.(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreaming) return
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
settleCleanupRef.current?.()
|
||||
settleCleanupRef.current = null
|
||||
|
||||
/**
|
||||
* Eased bottom-chase shared by the mutation observer and the seed below —
|
||||
@@ -190,42 +179,38 @@ export function useAutoScroll(
|
||||
prevScrollHeightRef.current = scrollHeight
|
||||
}
|
||||
|
||||
const onMutation = () => {
|
||||
/**
|
||||
* The single growth signal: the transcript sizer's height. Every source of
|
||||
* scrollHeight growth flows through it — virtualizer re-measures per
|
||||
* streamed token, CSS height animations (each frame re-measures the row),
|
||||
* the sizer min-height floor — so one ResizeObserver replaces a subtree
|
||||
* MutationObserver plus an `animationstart` deadline machine, and there is
|
||||
* exactly one reason the chase ever runs.
|
||||
*/
|
||||
const sizer = el.firstElementChild
|
||||
const onSizerResize = () => {
|
||||
prevScrollHeightRef.current = el.scrollHeight
|
||||
if (!stickyRef.current) return
|
||||
chase.kick()
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS-driven height animations (e.g. Radix Collapsible expanding mid-stream)
|
||||
* grow scrollHeight without triggering MutationObserver, so auto-scroll stops
|
||||
* following. Keep the one chase loop alive for a short window so the
|
||||
* container stays pinned while the animation runs. `animationstart` fires
|
||||
* for every child animation in the transcript (segment fade-ins, loader
|
||||
* keyframes, label crossfades) — kickUntil coalesces them into a single
|
||||
* extended deadline on the single loop; anything more snaps the glide.
|
||||
*/
|
||||
const onAnimationStart = () => chase.kickUntil(ANIMATION_FOLLOW_WINDOW)
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: true })
|
||||
el.addEventListener('touchstart', onTouchStart, { passive: true })
|
||||
el.addEventListener('touchmove', onTouchMove, { passive: true })
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
el.addEventListener('animationstart', onAnimationStart)
|
||||
el.addEventListener('pointerdown', onPointerDown, { passive: true })
|
||||
el.addEventListener('keydown', onKeyDown, { passive: true })
|
||||
window.addEventListener('pointerup', onPointerUp, { passive: true })
|
||||
window.addEventListener('pointercancel', onPointerUp, { passive: true })
|
||||
|
||||
const observer = new MutationObserver(onMutation)
|
||||
observer.observe(el, { childList: true, subtree: true, characterData: true })
|
||||
const observer = new ResizeObserver(onSizerResize)
|
||||
if (sizer) observer.observe(sizer)
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('touchstart', onTouchStart)
|
||||
el.removeEventListener('touchmove', onTouchMove)
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
el.removeEventListener('animationstart', onAnimationStart)
|
||||
el.removeEventListener('pointerdown', onPointerDown)
|
||||
el.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
@@ -234,12 +219,37 @@ export function useAutoScroll(
|
||||
chase.cancel()
|
||||
pointerDownRef.current = false
|
||||
lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY
|
||||
// End-of-turn content mounts just after teardown; follow it briefly. The
|
||||
// chase's own upward-move interrupt still protects a real user scroll
|
||||
// even with the gesture listeners gone.
|
||||
chase.kickUntil(POST_STREAM_SETTLE_WINDOW)
|
||||
// Growth can land after teardown with no observer alive: options mounted
|
||||
// late in the reveal, and a stop's stopped-row/actions appending once the
|
||||
// abort completes. Idle-follow briefly so that content isn't stranded
|
||||
// behind the input; a user who scrolled away stays put via the sticky
|
||||
// check.
|
||||
chase.kickUntil(POST_STOP_SETTLE_WINDOW)
|
||||
// The main gesture listeners are gone, so give the settle window its own
|
||||
// kill switch: the chase's clamp-aware interrupt catches wheel-scale
|
||||
// moves, but a slow trackpad glide during a concurrent shrink could slip
|
||||
// under it for up to the window's duration.
|
||||
const cancelOnGesture = (event: Event) => {
|
||||
if (event.type === 'wheel' && (event as WheelEvent).deltaY >= 0) return
|
||||
chase.cancel()
|
||||
}
|
||||
el.addEventListener('wheel', cancelOnGesture, { passive: true })
|
||||
el.addEventListener('touchmove', cancelOnGesture, { passive: true })
|
||||
const removeGestureGuard = () => {
|
||||
el.removeEventListener('wheel', cancelOnGesture)
|
||||
el.removeEventListener('touchmove', cancelOnGesture)
|
||||
}
|
||||
const guardTimeout = setTimeout(() => {
|
||||
removeGestureGuard()
|
||||
settleCleanupRef.current = null
|
||||
}, POST_STOP_SETTLE_WINDOW + 100)
|
||||
settleCleanupRef.current = () => {
|
||||
chase.cancel()
|
||||
clearTimeout(guardTimeout)
|
||||
removeGestureGuard()
|
||||
}
|
||||
}
|
||||
}, [isStreaming, scrollToBottom])
|
||||
}, [isStreaming])
|
||||
|
||||
return { ref: callbackRef, scrollToBottom }
|
||||
return { ref: callbackRef }
|
||||
}
|
||||
|
||||
@@ -28,10 +28,9 @@ export interface SmoothBottomChaseHandle {
|
||||
kick: () => void
|
||||
/**
|
||||
* Keep the loop alive for `durationMs` even while the gap is at rest,
|
||||
* re-checking every frame. Covers growth that arrives over several frames
|
||||
* with no observable trigger — a CSS height animation, or a virtualizer
|
||||
* re-measure settling after streaming stops. Repeat calls extend the
|
||||
* deadline; there is never more than one loop.
|
||||
* re-checking every frame. For growth that lands with no observable trigger
|
||||
* — content mounting just after a stream's observers tear down (a stop's
|
||||
* stopped-row/actions). Repeat calls extend the deadline; one loop only.
|
||||
*/
|
||||
kickUntil: (durationMs: number) => void
|
||||
cancel: () => void
|
||||
@@ -45,9 +44,12 @@ export interface SmoothBottomChaseHandle {
|
||||
*
|
||||
* Self-interrupting: chase writes only ever move the offset down, and content
|
||||
* growth leaves it where the last write put it — so an offset that moved UP
|
||||
* since the last write can only be a user scrolling away, and the loop parks
|
||||
* instead of fighting them. `shouldContinue` layers any caller-owned stickiness
|
||||
* on top (checked every frame).
|
||||
* since the last write, MORE than the bottom itself moved up, can only be a
|
||||
* user scrolling away, and the loop parks instead of fighting them. (A
|
||||
* content-shrink clamp moves the offset and the bottom together — e.g. the
|
||||
* transcript's floor drain — and must not read as a user scroll.)
|
||||
* `shouldContinue` layers any caller-owned stickiness on top (checked every
|
||||
* frame).
|
||||
*/
|
||||
export function createSmoothBottomChase(
|
||||
target: SmoothBottomChaseTarget,
|
||||
@@ -55,14 +57,14 @@ export function createSmoothBottomChase(
|
||||
): SmoothBottomChaseHandle {
|
||||
let raf: number | null = null
|
||||
let lastTop: number | null = null
|
||||
let lastBottomTop: number | null = null
|
||||
let deadline = 0
|
||||
|
||||
const park = () => {
|
||||
if (raf !== null) cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
lastTop = null
|
||||
// A stale deadline must not leak into a later plain kick() — kick alone
|
||||
// parks at rest, only a live kickUntil window idles through it.
|
||||
lastBottomTop = null
|
||||
deadline = 0
|
||||
}
|
||||
|
||||
@@ -77,14 +79,21 @@ export function createSmoothBottomChase(
|
||||
return
|
||||
}
|
||||
const top = target.getTop()
|
||||
if (lastTop !== null && top < lastTop - 1) {
|
||||
const bottomTop = target.getBottomTop()
|
||||
// A user scroll is an ACTUAL upward top move (first clause — growth alone
|
||||
// must not trip this) that exceeds any upward move of the bottom itself
|
||||
// (second clause — a shrink clamp moves both together).
|
||||
const topDrop = lastTop === null ? 0 : lastTop - top
|
||||
const bottomDrop = lastBottomTop === null ? 0 : lastBottomTop - bottomTop
|
||||
if (topDrop > 1 && topDrop > bottomDrop + 1) {
|
||||
park()
|
||||
return
|
||||
}
|
||||
const gap = target.getBottomTop() - top
|
||||
lastBottomTop = bottomTop
|
||||
const gap = bottomTop - top
|
||||
if (gap <= CHASE_REST_GAP) {
|
||||
// Within a kickUntil deadline the loop idles at rest instead of parking,
|
||||
// so growth in the deadline window is chased without a fresh trigger.
|
||||
// Within a kickUntil deadline, idle at rest instead of parking so
|
||||
// trigger-less growth inside the window is still chased.
|
||||
if (performance.now() >= deadline) {
|
||||
park()
|
||||
return
|
||||
@@ -105,12 +114,12 @@ export function createSmoothBottomChase(
|
||||
* Seed the upward-move interrupt baseline at (re)start so a user scroll-up
|
||||
* between the kick and the first frame parks the loop immediately — without
|
||||
* it the first step has no baseline and writes one downward frame against
|
||||
* the user (relevant on the teardown kickUntil, where the gesture listeners
|
||||
* are already gone).
|
||||
* the user.
|
||||
*/
|
||||
const start = () => {
|
||||
if (raf !== null) return
|
||||
lastTop = target.getTop()
|
||||
lastBottomTop = target.getBottomTop()
|
||||
raf = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user