Mobile: stabilise connection + terminal-fit fixes + share one RpcClient per host (#1481)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-05 18:58:13 -07:00 committed by GitHub
parent c28c06a9c2
commit 087c00b02e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1438 additions and 272 deletions

View File

@ -0,0 +1,406 @@
# Single Shared RPC Client per Host (Mobile)
Design doc for collapsing the per-screen WebSocket connection model into a
single shared `RpcClient` per paired host, owned by a React context that
sits above the route tree.
## Problem
The mobile app today opens **one WebSocket per screen per host**:
| Screen | Connections per host |
|---|---|
| Home (`app/index.tsx`) | 1 (with persistent `accounts.subscribe` stream) |
| Host detail (`app/h/[hostId]/index.tsx`) | +1 |
| Worktree session (`app/h/[hostId]/session/[worktreeId].tsx`) | +1 |
| Accounts (`app/h/[hostId]/accounts.tsx`) | +1 |
| Pair confirm (briefly) | +1 |
A user actively browsing one host typically holds **34 simultaneous
sockets** to the desktop runtime. Each call to `connect()` runs its own
E2EE handshake, allocates an ephemeral keypair, and runs an independent
reconnect loop with exponential backoff.
This causes three observable problems:
1. **Stuck-connecting / reconnecting for minutes.** The desktop's
`MAX_WS_CONNECTIONS = 32` is shared across all clients. A user who
navigates rapidly accumulates stale sockets faster than they can be
reaped by TCP keepalive (which can take 60300s on default systems
for half-open connections from a phone leaving Wi-Fi range or
backgrounding). Once the cap is hit, new sockets are rejected with WS
close code `1013 Maximum connections reached`. The mobile reconnect
loop does not recognize 1013 as terminal — it retries with backoff,
each retry also dropped, until enough stale sockets are reaped.
Result: a screen that should connect in <1s is stuck for 15 minutes.
2. **Tab create/delete hangs.** `client.sendRequest('terminal.create')`
awaits `waitForConnected()`. If the session-screen client is in
`connecting`/`reconnecting` state because its socket lost the cap
race, the await blocks until the 30s `REQUEST_TIMEOUT_MS` fires.
The user sees nothing happen.
3. **Triple cost on every cold-start.** Three E2EE handshakes,
three Curve25519 keypair generations, three subscription
re-registrations on every app launch. On low-end Android, this
visibly delays first paint by hundreds of milliseconds.
The architecture also wastes server resources: each socket carries its
own E2EE channel, its own subscription set, its own driver-state-machine
client identity (cf. `docs/mobile-presence-lock.md`). The server already
has logic to reconcile multi-socket-per-token tear-down
(`hasOtherConnections` in `ws-transport.ts`) — that logic exists *because*
this design forced the question; with a single client per host, it becomes
unnecessary.
## Today's transport ownership
Five files independently call `connect(endpoint, deviceToken, publicKeyB64)`:
```
mobile/app/pair-confirm.tsx # one-shot during pairing
mobile/app/pair-scan.tsx # one-shot during pairing
mobile/app/index.tsx # N (one per paired host)
mobile/app/h/[hostId]/index.tsx # 1 (host detail)
mobile/app/h/[hostId]/session/[worktreeId].tsx # 1 (session)
mobile/app/h/[hostId]/accounts.tsx # 1 (accounts)
```
Each owns a `useRef<RpcClient | null>` and calls `client.close()` from a
cleanup function. The home screen additionally maintains a
`clientsRef: Array<{ hostId, client }>` so its own usage of the per-host
client survives across navigation events.
This pattern works for *correctness* — every cleanup eventually closes
its socket — but it breaks under three real-world conditions:
1. **Rapid navigation.** Mounts spawn before unmounts complete; cleanup
`client.close()` runs after a new screen has already opened a fresh
socket to the same host. Two sockets briefly coexist for the same
token, multiplied across screens.
2. **Network drops.** A backgrounded/locked phone on a flaky network
leaves sockets half-open. The server doesn't get a FIN; cleanup
relies on TCP keepalive timing. Meanwhile the foreground app, on
resume, opens fresh sockets. The half-open ones eat the cap until
reaped.
3. **Hot reload during dev.** Each Metro hot reload fires a new render
tree without unmounting the old, so connections leak.
## Goal
> A paired host has at most **one active WebSocket** at any time, owned
> by a context provider above the route tree. All screens for that host
> share that client. The pair flows are the only places that create
> short-lived clients (and they explicitly close those after pairing
> completes).
This is the architectural fix to the symptoms above. Combined with the
two recently-shipped hotfixes (token cache + stable `useEffect`
dependency on home screen), this completes the connection-lifecycle
work.
## Design
### Layered ownership
```
RootLayout (<RpcClientProvider>)
└── routes
└── <HostScopedClientGate hostId={...}> // mounts when route has hostId
├── h/[hostId]/ // host detail
├── h/[hostId]/session/[worktreeId]/ // session
└── h/[hostId]/accounts/ // accounts
```
Two providers, layered:
1. **`RpcClientProvider` (root)** — owns one `RpcClient` per host,
keyed by `hostId`. Lifecycle: opens on first request for that
host's client, holds open until app shutdown OR until the host is
removed (`removeHost(hostId)` triggers explicit close). Reuses
existing `loadHosts()` cache from the recently-merged
`host-store.ts` work.
2. **`HostScopedClientGate` (per host)** — a thin route-layout
component placed at `app/h/_layout.tsx`. Reads `hostId` from
route params, requests the client for that host from the root
provider, exposes it via context to descendants, and renders a
loading state until the client reaches `connected`. Guarantees
every descendant screen sees the same client instance for that
host — no per-screen `connect()` calls.
The home screen (`app/index.tsx`) lives outside `HostScopedClientGate`
since it spans all hosts; it consumes the root provider directly via
a multi-host hook (see API below).
### API
```ts
// New file: mobile/src/transport/client-context.tsx
type RpcClientContext = {
// Get-or-open. Returns the singleton client for hostId; opens it
// lazily on first call, reuses it for all subsequent callers. Never
// returns null (returns a placeholder client in 'connecting' state
// if open hasn't completed).
getClient: (hostId: string) => RpcClient
// Connection state for a given host (driven by client.onStateChange).
useHostState: (hostId: string) => ConnectionState
// Useful for the home screen which renders all hosts at once.
useAllClients: () => Array<{ hostId: string; client: RpcClient }>
}
export const RpcClientProvider: React.FC<{ children: React.ReactNode }>
export const useHostClient: (hostId: string) => {
client: RpcClient
state: ConnectionState
}
```
Internal store (single `useRef` in the provider):
```ts
type StoreEntry = {
client: RpcClient
state: ConnectionState
refCount: number // number of active screens holding this client
closeTimer: NodeJS.Timeout | null
}
const store = useRef(new Map<string, StoreEntry>())
```
### Lifecycle rules
1. **Open on first read.** First `getClient(hostId)` call for a host
reads the host record (uses cached `loadHosts()`), then calls
`connect()` and stores the client. Subsequent calls return the
cached entry.
2. **Idle close timer.** When `refCount` drops to 0 (all screens for
that host unmounted), schedule a 30-second close timer. If a screen
for the same host mounts within 30s, cancel the timer. Otherwise,
close the client and remove from the store.
- Why 30s: covers fast tab-switching and back-navigation without
keeping idle sockets forever. Tunable based on observed behavior.
3. **Forced close on host removal.** `removeHost(hostId)` from
`host-store.ts` calls into the provider to close the client
immediately and delete the store entry.
4. **App backgrounded.** No special action — let TCP keepalive and
server-side reaping handle it. Reconnect happens on foreground.
5. **App foregrounded.** Trigger a `getState()` poll on every
non-closed entry; if any are in `disconnected` (TCP died while
backgrounded), the existing reconnect loop handles it. No new
client allocations.
### Public surface for screens
Each screen replaces:
```ts
// Before
const [client, setClient] = useState<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('disconnected')
useEffect(() => {
let rpcClient: RpcClient | null = null
void (async () => {
const hosts = await loadHosts()
const host = hosts.find((h) => h.id === hostId)
if (!host) return
rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState)
setClient(rpcClient)
})()
return () => {
rpcClient?.close()
}
}, [hostId])
```
with:
```ts
// After
const { client, state } = useHostClient(hostId)
```
Total LoC reduction across screens: ~150 lines.
### Pair flow exception
`app/pair-confirm.tsx` and `app/pair-scan.tsx` continue to call `connect()`
directly with **explicit `client.close()`** after the test request returns.
Reason: the host record doesn't yet exist in `loadHosts()` during pairing,
so the provider has nothing to look up. The pair flow's client is a
short-lived transient that delivers `getStatus()` once and then dies.
After `saveHost()` succeeds, the user is navigated away; the next time
they enter `/h/[hostId]/...`, the provider opens a fresh client through
the normal path.
### Streaming subscription handling
The home screen's `accounts.subscribe` stream and the session screen's
terminal subscriptions remain owned by their respective screens — the
provider doesn't manage subscriptions, only the underlying transport.
Each screen's effect calls `client.subscribe(...)` and stores the
returned unsubscribe function. On unmount, the screen unsubscribes
(returns to the existing per-screen pattern, just over a shared
transport). The transport's `subscribe()` already correctly multiplexes
multiple listeners on one WebSocket via the `id` field.
### State propagation
`useHostState(hostId)` returns the live `ConnectionState`. The provider
maintains a per-host `useState` keyed by hostId; the `client.onStateChange`
listener is wired once at client creation and updates the corresponding
state slot. `useHostState` reads from this state via `useSyncExternalStore`
or a context selector — the choice is mostly preference; in this
codebase, given the small state shape, a simple `useContext + useMemo`
of the matching slot is fine.
## Migration
Step-by-step, each step independently shippable:
1. **Add `RpcClientProvider` and `useHostClient`.** No callers yet.
Wire into `app/_layout.tsx`. Existing screens unchanged.
2. **Migrate session screen** (highest-risk, most-used). Replace
per-screen `connect()` with `useHostClient`. Test connection
behavior, terminal create/delete, scrollback hydration.
3. **Migrate host detail and accounts screens.** Same pattern.
4. **Migrate home screen.** Replace `clientsRef` with
`useAllClients()`. The home screen's per-host streaming
subscriptions move into a hook that runs per-host.
5. **Add `HostScopedClientGate`** at `app/h/_layout.tsx` to centralize
the gate and remove duplicated loading-state logic.
6. **Delete legacy code.** Remove the dead `connect()` import paths
from each screen. Codepoint reduction.
7. **Remove server-side `hasOtherConnections` complexity** in a
follow-up: with one socket per token, the multi-socket reconciliation
in `runtime-rpc.ts` `wsTransport.onConnectionClose` simplifies.
This is a desktop-side cleanup PR done after mobile rolls out.
Each step is tested in isolation; rollback per step is trivial.
## Risks
### R1: Connection loss while screens are mounted
**Risk.** Today, when a screen unmounts due to network loss, its
client closes and reopens on remount. Under the new design, a
network-loss-during-screen-mounted means the client lives but is in
`reconnecting` state.
**Mitigation.** The existing `RpcClient` already handles this — its
internal reconnect loop runs invisibly. Screens already render based
on `connState === 'connected'`, so they stay in their `connecting`
UI until the loop succeeds. No regression.
### R2: One bad host poisons the singleton
**Risk.** If the client for one host is wedged in `reconnecting` due
to a desktop-side issue, all screens for that host inherit the wedged
state. Under the per-screen design, navigating to a different screen
gave a fresh client with a chance to connect cleanly.
**Mitigation.** "Force reconnect" affordance: a button on the host
detail "Connection issues" UI calls
`provider.forceReconnect(hostId)` — close + reopen the client. Users
who hit a stuck state get a one-tap recovery without uninstalling.
Implemented as part of step 2.
### R3: Idle close timer races
**Risk.** A user navigates from session → home → back to session
within 35 seconds. The 30s idle timer fires between hops and closes
the client; the back-navigation has to wait for a fresh handshake.
**Mitigation.** Cancel the timer at `getClient(hostId)` time, not at
mount time. As long as the consumer holds a reference to the client,
the timer is paused. Standard refcount pattern.
### R4: Memory / state leak on rapid host removal
**Risk.** User removes a host while a screen for it is mounted.
The screen's reference is now dangling.
**Mitigation.** `removeHost(hostId)` triggers an explicit close +
delete from the store. Screens already handle `auth-failed` /
`disconnected` states (the client transitions to one of them on
forced close). Add a navigation-bounce in those states so the screen
returns to the host list.
### R5: Pair-flow socket leaks through provider
**Risk.** If pair-confirm crashes mid-handshake before its explicit
`close()`, the socket leaks.
**Mitigation.** Independent of the provider — same risk exists today.
Add a try/finally + cleanup useEffect in pair-confirm.
### R6: Provider re-initialization on hot reload (dev only)
**Risk.** Metro hot reload re-runs `RpcClientProvider`, possibly
spawning new clients while old ones are still in the store.
**Mitigation.** On provider mount, scan the store for entries whose
clients report `closed` state and prune. Acceptable dev-only friction.
## Test Plan
### Unit / hook tests
- `useHostClient` returns the same client instance across multiple
consumers for the same hostId.
- `removeHost(hostId)` immediately closes the client (assert via
`client.getState()`).
- Idle close timer: zero refcount → 30s wait → client closed.
- Idle close timer cancellation: zero refcount → 15s wait →
consumer subscribes → no close.
### Integration / manual
- Create one host; navigate home → host detail → session → back ×10
rapidly. Single socket on desktop (verify via desktop debug log).
- Background app for 5 min; foreground; verify reconnect uses same
client instance, no leak.
- Remove host while session screen mounted; screen bounces back to
home; client closed.
- Force-reconnect button on host detail; client closes and reopens
cleanly.
- Hot reload during development; no socket leak (verify desktop
active-connection count).
### Regression
- Terminal create/delete works during normal browse (Bug A from
initial reports — should never recur once cap pressure is removed).
- Scrollback hydration unchanged.
- Phone-fit / driver-lock state machine unchanged
(`docs/mobile-presence-lock.md` invariants hold).
## Out of scope
- **Desktop-side connection LRU eviction.** Useful as a defense-in-depth
but not needed once mobile self-limits to one socket per host.
- **Application-level ping/pong.** Worth adding but separate concern;
helps server reap dead sockets faster regardless of how many a single
client opens.
- **iOS share extension or system-wide deep-link integration.** Future
product work.
## Effort estimate
34 hours including tests and incremental migration. Each step
independently mergeable.
## References
- `docs/mobile-presence-lock.md` — driver-state-machine that depends
on per-client identity. Single-client-per-host simplifies but
doesn't break this contract.
- `docs/mobile-prefer-renderer-scrollback.md` — scrollback hydration
flow. Subscriptions remain per-screen; transport changes are
transparent.
- `mobile/src/transport/rpc-client.ts` — existing `connect()`
implementation; reconnect loop, E2EE handshake, subscription
multiplexing all preserved as-is.
- `src/main/runtime/rpc/ws-transport.ts` — server-side
`MAX_WS_CONNECTIONS = 32`, `hasOtherConnections` reconciliation
that becomes simpler post-migration.

View File

@ -7,6 +7,7 @@ import * as Notifications from 'expo-notifications'
import * as Linking from 'expo-linking'
import { colors } from '../src/theme/mobile-theme'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { RpcClientProvider } from '../src/transport/client-context'
// Why: keeps the native splash screen visible until the React tree is mounted
// and ready to render. Without this the user sees a blank white/black frame
@ -79,33 +80,35 @@ export default function RootLayout() {
}, [])
return (
<View style={styles.root} onLayout={onNavigatorLayout}>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bgPanel },
headerTintColor: colors.textPrimary,
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
contentStyle: { backgroundColor: colors.bgBase },
headerShadowVisible: false
}}
>
<Stack.Screen
name="index"
options={{
headerShown: false,
headerTitle: () => <OrcaLogo size={22} />
<RpcClientProvider>
<View style={styles.root} onLayout={onNavigatorLayout}>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bgPanel },
headerTintColor: colors.textPrimary,
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
contentStyle: { backgroundColor: colors.bgBase },
headerShadowVisible: false
}}
/>
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
</Stack>
</View>
>
<Stack.Screen
name="index"
options={{
headerShown: false,
headerTitle: () => <OrcaLogo size={22} />
}}
/>
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
</Stack>
</View>
</RpcClientProvider>
)
}

View File

@ -12,9 +12,10 @@ import {
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
import { connect, type RpcClient } from '../../../src/transport/rpc-client'
import type { RpcClient } from '../../../src/transport/rpc-client'
import { loadHosts } from '../../../src/transport/host-store'
import type { ConnectionState, RpcSuccess } from '../../../src/transport/types'
import { useHostClient } from '../../../src/transport/client-context'
import type { RpcSuccess } from '../../../src/transport/types'
import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme'
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
import {
@ -30,8 +31,8 @@ export default function AccountsScreen() {
const insets = useSafeAreaInsets()
const { hostId } = useLocalSearchParams<{ hostId: string }>()
const [client, setClient] = useState<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('connecting')
// Why: shared client per host. See docs/mobile-shared-client-per-host.md.
const { client, state: connState } = useHostClient(hostId)
const [hostName, setHostName] = useState<string>('')
const [snapshot, setSnapshot] = useState<AccountsSnapshot | null>(null)
const [error, setError] = useState<string | null>(null)
@ -39,34 +40,24 @@ export default function AccountsScreen() {
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
const clientRef = useRef<RpcClient | null>(null)
// Why: connect to the host's WebSocket on mount and tear down on
// unmount. Mirrors the connection lifecycle used in
// /h/[hostId]/index.tsx so reconnect/auth-failed states are handled
// identically.
useEffect(() => {
clientRef.current = client
}, [client])
useEffect(() => {
if (!hostId) return
let cancelled = false
let rpcClient: RpcClient | null = null
void (async () => {
const hosts = await loadHosts()
let stale = false
void loadHosts().then((hosts) => {
if (stale) return
const host = hosts.find((h) => h.id === hostId)
if (!host) {
if (!cancelled) setError('Host not found')
setError('Host not found')
return
}
if (!cancelled) setHostName(host.name)
rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => {
if (!cancelled) setConnState(state)
})
clientRef.current = rpcClient
if (!cancelled) setClient(rpcClient)
})()
setHostName(host.name)
})
return () => {
cancelled = true
if (rpcClient) rpcClient.close()
clientRef.current = null
stale = true
}
}, [hostId])

View File

@ -27,8 +27,14 @@ import {
Check,
UserCircle
} from 'lucide-react-native'
import { connect, type RpcClient } from '../../../src/transport/rpc-client'
import type { RpcClient } from '../../../src/transport/rpc-client'
import { loadHosts, updateLastConnected, removeHost } from '../../../src/transport/host-store'
import {
useHostClient,
useCloseHost,
useForceReconnect,
useReconnectAttempt
} from '../../../src/transport/client-context'
import type { ConnectionState, RpcSuccess } from '../../../src/transport/types'
import { triggerMediumImpact } from '../../../src/platform/haptics'
import { StatusDot } from '../../../src/components/StatusDot'
@ -39,7 +45,7 @@ import { ActionSheetContent } from '../../../src/components/ActionSheetModal'
import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { getCachedWorktrees } from '../../../src/cache/worktree-cache'
import { colors, spacing, typography } from '../../../src/theme/mobile-theme'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import {
loadPinnedIds,
savePinnedIds,
@ -85,6 +91,22 @@ const STATUS_LABELS: Record<ConnectionState, string> = {
'auth-failed': 'Auth failed'
}
// Why: same threshold as the home screen — kicks the label from
// "Reconnecting…" to "Can't connect" once the rpc-client has cycled enough
// times to indicate a real problem (wrong port, server down, network change).
const RECONNECT_FAILURE_THRESHOLD = 3
function getStatusDisplay(
state: ConnectionState,
attempts: number
): { label: string; isError: boolean } {
if (state === 'auth-failed') return { label: 'Auth failed', isError: true }
if (state === 'reconnecting' && attempts >= RECONNECT_FAILURE_THRESHOLD) {
return { label: "Can't connect", isError: true }
}
return { label: STATUS_LABELS[state], isError: false }
}
const SORT_OPTIONS: PickerOption<SortMode>[] = [
{ value: 'smart', label: 'Smart', subtitle: 'Unread and active first' },
{ value: 'name', label: 'Name', subtitle: 'Alphabetical by name' },
@ -245,9 +267,13 @@ export default function HostScreen() {
const [initialCache] = useState(() =>
hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
)
const [client, setClient] = useState<RpcClient | null>(null)
// Why: shared client per host owned by RpcClientProvider. See
// docs/mobile-shared-client-per-host.md.
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const clientRef = useRef<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('disconnected')
const closeHostClient = useCloseHost()
const forceReconnectHost = useForceReconnect()
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
const [hostName, setHostName] = useState('')
@ -303,17 +329,16 @@ export default function HostScreen() {
}
}, [hostId])
// Why: keep clientRef in sync so existing imperative call sites work
// unchanged. Also re-seed the cached worktree list on hostId change
// since the useState initializer only runs on first mount.
useEffect(() => {
clientRef.current = client
}, [client])
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.
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
if (freshCache) {
setWorktrees(freshCache)
@ -324,45 +349,20 @@ export default function HostScreen() {
setWorktrees([])
setLastKnownWorktrees([])
}
// Why: defer the RPC connection until after the navigation animation
// completes. Without this, connect() and loadHosts() block the JS
// thread during mount, delaying the screen transition by ~200-400ms.
// With cached worktrees the user sees content instantly; the live
// connection starts once the animation settles.
const rafId = requestAnimationFrame(() => {
if (disposed) return
void (async () => {
const hosts = await loadHosts()
const host = hosts.find((h) => h.id === hostId)
if (!host || disposed) {
if (!host && !disposed) setError('Host not found')
return
}
rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => {
if (!disposed) setConnState(state)
})
if (disposed) {
rpcClient.close()
rpcClient = null
return
}
setHostName(host.name)
clientRef.current = rpcClient
setClient(rpcClient)
await updateLastConnected(host.id)
})()
})
return () => {
disposed = true
cancelAnimationFrame(rafId)
rpcClient?.close()
if (clientRef.current === rpcClient) {
clientRef.current = null
if (!hostId) return
let stale = false
void loadHosts().then((hosts) => {
if (stale) return
const host = hosts.find((h) => h.id === hostId)
if (!host) {
setError('Host not found')
return
}
setHostName(host.name)
void updateLastConnected(host.id)
})
return () => {
stale = true
}
}, [hostId])
@ -499,9 +499,14 @@ export default function HostScreen() {
const handleRemoveHost = useCallback(async () => {
if (!hostId) return
// Why: close the shared client first so its WebSocket is gone before
// the host record disappears; otherwise the next loadHosts() the
// provider does (e.g. on remount) wouldn't find this host but the
// socket would still be open, leaking state.
closeHostClient(hostId)
await removeHost(hostId)
router.back()
}, [hostId, router])
}, [hostId, router, closeHostClient])
const openWorktreeSession = useCallback(
(item: Worktree) => {
@ -645,9 +650,27 @@ export default function HostScreen() {
{hostName || 'Host'}
</Text>
</View>
{connState !== 'connected' && (
<Text style={styles.statusText}>{STATUS_LABELS[connState]}</Text>
)}
{connState !== 'connected' &&
(() => {
const status = getStatusDisplay(connState, reconnectAttempts)
const showReconnectButton = status.isError && hostId && connState !== 'auth-failed'
return (
<View style={styles.statusRow}>
<Text style={[styles.statusText, status.isError && { color: colors.statusRed }]}>
{status.label}
</Text>
{showReconnectButton && (
<Pressable
style={styles.reconnectButton}
onPress={() => void forceReconnectHost(hostId!)}
hitSlop={8}
>
<Text style={styles.reconnectButtonText}>Reconnect</Text>
</Pressable>
)}
</View>
)
})()}
</View>
{/* Filter/sort/group toolbar */}
@ -1102,6 +1125,24 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
reconnectButton: {
paddingVertical: 4,
paddingHorizontal: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
reconnectButtonText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '600'
},
authBanner: {
backgroundColor: colors.bgPanel,
paddingVertical: spacing.sm,

View File

@ -15,8 +15,9 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { ArrowUp, ChevronLeft, Monitor, Plus, Smartphone } from 'lucide-react-native'
import { connect, type RpcClient } from '../../../../src/transport/rpc-client'
import type { RpcClient } from '../../../../src/transport/rpc-client'
import { loadHosts } from '../../../../src/transport/host-store'
import { useHostClient } from '../../../../src/transport/client-context'
import type { ConnectionState, RpcSuccess } from '../../../../src/transport/types'
import { triggerMediumImpact } from '../../../../src/platform/haptics'
import {
@ -123,8 +124,9 @@ export default function SessionScreen() {
}>()
const router = useRouter()
const insets = useSafeAreaInsets()
const [client, setClient] = useState<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('disconnected')
// Why: shared client per host owned by RpcClientProvider. See
// docs/mobile-shared-client-per-host.md.
const { client, state: connState } = useHostClient(hostId)
const [terminals, setTerminals] = useState<Terminal[]>([])
const [terminalsLoaded, setTerminalsLoaded] = useState(false)
const [input, setInput] = useState('')
@ -397,7 +399,19 @@ export default function SessionScreen() {
lastKnownTerminalCountRef.current = result.terminals.length
const current = activeHandleRef.current
setTerminals(result.terminals)
// Why: defense-in-depth dedupe. If the server ever returns a list
// with the same handle twice (race during rename/split, or stale
// process tracking), React would throw 'two children with same
// key' on render. Keep the first occurrence — list order matters
// for the tab strip, and createParams puts new tabs at the end.
const seen = new Set<string>()
const deduped = result.terminals.filter((t) => {
if (seen.has(t.handle)) return false
seen.add(t.handle)
return true
})
setTerminals(deduped)
setTerminalsLoaded(true)
if (!current || !result.terminals.some((t) => t.handle === current)) {
@ -419,34 +433,32 @@ export default function SessionScreen() {
[client, worktreeId, subscribeToTerminal, unsubscribeTerminal]
)
// Why: keep clientRef in sync with the shared client from
// useHostClient() so the existing imperative call sites
// (clientRef.current.sendRequest...) keep working without churn.
useEffect(() => {
let disposed = false
let rpcClient: RpcClient | null = null
void (async () => {
const hosts = await loadHosts()
const host = hosts.find((h) => h.id === hostId)
if (!host || disposed) return
deviceTokenRef.current = host.deviceToken
rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState)
if (disposed) {
rpcClient.close()
return
}
setClient(rpcClient)
clientRef.current = rpcClient
})()
clientRef.current = client
return () => {
disposed = true
clearTerminalCache()
rpcClient?.close()
if (clientRef.current === rpcClient) {
clientRef.current = null
}
}
}, [clearTerminalCache, hostId])
}, [client, clearTerminalCache])
// Why: deviceToken is read from host record so feature code can pass
// `client.id` on subscribe/send for driver-state-machine identity.
// The shared client itself stays alive across screens; we just need
// the token alongside the client.
useEffect(() => {
if (!hostId) return
let stale = false
void loadHosts().then((hosts) => {
if (stale) return
const host = hosts.find((h) => h.id === hostId)
if (host) deviceTokenRef.current = host.deviceToken
})
return () => {
stale = true
}
}, [hostId])
useEffect(() => {
void loadCustomKeys().then(setCustomKeys)
@ -456,71 +468,88 @@ export default function SessionScreen() {
// doesn't resize the window) and refit xterm once the layout settles so the
// terminal grid matches the new visible area. iOS exposes 'will' events that
// animate in sync with the IME; Android only fires 'did' events reliably.
useEffect(() => {
let refitTimer: ReturnType<typeof setTimeout> | null = null
const scheduleRefit = () => {
if (refitTimer) clearTimeout(refitTimer)
refitTimer = setTimeout(() => {
const handle = activeHandleRef.current
if (!handle) return
const ref = terminalRefs.current.get(handle)
if (!ref) return
void (async () => {
const dims = await ref.measureFitDimensions(terminalFrameHeightRef.current || undefined)
if (!dims) return
const prev = viewportRef.current
if (prev && prev.cols === dims.cols && prev.rows === dims.rows) return
viewportRef.current = dims
viewportMeasuredRef.current = true
// Why: prefer the in-place viewport update RPC over the legacy
// unsubscribe → subscribe cycle. This keeps the server-side
// mobile subscriber record alive (no driver=idle blip on the
// desktop banner; no false phone-fit baseline capture on the
// re-subscribe). The 'resized' event from the server reinits
// the xterm at the new dims via the existing subscription
// stream. Falls back to the unsubscribe/subscribe path if the
// RPC isn't available (older host build) or no client is
// connected. See docs/mobile-presence-lock.md.
const rpc = clientRef.current
const deviceToken = deviceTokenRef.current
if (rpc && deviceToken) {
try {
const response = await rpc.sendRequest('terminal.updateViewport', {
terminal: handle,
client: { id: deviceToken, type: 'mobile' as const },
viewport: dims
})
if (response.ok) {
return
}
} catch {
// Fall through to legacy resubscribe.
}
// Also drives re-measurement when other layout-affecting state changes
// (e.g. tab strip toggling visibility when the terminal count crosses
// 0↔1 — without this, a freshly-created 2nd tab subscribes with a
// stale viewport that doesn't account for the now-visible tab strip,
// and the server phone-fits to dims a few rows too tall).
const refitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const scheduleViewportRefit = useCallback(() => {
if (refitTimerRef.current) clearTimeout(refitTimerRef.current)
refitTimerRef.current = setTimeout(() => {
const handle = activeHandleRef.current
if (!handle) return
const ref = terminalRefs.current.get(handle)
if (!ref) return
void (async () => {
const dims = await ref.measureFitDimensions(terminalFrameHeightRef.current || undefined)
if (!dims) return
const prev = viewportRef.current
if (prev && prev.cols === dims.cols && prev.rows === dims.rows) return
viewportRef.current = dims
viewportMeasuredRef.current = true
// Why: prefer the in-place viewport update RPC over the legacy
// unsubscribe → subscribe cycle. This keeps the server-side
// mobile subscriber record alive (no driver=idle blip on the
// desktop banner; no false phone-fit baseline capture on the
// re-subscribe). See docs/mobile-presence-lock.md.
const rpc = clientRef.current
const deviceToken = deviceTokenRef.current
if (rpc && deviceToken) {
try {
const response = await rpc.sendRequest('terminal.updateViewport', {
terminal: handle,
client: { id: deviceToken, type: 'mobile' as const },
viewport: dims
})
if (response.ok) return
} catch {
// Fall through to legacy resubscribe.
}
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
subscribeToTerminal(handle)
})()
}, 150)
}
}
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
subscribeToTerminal(handle)
})()
}, 150)
}, [subscribeToTerminal, unsubscribeTerminal])
useEffect(() => {
const onShow = (e: KeyboardEvent) => {
setKeyboardHeight(e.endCoordinates?.height ?? 0)
scheduleRefit()
scheduleViewportRefit()
}
const onHide = () => {
setKeyboardHeight(0)
scheduleRefit()
scheduleViewportRefit()
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
const showSub = Keyboard.addListener(showEvent, onShow)
const hideSub = Keyboard.addListener(hideEvent, onHide)
return () => {
if (refitTimer) clearTimeout(refitTimer)
if (refitTimerRef.current) clearTimeout(refitTimerRef.current)
showSub.remove()
hideSub.remove()
}
}, [subscribeToTerminal, unsubscribeTerminal])
}, [scheduleViewportRefit])
// Why: the tab strip is hidden when only one terminal exists and shown
// once a second is created. Crossing the 1↔2 boundary changes the
// visible terminal area by ~40px, so the cached viewport dims in
// viewportRef become stale. Mark the viewport as un-measured so the
// next subscribe path's self-correcting loop (init → measure →
// resubscribe-with-fresh-viewport, see the !viewportMeasuredRef branch
// above) re-runs against the new layout. Also schedule an explicit
// refit to cover the case where no new subscribe is happening.
const tabStripVisible = terminals.length > 1
const prevTabStripVisibleRef = useRef(tabStripVisible)
useEffect(() => {
if (prevTabStripVisibleRef.current === tabStripVisible) return
prevTabStripVisibleRef.current = tabStripVisible
viewportMeasuredRef.current = false
scheduleViewportRefit()
}, [tabStripVisible, scheduleViewportRefit])
useEffect(() => {
if (hostId && worktreeId) {
@ -747,10 +776,17 @@ export default function SessionScreen() {
}
activeHandleRef.current = created.handle
setActiveHandle(created.handle)
setTerminals((prev) => [
...prev,
{ handle: created.handle, title: created.title || 'Terminal', isActive: true }
])
setTerminals((prev) => {
// Why: guard against duplicates if a parallel fetchTerminals()
// already inserted this handle. Without this, React throws
// 'two children with the same key' when both the optimistic
// insert and a canonical refetch race during creation.
if (prev.some((t) => t.handle === created.handle)) return prev
return [
...prev,
{ handle: created.handle, title: created.title || 'Terminal', isActive: true }
]
})
subscribeToTerminal(created.handle)
setTimeout(() => void fetchTerminals(), 500)
} else {

View File

@ -11,7 +11,10 @@ import {
GitPullRequest,
ChevronRight,
Terminal,
Plus
Plus,
RefreshCw,
PowerOff,
Edit3
} from 'lucide-react-native'
import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons'
import {
@ -22,13 +25,20 @@ import {
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts, removeHost, renameHost } from '../src/transport/host-store'
import { connect, type RpcClient } from '../src/transport/rpc-client'
import type { RpcClient } from '../src/transport/rpc-client'
import {
useAllHostClients,
useCloseHost,
useForceReconnect,
usePrimeHosts
} from '../src/transport/client-context'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
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 { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal'
import { ConfirmModal } from '../src/components/ConfirmModal'
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
import { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache'
@ -52,6 +62,23 @@ const STATUS_LABELS: Record<ConnectionState, string> = {
'auth-failed': 'Auth failed'
}
// Why: a few quick reconnects are normal (laptop wake, brief network blip).
// After this many failed attempts in a row, the user almost certainly has
// a real problem (wrong port, server down, network change), so escalate
// the label and color so it's obvious something's wrong.
const RECONNECT_FAILURE_THRESHOLD = 3
function getStatusDisplay(
state: ConnectionState,
attempts: number
): { label: string; isError: boolean } {
if (state === 'auth-failed') return { label: 'Auth failed', isError: true }
if (state === 'reconnecting' && attempts >= RECONNECT_FAILURE_THRESHOLD) {
return { label: "Can't connect", isError: true }
}
return { label: STATUS_LABELS[state], isError: false }
}
type StatsSummary = {
totalAgentsSpawned: number
totalPRsCreated: number
@ -198,13 +225,36 @@ export default function HomeScreen() {
const [renameTarget, setRenameTarget] = useState<HostProfile | null>(null)
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
const [stats, setStats] = useState<StatsSummary | null>(null)
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
null
)
const clientsRef = useRef<Array<{ hostId: string; client: RpcClient }>>([])
// Why: read shared clients from the per-host store. Replaces the prior
// pattern of opening N independent WebSockets here. See
// docs/mobile-shared-client-per-host.md.
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const allClients = useAllHostClients(hostIds)
const closeHostClient = useCloseHost()
const forceReconnectHost = useForceReconnect()
const primeHosts = usePrimeHosts()
// Why: feed the loaded HostProfiles into the provider's prime cache as
// soon as we have them. This avoids a second Keychain pass inside
// openEntry on cold start (which serialised behind the first one and
// showed up as multi-second connect latency).
useEffect(() => {
if (hosts.length > 0) primeHosts(hosts)
}, [hosts, primeHosts])
const allClientsRef = useRef<Array<{ hostId: string; client: RpcClient }>>([])
useEffect(() => {
allClientsRef.current = allClients.map((entry) => ({
hostId: entry.hostId,
client: entry.client
}))
}, [allClients])
// Why: hydrate the home page from a persisted snapshot on cold-start so
// Resume + Account-usage cards paint immediately with last-known data
@ -259,7 +309,7 @@ export default function HomeScreen() {
setLastVisited(JSON.parse(raw))
} catch {}
})
for (const entry of clientsRef.current) {
for (const entry of allClientsRef.current) {
if (entry.client.getState() === 'connected') {
fetchStats(entry.client, setStats, () => stale)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
@ -277,51 +327,91 @@ export default function HomeScreen() {
[hosts]
)
// Why: mirror per-host connection state into hostStates so existing
// render code (status dots, connecting indicators) keeps working.
useEffect(() => {
let disposed = false
const notifCleanups: Array<() => void> = []
const entries = hosts.flatMap((host) => {
if (!host.publicKeyB64 || !host.deviceToken) {
setHostStates((prev) => ({ ...prev, [host.id]: 'auth-failed' }))
return []
setHostAttempts((prev) => {
const next: Record<string, number> = { ...prev }
let changed = false
for (const entry of allClients) {
const a = entry.client.getReconnectAttempt()
if (next[entry.hostId] !== a) {
next[entry.hostId] = a
changed = true
}
}
setHostStates((prev) => ({
...prev,
[host.id]: prev[host.id] ?? 'connecting'
}))
let client: ReturnType<typeof connect>
try {
client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => {
if (disposed) return
setHostStates((prev) => ({ ...prev, [host.id]: state }))
})
} catch {
setHostStates((prev) => ({ ...prev, [host.id]: 'auth-failed' }))
return []
return changed ? next : prev
})
setHostStates((prev) => {
const next: Record<string, ConnectionState> = { ...prev }
let changed = false
const liveIds = new Set(allClients.map((e) => e.hostId))
for (const entry of allClients) {
if (next[entry.hostId] !== entry.state) {
next[entry.hostId] = entry.state
changed = true
}
}
// Why: when a paired host disappears from allClients (because the
// user tapped Disconnect, or the host record was invalid) the card
// must reflect that. We only force-update hosts whose state was
// already tracked — otherwise the initial-acquire frame (entry not
// yet materialised) would briefly flip every host to 'disconnected'.
for (const host of hosts) {
if (liveIds.has(host.id)) continue
if (!host.publicKeyB64 || !host.deviceToken) {
if (next[host.id] !== 'auth-failed') {
next[host.id] = 'auth-failed'
changed = true
}
continue
}
const prevState = next[host.id]
if (prevState && prevState !== 'disconnected' && prevState !== 'auth-failed') {
next[host.id] = 'disconnected'
changed = true
}
}
// Drop entries for hosts we no longer track at all.
for (const id of Object.keys(next)) {
if (!liveIds.has(id) && hosts.some((h) => h.id === id) === false) {
delete next[id]
changed = true
}
}
return changed ? next : prev
})
}, [allClients, hosts])
// Why: per-host streaming subscriptions (notifications + accounts) and
// one-shot stats fetches when each host transitions to 'connected'.
// Runs once per (hostId, client) pair and tears down when that pair
// changes. The provider keeps the underlying socket open across
// resubscription cycles so this is cheap.
useEffect(() => {
const cleanups: Array<() => void> = []
for (const entry of allClients) {
let unsubNotif: (() => void) | null = null
let unsubAccounts: (() => void) | null = null
let statsFetched = false
const unsubState = client.onStateChange((state) => {
const wireUp = (state: ConnectionState) => {
if (state === 'connected') {
if (!unsubNotif) {
unsubNotif = subscribeToDesktopNotifications(client)
unsubNotif = subscribeToDesktopNotifications(entry.client)
}
if (!unsubAccounts) {
unsubAccounts = client.subscribe('accounts.subscribe', null, (payload) => {
if (disposed || !payload || typeof payload !== 'object') return
unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
if (!payload || typeof payload !== 'object') return
const evt = payload as { type?: string; snapshot?: AccountsSnapshot }
if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) {
const snap = evt.snapshot
setAccountsByHost((prev) => ({ ...prev, [host.id]: snap }))
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: evt.snapshot! }))
}
})
}
if (!statsFetched) {
statsFetched = true
fetchStats(client, setStats, () => disposed)
fetchWorktreeInfo(client, host.id, setWorktreeInfo, () => disposed)
fetchStats(entry.client, setStats, () => false)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
}
} else {
if (unsubNotif) {
@ -333,25 +423,29 @@ export default function HomeScreen() {
unsubAccounts = null
}
}
})
notifCleanups.push(() => {
}
wireUp(entry.state)
const unsubState = entry.client.onStateChange(wireUp)
cleanups.push(() => {
unsubState()
unsubNotif?.()
unsubAccounts?.()
})
return [{ hostId: host.id, client }]
})
clientsRef.current = entries
return () => {
disposed = true
clientsRef.current = []
for (const cleanup of notifCleanups) cleanup()
for (const entry of entries) entry.client.close()
}
}, [hosts])
return () => {
for (const c of cleanups) c()
}
// Why: depend on the host-id set, not the whole allClients array, so
// resubscriptions don't fire on every render that produces a new
// array reference. Client identity is stable per hostId for the
// lifetime of the underlying transport.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
allClients
.map((e) => e.hostId)
.sort()
.join(',')
])
// Why: prefer the worktree the user last opened on this device so the
// "Resume" card reflects their mobile session history, not just the
@ -433,6 +527,9 @@ export default function HomeScreen() {
async function handleRemove() {
if (!confirmRemove) return
try {
// Why: close the shared client first so the WebSocket is gone
// before the host record disappears from loadHosts().
closeHostClient(confirmRemove.id)
await removeHost(confirmRemove.id)
setConfirmRemove(null)
setHosts(await loadHosts())
@ -538,8 +635,10 @@ export default function HomeScreen() {
ItemSeparatorComponent={CardGap}
renderItem={({ item }) => {
const state = hostStates[item.id] ?? 'connecting'
const attempts = hostAttempts[item.id] ?? 0
const connected = state === 'connected'
const info = worktreeInfo[item.id]
const status = getStatusDisplay(state, attempts)
return (
<Pressable
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
@ -564,14 +663,11 @@ export default function HomeScreen() {
{item.name}
</Text>
<View style={styles.hostMeta}>
<View
style={[
styles.statusDot,
{ backgroundColor: connected ? colors.statusGreen : colors.textMuted }
]}
/>
<Text style={styles.hostMetaItem}>
{STATUS_LABELS[state]}
<StatusDot state={state} />
<Text
style={[styles.hostMetaItem, status.isError && { color: colors.statusRed }]}
>
{status.label}
{connected && info
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
: ''}
@ -749,25 +845,52 @@ export default function HomeScreen() {
visible={actionTarget != null}
title={actionTarget?.name}
message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined}
actions={[
{
label: 'Rename',
actions={(() => {
const host = actionTarget
if (!host) return []
const state = hostStates[host.id] ?? 'connecting'
const isLive =
state === 'connected' ||
state === 'connecting' ||
state === 'handshaking' ||
state === 'reconnecting'
const items: ActionSheetAction[] = []
items.push({
label: 'Reconnect',
icon: RefreshCw,
onPress: () => {
const host = actionTarget
setActionTarget(null)
if (host) setRenameTarget(host)
void forceReconnectHost(host.id)
}
},
{
})
if (isLive) {
items.push({
label: 'Disconnect',
icon: PowerOff,
onPress: () => {
setActionTarget(null)
closeHostClient(host.id)
}
})
}
items.push({
label: 'Rename',
icon: Edit3,
onPress: () => {
setActionTarget(null)
setRenameTarget(host)
}
})
items.push({
label: 'Remove',
destructive: true,
onPress: () => {
const host = actionTarget
setActionTarget(null)
if (host) setConfirmRemove(host)
setConfirmRemove(host)
}
}
]}
})
return items
})()}
onClose={() => setActionTarget(null)}
/>

View File

@ -7,7 +7,7 @@ const stateColors: Record<ConnectionState, string> = {
connecting: colors.statusAmber,
handshaking: colors.statusAmber,
reconnecting: colors.statusAmber,
disconnected: colors.statusRed,
disconnected: colors.textMuted,
'auth-failed': colors.statusRed
}

View File

@ -0,0 +1,455 @@
// Why: collapses the per-screen WebSocket connection model into a single
// shared RpcClient per host. Implements the design in
// docs/mobile-shared-client-per-host.md.
//
// Lifecycle rules:
// - First request for a host opens its client lazily.
// - Refcount tracks active subscribers; when it drops to zero we schedule
// a 30-second idle close timer. If a new subscriber arrives within that
// window we cancel and reuse the same client.
// - removeHost() forces an immediate close so re-pairing gets a fresh
// transport.
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode
} from 'react'
import { connect, type RpcClient } from './rpc-client'
import { loadHosts } from './host-store'
import type { ConnectionState, HostProfile } from './types'
const IDLE_CLOSE_MS = 30_000
type StoreEntry = {
client: RpcClient
state: ConnectionState
refCount: number
idleTimer: ReturnType<typeof setTimeout> | null
unsubState: () => void
}
type ContextValue = {
acquire: (hostId: string, host?: HostProfile) => RpcClient | null
release: (hostId: string) => void
forceReconnect: (hostId: string) => Promise<void>
closeHost: (hostId: string) => void
getState: (hostId: string) => ConnectionState
getReconnectAttempt: (hostId: string) => number
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => Array<{ hostId: string; client: RpcClient }>
subscribeAllHosts: (listener: () => void) => () => void
// Why: lets the home screen feed already-loaded HostProfiles in so we
// don't pay loadHosts() latency twice (once in the focus-effect, again
// inside openEntry).
primeHosts: (hosts: HostProfile[]) => void
}
const Ctx = createContext<ContextValue | null>(null)
export function RpcClientProvider({ children }: { children: ReactNode }) {
// Why: entries live in a ref so updates don't force re-renders of the
// entire tree on every connection state change. State propagation goes
// through per-host listener Sets instead.
const storeRef = useRef<Map<string, StoreEntry>>(new Map())
const stateListenersRef = useRef<Map<string, Set<(state: ConnectionState) => void>>>(new Map())
const allHostsListenersRef = useRef<Set<() => void>>(new Set())
// Pending opens (avoid two acquire() callers in the same render racing the
// host lookup). Keyed by hostId, value is a sentinel resolved when the
// entry materialises.
const pendingOpensRef = useRef<Map<string, Promise<void>>>(new Map())
// Why: a fast-path cache of already-loaded HostProfiles. Screens that
// have run loadHosts() can call primeHosts() to populate this and skip
// the second loadHosts() inside openEntry. Without this we'd serialize
// two Keychain passes on cold start.
const primedHostsRef = useRef<Map<string, HostProfile>>(new Map())
function notifyHostState(hostId: string, state: ConnectionState) {
const set = stateListenersRef.current.get(hostId)
if (!set) return
for (const listener of set) listener(state)
}
function notifyAllHosts() {
for (const listener of allHostsListenersRef.current) listener()
}
const closeEntry = useCallback((hostId: string) => {
const entry = storeRef.current.get(hostId)
if (!entry) return
if (entry.idleTimer) clearTimeout(entry.idleTimer)
entry.unsubState()
entry.client.close()
storeRef.current.delete(hostId)
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
}, [])
const openEntry = useCallback(async (hostId: string): Promise<StoreEntry | null> => {
const existing = pendingOpensRef.current.get(hostId)
if (existing) {
await existing
return storeRef.current.get(hostId) ?? null
}
let resolve: () => void = () => {}
const promise = new Promise<void>((res) => {
resolve = res
})
pendingOpensRef.current.set(hostId, promise)
try {
// Why: prefer the primed cache (populated by primeHosts when the
// screen already ran loadHosts) so we don't serialize a second
// Keychain pass behind the first one on cold start.
let host = primedHostsRef.current.get(hostId)
if (!host) {
try {
const hosts = await loadHosts()
host = hosts.find((h) => h.id === hostId)
} catch {
// Why: a Keychain failure on cold start (rare but observed —
// happens when iOS Keychain is mid-unlock or Android Keystore
// races the JS bridge). Surface it as 'disconnected' so the
// home card flips off the perma-spinner and the user can hit
// Reconnect from the action sheet to retry.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
}
if (!host) return null
}
// Re-check after any await — another acquire() may have completed.
const after = storeRef.current.get(hostId)
if (after) return after
let client: RpcClient
try {
client = connect(host.endpoint, host.deviceToken, host.publicKeyB64)
} catch {
// Why: connect() can throw synchronously if the public key is
// malformed or the endpoint URL is invalid. Notify so the UI
// doesn't sit on a stale 'connecting' label forever.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
}
const unsubState = client.onStateChange((state) => {
const cur = storeRef.current.get(hostId)
if (!cur) return
cur.state = state
notifyHostState(hostId, state)
})
const entry: StoreEntry = {
client,
state: client.getState(),
refCount: 0,
idleTimer: null,
unsubState
}
storeRef.current.set(hostId, entry)
notifyHostState(hostId, entry.state)
notifyAllHosts()
return entry
} finally {
pendingOpensRef.current.delete(hostId)
resolve()
}
}, [])
// Why: `acquire` is the synchronous get-or-open. If the entry already
// exists, return its client immediately and bump the refcount. If not,
// kick off an async open (the consumer will subscribe via
// `subscribeHostState` and re-read once 'connecting' fires). Optionally
// accepts the HostProfile so the caller can avoid an extra loadHosts()
// pass inside openEntry.
const acquire = useCallback(
(hostId: string, host?: HostProfile): RpcClient | null => {
if (host) primedHostsRef.current.set(hostId, host)
const existing = storeRef.current.get(hostId)
if (existing) {
existing.refCount += 1
if (existing.idleTimer) {
clearTimeout(existing.idleTimer)
existing.idleTimer = null
}
return existing.client
}
// Trigger async open. The acquire-side will return null this tick and
// try again once the state listener fires; consumers are expected to
// call acquire() inside an effect that re-runs on state changes.
void openEntry(hostId).then((entry) => {
if (!entry) return
entry.refCount += 1
})
return null
},
[openEntry]
)
const primeHosts = useCallback((hosts: HostProfile[]) => {
for (const host of hosts) primedHostsRef.current.set(host.id, host)
}, [])
const release = useCallback(
(hostId: string) => {
const entry = storeRef.current.get(hostId)
if (!entry) return
entry.refCount = Math.max(0, entry.refCount - 1)
if (entry.refCount > 0) return
if (entry.idleTimer) clearTimeout(entry.idleTimer)
entry.idleTimer = setTimeout(() => {
// Why: only close if still idle when the timer fires. A late acquire
// would have cleared the timer.
const cur = storeRef.current.get(hostId)
if (!cur || cur.refCount > 0) return
closeEntry(hostId)
}, IDLE_CLOSE_MS)
},
[closeEntry]
)
const forceReconnect = useCallback(
async (hostId: string) => {
const entry = storeRef.current.get(hostId)
// Why: if the entry was previously closed (e.g. user tapped
// Disconnect), refCount is lost. Fall back to the number of active
// state listeners as a proxy for "screens currently watching this
// host," so the freshly-opened entry doesn't trip the idle-close
// timer immediately.
const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0
const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount)
if (entry) {
if (entry.idleTimer) clearTimeout(entry.idleTimer)
entry.unsubState()
entry.client.close()
storeRef.current.delete(hostId)
}
const fresh = await openEntry(hostId)
if (fresh) fresh.refCount = savedRefCount
},
[openEntry]
)
const getState = useCallback((hostId: string): ConnectionState => {
return storeRef.current.get(hostId)?.state ?? 'disconnected'
}, [])
const getReconnectAttempt = useCallback((hostId: string): number => {
return storeRef.current.get(hostId)?.client.getReconnectAttempt() ?? 0
}, [])
const subscribeHostState = useCallback(
(hostId: string, listener: (state: ConnectionState) => void) => {
let set = stateListenersRef.current.get(hostId)
if (!set) {
set = new Set()
stateListenersRef.current.set(hostId, set)
}
set.add(listener)
return () => {
const s = stateListenersRef.current.get(hostId)
if (!s) return
s.delete(listener)
if (s.size === 0) stateListenersRef.current.delete(hostId)
}
},
[]
)
const getAllClients = useCallback((): Array<{ hostId: string; client: RpcClient }> => {
const out: Array<{ hostId: string; client: RpcClient }> = []
for (const [hostId, entry] of storeRef.current) {
out.push({ hostId, client: entry.client })
}
return out
}, [])
const subscribeAllHosts = useCallback((listener: () => void) => {
allHostsListenersRef.current.add(listener)
return () => {
allHostsListenersRef.current.delete(listener)
}
}, [])
// Close all clients on provider unmount (app shutdown / hot reload).
useEffect(() => {
const store = storeRef.current
return () => {
for (const [hostId] of store) closeEntry(hostId)
}
}, [closeEntry])
const value = useMemo<ContextValue>(
() => ({
acquire,
release,
forceReconnect,
closeHost: closeEntry,
getState,
getReconnectAttempt,
subscribeHostState,
getAllClients,
subscribeAllHosts,
primeHosts
}),
[
acquire,
release,
forceReconnect,
closeEntry,
getState,
getReconnectAttempt,
subscribeHostState,
getAllClients,
subscribeAllHosts,
primeHosts
]
)
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
function useCtx(): ContextValue {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useHostClient must be used inside <RpcClientProvider>')
return ctx
}
// Why: the primary hook for screens. Acquires the shared client for a
// hostId on mount and releases on unmount. Re-renders when the host's
// connection state changes.
export function useHostClient(hostId: string | undefined): {
client: RpcClient | null
state: ConnectionState
} {
const ctx = useCtx()
const [, force] = useState(0)
const [state, setState] = useState<ConnectionState>(() =>
hostId ? ctx.getState(hostId) : 'disconnected'
)
const clientRef = useRef<RpcClient | null>(null)
useEffect(() => {
if (!hostId) {
clientRef.current = null
setState('disconnected')
return
}
let cancelled = false
// Subscribe before acquire so any state change during open is captured.
const unsub = ctx.subscribeHostState(hostId, (next) => {
if (cancelled) return
setState(next)
// Why: if the client was null at first acquire (async open), the
// first state change ('connecting'/'handshaking'/'connected') is our
// signal to re-read.
if (clientRef.current == null) {
const all = ctx.getAllClients()
const found = all.find((entry) => entry.hostId === hostId)
if (found) {
clientRef.current = found.client
force((n) => n + 1)
}
}
})
const initial = ctx.acquire(hostId)
if (initial) {
clientRef.current = initial
setState(ctx.getState(hostId))
}
return () => {
cancelled = true
unsub()
ctx.release(hostId)
clientRef.current = null
}
}, [ctx, hostId])
return { client: clientRef.current, state }
}
// Why: home screen renders all paired hosts at once. Acquires each on
// mount, releases on unmount. The provider's refcounting ensures we
// don't double-open if a host-detail screen is also open.
export function useAllHostClients(hostIds: string[]): Array<{
hostId: string
client: RpcClient
state: ConnectionState
}> {
const ctx = useCtx()
// Stable key so we don't tear down on every render of the array.
const key = useMemo(() => [...hostIds].sort().join(','), [hostIds])
const [tick, setTick] = useState(0)
useEffect(() => {
if (hostIds.length === 0) return
for (const id of hostIds) ctx.acquire(id)
const unsubs: Array<() => void> = []
for (const id of hostIds) {
unsubs.push(ctx.subscribeHostState(id, () => setTick((n) => n + 1)))
}
unsubs.push(ctx.subscribeAllHosts(() => setTick((n) => n + 1)))
return () => {
for (const u of unsubs) u()
for (const id of hostIds) ctx.release(id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key])
return useMemo(() => {
const out: Array<{ hostId: string; client: RpcClient; state: ConnectionState }> = []
for (const id of hostIds) {
const all = ctx.getAllClients().find((entry) => entry.hostId === id)
if (all) {
out.push({ hostId: id, client: all.client, state: ctx.getState(id) })
}
}
return out
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, tick])
}
// Why: removeHost() in host-store.ts must close the live client, but
// host-store has no React-side handle. Expose a hook that lets callers
// close a host after removal.
export function useCloseHost(): (hostId: string) => void {
const ctx = useCtx()
return ctx.closeHost
}
// Why: future-proof "Connection issues — try again" affordance.
export function useForceReconnect(): (hostId: string) => Promise<void> {
const ctx = useCtx()
return ctx.forceReconnect
}
// Why: lets the home screen feed already-loaded HostProfiles in so the
// provider can skip its own loadHosts() pass when it eventually opens
// each host — collapses two serial Keychain reads on cold-start into one.
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
const ctx = useCtx()
return ctx.primeHosts
}
// Why: lets the home/host-detail UI escalate "Reconnecting…" to a more
// alarming "Can't connect" once the rpc-client has cycled enough times to
// indicate something's actually wrong (wrong port, server down, network
// loss). Reads through the context so it stays in sync with the live
// rpc-client instance even after forceReconnect swaps the underlying
// client.
export function useReconnectAttempt(hostId: string | undefined): number {
const ctx = useCtx()
const [, force] = useState(0)
useEffect(() => {
if (!hostId) return
return ctx.subscribeHostState(hostId, () => force((n) => n + 1))
}, [ctx, hostId])
return hostId ? ctx.getReconnectAttempt(hostId) : 0
}

View File

@ -25,7 +25,27 @@ function tokenKey(hostId: string): string {
return `${TOKEN_KEY_PREFIX}${hostId}`
}
// Why: SecureStore reads on Android Keystore can take 50-200ms each, and
// loadHosts() is called from every screen mount + every useFocusEffect.
// Stack with N hosts and you get N*200ms blocking every navigation, which
// triggers connection-churn cycles in the home-screen useEffect. Cache
// per-hostId in memory; invalidate only on save/remove. The cache lives
// for the JS-runtime lifetime, which matches AsyncStorage semantics
// (cleared on app uninstall, persisted across foreground/background).
const tokenCache = new Map<string, string>()
let inflightLoad: Promise<HostProfile[]> | null = null
export async function loadHosts(): Promise<HostProfile[]> {
// Why: deduplicate concurrent loadHosts() calls so multiple screens
// mounting simultaneously share one Keychain read pass.
if (inflightLoad) return inflightLoad
inflightLoad = doLoadHosts().finally(() => {
inflightLoad = null
})
return inflightLoad
}
async function doLoadHosts(): Promise<HostProfile[]> {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
if (!raw) return []
let parsed: unknown
@ -48,12 +68,17 @@ export async function loadHosts(): Promise<HostProfile[]> {
const stored = StoredHostProfileSchema.safeParse(item)
if (!stored.success) continue
const token = await SecureStore.getItemAsync(tokenKey(stored.data.id), KEYCHAIN_OPTIONS)
let token = tokenCache.get(stored.data.id)
if (!token) {
// Why: orphaned metadata with no matching keychain entry — most
// likely a stale record from a development install. Skip it
// rather than surface a half-broken host.
continue
const fetched = await SecureStore.getItemAsync(tokenKey(stored.data.id), KEYCHAIN_OPTIONS)
if (!fetched) {
// Why: orphaned metadata with no matching keychain entry — most
// likely a stale record from a development install. Skip it
// rather than surface a half-broken host.
continue
}
token = fetched
tokenCache.set(stored.data.id, token)
}
out.push({ ...stored.data, deviceToken: token })
}
@ -100,6 +125,7 @@ export async function saveHost(host: HostProfile): Promise<void> {
}
await SecureStore.setItemAsync(tokenKey(stored.id), validated.deviceToken, KEYCHAIN_OPTIONS)
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
tokenCache.set(stored.id, validated.deviceToken)
}
export async function removeHost(hostId: string): Promise<void> {
@ -107,6 +133,7 @@ export async function removeHost(hostId: string): Promise<void> {
const filtered = hosts.filter((h) => h.id !== hostId)
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
tokenCache.delete(hostId)
}
export async function renameHost(hostId: string, newName: string): Promise<void> {

View File

@ -25,6 +25,9 @@ export type RpcClient = {
sendRequest: (method: string, params?: unknown) => Promise<RpcResponse>
subscribe: (method: string, params: unknown, onData: StreamingListener) => () => void
getState: () => ConnectionState
// Why: UI escalates "Reconnecting…" to "Can't connect" once attempts cross
// a threshold. 0 means never failed; counter is reset on successful open.
getReconnectAttempt: () => number
onStateChange: (listener: (state: ConnectionState) => void) => () => void
close: () => void
}
@ -32,6 +35,13 @@ export type RpcClient = {
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
const REQUEST_TIMEOUT_MS = 30_000
const HANDSHAKE_TIMEOUT_MS = 5_000
// Why: belt-and-suspenders against React Native WebSocket implementations
// that occasionally never fire onerror/onclose for an unreachable host
// (observed when waking the device with stale DNS). Without this safety
// net the UI sat on 'Connecting…' forever and only a Metro reload
// recovered. Five seconds is a generous upper bound — a healthy LAN WS
// typically connects in <100ms.
const CONNECT_TIMEOUT_MS = 5_000
export function connect(
endpoint: string,
@ -45,6 +55,7 @@ export function connect(
let reconnectAttempt = 0
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let handshakeTimer: ReturnType<typeof setTimeout> | null = null
let connectTimer: ReturnType<typeof setTimeout> | null = null
let intentionallyClosed = false
// Why: fresh ephemeral keypair per connection provides forward secrecy.
@ -96,7 +107,24 @@ export function connect(
ws = new WebSocket(endpoint)
connectTimer = setTimeout(() => {
connectTimer = null
// Why: WS still stuck before 'open' — force-close so onclose fires
// and reconnect logic kicks in (rather than sitting on 'connecting'
// until the user reloads Metro). Safe even if the WS is mid-open;
// close() will trigger the 'closing' → 'closed' transitions.
try {
ws?.close()
} catch {
// ignore — onclose will still wire up reconnect
}
}, CONNECT_TIMEOUT_MS)
ws.onopen = () => {
if (connectTimer) {
clearTimeout(connectTimer)
connectTimer = null
}
reconnectAttempt = 0
setState('handshaking')
@ -239,6 +267,10 @@ export function connect(
clearTimeout(handshakeTimer)
handshakeTimer = null
}
if (connectTimer) {
clearTimeout(connectTimer)
connectTimer = null
}
if (intentionallyClosed) {
setState('disconnected')
rejectAllPending('Connection closed')
@ -360,6 +392,10 @@ export function connect(
return state
},
getReconnectAttempt(): number {
return reconnectAttempt
},
onStateChange(listener: (state: ConnectionState) => void): () => void {
stateListeners.add(listener)
return () => stateListeners.delete(listener)
@ -375,6 +411,10 @@ export function connect(
clearTimeout(handshakeTimer)
handshakeTimer = null
}
if (connectTimer) {
clearTimeout(connectTimer)
connectTimer = null
}
if (ws) {
ws.close()
ws = null

View File

@ -137,10 +137,17 @@ export default function TerminalPane({
}
requestAnimationFrame(fitAffectedPanes)
// Why: belt-and-suspenders — if safeFit's fitAddon.fit() threw or
// was a no-op due to stale dimensions, this fallback uses the
// restored cols/rows from the runtime to force the resize. If
// safeFit already succeeded, the terminal is already at the right
// dims and this is a harmless no-op.
// was a no-op due to stale dimensions, fall back to a direct
// resize. ONLY fire if xterm is still parked at the prior
// mobile-fit dims, meaning safeFit failed to move it. Previously
// we also fired when xterm had moved to *any* size other than
// the captured baseline, which clobbered safeFit's correct
// DOM-measured fit when the desktop pane geometry had changed
// since mobile-fit started (e.g. user closed a split or resized
// the window while the phone was active). In that scenario the
// event.cols/rows is the stale baseline from the moment
// mobile-fit started, not the current pane geometry — applying
// it would shrink the terminal back to e.g. half-width.
setTimeout(() => {
for (const paneId of paneIds) {
const pane = manager.getPanes().find((p) => p.id === paneId)
@ -148,13 +155,12 @@ export default function TerminalPane({
continue
}
safeFit(pane)
// Fallback: if terminal is still at mobile dims, force resize
// using the restored dimensions from the runtime notification.
if (
event.cols > 0 &&
event.rows > 0 &&
(pane.terminal.cols !== event.cols || pane.terminal.rows !== event.rows)
) {
const stuckAtMobile =
event.priorCols != null &&
event.priorRows != null &&
pane.terminal.cols === event.priorCols &&
pane.terminal.rows === event.priorRows
if (stuckAtMobile && event.cols > 0 && event.rows > 0) {
pane.terminal.resize(event.cols, event.rows)
}
}

View File

@ -202,7 +202,9 @@ describe('onOverrideChange', () => {
ptyId: 'pty-1',
mode: 'mobile-fit',
cols: 49,
rows: 20
rows: 20,
priorCols: null,
priorRows: null
})
unsub()
@ -218,7 +220,28 @@ describe('onOverrideChange', () => {
ptyId: 'pty-1',
mode: 'desktop-fit',
cols: 120,
rows: 40
rows: 40,
priorCols: null,
priorRows: null
})
unsub()
})
it('passes prior mobile-fit dims to desktop-fit listeners', () => {
setFitOverride('pty-1', 'mobile-fit', 49, 20)
const listener = vi.fn()
const unsub = onOverrideChange(listener)
setFitOverride('pty-1', 'desktop-fit', 120, 40)
expect(listener).toHaveBeenCalledWith({
ptyId: 'pty-1',
mode: 'desktop-fit',
cols: 120,
rows: 40,
priorCols: 49,
priorRows: 20
})
unsub()

View File

@ -23,6 +23,13 @@ type OverrideChangeEvent = {
mode: 'mobile-fit' | 'desktop-fit'
cols: number
rows: number
// Why: the dimensions the PTY was at *before* this event fired. For a
// desktop-fit transition this is the prior mobile-fit cols/rows so
// listeners can check whether xterm is still stuck at phone dims and
// needs the safety-net resize, vs. already moved on (e.g. user resized
// the desktop pane while mobile was active).
priorCols: number | null
priorRows: number | null
}
type OverrideChangeListener = (event: OverrideChangeEvent) => void
const changeListeners = new Set<OverrideChangeListener>()
@ -44,12 +51,20 @@ export function setFitOverride(
cols: number,
rows: number
): void {
const prior = overridesByPtyId.get(ptyId) ?? null
if (mode === 'mobile-fit') {
overridesByPtyId.set(ptyId, { mode, cols, rows })
} else {
overridesByPtyId.delete(ptyId)
}
notifyChange({ ptyId, mode, cols, rows })
notifyChange({
ptyId,
mode,
cols,
rows,
priorCols: prior?.cols ?? null,
priorRows: prior?.rows ?? null
})
}
export function getPaneIdsForPty(ptyId: string): number[] {