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 {...@@ -79,6 +79,7 @@ import {
79} from "./ThreadComposer";79} from "./ThreadComposer";
80import { ThreadFeed } from "./ThreadFeed";80import { ThreadFeed } from "./ThreadFeed";
81import type { ThreadContentPresentation } from "./threadContentPresentation";81import type { ThreadContentPresentation } from "./threadContentPresentation";
82import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow";
8283
83export interface ThreadDetailScreenProps {84export interface ThreadDetailScreenProps {
84 readonly selectedThread: OrchestrationThreadShell;85 readonly selectedThread: OrchestrationThreadShell;
...@@ -257,9 +258,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -257,9 +258,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
257 const listRef = useRef<LegendListRef>(null);258 const listRef = useRef<LegendListRef>(null);
258 const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null);259 const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null);
259 const selectedThreadKeyRef = useRef(selectedThreadKey);260 const selectedThreadKeyRef = useRef(selectedThreadKey);
260 const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null);261 const lastScrolledSubmittedMessageIdRef = useRef<MessageId | null>(null);
261 const [composerExpanded, setComposerExpanded] = useState(false);262 const [composerExpanded, setComposerExpanded] = useState(false);
262 const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);263 const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
264 const [submittedMessageId, setSubmittedMessageId] = useState<MessageId | null>(null);
263 const [endFollowEnabled, setEndFollowEnabled] = useState(true);265 const [endFollowEnabled, setEndFollowEnabled] = useState(true);
264 // Android keys the safe-area padding on keyboard visibility (#5988): the266 // Android keys the safe-area padding on keyboard visibility (#5988): the
265 // back gesture closes the keyboard while the editor stays focused, and a267 // back gesture closes the keyboard while the editor stays focused, and a
...@@ -458,17 +460,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -458,17 +460,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
458460
459 useEffect(() => {461 useEffect(() => {
460 setAnchorMessageId(null);462 setAnchorMessageId(null);
461 lastScrolledAnchorMessageIdRef.current = null;463 setSubmittedMessageId(null);
464 lastScrolledSubmittedMessageIdRef.current = null;
462 setEndFollowEnabled(true);465 setEndFollowEnabled(true);
463 freeze.set(false);466 freeze.set(false);
464 }, [freeze, selectedThreadKey]);467 }, [freeze, selectedThreadKey]);
465468
466 useEffect(() => {469 useEffect(() => {
467 if (470 if (
468 anchorMessageId === null ||471 submittedMessageId === null ||
469 lastScrolledAnchorMessageIdRef.current === anchorMessageId ||472 lastScrolledSubmittedMessageIdRef.current === submittedMessageId ||
470 contentPresentationKind !== "ready" ||473 contentPresentationKind !== "ready" ||
471 !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId)474 !selectedThreadFeed.some(
475 (entry) => entry.type === "message" && entry.id === submittedMessageId,
476 )
472 ) {477 ) {
473 return;478 return;
474 }479 }
...@@ -478,7 +483,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -478,7 +483,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
478 if (selectedThreadKeyRef.current !== targetThreadKey) {483 if (selectedThreadKeyRef.current !== targetThreadKey) {
479 return;484 return;
480 }485 }
481 lastScrolledAnchorMessageIdRef.current = anchorMessageId;486 lastScrolledSubmittedMessageIdRef.current = submittedMessageId;
482 // Wait for the keyboard dismissal (started by blur() on send) to finish487 // Wait for the keyboard dismissal (started by blur() on send) to finish
483 // before scrolling: scrollMessageToEnd freezes keyboard-driven inset488 // before scrolling: scrollMessageToEnd freezes keyboard-driven inset
484 // updates while it runs, and a close event swallowed by that freeze489 // updates while it runs, and a close event swallowed by that freeze
...@@ -488,7 +493,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -488,7 +493,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
488 .then(() => {493 .then(() => {
489 if (494 if (
490 selectedThreadKeyRef.current !== targetThreadKey ||495 selectedThreadKeyRef.current !== targetThreadKey ||
491 lastScrolledAnchorMessageIdRef.current !== anchorMessageId496 lastScrolledSubmittedMessageIdRef.current !== submittedMessageId
492 ) {497 ) {
493 return;498 return;
494 }499 }
...@@ -497,17 +502,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -497,17 +502,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
497 .catch(() => {502 .catch(() => {
498 if (503 if (
499 selectedThreadKeyRef.current !== targetThreadKey ||504 selectedThreadKeyRef.current !== targetThreadKey ||
500 lastScrolledAnchorMessageIdRef.current !== anchorMessageId505 lastScrolledSubmittedMessageIdRef.current !== submittedMessageId
501 ) {506 ) {
502 return;507 return;
503 }508 }
504 lastScrolledAnchorMessageIdRef.current = null;509 lastScrolledSubmittedMessageIdRef.current = null;
505 freeze.set(false);510 freeze.set(false);
506 });511 });
507 });512 });
508 return () => cancelAnimationFrame(frame);513 return () => cancelAnimationFrame(frame);
509 }, [514 }, [
510 anchorMessageId,515 submittedMessageId,
511 freeze,516 freeze,
512 contentPresentationKind,517 contentPresentationKind,
513 selectedThreadFeed,518 selectedThreadFeed,
...@@ -517,15 +522,34 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -517,15 +522,34 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
517522
518 const handleSendMessage = useCallback(async () => {523 const handleSendMessage = useCallback(async () => {
519 const targetThreadKey = selectedThreadKey;524 const targetThreadKey = selectedThreadKey;
525 const hasUserMessage = selectedThreadFeed.some(
526 (entry) => entry.type === "message" && entry.message.role === "user",
527 );
520 const messageId = await props.onSendMessage();528 const messageId = await props.onSendMessage();
521 if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) {529 if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) {
522 return messageId;530 return messageId;
523 }531 }
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 );
526 composerEditorRef.current?.blur();543 composerEditorRef.current?.blur();
527 return messageId;544 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
530 const collapseComposer = useCallback(() => {554 const collapseComposer = useCallback(() => {
531 composerEditorRef.current?.blur();555 composerEditorRef.current?.blur();
...@@ -595,6 +619,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -595,6 +619,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
595 listRef={listRef}619 listRef={listRef}
596 freeze={freeze}620 freeze={freeze}
597 anchorMessageId={anchorMessageId}621 anchorMessageId={anchorMessageId}
622 submittedMessageId={submittedMessageId}
598 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}623 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
599 contentTopInset={0}624 contentTopInset={0}
600 contentBottomInset={estimatedOverlayHeight}625 contentBottomInset={estimatedOverlayHeight}
apps/mobile/src/features/threads/ThreadFeed.tsx+4-3
...@@ -151,6 +151,7 @@ export interface ThreadFeedProps {...@@ -151,6 +151,7 @@ export interface ThreadFeedProps {
151 readonly listRef: RefObject<LegendListRef | null>;151 readonly listRef: RefObject<LegendListRef | null>;
152 readonly freeze: SharedValue<boolean>;152 readonly freeze: SharedValue<boolean>;
153 readonly anchorMessageId: MessageId | null;153 readonly anchorMessageId: MessageId | null;
154 readonly submittedMessageId: MessageId | null;
154 readonly contentInsetEndAdjustment: SharedValue<number>;155 readonly contentInsetEndAdjustment: SharedValue<number>;
155 readonly contentTopInset?: number;156 readonly contentTopInset?: number;
156 readonly contentBottomInset?: number;157 readonly contentBottomInset?: number;
...@@ -1546,12 +1547,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1546,12 +1547,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1546 transitionEndFollow({ type: "reset" });1547 transitionEndFollow({ type: "reset" });
1547 }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]);1548 }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]);
1548 useEffect(() => {1549 useEffect(() => {
1549 if (props.anchorMessageId !== null) {1550 if (props.submittedMessageId !== null) {
1550 clearUserScrollSettle();1551 clearUserScrollSettle();
1551 userScrollSessionRef.current = false;1552 userScrollSessionRef.current = false;
1552 transitionEndFollow({ type: "reset" });1553 transitionEndFollow({ type: "reset" });
1553 }1554 }
1554 }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]);1555 }, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]);
15551556
1556 const expandedWorkGroupIds = useMemo(() => {1557 const expandedWorkGroupIds = useMemo(() => {
1557 const ids = new Set<string>();1558 const ids = new Set<string>();
...@@ -1600,7 +1601,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1600,7 +1601,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1600 resolveChatListAnchoredEndSpace(1601 resolveChatListAnchoredEndSpace(
1601 presentedFeed,1602 presentedFeed,
1602 props.anchorMessageId,1603 props.anchorMessageId,
1603 (entry) => (entry.type === "message" ? entry.id : null),1604 (entry) => (entry.type === "message" && entry.message.role === "user" ? entry.id : null),
1604 { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },1605 { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },
1605 ),1606 ),
1606 [presentedFeed, props.anchorMessageId, anchorTopInset],1607 [presentedFeed, props.anchorMessageId, anchorTopInset],
apps/mobile/src/features/threads/thread-feed-live-follow.test.ts+66-1
...@@ -1,6 +1,71 @@...@@ -1,6 +1,71 @@
1import { describe, expect, it } from "vite-plus/test";1import { 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
5describe("resolveThreadFeedLiveFollow", () => {70describe("resolveThreadFeedLiveFollow", () => {
6 it("pauses immediately when the user starts scrolling", () => {71 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 =...@@ -12,6 +12,24 @@ export type ThreadFeedLiveFollowEvent =
12 readonly userScrollSessionActive: boolean;12 readonly userScrollSessionActive: boolean;
13 };13 };
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
15export function resolveThreadFeedLiveFollow(33export function resolveThreadFeedLiveFollow(
16 current: boolean,34 current: boolean,
17 event: ThreadFeedLiveFollowEvent,35 event: ThreadFeedLiveFollowEvent,
apps/web/src/components/ChatView.logic.test.ts+23
...@@ -638,6 +638,29 @@ describe("hasServerAcknowledgedLocalDispatch", () => {...@@ -638,6 +638,29 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
638 ).toBe(false);638 ).toBe(false);
639 });639 });
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
641 it("acknowledges a settled newer turn", () => {664 it("acknowledges a settled newer turn", () => {
642 const localDispatch = createLocalDispatchSnapshot(665 const localDispatch = createLocalDispatchSnapshot(
643 makeThread({ latestTurn: completedTurn, session: readySession }),666 makeThread({ latestTurn: completedTurn, session: readySession }),
apps/web/src/components/ChatView.logic.ts+3
...@@ -601,6 +601,9 @@ export function hasServerAcknowledgedLocalDispatch(input: {...@@ -601,6 +601,9 @@ export function hasServerAcknowledgedLocalDispatch(input: {
601 if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) {601 if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) {
602 return true;602 return true;
603 }603 }
604 if (input.phase === "connecting") {
605 return false;
606 }
604607
605 const latestTurn = input.latestTurn ?? null;608 const latestTurn = input.latestTurn ?? null;
606 const session = input.session ?? null;609 const session = input.session ?? null;
apps/web/src/components/ChatView.tsx+21-28
...@@ -5297,21 +5297,25 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5297,21 +5297,25 @@ function ChatViewContent(props: ChatViewProps) {
5297 sizeBytes: image.sizeBytes,5297 sizeBytes: image.sizeBytes,
5298 previewUrl: image.previewUrl,5298 previewUrl: image.previewUrl,
5299 }));5299 }));
5300 // Sending always returns to the live edge. The new row becomes the5300 const shouldAnchorFirstMessage =
5301 // anchored end-space target so it lands near the top while the response5301 activeThread.latestTurn === null &&
5302 // streams into the reserved space below it.5302 !timelineMessages.some((message) => message.role === "user");
5303 isAtEndRef.current = true;5303 if (shouldAnchorFirstMessage) {
5304 timelineScrollModeRef.current = "anchoring-new-turn";5304 isAtEndRef.current = true;
5305 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;5305 timelineScrollModeRef.current = "anchoring-new-turn";
5306 setTimelineLiveFollowEnabled(true);5306 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
5307 pendingTimelineAnchorRef.current = messageIdForSend;5307 setTimelineLiveFollowEnabled(true);
5308 activeTimelineAnchorIndexRef.current = null;5308 pendingTimelineAnchorRef.current = messageIdForSend;
5309 showScrollDebouncer.current.cancel();5309 activeTimelineAnchorIndexRef.current = null;
5310 setShowScrollToBottom(false);5310 showScrollDebouncer.current.cancel();
5311 setTimelineAnchor({5311 setShowScrollToBottom(false);
5312 threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)),5312 setTimelineAnchor({
5313 messageId: messageIdForSend,5313 threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)),
5314 });5314 messageId: messageIdForSend,
5315 });
5316 } else {
5317 scrollToEnd();
5318 }
5315 setOptimisticUserMessages((existing) => [5319 setOptimisticUserMessages((existing) => [
5316 ...existing,5320 ...existing,
5317 {5321 {
...@@ -5815,19 +5819,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5815,19 +5819,7 @@ function ChatViewContent(props: ChatViewProps) {
5815 beginLocalDispatch({ preparingWorktree: false });5819 beginLocalDispatch({ preparingWorktree: false });
5816 setThreadError(threadIdForSend, null);5820 setThreadError(threadIdForSend, null);
58175821
5818 // Position this sent row once LegendList has measured the anchored tail.5822 scrollToEnd();
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 });
58315823
5832 setOptimisticUserMessages((existing) => [5824 setOptimisticUserMessages((existing) => [
5833 ...existing,5825 ...existing,
...@@ -5922,6 +5914,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5922,6 +5914,7 @@ function ChatViewContent(props: ChatViewProps) {
5922 persistThreadSettingsForNextTurn,5914 persistThreadSettingsForNextTurn,
5923 resetLocalDispatch,5915 resetLocalDispatch,
5924 runtimeMode,5916 runtimeMode,
5917 scrollToEnd,
5925 setComposerDraftInteractionMode,5918 setComposerDraftInteractionMode,
5926 setThreadError,5919 setThreadError,
5927 startThreadTurn,5920 startThreadTurn,
apps/web/src/components/chat/MessagesTimeline.test.tsx+34-12
...@@ -434,15 +434,12 @@ describe("MessagesTimeline", () => {...@@ -434,15 +434,12 @@ describe("MessagesTimeline", () => {
434 expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem");434 expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem");
435 });435 });
436436
437 it("anchors a sent attachment message using its measured height", () => {437 it("anchors the first user message using its measured height", () => {
438 const onAnchorReady = vi.fn();438 const onAnchorReady = vi.fn();
439 const firstEntry = buildUserTimelineEntry("First prompt.");439 const firstEntry = {
440 const secondEntry = {440 ...buildUserTimelineEntry("First prompt."),
441 ...buildUserTimelineEntry("Newest prompt."),
442 id: "entry-2",
443 message: {441 message: {
444 ...buildUserTimelineEntry("Newest prompt.").message,442 ...buildUserTimelineEntry("First prompt.").message,
445 id: MessageId.make("message-2"),
446 attachments: [443 attachments: [
447 {444 {
448 type: "image" as const,445 type: "image" as const,
...@@ -458,14 +455,14 @@ describe("MessagesTimeline", () => {...@@ -458,14 +455,14 @@ describe("MessagesTimeline", () => {
458 const markup = renderToStaticMarkup(455 const markup = renderToStaticMarkup(
459 <MessagesTimeline456 <MessagesTimeline
460 {...buildProps()}457 {...buildProps()}
461 anchorMessageId={secondEntry.message.id}458 anchorMessageId={firstEntry.message.id}
462 onAnchorReady={onAnchorReady}459 onAnchorReady={onAnchorReady}
463 contentInsetEndAdjustment={144}460 contentInsetEndAdjustment={144}
464 timelineEntries={[firstEntry, secondEntry]}461 timelineEntries={[firstEntry]}
465 />,462 />,
466 );463 );
467464
468 expect(markup).toContain('data-anchor-index="1"');465 expect(markup).toContain('data-anchor-index="0"');
469 expect(markup).toContain('data-anchor-offset="16"');466 expect(markup).toContain('data-anchor-offset="16"');
470 expect(markup).toContain('data-anchor-on-ready="true"');467 expect(markup).toContain('data-anchor-on-ready="true"');
471 expect(markup).not.toContain("data-anchor-max-size=");468 expect(markup).not.toContain("data-anchor-max-size=");
...@@ -477,7 +474,32 @@ describe("MessagesTimeline", () => {...@@ -477,7 +474,32 @@ describe("MessagesTimeline", () => {
477 expect(markup).toContain('data-maintain-visible-content-position-size="true"');474 expect(markup).toContain('data-maintain-visible-content-position-size="true"');
478 expect(markup).toContain('data-maintain-visible-content-position-restore="true"');475 expect(markup).toContain('data-maintain-visible-content-position-restore="true"');
479 expect(onAnchorReady).toHaveBeenCalledOnce();476 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();
481 });503 });
482504
483 it("hands end-following back to the list once the send anchor is released", () => {505 it("hands end-following back to the list once the send anchor is released", () => {
...@@ -498,7 +520,7 @@ describe("MessagesTimeline", () => {...@@ -498,7 +520,7 @@ describe("MessagesTimeline", () => {
498 renderToStaticMarkup(520 renderToStaticMarkup(
499 <MessagesTimeline521 <MessagesTimeline
500 {...buildProps()}522 {...buildProps()}
501 anchorMessageId={secondEntry.message.id}523 anchorMessageId={firstEntry.message.id}
502 timelineEntries={timelineEntries}524 timelineEntries={timelineEntries}
503 />,525 />,
504 ),526 ),
apps/web/src/components/chat/MessagesTimeline.tsx+1-1
...@@ -446,7 +446,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -446,7 +446,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
446 );446 );
447 const anchoredEndSpace = useMemo(() => {447 const anchoredEndSpace = useMemo(() => {
448 const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) =>448 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,
450 );450 );
451 return config ? { ...config, onReady: handleAnchorReady } : undefined;451 return config ? { ...config, onReady: handleAnchorReady } : undefined;
452 }, [anchorMessageId, handleAnchorReady, rows]);452 }, [anchorMessageId, handleAnchorReady, rows]);
packages/shared/src/chatList.test.ts+16-5
...@@ -16,24 +16,35 @@ const rows: ReadonlyArray<Row> = [...@@ -16,24 +16,35 @@ const rows: ReadonlyArray<Row> = [
16const getAnchorId = (row: Row) => (row.anchorable ? row.id : null);16const getAnchorId = (row: Row) => (row.anchorable ? row.id : null);
1717
18describe("resolveChatListAnchoredEndSpace", () => {18describe("resolveChatListAnchoredEndSpace", () => {
19 it("anchors the matching row using its measured height", () => {19 it("anchors only the first eligible row", () => {
20 expect(resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId)).toEqual({20 expect(resolveChatListAnchoredEndSpace(rows, "first", getAnchorId)).toEqual({
21 anchorIndex: 2,21 anchorIndex: 0,
22 anchorOffset: CHAT_LIST_ANCHOR_OFFSET,22 anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
23 });23 });
24 });24 });
2525
26 it("allows a surface to keep the anchor below its own header", () => {26 it("allows a surface to keep the anchor below its own header", () => {
27 expect(27 expect(
28 resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId, {28 resolveChatListAnchoredEndSpace(rows, "first", getAnchorId, {
29 anchorOffset: 132,29 anchorOffset: 132,
30 }),30 }),
31 ).toEqual({31 ).toEqual({
32 anchorIndex: 2,32 anchorIndex: 0,
33 anchorOffset: 132,33 anchorOffset: 132,
34 });34 });
35 });35 });
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
37 it("ignores ineligible rows and missing anchors", () => {48 it("ignores ineligible rows and missing anchors", () => {
38 expect(resolveChatListAnchoredEndSpace(rows, "ignored", getAnchorId)).toBeUndefined();49 expect(resolveChatListAnchoredEndSpace(rows, "ignored", getAnchorId)).toBeUndefined();
39 expect(resolveChatListAnchoredEndSpace(rows, "missing", getAnchorId)).toBeUndefined();50 expect(resolveChatListAnchoredEndSpace(rows, "missing", getAnchorId)).toBeUndefined();
packages/shared/src/chatList.ts+15-6
...@@ -19,14 +19,23 @@ export function resolveChatListAnchoredEndSpace<Item, AnchorId>(...@@ -19,14 +19,23 @@ export function resolveChatListAnchoredEndSpace<Item, AnchorId>(
19 return undefined;19 return undefined;
20 }20 }
2121
22 for (let index = items.length - 1; index >= 0; index -= 1) {22 for (let index = 0; index < items.length; index += 1) {
23 const item = items[index];23 const item = items[index];
24 if (item !== undefined && getAnchorId(item) === anchorId) {24 if (item === undefined) {
25 return {25 continue;
26 anchorIndex: index,
27 anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
28 };
29 }26 }
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;
30 }39 }
3140
32 return undefined;41 return undefined;