Improve mobile terminal UX and unify modal design language

- Add custom keyboard shortcuts (Ctrl+Key, Alt+Key, text macros) with persistent storage
- Add built-in terminal shortcuts (Ctrl+L/Z/R/A/E/W/U) and resize toggle
- Allow sending Enter on empty input for terminal convenience
- Fix delete worktree: correct RPC method (worktree.rm) with optimistic UI
- Fix modal transition flicker between action sheet and confirm dialog
- Unify all modals to consistent bottom drawer pattern with inner action groups

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-04-30 18:03:54 -04:00
co-authored by Orca
parent d2ce1c5ff1
commit e6f9cdc90b
7 changed files with 862 additions and 318 deletions
+21 -5
View File
@@ -339,13 +339,25 @@ export default function HostScreen() {
const handleDeleteWorktree = useCallback(
async (item: Worktree) => {
if (!client) return
const removeFromList = (list: Worktree[]) =>
list.filter((w) => w.worktreeId !== item.worktreeId)
setWorktrees(removeFromList)
setLastKnownWorktrees(removeFromList)
try {
await client.sendRequest('worktree.remove', {
worktree: `id:${item.worktreeId}`
const response = await client.sendRequest('worktree.rm', {
worktree: `id:${item.worktreeId}`,
force: true
})
if (!response.ok) {
setWorktrees((prev) => [...prev, item])
setLastKnownWorktrees((prev) => [...prev, item])
}
void fetchWorktrees()
} catch {
// Deletion failed — will be visible when list doesn't change
setWorktrees((prev) => [...prev, item])
setLastKnownWorktrees((prev) => [...prev, item])
}
},
[client, fetchWorktrees]
@@ -687,7 +699,7 @@ export default function HostScreen() {
{/* Worktree long-press action sheet */}
<ActionSheetModal
visible={actionTarget != null}
visible={actionTarget != null && confirmDelete == null}
title={actionTarget ? actionTarget.displayName || actionTarget.repo : undefined}
message={actionTarget?.branch}
actions={
@@ -737,12 +749,16 @@ export default function HostScreen() {
onPress: () => {
void handleDeleteWorktree(confirmDelete)
setConfirmDelete(null)
setActionTarget(null)
}
}
]
: []
}
onClose={() => setConfirmDelete(null)}
onClose={() => {
setConfirmDelete(null)
setActionTarget(null)
}}
/>
{/* Host remove confirmation */}
+114 -2
View File
@@ -12,6 +12,7 @@ import {
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { ArrowUp, ChevronLeft, Monitor, Plus, Smartphone } from 'lucide-react-native'
import { connect, type RpcClient } from '../../../../src/transport/rpc-client'
import { loadHosts } from '../../../../src/transport/host-store'
@@ -24,6 +25,11 @@ import {
import { StatusDot } from '../../../../src/components/StatusDot'
import { ActionSheetModal } from '../../../../src/components/ActionSheetModal'
import { TextInputModal } from '../../../../src/components/TextInputModal'
import {
CustomKeyModal,
loadCustomKeys,
type CustomKey
} from '../../../../src/components/CustomKeyModal'
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
type Terminal = {
@@ -49,7 +55,14 @@ const ACCESSORY_KEYS: AccessoryKey[] = [
{ label: '←', bytes: '\x1b[D' },
{ label: '→', bytes: '\x1b[C' },
{ label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
{ label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' }
{ label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
{ label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
{ label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
{ label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
{ label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
{ label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
{ label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
{ label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
]
// Why: persists fitted-handle sets across component remounts (e.g. navigating
@@ -121,6 +134,9 @@ export default function SessionScreen() {
const [createError, setCreateError] = useState('')
const [actionTarget, setActionTarget] = useState<Terminal | null>(null)
const [renameTarget, setRenameTarget] = useState<Terminal | null>(null)
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
const [deleteKeyTarget, setDeleteKeyTarget] = useState<CustomKey | null>(null)
const [fittedHandles, setFittedHandles] = useState<Set<string>>(
() => persistedFittedHandles.get(worktreeId!) ?? new Set()
)
@@ -321,6 +337,19 @@ export default function SessionScreen() {
}
}, [clearTerminalCache, hostId])
useEffect(() => {
void loadCustomKeys().then(setCustomKeys)
}, [])
const handleDeleteCustomKey = useCallback(
async (key: CustomKey) => {
const updated = customKeys.filter((k) => k.id !== key.id)
setCustomKeys(updated)
await AsyncStorage.setItem('orca:custom-accessory-keys', JSON.stringify(updated))
},
[customKeys]
)
useEffect(() => {
clearTerminalCache()
activeHandleRef.current = null
@@ -563,7 +592,7 @@ export default function SessionScreen() {
)
async function handleSend() {
if (!client || !activeHandle || !input.trim()) return
if (!client || !activeHandle) return
const text = input
setInput('')
@@ -805,6 +834,32 @@ export default function SessionScreen() {
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.accessoryContent}
>
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
pressed && styles.accessoryKeyPressed,
(!canSend || fitPending) && styles.accessoryKeyDisabled
]}
disabled={!canSend || fitPending}
onPress={() => {
if (activeHandle && fittedHandles.has(activeHandle)) {
void handleRestoreDesktopSize()
} else {
void handleFitToPhone()
}
}}
accessibilityLabel={
activeHandle && fittedHandles.has(activeHandle)
? 'Restore desktop size'
: 'Fit to phone'
}
>
{activeHandle && fittedHandles.has(activeHandle) ? (
<Monitor size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
) : (
<Smartphone size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
)}
</Pressable>
{ACCESSORY_KEYS.map((key) => (
<Pressable
key={key.label}
@@ -822,6 +877,36 @@ export default function SessionScreen() {
</Text>
</Pressable>
))}
{customKeys.map((key) => (
<Pressable
key={key.id}
style={({ pressed }) => [
styles.accessoryKey,
styles.customAccessoryKey,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handleAccessoryKey(key.bytes)}
onLongPress={() => {
triggerMediumImpact()
setDeleteKeyTarget(key)
}}
delayLongPress={400}
accessibilityLabel={`Send ${key.label}`}
>
<Text style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}>
{key.label}
</Text>
</Pressable>
))}
<Pressable
style={({ pressed }) => [styles.accessoryKey, pressed && styles.accessoryKeyPressed]}
onPress={() => setShowCustomKeyModal(true)}
accessibilityLabel="Add custom shortcut"
>
<Plus size={14} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
</ScrollView>
</View>
@@ -906,6 +991,29 @@ export default function SessionScreen() {
onSubmit={(value) => void handleRenameTerminal(value)}
onCancel={() => setRenameTarget(null)}
/>
<CustomKeyModal
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
onKeysChanged={setCustomKeys}
/>
<ActionSheetModal
visible={deleteKeyTarget != null}
title={deleteKeyTarget?.label ?? 'Shortcut'}
message="Remove this custom shortcut?"
actions={[
{
label: 'Remove',
destructive: true,
onPress: () => {
if (deleteKeyTarget) {
void handleDeleteCustomKey(deleteKeyTarget)
}
setDeleteKeyTarget(null)
}
}
]}
onClose={() => setDeleteKeyTarget(null)}
/>
</KeyboardAvoidingView>
)
}
@@ -1073,6 +1181,10 @@ const styles = StyleSheet.create({
accessoryKeyPressed: {
backgroundColor: colors.borderSubtle
},
customAccessoryKey: {
borderWidth: 1,
borderColor: colors.borderSubtle
},
accessoryKeyDisabled: {
opacity: 0.35
},
+74 -100
View File
@@ -1,6 +1,6 @@
import { Modal, View, Text, Pressable, StyleSheet, Platform } from 'react-native'
import { Edit3, Trash2, X, type LucideIcon } from 'lucide-react-native'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
export type ActionSheetAction = {
label: string
@@ -25,54 +25,53 @@ function iconForAction(label: string, destructive?: boolean, icon?: LucideIcon):
export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose}>
<Pressable style={styles.sheet} onPress={() => {}}>
<View style={styles.drawer}>
<View style={styles.handle} />
<View style={styles.header}>
<View style={styles.headerText}>
{title ? <Text style={styles.title}>{title}</Text> : null}
{(title || message) && (
<View style={styles.header}>
{title ? (
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
) : null}
{message ? <Text style={styles.message}>{message}</Text> : null}
</View>
<Pressable
style={({ pressed }) => [styles.closeButton, pressed && styles.rowPressed]}
onPress={onClose}
accessibilityLabel="Close"
>
<X size={18} color={colors.textSecondary} />
</Pressable>
</View>
)}
<View style={styles.actions}>
{actions.map((action) => {
<View style={styles.actionGroup}>
{actions.map((action, i) => {
const Icon = iconForAction(action.label, action.destructive, action.icon)
return (
<Pressable
key={action.label}
style={({ pressed }) => [styles.action, pressed && styles.rowPressed]}
onPress={() => {
onClose()
action.onPress()
}}
>
<View
style={[styles.actionIcon, action.destructive && styles.actionIconDestructive]}
<View key={action.label}>
{i > 0 && <View style={styles.separator} />}
<Pressable
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
onPress={() => {
onClose()
action.onPress()
}}
>
<Icon
size={18}
size={16}
color={action.destructive ? colors.statusRed : colors.textSecondary}
/>
</View>
<Text
style={[styles.actionText, action.destructive && styles.actionTextDestructive]}
>
{action.label}
</Text>
</Pressable>
<Text
style={[
styles.actionText,
action.destructive && styles.actionTextDestructive
]}
>
{action.label}
</Text>
</Pressable>
</View>
)
})}
</View>
</Pressable>
</View>
</Pressable>
</Modal>
)
@@ -81,97 +80,72 @@ export function ActionSheetModal({ visible, title, message, actions, onClose }:
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'transparent',
justifyContent: 'flex-end',
paddingHorizontal: spacing.sm
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end'
},
sheet: {
width: '100%',
backgroundColor: colors.bgPanel,
borderTopLeftRadius: 18,
borderTopRightRadius: 18,
paddingTop: spacing.sm,
drawer: {
backgroundColor: colors.bgBase,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: spacing.md,
paddingBottom: spacing.lg,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingBottom: spacing.xl + spacing.md,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.35,
shadowRadius: 18
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.2,
shadowRadius: 10
},
android: { elevation: 12 }
android: { elevation: 8 }
})
},
handle: {
alignSelf: 'center',
width: 38,
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.borderSubtle,
marginBottom: spacing.md
backgroundColor: colors.textMuted,
marginTop: spacing.sm,
marginBottom: spacing.md,
opacity: 0.4
},
header: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingBottom: spacing.md
},
headerText: {
flex: 1,
minWidth: 0,
paddingRight: spacing.md
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
title: {
fontSize: typography.titleSize,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: spacing.xs
fontSize: 13,
fontWeight: '500',
color: colors.textMuted
},
message: {
fontSize: 13,
color: colors.textSecondary,
lineHeight: 18
fontSize: 12,
color: colors.textMuted,
marginTop: 2
},
closeButton: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised
},
actions: {
gap: spacing.sm
},
action: {
minHeight: 54,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
borderRadius: radii.row,
backgroundColor: colors.bgBase,
actionGroup: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
rowPressed: {
backgroundColor: colors.bgRaised
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
actionIcon: {
width: 32,
height: 32,
borderRadius: 16,
action: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
marginRight: spacing.md
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
actionIconDestructive: {
backgroundColor: colors.statusRed + '18'
actionPressed: {
backgroundColor: colors.bgRaised
},
actionText: {
fontSize: typography.bodySize,
fontWeight: '600',
fontWeight: '500',
color: colors.textPrimary
},
actionTextDestructive: {
+383
View File
@@ -0,0 +1,383 @@
import { useState, useEffect, useCallback } from 'react'
import {
Modal,
View,
Text,
Pressable,
TextInput,
StyleSheet,
Platform,
ScrollView,
Switch
} from 'react-native'
import { ChevronLeft } from 'lucide-react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
const STORAGE_KEY = 'orca:custom-accessory-keys'
export type CustomKey = {
id: string
label: string
bytes: string
enter: boolean
}
type Step = 'choose-type' | 'pick-ctrl' | 'pick-alt' | 'text-macro'
const ALPHA_KEYS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
function ctrlBytes(letter: string): string {
return String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64)
}
function altBytes(letter: string): string {
return `\x1b${letter.toLowerCase()}`
}
type Props = {
visible: boolean
onClose: () => void
onKeysChanged: (keys: CustomKey[]) => void
}
export async function loadCustomKeys(): Promise<CustomKey[]> {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as CustomKey[]) : []
} catch {
return []
}
}
async function saveCustomKeys(keys: CustomKey[]): Promise<void> {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keys))
}
export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
const [step, setStep] = useState<Step>('choose-type')
const [macroLabel, setMacroLabel] = useState('')
const [macroText, setMacroText] = useState('')
const [macroEnter, setMacroEnter] = useState(true)
useEffect(() => {
if (visible) {
setStep('choose-type')
setMacroLabel('')
setMacroText('')
setMacroEnter(true)
}
}, [visible])
const addKey = useCallback(
async (key: Omit<CustomKey, 'id'>) => {
const existing = await loadCustomKeys()
const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` }
const updated = [...existing, newKey]
await saveCustomKeys(updated)
onKeysChanged(updated)
onClose()
},
[onClose, onKeysChanged]
)
const handleCtrlKey = useCallback(
(letter: string) => {
void addKey({ label: `Ctrl+${letter}`, bytes: ctrlBytes(letter), enter: false })
},
[addKey]
)
const handleAltKey = useCallback(
(letter: string) => {
void addKey({ label: `Alt+${letter}`, bytes: altBytes(letter), enter: false })
},
[addKey]
)
const handleMacroSave = useCallback(() => {
const label = macroLabel.trim() || macroText.trim().slice(0, 12)
const text = macroText
if (!label || !text) return
const bytes = macroEnter ? `${text}\r` : text
void addKey({ label, bytes, enter: false })
}, [addKey, macroLabel, macroText, macroEnter])
const showBack = step !== 'choose-type'
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose}>
<View style={styles.drawer}>
<View style={styles.handle} />
<View style={styles.header}>
{showBack ? (
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
onPress={() => setStep('choose-type')}
accessibilityLabel="Back"
>
<ChevronLeft size={18} color={colors.textSecondary} />
</Pressable>
) : (
<View style={styles.backSpacer} />
)}
<Text style={styles.title}>
{step === 'choose-type' && 'Add Shortcut'}
{step === 'pick-ctrl' && 'Ctrl + Key'}
{step === 'pick-alt' && 'Alt + Key'}
{step === 'text-macro' && 'Text Macro'}
</Text>
<View style={styles.backSpacer} />
</View>
{step === 'choose-type' && (
<View style={styles.group}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setStep('pick-ctrl')}
>
<Text style={styles.rowLabel}>Ctrl + Key</Text>
<Text style={styles.rowHint}>Control character shortcuts</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setStep('pick-alt')}
>
<Text style={styles.rowLabel}>Alt + Key</Text>
<Text style={styles.rowHint}>Alt/Option key combos</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setStep('text-macro')}
>
<Text style={styles.rowLabel}>Text Macro</Text>
<Text style={styles.rowHint}>Send custom text command</Text>
</Pressable>
</View>
)}
{(step === 'pick-ctrl' || step === 'pick-alt') && (
<View style={styles.group}>
<ScrollView style={styles.keyGridScroll} contentContainerStyle={styles.keyGrid}>
{ALPHA_KEYS.map((letter) => (
<Pressable
key={letter}
style={({ pressed }) => [styles.keyCell, pressed && styles.keyCellPressed]}
onPress={() =>
step === 'pick-ctrl' ? handleCtrlKey(letter) : handleAltKey(letter)
}
>
<Text style={styles.keyCellText}>{letter}</Text>
</Pressable>
))}
</ScrollView>
</View>
)}
{step === 'text-macro' && (
<View style={styles.group}>
<View style={styles.macroForm}>
<Text style={styles.fieldLabel}>Label</Text>
<TextInput
style={styles.fieldInput}
value={macroLabel}
onChangeText={setMacroLabel}
placeholder="e.g. Build"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.fieldLabel}>Command</Text>
<TextInput
style={styles.fieldInput}
value={macroText}
onChangeText={setMacroText}
placeholder="e.g. pnpm build"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>Press Enter</Text>
<Switch
value={macroEnter}
onValueChange={setMacroEnter}
trackColor={{ false: colors.bgRaised, true: colors.accentBlue }}
/>
</View>
<Pressable
style={[styles.saveButton, !macroText.trim() && styles.saveButtonDisabled]}
disabled={!macroText.trim()}
onPress={handleMacroSave}
>
<Text style={styles.saveButtonText}>Add Shortcut</Text>
</Pressable>
</View>
</View>
)}
</View>
</Pressable>
</Modal>
)
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end'
},
drawer: {
backgroundColor: colors.bgBase,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: spacing.md,
paddingBottom: spacing.xl + spacing.md,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.2,
shadowRadius: 10
},
android: { elevation: 8 }
})
},
handle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.textMuted,
marginTop: spacing.sm,
marginBottom: spacing.md,
opacity: 0.4
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingBottom: spacing.sm
},
backButton: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: 'center',
justifyContent: 'center'
},
backButtonPressed: {
backgroundColor: colors.bgRaised
},
backSpacer: {
width: 30
},
title: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
textAlign: 'center'
},
group: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
row: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary,
marginBottom: 1
},
rowHint: {
fontSize: 12,
color: colors.textMuted
},
keyGridScroll: {
maxHeight: 240
},
keyGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
justifyContent: 'center',
padding: spacing.md
},
keyCell: {
width: 42,
height: 38,
borderRadius: radii.button,
backgroundColor: colors.bgBase,
alignItems: 'center',
justifyContent: 'center'
},
keyCellPressed: {
backgroundColor: colors.bgRaised
},
keyCellText: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
fontFamily: typography.monoFamily
},
macroForm: {
padding: spacing.md,
gap: spacing.sm
},
fieldLabel: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary
},
fieldInput: {
backgroundColor: colors.bgBase,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: 14,
fontFamily: typography.monoFamily,
borderWidth: 1,
borderColor: colors.borderSubtle
},
switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.xs
},
switchLabel: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
saveButton: {
backgroundColor: colors.accentBlue,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button,
alignItems: 'center'
},
saveButtonDisabled: {
opacity: 0.5
},
saveButtonText: {
color: '#fff',
fontSize: typography.bodySize,
fontWeight: '600'
}
})
+112 -95
View File
@@ -11,7 +11,7 @@ import {
Image,
FlatList
} from 'react-native'
import { ChevronDown, Check, Terminal, X } from 'lucide-react-native'
import { ChevronDown, Check, Terminal } from 'lucide-react-native'
import Svg, { Path, G } from 'react-native-svg'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
@@ -210,38 +210,44 @@ function PickerListModal<T extends { id: string; label: string }>({
}) {
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose}>
<View style={styles.pickerCard} onStartShouldSetResponder={() => true}>
<Text style={styles.pickerTitle}>{title}</Text>
<FlatList
data={items}
keyExtractor={(item) => item.id}
style={styles.pickerList}
renderItem={({ item }) => {
const selected = item.id === selectedId
return (
<Pressable
style={[styles.pickerItem, selected && styles.pickerItemSelected]}
onPress={() => {
onSelect(item)
onClose()
}}
>
{renderIcon?.(item)}
<Text
style={[styles.pickerItemText, selected && styles.pickerItemTextSelected]}
numberOfLines={1}
<Pressable style={styles.pickerBackdrop} onPress={onClose}>
<View style={styles.pickerDrawer} onStartShouldSetResponder={() => true}>
<View style={styles.pickerHandle} />
<View style={styles.pickerHeader}>
<Text style={styles.pickerTitle}>{title}</Text>
</View>
<View style={styles.pickerGroup}>
<FlatList
data={items}
keyExtractor={(item) => item.id}
style={styles.pickerList}
ItemSeparatorComponent={() => <View style={styles.pickerSeparator} />}
renderItem={({ item }) => {
const selected = item.id === selectedId
return (
<Pressable
style={({ pressed }) => [
styles.pickerItem,
pressed && styles.pickerItemPressed
]}
onPress={() => {
onSelect(item)
onClose()
}}
>
{item.label}
</Text>
{selected && <Check size={14} color={colors.textPrimary} />}
</Pressable>
)
}}
/>
<Pressable style={styles.pickerCancel} onPress={onClose}>
<Text style={styles.pickerCancelText}>Cancel</Text>
</Pressable>
{renderIcon?.(item)}
<Text
style={[styles.pickerItemText, selected && styles.pickerItemTextSelected]}
numberOfLines={1}
>
{item.label}
</Text>
{selected && <Check size={14} color={colors.textPrimary} />}
</Pressable>
)
}}
/>
</View>
</View>
</Pressable>
</Modal>
@@ -335,15 +341,11 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
<>
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose}>
<View style={styles.card} onStartShouldSetResponder={() => true}>
<View style={styles.drawer} onStartShouldSetResponder={() => true}>
<View style={styles.handle} />
<View style={styles.header}>
<View>
<Text style={styles.title}>New Worktree</Text>
<Text style={styles.subtitle}>Pick a repository and agent</Text>
</View>
<Pressable style={styles.closeButton} onPress={onClose} hitSlop={8}>
<X size={18} color={colors.textSecondary} />
</Pressable>
<Text style={styles.title}>New Worktree</Text>
<Text style={styles.subtitle}>Pick a repository and agent</Text>
</View>
{loading ? (
@@ -458,46 +460,49 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end'
},
card: {
width: '100%',
maxWidth: 340,
backgroundColor: colors.bgPanel,
borderRadius: 14,
padding: spacing.xl,
drawer: {
backgroundColor: colors.bgBase,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: spacing.md,
paddingBottom: spacing.xl + spacing.md,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 12
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.2,
shadowRadius: 10
},
android: { elevation: 8 }
})
},
handle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.textMuted,
marginTop: spacing.sm,
marginBottom: spacing.md,
opacity: 0.4
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: spacing.lg
paddingHorizontal: spacing.xs,
marginBottom: spacing.md
},
title: {
fontSize: 17,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
subtitle: {
fontSize: 12,
color: colors.textSecondary,
fontSize: 13,
color: colors.textMuted,
marginTop: 2
},
closeButton: {
padding: spacing.xs
},
loadingContainer: {
paddingVertical: spacing.xl,
alignItems: 'center'
@@ -592,33 +597,57 @@ const styles = StyleSheet.create({
},
// Picker sub-modal styles
pickerCard: {
width: '100%',
maxWidth: 320,
pickerBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end'
},
pickerDrawer: {
backgroundColor: colors.bgBase,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: spacing.md,
paddingBottom: spacing.xl + spacing.md,
maxHeight: '70%',
backgroundColor: colors.bgPanel,
borderRadius: 14,
overflow: 'hidden',
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 12
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.2,
shadowRadius: 10
},
android: { elevation: 8 }
})
},
pickerTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
pickerHandle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.textMuted,
marginTop: spacing.sm,
marginBottom: spacing.md,
opacity: 0.4
},
pickerHeader: {
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
pickerTitle: {
fontSize: 13,
fontWeight: '500',
color: colors.textMuted
},
pickerGroup: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
pickerSeparator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
pickerList: {
flexGrow: 0
},
@@ -626,10 +655,10 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm + 2,
paddingHorizontal: spacing.lg
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
pickerItemSelected: {
pickerItemPressed: {
backgroundColor: colors.bgRaised
},
pickerItemText: {
@@ -639,17 +668,5 @@ const styles = StyleSheet.create({
},
pickerItemTextSelected: {
fontWeight: '600'
},
pickerCancel: {
alignItems: 'center',
paddingVertical: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
marginTop: spacing.xs
},
pickerCancelText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
}
})
+73 -70
View File
@@ -28,37 +28,39 @@ export function PickerModal<T extends string = string>({
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose}>
<Pressable style={styles.card} onPress={() => {}}>
<Text style={styles.title}>{title}</Text>
<View style={styles.options}>
{options.map((opt) => {
<View style={styles.drawer}>
<View style={styles.handle} />
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
</View>
<View style={styles.group}>
{options.map((opt, i) => {
const isSelected = opt.value === selected
return (
<Pressable
key={opt.value}
style={[styles.option, isSelected && styles.optionSelected]}
onPress={() => {
onSelect(opt.value)
onClose()
}}
>
<View style={styles.optionContent}>
<Text style={[styles.optionLabel, isSelected && styles.optionLabelSelected]}>
{opt.label}
</Text>
{opt.subtitle ? (
<Text style={styles.optionSubtitle}>{opt.subtitle}</Text>
) : null}
</View>
{isSelected && <Check size={16} color={colors.textPrimary} />}
</Pressable>
<View key={opt.value}>
{i > 0 && <View style={styles.separator} />}
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => {
onSelect(opt.value)
onClose()
}}
>
<View style={styles.rowContent}>
<Text style={[styles.rowLabel, isSelected && styles.rowLabelSelected]}>
{opt.label}
</Text>
{opt.subtitle ? <Text style={styles.rowSubtitle}>{opt.subtitle}</Text> : null}
</View>
{isSelected && <Check size={16} color={colors.textPrimary} />}
</Pressable>
</View>
)
})}
</View>
<Pressable style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeText}>Cancel</Text>
</Pressable>
</Pressable>
</View>
</Pressable>
</Modal>
)
@@ -67,75 +69,76 @@ export function PickerModal<T extends string = string>({
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end'
},
card: {
width: '100%',
maxWidth: 320,
backgroundColor: colors.bgPanel,
borderRadius: 14,
overflow: 'hidden',
drawer: {
backgroundColor: colors.bgBase,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: spacing.md,
paddingBottom: spacing.xl + spacing.md,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 12
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.2,
shadowRadius: 10
},
android: { elevation: 8 }
})
},
title: {
fontSize: 15,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
handle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.textMuted,
marginTop: spacing.sm,
marginBottom: spacing.md,
opacity: 0.4
},
header: {
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
options: {
paddingBottom: spacing.xs
title: {
fontSize: 13,
fontWeight: '500',
color: colors.textMuted
},
option: {
group: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.sm + 2,
paddingHorizontal: spacing.lg
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
optionSelected: {
rowPressed: {
backgroundColor: colors.bgRaised
},
optionContent: {
rowContent: {
flex: 1
},
optionLabel: {
rowLabel: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
optionLabelSelected: {
color: colors.textPrimary,
rowLabelSelected: {
fontWeight: '600'
},
optionSubtitle: {
rowSubtitle: {
fontSize: 11,
color: colors.textMuted,
marginTop: 1
},
closeButton: {
alignItems: 'center',
paddingVertical: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
marginTop: spacing.xs
},
closeText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
}
})
+85 -46
View File
@@ -36,35 +36,52 @@ export function TextInputModal({
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<Pressable style={styles.backdrop} onPress={onCancel}>
<Pressable style={styles.card} onPress={() => {}}>
<Text style={styles.title}>{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
<TextInput
style={styles.input}
value={value}
onChangeText={setValue}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
autoFocus
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={handleSubmit}
selectionColor={colors.accentBlue}
/>
<View style={styles.drawer}>
<View style={styles.handle} />
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
</View>
<View style={styles.group}>
<View style={styles.inputWrap}>
<TextInput
style={styles.input}
value={value}
onChangeText={setValue}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
autoFocus
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={handleSubmit}
selectionColor={colors.accentBlue}
/>
</View>
</View>
<View style={styles.actions}>
<Pressable style={styles.cancelButton} onPress={onCancel}>
<Pressable
style={({ pressed }) => [styles.cancelButton, pressed && styles.buttonPressed]}
onPress={onCancel}
>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
<Pressable
style={[styles.submitButton, !value.trim() && styles.submitButtonDisabled]}
style={({ pressed }) => [
styles.submitButton,
pressed && styles.buttonPressed,
!value.trim() && styles.submitButtonDisabled
]}
disabled={!value.trim()}
onPress={handleSubmit}
>
<Text style={styles.submitText}>Save</Text>
</Pressable>
</View>
</Pressable>
</View>
</Pressable>
</Modal>
)
@@ -73,73 +90,95 @@ export function TextInputModal({
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end'
},
card: {
width: '100%',
maxWidth: 340,
backgroundColor: colors.bgPanel,
borderRadius: 14,
padding: spacing.xl,
drawer: {
backgroundColor: colors.bgBase,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: spacing.md,
paddingBottom: spacing.xl + spacing.md,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 12
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.2,
shadowRadius: 10
},
android: { elevation: 8 }
})
},
handle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.textMuted,
marginTop: spacing.sm,
marginBottom: spacing.md,
opacity: 0.4
},
header: {
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
title: {
fontSize: 17,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: spacing.xs
color: colors.textPrimary
},
message: {
fontSize: 13,
color: colors.textSecondary,
marginBottom: spacing.md
color: colors.textMuted,
marginTop: 2
},
group: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
inputWrap: {
padding: spacing.md
},
input: {
backgroundColor: colors.bgRaised,
backgroundColor: colors.bgBase,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
fontSize: typography.bodySize,
borderWidth: 1,
borderColor: colors.borderSubtle,
marginBottom: spacing.lg
borderColor: colors.borderSubtle
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm
gap: spacing.sm,
marginTop: spacing.md
},
cancelButton: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
cancelText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
submitButton: {
backgroundColor: colors.accentBlue,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
buttonPressed: {
opacity: 0.7
},
submitButtonDisabled: {
opacity: 0.4
},
cancelText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
submitText: {
color: '#fff',
fontSize: typography.bodySize,