Files
orca/mobile/src/session/QuickCommandRow.tsx
T
Kelvin Amoaba d618a85ffd feat(mobile): redesign sheets, pickers and list screens (#36585)
Sheets slide on iOS's curve over their own height, ease when their content
changes, and paint a lighter surface with grouped rows and a drawn switch.
Create worktree, the model picker, the GitHub issue sheet, file preview,
Agent Session History and the task list take the redesign draft's layouts
at the app's smaller type sizes.
2026-10-31 04:35:07 +00:00

215 lines
7.1 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { View, Text, Pressable, StyleSheet } from 'react-native'
import { useClipboardWriter } from '../platform/clipboard'
import { triggerError } from '../platform/haptics'
import { Check, Copy, Pencil, Play, Trash2 } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
import {
getQuickCommandDisplayPreview,
getTerminalQuickCommandBody,
isAgentQuickCommand
} from '../terminal/quick-commands'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
type QuickCommandRowProps = {
command: TerminalQuickCommand
first: boolean
onLaunch: (command: TerminalQuickCommand) => void
onEdit: (command: TerminalQuickCommand) => void
onDelete: (command: TerminalQuickCommand) => void
disabled: boolean
}
type CopyFeedback = {
body: string
status: 'copied' | 'failed'
}
export function QuickCommandRow({
command,
first,
onLaunch,
onEdit,
onDelete,
disabled
}: QuickCommandRowProps) {
useMobileColorScheme()
const t = useMobileTranslation()
const clipboard = useClipboardWriter()
const isAgent = isAgentQuickCommand(command)
const body = getTerminalQuickCommandBody(command)
const canCopy = body.trim().length > 0
// Why: key feedback to the copied body so a prop change drops stale labels
// without setState-in-effect (react-doctor no-adjust-state-on-prop-change).
const [feedback, setFeedback] = useState<CopyFeedback | null>(null)
const copyResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const mountedRef = useRef(true)
const copyStatus: 'idle' | 'copied' | 'failed' =
feedback != null && feedback.body === body ? feedback.status : 'idle'
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current)
}
}
}, [])
// Drop any pending reset timer when the body changes; display status is already idle.
useEffect(() => {
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current)
copyResetTimerRef.current = null
}
}, [body])
const handleCopy = async (): Promise<void> => {
if (!canCopy || disabled) {
return
}
try {
await clipboard.writeText(body)
if (!mountedRef.current) {
return
}
setFeedback({ body, status: 'copied' })
} catch {
// Ignore late refusal after unmount; there is no row left to explain the haptic.
if (!mountedRef.current) {
return
}
triggerError()
setFeedback({ body, status: 'failed' })
}
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current)
}
copyResetTimerRef.current = setTimeout(() => {
copyResetTimerRef.current = null
if (mountedRef.current) {
setFeedback(null)
}
}, 1500)
}
const copyDisabled = disabled || !canCopy
const copyLabel =
copyStatus === 'copied'
? t('common.copied', 'Copied')
: copyStatus === 'failed'
? t('session.quickCommandRow.copyFailed', "Couldn't copy")
: canCopy
? t('session.quickCommandRow.copyCommand', 'Copy {{label}}', { label: command.label })
: t('session.quickCommandRow.nothingToCopy', 'Nothing to copy')
const copyIconColor =
copyStatus === 'copied'
? colors.statusGreen
: copyStatus === 'failed'
? colors.statusRed
: colors.textSecondary
return (
<View style={[styles.row, !first && styles.rowBorder, disabled && styles.disabled]}>
<Pressable
style={({ pressed }) => [styles.rowMain, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onLaunch(command)}
accessibilityRole="button"
accessibilityLabel={t('session.quickCommandRow.runCommand', 'Run {{label}}', {
label: command.label
})}
>
<View style={styles.rowIcon}>
{isAgent ? (
<MobileAgentIcon agentId={command.agent} size={16} />
) : (
<Play size={14} color={colors.textPrimary} fill={colors.textPrimary} />
)}
</View>
<View style={styles.rowText}>
<Text style={styles.rowLabel} numberOfLines={1}>
{command.label}
</Text>
<Text style={[styles.rowPreview, !isAgent && styles.mono]} numberOfLines={1}>
{getQuickCommandDisplayPreview(command)}
</Text>
</View>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.rowAction,
// Why: row already dims when `disabled`; only dim again for empty body.
!canCopy && styles.disabled,
pressed && !copyDisabled && styles.pressed
]}
disabled={copyDisabled}
onPress={() => void handleCopy()}
accessibilityRole="button"
accessibilityLabel={copyLabel}
accessibilityState={{ disabled: copyDisabled }}
>
{copyStatus === 'copied' ? (
<Check size={15} color={copyIconColor} />
) : (
<Copy size={15} color={copyIconColor} />
)}
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onEdit(command)}
accessibilityLabel={t('session.quickCommandRow.editCommand', 'Edit {{label}}', {
label: command.label
})}
>
<Pencil size={15} color={colors.textSecondary} />
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onDelete(command)}
accessibilityLabel={t('session.quickCommandRow.deleteCommand', 'Delete {{label}}', {
label: command.label
})}
>
<Trash2 size={15} color={colors.statusRed} />
</Pressable>
</View>
)
}
const styles = themedStyleSheet(() => ({
pressed: { backgroundColor: colors.borderSubtle },
disabled: { opacity: 0.45 },
row: { flexDirection: 'row', alignItems: 'center' },
rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle },
rowMain: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
paddingLeft: spacing.md,
minWidth: 0
},
rowIcon: {
width: 26,
height: 26,
borderRadius: radii.tile,
backgroundColor: colors.borderSubtle,
alignItems: 'center',
justifyContent: 'center'
},
rowText: { flex: 1, minWidth: 0 },
rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 },
mono: { fontFamily: typography.monoFamily },
rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' }
}))