From ceb6b56d1268c55bd7d3d78935f0dc90eebff9d7 Mon Sep 17 00:00:00 2001 From: Connor Chevli Date: Fri, 2 Oct 2026 01:43:32 -0700 Subject: [PATCH 1/4] feat: add a setting to anchor sent messages at the top of the timeline --- .../settings/SettingsThreadsRouteScreen.tsx | 28 +++++++++++++ .../features/threads/ThreadDetailScreen.tsx | 9 ++++ .../threads/thread-feed-live-follow.test.ts | 31 ++++++++++++++ .../threads/thread-feed-live-follow.ts | 13 ++++++ .../src/persistence/mobile-preferences.ts | 12 +++++- apps/web/src/components/ChatView.tsx | 42 ++++++++++++------- .../components/settings/SettingsPanels.tsx | 42 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 +++ docs/user/composer.md | 8 ++++ packages/contracts/src/settings.ts | 12 ++++++ packages/shared/src/chatList.test.ts | 20 ++++----- packages/shared/src/chatList.ts | 29 ++++--------- 12 files changed, 205 insertions(+), 47 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index e209ab0b6ce5..58af254bb291 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -44,6 +44,7 @@ export function SettingsThreadsRouteScreen() { contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} > + @@ -215,6 +216,33 @@ function AutoSettleSettingsRows() { ); } +/** + * Device-local counterpart of web's "Sent message position" client setting. + */ +function TimelineSettingsSection() { + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferences = useAtomValue(mobilePreferencesAtom); + const anchorsAtTop = + AsyncResult.isSuccess(preferences) && preferences.value.chatTurnAnchor === "top"; + + return ( + + + savePreferences({ chatTurnAnchor: value ? "top" : "bottom" })} + /> + + + Holds each message you send at the top of the screen so the reply reads downward. Off keeps + the conversation pinned to the latest message. + + + ); +} + /** * Device-local legacy toggles. Mobile has no client-settings sync, so this is * the counterpart of web's Settings → General → Legacy features backed by diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 14beefd6d178..25c97a4c6eb9 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -9,6 +9,8 @@ import type { CodexFeedbackSubmission, EnvironmentThreadStatus, } from "@t3tools/client-runtime/state/threads"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import type { LegendListRef } from "@legendapp/list/react-native"; @@ -75,6 +77,7 @@ import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; import { deviceEnvironment } from "../../state/device"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { useEnvironmentQuery } from "../../state/query"; import { threadDevicePreviews } from "../devices/threadDevicePreviews"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; @@ -337,6 +340,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ); const [anchorMessageId, setAnchorMessageId] = useState(null); const [submittedMessageId, setSubmittedMessageId] = useState(null); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const chatTurnAnchor = + (AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.chatTurnAnchor : null) ?? + "bottom"; 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 @@ -790,12 +797,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread hasStartedTurn: props.selectedThread.latestTurn !== null, hasUserMessage, queuedMessageCount: props.selectedThreadQueueCount, + turnAnchor: chatTurnAnchor, }), ); composerEditorRef.current?.blur(); return messageId; }, [ anchorMessageId, + chatTurnAnchor, clearUsageLimitsFor, props.onSendMessage, props.selectedThread.latestTurn, 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 13e81130823a..dcbbc10c479d 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 @@ -91,6 +91,7 @@ describe("resolveThreadFeedSubmissionAnchor", () => { hasStartedTurn: false, hasUserMessage: false, queuedMessageCount: 0, + turnAnchor: "bottom", }), ).toBe("first-message"); }); @@ -103,6 +104,7 @@ describe("resolveThreadFeedSubmissionAnchor", () => { hasStartedTurn: false, hasUserMessage: false, queuedMessageCount: 1, + turnAnchor: "bottom", }), ).toBe("first-message"); }); @@ -115,6 +117,7 @@ describe("resolveThreadFeedSubmissionAnchor", () => { hasStartedTurn: false, hasUserMessage: false, queuedMessageCount: 0, + turnAnchor: "bottom", }), ).toBe("first-message"); }); @@ -127,6 +130,7 @@ describe("resolveThreadFeedSubmissionAnchor", () => { hasStartedTurn: false, hasUserMessage: true, queuedMessageCount: 0, + turnAnchor: "bottom", }), ).toBeNull(); }); @@ -139,9 +143,36 @@ describe("resolveThreadFeedSubmissionAnchor", () => { hasStartedTurn: true, hasUserMessage: false, queuedMessageCount: 0, + turnAnchor: "bottom", }), ).toBeNull(); }); + + it("re-anchors every direct send when messages anchor at the top", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: true, + hasUserMessage: true, + queuedMessageCount: 0, + turnAnchor: "top", + }), + ).toBe("second-message"); + }); + + it("keeps the current framing for a send that queues behind others", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: true, + hasUserMessage: true, + queuedMessageCount: 1, + turnAnchor: "top", + }), + ).toBe("first-message"); + }); }); describe("resolveThreadFeedLiveFollow", () => { 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 431dda7550d1..69cd7e209ea4 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -1,3 +1,5 @@ +import type { ChatTurnAnchor } from "@t3tools/contracts"; + export type ThreadFeedLiveFollowEvent = | { readonly type: "reset" } | { readonly type: "user-scroll-begin" } @@ -47,13 +49,24 @@ export function shouldFollowThreadWorkGroupAppend(input: { ); } +/** + * Which sent message the feed should hold near the top while its turn streams. + * `bottom` anchors only the first message of a thread; `top` re-anchors every + * direct send. A message that queues behind others is not the live turn, so it + * leaves the current framing alone either way. + */ export function resolveThreadFeedSubmissionAnchor(input: { readonly currentAnchorMessageId: AnchorId | null; readonly submittedMessageId: AnchorId; readonly hasStartedTurn: boolean; readonly hasUserMessage: boolean; readonly queuedMessageCount: number; + readonly turnAnchor: ChatTurnAnchor; }): AnchorId | null { + if (input.turnAnchor === "top") { + return input.queuedMessageCount > 0 ? input.currentAnchorMessageId : input.submittedMessageId; + } + if (input.hasStartedTurn || input.hasUserMessage) { return null; } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 9514efdff424..95bc62f66331 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -5,7 +5,11 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import type { ProviderInstanceId, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { + ChatTurnAnchor, + ProviderInstanceId, + SidebarProjectGroupingMode, +} from "@t3tools/contracts"; import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior"; import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; import * as MobileDatabase from "./mobile-database"; @@ -30,6 +34,8 @@ export interface Preferences { readonly collapsedProjectGroups?: readonly string[]; /** What the Return key does in the composer on a hardware keyboard. iOS only. */ readonly composerEnterBehavior?: ComposerEnterBehavior; + /** Device-local counterpart of the web client's `chatTurnAnchor` setting. */ + readonly chatTurnAnchor?: ChatTurnAnchor; /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; @@ -97,6 +103,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { connectOnboardingOptOutAccounts?: ReadonlyArray; collapsedProjectGroups?: readonly string[]; composerEnterBehavior?: ComposerEnterBehavior; + chatTurnAnchor?: ChatTurnAnchor; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; planModeEnabled?: boolean; @@ -157,6 +164,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (parsed.composerEnterBehavior === "send" || parsed.composerEnterBehavior === "newline") { preferences.composerEnterBehavior = parsed.composerEnterBehavior; } + if (parsed.chatTurnAnchor === "bottom" || parsed.chatTurnAnchor === "top") { + preferences.chatTurnAnchor = parsed.chatTurnAnchor; + } if (typeof parsed.projectGroupingEnabled === "boolean") { preferences.projectGroupingEnabled = parsed.projectGroupingEnabled; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e5974dc47a13..5b1b833eebf1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5418,6 +5418,20 @@ export default function ChatView(props: ChatViewProps) { void legendListRef.current?.scrollToEnd?.({ animated }); }); }, []); + // Holds a message the user just sent near the top of the viewport so its turn + // reads downward from it. ChatView drives the streaming scrolls itself while + // this "anchoring-new-turn" mode lasts; see releaseChatTimelineAnchor. + const anchorSentMessage = useCallback((threadRef: ScopedThreadRef, messageId: MessageId) => { + isAtEndRef.current = true; + timelineScrollModeRef.current = "anchoring-new-turn"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); + pendingTimelineAnchorRef.current = messageId; + activeTimelineAnchorIndexRef.current = null; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + setTimelineAnchor({ threadKey: scopedThreadKey(threadRef), messageId }); + }, []); useLayoutEffect(() => { if (timelineScrollModeRef.current !== "anchoring-new-turn") { return; @@ -8235,22 +8249,16 @@ export default function ChatView(props: ChatViewProps) { ...(attachment.source ? { source: attachment.source } : {}), }, ); + // The first message of a thread always anchors so the opening turn reads + // from the top; later sends only do when the user opted in. 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, - }); + if (shouldAnchorFirstMessage || settings.chatTurnAnchor === "top") { + anchorSentMessage( + scopeThreadRef(activeThread.environmentId, threadIdForSend), + messageIdForSend, + ); } else { scrollToEnd(); } @@ -8957,7 +8965,11 @@ export default function ChatView(props: ChatViewProps) { beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); - scrollToEnd(); + if (settings.chatTurnAnchor === "top") { + anchorSentMessage(scopeThreadRef(environmentId, threadIdForSend), messageIdForSend); + } else { + scrollToEnd(); + } setOptimisticUserMessages((existing) => [ ...existing, @@ -9063,9 +9075,11 @@ export default function ChatView(props: ChatViewProps) { persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, + anchorSentMessage, scrollToEnd, setComposerDraftInteractionMode, setThreadError, + settings.chatTurnAnchor, startThreadTurn, environmentId, composerRef, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 142f472c7f31..bb1eb4954b9d 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -603,6 +603,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior ? ["Follow-up behavior"] : []), + ...(settings.chatTurnAnchor !== DEFAULT_UNIFIED_SETTINGS.chatTurnAnchor + ? ["Sent message position"] + : []), ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled ? ["Context window indicator"] : []), @@ -667,6 +670,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.composerRichTextEnabled, settings.sendShortcut, settings.followUpBehavior, + settings.chatTurnAnchor, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, @@ -783,6 +787,7 @@ export function useSettingsRestore(onRestored?: () => void) { composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled, sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, + chatTurnAnchor: DEFAULT_UNIFIED_SETTINGS.chatTurnAnchor, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, @@ -2797,6 +2802,43 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ chatTurnAnchor: DEFAULT_UNIFIED_SETTINGS.chatTurnAnchor }) + } + /> + ) : null + } + control={ + + } + /> + = [ const getAnchorId = (row: Row) => (row.anchorable ? row.id : null); describe("resolveChatListAnchoredEndSpace", () => { - it("anchors only the first eligible row", () => { + it("anchors the first eligible row", () => { expect(resolveChatListAnchoredEndSpace(rows, "first", getAnchorId)).toEqual({ anchorIndex: 0, anchorOffset: CHAT_LIST_ANCHOR_OFFSET, }); }); + it("anchors a later eligible row for follow-up sends", () => { + expect(resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId)).toEqual({ + anchorIndex: 2, + anchorOffset: CHAT_LIST_ANCHOR_OFFSET, + }); + }); + it("allows a surface to keep the anchor below its own header", () => { expect( resolveChatListAnchoredEndSpace(rows, "first", getAnchorId, { @@ -34,17 +41,6 @@ describe("resolveChatListAnchoredEndSpace", () => { }); }); - 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 adfdcad4eaf2..32b143196a84 100644 --- a/packages/shared/src/chatList.ts +++ b/packages/shared/src/chatList.ts @@ -9,6 +9,11 @@ export interface ChatListAnchorOptions { readonly anchorOffset?: number; } +/** + * Locates the row a chat list should hold near the top of the viewport while + * its turn streams in below. Callers decide which sent message (if any) gets + * anchored; this only maps that id to the LegendList `anchoredEndSpace` config. + */ export function resolveChatListAnchoredEndSpace( items: ReadonlyArray, anchorId: AnchorId | null, @@ -19,24 +24,8 @@ export function resolveChatListAnchoredEndSpace( return undefined; } - for (let index = 0; index < items.length; index += 1) { - const item = items[index]; - 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; + const anchorIndex = items.findIndex((item) => getAnchorId(item) === anchorId); + return anchorIndex === -1 + ? undefined + : { anchorIndex, anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET }; } From 7b25a9aa1129ba86012e6d7f7d3e417cf3508518 Mon Sep 17 00:00:00 2001 From: Connor Chevli Date: Fri, 2 Oct 2026 02:05:37 -0700 Subject: [PATCH 2/4] fix(web): keep steers with the live stream in top-anchor mode --- apps/web/src/components/ChatView.tsx | 7 +++++-- docs/user/composer.md | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5b1b833eebf1..db7443f5b2cc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8250,11 +8250,14 @@ export default function ChatView(props: ChatViewProps) { }, ); // The first message of a thread always anchors so the opening turn reads - // from the top; later sends only do when the user opted in. + // from the top; later sends only do when the user opted in. A steer is + // excluded: the live reply keeps streaming above it, so there is nothing + // to read downward from and the anchor would chase a moving target. const shouldAnchorFirstMessage = activeThread.latestTurn === null && !timelineMessages.some((message) => message.role === "user"); - if (shouldAnchorFirstMessage || settings.chatTurnAnchor === "top") { + const shouldAnchorNewTurn = settings.chatTurnAnchor === "top" && phase !== "running"; + if (shouldAnchorFirstMessage || shouldAnchorNewTurn) { anchorSentMessage( scopeThreadRef(activeThread.environmentId, threadIdForSend), messageIdForSend, diff --git a/docs/user/composer.md b/docs/user/composer.md index c9ad5cbe5a7a..9e8afe9ac025 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -54,7 +54,7 @@ in the composer and waits if the agent needs an approval or an answer. By default the conversation stays pinned to the latest message. In **Settings → General → Sent message position**, choose **Top** to hold each message you send at the top of the view while the reply streams in below it. -Messages that queue behind a running turn keep the current position. On mobile, +Messages that queue behind or steer a running turn stay with the live stream instead. On mobile, the same choice is **Settings → Thread behavior → Sent messages at top**. ## Queue messages offline on mobile From 8dd7dad312a9e658688a47dc1f1e222572a3d17e Mon Sep 17 00:00:00 2001 From: Connor Chevli Date: Fri, 2 Oct 2026 02:59:31 -0700 Subject: [PATCH 3/4] fix(web): returning to the live edge keeps the sent message anchored Scrolling up and back down after a top-anchored send released the anchor, collapsing its end space and dropping the message to the bottom of the viewport. Returning to the live edge (by gesture or the scroll-to-end pill) now resumes the anchored framing while the send-time anchor is installed. --- apps/web/src/components/ChatView.tsx | 79 ++++++++++++++++++---------- 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index db7443f5b2cc..50d14b7fcee0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1465,8 +1465,10 @@ const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; * message near the top while its turn streams, and it keeps LegendList's * maintainScrollAtEnd switched off for as long as it is installed — ChatView * drives the streaming scrolls itself, but only in "anchoring-new-turn" mode. - * So every return to the live edge has to release the anchor too, otherwise the - * timeline settles into "following-end" with nothing following anything. + * So a return to the live edge must either resume that mode (the anchor is + * still installed, so the live edge is the anchored framing) or release the + * anchor, otherwise the timeline settles into "following-end" with nothing + * following anything. */ function releaseChatTimelineAnchor( current: T, @@ -5321,6 +5323,8 @@ export default function ChatView(props: ChatViewProps) { const liveFollowUserScrollGenerationRef = useRef(0); // Manual navigation stops live-follow without removing anchored end space. // Collapsing that space during a gesture clamps the viewport back to the end. + // The anchor index stays too: the anchor is still installed, and returning + // to the live edge resumes the anchored framing from it. const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { cancelPositionRestoreRef.current?.(); anchorUserScrollGenerationRef.current += 1; @@ -5330,7 +5334,6 @@ export default function ChatView(props: ChatViewProps) { pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; }, []); const cancelTimelineLiveFollowForUserNavigationRef = useRef( cancelTimelineLiveFollowForUserNavigation, @@ -5400,24 +5403,35 @@ export default function ChatView(props: ChatViewProps) { pageScrollControllerRef.current?.releaseActiveKey(); }, []); // Live-follow stays active after send/thread-open until an actual list scroll - // gesture opts out. - const scrollToEnd = useCallback((animated = false) => { - cancelPositionRestoreRef.current?.(); - isAtEndRef.current = true; - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor(releaseChatTimelineAnchor); - requestAnimationFrame(() => { - void legendListRef.current?.scrollToEnd?.({ animated }); - }); - }, []); + // gesture opts out. `keepAnchor` is for returning to the live edge after such + // a gesture: an installed send-time anchor is resumed rather than released, + // since releasing collapses its end space and drops the sent message from + // the top of the viewport to the bottom. Sends and tool activity never keep + // it; the previous turn's framing is over. + const scrollToEnd = useCallback( + (animated = false, options?: { readonly keepAnchor?: boolean }) => { + cancelPositionRestoreRef.current?.(); + const resumeAnchor = + options?.keepAnchor === true && activeTimelineAnchorIndexRef.current !== null; + isAtEndRef.current = true; + timelineScrollModeRef.current = resumeAnchor ? "anchoring-new-turn" : "following-end"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); + pendingTimelineAnchorRef.current = null; + positionedTimelineAnchorRef.current = null; + settledTimelineAnchorRef.current = null; + if (!resumeAnchor) { + activeTimelineAnchorIndexRef.current = null; + setTimelineAnchor(releaseChatTimelineAnchor); + } + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + requestAnimationFrame(() => { + void legendListRef.current?.scrollToEnd?.({ animated }); + }); + }, + [], + ); // Holds a message the user just sent near the top of the viewport so its turn // reads downward from it. ChatView drives the streaming scrolls itself while // this "anchoring-new-turn" mode lasts; see releaseChatTimelineAnchor. @@ -5612,6 +5626,10 @@ export default function ChatView(props: ChatViewProps) { }, [activeThread?.id, isTimelineAtLogicalEnd, timelineRealContentOverflowsViewport]); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { + // The index is bookkeeping for the installed anchor and stays current in + // every mode: rows can be prepended while the user reads history, and a + // later return to the live edge resumes the framing from this index. + activeTimelineAnchorIndexRef.current = anchorIndex; // Anchored-end space can be remeasured when the turn completes. Once the // user has scrolled away (or returned to ordinary end-following), that // remeasurement must not restart the send-time anchor positioning. @@ -5621,7 +5639,6 @@ export default function ChatView(props: ChatViewProps) { if (pendingTimelineAnchorRef.current === messageId) { pendingTimelineAnchorRef.current = null; } - activeTimelineAnchorIndexRef.current = anchorIndex; if (positionedTimelineAnchorRef.current === messageId) { return; } @@ -5676,14 +5693,18 @@ export default function ChatView(props: ChatViewProps) { if (timelineScrollIntentRef.current === "toward-end") { composerRef.current?.restoreAfterTimelineReachedEnd(); } - timelineScrollModeRef.current = "following-end"; + // Reachable only once manual navigation has already broken follow. The + // user scrolled back to the live edge and expects the stream to stick + // to it again, exactly like the scroll-to-bottom pill: resume the + // anchored framing while the send-time anchor is still installed, + // otherwise fall back to ordinary end-following. + const resumeAnchor = activeTimelineAnchorIndexRef.current !== null; + timelineScrollModeRef.current = resumeAnchor ? "anchoring-new-turn" : "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; setTimelineLiveFollowEnabled(true); - // Reachable only once manual navigation has already broken follow, so - // the anchored turn framing is over: the user scrolled back to the live - // edge and expects the stream to stick to it again, exactly like the - // scroll-to-bottom pill. - setTimelineAnchor(releaseChatTimelineAnchor); + if (!resumeAnchor) { + setTimelineAnchor(releaseChatTimelineAnchor); + } showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); } else { @@ -9966,7 +9987,7 @@ export default function ChatView(props: ChatViewProps) { onPointerDown={(event) => event.preventDefault()} onClick={() => { composerRef.current?.restoreAfterTimelineReachedEnd(); - scrollToEnd(true); + scrollToEnd(true, { keepAnchor: true }); }} className="pointer-events-auto" size="xs" From e4175496f16ecbeedffa44cf32b65884fb1adfda Mon Sep 17 00:00:00 2001 From: Connor Chevli Date: Fri, 2 Oct 2026 12:18:37 -0700 Subject: [PATCH 4/4] fix(web): anchored streaming no longer leaves the reply tail behind the composer LegendList reports row positions relative to its rows layer, which starts one list header below the content start, while the scroll offset is a content offset. The anchored-turn reveal mixed the two, so every streaming scroll landed a header short and the end of the reply sat under the composer overlay. The list now reports its header size and the anchored metrics convert rows into scroll offsets with it. --- apps/web/src/components/ChatView.tsx | 8 ++++ .../src/components/chat/MessagesTimeline.tsx | 20 ++++++++++ .../chat/timelineScrollAnchoring.test.tsx | 38 ++++++++++++++++++- .../chat/timelineScrollAnchoring.ts | 34 ++++++++++++++--- 4 files changed, 94 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 50d14b7fcee0..5261a312acf4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5342,6 +5342,11 @@ export default function ChatView(props: ChatViewProps) { cancelTimelineLiveFollowForUserNavigationRef.current = cancelTimelineLiveFollowForUserNavigation; }, [cancelTimelineLiveFollowForUserNavigation]); + // Reported by the list; row positions it measures exclude this header. + const timelineHeaderSizeRef = useRef(0); + const onTimelineHeaderSizeChange = useCallback((headerSize: number) => { + timelineHeaderSizeRef.current = headerSize; + }, []); const getActiveTimelineTurnMetrics = useCallback( (list?: LegendListRef | null) => { const resolvedList = list ?? legendListRef.current; @@ -5356,6 +5361,7 @@ export default function ChatView(props: ChatViewProps) { anchorIndex, composerOverlayHeight: composerTimelineInset, anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, + headerSize: timelineHeaderSizeRef.current, }); }, [composerTimelineInset], @@ -5365,6 +5371,7 @@ export default function ChatView(props: ChatViewProps) { timelineContentOverflowsViewport((list ?? legendListRef.current)?.getState(), { composerInset: composerTimelineInset, anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, + headerSize: timelineHeaderSizeRef.current, }), [composerTimelineInset], ); @@ -9960,6 +9967,7 @@ export default function ChatView(props: ChatViewProps) { liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} onContentOverflowChange={setTimelineOverflows} + onHeaderSizeChange={onTimelineHeaderSizeChange} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} cancelPositionRestoreRef={cancelPositionRestoreRef} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 56b104865854..58e5166b43be 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -73,6 +73,7 @@ import { } from "react"; import { LegendList, + type LegendListMetrics, type LegendListRef, type MaintainScrollAtEndOptions, } from "@legendapp/list/react"; @@ -460,6 +461,11 @@ interface MessagesTimelineProps { * Reported after scrolls, row size changes, and viewport resizes. */ onContentOverflowChange?: (overflows: boolean) => void; + /** + * Height of the list header above the first row. Row positions from the + * list exclude it; see TimelineListMeasurementState. + */ + onHeaderSizeChange?: (headerSize: number) => void; onToolOutputCollapsedAtEnd?: () => void; onManualNavigation: () => void; cancelPositionRestoreRef?: React.RefObject<(() => void) | null>; @@ -520,6 +526,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ liveFollowEnabled, onIsAtEndChange, onContentOverflowChange, + onHeaderSizeChange, onToolOutputCollapsedAtEnd, onManualNavigation, cancelPositionRestoreRef, @@ -987,11 +994,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [anchoredEndSpace, contentInsetEndAdjustment], ); + const listHeaderSizeRef = useRef(0); const measureContentOverflow = useCallback( () => timelineContentOverflowsViewport(listRef.current?.getState?.(), { composerInset: contentInsetEndAdjustment, anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, + headerSize: listHeaderSizeRef.current, }), [contentInsetEndAdjustment, listRef], ); @@ -1013,6 +1022,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }); }, [measureContentOverflow, onContentOverflowChange]); useEffect(() => cancelContentOverflowFrame, [cancelContentOverflowFrame]); + // The list emits its header size after this component's layout effects, so + // the overflow read is repeated once the real header height is known. + const onListMetricsChange = useCallback( + (metrics: LegendListMetrics) => { + listHeaderSizeRef.current = metrics.headerSize; + onHeaderSizeChange?.(metrics.headerSize); + reportContentOverflow(); + }, + [onHeaderSizeChange, reportContentOverflow], + ); // The list's own layout effects have already run here, so estimated row // positions are in place. Reporting before the first paint lets a thread // open in its final composer layout instead of correcting it a frame later. @@ -1329,6 +1348,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ maintainScrollAtEndThreshold={1} onScroll={handleScroll} onItemSizeChanged={reportContentOverflow} + onMetricsChange={onListMetricsChange} className={cn( "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", topFadeEnabled && "topbar-scroll-fade", diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx index 5f98bd0f12d8..a6eec0c1f162 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -28,7 +28,7 @@ function buildState({ } describe("timelineContentOverflowsViewport", () => { - const inset = { composerInset: 100, anchorOffset: 24 }; + const inset = { composerInset: 100, anchorOffset: 24, headerSize: 0 }; it("reports overflow from the last row, not the inset spacer", () => { const fits = buildState({ positions: [0, 200], sizes: [200, 300], scrollLength: 700 }); @@ -38,6 +38,12 @@ describe("timelineContentOverflowsViewport", () => { expect(timelineContentOverflowsViewport(overflows, inset)).toBe(true); }); + it("counts the list header above the rows", () => { + const state = buildState({ positions: [0, 200], sizes: [200, 300], scrollLength: 700 }); + expect(timelineContentOverflowsViewport(state, inset)).toBe(false); + expect(timelineContentOverflowsViewport(state, { ...inset, headerSize: 100 })).toBe(true); + }); + it("treats an empty or unmeasured list as fitting", () => { expect(timelineContentOverflowsViewport(undefined, inset)).toBe(false); expect( @@ -80,6 +86,7 @@ describe("timeline scroll anchoring", () => { anchorIndex: 1, composerOverlayHeight: 180, anchorOffset: 16, + headerSize: 0, }); expect(metrics?.turnHeight).toBe(300); @@ -102,6 +109,7 @@ describe("timeline scroll anchoring", () => { anchorIndex: 1, composerOverlayHeight: 180, anchorOffset: 16, + headerSize: 0, }); expect(metrics?.lastBottom).toBe(2000); @@ -122,6 +130,7 @@ describe("timeline scroll anchoring", () => { anchorIndex: 1, composerOverlayHeight: 180, anchorOffset: 16, + headerSize: 0, }); expect(metrics?.turnHeight).toBe(580); @@ -142,6 +151,7 @@ describe("timeline scroll anchoring", () => { anchorIndex: 1, composerOverlayHeight: 180, anchorOffset: 16, + headerSize: 0, }); expect(metrics?.lastBottom).toBe(1540); @@ -149,6 +159,30 @@ describe("timeline scroll anchoring", () => { expect(metrics?.scrollDeltaToRevealEnd).toBe(76); }); + it("reveals the turn end in scroll offsets, which include the list header", () => { + // Rows start 60px into the content. Without the header the reveal lands + // 60px short and the last row's tail sits behind the composer. + const state = buildState({ + positions: [0, 900, 1180], + sizes: [800, 220, 360], + scroll: 900, + scrollLength: 760, + }); + + const metrics = getAnchoredTurnMetrics({ + state, + anchorIndex: 1, + composerOverlayHeight: 180, + anchorOffset: 16, + headerSize: 60, + }); + + expect(metrics?.anchorTop).toBe(960); + expect(metrics?.lastBottom).toBe(1600); + expect(metrics?.turnHeight).toBe(640); + expect(metrics?.scrollDeltaToRevealEnd).toBe(136); + }); + it("subtracts composer height from usable viewport height", () => { const state = buildState({ positions: [0, 300], @@ -161,12 +195,14 @@ describe("timeline scroll anchoring", () => { anchorIndex: 1, composerOverlayHeight: 0, anchorOffset: 16, + headerSize: 0, }); const withComposer = getAnchoredTurnMetrics({ state, anchorIndex: 1, composerOverlayHeight: 220, anchorOffset: 16, + headerSize: 0, }); expect(withoutComposer?.overflowsUsableViewport).toBe(false); diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index dabcd19dc4b5..6787488e22b7 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -5,6 +5,13 @@ export const CHAT_TIMELINE_ANCHOR_OFFSET = 24; export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling"; +/** + * LegendList reports row positions relative to its rows layer, which starts + * `headerSize` into the scrollable content, while `scroll` is a content + * offset. Every comparison between the two below adds the header back in; + * leaving it out lands reveal scrolls a header short, with the last row's + * tail behind the composer. + */ export interface TimelineListMeasurementState { readonly data: readonly unknown[]; readonly scroll: number; @@ -47,7 +54,11 @@ export function getRowBottom(state: TimelineListMeasurementState, index: number) */ export function timelineContentOverflowsViewport( state: TimelineListMeasurementState | undefined, - input: { readonly composerInset: number; readonly anchorOffset: number }, + input: { + readonly composerInset: number; + readonly anchorOffset: number; + readonly headerSize: number; + }, ): boolean { if (!state || !state.data || state.data.length === 0) { return false; @@ -61,30 +72,43 @@ export function timelineContentOverflowsViewport( return false; } const visibleScrollLength = Math.max(0, scrollLength - input.composerInset - input.anchorOffset); - return lastBottom > visibleScrollLength; + return input.headerSize + lastBottom > visibleScrollLength; } +/** + * Geometry of the turn that starts at the anchored row, in scroll offsets. + * `targetScrollToRevealEnd` is the offset that places the last row's bottom + * `anchorOffset` above the composer overlay. + */ export function getAnchoredTurnMetrics({ state, anchorIndex, composerOverlayHeight, anchorOffset, + headerSize, }: { readonly state: TimelineListMeasurementState; readonly anchorIndex: number; readonly composerOverlayHeight: number; readonly anchorOffset: number; + readonly headerSize: number; }): AnchoredTurnMetrics | null { if (state.data.length === 0) { return null; } const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1)); - const anchorTop = state.positionAtIndex(boundedAnchorIndex); - const lastBottom = getRowBottom(state, state.data.length - 1); - if (typeof anchorTop !== "number" || !Number.isFinite(anchorTop) || lastBottom === null) { + const anchorRowTop = state.positionAtIndex(boundedAnchorIndex); + const lastRowBottom = getRowBottom(state, state.data.length - 1); + if ( + typeof anchorRowTop !== "number" || + !Number.isFinite(anchorRowTop) || + lastRowBottom === null + ) { return null; } + const anchorTop = headerSize + anchorRowTop; + const lastBottom = headerSize + lastRowBottom; const usableViewportHeight = Math.max( 0,