From 285f95c045b5600896915e5e18546b7b2e6104e4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:47:48 -0700 Subject: [PATCH] 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 --- config/oxlint-anti-slop.json | 8 + .../MobileNativeChatQueuedMessageEditor.tsx | 185 ++++++--- .../MobileNativeChatQueuedMessages.test.tsx | 181 +++++++-- .../MobileNativeChatQueuedMessages.tsx | 375 +++++------------- mobile/src/session/MobileQueuedCardText.tsx | 48 ++- .../session/MobileQueuedMessageCardRow.tsx | 176 ++++++++ .../session/MobileQueuedMessageScrollList.tsx | 109 +++++ ...obile-native-chat-queued-message-styles.ts | 170 ++++++++ ...-mobile-native-chat-composer-tray.test.tsx | 1 + mobile/src/theme/use-large-system-text.ts | 9 + mobile/vitest.setup.ts | 13 + 11 files changed, 904 insertions(+), 371 deletions(-) create mode 100644 mobile/src/session/MobileQueuedMessageCardRow.tsx create mode 100644 mobile/src/session/MobileQueuedMessageScrollList.tsx create mode 100644 mobile/src/session/mobile-native-chat-queued-message-styles.ts create mode 100644 mobile/src/theme/use-large-system-text.ts diff --git a/config/oxlint-anti-slop.json b/config/oxlint-anti-slop.json index 081357dec92..c9ed54f137b 100644 --- a/config/oxlint-anti-slop.json +++ b/config/oxlint-anti-slop.json @@ -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. diff --git a/mobile/src/session/MobileNativeChatQueuedMessageEditor.tsx b/mobile/src/session/MobileNativeChatQueuedMessageEditor.tsx index 8062c097173..7b90b5f07bd 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessageEditor.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessageEditor.tsx @@ -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 " line, kept above the text. */ + attribution: string | null }): React.JSX.Element { const input = useRef(null) useEffect(() => { input.current?.focus() }, []) + const saveDisabled = editor.saving || !editor.canSave + const largeText = useLargeSystemText() + const actions = ( + <> + [ + styles.cancel, + pressed && styles.pressed, + editor.saving && styles.disabled + ]} + onPress={editor.cancel} + > + Cancel + + {/* A 40pt target around a smaller pill, so the line stays one button tall. */} + void editor.save()} + > + {({ pressed }) => ( + + Save + + )} + + + ) return ( + + + Editing message + {largeText ? null : actions} + + {largeText ? {actions} : null} + {attribution ? ( + + {attribution} + + ) : null} - - [ - styles.action, - pressed && styles.pressed, - editor.saving && styles.disabled - ]} - onPress={editor.cancel} - > - Cancel - - [ - styles.action, - styles.save, - pressed && styles.pressed, - (editor.saving || !editor.canSave) && styles.disabled - ]} - onPress={() => void editor.save()} - > - Save - - ) } +// 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 } }) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index b4b71225bd1..619f6ae76b3 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -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() + }) }) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index 21519864ec6..0316947ca70 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -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()) const [menuFor, setMenuFor] = useState(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 ? ( + [ + styles.headerAction, + pressed && styles.pressed, + clearing && styles.disabled + ]} + disabled={clearing} + onPress={() => void run(CLEAR_KEY, () => onClearAll(cards.map((card) => card.messageId)))} + > + + Clear all + + ) : null} + [ + styles.headerAction, + pressed && styles.pressed, + resuming && styles.disabled + ]} + disabled={resuming} + onPress={() => void run(RESUME_KEY, onResume && (() => onResume()))} + > + + Resume + + + ) : null const attentionCaption = !expanded ? cards.find((card) => card.needsAttention)?.caption : null const headerCountStyle = [styles.headerCount, attentionCaption && styles.captionReturned] return ( - {/* 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. */} {/* A polite region, as the app's other notices: React Native has no status role. */} - - {pause ? 'Paused' : 'Queued'} - - - · - - - {attentionCaption ? `${cards.length} · ${attentionCaption}` : cards.length} - - {pause ? ( - <> - {onClearAll ? ( - [ - styles.textAction, - pressed && styles.pressed, - clearing && styles.disabled - ]} - disabled={clearing} - onPress={() => - void run(CLEAR_KEY, () => onClearAll(cards.map((card) => card.messageId))) - } - > - - Clear all - - ) : null} - [ - styles.textAction, - pressed && styles.pressed, - resuming && styles.disabled - ]} - disabled={resuming} - onPress={() => void run(RESUME_KEY, onResume && (() => onResume()))} - > - - Resume - - - ) : 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. */} [styles.iconAction, pressed && styles.pressed]} + style={({ pressed }) => [styles.headerToggle, pressed && styles.pressed]} onPress={() => setExpanded(!expanded)} - > - {expanded ? ( - - ) : ( - - )} - - - {(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 ( - + + + {pause ? 'Paused' : 'Queued'} + + - {card.needsAttention ? ( - - ) : ( - - )} - - {card.attribution ? ( - - {card.attribution} - - ) : null} - {editing ? ( - - ) : ( - - )} - {card.caption ? ( - // A returned card's reason only reads whole, often at its end; a hold is one line. - - {card.caption} - - ) : null} - - {/* A command never steers: its Send shows only while the agent is idle. */} - {editing || card.waitsForAgent ? null : ( - [ - styles.textAction, - pressed && styles.pressed, - (busy || steerHeld) && styles.disabled - ]} - disabled={busy || steerHeld} - onPress={() => void run(card.messageId, onSend)} - > - {steers ? ( - - ) : ( - - )} - {steers ? 'Steer' : 'Send'} - - )} - {editing ? null : ( - [ - styles.iconAction, - pressed && styles.pressed, - busy && styles.disabled - ]} - disabled={busy} - onPress={() => void run(card.messageId, onDelete)} - > - - - )} - {/* The menu holds only Edit, which a command does not take. */} - {editing || card.command ? null : ( - [ - styles.iconAction, - pressed && styles.pressed, - editBlocked && styles.disabled - ]} - disabled={editBlocked} - onPress={() => setMenuFor(card.messageId)} - > - - - )} - - ) - })} + · + + {cards.length} + {attentionCaption ? ( + + {`· ${attentionCaption}`} + + ) : null} + + {largeText ? null : headerActions} + + {expanded ? ( + + ) : ( + + )} + + + {/* 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 ? ( + {headerActions} + ) : null} + {expanded ? ( + card.messageId)} + newestIsPersons={!cards.at(-1)?.attribution} + > + {(reveal) => + cards.map((card) => { + const busy = busyIds.has(card.messageId) + return ( + void run(card.messageId, onSend)} + onDelete={() => void run(card.messageId, onDelete)} + onMore={() => setMenuFor(card.messageId)} + onEditorLayout={reveal} + /> + ) + }) + } + + ) : null} ) } - -// 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 - } -}) diff --git a/mobile/src/session/MobileQueuedCardText.tsx b/mobile/src/session/MobileQueuedCardText.tsx index fe690bcad67..c1d5a6594ae 100644 --- a/mobile/src/session/MobileQueuedCardText.tsx +++ b/mobile/src/session/MobileQueuedCardText.tsx @@ -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 = ( {measure} - + {text} @@ -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} - + {expanded ? null : ( + + {text} + + )} {expanded ? 'Show less' : 'Show more'} ) - // The press sits inside the scroll, so a drag scrolls and only a tap folds it. - return expanded ? ( - - {toggle} - - ) : ( + return ( - {measure} + {expanded ? null : measure} {toggle} ) } const styles = StyleSheet.create({ - body: { - color: colors.textPrimary, - fontSize: typography.bodySize - }, - expandedBody: { - maxHeight: 240 - }, measure: { position: 'absolute', left: 0, diff --git a/mobile/src/session/MobileQueuedMessageCardRow.tsx b/mobile/src/session/MobileQueuedMessageCardRow.tsx new file mode 100644 index 00000000000..0a7de082f76 --- /dev/null +++ b/mobile/src/session/MobileQueuedMessageCardRow.tsx @@ -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 ( + onEditorLayout(event.nativeEvent.layout)} + > + + + ) + } + return +} + +function QueuedMessageCardContents({ + card, + busy, + editBlocked, + steerHeld, + onSend, + onDelete, + onMore +}: Omit): 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 ( + + + {card.needsAttention ? ( + + ) : ( + + )} + + {card.attribution ? ( + + {card.attribution} + + ) : null} + setExpanded(!expanded)} + /> + {card.caption && !returned ? ( + + {card.caption} + + ) : null} + + + {/* A command never steers: its Send shows only while the agent is idle. */} + {card.waitsForAgent ? null : ( + [ + styles.textAction, + pressed && styles.pressed, + (busy || steerHeld) && styles.disabled + ]} + disabled={busy || steerHeld} + onPress={onSend} + > + {steers ? ( + + ) : ( + + )} + {steers ? 'Steer' : 'Send'} + + )} + [ + styles.iconAction, + card.command && styles.lastIconAction, + pressed && styles.pressed, + busy && styles.disabled + ]} + disabled={busy} + onPress={onDelete} + > + + + {/* The menu holds only Edit, which a command does not take. */} + {card.command ? null : ( + [ + styles.iconAction, + styles.lastIconAction, + pressed && styles.pressed, + editBlocked && styles.disabled + ]} + disabled={editBlocked} + onPress={onMore} + > + + + )} + + + {expanded || (returned && card.caption) ? ( + + {expanded ? ( + setExpanded(false)} + > + {card.text} + + ) : null} + {returned && card.caption ? ( + {card.caption} + ) : null} + + ) : null} + + ) +} diff --git a/mobile/src/session/MobileQueuedMessageScrollList.tsx b/mobile/src/session/MobileQueuedMessageScrollList.tsx new file mode 100644 index 00000000000..0303a28efd7 --- /dev/null +++ b/mobile/src/session/MobileQueuedMessageScrollList.tsx @@ -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(null) + const metrics = useRef({ offset: 0, viewport: 0, content: 0 }) + const shownIds = useRef>(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) => { + 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 ( + + measure({ offset: event.nativeEvent.contentOffset.y })} + onLayout={(event) => measure({ viewport: event.nativeEvent.layout.height })} + onContentSizeChange={(_width, height) => measure({ content: height })} + > + {children(reveal)} + + {cues.above ? : null} + {cues.below ? : null} + + ) +} + +/** 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 ( + + + + + + + + + + + + ) +} + +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 } +}) diff --git a/mobile/src/session/mobile-native-chat-queued-message-styles.ts b/mobile/src/session/mobile-native-chat-queued-message-styles.ts new file mode 100644 index 00000000000..dab516bcaa3 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-queued-message-styles.ts @@ -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 + } +}) diff --git a/mobile/src/session/use-mobile-native-chat-composer-tray.test.tsx b/mobile/src/session/use-mobile-native-chat-composer-tray.test.tsx index ca18fb66ec7..100da4ba232 100644 --- a/mobile/src/session/use-mobile-native-chat-composer-tray.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-composer-tray.test.tsx @@ -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 }, diff --git a/mobile/src/theme/use-large-system-text.ts b/mobile/src/theme/use-large-system-text.ts new file mode 100644 index 00000000000..7383043e1a8 --- /dev/null +++ b/mobile/src/theme/use-large-system-text.ts @@ -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 +} diff --git a/mobile/vitest.setup.ts b/mobile/vitest.setup.ts index 740c1eb1bab..e1a3e32a6e2 100644 --- a/mobile/vitest.setup.ts +++ b/mobile/vitest.setup.ts @@ -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 })