feat(mobile): share Terminal and About settings with hosted pages

Move presentation to shared screens and persist terminal scale, autocomplete, shortcut layout and custom keys through paired-host page preferences. Reuse native fallback and external links, and correct native restore-setting response unwrapping. Deliberately update session parity and route census assertions. Full code gates and page export pass; iOS consumer verification is running.
This commit is contained in:
Jinwoo-H
2026-09-06 21:29:25 -04:00
parent ed6f610ecc
commit 97c4a050eb
32 changed files with 1929 additions and 941 deletions
+8 -147
View File
@@ -1,11 +1,7 @@
import { View, Text, StyleSheet, Pressable, Linking, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Linking, Platform } from 'react-native'
import { useRouter } from 'expo-router'
import { ChevronLeft, Globe } from 'lucide-react-native'
import Svg, { Path } from 'react-native-svg'
import Constants from 'expo-constants'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import AboutScreen from '../src/settings/about-screen'
// Why: read version + native build identifier from expo-constants at
// runtime so the About screen never drifts out of sync with app.json.
@@ -20,148 +16,13 @@ function getVersionLabel(): string {
return build ? `v${version} (${build})` : `v${version}`
}
function GithubIcon({ size = 16, color = colors.textSecondary }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</Svg>
)
}
function XIcon({ size = 16, color = colors.textSecondary }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</Svg>
)
}
export default function AboutScreen() {
export default function NativeAboutRoute() {
const router = useRouter()
const insets = useSafeAreaInsets()
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>About</Text>
</View>
<View style={styles.brand}>
<OrcaLogo size={28} />
<Text style={styles.brandName}>Orca</Text>
<Text style={styles.brandSub}>Open-source agent IDE for 100x builders</Text>
</View>
<View style={styles.section}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://onOrca.dev')}
>
<Globe size={16} color={colors.textSecondary} />
<Text style={styles.rowValue}>onOrca.dev</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://github.com/stablyai/orca')}
>
<GithubIcon />
<Text style={styles.rowValue}>stablyai/orca</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://x.com/orca_build')}
>
<XIcon />
<Text style={styles.rowValue}>@orca_build</Text>
</Pressable>
</View>
<Text style={styles.versionText}>{getVersionLabel()}</Text>
</View>
<AboutScreen
onBack={() => router.back()}
openExternal={(url) => Linking.openURL(url)}
versionLabel={getVersionLabel()}
/>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.xl
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
brand: {
alignItems: 'center',
paddingVertical: spacing.xl,
marginBottom: spacing.lg
},
brandName: {
fontSize: 22,
fontWeight: '800',
color: colors.textPrimary,
marginTop: spacing.sm
},
brandSub: {
fontSize: 13,
color: colors.textMuted,
marginTop: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowLabel: {
flex: 1,
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowValue: {
flex: 1,
textAlign: 'right',
fontSize: typography.bodySize,
color: colors.textSecondary
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
versionText: {
marginTop: spacing.lg,
textAlign: 'center',
fontSize: typography.metaSize,
color: colors.textMuted
}
})
+26 -371
View File
@@ -1,388 +1,43 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { View, Text, Pressable, Switch } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, {
useAnimatedRef,
useAnimatedScrollHandler,
useSharedValue
} from 'react-native-reanimated'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native'
import { colors, spacing } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state'
import { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles'
import {
loadTerminalAutocompleteEnabled,
loadTerminalTextScale,
saveTerminalAutocompleteEnabled,
saveTerminalTextScale
} from '../src/storage/preferences'
import { nativeTerminalSettingsOperations } from '../src/terminal/terminal-settings-operations'
import { nativeTerminalSettingsHost } from '../src/terminal/native-terminal-settings-host'
import TerminalSettingsScreen from '../src/terminal/terminal-settings-screen'
type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
type TextSizeValue = 'smallest' | 'smaller' | 'default' | 'large' | 'larger' | 'largest'
// scale = baseline zoom the terminal WebView applies on top of fit-to-width.
// Keep in sync with TERMINAL_TEXT_SCALES; pinch-to-zoom snaps to these values.
const TEXT_SIZE_OPTIONS: (PickerOption<TextSizeValue> & { scale: number })[] = [
{ value: 'smallest', label: 'Smallest (50%)', scale: 0.5 },
{ value: 'smaller', label: 'Smaller (75%)', scale: 0.75 },
{ value: 'default', label: 'Default (100%)', scale: 1 },
{ value: 'large', label: 'Large (125%)', scale: 1.25 },
{ value: 'larger', label: 'Larger (150%)', scale: 1.5 },
{ value: 'largest', label: 'Largest (200%)', scale: 2 }
]
function textSizeValueFromScale(scale: number): TextSizeValue {
return TEXT_SIZE_OPTIONS.find((o) => o.scale === scale)?.value ?? 'default'
}
function textSizeSummary(scale: number): string {
return (TEXT_SIZE_OPTIONS.find((o) => o.scale === scale) ?? TEXT_SIZE_OPTIONS[0]!).label
}
const AUTO_RESTORE_FIT_OPTIONS: (PickerOption<RestoreValue> & { ms: number | null })[] = [
{ value: 'indefinite', label: 'Keep at phone size (default)', ms: null },
{ value: '60s', label: 'After 1 minute', ms: 60_000 },
{ value: '5m', label: 'After 5 minutes', ms: 5 * 60_000 },
{ value: '30m', label: 'After 30 minutes', ms: 30 * 60_000 }
]
function valueFromMs(ms: number | null | undefined): RestoreValue {
if (ms == null) {
return 'indefinite'
}
const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms)
if (exact) {
return exact.value
}
// Why: server may return a non-preset ms (custom value, future preset,
// or server-side clamp). Snap to the closest finite preset so the
// picker's selected radio agrees with the row sublabel rendered by
// autoRestoreSummary ("After Xs").
let closest: (typeof AUTO_RESTORE_FIT_OPTIONS)[number] | null = null
let bestDelta = Infinity
for (const opt of AUTO_RESTORE_FIT_OPTIONS) {
if (opt.ms == null) {
continue
}
const delta = Math.abs(opt.ms - ms)
if (delta < bestDelta) {
bestDelta = delta
closest = opt
}
}
return closest ? closest.value : 'indefinite'
}
function autoRestoreSummary(ms: number | null | undefined): string {
if (ms === undefined) {
return '…'
}
if (ms === null) {
return AUTO_RESTORE_FIT_OPTIONS[0]!.label
}
const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms)
return exact ? exact.label : `After ${Math.round(ms / 1000)}s`
}
function HostFitRow({
client,
hostName,
ms,
onPress
}: {
client: RpcClient | null
hostName: string
ms: number | null | undefined
onPress: () => void
}): React.JSX.Element {
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
disabled={!client}
>
<Smartphone size={16} color={colors.textSecondary} />
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{hostName}</Text>
<Text style={styles.rowSublabel}>{autoRestoreSummary(ms)}</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
)
}
export default function TerminalSettingsScreen() {
export default function NativeTerminalSettingsRoute() {
const router = useRouter()
const insets = useSafeAreaInsets()
const [hosts, setHosts] = useState<HostProfile[]>([])
useEffect(() => {
void loadHosts().then(setHosts)
}, [])
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const { clients: hostClients } = useFocusedSettingsHostClients(hostIds)
const hostClientsById = useMemo(
() => new Map(hostClients.map((entry) => [entry.hostId, entry.client])),
[hostClients]
)
// Why: per-host current value, lazily fetched. We keep state at the
// screen level rather than per-row so the picker can render at root
// level — embedding PickerModal inside a row clipped its BottomDrawer
// absoluteFill backdrop to the ScrollView content frame and made the
// drawer appear cut-off.
const [hostMs, setHostMs] = useState<Record<string, number | null | undefined>>({})
const [pickerHostId, setPickerHostId] = useState<string | null>(null)
const [textScale, setTextScale] = useState(1)
const [textSizePickerOpen, setTextSizePickerOpen] = useState(false)
useEffect(() => {
void loadTerminalTextScale().then(setTextScale)
}, [])
const selectTextSize = useCallback((value: TextSizeValue) => {
const opt = TEXT_SIZE_OPTIONS.find((o) => o.value === value)
if (!opt) {
return
}
setTextScale(opt.scale)
void saveTerminalTextScale(opt.scale)
}, [])
const [autocompleteEnabled, setAutocompleteEnabled] = useState(false)
// Why: a fast toggle before the initial load resolves must win — otherwise the
// delayed read would clobber the user's choice with the stored (stale) value.
const userToggledAutocompleteRef = useRef(false)
useEffect(() => {
let stale = false
void loadTerminalAutocompleteEnabled().then((enabled) => {
if (!stale && !userToggledAutocompleteRef.current) {
setAutocompleteEnabled(enabled)
let active = true
void loadHosts().then((next) => {
if (active) {
setHosts(next)
}
})
return () => {
stale = true
active = false
}
}, [])
const toggleAutocomplete = useCallback((next: boolean) => {
userToggledAutocompleteRef.current = true
setAutocompleteEnabled(next)
void saveTerminalAutocompleteEnabled(next)
}, [])
useEffect(() => {
let cancelled = false
for (const host of hosts) {
const client = hostClientsById.get(host.id) ?? null
if (!client) {
continue
}
void client
.sendRequest('terminal.getAutoRestoreFit')
.then((resp) => {
if (cancelled) {
return
}
const value = (resp as { ms?: number | null } | null)?.ms
// Why: reconnect/status ticks can replay the same value; preserving
// object identity avoids rerendering every settings row again.
setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, host.id, value))
})
.catch(() => {
if (!cancelled) {
setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, host.id, null))
}
})
}
return () => {
cancelled = true
}
}, [hosts, hostClientsById])
async function selectValue(hostId: string, value: RestoreValue) {
const client = hostClientsById.get(hostId) ?? null
if (!client) {
return
}
const opt = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.value === value)
if (!opt) {
return
}
setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, opt.ms))
try {
const resp = (await client.sendRequest('terminal.setAutoRestoreFit', {
ms: opt.ms
})) as { ms?: number | null } | null
setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, resp?.ms))
} catch {
try {
const resp = (await client.sendRequest('terminal.getAutoRestoreFit')) as {
ms?: number | null
} | null
setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, resp?.ms))
} catch {
// give up silently — the next mount retries
}
}
}
const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null
const scrollRef = useAnimatedRef<Animated.ScrollView>()
const scrollOffsetY = useSharedValue(0)
const scrollContentHeight = useSharedValue(0)
const scrollHandler = useAnimatedScrollHandler((event) => {
scrollOffsetY.value = event.contentOffset.y
})
// Why: imperative toggle instead of state — a re-render while a drag gesture
// is active would rebuild the row gestures and could cancel the drag.
const setScrollEnabled = useCallback(
(enabled: boolean) => {
scrollRef.current?.setNativeProps({ scrollEnabled: enabled })
},
[scrollRef]
const hostIds = useMemo(() => hosts.map((host) => host.id), [hosts])
const { clients } = useFocusedSettingsHostClients(hostIds)
const settingsHosts = useMemo(
() =>
hosts.map((host) =>
nativeTerminalSettingsHost(
host,
clients.find((entry) => entry.hostId === host.id)?.client ?? null
)
),
[hosts, clients]
)
const handleDragActiveChange = useCallback(
(active: boolean) => setScrollEnabled(!active),
[setScrollEnabled]
)
return (
<GestureHandlerRootView style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
accessibilityLabel="Back to hosted session"
accessibilityRole="button"
style={styles.backButton}
onPress={() => router.back()}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Terminal</Text>
</View>
<Animated.ScrollView
ref={scrollRef}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
onContentSizeChange={(_width, height) => {
scrollContentHeight.value = height
}}
>
<Text style={styles.groupHeading}>WHEN YOU LEAVE THE APP</Text>
<Text style={styles.groupDescription}>
While you&apos;re using a terminal on your phone, Orca shrinks it to fit your screen. When
you close the app or switch away, this controls whether it stays at phone size (so
interactive CLI tools don&apos;t reflow) or resizes back to your desktop. You can always
use Restore this terminal or Restore all terminals on the banner to resize manually.
</Text>
{hosts.length === 0 ? (
<View style={[styles.section, styles.sectionTopGap]}>
<Text style={styles.emptyText}>
No paired desktops yet. Pair one to control terminal behavior.
</Text>
</View>
) : (
<View style={[styles.section, styles.sectionTopGap]}>
{hosts.map((host, idx) => {
const client = hostClientsById.get(host.id) ?? null
return (
<View key={host.id}>
{idx > 0 && <View style={styles.separator} />}
<HostFitRow
client={client}
hostName={host.name}
ms={hostMs[host.id]}
onPress={() => setPickerHostId(host.id)}
/>
</View>
)
})}
</View>
)}
<Text style={[styles.groupHeading, styles.inputGroupGap]}>TEXT SIZE</Text>
<Text style={styles.groupDescription}>
Scale the terminal text. Smaller sizes fit more columns with side margins; larger sizes
show fewer columns — drag sideways to pan. You can also pinch to zoom in the terminal
itself, which updates this setting. Per-device display only; doesn&apos;t change the
desktop terminal.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setTextSizePickerOpen(true)}
>
<Type size={16} color={colors.textSecondary} />
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Text size</Text>
<Text style={styles.rowSublabel}>{textSizeSummary(textScale)}</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
<Text style={[styles.groupHeading, styles.inputGroupGap]}>KEYBOARD INPUT</Text>
<Text style={styles.groupDescription}>
Enable phone-style autocomplete, autocorrect, and spelling suggestions in the terminal
command bar. Off by default so the keyboard never rewrites commands, flags, or paths.
Direct keyboard input (when keys go straight to the terminal) always sends raw keystrokes,
so suggestions don&apos;t apply there.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Autocomplete &amp; autocorrect</Text>
<Text style={styles.rowSublabel}>{autocompleteEnabled ? 'On' : 'Off'}</Text>
</View>
<Switch
value={autocompleteEnabled}
onValueChange={toggleAutocomplete}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
</View>
<TerminalShortcutSettings
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={handleDragActiveChange}
/>
</Animated.ScrollView>
<PickerModal<RestoreValue>
visible={pickerHost != null}
title={pickerHost ? `Restore ${pickerHost.name}` : ''}
options={AUTO_RESTORE_FIT_OPTIONS}
selected={valueFromMs(pickerHost ? hostMs[pickerHost.id] : null)}
onSelect={(v) => {
if (pickerHost) {
void selectValue(pickerHost.id, v)
}
}}
onClose={() => setPickerHostId(null)}
/>
<PickerModal<TextSizeValue>
visible={textSizePickerOpen}
title="Terminal text size"
options={TEXT_SIZE_OPTIONS}
selected={textSizeValueFromScale(textScale)}
onSelect={selectTextSize}
onClose={() => setTextSizePickerOpen(false)}
/>
</GestureHandlerRootView>
<TerminalSettingsScreen
hosts={settingsHosts}
operations={nativeTerminalSettingsOperations}
onBack={() => router.back()}
/>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { useRouter } from 'expo-router'
import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel'
import AboutScreen from '../src/settings/about-screen'
export default function HostedAboutRoute() {
const router = useRouter()
const shell = useMobileWebNativeShell()
return (
<AboutScreen
onBack={() => {
if (router.canGoBack()) {
router.back()
} else {
router.replace('/settings')
}
}}
linksAvailable={shell.client?.native.supports('openExternal') ?? false}
openExternal={async (url) => {
if (!shell.client) {
throw new Error('Native shell channel unavailable')
}
await shell.client.native.openExternal(url)
}}
versionLabel={
shell.context
? `Interface build ${shell.context.buildId.slice(0, 12)}`
: 'Loading interface build…'
}
/>
)
}
@@ -1,3 +1,4 @@
import { useRouter } from 'expo-router'
import { useEffect, useMemo } from 'react'
import { SessionScreen } from '../../../../app/h/[hostId]/session/[worktreeId]'
import { useMobileWebNativeShell } from '../../../../../src/mobile-web/src/native-shell-channel'
@@ -16,6 +17,7 @@ import { webHostSessionChatPendingDeliveryOperations } from '../../../../src/ses
import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params'
export default function HostMobileWebSessionRoute() {
const router = useRouter()
const shell = useMobileWebNativeShell()
const { worktreeId, name } = useMobileWebRouteParams<{
worktreeId: string
@@ -55,8 +57,11 @@ export default function HostMobileWebSessionRoute() {
[shell.client]
)
const sessionDeviceOperations = useMemo(
() => (shell.client ? webHostSessionDeviceOperations(shell.client) : undefined),
[shell.client]
() =>
shell.client
? webHostSessionDeviceOperations(shell.client, (target) => router.push(target))
: undefined,
[shell.client, router]
)
const sessionBrowserOperations = useMemo(
() => (shell.client ? webHostSessionBrowserOperations(shell.client) : undefined),
+58 -1
View File
@@ -1,12 +1,23 @@
import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel'
import { useState } from 'react'
import { Text } from 'react-native'
import { colors, typography, spacing } from '../src/theme/mobile-theme'
import { useRouter } from 'expo-router'
import { Globe, MessageSquare } from 'lucide-react-native'
import { Globe, MessageSquare, Terminal, Info, Shield, LifeBuoy } from 'lucide-react-native'
import { MobileSettingsFrame, MobileSettingsSection } from '../src/settings/mobile-settings-menu'
export default function HostedSettingsRoute() {
const router = useRouter()
const [linkError, setLinkError] = useState<string | null>(null)
const shell = useMobileWebNativeShell()
const disabled = !(shell.client?.native.supports('pagePreferences') ?? false)
const linksDisabled = !(shell.client?.native.supports('openExternal') ?? false)
const openExternal = (url: string) => {
setLinkError(null)
void shell.client?.native
.openExternal(url)
.catch(() => setLinkError('Could not open the link. Try again.'))
}
return (
<MobileSettingsFrame
onBack={() => {
@@ -19,6 +30,20 @@ export default function HostedSettingsRoute() {
>
<MobileSettingsSection
items={[
{
label: 'Terminal',
icon: Terminal,
disabled: !shell.client,
onPress: () => {
if (shell.client?.native.supports('pagePreferences')) {
router.push('/terminal-settings')
} else {
void shell.client
?.navigationRoute({ destination: 'terminalSettings' })
.catch(() => {})
}
}
},
{
label: 'Chat UI',
disabled,
@@ -30,9 +55,41 @@ export default function HostedSettingsRoute() {
disabled,
icon: Globe,
onPress: () => router.push('/browser-settings')
},
{ label: 'About', icon: Info, onPress: () => router.push('/about') }
]}
/>
<MobileSettingsSection
spaced
items={[
{
label: 'Privacy Policy',
icon: Shield,
external: true,
disabled: linksDisabled,
onPress: () => openExternal('https://www.onorca.dev/privacy')
},
{
label: 'Support',
icon: LifeBuoy,
external: true,
disabled: linksDisabled,
onPress: () => openExternal('https://github.com/stablyai/orca/issues')
}
]}
/>
{linkError && (
<Text
accessibilityRole="alert"
style={{
color: colors.textSecondary,
fontSize: typography.bodySize,
marginTop: spacing.md
}}
>
{linkError}
</Text>
)}
</MobileSettingsFrame>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'expo-router'
import { Pressable, Text } from 'react-native'
import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel'
import { MobileSettingsFrame } from '../src/settings/mobile-settings-menu'
import TerminalSettingsScreen from '../src/terminal/terminal-settings-screen'
import {
webTerminalSettingsHost,
webTerminalSettingsOperations
} from '../src/terminal/web-terminal-settings-operations'
import type { TerminalSettingsHost } from '../src/terminal/terminal-settings-operations'
import { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles'
export default function HostedTerminalSettingsRoute() {
const shell = useMobileWebNativeShell()
return <HostedTerminalSettings key={shell.context?.shellSessionId ?? 'pending'} />
}
function HostedTerminalSettings() {
const router = useRouter()
const shell = useMobileWebNativeShell()
const client = shell.client
const operations = useMemo(
() => (client ? webTerminalSettingsOperations(client) : null),
[client]
)
const [hosts, setHosts] = useState<TerminalSettingsHost[]>([])
const [loadingHost, setLoadingHost] = useState(true)
const [hostLoadFailed, setHostLoadFailed] = useState(false)
useEffect(() => {
let active = true
if (client) {
void webTerminalSettingsHost(client)
.then((host) => {
if (active) {
setHosts(host ? [host] : [])
}
})
.catch(() => {
if (active) {
setHostLoadFailed(true)
}
})
.finally(() => {
if (active) {
setLoadingHost(false)
}
})
}
return () => {
active = false
}
}, [client])
const onBack = () => {
if (router.canGoBack()) {
router.back()
} else {
router.replace('/settings')
}
}
if (!client?.native.supports('pagePreferences') || !operations) {
return (
<MobileSettingsFrame onBack={onBack}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Open device terminal settings"
style={styles.row}
onPress={() => {
void client?.navigationRoute({ destination: 'terminalSettings' }).catch(() => {})
}}
>
<Text style={styles.rowLabel}>Open device terminal settings</Text>
</Pressable>
</MobileSettingsFrame>
)
}
return (
<TerminalSettingsScreen
scope="host"
hosts={hosts}
operations={operations}
onBack={onBack}
hostUnavailableMessage={
loadingHost
? 'Loading terminal restore settings…'
: hostLoadFailed
? 'Could not load terminal restore settings. Go back and try again.'
: 'Terminal restore settings are not available with this desktop or app version.'
}
/>
)
}
+133 -355
View File
@@ -1,32 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
AppState,
View,
Text,
StyleSheet,
Pressable,
Switch,
type AppStateStatus
} from 'react-native'
import { useFocusEffect } from 'expo-router'
import { View, Text, Pressable, Switch, Platform } from 'react-native'
import { ChevronRight, X } from 'lucide-react-native'
import type Animated from 'react-native-reanimated'
import type { AnimatedRef, SharedValue } from 'react-native-reanimated'
import { CustomKeyModal, loadCustomKeys, saveCustomKeys, type CustomKey } from './CustomKeyModal'
import { CustomKeyModal } from './CustomKeyModal'
import { useTerminalShortcutSettings } from './use-terminal-shortcut-settings'
import { DragReorderList } from './DragReorderList'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { colors } from '../theme/mobile-theme'
import { terminalShortcutSettingsStyles as styles } from './terminal-shortcut-settings-styles'
import {
TERMINAL_ACCESSORY_KEYS,
type TerminalAccessoryKey
} from '../terminal/terminal-accessory-keys'
import {
getDefaultTerminalAccessoryLayout,
loadTerminalAccessoryLayout,
reorderTerminalAccessoryBuiltInIds,
saveTerminalAccessoryLayout,
setTerminalAccessoryBuiltInVisible,
type TerminalAccessoryLayout
} from '../terminal/terminal-accessory-layout'
nativeTerminalSettingsOperations,
type TerminalShortcutPreferences
} from '../terminal/terminal-settings-operations'
import type { TerminalAccessoryKey } from '../terminal/terminal-accessory-keys'
// Why: DragReorderList absolutely positions rows, so every row in a
// reorderable section must share one fixed height.
@@ -35,11 +20,13 @@ const REORDER_ROW_HEIGHT = 56
function ShortcutBarRow({
shortcutKey,
visible,
onToggle
onToggle,
disabled
}: {
shortcutKey: TerminalAccessoryKey
visible: boolean
onToggle: (visible: boolean) => void
disabled: boolean
}): React.JSX.Element {
return (
<View style={styles.reorderRowContent}>
@@ -50,10 +37,14 @@ function ShortcutBarRow({
<Text style={styles.rowLabel}>{shortcutKey.accessibilityLabel ?? shortcutKey.label}</Text>
</View>
<Switch
disabled={disabled}
accessibilityLabel={shortcutKey.accessibilityLabel ?? shortcutKey.label}
accessibilityState={{ disabled }}
value={visible}
onValueChange={onToggle}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
{...(Platform.OS === 'web' ? { activeThumbColor: colors.textPrimary } : {})}
/>
</View>
)
@@ -64,258 +55,145 @@ type Props = {
scrollOffsetY: SharedValue<number>
scrollContentHeight: SharedValue<number>
onDragActiveChange: (active: boolean) => void
preferences?: TerminalShortcutPreferences
}
export function TerminalShortcutSettings({
scrollRef,
scrollOffsetY,
scrollContentHeight,
onDragActiveChange
onDragActiveChange,
preferences = nativeTerminalSettingsOperations
}: Props): React.JSX.Element {
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
const [shortcutLayout, setShortcutLayout] = useState<TerminalAccessoryLayout>(
getDefaultTerminalAccessoryLayout
)
const layoutWriteChainRef = useRef<Promise<void>>(Promise.resolve())
const layoutWriteSeqRef = useRef(0)
const pendingLayoutWritesRef = useRef(0)
const persistLayout = useCallback((next: TerminalAccessoryLayout) => {
layoutWriteSeqRef.current += 1
pendingLayoutWritesRef.current += 1
layoutWriteChainRef.current = layoutWriteChainRef.current
.catch(() => {})
.then(() => saveTerminalAccessoryLayout(next))
.catch(() => {})
.finally(() => {
pendingLayoutWritesRef.current -= 1
})
}, [])
const refreshShortcutLayout = useCallback(() => {
const refreshSeq = layoutWriteSeqRef.current
void loadTerminalAccessoryLayout().then((layout) => {
if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) {
return
}
setShortcutLayout({
orderedBuiltInIds: layout.orderedBuiltInIds,
visibleBuiltInIds: layout.visibleBuiltInIds
})
})
}, [])
const customKeysWriteChainRef = useRef<Promise<void>>(Promise.resolve())
const customKeysWriteSeqRef = useRef(0)
const pendingCustomKeysWritesRef = useRef(0)
// Why: same stale-snapshot guard as persistLayout — a focus/AppState refresh
// racing an in-flight save must not overwrite the optimistic state.
const persistCustomKeys = useCallback((next: CustomKey[]) => {
customKeysWriteSeqRef.current += 1
pendingCustomKeysWritesRef.current += 1
customKeysWriteChainRef.current = customKeysWriteChainRef.current
.catch(() => {})
.then(() => saveCustomKeys(next))
.catch(() => {})
.finally(() => {
pendingCustomKeysWritesRef.current -= 1
})
}, [])
const refreshCustomKeys = useCallback(() => {
const refreshSeq = customKeysWriteSeqRef.current
void loadCustomKeys().then((keys) => {
if (pendingCustomKeysWritesRef.current > 0 || refreshSeq !== customKeysWriteSeqRef.current) {
return
}
setCustomKeys(keys)
})
}, [])
const handleDeleteCustomKey = useCallback(
(key: CustomKey) => {
setCustomKeys((current) => {
const updated = current.filter((k) => k.id !== key.id)
persistCustomKeys(updated)
return updated
})
},
[persistCustomKeys]
)
useFocusEffect(
useCallback(() => {
refreshShortcutLayout()
refreshCustomKeys()
}, [refreshShortcutLayout, refreshCustomKeys])
)
useEffect(() => {
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s === 'active') {
refreshShortcutLayout()
refreshCustomKeys()
}
})
return () => sub.remove()
}, [refreshShortcutLayout, refreshCustomKeys])
const toggleBuiltInKey = useCallback(
(id: string, visible: boolean) => {
setShortcutLayout((current) => {
const next = setTerminalAccessoryBuiltInVisible(current, id, visible)
persistLayout(next)
return next
})
},
[persistLayout]
)
const reorderBuiltInKeys = useCallback(
(orderedKeys: string[]) => {
setShortcutLayout((current) => {
const next = reorderTerminalAccessoryBuiltInIds(current, orderedKeys)
persistLayout(next)
return next
})
},
[persistLayout]
)
const resetBuiltInKeys = useCallback(() => {
const next = getDefaultTerminalAccessoryLayout()
setShortcutLayout(next)
persistLayout(next)
}, [persistLayout])
const reorderCustomKeys = useCallback(
(orderedKeys: string[]) => {
setCustomKeys((current) => {
const byId = new Map(current.map((key) => [key.id, key]))
const reordered = orderedKeys.flatMap((id) => {
const key = byId.get(id)
return key ? [key] : []
})
if (reordered.length !== current.length) {
return current
}
persistCustomKeys(reordered)
return reordered
})
},
[persistCustomKeys]
)
const visibleBuiltInSet = useMemo(
() => new Set(shortcutLayout.visibleBuiltInIds),
[shortcutLayout.visibleBuiltInIds]
)
const orderedAccessoryKeys = useMemo(() => {
const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key]))
return shortcutLayout.orderedBuiltInIds.flatMap((id) => {
const key = byId.get(id)
return key ? [key] : []
})
}, [shortcutLayout.orderedBuiltInIds])
const {
busy,
error,
customKeys,
showCustomKeyModal,
setShowCustomKeyModal,
visibleBuiltInSet,
orderedAccessoryKeys,
handleDeleteCustomKey,
toggleBuiltInKey,
reorderBuiltInKeys,
resetBuiltInKeys,
reorderCustomKeys,
customKeysWriteSeqRef,
setCustomKeys
} = useTerminalShortcutSettings(preferences)
return (
<>
<Text style={[styles.groupHeading, styles.groupTopGap]}>SHORTCUT BAR</Text>
<Text style={styles.groupDescription}>
Toggle keys to show or hide them, and hold the grip to drag a key into the order you want on
the terminal shortcut bar.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<DragReorderList
items={orderedAccessoryKeys}
itemKey={(shortcutKey) => shortcutKey.id}
rowHeight={REORDER_ROW_HEIGHT}
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={onDragActiveChange}
onReorder={reorderBuiltInKeys}
renderRow={(shortcutKey) => (
<ShortcutBarRow
shortcutKey={shortcutKey}
visible={visibleBuiltInSet.has(shortcutKey.id)}
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
/>
)}
/>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={resetBuiltInKeys}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Reset Defaults</Text>
<Text style={styles.rowSublabel}>
Show every built-in shortcut key in the original order
</Text>
</View>
</Pressable>
</View>
<Text style={[styles.groupHeading, styles.groupTopGap]}>CUSTOM SHORTCUTS</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{customKeys.length === 0 ? (
<>
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No custom shortcuts defined yet.</Text>
</View>
<View style={styles.separator} />
</>
) : (
{error && (
<Text accessibilityRole="alert" style={styles.groupDescription}>
{error}
</Text>
)}
<View pointerEvents={busy ? 'none' : 'auto'} accessibilityState={{ busy, disabled: busy }}>
<Text style={[styles.groupHeading, styles.groupTopGap]}>SHORTCUT BAR</Text>
<Text style={styles.groupDescription}>
Toggle keys to show or hide them, and hold the grip to drag a key into the order you want
on the terminal shortcut bar.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<DragReorderList
items={customKeys}
itemKey={(key) => key.id}
items={orderedAccessoryKeys}
itemKey={(shortcutKey) => shortcutKey.id}
rowHeight={REORDER_ROW_HEIGHT}
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={onDragActiveChange}
onReorder={reorderCustomKeys}
renderRow={(key) => (
<View style={styles.reorderRowContent}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{key.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{key.label}</Text>
<Text style={styles.rowSublabel} numberOfLines={1} ellipsizeMode="tail">
{key.bytes.replace(/\r/g, ' ↵')}
</Text>
</View>
<Pressable
style={({ pressed }) => [
styles.deleteButton,
pressed && styles.deleteButtonPressed
]}
onPress={() => handleDeleteCustomKey(key)}
>
<X size={16} color={colors.statusRed} />
</Pressable>
</View>
onReorder={reorderBuiltInKeys}
renderRow={(shortcutKey) => (
<ShortcutBarRow
shortcutKey={shortcutKey}
disabled={busy}
visible={visibleBuiltInSet.has(shortcutKey.id)}
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
/>
)}
/>
)}
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setShowCustomKeyModal(true)}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Add Custom Shortcut…</Text>
<Text style={styles.rowSublabel}>Create key combo or text macro</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
disabled={busy}
onPress={resetBuiltInKeys}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Reset Defaults</Text>
<Text style={styles.rowSublabel}>
Show every built-in shortcut key in the original order
</Text>
</View>
</Pressable>
</View>
<Text style={[styles.groupHeading, styles.groupTopGap]}>CUSTOM SHORTCUTS</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{customKeys.length === 0 ? (
<>
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No custom shortcuts defined yet.</Text>
</View>
<View style={styles.separator} />
</>
) : (
<DragReorderList
items={customKeys}
itemKey={(key) => key.id}
rowHeight={REORDER_ROW_HEIGHT}
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={onDragActiveChange}
onReorder={reorderCustomKeys}
renderRow={(key) => (
<View style={styles.reorderRowContent}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{key.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{key.label}</Text>
<Text style={styles.rowSublabel} numberOfLines={1} ellipsizeMode="tail">
{key.bytes.replace(/\r/g, ' ↵')}
</Text>
</View>
<Pressable
style={({ pressed }) => [
styles.deleteButton,
pressed && styles.deleteButtonPressed
]}
disabled={busy}
accessibilityRole="button"
accessibilityLabel={`Delete ${key.label}`}
onPress={() => handleDeleteCustomKey(key)}
>
<X size={16} color={colors.statusRed} />
</Pressable>
</View>
)}
/>
)}
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
disabled={busy}
accessibilityRole="button"
accessibilityLabel="Add custom shortcut"
onPress={() => setShowCustomKeyModal(true)}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Add Custom Shortcut…</Text>
<Text style={styles.rowSublabel}>Create key combo or text macro</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
</View>
<CustomKeyModal
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
loadKeys={preferences.loadKeys}
saveKeys={preferences.saveKeys}
onKeysChanged={(keys) => {
// Why: the modal already persisted this list; bumping the sequence
// discards refreshes that read storage before its save landed.
@@ -326,103 +204,3 @@ export function TerminalShortcutSettings({
</>
)
}
const styles = StyleSheet.create({
groupHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupTopGap: {
marginTop: spacing.xl
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
lineHeight: 20,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
sectionTopGap: {
marginTop: spacing.sm
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
// Why: rows inside DragReorderList get a fixed height and a trailing grip
// handle from the list itself, so content only pads on the left.
reorderRowContent: {
flex: 1,
height: '100%',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingLeft: spacing.md + 2
},
rowContent: {
flex: 1
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSublabel: {
fontSize: typography.bodySize - 2,
color: colors.textSecondary,
marginTop: 2
},
keycap: {
minWidth: 62,
alignItems: 'center',
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
keycapText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
emptyContainer: {
padding: spacing.md,
alignItems: 'center',
justifyContent: 'center'
},
emptyText: {
fontSize: typography.bodySize,
color: colors.textSecondary,
padding: spacing.md
},
deleteButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(239, 68, 68, 0.1)'
},
deleteButtonPressed: {
backgroundColor: 'rgba(239, 68, 68, 0.2)'
}
})
@@ -0,0 +1,101 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const terminalShortcutSettingsStyles = StyleSheet.create({
groupHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupTopGap: {
marginTop: spacing.xl
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
lineHeight: 20,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
sectionTopGap: {
marginTop: spacing.sm
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
// Why: rows inside DragReorderList get a fixed height and a trailing grip
// handle from the list itself, so content only pads on the left.
reorderRowContent: {
flex: 1,
height: '100%',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingLeft: spacing.md + 2
},
rowContent: {
flex: 1
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSublabel: {
fontSize: typography.bodySize - 2,
color: colors.textSecondary,
marginTop: 2
},
keycap: {
minWidth: 62,
alignItems: 'center',
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
keycapText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
emptyContainer: {
padding: spacing.md,
alignItems: 'center',
justifyContent: 'center'
},
emptyText: {
fontSize: typography.bodySize,
color: colors.textSecondary,
padding: spacing.md
},
deleteButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(239, 68, 68, 0.1)'
},
deleteButtonPressed: {
backgroundColor: 'rgba(239, 68, 68, 0.2)'
}
})
@@ -0,0 +1,67 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useTerminalShortcutSettings } from './use-terminal-shortcut-settings'
import { getDefaultTerminalAccessoryLayout } from '../terminal/terminal-accessory-layout'
import type { TerminalShortcutPreferences } from '../terminal/terminal-settings-operations'
vi.mock('react-native', () => ({ AppState: { addEventListener: () => ({ remove() {} }) } }))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: {} }))
vi.mock('expo-router', async () => {
const { useEffect } = await import('react')
return { useFocusEffect: (callback: () => void) => useEffect(callback, [callback]) }
})
let renderer: ReactTestRenderer | undefined
let state: ReturnType<typeof useTerminalShortcutSettings>
afterEach(() => {
act(() => renderer?.unmount())
renderer = undefined
})
function preferences() {
return {
loadLayout: vi.fn().mockResolvedValue(getDefaultTerminalAccessoryLayout()),
loadKeys: vi.fn().mockResolvedValue([]),
saveLayout: vi.fn().mockResolvedValue(undefined),
saveKeys: vi.fn().mockResolvedValue(undefined)
}
}
async function mount(storage: TerminalShortcutPreferences) {
function Harness() {
state = useTerminalShortcutSettings(storage)
return null
}
await act(async () => {
renderer = create(createElement(Harness))
})
}
describe('terminal shortcut settings persistence', () => {
it('writes layout changes to the injected page or native preference owner', async () => {
const storage = preferences()
await mount(storage)
expect(state.busy).toBe(false)
await act(async () => {
state.toggleBuiltInKey('escape', false)
})
expect(storage.saveLayout).toHaveBeenCalledTimes(1)
expect(storage.saveLayout.mock.calls[0]?.[0].visibleBuiltInIds).not.toContain('escape')
expect(state.visibleBuiltInSet.has('escape')).toBe(false)
expect(state.busy).toBe(false)
})
it('restores the last saved layout and reports failed persistence', async () => {
const storage = preferences()
storage.saveLayout.mockRejectedValue(new Error('storage failed'))
await mount(storage)
await act(async () => {
state.toggleBuiltInKey('escape', false)
})
expect(state.visibleBuiltInSet.has('escape')).toBe(true)
expect(state.error).toContain('Could not save shortcut layout')
expect(storage.saveLayout).toHaveBeenCalledTimes(1)
})
it('never enables controls over unreadable initial data', async () => {
const storage = preferences()
storage.loadKeys.mockRejectedValue(new Error('storage failed'))
await mount(storage)
expect(state.busy).toBe(true)
expect(state.error).toContain('Could not load custom shortcuts')
})
})
@@ -0,0 +1,210 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AppState, type AppStateStatus } from 'react-native'
import { useFocusEffect } from 'expo-router'
import type { CustomKey } from '../storage/terminal-custom-key-storage'
import type { TerminalShortcutPreferences } from '../terminal/terminal-settings-operations'
import { TERMINAL_ACCESSORY_KEYS } from '../terminal/terminal-accessory-keys'
import {
getDefaultTerminalAccessoryLayout,
reorderTerminalAccessoryBuiltInIds,
setTerminalAccessoryBuiltInVisible,
type TerminalAccessoryLayout
} from '../terminal/terminal-accessory-layout'
export function useTerminalShortcutSettings(preferences: TerminalShortcutPreferences) {
const [loadedKeys, setLoadedKeys] = useState(false)
const [loadedLayout, setLoadedLayout] = useState(false)
const [pendingWrites, setPendingWrites] = useState(0)
const [error, setError] = useState<string | null>(null)
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
const [shortcutLayout, setShortcutLayout] = useState<TerminalAccessoryLayout>(
getDefaultTerminalAccessoryLayout
)
const layoutWriteChainRef = useRef<Promise<void>>(Promise.resolve())
const layoutWriteSeqRef = useRef(0)
const pendingLayoutWritesRef = useRef(0)
const persistLayout = useCallback(
(next: TerminalAccessoryLayout) => {
setPendingWrites((count) => count + 1)
setError(null)
layoutWriteSeqRef.current += 1
pendingLayoutWritesRef.current += 1
layoutWriteChainRef.current = layoutWriteChainRef.current
.catch(() => {})
.then(() => preferences.saveLayout(next))
.catch(async () => {
setError('Could not save shortcut layout. Try again.')
try {
setShortcutLayout(await preferences.loadLayout())
} catch {
setLoadedLayout(false)
}
})
.finally(() => {
pendingLayoutWritesRef.current -= 1
setPendingWrites((count) => count - 1)
})
},
[preferences]
)
const refreshShortcutLayout = useCallback(() => {
const refreshSeq = layoutWriteSeqRef.current
void preferences
.loadLayout()
.then((layout) => {
if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) {
return
}
setLoadedLayout(true)
setShortcutLayout({
orderedBuiltInIds: layout.orderedBuiltInIds,
visibleBuiltInIds: layout.visibleBuiltInIds
})
})
.catch(() => setError('Could not load shortcut layout. Go back and try again.'))
}, [preferences])
const customKeysWriteChainRef = useRef<Promise<void>>(Promise.resolve())
const customKeysWriteSeqRef = useRef(0)
const pendingCustomKeysWritesRef = useRef(0)
// Why: same stale-snapshot guard as persistLayout — a focus/AppState refresh
// racing an in-flight save must not overwrite the optimistic state.
const persistCustomKeys = useCallback(
(next: CustomKey[]) => {
setPendingWrites((count) => count + 1)
setError(null)
customKeysWriteSeqRef.current += 1
pendingCustomKeysWritesRef.current += 1
customKeysWriteChainRef.current = customKeysWriteChainRef.current
.catch(() => {})
.then(() => preferences.saveKeys(next))
.catch(async () => {
setError('Could not save custom shortcuts. Try again.')
try {
setCustomKeys(await preferences.loadKeys())
} catch {
setLoadedKeys(false)
}
})
.finally(() => {
pendingCustomKeysWritesRef.current -= 1
setPendingWrites((count) => count - 1)
})
},
[preferences]
)
const refreshCustomKeys = useCallback(() => {
const refreshSeq = customKeysWriteSeqRef.current
void preferences
.loadKeys()
.then((keys) => {
if (
pendingCustomKeysWritesRef.current > 0 ||
refreshSeq !== customKeysWriteSeqRef.current
) {
return
}
setLoadedKeys(true)
setCustomKeys(keys)
})
.catch(() => setError('Could not load custom shortcuts. Go back and try again.'))
}, [preferences])
const handleDeleteCustomKey = useCallback(
(key: CustomKey) => {
const updated = customKeys.filter((entry) => entry.id !== key.id)
setCustomKeys(updated)
persistCustomKeys(updated)
},
[customKeys, persistCustomKeys]
)
useFocusEffect(
useCallback(() => {
refreshShortcutLayout()
refreshCustomKeys()
}, [refreshShortcutLayout, refreshCustomKeys])
)
useEffect(() => {
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s === 'active') {
refreshShortcutLayout()
refreshCustomKeys()
}
})
return () => sub.remove()
}, [refreshShortcutLayout, refreshCustomKeys])
const toggleBuiltInKey = useCallback(
(id: string, visible: boolean) => {
const next = setTerminalAccessoryBuiltInVisible(shortcutLayout, id, visible)
setShortcutLayout(next)
persistLayout(next)
},
[shortcutLayout, persistLayout]
)
const reorderBuiltInKeys = useCallback(
(orderedKeys: string[]) => {
const next = reorderTerminalAccessoryBuiltInIds(shortcutLayout, orderedKeys)
setShortcutLayout(next)
persistLayout(next)
},
[shortcutLayout, persistLayout]
)
const resetBuiltInKeys = useCallback(() => {
const next = getDefaultTerminalAccessoryLayout()
setShortcutLayout(next)
persistLayout(next)
}, [persistLayout])
const reorderCustomKeys = useCallback(
(orderedKeys: string[]) => {
const byId = new Map(customKeys.map((key) => [key.id, key]))
const reordered = orderedKeys.flatMap((id) => {
const key = byId.get(id)
return key ? [key] : []
})
if (reordered.length !== customKeys.length) {
return
}
setCustomKeys(reordered)
persistCustomKeys(reordered)
},
[customKeys, persistCustomKeys]
)
const visibleBuiltInSet = useMemo(
() => new Set(shortcutLayout.visibleBuiltInIds),
[shortcutLayout.visibleBuiltInIds]
)
const orderedAccessoryKeys = useMemo(() => {
const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key]))
return shortcutLayout.orderedBuiltInIds.flatMap((id) => {
const key = byId.get(id)
return key ? [key] : []
})
}, [shortcutLayout.orderedBuiltInIds])
return {
busy: !loadedKeys || !loadedLayout || pendingWrites > 0,
error,
customKeys,
showCustomKeyModal,
setShowCustomKeyModal,
shortcutLayout,
visibleBuiltInSet,
orderedAccessoryKeys,
handleDeleteCustomKey,
toggleBuiltInKey,
reorderBuiltInKeys,
resetBuiltInKeys,
reorderCustomKeys,
customKeysWriteSeqRef,
setCustomKeys
}
}
@@ -2,14 +2,17 @@ import { describe, expect, it } from 'vitest'
import { hostedPageRouteState, hostedPageStateTarget } from './hosted-page-route-state'
describe('hosted page route state', () => {
it.each(['/settings', '/native-chat-settings', '/browser-settings'])(
'roundtrips %s without document-scoped handles',
(pathname) => {
const state = hostedPageRouteState(pathname)
expect(hostedPageStateTarget(state)).toBe(pathname)
expect(JSON.parse(state!)).toEqual({ version: 1, pathname })
}
)
it.each([
'/settings',
'/native-chat-settings',
'/browser-settings',
'/terminal-settings',
'/about'
])('roundtrips %s without document-scoped handles', (pathname) => {
const state = hostedPageRouteState(pathname)
expect(hostedPageStateTarget(state)).toBe(pathname)
expect(JSON.parse(state!)).toEqual({ version: 1, pathname })
})
it.each([
undefined,
@@ -1,5 +1,11 @@
// Only page-owned, durable route names belong here; document-scoped resource handles do not.
const HOSTED_PAGE_ROUTES = new Set(['/settings', '/native-chat-settings', '/browser-settings'])
const HOSTED_PAGE_ROUTES = new Set([
'/settings',
'/about',
'/native-chat-settings',
'/browser-settings',
'/terminal-settings'
])
export function hostedPageRouteState(pathname: string): string | undefined {
return HOSTED_PAGE_ROUTES.has(pathname) ? JSON.stringify({ version: 1, pathname }) : undefined
@@ -6,7 +6,7 @@ import { hostedModuleGraph, hostedRoot, mobileRoot } from './hosted-module-graph
const HOSTED_SESSION_ROUTE = join(hostedRoot, 'h', '[hostId]', 'session', '[worktreeId].tsx')
// Native-only entry points: on the hosted page these either throw or silently do nothing.
const BANNED_IN_HOSTED_BUNDLE = ['Clipboard.setStringAsync', "router.push('/terminal-settings')"]
const BANNED_IN_HOSTED_BUNDLE = ['Clipboard.setStringAsync']
const BANNED_IN_SESSION_ROUTE = [
...BANNED_IN_HOSTED_BUNDLE,
'Linking.openURL',
@@ -32,6 +32,8 @@ describe('hosted route native API census', () => {
const graph = hostedModuleGraph()
expect(graph.length).toBeGreaterThan(500)
expect(graph).toContain(join(hostedRoot, 'terminal-settings.tsx'))
// Terminal settings now has a real hosted route; it is no longer a native-only destination.
// AsyncStorage is deliberately not censused: it is reachable today through the shared
// storage and transport modules, and on web it resolves to localStorage.
expect(offenders(graph, BANNED_IN_HOSTED_BUNDLE)).toEqual([])
@@ -65,7 +65,13 @@ describe('mobile native shell route ownership', () => {
it('hosts migrated preferences while retaining native recovery screens', () => {
const hostedRoutePaths = listRouteFiles(hostedRouteRoot)
const hostedSettings = ['settings', 'native-chat-settings', 'browser-settings']
const hostedSettings = [
'settings',
'native-chat-settings',
'browser-settings',
'terminal-settings',
'about'
]
for (const routeName of hostedSettings) {
expect(hostedRoutePaths).toContain(`${routeName}.tsx`)
}
@@ -71,8 +71,8 @@ const HEAD_MAIN_HOOK_SHA256 = '8a65c402639980ffda9132ce3dba84da8d85998f5c6b933a8
const HEAD_HOOK_BINDING_SHA256 = 'e43ab1ae9e6fd0eb126558207868f1da6322ba50bdeb4a6f7132e6a1f29a9474'
const HEAD_CALLBACK_IDENTITY_SHA256 =
'3ad3c833aa99bbfd3a4038bae70a0247192f51fb938a2fe3df86626dcfa3386e'
const HEAD_CALLBACK_BODY_SHA256 = '105697776621cd4eaa4242191844d562c6bf3673fdb42a0736da959a2ca8a574'
const HEAD_EFFECT_SHA256 = 'f81ef4b4794875643dd429e9dfb6cffab037feb68a334e260c0045a258c07d51'
const HEAD_CALLBACK_BODY_SHA256 = '4fd9fef0051a6eacd9c870bd577aeb17e6fac734d998c2a5023800eb335c573b'
const HEAD_EFFECT_SHA256 = '36e816e2114ad1b21e3bc88fea12f0ffdd8a830a5f2aeff90146ca71e626b2ab'
const HEAD_CONTENT_HOOK_SHA256 = 'd74431115b27c22dd38c29a510604554ca767cdd2585beaa73ec2e2dae0c5de4'
// Re-frozen when main's structured Claude chat (#18741) generalized the bare-launch gate
// from an `agent === 'codex'` literal to isAgentSessionHandleProvider.
@@ -22,7 +22,7 @@ describe('mobile web session screen binding', () => {
expect(hostedSessionRoute).toContain('webHostSessionTerminalOperations(shell.client)')
expect(hostedSessionRoute).toContain('webHostSessionFileOperations(shell.client)')
expect(hostedSessionRoute).toContain('webHostSessionMarkdownOperations(shell.client)')
expect(hostedSessionRoute).toContain('webHostSessionDeviceOperations(shell.client)')
expect(hostedSessionRoute).toContain('webHostSessionDeviceOperations(shell.client,')
expect(hostedSessionRoute).toContain('webHostSessionBrowserOperations(shell.client)')
expect(hostedSessionRoute).toContain('webHostSessionDictationOperations(shell.client)')
expect(hostedSessionRoute).toContain('webHostSessionNativeChatOperations(shell.client)')
@@ -46,12 +46,15 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
useFocusEffect(
useCallback(() => {
let stale = false
void sessionDeviceOperations?.loadTerminalAccessoryPreferences().then((preferences) => {
if (!stale) {
setCustomKeys(preferences.customKeys)
setVisibleBuiltInIds(preferences.visibleBuiltInIds)
}
})
void sessionDeviceOperations
?.loadTerminalAccessoryPreferences()
.then((preferences) => {
if (!stale) {
setCustomKeys(preferences.customKeys)
setVisibleBuiltInIds(preferences.visibleBuiltInIds)
}
})
.catch(() => {})
return () => {
stale = true
}
@@ -61,12 +64,15 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
useEffect(() => {
let mounted = true
const refresh = () => {
void sessionDeviceOperations?.loadTerminalAccessoryPreferences().then((preferences) => {
if (mounted) {
setCustomKeys(preferences.customKeys)
setVisibleBuiltInIds(preferences.visibleBuiltInIds)
}
})
void sessionDeviceOperations
?.loadTerminalAccessoryPreferences()
.then((preferences) => {
if (mounted) {
setCustomKeys(preferences.customKeys)
setVisibleBuiltInIds(preferences.visibleBuiltInIds)
}
})
.catch(() => {})
}
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s === 'active') {
@@ -4,7 +4,7 @@ import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-w
import { webHostSessionDeviceOperations } from './web-host-session-device-operations'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: vi.fn() }
default: { getItem: vi.fn(), setItem: vi.fn().mockResolvedValue(undefined) }
}))
describe('web host session device operations', () => {
@@ -15,7 +15,9 @@ describe('web host session device operations', () => {
it('applies the paired-host page preference to terminal link behavior', async () => {
const client = bridgeClient()
client.native.supports.mockReturnValue(true)
vi.mocked(AsyncStorage.getItem).mockResolvedValue('orca-browser')
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) =>
key === 'orca:terminalLinkOpenMode' ? 'orca-browser' : null
)
const operations = webHostSessionDeviceOperations(client as unknown as MobileWebBridgeClient)
await expect(operations.loadTerminalPreferences()).resolves.toEqual({
textScale: 1.25,
@@ -31,7 +33,7 @@ describe('web host session device operations', () => {
const operations = webHostSessionDeviceOperations(client as unknown as MobileWebBridgeClient)
expect((await operations.loadTerminalPreferences()).linkOpenMode).toBe('phone-browser')
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('temporarily unavailable'))
expect((await operations.loadTerminalPreferences()).linkOpenMode).toBe('phone-browser')
await expect(operations.loadTerminalPreferences()).rejects.toThrow('temporarily unavailable')
})
it('retains the native preference on shells without page storage', async () => {
@@ -40,6 +42,57 @@ describe('web host session device operations', () => {
expect((await operations.loadTerminalPreferences()).linkOpenMode).toBe('phone-browser')
expect(AsyncStorage.getItem).not.toHaveBeenCalled()
})
it('uses saved scale and autocomplete and stores pinch changes in the same page scope', async () => {
const client = bridgeClient()
client.native.supports.mockReturnValue(true)
vi.mocked(AsyncStorage.getItem).mockImplementation(
async (key) =>
({
'orca:terminalTextScale': '1.5',
'orca:terminalAutocompleteEnabled': 'false'
})[key] ?? null
)
const navigate = vi.fn()
const operations = webHostSessionDeviceOperations(
client as unknown as MobileWebBridgeClient,
navigate
)
expect(await operations.loadTerminalPreferences()).toEqual({
textScale: 1.5,
autocompleteEnabled: false,
linkOpenMode: 'phone-browser'
})
await operations.saveTerminalTextScale(2)
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:terminalTextScale', '2')
expect(client.native.terminalTextScaleUpdate).not.toHaveBeenCalled()
operations.openTerminalSettings()
expect(navigate).toHaveBeenCalledWith('/terminal-settings')
expect(client.navigationRoute).not.toHaveBeenCalled()
})
it('keeps an explicitly empty custom-key list and shares session edits with settings', async () => {
const client = bridgeClient()
client.native.supports.mockReturnValue(true)
const key = { id: 'build', label: 'Build', bytes: 'make', enter: true }
client.native.terminalAccessoryPreferences.mockResolvedValue({
customKeys: [key],
orderedBuiltInIds: ['escape', 'tab'],
visibleBuiltInIds: []
})
vi.mocked(AsyncStorage.getItem).mockImplementation(async (name) =>
name === 'orca:custom-accessory-keys' ? '[]' : null
)
const operations = webHostSessionDeviceOperations(client as unknown as MobileWebBridgeClient)
expect((await operations.loadTerminalAccessoryPreferences()).customKeys).toEqual([])
await operations.saveTerminalCustomKeys([key])
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:custom-accessory-keys',
JSON.stringify([key])
)
expect(client.native.terminalCustomKeysUpdate).not.toHaveBeenCalled()
})
it('routes shell-owned effects through named native bridge methods', async () => {
const client = bridgeClient()
const operations = webHostSessionDeviceOperations(client as unknown as MobileWebBridgeClient)
@@ -1,9 +1,16 @@
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
import { loadTerminalLinkOpenMode } from '../storage/preferences'
import { saveTerminalTextScale } from '../storage/preferences'
import { saveCustomKeys } from '../storage/terminal-custom-key-storage'
import {
loadWebHostTerminalPreferences,
loadWebHostTerminalAccessoryPreferences
} from '../terminal/web-terminal-preferences'
export { loadWebHostTerminalPreferences } from '../terminal/web-terminal-preferences'
import type { HostSessionDeviceOperations } from './host-session-device-operations'
export function webHostSessionDeviceOperations(
client: MobileWebBridgeClient
client: MobileWebBridgeClient,
navigate?: (target: string) => void
): HostSessionDeviceOperations {
return {
hapticFeedback(kind) {
@@ -19,30 +26,31 @@ export function webHostSessionDeviceOperations(
await client.native.openExternal(url)
},
openTerminalSettings() {
void client.navigationRoute({ destination: 'terminalSettings' }).catch(() => {})
if (navigate && client.native.supports('pagePreferences')) {
navigate('/terminal-settings')
} else {
void client.navigationRoute({ destination: 'terminalSettings' }).catch(() => {})
}
},
loadTerminalPreferences() {
return loadWebHostTerminalPreferences(client)
},
loadTerminalAccessoryPreferences() {
return client.native.terminalAccessoryPreferences()
return loadWebHostTerminalAccessoryPreferences(client)
},
async saveTerminalCustomKeys(customKeys) {
await client.native.terminalCustomKeysUpdate(customKeys)
if (client.native.supports('pagePreferences')) {
await saveCustomKeys([...customKeys])
} else {
await client.native.terminalCustomKeysUpdate(customKeys)
}
},
async saveTerminalTextScale(textScale) {
await client.native.terminalTextScaleUpdate(textScale)
if (client.native.supports('pagePreferences')) {
await saveTerminalTextScale(textScale)
} else {
await client.native.terminalTextScaleUpdate(textScale)
}
}
}
}
export async function loadWebHostTerminalPreferences(client: MobileWebBridgeClient) {
const preferences = await client.native.terminalPreferences()
if (!client.native.supports('pagePreferences')) {
return preferences
}
return {
...preferences,
linkOpenMode: await loadTerminalLinkOpenMode(preferences.linkOpenMode)
}
}
+186
View File
@@ -0,0 +1,186 @@
import { useState } from 'react'
import { View, Text, StyleSheet, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { ChevronLeft, Globe } from 'lucide-react-native'
import Svg, { Path } from 'react-native-svg'
import { OrcaLogo } from '../components/OrcaLogo'
import { colors, spacing, typography } from '../theme/mobile-theme'
function GithubIcon({ size = 16, color = colors.textSecondary }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</Svg>
)
}
function XIcon({ size = 16, color = colors.textSecondary }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</Svg>
)
}
export default function AboutScreen({
onBack,
openExternal,
versionLabel,
linksAvailable = true
}: {
onBack: () => void
openExternal: (url: string) => Promise<unknown>
versionLabel: string
linksAvailable?: boolean
}) {
const [error, setError] = useState<string | null>(null)
const openLink = (url: string) => {
setError(null)
void openExternal(url).catch(() => setError('Could not open the link. Try again.'))
}
const insets = useSafeAreaInsets()
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
style={styles.backButton}
accessibilityRole="button"
accessibilityLabel="Back"
onPress={onBack}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>About</Text>
</View>
<View style={styles.brand}>
<OrcaLogo size={28} />
<Text style={styles.brandName}>Orca</Text>
<Text style={styles.brandSub}>Open-source agent IDE for 100x builders</Text>
</View>
<View style={styles.section}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
accessibilityLabel="Orca website"
disabled={!linksAvailable}
onPress={() => openLink('https://onOrca.dev')}
>
<Globe size={16} color={colors.textSecondary} />
<Text style={styles.rowValue}>onOrca.dev</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
accessibilityLabel="Orca source code"
disabled={!linksAvailable}
onPress={() => openLink('https://github.com/stablyai/orca')}
>
<GithubIcon />
<Text style={styles.rowValue}>stablyai/orca</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
accessibilityLabel="Orca on X"
disabled={!linksAvailable}
onPress={() => openLink('https://x.com/orca_build')}
>
<XIcon />
<Text style={styles.rowValue}>@orca_build</Text>
</Pressable>
</View>
<Text style={styles.versionText}>{versionLabel}</Text>
{error && (
<Text accessibilityRole="alert" style={styles.versionText}>
{error}
</Text>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.xl
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
brand: {
alignItems: 'center',
paddingVertical: spacing.xl,
marginBottom: spacing.lg
},
brandName: {
fontSize: 22,
fontWeight: '800',
color: colors.textPrimary,
marginTop: spacing.sm
},
brandSub: {
fontSize: 13,
color: colors.textMuted,
marginTop: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowLabel: {
flex: 1,
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowValue: {
flex: 1,
textAlign: 'right',
fontSize: typography.bodySize,
color: colors.textSecondary
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
versionText: {
marginTop: spacing.lg,
textAlign: 'center',
fontSize: typography.metaSize,
color: colors.textMuted
}
})
+20 -11
View File
@@ -42,18 +42,22 @@ const TEXT_SCALE_KEY = 'orca:terminalTextScale'
export const TERMINAL_TEXT_SCALES = [0.5, 0.75, 1, 1.25, 1.5, 2] as const
const DEFAULT_TEXT_SCALE = 1
export async function loadTerminalTextScale(): Promise<number> {
export async function loadTerminalTextScale(
options: { fallback?: number; rejectReadFailure?: boolean } = {}
): Promise<number> {
const fallback = options.fallback ?? DEFAULT_TEXT_SCALE
try {
const raw = await AsyncStorage.getItem(TEXT_SCALE_KEY)
if (raw === null) {
return DEFAULT_TEXT_SCALE
return fallback
}
const parsed = Number(raw)
return (TERMINAL_TEXT_SCALES as readonly number[]).includes(parsed)
? parsed
: DEFAULT_TEXT_SCALE
} catch {
return DEFAULT_TEXT_SCALE
return (TERMINAL_TEXT_SCALES as readonly number[]).includes(parsed) ? parsed : fallback
} catch (error) {
if (options.rejectReadFailure) {
throw error
}
return fallback
}
}
@@ -66,12 +70,17 @@ const AUTOCOMPLETE_KEY = 'orca:terminalAutocompleteEnabled'
// Why: terminal command inputs default to autocorrect/suggestions OFF so the
// keyboard never mangles commands, flags, or paths. Users who want phone-style
// typing opt in via Settings → Terminal; the choice persists locally per device.
export async function loadTerminalAutocompleteEnabled(): Promise<boolean> {
export async function loadTerminalAutocompleteEnabled(
options: { fallback?: boolean; rejectReadFailure?: boolean } = {}
): Promise<boolean> {
try {
const raw = await AsyncStorage.getItem(AUTOCOMPLETE_KEY)
return raw === 'true'
} catch {
return false
return raw === null ? (options.fallback ?? false) : raw === 'true'
} catch (error) {
if (options.rejectReadFailure) {
throw error
}
return options.fallback ?? false
}
}
@@ -9,12 +9,35 @@ export type CustomKey = {
enter: boolean
}
export async function loadCustomKeys(): Promise<CustomKey[]> {
export async function loadCustomKeys(
options: { fallback?: CustomKey[]; rejectReadFailure?: boolean } = {}
): Promise<CustomKey[]> {
try {
const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)
return raw ? (JSON.parse(raw) as CustomKey[]) : []
} catch {
return []
if (raw === null) {
return options.fallback ?? []
}
const value: unknown = JSON.parse(raw)
if (
!Array.isArray(value) ||
!value.every(
(key) =>
key !== null &&
typeof key === 'object' &&
typeof key.id === 'string' &&
typeof key.label === 'string' &&
typeof key.bytes === 'string' &&
typeof key.enter === 'boolean'
)
) {
throw new Error('Invalid custom shortcuts')
}
return value as CustomKey[]
} catch (error) {
if (options.rejectReadFailure) {
throw error
}
return options.fallback ?? []
}
}
@@ -0,0 +1,28 @@
import type { RpcClient } from '../transport/rpc-client'
import type { TerminalSettingsHost } from './terminal-settings-operations'
export function nativeTerminalSettingsHost(
host: { id: string; name: string },
client: RpcClient | null
): TerminalSettingsHost {
async function request(method: string, params?: unknown): Promise<number | null> {
if (!client) {
throw new Error('Host is disconnected')
}
const response = await client.sendRequest(method, params)
if (!response.ok) {
throw new Error(response.error.message)
}
const result = response.result as { ms?: unknown } | null
if (result?.ms !== null && (typeof result?.ms !== 'number' || !Number.isFinite(result.ms))) {
throw new Error('Invalid terminal restore preference')
}
return result.ms as number | null
}
return {
id: host.id,
name: host.name,
loadFit: () => request('terminal.getAutoRestoreFit'),
saveFit: (ms) => request('terminal.setAutoRestoreFit', { ms })
}
}
@@ -212,15 +212,23 @@ export function getVisibleTerminalAccessoryKeys(
})
}
export async function loadTerminalAccessoryLayout(): Promise<TerminalAccessoryLayoutPreference> {
export async function loadTerminalAccessoryLayout(
options: { fallback?: TerminalAccessoryLayout; rejectReadFailure?: boolean } = {}
): Promise<TerminalAccessoryLayoutPreference> {
const fallback = options.fallback
? normalizeTerminalAccessoryLayoutPreference({ version: 2, ...options.fallback })
: defaultPreference()
try {
const raw = await AsyncStorage.getItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY)
if (!raw) {
return defaultPreference()
return fallback
}
return normalizeTerminalAccessoryLayoutPreference(JSON.parse(raw))
} catch {
return defaultPreference()
} catch (error) {
if (options.rejectReadFailure) {
throw error
}
return fallback
}
}
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest'
import { nativeTerminalSettingsHost } from './native-terminal-settings-host'
import {
webTerminalSettingsHost,
webTerminalSettingsOperations
} from './web-terminal-settings-operations'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: async () => null }
}))
describe('terminal restore settings adapters', () => {
it('shares the single native accessory-read slot across settings sections', async () => {
const client = {
native: {
supports: () => true,
terminalAccessoryPreferences: vi
.fn()
.mockResolvedValue({
customKeys: [],
orderedBuiltInIds: ['escape'],
visibleBuiltInIds: []
})
}
}
const settings = webTerminalSettingsOperations(client as unknown as MobileWebBridgeClient)
await Promise.all([settings.loadKeys(), settings.loadLayout()])
expect(client.native.terminalAccessoryPreferences).toHaveBeenCalledTimes(1)
})
it('unwraps the actual native RPC result and surfaces refusal', async () => {
const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: { ms: 60000 } })
const host = nativeTerminalSettingsHost({ id: 'host', name: 'Desktop' }, {
sendRequest
} as unknown as RpcClient)
expect(await host.loadFit()).toBe(60000)
sendRequest.mockResolvedValue({ ok: true, result: { ms: null } })
expect(await host.saveFit(null)).toBe(null)
sendRequest.mockResolvedValue({ ok: false, error: { message: 'denied' } })
await expect(host.saveFit(60000)).rejects.toThrow('denied')
expect(sendRequest).toHaveBeenLastCalledWith('terminal.setAutoRestoreFit', { ms: 60000 })
})
it('negotiates both host methods and sends no invented workspace', async () => {
const client = fixture()
const host = await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient)
expect(await host?.loadFit()).toBe(60000)
expect(client.host.request).toHaveBeenCalledWith({
method: 'terminal.getAutoRestoreFit',
params: {}
})
await host?.saveFit(null)
expect(client.host.request).toHaveBeenLastCalledWith({
method: 'terminal.setAutoRestoreFit',
params: { ms: null }
})
})
it('does not dispatch when the shell or catalog lacks host-scoped methods', async () => {
const client = fixture()
client.supportsShellFeature.mockReturnValue(false)
expect(await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient)).toBe(null)
expect(client.host.catalog).not.toHaveBeenCalled()
client.supportsShellFeature.mockReturnValue(true)
client.host.catalog.mockResolvedValue({ grants: [] })
expect(await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient)).toBe(null)
expect(client.host.request).not.toHaveBeenCalled()
})
it('does not retry an ambiguous host mutation', async () => {
const client = fixture()
const host = await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient)
client.host.request.mockRejectedValue(new Error('connection lost'))
await expect(host?.saveFit(60000)).rejects.toThrow('connection lost')
expect(client.host.request).toHaveBeenCalledTimes(1)
})
})
function fixture() {
return {
supportsShellFeature: vi.fn(() => true),
host: {
catalog: vi.fn().mockResolvedValue({
grants: ['terminal.getAutoRestoreFit', 'terminal.setAutoRestoreFit'].map((method) => ({
method,
scope: 'host'
}))
}),
request: vi.fn().mockResolvedValue({ ms: 60000 })
}
}
}
@@ -0,0 +1,49 @@
import {
loadTerminalAutocompleteEnabled,
loadTerminalTextScale,
saveTerminalAutocompleteEnabled,
saveTerminalTextScale
} from '../storage/preferences'
import {
loadCustomKeys,
saveCustomKeys,
type CustomKey
} from '../storage/terminal-custom-key-storage'
import {
loadTerminalAccessoryLayout,
saveTerminalAccessoryLayout,
type TerminalAccessoryLayout
} from './terminal-accessory-layout'
export type TerminalShortcutPreferences = {
loadKeys: () => Promise<CustomKey[]>
saveKeys: (keys: CustomKey[]) => Promise<void>
loadLayout: () => Promise<TerminalAccessoryLayout>
saveLayout: (layout: TerminalAccessoryLayout) => Promise<void>
}
export type TerminalSettingsOperations = TerminalShortcutPreferences & {
loadPreferences: () => Promise<{ textScale: number; autocompleteEnabled: boolean }>
saveTextScale: (scale: number) => Promise<void>
saveAutocomplete: (enabled: boolean) => Promise<void>
}
export type TerminalSettingsHost = {
id: string
name: string
loadFit: () => Promise<number | null>
saveFit: (ms: number | null) => Promise<number | null>
}
export const nativeTerminalSettingsOperations: TerminalSettingsOperations = {
async loadPreferences() {
const [textScale, autocompleteEnabled] = await Promise.all([
loadTerminalTextScale({ rejectReadFailure: true }),
loadTerminalAutocompleteEnabled({ rejectReadFailure: true })
])
return { textScale, autocompleteEnabled }
},
saveTextScale: saveTerminalTextScale,
saveAutocomplete: saveTerminalAutocompleteEnabled,
loadKeys: () => loadCustomKeys({ rejectReadFailure: true }),
saveKeys: saveCustomKeys,
loadLayout: () => loadTerminalAccessoryLayout({ rejectReadFailure: true }),
saveLayout: saveTerminalAccessoryLayout
}
@@ -0,0 +1,68 @@
import type { PickerOption } from '../components/PickerModal'
export type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
export type TextSizeValue = 'smallest' | 'smaller' | 'default' | 'large' | 'larger' | 'largest'
// scale = baseline zoom the terminal WebView applies on top of fit-to-width.
// Keep in sync with TERMINAL_TEXT_SCALES; pinch-to-zoom snaps to these values.
export const TEXT_SIZE_OPTIONS: (PickerOption<TextSizeValue> & { scale: number })[] = [
{ value: 'smallest', label: 'Smallest (50%)', scale: 0.5 },
{ value: 'smaller', label: 'Smaller (75%)', scale: 0.75 },
{ value: 'default', label: 'Default (100%)', scale: 1 },
{ value: 'large', label: 'Large (125%)', scale: 1.25 },
{ value: 'larger', label: 'Larger (150%)', scale: 1.5 },
{ value: 'largest', label: 'Largest (200%)', scale: 2 }
]
export function textSizeValueFromScale(scale: number): TextSizeValue {
return TEXT_SIZE_OPTIONS.find((o) => o.scale === scale)?.value ?? 'default'
}
export function textSizeSummary(scale: number): string {
return (TEXT_SIZE_OPTIONS.find((o) => o.scale === scale) ?? TEXT_SIZE_OPTIONS[0]!).label
}
export const AUTO_RESTORE_FIT_OPTIONS: (PickerOption<RestoreValue> & { ms: number | null })[] = [
{ value: 'indefinite', label: 'Keep at phone size (default)', ms: null },
{ value: '60s', label: 'After 1 minute', ms: 60_000 },
{ value: '5m', label: 'After 5 minutes', ms: 5 * 60_000 },
{ value: '30m', label: 'After 30 minutes', ms: 30 * 60_000 }
]
export function valueFromMs(ms: number | null | undefined): RestoreValue {
if (ms == null) {
return 'indefinite'
}
const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms)
if (exact) {
return exact.value
}
// Why: server may return a non-preset ms (custom value, future preset,
// or server-side clamp). Snap to the closest finite preset so the
// picker's selected radio agrees with the row sublabel rendered by
// autoRestoreSummary ("After Xs").
let closest: (typeof AUTO_RESTORE_FIT_OPTIONS)[number] | null = null
let bestDelta = Infinity
for (const opt of AUTO_RESTORE_FIT_OPTIONS) {
if (opt.ms == null) {
continue
}
const delta = Math.abs(opt.ms - ms)
if (delta < bestDelta) {
bestDelta = delta
closest = opt
}
}
return closest ? closest.value : 'indefinite'
}
export function autoRestoreSummary(ms: number | null | undefined): string {
if (ms === undefined) {
return '…'
}
if (ms === null) {
return AUTO_RESTORE_FIT_OPTIONS[0]!.label
}
const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms)
return exact ? exact.label : `After ${Math.round(ms / 1000)}s`
}
@@ -0,0 +1,252 @@
import { useCallback } from 'react'
import { View, Text, Pressable, Switch, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, {
useAnimatedRef,
useAnimatedScrollHandler,
useSharedValue
} from 'react-native-reanimated'
import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native'
import { colors, spacing } from '../theme/mobile-theme'
import { PickerModal } from '../components/PickerModal'
import { TerminalShortcutSettings } from '../components/TerminalShortcutSettings'
import { terminalSettingsScreenStyles as styles } from './terminal-settings-screen-styles'
import {
TEXT_SIZE_OPTIONS,
AUTO_RESTORE_FIT_OPTIONS,
textSizeValueFromScale,
textSizeSummary,
valueFromMs,
autoRestoreSummary,
type RestoreValue,
type TextSizeValue
} from './terminal-settings-options'
import type {
TerminalSettingsHost,
TerminalSettingsOperations
} from './terminal-settings-operations'
import { useTerminalSettingsState } from './use-terminal-settings-state'
function HostFitRow({
available,
hostName,
ms,
onPress
}: {
available: boolean
hostName: string
ms: number | null | undefined
onPress: () => void
}): React.JSX.Element {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Restore ${hostName}`}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
disabled={!available}
>
<Smartphone size={16} color={colors.textSecondary} />
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{hostName}</Text>
<Text style={styles.rowSublabel}>{autoRestoreSummary(ms)}</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
)
}
export default function TerminalSettingsScreen({
hosts,
operations,
onBack,
hostUnavailableMessage,
scope = 'device'
}: {
scope?: 'device' | 'host'
hostUnavailableMessage?: string
hosts: TerminalSettingsHost[]
operations: TerminalSettingsOperations
onBack: () => void
}) {
const insets = useSafeAreaInsets()
const {
hostMs,
pickerHostId,
setPickerHostId,
textScale,
textSizePickerOpen,
setTextSizePickerOpen,
autocompleteEnabled,
selectTextSize,
toggleAutocomplete,
selectValue,
busy,
error
} = useTerminalSettingsState(hosts, operations)
const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null
const scrollRef = useAnimatedRef<Animated.ScrollView>()
const scrollOffsetY = useSharedValue(0)
const scrollContentHeight = useSharedValue(0)
const scrollHandler = useAnimatedScrollHandler((event) => {
scrollOffsetY.value = event.contentOffset.y
})
// Why: imperative toggle instead of state — a re-render while a drag gesture
// is active would rebuild the row gestures and could cancel the drag.
const setScrollEnabled = useCallback(
(enabled: boolean) => {
scrollRef.current?.setNativeProps({ scrollEnabled: enabled })
},
[scrollRef]
)
const handleDragActiveChange = useCallback(
(active: boolean) => setScrollEnabled(!active),
[setScrollEnabled]
)
return (
<GestureHandlerRootView style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
accessibilityLabel="Back"
accessibilityRole="button"
style={styles.backButton}
onPress={onBack}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Terminal</Text>
</View>
<Animated.ScrollView
ref={scrollRef}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
onContentSizeChange={(_width, height) => {
scrollContentHeight.value = height
}}
>
{error && (
<Text accessibilityRole="alert" style={styles.groupDescription}>
{error}
</Text>
)}
<Text style={styles.groupHeading}>WHEN YOU LEAVE THE APP</Text>
<Text style={styles.groupDescription}>
While you&apos;re using a terminal on your phone, Orca shrinks it to fit your screen. When
you close the app or switch away, this controls whether it stays at phone size (so
interactive CLI tools don&apos;t reflow) or resizes back to your desktop. You can always
use Restore this terminal or Restore all terminals on the banner to resize manually.
</Text>
{hosts.length === 0 ? (
<View style={[styles.section, styles.sectionTopGap]}>
<Text style={styles.emptyText}>
{hostUnavailableMessage ??
'No paired desktops yet. Pair one to control terminal behavior.'}
</Text>
</View>
) : (
<View style={[styles.section, styles.sectionTopGap]}>
{hosts.map((host, idx) => {
return (
<View key={host.id}>
{idx > 0 && <View style={styles.separator} />}
<HostFitRow
available={!busy && hostMs[host.id] !== undefined}
hostName={host.name}
ms={hostMs[host.id]}
onPress={() => setPickerHostId(host.id)}
/>
</View>
)
})}
</View>
)}
<Text style={[styles.groupHeading, styles.inputGroupGap]}>TEXT SIZE</Text>
<Text style={styles.groupDescription}>
Scale the terminal text. Smaller sizes fit more columns with side margins; larger sizes
show fewer columns — drag sideways to pan. You can also pinch to zoom in the terminal
itself, which updates this setting. Display only
{scope === 'host' ? ' on this device for this paired host' : ' on this device'};
doesn&apos;t change the desktop terminal.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
accessibilityLabel="Text size"
disabled={busy}
onPress={() => setTextSizePickerOpen(true)}
>
<Type size={16} color={colors.textSecondary} />
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Text size</Text>
<Text style={styles.rowSublabel}>{textSizeSummary(textScale)}</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
<Text style={[styles.groupHeading, styles.inputGroupGap]}>KEYBOARD INPUT</Text>
<Text style={styles.groupDescription}>
Enable phone-style autocomplete, autocorrect, and spelling suggestions in the terminal
command bar. Off by default so the keyboard never rewrites commands, flags, or paths.
Direct keyboard input (when keys go straight to the terminal) always sends raw keystrokes,
so suggestions don&apos;t apply there.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Autocomplete &amp; autocorrect</Text>
<Text style={styles.rowSublabel}>{autocompleteEnabled ? 'On' : 'Off'}</Text>
</View>
<Switch
accessibilityLabel="Autocomplete and autocorrect"
accessibilityState={{ busy, disabled: busy }}
disabled={busy}
value={autocompleteEnabled}
onValueChange={toggleAutocomplete}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
{...(Platform.OS === 'web' ? { activeThumbColor: colors.textPrimary } : {})}
/>
</View>
</View>
<TerminalShortcutSettings
preferences={operations}
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={handleDragActiveChange}
/>
</Animated.ScrollView>
<PickerModal<RestoreValue>
visible={pickerHost != null}
title={pickerHost ? `Restore ${pickerHost.name}` : ''}
options={AUTO_RESTORE_FIT_OPTIONS}
selected={valueFromMs(pickerHost ? hostMs[pickerHost.id] : null)}
onSelect={(v) => {
if (pickerHost) {
void selectValue(pickerHost.id, v)
}
}}
onClose={() => setPickerHostId(null)}
/>
<PickerModal<TextSizeValue>
visible={textSizePickerOpen}
title="Terminal text size"
options={TEXT_SIZE_OPTIONS}
selected={textSizeValueFromScale(textScale)}
onSelect={selectTextSize}
onClose={() => setTextSizePickerOpen(false)}
/>
</GestureHandlerRootView>
)
}
@@ -0,0 +1,100 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useTerminalSettingsState } from './use-terminal-settings-state'
import type {
TerminalSettingsOperations,
TerminalSettingsHost
} from './terminal-settings-operations'
let renderer: ReactTestRenderer | undefined
let state: ReturnType<typeof useTerminalSettingsState>
afterEach(() => {
act(() => renderer?.unmount())
renderer = undefined
})
function fixture() {
return {
loadPreferences: vi.fn().mockResolvedValue({ textScale: 1.25, autocompleteEnabled: true }),
saveTextScale: vi.fn().mockResolvedValue(undefined),
saveAutocomplete: vi.fn().mockResolvedValue(undefined)
} as unknown as TerminalSettingsOperations
}
async function mount(operations: TerminalSettingsOperations, hosts: TerminalSettingsHost[] = []) {
function Harness() {
state = useTerminalSettingsState(hosts, operations)
return null
}
await act(async () => {
renderer = create(createElement(Harness))
})
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { resolve, promise }
}
describe('terminal settings state', () => {
it('does not enable preferences until storage loads or show a change before it saves', async () => {
const load = deferred<{ textScale: number; autocompleteEnabled: boolean }>()
const save = deferred<void>()
const operations = fixture()
vi.mocked(operations.loadPreferences).mockReturnValue(load.promise)
vi.mocked(operations.saveAutocomplete).mockReturnValue(save.promise)
await mount(operations)
expect(state.busy).toBe(true)
await act(async () => {
load.resolve({ textScale: 1.5, autocompleteEnabled: false })
})
expect(state.textScale).toBe(1.5)
expect(state.busy).toBe(false)
act(() => state.toggleAutocomplete(true))
expect(state.busy).toBe(true)
expect(state.autocompleteEnabled).toBe(false)
await act(async () => {
save.resolve()
})
expect(state.busy).toBe(false)
expect(state.autocompleteEnabled).toBe(true)
})
it('keeps controls disabled when storage cannot be read', async () => {
const operations = fixture()
vi.mocked(operations.loadPreferences).mockRejectedValue(new Error('storage unavailable'))
await mount(operations)
expect(state.busy).toBe(true)
expect(state.error).toContain('Could not load')
})
it('preserves the confirmed setting when saving fails', async () => {
const operations = fixture()
vi.mocked(operations.saveTextScale).mockRejectedValue(new Error('storage unavailable'))
await mount(operations)
await act(async () => {
state.selectTextSize('largest')
})
expect(state.textScale).toBe(1.25)
expect(state.busy).toBe(false)
expect(state.error).toContain('Could not save')
})
it('reflects the host acknowledgement and never retries failed host mutations', async () => {
const host = {
id: 'host',
name: 'Desktop',
loadFit: vi.fn().mockResolvedValue(null),
saveFit: vi.fn().mockResolvedValue(75000)
}
await mount(fixture(), [host])
await act(async () => {
await state.selectValue('host', '60s')
})
expect(state.hostMs.host).toBe(75000)
host.saveFit.mockRejectedValue(new Error('connection lost'))
await act(async () => {
await state.selectValue('host', '5m')
})
expect(state.hostMs.host).toBe(75000)
expect(host.saveFit).toHaveBeenCalledTimes(2)
expect(state.error).toContain('Could not save')
})
})
@@ -0,0 +1,137 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type {
TerminalSettingsHost,
TerminalSettingsOperations
} from './terminal-settings-operations'
import {
AUTO_RESTORE_FIT_OPTIONS,
TEXT_SIZE_OPTIONS,
type RestoreValue,
type TextSizeValue
} from './terminal-settings-options'
import { setTerminalAutoRestoreFitMsForHost } from './terminal-auto-restore-fit-state'
export function useTerminalSettingsState(
hosts: TerminalSettingsHost[],
operations: TerminalSettingsOperations
) {
const [hostMs, setHostMs] = useState<Record<string, number | null | undefined>>({})
const [pickerHostId, setPickerHostId] = useState<string | null>(null)
const [textScale, setTextScale] = useState(1)
const [textSizePickerOpen, setTextSizePickerOpen] = useState(false)
const [autocompleteEnabled, setAutocompleteEnabled] = useState(false)
const [busy, setBusy] = useState(true)
const [error, setError] = useState<string | null>(null)
const mounted = useRef(false)
useEffect(() => {
let active = true
mounted.current = true
setBusy(true)
void operations
.loadPreferences()
.then((preferences) => {
if (!active) {
return
}
setTextScale(preferences.textScale)
setAutocompleteEnabled(preferences.autocompleteEnabled)
setBusy(false)
})
.catch(() => {
if (active) {
setError('Could not load terminal preferences. Go back and try again.')
}
})
return () => {
active = false
mounted.current = false
}
}, [operations])
useEffect(() => {
let active = true
for (const host of hosts) {
void host
.loadFit()
.then((ms) => {
if (active) {
setHostMs((current) => setTerminalAutoRestoreFitMsForHost(current, host.id, ms))
}
})
.catch(() => {
if (active) {
setError('Could not load terminal restore preferences. Reconnect and try again.')
}
})
}
return () => {
active = false
}
}, [hosts])
const save = useCallback(async <T>(work: () => Promise<T>, complete: (result: T) => void) => {
setBusy(true)
setError(null)
try {
const result = await work()
if (mounted.current) {
complete(result)
}
} catch {
if (mounted.current) {
setError('Could not save terminal preferences. Try again.')
}
} finally {
if (mounted.current) {
setBusy(false)
}
}
}, [])
const selectTextSize = useCallback(
(value: TextSizeValue) => {
const option = TEXT_SIZE_OPTIONS.find((entry) => entry.value === value)
if (option) {
void save(
() => operations.saveTextScale(option.scale),
() => setTextScale(option.scale)
)
}
},
[operations, save]
)
const toggleAutocomplete = useCallback(
(enabled: boolean) => {
void save(
() => operations.saveAutocomplete(enabled),
() => setAutocompleteEnabled(enabled)
)
},
[operations, save]
)
const selectValue = useCallback(
async (hostId: string, value: RestoreValue) => {
const host = hosts.find((entry) => entry.id === hostId)
const option = AUTO_RESTORE_FIT_OPTIONS.find((entry) => entry.value === value)
if (!host || !option) {
return
}
await save(
() => host.saveFit(option.ms),
(ms) => setHostMs((current) => setTerminalAutoRestoreFitMsForHost(current, hostId, ms))
)
},
[hosts, save]
)
return {
hostMs,
pickerHostId,
setPickerHostId,
textScale,
textSizePickerOpen,
setTextSizePickerOpen,
autocompleteEnabled,
selectTextSize,
toggleAutocomplete,
selectValue,
busy,
error
}
}
@@ -0,0 +1,40 @@
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
import type { MobileWebTerminalTextScale } from '../../../src/shared/mobile-web/native-operation-contract'
import {
loadTerminalAutocompleteEnabled,
loadTerminalTextScale,
loadTerminalLinkOpenMode
} from '../storage/preferences'
import { loadCustomKeys } from '../storage/terminal-custom-key-storage'
import { loadTerminalAccessoryLayout } from './terminal-accessory-layout'
export async function loadWebHostTerminalPreferences(client: MobileWebBridgeClient) {
const native = await client.native.terminalPreferences()
if (!client.native.supports('pagePreferences')) {
return native
}
const [textScale, autocompleteEnabled, linkOpenMode] = await Promise.all([
loadTerminalTextScale({ fallback: native.textScale, rejectReadFailure: true }),
loadTerminalAutocompleteEnabled({
fallback: native.autocompleteEnabled,
rejectReadFailure: true
}),
loadTerminalLinkOpenMode(native.linkOpenMode)
])
return { textScale: textScale as MobileWebTerminalTextScale, autocompleteEnabled, linkOpenMode }
}
export async function loadWebHostTerminalAccessoryPreferences(client: MobileWebBridgeClient) {
const native = await client.native.terminalAccessoryPreferences()
if (!client.native.supports('pagePreferences')) {
return native
}
const [customKeys, layout] = await Promise.all([
loadCustomKeys({ fallback: native.customKeys, rejectReadFailure: true }),
loadTerminalAccessoryLayout({ fallback: native, rejectReadFailure: true })
])
return {
customKeys,
orderedBuiltInIds: layout.orderedBuiltInIds,
visibleBuiltInIds: layout.visibleBuiltInIds
}
}
@@ -0,0 +1,59 @@
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
import { MOBILE_WEB_SHELL_HOST_SCOPE_FEATURE } from '../../../src/shared/mobile-web/shell-feature-contract'
import {
nativeTerminalSettingsOperations,
type TerminalSettingsHost,
type TerminalSettingsOperations
} from './terminal-settings-operations'
import {
loadWebHostTerminalAccessoryPreferences,
loadWebHostTerminalPreferences
} from './web-terminal-preferences'
export function webTerminalSettingsOperations(
client: MobileWebBridgeClient
): TerminalSettingsOperations {
let accessoryRead: ReturnType<typeof loadWebHostTerminalAccessoryPreferences> | undefined
const readAccessories = () => {
// The two settings sections share the shell's single accessory-read slot.
accessoryRead ??= loadWebHostTerminalAccessoryPreferences(client).finally(() => {
accessoryRead = undefined
})
return accessoryRead
}
return {
...nativeTerminalSettingsOperations,
loadPreferences: () => loadWebHostTerminalPreferences(client),
loadKeys: async () => (await readAccessories()).customKeys,
loadLayout: readAccessories
}
}
const fitMethods = ['terminal.getAutoRestoreFit', 'terminal.setAutoRestoreFit']
export async function webTerminalSettingsHost(
client: MobileWebBridgeClient
): Promise<TerminalSettingsHost | null> {
if (!client.supportsShellFeature(MOBILE_WEB_SHELL_HOST_SCOPE_FEATURE)) {
return null
}
const catalog = await client.host.catalog(fitMethods)
if (
!fitMethods.every((method) =>
catalog.grants.some((grant) => grant.method === method && grant.scope === 'host')
)
) {
return null
}
async function request(method: string, params: Record<string, unknown> = {}) {
const result = (await client.host.request({ method, params })) as { ms?: unknown } | null
if (result?.ms !== null && (typeof result?.ms !== 'number' || !Number.isFinite(result.ms))) {
throw new Error('Invalid terminal restore preference')
}
return result.ms as number | null
}
return {
id: 'paired-host',
name: 'Paired desktop',
loadFit: () => request(fitMethods[0]!),
saveFit: (ms) => request(fitMethods[1]!, { ms })
}
}