From 4b4804a885d40c3252c46af15862a6678d260a76 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 24 Jun 2026 11:46:07 -0700 Subject: [PATCH 01/10] upgrade Legend List chat scrolling --- apps/mobile/package.json | 4 +- .../src/features/threads/ThreadComposer.tsx | 7 +- .../features/threads/ThreadDetailScreen.tsx | 67 +++++--- .../src/features/threads/ThreadFeed.tsx | 155 +++++++----------- apps/mobile/src/lib/threadFeedLayout.test.ts | 32 ---- apps/mobile/src/lib/threadFeedLayout.ts | 22 --- .../src/state/use-thread-composer-state.ts | 9 +- apps/web/package.json | 2 +- .../BranchToolbarBranchSelector.tsx | 11 +- apps/web/src/components/ChatView.tsx | 112 +++++++++---- apps/web/src/components/chat/ChatComposer.tsx | 26 +-- .../components/chat/MessagesTimeline.test.tsx | 34 +++- .../src/components/chat/MessagesTimeline.tsx | 39 ++--- .../components/chat/ModelPickerContent.tsx | 2 +- packages/shared/package.json | 4 + packages/shared/src/chatList.test.ts | 36 ++++ packages/shared/src/chatList.ts | 31 ++++ pnpm-lock.yaml | 26 +-- 18 files changed, 341 insertions(+), 278 deletions(-) delete mode 100644 apps/mobile/src/lib/threadFeedLayout.test.ts delete mode 100644 apps/mobile/src/lib/threadFeedLayout.ts create mode 100644 packages/shared/src/chatList.test.ts create mode 100644 packages/shared/src/chatList.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ddf5b2a0250..99b56ea58e3 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -44,7 +44,7 @@ "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/ui": "~56.0.8", - "@legendapp/list": "3.0.0-beta.44", + "@legendapp/list": "3.2.0", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", @@ -93,7 +93,7 @@ "react-native": "0.85.3", "react-native-gesture-handler": "~2.31.1", "react-native-image-viewing": "^0.2.2", - "react-native-keyboard-controller": "1.21.6", + "react-native-keyboard-controller": "1.21.7", "react-native-nitro-markdown": "^0.5.0", "react-native-nitro-modules": "^0.35.4", "react-native-reanimated": "4.3.1", diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 0050eb923be..75991cae885 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,6 +1,7 @@ import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import type { EnvironmentId, + MessageId, ModelSelection, OrchestrationThreadShell, ProviderInteractionMode, @@ -91,7 +92,7 @@ export interface ThreadComposerProps { readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; - readonly onSendMessage: () => Promise; + readonly onSendMessage: () => Promise; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; @@ -447,9 +448,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; const handleSend = useCallback(() => { - void onSendMessage().then(() => { - inputRef.current?.blur(); - }); + void onSendMessage(); }, [onSendMessage]); const handleCommandSelect = useCallback( (item: ComposerCommandItem) => { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 624e8fe14fe..c255992ab2f 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,7 +1,10 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; +import type { LegendListRef } from "@legendapp/list/react-native"; import type { ApprovalRequestId, EnvironmentId, + MessageId, ModelSelection, OrchestrationThreadShell, ProviderApprovalDecision, @@ -14,7 +17,7 @@ import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native"; +import { View, type GestureResponderEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -25,7 +28,6 @@ import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import type { LayoutVariant } from "../../lib/layout"; -import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout"; import type { PendingApproval, PendingUserInput, @@ -73,7 +75,7 @@ export interface ThreadDetailScreenProps { readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; - readonly onSendMessage: () => Promise; + readonly onSendMessage: () => Promise; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; @@ -206,25 +208,28 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const insets = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; - const composerRef = useRef(null); + const composerEditorRef = useRef(null); + const composerOverlayRef = useRef(null); + const listRef = useRef(null); const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); const [composerExpanded, setComposerExpanded] = useState(false); + const [anchorMessageId, setAnchorMessageId] = useState(null); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; - const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; - const [measuredOverlayHeight, setMeasuredOverlayHeight] = useState(0); + const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight + 8; + const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset( + listRef, + composerOverlayRef, + estimatedOverlayHeight, + ); + const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); - const feedBottomInset = resolveThreadFeedBottomInset({ - estimatedOverlayHeight, - measuredOverlayHeight, - gap: 8, - }); const selectedProviderSkills = useMemo( () => props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) @@ -254,15 +259,28 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread [completeDrawerGesture, isSplitLayout], ); - const handleOverlayLayout = useCallback((event: LayoutChangeEvent) => { - const nextHeight = Math.ceil(event.nativeEvent.layout.height); - setMeasuredOverlayHeight((current) => - Math.abs(current - nextHeight) > 1 ? nextHeight : current, - ); - }, []); + useEffect(() => { + setAnchorMessageId(null); + }, [props.selectedThread.id]); + + const handleSendMessage = useCallback(async () => { + const messageId = await props.onSendMessage(); + if (messageId === null) { + return null; + } + + setAnchorMessageId(messageId); + try { + await scrollMessageToEnd({ animated: true, closeKeyboard: true }); + } catch { + freeze.set(false); + composerEditorRef.current?.blur(); + } + return messageId; + }, [freeze, props.onSendMessage, scrollMessageToEnd]); const collapseComposer = useCallback(() => { - composerRef.current?.blur(); + composerEditorRef.current?.blur(); }, []); const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { @@ -315,10 +333,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread contentPresentation={props.contentPresentation} agentLabel={agentLabel} latestTurn={props.selectedThread.latestTurn} + listRef={listRef} + freeze={freeze} + anchorMessageId={anchorMessageId} + contentInsetEndAdjustment={contentInsetEndAdjustment} contentTopInset={headerHeight} - contentBottomInset={feedBottomInset} + contentBottomInset={estimatedOverlayHeight} layoutVariant={layoutVariant} - composerExpanded={composerExpanded} skills={selectedProviderSkills} /> @@ -332,7 +353,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} offset={{ closed: 0, opened: 0 }} > - + {props.activeWorkStartedAt ? ( ) : null} @@ -361,7 +382,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : null} ; + readonly freeze: SharedValue; + readonly anchorMessageId: MessageId | null; + readonly contentInsetEndAdjustment: SharedValue; readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly layoutVariant?: LayoutVariant; - readonly composerExpanded?: boolean; readonly skills?: ReadonlyArray; } @@ -1122,17 +1132,12 @@ function ThreadFeedPlaceholder(props: { export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const router = useRouter(); - const listRef = useRef(null); const copyFeedbackTimeoutRef = useRef | null>(null); - const scrollFrameRef = useRef(null); const foldSettleFrameRef = useRef(null); const foldSettleSecondFrameRef = useRef(null); - const suppressAutoFollowRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); - const isNearEndRef = useRef(true); - const initialScrollReadyRef = useRef(false); - const lastContentHeightRef = useRef(0); const { width: viewportWidth } = useWindowDimensions(); + const [foldToggleSettling, setFoldToggleSettling] = useState(false); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -1214,6 +1219,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds), [expandedTurnIds, props.feed, props.latestTurn], ); + const anchoredEndSpace = useMemo( + () => + resolveChatListAnchoredEndSpace(presentedFeed, props.anchorMessageId, (entry) => + entry.type === "message" || entry.type === "queued-message" ? entry.id : null, + ), + [presentedFeed, props.anchorMessageId], + ); const terminalAssistantMessageIds = useMemo(() => { const terminalIdsByTurn = new Map(); for (const entry of props.feed) { @@ -1229,54 +1241,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? props.latestTurn.turnId : null; - const scrollToEnd = useCallback(() => { - if (scrollFrameRef.current !== null) { - return; - } - scrollFrameRef.current = requestAnimationFrame(() => { - scrollFrameRef.current = null; - listRef.current?.scrollToEnd({ animated: false }); - }); - }, []); - - const onListScroll = useCallback( - (event: NativeSyntheticEvent | NativeScrollEvent) => { - const scrollEvent = "nativeEvent" in event ? event.nativeEvent : event; - const { contentInset, contentOffset, contentSize, layoutMeasurement } = scrollEvent; - isNearEndRef.current = isThreadFeedNearEnd( - { - contentHeight: contentSize.height, - viewportHeight: layoutMeasurement.height, - offsetY: contentOffset.y, - bottomInset: contentInset.bottom, - }, - THREAD_FEED_END_THRESHOLD, - ); - }, - [], - ); - - const onListContentSizeChange = useCallback( - (_width: number, height: number) => { - const contentGrew = height > lastContentHeightRef.current + 0.5; - lastContentHeightRef.current = height; - - if ( - initialScrollReadyRef.current && - contentGrew && - isNearEndRef.current && - !suppressAutoFollowRef.current - ) { - scrollToEnd(); - } - }, - [scrollToEnd], - ); - - const onListLoad = useCallback(() => { - initialScrollReadyRef.current = true; - }, []); - useEffect(() => { const previous = previousLatestTurnRef.current; previousLatestTurnRef.current = props.latestTurn; @@ -1308,9 +1272,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } - if (scrollFrameRef.current !== null) { - cancelAnimationFrame(scrollFrameRef.current); - } if (foldSettleFrameRef.current !== null) { cancelAnimationFrame(foldSettleFrameRef.current); } @@ -1358,7 +1319,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, []); const onToggleTurnFold = useCallback((turnId: TurnId) => { - suppressAutoFollowRef.current = true; + setFoldToggleSettling(true); if (foldSettleFrameRef.current !== null) { cancelAnimationFrame(foldSettleFrameRef.current); } @@ -1376,7 +1337,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); foldSettleFrameRef.current = requestAnimationFrame(() => { foldSettleSecondFrameRef.current = requestAnimationFrame(() => { - suppressAutoFollowRef.current = false; + setFoldToggleSettling(false); foldSettleFrameRef.current = null; foldSettleSecondFrameRef.current = null; }); @@ -1458,59 +1419,61 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); } - if (props.feed.length === 0) { - return ( - - ); - } - return ( <> - `${entry.type}:${entry.id}`} + keyExtractor={(entry) => entry.id} getItemType={(entry) => entry.type === "message" ? `message:${entry.message.role}` : entry.type } keyboardShouldPersistTaps="always" keyboardDismissMode="none" + keyboardLiftBehavior="whenAtEnd" estimatedItemSize={180} initialScrollAtEnd - onContentSizeChange={onListContentSizeChange} - onLoad={onListLoad} - onScroll={onListScroll} - scrollEventThrottle={16} ListHeaderComponent={} contentContainerStyle={{ paddingTop: 12, - paddingBottom: bottomContentInset, paddingHorizontal: horizontalPadding, }} /> + {props.feed.length === 0 ? ( + + + + ) : null} { - it("accounts for the bottom inset when measuring distance from the end", () => { - const metrics = { - contentHeight: 900, - viewportHeight: 600, - offsetY: 380, - bottomInset: 100, - }; - - expect(threadFeedDistanceFromEnd(metrics)).toBe(20); - expect(isThreadFeedNearEnd(metrics, 50)).toBe(true); - expect(isThreadFeedNearEnd(metrics, 10)).toBe(false); - }); - - it("does not double count chrome already included in the measured composer overlay", () => { - expect( - resolveThreadFeedBottomInset({ - estimatedOverlayHeight: 162, - measuredOverlayHeight: 182, - gap: 8, - }), - ).toBe(190); - }); -}); diff --git a/apps/mobile/src/lib/threadFeedLayout.ts b/apps/mobile/src/lib/threadFeedLayout.ts deleted file mode 100644 index de7946f866d..00000000000 --- a/apps/mobile/src/lib/threadFeedLayout.ts +++ /dev/null @@ -1,22 +0,0 @@ -export interface ThreadFeedScrollMetrics { - readonly contentHeight: number; - readonly viewportHeight: number; - readonly offsetY: number; - readonly bottomInset: number; -} - -export function threadFeedDistanceFromEnd(metrics: ThreadFeedScrollMetrics): number { - return metrics.contentHeight + metrics.bottomInset - metrics.viewportHeight - metrics.offsetY; -} - -export function isThreadFeedNearEnd(metrics: ThreadFeedScrollMetrics, threshold: number): boolean { - return threadFeedDistanceFromEnd(metrics) <= threshold; -} - -export function resolveThreadFeedBottomInset(input: { - readonly estimatedOverlayHeight: number; - readonly measuredOverlayHeight: number; - readonly gap: number; -}): number { - return Math.max(input.estimatedOverlayHeight, input.measuredOverlayHeight) + input.gap; -} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 0b8cba16e16..9e73aa552b7 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -150,7 +150,7 @@ export function useThreadComposerState() { const onSendMessage = useCallback(async () => { if (!selectedThreadShell) { - return; + return null; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); @@ -159,15 +159,16 @@ export function useThreadComposerState() { const text = draft.text.trim(); const attachments = draft.attachments; if (text.length === 0 && attachments.length === 0) { - return; + return null; } const metadata = makeQueuedMessageMetadata(); + const messageId = MessageId.make(metadata.messageId); try { await enqueueThreadOutboxMessage({ environmentId: selectedThreadShell.environmentId, threadId: selectedThreadShell.id, - messageId: MessageId.make(metadata.messageId), + messageId, commandId: CommandId.make(metadata.commandId), text, attachments, @@ -177,10 +178,12 @@ export function useThreadComposerState() { createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey); + return messageId; } catch (error) { setPendingConnectionError( error instanceof Error ? error.message : "Failed to save the queued message.", ); + return null; } }, [selectedThreadDetail, selectedThreadShell]); diff --git a/apps/web/package.json b/apps/web/package.json index 632e2d14395..484422f1a51 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,7 @@ "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@formkit/auto-animate": "^0.9.0", - "@legendapp/list": "3.0.0-beta.44", + "@legendapp/list": "3.2.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 7798f38e43e..e2ee24c3608 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -518,7 +518,7 @@ export function BranchToolbarBranchSelector({ return; } - branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); + void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); useEffect(() => { @@ -628,7 +628,7 @@ export function BranchToolbarBranchSelector({ if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { return; } - branchListRef.current?.scrollIndexIntoView?.({ + void branchListRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false, }); @@ -696,6 +696,13 @@ export function BranchToolbarBranchSelector({ ref={branchListRef} data={filteredBranchPickerItems} keyExtractor={(item) => item} + getItemType={(item) => + item === checkoutPullRequestItemValue + ? "checkout-pull-request" + : item === createBranchItemValue + ? "create-branch" + : "branch" + } renderItem={({ item, index }) => renderPickerItem(item, index)} estimatedItemSize={28} drawDistance={336} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 44429614b44..da37ebab9ce 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -41,7 +41,17 @@ import { truncate } from "@t3tools/shared/String"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; -import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + lazy, + memo, + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { @@ -1088,6 +1098,7 @@ function ChatViewContent(props: ChatViewProps) { const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + const [timelineAnchorMessageId, setTimelineAnchorMessageId] = useState(null); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< @@ -1137,12 +1148,33 @@ function ChatViewContent(props: ChatViewProps) { LastInvokedScriptByProjectSchema, ); const legendListRef = useRef(null); + const composerOverlayRef = useRef(null); + const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const terminalUiOpenByThreadRef = useRef>({}); + useLayoutEffect(() => { + const composerOverlay = composerOverlayRef.current; + if (!composerOverlay) return; + + const updateHeight = () => { + const nextHeight = Math.ceil(composerOverlay.getBoundingClientRect().height); + setComposerOverlayHeight((currentHeight) => + currentHeight === nextHeight ? currentHeight : nextHeight, + ); + }; + + updateHeight(); + if (typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver(updateHeight); + observer.observe(composerOverlay); + return () => observer.disconnect(); + }, []); + const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); @@ -3111,7 +3143,7 @@ function ChatViewContent(props: ChatViewProps) { // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. const scrollToEnd = useCallback((animated = false) => { - legendListRef.current?.scrollToEnd?.({ animated }); + void legendListRef.current?.scrollToEnd?.({ animated }); }, []); // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during @@ -3133,6 +3165,7 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPullRequestDialogState(null); + setTimelineAnchorMessageId(null); isAtEndRef.current = true; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); @@ -3690,14 +3723,13 @@ function ChatViewContent(props: ChatViewProps) { sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); - // Scroll to the current end *before* adding the optimistic message. - // This sets LegendList's internal isAtEnd=true so maintainScrollAtEnd - // automatically pins to the new item when the data changes. + // 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; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - await legendListRef.current?.scrollToEnd?.({ animated: false }); - + setTimelineAnchorMessageId(messageIdForSend); setOptimisticUserMessages((existing) => [ ...existing, { @@ -3711,6 +3743,7 @@ function ChatViewContent(props: ChatViewProps) { streaming: false, }, ]); + void legendListRef.current?.scrollToEnd?.({ animated: false }); setThreadError(threadIdForSend, null); if (expiredTerminalContextCount > 0) { @@ -4722,7 +4755,7 @@ function ChatViewContent(props: ChatViewProps) { {/* Main content area with optional plan sidebar */}
{/* Chat column */} -
+
{/* Messages Wrapper */}
{/* Messages — LegendList handles virtualization and scrolling internally */} @@ -4747,12 +4780,17 @@ function ChatViewContent(props: ChatViewProps) { timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + anchorMessageId={timelineAnchorMessageId} + contentInsetEndAdjustment={composerOverlayHeight} onIsAtEndChange={onIsAtEndChange} /> {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} {showScrollToBottom && ( -
+