diff --git a/docs/mobile-presence-lock.md b/docs/mobile-presence-lock.md index 3aaf8f7fe..12584a51d 100644 --- a/docs/mobile-presence-lock.md +++ b/docs/mobile-presence-lock.md @@ -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. diff --git a/mobile/app.json b/mobile/app.json index e4a8ffd0b..4a36126f8 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -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", diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 342077407..ff45db42f 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -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 } -const STATUS_LABELS: Record = { - 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[] = [ @@ -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(null) const closeHostClient = useCloseHost() const forceReconnectHost = useForceReconnect() @@ -705,33 +690,45 @@ export default function HostScreen() { router.back()}> - - - - {hostName || 'Host'} - - - {connState !== 'connected' && - (() => { - const status = getStatusDisplay(connState, reconnectAttempts) - const showReconnectButton = status.isError && hostId && connState !== 'auth-failed' - return ( - - - {status.label} + {(() => { + const headerVerdict = classifyConnection({ + state: connState, + reconnectAttempts, + lastConnectedAt + }) + return ( + <> + + + + {hostName || 'Host'} - {showReconnectButton && ( - void forceReconnectHost(hostId!)} - hitSlop={8} - > - Reconnect - - )} - ) - })()} + {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 ( + void forceReconnectHost(hostId!)} + hitSlop={8} + > + Reconnect + + ) + })()} + + ) + })()} {/* 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, diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 196a2f9fa..80d333426 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -163,6 +163,13 @@ export default function SessionScreen() { const webReadyHandlesRef = useRef>(new Set()) const activeHandleRef = useRef(null) const subscribeSeqRef = useRef>(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>(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 + // 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] diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index c5c4579c6..d25d4b04b 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -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 = { - 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(null) const [hostStates, setHostStates] = useState>({}) const [hostAttempts, setHostAttempts] = useState>({}) + const [hostLastConnected, setHostLastConnected] = useState>({}) const [stats, setStats] = useState(null) const [worktreeInfo, setWorktreeInfo] = useState>({}) const [accountsByHost, setAccountsByHost] = useState>({}) @@ -342,6 +318,18 @@ export default function HomeScreen() { } return changed ? next : prev }) + setHostLastConnected((prev) => { + const next: Record = { ...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 = { ...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 ( [styles.hostCard, pressed && styles.hostCardPressed]} @@ -663,11 +641,9 @@ export default function HomeScreen() { {item.name} - - - {status.label} + + + {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() { - ) : hosts.length > 0 && resumeLoading ? ( - <> - Resume - - - - - - - - ) : 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', diff --git a/mobile/src/components/StatusDot.tsx b/mobile/src/components/StatusDot.tsx index 86f36f450..a29b5e95e 100644 --- a/mobile/src/components/StatusDot.tsx +++ b/mobile/src/components/StatusDot.tsx @@ -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 = { connected: colors.statusGreen, @@ -11,8 +12,25 @@ const stateColors: Record = { 'auth-failed': colors.statusRed } -export function StatusDot({ state }: { state: ConnectionState }) { - return +// 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 } const styles = StyleSheet.create({ diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 9766354f1..13262fd2d 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -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 } type Props = { @@ -82,14 +87,49 @@ const XTERM_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 = ` } } - // 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 = ` 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 = ` 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 = ` 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 = ` 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 = ` } } - 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 = ` } 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 = ` }); 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(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 | null>(null) + const readyResolveRef = useRef<(() => void) | null>(null) const sendToWebView = useCallback((msg: TerminalMessage) => { messageIdRef.current += 1 @@ -592,6 +730,15 @@ export const TerminalWebView = forwardRef(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(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(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((resolve) => { + readyResolveRef.current = resolve + }) postMessage({ type: 'init', cols, rows, initialData }) }, clear() { @@ -642,6 +802,14 @@ export const TerminalWebView = forwardRef(function }, resetZoom() { postMessage({ type: 'reset-zoom' }) + }, + async awaitReady(): Promise { + // 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((resolve) => setTimeout(resolve, 3000))]) } }), [postMessage, sendToWebView] diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 176b80355..8807c4c33 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -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 +} diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts new file mode 100644 index 000000000..b9524150b --- /dev/null +++ b/mobile/src/transport/connection-health.ts @@ -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.' +} diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 770494cfe..8321c2bf1 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -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 | null = null let activityProbeTimer: ReturnType | 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 { 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 (300ms–3s) = 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() + 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() + 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 { + 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) diff --git a/src/main/runtime/fit-override-integration.test.ts b/src/main/runtime/fit-override-integration.test.ts index a8e7e2ba8..6bd09a750 100644 --- a/src/main/runtime/fit-override-integration.test.ts +++ b/src/main/runtime/fit-override-integration.test.ts @@ -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() }) diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index eea0cb885..bf474ec8d 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -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 }) }) diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index fd344c615..5c6d7a895 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -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') }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e23980750..2500573d3 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -344,6 +344,39 @@ export type DriverState = | { kind: 'desktop' } | { kind: 'mobile'; clientId: string } +// Why: per-PTY layout target — what the PTY *should* be at right now. +// `desktop` ⇒ runs at the desktop renderer's pane geometry; mobile passive +// watchers (mode='desktop') still receive scrollback. `phone` ⇒ runs at +// `ownerClientId`'s viewport; the desktop renderer's auto-fit is suppressed. +// See docs/mobile-terminal-layout-state-machine.md. +export type PtyLayoutTarget = + | { kind: 'desktop'; cols: number; rows: number } + | { kind: 'phone'; cols: number; rows: number; ownerClientId: string } + +// Why: authoritative layout state with monotonic seq. Bumped on every +// applyLayout success; emitted on mobile subscribe-stream events so clients +// drop stale events that arrive after a newer transition. +export type PtyLayoutState = PtyLayoutTarget & { + seq: number + appliedAt: number +} + +// Why: applyLayout result discriminator. Callers (especially RPC handlers) +// need to distinguish "shipped a new state at seq N" from "no-op — caller +// should not claim a seq it didn't produce." `pty-exited` is terminal; +// `resize-failed` is transient and the caller may retry. +export type ApplyLayoutResult = + | { ok: true; state: PtyLayoutState } + | { ok: false; reason: 'pty-exited' | 'resize-failed' } + +type LayoutQueueEntry = { + running: Promise | null + pending: { + target: PtyLayoutTarget + waiters: ((r: ApplyLayoutResult) => void)[] + }[] +} + export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() @@ -413,10 +446,13 @@ export class OrcaRuntimeService { } >() - // Why: server-authoritative display mode per terminal. 'auto' (default) means - // phone-fit when mobile subscribes, desktop otherwise. 'phone'/'desktop' lock - // the mode regardless of subscriber state. In-memory only — modes reset on restart. - private mobileDisplayModes = new Map() + // Why: server-authoritative display mode per terminal. 'auto' (default) + // means phone-fit when mobile subscribes, desktop otherwise. 'desktop' + // locks to no-resize regardless of subscriber state. The third historical + // value ('phone' = sticky phone-fit after unsubscribe) was removed since + // the toggle UI never produced it and nothing in product depended on it. + // In-memory only — modes reset on restart. + private mobileDisplayModes = new Map() // Why: tracks active mobile subscribers per PTY so the runtime can restore // desktop dimensions on unsubscribe and prevent orphaned overrides during @@ -514,11 +550,46 @@ export class OrcaRuntimeService { // Why: inline resize events replace the unsubscribe→resubscribe pattern. // Listeners are notified when mode changes or desktop restores, allowing // the subscribe stream to emit a 'resized' event with fresh scrollback. + // `seq` is the layout state-machine sequence number bumped on every + // applyLayout success; mobile clients use it to drop stale events that + // arrive after a newer transition. See docs/mobile-terminal-layout-state-machine.md. private resizeListeners = new Map< string, - Set<(event: { cols: number; rows: number; displayMode: string; reason: string }) => void> + Set< + (event: { + cols: number + rows: number + displayMode: string + reason: string + seq?: number + }) => void + > >() + // Why: per-PTY layout state machine. `applyLayout` is the sole writer of + // `layouts`, `terminalFitOverrides`, and `ptyController.resize`; every + // trigger method routes through `enqueueLayout`. The monotonic `seq` is + // emitted on the mobile subscribe stream so clients can drop stale events. + // See docs/mobile-terminal-layout-state-machine.md. + private layouts = new Map() + + // Why: per-PTY async serialization queue for applyLayout. Without + // serialization, two concurrent triggers can interleave around the + // ptyController.resize await and bump seq in the wrong order, defeating + // seq-as-truth. Coalesces same-kind same-owner viewport ticks so the + // keyboard-show/hide animation doesn't queue 10+ resizes; mode flips, + // take-floor, and different-owner targets always append (preserves + // multi-mobile fairness). See docs/mobile-terminal-layout-state-machine.md + // "enqueueLayout coalescing". + private layoutQueues = new Map() + + // Why: gate so enqueueLayout's "no layouts entry" short-circuit doesn't + // fire on the very first transition for a PTY (where the entry doesn't + // exist yet *because* we're about to create it). `handleMobileSubscribe` + // adds the ptyId before calling enqueueLayout and removes it after the + // call resolves. + private freshSubscribeGuard = new Set() + private stats: StatsCollector | null = null // Why (§3.3 + §7.1): the renderer-create path and coordinator // `probeWorktreeDrift` share this cache so a create that already fetched @@ -1312,19 +1383,23 @@ export class OrcaRuntimeService { // ─── Mobile Fit Override Management ───────────────────────── - resizeForClient( + // Why: legacy mobile RPC entrypoint. After the state-machine rewrite this + // is a thin shim that computes a `PtyLayoutTarget` and routes through + // `enqueueLayout`. Keeps the same observable return shape so older mobile + // builds continue to work. See docs/mobile-terminal-layout-state-machine.md. + async resizeForClient( ptyId: string, mode: 'mobile-fit' | 'restore', clientId: string, cols?: number, rows?: number - ): { + ): Promise<{ cols: number rows: number previousCols: number | null previousRows: number | null mode: 'mobile-fit' | 'desktop-fit' - } { + }> { if (mode === 'mobile-fit') { if (cols == null || rows == null || !Number.isFinite(cols) || !Number.isFinite(rows)) { throw new Error('invalid_dimensions') @@ -1334,34 +1409,44 @@ export class OrcaRuntimeService { const currentSize = this.getTerminalSize(ptyId) const existing = this.terminalFitOverrides.get(ptyId) - // Why: preserve the original desktop size from before any mobile-fit, - // so restore returns to the right dimensions even after multiple re-fits. + // Capture baseline cols/rows for the return value (existing override's + // baseline wins over current size to preserve original desktop dims + // across multiple re-fits). const previousCols = existing?.previousCols ?? currentSize?.cols ?? null const previousRows = existing?.previousRows ?? currentSize?.rows ?? null - this.terminalFitOverrides.set(ptyId, { - mode: 'mobile-fit', - cols: clampedCols, - rows: clampedRows, - previousCols, - previousRows, - updatedAt: Date.now(), - clientId - }) + // Why: legacy resizeForClient callers bypass handleMobileSubscribe, so + // mobileSubscribers stays empty and resolveDesktopRestoreTarget's step-1 + // (per-subscriber baseline) never matches. Stash the pre-fit PTY size + // into lastRendererSizes so restore lands on step 2 (renderer geometry) + // instead of step 3 (current phone-fit dims = no-op restore). + if (currentSize && !existing) { + this.lastRendererSizes.set(ptyId, { + cols: currentSize.cols, + rows: currentSize.rows + }) + } - const resized = this.ptyController?.resize?.(ptyId, clampedCols, clampedRows) - if (!resized) { - this.terminalFitOverrides.delete(ptyId) + this.freshSubscribeGuard.add(ptyId) + let result: ApplyLayoutResult + try { + result = await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: clientId + }) + } finally { + this.freshSubscribeGuard.delete(ptyId) + } + if (!result.ok) { throw new Error('resize_failed') } - this.resizeHeadlessTerminal(ptyId, clampedCols, clampedRows) - - this.notifier?.terminalFitOverrideChanged(ptyId, 'mobile-fit', clampedCols, clampedRows) // Why: mobile-fit via resizeForClient is a deliberate mobile action; - // the actor takes the floor. mobileTookFloor updates the actor's - // lastActedAt and re-applies phone-fit if previously in desktop mode. - this.mobileTookFloor(ptyId, clientId) + // the actor takes the floor (updates lastActedAt; mode-flip case is + // already handled by enqueueLayout above). + await this.mobileTookFloor(ptyId, clientId) return { cols: clampedCols, @@ -1377,39 +1462,30 @@ export class OrcaRuntimeService { if (!override) { throw new Error('no_active_override') } - // Why: only the owning client can restore, preventing one phone from - // undoing another phone's active fit. + // Only the owning client can restore — prevents one phone from undoing + // another phone's active fit. if (override.clientId !== clientId) { throw new Error('not_override_owner') } - const { previousCols: prevCols, previousRows: prevRows } = override - this.terminalFitOverrides.delete(ptyId) - - // Why: always resize the PTY back to pre-fit dimensions immediately, - // even for mounted leaves. Relying solely on the renderer chain - // (IPC notification → safeFit → fitAddon.fit → onResize → transport.resize) - // is fragile — any async gap leaves the PTY at phone dims while xterm - // looks correct, causing text to wrap at the wrong column. The renderer - // will still run safeFit and may send a second resize with the exact - // current pane geometry, which is harmless (SIGWINCH is idempotent). - if (prevCols != null && prevRows != null) { - this.ptyController?.resize?.(ptyId, prevCols, prevRows) - this.resizeHeadlessTerminal(ptyId, prevCols, prevRows) + const restore = this.resolveDesktopRestoreTarget(ptyId) + const result = await this.enqueueLayout(ptyId, { + kind: 'desktop', + cols: restore.cols, + rows: restore.rows + }) + if (!result.ok) { + throw new Error('resize_failed') } - // Why: send the restored dimensions so the renderer can fall back to a - // direct terminal.resize() if fitAddon.fit() silently fails. The renderer - // normally computes desktop dims from the container, but passing them here - // provides a guaranteed fallback to avoid leaving xterm at phone dims. - this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', prevCols ?? 0, prevRows ?? 0) - // Why: mobile clients subscribed to this terminal need to know the desktop - // restored, so they can update their UI (clear fitted state, resubscribe). - this.notifyFitOverrideListeners(ptyId, 'desktop-fit', prevCols ?? 0, prevRows ?? 0) + // Why: legacy mobile clients on the resizeForClient path also need a + // fit-override-listener notification (the renderer-side terminalFitOverrideChanged + // is already emitted by applyLayout's mode-flip path). + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', restore.cols, restore.rows) return { - cols: prevCols ?? 0, - rows: prevRows ?? 0, + cols: restore.cols, + rows: restore.rows, previousCols: null, previousRows: null, mode: 'desktop-fit' @@ -1429,23 +1505,24 @@ export class OrcaRuntimeService { } onClientDisconnected(clientId: string): void { - // Cancel all pending restore timers for this client — the client is gone, - // so the debounce is meaningless and could fire against a stale PTY state. + // (1) Cancel pending restore-debounce timers owned by this client. for (const [ptyId, entry] of this.pendingRestoreTimers) { if (entry.clientId === clientId) { clearTimeout(entry.timer) this.pendingRestoreTimers.delete(ptyId) } } - // Why: if the disconnecting client was in soft-leave grace, the grace - // is meaningless now (the client is gone for real). Promote each - // matching grace into immediate finalization: restore PTY dims to the - // captured baseline, drop driver to idle, and clear fit overrides. - // Without this, a phone that exited the screen (router.back → WS - // close) would leave the PTY stuck at phone dims forever — the soft - // grace held the inner-map empty so the mobileSubscribers loop below - // can't see it, and the 300ms restore timer could mis-fire after the - // grace if the PTY had already been mutated. + + // (2) Promote any soft-leave grace owned by this client into immediate + // finalization. Grace existed to absorb a quick re-subscribe; a real + // disconnect kills any chance of re-subscribe. + // + // Note: this is mode-decoupled (matches docs/mobile-terminal-layout-state-machine.md + // sub-case 2). Today's pre-rewrite code only restored when + // `mode === 'auto' && wasResizedToPhone`; the new design restores + // whenever the layout is currently `phone`. This is an intentional + // behavior fix — `mode === 'phone'` with no subscribers is a degenerate + // state nothing in product depends on. for (const [ptyId, soft] of this.pendingSoftLeavers) { if (soft.clientId !== clientId) { continue @@ -1453,94 +1530,112 @@ export class OrcaRuntimeService { clearTimeout(soft.timer) this.pendingSoftLeavers.delete(ptyId) - // Cancel any in-flight 300ms restore timer too — we'll do it now. + // Cancel any in-flight 300ms restore timer too — we'll handle it inline. const pending = this.pendingRestoreTimers.get(ptyId) if (pending) { clearTimeout(pending.timer) this.pendingRestoreTimers.delete(ptyId) } - const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' - const { previousCols, previousRows, wasResizedToPhone } = soft.record - if (mode === 'auto' && wasResizedToPhone) { - const fallback = this.lastRendererSizes.get(ptyId) - const cols = previousCols ?? fallback?.cols ?? null - const rows = previousRows ?? fallback?.rows ?? null - if (cols != null && rows != null) { - this.ptyController?.resize?.(ptyId, cols, rows) - this.resizeHeadlessTerminal(ptyId, cols, rows) - } - this.lastRendererSizes.delete(ptyId) - this.suppressResizesForMs(500) - this.terminalFitOverrides.delete(ptyId) - this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', cols ?? 0, rows ?? 0) - this.notifyFitOverrideListeners(ptyId, 'desktop-fit', cols ?? 0, rows ?? 0) + const cur = this.layouts.get(ptyId) + if (cur?.kind === 'phone') { + // Use the soft-leaver's snapshot baseline as a hint, falling + // through to resolveDesktopRestoreTarget for missing values. + const fallback = this.resolveDesktopRestoreTarget(ptyId) + const cols = soft.record.previousCols ?? fallback.cols + const rows = soft.record.previousRows ?? fallback.rows + void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) } this.setDriver(ptyId, { kind: 'idle' }) } - // Immediately restore PTYs that this client had phone-fitted (no debounce — - // client is gone, no point waiting for a re-subscribe that won't come). - // With the multi-mobile rekey, only the disconnecting client's record is - // removed from the inner map; peer mobile clients keep the floor and the - // banner stays mounted. + // (3) Immediate restore for PTYs where this client was the last + // mobile subscriber. With multi-mobile, peer subscribers keep the + // floor; only when the inner map empties do we transition to desktop. const ptysWithSurvivingPeers: string[] = [] + const ptysToRestore: { ptyId: string; baseline: { cols: number; rows: number } | null }[] = [] for (const [ptyId, inner] of this.mobileSubscribers) { const subscriber = inner.get(clientId) if (!subscriber) { continue } - const wasResizedToPhone = subscriber.wasResizedToPhone - const { previousCols, previousRows } = subscriber + // Snapshot baseline before deleting — needed once mobileSubscribers + // entry is gone for the resolveDesktopRestoreTarget chain. + const baseline = + subscriber.previousCols != null && subscriber.previousRows != null + ? { cols: subscriber.previousCols, rows: subscriber.previousRows } + : null inner.delete(clientId) if (inner.size > 0) { ptysWithSurvivingPeers.push(ptyId) - continue + } else { + this.mobileSubscribers.delete(ptyId) + ptysToRestore.push({ ptyId, baseline }) } - this.mobileSubscribers.delete(ptyId) - if (wasResizedToPhone) { - if (previousCols != null && previousRows != null) { - this.ptyController?.resize?.(ptyId, previousCols, previousRows) - this.resizeHeadlessTerminal(ptyId, previousCols, previousRows) - } - this.terminalFitOverrides.delete(ptyId) - this.notifier?.terminalFitOverrideChanged( - ptyId, - 'desktop-fit', - previousCols ?? 0, - previousRows ?? 0 - ) - this.notifyFitOverrideListeners(ptyId, 'desktop-fit', previousCols ?? 0, previousRows ?? 0) + } + for (const { ptyId, baseline } of ptysToRestore) { + const cur = this.layouts.get(ptyId) + if (cur?.kind === 'phone') { + const fallback = this.resolveDesktopRestoreTarget(ptyId) + const cols = baseline?.cols ?? fallback.cols + const rows = baseline?.rows ?? fallback.rows + void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) } this.setDriver(ptyId, { kind: 'idle' }) } - // Why: if peers survived but the disconnecting client was the active - // driver, re-elect the most-recent surviving subscriber as the driver - // and re-fit if needed. This keeps the lock/dim-selection invariant. + + // (4) Driver re-election where peers survived. If the disconnecting + // client was the active driver, the most-recent surviving actor takes + // the floor. for (const ptyId of ptysWithSurvivingPeers) { const driver = this.getDriver(ptyId) - if (driver.kind === 'mobile' && driver.clientId === clientId) { - const inner = this.mobileSubscribers.get(ptyId) - const next = inner ? this.pickMostRecentActor(inner) : null - if (next) { - this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) - this.applyMobileDisplayMode(ptyId) - } + if (driver.kind !== 'mobile' || driver.clientId !== clientId) { + continue } + const inner = this.mobileSubscribers.get(ptyId) + const next = inner ? this.pickMostRecentActor(inner) : null + if (!next) { + continue + } + this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) + + const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + if (mode === 'desktop') { + continue + } + const nextSub = inner!.get(next.clientId) + const nextViewport = nextSub?.viewport + if (!nextViewport) { + continue + } + void this.enqueueLayout(ptyId, { + kind: 'phone', + cols: nextViewport.cols, + rows: nextViewport.rows, + ownerClientId: next.clientId + }) } - // Legacy cleanup for any terminalFitOverrides not covered by mobileSubscribers + // (5) Legacy-callers fallback. Older mobile builds use resizeForClient + // directly and never populate mobileSubscribers. For those PTYs the + // override carries the owning clientId; restore the layout when the + // owner disconnects. resolveDesktopRestoreTarget reads lastRendererSizes + // (which the legacy mobile-fit branch stashes the pre-fit size into). for (const [ptyId, override] of this.terminalFitOverrides) { if (override.clientId !== clientId) { continue } - try { - this.resizeForClient(ptyId, 'restore', clientId) - } catch { - this.terminalFitOverrides.delete(ptyId) - this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) - this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) + if (this.mobileSubscribers.has(ptyId)) { + continue } + const cur = this.layouts.get(ptyId) + if (cur?.kind !== 'phone') { + continue + } + const fallback = this.resolveDesktopRestoreTarget(ptyId) + const cols = override.previousCols ?? fallback.cols + const rows = override.previousRows ?? fallback.rows + void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) } } @@ -1550,6 +1645,13 @@ export class OrcaRuntimeService { this.mobileDisplayModes.delete(ptyId) this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) + // Layout state machine: clear `layouts` and `layoutQueues`. Any + // already-queued applyLayout work for this ptyId will run, but every + // applyLayout re-checks `layouts.has(ptyId)` (or fresh-subscribe) and + // short-circuits with `pty-exited`. + this.layouts.delete(ptyId) + this.layoutQueues.delete(ptyId) + this.freshSubscribeGuard.delete(ptyId) const pendingRestore = this.pendingRestoreTimers.get(ptyId) if (pendingRestore) { clearTimeout(pendingRestore.timer) @@ -1621,11 +1723,11 @@ export class OrcaRuntimeService { } // Why: invoked from mobile RPC method handlers (terminal.send / setDisplayMode / - // resizeForClient / fresh subscribe with auto/phone). Records the actor as - // the most recent mobile driver and re-applies phone-fit if we were previously + // resizeForClient / fresh subscribe with auto). Records the actor as the + // most recent mobile driver and re-applies phone-fit if we were previously // in `desktop` mode (mobile reclaims a take-back). Mobile-to-mobile hand-offs // are no-ops for resize. - mobileTookFloor(ptyId: string, clientId: string): void { + async mobileTookFloor(ptyId: string, clientId: string): Promise { const inner = this.mobileSubscribers.get(ptyId) const sub = inner?.get(clientId) if (sub) { @@ -1635,17 +1737,13 @@ export class OrcaRuntimeService { const currentMode = this.mobileDisplayModes.get(ptyId) // Why: a deliberate mobile action implies mobile is resuming control. // If the display mode is currently 'desktop' (set by an earlier - // take-back), flip it back to 'auto' and re-apply so phone-fit takes - // hold again. Without flipping the mode, applyMobileDisplayMode would - // take the desktop branch and leave the PTY at desktop dims while the - // driver says `mobile`. The same path also covers the case where the - // driver flipped to `desktop` and we're returning to mobile control. - // See docs/mobile-presence-lock.md. + // take-back), flip it back to 'auto' (= map absence) and re-apply so + // phone-fit takes hold again. See docs/mobile-presence-lock.md. if (prev.kind === 'desktop' || currentMode === 'desktop') { - if (currentMode === 'desktop' || currentMode === undefined) { - this.mobileDisplayModes.set(ptyId, 'auto') + if (currentMode === 'desktop') { + this.mobileDisplayModes.delete(ptyId) } - this.applyMobileDisplayMode(ptyId) + await this.applyMobileDisplayMode(ptyId) } this.setDriver(ptyId, { kind: 'mobile', clientId }) } @@ -1660,11 +1758,11 @@ export class OrcaRuntimeService { // subscribe to capture the already-phone-fitted PTY size as its // restore baseline (stuck-dim bug on later disconnect). // No-op when the client isn't actually subscribed to this PTY. - updateMobileViewport( + async updateMobileViewport( ptyId: string, clientId: string, viewport: { cols: number; rows: number } - ): boolean { + ): Promise { const inner = this.mobileSubscribers.get(ptyId) const sub = inner?.get(clientId) if (!sub) { @@ -1688,39 +1786,17 @@ export class OrcaRuntimeService { const clampedCols = Math.max(20, Math.min(240, Math.round(driveViewport.cols))) const clampedRows = Math.max(8, Math.min(120, Math.round(driveViewport.rows))) - const currentSize = this.getTerminalSize(ptyId) - const alreadyAtTarget = currentSize?.cols === clampedCols && currentSize?.rows === clampedRows - if (!alreadyAtTarget) { - this.ptyController?.resize?.(ptyId, clampedCols, clampedRows) - this.resizeHeadlessTerminal(ptyId, clampedCols, clampedRows) - } - sub.wasResizedToPhone = true - this.terminalFitOverrides.set(ptyId, { - mode: 'mobile-fit', - cols: clampedCols, - rows: clampedRows, - previousCols: sub.previousCols, - previousRows: sub.previousRows, - updatedAt: Date.now(), - clientId - }) - this.notifier?.terminalFitOverrideChanged(ptyId, 'mobile-fit', clampedCols, clampedRows) - - // Why: emit a 'resized' event on the mobile subscription stream so the - // mobile xterm reinits inline at the new dims — same shape as a - // setDisplayMode-triggered resize, so the existing client-side handler - // path applies without changes. - this.notifyTerminalResize(ptyId, { - cols: clampedCols, - rows: clampedRows, - displayMode: mode, - reason: 'viewport-update' - }) - - // The driver is already mobile{this client} when we got here; refresh it + // The driver is already mobile{this client} when we got here; refresh // to update lastActedAt-based ordering on later actor selection. this.setDriver(ptyId, { kind: 'mobile', clientId }) + + await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: winner.clientId + }) return true } @@ -1728,12 +1804,12 @@ export class OrcaRuntimeService { // back" button). Forces the PTY back to desktop dims and flips the driver // to `desktop`, suppressing further mobile-driven dim changes until a // mobile actor takes the floor again. - reclaimTerminalForDesktop(ptyId: string): boolean { + async reclaimTerminalForDesktop(ptyId: string): Promise { if (!this.isMobileSubscriberActive(ptyId)) { return false } this.setMobileDisplayMode(ptyId, 'desktop') - this.applyMobileDisplayMode(ptyId) + await this.applyMobileDisplayMode(ptyId) this.setDriver(ptyId, { kind: 'desktop' }) return true } @@ -1779,9 +1855,249 @@ export class OrcaRuntimeService { return best ? { previousCols: best.previousCols, previousRows: best.previousRows } : null } + // ─── Layout state machine ───────────────────────────────────────── + // + // See docs/mobile-terminal-layout-state-machine.md. + // + // applyLayout is the SOLE writer of: + // - this.layouts + // - this.terminalFitOverrides + // - this.ptyController.resize (i.e. the actual PTY dims) + // + // Every trigger that wants to change PTY dims or flip mode goes through + // enqueueLayout, which serializes calls behind a per-PTY async queue + // (the await on ptyController.resize would otherwise let seq bumps reach + // the wire out of order). + + getLayout(ptyId: string): PtyLayoutState | null { + return this.layouts.get(ptyId) ?? null + } + + // Why: `enqueueLayout`'s "no layouts entry" short-circuit must not fire + // on the very first transition for a PTY (where the entry doesn't exist + // yet *because* we're about to create it). handleMobileSubscribe adds + // the ptyId to `freshSubscribeGuard` before calling enqueueLayout and + // removes it in a finally block. + private isFreshSubscribe(ptyId: string): boolean { + return this.freshSubscribeGuard.has(ptyId) + } + + // Why: four-step fallback chain for desktop-restore targets. Always + // returns a value; the terminal {80,24} branch is reached only under + // bug. Wrapping the chain as a single helper prevents callsite drift. + private resolveDesktopRestoreTarget(ptyId: string): { cols: number; rows: number } { + // 1. Earliest-by-subscribedAt subscriber with non-null baseline. + const inner = this.mobileSubscribers.get(ptyId) + if (inner) { + const earliest = this.pickEarliestRestoreTarget(inner) + if (earliest) { + return { cols: earliest.previousCols, rows: earliest.previousRows } + } + } + // 2. Most-recent desktop renderer geometry report. + const renderer = this.lastRendererSizes.get(ptyId) + if (renderer) { + return { cols: renderer.cols, rows: renderer.rows } + } + // 3. Current PTY size. + const size = this.getTerminalSize(ptyId) + if (size) { + return { cols: size.cols, rows: size.rows } + } + // 4. Hard default. + return { cols: 80, rows: 24 } + } + + // Why: a new viewport-only update from the same owner supersedes a + // queued same-shape tail. Mode flips, owner changes, and take-back + // append (losing a take-floor to a viewport tick would be a fairness + // hole — see "enqueueLayout coalescing" in the design doc). + private coalescesWith(prev: PtyLayoutTarget, next: PtyLayoutTarget): boolean { + if (prev.kind !== next.kind) { + return false + } + if (prev.kind === 'phone' && next.kind === 'phone') { + return prev.ownerClientId === next.ownerClientId + } + return true + } + + private enqueueLayout(ptyId: string, target: PtyLayoutTarget): Promise { + // Why: PTY-exit short-circuit. Fresh-subscribe gate lets the very first + // transition through even though `layouts` has no entry yet. + if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { + return Promise.resolve({ ok: false, reason: 'pty-exited' }) + } + + let entry = this.layoutQueues.get(ptyId) + if (!entry) { + entry = { running: null, pending: [] } + this.layoutQueues.set(ptyId, entry) + } + const queue = entry + + return new Promise((resolve) => { + if (!queue.running) { + queue.running = this.runLayoutSlot(ptyId, target, [resolve]) + return + } + const tail = queue.pending.at(-1) + if (tail && this.coalescesWith(tail.target, target)) { + tail.target = target + tail.waiters.push(resolve) + return + } + queue.pending.push({ target, waiters: [resolve] }) + }) + } + + private async runLayoutSlot( + ptyId: string, + target: PtyLayoutTarget, + waiters: ((r: ApplyLayoutResult) => void)[] + ): Promise { + let result: ApplyLayoutResult + try { + result = await this.applyLayout(ptyId, target) + } catch (err) { + // Why: defensive — applyLayout itself catches resize errors, but a + // throw from one of the synchronous map writes (e.g. notifier hook) + // must not jam the queue forever. + console.error('[layout] applyLayout threw', { ptyId, err }) + result = { ok: false, reason: 'resize-failed' } + } + for (const w of waiters) { + w(result) + } + + const queue = this.layoutQueues.get(ptyId) + if (!queue) { + return result + } + const next = queue.pending.shift() + if (next) { + queue.running = this.runLayoutSlot(ptyId, next.target, next.waiters) + } else { + queue.running = null + // Why: drop the entry once empty so the map doesn't grow without bound + // across short-lived PTYs. + this.layoutQueues.delete(ptyId) + } + return result + } + + private async applyLayout(ptyId: string, target: PtyLayoutTarget): Promise { + // Why: re-check pty-exit at the head of the slot — the queue may have + // accepted this target before onPtyExit ran. + if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { + return { ok: false, reason: 'pty-exited' } + } + + const prev = this.layouts.get(ptyId) ?? null + const seq = (prev?.seq ?? 0) + 1 + const next: PtyLayoutState = { ...target, seq, appliedAt: Date.now() } + + const currentSize = this.getTerminalSize(ptyId) + const dimsChanged = currentSize?.cols !== target.cols || currentSize?.rows !== target.rows + const modeChanged = (prev?.kind ?? 'desktop') !== target.kind + + // Snapshot for rollback. + const prevFitOverride = this.terminalFitOverrides.get(ptyId) ?? null + + // Tentative writes — the resize is the point of no return. + this.layouts.set(ptyId, next) + if (target.kind === 'phone') { + // Why: pull baseline cols+rows atomically from the same subscriber so + // they can't desync. + const baseline = (() => { + const inner = this.mobileSubscribers.get(ptyId) + if (!inner) { + return null + } + return this.pickEarliestRestoreTarget(inner) + })() + this.terminalFitOverrides.set(ptyId, { + mode: 'mobile-fit', + cols: target.cols, + rows: target.rows, + previousCols: baseline?.previousCols ?? null, + previousRows: baseline?.previousRows ?? null, + updatedAt: next.appliedAt, + clientId: target.ownerClientId + }) + } else { + this.terminalFitOverrides.delete(ptyId) + } + + if (dimsChanged) { + let ok = false + try { + const r = this.ptyController?.resize?.(ptyId, target.cols, target.rows) + ok = r ?? true + } catch (err) { + console.error('[layout] ptyController.resize threw', { ptyId, err }) + ok = false + } + if (!ok) { + // Roll back to pre-call snapshot. seq is NOT bumped on the wire + // because we never emit below. + if (prev) { + this.layouts.set(ptyId, prev) + } else { + this.layouts.delete(ptyId) + } + if (prevFitOverride) { + this.terminalFitOverrides.set(ptyId, prevFitOverride) + } else { + this.terminalFitOverrides.delete(ptyId) + } + return { ok: false, reason: 'resize-failed' } + } + this.resizeHeadlessTerminal(ptyId, target.cols, target.rows) + } + + // Why: emit fit-override-changed only when the *mode* flips. Layouts + // can change dims without flipping mode (keyboard show/hide while + // phone), and waking the renderer on every viewport tick is wasteful + // churn. + if (modeChanged) { + // Why: phone→desktop arms the renderer-cascade suppress window + // before the collateral safeFit IPCs arrive. See "Renderer cascade + // suppression". + if (target.kind === 'desktop') { + this.lastRendererSizes.delete(ptyId) + this.suppressResizesForMs(500) + } + this.notifier?.terminalFitOverrideChanged( + ptyId, + target.kind === 'phone' ? 'mobile-fit' : 'desktop-fit', + target.cols, + target.rows + ) + this.notifyFitOverrideListeners( + ptyId, + target.kind === 'phone' ? 'mobile-fit' : 'desktop-fit', + target.cols, + target.rows + ) + } + + // Mobile-facing event always fires (phone clients need to re-fit on + // every dim change, not just mode flips). + this.notifyTerminalResize(ptyId, { + cols: target.cols, + rows: target.rows, + displayMode: target.kind === 'phone' ? 'phone' : 'desktop', + reason: 'apply-layout', + seq + }) + + return { ok: true, state: next } + } + // ─── Server-Authoritative Mobile Display Mode ───────────────────── - setMobileDisplayMode(ptyId: string, mode: 'auto' | 'phone' | 'desktop'): void { + setMobileDisplayMode(ptyId: string, mode: 'auto' | 'desktop'): void { if (mode === 'auto') { this.mobileDisplayModes.delete(ptyId) } else { @@ -1789,7 +2105,7 @@ export class OrcaRuntimeService { } } - getMobileDisplayMode(ptyId: string): 'auto' | 'phone' | 'desktop' { + getMobileDisplayMode(ptyId: string): 'auto' | 'desktop' { return this.mobileDisplayModes.get(ptyId) ?? 'auto' } @@ -1798,6 +2114,33 @@ export class OrcaRuntimeService { return inner !== undefined && inner.size > 0 } + // Why: late-bind viewport on an existing subscriber record. Subscribers + // that registered before the mobile side measured (e.g. terminal first + // mounted while the WebView was still loading) have null viewport, and + // applyMobileDisplayMode's auto branch needs a viewport to phone-fit. + // The setDisplayMode RPC carries the latest viewport so we can patch it + // here just before applyMobileDisplayMode runs. + updateMobileSubscriberViewport( + ptyId: string, + clientId: string, + viewport: { cols: number; rows: number } + ): void { + const inner = this.mobileSubscribers.get(ptyId) + const record = inner?.get(clientId) + console.log('[fit][server] updateMobileSubscriberViewport', { + ptyId: ptyId.slice(-8), + clientId: clientId.slice(-8), + viewport, + hasInner: !!inner, + innerSize: inner?.size ?? 0, + hasRecord: !!record + }) + if (!record) { + return + } + record.viewport = viewport + } + // Why: server-side auto-fit on mobile subscribe. The runtime is the single // source of truth — the mobile client just passes its viewport and the runtime // decides whether to resize. This eliminates the measure→RPC→resubscribe @@ -1811,32 +2154,40 @@ export class OrcaRuntimeService { // a passive watch; it does NOT take the floor. The driver remains // `idle`/`desktop`. The lock banner is reserved for actual mobile // interaction (input/resize/setDisplayMode/auto-or-phone subscribe). - handleMobileSubscribe( + async handleMobileSubscribe( ptyId: string, clientId: string, viewport?: { cols: number; rows: number } - ): boolean { + ): Promise { const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' + const currentSize0 = this.getTerminalSize(ptyId) + console.log('[fit][server] handleMobileSubscribe', { + ptyId: ptyId.slice(-8), + clientId: clientId.slice(-8), + viewport, + mode, + currentSize: currentSize0, + hadInner: this.mobileSubscribers.has(ptyId) + }) if (!viewport) { + console.log('[fit][server] handleMobileSubscribe NO_VIEWPORT — skipping fit') return false } - // Why: cancel ALL pending restore timers for this ptyId on any new - // subscribe — the timer is keyed by ptyId+old-clientId but with the - // multi-mobile rekey, "any new subscriber" supersedes "any old client's - // restore". Without this, A unsub → B sub within 300ms could fire A's - // timer and snap PTY to desktop dims while B is meant to drive. + // Cancel pending restore timer for this ptyId — any new subscriber + // supersedes any old client's pending restore. const pendingRestore = this.pendingRestoreTimers.get(ptyId) if (pendingRestore) { clearTimeout(pendingRestore.timer) this.pendingRestoreTimers.delete(ptyId) } - // Why: resubscribe-grace honor. If the same client just unsubscribed - // within the soft-leave window, restore its prior record (preserving - // previousCols/Rows so we don't capture an already-phone-fitted PTY - // size as the new baseline). The driver state was kept at - // mobile{clientId} during the window, so no banner flash occurred. + const clampedCols = Math.max(20, Math.min(240, Math.round(viewport.cols))) + const clampedRows = Math.max(8, Math.min(120, Math.round(viewport.rows))) + + // Resubscribe-grace honor: same client returning within soft-leave + // window restores prior record (preserving baseline so we don't capture + // phone-fitted dims as the new baseline). const softLeaver = this.pendingSoftLeavers.get(ptyId) if (softLeaver && softLeaver.clientId === clientId) { clearTimeout(softLeaver.timer) @@ -1848,20 +2199,22 @@ export class OrcaRuntimeService { } inner.set(clientId, { ...softLeaver.record, - // Refresh viewport from the new subscribe payload — the keyboard - // state may have changed during the window. viewport, lastActedAt: Date.now() }) - // The driver was already mobile{clientId}; refresh to update - // listener wiring (and re-emit, harmless if unchanged). this.setDriver(ptyId, { kind: 'mobile', clientId }) - // If display-mode is auto/phone, reapply the fit at the new viewport - // so a keyboard show/hide that resubscribes (older clients) still - // updates dims correctly. updateMobileViewport is the preferred path - // and avoids the unsubscribe → subscribe cycle entirely. if (mode !== 'desktop') { - this.applyMobileDisplayMode(ptyId) + this.freshSubscribeGuard.add(ptyId) + try { + await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: clientId + }) + } finally { + this.freshSubscribeGuard.delete(ptyId) + } } return true } @@ -1872,18 +2225,9 @@ export class OrcaRuntimeService { this.mobileSubscribers.set(ptyId, inner) } - // Why: prefer lastRendererSizes (the actual pane geometry reported by the - // desktop renderer's safeFit via pty:resize IPC) over getTerminalSize (the - // server-side PTY size, which may be stale — e.g. 214 full-width when the - // pane is actually in a split at ~105). Fall back to existing subscriber's - // previousCols (re-subscribe case) then currentSize (first subscribe). - // - // Multi-mobile: if an existing subscriber on this PTY is already - // phone-fitted, the current PTY size is NOT a valid restore baseline for - // a *new* subscriber — it would point to a phone-fit dim, not the - // pre-mobile desktop size. Set previousCols/Rows to null so the new - // joiner is skipped from earliest-restore selection; the original - // subscriber's captured baseline remains the source of truth. See + // Capture restore baseline BEFORE applyLayout writes the override. + // Multi-mobile: peer joiner against an already-fitted PTY captures null + // — the existing baseline-holder's snapshot remains canonical. See // docs/mobile-presence-lock.md. const existing = inner.get(clientId) const someoneAlreadyFitted = [...inner.values()].some((s) => s.wasResizedToPhone) @@ -1899,11 +2243,9 @@ export class OrcaRuntimeService { const subscribedAt = existing?.subscribedAt ?? now if (mode === 'desktop') { - // Why: set previousCols/Rows to null so we don't capture a stale PTY - // size that may not match the actual pane geometry (e.g. 214 when the - // pane is in a split at 105). When the user later toggles to auto/phone, - // handleMobileSubscribe will capture currentSize at that point, which - // will be correct because safeFit has had time to adjust the PTY. + // Passive watch — null baseline (we'll capture later if user toggles + // to auto/phone, since safeFit will have converged by then). Do not + // flip driver. inner.set(clientId, { clientId, viewport, @@ -1913,8 +2255,6 @@ export class OrcaRuntimeService { subscribedAt, lastActedAt: now }) - // Subscribe-in-desktop-mode is passive: leave driver at idle/desktop. - // Do not transition to mobile{clientId}. return false } @@ -1928,32 +2268,34 @@ export class OrcaRuntimeService { lastActedAt: now }) - const clampedCols = Math.max(20, Math.min(240, Math.round(viewport.cols))) - const clampedRows = Math.max(8, Math.min(120, Math.round(viewport.rows))) + // Subscribe-fresh with auto/phone counts as "take the floor". + this.setDriver(ptyId, { kind: 'mobile', clientId }) - // Why: skip the PTY resize if already at the target dims. Re-subscribing - // to a terminal that was left at phone dims (no restore on tab switch) - // should not trigger another SIGWINCH → shell prompt redraw. - const alreadyAtTarget = currentSize?.cols === clampedCols && currentSize?.rows === clampedRows - if (!alreadyAtTarget) { - this.ptyController?.resize?.(ptyId, clampedCols, clampedRows) - this.resizeHeadlessTerminal(ptyId, clampedCols, clampedRows) - } - this.notifier?.terminalFitOverrideChanged(ptyId, 'mobile-fit', clampedCols, clampedRows) - - // Update terminalFitOverrides for desktop safeFit compatibility - this.terminalFitOverrides.set(ptyId, { - mode: 'mobile-fit', + // Route the actual resize through the state machine. The fresh-subscribe + // gate lets enqueueLayout's "no layouts entry" short-circuit pass on + // the very first transition for this PTY. + console.log('[fit][server] handleMobileSubscribe enqueueing phone fit', { + ptyId: ptyId.slice(-8), cols: clampedCols, rows: clampedRows, - previousCols, - previousRows, - updatedAt: Date.now(), - clientId + mode }) - - // Subscribe-fresh with auto/phone mode counts as "take the floor". - this.setDriver(ptyId, { kind: 'mobile', clientId }) + this.freshSubscribeGuard.add(ptyId) + try { + const result = await this.enqueueLayout(ptyId, { + kind: 'phone', + cols: clampedCols, + rows: clampedRows, + ownerClientId: clientId + }) + console.log('[fit][server] handleMobileSubscribe enqueue result', { + ptyId: ptyId.slice(-8), + result, + sizeAfter: this.getTerminalSize(ptyId) + }) + } finally { + this.freshSubscribeGuard.delete(ptyId) + } return true } @@ -1977,12 +2319,6 @@ export class OrcaRuntimeService { } const wasResizedToPhone = subscriber.wasResizedToPhone - // Why: snapshot the earliest-by-subscribe-time restore target BEFORE - // mutating the inner map. If the disconnecting client is the original - // baseline-holder, that information must survive into the last-leaver - // restore path even after their record is deleted. See - // docs/mobile-presence-lock.md "Restore-target selection". - const restoreTargetSnapshot = this.pickEarliestRestoreTarget(inner) inner.delete(clientId) if (inner.size > 0) { @@ -1990,9 +2326,7 @@ export class OrcaRuntimeService { // baseline (typical when peer joiners subscribed against an // already-phone-fitted PTY and got null prevCols), donate the baseline // to the earliest surviving subscriber so a future last-leaver can - // still restore correctly. Without this, A leaves first, B leaves - // last with null prevCols → no restore fires. See - // docs/mobile-presence-lock.md. + // still restore correctly. See docs/mobile-presence-lock.md. if ( subscriber.previousCols != null && subscriber.previousRows != null && @@ -2020,7 +2354,9 @@ export class OrcaRuntimeService { const next = this.pickMostRecentActor(inner) if (next) { this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) - this.applyMobileDisplayMode(ptyId) + // Fire-and-forget — handleMobileUnsubscribe stays sync; applyLayout + // failures self-recover on the next gesture. + void this.applyMobileDisplayMode(ptyId) } } return @@ -2030,13 +2366,9 @@ export class OrcaRuntimeService { this.mobileSubscribers.delete(ptyId) const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' - // Why: resubscribe-grace. Hold the driver=mobile{clientId} state and - // the leaving subscriber's record for ~250ms. If the same client - // re-subscribes in that window, handleMobileSubscribe cancels the - // pending-soft-leaver and re-inserts the record (preserving - // previousCols and avoiding a desktop-banner flash). Otherwise the - // grace timer fires, sets driver=idle, and lets the existing 300ms - // restore debounce (kept below) run as before. + // Resubscribe-grace: hold driver=mobile{clientId} for ~250ms so a quick + // re-subscribe (older clients without updateViewport) doesn't flash the + // desktop banner. See docs/mobile-presence-lock.md. const SOFT_LEAVE_GRACE_MS = 250 const existingSoft = this.pendingSoftLeavers.get(ptyId) if (existingSoft) { @@ -2045,7 +2377,6 @@ export class OrcaRuntimeService { } const softTimer = setTimeout(() => { this.pendingSoftLeavers.delete(ptyId) - // Why: only flip to idle if no peer has reclaimed in the meantime. if (!this.mobileSubscribers.has(ptyId)) { this.setDriver(ptyId, { kind: 'idle' }) } @@ -2065,45 +2396,36 @@ export class OrcaRuntimeService { }) if (mode === 'auto' && wasResizedToPhone) { - const existing = this.pendingRestoreTimers.get(ptyId) - if (existing) { - clearTimeout(existing.timer) + const existingTimer = this.pendingRestoreTimers.get(ptyId) + if (existingTimer) { + clearTimeout(existingTimer.timer) } - - // Restore target: earliest-by-subscribe-time among non-null - // previousCols/Rows captured BEFORE deletion. Falls back to the - // disconnecting subscriber's own dims and finally lastRendererSizes - // (matches the existing first-insert capture path). + // Snapshot the disconnecting subscriber's baseline NOW, before the + // timer fires. By the time the timer runs (300ms later), the + // subscriber map has been deleted; resolveDesktopRestoreTarget would + // fall through to lastRendererSizes → current PTY size (which is at + // phone dims, wrong). The disconnecting subscriber's baseline is the + // correct restore target. const fallback = this.lastRendererSizes.get(ptyId) - const previousCols = - restoreTargetSnapshot?.previousCols ?? subscriber.previousCols ?? fallback?.cols ?? null - const previousRows = - restoreTargetSnapshot?.previousRows ?? subscriber.previousRows ?? fallback?.rows ?? null + const restoreCols = + subscriber.previousCols ?? fallback?.cols ?? this.getTerminalSize(ptyId)?.cols ?? 80 + const restoreRows = + subscriber.previousRows ?? fallback?.rows ?? this.getTerminalSize(ptyId)?.rows ?? 24 const timer = setTimeout(() => { this.pendingRestoreTimers.delete(ptyId) if (this.isMobileSubscriberActive(ptyId)) { return } - if (previousCols != null && previousRows != null) { - this.ptyController?.resize?.(ptyId, previousCols, previousRows) - this.resizeHeadlessTerminal(ptyId, previousCols, previousRows) - } - this.lastRendererSizes.delete(ptyId) - this.suppressResizesForMs(500) - this.terminalFitOverrides.delete(ptyId) - this.notifier?.terminalFitOverrideChanged( - ptyId, - 'desktop-fit', - previousCols ?? 0, - previousRows ?? 0 - ) - this.notifyFitOverrideListeners(ptyId, 'desktop-fit', previousCols ?? 0, previousRows ?? 0) + void this.enqueueLayout(ptyId, { + kind: 'desktop', + cols: restoreCols, + rows: restoreRows + }) }, 300) this.pendingRestoreTimers.set(ptyId, { timer, clientId }) } - // 'phone' mode: keep phone dims (no restore needed) - // 'desktop' mode: was never resized, nothing to restore + // 'desktop' mode: was never resized, nothing to restore. } // Why: called when mode changes via terminal.setDisplayMode. Applies the @@ -2113,70 +2435,76 @@ export class OrcaRuntimeService { // Multi-mobile: the most recent mobile actor's viewport drives the active // phone-fit dims. The earliest-by-subscribe-time subscriber's // previousCols/Rows drive the desktop-restore target. - applyMobileDisplayMode(ptyId: string): void { + async applyMobileDisplayMode(ptyId: string): Promise { const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto' const inner = this.mobileSubscribers.get(ptyId) const subscriber = inner ? this.pickMostRecentActor(inner) : null const subscriberRecord = subscriber && inner ? inner.get(subscriber.clientId) : null + console.log('[fit][server] applyMobileDisplayMode', { + ptyId: ptyId.slice(-8), + mode, + hasInner: !!inner, + innerSize: inner?.size ?? 0, + subscriberId: subscriber?.clientId.slice(-8), + hasRecord: !!subscriberRecord, + recordViewport: subscriberRecord?.viewport, + wasResizedToPhone: subscriberRecord?.wasResizedToPhone + }) if (mode === 'desktop') { - // Find the first subscriber (any clientId) that was previously - // phone-fitted, and reset its flag. The desktop-restore target uses - // earliest-by-subscribe-time among non-null prevCols/Rows. + // Reset wasResizedToPhone on every fitted subscriber so a future + // toggle back to auto re-issues the resize. applyLayout owns the + // actual PTY resize + override delete + renderer notify. + let anyWasResized = false if (inner) { - const restore = this.pickEarliestRestoreTarget(inner) - let anyWasResized = false for (const sub of inner.values()) { if (sub.wasResizedToPhone) { anyWasResized = true sub.wasResizedToPhone = false } } - if (anyWasResized && restore) { - this.ptyController?.resize?.(ptyId, restore.previousCols, restore.previousRows) - this.resizeHeadlessTerminal(ptyId, restore.previousCols, restore.previousRows) - // Why: clear stale renderer size so the next mobile subscribe falls - // through to currentSize (which is correct after the server restore). - // Without this, a polluted 214 from a prior collateral safeFit cascade - // persists in lastRendererSizes and gets used as previousCols. - this.lastRendererSizes.delete(ptyId) - // Why: 500ms not 200ms — the desktop renderer's collateral safeFit - // cascade (IPC → React re-render → rAF → DOM measure → IPC back) - // takes ~360ms to propagate to background-tab terminals. - this.suppressResizesForMs(500) - this.terminalFitOverrides.delete(ptyId) - this.notifier?.terminalFitOverrideChanged( - ptyId, - 'desktop-fit', - restore.previousCols, - restore.previousRows - ) - } } - const size = this.getTerminalSize(ptyId) - this.notifyTerminalResize(ptyId, { - cols: size?.cols ?? 0, - rows: size?.rows ?? 0, - displayMode: 'desktop', - reason: 'mode-change' - }) - } else if (mode === 'phone' || mode === 'auto') { + if (anyWasResized) { + const restore = this.resolveDesktopRestoreTarget(ptyId) + await this.enqueueLayout(ptyId, { + kind: 'desktop', + cols: restore.cols, + rows: restore.rows + }) + } else { + // No subscriber was fitted — emit a mode-change resize event so + // the mobile client still learns the toggle landed. + const size = this.getTerminalSize(ptyId) + this.notifyTerminalResize(ptyId, { + cols: size?.cols ?? 0, + rows: size?.rows ?? 0, + displayMode: 'desktop', + reason: 'mode-change', + seq: this.layouts.get(ptyId)?.seq + }) + } + } else { + // mode === 'auto' — the only non-desktop mode after the 'phone' + // (sticky-fit) collapse. Phone-fit if the active subscriber has a + // viewport and we haven't already applied it. if (subscriberRecord && !subscriberRecord.wasResizedToPhone) { const viewport = subscriberRecord.viewport if (viewport) { - this.handleMobileSubscribe(ptyId, subscriberRecord.clientId, viewport) + await this.handleMobileSubscribe(ptyId, subscriberRecord.clientId, viewport) + return } } - // Why: always emit the mode change even when no resize occurred (e.g. - // subscriber missing, wasResizedToPhone already true, or no viewport). - // Without this the mobile client never learns the mode changed and its - // toggle button gets stuck showing the old state. + // Why: always emit the mode change even when no resize occurred — the + // mobile client needs to learn the toggle landed even if dims didn't + // actually change. Carry the current seq (or undefined if no layout + // entry yet) so the mobile-side stale-event filter behaves correctly. const size = this.getTerminalSize(ptyId) this.notifyTerminalResize(ptyId, { cols: size?.cols ?? 0, rows: size?.rows ?? 0, - displayMode: mode, - reason: 'mode-change' + displayMode: 'auto', + reason: 'mode-change', + seq: this.layouts.get(ptyId)?.seq }) } } @@ -2185,18 +2513,25 @@ export class OrcaRuntimeService { // resizes a PTY (e.g. via safeFit after window resize, split, or desktop-mode // restore). Stores the renderer-reported size so handleMobileSubscribe can use // the actual pane geometry instead of a stale PTY size for previousCols. + // This is a passive geometry report — it does NOT call applyLayout; the + // PTY is already at the reported size. onExternalPtyResize(ptyId: string, cols: number, rows: number): void { + // The pty:resize IPC handler is supposed to gate via `isResizeSuppressed` + // before calling here, but defend against callers that don't. + if (this.isResizeSuppressed()) { + return + } this.lastRendererSizes.set(ptyId, { cols, rows }) const inner = this.mobileSubscribers.get(ptyId) if (!inner) { return } - // Capture the renderer-reported size as the next-restore target on any - // subscriber that hasn't yet been phone-fitted. Subscribers in - // wasResizedToPhone state already have a captured pre-fit baseline. + // Refresh the renderer-current size as the next-restore target on every + // subscriber that already has a non-null baseline. Subscribers with null + // baselines (joined while a peer had already phone-fitted) stay null. for (const sub of inner.values()) { - if (!sub.wasResizedToPhone) { + if (sub.previousCols != null && sub.previousRows != null) { sub.previousCols = cols sub.previousRows = rows } @@ -2216,7 +2551,13 @@ export class OrcaRuntimeService { subscribeToTerminalResize( ptyId: string, - listener: (event: { cols: number; rows: number; displayMode: string; reason: string }) => void + listener: (event: { + cols: number + rows: number + displayMode: string + reason: string + seq?: number + }) => void ): () => void { let listeners = this.resizeListeners.get(ptyId) if (!listeners) { @@ -2234,7 +2575,7 @@ export class OrcaRuntimeService { private notifyTerminalResize( ptyId: string, - event: { cols: number; rows: number; displayMode: string; reason: string } + event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } ): void { const listeners = this.resizeListeners.get(ptyId) if (!listeners) { diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 97c5224cc..da0cd8a9d 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -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((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 }) })