Skip to content

Commit d7d42fa

Browse files
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
1 parent b49fe16 commit d7d42fa

4 files changed

Lines changed: 191 additions & 86 deletions

File tree

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

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils'
3030
const FILE_SUBAGENT_ID = 'file'
3131
/** Quiet period before the shimmer takes the slot back from streamed output. */
3232
const STREAM_IDLE_DELAY_MS = 1_500
33+
/**
34+
* The vertical extent (10px gap + 36px row) shared by the shimmer slot and the
35+
* actions row that replaces it at settle. The swap is only jump-free because
36+
* these are equal; changing one side without the other reintroduces a scroll
37+
* clamp at end of turn. (A stopped turn's stacked rows are exempt — their
38+
* extra height is glided-in growth, not a swap.)
39+
*/
40+
const TAIL_REGION_CLASSES = 'mt-[10px] flex h-[36px] items-center'
3341

3442
interface TextSegment {
3543
type: 'text'
@@ -853,8 +861,16 @@ function MessageContentInner({
853861
}, [visibleStreamActivityKey, isStreaming])
854862

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

860876
const onPhaseChangeRef = useRef(onPhaseChange)
@@ -905,13 +921,13 @@ function MessageContentInner({
905921
onQuestionDismiss={onQuestionDismiss}
906922
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
907923
onRevealStateChange={
908-
i === segments.length - 1 ? handleTrailingRevealChange : undefined
924+
i === revealTailIndex ? handleTrailingRevealChange : undefined
909925
}
910926
onStreamActivityChange={
911-
i === segments.length - 1 ? handleTrailingStreamActivityChange : undefined
927+
i === revealTailIndex ? handleTrailingStreamActivityChange : undefined
912928
}
913929
onPendingTagChange={
914-
i === segments.length - 1 ? handleTrailingPendingTagChange : undefined
930+
i === revealTailIndex ? handleTrailingPendingTagChange : undefined
915931
}
916932
/>
917933
)
@@ -943,22 +959,21 @@ function MessageContentInner({
943959
<Options items={segment.items} onSelect={onOptionSelect} />
944960
</div>
945961
)
962+
// The stopped row renders in the tail region below, in the
963+
// shimmer's place — a stop while the shimmer is visible must read
964+
// as an in-place replacement, not the shimmer vanishing from the
965+
// tail while a row mounts up here.
946966
case 'stopped':
947-
return (
948-
<div key={`stopped-${i}`} className='flex items-center gap-[8px]'>
949-
<CircleStop className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
950-
<span className='text-[14px] text-[var(--text-body)]'>Stopped by user</span>
951-
</div>
952-
)
967+
return null
953968
}
954969
})}
955970
</div>
956971
{thinkingExpanded && isLast ? (
957972
// Fixed-height placeholder for the NEXT piece of output: the shimmer
958973
// and arriving output trade places via opacity only, so mid-turn swaps
959974
// can't move layout. A sibling of the space-y stack (not a child), so
960-
// it carries no stray sibling margin — pt-[10px] is its own gap.
961-
<div aria-hidden={!showShimmer} className='pt-[10px]'>
975+
// it carries no stray sibling margin.
976+
<div aria-hidden={!showShimmer} className={TAIL_REGION_CLASSES}>
962977
<div
963978
className={cn(
964979
'transition-opacity duration-200 ease-out',
@@ -968,11 +983,22 @@ function MessageContentInner({
968983
<PendingTagIndicator label={thinkingLabel ?? 'Thinking…'} />
969984
</div>
970985
</div>
986+
) : // The settled tail takes the slot's place in the SAME render and at the
987+
// SAME extent (TAIL_REGION_CLASSES), so the swap is height-neutral by
988+
// construction — no reflow for the pinned scroller to absorb. A stopped
989+
// turn instead stacks compact natural rows (10px gaps, no 36px boxes):
990+
// its extra height is glided-in growth either way, so only the
991+
// shimmer-swap occupant needs the fixed extent.
992+
lastSegment?.type === 'stopped' ? (
993+
<>
994+
<div className='mt-[10px] flex items-center gap-[8px]'>
995+
<CircleStop className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
996+
<span className='text-[14px] text-[var(--text-body)]'>Stopped by user</span>
997+
</div>
998+
{actions && <div className='mt-[10px]'>{actions}</div>}
999+
</>
9711000
) : (
972-
// The actions row takes the slot's place in the SAME render — a single
973-
// ~10px reflow instead of a collapse the buttons would ride upward or a
974-
// late mount the chase would visibly scroll to.
975-
actions && <div className='mt-2.5'>{actions}</div>
1001+
actions && <div className={TAIL_REGION_CLASSES}>{actions}</div>
9761002
)}
9771003
</div>
9781004
)

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

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from 'react'
1313
import { cn } from '@sim/emcn'
1414
import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual'
15+
import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase'
1516
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
1617
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
1718
import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
@@ -109,7 +110,7 @@ const UNSCROLLED = Symbol('unscrolled')
109110
const LAYOUT_STYLES = {
110111
'mothership-view': {
111112
scrollContainer:
112-
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-2 [scrollbar-gutter:stable_both-edges]',
113+
'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]',
113114
sizer: 'relative mx-auto w-full max-w-[48rem]',
114115
rowGap: 'pb-6',
115116
userRow: 'flex flex-col items-end gap-[6px] pt-3',
@@ -120,7 +121,8 @@ const LAYOUT_STYLES = {
120121
footerInner: 'mx-auto max-w-[48rem]',
121122
},
122123
'copilot-view': {
123-
scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4',
124+
scrollContainer:
125+
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4 [overflow-anchor:none]',
124126
sizer: 'relative w-full',
125127
rowGap: 'pb-4',
126128
userRow: 'flex flex-col items-end gap-[6px] pt-2',
@@ -302,6 +304,9 @@ export function MothershipChat({
302304
const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating)
303305
const sizerRef = useRef<HTMLDivElement | null>(null)
304306
const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null)
307+
const sizerFloorAppliedRef = useRef(0)
308+
const floorDrainRafRef = useRef(0)
309+
useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), [])
305310

306311
/**
307312
* Sizer floor while streaming: `scrollHeight` must never dip below the
@@ -318,16 +323,57 @@ export function MothershipChat({
318323
* Active on the same signal as auto-scroll: the reveal keeps re-parsing
319324
* markdown (and shrinking) after the network stream closes, so the floor
320325
* must hold through `lastRowAnimating` too.
326+
*
327+
* Release is DRAINED, not cliffed: while active the floor forbids
328+
* scrollHeight from dropping, so permanent shrinks over the turn accrue as
329+
* phantom space (debt). Clearing min-height in one commit released that
330+
* whole debt as a single clamp — the end-of-turn downward jump. Instead the
331+
* floor glides down to the natural size at the chase's rate; the browser's
332+
* clamp follows a few px per frame, which reads as the same eased settle as
333+
* the rest of the stream. Instant-clears when the debt is sub-pixel or the
334+
* user isn't pinned (shrinking below-viewport space is invisible then).
321335
*/
322336
const floorActive = isStreamActive || lastRowAnimating
323337
useLayoutEffect(() => {
324338
const sizer = sizerRef.current
325339
const el = scrollElementRef.current
326340
if (!sizer || !el) return
327341
if (!floorActive) {
328-
sizer.style.minHeight = ''
342+
if (sizerFloorAppliedRef.current === 0) return
343+
// A drain already in flight keeps its own rAF cadence — settle-burst
344+
// commits re-enter this branch and must not add extra steps in layout,
345+
// which would accelerate the release past the eased rate.
346+
if (floorDrainRafRef.current !== 0) return
347+
scrollerPaddingRef.current = null
348+
const drain = () => {
349+
const target = virtualizer.getTotalSize()
350+
const current = sizerFloorAppliedRef.current
351+
if (current === 0) {
352+
floorDrainRafRef.current = 0
353+
return
354+
}
355+
// Instant-clear only when the whole remaining debt sits BELOW the
356+
// viewport (debt ≤ distance-from-bottom) — then the shrink is
357+
// invisible. A merely-unpinned viewport with debt larger than its
358+
// slack would still clamp, so it keeps the eased drain instead.
359+
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
360+
const debt = current - target
361+
if (debt <= 1 || debt <= distance) {
362+
sizerFloorAppliedRef.current = 0
363+
floorDrainRafRef.current = 0
364+
sizer.style.minHeight = ''
365+
return
366+
}
367+
const next = Math.floor(current - Math.max(1, debt * SMOOTH_CHASE_RATE))
368+
sizerFloorAppliedRef.current = next
369+
sizer.style.minHeight = `${next}px`
370+
floorDrainRafRef.current = requestAnimationFrame(drain)
371+
}
372+
floorDrainRafRef.current = requestAnimationFrame(drain)
329373
return
330374
}
375+
cancelAnimationFrame(floorDrainRafRef.current)
376+
floorDrainRafRef.current = 0
331377
if (!scrollerPaddingRef.current) {
332378
const style = getComputedStyle(el)
333379
scrollerPaddingRef.current = {
@@ -336,7 +382,21 @@ export function MothershipChat({
336382
}
337383
}
338384
const padding = scrollerPaddingRef.current
339-
const floor = Math.max(0, el.scrollTop + el.clientHeight - padding.top - padding.bottom)
385+
// Math.floor, not the raw float: a fractional min-height can round
386+
// scrollHeight 1px ABOVE the scrolled-to extent, and that phantom 1px gap
387+
// re-derives 1px higher after every chase step — a visible 1px/frame
388+
// upward creep whenever the floor is what's holding scrollHeight.
389+
const floor = Math.max(
390+
0,
391+
Math.floor(el.scrollTop + el.clientHeight - padding.top - padding.bottom)
392+
)
393+
// Dead-band: the floor feeds back into its own inputs (a floored value can
394+
// land a fraction BELOW the extent, the browser clamps scrollTop, and the
395+
// next commit re-derives from the clamped position — a visible ~1px×N
396+
// downward cascade on fractional-scrollTop displays). Sub-pixel deltas are
397+
// rounding noise from that loop, never real growth; only apply real moves.
398+
if (Math.abs(floor - sizerFloorAppliedRef.current) <= 1) return
399+
sizerFloorAppliedRef.current = floor
340400
sizer.style.minHeight = `${floor}px`
341401
})
342402
const setScrollElement = useCallback(

0 commit comments

Comments
 (0)