From a78347e1fbe46ceff6c0b58451ab280488e6b90a Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 30 Oct 2026 17:46:41 -0700 Subject: [PATCH 1/2] fix(mobile): native chat shows a streaming reply token by token The live text was read from the snapshot inside renderItem, but LegendList re-renders a cell only when its data or extraData changes. The rows do not change while a turn streams, so the reply showed only when the turn ended. Each row now carries its text in the list data, and a row keeps the same entry until its text changes, so only the streaming row re-renders. The data change also lets maintainScrollAtEnd follow the stream while the reader is at the bottom. --- .../ChatSessionView/ChatSessionView.tsx | 29 ++++++++++++------- .../utils/withRowText/index.ts | 1 + .../utils/withRowText/withRowText.test.ts | 22 ++++++++++++++ .../utils/withRowText/withRowText.ts | 26 +++++++++++++++++ 4 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/screens/(authenticated)/workspace/[id]/components/ChatSessionView/utils/withRowText/index.ts create mode 100644 apps/mobile/screens/(authenticated)/workspace/[id]/components/ChatSessionView/utils/withRowText/withRowText.test.ts create mode 100644 apps/mobile/screens/(authenticated)/workspace/[id]/components/ChatSessionView/utils/withRowText/withRowText.ts diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/components/ChatSessionView/ChatSessionView.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/components/ChatSessionView/ChatSessionView.tsx index c9dcdb1a919..c885b24a5a4 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/components/ChatSessionView/ChatSessionView.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/components/ChatSessionView/ChatSessionView.tsx @@ -55,6 +55,7 @@ import { DOCK_CHIP_HEIGHT, DockChip } from "./components/DockChip"; import { ScrollToBottom } from "./components/ScrollToBottom"; import { StickToBottom } from "./components/StickToBottom"; import { useTapHandlers } from "./hooks/useTapHandlers"; +import { type TextRow, withRowText } from "./utils/withRowText"; const CONFIG_OPTIONS_GRACE_MS = 1000; const BANNER_SPACE = 36; @@ -139,6 +140,20 @@ export const ChatSessionView = forwardRef< () => groupActivity(chatRows(groups, chat.outbox)), [groups, chat.outbox], ); + const [textRowCache] = useState(() => new WeakMap()); + const textRows = useMemo( + () => + withRowText( + rows, + (row) => + row.kind === "item" && + (row.item.kind === "agent_message" || row.item.kind === "reasoning") + ? displayText(chat.snapshot, row.item.id) + : "", + textRowCache, + ), + [rows, chat.snapshot, textRowCache], + ); const positions = useMemo(() => groupPositions(rows), [rows]); const replyEnds = useMemo(() => lastReplyKeys(rows), [rows]); const [dockHeight, setDockHeight] = useState(0); @@ -367,7 +382,7 @@ export const ChatSessionView = forwardRef< }, [openActivityKey, rows, chat.snapshot]); const renderRow = useCallback( - ({ item: row, index }: { item: ChatRow; index: number }) => { + ({ item: { row, text }, index }: { item: TextRow; index: number }) => { const position = positions[index] ?? "single"; const parent = row.kind === "item" @@ -390,13 +405,7 @@ export const ChatSessionView = forwardRef< onRetryPrompt={chat.retryPrompt} isLastReply={replyEnds.has(row.key)} row={row} - text={ - row.kind === "item" && - (row.item.kind === "agent_message" || - row.item.kind === "reasoning") - ? displayText(chat.snapshot, row.item.id) - : "" - } + text={text} /> ); @@ -440,8 +449,8 @@ export const ChatSessionView = forwardRef< contentContainerStyle={{ paddingTop: headerHeight + 16 + (banner ? BANNER_SPACE : 0), }} - data={rows} - keyExtractor={(row) => row.key} + data={textRows} + keyExtractor={({ row }) => row.key} ListHeaderComponent={ chat.hasOlder ? ( { + test("gives only the row whose text changed a new entry", () => { + const cache = new WeakMap(); + const texts = new Map([[reply, "Hel"]]); + const textOf = (row: ChatRow) => texts.get(row) ?? ""; + + const first = withRowText([working, reply], textOf, cache); + texts.set(reply, "Hello"); + const second = withRowText([working, reply], textOf, cache); + + expect(second[0]).toBe(first[0]); + expect(second[1]).not.toBe(first[1]); + expect(second[1]).toEqual({ row: reply, text: "Hello" }); + }); +}); diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/components/ChatSessionView/utils/withRowText/withRowText.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/components/ChatSessionView/utils/withRowText/withRowText.ts new file mode 100644 index 00000000000..9c56184c192 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/components/ChatSessionView/utils/withRowText/withRowText.ts @@ -0,0 +1,26 @@ +import type { ChatRow } from "../../../../utils/chatRows"; + +export interface TextRow { + row: ChatRow; + text: string; +} + +/** + * Pairs each row with its text. A row keeps its entry until its text changes, + * so a list that re-renders a cell only when its data changes updates just + * the streaming row. + */ +export function withRowText( + rows: readonly ChatRow[], + textOf: (row: ChatRow) => string, + previous: WeakMap, +): TextRow[] { + return rows.map((row) => { + const text = textOf(row); + const kept = previous.get(row); + if (kept?.text === text) return kept; + const entry = { row, text }; + previous.set(row, entry); + return entry; + }); +} From 0f78a945bb600128295fca98921bc45791331e89 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 30 Oct 2026 17:46:41 -0700 Subject: [PATCH 2/2] fix(mobile): chat stops following the stream while the reader drags Every streamed chunk is now a data change, and maintainScrollAtEnd scrolled to the end on each one while the reader was within its threshold, so a slow drag up was pulled back down. Following is off from drag start to drag end. --- .../components/ai-elements/conversation.tsx | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/mobile/components/ai-elements/conversation.tsx b/apps/mobile/components/ai-elements/conversation.tsx index bce0771c58f..cc6b8231d87 100644 --- a/apps/mobile/components/ai-elements/conversation.tsx +++ b/apps/mobile/components/ai-elements/conversation.tsx @@ -24,6 +24,10 @@ import { Text } from "@/components/ui/text"; import { cn } from "@/lib/utils"; const AT_BOTTOM_THRESHOLD = 48; +const MAINTAIN_SCROLL_AT_END = { + animated: false, + on: { dataChange: true, layout: true }, +}; interface ConversationContextType { isAtBottom: boolean; @@ -60,11 +64,14 @@ export const Conversation = ({ contentContainerClassName, contentContainerStyle, onScroll, + onScrollBeginDrag, + onScrollEndDrag, children, ...listProps }: ConversationProps) => { const listRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); + const [isDragging, setIsDragging] = useState(false); const resolvedContentContainerStyle = useResolveClassNames( contentContainerClassName ?? "", ); @@ -81,6 +88,22 @@ export const Conversation = ({ [onScroll], ); + const handleScrollBeginDrag = useCallback( + (event: NativeSyntheticEvent) => { + setIsDragging(true); + onScrollBeginDrag?.(event); + }, + [onScrollBeginDrag], + ); + + const handleScrollEndDrag = useCallback( + (event: NativeSyntheticEvent) => { + setIsDragging(false); + onScrollEndDrag?.(event); + }, + [onScrollEndDrag], + ); + const scrollToBottom = useCallback(() => { listRef.current?.scrollToEnd({ animated: true }); }, []); @@ -105,11 +128,9 @@ export const Conversation = ({ // makes expanding a collapsible (tool card) yank the whole list up, // so the chevron the user just tapped jumps away. Streaming still // sticks to bottom via `dataChange` (every chunk replaces the data - // array) and `layout` covers keyboard/list resizes. - maintainScrollAtEnd={{ - animated: false, - on: { dataChange: true, layout: true }, - }} + // array) and `layout` covers keyboard/list resizes. Off while a + // finger drags, or each chunk pulls the reader back down. + maintainScrollAtEnd={isDragging ? false : MAINTAIN_SCROLL_AT_END} maintainScrollAtEndThreshold={0.15} recycleItems={false} renderItem={renderItem} @@ -120,6 +141,8 @@ export const Conversation = ({ : contentContainerStyle } onScroll={handleScroll} + onScrollBeginDrag={handleScrollBeginDrag} + onScrollEndDrag={handleScrollEndDrag} ref={listRef} /> {children}