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 <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-04-30 18:03:54 -04:00
co-authored by Orca
parent 92aeac9692
commit 14a314a29d
18 changed files with 904 additions and 124 deletions
+3 -1
View File
@@ -23,7 +23,9 @@ export default function RootLayout() {
headerTitle: () => <OrcaLogo size={22} />
}}
/>
<Stack.Screen name="pair-scan" options={{ title: 'Pair Host' }} />
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
</Stack>
</>
+146
View File
@@ -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 (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</Svg>
)
}
function XIcon({ size = 16, color = colors.textSecondary }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</Svg>
)
}
export default function AboutScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>About</Text>
</View>
<View style={styles.brand}>
<OrcaLogo size={28} />
<Text style={styles.brandName}>Orca</Text>
<Text style={styles.brandSub}>Open-source agent IDE for 100x builders</Text>
</View>
<View style={styles.section}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://onOrca.dev')}
>
<Globe size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Website</Text>
<Text style={styles.rowValue}>onOrca.dev</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://github.com/stablyai/orca')}
>
<GithubIcon />
<Text style={styles.rowLabel}>GitHub</Text>
<Text style={styles.rowValue}>stablyai/orca</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://x.com/orca_build')}
>
<XIcon />
<Text style={styles.rowLabel}>X</Text>
<Text style={styles.rowValue}>@orca_build</Text>
</Pressable>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.xl
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
brand: {
alignItems: 'center',
paddingVertical: spacing.xl,
marginBottom: spacing.lg
},
brandName: {
fontSize: 22,
fontWeight: '800',
color: colors.textPrimary,
marginTop: spacing.sm
},
brandSub: {
fontSize: 13,
color: colors.textMuted,
marginTop: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowLabel: {
flex: 1,
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowValue: {
fontSize: typography.bodySize,
color: colors.accentBlue
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
}
})
+177 -23
View File
@@ -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<string>
}
const STATUS_LABELS: Record<ConnectionState, string> = {
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<string>): boolean {
function buildSections(
worktrees: Worktree[],
sortMode: SortMode,
filterMode: FilterMode,
filters: FilterState,
search: string,
groupMode: GroupMode,
pinnedIds: Set<string>
): 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<SortMode>('smart')
const [filterMode, setFilterMode] = useState<FilterMode>('all')
const [filters, setFilters] = useState<FilterState>({
activeOnly: false,
selectedRepos: new Set()
})
const [groupMode, setGroupMode] = useState<GroupMode>('none')
// Modals
const [showSortPicker, setShowSortPicker] = useState(false)
const [showGroupPicker, setShowGroupPicker] = useState(false)
const [showFilterModal, setShowFilterModal] = useState(false)
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(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<string, string>()
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 */}
<View style={styles.toolbar}>
<Pressable
style={[styles.filterChip, filterMode === 'active' && styles.filterChipActive]}
onPress={handleFilterToggle}
style={[styles.filterChip, activeFilterCount > 0 && styles.filterChipActive]}
onPress={() => setShowFilterModal(true)}
>
<Filter
size={12}
color={activeFilterCount > 0 ? colors.textPrimary : colors.textSecondary}
/>
<Text
style={[
styles.filterChipText,
filterMode === 'active' && styles.filterChipTextActive
]}
style={[styles.filterChipText, activeFilterCount > 0 && styles.filterChipTextActive]}
>
Active
Filter{activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}
</Text>
</Pressable>
@@ -574,8 +630,8 @@ export default function HostScreen() {
<Text style={styles.emptyText}>
{search
? 'No matching worktrees'
: filterMode === 'active'
? 'No active worktrees'
: activeFilterCount > 0
? 'No worktrees match filters'
: 'No worktrees'}
</Text>
</View>
@@ -697,6 +753,48 @@ export default function HostScreen() {
onClose={() => setShowGroupPicker(false)}
/>
{/* Filter modal — matches desktop's Status + Repositories dropdown */}
<BottomDrawer visible={showFilterModal} onClose={() => setShowFilterModal(false)}>
<View style={styles.filterModalHeader}>
<Text style={styles.filterModalTitle}>Filter</Text>
{activeFilterCount > 0 && (
<Pressable onPress={clearFilters}>
<Text style={styles.clearFiltersText}>Clear filters</Text>
</Pressable>
)}
</View>
<Text style={styles.filterSectionLabel}>Status</Text>
<View style={styles.filterGroup}>
<Pressable style={styles.filterRow} onPress={toggleActiveFilter}>
<Text style={styles.filterRowText}>Active only</Text>
{filters.activeOnly && <Check size={14} color={colors.textPrimary} />}
</Pressable>
</View>
{uniqueRepos.length > 1 && (
<>
<Text style={styles.filterSectionLabel}>Repositories</Text>
<View style={styles.filterGroup}>
{uniqueRepos.map((repo, i) => (
<View key={repo.name}>
{i > 0 && <View style={styles.filterSeparator} />}
<Pressable style={styles.filterRow} onPress={() => toggleRepoFilter(repo.name)}>
<View style={[styles.filterRepoDot, { backgroundColor: repo.color }]} />
<Text style={styles.filterRowText} numberOfLines={1}>
{repo.name}
</Text>
{filters.selectedRepos.has(repo.name) && (
<Check size={14} color={colors.textPrimary} />
)}
</Pressable>
</View>
))}
</View>
</>
)}
</BottomDrawer>
{/* Worktree long-press action sheet */}
<ActionSheetModal
visible={actionTarget != null && confirmDelete == null}
@@ -873,6 +971,9 @@ const styles = StyleSheet.create({
borderBottomColor: colors.borderSubtle
},
filterChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm + 2,
paddingVertical: spacing.xs,
borderRadius: 12,
@@ -1061,5 +1162,58 @@ const styles = StyleSheet.create({
minWidth: 16,
textAlign: 'right',
paddingTop: 3
},
filterModalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.xs,
marginBottom: spacing.md
},
filterModalTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
clearFiltersText: {
fontSize: 13,
color: colors.accentBlue
},
filterSectionLabel: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
filterGroup: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden',
marginBottom: spacing.md
},
filterRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2,
gap: spacing.sm
},
filterRowText: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary
},
filterSeparator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
filterRepoDot: {
width: 8,
height: 8,
borderRadius: 4
}
})
+11 -1
View File
@@ -170,6 +170,7 @@ export default function SessionScreen() {
const locallyCreatedHandlesRef = useRef<Set<string>>(new Set())
const activeHandleRef = useRef<string | null>(null)
const subscribeSeqRef = useRef<Map<string, number>>(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
}
}
+20 -9
View File
@@ -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)}
/>
<Pressable style={styles.settingsButton} onPress={() => router.push('/settings')}>
<Settings size={16} color={colors.textMuted} />
<Text style={styles.settingsText}>Settings</Text>
</Pressable>
</SafeAreaView>
)
}
@@ -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'
}
})
+87 -35
View File
@@ -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 (
<View style={styles.step}>
<View style={styles.stepBadge}>
<Text style={styles.stepNumber}>{number}</Text>
</View>
<View style={styles.stepIcon}>{icon}</View>
<Text style={styles.stepText}>{text}</Text>
</View>
)
@@ -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 (
<View style={styles.container}>
<View style={[styles.container, containerPadding]}>
<ActivityIndicator color={colors.textSecondary} />
</View>
)
@@ -104,7 +107,10 @@ export default function PairScanScreen() {
if (!permission.granted) {
return (
<View style={styles.container}>
<View style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<View style={styles.centered}>
<Text style={styles.title}>Camera Permission</Text>
<Text style={styles.subtitle}>
@@ -119,32 +125,32 @@ export default function PairScanScreen() {
}
return (
<View style={styles.container}>
<View style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<View style={styles.steps}>
<Step
number={1}
icon={<Monitor size={14} color={colors.textSecondary} />}
text="Open Orca on your computer"
/>
<Step
number={2}
icon={<Settings size={14} color={colors.textSecondary} />}
text="Go to Settings → Mobile"
/>
<Step
number={3}
icon={<Smartphone size={14} color={colors.textSecondary} />}
text="Point this camera at the QR code"
/>
<Step number={1} text="Open Orca on your computer" />
<Step number={2} text="Go to Settings → Mobile" />
<Step number={3} text="Scan the QR code" />
</View>
{status === 'scanning' && (
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarCodeScanned}
/>
<View style={styles.cameraWrap}>
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={handleBarCodeScanned}
/>
<View style={styles.reticle} pointerEvents="none">
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
</View>
)}
{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',
+80
View File
@@ -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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Settings</Text>
</View>
<View style={styles.section}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/about')}
>
<Info size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>About</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.xl
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
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
}
})
+22 -6
View File
@@ -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) {
<View style={styles.anchor} pointerEvents="box-none">
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.drawer, drawerStyle]}>
<Animated.View
style={[styles.drawer, { paddingBottom: insets.bottom + spacing.lg }, drawerStyle]}
>
<View style={styles.handle} />
{children}
<View style={styles.bottomExtension} />
</Animated.View>
</GestureDetector>
</View>
@@ -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
}
})
+184 -34
View File
@@ -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<AgentOption>(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<string | null>(null)
const [setupSource, setSetupSource] = useState<string | null>(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<string, unknown> = {
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 (
<>
<BottomDrawer visible={visible} onClose={onClose}>
<View style={styles.header}>
<Text style={styles.title}>New Worktree</Text>
<Text style={styles.subtitle}>Pick a repository and agent</Text>
<Text style={styles.title}>Create Workspace</Text>
<Text style={styles.subtitle}>
Pick a repository and agent to spin up a new workspace.
</Text>
</View>
{loading ? (
@@ -347,7 +393,6 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
</View>
) : (
<>
{/* Repository */}
<View style={styles.field}>
<Text style={styles.label}>Repository</Text>
<Pressable style={styles.fieldButton} onPress={() => setShowRepoPicker(true)}>
@@ -361,9 +406,10 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
</Pressable>
</View>
{/* Name */}
<View style={styles.field}>
<Text style={styles.label}>Name</Text>
<Text style={styles.label}>
Workspace Name <Text style={styles.labelHint}>[Optional]</Text>
</Text>
<TextInput
style={styles.input}
value={name}
@@ -371,7 +417,7 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
setName(t)
setError('')
}}
placeholder="my-feature"
placeholder="Workspace name"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
@@ -383,7 +429,6 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
/>
</View>
{/* Agent */}
<View style={styles.field}>
<Text style={styles.label}>Agent</Text>
<Pressable style={styles.fieldButton} onPress={() => setShowAgentPicker(true)}>
@@ -395,12 +440,65 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
</Pressable>
</View>
<Pressable style={styles.advancedToggle} onPress={() => setShowAdvanced(!showAdvanced)}>
<Text style={styles.advancedText}>Advanced</Text>
{showAdvanced ? (
<ChevronUp size={14} color={colors.textSecondary} />
) : (
<ChevronDown size={14} color={colors.textSecondary} />
)}
</Pressable>
{showAdvanced && (
<>
<View style={styles.field}>
<Text style={styles.label}>Note</Text>
<TextInput
style={styles.input}
value={note}
onChangeText={setNote}
placeholder="Write a note"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{setupCommand ? (
<View style={styles.field}>
<View style={styles.setupHeader}>
<Text style={styles.label}>Setup script</Text>
{setupSource && (
<View style={styles.sourceBadge}>
<Text style={styles.sourceBadgeText}>
{setupSource === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'}
</Text>
</View>
)}
</View>
<View style={styles.setupBox}>
<View style={styles.setupToggleRow}>
<Text style={styles.setupToggleLabel}>Run setup command</Text>
<Switch
value={runSetup}
onValueChange={setRunSetup}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
style={styles.setupSwitch}
/>
</View>
<View style={styles.setupCommandBlock}>
<Text style={styles.setupCommand}>{setupCommand}</Text>
</View>
</View>
</View>
) : null}
</>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
<View style={styles.actions}>
<Pressable style={styles.cancelButton} onPress={onClose}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
<Pressable
style={[styles.createButton, !canCreate && styles.createButtonDisabled]}
disabled={!canCreate}
@@ -409,7 +507,7 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
{creating ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<Text style={styles.createText}>Create</Text>
<Text style={styles.createText}>Create Workspace</Text>
)}
</Pressable>
</View>
@@ -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,
+3 -1
View File
@@ -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<Set<string>> {
-3
View File
@@ -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) {
+83
View File
@@ -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'])
})
})
})
+38 -2
View File
@@ -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<CreateWorktreeResult> {
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
+5
View File
@@ -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)
})
]
+11 -7
View File
@@ -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({
+12
View File
@@ -853,6 +853,18 @@ export type PreloadApi = {
runtime: {
syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise<RuntimeStatus>
getStatus: () => Promise<RuntimeStatus>
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<RateLimitState>
@@ -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: () => () => {} }
}
})
+2 -2
View File
@@ -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)
})