Fix mobile review findings

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-04-30 18:03:56 -04:00
co-authored by Orca
parent ec693359d8
commit b2612fa14d
14 changed files with 315 additions and 95 deletions
+8 -1
View File
@@ -15,13 +15,20 @@
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.stably.orca.mobile"
"bundleIdentifier": "com.stably.orca.mobile",
"infoPlist": {
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true
}
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#111111"
},
"usesCleartextTraffic": true,
"allowBackup": false,
"package": "com.stably.orca.mobile"
},
+20 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import {
View,
Text,
@@ -239,6 +239,7 @@ export default function HostScreen() {
hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
)
const [client, setClient] = useState<RpcClient | null>(null)
const clientRef = useRef<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('disconnected')
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
@@ -276,8 +277,10 @@ export default function HostScreen() {
// Load persisted pins and preferences
useEffect(() => {
if (!hostId) return
let stale = false
void (async () => {
const [pins, prefs] = await Promise.all([loadPinnedIds(hostId), loadPreferences(hostId)])
if (stale) return
setPinnedIds(pins)
setSortMode(prefs.sortMode as SortMode)
setFilters({
@@ -288,11 +291,19 @@ export default function HostScreen() {
setCollapsedGroups(new Set(prefs.collapsedGroups))
setPrefsLoaded(true)
})()
return () => {
stale = true
}
}, [hostId])
useEffect(() => {
let disposed = false
let rpcClient: RpcClient | null = null
clientRef.current = null
setClient(null)
setConnState('connecting')
setHostName('')
setError('')
// Why: re-seed from the current host's cache on every hostId change.
// The useState initializer only runs on first mount, so if Expo Router
// reuses this screen with a different hostId, we must reset here.
@@ -331,6 +342,7 @@ export default function HostScreen() {
return
}
setHostName(host.name)
clientRef.current = rpcClient
setClient(rpcClient)
await updateLastConnected(host.id)
@@ -341,14 +353,20 @@ export default function HostScreen() {
disposed = true
cancelAnimationFrame(rafId)
rpcClient?.close()
if (clientRef.current === rpcClient) {
clientRef.current = null
}
}
}, [hostId])
const fetchWorktrees = useCallback(async () => {
if (!client || connState !== 'connected') return
const requestClient = client
const requestHostId = hostId
try {
const response = await client.sendRequest('worktree.ps')
const response = await requestClient.sendRequest('worktree.ps')
if (clientRef.current !== requestClient || hostId !== requestHostId) return
if (response.ok) {
const result = (response as RpcSuccess).result as { worktrees: Worktree[] }
setWorktrees(result.worktrees)
+76 -36
View File
@@ -167,6 +167,7 @@ export default function SessionScreen() {
// the current value to avoid re-fitting while a fit is already in progress.
const fitPendingRef = useRef(false)
fitPendingRef.current = fitPending
const clientRef = useRef<RpcClient | null>(null)
// Why: tracks terminals the user explicitly restored to desktop size.
// Without this, switching away and back would auto-fit them again.
const manuallyRestoredRef = useRef<Set<string>>(new Set())
@@ -283,9 +284,17 @@ export default function SessionScreen() {
async (handle: string, rpcClient: RpcClient) => {
const clientId = deviceTokenRef.current
if (!clientId) return
const seq = subscribeSeqRef.current.get(handle)
const dims = await getTerminalRef(handle)?.measureFitDimensions()
if (!dims) return
if (
clientRef.current !== rpcClient ||
!terminalRefs.current.has(handle) ||
subscribeSeqRef.current.get(handle) !== seq
) {
return
}
const response = await rpcClient.sendRequest('terminal.resizeForClient', {
terminal: handle,
@@ -295,6 +304,13 @@ export default function SessionScreen() {
clientId
})
if (!response.ok) return
if (
clientRef.current !== rpcClient ||
!terminalRefs.current.has(handle) ||
subscribeSeqRef.current.get(handle) !== seq
) {
return
}
updateFittedHandles((prev) => new Set(prev).add(handle))
resubscribeWithoutFocus(handle)
@@ -303,44 +319,58 @@ export default function SessionScreen() {
[getTerminalRef, resubscribeWithoutFocus, updateFittedHandles]
)
const handleFitToPhone = useCallback(async () => {
if (!client || !activeHandle || fitPending) return
const handleFitToPhone = useCallback(
async (targetHandle = activeHandle) => {
if (!client || !targetHandle || fitPending) return
manuallyRestoredRef.current.delete(activeHandle)
setFitPending(true)
try {
await fitToPhoneCore(activeHandle, client)
} finally {
setFitPending(false)
}
}, [client, activeHandle, fitPending, fitToPhoneCore])
manuallyRestoredRef.current.delete(targetHandle)
setFitPending(true)
try {
await fitToPhoneCore(targetHandle, client)
} finally {
setFitPending(false)
}
},
[client, activeHandle, fitPending, fitToPhoneCore]
)
const handleRestoreDesktopSize = useCallback(async () => {
if (!client || !activeHandle) return
const handle = activeHandle
const clientId = deviceTokenRef.current
if (!clientId) return
const handleRestoreDesktopSize = useCallback(
async (targetHandle = activeHandle) => {
if (!client || !targetHandle) return
const handle = targetHandle
const clientId = deviceTokenRef.current
if (!clientId) return
const seq = subscribeSeqRef.current.get(handle)
try {
const response = await client.sendRequest('terminal.resizeForClient', {
terminal: handle,
mode: 'restore',
clientId
})
if (!response.ok) return
try {
const response = await client.sendRequest('terminal.resizeForClient', {
terminal: handle,
mode: 'restore',
clientId
})
if (!response.ok) return
if (
clientRef.current !== client ||
!terminalRefs.current.has(handle) ||
subscribeSeqRef.current.get(handle) !== seq
) {
return
}
manuallyRestoredRef.current.add(handle)
updateFittedHandles((prev) => {
const next = new Set(prev)
next.delete(handle)
return next
})
resubscribeWithoutFocus(handle)
setTimeout(() => getTerminalRef(handle)?.resetZoom(), 500)
} catch {
// Restore failed — keep current state.
}
}, [client, activeHandle, getTerminalRef, resubscribeWithoutFocus, updateFittedHandles])
manuallyRestoredRef.current.add(handle)
updateFittedHandles((prev) => {
const next = new Set(prev)
next.delete(handle)
return next
})
resubscribeWithoutFocus(handle)
setTimeout(() => getTerminalRef(handle)?.resetZoom(), 500)
} catch {
// Restore failed — keep current state.
}
},
[client, activeHandle, getTerminalRef, resubscribeWithoutFocus, updateFittedHandles]
)
useEffect(() => {
let disposed = false
@@ -360,12 +390,16 @@ export default function SessionScreen() {
return
}
setClient(rpcClient)
clientRef.current = rpcClient
})()
return () => {
disposed = true
clearTerminalCache()
rpcClient?.close()
if (clientRef.current === rpcClient) {
clientRef.current = null
}
}
}, [clearTerminalCache, hostId])
@@ -1028,8 +1062,11 @@ export default function SessionScreen() {
label: 'Restore Desktop Size',
icon: Monitor,
onPress: () => {
const target = actionTarget
setActionTarget(null)
void handleRestoreDesktopSize()
if (target) {
void handleRestoreDesktopSize(target.handle)
}
}
}
]
@@ -1038,8 +1075,11 @@ export default function SessionScreen() {
label: fitPending ? 'Fitting…' : 'Fit to Phone',
icon: Smartphone,
onPress: () => {
const target = actionTarget
setActionTarget(null)
void handleFitToPhone()
if (target) {
void handleFitToPhone(target.handle)
}
}
}
]),
+57 -6
View File
@@ -4,7 +4,6 @@ import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter, useFocusEffect } from 'expo-router'
import {
Monitor,
MoreHorizontal,
QrCode,
Settings,
Bot,
@@ -21,12 +20,11 @@ import { subscribeToDesktopNotifications } from '../src/notifications/mobile-not
import type { ConnectionState, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { StatusDot } from '../src/components/StatusDot'
import { TextInputModal } from '../src/components/TextInputModal'
import { ActionSheetModal } from '../src/components/ActionSheetModal'
import { ConfirmModal } from '../src/components/ConfirmModal'
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { colors, spacing, radii } from '../src/theme/mobile-theme'
function endpointLabel(endpoint: string): string {
try {
@@ -104,6 +102,18 @@ function fetchWorktreeInfo(
) => void,
disposed: () => boolean
) {
const markLoaded = () => {
setInfo((prev) => ({
...prev,
[hostId]: {
hostId,
totalWorktrees: 0,
activeCount: 0,
lastActiveWorktree: null
}
}))
}
client
.sendRequest('worktree.ps')
.then((response) => {
@@ -124,9 +134,13 @@ function fetchWorktreeInfo(
lastActiveWorktree: lastActive
}
}))
} else {
markLoaded()
}
})
.catch(() => {})
.catch(() => {
if (!disposed()) markLoaded()
})
}
// Why: repo names get a stable color derived from hashing, matching the
@@ -260,6 +274,20 @@ export default function HomeScreen() {
return null
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
const resumeLoading = useMemo(
() =>
sortedHosts.some((host) => {
const state = hostStates[host.id] ?? 'connecting'
return (
state === 'connecting' ||
state === 'handshaking' ||
state === 'reconnecting' ||
(state === 'connected' && !worktreeInfo[host.id])
)
}),
[sortedHosts, hostStates, worktreeInfo]
)
async function handleRename(newName: string) {
if (!renameTarget) return
try {
@@ -423,7 +451,7 @@ export default function HomeScreen() {
ListFooterComponent={
<View>
{/* ─── Resume card ─── */}
{resumeWorktree && (
{resumeWorktree ? (
<>
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Resume</Text>
<Pressable
@@ -458,7 +486,18 @@ export default function HomeScreen() {
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</>
)}
) : hosts.length > 0 && resumeLoading ? (
<>
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Resume</Text>
<View style={styles.resumeCard}>
<View style={[styles.resumeIcon, styles.skeletonBlock]} />
<View style={styles.resumeMain}>
<View style={[styles.skeletonLine, { width: '55%' }]} />
<View style={[styles.skeletonLine, { width: '35%', marginTop: 6 }]} />
</View>
</View>
</>
) : null}
{/* ─── Quick actions ─── */}
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
@@ -774,6 +813,18 @@ const styles = StyleSheet.create({
flex: 1
},
/* ─── Skeleton ─── */
skeletonBlock: {
backgroundColor: colors.bgRaised,
opacity: 0.5
},
skeletonLine: {
height: 12,
borderRadius: 4,
backgroundColor: colors.bgRaised,
opacity: 0.5
},
/* ─── Quick actions ─── */
quickActions: {
flexDirection: 'row',
+5 -2
View File
@@ -49,12 +49,13 @@ export default function PairScanScreen() {
async function testAndSave(offer: PairingOffer) {
setStatus('connecting')
const client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64)
let client: ReturnType<typeof connect> | null = null
try {
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64)
const response = await client.sendRequest('status.get')
client.close()
client = null
if (!response.ok) {
if (response.error.code === 'unauthorized') {
@@ -86,6 +87,8 @@ export default function PairScanScreen() {
setStatus('error')
setErrorMessage('Cannot connect — check that your computer is on the same network')
processingRef.current = false
} finally {
client?.close()
}
}
+7 -1
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import {
View,
Text,
@@ -114,6 +114,12 @@ export default function TroubleshootScreen() {
const [checks, setChecks] = useState<CheckResult[]>([])
const abortRef = useRef(false)
useEffect(() => {
return () => {
abortRef.current = true
}
}, [])
const toggleSection = useCallback((id: string) => {
setExpandedId((prev) => (prev === id ? null : id))
}, [])
+45 -18
View File
@@ -6,7 +6,8 @@ import {
Platform,
useWindowDimensions,
ScrollView,
Keyboard
Keyboard,
BackHandler
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
@@ -91,6 +92,16 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
}
}, [visible, insets.bottom])
useEffect(() => {
if (!visible) return
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
onClose()
return true
})
return () => sub.remove()
}, [visible, onClose])
const dismiss = useCallback(() => {
onClose()
}, [onClose])
@@ -145,28 +156,41 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
if (!mounted) return null
return (
<Animated.View style={[styles.overlay, pointerStyle]}>
<Animated.View style={[styles.overlay, pointerStyle]} accessibilityViewIsModal aria-modal>
<GestureHandlerRootView style={styles.root}>
<Animated.View style={[styles.backdrop, backdropStyle]}>
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
</Animated.View>
<View style={styles.anchor} pointerEvents="box-none">
<GestureDetector gesture={panGesture}>
<Animated.View
style={[styles.drawer, { paddingBottom: insets.bottom + spacing.lg }, drawerStyle]}
>
<View style={styles.handle} />
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
<Animated.View
style={[
styles.drawer,
{
maxHeight: screenHeight - insets.top - spacing.lg,
paddingBottom: insets.bottom + spacing.lg
},
drawerStyle
]}
>
<GestureDetector gesture={panGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
{children}
</ScrollView>
<View style={styles.bottomExtension} />
</Animated.View>
</GestureDetector>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
<View style={styles.bottomExtension} />
</Animated.View>
</View>
</GestureHandlerRootView>
</Animated.View>
@@ -210,10 +234,13 @@ const styles = StyleSheet.create({
height: 4,
borderRadius: 2,
backgroundColor: colors.textMuted,
marginTop: spacing.sm,
marginBottom: spacing.md,
opacity: 0.4
},
handleHitArea: {
alignItems: 'center',
paddingTop: spacing.sm,
paddingBottom: spacing.md
},
bottomExtension: {
position: 'absolute',
bottom: -500,
+8 -3
View File
@@ -269,7 +269,12 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!visible || !client) return
if (!visible) {
setShowRepoPicker(false)
setShowAgentPicker(false)
return
}
if (!client) return
let stale = false
setName('')
setNote('')
@@ -526,7 +531,7 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
{/* Sub-modals for pickers — rendered outside the main modal so they
layer on top and scroll without touch conflicts. */}
<PickerListModal
visible={showRepoPicker}
visible={visible && showRepoPicker}
title="Repository"
items={repos.map((r) => ({ id: r.id, label: r.displayName, _repo: r }))}
selectedId={selectedRepo?.id ?? ''}
@@ -535,7 +540,7 @@ export function NewWorktreeModal({ visible, client, onCreated, onClose }: Props)
/>
<PickerListModal
visible={showAgentPicker}
visible={visible && showAgentPicker}
title="Agent"
items={ALL_AGENTS}
selectedId={selectedAgent.id}
@@ -70,26 +70,42 @@ export function subscribeToDesktopNotifications(client: RpcClient): () => void {
configureNotificationChannel()
let subscriptionId: string | null = null
let disposed = false
function unsubscribeServer(id: string) {
if (client.getState() === 'connected') {
client.sendRequest('notifications.unsubscribe', { subscriptionId: id }).catch(() => {})
}
}
const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => {
const event = data as NotificationEvent | SubscribeResult | { type: 'end' }
if (event.type === 'ready') {
subscriptionId = (event as SubscribeResult).subscriptionId
if (disposed) {
unsubscribeServer(subscriptionId)
unsubscribeStream()
}
return
}
if (event.type === 'end') return
if (event.type === 'end') {
if (disposed) unsubscribeStream()
return
}
if (disposed) return
if (event.type === 'notification') {
void showLocalNotification(event as NotificationEvent)
}
})
return () => {
unsubscribeStream()
disposed = true
// Why: the client may already be closed when this cleanup runs (component
// unmount races with disconnect). sendRequest rejects immediately on a
// closed client — swallow it since server-side cleanup happens via
// connection-close anyway.
if (subscriptionId && client.getState() === 'connected') {
client.sendRequest('notifications.unsubscribe', { subscriptionId }).catch(() => {})
if (subscriptionId) {
unsubscribeStream()
unsubscribeServer(subscriptionId)
}
}
}
+22 -2
View File
@@ -33,12 +33,25 @@ const DEFAULT_PREFS: HostPreferences = {
collapsedGroups: [],
selectedRepos: []
}
const SORT_MODES = new Set(['smart', 'recent', 'name'])
const FILTER_MODES = new Set(['all', 'active'])
const GROUP_MODES = new Set(['none', 'repo', 'prStatus'])
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: []
}
function allowedString(value: unknown, allowed: Set<string>, fallback: string): string {
return typeof value === 'string' && allowed.has(value) ? value : fallback
}
export async function loadPinnedIds(hostId: string): Promise<Set<string>> {
try {
const raw = await AsyncStorage.getItem(PINS_PREFIX + hostId)
if (!raw) return new Set()
return new Set(JSON.parse(raw) as string[])
return new Set(stringArray(JSON.parse(raw)))
} catch {
return new Set()
}
@@ -52,7 +65,14 @@ export async function loadPreferences(hostId: string): Promise<HostPreferences>
try {
const raw = await AsyncStorage.getItem(PREFS_PREFIX + hostId)
if (!raw) return DEFAULT_PREFS
return { ...DEFAULT_PREFS, ...(JSON.parse(raw) as Partial<HostPreferences>) }
const parsed = JSON.parse(raw) as Partial<HostPreferences>
return {
sortMode: allowedString(parsed.sortMode, SORT_MODES, DEFAULT_PREFS.sortMode),
filterMode: allowedString(parsed.filterMode, FILTER_MODES, DEFAULT_PREFS.filterMode),
groupMode: allowedString(parsed.groupMode, GROUP_MODES, DEFAULT_PREFS.groupMode),
collapsedGroups: stringArray(parsed.collapsedGroups),
selectedRepos: stringArray(parsed.selectedRepos)
}
} catch {
return DEFAULT_PREFS
}
+3
View File
@@ -324,8 +324,10 @@ const XTERM_HTML = `<!DOCTYPE html>
} else if (msg.type === 'write') {
write(msg.data);
} else if (msg.type === 'clear') {
terminalGeneration++;
writeQueue = [];
afterDrainCallbacks = [];
writesDraining = false;
if (term) { term.clear(); term.reset(); }
} else if (msg.type === 'measure') {
measureFitDimensions();
@@ -573,6 +575,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
measureFitDimensions(): Promise<{ cols: number; rows: number } | null> {
if (!isWebReadyRef.current) return Promise.resolve(null)
return new Promise((resolve) => {
measureResolveRef.current?.(null)
measureResolveRef.current = resolve
sendToWebView({ type: 'measure' })
// Why: if the WebView doesn't respond within 2s (e.g., xterm
+7 -2
View File
@@ -1,5 +1,5 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { HostProfile } from './types'
import { HostProfileSchema, type HostProfile } from './types'
const STORAGE_KEY = 'orca:hosts'
@@ -7,7 +7,12 @@ export async function loadHosts(): Promise<HostProfile[]> {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
if (!raw) return []
try {
return JSON.parse(raw) as HostProfile[]
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.flatMap((item) => {
const result = HostProfileSchema.safeParse(item)
return result.success ? [result.data] : []
})
} catch {
return []
}
+28 -18
View File
@@ -66,8 +66,10 @@ export function connect(
state = next
if (next === 'connected') {
for (const w of connectWaiters.splice(0)) w.resolve()
} else if (next === 'disconnected') {
for (const w of connectWaiters.splice(0)) w.reject(new Error('Connection closed'))
} else if (next === 'disconnected' || next === 'auth-failed') {
const reason =
next === 'auth-failed' ? 'Unauthorized — pairing may be revoked' : 'Connection closed'
for (const w of connectWaiters.splice(0)) w.reject(new Error(reason))
}
for (const listener of stateListeners) {
listener(next)
@@ -224,6 +226,7 @@ export function connect(
rejectAllPending('Connection closed')
return
}
rejectAllPending('Connection interrupted')
setState('reconnecting')
scheduleReconnect()
}
@@ -249,10 +252,12 @@ export function connect(
}
}
function sendEncrypted(request: RpcRequest) {
function sendEncrypted(request: RpcRequest): boolean {
if (ws && ws.readyState === WebSocket.OPEN && sharedKey) {
ws.send(encrypt(JSON.stringify(request), sharedKey))
return true
}
return false
}
openConnection()
@@ -279,7 +284,11 @@ export function connect(
}
})
sendEncrypted({ id, deviceToken, method, params })
if (!sendEncrypted({ id, deviceToken, method, params })) {
pending.delete(id)
clearTimeout(timeout)
reject(new Error('Connection interrupted'))
}
})
},
@@ -294,20 +303,21 @@ export function connect(
return () => {
const stream = streamListeners.get(id)
streamListeners.delete(id)
sendEncrypted({
id: nextId(),
deviceToken,
method: 'terminal.unsubscribe',
// Why: the runtime keys terminal subscription cleanup by terminal
// handle, not by the RPC request id used to open the stream.
params:
stream?.method === 'terminal.subscribe' &&
stream.params &&
typeof stream.params === 'object' &&
typeof (stream.params as { terminal?: unknown }).terminal === 'string'
? { subscriptionId: (stream.params as { terminal: string }).terminal }
: { subscriptionId: id }
})
if (
stream?.method === 'terminal.subscribe' &&
stream.params &&
typeof stream.params === 'object' &&
typeof (stream.params as { terminal?: unknown }).terminal === 'string'
) {
sendEncrypted({
id: nextId(),
deviceToken,
method: 'terminal.unsubscribe',
// Why: the runtime keys terminal subscription cleanup by terminal
// handle, not by the RPC request id used to open the stream.
params: { subscriptionId: (stream.params as { terminal: string }).terminal }
})
}
}
},
+9
View File
@@ -51,3 +51,12 @@ export type HostProfile = {
publicKeyB64: string
lastConnected: number
}
export const HostProfileSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
endpoint: z.string().min(1),
deviceToken: z.string().min(1),
publicKeyB64: z.string().min(1),
lastConnected: z.number().finite()
})