authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-22 05:28:36-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-22 05:28:36-07:00
log2274444e92275fda2033de6636e3143ac3599ffb
treea3dacd1fd8a318a3e17bb5f27033578c0deef1a3
parentf34b9d31b3ddbd53a553141fd769721fca4b8eb5
signature Signed by PGP key B5690EEEBB952194

fix(chat): stop pushing follow-up messages to the top (#7897)


11 files changed, 238 insertions(+), 68 deletions(-)

apps/mobile/src/features/threads/ThreadDetailScreen.tsx+37-12
......@@ -79,6 +79,7 @@ import {
7979} from "./ThreadComposer";
8080import { ThreadFeed } from "./ThreadFeed";
8181import type { ThreadContentPresentation } from "./threadContentPresentation";
82import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow";
8283
8384export interface ThreadDetailScreenProps {
8485 readonly selectedThread: OrchestrationThreadShell;
......@@ -257,9 +258,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
257258 const listRef = useRef<LegendListRef>(null);
258259 const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null);
259260 const selectedThreadKeyRef = useRef(selectedThreadKey);
260 const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null);
261 const lastScrolledSubmittedMessageIdRef = useRef<MessageId | null>(null);
261262 const [composerExpanded, setComposerExpanded] = useState(false);
262263 const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
264 const [submittedMessageId, setSubmittedMessageId] = useState<MessageId | null>(null);
263265 const [endFollowEnabled, setEndFollowEnabled] = useState(true);
264266 // Android keys the safe-area padding on keyboard visibility (#5988): the
265267 // back gesture closes the keyboard while the editor stays focused, and a
......@@ -458,17 +460,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
458460
459461 useEffect(() => {
460462 setAnchorMessageId(null);
461 lastScrolledAnchorMessageIdRef.current = null;
463 setSubmittedMessageId(null);
464 lastScrolledSubmittedMessageIdRef.current = null;
462465 setEndFollowEnabled(true);
463466 freeze.set(false);
464467 }, [freeze, selectedThreadKey]);
465468
466469 useEffect(() => {
467470 if (
468 anchorMessageId === null ||
469 lastScrolledAnchorMessageIdRef.current === anchorMessageId ||
471 submittedMessageId === null ||
472 lastScrolledSubmittedMessageIdRef.current === submittedMessageId ||
470473 contentPresentationKind !== "ready" ||
471 !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId)
474 !selectedThreadFeed.some(
475 (entry) => entry.type === "message" && entry.id === submittedMessageId,
476 )
472477 ) {
473478 return;
474479 }
......@@ -478,7 +483,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
478483 if (selectedThreadKeyRef.current !== targetThreadKey) {
479484 return;
480485 }
481 lastScrolledAnchorMessageIdRef.current = anchorMessageId;
486 lastScrolledSubmittedMessageIdRef.current = submittedMessageId;
482487 // Wait for the keyboard dismissal (started by blur() on send) to finish
483488 // before scrolling: scrollMessageToEnd freezes keyboard-driven inset
484489 // updates while it runs, and a close event swallowed by that freeze
......@@ -488,7 +493,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
488493 .then(() => {
489494 if (
490495 selectedThreadKeyRef.current !== targetThreadKey ||
491 lastScrolledAnchorMessageIdRef.current !== anchorMessageId
496 lastScrolledSubmittedMessageIdRef.current !== submittedMessageId
492497 ) {
493498 return;
494499 }
......@@ -497,17 +502,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
497502 .catch(() => {
498503 if (
499504 selectedThreadKeyRef.current !== targetThreadKey ||
500 lastScrolledAnchorMessageIdRef.current !== anchorMessageId
505 lastScrolledSubmittedMessageIdRef.current !== submittedMessageId
501506 ) {
502507 return;
503508 }
504 lastScrolledAnchorMessageIdRef.current = null;
509 lastScrolledSubmittedMessageIdRef.current = null;
505510 freeze.set(false);
506511 });
507512 });
508513 return () => cancelAnimationFrame(frame);
509514 }, [
510 anchorMessageId,
515 submittedMessageId,
511516 freeze,
512517 contentPresentationKind,
513518 selectedThreadFeed,
......@@ -517,15 +522,34 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
517522
518523 const handleSendMessage = useCallback(async () => {
519524 const targetThreadKey = selectedThreadKey;
525 const hasUserMessage = selectedThreadFeed.some(
526 (entry) => entry.type === "message" && entry.message.role === "user",
527 );
520528 const messageId = await props.onSendMessage();
521529 if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) {
522530 return messageId;
523531 }
524532
525 setAnchorMessageId(messageId);
533 setSubmittedMessageId(messageId);
534 setAnchorMessageId(
535 resolveThreadFeedSubmissionAnchor({
536 currentAnchorMessageId: anchorMessageId,
537 submittedMessageId: messageId,
538 hasStartedTurn: props.selectedThread.latestTurn !== null,
539 hasUserMessage,
540 queuedMessageCount: props.selectedThreadQueueCount,
541 }),
542 );
526543 composerEditorRef.current?.blur();
527544 return messageId;
528 }, [props.onSendMessage, selectedThreadKey]);
545 }, [
546 anchorMessageId,
547 props.onSendMessage,
548 props.selectedThread.latestTurn,
549 props.selectedThreadQueueCount,
550 selectedThreadFeed,
551 selectedThreadKey,
552 ]);
529553
530554 const collapseComposer = useCallback(() => {
531555 composerEditorRef.current?.blur();
......@@ -595,6 +619,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
595619 listRef={listRef}
596620 freeze={freeze}
597621 anchorMessageId={anchorMessageId}
622 submittedMessageId={submittedMessageId}
598623 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
599624 contentTopInset={0}
600625 contentBottomInset={estimatedOverlayHeight}
apps/mobile/src/features/threads/ThreadFeed.tsx+4-3
......@@ -151,6 +151,7 @@ export interface ThreadFeedProps {
151151 readonly listRef: RefObject<LegendListRef | null>;
152152 readonly freeze: SharedValue<boolean>;
153153 readonly anchorMessageId: MessageId | null;
154 readonly submittedMessageId: MessageId | null;
154155 readonly contentInsetEndAdjustment: SharedValue<number>;
155156 readonly contentTopInset?: number;
156157 readonly contentBottomInset?: number;
......@@ -1546,12 +1547,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
15461547 transitionEndFollow({ type: "reset" });
15471548 }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]);
15481549 useEffect(() => {
1549 if (props.anchorMessageId !== null) {
1550 if (props.submittedMessageId !== null) {
15501551 clearUserScrollSettle();
15511552 userScrollSessionRef.current = false;
15521553 transitionEndFollow({ type: "reset" });
15531554 }
1554 }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]);
1555 }, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]);
15551556
15561557 const expandedWorkGroupIds = useMemo(() => {
15571558 const ids = new Set<string>();
......@@ -1600,7 +1601,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
16001601 resolveChatListAnchoredEndSpace(
16011602 presentedFeed,
16021603 props.anchorMessageId,
1603 (entry) => (entry.type === "message" ? entry.id : null),
1604 (entry) => (entry.type === "message" && entry.message.role === "user" ? entry.id : null),
16041605 { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },
16051606 ),
16061607 [presentedFeed, props.anchorMessageId, anchorTopInset],
apps/mobile/src/features/threads/thread-feed-live-follow.test.ts+66-1
......@@ -1,6 +1,71 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { resolveThreadFeedLiveFollow } from "./thread-feed-live-follow";
3import {
4 resolveThreadFeedLiveFollow,
5 resolveThreadFeedSubmissionAnchor,
6} from "./thread-feed-live-follow";
7
8describe("resolveThreadFeedSubmissionAnchor", () => {
9 it("anchors the first user message in a thread", () => {
10 expect(
11 resolveThreadFeedSubmissionAnchor({
12 currentAnchorMessageId: null,
13 submittedMessageId: "first-message",
14 hasStartedTurn: false,
15 hasUserMessage: false,
16 queuedMessageCount: 0,
17 }),
18 ).toBe("first-message");
19 });
20
21 it("preserves the first-message anchor when another message is queued", () => {
22 expect(
23 resolveThreadFeedSubmissionAnchor({
24 currentAnchorMessageId: "first-message",
25 submittedMessageId: "second-message",
26 hasStartedTurn: false,
27 hasUserMessage: false,
28 queuedMessageCount: 1,
29 }),
30 ).toBe("first-message");
31 });
32
33 it("preserves the first-message anchor after its outbox entry drains", () => {
34 expect(
35 resolveThreadFeedSubmissionAnchor({
36 currentAnchorMessageId: "first-message",
37 submittedMessageId: "second-message",
38 hasStartedTurn: false,
39 hasUserMessage: false,
40 queuedMessageCount: 0,
41 }),
42 ).toBe("first-message");
43 });
44
45 it("does not anchor a follow-up after a user message appears", () => {
46 expect(
47 resolveThreadFeedSubmissionAnchor({
48 currentAnchorMessageId: "first-message",
49 submittedMessageId: "second-message",
50 hasStartedTurn: false,
51 hasUserMessage: true,
52 queuedMessageCount: 0,
53 }),
54 ).toBeNull();
55 });
56
57 it("does not anchor a thread that has already started a turn", () => {
58 expect(
59 resolveThreadFeedSubmissionAnchor({
60 currentAnchorMessageId: null,
61 submittedMessageId: "second-message",
62 hasStartedTurn: true,
63 hasUserMessage: false,
64 queuedMessageCount: 0,
65 }),
66 ).toBeNull();
67 });
68});
469
570describe("resolveThreadFeedLiveFollow", () => {
671 it("pauses immediately when the user starts scrolling", () => {
apps/mobile/src/features/threads/thread-feed-live-follow.ts+18
......@@ -12,6 +12,24 @@ export type ThreadFeedLiveFollowEvent =
1212 readonly userScrollSessionActive: boolean;
1313 };
1414
15export function resolveThreadFeedSubmissionAnchor<AnchorId>(input: {
16 readonly currentAnchorMessageId: AnchorId | null;
17 readonly submittedMessageId: AnchorId;
18 readonly hasStartedTurn: boolean;
19 readonly hasUserMessage: boolean;
20 readonly queuedMessageCount: number;
21}): AnchorId | null {
22 if (input.hasStartedTurn || input.hasUserMessage) {
23 return null;
24 }
25
26 if (input.currentAnchorMessageId !== null) {
27 return input.currentAnchorMessageId;
28 }
29
30 return input.queuedMessageCount > 0 ? null : input.submittedMessageId;
31}
32
1533export function resolveThreadFeedLiveFollow(
1634 current: boolean,
1735 event: ThreadFeedLiveFollowEvent,
apps/web/src/components/ChatView.logic.test.ts+23
......@@ -638,6 +638,29 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
638638 ).toBe(false);
639639 });
640640
641 it("keeps a follow-up active while its provider session is starting", () => {
642 const localDispatch = createLocalDispatchSnapshot(
643 makeThread({ latestTurn: completedTurn, session: readySession }),
644 );
645
646 expect(
647 hasServerAcknowledgedLocalDispatch({
648 localDispatch,
649 phase: "connecting",
650 latestTurn: completedTurn,
651 latestUserMessageId: MessageId.make("message-followup"),
652 session: {
653 ...readySession,
654 status: "starting",
655 updatedAt: "2026-03-29T00:01:00.000Z",
656 },
657 hasPendingApproval: false,
658 hasPendingUserInput: false,
659 threadError: null,
660 }),
661 ).toBe(false);
662 });
663
641664 it("acknowledges a settled newer turn", () => {
642665 const localDispatch = createLocalDispatchSnapshot(
643666 makeThread({ latestTurn: completedTurn, session: readySession }),
apps/web/src/components/ChatView.logic.ts+3
......@@ -601,6 +601,9 @@ export function hasServerAcknowledgedLocalDispatch(input: {
601601 if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) {
602602 return true;
603603 }
604 if (input.phase === "connecting") {
605 return false;
606 }
604607
605608 const latestTurn = input.latestTurn ?? null;
606609 const session = input.session ?? null;
apps/web/src/components/ChatView.tsx+21-28
......@@ -5297,21 +5297,25 @@ function ChatViewContent(props: ChatViewProps) {
52975297 sizeBytes: image.sizeBytes,
52985298 previewUrl: image.previewUrl,
52995299 }));
5300 // Sending always returns to the live edge. The new row becomes the
5301 // anchored end-space target so it lands near the top while the response
5302 // streams into the reserved space below it.
5303 isAtEndRef.current = true;
5304 timelineScrollModeRef.current = "anchoring-new-turn";
5305 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
5306 setTimelineLiveFollowEnabled(true);
5307 pendingTimelineAnchorRef.current = messageIdForSend;
5308 activeTimelineAnchorIndexRef.current = null;
5309 showScrollDebouncer.current.cancel();
5310 setShowScrollToBottom(false);
5311 setTimelineAnchor({
5312 threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)),
5313 messageId: messageIdForSend,
5314 });
5300 const shouldAnchorFirstMessage =
5301 activeThread.latestTurn === null &&
5302 !timelineMessages.some((message) => message.role === "user");
5303 if (shouldAnchorFirstMessage) {
5304 isAtEndRef.current = true;
5305 timelineScrollModeRef.current = "anchoring-new-turn";
5306 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
5307 setTimelineLiveFollowEnabled(true);
5308 pendingTimelineAnchorRef.current = messageIdForSend;
5309 activeTimelineAnchorIndexRef.current = null;
5310 showScrollDebouncer.current.cancel();
5311 setShowScrollToBottom(false);
5312 setTimelineAnchor({
5313 threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)),
5314 messageId: messageIdForSend,
5315 });
5316 } else {
5317 scrollToEnd();
5318 }
53155319 setOptimisticUserMessages((existing) => [
53165320 ...existing,
53175321 {
......@@ -5815,19 +5819,7 @@ function ChatViewContent(props: ChatViewProps) {
58155819 beginLocalDispatch({ preparingWorktree: false });
58165820 setThreadError(threadIdForSend, null);
58175821
5818 // Position this sent row once LegendList has measured the anchored tail.
5819 isAtEndRef.current = true;
5820 timelineScrollModeRef.current = "anchoring-new-turn";
5821 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
5822 setTimelineLiveFollowEnabled(true);
5823 pendingTimelineAnchorRef.current = messageIdForSend;
5824 activeTimelineAnchorIndexRef.current = null;
5825 showScrollDebouncer.current.cancel();
5826 setShowScrollToBottom(false);
5827 setTimelineAnchor({
5828 threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)),
5829 messageId: messageIdForSend,
5830 });
5822 scrollToEnd();
58315823
58325824 setOptimisticUserMessages((existing) => [
58335825 ...existing,
......@@ -5922,6 +5914,7 @@ function ChatViewContent(props: ChatViewProps) {
59225914 persistThreadSettingsForNextTurn,
59235915 resetLocalDispatch,
59245916 runtimeMode,
5917 scrollToEnd,
59255918 setComposerDraftInteractionMode,
59265919 setThreadError,
59275920 startThreadTurn,
apps/web/src/components/chat/MessagesTimeline.test.tsx+34-12
......@@ -434,15 +434,12 @@ describe("MessagesTimeline", () => {
434434 expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem");
435435 });
436436
437 it("anchors a sent attachment message using its measured height", () => {
437 it("anchors the first user message using its measured height", () => {
438438 const onAnchorReady = vi.fn();
439 const firstEntry = buildUserTimelineEntry("First prompt.");
440 const secondEntry = {
441 ...buildUserTimelineEntry("Newest prompt."),
442 id: "entry-2",
439 const firstEntry = {
440 ...buildUserTimelineEntry("First prompt."),
443441 message: {
444 ...buildUserTimelineEntry("Newest prompt.").message,
445 id: MessageId.make("message-2"),
442 ...buildUserTimelineEntry("First prompt.").message,
446443 attachments: [
447444 {
448445 type: "image" as const,
......@@ -458,14 +455,14 @@ describe("MessagesTimeline", () => {
458455 const markup = renderToStaticMarkup(
459456 <MessagesTimeline
460457 {...buildProps()}
461 anchorMessageId={secondEntry.message.id}
458 anchorMessageId={firstEntry.message.id}
462459 onAnchorReady={onAnchorReady}
463460 contentInsetEndAdjustment={144}
464 timelineEntries={[firstEntry, secondEntry]}
461 timelineEntries={[firstEntry]}
465462 />,
466463 );
467464
468 expect(markup).toContain('data-anchor-index="1"');
465 expect(markup).toContain('data-anchor-index="0"');
469466 expect(markup).toContain('data-anchor-offset="16"');
470467 expect(markup).toContain('data-anchor-on-ready="true"');
471468 expect(markup).not.toContain("data-anchor-max-size=");
......@@ -477,7 +474,32 @@ describe("MessagesTimeline", () => {
477474 expect(markup).toContain('data-maintain-visible-content-position-size="true"');
478475 expect(markup).toContain('data-maintain-visible-content-position-restore="true"');
479476 expect(onAnchorReady).toHaveBeenCalledOnce();
480 expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1);
477 expect(onAnchorReady).toHaveBeenCalledWith(firstEntry.message.id, 0);
478 });
479
480 it("does not reserve end space for a follow-up user message", () => {
481 const onAnchorReady = vi.fn();
482 const firstEntry = buildUserTimelineEntry("First prompt.");
483 const secondEntry = {
484 ...buildUserTimelineEntry("Newest prompt."),
485 id: "entry-2",
486 message: {
487 ...buildUserTimelineEntry("Newest prompt.").message,
488 id: MessageId.make("message-2"),
489 },
490 };
491 const markup = renderToStaticMarkup(
492 <MessagesTimeline
493 {...buildProps()}
494 anchorMessageId={secondEntry.message.id}
495 onAnchorReady={onAnchorReady}
496 timelineEntries={[firstEntry, secondEntry]}
497 />,
498 );
499
500 expect(markup).not.toContain("data-anchor-index=");
501 expect(markup).toContain('data-maintain-scroll-at-end="enabled"');
502 expect(onAnchorReady).not.toHaveBeenCalled();
481503 });
482504
483505 it("hands end-following back to the list once the send anchor is released", () => {
......@@ -498,7 +520,7 @@ describe("MessagesTimeline", () => {
498520 renderToStaticMarkup(
499521 <MessagesTimeline
500522 {...buildProps()}
501 anchorMessageId={secondEntry.message.id}
523 anchorMessageId={firstEntry.message.id}
502524 timelineEntries={timelineEntries}
503525 />,
504526 ),
apps/web/src/components/chat/MessagesTimeline.tsx+1-1
......@@ -446,7 +446,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
446446 );
447447 const anchoredEndSpace = useMemo(() => {
448448 const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) =>
449 row.kind === "message" ? row.message.id : null,
449 row.kind === "message" && row.message.role === "user" ? row.message.id : null,
450450 );
451451 return config ? { ...config, onReady: handleAnchorReady } : undefined;
452452 }, [anchorMessageId, handleAnchorReady, rows]);
packages/shared/src/chatList.test.ts+16-5
......@@ -16,24 +16,35 @@ const rows: ReadonlyArray<Row> = [
1616const getAnchorId = (row: Row) => (row.anchorable ? row.id : null);
1717
1818describe("resolveChatListAnchoredEndSpace", () => {
19 it("anchors the matching row using its measured height", () => {
20 expect(resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId)).toEqual({
21 anchorIndex: 2,
19 it("anchors only the first eligible row", () => {
20 expect(resolveChatListAnchoredEndSpace(rows, "first", getAnchorId)).toEqual({
21 anchorIndex: 0,
2222 anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
2323 });
2424 });
2525
2626 it("allows a surface to keep the anchor below its own header", () => {
2727 expect(
28 resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId, {
28 resolveChatListAnchoredEndSpace(rows, "first", getAnchorId, {
2929 anchorOffset: 132,
3030 }),
3131 ).toEqual({
32 anchorIndex: 2,
32 anchorIndex: 0,
3333 anchorOffset: 132,
3434 });
3535 });
3636
37 it("does not reserve end space for later eligible rows", () => {
38 expect(resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId)).toBeUndefined();
39 });
40
41 it("skips ineligible rows before the first anchor", () => {
42 expect(resolveChatListAnchoredEndSpace(rows.slice(1), "latest", getAnchorId)).toEqual({
43 anchorIndex: 1,
44 anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
45 });
46 });
47
3748 it("ignores ineligible rows and missing anchors", () => {
3849 expect(resolveChatListAnchoredEndSpace(rows, "ignored", getAnchorId)).toBeUndefined();
3950 expect(resolveChatListAnchoredEndSpace(rows, "missing", getAnchorId)).toBeUndefined();
packages/shared/src/chatList.ts+15-6
......@@ -19,14 +19,23 @@ export function resolveChatListAnchoredEndSpace<Item, AnchorId>(
1919 return undefined;
2020 }
2121
22 for (let index = items.length - 1; index >= 0; index -= 1) {
22 for (let index = 0; index < items.length; index += 1) {
2323 const item = items[index];
24 if (item !== undefined && getAnchorId(item) === anchorId) {
25 return {
26 anchorIndex: index,
27 anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
28 };
24 if (item === undefined) {
25 continue;
2926 }
27
28 const itemAnchorId = getAnchorId(item);
29 if (itemAnchorId === null) {
30 continue;
31 }
32
33 return itemAnchorId === anchorId
34 ? {
35 anchorIndex: index,
36 anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
37 }
38 : undefined;
3039 }
3140
3241 return undefined;