mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
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.
215 lines
7.1 KiB
TypeScript
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' }
|
|
}))
|