Fix restored mobile terminals and workspace visibility parity (#8789)

* Fix mobile cutover activation and usage refresh loops

Co-authored-by: Orca <help@stably.ai>

* Fix restored mobile terminal state parity

Co-authored-by: Orca <help@stably.ai>

* Fix migrated PTY workspace attribution

Co-authored-by: Orca <help@stably.ai>

* Fix overlapping mobile terminal surface swaps

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-07-14 19:36:47 -07:00
committed by GitHub
co-authored by Orca
parent d9f7fd08f7
commit 42ee45f392
19 changed files with 1240 additions and 96 deletions
+83 -64
View File
@@ -184,6 +184,7 @@ import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-n
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from '../../../../src/session/session-tab-snapshot-gate'
import {
@@ -197,8 +198,14 @@ import {
isDictationSetupRequiredError
} from '../../../../src/dictation/mobile-dictation-setup'
import { TerminalPaneView } from '../../../../src/session/TerminalPaneView'
import {
activateMobileSessionTab,
focusMobileTerminal
} from '../../../../src/session/mobile-session-tab-activation'
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
import {
getRepoIdFromMobileWorktreeId,
getActiveTabIdForHandle,
isFileExistsErrorMessage,
isGestureMouseTrackingMode,
MOBILE_SESSION_STATUS_LABELS,
@@ -206,7 +213,8 @@ import {
TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS,
TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES,
TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS,
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
updateTerminalCwdFromStreamEvent
} from '../../../../src/session/mobile-session-route-helpers'
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
@@ -245,21 +253,6 @@ type TerminalLiveAccessoryInput = ReturnType<typeof createTerminalLiveAccessoryI
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
function getActiveTabIdForHandle(
tabs: MobileSessionTab[],
terminalHandle: string | null
): string | null {
if (!terminalHandle) {
return null
}
return (
tabs.find(
(tab): tab is Extract<MobileSessionTab, { type: 'terminal' }> =>
tab.type === 'terminal' && tab.terminal === terminalHandle
)?.id ?? terminalHandle
)
}
function MarkdownReader({
documentId,
doc,
@@ -800,21 +793,6 @@ function FileReader({
return renderSourceText(doc.content)
}
function updateTerminalCwdFromStreamEvent(
handle: string,
data: Record<string, unknown>,
terminalCwd: Map<string, string>
): void {
if (!('cwd' in data)) {
return
}
if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
terminalCwd.set(handle, data.cwd)
return
}
terminalCwd.delete(handle)
}
export default function SessionScreen() {
const {
hostId,
@@ -1031,6 +1009,7 @@ export default function SessionScreen() {
const terminalUnsubsRef = useRef<Map<string, () => void>>(new Map())
const subscribingHandlesRef = useRef<Set<string>>(new Set())
const initializedHandlesRef = useRef<Set<string>>(new Set())
const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics())
// Why: WebViews load xterm.js from CDN asynchronously. Hidden WebViews
// (opacity:0) may have delayed JS execution on iOS. We must not subscribe
// until the WebView has fired web-ready, otherwise init() messages queue
@@ -1315,6 +1294,7 @@ export default function SessionScreen() {
terminalUnsubsRef.current.get(handle)?.()
terminalUnsubsRef.current.delete(handle)
subscribingHandlesRef.current.delete(handle)
terminalDiagnosticsRef.current.terminalUnsubscribed(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
@@ -1329,6 +1309,7 @@ export default function SessionScreen() {
terminalUnsubsRef.current.clear()
subscribingHandlesRef.current.clear()
initializedHandlesRef.current.clear()
terminalDiagnosticsRef.current.clearTerminalCache()
webReadyHandlesRef.current.clear()
subscribeSeqRef.current.clear()
layoutSeqRef.current.clear()
@@ -1350,6 +1331,7 @@ export default function SessionScreen() {
const dims = await getTerminalRef(handle)?.measureFitDimensions(
terminalFrameHeightRef.current || undefined
)
terminalDiagnosticsRef.current.viewportMeasured(handle, dims, terminalFrameHeightRef.current)
if (dims) {
viewportRef.current = dims
viewportMeasuredRef.current = true
@@ -1360,25 +1342,34 @@ export default function SessionScreen() {
const subscribeToTerminal = useCallback(
(handle: string) => {
const diagnostics = terminalDiagnosticsRef.current
const logSkippedGate = (reason: string) =>
diagnostics.streamSkipped(handle, reason, handle === activeHandleRef.current)
if (!client) {
logSkippedGate('no-client')
return
}
if (terminalUnsubsRef.current.has(handle)) {
logSkippedGate('already-subscribed')
return
}
if (subscribingHandlesRef.current.has(handle)) {
logSkippedGate('subscribe-in-flight')
return
}
if (!getTerminalRef(handle)) {
logSkippedGate('no-webview-ref')
return
}
if (!webReadyHandlesRef.current.has(handle)) {
logSkippedGate('webview-not-ready')
return
}
subscribingHandlesRef.current.add(handle)
const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1
subscribeSeqRef.current.set(handle, seq)
diagnostics.streamArmed(handle, seq, viewportRef.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
@@ -1397,6 +1388,7 @@ export default function SessionScreen() {
return
}
const data = result as Record<string, unknown>
diagnostics.firstStreamEvent(handle, seq, data.type)
// 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
@@ -1429,6 +1421,7 @@ export default function SessionScreen() {
return
}
if (data.type === 'scrollback') {
diagnostics.streamScrollback(handle, seq, eventSeq, data)
if (initializedHandlesRef.current.has(handle)) {
return
}
@@ -1520,6 +1513,7 @@ export default function SessionScreen() {
// server still has a null viewport for THIS subscriber
// record — we MUST resubscribe so the server stores it.
if (dims) {
diagnostics.streamResubscribing(handle, seq, dims)
viewportRef.current = dims
viewportMeasuredRef.current = true
unsubscribeTerminal(handle)
@@ -1564,6 +1558,7 @@ export default function SessionScreen() {
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const serialized = typeof data.serialized === 'string' ? data.serialized : null
diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null)
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
if (serialized != null) {
getTerminalRef(handle)?.init(cols, rows, serialized, true, oscLinks)
@@ -1733,6 +1728,7 @@ export default function SessionScreen() {
const applySessionTabs = useCallback(
(result: SessionTabsResult) => {
const diagnostics = terminalDiagnosticsRef.current
// Reject out-of-order snapshots, then suppress just-closed tabs until the
// publisher confirms their absence. See session-tab-snapshot-gate.
if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) {
@@ -1789,15 +1785,21 @@ export default function SessionScreen() {
const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current
const pendingActiveTerminalHandle = pendingActiveTerminalHandleRef.current
let active = snapshotActive
let selectionSource = 'snapshot'
if (pendingActiveSessionTabId) {
if (snapshotActive?.id === pendingActiveSessionTabId) {
pendingActiveSessionTabIdRef.current = null
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
pendingActiveSessionTabIdRef.current = null
} else {
selectionSource = 'pending-tab-local-ack'
}
} else {
const pendingTab = nextTabs.find((tab) => tab.id === pendingActiveSessionTabId)
if (pendingTab) {
// Why: desktop tab snapshots can lag a mobile tap while activate RPC
// is in flight. Keep the locally selected tab to avoid snapping back.
active = pendingTab
selectionSource = 'pending-tab'
} else {
pendingActiveSessionTabIdRef.current = null
}
@@ -1815,12 +1817,17 @@ export default function SessionScreen() {
snapshotActive?.type === 'terminal' &&
snapshotActive.terminal === pendingActiveTerminalHandle
) {
pendingActiveTerminalHandleRef.current = null
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
pendingActiveTerminalHandleRef.current = null
} else {
selectionSource = 'pending-handle-local-ack'
}
} else if (pendingTerminalTab) {
// Why: desktop active flags can lag a mobile terminal tap. Key by
// terminal handle too, because fallback PTY tabs may not yet have a
// stable session tab id during new-worktree startup.
active = pendingTerminalTab
selectionSource = 'pending-handle-tab'
} else if (pendingTerminalExists) {
const nextActiveTabId = getActiveTabIdForHandle(nextTabs, pendingActiveTerminalHandle)
activeSessionTabIdRef.current = nextActiveTabId
@@ -1833,6 +1840,7 @@ export default function SessionScreen() {
pendingActiveTerminalHandleRef.current = null
}
}
diagnostics.tabsApplied(result, nextTabs, active, selectionSource)
activeSessionTabTypeRef.current = active?.type ?? null
activeSessionTabIdRef.current = active?.id ?? null
setActiveSessionTabId(active?.id ?? null)
@@ -2377,20 +2385,25 @@ export default function SessionScreen() {
const fetchSessionTabs = useCallback(async () => {
if (!client) {
terminalDiagnosticsRef.current.tabsFetchSkipped('no-client')
return
}
if (fetchSessionTabsInFlightRef.current) {
terminalDiagnosticsRef.current.tabsFetchSkipped('already-in-flight')
return
}
fetchSessionTabsInFlightRef.current = true
terminalDiagnosticsRef.current.tabsFetchStarted(worktreeId)
try {
const response = await client.sendRequest('session.tabs.list', {
worktree: `id:${worktreeId}`
})
if (!response.ok) {
terminalDiagnosticsRef.current.tabsFetchFailed((response as RpcFailure).error.code)
return
}
const result = (response as RpcSuccess).result as SessionTabsResult
terminalDiagnosticsRef.current.tabsFetchSucceeded(result)
applySessionTabs(result)
// Focus a just-opened browser tab once it appears in the snapshot, via the
// normal activate path so it sticks and the user can still switch away.
@@ -2404,7 +2417,8 @@ export default function SessionScreen() {
switchSessionTabRef.current?.(browserTab)
}
}
} catch {
} catch (error) {
terminalDiagnosticsRef.current.tabsFetchErrored(error)
// Keep the last tab snapshot visible during reconnect/backoff.
} finally {
fetchSessionTabsInFlightRef.current = false
@@ -2689,6 +2703,7 @@ export default function SessionScreen() {
pendingBrowserFocusPageIdRef.current = null
pendingTerminalActivationAttemptRef.current = null
initialEmptySessionAutoCreateRef.current = null
terminalDiagnosticsRef.current.resetRoute()
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
closedTabTombstonesRef.current.clear()
for (const queued of terminalGestureInputQueuesRef.current.values()) {
@@ -2912,6 +2927,7 @@ export default function SessionScreen() {
(tab): tab is Extract<MobileSessionTab, { type: 'terminal' }> =>
tab.type === 'terminal' && tab.terminal === handle
)
terminalDiagnosticsRef.current.tabSwitch('terminal', matchingTab?.id ?? '', false, handle)
pendingActiveSessionTabIdRef.current = matchingTab?.id ?? null
pendingActiveTerminalHandleRef.current = handle
activeSessionTabTypeRef.current = 'terminal'
@@ -2931,15 +2947,15 @@ export default function SessionScreen() {
}
subscribeToTerminal(handle)
if (client) {
void client.sendRequest('terminal.focus', { terminal: handle }).catch(() => {})
void focusMobileTerminal(client, handle).catch(() => {})
if (matchingTab) {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id,
notifyClients: false
})
.catch(() => {})
// Why: persist selection for headless hosts; the snapshot gate keeps
// this phone-local acknowledgement from impersonating desktop focus.
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id,
notifyClients: false
}).catch(() => {})
}
}
},
@@ -2960,6 +2976,7 @@ export default function SessionScreen() {
switchTab(tab.terminal)
return
}
terminalDiagnosticsRef.current.tabSwitch('terminal', tab.id, true)
triggerSelection()
pendingActiveSessionTabIdRef.current = tab.id
pendingActiveTerminalHandleRef.current = null
@@ -2973,18 +2990,17 @@ export default function SessionScreen() {
activeHandleRef.current = null
setActiveHandle(null)
if (client) {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
})
.catch(() => {})
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
}).catch(() => {})
}
return
}
triggerSelection()
terminalDiagnosticsRef.current.tabSwitch(tab.type, tab.id, false)
pendingActiveSessionTabIdRef.current = tab.id
pendingActiveTerminalHandleRef.current = null
activeSessionTabTypeRef.current = tab.type
@@ -2997,13 +3013,11 @@ export default function SessionScreen() {
activeHandleRef.current = null
setActiveHandle(null)
if (client) {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
})
.catch(() => {})
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
}).catch(() => {})
}
if (tab.type === 'browser') {
return
@@ -3031,6 +3045,7 @@ export default function SessionScreen() {
// init messages. This prevents the blank terminal race where init() was
// queued before the WebView loaded.
const setTerminalWebViewRef = useCallback((handle: string, ref: TerminalWebViewHandle | null) => {
terminalDiagnosticsRef.current.webViewRef(handle, ref != null)
if (ref) {
terminalRefs.current.set(handle, ref)
} else {
@@ -3049,6 +3064,11 @@ export default function SessionScreen() {
(handle: string) => {
const wasAlreadyReady = webReadyHandlesRef.current.has(handle)
webReadyHandlesRef.current.add(handle)
terminalDiagnosticsRef.current.webViewReady(
handle,
wasAlreadyReady,
handle === activeHandleRef.current
)
if (wasAlreadyReady && initializedHandlesRef.current.has(handle)) {
// Why: the native WebView reloaded (Metro hot reload or Android
// process churn). The old xterm buffer is gone, so force a fresh
@@ -3085,7 +3105,7 @@ export default function SessionScreen() {
})()
}
},
[measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
[getTerminalRef, measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
)
useEffect(() => {
@@ -4302,13 +4322,12 @@ export default function SessionScreen() {
// Why: a hydrated headless/server-owned tab can already be active but still
// pending; activation is the RPC that materializes or focuses its PTY handle.
pendingTerminalActivationAttemptRef.current = activationKey
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: activePendingTerminalTab.id,
leafId: activePendingTerminalTab.leafId,
notifyClients: false
})
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: activePendingTerminalTab.id,
leafId: activePendingTerminalTab.leafId,
notifyClients: false
})
.then((response) => {
if (!response.ok) {
if (pendingTerminalActivationAttemptRef.current === activationKey) {
@@ -33,3 +33,31 @@ export function isGestureMouseTrackingMode(
): boolean {
return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any'
}
export function getActiveTabIdForHandle(
tabs: ReadonlyArray<{ id: string; type: string; terminal?: string | null }>,
terminalHandle: string | null
): string | null {
if (!terminalHandle) {
return null
}
return (
tabs.find((tab) => tab.type === 'terminal' && tab.terminal === terminalHandle)?.id ??
terminalHandle
)
}
export function updateTerminalCwdFromStreamEvent(
handle: string,
data: Readonly<Record<string, unknown>>,
terminalCwd: Map<string, string>
): void {
if (!('cwd' in data)) {
return
}
if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
terminalCwd.set(handle, data.cwd)
return
}
terminalCwd.delete(handle)
}
@@ -58,7 +58,7 @@ describe('mobile session startup', () => {
expect(pendingActivationEffect).toContain(
'pendingTerminalActivationAttemptRef.current === activationKey'
)
expect(pendingActivationEffect).toContain("sendRequest('session.tabs.activate'")
expect(pendingActivationEffect).toContain('activateMobileSessionTab(client,')
expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id')
expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId')
expect(pendingActivationEffect).toContain('notifyClients: false')
@@ -68,8 +68,19 @@ describe('mobile session startup', () => {
expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()')
})
it('keeps mobile session tab activation local to the phone', () => {
const activationRequests = source.split("sendRequest('session.tabs.activate'").slice(1)
it('mirrors ready terminal taps while persisting them for headless hosts', () => {
const readyTerminalSwitch = sliceBetween(
'const switchTab = useCallback(',
'const switchSessionTab = useCallback('
)
expect(readyTerminalSwitch).toContain('focusMobileTerminal(client, handle)')
expect(readyTerminalSwitch).toContain('activateMobileSessionTab(client,')
expect(readyTerminalSwitch).toContain('notifyClients: false')
})
it('keeps background and pending session-tab activation local to the phone', () => {
const activationRequests = source.split('activateMobileSessionTab(client,').slice(1)
expect(activationRequests).toHaveLength(4)
for (const request of activationRequests) {
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import { activateMobileSessionTab, focusMobileTerminal } from './mobile-session-tab-activation'
function success(): RpcResponse {
return { id: 'rpc-1', ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } }
}
function clientWith(sendRequest: RpcClient['sendRequest']): Pick<RpcClient, 'sendRequest'> {
return { sendRequest }
}
describe('mobile session tab activation', () => {
it('retries terminal focus once on the authenticated replacement after cutover', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValueOnce(new LogicalClientCutoverError())
.mockResolvedValueOnce(success())
await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).resolves.toMatchObject(
{
ok: true
}
)
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', { terminal: 'terminal-1' })
expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', { terminal: 'terminal-1' })
})
it('retries session-tab activation with the same target after cutover', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValueOnce(new LogicalClientCutoverError())
.mockResolvedValueOnce(success())
const params = {
worktree: 'id:worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
notifyClients: false as const
}
await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({
ok: true
})
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.activate', params)
expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', params)
})
it('does not retry unrelated transport failures', async () => {
const sendRequest = vi.fn<RpcClient['sendRequest']>().mockRejectedValue(new Error('offline'))
await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).rejects.toThrow(
'offline'
)
expect(sendRequest).toHaveBeenCalledOnce()
})
it('retries at most once when consecutive cutovers interrupt activation', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValue(new LogicalClientCutoverError())
await expect(
activateMobileSessionTab(clientWith(sendRequest), {
worktree: 'id:worktree-1',
tabId: 'tab-1',
notifyClients: false
})
).rejects.toBeInstanceOf(LogicalClientCutoverError)
expect(sendRequest).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,90 @@
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcResponse } from '../transport/types'
import {
getMobileTerminalDiagnosticErrorName,
logMobileTerminalDiagnostic,
shortenMobileTerminalDiagnosticId
} from './mobile-terminal-diagnostics'
type ActivationClient = Pick<RpcClient, 'sendRequest'>
type MobileSessionTabActivationParams = {
worktree: string
tabId: string
leafId?: string
notifyClients: false
}
async function retryIdempotentActivationAfterCutover(
request: () => Promise<RpcResponse>,
operation: 'terminal.focus' | 'session.tabs.activate',
target: string
): Promise<RpcResponse> {
const diagnosticTarget = shortenMobileTerminalDiagnosticId(target)
logMobileTerminalDiagnostic('activation-request', { operation, target: diagnosticTarget })
try {
const response = await request()
logMobileTerminalDiagnostic('activation-result', {
operation,
target: diagnosticTarget,
ok: response.ok,
rpcCode: response.ok ? null : response.error.code
})
return response
} catch (error) {
if (!(error instanceof LogicalClientCutoverError)) {
logMobileTerminalDiagnostic('activation-error', {
operation,
target: diagnosticTarget,
errorName: getMobileTerminalDiagnosticErrorName(error)
})
throw error
}
logMobileTerminalDiagnostic('activation-cutover-retry', {
operation,
target: diagnosticTarget
})
// Why: cutover rejects ambiguous in-flight work after the replacement is
// active; these state-setting requests are idempotent and safe to repeat once.
try {
const response = await request()
logMobileTerminalDiagnostic('activation-result', {
operation,
target: diagnosticTarget,
ok: response.ok,
rpcCode: response.ok ? null : response.error.code
})
return response
} catch (retryError) {
logMobileTerminalDiagnostic('activation-error', {
operation,
target: diagnosticTarget,
errorName: getMobileTerminalDiagnosticErrorName(retryError)
})
throw retryError
}
}
}
export function focusMobileTerminal(
client: ActivationClient,
terminal: string
): Promise<RpcResponse> {
return retryIdempotentActivationAfterCutover(
() => client.sendRequest('terminal.focus', { terminal }),
'terminal.focus',
terminal
)
}
export function activateMobileSessionTab(
client: ActivationClient,
params: MobileSessionTabActivationParams
): Promise<RpcResponse> {
return retryIdempotentActivationAfterCutover(
() => client.sendRequest('session.tabs.activate', params),
'session.tabs.activate',
params.tabId
)
}
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import {
getMobileTerminalDiagnosticErrorName,
logMobileTerminalDiagnostic,
MobileTerminalDiagnostics,
shortenMobileTerminalDiagnosticId
} from './mobile-terminal-diagnostics'
describe('mobile terminal diagnostics', () => {
it('keeps only the correlatable suffix of identifiers', () => {
expect(shortenMobileTerminalDiagnosticId('terminal-secret-prefix-12345678')).toBe('12345678')
expect(shortenMobileTerminalDiagnosticId('short')).toBe('short')
expect(shortenMobileTerminalDiagnosticId(null)).toBeNull()
})
it('reports thrown error types without copying potentially sensitive messages', () => {
expect(getMobileTerminalDiagnosticErrorName(new TypeError('/private/worktree failed'))).toBe(
'TypeError'
)
expect(getMobileTerminalDiagnosticErrorName('raw failure')).toBe('string')
})
it('uses one filterable structured log tag', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
logMobileTerminalDiagnostic('stream-armed', { handle: '12345678', seq: 2 })
expect(log).toHaveBeenCalledWith('[terminal-diagnostic]', 'stream-armed', {
handle: '12345678',
seq: 2
})
log.mockRestore()
})
it('forgets first-event state when a terminal unsubscribes', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const diagnostics = new MobileTerminalDiagnostics()
diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed')
diagnostics.terminalUnsubscribed('terminal-1')
diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed')
expect(log).toHaveBeenCalledTimes(2)
log.mockRestore()
})
})
@@ -0,0 +1,291 @@
const MOBILE_TERMINAL_DIAGNOSTIC_TAG = '[terminal-diagnostic]'
type MobileTerminalDiagnosticValue = string | number | boolean | null | undefined
export type MobileTerminalDiagnosticDetails = Readonly<
Record<string, MobileTerminalDiagnosticValue>
>
type DiagnosticTab = {
readonly id: string
readonly type: string
readonly isActive: boolean
readonly terminal?: string | null
}
type DiagnosticTabsSnapshot = {
readonly publicationEpoch?: string
readonly snapshotVersion: number
readonly tabs: readonly DiagnosticTab[]
}
type DiagnosticDimensions = { readonly cols: number; readonly rows: number } | null | undefined
// Why: full runtime identifiers make shared logs unnecessarily sensitive; the
// suffix is enough to correlate lifecycle events within one reproduction.
export function shortenMobileTerminalDiagnosticId(value: string | null | undefined): string | null {
if (!value) {
return null
}
return value.slice(-8)
}
export function getMobileTerminalDiagnosticErrorName(error: unknown): string {
if (error instanceof Error && error.name) {
return error.name
}
return typeof error
}
export function logMobileTerminalDiagnostic(
event: string,
details: MobileTerminalDiagnosticDetails = {}
): void {
// Why: lifecycle diagnostics are intentionally available for HMR repros,
// but high-frequency WebView events must not add production log overhead.
if (typeof __DEV__ !== 'undefined' && !__DEV__) {
return
}
// Keep this structured and content-free so users can safely share a filtered log.
console.log(MOBILE_TERMINAL_DIAGNOSTIC_TAG, event, details)
}
export class MobileTerminalDiagnostics {
private readonly streamGateByHandle = new Map<string, string>()
private readonly firstStreamEventSeqByHandle = new Map<string, number>()
private lastFetchedTabsSignature: string | null = null
private lastAppliedTabsSignature: string | null = null
private lastTabsFetchStartAt = 0
private tabsFetchSkipLogged = false
clearTerminalCache(): void {
this.streamGateByHandle.clear()
this.firstStreamEventSeqByHandle.clear()
}
resetRoute(): void {
this.clearTerminalCache()
this.lastFetchedTabsSignature = null
this.lastAppliedTabsSignature = null
this.lastTabsFetchStartAt = 0
this.tabsFetchSkipLogged = false
}
terminalUnsubscribed(handle: string): void {
this.streamGateByHandle.delete(handle)
this.firstStreamEventSeqByHandle.delete(handle)
}
viewportMeasured(handle: string, dims: DiagnosticDimensions, frameHeight: number): void {
logMobileTerminalDiagnostic('viewport-measure', {
handle: shortenMobileTerminalDiagnosticId(handle),
ok: dims != null,
cols: dims?.cols,
rows: dims?.rows,
frameHeight: Math.round(frameHeight)
})
}
streamSkipped(handle: string, reason: string, isActive: boolean): void {
if (this.streamGateByHandle.get(handle) === reason) {
return
}
this.streamGateByHandle.set(handle, reason)
logMobileTerminalDiagnostic('stream-skipped', {
handle: shortenMobileTerminalDiagnosticId(handle),
reason,
isActive
})
}
streamArmed(handle: string, seq: number, viewport: DiagnosticDimensions): void {
this.streamGateByHandle.delete(handle)
logMobileTerminalDiagnostic('stream-armed', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
hasViewport: viewport != null,
viewportCols: viewport?.cols,
viewportRows: viewport?.rows
})
}
firstStreamEvent(handle: string, seq: number, type: unknown): void {
if (this.firstStreamEventSeqByHandle.get(handle) === seq) {
return
}
this.firstStreamEventSeqByHandle.set(handle, seq)
logMobileTerminalDiagnostic('stream-first-event', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
type: typeof type === 'string' ? type : 'unknown'
})
}
streamScrollback(
handle: string,
seq: number,
eventSeq: number | null,
data: Readonly<Record<string, unknown>>
): void {
logMobileTerminalDiagnostic('stream-scrollback', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
eventSeq,
cols: typeof data.cols === 'number' ? data.cols : null,
rows: typeof data.rows === 'number' ? data.rows : null,
serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0,
displayMode: typeof data.displayMode === 'string' ? data.displayMode : null,
source: typeof data.source === 'string' ? data.source : null,
scrollbackRows: typeof data.scrollbackRows === 'number' ? data.scrollbackRows : null,
truncated: data.truncated === true || data.truncatedByByteBudget === true
})
}
streamResubscribing(handle: string, seq: number, dims: { cols: number; rows: number }): void {
logMobileTerminalDiagnostic('stream-resubscribe-for-viewport', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
cols: dims.cols,
rows: dims.rows
})
}
streamResized(
handle: string,
seq: number,
eventSeq: number | null,
data: Readonly<Record<string, unknown>>,
hasRef: boolean
): void {
logMobileTerminalDiagnostic('stream-resized', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
eventSeq,
cols: typeof data.cols === 'number' ? data.cols : null,
rows: typeof data.rows === 'number' ? data.rows : null,
serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0,
hasRef
})
}
tabsApplied(
snapshot: DiagnosticTabsSnapshot,
tabs: readonly DiagnosticTab[],
activeTab: DiagnosticTab | null,
selectionSource: string
): void {
const activeHandle =
activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string'
? activeTab.terminal
: null
const appliedSnapshot = { ...snapshot, tabs }
const signature = [
appliedSnapshot.publicationEpoch ?? '',
appliedSnapshot.snapshotVersion,
activeTab?.id ?? '',
activeHandle ?? '',
selectionSource
].join(':')
if (this.lastAppliedTabsSignature === signature) {
return
}
this.lastAppliedTabsSignature = signature
this.logTabs('tabs-applied', appliedSnapshot, activeTab, activeHandle, { selectionSource })
}
tabsFetchSkipped(reason: string): void {
if (reason === 'already-in-flight' && this.tabsFetchSkipLogged) {
return
}
this.tabsFetchSkipLogged = reason === 'already-in-flight'
logMobileTerminalDiagnostic('tabs-fetch-skipped', { reason })
}
tabsFetchStarted(worktreeId: string): void {
this.tabsFetchSkipLogged = false
const now = Date.now()
if (this.lastFetchedTabsSignature != null && now - this.lastTabsFetchStartAt < 10_000) {
return
}
this.lastTabsFetchStartAt = now
logMobileTerminalDiagnostic('tabs-fetch-start', {
worktree: shortenMobileTerminalDiagnosticId(worktreeId)
})
}
tabsFetchFailed(rpcCode: string): void {
logMobileTerminalDiagnostic('tabs-fetch-rpc-failure', { rpcCode })
}
tabsFetchErrored(error: unknown): void {
logMobileTerminalDiagnostic('tabs-fetch-error', {
errorName: getMobileTerminalDiagnosticErrorName(error)
})
}
tabsFetchSucceeded(snapshot: DiagnosticTabsSnapshot): void {
const activeTab = snapshot.tabs.find((tab) => tab.isActive) ?? null
const signature = [
snapshot.publicationEpoch ?? '',
snapshot.snapshotVersion,
snapshot.tabs.length,
activeTab?.id ?? '',
activeTab?.type ?? ''
].join(':')
if (this.lastFetchedTabsSignature === signature) {
return
}
this.lastFetchedTabsSignature = signature
const activeHandle =
activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string'
? activeTab.terminal
: null
this.logTabs('tabs-fetch-success', snapshot, activeTab, activeHandle)
}
tabSwitch(tabType: string, tabId: string, pending: boolean, handle?: string): void {
logMobileTerminalDiagnostic('tab-switch', {
tabType,
tab: shortenMobileTerminalDiagnosticId(tabId),
handle: shortenMobileTerminalDiagnosticId(handle),
pending
})
}
webViewRef(handle: string, attached: boolean): void {
logMobileTerminalDiagnostic('webview-ref', {
handle: shortenMobileTerminalDiagnosticId(handle),
attached
})
}
webViewReady(handle: string, reload: boolean, isActive: boolean): void {
logMobileTerminalDiagnostic('webview-ready', {
handle: shortenMobileTerminalDiagnosticId(handle),
reload,
isActive
})
}
private logTabs(
event: 'tabs-applied' | 'tabs-fetch-success',
snapshot: DiagnosticTabsSnapshot,
activeTab: DiagnosticTab | null,
activeHandle: string | null,
extra: MobileTerminalDiagnosticDetails = {}
): void {
logMobileTerminalDiagnostic(event, {
publication: shortenMobileTerminalDiagnosticId(snapshot.publicationEpoch),
snapshotVersion: snapshot.snapshotVersion,
tabCount: snapshot.tabs.length,
terminalTabCount: snapshot.tabs.filter((tab) => tab.type === 'terminal').length,
pendingTerminalCount: snapshot.tabs.filter(
(tab) => tab.type === 'terminal' && typeof tab.terminal !== 'string'
).length,
activeTab: shortenMobileTerminalDiagnosticId(activeTab?.id),
activeType: activeTab?.type ?? null,
activeHandle: shortenMobileTerminalDiagnosticId(activeHandle),
...extra
})
}
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from './session-tab-snapshot-gate'
@@ -50,6 +51,18 @@ describe('acceptSessionSnapshot', () => {
})
})
describe('confirmsMirroredTabSelection', () => {
it('does not treat phone-local persistence as desktop focus confirmation', () => {
expect(confirmsMirroredTabSelection('mobile-local:abc')).toBe(false)
})
it('accepts renderer, headless, and legacy publications as confirmation', () => {
expect(confirmsMirroredTabSelection('renderer:abc')).toBe(true)
expect(confirmsMirroredTabSelection('headless:abc')).toBe(true)
expect(confirmsMirroredTabSelection()).toBe(true)
})
})
describe('applyClosedTabTombstones', () => {
const tab = (id: string): { id: string } => ({ id })
@@ -33,6 +33,12 @@ export function acceptSessionSnapshot(
return true
}
export function confirmsMirroredTabSelection(publicationEpoch?: string): boolean {
// Why: phone-local persistence acknowledges the mutation, not host focus;
// keep the pending guard until any host publication confirms the selection.
return !publicationEpoch?.startsWith('mobile-local:')
}
/**
* Drops tabs the user just closed locally (tombstoned) until the publisher's
* snapshot also drops them or the tombstone expires. Mutates `tombstones`,
+7 -24
View File
@@ -6,6 +6,7 @@ import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
import { TERMINAL_SURFACE_SWAP_JS } from './terminal-webview-surface-swap-injected'
import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected'
import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
@@ -217,6 +218,7 @@ window.onerror = function(msg) {
var statusDotPendingSelector = false;
var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
var term = null; ${TERMINAL_QUERY_REPLY_JS}
${TERMINAL_SURFACE_SWAP_JS}
var scrollIndicator = document.getElementById('scroll-indicator');
var scrollThumb = document.getElementById('scroll-thumb');
var scrollIndicatorHideTimer = null;
@@ -713,22 +715,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : [];
initialOscLinkRowOffset = 0;
initialOscLinkEvictionReady = false;
var oldTerm = term;
var oldSurface = surface;
var nextSurface = null;
disposeTermObservers();
if (oldTerm) {
nextSurface = document.createElement('div');
nextSurface.id = 'terminal-surface';
nextSurface.style.visibility = 'hidden';
nextSurface.style.position = 'absolute';
nextSurface.style.left = '0';
nextSurface.style.top = '0';
document.getElementById('terminal-container').appendChild(nextSurface);
surface = nextSurface;
attachSurfaceEventHandlers(surface);
oldSurface.removeAttribute('id');
}
var surfaceSwap = beginTerminalSurfaceSwap();
var nextSurface = surfaceSwap.nextSurface;
applyTerminalTheme(nextTheme);
term = new Terminal({
@@ -749,6 +737,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
convertEol: false,
allowProposedApi: true
});
var nextTerm = term;
pendingTerm = nextTerm;
term.open(surface);
attachWebglAddon(true);
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
@@ -768,14 +758,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
everReady = true;
afterWritesDrained(function() {
if (gen !== terminalGeneration) return;
if (nextSurface && oldSurface) {
nextSurface.style.visibility = 'visible';
nextSurface.style.position = '';
nextSurface.style.left = '';
nextSurface.style.top = '';
oldSurface.remove();
if (oldTerm) oldTerm.dispose();
}
commitTerminalSurfaceSwap(surfaceSwap, nextTerm);
// Why: restore the reader's place after the rewrapped buffer replays.
// Replay lands at bottom, so only act when they were scrolled up (rows>0).
if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) {
@@ -0,0 +1,132 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { XTERM_HTML } from './terminal-webview-html'
function iifeSource(): string {
const start = XTERM_HTML.indexOf('(function() {')
const end = XTERM_HTML.lastIndexOf('})();')
return XTERM_HTML.slice(start, end + '})();'.length)
}
function bodyMarkup(): string {
const start = XTERM_HTML.indexOf('<body>') + '<body>'.length
const end = XTERM_HTML.indexOf('<script>', start)
return XTERM_HTML.slice(start, end)
}
type TerminalStub = ReturnType<typeof makeTerminal>
function makeTerminal(writeCallbacks: Array<() => void>) {
const terminal = {
cols: 80,
rows: 24,
options: { fontSize: 13 },
modes: {},
element: null as HTMLElement | null,
disposed: false,
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } } },
buffer: {
active: {
viewportY: 0,
baseY: 0,
length: 1,
cursorY: 0,
type: 'normal' as const,
getLine: () => null
}
},
write(_data: string, callback?: () => void) {
if (callback) {
writeCallbacks.push(callback)
}
},
open(surface: HTMLElement) {
terminal.element = surface
},
loadAddon() {},
resize(cols: number, rows: number) {
terminal.cols = cols
terminal.rows = rows
},
clear() {},
reset() {},
refresh() {},
selectAll() {},
clearSelection() {},
select() {},
scrollLines() {},
scrollToBottom() {},
scrollToLine() {},
getSelection: () => '',
onData: () => ({ dispose() {} }),
onLineFeed: () => ({ dispose() {} }),
onScroll: () => ({ dispose() {} }),
onWriteParsed: () => ({ dispose() {} }),
dispose() {
terminal.disposed = true
}
}
return terminal
}
function dispatchInit(cols: number, initialData: string): void {
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'init', cols, rows: 40, initialData })
})
)
}
describe('terminal WebView init surface replacement', () => {
let animationFrames: Array<() => void>
let terminals: TerminalStub[]
let writeCallbacks: Array<() => void>
beforeEach(() => {
animationFrames = []
terminals = []
writeCallbacks = []
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
animationFrames.push(callback)
return animationFrames.length
})
Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true })
const webWindow = window as unknown as {
Terminal: new () => TerminalStub
ReactNativeWebView: { postMessage: (data: string) => void }
}
webWindow.Terminal = function () {
const terminal = makeTerminal(writeCallbacks)
terminals.push(terminal)
return terminal
} as unknown as new () => TerminalStub
webWindow.ReactNativeWebView = { postMessage: vi.fn() }
document.body.innerHTML = bodyMarkup()
// eslint-disable-next-line no-new-func
new Function(iifeSource())()
})
it('commits only the newest surface when phone-fit init calls overlap', () => {
// Why: restored terminals can receive desktop scrollback, a phone resize,
// and phone scrollback before any xterm replay callback has completed.
dispatchInit(120, 'desktop')
animationFrames.shift()?.()
dispatchInit(51, 'phone-resize')
animationFrames.shift()?.()
dispatchInit(51, 'phone-scrollback')
animationFrames.shift()?.()
expect(terminals).toHaveLength(3)
expect(writeCallbacks).toHaveLength(3)
writeCallbacks[2]?.()
writeCallbacks[0]?.()
writeCallbacks[1]?.()
const surfaces = document.querySelectorAll('#terminal-container > div')
expect(surfaces).toHaveLength(1)
expect(surfaces[0]?.id).toBe('terminal-surface')
expect((surfaces[0] as HTMLElement).style.visibility).toBe('visible')
expect(terminals.map((terminal) => terminal.disposed)).toEqual([true, true, false])
})
})
@@ -0,0 +1,49 @@
export const TERMINAL_SURFACE_SWAP_JS = String.raw`
// Why: phone-fit startup can issue several init() calls before xterm finishes
// replaying. Track the last painted surface separately from its replacement.
var committedTerm = null;
var committedSurface = surface;
var pendingTerm = null;
var pendingSurface = null;
function beginTerminalSurfaceSwap() {
// Why: a superseded hidden replacement must not remain between the last
// painted surface and the newest one, or the newest commits below the viewport.
if (pendingSurface) {
try { pendingSurface.remove(); } catch (e) {}
if (pendingTerm) try { pendingTerm.dispose(); } catch (e) {}
pendingSurface = null;
pendingTerm = null;
}
var swap = {
oldTerm: committedTerm,
oldSurface: committedSurface,
nextSurface: document.createElement('div')
};
disposeTermObservers();
swap.nextSurface.id = 'terminal-surface';
swap.nextSurface.style.visibility = 'hidden';
swap.nextSurface.style.position = 'absolute';
swap.nextSurface.style.left = '0';
swap.nextSurface.style.top = '0';
document.getElementById('terminal-container').appendChild(swap.nextSurface);
surface = swap.nextSurface;
pendingSurface = swap.nextSurface;
attachSurfaceEventHandlers(surface);
swap.oldSurface.removeAttribute('id');
return swap;
}
function commitTerminalSurfaceSwap(swap, nextTerm) {
swap.nextSurface.style.visibility = 'visible';
swap.nextSurface.style.position = '';
swap.nextSurface.style.left = '';
swap.nextSurface.style.top = '';
swap.oldSurface.remove();
if (swap.oldTerm) swap.oldTerm.dispose();
committedTerm = nextTerm;
committedSurface = swap.nextSurface;
pendingTerm = null;
pendingSurface = null;
}
`
+51
View File
@@ -764,6 +764,39 @@ describe('RateLimitService', () => {
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
})
it('does not refetch fresh provider data for replayed mobile subscriptions', async () => {
const service = new RateLimitService()
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10))
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20))
await service.refreshIfStale()
await service.refreshIfStale()
await service.refreshIfStale()
expect(fetchClaudeRateLimits).toHaveBeenCalledOnce()
expect(fetchCodexRateLimits).toHaveBeenCalledOnce()
})
it('does not queue a follow-up fetch when a mobile subscription replays mid-fetch', async () => {
const service = new RateLimitService()
const claude = deferred<ProviderRateLimits>()
const codex = deferred<ProviderRateLimits>()
vi.mocked(fetchClaudeRateLimits).mockReturnValue(claude.promise)
vi.mocked(fetchCodexRateLimits).mockReturnValue(codex.promise)
const firstRefresh = service.refreshIfStale()
await Promise.resolve()
const replayedRefresh = service.refreshIfStale()
claude.resolve(okProvider('claude', 10))
codex.resolve(okProvider('codex', 20))
await firstRefresh
await replayedRefresh
expect(fetchClaudeRateLimits).toHaveBeenCalledOnce()
expect(fetchCodexRateLimits).toHaveBeenCalledOnce()
})
it('waits for a queued explicit refresh when another fetch is already in flight', async () => {
const service = new RateLimitService()
const firstClaude = deferred<ProviderRateLimits>()
@@ -1285,6 +1318,24 @@ describe('RateLimitService', () => {
)
})
it('does not start overlapping inactive Claude preview fetches', async () => {
const service = new RateLimitService()
const accountFetch = deferred<ProviderRateLimits>()
service.setInactiveClaudeAccountsResolver(() => [
{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }
])
vi.mocked(fetchManagedAccountUsage).mockReturnValueOnce(accountFetch.promise)
const firstFetch = service.fetchInactiveClaudeAccountsOnOpen()
await Promise.resolve()
await service.fetchInactiveClaudeAccountsOnOpen()
expect(fetchManagedAccountUsage).toHaveBeenCalledTimes(1)
accountFetch.resolve(okProvider('claude', 50, Date.now()))
await firstFetch
})
it('does not start overlapping inactive Codex preview fetches', async () => {
const service = new RateLimitService()
const accountFetch = deferred<ProviderRateLimits>()
+11
View File
@@ -347,6 +347,14 @@ export class RateLimitService {
return this.getState()
}
async refreshIfStale(): Promise<RateLimitState> {
// Why: reconnecting mobile subscribers need fresh backgrounded-desktop data,
// but replaying a subscription must not queue another forced provider fetch.
const plan = this.getActiveWindowRefreshPlan(Date.now())
await this.runActiveWindowRefreshPlan(plan)
return this.getState()
}
async refreshGrok(): Promise<RateLimitState> {
await this.fetchGrokOnly({ force: true })
return this.getState()
@@ -477,6 +485,9 @@ export class RateLimitService {
return
}
this.pruneInactiveClaudeState()
if (this.inactiveClaudeFetching.size > 0) {
return
}
const accounts = this.inactiveClaudeAccountsResolver?.() ?? []
if (accounts.length === 0) {
return
+182
View File
@@ -7404,6 +7404,70 @@ describe('OrcaRuntimeService', () => {
})
})
it('falls back to provider history when a mounted renderer has not hydrated yet', async () => {
const { runtime } = createSideEffectRuntime()
const serializeBuffer = vi.fn().mockResolvedValue({
data: '',
cols: 80,
rows: 24
})
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: '',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
seq: 900,
source: 'headless'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeBuffer,
serializeProviderBuffer,
hasRendererSerializer: () => true
})
const snapshot = await runtime.serializeTerminalBuffer('pty-restored', {
scrollbackRows: 5000
})
expect(serializeBuffer).toHaveBeenCalledOnce()
expect(serializeProviderBuffer).toHaveBeenCalledWith('pty-restored', {
scrollbackRows: 5000
})
expect(snapshot).toMatchObject({
data: '',
scrollbackAnsi: 'restored history\r\n',
source: 'headless'
})
})
it('keeps an empty renderer snapshot when the provider has no retained content', async () => {
const { runtime } = createSideEffectRuntime()
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: '',
scrollbackAnsi: '',
cols: 120,
rows: 40,
seq: 0,
source: 'headless'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeBuffer: vi.fn().mockResolvedValue({ data: '', cols: 51, rows: 40 }),
serializeProviderBuffer,
hasRendererSerializer: () => true
})
const snapshot = await runtime.serializeTerminalBuffer('pty-new')
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
expect(snapshot).toMatchObject({ data: '', cols: 51, rows: 40, source: 'renderer' })
})
it('does not let pre-response bytes hide restored provider history', async () => {
const { runtime } = createSideEffectRuntime()
const serializeProviderBuffer = vi.fn().mockResolvedValue({
@@ -22459,6 +22523,124 @@ describe('OrcaRuntimeService', () => {
})
})
it('attributes live legacy PTYs from saved layout bindings when their panes are hidden', async () => {
const session = makeWorkspaceSessionWithHeadlessTerminal()
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...session,
tabsByWorktree: {
[TEST_WORKTREE_ID]: session.tabsByWorktree[TEST_WORKTREE_ID]!.map((tab) => ({
...tab,
ptyId: null
}))
}
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
write: vi.fn(() => true),
kill: vi.fn(() => true),
getForegroundProcess: vi.fn(async () => null),
// Legacy local PTYs have opaque ids and the local provider cannot recover cwd.
listProcesses: vi.fn(async () => [{ id: 'persisted-pty', cwd: '', title: 'shell' }])
})
const { worktrees } = await runtime.getWorktreePs()
expect(worktrees[0]).toMatchObject({
worktreeId: TEST_WORKTREE_ID,
hasHostSidebarActivity: true,
hasAttachedPty: true,
liveTerminalCount: 1
})
})
it('prefers migrated layout ownership over a worktree id frozen in the PTY id', async () => {
const priorWorktreeId = `${TEST_REPO_ID}::/tmp/worktree-before-rename`
const migratedPtyId = `${priorWorktreeId}@@daemon-controller-pty`
const session = makeWorkspaceSessionWithHeadlessTerminal()
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...session,
tabsByWorktree: {
[TEST_WORKTREE_ID]: session.tabsByWorktree[TEST_WORKTREE_ID]!.map((tab) => ({
...tab,
ptyId: null
}))
},
terminalLayoutsByTabId: {
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: migratedPtyId })
}
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
write: vi.fn(() => true),
kill: vi.fn(() => true),
getForegroundProcess: vi.fn(async () => null),
listProcesses: vi.fn(async () => [{ id: migratedPtyId, cwd: '', title: 'shell' }])
})
const { worktrees } = await runtime.getWorktreePs()
expect(worktrees[0]).toMatchObject({
worktreeId: TEST_WORKTREE_ID,
hasHostSidebarActivity: true,
hasAttachedPty: true,
liveTerminalCount: 1
})
})
it('preserves deferred startup activity before restored terminal panes mount', async () => {
const session = makeWorkspaceSessionWithHeadlessTerminal({
activeWorktreeIdsOnShutdown: [TEST_WORKTREE_ID]
})
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session)
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
write: vi.fn(() => true),
kill: vi.fn(() => true),
getForegroundProcess: vi.fn(async () => null),
listProcesses: vi.fn(async () => [])
})
const { worktrees } = await runtime.getWorktreePs()
expect(worktrees[0]).toMatchObject({
worktreeId: TEST_WORKTREE_ID,
hasHostSidebarActivity: true
})
})
it('marks saved browser tabs as host sidebar activity like desktop', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: {},
terminalLayoutsByTabId: {},
browserTabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
id: 'browser-1',
worktreeId: TEST_WORKTREE_ID,
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
}
})
)
const runtime = new OrcaRuntimeService(runtimeStore as never)
const { worktrees } = await runtime.getWorktreePs()
expect(worktrees[0]).toMatchObject({
worktreeId: TEST_WORKTREE_ID,
hasHostSidebarActivity: true
})
})
it('falls back to the path-keyed GitHub cache entry', async () => {
const runtimeStore = {
...store,
+95 -1
View File
@@ -7269,7 +7269,20 @@ export class OrcaRuntimeService {
}
const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts)
return rendererSnapshot ?? this.serializeProviderTerminalBuffer(ptyId, opts)
if (!rendererSnapshot) {
return this.serializeProviderTerminalBuffer(ptyId, opts)
}
if (rendererSnapshot.data.length > 0) {
return rendererSnapshot
}
// Why: parked desktop panes register serializers before their xterm has
// hydrated. Treat that empty shell as provisional so retained provider
// history can restore mobile without forcing the desktop pane to mount.
const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts)
return providerSnapshot &&
(providerSnapshot.data.length > 0 || Boolean(providerSnapshot.scrollbackAnsi))
? providerSnapshot
: rendererSnapshot
}
private async serializeRendererTerminalBuffer(
@@ -8061,6 +8074,17 @@ export class OrcaRuntimeService {
])
}
// Why: connection migration replays subscriptions; use the stale-aware lane
// so a reconnect cannot turn one mobile viewer into continuous forced fetches.
async refreshAccountsForMobileSubscriber(): Promise<void> {
const { rateLimits } = this.requireAccountServices()
await Promise.allSettled([
rateLimits.refreshIfStale(),
rateLimits.fetchInactiveClaudeAccountsOnOpen(),
rateLimits.fetchInactiveCodexAccountsOnOpen()
])
}
selectClaudeAccount(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
return this.requireAccountServices().claudeAccounts.selectAccount(accountId)
}
@@ -11494,6 +11518,7 @@ export class OrcaRuntimeService {
const previousLastOutputAt = summary.lastOutputAt
summary.liveTerminalCount += 1
summary.hasAttachedPty = true
summary.hasHostSidebarActivity = true
summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt)
summary.status = mergeWorktreeStatus(summary.status, 'active')
if (
@@ -11505,6 +11530,19 @@ export class OrcaRuntimeService {
}
const session = this.store?.getWorkspaceSession?.()
for (const worktreeId of session?.activeWorktreeIdsOnShutdown ?? []) {
const summary = this.getSummaryForRuntimeWorktreeId(
summaries,
runtimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds,
worktreeId
)
if (summary) {
// Why: desktop advertises deferred reattach ids as live before their
// panes mount; mobile must preserve the same startup activity view.
summary.hasHostSidebarActivity = true
}
}
for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) {
if (tabs.length === 0) {
continue
@@ -11534,6 +11572,23 @@ export class OrcaRuntimeService {
}
}
for (const [worktreeId, tabs] of Object.entries(session?.browserTabsByWorktree ?? {})) {
if (tabs.length === 0) {
continue
}
const summary = this.getSummaryForRuntimeWorktreeId(
summaries,
runtimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds,
worktreeId
)
if (summary) {
// Why: desktop's sleeping predicate treats any open browser workspace
// as active, so the mobile host projection must preserve that parity.
summary.hasHostSidebarActivity = true
}
}
// Why: surface the desktop's focused worktree so mobile can scroll it into
// view and highlight it. Resolve through getSummaryForRuntimeWorktreeId so
// SSH/remote path-projected ids match the same way tabsByWorktree does.
@@ -20606,10 +20661,16 @@ export class OrcaRuntimeService {
return null
}
const sessions = sessionsResult.value
const persistedWorktreeIdByPtyId = indexPersistedPtyWorktreeBindings(
this.store?.getWorkspaceSession?.()
)
const livePtyIds = new Set(sessions.map((session) => session.id))
for (const session of sessions) {
this.adoptControllerTerminalHandle(session.id, session.terminalHandle)
// Why: workspace identity migration rekeys persisted ownership while a
// running daemon PTY keeps the worktree id minted into its session id.
const worktreeId =
persistedWorktreeIdByPtyId.get(session.id) ??
inferWorktreeIdFromPtyId(session.id) ??
findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd)
if (targetWorktreeId && worktreeId !== targetWorktreeId) {
@@ -26910,6 +26971,39 @@ function inferWorktreeIdFromPtyId(ptyId: string): string | null {
return parsePtySessionId(ptyId).worktreeId
}
function indexPersistedPtyWorktreeBindings(
session: WorkspaceSessionState | null | undefined
): ReadonlyMap<string, string> {
const worktreeIdByPtyId = new Map<string, string>()
const ambiguousPtyIds = new Set<string>()
const bind = (ptyId: string | null | undefined, worktreeId: string): void => {
if (!ptyId || ambiguousPtyIds.has(ptyId)) {
return
}
const existingWorktreeId = worktreeIdByPtyId.get(ptyId)
if (existingWorktreeId && existingWorktreeId !== worktreeId) {
// Why: corrupt/stale duplicate bindings must not attribute a live PTY to
// whichever workspace happened to be visited first.
worktreeIdByPtyId.delete(ptyId)
ambiguousPtyIds.add(ptyId)
return
}
worktreeIdByPtyId.set(ptyId, worktreeId)
}
for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) {
for (const tab of tabs) {
bind(tab.ptyId, worktreeId)
bind(session?.remoteSessionIdsByTabId?.[tab.id], worktreeId)
const layout = session?.terminalLayoutsByTabId[tab.id]
for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) {
bind(ptyId, worktreeId)
}
}
}
return worktreeIdByPtyId
}
function setsEqual<T>(a: ReadonlySet<T>, b: ReadonlySet<T>): boolean {
if (a.size !== b.size) {
return false
@@ -337,6 +337,11 @@ describe('remote runtime request connection integration', () => {
listener({ claude: null, codex: null })
}
},
refreshAccountsForMobileSubscriber: async () => {
for (const listener of accountsListeners) {
listener({ claude: null, codex: null })
}
},
onAccountsChanged: (listener: (snapshot: unknown) => void) => {
accountsListeners.add(listener)
return () => accountsListeners.delete(listener)
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { isStreamingMethod } from '../core'
import { ACCOUNT_METHODS } from './accounts'
function method(name: string) {
const found = ACCOUNT_METHODS.find((candidate) => candidate.name === name)
if (!found) {
throw new Error(`Missing method ${name}`)
}
return found
}
describe('account RPC methods', () => {
it('keeps explicit account-list refreshes on the forced refresh lane', async () => {
const snapshot = { claude: null, codex: null }
const runtime = {
refreshAccountsForMobile: vi.fn().mockResolvedValue(undefined),
getAccountsSnapshot: vi.fn(() => snapshot)
} as unknown as OrcaRuntimeService
const list = method('accounts.list')
if (isStreamingMethod(list)) {
throw new Error('accounts.list must be a request method')
}
await expect(list.handler(undefined, { runtime })).resolves.toBe(snapshot)
expect(runtime.refreshAccountsForMobile).toHaveBeenCalledOnce()
})
it('uses a stale-aware refresh when a connection replays the subscription', async () => {
const snapshot = { claude: null, codex: null }
let cleanup: (() => void) | undefined
const runtime = {
getAccountsSnapshot: vi.fn(() => snapshot),
onAccountsChanged: vi.fn(() => vi.fn()),
registerSubscriptionCleanup: vi.fn((_id: string, nextCleanup: () => void) => {
cleanup = nextCleanup
}),
refreshAccountsForMobile: vi.fn().mockResolvedValue(undefined),
refreshAccountsForMobileSubscriber: vi.fn().mockResolvedValue(undefined)
} as unknown as OrcaRuntimeService
const subscribe = method('accounts.subscribe')
if (!isStreamingMethod(subscribe)) {
throw new Error('accounts.subscribe must be a streaming method')
}
const emit = vi.fn()
const running = subscribe.handler(undefined, { runtime, connectionId: 'connection-1' }, emit)
await vi.waitFor(() => {
expect(runtime.refreshAccountsForMobileSubscriber).toHaveBeenCalledOnce()
})
expect(runtime.refreshAccountsForMobile).not.toHaveBeenCalled()
expect(emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'ready', snapshot }))
cleanup?.()
await running
})
})
+4 -4
View File
@@ -92,11 +92,11 @@ export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [
)
// Why: emit the current snapshot synchronously so the phone has
// something to render immediately, then kick a forced refresh that
// will broadcast a fresh snapshot through the listener once each
// provider fetch completes.
// something to render immediately, then refresh only stale data.
// Connection cutovers replay this subscription and must not turn the
// manual-force lane into an unbounded provider-fetch loop.
emit({ type: 'ready', subscriptionId, snapshot: runtime.getAccountsSnapshot() })
void runtime.refreshAccountsForMobile()
void runtime.refreshAccountsForMobileSubscriber()
})
}
}),