fix(native-chat): a dot between the queue header's label and its count (#29268)

* fix(native-chat): a dot between the queue header's label and its count

* style(mobile): wrap the queue header dot's props

* refactor(mobile): move the queued card's text into its own file

The queue file sat at the 400-line cap, so the header dot had no room.
This commit is contained in:
Brennan Benson
2026-10-16 18:39:08 -07:00
committed by GitHub
parent fd1c6a592c
commit 07558ba8ce
4 changed files with 92 additions and 73 deletions
@@ -1,5 +1,5 @@
import { useRef, useState } from 'react'
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import {
AlertCircle,
ChevronDown,
@@ -17,6 +17,7 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { MobileQueuedMessageCard } from './mobile-structured-queued-message-cards'
import type { MobileQueuePause } from './mobile-structured-queued-message-feed'
import type { MobileQueuedMessageEdit } from './use-mobile-structured-queued-message-controls'
import { MobileQueuedCardText } from './MobileQueuedCardText'
/** The header's in-flight keys beside the cards' message ids, which never contain NUL. */
const RESUME_KEY = '\u0000resume'
@@ -99,6 +100,13 @@ export function MobileNativeChatQueuedMessages({
<Text style={[styles.headerLabel, pause && styles.headerLabelPaused]}>
{pause ? 'Paused' : 'Queued'}
</Text>
<Text
style={styles.headerLabel}
importantForAccessibility="no"
accessibilityElementsHidden
>
·
</Text>
<Text style={headerCountStyle} numberOfLines={1}>
{attentionCaption ? `${cards.length} · ${attentionCaption}` : cards.length}
</Text>
@@ -273,61 +281,6 @@ 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
@@ -378,19 +331,6 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
gap: 2
},
body: {
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
@@ -0,0 +1,78 @@
import { useState } from 'react'
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { colors, typography } from '../theme/mobile-theme'
/** 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. */
export 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>
)
}
const styles = StyleSheet.create({
body: {
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
}
})
@@ -504,7 +504,7 @@ describe('NativeChatQueuedMessageList', () => {
it('a paused queue holds every card, in order, under one header with Clear all and Resume', () => {
renderHeldQueue([waitingDraft('a', 1), waitingDraft('b', 2)], { reason: 'stopped' })
const header = screen.getByRole('list').previousElementSibling!
expect(header.textContent).toBe('Paused2Clear allResume')
expect(header.textContent).toBe('Paused·2Clear allResume')
expect(screen.getAllByRole('button', { name: 'Steer' })).toHaveLength(2)
})
@@ -518,7 +518,7 @@ describe('NativeChatQueuedMessageList', () => {
it('a queue that drains on its own (or after a restart) reads Queued, with no Resume; no cards, nothing', () => {
renderHeldQueue([waitingDraft('waiting', 1)], null)
expect(screen.getByRole('list').previousElementSibling?.textContent).toBe('Queued1')
expect(screen.getByRole('list').previousElementSibling?.textContent).toBe('Queued·1')
expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Clear all' })).toBeNull()
cleanup()
@@ -538,7 +538,7 @@ describe('NativeChatQueuedMessageList', () => {
stopped
)
expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull()
expect(container.textContent).toContain('Queued3')
expect(container.textContent).toContain('Queued·3')
})
it('Resume shows it is pending and hands focus back', async () => {
@@ -622,7 +622,7 @@ describe('NativeChatQueuedMessageList', () => {
)
// The header is the box's first row, outside the list; the list is its second.
expect(box.children).toHaveLength(2)
expect(box.firstElementChild?.textContent).toContain('Paused3')
expect(box.firstElementChild?.textContent).toContain('Paused·3')
expect(box.lastElementChild).toBe(list)
expect(list.className.split(' ')).toContain('divide-y')
const rows = within(list).getAllByRole('listitem')
@@ -198,6 +198,7 @@ function NativeChatQueueHeader({
? translate('components.native-chat.queuedMessages.paused', 'Paused')
: translate('components.native-chat.queuedMessages.queued', 'Queued')}
</p>
<span aria-hidden>·</span>
<span className={cn('tabular-nums', attentionCaption ? 'shrink-0' : 'min-w-0 flex-1')}>
{count}
</span>