From f4bec5a4cdde15527510a3ca274bdbf792eece59 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:10:37 -0800 Subject: [PATCH] fix(mobile): scroll a failed queued message into view and keep the lit Steer pill's press - A failure that opens the queue scrolls its row into view, so on a short screen the reason the list opened is visible above Show less. - The pressed opacity comes after the lit fill on the Steer pill. - Test the lit pill (orca #40820) and the failure scroll. --- .../MobileNativeChatQueuedMessages.test.tsx | 58 +++++++++++++++++++ .../input/MobileNativeChatQueuedMessages.tsx | 39 +++++++++---- .../input/MobileQueuedMessageCardRow.tsx | 7 ++- .../input/MobileQueuedMessageScrollList.tsx | 47 +++++++++++---- .../input/mobile-queued-row-frame.ts | 26 +++++++++ 5 files changed, 154 insertions(+), 23 deletions(-) create mode 100644 mobile/src/session/native-chat/input/mobile-queued-row-frame.ts diff --git a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx index 0f6bc715790..7a89c93208e 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.test.tsx @@ -10,6 +10,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { MobileNativeChatQueuedMessages } from './MobileNativeChatQueuedMessages' import type { ActionSheetAction } from '../../../components/ActionSheetModal' import { MobileNativeChatQueueAbovePromptContext } from '../mobile-native-chat-lower-region-context' +import { createMobileQueuedHeadLight } from './mobile-queued-head-light' +import { queuedMessageStyles as styles } from './mobile-native-chat-queued-message-styles' +import { pressedStyle } from '../../../theme/mobile-theme' let fontScale = 1 let windowWidth = 390 @@ -307,6 +310,61 @@ describe('MobileNativeChatQueuedMessages', () => { expect(rows(mounted)).toHaveLength(3) }) + it('scrolls a failure that opens a short list into view', async () => { + const scrollTo = vi.fn() + const mounted = create(createElement('View'), { + createNodeMock: (node) => + node.type === 'ScrollView' + ? { scrollTo, scrollToEnd: vi.fn(), flashScrollIndicators: vi.fn() } + : null + }) + renderer = mounted + await act(async () => mounted.update(element({ cards: three() }, true))) + const failedThird = [ + card({ messageId: 'a' }), + card({ messageId: 'b' }), + card({ messageId: 'c', needsAttention: true, caption: "Couldn't send" }) + ] + await act(async () => mounted.update(element({ cards: failedThird }, true))) + expect(rows(mounted)).toHaveLength(3) + // Rows report before the list knows its own size, as when it has just opened. + await act(async () => { + rows(mounted).forEach((row, index) => + row.props.onLayout({ nativeEvent: { layout: { height: index === 2 ? 80 : 60 } } }) + ) + }) + expect(scrollTo).not.toHaveBeenCalled() + const scroll = mounted.root.find((node) => String(node.type) === 'ScrollView') + await act(async () => { + scroll.props.onLayout({ nativeEvent: { layout: { height: 100 } } }) + scroll.props.onContentSizeChange(0, 200) + }) + // The failed row spans 120–200 of a 100pt list, so the list scrolls to its end. + expect(scrollTo).toHaveBeenCalledWith({ y: 100, animated: false }) + }) + + it("fills the head's Steer pill while the composer previews Steer queued", async () => { + const headLight = createMobileQueuedHeadLight() + const mounted = await mount({ cards: three(), agentWorking: true, headLight }) + const pills = () => + mounted.root.findAll( + (node) => + typeof node.type === 'string' && + node.props.accessibilityLabel === 'Submit without interrupting the model' + ) + const label = (pill: ReturnType[number]) => + pill.find((node) => String(node.type) === 'Text').props.style + expect(pills()[0]!.props.style({ pressed: false })).not.toContain(styles.litAction) + await act(async () => headLight.set(true)) + const [head, second] = pills() + expect(head!.props.style({ pressed: false })).toContain(styles.litAction) + expect(label(head!)).toContain(styles.litActionLabel) + // A press still dims the filled pill. + const pressed = head!.props.style({ pressed: true }) + expect(pressed.indexOf(pressedStyle)).toBeGreaterThan(pressed.indexOf(styles.litAction)) + expect(second!.props.style({ pressed: false })).not.toContain(styles.litAction) + }) + it('keeps a folded list folded when a whole new queue replaces it', async () => { const mounted = await mount({ cards: three() }, true) await act(async () => diff --git a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx index ef8e90fa1cf..c679112f1a0 100644 --- a/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/native-chat/input/MobileNativeChatQueuedMessages.tsx @@ -1,7 +1,7 @@ import { MobileNativeChatQueueFrame } from './MobileNativeChatQueueFrame' import { DragReorderList } from '../../../components/DragReorderList' import type { DragReorderMove } from '../../../components/drag-reorder-positions' -import { useContext, useLayoutEffect, useState, type ReactNode } from 'react' +import { useContext, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { Pressable, Text, View, type LayoutRectangle } from 'react-native' import { ChevronDown, ChevronUp, Pencil, Play, Trash2 } from 'lucide-react-native' import { ActionSheetModal } from '../../../components/ActionSheetModal' @@ -22,6 +22,7 @@ import type { MobileQueuedMessageEdit } from './use-mobile-structured-queued-mes import type { MobileQueuedMessageInlineEditor } from './mobile-queued-message-edit-draft' import { MobileQueuedMessageCardRow } from './MobileQueuedMessageCardRow' import { MobileQueuedMessageScrollList } from './MobileQueuedMessageScrollList' +import { queuedRowFrame } from './mobile-queued-row-frame' import { queuedMessageStyles as styles } from './mobile-native-chat-queued-message-styles' import { MobileNativeChatQueueAbovePromptContext, @@ -106,6 +107,11 @@ export function MobileNativeChatQueuedMessages({ const liveKey = `${liveIds.join('\u0000')}\u0001${attentionIds.join('\u0000')}` const [seen, setSeen] = useState({ key: liveKey, ids: liveIds, attentionIds }) const largeText = useLargeSystemText() + // The card a failure just opened the list for, scrolled into view once every row has a height. + const [showId, setShowId] = useState( + () => cards?.slice(1).find((card) => card.needsAttention)?.messageId ?? null + ) + const rowHeights = useRef(new Map()) if (liveKey !== seen.key) { setSeen({ key: liveKey, ids: liveIds, attentionIds }) const newest = cards?.at(-1) @@ -121,12 +127,12 @@ export function MobileNativeChatQueuedMessages({ } // A card that newly needs attention (failed or returned) behind the fold opens the list, // so its own Send is in reach. - if ( - attentionIds.some( - (messageId) => messageId !== liveIds[0] && !seen.attentionIds.includes(messageId) - ) - ) { + const failedBehindFold = attentionIds.find( + (messageId) => messageId !== liveIds[0] && !seen.attentionIds.includes(messageId) + ) + if (failedBehindFold !== undefined) { setOpenChoice({ open: true, abovePrompt }) + setShowId(failedBehindFold) } // Forget expanded ids of cards that left the queue. if ([...expandedIds].some((messageId) => !liveIds.includes(messageId))) { @@ -216,9 +222,18 @@ export function MobileNativeChatQueuedMessages({ : hiddenCount === 1 ? t('chat.queuedMessages.moreCount_one', '{{count}} more', { count: hiddenCount }) : t('chat.queuedMessages.moreCount_other', '{{count}} more', { count: hiddenCount }) + const measureRow = (messageId: string, height: number, show: (row: LayoutRectangle) => void) => { + rowHeights.current.set(messageId, height) + const frame = showAll ? queuedRowFrame(messageIds, rowHeights.current, showId) : null + if (frame) { + setShowId(null) + show(frame) + } + } const renderRow = ( card: MobileQueuedMessageCard, reveal: (row: LayoutRectangle) => void, + show: (row: LayoutRectangle) => void, handle?: ReactNode ) => { const first = card.messageId === cards[0]!.messageId @@ -253,6 +268,7 @@ export function MobileNativeChatQueuedMessages({ onSend={() => void run(card.messageId, onSend)} onMore={() => setMenuFor(card.messageId)} onEditorLayout={reveal} + onRowHeight={(height) => measureRow(card.messageId, height, show)} /> ) } @@ -298,7 +314,7 @@ export function MobileNativeChatQueuedMessages({ newestIsPersons={!cards.at(-1)?.from} inlineEditorActive={editor !== undefined} > - {(reveal, drag) => { + {(reveal, drag, show) => { // Order matters only with two or more cards in view, so the grip shows only then. return onMove && !editor && showAll && cards.length > 1 ? ( busyIds.has(MOVE_KEY) || busyIds.has(card.messageId)} rowPresentation="plain" liftFromRow - renderRow={(card, handle) => renderRow(card, reveal, handle)} + renderRow={(card, handle) => renderRow(card, reveal, show, handle)} onMove={(move) => run(move.movedId, () => onMove(move), [MOVE_KEY])} {...drag} /> ) : ( - shownCards.map((card) => renderRow(card, reveal)) + shownCards.map((card) => renderRow(card, reveal, show)) ) }} @@ -322,7 +338,10 @@ export function MobileNativeChatQueuedMessages({ accessibilityRole="button" accessibilityState={{ expanded: showAll }} style={({ pressed }) => [styles.foldToggle, pressed && styles.pressed]} - onPress={() => setOpenChoice({ open: !showAll, abovePrompt })} + onPress={() => { + setShowId(null) + setOpenChoice({ open: !showAll, abovePrompt }) + }} > void onMore: () => void onEditorLayout: (row: LayoutRectangle) => void + /** The compact row's height, so the list can scroll to a row that needs attention. */ + onRowHeight?: (height: number) => void } export function MobileQueuedMessageCardRow({ @@ -85,6 +87,7 @@ function QueuedMessageCardContents({ onToggleExpanded, onSend, onMore, + onRowHeight, dragHandle }: Omit): React.JSX.Element { useMobileColorScheme() @@ -102,6 +105,7 @@ function QueuedMessageCardContents({ onRowHeight(event.nativeEvent.layout.height))} > {dragHandle} @@ -182,9 +186,10 @@ function QueuedMessageCardContents({ hitSlop={STEER_HIT_SLOP} style={({ pressed }) => [ styles.steerPill, - pressed && pressedStyle, // Filled like the composer's primary while Steer queued would press it. lit && styles.litAction, + // Opacity, so a lit pill still answers a press. + pressed && pressedStyle, (busy || steerHeld) && styles.disabled ]} disabled={busy || steerHeld} diff --git a/mobile/src/session/native-chat/input/MobileQueuedMessageScrollList.tsx b/mobile/src/session/native-chat/input/MobileQueuedMessageScrollList.tsx index b0efea33c37..fa351f25a41 100644 --- a/mobile/src/session/native-chat/input/MobileQueuedMessageScrollList.tsx +++ b/mobile/src/session/native-chat/input/MobileQueuedMessageScrollList.tsx @@ -39,8 +39,12 @@ export function MobileQueuedMessageScrollList({ /** The newest card has no sender: the person just queued it, so the list shows it. */ newestIsPersons: boolean inlineEditorActive?: boolean - /** Rows, given `reveal` to keep an edited row in view. */ - children: (reveal: (row: LayoutRectangle) => void, drag: MobileQueueDragViewport) => ReactNode + /** Rows, given `reveal` to keep an edited row in view and `show` to scroll the list to a row. */ + children: ( + reveal: (row: LayoutRectangle) => void, + drag: MobileQueueDragViewport, + show: (row: LayoutRectangle) => void + ) => ReactNode }): React.JSX.Element { const scrollRef = useAnimatedRef() const narrow = useWindowDimensions().width < QUEUED_ROW_NARROW_WIDTH @@ -67,6 +71,10 @@ export function MobileQueuedMessageScrollList({ const above = m.offset > 1 const below = m.offset + m.viewport < m.content - 1 setCues((prev) => (prev.above === above && prev.below === below ? prev : { above, below })) + const pending = pendingShow.current + if (pending && m.viewport > 0 && m.content >= pending.y + pending.height) { + show(pending) + } if (!flashed.current && m.viewport > 0 && m.content > m.viewport + 1) { flashed.current = true if (Platform.OS === 'ios') { @@ -75,12 +83,15 @@ export function MobileQueuedMessageScrollList({ } } // The nearest scroll that shows the whole row, as desktop's editor does, clear of the edge fades. - const reveal = (row: LayoutRectangle) => { - if (lowerRegion) { - lowerRegion.layoutChanged() + const pendingShow = useRef(null) + const show = (row: LayoutRectangle) => { + const { offset, viewport, content } = metrics.current + // A list that just opened may not have measured itself yet; show the row once it has. + if (viewport === 0 || content < row.y + row.height) { + pendingShow.current = row return } - const { offset, viewport, content } = metrics.current + pendingShow.current = null if (viewport === 0) { return } @@ -94,6 +105,14 @@ export function MobileQueuedMessageScrollList({ scrollRef.current?.scrollTo({ y: bottom - viewport, animated: false }) } } + // An inline editor inside a lower region lets that region scroll instead. + const reveal = (row: LayoutRectangle) => { + if (lowerRegion) { + lowerRegion.layoutChanged() + return + } + show(row) + } return ( // Always shrinkable: under a height limit the list gives up rows (and scrolls) before the box // clips the fold row below it. @@ -117,12 +136,16 @@ export function MobileQueuedMessageScrollList({ measure({ content: height }) }} > - {children(reveal, { - scrollRef, - scrollOffsetY, - scrollContentHeight, - onDragActiveChange: setDragging - })} + {children( + reveal, + { + scrollRef, + scrollOffsetY, + scrollContentHeight, + onDragActiveChange: setDragging + }, + show + )} {cues.above ? : null} {cues.below ? : null} diff --git a/mobile/src/session/native-chat/input/mobile-queued-row-frame.ts b/mobile/src/session/native-chat/input/mobile-queued-row-frame.ts new file mode 100644 index 00000000000..05e76ef41e3 --- /dev/null +++ b/mobile/src/session/native-chat/input/mobile-queued-row-frame.ts @@ -0,0 +1,26 @@ +import type { LayoutRectangle } from 'react-native' + +/** Where a row sits in the queue's scroll content: rows stack in order, so it starts below those before it. */ +export function queuedRowFrame( + messageIds: readonly string[], + heights: ReadonlyMap, + targetId: string | null +): LayoutRectangle | null { + const index = targetId === null ? -1 : messageIds.indexOf(targetId) + if (index === -1) { + return null + } + let top = 0 + for (const [at, messageId] of messageIds.entries()) { + const height = heights.get(messageId) + // Not every row has laid out yet. + if (height === undefined) { + return null + } + if (at === index) { + return { x: 0, y: top, width: 0, height } + } + top += height + } + return null +}