diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx index c42084138..03bc9082a 100644 --- a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx @@ -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(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. diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 96b9ed9c2..effeb38e7 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -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(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 ( -
- {children} -
+ // 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. + <> +
+ {children} +
+ {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 && ( - - - - )} - {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. -
-
- -
-
- )} - + 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. +
+
+ +
+
) return ( - + 0 ? ( +
+ +
+ ) : null + } + >
@@ -1342,7 +1344,10 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId } } const remote = $connection.get()?.mode === 'remote' - const requestGateway = (method: string, params?: Record) => gateway.request(method, params) + + const requestGateway = (method: string, params?: Record) => + gateway.request(method, params) + const refs: InlineRefInput[] = [] for (const candidate of osDrops) { diff --git a/apps/desktop/src/hooks/use-stuck-to-top.ts b/apps/desktop/src/hooks/use-stuck-to-top.ts deleted file mode 100644 index ed1e139d7..000000000 --- a/apps/desktop/src/hooks/use-stuck-to-top.ts +++ /dev/null @@ -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, 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 -} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index ec0665cac..f203aaf99 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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