Files
orca/mobile/src/session/QuickCommandsSheet.tsx
T
OrcaWinandOrcaWin d8eef80bf4 feat(mobile): localize sessions, terminal, agent chat, browser and files (#35553)
Moves ~970 strings in session screens, the terminal, agent chat (including the
copy mobile used to render straight from shared English records), the browser pane
and the file explorer to the translation API, with all ten translations.

- Chat tool-run sentences, task lists, receipts, notices and turn status take
  translated words; the shared sentence joiner accepts an optional continuation so
  German nouns and Turkish casing survive mid-sentence (desktop unchanged)
- "Used the browser" vs "… 21 times" and resolved PR comment headers use explicit
  keys instead of plural rules, which would drop the count for Russian 21, 31, …
- Action sheet rows carry stable ids instead of being keyed by translated labels
- Rename inputs keep the raw title rather than a translated placeholder

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-29 03:55:41 +00:00

289 lines
9.3 KiB
TypeScript

import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Alert, View, Text, Pressable } from 'react-native'
import { ChevronLeft } from 'lucide-react-native'
import { colors, spacing } from '../theme/mobile-theme'
import { themedStyleSheet } from '../theme/themed-styles'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { BottomDrawer } from '../components/BottomDrawer'
import type { RpcClient } from '../transport/rpc-client'
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
import { getQuickCommandPreview, quickCommandMatchesRepo } from '../terminal/quick-commands'
import { useQuickCommands } from './use-quick-commands'
import { QuickCommandEditorForm } from './QuickCommandEditorForm'
import { QuickCommandAgentPicker, QuickCommandsList } from './QuickCommandsList'
import {
createEmptyQuickCommandDraft,
draftToQuickCommand,
quickCommandToDraft,
type QuickCommandDraft
} from './quick-command-draft'
import { useMobileTranslation } from '../i18n/use-mobile-translation'
type Props = {
visible: boolean
onClose: () => void
client: RpcClient | null
repoId: string | null
repoName: string | null
agentPromptMaxLength: number | null
onLaunch: (command: TerminalQuickCommand) => boolean
}
type SheetView = 'list' | 'editor' | 'agent'
export function QuickCommandsSheet({
visible,
onClose,
client,
repoId,
repoName,
agentPromptMaxLength,
onLaunch
}: Props) {
useMobileColorScheme()
const t = useMobileTranslation()
const { commands, loading, ready, error, persist } = useQuickCommands({
client,
enabled: visible
})
const [view, setView] = useState<SheetView>('list')
const [query, setQuery] = useState('')
const [draft, setDraft] = useState<QuickCommandDraft | null>(null)
const [saving, setSaving] = useState(false)
const saveOwnerRef = useRef<symbol | null>(null)
const draftRevisionRef = useRef(0)
const editorGenerationRef = useRef(0)
const previousSurface = useRef({ visible, client, repoId })
useLayoutEffect(() => {
const contextChanged =
previousSurface.current.client !== client || previousSurface.current.repoId !== repoId
if (visible !== previousSurface.current.visible || contextChanged) {
draftRevisionRef.current += 1
editorGenerationRef.current += 1
previousSurface.current = { visible, client, repoId }
if (contextChanged) {
saveOwnerRef.current = null
setSaving(false)
}
if (visible || contextChanged) {
setView('list')
setQuery('')
setDraft(null)
}
}
}, [visible, client, repoId])
// Why: prompt bodies can total megabytes. Lowercase them once per settings
// update instead of allocating the same search text on every keystroke.
const searchableCommands = useMemo(() => {
return commands
.filter((command) => quickCommandMatchesRepo(command, repoId))
.map((command) => ({
command,
searchText: `${command.label} ${getQuickCommandPreview(command)}`.toLowerCase()
}))
}, [commands, repoId])
const visibleCommands = useMemo(() => {
const trimmed = query.trim().toLowerCase()
return searchableCommands
.filter((entry) => !trimmed || entry.searchText.includes(trimmed))
.map((entry) => entry.command)
}, [query, searchableCommands])
const repoCommands = visibleCommands.filter((command) => command.scope?.type === 'repo')
const globalCommands = visibleCommands.filter((command) => command.scope?.type !== 'repo')
const openEditor = (command?: TerminalQuickCommand) => {
draftRevisionRef.current += 1
editorGenerationRef.current += 1
setDraft(
command
? quickCommandToDraft(command)
: createEmptyQuickCommandDraft(repoId ? { type: 'repo', repoId } : { type: 'global' })
)
setView('editor')
}
const handleLaunch = (command: TerminalQuickCommand) => {
if (onLaunch(command)) {
onClose()
}
}
const handleDelete = (command: TerminalQuickCommand) => {
// Why: quick commands sync with desktop, so an accidental one-tap delete
// removes shared data rather than only dismissing a local row.
Alert.alert(
t('session.quickCommandsSheet.deleteConfirmTitle', 'Delete "{{label}}"?', {
label: command.label || t('session.quickCommandsSheet.untitled', 'Untitled')
}),
t(
'session.quickCommandsSheet.deleteConfirmMessage',
'This quick command will be removed from your saved list.'
),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{
text: t('common.delete', 'Delete'),
style: 'destructive',
onPress: () => {
void persist({ type: 'delete', id: command.id })
}
}
]
)
}
const handleSave = async () => {
if (!draft || saveOwnerRef.current !== null) {
return
}
const built = draftToQuickCommand(draft, agentPromptMaxLength)
if (!built) {
return
}
// Why: state cannot lock out a second tap until React commits the disabled UI.
const saveOwner = Symbol()
saveOwnerRef.current = saveOwner
setSaving(true)
const savedRevision = draftRevisionRef.current
const savedEditor = editorGenerationRef.current
try {
const ok = await persist({ type: 'upsert', command: built })
if (ok && draftRevisionRef.current === savedRevision) {
setView('list')
setDraft(null)
} else if (ok && editorGenerationRef.current === savedEditor) {
setDraft((current) => (current ? { ...current, id: built.id } : current))
}
} finally {
if (saveOwnerRef.current === saveOwner) {
saveOwnerRef.current = null
setSaving(false)
}
}
}
const title =
view === 'editor'
? draft?.id
? t('session.quickCommandsSheet.editTitle', 'Edit Quick Command')
: t('session.quickCommandsSheet.addTitle', 'Add Quick Command')
: view === 'agent'
? t('session.quickCommandsSheet.chooseAgentTitle', 'Choose Agent')
: t('session.quickCommandsSheet.title', 'Quick Commands')
return (
<BottomDrawer visible={visible} onClose={onClose}>
<View style={styles.header}>
{view === 'list' ? (
<View style={styles.backSpacer} />
) : (
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.pressed]}
onPress={() => {
draftRevisionRef.current += 1
setView(view === 'agent' ? 'editor' : 'list')
}}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ChevronLeft size={18} color={colors.textSecondary} />
</Pressable>
)}
<Text style={styles.title}>{title}</Text>
<View style={styles.backSpacer} />
</View>
{view === 'editor' && draft ? (
<View style={styles.editorDesc}>
<Text style={styles.descText}>
{t(
'session.quickCommandsSheet.editorDescription',
'Save terminal commands or agent prompts for quick access.'
)}
</Text>
</View>
) : null}
{view === 'list' ? (
<QuickCommandsList
repoCommands={repoCommands}
globalCommands={globalCommands}
totalCount={searchableCommands.length}
query={query}
loading={loading}
disabled={!ready}
error={error}
onQueryChange={setQuery}
onLaunch={handleLaunch}
onEdit={openEditor}
onDelete={handleDelete}
onAdd={() => openEditor()}
/>
) : null}
{view === 'editor' && draft ? (
<QuickCommandEditorForm
draft={draft}
mode={draft.id ? 'edit' : 'add'}
saving={saving || !ready}
error={error}
repoId={repoId}
repoName={repoName}
agentPromptMaxLength={agentPromptMaxLength}
onChange={(patch) => {
draftRevisionRef.current += 1
setDraft((current) => (current ? { ...current, ...patch } : current))
}}
onOpenAgentPicker={() => {
draftRevisionRef.current += 1
setView('agent')
}}
onCancel={() => {
draftRevisionRef.current += 1
setView('list')
setDraft(null)
}}
onSave={() => void handleSave()}
/>
) : null}
{view === 'agent' && draft ? (
<QuickCommandAgentPicker
selected={draft.agent}
onSelect={(agent) => {
draftRevisionRef.current += 1
setDraft((current) => (current ? { ...current, agent } : current))
setView('editor')
}}
/>
) : null}
</BottomDrawer>
)
}
const styles = themedStyleSheet(() => ({
header: { flexDirection: 'row', alignItems: 'center', paddingBottom: spacing.sm },
backButton: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: 'center',
justifyContent: 'center'
},
backSpacer: { width: 30 },
title: {
flex: 1,
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
textAlign: 'center'
},
pressed: { backgroundColor: colors.bgRaised },
editorDesc: { paddingHorizontal: spacing.xs, paddingBottom: spacing.sm },
descText: { fontSize: 12, color: colors.textMuted }
}))