mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user