mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`
|
||||
@@ -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>()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user