Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 25 additions & 18 deletions desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,8 @@ function makePinnedCenterNodes() {
disconnect() {}

observe(target) {
this.target = target;
this.targets ??= [];
this.targets.push(target);
}
};

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 },
Expand Down
44 changes: 38 additions & 6 deletions desktop/src/features/messages/ui/useAnchoredScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -743,17 +775,17 @@ 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
) {
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
}
});
observer.observe(content);
const container = scrollContainerRef.current;
if (container && container !== content) observer.observe(container);
return () => {
observer.disconnect();
if (targetSettleRafRef.current !== null) {
Expand All @@ -764,9 +796,8 @@ export function useAnchoredScroll({
}, [
channelId,
contentRef,
repinPinnedCenter,
schedulePinnedTargetSettle,
scrollContainerRef,
settleAtBottomAfterLayout,
virtualizerOwnsPrependAnchoring,
]);

Expand Down Expand Up @@ -919,6 +950,7 @@ export function useAnchoredScroll({
newMessageCount,
highlightedMessageId,
scrollToBottom: scrollToBottomImperative,
settleAtBottomAfterLayout,
scrollToBottomOnNextUpdate,
scrollToMessage: scrollToMessageImperative,
onVirtualizerAtBottomStateChange,
Expand Down
Loading