From 30a09f3bd9728873592a89f66cfc2371a7eef2f7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 29 May 2026 19:39:52 -0400 Subject: [PATCH] Add mobile terminal shortcut bar customization (#3012) Co-authored-by: Orca --- .gitignore | 1 + mobile/app/_layout.tsx | 30 +- .../app/h/[hostId]/session/[worktreeId].tsx | 104 +- mobile/app/pair-confirm.tsx | 24 +- mobile/app/pair-scan.tsx | 4 +- mobile/app/pair.tsx | 87 + mobile/app/terminal-settings.tsx | 252 +- mobile/mock-homepage.html | 2285 +++++++----- mobile/mock-tasks.html | 3193 ++++++++++------- mobile/src/components/CustomKeyModal.tsx | 15 +- .../terminal/terminal-accessory-keys.test.ts | 35 +- .../src/terminal/terminal-accessory-keys.ts | 62 +- .../terminal-accessory-layout.test.ts | 142 + .../src/terminal/terminal-accessory-layout.ts | 137 + mobile/src/transport/pairing.test.ts | 21 + mobile/src/transport/pairing.ts | 29 +- src/cli/help.ts | 2 +- src/cli/runtime/client.ts | 2 +- src/cli/runtime/websocket-transport.test.ts | 5 +- src/cli/specs/environment.ts | 2 +- .../settings/RuntimeEnvironmentsPane.tsx | 2 +- src/renderer/src/web/WebConnect.tsx | 2 +- src/renderer/src/web/web-pairing.test.ts | 27 + src/renderer/src/web/web-pairing.ts | 9 +- src/shared/pairing.test.ts | 16 +- src/shared/pairing.ts | 34 +- src/shared/runtime-environment-store.ts | 2 +- src/shared/telemetry-events.test.ts | 2 + 28 files changed, 4103 insertions(+), 2423 deletions(-) create mode 100644 mobile/app/pair.tsx create mode 100644 mobile/src/terminal/terminal-accessory-layout.test.ts create mode 100644 mobile/src/terminal/terminal-accessory-layout.ts create mode 100644 mobile/src/transport/pairing.test.ts create mode 100644 src/renderer/src/web/web-pairing.test.ts diff --git a/.gitignore b/.gitignore index 1078c5105dd..de8a6eb8af3 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ docs/** !docs/reference/ !docs/reference/** !docs/STYLEGUIDE.md +!docs/mobile-terminal-shortcut-bar.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 188d52fe604..e6e091edf72 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -10,6 +10,7 @@ import { OrcaLogo } from '../src/components/OrcaLogo' import { RpcClientProvider } from '../src/transport/client-context' import { getNotificationNavigationPath } from '../src/notifications/notification-routing' import { loadHosts } from '../src/transport/host-store' +import { extractPairingCodeFromUrl } from '../src/transport/pairing' // Why: keeps the native splash screen visible until the React tree is mounted // and ready to render. Without this the user sees a blank white/black frame @@ -30,39 +31,23 @@ Notifications.setNotificationHandler({ }) }) -// Why: extract the path+payload that follows the orca://pair anchor so we -// can route it to the confirm screen. Accept either a hash payload -// (`orca://pair#`, the QR / shared form) or a query param -// (`orca://pair?code=<...>`, future-proof for share sheets that strip -// fragments). -function extractPairCode(url: string): string | null { - if (!url.startsWith('orca://pair')) return null - const hashIndex = url.indexOf('#') - if (hashIndex !== -1) { - return url.slice(hashIndex + 1) || null - } - const queryIndex = url.indexOf('?') - if (queryIndex !== -1) { - const params = new URLSearchParams(url.slice(queryIndex + 1)) - return params.get('code') - } - return null -} - export default function RootLayout() { const router = useRouter() const handledNotificationIdsRef = useRef>(new Set()) - // Why: route `orca://pair#` deep links to the confirm screen so + // Why: route `orca://pair?...` deep links to the confirm screen so // the same pairing flow runs whether the link arrived via QR scan, // paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL // covers cold-start (link tapped while app was closed); the listener // covers warm-start (link tapped while app is in memory). useEffect(() => { function handleUrl(url: string) { - const code = extractPairCode(url) + const code = extractPairingCodeFromUrl(url) if (code) { - router.push({ pathname: '/pair-confirm', params: { code } }) + // Why: Android camera launches can leave Expo Router's unmatched + // `orca://pair` route underneath this screen; replacing keeps cancel + // and edge-back from revealing the router error page. + router.replace({ pathname: '/pair-confirm', params: { code } }) } } @@ -169,6 +154,7 @@ export default function RootLayout() { }} /> + diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 5bd76d65e03..05a41c98775 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -18,13 +18,14 @@ import { type TextStyle } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useLocalSearchParams, useRouter } from 'expo-router' +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import AsyncStorage from '@react-native-async-storage/async-storage' import { AlertTriangle, ArrowUp, ChevronLeft, ChevronRight, + ChevronsRight, Eraser, Folder, File, @@ -59,7 +60,11 @@ import { type TerminalModes, type TerminalWebViewHandle } from '../../../../src/terminal/TerminalWebView' -import { TERMINAL_ACCESSORY_KEYS } from '../../../../src/terminal/terminal-accessory-keys' +import { + getDefaultTerminalAccessoryBuiltInIds, + getVisibleTerminalAccessoryKeys, + loadTerminalAccessoryLayout +} from '../../../../src/terminal/terminal-accessory-layout' import { getTerminalLiveSpecialKeyBytes, isTerminalLiveInputWithinByteLimit @@ -724,8 +729,15 @@ export default function SessionScreen() { const [leaveDrafts, setLeaveDrafts] = useState(null) const [renameTarget, setRenameTarget] = useState(null) const [customKeys, setCustomKeys] = useState([]) + const [visibleBuiltInIds, setVisibleBuiltInIds] = useState( + getDefaultTerminalAccessoryBuiltInIds + ) const [showCustomKeyModal, setShowCustomKeyModal] = useState(false) const [deleteKeyTarget, setDeleteKeyTarget] = useState(null) + const visibleBuiltInAccessoryKeys = useMemo( + () => getVisibleTerminalAccessoryKeys(visibleBuiltInIds), + [visibleBuiltInIds] + ) // Why: in Expo SDK 55 edge-to-edge mode the OS does NOT resize the window when // the IME opens — the keyboard draws on top of the app. We track the keyboard // height ourselves and translate the input/accessory area above the IME without @@ -1716,6 +1728,34 @@ export default function SessionScreen() { void loadCustomKeys().then(setCustomKeys) }, []) + useFocusEffect( + useCallback(() => { + let stale = false + void loadTerminalAccessoryLayout().then((layout) => { + if (!stale) setVisibleBuiltInIds(layout.visibleBuiltInIds) + }) + return () => { + stale = true + } + }, []) + ) + + useEffect(() => { + let mounted = true + const refresh = () => { + void loadTerminalAccessoryLayout().then((layout) => { + if (mounted) setVisibleBuiltInIds(layout.visibleBuiltInIds) + }) + } + const sub = AppState.addEventListener('change', (s: AppStateStatus) => { + if (s === 'active') refresh() + }) + return () => { + mounted = false + sub.remove() + } + }, []) + // Why: re-measure when non-keyboard layout-affecting state changes // (e.g. tab strip toggling visibility when the terminal count crosses // 0↔1 — without this, a freshly-created 2nd tab subscribes with a @@ -1815,6 +1855,11 @@ export default function SessionScreen() { [customKeys] ) + const handleManageShortcuts = useCallback(() => { + setShowCustomKeyModal(false) + router.push('/terminal-settings') + }, [router]) + useEffect(() => { clearTerminalCache() activeHandleRef.current = null @@ -3279,15 +3324,16 @@ export default function SessionScreen() { : 'Switch to live terminal input' } > - - Live - + {canPaste && ( )} - {TERMINAL_ACCESSORY_KEYS.map((key) => ( + {visibleBuiltInAccessoryKeys.map((key) => ( [ styles.accessoryKey, pressed && styles.accessoryKeyPressed, @@ -3383,12 +3429,9 @@ export default function SessionScreen() { onPress={focusLiveInput} accessibilityLabel="Focus live terminal input" > - - - Live - + - Keyboard input goes to terminal + Keyboard input directly goes to terminal setShowCustomKeyModal(false)} onKeysChanged={setCustomKeys} + onManageShortcuts={handleManageShortcuts} /> ([]) + const cancel = useCallback(() => { + router.replace('/') + }, [router]) + + useFocusEffect( + useCallback(() => { + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + cancel() + return true + }) + return () => subscription.remove() + }, [cancel]) + ) + useEffect(() => { if (!params.code) { setStatus('error') @@ -118,10 +132,6 @@ export default function PairConfirmScreen() { } } - function cancel() { - router.replace('/') - } - const containerPadding = { paddingTop: insets.top + spacing.sm } return ( diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index ca8a8673e7a..b64ea284a87 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -210,7 +210,7 @@ export default function PairScanScreen() { visible={pasteVisible} title="Paste pairing code" message="Copy the code shown under the QR on your computer." - placeholder="orca://pair#... or paste the code" + placeholder="orca://pair?code=... or paste the code" onSubmit={handlePasteSubmit} onCancel={() => setPasteVisible(false)} /> @@ -306,7 +306,7 @@ export default function PairScanScreen() { visible={pasteVisible} title="Paste pairing code" message="Copy the code shown under the QR on your computer." - placeholder="orca://pair#... or paste the code" + placeholder="orca://pair?code=... or paste the code" onSubmit={handlePasteSubmit} onCancel={() => setPasteVisible(false)} /> diff --git a/mobile/app/pair.tsx b/mobile/app/pair.tsx new file mode 100644 index 00000000000..6f3483763e1 --- /dev/null +++ b/mobile/app/pair.tsx @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useState } from 'react' +import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' +import { extractPairingCodeFromUrl } from '../src/transport/pairing' + +export default function PairRedirectScreen() { + const router = useRouter() + const params = useLocalSearchParams<{ code?: string }>() + const [missingCode, setMissingCode] = useState(false) + + const goHome = useCallback(() => { + router.replace('/') + }, [router]) + + useEffect(() => { + let disposed = false + + async function redirectToConfirm() { + const codeParam = Array.isArray(params.code) ? params.code[0] : params.code + if (codeParam) { + router.replace({ pathname: '/pair-confirm', params: { code: codeParam } }) + return + } + + const initialUrl = await Linking.getInitialURL().catch(() => null) + const code = initialUrl ? extractPairingCodeFromUrl(initialUrl) : null + if (disposed) { + return + } + if (code) { + router.replace({ pathname: '/pair-confirm', params: { code } }) + return + } + setMissingCode(true) + } + + void redirectToConfirm() + return () => { + disposed = true + } + }, [params.code, router]) + + return ( + + {missingCode ? ( + <> + Missing pairing code + + Back to home + + + ) : ( + + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + errorText: { + color: colors.statusRed, + fontSize: typography.bodySize, + lineHeight: 20, + marginBottom: spacing.xl, + textAlign: 'center' + }, + primaryButton: { + alignItems: 'center', + backgroundColor: colors.textPrimary, + borderRadius: radii.button, + paddingHorizontal: spacing.xl, + paddingVertical: spacing.sm + 2 + }, + primaryButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index abcf324a855..107ed53f925 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -1,14 +1,40 @@ -import { useEffect, useMemo, useState } from 'react' -import { View, Text, StyleSheet, Pressable, ScrollView } from 'react-native' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + AppState, + View, + Text, + StyleSheet, + Pressable, + ScrollView, + Switch, + type AppStateStatus +} from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useRouter } from 'expo-router' -import { ChevronLeft, ChevronRight, Smartphone } from 'lucide-react-native' -import { colors, spacing, typography } from '../src/theme/mobile-theme' +import { useFocusEffect, useRouter } from 'expo-router' +import { ChevronLeft, ChevronRight, Smartphone, X } from 'lucide-react-native' +import { + CustomKeyModal, + loadCustomKeys, + saveCustomKeys, + type CustomKey +} from '../src/components/CustomKeyModal' +import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' import { useAllHostClients } from '../src/transport/client-context' import type { RpcClient } from '../src/transport/rpc-client' import { PickerModal, type PickerOption } from '../src/components/PickerModal' +import { + TERMINAL_ACCESSORY_KEYS, + type TerminalAccessoryKey +} from '../src/terminal/terminal-accessory-keys' +import { + getDefaultTerminalAccessoryBuiltInIds, + loadTerminalAccessoryLayout, + resetTerminalAccessoryBuiltInIds, + saveTerminalAccessoryLayout, + setTerminalAccessoryBuiltInVisible +} from '../src/terminal/terminal-accessory-layout' type RestoreValue = 'indefinite' | '60s' | '5m' | '30m' @@ -74,6 +100,33 @@ function HostFitRow({ ) } +function ShortcutBarRow({ + shortcutKey, + visible, + onToggle +}: { + shortcutKey: TerminalAccessoryKey + visible: boolean + onToggle: (visible: boolean) => void +}): React.JSX.Element { + return ( + + + {shortcutKey.label} + + + {shortcutKey.accessibilityLabel ?? shortcutKey.label} + + + + ) +} + export default function TerminalSettingsScreen() { const router = useRouter() const insets = useSafeAreaInsets() @@ -84,6 +137,9 @@ export default function TerminalSettingsScreen() { const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) const hostClients = useAllHostClients(hostIds) + const [customKeys, setCustomKeys] = useState([]) + const [showCustomKeyModal, setShowCustomKeyModal] = useState(false) + // 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 @@ -91,6 +147,79 @@ export default function TerminalSettingsScreen() { // drawer appear cut-off. const [hostMs, setHostMs] = useState>({}) const [pickerHostId, setPickerHostId] = useState(null) + const [visibleBuiltInIds, setVisibleBuiltInIds] = useState( + getDefaultTerminalAccessoryBuiltInIds + ) + const layoutWriteChainRef = useRef>(Promise.resolve()) + const layoutWriteSeqRef = useRef(0) + const pendingLayoutWritesRef = useRef(0) + + const persistLayout = useCallback((nextIds: string[]) => { + layoutWriteSeqRef.current += 1 + pendingLayoutWritesRef.current += 1 + layoutWriteChainRef.current = layoutWriteChainRef.current + .catch(() => {}) + .then(() => saveTerminalAccessoryLayout(nextIds)) + .catch(() => {}) + .finally(() => { + pendingLayoutWritesRef.current -= 1 + }) + }, []) + + const refreshShortcutLayout = useCallback(() => { + const refreshSeq = layoutWriteSeqRef.current + void loadTerminalAccessoryLayout().then((layout) => { + if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) return + setVisibleBuiltInIds(layout.visibleBuiltInIds) + }) + }, []) + + const refreshCustomKeys = useCallback(() => { + void loadCustomKeys().then(setCustomKeys) + }, []) + + const handleDeleteCustomKey = useCallback( + async (key: CustomKey) => { + const updated = customKeys.filter((k) => k.id !== key.id) + setCustomKeys(updated) + await saveCustomKeys(updated) + }, + [customKeys] + ) + + 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) => { + setVisibleBuiltInIds((current) => { + const next = setTerminalAccessoryBuiltInVisible(current, id, visible) + persistLayout(next) + return next + }) + }, + [persistLayout] + ) + + const resetBuiltInKeys = useCallback(() => { + const next = resetTerminalAccessoryBuiltInIds() + setVisibleBuiltInIds(next) + persistLayout(next) + }, [persistLayout]) useEffect(() => { let cancelled = false @@ -143,6 +272,7 @@ export default function TerminalSettingsScreen() { } const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null + const visibleBuiltInSet = useMemo(() => new Set(visibleBuiltInIds), [visibleBuiltInIds]) return ( @@ -186,6 +316,76 @@ export default function TerminalSettingsScreen() { })} )} + + SHORTCUT BAR + + {TERMINAL_ACCESSORY_KEYS.map((shortcutKey, idx) => ( + + {idx > 0 && } + toggleBuiltInKey(shortcutKey.id, visible)} + /> + + ))} + + [styles.row, pressed && styles.rowPressed]} + onPress={resetBuiltInKeys} + > + + Reset Defaults + Show every built-in shortcut key + + + + + CUSTOM SHORTCUTS + + {customKeys.length === 0 ? ( + + No custom shortcuts defined yet. + + ) : ( + customKeys.map((key, idx) => ( + + {idx > 0 && } + + + {key.label} + + + {key.label} + + {key.bytes.replace(/\r/g, ' ↵')} + + + [ + styles.deleteButton, + pressed && styles.deleteButtonPressed + ]} + onPress={() => handleDeleteCustomKey(key)} + > + + + + + )) + )} + + [styles.row, pressed && styles.rowPressed]} + onPress={() => setShowCustomKeyModal(true)} + > + + Add Custom Shortcut… + Create key combo or text macro + + + + @@ -198,6 +398,14 @@ export default function TerminalSettingsScreen() { }} onClose={() => setPickerHostId(null)} /> + + setShowCustomKeyModal(false)} + onKeysChanged={(keys) => { + setCustomKeys(keys) + }} + /> ) } @@ -239,6 +447,9 @@ const styles = StyleSheet.create({ marginBottom: spacing.xs, paddingHorizontal: spacing.xs }, + groupTopGap: { + marginTop: spacing.xl + }, groupDescription: { fontSize: typography.bodySize - 1, color: colors.textSecondary, @@ -247,7 +458,7 @@ const styles = StyleSheet.create({ }, section: { backgroundColor: colors.bgPanel, - borderRadius: 12, + borderRadius: radii.card, overflow: 'hidden' }, sectionTopGap: { @@ -281,9 +492,38 @@ const styles = StyleSheet.create({ 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' + }, + 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)' } }) diff --git a/mobile/mock-homepage.html b/mobile/mock-homepage.html index b541dbb0a95..fe8301fd9bd 100644 --- a/mobile/mock-homepage.html +++ b/mobile/mock-homepage.html @@ -1,999 +1,1374 @@ - + - - - -Orca Mobile – Homepage Redesign - - - - -
- - - -
- - -
-
-
- - Orca -
-
- -
-
Welcome back
-
- -
-
- -
294
-
Agents
-
-
- -
1d 14h
-
Agent time
-
-
- -
277
-
PRs
-
-
- -
Desktops
-
-
-
- -
-
-
-
-
Host 1
-
-
Connected
-
-
- -
+ .empty-steps { + width: 100%; + padding: 0 24px 40px; + display: flex; + flex-direction: column; + gap: 0; + } + .empty-step { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 16px 0; + } + .empty-step + .empty-step { + border-top: 1px solid var(--border-subtle); + } + .step-num { + width: 28px; + height: 28px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + color: var(--text-secondary); + flex-shrink: 0; + margin-top: 1px; + } + .step-text { + flex: 1; + } + .step-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 3px; + } + .step-desc { + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; + } + + + +
+ + +
-
-
- -
-
-
Pair another desktop
-
Scan a QR code from Orca desktop
-
-
-
- -
- -
- - Settings -
-
- - -
-
-
- - Orca -
-
- - -
-
- -
- -
Welcome back
-
- - -
-
-
- -
-
294
-
Agents
-
-
-
- -
-
1d 14h
-
Agent time
-
-
-
- -
-
277
-
PRs
-
-
- -
Tasks
-
-
-
- -
-
-
Task inbox
-
- - - - - - GitHub, GitLab, Linear + +
+
+
+ + + + Orca
-
7
-
- -
-
-
-
-
3
-
Assigned
-
-
-
2
-
Review
-
-
-
2
-
Drafts
-
-
-
-
Best candidate: puts issues/PRs at the same hierarchy as desktops without overloading Quick Actions.
- -
Desktops
-
-
-
- -
+
+
Welcome back
-
-
Host 1
-
- 12 worktrees -
- 3 active + +
+
+ + + + +
294
+
Agents
+
+
+ + + + +
1d 14h
+
Agent time
+
+
+ + + + + + +
277
+
PRs
-
-
- - 7 + +
Desktops
+
+
+
+ + + + + +
+
+
+
+
Host 1
+
+
Connected
+
+
+ + + + + +
-
- + +
+
+ + + + + + +
+
+
Pair another desktop
+
+ Scan a QR code from Orca desktop +
+
-
-
-
- -
-
-
-
Work Laptop
-
- Disconnected -
- Last seen 2h ago -
-
-
- -
-
-
+
- -
Resume
-
-
- - -
-
-
fix-auth-middleware
-
- - orca  ·  feat/auth-v2 -
-
-
- -
-
- - -
Quick Actions
- - - -
Recent Activity
-
-
-
-
-
-
Assigned: Investigate remote task loading
-
GitHub · Host 1 · 8 min ago
-
-
-
-
-
-
Agent completed: fix login validation
-
Host 1 · 12 min ago
-
-
-
-
-
-
PR #284 merged: update auth middleware
-
Host 1 · 1h ago
-
-
-
-
-
-
Agent started: refactor payment flow
-
Host 1 · 2h ago
-
-
-
-
- -
-
- - -
-
-
- - Orca -
-
- -
-
- -
- -
Welcome to Orca
-
- -
-
-
- - - - - +
+ + + + Settings
-
Connect your desktop
-
- Pair with Orca on your computer to monitor worktrees, watch agents work, and manage terminals — all from your phone. -
-
-
-
How it works
-
-
1
-
-
Open Orca desktop
-
Go to Settings → Mobile and generate a pairing QR code.
+ +
+
+
+ + + + Orca +
+
+ +
-
-
2
-
-
Scan the code
-
Tap the button above to open the scanner. Point at the QR code on your screen.
+ +
+
Welcome back
+
+ + +
+
+
+ + + + +
+
294
+
Agents
+
+
+
+ + + + +
+
1d 14h
+
Agent time
+
+
+
+ + + + + + +
+
277
+
PRs
-
-
3
-
-
You're connected
-
Your desktop will appear here. Everything is encrypted end-to-end.
+ +
Tasks
+
+
+
+ + + + + + +
+
+
Task inbox
+
+ + + + + + GitHub, GitLab, Linear +
+
+
7
+
+ + + +
+
+
+
+
3
+
Assigned
+
+
+
2
+
Review
+
+
+
2
+
Drafts
+
+
+
+
+ Best candidate: puts issues/PRs at the same hierarchy as desktops without overloading Quick + Actions. +
+ + +
Desktops
+
+
+
+ + + + + +
+
+
+
Host 1
+
+ 12 worktrees +
+ 3 active +
+
+
+
+ + + + + + + 7 +
+
+ + + +
+
+
+ +
+
+ + + + + +
+
+
+
Work Laptop
+
+ Disconnected +
+ Last seen 2h ago +
+
+
+ + + +
+
+
+ + +
Resume
+
+
+ + + + + +
+
+
fix-auth-middleware
+
+ + orca  ·  feat/auth-v2 +
+
+
+ + + +
+
+ + +
Quick Actions
+ + + +
Recent Activity
+
+
+
+
+
+
Assigned: Investigate remote task loading
+
GitHub · Host 1 · 8 min ago
+
+
+
+
+
+
Agent completed: fix login validation
+
Host 1 · 12 min ago
+
+
+
+
+
+
PR #284 merged: update auth middleware
+
Host 1 · 1h ago
+
+
+
+
+
+
Agent started: refactor payment flow
+
Host 1 · 2h ago
+
+
+
+
+ +
+
+ + +
+
+
+ + + + Orca +
+
+ +
+
+ +
+
Welcome to Orca
+
+ +
+
+
+ + + + + + +
+
Connect your desktop
+
+ Pair with Orca on your computer to monitor worktrees, watch agents work, and manage + terminals — all from your phone. +
+ +
+ +
+
How it works
+
+
1
+
+
Open Orca desktop
+
Go to Settings → Mobile and generate a pairing QR code.
+
+
+
+
2
+
+
Scan the code
+
+ Tap the button above to open the scanner. Point at the QR code on your screen. +
+
+
+
+
3
+
+
You're connected
+
+ Your desktop will appear here. Everything is encrypted end-to-end. +
+
+
-
-
- - + + diff --git a/mobile/mock-tasks.html b/mobile/mock-tasks.html index 73ae68f3c4b..86eaa16ff5b 100644 --- a/mobile/mock-tasks.html +++ b/mobile/mock-tasks.html @@ -1,1030 +1,1420 @@ - + - - - -Orca Mobile - Tasks Mock - - - -
-
-
-
-
- -
- -

Tasks

+ + + + Orca Mobile - Tasks Mock + + + +
+
+
+
+
+ +
+ +

Tasks

+
+ +
- - + +
+ + +
+ + +
+ +
+

-
- - +
+
No GitHub tasks
+
+
+ +
- -
-

-
-
No GitHub tasks
-
-
+
+
+
- + - + function simulateLoad() { + state.loading = true + render() + window.setTimeout(() => { + state.loading = false + render() + }, 240) + } + + function showToast(message) { + toast.textContent = message + toast.classList.add('visible') + window.clearTimeout(showToast.timer) + showToast.timer = window.setTimeout(() => toast.classList.remove('visible'), 1500) + } + + function mutateSelectedStatus() { + const item = state.selectedItem + if (!item) return + if (state.provider === 'linear') return + if (item.state === 'closed') { + item.state = state.provider === 'gitlab' ? 'opened' : 'open' + item.status = 'Open' + } else { + item.state = 'closed' + item.status = 'Closed' + } + closeDrawer() + render() + showToast('Status updated') + } + + function createIssueFromForm() { + const title = state.createTitle.trim() + if (!title) return + const target = + state.provider === 'linear' + ? teams.find((team) => team.id === state.createTargetId) || teams[0] + : repos.find((repo) => repo.id === state.createTargetId) || repos[0] + const newItem = + state.provider === 'linear' + ? { + key: `lin-${Date.now()}`, + identifier: 'MOB-500', + title, + team: target.name, + status: 'Todo', + state: 'todo', + updatedAt: 'now', + labels: [] + } + : { + key: `${state.provider}-${Date.now()}`, + type: 'issue', + number: state.provider === 'github' ? 1291 : 75, + title, + repoId: target.id, + repoName: target.displayName, + status: 'Open', + state: state.provider === 'github' ? 'open' : 'opened', + updatedAt: 'now', + labels: [] + } + tasks[state.provider].unshift(newItem) + state.createTitle = '' + state.createBody = '' + closeDrawer() + render() + showToast('Issue created') + } + + function findCurrentItem(key) { + if (state.provider === 'gitlab' && state.gitlabView === 'todos') + return tasks.gitlabTodos.find((item) => item.key === key) + return tasks[state.provider].find((item) => item.key === key) + } + + function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } + + function escapeAttr(value) { + return escapeHtml(value).replace(/`/g, '`') + } + + document.addEventListener('click', (event) => { + const trigger = event.target.closest('[data-action]') + if (!trigger) return + const action = trigger.dataset.action + + if (action === 'open-provider') openDrawer('provider') + if (action === 'open-github-kind') openDrawer('github-kind') + if (action === 'open-github-preset') openDrawer('github-preset') + if (action === 'open-gitlab-view') openDrawer('gitlab-view') + if (action === 'open-gitlab-filter') openDrawer('gitlab-filter') + if (action === 'open-linear-filter') openDrawer('linear-filter') + if (action === 'close-drawer') closeDrawer() + if (action === 'open-create') { + state.createTitle = '' + state.createBody = '' + state.createTargetId = state.provider === 'linear' ? teams[0].id : repos[0].id + openDrawer('create') + } + if (action === 'open-create-target') openDrawer('create-target') + if (action === 'clear-search') { + state.query = '' + searchInput.value = '' + render() + } + if (action === 'refresh') { + simulateLoad() + showToast('Tasks refreshed') + } + if (action === 'toast') showToast(trigger.dataset.message || 'Action') + if (action === 'open-action') { + const item = findCurrentItem(trigger.dataset.key) + if (state.provider === 'gitlab' && state.gitlabView === 'todos') { + showToast('Would open GitLab todo URL') + return + } + if (item) openDrawer('action', item) + } + if (action === 'select-provider') setProvider(trigger.dataset.value) + if (action === 'select-github-kind') { + state.githubKind = trigger.dataset.value + const preset = state.githubKind === 'prs' ? prPresets[0] : issuePresets[0] + state.githubPreset = preset.value + state.query = preset.query + searchInput.value = state.query + closeDrawer() + render() + } + if (action === 'select-github-preset') { + const preset = [...issuePresets, ...prPresets].find( + (entry) => entry.value === trigger.dataset.value + ) + if (preset) { + state.githubPreset = preset.value + state.githubKind = + preset.value === 'issues' || preset.value === 'my-issues' ? 'issues' : 'prs' + state.query = preset.query + searchInput.value = state.query + } + closeDrawer() + render() + } + if (action === 'select-gitlab-view') { + state.gitlabView = trigger.dataset.value + closeDrawer() + render() + } + if (action === 'select-gitlab-filter') { + state.gitlabFilter = trigger.dataset.value + closeDrawer() + render() + } + if (action === 'select-linear-filter') { + state.linearFilter = trigger.dataset.value + closeDrawer() + render() + } + if (action === 'select-create-target') { + state.createTargetId = trigger.dataset.value + openDrawer('create') + } + if (action === 'select-workspace-target') { + closeDrawer() + showToast('Workspace created') + } + if (action === 'create-workspace') { + if (state.provider === 'linear' && repos.filter((repo) => !repo.unsupported).length > 1) + openDrawer('workspace-target', state.selectedItem) + else { + closeDrawer() + showToast('Workspace created') + } + } + if (action === 'open-browser') showToast('Would open task URL') + if (action === 'toggle-status') mutateSelectedStatus() + if (action === 'set-linear-status') { + const selected = linearStates.find((entry) => entry.id === trigger.dataset.value) + if (state.selectedItem && selected) { + state.selectedItem.state = selected.id + state.selectedItem.status = selected.name + } + closeDrawer() + render() + showToast('Linear status updated') + } + if (action === 'create-issue') createIssueFromForm() + }) + + document.addEventListener('input', (event) => { + if (event.target === searchInput) { + state.query = searchInput.value + render() + return + } + if (event.target.id === 'createTitle') { + state.createTitle = event.target.value + renderDrawer() + const input = document.getElementById('createTitle') + input.focus() + input.setSelectionRange(input.value.length, input.value.length) + } + if (event.target.id === 'createBody') { + state.createBody = event.target.value + } + }) + + render() + + diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index eb6b38d89ba..7931d4f9485 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -61,6 +61,7 @@ type Props = { visible: boolean onClose: () => void onKeysChanged: (keys: CustomKey[]) => void + onManageShortcuts?: () => void } export async function loadCustomKeys(): Promise { @@ -76,7 +77,7 @@ export async function saveCustomKeys(keys: CustomKey[]): Promise { await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys)) } -export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { +export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) { const [step, setStep] = useState('choose-type') const [shortcutKey, setShortcutKey] = useState('c') const [shortcutModifiers, setShortcutModifiers] = useState(['ctrl']) @@ -216,6 +217,18 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { Text Macro Send custom text command + {onManageShortcuts ? ( + <> + + [styles.row, pressed && styles.rowPressed]} + onPress={onManageShortcuts} + > + Manage Shortcuts + Show or hide default shortcut keys + + + ) : null} )} diff --git a/mobile/src/terminal/terminal-accessory-keys.test.ts b/mobile/src/terminal/terminal-accessory-keys.test.ts index 650f45dc4e1..13a91824819 100644 --- a/mobile/src/terminal/terminal-accessory-keys.test.ts +++ b/mobile/src/terminal/terminal-accessory-keys.test.ts @@ -4,20 +4,44 @@ import { buildTerminalShortcutKey, TERMINAL_ACCESSORY_KEYS } from './terminal-ac describe('TERMINAL_ACCESSORY_KEYS', () => { it('sends reverse-tab with a non-repeatable Shift+Tab key', () => { - const key = TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.label === 'Shift+Tab') + const key = TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.id === 'shiftTab') expect(key).toEqual({ + id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }) }) + it('includes a non-repeatable Enter default key', () => { + expect(TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.id === 'enter')).toEqual({ + id: 'enter', + label: 'Enter', + bytes: '\r', + accessibilityLabel: 'Enter' + }) + }) + + it('has unique non-empty built-in ids', () => { + const ids = TERMINAL_ACCESSORY_KEYS.map((key) => key.id) + + expect(ids.every((id) => id.length > 0)).toBe(true) + expect(new Set(ids).size).toBe(ids.length) + }) + it('keeps repeat behavior explicit for built-in terminal keys', () => { - const repeatableLabels = new Set(['⌫', 'Del', '↑', '↓', '←', '→']) + const repeatableIds = new Set([ + 'backspace', + 'delete', + 'arrowUp', + 'arrowDown', + 'arrowLeft', + 'arrowRight' + ]) for (const key of TERMINAL_ACCESSORY_KEYS) { - expect(key.repeatable === true).toBe(repeatableLabels.has(key.label)) + expect(key.repeatable === true).toBe(repeatableIds.has(key.id)) } }) @@ -45,6 +69,11 @@ describe('TERMINAL_ACCESSORY_KEYS', () => { bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }) + expect(buildTerminalShortcutKey({ key: 'enter', modifiers: [] })).toEqual({ + label: 'Enter', + bytes: '\r', + accessibilityLabel: 'Enter' + }) expect(buildTerminalShortcutKey({ key: 'arrowRight', modifiers: ['ctrl', 'shift'] })).toEqual({ label: 'Ctrl+Shift+→', bytes: '\x1b[1;6C', diff --git a/mobile/src/terminal/terminal-accessory-keys.ts b/mobile/src/terminal/terminal-accessory-keys.ts index 82ef9d83fc9..2923c25431b 100644 --- a/mobile/src/terminal/terminal-accessory-keys.ts +++ b/mobile/src/terminal/terminal-accessory-keys.ts @@ -1,4 +1,5 @@ export type TerminalAccessoryKey = { + id: string label: string bytes: string accessibilityLabel?: string @@ -199,25 +200,50 @@ export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [ })) export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [ - { label: 'Esc', bytes: '\x1b' }, - { label: 'Tab', bytes: '\t' }, + { id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' }, + { id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' }, + { id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' }, // Why: terminal apps recognize ESC [ Z as the reverse-tab sequence. - { label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }, - { label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true }, - { label: 'Del', bytes: '\x1b[3~', accessibilityLabel: 'Forward delete', repeatable: true }, - { label: '↑', bytes: '\x1b[A', repeatable: true }, - { label: '↓', bytes: '\x1b[B', repeatable: true }, - { label: '←', bytes: '\x1b[D', repeatable: true }, - { label: '→', bytes: '\x1b[C', repeatable: true }, - { label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' }, - { label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' }, - { label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' }, - { label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' }, - { label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' }, - { label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' }, - { label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' }, - { label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' }, - { label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' } + { id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }, + { id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true }, + { + id: 'delete', + label: 'Del', + bytes: '\x1b[3~', + accessibilityLabel: 'Forward delete', + repeatable: true + }, + { id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true }, + { + id: 'arrowDown', + label: '↓', + bytes: '\x1b[B', + accessibilityLabel: 'Arrow Down', + repeatable: true + }, + { + id: 'arrowLeft', + label: '←', + bytes: '\x1b[D', + accessibilityLabel: 'Arrow Left', + repeatable: true + }, + { + id: 'arrowRight', + label: '→', + bytes: '\x1b[C', + accessibilityLabel: 'Arrow Right', + repeatable: true + }, + { id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' }, + { id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' }, + { id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' }, + { id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' }, + { id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' }, + { id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' }, + { id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' }, + { id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' }, + { id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' } ] export function buildTerminalShortcutKey( diff --git a/mobile/src/terminal/terminal-accessory-layout.test.ts b/mobile/src/terminal/terminal-accessory-layout.test.ts new file mode 100644 index 00000000000..57e1d7f0343 --- /dev/null +++ b/mobile/src/terminal/terminal-accessory-layout.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, + createTerminalAccessoryLayoutPreference, + getDefaultTerminalAccessoryBuiltInIds, + getVisibleTerminalAccessoryKeys, + loadTerminalAccessoryLayout, + normalizeTerminalAccessoryLayoutPreference, + resetTerminalAccessoryBuiltInIds, + saveTerminalAccessoryLayout, + setTerminalAccessoryBuiltInVisible +} from './terminal-accessory-layout' + +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: asyncStorageMock +})) + +describe('terminal accessory layout', () => { + beforeEach(() => { + asyncStorageMock.getItem.mockReset() + asyncStorageMock.setItem.mockReset() + }) + + it('defaults include enter', () => { + expect(getDefaultTerminalAccessoryBuiltInIds()).toContain('enter') + expect(getVisibleTerminalAccessoryKeys(getDefaultTerminalAccessoryBuiltInIds())).toContainEqual( + expect.objectContaining({ id: 'enter', bytes: '\r' }) + ) + }) + + it('normalizes invalid storage to defaults', () => { + expect(normalizeTerminalAccessoryLayoutPreference(null).visibleBuiltInIds).toEqual( + getDefaultTerminalAccessoryBuiltInIds() + ) + expect( + normalizeTerminalAccessoryLayoutPreference({ + version: 1, + visibleBuiltInIds: ['escape'] + }).visibleBuiltInIds + ).toEqual(getDefaultTerminalAccessoryBuiltInIds()) + }) + + it('returns defaults for corrupt or unreadable storage', async () => { + asyncStorageMock.getItem.mockResolvedValueOnce('{') + await expect(loadTerminalAccessoryLayout()).resolves.toEqual( + createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds()) + ) + + asyncStorageMock.getItem.mockRejectedValueOnce(new Error('unreadable')) + await expect(loadTerminalAccessoryLayout()).resolves.toEqual( + createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds()) + ) + }) + + it('ignores removed ids and de-dupes visible ids', () => { + expect( + normalizeTerminalAccessoryLayoutPreference({ + version: 1, + visibleBuiltInIds: ['escape', 'removed', 'escape', 'tab'], + knownBuiltInIds: getDefaultTerminalAccessoryBuiltInIds() + }).visibleBuiltInIds + ).toEqual(['escape', 'tab']) + }) + + it('appends new defaults only when absent from known ids', () => { + const current = ['escape', 'tab', 'enter'] + + expect( + normalizeTerminalAccessoryLayoutPreference( + { + version: 1, + visibleBuiltInIds: ['escape'], + knownBuiltInIds: ['escape', 'tab'] + }, + current + ).visibleBuiltInIds + ).toEqual(['escape', 'enter']) + + expect( + normalizeTerminalAccessoryLayoutPreference( + { + version: 1, + visibleBuiltInIds: ['escape'], + knownBuiltInIds: current + }, + current + ).visibleBuiltInIds + ).toEqual(['escape']) + }) + + it('keeps hidden known defaults hidden, including an all-hidden layout', () => { + const current = ['escape', 'tab', 'enter'] + + expect( + normalizeTerminalAccessoryLayoutPreference( + { + version: 1, + visibleBuiltInIds: [], + knownBuiltInIds: current + }, + current + ).visibleBuiltInIds + ).toEqual([]) + }) + + it('toggle and reset helpers preserve built-in order', () => { + expect(setTerminalAccessoryBuiltInVisible(['tab'], 'escape', true, ['escape', 'tab'])).toEqual([ + 'escape', + 'tab' + ]) + expect( + setTerminalAccessoryBuiltInVisible(['escape', 'tab'], 'escape', false, ['escape', 'tab']) + ).toEqual(['tab']) + expect(resetTerminalAccessoryBuiltInIds()).toEqual(getDefaultTerminalAccessoryBuiltInIds()) + }) + + it('saves visible ids with current known built-in ids', async () => { + asyncStorageMock.setItem.mockResolvedValueOnce(undefined) + + await saveTerminalAccessoryLayout(['tab', 'tab', 'missing']) + + expect(asyncStorageMock.setItem).toHaveBeenCalledWith( + TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, + JSON.stringify(createTerminalAccessoryLayoutPreference(['tab'])) + ) + }) + + it('rejects write failures without mutating helper output', async () => { + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('nope')) + + await expect(saveTerminalAccessoryLayout(['escape'])).rejects.toThrow('nope') + expect(createTerminalAccessoryLayoutPreference(['escape']).visibleBuiltInIds).toEqual([ + 'escape' + ]) + }) +}) diff --git a/mobile/src/terminal/terminal-accessory-layout.ts b/mobile/src/terminal/terminal-accessory-layout.ts new file mode 100644 index 00000000000..2584e12fe3e --- /dev/null +++ b/mobile/src/terminal/terminal-accessory-layout.ts @@ -0,0 +1,137 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +import { TERMINAL_ACCESSORY_KEYS, type TerminalAccessoryKey } from './terminal-accessory-keys' + +export const TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY = 'orca:terminal-accessory-layout' + +export type TerminalAccessoryLayoutPreference = { + version: 1 + visibleBuiltInIds: string[] + knownBuiltInIds: string[] +} + +function builtInIds(): string[] { + return TERMINAL_ACCESSORY_KEYS.map((key) => key.id) +} + +function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreference { + return { + version: 1, + visibleBuiltInIds: [...ids], + knownBuiltInIds: [...ids] + } +} + +function stringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null + return value.every((item): item is string => typeof item === 'string') ? value : null +} + +function dedupeKnownIds(ids: string[], builtInSet: Set): string[] { + const seen = new Set() + const out: string[] = [] + for (const id of ids) { + if (!builtInSet.has(id) || seen.has(id)) continue + seen.add(id) + out.push(id) + } + return out +} + +export function getDefaultTerminalAccessoryBuiltInIds(): string[] { + return builtInIds() +} + +export function normalizeTerminalAccessoryLayoutPreference( + value: unknown, + currentBuiltInIds = builtInIds() +): TerminalAccessoryLayoutPreference { + const fallback = defaultPreference(currentBuiltInIds) + if (!value || typeof value !== 'object') return fallback + + const candidate = value as { + version?: unknown + visibleBuiltInIds?: unknown + knownBuiltInIds?: unknown + } + const visibleInput = stringArray(candidate.visibleBuiltInIds) + const knownInput = stringArray(candidate.knownBuiltInIds) + if (candidate.version !== 1 || !visibleInput || !knownInput) return fallback + + const builtInSet = new Set(currentBuiltInIds) + const knownInputSet = new Set(knownInput.filter((id) => builtInSet.has(id))) + const visibleBuiltInIds = dedupeKnownIds(visibleInput, builtInSet) + + for (const id of currentBuiltInIds) { + if (!knownInputSet.has(id) && !visibleBuiltInIds.includes(id)) { + visibleBuiltInIds.push(id) + } + } + + return { + version: 1, + visibleBuiltInIds, + knownBuiltInIds: [...currentBuiltInIds] + } +} + +export function createTerminalAccessoryLayoutPreference( + visibleBuiltInIds: string[], + currentBuiltInIds = builtInIds() +): TerminalAccessoryLayoutPreference { + return { + version: 1, + visibleBuiltInIds: dedupeKnownIds(visibleBuiltInIds, new Set(currentBuiltInIds)), + knownBuiltInIds: [...currentBuiltInIds] + } +} + +export function setTerminalAccessoryBuiltInVisible( + visibleBuiltInIds: string[], + id: string, + visible: boolean, + currentBuiltInIds = builtInIds() +): string[] { + const builtInSet = new Set(currentBuiltInIds) + if (!builtInSet.has(id)) { + return createTerminalAccessoryLayoutPreference(visibleBuiltInIds, currentBuiltInIds) + .visibleBuiltInIds + } + + const selected = new Set(dedupeKnownIds(visibleBuiltInIds, builtInSet)) + if (visible) { + selected.add(id) + } else { + selected.delete(id) + } + return currentBuiltInIds.filter((builtInId) => selected.has(builtInId)) +} + +export function resetTerminalAccessoryBuiltInIds(): string[] { + return builtInIds() +} + +export function getVisibleTerminalAccessoryKeys( + visibleBuiltInIds: string[] +): TerminalAccessoryKey[] { + const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key])) + return dedupeKnownIds(visibleBuiltInIds, new Set(byId.keys())).flatMap((id) => { + const key = byId.get(id) + return key ? [key] : [] + }) +} + +export async function loadTerminalAccessoryLayout(): Promise { + try { + const raw = await AsyncStorage.getItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY) + if (!raw) return defaultPreference() + return normalizeTerminalAccessoryLayoutPreference(JSON.parse(raw)) + } catch { + return defaultPreference() + } +} + +export async function saveTerminalAccessoryLayout(visibleBuiltInIds: string[]): Promise { + const preference = createTerminalAccessoryLayoutPreference(visibleBuiltInIds) + await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference)) +} diff --git a/mobile/src/transport/pairing.test.ts b/mobile/src/transport/pairing.test.ts new file mode 100644 index 00000000000..5b0a39bf037 --- /dev/null +++ b/mobile/src/transport/pairing.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { extractPairingCodeFromUrl } from './pairing' + +describe('pairing deep links', () => { + it('extracts the QR pairing code from the hash payload', () => { + expect(extractPairingCodeFromUrl('orca://pair#abc123')).toBe('abc123') + }) + + it('extracts the pairing code from a query param', () => { + expect(extractPairingCodeFromUrl('orca://pair?code=abc123')).toBe('abc123') + }) + + it('prefers the query pairing code when both query and hash are present', () => { + expect(extractPairingCodeFromUrl('orca://pair?code=query-code#hash-code')).toBe('query-code') + }) + + it('ignores empty and unrelated URLs', () => { + expect(extractPairingCodeFromUrl('orca://pair')).toBeNull() + expect(extractPairingCodeFromUrl('https://example.com/pair#abc123')).toBeNull() + }) +}) diff --git a/mobile/src/transport/pairing.ts b/mobile/src/transport/pairing.ts index 2b54f4e58db..a3a22b8bf6e 100644 --- a/mobile/src/transport/pairing.ts +++ b/mobile/src/transport/pairing.ts @@ -7,15 +7,36 @@ import { PairingOfferSchema, type PairingOffer } from './types' export function decodePairingUrl(url: string): PairingOffer | null { try { - const hashIndex = url.indexOf('#') - if (!url.startsWith('orca://pair') || hashIndex === -1) return null - return decodePairingBase64(url.slice(hashIndex + 1)) + const code = extractPairingCodeFromUrl(url) + if (!code) return null + return decodePairingBase64(code) } catch { return null } } -// Why: accept either an `orca://pair#` URL or the bare base64 +// Why: system camera apps hand us the raw custom-scheme URL. Keeping +// extraction here makes QR scan, paste, and external deep-link flows +// accept the same URL shapes. +export function extractPairingCodeFromUrl(url: string): string | null { + if (!url.startsWith('orca://pair')) return null + const queryIndex = url.indexOf('?') + if (queryIndex !== -1) { + const query = url.slice(queryIndex + 1).split('#')[0] ?? '' + const params = new URLSearchParams(query) + const code = params.get('code') + if (code) { + return code + } + } + const hashIndex = url.indexOf('#') + if (hashIndex !== -1) { + return url.slice(hashIndex + 1) || null + } + return null +} + +// Why: accept either an `orca://pair?...` URL or the bare base64 // string so the paste-pair flow can take whichever the user actually // copied from desktop. export function parsePairingCode(input: string): PairingOffer | null { diff --git a/src/cli/help.ts b/src/cli/help.ts index 1e1202dc345..50de2d06059 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -209,7 +209,7 @@ Wait Options: Output Options: --json Emit machine-readable JSON instead of human text - --pairing-code Connect to a remote Orca runtime using an orca://pair#... code + --pairing-code Connect to a remote Orca runtime using an orca://pair?... code --environment Connect using a saved environment id or name --help Show this help message diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index aaff8ec224c..aa930341eee 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -209,7 +209,7 @@ function resolveRemotePairing( if (!pairing) { throw new RuntimeClientError( 'invalid_argument', - 'Invalid remote pairing code. Expected an orca://pair#... URL or bare pairing payload.' + 'Invalid remote pairing code. Expected an orca://pair?... URL or bare pairing payload.' ) } return pairing diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 38ccf06f956..3ad50666be8 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -66,7 +66,10 @@ describe('CLI remote WebSocket transport', () => { deviceToken: runtime.deviceToken, publicKeyB64: runtime.publicKeyB64 } - const barePayload = encodePairingOffer(offer).split('#')[1]! + const pairingUrl = encodePairingOffer(offer) + const barePayload = new URLSearchParams(pairingUrl.slice(pairingUrl.indexOf('?') + 1)).get( + 'code' + )! const client = new RuntimeClient('/tmp/unused', 5_000, barePayload) const status = await client.getCliStatus() diff --git a/src/cli/specs/environment.ts b/src/cli/specs/environment.ts index 6bfe1940b65..2b10baff97e 100644 --- a/src/cli/specs/environment.ts +++ b/src/cli/specs/environment.ts @@ -7,7 +7,7 @@ export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [ summary: 'Save a remote Orca runtime environment from a pairing code', usage: 'orca environment add --name --pairing-code [--json]', allowedFlags: [...GLOBAL_FLAGS, 'name'], - examples: ['orca environment add --name work-laptop --pairing-code orca://pair#...'] + examples: ['orca environment add --name work-laptop --pairing-code orca://pair?code=...'] }, { path: ['environment', 'list'], diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index 12494834c95..d90bba6bb8f 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -316,7 +316,7 @@ export function RuntimeEnvironmentsPane({ aria-describedby="runtime-server-pairing-code-help" value={pairingCode} onChange={(event) => setPairingCode(event.target.value)} - placeholder="orca://pair#..." + placeholder="orca://pair?code=..." className="h-8 min-w-0 font-mono text-xs" />

diff --git a/src/renderer/src/web/WebConnect.tsx b/src/renderer/src/web/WebConnect.tsx index a7b9c56a969..984c7126e23 100644 --- a/src/renderer/src/web/WebConnect.tsx +++ b/src/renderer/src/web/WebConnect.tsx @@ -100,7 +100,7 @@ export default function WebConnect({ id="web-runtime-pairing-code" value={pairingCode} onChange={(event) => setPairingCode(event.target.value)} - placeholder="orca://pair#..." + placeholder="orca://pair?code=..." autoComplete="off" spellCheck={false} /> diff --git a/src/renderer/src/web/web-pairing.test.ts b/src/renderer/src/web/web-pairing.test.ts new file mode 100644 index 00000000000..9c62650a1a7 --- /dev/null +++ b/src/renderer/src/web/web-pairing.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { parseWebPairingInput, type WebPairingOffer } from './web-pairing' + +describe('web pairing input', () => { + const offer: WebPairingOffer = { + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: 'public-key' + } + + function encodeOffer() { + return Buffer.from(JSON.stringify(offer), 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + } + + it('parses query-form pairing URLs', () => { + expect(parseWebPairingInput(`orca://pair?code=${encodeOffer()}`)).toEqual(offer) + }) + + it('still parses legacy hash-form pairing URLs', () => { + expect(parseWebPairingInput(`orca://pair#${encodeOffer()}`)).toEqual(offer) + }) +}) diff --git a/src/renderer/src/web/web-pairing.ts b/src/renderer/src/web/web-pairing.ts index cc2fc747ab5..6677995535d 100644 --- a/src/renderer/src/web/web-pairing.ts +++ b/src/renderer/src/web/web-pairing.ts @@ -15,6 +15,13 @@ export function parseWebPairingInput(input: string): WebPairingOffer | null { try { if (trimmed.startsWith('orca://pair')) { + const queryIndex = trimmed.indexOf('?') + if (queryIndex !== -1) { + const query = trimmed.slice(queryIndex + 1).split('#')[0] ?? '' + const params = new URLSearchParams(query) + const code = params.get('code') + return code ? decodePairingPayload(code) : null + } const hashIndex = trimmed.indexOf('#') if (hashIndex === -1) { return null @@ -88,7 +95,7 @@ function decodePairingPayload(base64url: string): WebPairingOffer | null { function base64UrlToBytes(value: string): Uint8Array { const base64 = value.replace(/-/g, '+').replace(/_/g, '/') const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=') - const binary = window.atob(padded) + const binary = globalThis.atob(padded) const bytes = new Uint8Array(binary.length) for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index) diff --git a/src/shared/pairing.test.ts b/src/shared/pairing.test.ts index cf469f6ed4b..3c971e61da1 100644 --- a/src/shared/pairing.test.ts +++ b/src/shared/pairing.test.ts @@ -16,7 +16,7 @@ describe('pairing offer', () => { it('encode then decode round-trips correctly', () => { const url = encodePairingOffer(offer) - expect(url).toMatch(/^orca:\/\/pair#/) + expect(url).toMatch(/^orca:\/\/pair\?code=/) const decoded = decodePairingOffer(url) expect(decoded).toEqual(offer) @@ -24,18 +24,24 @@ describe('pairing offer', () => { it('encoded URL uses base64url (no +, /, or = characters)', () => { const url = encodePairingOffer(offer) - const fragment = url.split('#')[1]! - expect(fragment).not.toMatch(/[+/=]/) + const code = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')! + expect(code).not.toMatch(/[+/=]/) }) it('rejects URLs with wrong scheme', () => { expect(() => decodePairingOffer('https://example.com#abc')).toThrow('Invalid pairing URL') }) - it('rejects URLs without fragment', () => { + it('rejects URLs without a pairing code', () => { expect(() => decodePairingOffer('orca://pair')).toThrow('Invalid pairing URL') }) + it('decodes legacy hash URLs', () => { + const url = encodePairingOffer(offer) + const code = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')! + expect(decodePairingOffer(`orca://pair#${code}`)).toEqual(offer) + }) + it('rejects payloads with missing fields', () => { const partial = { v: 2, endpoint: 'ws://host:1234' } const base64 = Buffer.from(JSON.stringify(partial)).toString('base64') @@ -70,7 +76,7 @@ describe('parsePairingCode', () => { it('parses a bare base64url payload (without scheme prefix)', () => { const url = encodePairingOffer(offer) - const base64url = url.split('#')[1]! + const base64url = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')! expect(parsePairingCode(base64url)).toEqual(offer) }) diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts index 4e9b5beba80..220525c7665 100644 --- a/src/shared/pairing.ts +++ b/src/shared/pairing.ts @@ -20,18 +20,40 @@ export function encodePairingOffer(offer: PairingOffer): string { .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') - return `orca://pair#${base64url}` + // Why: Android camera intents and Expo Router preserve query params more + // reliably than URL fragments when launching a custom-scheme app. + return `orca://pair?code=${base64url}` } export function decodePairingOffer(url: string): PairingOffer { - const hashIndex = url.indexOf('#') - if (!url.startsWith('orca://pair') || hashIndex === -1) { - throw new Error('Invalid pairing URL: must start with orca://pair#') + const code = extractPairingCodeFromUrl(url) + if (!code) { + throw new Error('Invalid pairing URL: must start with orca://pair and include a pairing code') } - return decodePairingBase64(url.slice(hashIndex + 1)) + return decodePairingBase64(code) } -// Why: accept either an `orca://pair#` URL or the bare base64 +function extractPairingCodeFromUrl(url: string): string | null { + if (!url.startsWith('orca://pair')) { + return null + } + const queryIndex = url.indexOf('?') + if (queryIndex !== -1) { + const query = url.slice(queryIndex + 1).split('#')[0] ?? '' + const params = new URLSearchParams(query) + const code = params.get('code') + if (code) { + return code + } + } + const hashIndex = url.indexOf('#') + if (hashIndex !== -1) { + return url.slice(hashIndex + 1) || null + } + return null +} + +// Why: accept either an `orca://pair?...` URL or the bare base64 // string so the mobile paste-pair flow can take whichever the user // actually copied from desktop. export function parsePairingCode(input: string): PairingOffer | null { diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index 2682ffb74dd..465d8af82b5 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -42,7 +42,7 @@ export function addEnvironmentFromPairingCode( if (!offer) { throw new RuntimeEnvironmentStoreError( 'invalid_argument', - 'Invalid pairing code. Expected an orca://pair#... URL or bare pairing payload.' + 'Invalid pairing code. Expected an orca://pair?... URL or bare pairing payload.' ) } const store = readEnvironmentStore(userDataPath) diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index 90924dd5033..4363144af8c 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: telemetry schema tests keep related event + invariants together so cross-event payload rules stay easy to audit. */ // Schema round-trip coverage for the event map. Fail-closed invariants that // must hold: agent_error is enum-only (error_message / error_stack rejected // by `.strict()`), unknown enum values fail, and any well-formed payload