Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(mothership): stop-button transitions freeze in place#5838
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
fde912efff6119e7ea9dbc9d7e7ccb63d014bb8fbdf7a94dafb104737b6c86fecacda74757422243cd37File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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,16 +323,57 @@ 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(() => { | ||
| const sizer = sizerRef.current | ||
| 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 | ||
TheodoreSpeaks marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| const next = Math.floor(current - Math.max(1, debt * SMOOTH_CHASE_RATE)) | ||
| sizerFloorAppliedRef.current = next | ||
| sizer.style.minHeight = `${next}px` | ||
| floorDrainRafRef.current = requestAnimationFrame(drain) | ||
TheodoreSpeaks marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| 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) | ||
TheodoreSpeaks marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| ) | ||
| // 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( | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.