Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
065a3d3
fix(native-chat): read a queued card's whole text, and keep Edit from…
brennanb2025 Oct 6, 2026
342e2ae
fix(native-chat): keep the full-text hover title on a folded queued card
brennanb2025 Oct 6, 2026
d6bd4b1
fix(native-chat): phone card opens only when it clips; test the compo…
brennanb2025 Oct 6, 2026
3d48be9
fix(mobile): the unseen measuring copy of a queued card takes no touches
brennanb2025 Oct 6, 2026
cf22723
fix(native-chat): open a queued card's full text below its row, at th…
brennanb2025 Oct 6, 2026
774f669
fix(native-chat): the queued card's toggle names the opened text only…
brennanb2025 Oct 7, 2026
ead9880
refactor(native-chat): the queue reads pending prompts itself, keepin…
brennanb2025 Oct 7, 2026
17402f0
Merge remote-tracking branch 'origin/main' into brennanb2025/queued-c…
brennanb2025 Oct 7, 2026
b47690e
Merge remote-tracking branch 'origin/main' into brennanb2025/queued-c…
brennanb2025 Oct 7, 2026
bc47f08
Merge remote-tracking branch 'origin/main' into brennanb2025/queued-c…
brennanb2025 Oct 7, 2026
60b5501
Merge remote-tracking branch 'origin/main' into brennanb2025/queued-c…
brennanb2025 Oct 8, 2026
099c8aa
Merge remote-tracking branch 'origin/main' into brennanb2025/queued-c…
brennanb2025 Oct 8, 2026
9696ce4
Merge remote-tracking branch 'origin/main' into brennanb2025/queued-c…
brennanb2025 Oct 8, 2026
b64e7d6
refactor(native-chat): keep this PR to reading a queued message in fu…
brennanb2025 Oct 8, 2026
2875b88
Merge remote-tracking branch 'origin/main' into brennanb2025/queued-c…
brennanb2025 Oct 8, 2026
1318125
test(native-chat): queue list rig passes the pending-prompt flag main…
brennanb2025 Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 60 additions & 4 deletions mobile/src/session/MobileNativeChatQueuedMessages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { MobileQueuedMessageCard } from './mobile-structured-queued-message

vi.mock('react-native', () => ({
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
Expand Down Expand Up @@ -60,9 +61,10 @@ describe('MobileNativeChatQueuedMessages', () => {
return mounted
}

/** What a reader sees: the unseen copy that measures the card's text is left out. */
function texts(mounted: ReactTestRenderer): unknown[] {
return mounted.root
.findAll((node) => String(node.type) === 'Text')
.findAll((node) => String(node.type) === 'Text' && !node.props.accessibilityElementsHidden)
.map((node) => node.props.children)
}

Expand All @@ -85,13 +87,63 @@ describe('MobileNativeChatQueuedMessages', () => {
expect(texts(mounted)).toEqual(['next', 'Steer'])
// At most two lines: the row has no hover title to read the rest from.
expect(
mounted.root.find((node) => String(node.type) === 'Text' && node.props.children === 'next')
.props.numberOfLines
mounted.root.find(
(node) =>
String(node.type) === 'Text' &&
node.props.children === 'next' &&
!node.props.accessibilityElementsHidden
).props.numberOfLines
).toBe(2)
expect(nodeTypes(rows[0]!)).toContain('ListEnd')
expect(nodeTypes(rows[0]!)).not.toContain('AlertCircle')
})

// The hidden copy lays the whole text out at the card's width; its line count says if it clips.
async function layOut(mounted: ReactTestRenderer, text: string, lines: number): Promise<void> {
const measure = mounted.root.find(
(node) =>
String(node.type) === 'Text' &&
node.props.children === text &&
typeof node.props.onTextLayout === 'function'
)
await act(async () =>
measure.props.onTextLayout({ nativeEvent: { lines: Array.from({ length: lines }) } })
)
}

it('a clipped card opens to the whole message on tap, line breaks kept, and a second tap folds it', async () => {
const text = 'You have 1 orchestration message.\nRun `orca orchestration check --run run_e99`'
const mounted = await mount({ cards: [card({ messageId: 'mail', text })] })
await layOut(mounted, text, 5)
const body = () =>
mounted.root.find(
(node) =>
String(node.type) === 'Text' &&
node.props.children === text &&
node.props.onTextLayout === undefined
)
const toggle = () => mounted.root.findByProps({ testID: 'queued-card-text' })
expect(body().props.numberOfLines).toBe(2)
expect(toggle().props.accessibilityState).toEqual({ expanded: false })
expect(texts(mounted)).toContain('Show more')
await act(async () => toggle().props.onPress())
expect(body().props.numberOfLines).toBeUndefined()
expect(toggle().props.accessibilityState).toEqual({ expanded: true })
expect(texts(mounted)).toContain('Show less')
// A long message scrolls inside a capped box rather than pushing the composer away.
const scroll = mounted.root.find((node) => String(node.type) === 'ScrollView')
expect(flatStyle(scroll.props.style).maxHeight).toBeGreaterThan(0)
await act(async () => toggle().props.onPress())
expect(body().props.numberOfLines).toBe(2)
})

it('a card whose text fits its two lines is plain text, not a button', async () => {
const mounted = await mount({ cards: [card({ messageId: 'short', text: 'ok' })] })
await layOut(mounted, 'ok', 1)
expect(mounted.root.findAllByProps({ testID: 'queued-card-text' })).toHaveLength(0)
expect(texts(mounted)).not.toContain('Show more')
})

it('divides rows with hairlines inside one box, the first row undivided', async () => {
const mounted = await mount({ cards: [card({ messageId: 'a' }), card({ messageId: 'b' })] })
const rows = mounted.root.findAllByProps({ testID: 'queued-card-row' })
Expand Down Expand Up @@ -422,8 +474,12 @@ describe('MobileNativeChatQueuedMessages', () => {
onDelete: vi.fn(async () => true),
onEdit: vi.fn(async () => true)
})
// The card's text is its own tap target, sized by the text it shows.
const buttons = mounted.root.findAll(
(node) => typeof node.type === 'string' && node.props.accessibilityRole === 'button'
(node) =>
typeof node.type === 'string' &&
node.props.accessibilityRole === 'button' &&
node.props.testID !== 'queued-card-text'
)
expect(buttons.map((button) => button.props.accessibilityLabel)).toEqual([
'Resume sending the queued messages',
Expand Down
71 changes: 66 additions & 5 deletions mobile/src/session/MobileNativeChatQueuedMessages.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import {
AlertCircle,
CornerDownRight,
Expand Down Expand Up @@ -128,10 +128,7 @@ export function MobileNativeChatQueuedMessages({
{card.attribution}
</Text>
) : null}
{/* Two lines, not the desktop's one: the phone row has no hover title to read the rest. */}
<Text style={styles.body} numberOfLines={2}>
{card.text}
</Text>
<MobileQueuedCardText text={card.text} />
{card.caption ? (
// A returned card's reason only reads whole, often at its end; a hold is one line.
<Text
Expand Down Expand Up @@ -227,6 +224,61 @@ export function MobileNativeChatQueuedMessages({
)
}

/** Two lines, opening on tap when it clips: a card can hold another agent's message the person
* never read, and Steer or Delete must not be a blind choice. Opened, a long one scrolls in a
* capped box. */
function MobileQueuedCardText({ text }: { text: string }): React.JSX.Element {
const [expanded, setExpanded] = useState(false)
const [clipped, setClipped] = useState(false)
// The whole text laid out unseen at the card's width: more than two lines is what clips.
const measure = (
<Text
style={[styles.body, styles.measure]}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
onTextLayout={(event) => setClipped(event.nativeEvent.lines.length > 2)}
>
{text}
</Text>
)
if (!clipped && !expanded) {
return (
<View>
{measure}
<Text style={styles.body} numberOfLines={2}>
{text}
</Text>
</View>
)
}
const toggle = (
<Pressable
testID="queued-card-text"
accessibilityRole="button"
accessibilityState={{ expanded }}
accessibilityHint={expanded ? 'Shows less of the message' : 'Shows the whole message'}
onPress={() => setExpanded(!expanded)}
>
<Text style={styles.body} numberOfLines={expanded ? undefined : 2}>
{text}
</Text>
<Text style={styles.caption}>{expanded ? 'Show less' : 'Show more'}</Text>
</Pressable>
)
// The press sits inside the scroll, so a drag scrolls and only a tap folds it.
return expanded ? (
<ScrollView style={styles.expandedBody} nestedScrollEnabled>
{toggle}
</ScrollView>
) : (
<View>
{measure}
{toggle}
</View>
)
}

// Every action touches as a 44pt target (platform floor) inside its row: Android drops touches
// outside the parent, so the row is at least that tall and nothing overhangs it.
const MIN_TOUCH_TARGET = 44
Expand Down Expand Up @@ -269,6 +321,15 @@ const styles = StyleSheet.create({
color: colors.textPrimary,
fontSize: typography.bodySize
},
expandedBody: {
maxHeight: 240
},
measure: {
position: 'absolute',
left: 0,
right: 0,
opacity: 0
},
caption: {
color: colors.textMuted,
fontSize: typography.metaSize
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useCallback, useRef, useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
Expand All @@ -8,6 +7,7 @@ import {
} from '../../../../shared/native-chat-ask-row'
import { useNativeChatDisclosure } from './native-chat-disclosure-store'
import { NativeChatToolRunIcon } from './NativeChatToolIcon'
import { useNativeChatClippedLine } from './use-native-chat-clipped-line'

const ROW_CLASS_NAME =
'flex min-h-6 w-full items-center gap-1.5 py-0.5 text-left text-sm leading-relaxed text-muted-foreground'
Expand Down Expand Up @@ -50,23 +50,7 @@ export function NativeChatAwaitingInputRow({
)
// Seeded from `open`: a row remounted open was clipped when the reader opened
// it, and dropping the toggle as it folds would drop keyboard focus with it.
const [clipped, setClipped] = useState(open)
const observerRef = useRef<ResizeObserver | null>(null)
// Attached to the question only while it sits on the line; an open row keeps its verdict.
const measureLine = useCallback((line: HTMLSpanElement | null) => {
observerRef.current?.disconnect()
observerRef.current = null
if (!line) {
return
}
const measure = (): void => setClipped(line.scrollWidth > line.clientWidth)
measure()
if (typeof ResizeObserver === 'undefined') {
return
}
observerRef.current = new ResizeObserver(measure)
observerRef.current.observe(line)
}, [])
const [clipped, measureLine] = useNativeChatClippedLine(open)

const question = subject?.kind === 'question' ? subject.text : null
const questions = subject?.kind === 'questions' && listsQuestions ? subject.questions : null
Expand Down
Loading
Loading