fix(desktop): stop streaming autoscroll bounce; move attachments below user bubble

Streaming auto-follow chased content growth while parked at the bottom,
which rubber-banded — the tail pin and the virtualizer's own measurement
adjustments fought for scrollTop. Drop it; the one-time new-turn jump
already lands a fresh message in view and the viewport stays put after.

Attachments rendered inside the editable user bubble and were collapsed
via an IntersectionObserver + [data-stuck] CSS hack while the bubble was
pinned. Render them as a flow sibling BELOW the sticky bubble instead, so
they scroll away behind it naturally — no observer, no collapse. Image
refs still render as thumbnails, file refs as chips; no border. Removes
the now-unused useStuckToTop hook and its CSS.
This commit is contained in:
Brooklyn Nicholson 2026-06-12 19:58:25 -05:00
parent a1c6349c1f
commit d14f6c9563
4 changed files with 50 additions and 130 deletions

View file

@ -404,24 +404,10 @@ function useThreadScrollAnchor({
}
}, [scrollerRef, stickyBottomRef])
// Streaming auto-follow: while — and ONLY while — parked at the bottom, chase
// content growth (streaming tokens, late measurement, Shiki re-highlight) so
// the tail stays in view. One upward pixel (scroll/wheel/touch above) flips
// the gate false and following stops until the user returns to the bottom.
// Keyed on the virtualizer's own size signal and pinned in useLayoutEffect —
// the virtualizer's scrollToFn runs in the same pre-paint pass, so the two
// don't fight (no rubber-banding). pinToBottom no-ops at bottom, so rapid
// growth is cheap.
const totalSize = virtualizer.getTotalSize()
const prevTotalSizeRef = useRef<number | null>(null)
useLayoutEffect(() => {
const prev = prevTotalSizeRef.current
prevTotalSizeRef.current = totalSize
if (enabled && prev !== null && totalSize > prev && stickyBottomRef.current) {
pinToBottom()
}
}, [enabled, pinToBottom, stickyBottomRef, totalSize])
// No streaming auto-follow: chasing content growth while parked at the bottom
// rubber-bands (the tail and the virtualizer's own measurement adjustments
// fight for scrollTop). The one-time new-turn jump below already lands a fresh
// message in view; from there the viewport stays put unless the user jumps.
// The floating jump button asks us to return to the bottom; same re-arm + pin
// path as a new turn.

View file

@ -52,7 +52,12 @@ import {
} from '@/app/chat/composer/rich-editor'
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import { extractDroppedFiles, HERMES_PATHS_MIME, isImagePath, partitionDroppedFiles } from '@/app/chat/hooks/use-composer-actions'
import {
extractDroppedFiles,
HERMES_PATHS_MIME,
isImagePath,
partitionDroppedFiles
} from '@/app/chat/hooks/use-composer-actions'
import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
@ -81,7 +86,6 @@ import {
import { Loader } from '@/components/ui/loader'
import type { HermesGateway } from '@/hermes'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useStuckToTop } from '@/hooks/use-stuck-to-top'
import { useI18n } from '@/i18n'
import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
@ -708,22 +712,22 @@ function messageAttachmentRefs(value: unknown): string[] {
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
}
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement | null>(null)
// --sticky-human-top is 0.23rem (~4px); the sentinel trips when the bubble
// parks there. Collapses sticky attachments via [data-stuck] (see styles.css).
const stuck = useStuckToTop(ref, 4)
function StickyHumanMessageContainer({ attachments, children }: { attachments?: ReactNode; children: ReactNode }) {
return (
<div
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
data-role="user"
data-slot="aui_user-message-root"
data-stuck={stuck ? 'true' : undefined}
ref={ref}
>
{children}
</div>
// Fragment, not a wrapper: a wrapping element becomes the sticky's
// containing block (it'd stick within its own height = never). The bubble
// and attachments are flow siblings so the bubble pins against the scroller
// while attachments below it scroll away.
<>
<div
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-1"
data-role="user"
data-slot="aui_user-message-root"
>
{children}
</div>
{attachments}
</>
)
}
@ -863,33 +867,31 @@ const UserMessage: FC<{
'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)'
)
const bubbleContent = (
<>
{/* Attachments collapse to nothing while the bubble rests (incl. stuck at
the top of the viewport) so a message with attachments doesn't eat the
screen; they expand with the body when the bubble is focused / the edit
composer opens (see styles.css .sticky-human-attachments). */}
{attachmentRefs.length > 0 && (
<span className="sticky-human-attachments -mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
<DirectiveContent text={attachmentRefs.join(' ')} />
</span>
)}
{hasBody && (
// Render the user's text through a minimal markdown pipeline:
// backtick `code` and ``` fenced ``` blocks, with directive chips
// (`@file:` etc.) still resolved inside the plain-text spans.
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
<div ref={clampInnerRef}>
<UserMessageText className="wrap-anywhere" text={messageText} />
</div>
</div>
)}
</>
const bubbleContent = hasBody && (
// Render the user's text through a minimal markdown pipeline:
// backtick `code` and ``` fenced ``` blocks, with directive chips
// (`@file:` etc.) still resolved inside the plain-text spans.
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
<div ref={clampInnerRef}>
<UserMessageText className="wrap-anywhere" text={messageText} />
</div>
</div>
)
return (
<MessagePrimitive.Root asChild>
<StickyHumanMessageContainer>
<StickyHumanMessageContainer
attachments={
// Attachments live BELOW the sticky bubble in normal flow, so they
// scroll away behind the pinned bubble instead of riding along with
// it. Image refs render as thumbnails, file refs as chips; no border.
attachmentRefs.length > 0 ? (
<div className="flex flex-wrap gap-1 -mt-3 mb-2">
<DirectiveContent text={attachmentRefs.join(' ')} />
</div>
) : null
}
>
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
<div className="relative w-full">
@ -1342,7 +1344,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
}
const remote = $connection.get()?.mode === 'remote'
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) => gateway.request<T>(method, params)
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) =>
gateway.request<T>(method, params)
const refs: InlineRefInput[] = []
for (const candidate of osDrops) {

View file

@ -1,60 +0,0 @@
import { type RefObject, useEffect, useState } from 'react'
/** Nearest scrollable ancestor (the IntersectionObserver root). */
function scrollParent(el: Element | null): Element | null {
let node = el?.parentElement ?? null
while (node) {
const overflowY = getComputedStyle(node).overflowY
if (overflowY === 'auto' || overflowY === 'scroll') {
return node
}
node = node.parentElement
}
return null
}
/**
* True while `ref` is pinned at the top of its scroll container by
* `position: sticky`. Detects it with a zero-height sentinel inserted just
* above the element: once the sentinel scrolls out under the sticky offset, the
* element is stuck. `stickyTopPx` is the element's `top` offset so the sentinel
* trips exactly when the element parks. CSS-native no scroll/pointer math.
*/
export function useStuckToTop(ref: RefObject<HTMLElement | null>, stickyTopPx = 0): boolean {
const [stuck, setStuck] = useState(false)
useEffect(() => {
const el = ref.current
if (!el || typeof IntersectionObserver === 'undefined') {
return
}
const root = scrollParent(el)
const sentinel = document.createElement('div')
sentinel.setAttribute('aria-hidden', 'true')
sentinel.style.cssText = 'position:absolute;top:0;left:0;height:1px;width:1px;pointer-events:none;'
el.style.position ||= 'relative'
el.prepend(sentinel)
const observer = new IntersectionObserver(
([entry]) => setStuck(entry.intersectionRatio === 0),
// Pull the root's top edge down by the sticky offset so the sentinel
// leaves the observed band exactly when the element parks.
{ root, rootMargin: `-${stickyTopPx + 1}px 0px 0px 0px`, threshold: [0, 1] }
)
observer.observe(sentinel)
return () => {
observer.disconnect()
sentinel.remove()
}
}, [ref, stickyTopPx])
return stuck
}

View file

@ -918,17 +918,6 @@ canvas {
mask-image: none;
}
/* Attachment chips sit above the clamped text. They render normally in flow,
but collapse to nothing while the bubble is stuck to the top of the viewport
(data-stuck, set by an IntersectionObserver sentinel) so a prompt with
attachments can't eat the screen as you scroll past it during a stream. */
[data-stuck='true'] .sticky-human-attachments {
max-height: 0;
overflow: hidden;
border-bottom-width: 0;
padding-bottom: 0;
}
/* The thread renders items in natural document flow (padding spacers, not
transforms) and @tanstack/react-virtual already adjusts scrollTop itself
when an off-screen turn is measured and its real height differs from the