fix(mobile): a compact phone queue: shorter header, a capped scrolling list, and an editor that fits the card (#30016)

* fix(mobile): a compact queue: shorter header, a capped scrolling list, and an editor that fits the card

* fix(mobile): keep queue actions visible and reveal new cards

* test(mobile): allow shared SVG host fixture

* fix(mobile): queue header and editor controls grow with large system text instead of clipping

* fix(mobile): at large text sizes the queue header's buttons wrap below its label instead of squeezing it out

* fix(mobile): at large text sizes the queued editor's Cancel and Save wrap below its heading instead of truncating it

* fix(mobile): at large text sizes the queue header's and editor's buttons take a line of their own

* fix(mobile): the large-text button lines wrap when one button is all a small phone's line holds

* test(mobile): the composer tray test's react-native stub reports the text scale

* fix(mobile): while a card is edited at large text, the paused header's button line gives its height to the editor
This commit is contained in:
Brennan Benson
2026-10-17 13:47:48 -07:00
committed by GitHub
parent 6287ff6f62
commit 285f95c045
11 changed files with 904 additions and 371 deletions
+8
View File
@@ -89,6 +89,14 @@
"anti-slop/no-module-mocking": "off"
}
},
// Mobile's test-only setup supplies SVG host primitives without loading native Flow code.
// The shared fixture preserves native module boundaries in Node-based render tests.
{
"files": ["**/mobile/vitest.setup.ts"],
"rules": {
"anti-slop/no-module-mocking": "off"
}
},
// The exemptions below are file-scoped rather than inline `oxlint-disable` comments
// because the root lint scan does not load this plugin, so an inline directive naming
// an anti-slop rule always reads back as an unused directive there.
@@ -1,88 +1,171 @@
import { useEffect, useRef } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { Pencil } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { useLargeSystemText } from '../theme/use-large-system-text'
import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'
import type { MobileQueuedMessageInlineEditor } from './use-mobile-structured-queued-edit'
const PENCIL_SIZE = 14
// The composer's own button height.
const EDITOR_ACTION_HEIGHT = 40
/** The editor that replaces a queued card's row: one action line, then the text on the card. Save
* sits on that line rather than under the field, as the keyboard leaves little height. */
export function MobileNativeChatQueuedMessageEditor({
editor
editor,
attribution
}: {
editor: MobileQueuedMessageInlineEditor
/** The card's "From <name>" line, kept above the text. */
attribution: string | null
}): React.JSX.Element {
const input = useRef<TextInput>(null)
useEffect(() => {
input.current?.focus()
}, [])
const saveDisabled = editor.saving || !editor.canSave
const largeText = useLargeSystemText()
const actions = (
<>
<Pressable
accessibilityRole="button"
accessibilityLabel="Cancel editing queued message"
accessibilityState={{ disabled: editor.saving }}
disabled={editor.saving}
style={({ pressed }) => [
styles.cancel,
pressed && styles.pressed,
editor.saving && styles.disabled
]}
onPress={editor.cancel}
>
<Text style={styles.cancelLabel}>Cancel</Text>
</Pressable>
{/* A 40pt target around a smaller pill, so the line stays one button tall. */}
<Pressable
accessibilityRole="button"
accessibilityLabel="Save queued message"
accessibilityState={{ disabled: saveDisabled }}
disabled={saveDisabled}
style={[styles.save, saveDisabled && styles.disabled]}
onPress={() => void editor.save()}
>
{({ pressed }) => (
<View style={[styles.savePill, pressed && styles.savePillPressed]}>
<Text style={styles.saveLabel}>Save</Text>
</View>
)}
</Pressable>
</>
)
return (
<View style={styles.editor} testID="queued-card-editor">
<View style={styles.head}>
<Pencil size={PENCIL_SIZE} color={colors.textMuted} strokeWidth={2} />
<Text style={styles.label}>Editing message</Text>
{largeText ? null : actions}
</View>
{largeText ? <View style={styles.actionsLine}>{actions}</View> : null}
{attribution ? (
<Text style={styles.attribution} numberOfLines={1}>
{attribution}
</Text>
) : null}
<TextInput
ref={input}
accessibilityLabel="Edit queued message"
value={editor.text}
editable={!editor.saving}
multiline
selectionColor={colors.accentBlue}
style={styles.input}
onChangeText={editor.change}
/>
<View style={styles.actions}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Cancel editing queued message"
accessibilityState={{ disabled: editor.saving }}
disabled={editor.saving}
style={({ pressed }) => [
styles.action,
pressed && styles.pressed,
editor.saving && styles.disabled
]}
onPress={editor.cancel}
>
<Text style={styles.label}>Cancel</Text>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel="Save queued message"
accessibilityState={{ disabled: editor.saving || !editor.canSave }}
disabled={editor.saving || !editor.canSave}
style={({ pressed }) => [
styles.action,
styles.save,
pressed && styles.pressed,
(editor.saving || !editor.canSave) && styles.disabled
]}
onPress={() => void editor.save()}
>
<Text style={styles.label}>Save</Text>
</Pressable>
</View>
</View>
)
}
// The text column under the pencil, where the card's text was.
const TEXT_INDENT = PENCIL_SIZE + spacing.sm
const styles = StyleSheet.create({
editor: { gap: spacing.xs },
input: {
minHeight: 44,
maxHeight: 160,
padding: spacing.sm,
color: colors.textPrimary,
fontSize: TEXT_INPUT_FONT_SIZE,
backgroundColor: colors.bgBase,
borderRadius: radii.input,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
editor: {
backgroundColor: colors.bgRaised,
paddingLeft: spacing.md,
paddingRight: spacing.xs,
paddingBottom: spacing.sm
},
actions: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.xs },
action: {
minWidth: 44,
minHeight: 44,
paddingHorizontal: spacing.md,
justifyContent: 'center',
head: {
minHeight: EDITOR_ACTION_HEIGHT,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
// Large text: Cancel and Save on a line of their own, under the heading.
actionsLine: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-end',
gap: spacing.sm
},
label: {
flex: 1,
color: colors.textMuted,
fontSize: typography.metaSize,
lineHeight: typography.metaSize + spacing.xs
},
// minHeight, not height: the action line grows with the system text size instead of clipping.
cancel: {
minHeight: EDITOR_ACTION_HEIGHT,
minWidth: 56,
paddingHorizontal: spacing.sm,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
save: { backgroundColor: colors.bgRaised },
label: { color: colors.textPrimary, fontSize: typography.metaSize, fontWeight: '500' },
cancelLabel: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '500'
},
save: {
minHeight: EDITOR_ACTION_HEIGHT,
justifyContent: 'center'
},
savePill: {
minHeight: spacing.xl + spacing.xs,
paddingHorizontal: spacing.md,
borderRadius: radii.button,
backgroundColor: colors.textPrimary,
alignItems: 'center',
justifyContent: 'center'
},
savePillPressed: {
backgroundColor: colors.textSecondary
},
saveLabel: {
color: colors.bgBase,
fontSize: typography.metaSize,
fontWeight: '600'
},
attribution: {
marginLeft: TEXT_INDENT,
color: colors.textMuted,
fontSize: typography.metaSize,
lineHeight: typography.metaSize + spacing.xs
},
// On the card itself: no fill or border, and no padding above or below the text.
input: {
marginLeft: TEXT_INDENT,
padding: 0,
paddingRight: spacing.md,
maxHeight: 80,
color: colors.textPrimary,
fontSize: TEXT_INPUT_FONT_SIZE,
lineHeight: 20,
textAlignVertical: 'top'
},
pressed: { backgroundColor: colors.bgPanel },
disabled: { opacity: 0.5 }
})
@@ -5,7 +5,10 @@ import type { ActionSheetAction } from '../components/ActionSheetModal'
import { MobileNativeChatQueuedMessages } from './MobileNativeChatQueuedMessages'
import type { MobileQueuedMessageCard } from './mobile-structured-queued-message-cards'
let fontScale = 1
vi.mock('react-native', () => ({
useWindowDimensions: () => ({ fontScale }),
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
@@ -81,7 +84,7 @@ describe('MobileNativeChatQueuedMessages', () => {
return mounted.root.find((node) => String(node.type) === 'ActionSheetModal')
}
it('edits inside the card with Save and Cancel while keeping another card visible', async () => {
it('edits in place of the card row with Save and Cancel while keeping another card visible', async () => {
const change = vi.fn()
const save = vi.fn(async () => true)
const cancel = vi.fn()
@@ -108,11 +111,16 @@ describe('MobileNativeChatQueuedMessages', () => {
})
const input = mounted.root.findByProps({ accessibilityLabel: 'Edit queued message' })
expect(input.props.value).toBe('edited text')
// The editor's head line stands in for the card's icon, caption and actions; the sender stays.
const editedRow = mounted.root.findAllByProps({ testID: 'queued-card-row' })[0]!
expect(texts(mounted)).toContain('Editing message')
expect(texts(mounted)).toContain('From Alex')
expect(texts(mounted)).toContain('Returned')
expect(nodeTypes(mounted.root.findAllByProps({ testID: 'queued-card-row' })[0]!)).toContain(
'AlertCircle'
)
expect(texts(mounted)).not.toContain('Returned')
expect(nodeTypes(editedRow)).toContain('Pencil')
expect(nodeTypes(editedRow)).not.toContain('AlertCircle')
expect(
editedRow.findAll((node) => node.props.accessibilityLabel === 'Delete this queued message')
).toHaveLength(0)
act(() => input.props.onChangeText('updated text'))
expect(change).toHaveBeenCalledWith('updated text')
await act(async () =>
@@ -183,8 +191,13 @@ describe('MobileNativeChatQueuedMessages', () => {
}
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 })] })
const text = Array.from({ length: 100 }, (_, index) => `Line ${index + 1}`).join('\n')
const mounted = await mount({
cards: [card({ messageId: 'mail', text })],
onSend: vi.fn(async () => true),
onDelete: vi.fn(async () => true),
onEdit: vi.fn(async () => true)
})
await layOut(mounted, text, 5)
const body = () =>
mounted.root.find(
@@ -201,9 +214,21 @@ describe('MobileNativeChatQueuedMessages', () => {
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)
const row = mounted.root.findByProps({ testID: 'queued-card-row' })
// The document follows the compact controls, so its height cannot center actions offscreen.
const controlLine = row.findByProps({ accessibilityLabel: 'More actions' }).parent?.parent
expect(controlLine?.findAll((node) => node === body())).toHaveLength(0)
expect(body().parent?.parent?.parent).toBe(row)
expect(row.children.indexOf(controlLine!)).toBeLessThan(
row.children.indexOf(body().parent!.parent!)
)
// The queue's capped list scrolls a long message: the card opens no scroll box of its own.
const scrolls = mounted.root.findAll((node) => String(node.type) === 'ScrollView')
expect(scrolls).toHaveLength(1)
expect(flatStyle(scrolls[0]!.props.style).maxHeight).toBe(160)
await act(async () => body().parent?.props.onPress())
expect(body().props.numberOfLines).toBe(2)
await act(async () => toggle().props.onPress())
await act(async () => toggle().props.onPress())
expect(body().props.numberOfLines).toBe(2)
})
@@ -242,8 +267,9 @@ describe('MobileNativeChatQueuedMessages', () => {
})
it("lets a returned card's reason wrap whole, in the destructive color, beside an alert", async () => {
const reason =
"Claude didn't accept this message: Claude does not support the image type .bmp in a steering message."
const reason = Array.from({ length: 100 }, (_, index) => `Rejection line ${index + 1}`).join(
'\n'
)
const mounted = await mount({
cards: [
card({ messageId: 'r', state: 'returned', needsAttention: true, caption: reason }),
@@ -257,14 +283,16 @@ describe('MobileNativeChatQueuedMessages', () => {
expect(flatStyle(caption.props.style).color).toBe('#ef4444')
const [returnedRow, waitingRow] = mounted.root.findAllByProps({ testID: 'queued-card-row' })
expect(nodeTypes(returnedRow!)).toContain('AlertCircle')
expect(caption.parent?.parent).toBe(returnedRow)
expect(returnedRow!.children.indexOf(caption.parent!)).toBe(1)
expect(nodeTypes(waitingRow!)).not.toContain('AlertCircle')
const toggle = () => mounted.root.findByProps({ accessibilityLabel: 'Queued messages' })
await act(async () => toggle().props.onPress())
expect(mounted.root.findAllByProps({ testID: 'queued-card-row' })).toHaveLength(0)
expect(texts(mounted)).toContain(`2 · ${reason}`)
expect(texts(mounted)).toContain(`· ${reason}`)
const headerCaption = mounted.root
.findByProps({ testID: 'queued-header' })
.find((node) => String(node.type) === 'Text' && node.props.children === `2 · ${reason}`)
.find((node) => String(node.type) === 'Text' && node.props.children === `· ${reason}`)
expect(flatStyle(headerCaption.props.style).color).toBe('#ef4444')
await act(async () => toggle().props.onPress())
expect(texts(mounted).filter((text) => text === reason)).toHaveLength(1)
@@ -299,7 +327,7 @@ describe('MobileNativeChatQueuedMessages', () => {
expect(nodeTypes(rows[1]!)).toContain('CornerDownRight')
const toggle = () => mounted.root.findByProps({ accessibilityLabel: 'Queued messages' })
await act(async () => toggle().props.onPress())
expect(texts(mounted)).toContain("3 · Couldn't send — tap Send to retry")
expect(texts(mounted)).toContain("· Couldn't send — tap Send to retry")
await act(async () => toggle().props.onPress())
expect(
texts(mounted).filter((text) => text === "Couldn't send — tap Send to retry")
@@ -481,8 +509,9 @@ describe('MobileNativeChatQueuedMessages', () => {
const paused = await mountPaused({ pause: { reason: 'stopped' } })
expect(texts(paused).slice(0, 4)).toEqual(['Paused', 1, 'Clear all', 'Resume'])
expect(nodeTypes(header(paused))).toEqual(expect.arrayContaining(['Trash2', 'Play']))
const rowIds = header(paused).parent!.children.flatMap((child) =>
typeof child === 'string' ? [] : [child.props.testID]
const scroll = header(paused).parent!.find((node) => String(node.type) === 'ScrollView')
const rowIds = [header(paused), ...scroll.findAllByProps({ testID: 'queued-card-row' })].map(
(row) => row.props.testID
)
expect(rowIds).toEqual(['queued-header', 'queued-card-row'])
const [cardRow] = paused.root.findAllByProps({ testID: 'queued-card-row' })
@@ -593,6 +622,51 @@ describe('MobileNativeChatQueuedMessages', () => {
}
})
it('gives the header and editor buttons a line of their own at large text sizes', async () => {
fontScale = 2
try {
const editor = {
messageId: 'waiting-1',
text: 'next',
saving: false,
canSave: true,
change: vi.fn(),
save: vi.fn(async () => true),
cancel: vi.fn()
}
const mounted = await mountPaused({ pause: { reason: 'stopped' } })
const header = mounted.root.findByProps({ testID: 'queued-header' })
for (const label of ['Delete every queued message', 'Resume sending the queued messages']) {
const button = mounted.root.findByProps({ accessibilityLabel: label })
expect(header.findAll((node) => node === button)).toHaveLength(0)
}
// Editing, the keyboard leaves no room for that line: it gives way to the editor.
await act(async () => {
mounted.update(
createElement(MobileNativeChatQueuedMessages, {
cards: [waiting],
onSend: vi.fn(async () => true),
onClearAll: vi.fn(async () => true),
pause: { reason: 'stopped' },
editor
})
)
})
expect(
mounted.root.findAllByProps({ accessibilityLabel: 'Resume sending the queued messages' })
).toHaveLength(0)
const head = mounted.root.find(
(node) => String(node.type) === 'Text' && node.props.children === 'Editing message'
).parent!
for (const label of ['Cancel editing queued message', 'Save queued message']) {
const button = mounted.root.findByProps({ accessibilityLabel: label })
expect(head.findAll((node) => node === button)).toHaveLength(0)
}
} finally {
fontScale = 1
}
})
it('keeps Steer on its cards: one card can still go beside the paused rest', async () => {
const onSend = vi.fn(async () => true)
const mounted = await mountPaused({ pause: { reason: 'stopped' }, onSend })
@@ -606,7 +680,7 @@ describe('MobileNativeChatQueuedMessages', () => {
})
})
it("gives every action, the header's included, at least a 44pt touch target in a 44pt row", async () => {
it('gives every card action a 44pt-tall target in a 44pt row, under a 32pt header', async () => {
const mounted = await mount({
cards: [card({ messageId: 'waiting-1' })],
pause: { reason: 'stopped' },
@@ -622,24 +696,77 @@ describe('MobileNativeChatQueuedMessages', () => {
node.props.accessibilityRole === 'button' &&
node.props.testID !== 'queued-card-text'
)
const style = (button: (typeof buttons)[number]) =>
flatStyle(button.props.style({ pressed: false }))
expect(buttons.map((button) => button.props.accessibilityLabel)).toEqual([
'Queued messages',
'Delete every queued message',
'Resume sending the queued messages',
'Queued messages',
'Submit without interrupting the model',
'Delete this queued message',
'More actions'
])
for (const button of buttons) {
const style = flatStyle(button.props.style({ pressed: false }))
expect(style.minHeight).toBeGreaterThanOrEqual(44)
expect(style.minWidth).toBeGreaterThanOrEqual(44)
// The header row itself is the fold control, so the whole 32pt row is its target.
const header = mounted.root.findByProps({ testID: 'queued-header' })
expect(flatStyle(header.props.style).minHeight).toBe(32)
expect(style(buttons[0]!)).toMatchObject({ position: 'absolute', top: 0, bottom: 0 })
// A minimum, so the header buttons grow with large system text instead of clipping.
for (const button of buttons.slice(1, 3)) {
expect(style(button).minHeight).toBe(32)
expect(style(button).height).toBeUndefined()
expect(style(button).minWidth).toBeGreaterThanOrEqual(44)
}
for (const row of [
mounted.root.findByProps({ testID: 'queued-header' }),
...mounted.root.findAllByProps({ testID: 'queued-card-row' })
]) {
// Grouped with no gap; the last one keeps the full 44.
const cardActions = buttons.slice(3).map(style)
for (const action of cardActions) {
expect(action.minHeight).toBeGreaterThanOrEqual(44)
}
expect(cardActions[0]!.minWidth).toBe(44)
expect(cardActions.map((action) => action.width)).toEqual([undefined, 40, 44])
for (const row of mounted.root.findAllByProps({ testID: 'queued-card-row' })) {
expect(flatStyle(row.props.style).minHeight).toBeGreaterThanOrEqual(44)
}
})
it('caps a long queue in a scrolling list and shows a card the person just queued', async () => {
const scrollToEnd = vi.fn()
const mounted = create(createElement('View'), {
createNodeMock: (element) =>
element.type === 'ScrollView' ? { scrollToEnd, flashScrollIndicators: vi.fn() } : null
})
renderer = mounted
const ids = ['a', 'b', 'c', 'd', 'e']
const show = async (cards: MobileQueuedMessageCard[]) => {
await act(async () => {
mounted.update(createElement(MobileNativeChatQueuedMessages, { cards }))
})
return mounted.root.find((node) => String(node.type) === 'ScrollView')
}
const scroll = await show(ids.map((messageId) => card({ messageId })))
// The header stays outside the list, which never grows past desktop's cap.
expect(flatStyle(scroll.props.style).maxHeight).toBe(160)
expect(scroll.findAllByProps({ testID: 'queued-header' })).toHaveLength(0)
expect(scroll.findAllByProps({ testID: 'queued-card-row' })).toHaveLength(5)
await act(async () => scroll.props.onContentSizeChange(0, ids.length * 44))
expect(scrollToEnd).not.toHaveBeenCalled()
// Another agent's card waits in line unseen; the person's own new card is scrolled to.
await show([...ids, 'f'].map((messageId) => card({ messageId, attribution: 'From Alex' })))
expect(scrollToEnd).not.toHaveBeenCalled()
await show([...ids, 'f', 'g'].map((messageId) => card({ messageId })))
expect(scrollToEnd).toHaveBeenCalledExactlyOnceWith({ animated: false })
scrollToEnd.mockClear()
// Draining the head and appending our card in one update keeps the same total height.
await show(['b', 'c', 'd', 'e', 'f', 'g', 'h'].map((messageId) => card({ messageId })))
expect(scrollToEnd).toHaveBeenCalledExactlyOnceWith({ animated: false })
scrollToEnd.mockClear()
// A later expansion or agent append cannot consume a stranded own-append request.
await act(async () => scroll.props.onContentSizeChange(0, 2400))
await show(
['b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'].map((messageId) =>
card({ messageId, attribution: 'From Alex' })
)
)
await act(async () => scroll.props.onContentSizeChange(0, 2444))
expect(scrollToEnd).not.toHaveBeenCalled()
})
})
@@ -1,25 +1,16 @@
import { useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import {
AlertCircle,
ChevronDown,
ChevronRight,
CornerDownRight,
ListEnd,
MoreHorizontal,
Pencil,
Play,
Send,
Trash2
} from 'lucide-react-native'
import { Pressable, Text, View } from 'react-native'
import { ChevronDown, ChevronRight, Pencil, Play, Trash2 } from 'lucide-react-native'
import { ActionSheetModal } from '../components/ActionSheetModal'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { colors } from '../theme/mobile-theme'
import { useLargeSystemText } from '../theme/use-large-system-text'
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 type { MobileQueuedMessageInlineEditor } from './use-mobile-structured-queued-edit'
import { MobileNativeChatQueuedMessageEditor } from './MobileNativeChatQueuedMessageEditor'
import { MobileQueuedCardText } from './MobileQueuedCardText'
import { MobileQueuedMessageCardRow } from './MobileQueuedMessageCardRow'
import { MobileQueuedMessageScrollList } from './MobileQueuedMessageScrollList'
import { queuedMessageStyles as styles } from './mobile-native-chat-queued-message-styles'
/** The header's in-flight keys beside the cards' message ids, which never contain NUL. */
const RESUME_KEY = '\u0000resume'
@@ -61,6 +52,7 @@ export function MobileNativeChatQueuedMessages({
const inFlightRef = useRef(new Set<string>())
const [menuFor, setMenuFor] = useState<string | null>(null)
const [expanded, setExpanded] = useState(true)
const largeText = useLargeSystemText()
if (!cards || cards.length === 0) {
// Collapsing is local to this queue's run: once it empties, the next card shows open.
if (!expanded) {
@@ -89,187 +81,121 @@ export function MobileNativeChatQueuedMessages({
if (menuFor !== null && !menuCard) {
setMenuFor(null)
}
const headerActions = pause ? (
<>
{onClearAll ? (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: clearing }}
accessibilityLabel="Delete every queued message"
style={({ pressed }) => [
styles.headerAction,
pressed && styles.pressed,
clearing && styles.disabled
]}
disabled={clearing}
onPress={() => void run(CLEAR_KEY, () => onClearAll(cards.map((card) => card.messageId)))}
>
<Trash2 size={12} color={colors.textPrimary} strokeWidth={2} />
<Text style={styles.actionLabel}>Clear all</Text>
</Pressable>
) : null}
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: resuming }}
accessibilityLabel="Resume sending the queued messages"
style={({ pressed }) => [
styles.headerAction,
pressed && styles.pressed,
resuming && styles.disabled
]}
disabled={resuming}
onPress={() => void run(RESUME_KEY, onResume && (() => onResume()))}
>
<Play size={12} color={colors.textPrimary} strokeWidth={2} />
<Text style={styles.actionLabel}>Resume</Text>
</Pressable>
</>
) : null
const attentionCaption = !expanded ? cards.find((card) => card.needsAttention)?.caption : null
const headerCountStyle = [styles.headerCount, attentionCaption && styles.captionReturned]
return (
<View style={styles.list}>
{/* One box: the header is its first row, and each card a row below it. */}
{/* One box: the header is its first row, and the capped list of cards below it. */}
<View style={styles.box}>
{/* A polite region, as the app's other notices: React Native has no status role. */}
<View
testID="queued-header"
style={[styles.row, pause && styles.headerPaused]}
style={[styles.header, pause && styles.headerPaused]}
accessibilityLiveRegion="polite"
>
<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>
{pause ? (
<>
{onClearAll ? (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: clearing }}
accessibilityLabel="Delete every queued message"
style={({ pressed }) => [
styles.textAction,
pressed && styles.pressed,
clearing && styles.disabled
]}
disabled={clearing}
onPress={() =>
void run(CLEAR_KEY, () => onClearAll(cards.map((card) => card.messageId)))
}
>
<Trash2 size={12} color={colors.textPrimary} strokeWidth={2} />
<Text style={styles.actionLabel}>Clear all</Text>
</Pressable>
) : null}
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: resuming }}
accessibilityLabel="Resume sending the queued messages"
style={({ pressed }) => [
styles.textAction,
pressed && styles.pressed,
resuming && styles.disabled
]}
disabled={resuming}
onPress={() => void run(RESUME_KEY, onResume && (() => onResume()))}
>
<Play size={12} color={colors.textPrimary} strokeWidth={2} />
<Text style={styles.actionLabel}>Resume</Text>
</Pressable>
</>
) : null}
{/* The whole row folds the cards. It sits behind the labels rather than around the
header's buttons, which VoiceOver could not reach inside another button. */}
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded }}
accessibilityLabel="Queued messages"
style={({ pressed }) => [styles.iconAction, pressed && styles.pressed]}
style={({ pressed }) => [styles.headerToggle, pressed && styles.pressed]}
onPress={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown size={14} color={colors.textMuted} strokeWidth={2} />
) : (
<ChevronRight size={14} color={colors.textMuted} strokeWidth={2} />
)}
</Pressable>
</View>
{(expanded ? cards : []).map((card) => {
const editing = editor?.messageId === card.messageId
const busy = busyIds.has(card.messageId)
const editBlocked = busy || editor !== undefined || !onEdit
const returned = card.state === 'returned'
// "Steer" submits beside the running turn, the paused queue's cards too; a card whose
// own send failed, or a returned one, is sent again.
const steers = !returned && !card.paused && !card.command
return (
<View
key={card.messageId}
testID="queued-card-row"
style={[styles.row, styles.divided]}
/>
<View style={styles.headerLabels} pointerEvents="none">
<Text style={[styles.headerLabel, pause && styles.headerLabelPaused]}>
{pause ? 'Paused' : 'Queued'}
</Text>
<Text
style={styles.headerLabel}
importantForAccessibility="no"
accessibilityElementsHidden
>
{card.needsAttention ? (
<AlertCircle size={14} color={colors.statusRed} strokeWidth={2} />
) : (
<ListEnd size={14} color={colors.textMuted} strokeWidth={2} />
)}
<View style={styles.textColumn}>
{card.attribution ? (
<Text style={styles.caption} numberOfLines={1}>
{card.attribution}
</Text>
) : null}
{editing ? (
<MobileNativeChatQueuedMessageEditor editor={editor} />
) : (
<MobileQueuedCardText text={card.text} />
)}
{card.caption ? (
// A returned card's reason only reads whole, often at its end; a hold is one line.
<Text
style={[styles.caption, returned && styles.captionReturned]}
numberOfLines={returned ? undefined : 1}
>
{card.caption}
</Text>
) : null}
</View>
{/* A command never steers: its Send shows only while the agent is idle. */}
{editing || card.waitsForAgent ? null : (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: busy || steerHeld }}
accessibilityLabel={
returned
? 'Send this message again'
: steers
? 'Submit without interrupting the model'
: 'Send this message'
}
style={({ pressed }) => [
styles.textAction,
pressed && styles.pressed,
(busy || steerHeld) && styles.disabled
]}
disabled={busy || steerHeld}
onPress={() => void run(card.messageId, onSend)}
>
{steers ? (
<CornerDownRight size={12} color={colors.textPrimary} strokeWidth={2} />
) : (
<Send size={12} color={colors.textPrimary} strokeWidth={2} />
)}
<Text style={styles.actionLabel}>{steers ? 'Steer' : 'Send'}</Text>
</Pressable>
)}
{editing ? null : (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: busy }}
accessibilityLabel="Delete this queued message"
style={({ pressed }) => [
styles.iconAction,
pressed && styles.pressed,
busy && styles.disabled
]}
disabled={busy}
onPress={() => void run(card.messageId, onDelete)}
>
<Trash2 size={14} color={colors.textPrimary} strokeWidth={2} />
</Pressable>
)}
{/* The menu holds only Edit, which a command does not take. */}
{editing || card.command ? null : (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: editBlocked }}
accessibilityLabel="More actions"
style={({ pressed }) => [
styles.iconAction,
pressed && styles.pressed,
editBlocked && styles.disabled
]}
disabled={editBlocked}
onPress={() => setMenuFor(card.messageId)}
>
<MoreHorizontal size={14} color={colors.textPrimary} strokeWidth={2} />
</Pressable>
)}
</View>
)
})}
·
</Text>
<Text style={headerCountStyle}>{cards.length}</Text>
{attentionCaption ? (
<Text style={styles.headerCaption} numberOfLines={1}>
{`· ${attentionCaption}`}
</Text>
) : null}
</View>
{largeText ? null : headerActions}
<View style={styles.chevron} pointerEvents="none">
{expanded ? (
<ChevronDown size={12} color={colors.textMuted} strokeWidth={2} />
) : (
<ChevronRight size={12} color={colors.textMuted} strokeWidth={2} />
)}
</View>
</View>
{/* While a card is edited at large text, the line yields its height to the editor: with
the keyboard up, a small phone has no room for both. */}
{largeText && headerActions && !editor ? (
<View style={[styles.headerActionsLine, styles.headerPaused]}>{headerActions}</View>
) : null}
{expanded ? (
<MobileQueuedMessageScrollList
messageIds={cards.map((card) => card.messageId)}
newestIsPersons={!cards.at(-1)?.attribution}
>
{(reveal) =>
cards.map((card) => {
const busy = busyIds.has(card.messageId)
return (
<MobileQueuedMessageCardRow
key={card.messageId}
card={card}
editor={editor?.messageId === card.messageId ? editor : undefined}
busy={busy}
editBlocked={busy || editor !== undefined || !onEdit}
steerHeld={steerHeld}
onSend={() => void run(card.messageId, onSend)}
onDelete={() => void run(card.messageId, onDelete)}
onMore={() => setMenuFor(card.messageId)}
onEditorLayout={reveal}
/>
)
})
}
</MobileQueuedMessageScrollList>
) : null}
</View>
<ActionSheetModal
visible={menuCard !== undefined}
@@ -292,90 +218,3 @@ export function MobileNativeChatQueuedMessages({
</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
const styles = StyleSheet.create({
list: {
marginHorizontal: spacing.lg,
marginVertical: spacing.xs
},
box: {
backgroundColor: colors.bgPanel,
borderRadius: radii.row,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
overflow: 'hidden'
},
row: {
minHeight: MIN_TOUCH_TARGET,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingLeft: spacing.md
},
divided: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle
},
headerPaused: {
backgroundColor: colors.bgRaised
},
headerLabel: {
color: colors.textMuted,
fontSize: typography.metaSize
},
headerLabelPaused: {
color: colors.textPrimary,
fontWeight: '500'
},
headerCount: {
flex: 1,
color: colors.textMuted,
fontSize: typography.metaSize,
fontVariant: ['tabular-nums']
},
textColumn: {
flex: 1,
minWidth: 0,
paddingVertical: spacing.sm,
gap: 2
},
caption: {
color: colors.textMuted,
fontSize: typography.metaSize
},
captionReturned: {
color: colors.statusRed
},
textAction: {
minHeight: MIN_TOUCH_TARGET,
minWidth: MIN_TOUCH_TARGET,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
borderRadius: radii.button
},
iconAction: {
minHeight: MIN_TOUCH_TARGET,
minWidth: MIN_TOUCH_TARGET,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
actionLabel: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '500'
},
pressed: {
backgroundColor: colors.bgRaised
},
disabled: {
opacity: 0.5
}
})
+23 -25
View File
@@ -1,17 +1,25 @@
import { useState } from 'react'
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { colors, typography } from '../theme/mobile-theme'
import { queuedMessageStyles } from './mobile-native-chat-queued-message-styles'
/** 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)
* never read, and Steer or Delete must not be a blind choice. Opened, the queue's own capped list
* scrolls through a long one: a second scroll box inside it would be taller than its viewport. */
export function MobileQueuedCardText({
text,
expanded,
onToggle
}: {
text: string
expanded: boolean
onToggle: () => void
}): React.JSX.Element {
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]}
style={[queuedMessageStyles.bodyText, styles.measure]}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
@@ -24,7 +32,7 @@ export function MobileQueuedCardText({ text }: { text: string }): React.JSX.Elem
return (
<View>
{measure}
<Text style={styles.body} numberOfLines={2}>
<Text style={queuedMessageStyles.bodyText} numberOfLines={2}>
{text}
</Text>
</View>
@@ -36,35 +44,25 @@ export function MobileQueuedCardText({ text }: { text: string }): React.JSX.Elem
accessibilityRole="button"
accessibilityState={{ expanded }}
accessibilityHint={expanded ? 'Shows less of the message' : 'Shows the whole message'}
onPress={() => setExpanded(!expanded)}
onPress={onToggle}
>
<Text style={styles.body} numberOfLines={expanded ? undefined : 2}>
{text}
</Text>
{expanded ? null : (
<Text style={queuedMessageStyles.bodyText} numberOfLines={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>
) : (
return (
<View>
{measure}
{expanded ? null : measure}
{toggle}
</View>
)
}
const styles = StyleSheet.create({
body: {
color: colors.textPrimary,
fontSize: typography.bodySize
},
expandedBody: {
maxHeight: 240
},
measure: {
position: 'absolute',
left: 0,
@@ -0,0 +1,176 @@
import { useState } from 'react'
import { Pressable, Text, View, type LayoutRectangle } from 'react-native'
import {
AlertCircle,
CornerDownRight,
ListEnd,
MoreHorizontal,
Send,
Trash2
} from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import type { MobileQueuedMessageCard } from './mobile-structured-queued-message-cards'
import type { MobileQueuedMessageInlineEditor } from './use-mobile-structured-queued-edit'
import { MobileNativeChatQueuedMessageEditor } from './MobileNativeChatQueuedMessageEditor'
import { MobileQueuedCardText } from './MobileQueuedCardText'
import { queuedMessageStyles as styles } from './mobile-native-chat-queued-message-styles'
type QueuedMessageCardRowProps = {
card: MobileQueuedMessageCard
/** Set only on the card being edited. */
editor?: MobileQueuedMessageInlineEditor
busy: boolean
editBlocked: boolean
steerHeld: boolean
onSend: () => void
onDelete: () => void
onMore: () => void
/** The edited row's place in the list, so the list can keep it in view. */
onEditorLayout: (row: LayoutRectangle) => void
}
/** One queued card: its text and actions, or, while it is edited, the editor in their place. */
export function MobileQueuedMessageCardRow({
card,
editor,
onEditorLayout,
...actions
}: QueuedMessageCardRowProps): React.JSX.Element {
if (editor) {
// The editor's own head line stands in for the icon, the actions and the caption.
return (
<View
testID="queued-card-row"
style={styles.divided}
onLayout={(event) => onEditorLayout(event.nativeEvent.layout)}
>
<MobileNativeChatQueuedMessageEditor editor={editor} attribution={card.attribution} />
</View>
)
}
return <QueuedMessageCardContents card={card} {...actions} />
}
function QueuedMessageCardContents({
card,
busy,
editBlocked,
steerHeld,
onSend,
onDelete,
onMore
}: Omit<QueuedMessageCardRowProps, 'editor' | 'onEditorLayout'>): React.JSX.Element {
const [expanded, setExpanded] = useState(false)
const returned = card.state === 'returned'
// "Steer" submits beside the running turn, the paused queue's cards too; a card whose own send
// failed, or a returned one, is sent again.
const steers = !returned && !card.paused && !card.command
return (
<View testID="queued-card-row" style={[styles.card, styles.divided]}>
<View style={styles.row}>
{card.needsAttention ? (
<AlertCircle size={14} color={colors.statusRed} strokeWidth={2} />
) : (
<ListEnd size={14} color={colors.textMuted} strokeWidth={2} />
)}
<View style={styles.textColumn}>
{card.attribution ? (
<Text style={styles.caption} numberOfLines={1}>
{card.attribution}
</Text>
) : null}
<MobileQueuedCardText
text={card.text}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
/>
{card.caption && !returned ? (
<Text style={styles.caption} numberOfLines={1}>
{card.caption}
</Text>
) : null}
</View>
<View style={styles.actions}>
{/* A command never steers: its Send shows only while the agent is idle. */}
{card.waitsForAgent ? null : (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: busy || steerHeld }}
accessibilityLabel={
returned
? 'Send this message again'
: steers
? 'Submit without interrupting the model'
: 'Send this message'
}
style={({ pressed }) => [
styles.textAction,
pressed && styles.pressed,
(busy || steerHeld) && styles.disabled
]}
disabled={busy || steerHeld}
onPress={onSend}
>
{steers ? (
<CornerDownRight size={12} color={colors.textPrimary} strokeWidth={2} />
) : (
<Send size={12} color={colors.textPrimary} strokeWidth={2} />
)}
<Text style={styles.actionLabel}>{steers ? 'Steer' : 'Send'}</Text>
</Pressable>
)}
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: busy }}
accessibilityLabel="Delete this queued message"
style={({ pressed }) => [
styles.iconAction,
card.command && styles.lastIconAction,
pressed && styles.pressed,
busy && styles.disabled
]}
disabled={busy}
onPress={onDelete}
>
<Trash2 size={14} color={colors.textPrimary} strokeWidth={2} />
</Pressable>
{/* The menu holds only Edit, which a command does not take. */}
{card.command ? null : (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: editBlocked }}
accessibilityLabel="More actions"
style={({ pressed }) => [
styles.iconAction,
styles.lastIconAction,
pressed && styles.pressed,
editBlocked && styles.disabled
]}
disabled={editBlocked}
onPress={onMore}
>
<MoreHorizontal size={14} color={colors.textPrimary} strokeWidth={2} />
</Pressable>
)}
</View>
</View>
{expanded || (returned && card.caption) ? (
<View style={styles.expandedBody}>
{expanded ? (
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: true }}
accessibilityHint="Shows less of the message"
onPress={() => setExpanded(false)}
>
<Text style={styles.bodyText}>{card.text}</Text>
</Pressable>
) : null}
{returned && card.caption ? (
<Text style={[styles.caption, styles.captionReturned]}>{card.caption}</Text>
) : null}
</View>
) : null}
</View>
)
}
@@ -0,0 +1,109 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { Platform, ScrollView, StyleSheet, View, type LayoutRectangle } from 'react-native'
import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'
import { colors, spacing } from '../theme/mobile-theme'
/** Desktop's `max-h-40`: three one-line cards and part of a fourth, whose cut-off edge is the
* sign that the list scrolls. */
export const QUEUE_LIST_MAX_HEIGHT = 160
type Metrics = { offset: number; viewport: number; content: number }
/** The queued cards under the fixed header, capped so a long queue never takes over the screen. */
export function MobileQueuedMessageScrollList({
messageIds,
newestIsPersons,
children
}: {
messageIds: readonly string[]
/** The newest card has no sender: the person just queued it, so the list shows it. */
newestIsPersons: boolean
/** Rows, given `reveal` to keep an edited row in view. */
children: (reveal: (row: LayoutRectangle) => void) => ReactNode
}): React.JSX.Element {
const scrollRef = useRef<ScrollView>(null)
const metrics = useRef<Metrics>({ offset: 0, viewport: 0, content: 0 })
const shownIds = useRef<ReadonlySet<string>>(new Set(messageIds))
const flashed = useRef(false)
const [cues, setCues] = useState({ above: false, below: false })
useLayoutEffect(() => {
const newest = messageIds.at(-1)
const appended =
shownIds.current.size > 0 && newest !== undefined && !shownIds.current.has(newest)
shownIds.current = new Set(messageIds)
// Reveal the committed own append even when a simultaneous removal keeps content size equal.
if (appended && newestIsPersons) {
scrollRef.current?.scrollToEnd({ animated: false })
}
}, [messageIds, newestIsPersons])
const measure = (next: Partial<Metrics>) => {
const m = Object.assign(metrics.current, next)
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 }))
if (!flashed.current && m.viewport > 0 && m.content > m.viewport + 1) {
flashed.current = true
if (Platform.OS === 'ios') {
scrollRef.current?.flashScrollIndicators()
}
}
}
// The nearest scroll that shows the whole row, as desktop's editor does.
const reveal = (row: LayoutRectangle) => {
const { offset, viewport } = metrics.current
if (viewport === 0) {
return
}
if (row.y < offset) {
scrollRef.current?.scrollTo({ y: row.y, animated: false })
} else if (row.y + row.height > offset + viewport) {
scrollRef.current?.scrollTo({ y: row.y + row.height - viewport, animated: false })
}
}
return (
<View>
<ScrollView
ref={scrollRef}
style={styles.scroll}
nestedScrollEnabled
showsVerticalScrollIndicator
persistentScrollbar
keyboardShouldPersistTaps="handled"
scrollEventThrottle={32}
onScroll={(event) => measure({ offset: event.nativeEvent.contentOffset.y })}
onLayout={(event) => measure({ viewport: event.nativeEvent.layout.height })}
onContentSizeChange={(_width, height) => measure({ content: height })}
>
{children(reveal)}
</ScrollView>
{cues.above ? <ListEdgeFade edge="top" /> : null}
{cues.below ? <ListEdgeFade edge="bottom" /> : null}
</View>
)
}
/** Fades the cards into the box's fill at an edge more content lies past. */
function ListEdgeFade({ edge }: { edge: 'top' | 'bottom' }): React.JSX.Element {
const id = `queued-list-fade-${edge}`
const top = edge === 'top'
return (
<View pointerEvents="none" style={[styles.fade, top ? styles.fadeTop : styles.fadeBottom]}>
<Svg width="100%" height="100%">
<Defs>
<LinearGradient id={id} x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor={colors.bgPanel} stopOpacity={top ? 1 : 0} />
<Stop offset="1" stopColor={colors.bgPanel} stopOpacity={top ? 0 : 1} />
</LinearGradient>
</Defs>
<Rect width="100%" height="100%" fill={`url(#${id})`} />
</Svg>
</View>
)
}
const styles = StyleSheet.create({
scroll: { maxHeight: QUEUE_LIST_MAX_HEIGHT },
fade: { position: 'absolute', left: 0, right: 0, height: spacing.lg },
fadeTop: { top: 0 },
fadeBottom: { bottom: 0 }
})
@@ -0,0 +1,170 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
// A card's actions touch as 44pt targets (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
const HEADER_HEIGHT = spacing.xl + spacing.sm
const META_LINE_HEIGHT = typography.metaSize + spacing.xs
export const queuedMessageStyles = StyleSheet.create({
list: {
marginHorizontal: spacing.lg,
marginVertical: spacing.xs
},
box: {
backgroundColor: colors.bgPanel,
borderRadius: radii.row,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
overflow: 'hidden'
},
header: {
minHeight: HEADER_HEIGHT,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingLeft: spacing.md
},
// Large text: Clear all and Resume on a line of their own, under the label; at the largest sizes
// one button is all a small phone's line holds, so they wrap.
headerActionsLine: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-end',
gap: spacing.xs,
paddingHorizontal: spacing.xs
},
headerToggle: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0
},
headerLabels: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
headerPaused: {
backgroundColor: colors.bgRaised
},
headerLabel: {
color: colors.textMuted,
fontSize: typography.metaSize,
lineHeight: META_LINE_HEIGHT,
paddingVertical: spacing.sm
},
headerLabelPaused: {
color: colors.textPrimary,
fontWeight: '500'
},
headerCount: {
color: colors.textMuted,
fontSize: typography.metaSize,
lineHeight: META_LINE_HEIGHT,
paddingVertical: spacing.sm,
fontVariant: ['tabular-nums']
},
// A long reason truncates in the space left; the count beside it never does.
headerCaption: {
flex: 1,
minWidth: 0,
color: colors.statusRed,
fontSize: typography.metaSize,
lineHeight: META_LINE_HEIGHT,
paddingVertical: spacing.sm
},
// minHeight, not height: rows grow with the system text size instead of clipping.
headerAction: {
minHeight: HEADER_HEIGHT,
minWidth: MIN_TOUCH_TARGET,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
borderRadius: radii.button
},
chevron: {
width: 36,
minHeight: HEADER_HEIGHT,
alignItems: 'center',
justifyContent: 'center'
},
card: {
minHeight: MIN_TOUCH_TARGET
},
row: {
minHeight: MIN_TOUCH_TARGET,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingLeft: spacing.md
},
divided: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle
},
textColumn: {
flex: 1,
minWidth: 0,
paddingVertical: spacing.sm,
gap: 2
},
bodyText: {
color: colors.textPrimary,
fontSize: typography.bodySize,
lineHeight: typography.bodySize + 6
},
expandedBody: {
paddingLeft: spacing.md + 14 + spacing.sm,
paddingRight: spacing.md,
paddingBottom: spacing.sm
},
caption: {
color: colors.textMuted,
fontSize: typography.metaSize
},
captionReturned: {
color: colors.statusRed
},
actions: {
flexDirection: 'row'
},
textAction: {
minHeight: MIN_TOUCH_TARGET,
minWidth: MIN_TOUCH_TARGET,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
borderRadius: radii.button
},
// The composer's 40pt button width; the row's last action keeps the full 44.
iconAction: {
minHeight: MIN_TOUCH_TARGET,
width: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
lastIconAction: {
width: MIN_TOUCH_TARGET
},
actionLabel: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '500'
},
pressed: {
backgroundColor: colors.bgRaised
},
disabled: {
opacity: 0.5
}
})
@@ -14,6 +14,7 @@ import type { MobileStructuredBackgroundTasks } from './use-mobile-structured-ba
vi.mock('react-native', () => ({
Dimensions: { get: () => ({ width: 430, height: 900 }) },
useWindowDimensions: () => ({ fontScale: 1 }),
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
@@ -0,0 +1,9 @@
import { useWindowDimensions } from 'react-native'
// iOS "xxxLarge" (1.35) and up: a label plus two buttons no longer fit one line of a small phone.
const LARGE_TEXT_FONT_SCALE = 1.3
/** True at the system's larger text sizes, where a row's buttons move to a line of their own. */
export function useLargeSystemText(): boolean {
return useWindowDimensions().fontScale > LARGE_TEXT_FONT_SCALE
}
+13
View File
@@ -1 +1,14 @@
import { vi } from 'vitest'
// Native SVG's generated components import Flow-only native subpaths outside the RN host mocks.
vi.mock('react-native-svg', () => ({
default: 'Svg',
Defs: 'Defs',
G: 'G',
LinearGradient: 'LinearGradient',
Path: 'Path',
Rect: 'Rect',
Stop: 'Stop'
}))
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })