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:
parent
a1c6349c1f
commit
d14f6c9563
4 changed files with 50 additions and 130 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue