diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 48ae99ff410..43e25e4ff26 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -10,6 +10,7 @@ import { } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter } from 'expo-router' +import * as Haptics from 'expo-haptics' import { Search, X, @@ -19,12 +20,16 @@ import { SlidersHorizontal, Layers, ChevronDown, - ChevronRight + ChevronRight, + ChevronLeft, + Plus, + Moon } from 'lucide-react-native' import { connect, type RpcClient } from '../../../src/transport/rpc-client' import { loadHosts, updateLastConnected, removeHost } from '../../../src/transport/host-store' import type { ConnectionState, RpcSuccess } from '../../../src/transport/types' import { StatusDot } from '../../../src/components/StatusDot' +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' @@ -236,6 +241,7 @@ export default function HostScreen() { const [actionTarget, setActionTarget] = useState(null) const [confirmDelete, setConfirmDelete] = useState(null) const [confirmRemoveHost, setConfirmRemoveHost] = useState(false) + const [showNewWorktree, setShowNewWorktree] = useState(false) // Persisted pin state const [pinnedIds, setPinnedIds] = useState>(new Set()) @@ -435,13 +441,18 @@ export default function HostScreen() { + router.back()}> + + {hostName || 'Host'} - {STATUS_LABELS[connState]} + {connState !== 'connected' && ( + {STATUS_LABELS[connState]} + )} {/* Filter/sort/group toolbar */} @@ -476,6 +487,17 @@ export default function HostScreen() { + setShowNewWorktree(true)} + disabled={connState !== 'connected'} + > + + + setShowSearch((s) => !s)}> {showSearch ? ( @@ -583,7 +605,10 @@ export default function HostScreen() { style={({ pressed }) => [styles.worktreeRow, pressed && styles.worktreeRowPressed]} disabled={isReadOnly} onPress={() => openWorktreeSession(item)} - onLongPress={() => setActionTarget(item)} + onLongPress={() => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium) + setActionTarget(item) + }} delayLongPress={400} > {/* Left indicator */} @@ -668,6 +693,17 @@ export default function HostScreen() { actions={ actionTarget ? [ + { + label: 'Sleep', + icon: Moon, + onPress: () => { + if (client) { + void client.sendRequest('worktree.sleep', { + worktree: `id:${actionTarget.worktreeId}` + }) + } + } + }, { label: pinnedIds.has(actionTarget.worktreeId) ? 'Unpin' : 'Pin', onPress: () => togglePin(actionTarget.worktreeId) @@ -723,6 +759,18 @@ export default function HostScreen() { ]} onClose={() => setConfirmRemoveHost(false)} /> + + { + void fetchWorktrees() + const params = new URLSearchParams({ name: worktreeName }) + if (agentCommand) params.set('agent', agentCommand) + router.push(`/h/${hostId}/session/${encodeURIComponent(worktreeId)}?${params.toString()}`) + }} + onClose={() => setShowNewWorktree(false)} + /> ) } @@ -752,6 +800,13 @@ const styles = StyleSheet.create({ paddingTop: spacing.xs, paddingHorizontal: spacing.lg }, + backButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.xs + }, hostIdentity: { flex: 1, flexDirection: 'row', @@ -841,6 +896,9 @@ const styles = StyleSheet.create({ toolbarSpacer: { flex: 1 }, + newButton: { + padding: spacing.xs + }, searchToggle: { padding: spacing.xs }, @@ -916,7 +974,7 @@ const styles = StyleSheet.create({ indicatorCol: { width: 20, alignItems: 'center', - paddingTop: 3, + paddingTop: 6, marginRight: spacing.sm, gap: 4 }, diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index bf58eff5e49..664dc498547 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -12,6 +12,7 @@ import { } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter } from 'expo-router' +import * as Haptics from 'expo-haptics' import { ArrowUp, ChevronLeft, Monitor, Plus, Smartphone } from 'lucide-react-native' import { connect, type RpcClient } from '../../../../src/transport/rpc-client' import { loadHosts } from '../../../../src/transport/host-store' @@ -69,11 +70,13 @@ export default function SessionScreen() { const { hostId, worktreeId, - name: worktreeName + name: worktreeName, + agent: agentCommand } = useLocalSearchParams<{ hostId: string worktreeId: string name?: string + agent?: string }>() const router = useRouter() const [client, setClient] = useState(null) @@ -116,6 +119,7 @@ export default function SessionScreen() { // Existing desktop terminals should keep their desktop dimensions until // the user explicitly chooses "Fit to Phone". const mobileCreatedHandlesRef = useRef>(new Set()) + const agentCommandConsumedRef = useRef(false) const termRef = useRef(null) const unsubRef = useRef<(() => void) | null>(null) const activeHandleRef = useRef(null) @@ -123,14 +127,9 @@ export default function SessionScreen() { const canSend = connState === 'connected' && activeHandle != null - const writeLines = useCallback((lines: string[] | string | undefined) => { + const formatLines = useCallback((lines: string[] | string | undefined) => { const text = Array.isArray(lines) ? lines.join('\r\n') : (lines ?? '') - if (text) { - termRef.current?.write(text) - if (Array.isArray(lines)) { - termRef.current?.write('\r\n') - } - } + return text && Array.isArray(lines) ? `${text}\r\n` : text }, []) // Why: after a fit/restore resize the PTY grid changes, so we must @@ -160,12 +159,11 @@ export default function SessionScreen() { if (data.type === 'scrollback') { const cols = (data.cols as number) || 80 const rows = (data.rows as number) || 24 - termRef.current?.init(cols, rows) - if (typeof data.serialized === 'string' && data.serialized.length > 0) { - termRef.current?.write(data.serialized) - } else { - writeLines(data.lines as string[] | string | undefined) - } + const initialData = + typeof data.serialized === 'string' && data.serialized.length > 0 + ? data.serialized + : formatLines(data.lines as string[] | string | undefined) + termRef.current?.init(cols, rows, initialData) } else if (data.type === 'data') { termRef.current?.write(data.chunk as string) } @@ -178,7 +176,7 @@ export default function SessionScreen() { } })() }, - [client, writeLines] + [client, formatLines] ) // Why: extracted so both manual "Fit to Phone" and auto-fit-on-subscribe @@ -285,15 +283,25 @@ export default function SessionScreen() { subscribeSeqRef.current = seq const rpcClient = client + // Why: phone-fitted terminals skip terminal.focus (which would cause + // the desktop to auto-resize back to desktop dimensions). Instead we + // subscribe directly and compare the scrollback geometry against the + // phone dimensions. If they match, the content is already correct and + // we avoid a resize→SIGWINCH→redraw cycle that strips TUI colors. + // If they don't match (desktop auto-restored while we were away), + // we refit — but only then. + const shouldAutoFit = + !manuallyRestoredRef.current.has(handle) && + (mobileCreatedHandlesRef.current.has(handle) || fittedHandlesRef.current.has(handle)) + void (async () => { - try { - // Why: hidden/new desktop panes can still be at their 80x24 spawn - // geometry. Focusing first lets the desktop renderer fit xterm, - // resize the PTY, and register a serializer before mobile subscribes. - await client.sendRequest('terminal.focus', { terminal: handle }) - await new Promise((resolve) => setTimeout(resolve, 150)) - } catch { - // Continue with best-effort streaming if desktop focus is unavailable. + if (!shouldAutoFit) { + try { + await client.sendRequest('terminal.focus', { terminal: handle }) + await new Promise((resolve) => setTimeout(resolve, 150)) + } catch { + // Continue with best-effort streaming if desktop focus is unavailable. + } } if (subscribeSeqRef.current !== seq || activeHandleRef.current !== handle) { @@ -308,29 +316,25 @@ export default function SessionScreen() { if (data.type === 'scrollback') { const cols = (data.cols as number) || 80 const rows = (data.rows as number) || 24 - termRef.current?.init(cols, rows) - if (typeof data.serialized === 'string' && data.serialized.length > 0) { - termRef.current?.write(data.serialized) - } else { - writeLines(data.lines as string[] | string | undefined) - } - // Why: auto-fit terminals that (a) were created during this - // mobile session, or (b) the user previously fitted to phone - // and didn't manually restore. Case (b) handles the scenario - // where navigating away disconnects the WebSocket (triggering - // auto-restore on desktop) and coming back should re-apply - // the phone fit so the user doesn't lose their aspect ratio. - if ( - !manuallyRestoredRef.current.has(handle) && - (mobileCreatedHandlesRef.current.has(handle) || - fittedHandlesRef.current.has(handle)) && - !fitPendingRef.current - ) { - setTimeout(() => { - if (activeHandleRef.current === handle && subscribeSeqRef.current === seq) { - void fitToPhoneCore(handle, rpcClient) + const initialData = + typeof data.serialized === 'string' && data.serialized.length > 0 + ? data.serialized + : formatLines(data.lines as string[] | string | undefined) + termRef.current?.init(cols, rows, initialData) + // Why: only refit if the scrollback geometry doesn't match + // the phone WebView dimensions. This avoids unnecessary + // resize→SIGWINCH cycles on tab switches when the terminal + // is already at the correct phone size. + if (shouldAutoFit && !fitPendingRef.current) { + void (async () => { + const dims = await termRef.current?.measureFitDimensions() + if (!dims) return + if (cols !== dims.cols || rows !== dims.rows) { + if (activeHandleRef.current === handle && subscribeSeqRef.current === seq) { + void fitToPhoneCore(handle, rpcClient) + } } - }, 600) + })() } } else if (data.type === 'data') { termRef.current?.write(data.chunk as string) @@ -344,7 +348,7 @@ export default function SessionScreen() { } })() }, - [client, writeLines, fitToPhoneCore] + [client, formatLines, fitToPhoneCore] ) const fetchTerminals = useCallback( @@ -418,6 +422,47 @@ export default function SessionScreen() { return () => clearInterval(interval) }, [connState, fetchTerminals]) + // Why: when navigating here from the "New Worktree" modal with an agent + // command, create the terminal from the session page (not the modal) so + // it goes through the normal mobile-created → auto-fit-to-phone flow. + // The modal can't do this because the terminal would start at the desktop's + // default geometry before the mobile WebView is mounted. + useEffect(() => { + if ( + !agentCommand || + !client || + connState !== 'connected' || + !terminalsLoaded || + agentCommandConsumedRef.current + ) { + return + } + agentCommandConsumedRef.current = true + + void (async () => { + try { + const response = await client.sendRequest('terminal.create', { + worktree: `id:${worktreeId}`, + command: agentCommand + }) + if (response.ok) { + const result = (response as RpcSuccess).result as TerminalCreateResult + const created = result.terminal + mobileCreatedHandlesRef.current.add(created.handle) + activeHandleRef.current = created.handle + setActiveHandle(created.handle) + setTerminals((prev) => [ + ...prev, + { handle: created.handle, title: created.title || 'Terminal', isActive: true } + ]) + subscribeToTerminal(created.handle) + } + } catch { + // Agent terminal creation failed — user can create manually via + + } + })() + }, [agentCommand, client, connState, terminalsLoaded, worktreeId, subscribeToTerminal]) + const switchTab = useCallback( (handle: string) => { activeHandleRef.current = handle @@ -600,7 +645,10 @@ export default function SessionScreen() { key={t.handle} style={[styles.tab, t.handle === activeHandle && styles.tabActive]} onPress={() => switchTab(t.handle)} - onLongPress={() => setActionTarget(t)} + onLongPress={() => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium) + setActionTarget(t) + }} delayLongPress={400} > void handleSend()} accessibilityLabel="Send command" > - + @@ -949,7 +997,7 @@ const styles = StyleSheet.create({ marginRight: spacing.sm }, sendButton: { - backgroundColor: colors.accentBlue, + backgroundColor: colors.bgRaised, width: 34, height: 34, borderRadius: 17, diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 2cba39717d8..61018a91723 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -2,6 +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 * as Haptics from 'expo-haptics' import { MoreHorizontal, QrCode, Server } from 'lucide-react-native' import { loadHosts, removeHost, renameHost } from '../src/transport/host-store' import { connect } from '../src/transport/rpc-client' @@ -114,7 +115,10 @@ export default function HomeScreen() { [styles.hostCard, pressed && styles.hostCardPressed]} onPress={() => router.push(`/h/${item.id}`)} - onLongPress={() => setActionTarget(item)} + onLongPress={() => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium) + setActionTarget(item) + }} delayLongPress={400} > diff --git a/mobile/package.json b/mobile/package.json index 8b0979a4b76..b479a8244c5 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -17,6 +17,7 @@ "expo-camera": "^55.0.16", "expo-constants": "^55.0.15", "expo-crypto": "^55.0.14", + "expo-haptics": "^55.0.14", "expo-linking": "^55.0.14", "expo-router": "^55.0.13", "expo-status-bar": "^55.0.5", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 6e90529a984..1d731e06e1d 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: expo-crypto: specifier: ^55.0.14 version: 55.0.14(expo@55.0.17) + expo-haptics: + specifier: ^55.0.14 + version: 55.0.14(expo@55.0.17) expo-linking: specifier: ^55.0.14 version: 55.0.14(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) @@ -3121,6 +3124,11 @@ packages: react: '*' react-native: '*' + expo-haptics@55.0.14: + resolution: {integrity: sha512-KjDItBsA9mi1f5nRwf8g1wOdfEcLHwvEdt5Jl1sMCDETR/homcGOl+F3QIiPOl/PRlbGVieQsjTtF4DGtHOj6g==} + peerDependencies: + expo: '*' + expo-image@55.0.9: resolution: {integrity: sha512-+NVgWv+tr7a6EpBEaIIVVp+XfruRA2JL5xOxvd6ajvFGdH0rOhagwX1m1piAII6w7sh6uAnBr8X+fDZsav7B2w==} peerDependencies: @@ -9355,6 +9363,10 @@ snapshots: react: 19.2.0 react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + expo-haptics@55.0.14(expo@55.0.17): + dependencies: + expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-image@55.0.9(expo@55.0.17)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): dependencies: expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)