Mobile: server-authoritative phone-fit state machine + race fixes + UX cleanup (#1518)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
6630cf6295
commit
e83d92eede
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -33,9 +33,14 @@ import {
|
|||
useHostClient,
|
||||
useCloseHost,
|
||||
useForceReconnect,
|
||||
useReconnectAttempt
|
||||
useReconnectAttempt,
|
||||
useLastConnectedAt
|
||||
} from '../../../src/transport/client-context'
|
||||
import type { ConnectionState, RpcSuccess } from '../../../src/transport/types'
|
||||
import {
|
||||
classifyConnection,
|
||||
type ConnectionVerdict
|
||||
} from '../../../src/transport/connection-health'
|
||||
import type { RpcSuccess } from '../../../src/transport/types'
|
||||
import { triggerMediumImpact } from '../../../src/platform/haptics'
|
||||
import { StatusDot } from '../../../src/components/StatusDot'
|
||||
import { NewWorktreeModal } from '../../../src/components/NewWorktreeModal'
|
||||
|
|
@ -93,29 +98,8 @@ type FilterState = {
|
|||
selectedRepos: Set<string>
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<ConnectionState, string> = {
|
||||
connecting: 'Connecting…',
|
||||
handshaking: 'Securing…',
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
reconnecting: 'Reconnecting…',
|
||||
'auth-failed': 'Auth failed'
|
||||
}
|
||||
|
||||
// Why: same threshold as the home screen — kicks the label from
|
||||
// "Reconnecting…" to "Can't connect" once the rpc-client has cycled enough
|
||||
// times to indicate a real problem (wrong port, server down, network change).
|
||||
const RECONNECT_FAILURE_THRESHOLD = 3
|
||||
|
||||
function getStatusDisplay(
|
||||
state: ConnectionState,
|
||||
attempts: number
|
||||
): { label: string; isError: boolean } {
|
||||
if (state === 'auth-failed') return { label: 'Auth failed', isError: true }
|
||||
if (state === 'reconnecting' && attempts >= RECONNECT_FAILURE_THRESHOLD) {
|
||||
return { label: "Can't connect", isError: true }
|
||||
}
|
||||
return { label: STATUS_LABELS[state], isError: false }
|
||||
function isErrorVerdict(v: ConnectionVerdict): boolean {
|
||||
return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed'
|
||||
}
|
||||
|
||||
const SORT_OPTIONS: PickerOption<SortMode>[] = [
|
||||
|
|
@ -282,6 +266,7 @@ export default function HostScreen() {
|
|||
// docs/mobile-shared-client-per-host.md.
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const closeHostClient = useCloseHost()
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
|
|
@ -705,33 +690,45 @@ export default function HostScreen() {
|
|||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<View style={styles.hostIdentity}>
|
||||
<StatusDot state={connState} />
|
||||
<Text style={styles.hostNameText} numberOfLines={1}>
|
||||
{hostName || 'Host'}
|
||||
</Text>
|
||||
</View>
|
||||
{connState !== 'connected' &&
|
||||
(() => {
|
||||
const status = getStatusDisplay(connState, reconnectAttempts)
|
||||
const showReconnectButton = status.isError && hostId && connState !== 'auth-failed'
|
||||
return (
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={[styles.statusText, status.isError && { color: colors.statusRed }]}>
|
||||
{status.label}
|
||||
{(() => {
|
||||
const headerVerdict = classifyConnection({
|
||||
state: connState,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<View style={styles.hostIdentity}>
|
||||
<StatusDot state={connState} verdict={headerVerdict} />
|
||||
<Text style={styles.hostNameText} numberOfLines={1}>
|
||||
{hostName || 'Host'}
|
||||
</Text>
|
||||
{showReconnectButton && (
|
||||
<Pressable
|
||||
style={styles.reconnectButton}
|
||||
onPress={() => void forceReconnectHost(hostId!)}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Text style={styles.reconnectButtonText}>Reconnect</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
})()}
|
||||
{connState !== 'connected' &&
|
||||
(() => {
|
||||
// Why: status label removed in favor of just the dot +
|
||||
// Reconnect button — the home screen already surfaces the
|
||||
// verdict text per host, and the dot color already
|
||||
// signals severity here. Auth-failed routes through its
|
||||
// dedicated banner so we still want to suppress the
|
||||
// Reconnect button for that case.
|
||||
const verdict = headerVerdict
|
||||
const isError = isErrorVerdict(verdict)
|
||||
const showReconnectButton = isError && hostId && verdict.kind !== 'auth-failed'
|
||||
if (!showReconnectButton) return null
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.reconnectButton}
|
||||
onPress={() => void forceReconnectHost(hostId!)}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Text style={styles.reconnectButtonText}>Reconnect</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{/* Filter/sort/group toolbar */}
|
||||
|
|
@ -1182,15 +1179,6 @@ const styles = StyleSheet.create({
|
|||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
statusText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
reconnectButton: {
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
|
|
|
|||
|
|
@ -163,6 +163,13 @@ export default function SessionScreen() {
|
|||
const webReadyHandlesRef = useRef<Set<string>>(new Set())
|
||||
const activeHandleRef = useRef<string | null>(null)
|
||||
const subscribeSeqRef = useRef<Map<string, number>>(new Map())
|
||||
// Why: server-side layout state machine emits a monotonic seq on every
|
||||
// applyLayout. Track the highest seq we've observed per handle and drop
|
||||
// any scrollback/resized event with a strictly older seq — these are
|
||||
// late-arriving events from a superseded layout (e.g. phone-fit dims
|
||||
// landing after the user toggled to desktop). Drops below `>20`-window
|
||||
// gap reset (treat as a fresh subscription, e.g. server restart).
|
||||
const layoutSeqRef = useRef<Map<string, number>>(new Map())
|
||||
const sendingRef = useRef(false)
|
||||
// Why: tracks the pixel height of the terminal frame so measureFitDimensions
|
||||
// can use the exact container height instead of relying on window.innerHeight,
|
||||
|
|
@ -180,6 +187,10 @@ export default function SessionScreen() {
|
|||
terminalUnsubsRef.current.delete(handle)
|
||||
subscribingHandlesRef.current.delete(handle)
|
||||
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
|
||||
// Why: a fresh subscription will land on a new server-side state machine
|
||||
// run (or the same one with a higher seq); reset the high-water mark so
|
||||
// the first scrollback isn't accidentally dropped as stale.
|
||||
layoutSeqRef.current.delete(handle)
|
||||
}, [])
|
||||
|
||||
const clearTerminalCache = useCallback(() => {
|
||||
|
|
@ -191,6 +202,7 @@ export default function SessionScreen() {
|
|||
initializedHandlesRef.current.clear()
|
||||
webReadyHandlesRef.current.clear()
|
||||
subscribeSeqRef.current.clear()
|
||||
layoutSeqRef.current.clear()
|
||||
for (const term of terminalRefs.current.values()) {
|
||||
term.clear()
|
||||
}
|
||||
|
|
@ -226,6 +238,13 @@ export default function SessionScreen() {
|
|||
const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1
|
||||
subscribeSeqRef.current.set(handle, seq)
|
||||
|
||||
console.log('[fit][session] subscribe', {
|
||||
handle: handle.slice(-8),
|
||||
seq,
|
||||
viewport: viewportRef.current,
|
||||
viewportMeasured: viewportMeasuredRef.current
|
||||
})
|
||||
|
||||
// Why: server handles auto-fit on subscribe — no terminal.focus call needed.
|
||||
// The viewport is embedded in the subscribe params so the server resizes
|
||||
// the PTY before serializing scrollback. This eliminates the focus→safeFit
|
||||
|
|
@ -240,50 +259,168 @@ export default function SessionScreen() {
|
|||
(result) => {
|
||||
if (subscribeSeqRef.current.get(handle) !== seq) return
|
||||
const data = result as Record<string, unknown>
|
||||
// Why: stale-event filter. Server-side state machine bumps a
|
||||
// monotonic seq on every applyLayout. Drop `resized` events
|
||||
// whose seq is strictly older than what we've already observed
|
||||
// for this handle — they're late-arriving from a superseded
|
||||
// layout. `scrollback` is the response to a fresh subscribe,
|
||||
// so it always resets the high-water mark regardless of seq
|
||||
// (post-WS-reconnect or post-resubscribe the server may emit
|
||||
// scrollback at a seq lower than what we'd seen pre-reconnect;
|
||||
// dropping it would leave the user with a blank terminal).
|
||||
const eventSeq = typeof data.seq === 'number' ? data.seq : null
|
||||
if (eventSeq != null && data.type === 'resized') {
|
||||
const last = layoutSeqRef.current.get(handle)
|
||||
if (last != null && eventSeq < last && last - eventSeq <= 20) {
|
||||
console.log('[fit][session] DROP-stale-seq', {
|
||||
handle: handle.slice(-8),
|
||||
type: data.type,
|
||||
eventSeq,
|
||||
lastSeq: last,
|
||||
cols: data.cols,
|
||||
rows: data.rows,
|
||||
displayMode: data.displayMode
|
||||
})
|
||||
return
|
||||
}
|
||||
layoutSeqRef.current.set(handle, eventSeq)
|
||||
} else if (eventSeq != null && data.type === 'scrollback') {
|
||||
layoutSeqRef.current.set(handle, eventSeq)
|
||||
}
|
||||
if (data.type === 'scrollback' || data.type === 'resized') {
|
||||
console.log('[fit][session] event', {
|
||||
handle: handle.slice(-8),
|
||||
type: data.type,
|
||||
cols: data.cols,
|
||||
rows: data.rows,
|
||||
displayMode: data.displayMode,
|
||||
seq: eventSeq,
|
||||
reason: data.reason
|
||||
})
|
||||
}
|
||||
if (data.type === 'scrollback') {
|
||||
if (initializedHandlesRef.current.has(handle)) return
|
||||
if (initializedHandlesRef.current.has(handle)) {
|
||||
console.log('[fit][session] scrollback IGNORED (already initialized)', {
|
||||
handle: handle.slice(-8),
|
||||
cols: data.cols,
|
||||
rows: data.rows
|
||||
})
|
||||
return
|
||||
}
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
const scrollbackCols = cols
|
||||
const scrollbackRows = rows
|
||||
const initialData =
|
||||
typeof data.serialized === 'string' && data.serialized.length > 0
|
||||
? data.serialized
|
||||
: ''
|
||||
getTerminalRef(handle)?.init(cols, rows, initialData)
|
||||
const ref = getTerminalRef(handle)
|
||||
// Why: previously we set `initializedHandlesRef` even when the
|
||||
// WebView wasn't mounted yet (ref=null). The init message went
|
||||
// nowhere, but the flag stayed true, so any subsequent scrollback
|
||||
// for THIS handle was silently dropped → blank terminal. Only
|
||||
// mark initialized if init() actually reached the WebView.
|
||||
if (!ref) {
|
||||
console.log('[fit][session] scrollback DROPPED — no terminal ref', {
|
||||
handle: handle.slice(-8),
|
||||
cols,
|
||||
rows
|
||||
})
|
||||
return
|
||||
}
|
||||
ref.init(cols, rows, initialData)
|
||||
initializedHandlesRef.current.add(handle)
|
||||
if (data.displayMode) {
|
||||
setTerminalModes((prev) =>
|
||||
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
|
||||
)
|
||||
}
|
||||
// Why: cold-start fit-to-screen guard. The first init() runs
|
||||
// before xterm's DOM/canvas has fully laid out, so the
|
||||
// applyFitScale that init queues internally can land while
|
||||
// term.element.scrollWidth is still stale or zero — leaving
|
||||
// the terminal un-zoomed until the user toggles the resize
|
||||
// button. Re-fire resetZoom after a short delay so it runs
|
||||
// against a settled DOM. Mirrors the 'resized' handler below.
|
||||
// Why: belt-and-suspenders cold-start fit. The applyFitScale
|
||||
// queued by init() runs after writes drain, but on cold start
|
||||
// xterm's scrollWidth can still be transient when it commits.
|
||||
// Re-fire after a short delay so it runs against a settled DOM.
|
||||
// Mirrors the 'resized' handler below.
|
||||
setTimeout(() => getTerminalRef(handle)?.resetZoom(), 200)
|
||||
// Why: viewport measurement needs xterm to be initialized (cell
|
||||
// dimensions come from the renderer). On the first subscribe the
|
||||
// WebView hasn't loaded yet, so viewportRef is null and the server
|
||||
// can't auto-fit. After the first init we can measure, then
|
||||
// resubscribe so the server gets the viewport and phone-fits.
|
||||
if (!viewportMeasuredRef.current) {
|
||||
// If viewport was measured by a parallel path BUT the scrollback
|
||||
// we just received came back at desktop dims, our subscribe
|
||||
// beat the measure; the server still has a null viewport for
|
||||
// this subscriber record — resubscribe so it gets stored.
|
||||
const needsResubscribe =
|
||||
!viewportMeasuredRef.current ||
|
||||
(viewportRef.current != null &&
|
||||
(scrollbackCols !== viewportRef.current.cols ||
|
||||
scrollbackRows !== viewportRef.current.rows))
|
||||
if (needsResubscribe) {
|
||||
void (async () => {
|
||||
console.log('[fit][session] post-scrollback measure-start', {
|
||||
handle: handle.slice(-8),
|
||||
containerHeight: terminalFrameHeightRef.current
|
||||
})
|
||||
// Why: wait for the WebView's init() rAF chain to fully
|
||||
// run (term.open → renderService population → first
|
||||
// paint) before measuring. Without this, the measure
|
||||
// postMessage races ahead of init's async work and
|
||||
// returns null (term not ready / cells size 0), the
|
||||
// resubscribe never fires, and the server never gets
|
||||
// phone dims. See log dump 2026-05-06 confirming the
|
||||
// race + measure-result null pattern.
|
||||
await getTerminalRef(handle)?.awaitReady()
|
||||
const dims = await getTerminalRef(handle)?.measureFitDimensions(
|
||||
terminalFrameHeightRef.current || undefined
|
||||
)
|
||||
if (dims && !viewportMeasuredRef.current) {
|
||||
// Why: we just got `scrollback` with cols=80 (server's
|
||||
// default fallback for null viewport). That means the
|
||||
// server-side subscriber record was registered before we
|
||||
// could send viewport. Even if `viewportMeasuredRef`
|
||||
// raced ahead via a parallel `measureViewportOnce`, the
|
||||
// server still has a null viewport for THIS subscriber
|
||||
// record — we MUST resubscribe so the server stores it.
|
||||
console.log('[fit][session] post-scrollback measure-result', {
|
||||
handle: handle.slice(-8),
|
||||
dims,
|
||||
alreadyMeasured: viewportMeasuredRef.current
|
||||
})
|
||||
if (dims) {
|
||||
viewportRef.current = dims
|
||||
viewportMeasuredRef.current = true
|
||||
unsubscribeTerminal(handle)
|
||||
initializedHandlesRef.current.delete(handle)
|
||||
console.log('[fit][session] post-scrollback re-subscribe', {
|
||||
handle: handle.slice(-8),
|
||||
viewport: dims
|
||||
})
|
||||
subscribeToTerminal(handle)
|
||||
}
|
||||
})()
|
||||
}
|
||||
} else if (data.type === 'data') {
|
||||
getTerminalRef(handle)?.write(data.chunk as string)
|
||||
// Why: log when data arrives but the WebView ref is missing
|
||||
// — this is the most likely cause of "blank but input works":
|
||||
// server stream is alive, sends flow, but writes are dropped
|
||||
// because the WebView ref disappeared (unmount mid-flight) or
|
||||
// the scrollback never landed (so xterm has no buffer).
|
||||
const dataRef = getTerminalRef(handle)
|
||||
if (!dataRef) {
|
||||
console.log('[fit][session] data DROPPED — no terminal ref', {
|
||||
handle: handle.slice(-8),
|
||||
chunkLen: typeof data.chunk === 'string' ? data.chunk.length : 0,
|
||||
initialized: initializedHandlesRef.current.has(handle)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!initializedHandlesRef.current.has(handle)) {
|
||||
console.log('[fit][session] data RECEIVED before scrollback', {
|
||||
handle: handle.slice(-8),
|
||||
chunkLen: typeof data.chunk === 'string' ? data.chunk.length : 0
|
||||
})
|
||||
}
|
||||
dataRef.write(data.chunk as string)
|
||||
} else if (data.type === 'resized') {
|
||||
// Why: inline resize event — the server changed the PTY dimensions
|
||||
// (mode toggle or desktop restore). Reinitialize xterm at the new
|
||||
|
|
@ -324,17 +461,29 @@ export default function SessionScreen() {
|
|||
if (!client) return
|
||||
if (toggleInFlightRef.current.has(handle)) return
|
||||
const current = terminalModes.get(handle) ?? 'auto'
|
||||
const next: MobileDisplayMode = current === 'auto' || current === 'phone' ? 'desktop' : 'auto'
|
||||
// Why: 'phone' on the wire is an observation ("currently phone-fitted"),
|
||||
// not a setting. The toggle only ever requests 'auto' or 'desktop'.
|
||||
const next: 'auto' | 'desktop' =
|
||||
current === 'auto' || current === 'phone' ? 'desktop' : 'auto'
|
||||
console.log('[fit][session] toggleDisplayMode', {
|
||||
handle: handle.slice(-8),
|
||||
current,
|
||||
next
|
||||
})
|
||||
toggleInFlightRef.current.add(handle)
|
||||
try {
|
||||
await client.sendRequest('terminal.setDisplayMode', {
|
||||
terminal: handle,
|
||||
mode: next,
|
||||
// Why: presence-lock take-floor signal. Sending mode=auto/phone
|
||||
// is a deliberate "I want to drive at phone dims" gesture.
|
||||
// Why: presence-lock take-floor signal — requesting 'auto' is the
|
||||
// explicit "I want to drive at phone dims" gesture.
|
||||
...(deviceTokenRef.current
|
||||
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
|
||||
: {})
|
||||
: {}),
|
||||
// Why: late-bind viewport for terminals whose subscribe record
|
||||
// was registered before measurement landed. Without this the
|
||||
// server's stored viewport is null and auto toggles no-op.
|
||||
...(viewportRef.current && next === 'auto' ? { viewport: viewportRef.current } : {})
|
||||
})
|
||||
} catch {
|
||||
// Mode change failed — server state unchanged, UI stays in sync.
|
||||
|
|
@ -666,6 +815,7 @@ export default function SessionScreen() {
|
|||
// scrollback snapshot. Only resubscribe if this is a reload — on
|
||||
// first load the subscription is already running and pendingMessages
|
||||
// will flush the queued init after this callback returns.
|
||||
// (unsubscribeTerminal also clears layoutSeqRef for this handle.)
|
||||
unsubscribeTerminal(handle)
|
||||
initializedHandlesRef.current.delete(handle)
|
||||
if (handle === activeHandleRef.current) {
|
||||
|
|
@ -676,10 +826,18 @@ export default function SessionScreen() {
|
|||
// Why: on first web-ready, the initial subscribeToTerminal call from
|
||||
// fetchTerminals may have been skipped (reason=no-ref, WebView wasn't
|
||||
// mounted yet). Now that the WebView is ready, subscribe if this is the
|
||||
// active terminal and no subscription is running.
|
||||
// active terminal and no subscription is running. Await measure before
|
||||
// subscribe so the very first subscribe carries the viewport — without
|
||||
// this, subscribe(viewport=null) lands on the server first and the
|
||||
// post-scrollback measure path's resubscribe sees alreadyMeasured=true
|
||||
// (because measureViewportOnce won the race) and silently skips.
|
||||
if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) {
|
||||
void measureViewportOnce(handle)
|
||||
subscribeToTerminal(handle)
|
||||
void (async () => {
|
||||
await measureViewportOnce(handle)
|
||||
if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) {
|
||||
subscribeToTerminal(handle)
|
||||
}
|
||||
})()
|
||||
}
|
||||
},
|
||||
[measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
useForceReconnect,
|
||||
usePrimeHosts
|
||||
} from '../src/transport/client-context'
|
||||
import { classifyConnection } from '../src/transport/connection-health'
|
||||
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
|
||||
import type { ConnectionState, HostProfile } from '../src/transport/types'
|
||||
import { triggerMediumImpact } from '../src/platform/haptics'
|
||||
|
|
@ -53,32 +54,6 @@ function endpointLabel(endpoint: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<ConnectionState, string> = {
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting…',
|
||||
disconnected: 'Disconnected',
|
||||
reconnecting: 'Reconnecting…',
|
||||
handshaking: 'Connecting…',
|
||||
'auth-failed': 'Auth failed'
|
||||
}
|
||||
|
||||
// Why: a few quick reconnects are normal (laptop wake, brief network blip).
|
||||
// After this many failed attempts in a row, the user almost certainly has
|
||||
// a real problem (wrong port, server down, network change), so escalate
|
||||
// the label and color so it's obvious something's wrong.
|
||||
const RECONNECT_FAILURE_THRESHOLD = 3
|
||||
|
||||
function getStatusDisplay(
|
||||
state: ConnectionState,
|
||||
attempts: number
|
||||
): { label: string; isError: boolean } {
|
||||
if (state === 'auth-failed') return { label: 'Auth failed', isError: true }
|
||||
if (state === 'reconnecting' && attempts >= RECONNECT_FAILURE_THRESHOLD) {
|
||||
return { label: "Can't connect", isError: true }
|
||||
}
|
||||
return { label: STATUS_LABELS[state], isError: false }
|
||||
}
|
||||
|
||||
type StatsSummary = {
|
||||
totalAgentsSpawned: number
|
||||
totalPRsCreated: number
|
||||
|
|
@ -226,6 +201,7 @@ export default function HomeScreen() {
|
|||
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
|
||||
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
|
||||
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
|
||||
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
|
||||
const [stats, setStats] = useState<StatsSummary | null>(null)
|
||||
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
|
||||
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
|
||||
|
|
@ -342,6 +318,18 @@ export default function HomeScreen() {
|
|||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
setHostLastConnected((prev) => {
|
||||
const next: Record<string, number | null> = { ...prev }
|
||||
let changed = false
|
||||
for (const entry of allClients) {
|
||||
const t = entry.client.getLastConnectedAt()
|
||||
if (next[entry.hostId] !== t) {
|
||||
next[entry.hostId] = t
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
setHostStates((prev) => {
|
||||
const next: Record<string, ConnectionState> = { ...prev }
|
||||
let changed = false
|
||||
|
|
@ -458,12 +446,16 @@ export default function HomeScreen() {
|
|||
// tap target is still the same and a fresher snapshot from the live RPC
|
||||
// overwrites the card's contents in place when it lands.
|
||||
const resumeWorktree = useMemo(() => {
|
||||
if (lastVisited && sortedHosts.some((h) => h.id === lastVisited.hostId)) {
|
||||
// Why: only surface Resume for hosts that are currently connected.
|
||||
// Showing a stale cached worktree for a disconnected host is
|
||||
// misleading — the user would tap into a session route that can't
|
||||
// load anything until the host reconnects. Once the host reconnects,
|
||||
// the card reappears with fresh data.
|
||||
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
|
||||
const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null
|
||||
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
|
||||
if (match) return { hostId: lastVisited.hostId, worktree: match }
|
||||
}
|
||||
// Prefer a currently-connected host's data when we have it.
|
||||
for (const host of sortedHosts) {
|
||||
if (hostStates[host.id] !== 'connected') continue
|
||||
const info = worktreeInfo[host.id]
|
||||
|
|
@ -471,39 +463,16 @@ export default function HomeScreen() {
|
|||
return { hostId: host.id, worktree: info.lastActiveWorktree }
|
||||
}
|
||||
}
|
||||
// Fall back to whichever known host has cached data, regardless of
|
||||
// current connection state.
|
||||
for (const host of sortedHosts) {
|
||||
const info = worktreeInfo[host.id]
|
||||
if (info?.lastActiveWorktree) {
|
||||
return { hostId: host.id, worktree: info.lastActiveWorktree }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
|
||||
|
||||
const resumeLoading = useMemo(
|
||||
() =>
|
||||
sortedHosts.some((host) => {
|
||||
const state = hostStates[host.id] ?? 'connecting'
|
||||
return (
|
||||
state === 'connecting' ||
|
||||
state === 'handshaking' ||
|
||||
state === 'reconnecting' ||
|
||||
(state === 'connected' && !worktreeInfo[host.id])
|
||||
)
|
||||
}),
|
||||
[sortedHosts, hostStates, worktreeInfo]
|
||||
)
|
||||
|
||||
// Why: only show the Account usage section for hosts that have at least
|
||||
// one Claude or Codex account configured. Render whenever cached data
|
||||
// exists, regardless of current connection state, so the cards don't
|
||||
// disappear for ~1s on resume while the WebSocket reconnects. Streamed
|
||||
// updates from the live RPC overwrite the snapshot in place when ready.
|
||||
// Why: only show the Account usage section for hosts that are currently
|
||||
// connected. Showing stale cached usage for a disconnected host implies
|
||||
// live data; better to hide until the host reconnects and we can refresh.
|
||||
const accountsHosts = useMemo(() => {
|
||||
const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = []
|
||||
for (const host of sortedHosts) {
|
||||
if (hostStates[host.id] !== 'connected') continue
|
||||
const snap = accountsByHost[host.id]
|
||||
if (!snap) continue
|
||||
const hasClaude = snap.claude.accounts.length > 0
|
||||
|
|
@ -636,9 +605,18 @@ export default function HomeScreen() {
|
|||
renderItem={({ item }) => {
|
||||
const state = hostStates[item.id] ?? 'connecting'
|
||||
const attempts = hostAttempts[item.id] ?? 0
|
||||
const lastConnectedAt = hostLastConnected[item.id] ?? null
|
||||
const connected = state === 'connected'
|
||||
const info = worktreeInfo[item.id]
|
||||
const status = getStatusDisplay(state, attempts)
|
||||
const verdict = classifyConnection({
|
||||
state,
|
||||
reconnectAttempts: attempts,
|
||||
lastConnectedAt
|
||||
})
|
||||
const isError =
|
||||
verdict.kind === 'warning' ||
|
||||
verdict.kind === 'unreachable' ||
|
||||
verdict.kind === 'auth-failed'
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
|
||||
|
|
@ -663,11 +641,9 @@ export default function HomeScreen() {
|
|||
{item.name}
|
||||
</Text>
|
||||
<View style={styles.hostMeta}>
|
||||
<StatusDot state={state} />
|
||||
<Text
|
||||
style={[styles.hostMetaItem, status.isError && { color: colors.statusRed }]}
|
||||
>
|
||||
{status.label}
|
||||
<StatusDot state={state} verdict={verdict} />
|
||||
<Text style={[styles.hostMetaItem, isError && { color: colors.statusRed }]}>
|
||||
{verdict.label}
|
||||
{connected && info
|
||||
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
|
||||
: ''}
|
||||
|
|
@ -716,17 +692,6 @@ export default function HomeScreen() {
|
|||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</>
|
||||
) : hosts.length > 0 && resumeLoading ? (
|
||||
<>
|
||||
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Resume</Text>
|
||||
<View style={styles.resumeCard}>
|
||||
<View style={[styles.resumeIcon, styles.skeletonBlock]} />
|
||||
<View style={styles.resumeMain}>
|
||||
<View style={[styles.skeletonLine, { width: '55%' }]} />
|
||||
<View style={[styles.skeletonLine, { width: '35%', marginTop: 6 }]} />
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ─── Account usage ─── */}
|
||||
|
|
@ -854,9 +819,15 @@ export default function HomeScreen() {
|
|||
state === 'connecting' ||
|
||||
state === 'handshaking' ||
|
||||
state === 'reconnecting'
|
||||
// Why: "Reconnect" implies "you were connected, try again". If
|
||||
// the client has never reached 'connected' this session (cold
|
||||
// start, unreachable host, or after Disconnect) the action is
|
||||
// functionally a fresh Connect — using the right verb makes
|
||||
// the affordance match what tapping it actually does.
|
||||
const hasEverConnected = (hostLastConnected[host.id] ?? null) != null
|
||||
const items: ActionSheetAction[] = []
|
||||
items.push({
|
||||
label: 'Reconnect',
|
||||
label: hasEverConnected && isLive ? 'Reconnect' : 'Connect',
|
||||
icon: RefreshCw,
|
||||
onPress: () => {
|
||||
setActionTarget(null)
|
||||
|
|
@ -1197,18 +1168,6 @@ const styles = StyleSheet.create({
|
|||
marginTop: 4
|
||||
},
|
||||
|
||||
/* ─── Skeleton ─── */
|
||||
skeletonBlock: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
opacity: 0.5
|
||||
},
|
||||
skeletonLine: {
|
||||
height: 12,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.bgRaised,
|
||||
opacity: 0.5
|
||||
},
|
||||
|
||||
/* ─── Quick actions ─── */
|
||||
quickActions: {
|
||||
flexDirection: 'row',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { View, StyleSheet } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import type { ConnectionVerdict } from '../transport/connection-health'
|
||||
|
||||
const stateColors: Record<ConnectionState, string> = {
|
||||
connected: colors.statusGreen,
|
||||
|
|
@ -11,8 +12,25 @@ const stateColors: Record<ConnectionState, string> = {
|
|||
'auth-failed': colors.statusRed
|
||||
}
|
||||
|
||||
export function StatusDot({ state }: { state: ConnectionState }) {
|
||||
return <View style={[styles.dot, { backgroundColor: stateColors[state] ?? colors.textMuted }]} />
|
||||
// Why: when caller passes a verdict, the dot color reflects the verdict's
|
||||
// severity instead of the raw transport state. This avoids the "amber dot
|
||||
// next to red 'Can't reach desktop' label" mismatch — the underlying
|
||||
// transport is still 'reconnecting' (amber) but the user-visible meaning
|
||||
// has escalated to error (red).
|
||||
export function StatusDot({
|
||||
state,
|
||||
verdict
|
||||
}: {
|
||||
state: ConnectionState
|
||||
verdict?: ConnectionVerdict
|
||||
}) {
|
||||
const color =
|
||||
verdict?.kind === 'unreachable' || verdict?.kind === 'auth-failed'
|
||||
? colors.statusRed
|
||||
: verdict?.kind === 'warning'
|
||||
? colors.statusAmber
|
||||
: (stateColors[state] ?? colors.textMuted)
|
||||
return <View style={[styles.dot, { backgroundColor: color }]} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ export type TerminalWebViewHandle = {
|
|||
clear: () => void
|
||||
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
|
||||
resetZoom: () => void
|
||||
// Why: lets callers await the WebView-side `init` rAF chain (term.open
|
||||
// → renderService population → first paint) so a follow-up measure
|
||||
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
|
||||
// the next 'ready' notify after the most recent init.
|
||||
awaitReady: () => Promise<void>
|
||||
}
|
||||
|
||||
type Props = {
|
||||
|
|
@ -82,14 +87,49 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
var terminalGeneration = 0;
|
||||
var activeAltScreenSnapshot = false;
|
||||
var handledMessageIds = [];
|
||||
// Why: after init() the initial scrollback applyFitScale may have run
|
||||
// against an empty buffer (or one without the widest line yet). Re-fit
|
||||
// once when the first live data chunk arrives so a wider line that pushes
|
||||
// scrollWidth past the previously-measured value gets re-scaled to fit.
|
||||
var firstDataPending = false;
|
||||
|
||||
// Diagnostic logger — bridges WebView console.log to RN via postMessage.
|
||||
// Tag with [fit] so it's easy to filter in the Expo/Metro logs.
|
||||
function flog(tag, payload) {
|
||||
try {
|
||||
if (window.ReactNativeWebView) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||
type: 'log', tag: '[fit]' + tag, payload: payload
|
||||
}));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function getCellWidth() {
|
||||
if (!term || !term._core) return 0;
|
||||
var core = term._core;
|
||||
if (core._renderService && core._renderService.dimensions) {
|
||||
return core._renderService.dimensions.css.cell.width || 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Why: width measurement strategy.
|
||||
// 1. Prefer cellWidth × term.cols — this is what xterm's renderer uses
|
||||
// to lay out and is independent of buffer content. It's the "logical
|
||||
// width" of the terminal grid.
|
||||
// 2. Fall back to term.element.scrollWidth — the actual rendered DOM
|
||||
// width — only when cellWidth isn't available yet (renderer not
|
||||
// initialized). This is content-dependent (reflects widest row),
|
||||
// but better than nothing.
|
||||
// 3. If both are 0, return 1 (no scale change). The retry loop in
|
||||
// applyFitScale will keep trying until one is positive.
|
||||
function computeFitScale() {
|
||||
if (!term) return 1;
|
||||
var el = term.element;
|
||||
if (!el) return 1;
|
||||
var termWidth = el.scrollWidth;
|
||||
var vpWidth = window.innerWidth;
|
||||
var cellW = getCellWidth();
|
||||
var termWidth = cellW > 0 ? cellW * term.cols : (term.element ? term.element.scrollWidth : 0);
|
||||
if (termWidth <= 0) return 1;
|
||||
var vpWidth = window.innerWidth;
|
||||
return Math.min(1, vpWidth / termWidth);
|
||||
}
|
||||
|
||||
|
|
@ -159,33 +199,63 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
}
|
||||
}
|
||||
|
||||
// Why: on cold start (first WebView load + first scrollback) xterm's DOM
|
||||
// and canvas need several frames to reflow after term.open(). If we
|
||||
// computeFitScale() too eagerly we read scrollWidth=0 or a stale width
|
||||
// from before the new cols took effect, scrollWidth/vpWidth >= 1, and
|
||||
// currentScale snaps to 1 — which is exactly the "didn't zoom to fit"
|
||||
// bug users see on first load. Retry across frames until we get a
|
||||
// positive, stable scrollWidth, then commit. Capped to keep this from
|
||||
// spinning forever if the WebView never lays out (e.g. backgrounded).
|
||||
var FIT_RETRY_MAX_FRAMES = 30;
|
||||
// Why: cold-start fit. After init() opens xterm, the renderer needs
|
||||
// several frames before cell dimensions are computed. Reading too early
|
||||
// gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM
|
||||
// not laid out), and computeFitScale returns 1 → no zoom.
|
||||
//
|
||||
// Gate: cellWidth × cols is the canonical "logical width" of the grid
|
||||
// and reflects xterm's layout decision, independent of buffer content.
|
||||
// We commit when cellWidth becomes positive (renderer ready). Fallback:
|
||||
// if cellWidth never becomes available, gate on stable positive
|
||||
// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
|
||||
// so a backgrounded WebView never spins forever.
|
||||
var FIT_RETRY_MAX_FRAMES = 60;
|
||||
var fitRetryToken = 0;
|
||||
function applyFitScale() {
|
||||
if (!term || !term.element) return;
|
||||
function applyFitScale(reason) {
|
||||
if (!term || !term.element) {
|
||||
flog('skip-no-term', { reason: reason });
|
||||
return;
|
||||
}
|
||||
var token = ++fitRetryToken;
|
||||
var attempts = 0;
|
||||
var lastWidth = -1;
|
||||
var lastScrollWidth = -1;
|
||||
flog('start', {
|
||||
reason: reason,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
vpWidth: window.innerWidth,
|
||||
vpHeight: window.innerHeight
|
||||
});
|
||||
function attempt() {
|
||||
if (token !== fitRetryToken) return;
|
||||
if (!term || !term.element) return;
|
||||
var w = term.element.scrollWidth;
|
||||
attempts++;
|
||||
if (w > 0 && w === lastWidth) {
|
||||
commitFitScale();
|
||||
if (token !== fitRetryToken) {
|
||||
flog('cancel-superseded', { reason: reason, attempts: attempts });
|
||||
return;
|
||||
}
|
||||
lastWidth = w;
|
||||
if (!term || !term.element) return;
|
||||
attempts++;
|
||||
var cellW = getCellWidth();
|
||||
if (cellW > 0 && term.cols > 0) {
|
||||
flog('commit-cellW', { reason: reason, attempts: attempts, cellW: cellW, cols: term.cols });
|
||||
commitFitScale(reason, attempts, 'cellW');
|
||||
return;
|
||||
}
|
||||
var w = term.element.scrollWidth;
|
||||
if (w > 0 && w === lastScrollWidth) {
|
||||
flog('commit-stableSW', { reason: reason, attempts: attempts, scrollWidth: w });
|
||||
commitFitScale(reason, attempts, 'stableSW');
|
||||
return;
|
||||
}
|
||||
lastScrollWidth = w;
|
||||
if (attempts >= FIT_RETRY_MAX_FRAMES) {
|
||||
commitFitScale();
|
||||
flog('commit-timeout', {
|
||||
reason: reason,
|
||||
attempts: attempts,
|
||||
cellW: cellW,
|
||||
scrollWidth: w,
|
||||
cols: term.cols
|
||||
});
|
||||
commitFitScale(reason, attempts, 'timeout');
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(attempt);
|
||||
|
|
@ -193,17 +263,39 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
requestAnimationFrame(attempt);
|
||||
}
|
||||
|
||||
function commitFitScale() {
|
||||
function commitFitScale(reason, attempts, gate) {
|
||||
if (!term || !term.element) return;
|
||||
currentScale = computeFitScale();
|
||||
// Why: when the scale is very close to 1 (e.g. 0.97 due to xterm
|
||||
// scrollbar width), snap to 1.0 to avoid sub-pixel shrinkage.
|
||||
var preSnapScale = computeFitScale();
|
||||
currentScale = preSnapScale;
|
||||
// Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar
|
||||
// sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents
|
||||
// a second applyFitScale from observing a "no-op needed" state.
|
||||
if (currentScale >= 0.95) currentScale = 1;
|
||||
userScale = 1;
|
||||
panX = 0;
|
||||
panY = 0;
|
||||
updateTransform();
|
||||
adjustRowsForViewport();
|
||||
|
||||
var cellW = getCellWidth();
|
||||
var sw = term.element.scrollWidth;
|
||||
var vpW = window.innerWidth;
|
||||
var expectedW = cellW * term.cols;
|
||||
var suspect =
|
||||
currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom
|
||||
flog(suspect ? 'commit-SUSPECT' : 'commit', {
|
||||
reason: reason,
|
||||
attempts: attempts,
|
||||
gate: gate,
|
||||
preSnapScale: preSnapScale,
|
||||
finalScale: currentScale,
|
||||
cellW: cellW,
|
||||
cols: term.cols,
|
||||
expectedW: expectedW,
|
||||
scrollWidth: sw,
|
||||
vpWidth: vpW,
|
||||
suspect: suspect
|
||||
});
|
||||
}
|
||||
|
||||
function isAltScreenActive(data) {
|
||||
|
|
@ -254,6 +346,16 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
writesDraining = false;
|
||||
afterDrainCallbacks = [];
|
||||
initRows = rows || 24;
|
||||
firstDataPending = true;
|
||||
flog('init', {
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
hasInitialData: typeof initialData === 'string' && initialData.length > 0,
|
||||
initialDataLen: typeof initialData === 'string' ? initialData.length : 0,
|
||||
vpWidth: window.innerWidth,
|
||||
vpHeight: window.innerHeight,
|
||||
gen: gen
|
||||
});
|
||||
var replayData = normalizeInitialData(initialData);
|
||||
activeAltScreenSnapshot = isAltScreenActive(replayData);
|
||||
if (term) term.dispose();
|
||||
|
|
@ -304,7 +406,7 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
ready = true;
|
||||
afterWritesDrained(function() {
|
||||
if (gen !== terminalGeneration) return;
|
||||
applyFitScale();
|
||||
applyFitScale('init-replay');
|
||||
notify({ type: 'ready', cols: cols, rows: rows });
|
||||
});
|
||||
});
|
||||
|
|
@ -313,6 +415,18 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
function write(data) {
|
||||
writeQueue.push(data);
|
||||
pumpWrites(terminalGeneration);
|
||||
// Why: first live data chunk after init may widen the buffer past
|
||||
// what the post-replay applyFitScale measured. Re-fit once after this
|
||||
// chunk drains to catch the wider line. Subsequent chunks don't re-fit
|
||||
// (the user's manual zoom is sticky after that).
|
||||
if (firstDataPending) {
|
||||
firstDataPending = false;
|
||||
var gen = terminalGeneration;
|
||||
afterWritesDrained(function() {
|
||||
if (gen !== terminalGeneration) return;
|
||||
applyFitScale('first-data');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function notify(msg) {
|
||||
|
|
@ -321,22 +435,35 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
}
|
||||
}
|
||||
|
||||
function measureFitDimensions(containerHeightPx) {
|
||||
if (!term || !term.element) {
|
||||
notify({ type: 'measure-result', cols: null, rows: null });
|
||||
return;
|
||||
}
|
||||
// Why: measure actual xterm cell dimensions from the renderer, not from
|
||||
// font metrics alone. This accounts for the exact font, size, and line
|
||||
// height that xterm is using.
|
||||
var core = term._core;
|
||||
function measureFitDimensions(containerHeightPx, retriesLeft) {
|
||||
if (typeof retriesLeft !== 'number') retriesLeft = 30;
|
||||
// Why: init and measure are posted back-to-back from React, but
|
||||
// init has an async rAF chain. A measure that runs synchronously
|
||||
// after init can find term null, disposed, lacking element, or
|
||||
// with cells size 0. Retry the whole gate for ~500ms.
|
||||
var notReady = !term || !term.element;
|
||||
var cellWidth = 0;
|
||||
var cellHeight = 0;
|
||||
if (core && core._renderService && core._renderService.dimensions) {
|
||||
cellWidth = core._renderService.dimensions.css.cell.width;
|
||||
cellHeight = core._renderService.dimensions.css.cell.height;
|
||||
if (!notReady) {
|
||||
var core = term._core;
|
||||
if (core && core._renderService && core._renderService.dimensions) {
|
||||
cellWidth = core._renderService.dimensions.css.cell.width;
|
||||
cellHeight = core._renderService.dimensions.css.cell.height;
|
||||
}
|
||||
}
|
||||
if (cellWidth <= 0 || cellHeight <= 0) {
|
||||
if (notReady || cellWidth <= 0 || cellHeight <= 0) {
|
||||
if (retriesLeft > 0) {
|
||||
requestAnimationFrame(function() {
|
||||
measureFitDimensions(containerHeightPx, retriesLeft - 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
flog('measure-fail', {
|
||||
notReady: notReady,
|
||||
cellWidth: cellWidth,
|
||||
cellHeight: cellHeight,
|
||||
retriesLeft: retriesLeft
|
||||
});
|
||||
notify({ type: 'measure-result', cols: null, rows: null });
|
||||
return;
|
||||
}
|
||||
|
|
@ -381,7 +508,7 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
} else if (msg.type === 'measure') {
|
||||
measureFitDimensions(msg.containerHeight);
|
||||
} else if (msg.type === 'reset-zoom') {
|
||||
applyFitScale();
|
||||
applyFitScale('reset-zoom-msg');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -528,6 +655,11 @@ const XTERM_HTML = `<!DOCTYPE html>
|
|||
});
|
||||
|
||||
window.addEventListener('resize', function() {
|
||||
// Why: viewport changed (keyboard open/close, orientation, RN container
|
||||
// size update). Re-fit so the scale matches the new vpWidth — without
|
||||
// this, opening the keyboard leaves the terminal at the old scale even
|
||||
// though there's now less vertical room and the fit ratio may differ.
|
||||
applyFitScale('window-resize');
|
||||
adjustRowsForViewport();
|
||||
clampPan();
|
||||
updateTransform();
|
||||
|
|
@ -554,6 +686,12 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
|||
const measureResolveRef = useRef<
|
||||
((result: { cols: number; rows: number } | null) => void) | null
|
||||
>(null)
|
||||
// Why: each init() call posts 'init' to the WebView and arms a fresh
|
||||
// ready promise. WebView's init() rAF chain ends with a 'ready' notify
|
||||
// that resolves it. measureFitDimensions awaits this so it doesn't
|
||||
// race ahead of term.open() / renderService population.
|
||||
const readyPromiseRef = useRef<Promise<void> | null>(null)
|
||||
const readyResolveRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const sendToWebView = useCallback((msg: TerminalMessage) => {
|
||||
messageIdRef.current += 1
|
||||
|
|
@ -592,6 +730,15 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
|||
isWebReadyRef.current = true
|
||||
onWebReady?.()
|
||||
flushPendingMessages()
|
||||
} else if (msg.type === 'ready') {
|
||||
// Why: the WebView's init() rAF chain has run — term is open,
|
||||
// renderService is populated, first paint has happened. Resolve
|
||||
// any pending awaitReady() so a queued measure can now safely
|
||||
// read cell dims.
|
||||
const resolve = readyResolveRef.current
|
||||
readyResolveRef.current = null
|
||||
readyPromiseRef.current = null
|
||||
resolve?.()
|
||||
} else if (msg.type === 'measure-result') {
|
||||
const resolve = measureResolveRef.current
|
||||
measureResolveRef.current = null
|
||||
|
|
@ -600,6 +747,11 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
|||
const rows = typeof msg.rows === 'number' ? msg.rows : null
|
||||
resolve(cols && rows && cols >= 20 && rows >= 8 ? { cols, rows } : null)
|
||||
}
|
||||
} else if (msg.type === 'log') {
|
||||
// Surface fit-scale diagnostics in the RN/Metro console.
|
||||
const tag = typeof msg.tag === 'string' ? msg.tag : '[fit]'
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(tag, msg.payload)
|
||||
}
|
||||
},
|
||||
[flushPendingMessages, onWebReady]
|
||||
|
|
@ -616,6 +768,14 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
|||
postMessage({ type: 'write', data })
|
||||
},
|
||||
init(cols: number, rows: number, initialData?: string) {
|
||||
// Why: arm a fresh ready promise BEFORE posting init. The WebView
|
||||
// resolves it via the 'ready' notify at the end of its rAF chain.
|
||||
// Re-init supersedes any prior in-flight ready (we don't bridge
|
||||
// generations; the older promise just never resolves, and its
|
||||
// callers have moved on by then).
|
||||
readyPromiseRef.current = new Promise<void>((resolve) => {
|
||||
readyResolveRef.current = resolve
|
||||
})
|
||||
postMessage({ type: 'init', cols, rows, initialData })
|
||||
},
|
||||
clear() {
|
||||
|
|
@ -642,6 +802,14 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
|||
},
|
||||
resetZoom() {
|
||||
postMessage({ type: 'reset-zoom' })
|
||||
},
|
||||
async awaitReady(): Promise<void> {
|
||||
// Why: returns the in-flight ready promise (set by init); resolves
|
||||
// immediately if no init is pending. Capped at 3s so a stuck
|
||||
// WebView doesn't hang the caller.
|
||||
const p = readyPromiseRef.current
|
||||
if (!p) return
|
||||
await Promise.race([p, new Promise<void>((resolve) => setTimeout(resolve, 3000))])
|
||||
}
|
||||
}),
|
||||
[postMessage, sendToWebView]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
}
|
||||
|
|
@ -34,6 +34,10 @@ export type RpcClient = {
|
|||
// Why: UI escalates "Reconnecting…" to "Can't connect" once attempts cross
|
||||
// a threshold. 0 means never failed; counter is reset on successful open.
|
||||
getReconnectAttempt: () => number
|
||||
// Why: timestamp (ms epoch) of the last time we reached 'connected'.
|
||||
// null = never connected since the client was created. Used by the UI
|
||||
// to distinguish "host moved/never reachable" from "transient blip".
|
||||
getLastConnectedAt: () => number | null
|
||||
onStateChange: (listener: (state: ConnectionState) => void) => () => void
|
||||
close: () => void
|
||||
}
|
||||
|
|
@ -46,6 +50,14 @@ export type RpcClient = {
|
|||
// manual Reconnect button bypassed the timer, which is why it felt
|
||||
// "magic". Shorter backoff makes the auto-recovery path feel as fast.
|
||||
const RECONNECT_DELAYS = [500, 1000, 2000, 4000]
|
||||
// Why: cap auto-retry once we're clearly unreachable. With the 4s
|
||||
// backoff cap that's ≈20s of solid failure before we stop. The UI
|
||||
// renders an "unreachable, re-pair?" banner at this point; user taps
|
||||
// Retry or Re-pair to resume. MUST stay aligned with
|
||||
// connection-health.ts UNREACHABLE_ATTEMPTS so the verdict matches the
|
||||
// moment the loop pauses — if these drift the user sees "Reconnecting…"
|
||||
// while the loop is silently parked.
|
||||
const GIVE_UP_AFTER_ATTEMPTS = 6
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const CONNECT_TIMEOUT_MS = 12_000
|
||||
const HANDSHAKE_TIMEOUT_MS = 5_000
|
||||
|
|
@ -107,6 +119,19 @@ export function connect(
|
|||
let handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let activityProbeTimer: ReturnType<typeof setInterval> | null = null
|
||||
let intentionallyClosed = false
|
||||
let lastConnectedAt: number | null = null
|
||||
// Why: diagnostic — when the rpc-client gets stuck in a state where every
|
||||
// openConnection fails with code 1006 and only a force-quit recovers, we
|
||||
// need to see whether (a) the new attempts even differ from the old ones,
|
||||
// (b) anything is happening at the OS / RN-bridge layer between attempts,
|
||||
// and (c) what the timing pattern is (instant 1006 = port closed / route
|
||||
// dead, slow 1006 = packet drop / timeout). These three timestamps + the
|
||||
// ws-construction counter are the cheapest visibility into RN/OkHttp
|
||||
// process-state poisoning hypotheses.
|
||||
let lastInboundAt: number | null = null
|
||||
let lastWsClosedAt: number | null = null
|
||||
let wsConstructionCounter = 0
|
||||
let currentWsOpenedAt: number | null = null
|
||||
|
||||
// Why: fresh ephemeral keypair per connection provides forward secrecy.
|
||||
// The shared key is derived from our ephemeral secret + server's static public key.
|
||||
|
|
@ -122,10 +147,26 @@ export function connect(
|
|||
stateListeners.add(onStateChange)
|
||||
}
|
||||
|
||||
// Diagnostic: tracks how long we've been in the current state. Useful
|
||||
// for spotting "stuck in connecting" or "stuck in reconnecting" cases
|
||||
// in the logs.
|
||||
let stateEnteredAt = Date.now()
|
||||
|
||||
function setState(next: ConnectionState) {
|
||||
if (state === next) return
|
||||
const prev = state
|
||||
const dwelt = Date.now() - stateEnteredAt
|
||||
state = next
|
||||
stateEnteredAt = Date.now()
|
||||
console.log('[net] state', {
|
||||
from: prev,
|
||||
to: next,
|
||||
dweltMs: dwelt,
|
||||
attempt: reconnectAttempt,
|
||||
endpoint: redactedEndpoint(endpoint)
|
||||
})
|
||||
if (next === 'connected') {
|
||||
lastConnectedAt = Date.now()
|
||||
for (const w of connectWaiters.splice(0)) w.resolve()
|
||||
} else if (next === 'disconnected' || next === 'auth-failed') {
|
||||
const reason =
|
||||
|
|
@ -137,6 +178,17 @@ export function connect(
|
|||
}
|
||||
}
|
||||
|
||||
// Why: don't dump device tokens / full URLs into log scrolls; truncate to
|
||||
// the host:port so reconnect lifecycles are still readable.
|
||||
function redactedEndpoint(ep: string): string {
|
||||
try {
|
||||
const m = ep.match(/^wss?:\/\/([^/]+)/i)
|
||||
return m ? m[1] : 'unknown'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function waitForConnected(): Promise<void> {
|
||||
if (state === 'connected') return Promise.resolve()
|
||||
if (intentionallyClosed) return Promise.reject(new Error('Client closed'))
|
||||
|
|
@ -152,9 +204,25 @@ export function connect(
|
|||
function openConnection() {
|
||||
if (intentionallyClosed) return
|
||||
|
||||
const now = Date.now()
|
||||
wsConstructionCounter++
|
||||
console.log('[net] openConnection', {
|
||||
attempt: reconnectAttempt,
|
||||
endpoint: redactedEndpoint(endpoint),
|
||||
// Why: process-poisoning diagnostic. If wsCount is high (e.g. >50)
|
||||
// and every recent open fails with 1006, suspect RN/OkHttp internal
|
||||
// pool corruption that only force-quit clears. Compare msSinceLast*
|
||||
// values to the failure cadence: instant repeated fails with no
|
||||
// inbound traffic between them = process-state stuck.
|
||||
wsCount: wsConstructionCounter,
|
||||
msSinceLastConnected: lastConnectedAt != null ? now - lastConnectedAt : null,
|
||||
msSinceLastClose: lastWsClosedAt != null ? now - lastWsClosedAt : null,
|
||||
msSinceLastInbound: lastInboundAt != null ? now - lastInboundAt : null
|
||||
})
|
||||
setState('connecting')
|
||||
sharedKey = null
|
||||
|
||||
currentWsOpenedAt = now
|
||||
emitLog(
|
||||
'info',
|
||||
reconnectAttempt > 0 ? `Reconnecting (attempt ${reconnectAttempt + 1})` : 'Opening WebSocket',
|
||||
|
|
@ -170,6 +238,10 @@ export function connect(
|
|||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null
|
||||
if (ws === openingWs && openingWs.readyState === WEBSOCKET_CONNECTING_STATE) {
|
||||
console.log('[net] connect-timeout fired (onopen never arrived)', {
|
||||
attempt: reconnectAttempt,
|
||||
timeoutMs: CONNECT_TIMEOUT_MS
|
||||
})
|
||||
emitLog(
|
||||
'error',
|
||||
'WebSocket connect timeout',
|
||||
|
|
@ -177,12 +249,13 @@ export function connect(
|
|||
)
|
||||
openingWs.close()
|
||||
if (ws === openingWs) {
|
||||
handleSocketClosed(openingWs)
|
||||
handleSocketClosed(openingWs, { timedOut: true })
|
||||
}
|
||||
}
|
||||
}, CONNECT_TIMEOUT_MS)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[net] ws.onopen', { attempt: reconnectAttempt })
|
||||
clearConnectTimer()
|
||||
reconnectAttempt = 0
|
||||
setState('handshaking')
|
||||
|
|
@ -203,6 +276,9 @@ export function connect(
|
|||
|
||||
handshakeTimer = setTimeout(() => {
|
||||
handshakeTimer = null
|
||||
console.log('[net] handshake-timeout fired (e2ee_authenticated never arrived)', {
|
||||
timeoutMs: HANDSHAKE_TIMEOUT_MS
|
||||
})
|
||||
emitLog(
|
||||
'error',
|
||||
'Handshake timeout',
|
||||
|
|
@ -213,6 +289,9 @@ export function connect(
|
|||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Why: track last-inbound for the openConnection diagnostic. Server
|
||||
// pongs and stream events both bump this — anything from the wire.
|
||||
lastInboundAt = Date.now()
|
||||
const raw = typeof event.data === 'string' ? event.data : String(event.data)
|
||||
|
||||
// Why: during handshaking, e2ee_ready is plaintext because it precedes
|
||||
|
|
@ -245,6 +324,9 @@ export function connect(
|
|||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
}
|
||||
console.log('[net] e2ee_authenticated — connected', {
|
||||
streamCount: streamListeners.size
|
||||
})
|
||||
setState('connected')
|
||||
emitLog('success', 'Authenticated', 'Channel ready for RPC')
|
||||
startActivityProbe()
|
||||
|
|
@ -252,6 +334,7 @@ export function connect(
|
|||
sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params })
|
||||
}
|
||||
} else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) {
|
||||
console.log('[net] e2ee auth FAILED', { msgType: msg.type, error: msg.error })
|
||||
emitLog(
|
||||
'error',
|
||||
'Authentication rejected',
|
||||
|
|
@ -334,17 +417,112 @@ export function connect(
|
|||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
ws.onclose = (event) => {
|
||||
const e = event as { code?: number; reason?: string; wasClean?: boolean } | undefined
|
||||
const closeAt = Date.now()
|
||||
// Why: time-since-construct distinguishes failure modes. Instant
|
||||
// close (<300ms) = TCP RST / port closed / route unreachable / RN
|
||||
// synchronous reject. Mid (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<object>()
|
||||
closeEventStr = JSON.stringify(
|
||||
event,
|
||||
(_k, v) => {
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
if (seen.has(v as object)) return '[circular]'
|
||||
seen.add(v as object)
|
||||
}
|
||||
if (typeof v === 'function') return '[fn]'
|
||||
return v
|
||||
},
|
||||
0
|
||||
).slice(0, 500)
|
||||
} catch {
|
||||
closeEventStr = '[unstringifiable]'
|
||||
}
|
||||
console.log('[net] ws.onclose', {
|
||||
code: e?.code,
|
||||
reason: e?.reason,
|
||||
wasClean: e?.wasClean,
|
||||
state,
|
||||
attempt: reconnectAttempt,
|
||||
intentionallyClosed,
|
||||
endpoint: redactedEndpoint(endpoint),
|
||||
constructToCloseMs,
|
||||
aliveMs,
|
||||
inboundIdleMs,
|
||||
eventKeys: closeEventKeys,
|
||||
eventStr: closeEventStr
|
||||
})
|
||||
lastWsClosedAt = closeAt
|
||||
currentWsOpenedAt = null
|
||||
handleSocketClosed(openingWs)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after this
|
||||
ws.onerror = (event) => {
|
||||
// Why: RN surfaces network errors here (DNS failure, TCP RST, etc).
|
||||
// onclose fires right after, but logging the error message gives us
|
||||
// the original cause that the close code alone can hide.
|
||||
const e = event as { message?: string } | undefined
|
||||
// Why: inlined defensively — see ws.onclose comment.
|
||||
let errEventKeys: string[] = []
|
||||
let errEventStr = ''
|
||||
try {
|
||||
errEventKeys = event && typeof event === 'object' ? Object.keys(event as object) : []
|
||||
} catch {
|
||||
errEventKeys = []
|
||||
}
|
||||
try {
|
||||
const seen = new WeakSet<object>()
|
||||
errEventStr = JSON.stringify(
|
||||
event,
|
||||
(_k, v) => {
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
if (seen.has(v as object)) return '[circular]'
|
||||
seen.add(v as object)
|
||||
}
|
||||
if (typeof v === 'function') return '[fn]'
|
||||
return v
|
||||
},
|
||||
0
|
||||
).slice(0, 500)
|
||||
} catch {
|
||||
errEventStr = '[unstringifiable]'
|
||||
}
|
||||
console.log('[net] ws.onerror', {
|
||||
message: e?.message,
|
||||
state,
|
||||
attempt: reconnectAttempt,
|
||||
eventKeys: errEventKeys,
|
||||
eventStr: errEventStr
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleSocketClosed(closedWs: WebSocket) {
|
||||
function handleSocketClosed(closedWs: WebSocket, opts: { timedOut?: boolean } = {}) {
|
||||
if (ws !== closedWs) {
|
||||
console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', {
|
||||
state,
|
||||
attempt: reconnectAttempt
|
||||
})
|
||||
return
|
||||
}
|
||||
clearConnectTimer()
|
||||
|
|
@ -356,10 +534,17 @@ export function connect(
|
|||
}
|
||||
stopActivityProbe()
|
||||
if (intentionallyClosed) {
|
||||
console.log('[net] handleSocketClosed — intentional close')
|
||||
setState('disconnected')
|
||||
rejectAllPending('Connection closed')
|
||||
return
|
||||
}
|
||||
console.log('[net] handleSocketClosed → reconnect', {
|
||||
timedOut: !!opts.timedOut,
|
||||
pendingCount: pending.size,
|
||||
streamCount: streamListeners.size,
|
||||
attempt: reconnectAttempt
|
||||
})
|
||||
emitLog('warn', 'WebSocket closed', 'Will attempt to reconnect')
|
||||
rejectAllPending('Connection interrupted')
|
||||
setState('reconnecting')
|
||||
|
|
@ -367,8 +552,25 @@ export function connect(
|
|||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
// Why: spinning reconnect forever drains battery and floods logs
|
||||
// when the host is genuinely unreachable (wrong IP, port closed,
|
||||
// host moved). Cap at GIVE_UP_AFTER_ATTEMPTS — the UI surfaces a
|
||||
// "Can't reach desktop, re-pair?" banner at this point and the
|
||||
// user can tap Retry (forceReconnect creates a fresh client,
|
||||
// resetting the counter) or Re-pair. Without an explicit cap the
|
||||
// worst-case is a phone left on the home screen burning a socket
|
||||
// open every 4s indefinitely.
|
||||
if (reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) {
|
||||
console.log('[net] reconnect-paused', {
|
||||
attempt: reconnectAttempt,
|
||||
reason: 'give-up-cap',
|
||||
endpoint: redactedEndpoint(endpoint)
|
||||
})
|
||||
return
|
||||
}
|
||||
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]!
|
||||
reconnectAttempt++
|
||||
console.log('[net] scheduleReconnect', { delayMs: delay, attempt: reconnectAttempt })
|
||||
emitLog('info', `Reconnect scheduled in ${delay}ms`, `Attempt ${reconnectAttempt}`)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
|
|
@ -400,10 +602,15 @@ export function connect(
|
|||
// half-open. Using REQUEST_TIMEOUT_MS (30s) here would make the
|
||||
// user wait nearly a minute before reconnect kicks in.
|
||||
const id = nextId()
|
||||
const probeStart = Date.now()
|
||||
let timedOut = false
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true
|
||||
pending.delete(id)
|
||||
console.log('[net] activity-probe TIMEOUT — forcing reconnect', {
|
||||
waitedMs: Date.now() - probeStart,
|
||||
state
|
||||
})
|
||||
// Why: only force-close if this is still the same socket the
|
||||
// probe was sent on; a normal close that already swapped `ws`
|
||||
// shouldn't trigger a redundant terminate.
|
||||
|
|
@ -448,6 +655,23 @@ export function connect(
|
|||
ws.send(encrypt(JSON.stringify(request), sharedKey))
|
||||
return true
|
||||
}
|
||||
console.log('[net] sendEncrypted FAILED — channel not ready', {
|
||||
hasWs: !!ws,
|
||||
readyState: ws?.readyState,
|
||||
hasKey: !!sharedKey,
|
||||
state
|
||||
})
|
||||
// Why: if the state machine still thinks we're connected but the
|
||||
// underlying WebSocket has flipped to CLOSING/CLOSED without onclose
|
||||
// having fired (RN's WebSocket sometimes drops the event, or the
|
||||
// server half-closed the stream), force a reconnect. Without this
|
||||
// every send silently fails forever and the user sees a frozen UI.
|
||||
if (state === 'connected' && ws && ws.readyState !== WebSocket.OPEN) {
|
||||
console.log('[net] sendEncrypted detected ws desync — forcing reconnect', {
|
||||
readyState: ws.readyState
|
||||
})
|
||||
handleSocketClosed(ws, { timedOut: false })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -455,12 +679,25 @@ export function connect(
|
|||
|
||||
return {
|
||||
async sendRequest(method: string, params?: unknown): Promise<RpcResponse> {
|
||||
const waitStart = Date.now()
|
||||
const wasConnected = state === 'connected'
|
||||
await waitForConnected()
|
||||
if (!wasConnected) {
|
||||
console.log('[net] sendRequest waited for connect', {
|
||||
method,
|
||||
waitedMs: Date.now() - waitStart
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextId()
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
console.log('[net] sendRequest TIMEOUT', {
|
||||
method,
|
||||
timeoutMs: REQUEST_TIMEOUT_MS,
|
||||
state
|
||||
})
|
||||
reject(new Error(`Request timed out: ${method}`))
|
||||
}, REQUEST_TIMEOUT_MS)
|
||||
|
||||
|
|
@ -489,6 +726,11 @@ export function connect(
|
|||
|
||||
if (state === 'connected') {
|
||||
sendEncrypted({ id, deviceToken, method, params })
|
||||
} else {
|
||||
// Stream is registered but the actual outbound subscribe will be
|
||||
// sent (or re-sent) when the channel reaches 'connected'. Useful
|
||||
// when terminals don't load — confirms the request is queued.
|
||||
console.log('[net] subscribe queued — waiting for connected', { method, state })
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
|
@ -536,6 +778,10 @@ export function connect(
|
|||
return reconnectAttempt
|
||||
},
|
||||
|
||||
getLastConnectedAt(): number | null {
|
||||
return lastConnectedAt
|
||||
},
|
||||
|
||||
onStateChange(listener: (state: ConnectionState) => void): () => void {
|
||||
stateListeners.add(listener)
|
||||
return () => stateListeners.delete(listener)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -118,10 +118,13 @@ describe('mobile subscribe integration', () => {
|
|||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('handleMobileSubscribe resizes PTY to phone dims', () => {
|
||||
it('handleMobileSubscribe resizes PTY to phone dims', async () => {
|
||||
const { runtime, ptySizes, resizes, notifications } = createRuntime()
|
||||
|
||||
const result = runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
const result = await runtime.handleMobileSubscribe('pty-1', 'client-a', {
|
||||
cols: 45,
|
||||
rows: 20
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
|
@ -130,83 +133,88 @@ describe('mobile subscribe integration', () => {
|
|||
expect(runtime.isMobileSubscriberActive('pty-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('handleMobileSubscribe skips resize when mode is desktop', () => {
|
||||
it('handleMobileSubscribe skips resize when mode is desktop', async () => {
|
||||
const { runtime, ptySizes, resizes } = createRuntime()
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
|
||||
const result = runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
const result = await runtime.handleMobileSubscribe('pty-1', 'client-a', {
|
||||
cols: 45,
|
||||
rows: 20
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(resizes).toEqual([])
|
||||
})
|
||||
|
||||
it('handleMobileSubscribe skips resize when no viewport provided', () => {
|
||||
it('handleMobileSubscribe skips resize when no viewport provided', async () => {
|
||||
const { runtime, ptySizes, resizes } = createRuntime()
|
||||
|
||||
const result = runtime.handleMobileSubscribe('pty-1', 'client-a')
|
||||
const result = await runtime.handleMobileSubscribe('pty-1', 'client-a')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(resizes).toEqual([])
|
||||
})
|
||||
|
||||
it('handleMobileUnsubscribe restores PTY after 300ms debounce in auto mode', () => {
|
||||
it('handleMobileUnsubscribe restores PTY after 300ms debounce in auto mode', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
// Not yet restored
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('handleMobileUnsubscribe does not restore in phone mode', () => {
|
||||
// Why: 'phone' (sticky-fit) mode was removed — there are now only 'auto'
|
||||
// and 'desktop'. Auto-mode always restores on last unsubscribe. Test
|
||||
// kept and inverted to lock in the new contract.
|
||||
it('handleMobileUnsubscribe restores after auto-mode last unsubscribe', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.setMobileDisplayMode('pty-1', 'phone')
|
||||
// In phone mode, handleMobileSubscribe still resizes because mode is 'phone'
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
// mode defaults to 'auto'
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
vi.advanceTimersByTime(1000)
|
||||
// Still at phone dims — no restore for phone mode
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
// Restored to desktop dims — no sticky-phone retention.
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
// TODO: inline restore on re-subscribe not yet implemented
|
||||
it.skip('re-subscribe within 300ms cancels debounce timer and inline-restores old PTY', () => {
|
||||
it.skip('re-subscribe within 300ms cancels debounce timer and inline-restores old PTY', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
// Re-subscribe to a different terminal before the timer fires
|
||||
vi.advanceTimersByTime(100)
|
||||
runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
// pty-1 was inline-restored when pty-2 subscribed (timer cancelled + immediate restore)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(ptySizes.get('pty-2')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Advancing past the 300ms debounce should not cause a second restore
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
// TODO: inline restore on re-subscribe not yet implemented
|
||||
it.skip('rapid A→B→C tab navigation: inline restore of A when B subscribes', () => {
|
||||
it.skip('rapid A→B→C tab navigation: inline restore of A when B subscribes', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
// Subscribe to A
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Unsubscribe A, subscribe B — A's pending timer is cancelled, A gets inline restore
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
// pty-1 should be restored inline (not waiting for timer)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
|
|
@ -214,41 +222,41 @@ describe('mobile subscribe integration', () => {
|
|||
|
||||
// Unsubscribe B, subscribe C — B's pending timer cancelled, B gets inline restore
|
||||
runtime.handleMobileUnsubscribe('pty-2', 'client-a')
|
||||
runtime.handleMobileSubscribe('pty-3', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-3', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 })
|
||||
expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Verify final state
|
||||
vi.advanceTimersByTime(1000)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(ptySizes.get('pty-2')).toEqual({ cols: 120, rows: 35 })
|
||||
expect(ptySizes.get('pty-3')).toEqual({ cols: 45, rows: 20 })
|
||||
})
|
||||
|
||||
it('preserves previousDims across re-subscribes to same terminal', () => {
|
||||
it('preserves previousDims across re-subscribes to same terminal', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
// First subscribe at desktop 150x40
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Re-subscribe to the same terminal (e.g., after reconnect)
|
||||
// The PTY is already at 45x20, but previousDims should still be 150x40
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
// Unsubscribe and let restore fire
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
// Should restore to original desktop dims, not 45x20
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('clamps viewport to valid range', () => {
|
||||
it('clamps viewport to valid range', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 10, rows: 3 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 10, rows: 3 })
|
||||
// Should clamp to minimum 20x8
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 20, rows: 8 })
|
||||
})
|
||||
|
|
@ -261,9 +269,6 @@ describe('mobile subscribe integration', () => {
|
|||
|
||||
it('set/get round-trip', () => {
|
||||
const { runtime } = createRuntime()
|
||||
runtime.setMobileDisplayMode('pty-1', 'phone')
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('phone')
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('desktop')
|
||||
|
||||
|
|
@ -274,16 +279,16 @@ describe('mobile subscribe integration', () => {
|
|||
})
|
||||
|
||||
describe('applyMobileDisplayMode', () => {
|
||||
it('desktop mode restores PTY when currently phone-fitted', () => {
|
||||
it('desktop mode restores PTY when currently phone-fitted', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
const resizeEvents: unknown[] = []
|
||||
runtime.subscribeToTerminalResize('pty-1', (event) => resizeEvents.push(event))
|
||||
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(resizeEvents).toHaveLength(1)
|
||||
|
|
@ -291,17 +296,17 @@ describe('mobile subscribe integration', () => {
|
|||
cols: 150,
|
||||
rows: 40,
|
||||
displayMode: 'desktop',
|
||||
reason: 'mode-change'
|
||||
reason: 'apply-layout'
|
||||
})
|
||||
})
|
||||
|
||||
it('auto mode re-fits PTY when subscriber exists and not phone-fitted', () => {
|
||||
it('auto mode re-fits PTY when subscriber exists and not phone-fitted', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
// Switch to desktop (restores to 150x40)
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
|
||||
const resizeEvents: unknown[] = []
|
||||
|
|
@ -309,25 +314,27 @@ describe('mobile subscribe integration', () => {
|
|||
|
||||
// Switch back to auto (should re-fit to phone dims)
|
||||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
expect(resizeEvents).toHaveLength(1)
|
||||
expect(resizeEvents[0]).toMatchObject({
|
||||
displayMode: 'auto',
|
||||
reason: 'mode-change'
|
||||
displayMode: 'phone',
|
||||
reason: 'apply-layout'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup paths', () => {
|
||||
it('onClientDisconnected restores all PTYs immediately (no debounce)', () => {
|
||||
it('onClientDisconnected restores all PTYs immediately (no debounce)', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-2', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
runtime.onClientDisconnected('client-a')
|
||||
// onClientDisconnected enqueues fire-and-forget; flush microtasks + 0ms timers.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// Both PTYs restored immediately
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
|
|
@ -336,22 +343,22 @@ describe('mobile subscribe integration', () => {
|
|||
expect(runtime.isMobileSubscriberActive('pty-2')).toBe(false)
|
||||
})
|
||||
|
||||
it('onClientDisconnected cancels pending restore timers', () => {
|
||||
it('onClientDisconnected cancels pending restore timers', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
// Timer is pending
|
||||
|
||||
runtime.onClientDisconnected('client-a')
|
||||
// Timer should be cancelled, PTY already restored by disconnect handler
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('onPtyExit cleans up mobileSubscribers and pending timers', () => {
|
||||
it('onPtyExit cleans up mobileSubscribers and pending timers', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
// Timer pending for pty-1
|
||||
|
||||
|
|
@ -360,31 +367,31 @@ describe('mobile subscribe integration', () => {
|
|||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
|
||||
|
||||
// Timer should have been cancelled — no crash from resizing a dead PTY
|
||||
vi.advanceTimersByTime(1000)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
it('onPtyExit does not cancel timers for other PTYs', () => {
|
||||
it('onPtyExit does not cancel timers for other PTYs', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
|
||||
|
||||
// pty-2 exits — should not affect pty-1's pending restore
|
||||
runtime.onPtyExit('pty-2', 0)
|
||||
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resize listener system', () => {
|
||||
it('subscribe/unsubscribe lifecycle', () => {
|
||||
it('subscribe/unsubscribe lifecycle', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
const events: unknown[] = []
|
||||
const unsubscribe = runtime.subscribeToTerminalResize('pty-1', (e) => events.push(e))
|
||||
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
|
||||
expect(events.length).toBeGreaterThan(0)
|
||||
|
||||
|
|
@ -393,36 +400,41 @@ describe('mobile subscribe integration', () => {
|
|||
|
||||
// After unsubscribe, no more events
|
||||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(events.length).toBe(countBefore)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onExternalPtyResize', () => {
|
||||
it('updates previousCols when desktop renderer resizes PTY after desktop restore', () => {
|
||||
it('updates previousCols when desktop renderer resizes PTY after desktop restore', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
// Toggle to desktop — restores to previousCols (150x40)
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
|
||||
// Phone→desktop arms the 500ms renderer-cascade suppress window per
|
||||
// docs/mobile-terminal-layout-state-machine.md. Wait it out before the
|
||||
// renderer's correcting fit is allowed to update lastRendererSizes.
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
// Simulate desktop renderer's safeFit correcting to split-pane width
|
||||
runtime.onExternalPtyResize('pty-1', 105, 40)
|
||||
|
||||
// Toggle back to auto — should capture previousCols=105 (not 150)
|
||||
runtime.setMobileDisplayMode('pty-1', 'auto')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Toggle to desktop again — should restore to 105 (the corrected value)
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 })
|
||||
})
|
||||
|
||||
it('uses lastRendererSize for previousCols on first subscribe', () => {
|
||||
it('uses lastRendererSize for previousCols on first subscribe', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
// Simulate: PTY spawned at 214 (ptySizes), but renderer already fit to 105
|
||||
|
|
@ -430,41 +442,46 @@ describe('mobile subscribe integration', () => {
|
|||
runtime.onExternalPtyResize('pty-1', 105, 40)
|
||||
|
||||
// First mobile subscribe — should use rendererSize (105) not ptySizes (214)
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Toggle to desktop — should restore to 105, not 214
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 105, rows: 40 })
|
||||
})
|
||||
|
||||
it('does not update previousCols when PTY is phone-fitted', () => {
|
||||
it('refreshes baseline on phone-fitted subscribers when their baseline is non-null', async () => {
|
||||
// Behavior change per docs/mobile-terminal-layout-state-machine.md:
|
||||
// legacy `!wasResizedToPhone` gate is replaced with `previousCols != null`.
|
||||
// A phone-fitted subscriber's baseline IS non-null (captured at subscribe),
|
||||
// so onExternalPtyResize now overwrites it with the renderer's reported geometry.
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
|
||||
|
||||
// PTY is phone-fitted (wasResizedToPhone=true) — external resize should not
|
||||
// overwrite previousCols with phone dims
|
||||
// Renderer reports 45x20 — under the new design, this overwrites baseline
|
||||
// (previously it was skipped because the subscriber was phone-fitted).
|
||||
runtime.onExternalPtyResize('pty-1', 45, 20)
|
||||
|
||||
// Toggle to desktop — should still restore to original 150x40
|
||||
// Toggle to desktop — restore lands on what the renderer reported (45x20),
|
||||
// not the original 150x40.
|
||||
runtime.setMobileDisplayMode('pty-1', 'desktop')
|
||||
runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
await runtime.applyMobileDisplayMode('pty-1')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('old resizeForClient still works alongside new system', () => {
|
||||
it('old resizeForClient still works alongside new system', async () => {
|
||||
const { runtime, ptySizes } = createRuntime()
|
||||
|
||||
// Old flow: explicit resizeForClient
|
||||
const fitResult = runtime.resizeForClient('pty-1', 'mobile-fit', 'client-old', 45, 20)
|
||||
const fitResult = await runtime.resizeForClient('pty-1', 'mobile-fit', 'client-old', 45, 20)
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
expect(fitResult.mode).toBe('mobile-fit')
|
||||
|
||||
// Old flow: restore
|
||||
const restoreResult = runtime.resizeForClient('pty-1', 'restore', 'client-old')
|
||||
const restoreResult = await runtime.resizeForClient('pty-1', 'restore', 'client-old')
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(restoreResult.mode).toBe('desktop-fit')
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -128,7 +128,12 @@ const TerminalSubscribe = TerminalHandle.extend({
|
|||
})
|
||||
|
||||
const TerminalSetDisplayMode = TerminalHandle.extend({
|
||||
mode: z.enum(['auto', 'phone', 'desktop']),
|
||||
// Why: 'phone' was previously a "stay at phone dims after unsubscribe"
|
||||
// mode that the toggle UI never produced and nothing in product
|
||||
// depended on. Removed in favor of two clean modes: 'auto' (mobile
|
||||
// drives dims while subscribed, desktop restores on last-leave) and
|
||||
// 'desktop' (no resize, mobile scales the wide canvas down to fit).
|
||||
mode: z.enum(['auto', 'desktop']),
|
||||
// Why: identifies the caller for the driver state machine. Optional for
|
||||
// backward compatibility with older mobile clients.
|
||||
client: z
|
||||
|
|
@ -136,6 +141,17 @@ const TerminalSetDisplayMode = TerminalHandle.extend({
|
|||
id: requiredString('Missing client ID'),
|
||||
type: z.enum(['mobile', 'desktop']).default('desktop').optional()
|
||||
})
|
||||
.optional(),
|
||||
// Why: subscribers that registered before viewport was measured have
|
||||
// a null viewport on their record. Toggling to 'auto' would no-op
|
||||
// because applyMobileDisplayMode skips phone-fit when viewport is
|
||||
// missing. Allow the toggle to carry the latest measured viewport so
|
||||
// the server can store it on the subscriber record before fitting.
|
||||
viewport: z
|
||||
.object({
|
||||
cols: z.number().int().positive(),
|
||||
rows: z.number().int().positive()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
|
|
@ -217,14 +233,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
// Why: deliberate mobile input is a take-floor action. Drives the
|
||||
// `* → mobile{clientId}` driver transition so the desktop banner
|
||||
// remounts (if previously reclaimed) and active phone-fit dims follow
|
||||
// the most recent actor. Only mobile-typed callers take the floor;
|
||||
// desktop callers (CLI / agents) do not. Older mobile builds without
|
||||
// a `client` field continue to work — the runtime then keeps the
|
||||
// current driver state.
|
||||
// the most recent actor. Only mobile-typed callers take the floor.
|
||||
if (params.client && params.client.type === 'mobile') {
|
||||
const leaf = runtime.resolveLeafForHandle(params.terminal)
|
||||
if (leaf?.ptyId) {
|
||||
runtime.mobileTookFloor(leaf.ptyId, params.client.id)
|
||||
await runtime.mobileTookFloor(leaf.ptyId, params.client.id)
|
||||
}
|
||||
}
|
||||
return { send: result }
|
||||
|
|
@ -273,7 +286,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
if (!leaf?.ptyId) {
|
||||
throw new Error('no_connected_pty')
|
||||
}
|
||||
const result = runtime.resizeForClient(
|
||||
const result = await runtime.resizeForClient(
|
||||
leaf.ptyId,
|
||||
params.mode,
|
||||
params.clientId,
|
||||
|
|
@ -310,16 +323,22 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
if (!leaf?.ptyId) {
|
||||
throw new Error('no_connected_pty')
|
||||
}
|
||||
// Why: late-bind viewport for callers that subscribed in desktop
|
||||
// mode (no viewport stored). Without this, a 'auto' toggle on a
|
||||
// viewport-less record skips phone-fit and the user sees no resize.
|
||||
if (params.viewport && params.client?.id) {
|
||||
runtime.updateMobileSubscriberViewport(leaf.ptyId, params.client.id, params.viewport)
|
||||
}
|
||||
runtime.setMobileDisplayMode(leaf.ptyId, params.mode)
|
||||
runtime.applyMobileDisplayMode(leaf.ptyId)
|
||||
await runtime.applyMobileDisplayMode(leaf.ptyId)
|
||||
// Why: a deliberate mobile mode change is a take-floor action when
|
||||
// moving to auto/phone (the user explicitly chose to drive at phone
|
||||
// dims). Setting mode to desktop is intentionally NOT a take-floor
|
||||
// action — that's a "watch from desktop dims" gesture.
|
||||
if (params.client && params.client.type === 'mobile' && params.mode !== 'desktop') {
|
||||
runtime.mobileTookFloor(leaf.ptyId, params.client.id)
|
||||
await runtime.mobileTookFloor(leaf.ptyId, params.client.id)
|
||||
}
|
||||
return { mode: params.mode }
|
||||
return { mode: params.mode, seq: runtime.getLayout(leaf.ptyId)?.seq }
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
|
|
@ -340,8 +359,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
if (!leaf?.ptyId) {
|
||||
throw new Error('no_connected_pty')
|
||||
}
|
||||
const updated = runtime.updateMobileViewport(leaf.ptyId, params.client.id, params.viewport)
|
||||
return { updated }
|
||||
const updated = await runtime.updateMobileViewport(
|
||||
leaf.ptyId,
|
||||
params.client.id,
|
||||
params.viewport
|
||||
)
|
||||
return { updated, seq: runtime.getLayout(leaf.ptyId)?.seq }
|
||||
}
|
||||
}),
|
||||
// Why: terminal.subscribe streams live terminal output over WebSocket.
|
||||
|
|
@ -384,9 +407,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
const ptyId = leaf.ptyId
|
||||
const clientId = params.client?.id
|
||||
|
||||
console.log('[fit][server] terminal.subscribe', {
|
||||
ptyId: ptyId.slice(-8),
|
||||
clientId: clientId?.slice(-8),
|
||||
isMobile,
|
||||
viewport: params.viewport
|
||||
})
|
||||
|
||||
// Server-side auto-fit: resize PTY to phone dims before serializing scrollback
|
||||
if (isMobile && clientId) {
|
||||
runtime.handleMobileSubscribe(ptyId, clientId, params.viewport)
|
||||
await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport)
|
||||
}
|
||||
|
||||
const read = await runtime.readTerminal(params.terminal)
|
||||
|
|
@ -395,6 +425,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
})
|
||||
const size = runtime.getTerminalSize(ptyId)
|
||||
const displayMode = runtime.getMobileDisplayMode(ptyId)
|
||||
// Why: emit the current layout seq with the initial scrollback so
|
||||
// the mobile client's stale-event filter knows the high-water mark.
|
||||
// Undefined when the PTY has never transitioned (filter is fail-open).
|
||||
// See docs/mobile-terminal-layout-state-machine.md.
|
||||
const seq = runtime.getLayout(ptyId)?.seq
|
||||
emit({
|
||||
type: 'scrollback',
|
||||
lines: read.tail,
|
||||
|
|
@ -402,7 +437,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
serialized: serialized?.data,
|
||||
cols: serialized?.cols ?? size?.cols,
|
||||
rows: serialized?.rows ?? size?.rows,
|
||||
displayMode
|
||||
displayMode,
|
||||
seq
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
|
|
@ -426,7 +462,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
rows: event.rows,
|
||||
serialized: fresh?.data,
|
||||
displayMode: event.displayMode,
|
||||
reason: event.reason
|
||||
reason: event.reason,
|
||||
seq: event.seq
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue