Skip to content

Commit cb63d01

Browse files
fix(mothership): follow the post-stop drain to the end instead of freezing
1 parent c9d7e7c commit cb63d01

4 files changed

Lines changed: 50 additions & 35 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -860,8 +860,16 @@ function MessageContentInner({
860860
}, [visibleStreamActivityKey, isStreaming])
861861

862862
const lastSegment = segments[segments.length - 1]
863-
const hasTrailingTextSegment = lastSegment?.type === 'text'
864-
const isRevealing = hasTrailingTextSegment && trailingRevealing
863+
// The reveal tail is the last TEXT segment — a stopped block appends AFTER
864+
// the text that is still visibly draining, and treating the turn as settled
865+
// the moment it lands tears down the scroll machinery mid-reveal.
866+
const revealTailIndex =
867+
lastSegment?.type === 'stopped' && segments[segments.length - 2]?.type === 'text'
868+
? segments.length - 2
869+
: lastSegment?.type === 'text'
870+
? segments.length - 1
871+
: -1
872+
const isRevealing = revealTailIndex >= 0 && trailingRevealing
865873
const phase = deriveMessagePhase({ isStreaming, isRevealing })
866874

867875
const onPhaseChangeRef = useRef(onPhaseChange)
@@ -912,13 +920,13 @@ function MessageContentInner({
912920
onQuestionDismiss={onQuestionDismiss}
913921
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
914922
onRevealStateChange={
915-
i === segments.length - 1 ? handleTrailingRevealChange : undefined
923+
i === revealTailIndex ? handleTrailingRevealChange : undefined
916924
}
917925
onStreamActivityChange={
918-
i === segments.length - 1 ? handleTrailingStreamActivityChange : undefined
926+
i === revealTailIndex ? handleTrailingStreamActivityChange : undefined
919927
}
920928
onPendingTagChange={
921-
i === segments.length - 1 ? handleTrailingPendingTagChange : undefined
929+
i === revealTailIndex ? handleTrailingPendingTagChange : undefined
922930
}
923931
/>
924932
)

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

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -301,19 +301,7 @@ export function MothershipChat({
301301
const messages = useDeferredValue(messagesProp)
302302
const [lastRowAnimating, setLastRowAnimating] = useState(false)
303303
const scrollElementRef = useRef<HTMLDivElement | null>(null)
304-
const { ref: autoScrollRef, detach: detachAutoScroll } = useAutoScroll(
305-
isStreamActive || lastRowAnimating
306-
)
307-
/**
308-
* Stop means freeze: detach auto-scroll exactly like a user scroll-away, so
309-
* every chase path — mutation kicks while the reveal drains, animation
310-
* follows, the settle window — parks instead of nudging the transcript the
311-
* user just halted. The next stream re-seeds stickiness from position.
312-
*/
313-
const handleStopGeneration = useCallback(() => {
314-
detachAutoScroll()
315-
onStopGeneration()
316-
}, [detachAutoScroll, onStopGeneration])
304+
const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating)
317305
const sizerRef = useRef<HTMLDivElement | null>(null)
318306
const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null)
319307
const sizerFloorAppliedRef = useRef(0)
@@ -676,7 +664,7 @@ export function MothershipChat({
676664
ref={userInputRef}
677665
onSubmit={onSubmit}
678666
isSending={isStreamActive}
679-
onStopGeneration={handleStopGeneration}
667+
onStopGeneration={onStopGeneration}
680668
isInitialView={false}
681669
onSendQueuedHead={handleSendQueuedHead}
682670
onEditQueuedTail={handleEditQueuedTail}

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

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ const USER_GESTURE_WINDOW = 250
2525
* in the listener) is the other upward shortcut; plain `Space` pages down.
2626
*/
2727
const SCROLL_UP_KEYS = new Set(['ArrowUp', 'PageUp', 'Home'])
28+
/**
29+
* How long the chase idle-follows after stream teardown. Covers content that
30+
* mounts with the observers already gone — a stop's stopped-row and actions
31+
* append only after the abort round-trips.
32+
*/
33+
const POST_STOP_SETTLE_WINDOW = 800
2834
/**
2935
* Manages sticky auto-scroll for a streaming chat container.
3036
*
@@ -34,10 +40,7 @@ const SCROLL_UP_KEYS = new Set(['ArrowUp', 'PageUp', 'Home'])
3440
* of the bottom to re-engage. Each streaming start re-seeds stickiness from the
3541
* current scroll position, so a user who scrolled up beforehand stays put.
3642
*
37-
* Returns `ref` (callback ref for the scroll container) and `detach` for
38-
* programmatic freezes (a user stop) — it parks every chase path exactly like
39-
* a user scroll-away, until the user scrolls back to the bottom or the next
40-
* stream re-seeds stickiness.
43+
* Returns `ref`, the callback ref for the scroll container.
4144
*/
4245
export function useAutoScroll(isStreaming: boolean) {
4346
const containerRef = useRef<HTMLDivElement>(null)
@@ -59,11 +62,6 @@ export function useAutoScroll(isStreaming: boolean) {
5962
*/
6063
const lastUserGestureAtRef = useRef(Number.NEGATIVE_INFINITY)
6164

62-
const detach = useCallback(() => {
63-
stickyRef.current = false
64-
userDetachedRef.current = true
65-
}, [])
66-
6765
const callbackRef = useCallback((el: HTMLDivElement | null) => {
6866
containerRef.current = el
6967
}, [])
@@ -210,13 +208,14 @@ export function useAutoScroll(isStreaming: boolean) {
210208
chase.cancel()
211209
pointerDownRef.current = false
212210
lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY
213-
// Teardown can land mid-glide (options mounted late in the reveal, gap
214-
// not yet closed) — canceling there strands the follow-ups behind the
215-
// input. One plain kick runs the loop to rest and parks; a stopped turn
216-
// stays frozen because the detached sticky check parks it on frame one.
217-
chase.kick()
211+
// Growth can land after teardown with no observer alive: options mounted
212+
// late in the reveal, and a stop's stopped-row/actions appending once the
213+
// abort completes. Idle-follow briefly so that content isn't stranded
214+
// behind the input; a user who scrolled away stays put via the sticky
215+
// check.
216+
chase.kickUntil(POST_STOP_SETTLE_WINDOW)
218217
}
219218
}, [isStreaming])
220219

221-
return { ref: callbackRef, detach }
220+
return { ref: callbackRef }
222221
}

apps/sim/lib/core/utils/smooth-bottom-chase.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ export interface SmoothBottomChaseHandle {
2626
isActive: () => boolean
2727
/** Start the loop if parked. Call after content growth. */
2828
kick: () => void
29+
/**
30+
* Keep the loop alive for `durationMs` even while the gap is at rest,
31+
* re-checking every frame. For growth that lands with no observable trigger
32+
* — content mounting just after a stream's observers tear down (a stop's
33+
* stopped-row/actions). Repeat calls extend the deadline; one loop only.
34+
*/
35+
kickUntil: (durationMs: number) => void
2936
cancel: () => void
3037
}
3138

@@ -47,11 +54,13 @@ export function createSmoothBottomChase(
4754
): SmoothBottomChaseHandle {
4855
let raf: number | null = null
4956
let lastTop: number | null = null
57+
let deadline = 0
5058

5159
const park = () => {
5260
if (raf !== null) cancelAnimationFrame(raf)
5361
raf = null
5462
lastTop = null
63+
deadline = 0
5564
}
5665

5766
const step = () => {
@@ -71,7 +80,14 @@ export function createSmoothBottomChase(
7180
}
7281
const gap = target.getBottomTop() - top
7382
if (gap <= CHASE_REST_GAP) {
74-
park()
83+
// Within a kickUntil deadline, idle at rest instead of parking so
84+
// trigger-less growth inside the window is still chased.
85+
if (performance.now() >= deadline) {
86+
park()
87+
return
88+
}
89+
lastTop = top
90+
raf = requestAnimationFrame(step)
7591
return
7692
}
7793
target.setTop(top + Math.max(1, gap * SMOOTH_CHASE_RATE))
@@ -97,6 +113,10 @@ export function createSmoothBottomChase(
97113
return {
98114
isActive: () => raf !== null,
99115
kick: start,
116+
kickUntil: (durationMs: number) => {
117+
deadline = Math.max(deadline, performance.now() + durationMs)
118+
start()
119+
},
100120
cancel: park,
101121
}
102122
}

0 commit comments

Comments
 (0)