Add haptic feedback, sleep action, and fix send button styling

- Add expo-haptics medium impact feedback on all long-press interactions
  (terminal tabs, worktree cards, host cards)
- Add "Sleep" option with Moon icon to worktree action sheet, calling
  worktree.sleep RPC method
- Change send button from bright blue (accentBlue) to subtle dark
  (bgRaised) background with textSecondary icon color

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-04-30 18:03:24 -04:00
co-authored by Orca
parent fc75b28f7d
commit 42e9ed2ca1
5 changed files with 177 additions and 54 deletions
+62 -4
View File
@@ -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<Worktree | null>(null)
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
const [showNewWorktree, setShowNewWorktree] = useState(false)
// Persisted pin state
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
@@ -435,13 +441,18 @@ export default function HostScreen() {
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.topChrome}>
<View style={styles.statusBar}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textPrimary} />
</Pressable>
<View style={styles.hostIdentity}>
<StatusDot state={connState} />
<Text style={styles.hostNameText} numberOfLines={1}>
{hostName || 'Host'}
</Text>
</View>
<Text style={styles.statusText}>{STATUS_LABELS[connState]}</Text>
{connState !== 'connected' && (
<Text style={styles.statusText}>{STATUS_LABELS[connState]}</Text>
)}
</View>
{/* Filter/sort/group toolbar */}
@@ -476,6 +487,17 @@ export default function HostScreen() {
<View style={styles.toolbarSpacer} />
<Pressable
style={styles.newButton}
onPress={() => setShowNewWorktree(true)}
disabled={connState !== 'connected'}
>
<Plus
size={16}
color={connState === 'connected' ? colors.textPrimary : colors.textMuted}
/>
</Pressable>
<Pressable style={styles.searchToggle} onPress={() => setShowSearch((s) => !s)}>
{showSearch ? (
<X size={16} color={colors.textSecondary} />
@@ -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)}
/>
<NewWorktreeModal
visible={showNewWorktree}
client={client}
onCreated={(worktreeId, worktreeName, agentCommand) => {
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)}
/>
</SafeAreaView>
)
}
@@ -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
},
+97 -49
View File
@@ -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<RpcClient | null>(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<Set<string>>(new Set())
const agentCommandConsumedRef = useRef(false)
const termRef = useRef<TerminalWebViewHandle>(null)
const unsubRef = useRef<(() => void) | null>(null)
const activeHandleRef = useRef<string | null>(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}
>
<Text
@@ -699,7 +747,7 @@ export default function SessionScreen() {
onPress={() => void handleSend()}
accessibilityLabel="Send command"
>
<ArrowUp size={18} color="#fff" strokeWidth={2.5} />
<ArrowUp size={18} color={colors.textSecondary} strokeWidth={2.5} />
</Pressable>
</View>
@@ -949,7 +997,7 @@ const styles = StyleSheet.create({
marginRight: spacing.sm
},
sendButton: {
backgroundColor: colors.accentBlue,
backgroundColor: colors.bgRaised,
width: 34,
height: 34,
borderRadius: 17,
+5 -1
View File
@@ -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() {
<Pressable
style={({ pressed }) => [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}
>
<View style={styles.hostIcon}>
+1
View File
@@ -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",
+12
View File
@@ -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)