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.
This commit is contained in:
Jinjing
2026-11-12 10:10:37 -08:00
parent 60abe1579c
commit f4bec5a4cd
5 changed files with 154 additions and 23 deletions
@@ -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<typeof pills>[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 () =>
@@ -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<string | null>(
() => cards?.slice(1).find((card) => card.needsAttention)?.messageId ?? null
)
const rowHeights = useRef(new Map<string, number>())
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 ? (
<DragReorderList
@@ -307,12 +323,12 @@ export function MobileNativeChatQueuedMessages({
isMoveDisabled={(card) => 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))
)
}}
</MobileQueuedMessageScrollList>
@@ -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 })
}}
>
<Text
style={styles.foldLabel}
@@ -49,6 +49,8 @@ type QueuedMessageCardRowProps = {
onSend: () => 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<QueuedMessageCardRowProps, 'editor' | 'onEditorLayout'>): React.JSX.Element {
useMobileColorScheme()
@@ -102,6 +105,7 @@ function QueuedMessageCardContents({
<View
testID="queued-card-row"
style={[styles.card, divided && styles.divided, lit && styles.lit]}
onLayout={onRowHeight && ((event) => onRowHeight(event.nativeEvent.layout.height))}
>
<View style={[styles.row, dragHandle ? styles.rowWithHandle : null]}>
{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}
@@ -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<Animated.ScrollView>()
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<LayoutRectangle | null>(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
)}
</Animated.ScrollView>
{cues.above ? <MobileEdgeFade edge="top" /> : null}
{cues.below ? <MobileEdgeFade edge="bottom" /> : null}
@@ -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<string, number>,
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
}