mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user