From 97d589bf76f79763730a5f1e9cacc39bf7442391 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 28 Jul 2026 16:01:28 -0600 Subject: [PATCH] fix(desktop): clear stale thread new-message pill Reconcile anchored-scroll state when passive layout changes put the native thread scroller at its physical floor, and let composer padding changes use the same hook-owned settlement path. Co-authored-by: Carl Signed-off-by: Wes --- .../messages/ui/MessageThreadPanel.tsx | 43 +++++++------ .../ui/useAnchoredScroll.lifecycle.test.mjs | 62 ++++++++++++++++++- .../features/messages/ui/useAnchoredScroll.ts | 44 +++++++++++-- 3 files changed, 122 insertions(+), 27 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 59c92c7a69..08a57fa4c6 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -251,11 +251,6 @@ export function MessageThreadPanel({ // conditional activity accessory (agent working and/or someone typing). const hasComposerBottomActivity = activityAccessoryVisible || threadTypingPubkeys.length > 0; - useComposerHeightPadding( - threadBodyRef, - threadComposerWrapperRef, - isSinglePanelView, - ); // Live ref so onCaptureSendContext can read reply state at submit time // (before any async mention-flow awaits change navigation state). @@ -490,19 +485,31 @@ export function MessageThreadPanel({ threadHead, ]); - const { isAtBottom, newMessageCount, onScroll, scrollToBottom } = - useAnchoredScroll({ - channelId: threadHeadId, - contentRef: threadContentRef, - isLoading: threadRepliesPending || repliesRenderState === "pending", - messages: threadMessages, - highlightTargetMessage: scrollTargetHighlights, - onTargetReached: onScrollTargetResolved, - onTargetSettled: onScrollTargetSettled, - pinTargetCentered: !scrollTargetHighlights, - scrollContainerRef: threadBodyRef, - targetMessageId: scrollTargetId, - }); + const { + isAtBottom, + newMessageCount, + onScroll, + scrollToBottom, + settleAtBottomAfterLayout, + } = useAnchoredScroll({ + channelId: threadHeadId, + contentRef: threadContentRef, + isLoading: threadRepliesPending || repliesRenderState === "pending", + messages: threadMessages, + highlightTargetMessage: scrollTargetHighlights, + onTargetReached: onScrollTargetResolved, + onTargetSettled: onScrollTargetSettled, + pinTargetCentered: !scrollTargetHighlights, + scrollContainerRef: threadBodyRef, + targetMessageId: scrollTargetId, + }); + useComposerHeightPadding( + threadBodyRef, + threadComposerWrapperRef, + isSinglePanelView, + "padding", + settleAtBottomAfterLayout, + ); const knownAgentPubkeys = useKnownAgentPubkeys(); const initialAgentPubkeys = React.useMemo(() => { diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs index 507f654cd0..1fdfb05857 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs @@ -218,7 +218,8 @@ function makePinnedCenterNodes() { disconnect() {} observe(target) { - this.target = target; + this.targets ??= []; + this.targets.push(target); } }; @@ -246,6 +247,18 @@ function Harness({ channelId, onTargetSettled, refs }) { return null; } +function BottomStateHarness({ messages, onState, refs }) { + const anchored = useAnchoredScroll({ + channelId: "conversation", + contentRef: refs.content, + isLoading: false, + messages, + scrollContainerRef: refs.container, + }); + onState(anchored); + return null; +} + function VirtualTargetHarness({ refs }) { const didRun = React.useRef(false); const bottomApi = useVirtualizedBottomSettle( @@ -294,7 +307,10 @@ test("channel change attaches pinned-center observers after refs mount", async ( }); assert.equal(nodes.resizeObservers.length, 1); - assert.equal(nodes.resizeObservers[0].target, nodes.content); + assert.deepEqual(nodes.resizeObservers[0].targets, [ + nodes.content, + nodes.container, + ]); assert.equal(nodes.container.listeners.get("wheel")?.length, 1); await act(async () => { @@ -313,7 +329,47 @@ test("channel change attaches pinned-center observers after refs mount", async ( }); }); -test("pinned target settles only after resize correction and a paint frame", async () => { +test("container resize clears a stale new-message state at the physical floor", async () => { + const refs = { + container: { current: null }, + content: { current: null }, + }; + const root = createRoot(document.createElement("div")); + const nodes = makePinnedCenterNodes(); + refs.container.current = nodes.container; + refs.content.current = nodes.content; + let state = null; + const render = (messages) => + root.render( + React.createElement(BottomStateHarness, { + messages, + onState: (nextState) => { + state = nextState; + }, + refs, + }), + ); + + await act(async () => render([{ id: "first" }])); + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + nodes.container.scrollTop = 100; + await act(async () => state.onScroll()); + nodes.container.scrollTop = 100; + await act(async () => state.onScroll()); + await act(async () => render([{ id: "first" }, { id: "second" }])); + assert.equal(state.isAtBottom, false); + assert.equal(state.newMessageCount, 1); + + // A taller viewport reaches the floor without producing a native scroll. + nodes.container.clientHeight = 900; + await act(async () => nodes.resizeObservers[0].callback()); + + assert.equal(state.isAtBottom, true); + assert.equal(state.newMessageCount, 0); + await act(async () => root.unmount()); +}); + +test("pinned target resize reconciles bottom state before retiring", async () => { const refs = { container: { current: null }, content: { current: null }, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index ead5771294..add9439599 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -73,6 +73,9 @@ type UseAnchoredScrollResult = { highlightedMessageId: string | null; /** Imperative: scroll to bottom. */ scrollToBottom: (behavior?: ScrollBehavior) => void; + /** Re-pins after a layout owner changes trailing geometry. Returns true when + * the hook handled the settlement, including a preserved pinned target. */ + settleAtBottomAfterLayout: () => boolean; /** Arm a one-shot scroll-to-bottom that fires on the next appended message * (used by the composer's send flow). */ scrollToBottomOnNextUpdate: () => void; @@ -383,6 +386,35 @@ export function useAnchoredScroll({ forceBottomOnNextAppendRef.current = true; }, []); + const settleAtBottomAfterLayout = React.useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return false; + if (anchorRef.current.kind === "pinned-center") { + repinPinnedCenter(); + const atBottom = isAtBottomNow(container); + setIsAtBottom((previous) => + previous === atBottom ? previous : atBottom, + ); + if (atBottom) setNewMessageCount(0); + schedulePinnedTargetSettle(anchorRef.current.messageId); + return true; + } + if (!isAtBottomNow(container)) return false; + + anchorRef.current = { kind: "at-bottom" }; + setIsAtBottom(true); + setNewMessageCount(0); + if (!virtualizerOwnsPrependAnchoring) { + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + } + return true; + }, [ + repinPinnedCenter, + schedulePinnedTargetSettle, + scrollContainerRef, + virtualizerOwnsPrependAnchoring, + ]); + const highlightMessage = React.useCallback((messageId: string) => { if (highlightTimeoutRef.current !== null) { window.clearTimeout(highlightTimeoutRef.current); @@ -743,10 +775,8 @@ export function useAnchoredScroll({ const observer = new ResizeObserver(() => { const container = scrollContainerRef.current; if (!container) return; - if (anchorRef.current.kind === "pinned-center") { - repinPinnedCenter(); - schedulePinnedTargetSettle(anchorRef.current.messageId); - } else if ( + if (settleAtBottomAfterLayout()) return; + if ( anchorRef.current.kind === "at-bottom" && !virtualizerOwnsPrependAnchoring ) { @@ -754,6 +784,8 @@ export function useAnchoredScroll({ } }); observer.observe(content); + const container = scrollContainerRef.current; + if (container && container !== content) observer.observe(container); return () => { observer.disconnect(); if (targetSettleRafRef.current !== null) { @@ -764,9 +796,8 @@ export function useAnchoredScroll({ }, [ channelId, contentRef, - repinPinnedCenter, - schedulePinnedTargetSettle, scrollContainerRef, + settleAtBottomAfterLayout, virtualizerOwnsPrependAnchoring, ]); @@ -919,6 +950,7 @@ export function useAnchoredScroll({ newMessageCount, highlightedMessageId, scrollToBottom: scrollToBottomImperative, + settleAtBottomAfterLayout, scrollToBottomOnNextUpdate, scrollToMessage: scrollToMessageImperative, onVirtualizerAtBottomStateChange,