Files
orca/mobile/src/session/MobileQueuedMessageCardRow.tsx
T
Brennan Benson 04548087a5 fix(mobile): edit a queued message in a plain text box with compact cancel and save buttons (#30382)
* fix(mobile): edit a queued message in a plain text field with compact cancel and save buttons

* fix(mobile): the queued edit field grows from one line to four in the phone's web page

* fix(mobile): the queued edit's cancel and save buttons take 44pt-tall taps, and the queue no longer jumps while typing

- Measure the edit field's height in a hidden copy of the textarea instead
  of collapsing the live one: WebKit clamped the queue list's scroll
  position on every keystroke. It also re-measures when the field's width
  changes (rotation).
- react-native-web drops hitSlop, so the X and check were 28pt targets.
  Each button is now a 44pt-tall target reaching its neighbours, with the
  28pt square drawn inside; the layout looks the same.
- Announce saving with aria-busy, which react-native-web maps.
- List the new web sibling in web-overrides.json.
2026-10-19 10:17:21 -07:00

177 lines
6.2 KiB
TypeScript

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 replaces the icon, the text, 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>
)
}