From 14a314a29dc98ecda8713e729312f958e5dfd740 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 29 Apr 2026 03:59:26 -0400 Subject: [PATCH] fix: terminal output duplication on mobile Remove duplicate onPtyData call in pty.ts that caused every daemon-backed PTY data event to be dispatched twice through dataListeners, doubling all terminal output sent to mobile clients. Also: clean up registerSubscriptionCleanup to evict stale listeners on reconnect, add regression tests, fix CI lint/type errors, add mobile UX improvements (worktree creation, settings, about screen). Co-authored-by: Orca --- mobile/app/_layout.tsx | 4 +- mobile/app/about.tsx | 146 ++++++++++++ mobile/app/h/[hostId]/index.tsx | 200 ++++++++++++++-- .../app/h/[hostId]/session/[worktreeId].tsx | 12 +- mobile/app/index.tsx | 29 ++- mobile/app/pair-scan.tsx | 122 +++++++--- mobile/app/settings.tsx | 80 +++++++ mobile/src/components/BottomDrawer.tsx | 28 ++- mobile/src/components/NewWorktreeModal.tsx | 218 +++++++++++++++--- mobile/src/storage/preferences.ts | 4 +- src/main/ipc/pty.ts | 3 - src/main/runtime/orca-runtime.test.ts | 83 +++++++ src/main/runtime/orca-runtime.ts | 40 +++- src/main/runtime/rpc/methods/repo.ts | 5 + src/main/runtime/rpc/methods/worktree.ts | 18 +- src/preload/api-types.ts | 12 + src/renderer/src/hooks/useIpcEvents.test.ts | 20 ++ src/renderer/src/hooks/useIpcEvents.ts | 4 +- 18 files changed, 904 insertions(+), 124 deletions(-) create mode 100644 mobile/app/about.tsx create mode 100644 mobile/app/settings.tsx diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index fa83b53a319..2d940287c2d 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -23,7 +23,9 @@ export default function RootLayout() { headerTitle: () => }} /> - + + + diff --git a/mobile/app/about.tsx b/mobile/app/about.tsx new file mode 100644 index 00000000000..46454a2c38f --- /dev/null +++ b/mobile/app/about.tsx @@ -0,0 +1,146 @@ +import { View, Text, StyleSheet, Pressable, Linking } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import { ChevronLeft, Globe } from 'lucide-react-native' +import Svg, { Path } from 'react-native-svg' +import { OrcaLogo } from '../src/components/OrcaLogo' +import { colors, spacing, typography } from '../src/theme/mobile-theme' + +function GithubIcon({ size = 16, color = colors.textSecondary }) { + return ( + + + + ) +} + +function XIcon({ size = 16, color = colors.textSecondary }) { + return ( + + + + ) +} + +export default function AboutScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + + return ( + + + router.back()}> + + + About + + + + + Orca + Open-source agent IDE for 100x builders + + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => void Linking.openURL('https://onOrca.dev')} + > + + Website + onOrca.dev + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => void Linking.openURL('https://github.com/stablyai/orca')} + > + + GitHub + stablyai/orca + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => void Linking.openURL('https://x.com/orca_build')} + > + + X + @orca_build + + + + ) +} + +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: { + fontSize: typography.bodySize, + color: colors.accentBlue + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + } +}) diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index eea266a3c86..120ecc0cf77 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -22,7 +22,9 @@ import { ChevronRight, ChevronLeft, Plus, - Moon + Moon, + Filter, + Check } from 'lucide-react-native' import { connect, type RpcClient } from '../../../src/transport/rpc-client' import { loadHosts, updateLastConnected, removeHost } from '../../../src/transport/host-store' @@ -33,6 +35,7 @@ import { NewWorktreeModal } from '../../../src/components/NewWorktreeModal' import { AgentSpinner } from '../../../src/components/AgentSpinner' import { PickerModal, type PickerOption } from '../../../src/components/PickerModal' import { ActionSheetModal } from '../../../src/components/ActionSheetModal' +import { BottomDrawer } from '../../../src/components/BottomDrawer' import { colors, spacing, typography } from '../../../src/theme/mobile-theme' import { loadPinnedIds, @@ -57,9 +60,14 @@ type Worktree = { } type SortMode = 'smart' | 'name' | 'recent' -type FilterMode = 'all' | 'active' +type _FilterMode = 'all' | 'active' type GroupMode = 'none' | 'repo' | 'prStatus' +type FilterState = { + activeOnly: boolean + selectedRepos: Set +} + const STATUS_LABELS: Record = { connecting: 'Connecting…', handshaking: 'Securing…', @@ -114,11 +122,14 @@ function sortWorktrees(worktrees: Worktree[], mode: SortMode): Worktree[] { }) } -function filterWorktrees(worktrees: Worktree[], filter: FilterMode, search: string): Worktree[] { +function filterWorktrees(worktrees: Worktree[], filters: FilterState, search: string): Worktree[] { let result = worktrees - if (filter === 'active') { + if (filters.activeOnly) { result = result.filter(isWorktreeActive) } + if (filters.selectedRepos.size > 0) { + result = result.filter((w) => filters.selectedRepos.has(w.repo)) + } if (search.trim()) { const q = search.toLowerCase() result = result.filter( @@ -162,12 +173,12 @@ function isWorktreePinned(w: Worktree, localPins: Set): boolean { function buildSections( worktrees: Worktree[], sortMode: SortMode, - filterMode: FilterMode, + filters: FilterState, search: string, groupMode: GroupMode, pinnedIds: Set ): Section[] { - const filtered = filterWorktrees(worktrees, filterMode, search) + const filtered = filterWorktrees(worktrees, filters, search) const sorted = sortWorktrees(filtered, sortMode) const pinned = sorted.filter((w) => isWorktreePinned(w, pinnedIds)) @@ -232,12 +243,16 @@ export default function HostScreen() { const [search, setSearch] = useState('') const [showSearch, setShowSearch] = useState(false) const [sortMode, setSortMode] = useState('smart') - const [filterMode, setFilterMode] = useState('all') + const [filters, setFilters] = useState({ + activeOnly: false, + selectedRepos: new Set() + }) const [groupMode, setGroupMode] = useState('none') // Modals const [showSortPicker, setShowSortPicker] = useState(false) const [showGroupPicker, setShowGroupPicker] = useState(false) + const [showFilterModal, setShowFilterModal] = useState(false) const [actionTarget, setActionTarget] = useState(null) const [confirmDelete, setConfirmDelete] = useState(null) const [confirmRemoveHost, setConfirmRemoveHost] = useState(false) @@ -255,7 +270,10 @@ export default function HostScreen() { const [pins, prefs] = await Promise.all([loadPinnedIds(hostId), loadPreferences(hostId)]) setPinnedIds(pins) setSortMode(prefs.sortMode as SortMode) - setFilterMode(prefs.filterMode as FilterMode) + setFilters({ + activeOnly: prefs.filterMode === 'active', + selectedRepos: new Set(prefs.selectedRepos ?? []) + }) setGroupMode(prefs.groupMode as GroupMode) setCollapsedGroups(new Set(prefs.collapsedGroups)) setPrefsLoaded(true) @@ -391,14 +409,43 @@ export default function HostScreen() { [hostId] ) - const handleFilterToggle = useCallback(() => { - setFilterMode((prev) => { - const next: FilterMode = prev === 'all' ? 'active' : 'all' - if (hostId) void savePreferences(hostId, { filterMode: next }) + const toggleActiveFilter = useCallback(() => { + setFilters((prev) => { + const next = { ...prev, activeOnly: !prev.activeOnly } + if (hostId) + void savePreferences(hostId, { + filterMode: next.activeOnly ? 'active' : 'all' + }) return next }) }, [hostId]) + const toggleRepoFilter = useCallback( + (repo: string) => { + setFilters((prev) => { + const next = new Set(prev.selectedRepos) + if (next.has(repo)) next.delete(repo) + else next.add(repo) + const updated = { ...prev, selectedRepos: next } + if (hostId) void savePreferences(hostId, { selectedRepos: [...next] }) + return updated + }) + }, + [hostId] + ) + + const clearFilters = useCallback(() => { + setFilters({ activeOnly: false, selectedRepos: new Set() }) + if (hostId) void savePreferences(hostId, { filterMode: 'all', selectedRepos: [] }) + }, [hostId]) + + const activeFilterCount = useMemo(() => { + let count = 0 + if (filters.activeOnly) count++ + count += filters.selectedRepos.size + return count + }, [filters]) + const handleGroupChange = useCallback( (value: GroupMode) => { setGroupMode(value) @@ -412,6 +459,14 @@ export default function HostScreen() { ? lastKnownWorktrees : worktrees + const uniqueRepos = useMemo(() => { + const repos = new Map() + for (const w of displayWorktrees) { + if (!repos.has(w.repo)) repos.set(w.repo, repoColor(w.repo)) + } + return [...repos.entries()].map(([name, color]) => ({ name, color })) + }, [displayWorktrees]) + const toggleCollapsed = useCallback( (title: string) => { setCollapsedGroups((prev) => { @@ -426,8 +481,8 @@ export default function HostScreen() { ) const rawSections = useMemo( - () => buildSections(displayWorktrees, sortMode, filterMode, search, groupMode, pinnedIds), - [displayWorktrees, sortMode, filterMode, search, groupMode, pinnedIds] + () => buildSections(displayWorktrees, sortMode, filters, search, groupMode, pinnedIds), + [displayWorktrees, sortMode, filters, search, groupMode, pinnedIds] ) const sections = useMemo( @@ -470,16 +525,17 @@ export default function HostScreen() { {/* Filter/sort/group toolbar */} 0 && styles.filterChipActive]} + onPress={() => setShowFilterModal(true)} > + 0 ? colors.textPrimary : colors.textSecondary} + /> 0 && styles.filterChipTextActive]} > - Active + Filter{activeFilterCount > 0 ? ` (${activeFilterCount})` : ''} @@ -574,8 +630,8 @@ export default function HostScreen() { {search ? 'No matching worktrees' - : filterMode === 'active' - ? 'No active worktrees' + : activeFilterCount > 0 + ? 'No worktrees match filters' : 'No worktrees'} @@ -697,6 +753,48 @@ export default function HostScreen() { onClose={() => setShowGroupPicker(false)} /> + {/* Filter modal — matches desktop's Status + Repositories dropdown */} + setShowFilterModal(false)}> + + Filter + {activeFilterCount > 0 && ( + + Clear filters + + )} + + + Status + + + Active only + {filters.activeOnly && } + + + + {uniqueRepos.length > 1 && ( + <> + Repositories + + {uniqueRepos.map((repo, i) => ( + + {i > 0 && } + toggleRepoFilter(repo.name)}> + + + {repo.name} + + {filters.selectedRepos.has(repo.name) && ( + + )} + + + ))} + + + )} + + {/* Worktree long-press action sheet */} >(new Set()) const activeHandleRef = useRef(null) const subscribeSeqRef = useRef>(new Map()) + const sendingRef = useRef(false) const canSend = connState === 'connected' && activeHandle != null @@ -511,6 +512,12 @@ export default function SessionScreen() { useEffect(() => { if (connState === 'connected') { + // Why: on reconnect the RPC client auto-resends terminal.subscribe, + // creating new server-side handlers. Clear local subscription state + // so subscribeToTerminal's guards don't block fresh subscriptions, + // and clear xterm buffers so the new scrollback snapshot replaces + // stale content (including data that arrived while disconnected). + clearTerminalCache() setTerminalsLoaded(false) void (async () => { // Why: worktree.create already asks the desktop renderer to activate @@ -592,7 +599,8 @@ export default function SessionScreen() { ) async function handleSend() { - if (!client || !activeHandle) return + if (!client || !activeHandle || sendingRef.current) return + sendingRef.current = true const text = input setInput('') @@ -605,6 +613,8 @@ export default function SessionScreen() { }) } catch { setInput(text) + } finally { + sendingRef.current = false } } diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index e1f32ddede3..37fecb0d46b 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react' import { View, Text, StyleSheet, Pressable, FlatList } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useRouter, useFocusEffect } from 'expo-router' -import { MoreHorizontal, QrCode, Server } from 'lucide-react-native' +import { MoreHorizontal, QrCode, Server, Settings } from 'lucide-react-native' import { loadHosts, removeHost, renameHost } from '../src/transport/host-store' import { connect } from '../src/transport/rpc-client' import type { ConnectionState, HostProfile } from '../src/transport/types' @@ -206,6 +206,11 @@ export default function HomeScreen() { ]} onClose={() => setConfirmRemove(null)} /> + + router.push('/settings')}> + + Settings + ) } @@ -228,14 +233,6 @@ const styles = StyleSheet.create({ minWidth: 0 }, logoMark: { - width: 36, - height: 36, - borderRadius: 10, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, marginRight: spacing.sm }, brandName: { @@ -393,5 +390,19 @@ const styles = StyleSheet.create({ color: colors.textMuted, fontSize: typography.metaSize, marginTop: 2 + }, + settingsButton: { + flexDirection: 'row', + alignSelf: 'center', + alignItems: 'center', + gap: spacing.xs, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + marginBottom: spacing.xl + }, + settingsText: { + fontSize: 13, + color: colors.textMuted, + fontWeight: '500' } }) diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index 5c5dc32fc55..81c12be81b4 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -1,21 +1,21 @@ import { useState, useRef, useCallback } from 'react' import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' import { CameraView, useCameraPermissions } from 'expo-camera' import { useRouter } from 'expo-router' -import { Monitor, Settings, Smartphone } from 'lucide-react-native' +import { ChevronLeft } from 'lucide-react-native' import { decodePairingUrl } from '../src/transport/pairing' import { connect } from '../src/transport/rpc-client' import { saveHost, getNextHostName } from '../src/transport/host-store' import type { PairingOffer } from '../src/transport/types' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' -function Step({ number, icon, text }: { number: number; icon: React.ReactNode; text: string }) { +function Step({ number, text }: { number: number; text: string }) { return ( {number} - {icon} {text} ) @@ -23,6 +23,7 @@ function Step({ number, icon, text }: { number: number; icon: React.ReactNode; t export default function PairScanScreen() { const router = useRouter() + const insets = useSafeAreaInsets() const [permission, requestPermission] = useCameraPermissions() const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning') const [errorMessage, setErrorMessage] = useState('') @@ -94,9 +95,11 @@ export default function PairScanScreen() { processingRef.current = false } + const containerPadding = { paddingTop: insets.top + spacing.sm } + if (!permission) { return ( - + ) @@ -104,7 +107,10 @@ export default function PairScanScreen() { if (!permission.granted) { return ( - + + router.back()}> + + Camera Permission @@ -119,32 +125,32 @@ export default function PairScanScreen() { } return ( - + + router.back()}> + + + - } - text="Open Orca on your computer" - /> - } - text="Go to Settings → Mobile" - /> - } - text="Point this camera at the QR code" - /> + + + {status === 'scanning' && ( - + + + + + + + + + )} {status === 'connecting' && ( @@ -172,9 +178,18 @@ const styles = StyleSheet.create({ backgroundColor: colors.bgBase, padding: spacing.lg }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.sm + }, steps: { gap: spacing.sm, - marginBottom: spacing.lg + marginBottom: spacing.lg, + marginLeft: 7 }, step: { flexDirection: 'row', @@ -194,20 +209,57 @@ const styles = StyleSheet.create({ fontWeight: '700', color: colors.textSecondary }, - stepIcon: { - width: 20, - alignItems: 'center' - }, stepText: { fontSize: typography.bodySize, - color: colors.textSecondary, - flex: 1 + color: colors.textSecondary }, - camera: { + cameraWrap: { flex: 1, borderRadius: radii.camera, overflow: 'hidden' }, + camera: { + ...StyleSheet.absoluteFillObject + }, + reticle: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center' + }, + corner: { + position: 'absolute', + width: 28, + height: 28, + borderColor: 'rgba(255,255,255,0.7)' + }, + cornerTL: { + top: '30%', + left: '20%', + borderTopWidth: 2.5, + borderLeftWidth: 2.5, + borderTopLeftRadius: 6 + }, + cornerTR: { + top: '30%', + right: '20%', + borderTopWidth: 2.5, + borderRightWidth: 2.5, + borderTopRightRadius: 6 + }, + cornerBL: { + bottom: '30%', + left: '20%', + borderBottomWidth: 2.5, + borderLeftWidth: 2.5, + borderBottomLeftRadius: 6 + }, + cornerBR: { + bottom: '30%', + right: '20%', + borderBottomWidth: 2.5, + borderRightWidth: 2.5, + borderBottomRightRadius: 6 + }, centered: { flex: 1, alignItems: 'center', diff --git a/mobile/app/settings.tsx b/mobile/app/settings.tsx new file mode 100644 index 00000000000..0dd1da8c395 --- /dev/null +++ b/mobile/app/settings.tsx @@ -0,0 +1,80 @@ +import { View, Text, StyleSheet, Pressable, Linking } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import { ChevronLeft, ChevronRight, Info } from 'lucide-react-native' +import { OrcaLogo } from '../src/components/OrcaLogo' +import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' + +export default function SettingsScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + + return ( + + + router.back()}> + + + Settings + + + + [styles.row, pressed && styles.rowPressed]} + onPress={() => router.push('/about')} + > + + About + + + + + ) +} + +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 + }, + 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 + } +}) diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 56eba5f36c7..ff6ac3f2fdf 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -1,5 +1,6 @@ import { type ReactNode, useCallback, useEffect } from 'react' import { Modal, View, Pressable, StyleSheet, Platform, useWindowDimensions } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' import Animated, { useSharedValue, @@ -13,7 +14,7 @@ import Animated, { import { colors, spacing } from '../theme/mobile-theme' const DISMISS_THRESHOLD = 80 -const SPRING_CONFIG = { damping: 20, stiffness: 300 } +const SPRING_CONFIG = { damping: 28, stiffness: 400 } // Why: negative translateY (pulling up) is damped with a rubber-band factor // so the drawer resists upward dragging — a subtle polish touch that signals // the drawer cannot expand further. @@ -29,6 +30,7 @@ export function BottomDrawer({ visible, onClose, children }: Props) { const translateY = useSharedValue(0) const backdropOpacity = useSharedValue(0) const { height: screenHeight } = useWindowDimensions() + const insets = useSafeAreaInsets() useEffect(() => { if (visible) { @@ -51,9 +53,13 @@ export function BottomDrawer({ visible, onClose, children }: Props) { }) .onEnd((e) => { if (e.translationY > DISMISS_THRESHOLD || e.velocityY > 500) { - translateY.value = withTiming(screenHeight, { duration: 200 }) - backdropOpacity.value = withTiming(0, { duration: 200 }) - runOnJS(dismiss)() + const velocity = Math.max(e.velocityY, 800) + const remaining = screenHeight - e.translationY + const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300) + translateY.value = withTiming(screenHeight, { duration }, () => { + runOnJS(dismiss)() + }) + backdropOpacity.value = withTiming(0, { duration }) } else { translateY.value = withSpring(0, SPRING_CONFIG) } @@ -76,9 +82,12 @@ export function BottomDrawer({ visible, onClose, children }: Props) { - + {children} + @@ -104,7 +113,6 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingHorizontal: spacing.md, - paddingBottom: spacing.xl + spacing.md, ...Platform.select({ ios: { shadowColor: '#000', @@ -124,5 +132,13 @@ const styles = StyleSheet.create({ marginTop: spacing.sm, marginBottom: spacing.md, opacity: 0.4 + }, + bottomExtension: { + position: 'absolute', + bottom: -100, + left: 0, + right: 0, + height: 100, + backgroundColor: colors.bgBase } }) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index aaa92bfcc13..20d7f1a875f 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -4,13 +4,14 @@ import { Text, TextInput, Pressable, + Switch, StyleSheet, Platform, ActivityIndicator, Image, FlatList } from 'react-native' -import { ChevronDown, Check, Terminal } from 'lucide-react-native' +import { ChevronDown, ChevronUp, Check, Terminal } from 'lucide-react-native' import Svg, { Path, G } from 'react-native-svg' import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' @@ -262,6 +263,11 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props) const [selectedAgent, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) const [showAgentPicker, setShowAgentPicker] = useState(false) const [name, setName] = useState('') + const [note, setNote] = useState('') + const [showAdvanced, setShowAdvanced] = useState(false) + const [setupCommand, setSetupCommand] = useState(null) + const [setupSource, setSetupSource] = useState(null) + const [runSetup, setRunSetup] = useState(true) const [creating, setCreating] = useState(false) const [error, setError] = useState('') const [loading, setLoading] = useState(true) @@ -269,6 +275,11 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props) useEffect(() => { if (!visible || !client) return setName('') + setNote('') + setShowAdvanced(false) + setSetupCommand(null) + setSetupSource(null) + setRunSetup(true) setError('') setCreating(false) setShowRepoPicker(false) @@ -296,8 +307,37 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props) })() }, [visible, client]) + useEffect(() => { + if (!client || !selectedRepo) { + setSetupCommand(null) + setSetupSource(null) + return + } + void (async () => { + try { + const response = await client.sendRequest('repo.hooks', { + repo: `id:${selectedRepo.id}` + }) + if (response.ok) { + const result = (response as RpcSuccess).result as { + hooks: { scripts: { setup?: string } } | null + source: string | null + setupRunPolicy: string + } + const cmd = result.hooks?.scripts.setup ?? null + setSetupCommand(cmd) + setSetupSource(result.source) + setRunSetup(result.setupRunPolicy !== 'skip-by-default') + } + } catch { + setSetupCommand(null) + setSetupSource(null) + } + })() + }, [client, selectedRepo]) + async function handleCreate() { - if (!client || !selectedRepo || !name.trim()) return + if (!client || !selectedRepo) return setCreating(true) setError('') @@ -305,36 +345,42 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props) const command = selectedAgent.id !== '__blank__' ? AGENT_COMMANDS[selectedAgent.id] : undefined - const response = await client.sendRequest('worktree.create', { + const params: Record = { repo: `id:${selectedRepo.id}`, - name: name.trim(), - startupCommand: command - }) + startupCommand: command, + setupDecision: runSetup ? 'inherit' : 'skip' + } + if (name.trim()) params.name = name.trim() + if (note.trim()) params.comment = note.trim() + + const response = await client.sendRequest('worktree.create', params) if (response.ok) { const result = (response as RpcSuccess).result as { worktree: { id: string } } const worktreeId = result.worktree.id onClose() - onCreated(worktreeId, name.trim()) + onCreated(worktreeId, name.trim() || 'New workspace') } else { setError(response.error.message) } } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to create worktree') + setError(e instanceof Error ? e.message : 'Failed to create workspace') } finally { setCreating(false) } } - const canCreate = selectedRepo != null && name.trim().length > 0 && !creating + const canCreate = selectedRepo != null && !creating return ( <> - New Worktree - Pick a repository and agent + Create Workspace + + Pick a repository and agent to spin up a new workspace. + {loading ? ( @@ -347,7 +393,6 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props) ) : ( <> - {/* Repository */} Repository setShowRepoPicker(true)}> @@ -361,9 +406,10 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props) - {/* Name */} - Name + + Workspace Name [Optional] + - {/* Agent */} Agent setShowAgentPicker(true)}> @@ -395,12 +440,65 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props) + setShowAdvanced(!showAdvanced)}> + Advanced + {showAdvanced ? ( + + ) : ( + + )} + + + {showAdvanced && ( + <> + + Note + + + + {setupCommand ? ( + + + Setup script + {setupSource && ( + + + {setupSource === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'} + + + )} + + + + Run setup command + + + + {setupCommand} + + + + ) : null} + + )} + {error ? {error} : null} - - Cancel - ) : ( - Create + Create Workspace )} @@ -468,13 +566,15 @@ const styles = StyleSheet.create({ marginBottom: spacing.md }, label: { - fontSize: 12, + fontSize: 13, fontWeight: '500', color: colors.textSecondary, - textTransform: 'uppercase', - letterSpacing: 0.5, marginBottom: spacing.xs }, + labelHint: { + fontWeight: '400', + color: colors.textMuted + }, fieldButton: { flexDirection: 'row', alignItems: 'center', @@ -509,22 +609,72 @@ const styles = StyleSheet.create({ fontSize: 13, marginBottom: spacing.md }, + advancedToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingVertical: spacing.sm, + marginBottom: spacing.xs + }, + advancedText: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textSecondary + }, + setupHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.xs + }, + sourceBadge: { + backgroundColor: colors.bgRaised, + borderRadius: 4, + paddingHorizontal: spacing.xs + 2, + paddingVertical: 2 + }, + sourceBadgeText: { + fontSize: 10, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5 + }, + setupBox: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md + }, + setupToggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm + }, + setupToggleLabel: { + fontSize: 13, + color: colors.textSecondary + }, + setupSwitch: { + transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }] + }, + setupCommandBlock: { + backgroundColor: colors.bgBase, + borderRadius: 6, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.sm + }, + setupCommand: { + fontSize: 13, + fontFamily: typography.monoFamily, + color: colors.textPrimary + }, actions: { flexDirection: 'row', justifyContent: 'flex-end', - gap: spacing.sm, marginTop: spacing.sm }, - cancelButton: { - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - borderRadius: radii.button - }, - cancelText: { - color: colors.textSecondary, - fontSize: typography.bodySize, - fontWeight: '500' - }, createButton: { backgroundColor: colors.textPrimary, paddingHorizontal: spacing.lg, diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 52f6d28ee9a..a89f3edc7b4 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -8,13 +8,15 @@ export type HostPreferences = { filterMode: string groupMode: string collapsedGroups: string[] + selectedRepos: string[] } const DEFAULT_PREFS: HostPreferences = { sortMode: 'smart', filterMode: 'all', groupMode: 'none', - collapsedGroups: [] + collapsedGroups: [], + selectedRepos: [] } export async function loadPinnedIds(hostId: string): Promise> { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 576fe2c060d..46bdaec6972 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -424,9 +424,6 @@ export function registerPtyHandlers( pendingData.clear() return } - if (!isLocalProvider) { - runtime?.onPtyData(payload.id, payload.data, Date.now()) - } const existing = pendingData.get(payload.id) pendingData.set(payload.id, existing ? existing + payload.data : payload.data) if (!flushTimer) { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index bc16494fdad..649fd9cb3d2 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1922,4 +1922,87 @@ describe('OrcaRuntimeService', () => { expect(getRegisteredTabsMock).toHaveBeenCalledWith(`${TEST_REPO_ID}::/tmp/worktree-b`) }) }) + + describe('subscribeToTerminalData', () => { + it('delivers PTY data to a subscriber', () => { + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-1', TEST_WORKTREE_ID) + + const chunks: string[] = [] + runtime.subscribeToTerminalData('pty-1', (data) => chunks.push(data)) + + runtime.onPtyData('pty-1', 'hello\n', 100) + runtime.onPtyData('pty-1', 'world\n', 101) + + expect(chunks).toEqual(['hello\n', 'world\n']) + }) + + it('unsubscribe removes the listener so data is not delivered twice', () => { + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-1', TEST_WORKTREE_ID) + + const chunks: string[] = [] + const unsub = runtime.subscribeToTerminalData('pty-1', (data) => chunks.push(data)) + + runtime.onPtyData('pty-1', 'before\n', 100) + unsub() + runtime.onPtyData('pty-1', 'after\n', 101) + + expect(chunks).toEqual(['before\n']) + }) + + it('delivers data exactly once per listener even with multiple subscribers', () => { + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-1', TEST_WORKTREE_ID) + + const chunksA: string[] = [] + const chunksB: string[] = [] + const unsubA = runtime.subscribeToTerminalData('pty-1', (data) => chunksA.push(data)) + runtime.subscribeToTerminalData('pty-1', (data) => chunksB.push(data)) + + runtime.onPtyData('pty-1', 'hello\n', 100) + unsubA() + runtime.onPtyData('pty-1', 'world\n', 101) + + expect(chunksA).toEqual(['hello\n']) + expect(chunksB).toEqual(['hello\n', 'world\n']) + }) + }) + + describe('registerSubscriptionCleanup', () => { + it('cleans up the previous subscription when re-registering the same id', () => { + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-1', TEST_WORKTREE_ID) + + const chunksA: string[] = [] + const chunksB: string[] = [] + const unsubA = runtime.subscribeToTerminalData('pty-1', (data) => chunksA.push(data)) + runtime.registerSubscriptionCleanup('sub-1', () => unsubA()) + + const unsubB = runtime.subscribeToTerminalData('pty-1', (data) => chunksB.push(data)) + runtime.registerSubscriptionCleanup('sub-1', () => unsubB()) + + runtime.onPtyData('pty-1', 'after-resubscribe\n', 100) + + // Why: only the second listener should receive data. The first was + // cleaned up when registerSubscriptionCleanup replaced 'sub-1'. + expect(chunksA).toEqual([]) + expect(chunksB).toEqual(['after-resubscribe\n']) + }) + + it('cleanupSubscription removes the listener', () => { + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-1', TEST_WORKTREE_ID) + + const chunks: string[] = [] + const unsub = runtime.subscribeToTerminalData('pty-1', (data) => chunks.push(data)) + runtime.registerSubscriptionCleanup('sub-1', () => unsub()) + + runtime.onPtyData('pty-1', 'before\n', 100) + runtime.cleanupSubscription('sub-1') + runtime.onPtyData('pty-1', 'after\n', 101) + + expect(chunks).toEqual(['before\n']) + }) + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3f534d5754a..1e63ad76194 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -88,7 +88,14 @@ import { searchBaseRefs } from '../git/repo' import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree' -import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks' +import { + createSetupRunnerScript, + getEffectiveHooks, + getEffectiveSetupRunPolicy, + hasHooksFile, + runHook, + shouldRunSetupForCreate +} from '../hooks' import { REPO_COLORS } from '../../shared/constants' import { listRepoWorktrees } from '../repo-worktrees' import type { Store } from '../persistence' @@ -696,6 +703,14 @@ export class OrcaRuntimeService { } registerSubscriptionCleanup(subscriptionId: string, cleanup: () => void): void { + // Why: mobile clients reconnect frequently (phone lock, network switch). + // The RPC client re-sends terminal.subscribe on reconnect, creating a new + // handler before the old one is cleaned up. Without this, the old data + // listener leaks in dataListeners and duplicates every PTY data event. + const existing = this.subscriptionCleanups.get(subscriptionId) + if (existing) { + existing() + } this.subscriptionCleanups.set(subscriptionId, cleanup) } @@ -1430,6 +1445,19 @@ export class OrcaRuntimeService { } } + async getRepoHooks(repoSelector: string) { + const repo = await this.resolveRepoSelector(repoSelector) + const hasFile = hasHooksFile(repo.path) + const hooks = getEffectiveHooks(repo) + const setupRunPolicy = getEffectiveSetupRunPolicy(repo) + return { + hasHooksFile: hasFile, + hooks, + setupRunPolicy, + source: hasFile ? 'orca.yaml' : hooks ? 'legacy' : null + } + } + async listManagedWorktrees( repoSelector?: string, limit = DEFAULT_WORKTREE_LIST_LIMIT @@ -1476,6 +1504,7 @@ export class OrcaRuntimeService { linkedIssue?: number | null comment?: string runHooks?: boolean + setupDecision?: 'run' | 'skip' | 'inherit' }): Promise { if (!this.store) { throw new Error('runtime_unavailable') @@ -1563,7 +1592,14 @@ export class OrcaRuntimeService { let setup: CreateWorktreeResult['setup'] let warning: string | undefined const hooks = getEffectiveHooks(repo) - if (hooks?.scripts.setup && args.runHooks === true) { + // Why: setupDecision lets mobile/CLI callers control whether the setup + // script runs. 'skip' suppresses it, 'run' forces it, 'inherit' (default) + // defers to the repo's orca.yaml setupRunPolicy. runHooks === true maps + // to 'run' for backwards compatibility with the desktop create flow. + const effectiveDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit') + const shouldRunSetup = + hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) + if (shouldRunSetup && hooks?.scripts.setup) { if (this.authoritativeWindowId !== null) { try { // Why: CLI-created worktrees must use the same runner-script path as the diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index b3a851dac0d..3af1133f836 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -52,5 +52,10 @@ export const REPO_METHODS: RpcMethod[] = [ params: RepoSearchRefs, handler: async (params, { runtime }) => runtime.searchRepoRefs(params.repo, params.query, params.limit) + }), + defineMethod({ + name: 'repo.hooks', + params: RepoSelector, + handler: async (params, { runtime }) => runtime.getRepoHooks(params.repo) }) ] diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index c36b62a5c47..749672fcff5 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -28,14 +28,17 @@ const WorktreeCreate = z.object({ .unknown() .transform((v) => (typeof v === 'string' ? v : '')) .pipe(z.string().min(1, 'Missing repo selector')), - name: z - .unknown() - .transform((v) => (typeof v === 'string' ? v : '')) - .pipe(z.string().min(1, 'Missing worktree name')), + name: OptionalString, baseBranch: OptionalString, linkedIssue: TriStateLinkedIssue, comment: OptionalString, - runHooks: OptionalBoolean + runHooks: OptionalBoolean, + setupDecision: z + .unknown() + .transform((v) => + typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined + ) + .pipe(z.enum(['run', 'skip', 'inherit']).optional()) }) const WorktreeSet = WorktreeSelector.extend({ @@ -78,11 +81,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.createManagedWorktree({ repoSelector: params.repo, - name: params.name, + name: params.name ?? '', baseBranch: params.baseBranch, linkedIssue: params.linkedIssue, comment: params.comment, - runHooks: params.runHooks === true + runHooks: params.runHooks === true, + setupDecision: params.setupDecision }) }), defineMethod({ diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b9ca12740e0..03e49df8c35 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -853,6 +853,18 @@ export type PreloadApi = { runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise getStatus: () => Promise + getTerminalFitOverrides: () => Promise< + { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] + > + restoreTerminalFit: (ptyId: string) => Promise<{ restored: boolean }> + onTerminalFitOverrideChanged: ( + callback: (event: { + ptyId: string + mode: 'mobile-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + ) => () => void } rateLimits: { get: () => Promise diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 8948e0ae82a..cfab03a62d7 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -797,6 +797,10 @@ describe('useIpcEvents browser tab close routing', () => { onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {} + }, agentStatus: { onSet: () => () => {} } } }) @@ -982,6 +986,10 @@ describe('useIpcEvents browser tab close routing', () => { onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {} + }, agentStatus: { onSet: () => () => {} } } }) @@ -1162,6 +1170,10 @@ describe('useIpcEvents browser tab close routing', () => { onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {} + }, agentStatus: { onSet: () => () => {} } } }) @@ -1340,6 +1352,10 @@ describe('useIpcEvents shortcut hint clearing', () => { get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }), onUpdate: () => () => {} }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {} + }, ssh: { listTargets: () => Promise.resolve([]), listPortForwards: () => Promise.resolve([]), @@ -1547,6 +1563,10 @@ describe('useIpcEvents CLI-created worktree activation', () => { onDetectedPortsChanged: () => () => {}, onCredentialResolved: () => () => {} }, + runtime: { + getTerminalFitOverrides: () => Promise.resolve([]), + onTerminalFitOverrideChanged: () => () => {} + }, agentStatus: { onSet: () => () => {} } } }) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 6dcf269153a..d6f3f187120 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -169,7 +169,7 @@ export function useIpcEvents(): void { ) unsubs.push( - window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup }) => { + window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup }) => { void (async () => { // Why: fetch worktrees first so the activation helper can resolve // the CLI-created worktree via findWorktreeById — it arrived from @@ -180,7 +180,7 @@ export function useIpcEvents(): void { // ones. This records the visit in the back/forward history stack // (recordWorktreeVisit), without which the nav buttons would // ignore the CLI-driven workspace switch. - activateAndRevealWorktree(worktreeId, { setup }) + activateAndRevealWorktree(worktreeId, { setup, startup }) })().catch((error) => { console.error('Failed to activate CLI-created worktree:', error) })