Mobile: server-authoritative phone-fit state machine + race fixes + UX cleanup (#1518)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-06 21:54:23 -07:00 committed by GitHub
parent 6630cf6295
commit e83d92eede
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1759 additions and 707 deletions

View File

@ -105,14 +105,14 @@ Invariants:
| Current driver | Trigger | Next driver | Side effect |
|---|---|---|---|
| `idle` | mobile subscribes with `displayMode='auto'\|'phone'` (first client for this ptyId) | `mobile{clientId}` | banner mounts on desktop; PTY resizes to phone dims |
| `idle` | mobile subscribes with `displayMode='auto'` (first client for this ptyId) | `mobile{clientId}` | banner mounts on desktop; PTY resizes to phone dims |
| `idle` | mobile subscribes with `displayMode='desktop'` (first client for this ptyId) | `desktop` | inner subscriber map populated; **no** banner; PTY stays at desktop dims |
| `idle` | desktop input or first PTY data after no subscribers | `idle` (no transition) | — |
| `mobile{A}` | desktop clicks **Take back** | `desktop` | banner unmounts; PTY snaps to desktop dims if at phone dims |
| `mobile{A}` | mobile A sends input/resize/setDisplayMode | `mobile{A}` (no transition) | — |
| `mobile{A}` | mobile B sends input | `mobile{B}` | (no banner change; both are "mobile") |
| `mobile{A}` | last mobile client unsubscribes | `idle` | banner unmounts |
| `desktop` | any mobile client sends input/resize | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims if that client's mode is auto/phone |
| `desktop` | any mobile client sends input/resize | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims if that client's mode is auto |
| `desktop` | mobile sets `displayMode` to `auto` or `phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims (deliberate "I want to drive" gesture) |
| `desktop` | mobile sets `displayMode` to `desktop` | `desktop` (no transition) | — (already desktop-mode watching) |
| `desktop` | mobile subscribes-fresh with `auto`/`phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims |
@ -174,7 +174,7 @@ connected; mobile sees the desktop-sized terminal because PTY snapped back
**Mobile types something while you're reclaimed.** Banner reappears. Your
next keystroke is blocked. PTY may snap back to phone dims (if mobile is in
auto/phone mode).
auto mode).
**Mobile disconnects.** Banner gone permanently. Driver returns to `idle`.
@ -207,7 +207,7 @@ This is asymmetric and we are accepting it deliberately:
- **Mobile reclaim is naturally signaled.** When a mobile user types
while desktop drives, the runtime flips the driver to
`mobile{thatClient}`, the desktop banner remounts, and (if mobile is in
auto/phone mode) the PTY snaps back to phone dims. The mobile user sees
auto mode) the PTY snaps back to phone dims. The mobile user sees
the pane reflow and their keystrokes appear in the output stream. There
is no silent black-hole condition on mobile that a banner would
resolve.
@ -255,7 +255,7 @@ listener. The `TerminalPane` banner mounts when
### Why not reuse `getFitOverrideForPty`?
The fit override only fires when the PTY was actually resized
(`mode='auto'|'phone'` and `wasResizedToPhone=true`). It misses the
(`mode='auto'` and `wasResizedToPhone=true`). It misses the
desktop-mode case where mobile is subscribed but no resize happened. The
driver state is broader than the fit override.

View File

@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.4",
"version": "0.0.5",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@ -16,7 +16,7 @@
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.stably.orca.mobile",
"buildNumber": "11",
"buildNumber": "1",
"infoPlist": {
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
"NSAppTransportSecurity": {
@ -51,7 +51,8 @@
},
"usesCleartextTraffic": true,
"allowBackup": false,
"package": "com.stably.orca.mobile"
"package": "com.stably.orca.mobile",
"versionCode": 1
},
"plugins": [
"expo-router",

View File

@ -33,9 +33,14 @@ import {
useHostClient,
useCloseHost,
useForceReconnect,
useReconnectAttempt
useReconnectAttempt,
useLastConnectedAt
} from '../../../src/transport/client-context'
import type { ConnectionState, RpcSuccess } from '../../../src/transport/types'
import {
classifyConnection,
type ConnectionVerdict
} from '../../../src/transport/connection-health'
import type { RpcSuccess } from '../../../src/transport/types'
import { triggerMediumImpact } from '../../../src/platform/haptics'
import { StatusDot } from '../../../src/components/StatusDot'
import { NewWorktreeModal } from '../../../src/components/NewWorktreeModal'
@ -93,29 +98,8 @@ type FilterState = {
selectedRepos: Set<string>
}
const STATUS_LABELS: Record<ConnectionState, string> = {
connecting: 'Connecting…',
handshaking: 'Securing…',
connected: 'Connected',
disconnected: 'Disconnected',
reconnecting: 'Reconnecting…',
'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 }
function isErrorVerdict(v: ConnectionVerdict): boolean {
return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed'
}
const SORT_OPTIONS: PickerOption<SortMode>[] = [
@ -282,6 +266,7 @@ export default function HostScreen() {
// docs/mobile-shared-client-per-host.md.
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const clientRef = useRef<RpcClient | null>(null)
const closeHostClient = useCloseHost()
const forceReconnectHost = useForceReconnect()
@ -705,33 +690,45 @@ export default function HostScreen() {
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textPrimary} />
</Pressable>
<View style={styles.hostIdentity}>
<StatusDot state={connState} />
<Text style={styles.hostNameText} numberOfLines={1}>
{hostName || 'Host'}
</Text>
</View>
{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}
{(() => {
const headerVerdict = classifyConnection({
state: connState,
reconnectAttempts,
lastConnectedAt
})
return (
<>
<View style={styles.hostIdentity}>
<StatusDot state={connState} verdict={headerVerdict} />
<Text style={styles.hostNameText} numberOfLines={1}>
{hostName || 'Host'}
</Text>
{showReconnectButton && (
<Pressable
style={styles.reconnectButton}
onPress={() => void forceReconnectHost(hostId!)}
hitSlop={8}
>
<Text style={styles.reconnectButtonText}>Reconnect</Text>
</Pressable>
)}
</View>
)
})()}
{connState !== 'connected' &&
(() => {
// Why: status label removed in favor of just the dot +
// Reconnect button — the home screen already surfaces the
// verdict text per host, and the dot color already
// signals severity here. Auth-failed routes through its
// dedicated banner so we still want to suppress the
// Reconnect button for that case.
const verdict = headerVerdict
const isError = isErrorVerdict(verdict)
const showReconnectButton = isError && hostId && verdict.kind !== 'auth-failed'
if (!showReconnectButton) return null
return (
<Pressable
style={styles.reconnectButton}
onPress={() => void forceReconnectHost(hostId!)}
hitSlop={8}
>
<Text style={styles.reconnectButtonText}>Reconnect</Text>
</Pressable>
)
})()}
</>
)
})()}
</View>
{/* Filter/sort/group toolbar */}
@ -1182,15 +1179,6 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: colors.textPrimary
},
statusText: {
color: colors.textSecondary,
fontSize: typography.metaSize
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
reconnectButton: {
paddingVertical: 4,
paddingHorizontal: spacing.sm,

View File

@ -163,6 +163,13 @@ export default function SessionScreen() {
const webReadyHandlesRef = useRef<Set<string>>(new Set())
const activeHandleRef = useRef<string | null>(null)
const subscribeSeqRef = useRef<Map<string, number>>(new Map())
// Why: server-side layout state machine emits a monotonic seq on every
// applyLayout. Track the highest seq we've observed per handle and drop
// any scrollback/resized event with a strictly older seq — these are
// late-arriving events from a superseded layout (e.g. phone-fit dims
// landing after the user toggled to desktop). Drops below `>20`-window
// gap reset (treat as a fresh subscription, e.g. server restart).
const layoutSeqRef = useRef<Map<string, number>>(new Map())
const sendingRef = useRef(false)
// Why: tracks the pixel height of the terminal frame so measureFitDimensions
// can use the exact container height instead of relying on window.innerHeight,
@ -180,6 +187,10 @@ export default function SessionScreen() {
terminalUnsubsRef.current.delete(handle)
subscribingHandlesRef.current.delete(handle)
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
// Why: a fresh subscription will land on a new server-side state machine
// run (or the same one with a higher seq); reset the high-water mark so
// the first scrollback isn't accidentally dropped as stale.
layoutSeqRef.current.delete(handle)
}, [])
const clearTerminalCache = useCallback(() => {
@ -191,6 +202,7 @@ export default function SessionScreen() {
initializedHandlesRef.current.clear()
webReadyHandlesRef.current.clear()
subscribeSeqRef.current.clear()
layoutSeqRef.current.clear()
for (const term of terminalRefs.current.values()) {
term.clear()
}
@ -226,6 +238,13 @@ export default function SessionScreen() {
const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1
subscribeSeqRef.current.set(handle, seq)
console.log('[fit][session] subscribe', {
handle: handle.slice(-8),
seq,
viewport: viewportRef.current,
viewportMeasured: viewportMeasuredRef.current
})
// Why: server handles auto-fit on subscribe — no terminal.focus call needed.
// The viewport is embedded in the subscribe params so the server resizes
// the PTY before serializing scrollback. This eliminates the focus→safeFit
@ -240,50 +259,168 @@ export default function SessionScreen() {
(result) => {
if (subscribeSeqRef.current.get(handle) !== seq) return
const data = result as Record<string, unknown>
// Why: stale-event filter. Server-side state machine bumps a
// monotonic seq on every applyLayout. Drop `resized` events
// whose seq is strictly older than what we've already observed
// for this handle — they're late-arriving from a superseded
// layout. `scrollback` is the response to a fresh subscribe,
// so it always resets the high-water mark regardless of seq
// (post-WS-reconnect or post-resubscribe the server may emit
// scrollback at a seq lower than what we'd seen pre-reconnect;
// dropping it would leave the user with a blank terminal).
const eventSeq = typeof data.seq === 'number' ? data.seq : null
if (eventSeq != null && data.type === 'resized') {
const last = layoutSeqRef.current.get(handle)
if (last != null && eventSeq < last && last - eventSeq <= 20) {
console.log('[fit][session] DROP-stale-seq', {
handle: handle.slice(-8),
type: data.type,
eventSeq,
lastSeq: last,
cols: data.cols,
rows: data.rows,
displayMode: data.displayMode
})
return
}
layoutSeqRef.current.set(handle, eventSeq)
} else if (eventSeq != null && data.type === 'scrollback') {
layoutSeqRef.current.set(handle, eventSeq)
}
if (data.type === 'scrollback' || data.type === 'resized') {
console.log('[fit][session] event', {
handle: handle.slice(-8),
type: data.type,
cols: data.cols,
rows: data.rows,
displayMode: data.displayMode,
seq: eventSeq,
reason: data.reason
})
}
if (data.type === 'scrollback') {
if (initializedHandlesRef.current.has(handle)) return
if (initializedHandlesRef.current.has(handle)) {
console.log('[fit][session] scrollback IGNORED (already initialized)', {
handle: handle.slice(-8),
cols: data.cols,
rows: data.rows
})
return
}
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const scrollbackCols = cols
const scrollbackRows = rows
const initialData =
typeof data.serialized === 'string' && data.serialized.length > 0
? data.serialized
: ''
getTerminalRef(handle)?.init(cols, rows, initialData)
const ref = getTerminalRef(handle)
// Why: previously we set `initializedHandlesRef` even when the
// WebView wasn't mounted yet (ref=null). The init message went
// nowhere, but the flag stayed true, so any subsequent scrollback
// for THIS handle was silently dropped → blank terminal. Only
// mark initialized if init() actually reached the WebView.
if (!ref) {
console.log('[fit][session] scrollback DROPPED — no terminal ref', {
handle: handle.slice(-8),
cols,
rows
})
return
}
ref.init(cols, rows, initialData)
initializedHandlesRef.current.add(handle)
if (data.displayMode) {
setTerminalModes((prev) =>
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
)
}
// Why: cold-start fit-to-screen guard. The first init() runs
// before xterm's DOM/canvas has fully laid out, so the
// applyFitScale that init queues internally can land while
// term.element.scrollWidth is still stale or zero — leaving
// the terminal un-zoomed until the user toggles the resize
// button. Re-fire resetZoom after a short delay so it runs
// against a settled DOM. Mirrors the 'resized' handler below.
// Why: belt-and-suspenders cold-start fit. The applyFitScale
// queued by init() runs after writes drain, but on cold start
// xterm's scrollWidth can still be transient when it commits.
// Re-fire after a short delay so it runs against a settled DOM.
// Mirrors the 'resized' handler below.
setTimeout(() => getTerminalRef(handle)?.resetZoom(), 200)
// Why: viewport measurement needs xterm to be initialized (cell
// dimensions come from the renderer). On the first subscribe the
// WebView hasn't loaded yet, so viewportRef is null and the server
// can't auto-fit. After the first init we can measure, then
// resubscribe so the server gets the viewport and phone-fits.
if (!viewportMeasuredRef.current) {
// If viewport was measured by a parallel path BUT the scrollback
// we just received came back at desktop dims, our subscribe
// beat the measure; the server still has a null viewport for
// this subscriber record — resubscribe so it gets stored.
const needsResubscribe =
!viewportMeasuredRef.current ||
(viewportRef.current != null &&
(scrollbackCols !== viewportRef.current.cols ||
scrollbackRows !== viewportRef.current.rows))
if (needsResubscribe) {
void (async () => {
console.log('[fit][session] post-scrollback measure-start', {
handle: handle.slice(-8),
containerHeight: terminalFrameHeightRef.current
})
// Why: wait for the WebView's init() rAF chain to fully
// run (term.open → renderService population → first
// paint) before measuring. Without this, the measure
// postMessage races ahead of init's async work and
// returns null (term not ready / cells size 0), the
// resubscribe never fires, and the server never gets
// phone dims. See log dump 2026-05-06 confirming the
// race + measure-result null pattern.
await getTerminalRef(handle)?.awaitReady()
const dims = await getTerminalRef(handle)?.measureFitDimensions(
terminalFrameHeightRef.current || undefined
)
if (dims && !viewportMeasuredRef.current) {
// Why: we just got `scrollback` with cols=80 (server's
// default fallback for null viewport). That means the
// server-side subscriber record was registered before we
// could send viewport. Even if `viewportMeasuredRef`
// raced ahead via a parallel `measureViewportOnce`, the
// server still has a null viewport for THIS subscriber
// record — we MUST resubscribe so the server stores it.
console.log('[fit][session] post-scrollback measure-result', {
handle: handle.slice(-8),
dims,
alreadyMeasured: viewportMeasuredRef.current
})
if (dims) {
viewportRef.current = dims
viewportMeasuredRef.current = true
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
console.log('[fit][session] post-scrollback re-subscribe', {
handle: handle.slice(-8),
viewport: dims
})
subscribeToTerminal(handle)
}
})()
}
} else if (data.type === 'data') {
getTerminalRef(handle)?.write(data.chunk as string)
// Why: log when data arrives but the WebView ref is missing
// — this is the most likely cause of "blank but input works":
// server stream is alive, sends flow, but writes are dropped
// because the WebView ref disappeared (unmount mid-flight) or
// the scrollback never landed (so xterm has no buffer).
const dataRef = getTerminalRef(handle)
if (!dataRef) {
console.log('[fit][session] data DROPPED — no terminal ref', {
handle: handle.slice(-8),
chunkLen: typeof data.chunk === 'string' ? data.chunk.length : 0,
initialized: initializedHandlesRef.current.has(handle)
})
return
}
if (!initializedHandlesRef.current.has(handle)) {
console.log('[fit][session] data RECEIVED before scrollback', {
handle: handle.slice(-8),
chunkLen: typeof data.chunk === 'string' ? data.chunk.length : 0
})
}
dataRef.write(data.chunk as string)
} else if (data.type === 'resized') {
// Why: inline resize event — the server changed the PTY dimensions
// (mode toggle or desktop restore). Reinitialize xterm at the new
@ -324,17 +461,29 @@ export default function SessionScreen() {
if (!client) return
if (toggleInFlightRef.current.has(handle)) return
const current = terminalModes.get(handle) ?? 'auto'
const next: MobileDisplayMode = current === 'auto' || current === 'phone' ? 'desktop' : 'auto'
// Why: 'phone' on the wire is an observation ("currently phone-fitted"),
// not a setting. The toggle only ever requests 'auto' or 'desktop'.
const next: 'auto' | 'desktop' =
current === 'auto' || current === 'phone' ? 'desktop' : 'auto'
console.log('[fit][session] toggleDisplayMode', {
handle: handle.slice(-8),
current,
next
})
toggleInFlightRef.current.add(handle)
try {
await client.sendRequest('terminal.setDisplayMode', {
terminal: handle,
mode: next,
// Why: presence-lock take-floor signal. Sending mode=auto/phone
// is a deliberate "I want to drive at phone dims" gesture.
// Why: presence-lock take-floor signal — requesting 'auto' is the
// explicit "I want to drive at phone dims" gesture.
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
: {}),
// Why: late-bind viewport for terminals whose subscribe record
// was registered before measurement landed. Without this the
// server's stored viewport is null and auto toggles no-op.
...(viewportRef.current && next === 'auto' ? { viewport: viewportRef.current } : {})
})
} catch {
// Mode change failed — server state unchanged, UI stays in sync.
@ -666,6 +815,7 @@ export default function SessionScreen() {
// scrollback snapshot. Only resubscribe if this is a reload — on
// first load the subscription is already running and pendingMessages
// will flush the queued init after this callback returns.
// (unsubscribeTerminal also clears layoutSeqRef for this handle.)
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
if (handle === activeHandleRef.current) {
@ -676,10 +826,18 @@ export default function SessionScreen() {
// Why: on first web-ready, the initial subscribeToTerminal call from
// fetchTerminals may have been skipped (reason=no-ref, WebView wasn't
// mounted yet). Now that the WebView is ready, subscribe if this is the
// active terminal and no subscription is running.
// active terminal and no subscription is running. Await measure before
// subscribe so the very first subscribe carries the viewport — without
// this, subscribe(viewport=null) lands on the server first and the
// post-scrollback measure path's resubscribe sees alreadyMeasured=true
// (because measureViewportOnce won the race) and silently skips.
if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) {
void measureViewportOnce(handle)
subscribeToTerminal(handle)
void (async () => {
await measureViewportOnce(handle)
if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) {
subscribeToTerminal(handle)
}
})()
}
},
[measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]

View File

@ -32,6 +32,7 @@ import {
useForceReconnect,
usePrimeHosts
} from '../src/transport/client-context'
import { classifyConnection } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
import type { ConnectionState, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
@ -53,32 +54,6 @@ function endpointLabel(endpoint: string): string {
}
}
const STATUS_LABELS: Record<ConnectionState, string> = {
connected: 'Connected',
connecting: 'Connecting…',
disconnected: 'Disconnected',
reconnecting: 'Reconnecting…',
handshaking: 'Connecting…',
'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
@ -226,6 +201,7 @@ export default function HomeScreen() {
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
const [stats, setStats] = useState<StatsSummary | null>(null)
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
@ -342,6 +318,18 @@ export default function HomeScreen() {
}
return changed ? next : prev
})
setHostLastConnected((prev) => {
const next: Record<string, number | null> = { ...prev }
let changed = false
for (const entry of allClients) {
const t = entry.client.getLastConnectedAt()
if (next[entry.hostId] !== t) {
next[entry.hostId] = t
changed = true
}
}
return changed ? next : prev
})
setHostStates((prev) => {
const next: Record<string, ConnectionState> = { ...prev }
let changed = false
@ -458,12 +446,16 @@ export default function HomeScreen() {
// tap target is still the same and a fresher snapshot from the live RPC
// overwrites the card's contents in place when it lands.
const resumeWorktree = useMemo(() => {
if (lastVisited && sortedHosts.some((h) => h.id === lastVisited.hostId)) {
// Why: only surface Resume for hosts that are currently connected.
// Showing a stale cached worktree for a disconnected host is
// misleading — the user would tap into a session route that can't
// load anything until the host reconnects. Once the host reconnects,
// the card reappears with fresh data.
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
if (match) return { hostId: lastVisited.hostId, worktree: match }
}
// Prefer a currently-connected host's data when we have it.
for (const host of sortedHosts) {
if (hostStates[host.id] !== 'connected') continue
const info = worktreeInfo[host.id]
@ -471,39 +463,16 @@ export default function HomeScreen() {
return { hostId: host.id, worktree: info.lastActiveWorktree }
}
}
// Fall back to whichever known host has cached data, regardless of
// current connection state.
for (const host of sortedHosts) {
const info = worktreeInfo[host.id]
if (info?.lastActiveWorktree) {
return { hostId: host.id, worktree: info.lastActiveWorktree }
}
}
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]
)
// Why: only show the Account usage section for hosts that have at least
// one Claude or Codex account configured. Render whenever cached data
// exists, regardless of current connection state, so the cards don't
// disappear for ~1s on resume while the WebSocket reconnects. Streamed
// updates from the live RPC overwrite the snapshot in place when ready.
// Why: only show the Account usage section for hosts that are currently
// connected. Showing stale cached usage for a disconnected host implies
// live data; better to hide until the host reconnects and we can refresh.
const accountsHosts = useMemo(() => {
const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = []
for (const host of sortedHosts) {
if (hostStates[host.id] !== 'connected') continue
const snap = accountsByHost[host.id]
if (!snap) continue
const hasClaude = snap.claude.accounts.length > 0
@ -636,9 +605,18 @@ export default function HomeScreen() {
renderItem={({ item }) => {
const state = hostStates[item.id] ?? 'connecting'
const attempts = hostAttempts[item.id] ?? 0
const lastConnectedAt = hostLastConnected[item.id] ?? null
const connected = state === 'connected'
const info = worktreeInfo[item.id]
const status = getStatusDisplay(state, attempts)
const verdict = classifyConnection({
state,
reconnectAttempts: attempts,
lastConnectedAt
})
const isError =
verdict.kind === 'warning' ||
verdict.kind === 'unreachable' ||
verdict.kind === 'auth-failed'
return (
<Pressable
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
@ -663,11 +641,9 @@ export default function HomeScreen() {
{item.name}
</Text>
<View style={styles.hostMeta}>
<StatusDot state={state} />
<Text
style={[styles.hostMetaItem, status.isError && { color: colors.statusRed }]}
>
{status.label}
<StatusDot state={state} verdict={verdict} />
<Text style={[styles.hostMetaItem, isError && { color: colors.statusRed }]}>
{verdict.label}
{connected && info
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
: ''}
@ -716,17 +692,6 @@ 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}
{/* ─── Account usage ─── */}
@ -854,9 +819,15 @@ export default function HomeScreen() {
state === 'connecting' ||
state === 'handshaking' ||
state === 'reconnecting'
// Why: "Reconnect" implies "you were connected, try again". If
// the client has never reached 'connected' this session (cold
// start, unreachable host, or after Disconnect) the action is
// functionally a fresh Connect — using the right verb makes
// the affordance match what tapping it actually does.
const hasEverConnected = (hostLastConnected[host.id] ?? null) != null
const items: ActionSheetAction[] = []
items.push({
label: 'Reconnect',
label: hasEverConnected && isLive ? 'Reconnect' : 'Connect',
icon: RefreshCw,
onPress: () => {
setActionTarget(null)
@ -1197,18 +1168,6 @@ const styles = StyleSheet.create({
marginTop: 4
},
/* ─── Skeleton ─── */
skeletonBlock: {
backgroundColor: colors.bgRaised,
opacity: 0.5
},
skeletonLine: {
height: 12,
borderRadius: 4,
backgroundColor: colors.bgRaised,
opacity: 0.5
},
/* ─── Quick actions ─── */
quickActions: {
flexDirection: 'row',

View File

@ -1,6 +1,7 @@
import { View, StyleSheet } from 'react-native'
import { colors } from '../theme/mobile-theme'
import type { ConnectionState } from '../transport/types'
import type { ConnectionVerdict } from '../transport/connection-health'
const stateColors: Record<ConnectionState, string> = {
connected: colors.statusGreen,
@ -11,8 +12,25 @@ const stateColors: Record<ConnectionState, string> = {
'auth-failed': colors.statusRed
}
export function StatusDot({ state }: { state: ConnectionState }) {
return <View style={[styles.dot, { backgroundColor: stateColors[state] ?? colors.textMuted }]} />
// Why: when caller passes a verdict, the dot color reflects the verdict's
// severity instead of the raw transport state. This avoids the "amber dot
// next to red 'Can't reach desktop' label" mismatch — the underlying
// transport is still 'reconnecting' (amber) but the user-visible meaning
// has escalated to error (red).
export function StatusDot({
state,
verdict
}: {
state: ConnectionState
verdict?: ConnectionVerdict
}) {
const color =
verdict?.kind === 'unreachable' || verdict?.kind === 'auth-failed'
? colors.statusRed
: verdict?.kind === 'warning'
? colors.statusAmber
: (stateColors[state] ?? colors.textMuted)
return <View style={[styles.dot, { backgroundColor: color }]} />
}
const styles = StyleSheet.create({

View File

@ -10,6 +10,11 @@ export type TerminalWebViewHandle = {
clear: () => void
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
resetZoom: () => void
// Why: lets callers await the WebView-side `init` rAF chain (term.open
// → renderService population → first paint) so a follow-up measure
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
// the next 'ready' notify after the most recent init.
awaitReady: () => Promise<void>
}
type Props = {
@ -82,14 +87,49 @@ const XTERM_HTML = `<!DOCTYPE html>
var terminalGeneration = 0;
var activeAltScreenSnapshot = false;
var handledMessageIds = [];
// Why: after init() the initial scrollback applyFitScale may have run
// against an empty buffer (or one without the widest line yet). Re-fit
// once when the first live data chunk arrives so a wider line that pushes
// scrollWidth past the previously-measured value gets re-scaled to fit.
var firstDataPending = false;
// Diagnostic logger — bridges WebView console.log to RN via postMessage.
// Tag with [fit] so it's easy to filter in the Expo/Metro logs.
function flog(tag, payload) {
try {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'log', tag: '[fit]' + tag, payload: payload
}));
}
} catch (e) {}
}
function getCellWidth() {
if (!term || !term._core) return 0;
var core = term._core;
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.width || 0;
}
return 0;
}
// Why: width measurement strategy.
// 1. Prefer cellWidth × term.cols — this is what xterm's renderer uses
// to lay out and is independent of buffer content. It's the "logical
// width" of the terminal grid.
// 2. Fall back to term.element.scrollWidth — the actual rendered DOM
// width — only when cellWidth isn't available yet (renderer not
// initialized). This is content-dependent (reflects widest row),
// but better than nothing.
// 3. If both are 0, return 1 (no scale change). The retry loop in
// applyFitScale will keep trying until one is positive.
function computeFitScale() {
if (!term) return 1;
var el = term.element;
if (!el) return 1;
var termWidth = el.scrollWidth;
var vpWidth = window.innerWidth;
var cellW = getCellWidth();
var termWidth = cellW > 0 ? cellW * term.cols : (term.element ? term.element.scrollWidth : 0);
if (termWidth <= 0) return 1;
var vpWidth = window.innerWidth;
return Math.min(1, vpWidth / termWidth);
}
@ -159,33 +199,63 @@ const XTERM_HTML = `<!DOCTYPE html>
}
}
// Why: on cold start (first WebView load + first scrollback) xterm's DOM
// and canvas need several frames to reflow after term.open(). If we
// computeFitScale() too eagerly we read scrollWidth=0 or a stale width
// from before the new cols took effect, scrollWidth/vpWidth >= 1, and
// currentScale snaps to 1 — which is exactly the "didn't zoom to fit"
// bug users see on first load. Retry across frames until we get a
// positive, stable scrollWidth, then commit. Capped to keep this from
// spinning forever if the WebView never lays out (e.g. backgrounded).
var FIT_RETRY_MAX_FRAMES = 30;
// Why: cold-start fit. After init() opens xterm, the renderer needs
// several frames before cell dimensions are computed. Reading too early
// gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM
// not laid out), and computeFitScale returns 1 → no zoom.
//
// Gate: cellWidth × cols is the canonical "logical width" of the grid
// and reflects xterm's layout decision, independent of buffer content.
// We commit when cellWidth becomes positive (renderer ready). Fallback:
// if cellWidth never becomes available, gate on stable positive
// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
// so a backgrounded WebView never spins forever.
var FIT_RETRY_MAX_FRAMES = 60;
var fitRetryToken = 0;
function applyFitScale() {
if (!term || !term.element) return;
function applyFitScale(reason) {
if (!term || !term.element) {
flog('skip-no-term', { reason: reason });
return;
}
var token = ++fitRetryToken;
var attempts = 0;
var lastWidth = -1;
var lastScrollWidth = -1;
flog('start', {
reason: reason,
cols: term.cols,
rows: term.rows,
vpWidth: window.innerWidth,
vpHeight: window.innerHeight
});
function attempt() {
if (token !== fitRetryToken) return;
if (!term || !term.element) return;
var w = term.element.scrollWidth;
attempts++;
if (w > 0 && w === lastWidth) {
commitFitScale();
if (token !== fitRetryToken) {
flog('cancel-superseded', { reason: reason, attempts: attempts });
return;
}
lastWidth = w;
if (!term || !term.element) return;
attempts++;
var cellW = getCellWidth();
if (cellW > 0 && term.cols > 0) {
flog('commit-cellW', { reason: reason, attempts: attempts, cellW: cellW, cols: term.cols });
commitFitScale(reason, attempts, 'cellW');
return;
}
var w = term.element.scrollWidth;
if (w > 0 && w === lastScrollWidth) {
flog('commit-stableSW', { reason: reason, attempts: attempts, scrollWidth: w });
commitFitScale(reason, attempts, 'stableSW');
return;
}
lastScrollWidth = w;
if (attempts >= FIT_RETRY_MAX_FRAMES) {
commitFitScale();
flog('commit-timeout', {
reason: reason,
attempts: attempts,
cellW: cellW,
scrollWidth: w,
cols: term.cols
});
commitFitScale(reason, attempts, 'timeout');
return;
}
requestAnimationFrame(attempt);
@ -193,17 +263,39 @@ const XTERM_HTML = `<!DOCTYPE html>
requestAnimationFrame(attempt);
}
function commitFitScale() {
function commitFitScale(reason, attempts, gate) {
if (!term || !term.element) return;
currentScale = computeFitScale();
// Why: when the scale is very close to 1 (e.g. 0.97 due to xterm
// scrollbar width), snap to 1.0 to avoid sub-pixel shrinkage.
var preSnapScale = computeFitScale();
currentScale = preSnapScale;
// Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar
// sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents
// a second applyFitScale from observing a "no-op needed" state.
if (currentScale >= 0.95) currentScale = 1;
userScale = 1;
panX = 0;
panY = 0;
updateTransform();
adjustRowsForViewport();
var cellW = getCellWidth();
var sw = term.element.scrollWidth;
var vpW = window.innerWidth;
var expectedW = cellW * term.cols;
var suspect =
currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom
flog(suspect ? 'commit-SUSPECT' : 'commit', {
reason: reason,
attempts: attempts,
gate: gate,
preSnapScale: preSnapScale,
finalScale: currentScale,
cellW: cellW,
cols: term.cols,
expectedW: expectedW,
scrollWidth: sw,
vpWidth: vpW,
suspect: suspect
});
}
function isAltScreenActive(data) {
@ -254,6 +346,16 @@ const XTERM_HTML = `<!DOCTYPE html>
writesDraining = false;
afterDrainCallbacks = [];
initRows = rows || 24;
firstDataPending = true;
flog('init', {
cols: cols,
rows: rows,
hasInitialData: typeof initialData === 'string' && initialData.length > 0,
initialDataLen: typeof initialData === 'string' ? initialData.length : 0,
vpWidth: window.innerWidth,
vpHeight: window.innerHeight,
gen: gen
});
var replayData = normalizeInitialData(initialData);
activeAltScreenSnapshot = isAltScreenActive(replayData);
if (term) term.dispose();
@ -304,7 +406,7 @@ const XTERM_HTML = `<!DOCTYPE html>
ready = true;
afterWritesDrained(function() {
if (gen !== terminalGeneration) return;
applyFitScale();
applyFitScale('init-replay');
notify({ type: 'ready', cols: cols, rows: rows });
});
});
@ -313,6 +415,18 @@ const XTERM_HTML = `<!DOCTYPE html>
function write(data) {
writeQueue.push(data);
pumpWrites(terminalGeneration);
// Why: first live data chunk after init may widen the buffer past
// what the post-replay applyFitScale measured. Re-fit once after this
// chunk drains to catch the wider line. Subsequent chunks don't re-fit
// (the user's manual zoom is sticky after that).
if (firstDataPending) {
firstDataPending = false;
var gen = terminalGeneration;
afterWritesDrained(function() {
if (gen !== terminalGeneration) return;
applyFitScale('first-data');
});
}
}
function notify(msg) {
@ -321,22 +435,35 @@ const XTERM_HTML = `<!DOCTYPE html>
}
}
function measureFitDimensions(containerHeightPx) {
if (!term || !term.element) {
notify({ type: 'measure-result', cols: null, rows: null });
return;
}
// Why: measure actual xterm cell dimensions from the renderer, not from
// font metrics alone. This accounts for the exact font, size, and line
// height that xterm is using.
var core = term._core;
function measureFitDimensions(containerHeightPx, retriesLeft) {
if (typeof retriesLeft !== 'number') retriesLeft = 30;
// Why: init and measure are posted back-to-back from React, but
// init has an async rAF chain. A measure that runs synchronously
// after init can find term null, disposed, lacking element, or
// with cells size 0. Retry the whole gate for ~500ms.
var notReady = !term || !term.element;
var cellWidth = 0;
var cellHeight = 0;
if (core && core._renderService && core._renderService.dimensions) {
cellWidth = core._renderService.dimensions.css.cell.width;
cellHeight = core._renderService.dimensions.css.cell.height;
if (!notReady) {
var core = term._core;
if (core && core._renderService && core._renderService.dimensions) {
cellWidth = core._renderService.dimensions.css.cell.width;
cellHeight = core._renderService.dimensions.css.cell.height;
}
}
if (cellWidth <= 0 || cellHeight <= 0) {
if (notReady || cellWidth <= 0 || cellHeight <= 0) {
if (retriesLeft > 0) {
requestAnimationFrame(function() {
measureFitDimensions(containerHeightPx, retriesLeft - 1);
});
return;
}
flog('measure-fail', {
notReady: notReady,
cellWidth: cellWidth,
cellHeight: cellHeight,
retriesLeft: retriesLeft
});
notify({ type: 'measure-result', cols: null, rows: null });
return;
}
@ -381,7 +508,7 @@ const XTERM_HTML = `<!DOCTYPE html>
} else if (msg.type === 'measure') {
measureFitDimensions(msg.containerHeight);
} else if (msg.type === 'reset-zoom') {
applyFitScale();
applyFitScale('reset-zoom-msg');
}
}
@ -528,6 +655,11 @@ const XTERM_HTML = `<!DOCTYPE html>
});
window.addEventListener('resize', function() {
// Why: viewport changed (keyboard open/close, orientation, RN container
// size update). Re-fit so the scale matches the new vpWidth — without
// this, opening the keyboard leaves the terminal at the old scale even
// though there's now less vertical room and the fit ratio may differ.
applyFitScale('window-resize');
adjustRowsForViewport();
clampPan();
updateTransform();
@ -554,6 +686,12 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
const measureResolveRef = useRef<
((result: { cols: number; rows: number } | null) => void) | null
>(null)
// Why: each init() call posts 'init' to the WebView and arms a fresh
// ready promise. WebView's init() rAF chain ends with a 'ready' notify
// that resolves it. measureFitDimensions awaits this so it doesn't
// race ahead of term.open() / renderService population.
const readyPromiseRef = useRef<Promise<void> | null>(null)
const readyResolveRef = useRef<(() => void) | null>(null)
const sendToWebView = useCallback((msg: TerminalMessage) => {
messageIdRef.current += 1
@ -592,6 +730,15 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
isWebReadyRef.current = true
onWebReady?.()
flushPendingMessages()
} else if (msg.type === 'ready') {
// Why: the WebView's init() rAF chain has run — term is open,
// renderService is populated, first paint has happened. Resolve
// any pending awaitReady() so a queued measure can now safely
// read cell dims.
const resolve = readyResolveRef.current
readyResolveRef.current = null
readyPromiseRef.current = null
resolve?.()
} else if (msg.type === 'measure-result') {
const resolve = measureResolveRef.current
measureResolveRef.current = null
@ -600,6 +747,11 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
const rows = typeof msg.rows === 'number' ? msg.rows : null
resolve(cols && rows && cols >= 20 && rows >= 8 ? { cols, rows } : null)
}
} else if (msg.type === 'log') {
// Surface fit-scale diagnostics in the RN/Metro console.
const tag = typeof msg.tag === 'string' ? msg.tag : '[fit]'
// eslint-disable-next-line no-console
console.log(tag, msg.payload)
}
},
[flushPendingMessages, onWebReady]
@ -616,6 +768,14 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
postMessage({ type: 'write', data })
},
init(cols: number, rows: number, initialData?: string) {
// Why: arm a fresh ready promise BEFORE posting init. The WebView
// resolves it via the 'ready' notify at the end of its rAF chain.
// Re-init supersedes any prior in-flight ready (we don't bridge
// generations; the older promise just never resolves, and its
// callers have moved on by then).
readyPromiseRef.current = new Promise<void>((resolve) => {
readyResolveRef.current = resolve
})
postMessage({ type: 'init', cols, rows, initialData })
},
clear() {
@ -642,6 +802,14 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
},
resetZoom() {
postMessage({ type: 'reset-zoom' })
},
async awaitReady(): Promise<void> {
// Why: returns the in-flight ready promise (set by init); resolves
// immediately if no init is pending. Capped at 3s so a stuck
// WebView doesn't hang the caller.
const p = readyPromiseRef.current
if (!p) return
await Promise.race([p, new Promise<void>((resolve) => setTimeout(resolve, 3000))])
}
}),
[postMessage, sendToWebView]

View File

@ -37,6 +37,11 @@ type ContextValue = {
closeHost: (hostId: string) => void
getState: (hostId: string) => ConnectionState
getReconnectAttempt: (hostId: string) => number
// Why: timestamp (ms epoch) of the last successful 'connected' state
// transition for this host, or null if never connected this session.
// Used by the UI to escalate "Reconnecting…" into a "host appears
// unreachable, re-pair?" prompt.
getLastConnectedAt: (hostId: string) => number | null
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => Array<{ hostId: string; client: RpcClient }>
subscribeAllHosts: (listener: () => void) => () => void
@ -230,6 +235,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
return storeRef.current.get(hostId)?.client.getReconnectAttempt() ?? 0
}, [])
const getLastConnectedAt = useCallback((hostId: string): number | null => {
return storeRef.current.get(hostId)?.client.getLastConnectedAt() ?? null
}, [])
const subscribeHostState = useCallback(
(hostId: string, listener: (state: ConnectionState) => void) => {
let set = stateListenersRef.current.get(hostId)
@ -289,6 +298,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
closeHost: closeEntry,
getState,
getReconnectAttempt,
getLastConnectedAt,
subscribeHostState,
getAllClients,
subscribeAllHosts,
@ -301,6 +311,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
closeEntry,
getState,
getReconnectAttempt,
getLastConnectedAt,
subscribeHostState,
getAllClients,
subscribeAllHosts,
@ -448,3 +459,18 @@ export function useReconnectAttempt(hostId: string | undefined): number {
}, [ctx, hostId])
return hostId ? ctx.getReconnectAttempt(hostId) : 0
}
// Why: timestamp of last successful connect for this host, or null if
// the client has never connected. Combined with reconnectAttempt this
// distinguishes "transient blip" (recently connected) from "host
// appears unreachable" (never connected, or hasn't connected in N
// seconds despite many retry attempts).
export function useLastConnectedAt(hostId: string | undefined): number | null {
const ctx = useCtx()
const [, force] = useState(0)
useEffect(() => {
if (!hostId) return
return ctx.subscribeHostState(hostId, () => force((n) => n + 1))
}, [ctx, hostId])
return hostId ? ctx.getLastConnectedAt(hostId) : null
}

View File

@ -0,0 +1,86 @@
import type { ConnectionState } from './types'
// Why: thresholds for escalating connection UX from neutral
// "Reconnecting…" to alarming "host appears unreachable, re-pair?".
//
// - WARNING_ATTEMPTS: 3 → label flips to "Can't connect" (existing
// behavior). Calibrated to absorb a normal laptop wake / brief
// network blip without alarming the user.
// - UNREACHABLE_ATTEMPTS: 6 → with the 4s backoff cap that's ≈ 20s of
// continuous failure. Combined with the never-connected /
// stale-since-last-connect heuristic below, this is the trigger to
// surface a "re-pair?" affordance.
// - STALE_SINCE_LAST_CONNECT_MS: 60s → if we WERE connected this
// session but haven't been for ≥ 1 minute despite the retry loop
// spinning, treat the same as never-connected. Catches the case
// where the desktop's IP changed mid-session.
export const WARNING_ATTEMPTS = 3
export const UNREACHABLE_ATTEMPTS = 6
export const STALE_SINCE_LAST_CONNECT_MS = 60_000
export type ConnectionVerdict =
| { kind: 'normal'; label: string }
| { kind: 'warning'; label: string } // "Can't connect"
| { kind: 'unreachable'; label: string; reason: 'never-connected' | 'stale' }
| { kind: 'auth-failed'; label: string }
// Why: the rpc-client's lastConnectedAt is a one-shot timestamp; we have
// to recompute "are we currently stale" against now() each render.
// Centralized so home + host-detail show identical verdicts.
export function classifyConnection(args: {
state: ConnectionState
reconnectAttempts: number
lastConnectedAt: number | null
nowMs?: number
}): ConnectionVerdict {
const { state, reconnectAttempts, lastConnectedAt } = args
const now = args.nowMs ?? Date.now()
if (state === 'auth-failed') {
return { kind: 'auth-failed', label: 'Auth failed' }
}
// Connected / connecting / handshaking are normal.
if (state === 'connected') return { kind: 'normal', label: 'Connected' }
if (state === 'connecting' || state === 'handshaking') {
return { kind: 'normal', label: 'Connecting…' }
}
if (state === 'disconnected') {
return { kind: 'normal', label: 'Disconnected' }
}
// state === 'reconnecting' from here.
if (reconnectAttempts >= UNREACHABLE_ATTEMPTS) {
if (lastConnectedAt == null) {
return {
kind: 'unreachable',
label: "Can't reach desktop",
reason: 'never-connected'
}
}
if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) {
return {
kind: 'unreachable',
label: "Can't reach desktop",
reason: 'stale'
}
}
}
if (reconnectAttempts >= WARNING_ATTEMPTS) {
return { kind: 'warning', label: "Can't connect" }
}
return { kind: 'normal', label: 'Reconnecting…' }
}
// Why: the message under the banner explains what likely happened so the
// user understands why we're suggesting Re-pair. Tuned to be specific
// about IP/port without being technical (we don't want to leak
// "ws://192.168.x.y:port" unless someone is debugging).
export function unreachableHint(reason: 'never-connected' | 'stale'): string {
return reason === 'never-connected'
? "Can't reach this Orca desktop. Its network address may have changed since pairing — try re-pairing from the desktop's Settings → Mobile screen."
: 'Lost contact with the Orca desktop. If your network changed (different Wi-Fi, IP renewed, or desktop restarted), try re-pairing.'
}

View File

@ -34,6 +34,10 @@ export type RpcClient = {
// 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
// Why: timestamp (ms epoch) of the last time we reached 'connected'.
// null = never connected since the client was created. Used by the UI
// to distinguish "host moved/never reachable" from "transient blip".
getLastConnectedAt: () => number | null
onStateChange: (listener: (state: ConnectionState) => void) => () => void
close: () => void
}
@ -46,6 +50,14 @@ export type RpcClient = {
// manual Reconnect button bypassed the timer, which is why it felt
// "magic". Shorter backoff makes the auto-recovery path feel as fast.
const RECONNECT_DELAYS = [500, 1000, 2000, 4000]
// Why: cap auto-retry once we're clearly unreachable. With the 4s
// backoff cap that's ≈20s of solid failure before we stop. The UI
// renders an "unreachable, re-pair?" banner at this point; user taps
// Retry or Re-pair to resume. MUST stay aligned with
// connection-health.ts UNREACHABLE_ATTEMPTS so the verdict matches the
// moment the loop pauses — if these drift the user sees "Reconnecting…"
// while the loop is silently parked.
const GIVE_UP_AFTER_ATTEMPTS = 6
const REQUEST_TIMEOUT_MS = 30_000
const CONNECT_TIMEOUT_MS = 12_000
const HANDSHAKE_TIMEOUT_MS = 5_000
@ -107,6 +119,19 @@ export function connect(
let handshakeTimer: ReturnType<typeof setTimeout> | null = null
let activityProbeTimer: ReturnType<typeof setInterval> | null = null
let intentionallyClosed = false
let lastConnectedAt: number | null = null
// Why: diagnostic — when the rpc-client gets stuck in a state where every
// openConnection fails with code 1006 and only a force-quit recovers, we
// need to see whether (a) the new attempts even differ from the old ones,
// (b) anything is happening at the OS / RN-bridge layer between attempts,
// and (c) what the timing pattern is (instant 1006 = port closed / route
// dead, slow 1006 = packet drop / timeout). These three timestamps + the
// ws-construction counter are the cheapest visibility into RN/OkHttp
// process-state poisoning hypotheses.
let lastInboundAt: number | null = null
let lastWsClosedAt: number | null = null
let wsConstructionCounter = 0
let currentWsOpenedAt: number | null = null
// Why: fresh ephemeral keypair per connection provides forward secrecy.
// The shared key is derived from our ephemeral secret + server's static public key.
@ -122,10 +147,26 @@ export function connect(
stateListeners.add(onStateChange)
}
// Diagnostic: tracks how long we've been in the current state. Useful
// for spotting "stuck in connecting" or "stuck in reconnecting" cases
// in the logs.
let stateEnteredAt = Date.now()
function setState(next: ConnectionState) {
if (state === next) return
const prev = state
const dwelt = Date.now() - stateEnteredAt
state = next
stateEnteredAt = Date.now()
console.log('[net] state', {
from: prev,
to: next,
dweltMs: dwelt,
attempt: reconnectAttempt,
endpoint: redactedEndpoint(endpoint)
})
if (next === 'connected') {
lastConnectedAt = Date.now()
for (const w of connectWaiters.splice(0)) w.resolve()
} else if (next === 'disconnected' || next === 'auth-failed') {
const reason =
@ -137,6 +178,17 @@ export function connect(
}
}
// Why: don't dump device tokens / full URLs into log scrolls; truncate to
// the host:port so reconnect lifecycles are still readable.
function redactedEndpoint(ep: string): string {
try {
const m = ep.match(/^wss?:\/\/([^/]+)/i)
return m ? m[1] : 'unknown'
} catch {
return 'unknown'
}
}
function waitForConnected(): Promise<void> {
if (state === 'connected') return Promise.resolve()
if (intentionallyClosed) return Promise.reject(new Error('Client closed'))
@ -152,9 +204,25 @@ export function connect(
function openConnection() {
if (intentionallyClosed) return
const now = Date.now()
wsConstructionCounter++
console.log('[net] openConnection', {
attempt: reconnectAttempt,
endpoint: redactedEndpoint(endpoint),
// Why: process-poisoning diagnostic. If wsCount is high (e.g. >50)
// and every recent open fails with 1006, suspect RN/OkHttp internal
// pool corruption that only force-quit clears. Compare msSinceLast*
// values to the failure cadence: instant repeated fails with no
// inbound traffic between them = process-state stuck.
wsCount: wsConstructionCounter,
msSinceLastConnected: lastConnectedAt != null ? now - lastConnectedAt : null,
msSinceLastClose: lastWsClosedAt != null ? now - lastWsClosedAt : null,
msSinceLastInbound: lastInboundAt != null ? now - lastInboundAt : null
})
setState('connecting')
sharedKey = null
currentWsOpenedAt = now
emitLog(
'info',
reconnectAttempt > 0 ? `Reconnecting (attempt ${reconnectAttempt + 1})` : 'Opening WebSocket',
@ -170,6 +238,10 @@ export function connect(
connectTimer = setTimeout(() => {
connectTimer = null
if (ws === openingWs && openingWs.readyState === WEBSOCKET_CONNECTING_STATE) {
console.log('[net] connect-timeout fired (onopen never arrived)', {
attempt: reconnectAttempt,
timeoutMs: CONNECT_TIMEOUT_MS
})
emitLog(
'error',
'WebSocket connect timeout',
@ -177,12 +249,13 @@ export function connect(
)
openingWs.close()
if (ws === openingWs) {
handleSocketClosed(openingWs)
handleSocketClosed(openingWs, { timedOut: true })
}
}
}, CONNECT_TIMEOUT_MS)
ws.onopen = () => {
console.log('[net] ws.onopen', { attempt: reconnectAttempt })
clearConnectTimer()
reconnectAttempt = 0
setState('handshaking')
@ -203,6 +276,9 @@ export function connect(
handshakeTimer = setTimeout(() => {
handshakeTimer = null
console.log('[net] handshake-timeout fired (e2ee_authenticated never arrived)', {
timeoutMs: HANDSHAKE_TIMEOUT_MS
})
emitLog(
'error',
'Handshake timeout',
@ -213,6 +289,9 @@ export function connect(
}
ws.onmessage = (event) => {
// Why: track last-inbound for the openConnection diagnostic. Server
// pongs and stream events both bump this — anything from the wire.
lastInboundAt = Date.now()
const raw = typeof event.data === 'string' ? event.data : String(event.data)
// Why: during handshaking, e2ee_ready is plaintext because it precedes
@ -245,6 +324,9 @@ export function connect(
clearTimeout(handshakeTimer)
handshakeTimer = null
}
console.log('[net] e2ee_authenticated — connected', {
streamCount: streamListeners.size
})
setState('connected')
emitLog('success', 'Authenticated', 'Channel ready for RPC')
startActivityProbe()
@ -252,6 +334,7 @@ export function connect(
sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params })
}
} else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) {
console.log('[net] e2ee auth FAILED', { msgType: msg.type, error: msg.error })
emitLog(
'error',
'Authentication rejected',
@ -334,17 +417,112 @@ export function connect(
}
}
ws.onclose = () => {
ws.onclose = (event) => {
const e = event as { code?: number; reason?: string; wasClean?: boolean } | undefined
const closeAt = Date.now()
// Why: time-since-construct distinguishes failure modes. Instant
// close (<300ms) = TCP RST / port closed / route unreachable / RN
// synchronous reject. Mid (300ms3s) = DNS/connect attempt + reset.
// Slow (>3s) = TCP SYN timeout / packet loss / NAT wedge. If an
// entire reconnect burst is all instant, the problem is local
// process state or routing, not packet loss.
const constructToCloseMs = currentWsOpenedAt != null ? closeAt - currentWsOpenedAt : null
const aliveMs =
currentWsOpenedAt != null && state === 'connected' ? closeAt - currentWsOpenedAt : null
const inboundIdleMs = lastInboundAt != null ? closeAt - lastInboundAt : null
// Why: inline the diagnostic dump. Earlier hot-reload tripped
// `Property 'enumKeys' doesn't exist` because a stale closure
// captured a half-loaded module. Inlining keeps the handler's
// behavior fully decided at construction time.
let closeEventKeys: string[] = []
let closeEventStr = ''
try {
closeEventKeys = event && typeof event === 'object' ? Object.keys(event as object) : []
} catch {
closeEventKeys = []
}
try {
const seen = new WeakSet<object>()
closeEventStr = JSON.stringify(
event,
(_k, v) => {
if (typeof v === 'object' && v !== null) {
if (seen.has(v as object)) return '[circular]'
seen.add(v as object)
}
if (typeof v === 'function') return '[fn]'
return v
},
0
).slice(0, 500)
} catch {
closeEventStr = '[unstringifiable]'
}
console.log('[net] ws.onclose', {
code: e?.code,
reason: e?.reason,
wasClean: e?.wasClean,
state,
attempt: reconnectAttempt,
intentionallyClosed,
endpoint: redactedEndpoint(endpoint),
constructToCloseMs,
aliveMs,
inboundIdleMs,
eventKeys: closeEventKeys,
eventStr: closeEventStr
})
lastWsClosedAt = closeAt
currentWsOpenedAt = null
handleSocketClosed(openingWs)
}
ws.onerror = () => {
// onclose will fire after this
ws.onerror = (event) => {
// Why: RN surfaces network errors here (DNS failure, TCP RST, etc).
// onclose fires right after, but logging the error message gives us
// the original cause that the close code alone can hide.
const e = event as { message?: string } | undefined
// Why: inlined defensively — see ws.onclose comment.
let errEventKeys: string[] = []
let errEventStr = ''
try {
errEventKeys = event && typeof event === 'object' ? Object.keys(event as object) : []
} catch {
errEventKeys = []
}
try {
const seen = new WeakSet<object>()
errEventStr = JSON.stringify(
event,
(_k, v) => {
if (typeof v === 'object' && v !== null) {
if (seen.has(v as object)) return '[circular]'
seen.add(v as object)
}
if (typeof v === 'function') return '[fn]'
return v
},
0
).slice(0, 500)
} catch {
errEventStr = '[unstringifiable]'
}
console.log('[net] ws.onerror', {
message: e?.message,
state,
attempt: reconnectAttempt,
eventKeys: errEventKeys,
eventStr: errEventStr
})
}
}
function handleSocketClosed(closedWs: WebSocket) {
function handleSocketClosed(closedWs: WebSocket, opts: { timedOut?: boolean } = {}) {
if (ws !== closedWs) {
console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', {
state,
attempt: reconnectAttempt
})
return
}
clearConnectTimer()
@ -356,10 +534,17 @@ export function connect(
}
stopActivityProbe()
if (intentionallyClosed) {
console.log('[net] handleSocketClosed — intentional close')
setState('disconnected')
rejectAllPending('Connection closed')
return
}
console.log('[net] handleSocketClosed → reconnect', {
timedOut: !!opts.timedOut,
pendingCount: pending.size,
streamCount: streamListeners.size,
attempt: reconnectAttempt
})
emitLog('warn', 'WebSocket closed', 'Will attempt to reconnect')
rejectAllPending('Connection interrupted')
setState('reconnecting')
@ -367,8 +552,25 @@ export function connect(
}
function scheduleReconnect() {
// Why: spinning reconnect forever drains battery and floods logs
// when the host is genuinely unreachable (wrong IP, port closed,
// host moved). Cap at GIVE_UP_AFTER_ATTEMPTS — the UI surfaces a
// "Can't reach desktop, re-pair?" banner at this point and the
// user can tap Retry (forceReconnect creates a fresh client,
// resetting the counter) or Re-pair. Without an explicit cap the
// worst-case is a phone left on the home screen burning a socket
// open every 4s indefinitely.
if (reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) {
console.log('[net] reconnect-paused', {
attempt: reconnectAttempt,
reason: 'give-up-cap',
endpoint: redactedEndpoint(endpoint)
})
return
}
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]!
reconnectAttempt++
console.log('[net] scheduleReconnect', { delayMs: delay, attempt: reconnectAttempt })
emitLog('info', `Reconnect scheduled in ${delay}ms`, `Attempt ${reconnectAttempt}`)
reconnectTimer = setTimeout(() => {
reconnectTimer = null
@ -400,10 +602,15 @@ export function connect(
// half-open. Using REQUEST_TIMEOUT_MS (30s) here would make the
// user wait nearly a minute before reconnect kicks in.
const id = nextId()
const probeStart = Date.now()
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
pending.delete(id)
console.log('[net] activity-probe TIMEOUT — forcing reconnect', {
waitedMs: Date.now() - probeStart,
state
})
// Why: only force-close if this is still the same socket the
// probe was sent on; a normal close that already swapped `ws`
// shouldn't trigger a redundant terminate.
@ -448,6 +655,23 @@ export function connect(
ws.send(encrypt(JSON.stringify(request), sharedKey))
return true
}
console.log('[net] sendEncrypted FAILED — channel not ready', {
hasWs: !!ws,
readyState: ws?.readyState,
hasKey: !!sharedKey,
state
})
// Why: if the state machine still thinks we're connected but the
// underlying WebSocket has flipped to CLOSING/CLOSED without onclose
// having fired (RN's WebSocket sometimes drops the event, or the
// server half-closed the stream), force a reconnect. Without this
// every send silently fails forever and the user sees a frozen UI.
if (state === 'connected' && ws && ws.readyState !== WebSocket.OPEN) {
console.log('[net] sendEncrypted detected ws desync — forcing reconnect', {
readyState: ws.readyState
})
handleSocketClosed(ws, { timedOut: false })
}
return false
}
@ -455,12 +679,25 @@ export function connect(
return {
async sendRequest(method: string, params?: unknown): Promise<RpcResponse> {
const waitStart = Date.now()
const wasConnected = state === 'connected'
await waitForConnected()
if (!wasConnected) {
console.log('[net] sendRequest waited for connect', {
method,
waitedMs: Date.now() - waitStart
})
}
return new Promise((resolve, reject) => {
const id = nextId()
const timeout = setTimeout(() => {
pending.delete(id)
console.log('[net] sendRequest TIMEOUT', {
method,
timeoutMs: REQUEST_TIMEOUT_MS,
state
})
reject(new Error(`Request timed out: ${method}`))
}, REQUEST_TIMEOUT_MS)
@ -489,6 +726,11 @@ export function connect(
if (state === 'connected') {
sendEncrypted({ id, deviceToken, method, params })
} else {
// Stream is registered but the actual outbound subscribe will be
// sent (or re-sent) when the channel reaches 'connected'. Useful
// when terminals don't load — confirms the request is queued.
console.log('[net] subscribe queued — waiting for connected', { method, state })
}
return () => {
@ -536,6 +778,10 @@ export function connect(
return reconnectAttempt
},
getLastConnectedAt(): number | null {
return lastConnectedAt
},
onStateChange(listener: (state: ConnectionState) => void): () => void {
stateListeners.add(listener)
return () => stateListeners.delete(listener)

View File

@ -68,7 +68,7 @@ const store = {
}
describe('fit override integration', () => {
it('full lifecycle: fit → getSize → restore → verify PTY dims', () => {
it('full lifecycle: fit → getSize → restore → verify PTY dims', async () => {
const runtime = new OrcaRuntimeService(store)
const currentSize = { cols: 150, rows: 40 }
const resizes: { ptyId: string; cols: number; rows: number }[] = []
@ -130,7 +130,7 @@ describe('fit override integration', () => {
expect(currentSize).toEqual({ cols: 150, rows: 40 })
console.log('\n=== Step 2: Mobile fit to 45x20 ===')
const fitResult = runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20)
const fitResult = await runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20)
console.log('Fit result:', fitResult)
console.log('PTY size after fit:', currentSize)
console.log('Override:', runtime.getTerminalFitOverride('pty-1'))
@ -142,7 +142,7 @@ describe('fit override integration', () => {
// Simulate what runtime:restoreTerminalFit IPC handler does
const override = runtime.getTerminalFitOverride('pty-1')
expect(override).not.toBeNull()
const restoreResult = runtime.resizeForClient('pty-1', 'restore', override!.clientId)
const restoreResult = await runtime.resizeForClient('pty-1', 'restore', override!.clientId)
console.log('Restore result:', restoreResult)
console.log('PTY size after restore:', currentSize)
console.log('Override after restore:', runtime.getTerminalFitOverride('pty-1'))
@ -165,17 +165,17 @@ describe('fit override integration', () => {
console.log('\n=== Step 6: Mobile restore via RPC path ===')
// Re-fit, then restore via the mobile RPC handler path
runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20)
await runtime.resizeForClient('pty-1', 'mobile-fit', 'client-phone', 45, 20)
expect(currentSize).toEqual({ cols: 45, rows: 20 })
// This is what terminal.resizeForClient RPC handler does
const mobileRestore = runtime.resizeForClient('pty-1', 'restore', 'client-phone')
const mobileRestore = await runtime.resizeForClient('pty-1', 'restore', 'client-phone')
console.log('Mobile restore result:', mobileRestore)
console.log('PTY size after mobile restore:', currentSize)
expect(currentSize).toEqual({ cols: 150, rows: 40 })
})
it('restore resizes PTY even with mounted leaf (the bug fix)', () => {
it('restore resizes PTY even with mounted leaf (the bug fix)', async () => {
const runtime = new OrcaRuntimeService(store)
let ptySize = { cols: 120, rows: 35 }
const resizes: string[] = []
@ -229,17 +229,17 @@ describe('fit override integration', () => {
})
// Mobile fit
runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-a', 42, 18)
await runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-a', 42, 18)
expect(ptySize).toEqual({ cols: 42, rows: 18 })
// Restore — THIS is the critical assertion.
// Before the fix, mounted leaves skipped the PTY resize.
runtime.resizeForClient('pty-1', 'restore', 'phone-a')
await runtime.resizeForClient('pty-1', 'restore', 'phone-a')
expect(ptySize).toEqual({ cols: 120, rows: 35 })
expect(resizes).toEqual(['pty-1:42x18', 'pty-1:120x35'])
})
it('disconnect auto-restore also resizes PTY', () => {
it('disconnect auto-restore also resizes PTY', async () => {
const runtime = new OrcaRuntimeService(store)
let ptySize = { cols: 100, rows: 30 }
@ -267,11 +267,13 @@ describe('fit override integration', () => {
terminalDriverChanged: vi.fn()
})
runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-disconnect', 45, 20)
await runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-disconnect', 45, 20)
expect(ptySize).toEqual({ cols: 45, rows: 20 })
// Simulate WS disconnect
runtime.onClientDisconnected('phone-disconnect')
// onClientDisconnected enqueues fire-and-forget; flush microtasks
await new Promise((r) => setTimeout(r, 0))
expect(ptySize).toEqual({ cols: 100, rows: 30 })
expect(runtime.getTerminalFitOverride('pty-1')).toBeNull()
})

View File

@ -110,20 +110,20 @@ describe('mobile presence lock — driver state machine', () => {
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' })
})
it('handleMobileSubscribe in auto mode transitions idle → mobile{clientId}', () => {
it('handleMobileSubscribe in auto mode transitions idle → mobile{clientId}', async () => {
const { runtime, driverEvents } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'mobile', clientId: 'phone-A' })
})
it('handleMobileSubscribe in desktop mode is passive — does NOT take floor', () => {
it('handleMobileSubscribe in desktop mode is passive — does NOT take floor', async () => {
const { runtime, driverEvents } = createRuntime()
// Pretend a previous take-back put us in desktop mode.
runtime.setMobileDisplayMode('pty-1', 'desktop')
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
// Driver stays idle (the desktop banner was already gone). Phone is
// "passively watching" at desktop dims.
@ -131,37 +131,37 @@ describe('mobile presence lock — driver state machine', () => {
expect(driverEvents.find((e) => e.driver.kind === 'mobile')).toBeUndefined()
})
it('reclaimTerminalForDesktop transitions mobile → desktop and is idempotent', () => {
it('reclaimTerminalForDesktop transitions mobile → desktop and is idempotent', async () => {
const { runtime } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
expect(runtime.getDriver('pty-1').kind).toBe('mobile')
expect(runtime.reclaimTerminalForDesktop('pty-1')).toBe(true)
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true)
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' })
// Idempotent — second call is a no-op (no active mobile subscriber to
// reclaim from).
expect(runtime.reclaimTerminalForDesktop('pty-1')).toBe(true)
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true)
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' })
})
it('mobileTookFloor after reclaim re-applies phone-fit and flips driver back to mobile', () => {
it('mobileTookFloor after reclaim re-applies phone-fit and flips driver back to mobile', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
runtime.reclaimTerminalForDesktop('pty-1')
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.reclaimTerminalForDesktop('pty-1')
expect(runtime.getDriver('pty-1').kind).toBe('desktop')
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
runtime.mobileTookFloor('pty-1', 'phone-A')
await runtime.mobileTookFloor('pty-1', 'phone-A')
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
// PTY is back at phone dims.
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
})
it('handleMobileUnsubscribe last leaver flips driver to idle after soft-leave grace', () => {
it('handleMobileUnsubscribe last leaver flips driver to idle after soft-leave grace', async () => {
const { runtime, driverEvents } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
@ -170,31 +170,31 @@ describe('mobile presence lock — driver state machine', () => {
// doesn't cause a desktop banner flash.
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
vi.advanceTimersByTime(250)
await vi.advanceTimersByTimeAsync(250)
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' })
expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'idle' })
})
it('resubscribe within soft-leave grace cancels idle without driver flap', () => {
it('resubscribe within soft-leave grace cancels idle without driver flap', async () => {
const { runtime, driverEvents } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
// Same client re-subscribes inside the grace window — no idle should
// ever be observed by the renderer.
vi.advanceTimersByTime(100)
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
vi.advanceTimersByTime(500)
await vi.advanceTimersByTimeAsync(100)
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await vi.advanceTimersByTimeAsync(500)
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
expect(driverEvents.find((e) => e.driver.kind === 'idle')).toBeUndefined()
})
it('onPtyExit clears driver state and emits idle', () => {
it('onPtyExit clears driver state and emits idle', async () => {
const { runtime, driverEvents } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
runtime.onPtyExit('pty-1', 0)
@ -209,25 +209,25 @@ describe('mobile presence lock — multi-mobile semantics', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('most-recent actor wins active phone-fit dims (B subscribes after A)', () => {
it('most-recent actor wins active phone-fit dims (B subscribes after A)', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
// Advance fake clock so B's subscribedAt is strictly greater than A's.
vi.advanceTimersByTime(10)
await vi.advanceTimersByTimeAsync(10)
// B's narrower viewport must win when it subscribes.
runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 38, rows: 18 })
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' })
})
it('B unsubscribes — A still present, driver re-elects to A', () => {
it('B unsubscribes — A still present, driver re-elects to A', async () => {
const { runtime, driverEvents } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
vi.advanceTimersByTime(10)
runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await vi.advanceTimersByTimeAsync(10)
await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
runtime.handleMobileUnsubscribe('pty-1', 'phone-B')
@ -235,51 +235,53 @@ describe('mobile presence lock — multi-mobile semantics', () => {
expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'mobile', clientId: 'phone-A' })
})
it('A then B unsubscribes — peer survives; final unsubscribe goes idle', () => {
it('A then B unsubscribes — peer survives; final unsubscribe goes idle', async () => {
const { runtime } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
vi.advanceTimersByTime(10)
runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await vi.advanceTimersByTimeAsync(10)
await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' })
runtime.handleMobileUnsubscribe('pty-1', 'phone-B')
// Last leaver enters soft-grace; advance past it before asserting idle.
vi.advanceTimersByTime(250)
await vi.advanceTimersByTimeAsync(250)
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' })
})
it('terminal.send by phone-B updates lastActedAt — applyMobileDisplayMode picks B viewport', () => {
it('terminal.send by phone-B updates lastActedAt — applyMobileDisplayMode picks B viewport', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
// Advance the fake clock so phone-B's subscribe records a strictly
// later subscribedAt/lastActedAt — keeps tie-break deterministic.
vi.advanceTimersByTime(10)
runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
await vi.advanceTimersByTimeAsync(10)
await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
// Switch to desktop, then phone-B types — its viewport wins on re-fit.
runtime.setMobileDisplayMode('pty-1', 'desktop')
runtime.applyMobileDisplayMode('pty-1')
await runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
// Simulate B taking the floor by typing (advance again so lastActedAt
// is unambiguously the most recent).
vi.advanceTimersByTime(10)
runtime.mobileTookFloor('pty-1', 'phone-B')
await vi.advanceTimersByTimeAsync(10)
await runtime.mobileTookFloor('pty-1', 'phone-B')
expect(ptySizes.get('pty-1')).toEqual({ cols: 38, rows: 18 })
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' })
})
it('updateMobileViewport re-fits PTY without flipping the driver', () => {
it('updateMobileViewport re-fits PTY without flipping the driver', async () => {
const { runtime, ptySizes, driverEvents } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 38 })
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
const before = driverEvents.length
// Keyboard opens — viewport shrinks.
expect(runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 })).toBe(true)
expect(await runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 })).toBe(
true
)
expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 16 })
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
@ -288,18 +290,20 @@ describe('mobile presence lock — multi-mobile semantics', () => {
expect(driverEvents.slice(before).every((e) => e.driver.kind === 'mobile')).toBe(true)
})
it('updateMobileViewport then disconnect restores PTY to original baseline', () => {
it('updateMobileViewport then disconnect restores PTY to original baseline', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 38 })
// Keyboard cycles a few times.
runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 })
runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 38 })
runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 })
await runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 })
await runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 38 })
await runtime.updateMobileViewport('pty-1', 'phone-A', { cols: 49, rows: 16 })
// Phone disconnects (router.back → WS close).
runtime.onClientDisconnected('phone-A')
// onClientDisconnected enqueues fire-and-forget; flush microtasks + 0ms timers.
await vi.advanceTimersByTimeAsync(0)
// PTY must restore to the original 150x40 baseline, not the last
// phone-fit dim. This was the stuck-dim bug.
@ -307,39 +311,40 @@ describe('mobile presence lock — multi-mobile semantics', () => {
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' })
})
it('legacy unsubscribe → resubscribe within grace preserves baseline (regression)', () => {
it('legacy unsubscribe → resubscribe within grace preserves baseline (regression)', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 38 })
// Simulate legacy keyboard re-subscribe cycle within grace.
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
vi.advanceTimersByTime(100)
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 16 })
await vi.advanceTimersByTimeAsync(100)
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 16 })
// Apply mode after re-subscribe so the new viewport drives PTY dims.
runtime.applyMobileDisplayMode('pty-1')
await runtime.applyMobileDisplayMode('pty-1')
// PTY at new viewport, phone-A still drives.
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
// Disconnect — must restore to original 150x40, not 49x16.
runtime.onClientDisconnected('phone-A')
await vi.advanceTimersByTimeAsync(0)
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
})
it('earliest-subscribe restore target is preserved when peers churn', () => {
it('earliest-subscribe restore target is preserved when peers churn', async () => {
const { runtime, ptySizes } = createRuntime()
// A captures the original 150x40 baseline.
runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
// B subscribes later at 38x18 (advance clock for unambiguous ordering).
vi.advanceTimersByTime(10)
runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
await vi.advanceTimersByTimeAsync(10)
await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
// A leaves, B leaves — final restore must use A's earliest baseline (150x40),
// NOT B's (which captured 45x20 when it joined a phone-fitted PTY).
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
runtime.handleMobileUnsubscribe('pty-1', 'phone-B')
vi.advanceTimersByTime(300)
await vi.advanceTimersByTimeAsync(300)
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
})

View File

@ -118,10 +118,13 @@ describe('mobile subscribe integration', () => {
vi.useRealTimers()
})
it('handleMobileSubscribe resizes PTY to phone dims', () => {
it('handleMobileSubscribe resizes PTY to phone dims', async () => {
const { runtime, ptySizes, resizes, notifications } = createRuntime()
const result = runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
const result = await runtime.handleMobileSubscribe('pty-1', 'client-a', {
cols: 45,
rows: 20
})
expect(result).toBe(true)
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
@ -130,83 +133,88 @@ describe('mobile subscribe integration', () => {
expect(runtime.isMobileSubscriberActive('pty-1')).toBe(true)
})
it('handleMobileSubscribe skips resize when mode is desktop', () => {
it('handleMobileSubscribe skips resize when mode is desktop', async () => {
const { runtime, ptySizes, resizes } = createRuntime()
runtime.setMobileDisplayMode('pty-1', 'desktop')
const result = runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
const result = await runtime.handleMobileSubscribe('pty-1', 'client-a', {
cols: 45,
rows: 20
})
expect(result).toBe(false)
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
expect(resizes).toEqual([])
})
it('handleMobileSubscribe skips resize when no viewport provided', () => {
it('handleMobileSubscribe skips resize when no viewport provided', async () => {
const { runtime, ptySizes, resizes } = createRuntime()
const result = runtime.handleMobileSubscribe('pty-1', 'client-a')
const result = await runtime.handleMobileSubscribe('pty-1', 'client-a')
expect(result).toBe(false)
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
expect(resizes).toEqual([])
})
it('handleMobileUnsubscribe restores PTY after 300ms debounce in auto mode', () => {
it('handleMobileUnsubscribe restores PTY after 300ms debounce in auto mode', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
// Not yet restored
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
vi.advanceTimersByTime(300)
await vi.advanceTimersByTimeAsync(300)
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
})
it('handleMobileUnsubscribe does not restore in phone mode', () => {
// Why: 'phone' (sticky-fit) mode was removed — there are now only 'auto'
// and 'desktop'. Auto-mode always restores on last unsubscribe. Test
// kept and inverted to lock in the new contract.
it('handleMobileUnsubscribe restores after auto-mode last unsubscribe', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.setMobileDisplayMode('pty-1', 'phone')
// In phone mode, handleMobileSubscribe still resizes because mode is 'phone'
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
// mode defaults to 'auto'
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
vi.advanceTimersByTime(1000)
// Still at phone dims — no restore for phone mode
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
await vi.advanceTimersByTimeAsync(1000)
// Restored to desktop dims — no sticky-phone retention.
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
})
// TODO: inline restore on re-subscribe not yet implemented
it.skip('re-subscribe within 300ms cancels debounce timer and inline-restores old PTY', () => {
it.skip('re-subscribe within 300ms cancels debounce timer and inline-restores old PTY', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
// Re-subscribe to a different terminal before the timer fires
vi.advanceTimersByTime(100)
runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
await vi.advanceTimersByTimeAsync(100)
await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
// pty-1 was inline-restored when pty-2 subscribed (timer cancelled + immediate restore)
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
expect(ptySizes.get('pty-2')).toEqual({ cols: 45, rows: 20 })
// Advancing past the 300ms debounce should not cause a second restore
vi.advanceTimersByTime(300)
await vi.advanceTimersByTimeAsync(300)
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
})
// TODO: inline restore on re-subscribe not yet implemented
it.skip('rapid A→B→C tab navigation: inline restore of A when B subscribes', () => {
it.skip('rapid A→B→C tab navigation: inline restore of A when B subscribes', async () => {
const { runtime, ptySizes } = createRuntime()
// Subscribe to A
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
// Unsubscribe A, subscribe B — A's pending timer is cancelled, A gets inline restore
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
// pty-1 should be restored inline (not waiting for timer)
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
@ -214,41 +222,41 @@ describe('mobile subscribe integration', () => {
// Unsubscribe B, subscribe C — B's pending timer cancelled, B gets inline restore
runtime.handleMobileUnsubscribe('pty-2', 'client-a')
runtime.handleMobileSubscribe('pty-3', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-3', 'client-a', { cols: 45, rows: 20 })
expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 })
expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 })
// Verify final state
vi.advanceTimersByTime(1000)
await vi.advanceTimersByTimeAsync(1000)
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 })
expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 })
})
it('preserves previousDims across re-subscribes to same terminal', () => {
it('preserves previousDims across re-subscribes to same terminal', async () => {
const { runtime, ptySizes } = createRuntime()
// First subscribe at desktop 150x40
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
// Re-subscribe to the same terminal (e.g., after reconnect)
// The PTY is already at 45x20, but previousDims should still be 150x40
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
// Unsubscribe and let restore fire
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
vi.advanceTimersByTime(300)
await vi.advanceTimersByTimeAsync(300)
// Should restore to original desktop dims, not 45x20
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
})
it('clamps viewport to valid range', () => {
it('clamps viewport to valid range', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 10, rows: 3 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 10, rows: 3 })
// Should clamp to minimum 20x8
expect(ptySizes.get('pty-1')).toEqual({ cols: 20, rows: 8 })
})
@ -261,9 +269,6 @@ describe('mobile subscribe integration', () => {
it('set/get round-trip', () => {
const { runtime } = createRuntime()
runtime.setMobileDisplayMode('pty-1', 'phone')
expect(runtime.getMobileDisplayMode('pty-1')).toBe('phone')
runtime.setMobileDisplayMode('pty-1', 'desktop')
expect(runtime.getMobileDisplayMode('pty-1')).toBe('desktop')
@ -274,16 +279,16 @@ describe('mobile subscribe integration', () => {
})
describe('applyMobileDisplayMode', () => {
it('desktop mode restores PTY when currently phone-fitted', () => {
it('desktop mode restores PTY when currently phone-fitted', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
const resizeEvents: unknown[] = []
runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event))
runtime.setMobileDisplayMode('pty-1', 'desktop')
runtime.applyMobileDisplayMode('pty-1')
await runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
expect(resizeEvents).toHaveLength(1)
@ -291,17 +296,17 @@ describe('mobile subscribe integration', () => {
cols: 150,
rows: 40,
displayMode: 'desktop',
reason: 'mode-change'
reason: 'apply-layout'
})
})
it('auto mode re-fits PTY when subscriber exists and not phone-fitted', () => {
it('auto mode re-fits PTY when subscriber exists and not phone-fitted', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
// Switch to desktop (restores to 150x40)
runtime.setMobileDisplayMode('pty-1', 'desktop')
runtime.applyMobileDisplayMode('pty-1')
await runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
const resizeEvents: unknown[] = []
@ -309,25 +314,27 @@ describe('mobile subscribe integration', () => {
// Switch back to auto (should re-fit to phone dims)
runtime.setMobileDisplayMode('pty-1', 'auto')
runtime.applyMobileDisplayMode('pty-1')
await runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
expect(resizeEvents).toHaveLength(1)
expect(resizeEvents[0]).toMatchObject({
displayMode: 'auto',
reason: 'mode-change'
displayMode: 'phone',
reason: 'apply-layout'
})
})
})
describe('cleanup paths', () => {
it('onClientDisconnected restores all PTYs immediately (no debounce)', () => {
it('onClientDisconnected restores all PTYs immediately (no debounce)', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
runtime.onClientDisconnected('client-a')
// onClientDisconnected enqueues fire-and-forget; flush microtasks + 0ms timers.
await vi.advanceTimersByTimeAsync(0)
// Both PTYs restored immediately
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
@ -336,22 +343,22 @@ describe('mobile subscribe integration', () => {
expect(runtime.isMobileSubscriberActive('pty-2')).toBe(false)
})
it('onClientDisconnected cancels pending restore timers', () => {
it('onClientDisconnected cancels pending restore timers', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
// Timer is pending
runtime.onClientDisconnected('client-a')
// Timer should be cancelled, PTY already restored by disconnect handler
vi.advanceTimersByTime(1000)
await vi.advanceTimersByTimeAsync(1000)
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
})
it('onPtyExit cleans up mobileSubscribers and pending timers', () => {
it('onPtyExit cleans up mobileSubscribers and pending timers', async () => {
const { runtime } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
// Timer pending for pty-1
@ -360,31 +367,31 @@ describe('mobile subscribe integration', () => {
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
// Timer should have been cancelled — no crash from resizing a dead PTY
vi.advanceTimersByTime(1000)
await vi.advanceTimersByTimeAsync(1000)
})
it('onPtyExit does not cancel timers for other PTYs', () => {
it('onPtyExit does not cancel timers for other PTYs', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
// pty-2 exits — should not affect pty-1's pending restore
runtime.onPtyExit('pty-2', 0)
vi.advanceTimersByTime(300)
await vi.advanceTimersByTimeAsync(300)
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
})
})
describe('resize listener system', () => {
it('subscribe/unsubscribe lifecycle', () => {
it('subscribe/unsubscribe lifecycle', async () => {
const { runtime } = createRuntime()
const events: unknown[] = []
const unsubscribe = runtime.subscribeToTerminalResize('pty-1', (e) => events.push(e))
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
runtime.setMobileDisplayMode('pty-1', 'desktop')
runtime.applyMobileDisplayMode('pty-1')
await runtime.applyMobileDisplayMode('pty-1')
expect(events.length).toBeGreaterThan(0)
@ -393,36 +400,41 @@ describe('mobile subscribe integration', () => {
// After unsubscribe, no more events
runtime.setMobileDisplayMode('pty-1', 'auto')
runtime.applyMobileDisplayMode('pty-1')
await runtime.applyMobileDisplayMode('pty-1')
expect(events.length).toBe(countBefore)
})
})
describe('onExternalPtyResize', () => {
it('updates previousCols when desktop renderer resizes PTY after desktop restore', () => {
it('updates previousCols when desktop renderer resizes PTY after desktop restore', async () => {
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
// Toggle to desktop — restores to previousCols (150x40)
runtime.setMobileDisplayMode('pty-1', 'desktop')
runtime.applyMobileDisplayMode('pty-1')
await runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
// Phone→desktop arms the 500ms renderer-cascade suppress window per
// docs/mobile-terminal-layout-state-machine.md. Wait it out before the
// renderer's correcting fit is allowed to update lastRendererSizes.
await vi.advanceTimersByTimeAsync(500)
// Simulate desktop renderer's safeFit correcting to split-pane width
runtime.onExternalPtyResize('pty-1', 105, 40)
// Toggle back to auto — should capture previousCols=105 (not 150)
runtime.setMobileDisplayMode('pty-1', 'auto')
runtime.applyMobileDisplayMode('pty-1')
await runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
// Toggle to desktop again — should restore to 105 (the corrected value)
runtime.setMobileDisplayMode('pty-1', 'desktop')
runtime.applyMobileDisplayMode('pty-1')
await runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 })
})
it('uses lastRendererSize for previousCols on first subscribe', () => {
it('uses lastRendererSize for previousCols on first subscribe', async () => {
const { runtime, ptySizes } = createRuntime()
// Simulate: PTY spawned at 214 (ptySizes), but renderer already fit to 105
@ -430,41 +442,46 @@ describe('mobile subscribe integration', () => {
runtime.onExternalPtyResize('pty-1', 105, 40)
// First mobile subscribe — should use rendererSize (105) not ptySizes (214)
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
// Toggle to desktop — should restore to 105, not 214
runtime.setMobileDisplayMode('pty-1', 'desktop')
runtime.applyMobileDisplayMode('pty-1')
await runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 })
})
it('does not update previousCols when PTY is phone-fitted', () => {
it('refreshes baseline on phone-fitted subscribers when their baseline is non-null', async () => {
// Behavior change per docs/mobile-terminal-layout-state-machine.md:
// legacy `!wasResizedToPhone` gate is replaced with `previousCols != null`.
// A phone-fitted subscriber's baseline IS non-null (captured at subscribe),
// so onExternalPtyResize now overwrites it with the renderer's reported geometry.
const { runtime, ptySizes } = createRuntime()
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
// PTY is phone-fitted (wasResizedToPhone=true) — external resize should not
// overwrite previousCols with phone dims
// Renderer reports 45x20 — under the new design, this overwrites baseline
// (previously it was skipped because the subscriber was phone-fitted).
runtime.onExternalPtyResize('pty-1', 45, 20)
// Toggle to desktop — should still restore to original 150x40
// Toggle to desktop — restore lands on what the renderer reported (45x20),
// not the original 150x40.
runtime.setMobileDisplayMode('pty-1', 'desktop')
runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
await runtime.applyMobileDisplayMode('pty-1')
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
})
})
describe('backward compatibility', () => {
it('old resizeForClient still works alongside new system', () => {
it('old resizeForClient still works alongside new system', async () => {
const { runtime, ptySizes } = createRuntime()
// Old flow: explicit resizeForClient
const fitResult = runtime.resizeForClient('pty-1', 'mobile-fit', 'client-old', 45, 20)
const fitResult = await runtime.resizeForClient('pty-1', 'mobile-fit', 'client-old', 45, 20)
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
expect(fitResult.mode).toBe('mobile-fit')
// Old flow: restore
const restoreResult = runtime.resizeForClient('pty-1', 'restore', 'client-old')
const restoreResult = await runtime.resizeForClient('pty-1', 'restore', 'client-old')
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
expect(restoreResult.mode).toBe('desktop-fit')
})

File diff suppressed because it is too large Load Diff

View File

@ -128,7 +128,12 @@ const TerminalSubscribe = TerminalHandle.extend({
})
const TerminalSetDisplayMode = TerminalHandle.extend({
mode: z.enum(['auto', 'phone', 'desktop']),
// Why: 'phone' was previously a "stay at phone dims after unsubscribe"
// mode that the toggle UI never produced and nothing in product
// depended on. Removed in favor of two clean modes: 'auto' (mobile
// drives dims while subscribed, desktop restores on last-leave) and
// 'desktop' (no resize, mobile scales the wide canvas down to fit).
mode: z.enum(['auto', 'desktop']),
// Why: identifies the caller for the driver state machine. Optional for
// backward compatibility with older mobile clients.
client: z
@ -136,6 +141,17 @@ const TerminalSetDisplayMode = TerminalHandle.extend({
id: requiredString('Missing client ID'),
type: z.enum(['mobile', 'desktop']).default('desktop').optional()
})
.optional(),
// Why: subscribers that registered before viewport was measured have
// a null viewport on their record. Toggling to 'auto' would no-op
// because applyMobileDisplayMode skips phone-fit when viewport is
// missing. Allow the toggle to carry the latest measured viewport so
// the server can store it on the subscriber record before fitting.
viewport: z
.object({
cols: z.number().int().positive(),
rows: z.number().int().positive()
})
.optional()
})
@ -217,14 +233,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
// Why: deliberate mobile input is a take-floor action. Drives the
// `* → mobile{clientId}` driver transition so the desktop banner
// remounts (if previously reclaimed) and active phone-fit dims follow
// the most recent actor. Only mobile-typed callers take the floor;
// desktop callers (CLI / agents) do not. Older mobile builds without
// a `client` field continue to work — the runtime then keeps the
// current driver state.
// the most recent actor. Only mobile-typed callers take the floor.
if (params.client && params.client.type === 'mobile') {
const leaf = runtime.resolveLeafForHandle(params.terminal)
if (leaf?.ptyId) {
runtime.mobileTookFloor(leaf.ptyId, params.client.id)
await runtime.mobileTookFloor(leaf.ptyId, params.client.id)
}
}
return { send: result }
@ -273,7 +286,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (!leaf?.ptyId) {
throw new Error('no_connected_pty')
}
const result = runtime.resizeForClient(
const result = await runtime.resizeForClient(
leaf.ptyId,
params.mode,
params.clientId,
@ -310,16 +323,22 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (!leaf?.ptyId) {
throw new Error('no_connected_pty')
}
// Why: late-bind viewport for callers that subscribed in desktop
// mode (no viewport stored). Without this, a 'auto' toggle on a
// viewport-less record skips phone-fit and the user sees no resize.
if (params.viewport && params.client?.id) {
runtime.updateMobileSubscriberViewport(leaf.ptyId, params.client.id, params.viewport)
}
runtime.setMobileDisplayMode(leaf.ptyId, params.mode)
runtime.applyMobileDisplayMode(leaf.ptyId)
await runtime.applyMobileDisplayMode(leaf.ptyId)
// Why: a deliberate mobile mode change is a take-floor action when
// moving to auto/phone (the user explicitly chose to drive at phone
// dims). Setting mode to desktop is intentionally NOT a take-floor
// action — that's a "watch from desktop dims" gesture.
if (params.client && params.client.type === 'mobile' && params.mode !== 'desktop') {
runtime.mobileTookFloor(leaf.ptyId, params.client.id)
await runtime.mobileTookFloor(leaf.ptyId, params.client.id)
}
return { mode: params.mode }
return { mode: params.mode, seq: runtime.getLayout(leaf.ptyId)?.seq }
}
}),
defineMethod({
@ -340,8 +359,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (!leaf?.ptyId) {
throw new Error('no_connected_pty')
}
const updated = runtime.updateMobileViewport(leaf.ptyId, params.client.id, params.viewport)
return { updated }
const updated = await runtime.updateMobileViewport(
leaf.ptyId,
params.client.id,
params.viewport
)
return { updated, seq: runtime.getLayout(leaf.ptyId)?.seq }
}
}),
// Why: terminal.subscribe streams live terminal output over WebSocket.
@ -384,9 +407,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const ptyId = leaf.ptyId
const clientId = params.client?.id
console.log('[fit][server] terminal.subscribe', {
ptyId: ptyId.slice(-8),
clientId: clientId?.slice(-8),
isMobile,
viewport: params.viewport
})
// Server-side auto-fit: resize PTY to phone dims before serializing scrollback
if (isMobile && clientId) {
runtime.handleMobileSubscribe(ptyId, clientId, params.viewport)
await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport)
}
const read = await runtime.readTerminal(params.terminal)
@ -395,6 +425,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
})
const size = runtime.getTerminalSize(ptyId)
const displayMode = runtime.getMobileDisplayMode(ptyId)
// Why: emit the current layout seq with the initial scrollback so
// the mobile client's stale-event filter knows the high-water mark.
// Undefined when the PTY has never transitioned (filter is fail-open).
// See docs/mobile-terminal-layout-state-machine.md.
const seq = runtime.getLayout(ptyId)?.seq
emit({
type: 'scrollback',
lines: read.tail,
@ -402,7 +437,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
serialized: serialized?.data,
cols: serialized?.cols ?? size?.cols,
rows: serialized?.rows ?? size?.rows,
displayMode
displayMode,
seq
})
await new Promise<void>((resolve) => {
@ -426,7 +462,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
rows: event.rows,
serialized: fresh?.data,
displayMode: event.displayMode,
reason: event.reason
reason: event.reason,
seq: event.seq
})
})