diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e234838394ba..2c6860199722 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -79,6 +79,7 @@ import { } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; import type { ThreadContentPresentation } from "./threadContentPresentation"; +import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; export interface ThreadDetailScreenProps { readonly selectedThread: OrchestrationThreadShell; @@ -257,9 +258,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const listRef = useRef(null); const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); const selectedThreadKeyRef = useRef(selectedThreadKey); - const lastScrolledAnchorMessageIdRef = useRef(null); + const lastScrolledSubmittedMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); const [anchorMessageId, setAnchorMessageId] = useState(null); + const [submittedMessageId, setSubmittedMessageId] = useState(null); const [endFollowEnabled, setEndFollowEnabled] = useState(true); // Android keys the safe-area padding on keyboard visibility (#5988): the // back gesture closes the keyboard while the editor stays focused, and a @@ -458,17 +460,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useEffect(() => { setAnchorMessageId(null); - lastScrolledAnchorMessageIdRef.current = null; + setSubmittedMessageId(null); + lastScrolledSubmittedMessageIdRef.current = null; setEndFollowEnabled(true); freeze.set(false); }, [freeze, selectedThreadKey]); useEffect(() => { if ( - anchorMessageId === null || - lastScrolledAnchorMessageIdRef.current === anchorMessageId || + submittedMessageId === null || + lastScrolledSubmittedMessageIdRef.current === submittedMessageId || contentPresentationKind !== "ready" || - !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId) + !selectedThreadFeed.some( + (entry) => entry.type === "message" && entry.id === submittedMessageId, + ) ) { return; } @@ -478,7 +483,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (selectedThreadKeyRef.current !== targetThreadKey) { return; } - lastScrolledAnchorMessageIdRef.current = anchorMessageId; + lastScrolledSubmittedMessageIdRef.current = submittedMessageId; // Wait for the keyboard dismissal (started by blur() on send) to finish // before scrolling: scrollMessageToEnd freezes keyboard-driven inset // updates while it runs, and a close event swallowed by that freeze @@ -488,7 +493,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread .then(() => { if ( selectedThreadKeyRef.current !== targetThreadKey || - lastScrolledAnchorMessageIdRef.current !== anchorMessageId + lastScrolledSubmittedMessageIdRef.current !== submittedMessageId ) { return; } @@ -497,17 +502,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread .catch(() => { if ( selectedThreadKeyRef.current !== targetThreadKey || - lastScrolledAnchorMessageIdRef.current !== anchorMessageId + lastScrolledSubmittedMessageIdRef.current !== submittedMessageId ) { return; } - lastScrolledAnchorMessageIdRef.current = null; + lastScrolledSubmittedMessageIdRef.current = null; freeze.set(false); }); }); return () => cancelAnimationFrame(frame); }, [ - anchorMessageId, + submittedMessageId, freeze, contentPresentationKind, selectedThreadFeed, @@ -517,15 +522,34 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const handleSendMessage = useCallback(async () => { const targetThreadKey = selectedThreadKey; + const hasUserMessage = selectedThreadFeed.some( + (entry) => entry.type === "message" && entry.message.role === "user", + ); const messageId = await props.onSendMessage(); if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { return messageId; } - setAnchorMessageId(messageId); + setSubmittedMessageId(messageId); + setAnchorMessageId( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: anchorMessageId, + submittedMessageId: messageId, + hasStartedTurn: props.selectedThread.latestTurn !== null, + hasUserMessage, + queuedMessageCount: props.selectedThreadQueueCount, + }), + ); composerEditorRef.current?.blur(); return messageId; - }, [props.onSendMessage, selectedThreadKey]); + }, [ + anchorMessageId, + props.onSendMessage, + props.selectedThread.latestTurn, + props.selectedThreadQueueCount, + selectedThreadFeed, + selectedThreadKey, + ]); const collapseComposer = useCallback(() => { composerEditorRef.current?.blur(); @@ -595,6 +619,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread listRef={listRef} freeze={freeze} anchorMessageId={anchorMessageId} + submittedMessageId={submittedMessageId} contentInsetEndAdjustment={combinedContentInsetEndAdjustment} contentTopInset={0} contentBottomInset={estimatedOverlayHeight} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index f00736772766..d3aa65673bbb 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -151,6 +151,7 @@ export interface ThreadFeedProps { readonly listRef: RefObject; readonly freeze: SharedValue; readonly anchorMessageId: MessageId | null; + readonly submittedMessageId: MessageId | null; readonly contentInsetEndAdjustment: SharedValue; readonly contentTopInset?: number; readonly contentBottomInset?: number; @@ -1546,12 +1547,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { transitionEndFollow({ type: "reset" }); }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]); useEffect(() => { - if (props.anchorMessageId !== null) { + if (props.submittedMessageId !== null) { clearUserScrollSettle(); userScrollSessionRef.current = false; transitionEndFollow({ type: "reset" }); } - }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]); + }, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]); const expandedWorkGroupIds = useMemo(() => { const ids = new Set(); @@ -1600,7 +1601,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { resolveChatListAnchoredEndSpace( presentedFeed, props.anchorMessageId, - (entry) => (entry.type === "message" ? entry.id : null), + (entry) => (entry.type === "message" && entry.message.role === "user" ? entry.id : null), { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, ), [presentedFeed, props.anchorMessageId, anchorTopInset], diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts index 8cc68cb3c525..2ea207923429 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -1,6 +1,71 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveThreadFeedLiveFollow } from "./thread-feed-live-follow"; +import { + resolveThreadFeedLiveFollow, + resolveThreadFeedSubmissionAnchor, +} from "./thread-feed-live-follow"; + +describe("resolveThreadFeedSubmissionAnchor", () => { + it("anchors the first user message in a thread", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: null, + submittedMessageId: "first-message", + hasStartedTurn: false, + hasUserMessage: false, + queuedMessageCount: 0, + }), + ).toBe("first-message"); + }); + + it("preserves the first-message anchor when another message is queued", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: false, + hasUserMessage: false, + queuedMessageCount: 1, + }), + ).toBe("first-message"); + }); + + it("preserves the first-message anchor after its outbox entry drains", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: false, + hasUserMessage: false, + queuedMessageCount: 0, + }), + ).toBe("first-message"); + }); + + it("does not anchor a follow-up after a user message appears", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: false, + hasUserMessage: true, + queuedMessageCount: 0, + }), + ).toBeNull(); + }); + + it("does not anchor a thread that has already started a turn", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: null, + submittedMessageId: "second-message", + hasStartedTurn: true, + hasUserMessage: false, + queuedMessageCount: 0, + }), + ).toBeNull(); + }); +}); describe("resolveThreadFeedLiveFollow", () => { it("pauses immediately when the user starts scrolling", () => { diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts index babe18f0c1cb..312fd67473e5 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -12,6 +12,24 @@ export type ThreadFeedLiveFollowEvent = readonly userScrollSessionActive: boolean; }; +export function resolveThreadFeedSubmissionAnchor(input: { + readonly currentAnchorMessageId: AnchorId | null; + readonly submittedMessageId: AnchorId; + readonly hasStartedTurn: boolean; + readonly hasUserMessage: boolean; + readonly queuedMessageCount: number; +}): AnchorId | null { + if (input.hasStartedTurn || input.hasUserMessage) { + return null; + } + + if (input.currentAnchorMessageId !== null) { + return input.currentAnchorMessageId; + } + + return input.queuedMessageCount > 0 ? null : input.submittedMessageId; +} + export function resolveThreadFeedLiveFollow( current: boolean, event: ThreadFeedLiveFollowEvent, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 194418c8309f..66e83f1f7e62 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -638,6 +638,29 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(false); }); + it("keeps a follow-up active while its provider session is starting", () => { + const localDispatch = createLocalDispatchSnapshot( + makeThread({ latestTurn: completedTurn, session: readySession }), + ); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "connecting", + latestTurn: completedTurn, + latestUserMessageId: MessageId.make("message-followup"), + session: { + ...readySession, + status: "starting", + updatedAt: "2026-03-29T00:01:00.000Z", + }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + }); + it("acknowledges a settled newer turn", () => { const localDispatch = createLocalDispatchSnapshot( makeThread({ latestTurn: completedTurn, session: readySession }), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1662ef91b92d..b790aa025a1f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -601,6 +601,9 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } + if (input.phase === "connecting") { + return false; + } const latestTurn = input.latestTurn ?? null; const session = input.session ?? null; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8dcb6b8ed932..20966deb0c4f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5297,21 +5297,25 @@ function ChatViewContent(props: ChatViewProps) { sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); - // Sending always returns to the live edge. The new row becomes the - // anchored end-space target so it lands near the top while the response - // streams into the reserved space below it. - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); + const shouldAnchorFirstMessage = + activeThread.latestTurn === null && + !timelineMessages.some((message) => message.role === "user"); + if (shouldAnchorFirstMessage) { + isAtEndRef.current = true; + timelineScrollModeRef.current = "anchoring-new-turn"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); + pendingTimelineAnchorRef.current = messageIdForSend; + activeTimelineAnchorIndexRef.current = null; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + setTimelineAnchor({ + threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), + messageId: messageIdForSend, + }); + } else { + scrollToEnd(); + } setOptimisticUserMessages((existing) => [ ...existing, { @@ -5815,19 +5819,7 @@ function ChatViewContent(props: ChatViewProps) { beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); - // Position this sent row once LegendList has measured the anchored tail. - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); + scrollToEnd(); setOptimisticUserMessages((existing) => [ ...existing, @@ -5922,6 +5914,7 @@ function ChatViewContent(props: ChatViewProps) { persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, + scrollToEnd, setComposerDraftInteractionMode, setThreadError, startThreadTurn, diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c7f1f8febb46..0c2c785fc120 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -434,15 +434,12 @@ describe("MessagesTimeline", () => { expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem"); }); - it("anchors a sent attachment message using its measured height", () => { + it("anchors the first user message using its measured height", () => { const onAnchorReady = vi.fn(); - const firstEntry = buildUserTimelineEntry("First prompt."); - const secondEntry = { - ...buildUserTimelineEntry("Newest prompt."), - id: "entry-2", + const firstEntry = { + ...buildUserTimelineEntry("First prompt."), message: { - ...buildUserTimelineEntry("Newest prompt.").message, - id: MessageId.make("message-2"), + ...buildUserTimelineEntry("First prompt.").message, attachments: [ { type: "image" as const, @@ -458,14 +455,14 @@ describe("MessagesTimeline", () => { const markup = renderToStaticMarkup( , ); - expect(markup).toContain('data-anchor-index="1"'); + expect(markup).toContain('data-anchor-index="0"'); expect(markup).toContain('data-anchor-offset="16"'); expect(markup).toContain('data-anchor-on-ready="true"'); expect(markup).not.toContain("data-anchor-max-size="); @@ -477,7 +474,32 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-maintain-visible-content-position-size="true"'); expect(markup).toContain('data-maintain-visible-content-position-restore="true"'); expect(onAnchorReady).toHaveBeenCalledOnce(); - expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1); + expect(onAnchorReady).toHaveBeenCalledWith(firstEntry.message.id, 0); + }); + + it("does not reserve end space for a follow-up user message", () => { + const onAnchorReady = vi.fn(); + const firstEntry = buildUserTimelineEntry("First prompt."); + const secondEntry = { + ...buildUserTimelineEntry("Newest prompt."), + id: "entry-2", + message: { + ...buildUserTimelineEntry("Newest prompt.").message, + id: MessageId.make("message-2"), + }, + }; + const markup = renderToStaticMarkup( + , + ); + + expect(markup).not.toContain("data-anchor-index="); + expect(markup).toContain('data-maintain-scroll-at-end="enabled"'); + expect(onAnchorReady).not.toHaveBeenCalled(); }); it("hands end-following back to the list once the send anchor is released", () => { @@ -498,7 +520,7 @@ describe("MessagesTimeline", () => { renderToStaticMarkup( , ), diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 438a9ce90034..af920c0d6156 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -446,7 +446,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const anchoredEndSpace = useMemo(() => { const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => - row.kind === "message" ? row.message.id : null, + row.kind === "message" && row.message.role === "user" ? row.message.id : null, ); return config ? { ...config, onReady: handleAnchorReady } : undefined; }, [anchorMessageId, handleAnchorReady, rows]); diff --git a/packages/shared/src/chatList.test.ts b/packages/shared/src/chatList.test.ts index 58e0e03f0260..78cf90f2aa88 100644 --- a/packages/shared/src/chatList.test.ts +++ b/packages/shared/src/chatList.test.ts @@ -16,24 +16,35 @@ const rows: ReadonlyArray = [ const getAnchorId = (row: Row) => (row.anchorable ? row.id : null); describe("resolveChatListAnchoredEndSpace", () => { - it("anchors the matching row using its measured height", () => { - expect(resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId)).toEqual({ - anchorIndex: 2, + it("anchors only the first eligible row", () => { + expect(resolveChatListAnchoredEndSpace(rows, "first", getAnchorId)).toEqual({ + anchorIndex: 0, anchorOffset: CHAT_LIST_ANCHOR_OFFSET, }); }); it("allows a surface to keep the anchor below its own header", () => { expect( - resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId, { + resolveChatListAnchoredEndSpace(rows, "first", getAnchorId, { anchorOffset: 132, }), ).toEqual({ - anchorIndex: 2, + anchorIndex: 0, anchorOffset: 132, }); }); + it("does not reserve end space for later eligible rows", () => { + expect(resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId)).toBeUndefined(); + }); + + it("skips ineligible rows before the first anchor", () => { + expect(resolveChatListAnchoredEndSpace(rows.slice(1), "latest", getAnchorId)).toEqual({ + anchorIndex: 1, + anchorOffset: CHAT_LIST_ANCHOR_OFFSET, + }); + }); + it("ignores ineligible rows and missing anchors", () => { expect(resolveChatListAnchoredEndSpace(rows, "ignored", getAnchorId)).toBeUndefined(); expect(resolveChatListAnchoredEndSpace(rows, "missing", getAnchorId)).toBeUndefined(); diff --git a/packages/shared/src/chatList.ts b/packages/shared/src/chatList.ts index a034cd02f312..adfdcad4eaf2 100644 --- a/packages/shared/src/chatList.ts +++ b/packages/shared/src/chatList.ts @@ -19,14 +19,23 @@ export function resolveChatListAnchoredEndSpace( return undefined; } - for (let index = items.length - 1; index >= 0; index -= 1) { + for (let index = 0; index < items.length; index += 1) { const item = items[index]; - if (item !== undefined && getAnchorId(item) === anchorId) { - return { - anchorIndex: index, - anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET, - }; + if (item === undefined) { + continue; } + + const itemAnchorId = getAnchorId(item); + if (itemAnchorId === null) { + continue; + } + + return itemAnchorId === anchorId + ? { + anchorIndex: index, + anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET, + } + : undefined; } return undefined;