Files
orca/mobile/src/session/use-mobile-session-tab-application.ts
T
Jinwoo Hong 50b3d2e34c fix(mobile): accept a fresh terminal snapshot after a stream ends (#27539)
* fix(mobile): accept a fresh terminal snapshot after a stream ends

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the native chat resume's duplicate snapshot-mark clear

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the native chat stream's snapshot-mark wiring

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): drop the refit adapter's dead snapshot-mark argument

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-10-11 17:23:20 -04:00

247 lines
10 KiB
TypeScript

import {
pendingSelectionHandle,
pendingSelectionTabId,
resolveLaunchedSelection,
withoutPendingHandle,
withoutPendingTabId
} from './pending-session-selection'
import { useCallback } from 'react'
import {
getTerminalRecordsFromSessionTabs,
mergeTerminalRecordsByCurrentOrder,
mobileSessionTabsEqual,
terminalRecordsEqual
} from './mobile-terminal-records'
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
confirmsMirroredTabSelection
} from './session-tab-snapshot-gate'
import type { SessionTabsApplyOutcome } from './mobile-session-tabs-stream-health'
import { getActiveTabIdForHandle } from './mobile-session-route-helpers'
import { resolveActiveSessionTab } from './active-session-tab'
import { activateMobileSessionTab } from './mobile-session-tab-activation'
import { releaseTerminalCreateLock } from './terminal-create-lock'
import type { MobileSessionTab, SessionTabsResult } from './mobile-session-route-types'
import type { MobileSessionTerminalListModel } from './use-mobile-session-terminal-list'
export function useMobileSessionTabApplication(scope: MobileSessionTerminalListModel) {
const {
setTerminals,
terminalsRef,
setSessionTabs,
sessionTabsRef,
appliedSnapshotMarkerRef,
appliedSessionTabsRevisionRef,
closedTabTombstonesRef,
reconcileBufferedDraftsRef,
setTerminalsLoaded,
defaultTerminalHandlesToLiveInput,
setActiveHandle,
setActiveSessionTabId,
activeSessionTabIdRef,
selectedSessionTabIdRef,
markdownDocsRef,
terminalDiagnosticsRef,
activeHandleRef,
activeSessionTabTypeRef,
pendingSelectionRef,
pendingBrowserFocusPageIdRef,
initialSessionAutoCreateRef,
unsubscribeTerminal,
subscribeToTerminal,
lastKnownTerminalCountRef,
clientRef,
creatingTerminalRef,
setCreating
} = scope
const applySessionTabs = useCallback(
(result: SessionTabsResult): SessionTabsApplyOutcome<MobileSessionTab> => {
const diagnostics = terminalDiagnosticsRef.current
// Reject stale snapshots; suppress just-closed tabs until the publisher confirms absence — see session-tab-snapshot-gate.
if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) {
return { accepted: false }
}
const applicationRevision = ++appliedSessionTabsRevisionRef.current
let nextTabs = applyClosedTabTombstones(
result.tabs,
closedTabTombstonesRef.current,
Date.now()
)
const presentTabIds = new Set(nextTabs.map((tab) => tab.id))
const orphanedDraftTabs: MobileSessionTab[] = []
const currentMarkdownDocs = markdownDocsRef.current
const currentSessionTabs = sessionTabsRef.current
for (const [tabId, doc] of currentMarkdownDocs) {
if (doc.status !== 'ready' || !doc.isDirty || presentTabIds.has(tabId)) {
continue
}
const draftTab = currentSessionTabs.find(
(tab): tab is Extract<MobileSessionTab, { type: 'markdown' }> =>
tab.type === 'markdown' && tab.id === tabId
)
if (draftTab) {
// Why: mobile edits live on the phone until Save; if the desktop tab vanishes, keep drafts reachable for copy/discard.
orphanedDraftTabs.push({ ...draftTab, isActive: tabId === activeSessionTabIdRef.current })
}
}
if (orphanedDraftTabs.length > 0) {
nextTabs = [...orphanedDraftTabs, ...nextTabs]
}
reconcileBufferedDraftsRef.current(currentSessionTabs, nextTabs, {
retainMissingSurfaces: result.tabs.length === 0
})
sessionTabsRef.current = nextTabs
initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0
// Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop.
setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs))
const terminalTabs = getTerminalRecordsFromSessionTabs(nextTabs)
const terminalTabHandles = terminalTabs.map((terminal) => terminal.handle)
defaultTerminalHandlesToLiveInput(terminalTabHandles)
const mergedTerminalsForActive = mergeTerminalRecordsByCurrentOrder(
terminalTabs,
terminalsRef.current
)
terminalsRef.current = mergedTerminalsForActive
setTerminals((prev) =>
terminalRecordsEqual(prev, mergedTerminalsForActive) ? prev : mergedTerminalsForActive
)
lastKnownTerminalCountRef.current = Math.max(
lastKnownTerminalCountRef.current,
terminalTabs.length
)
setTerminalsLoaded(true)
const outcome = {
accepted: true as const,
effectiveTabs: nextTabs,
applicationRevision
}
const followsHost = result.navigationIntent === 'follow'
if (followsHost) {
pendingSelectionRef.current = withoutPendingHandle(
pendingSelectionRef.current?.kind === 'launched' ? null : pendingSelectionRef.current
)
pendingBrowserFocusPageIdRef.current = null
} else {
const pending = pendingSelectionRef.current
const launch = resolveLaunchedSelection(pending, nextTabs)
pendingSelectionRef.current = launch.selection
if (launch.landedTabId && pending?.kind === 'launched') {
// Why: the reply can wait a minute on prompt delivery; the tab is what the lock waited for.
releaseTerminalCreateLock({ creatingTerminalRef, setCreating }, pending.lock)
}
if (launch.landedTabId && clientRef.current) {
// Why: a launch carries no navigation, so record the pick as a tap does, or the host keeps this device on the old tab.
void activateMobileSessionTab(clientRef.current, {
worktree: `id:${result.worktree}`,
tabId: launch.landedTabId,
notifyClients: false,
navigation: 'caller',
intent: 'user'
}).catch(() => {})
}
}
const pendingActiveSessionTabId = pendingSelectionTabId(pendingSelectionRef.current)
const pendingActiveTerminalHandle = pendingSelectionHandle(pendingSelectionRef.current)
const resolved = resolveActiveSessionTab(nextTabs, {
pendingActiveSessionTabId,
selectedSessionTabId: selectedSessionTabIdRef.current,
navigationIntent: result.navigationIntent
})
let active = resolved.activeTab
let selectionSource: string = resolved.selectionSource
if (resolved.clearPendingActiveSessionTabId) {
const localAck =
!followsHost &&
nextTabs.find((tab) => tab.isActive)?.id === pendingActiveSessionTabId &&
!confirmsMirroredTabSelection(result.publicationEpoch)
selectionSource = localAck ? 'pending-tab-local-ack' : selectionSource
if (!localAck) {
pendingSelectionRef.current = withoutPendingTabId(pendingSelectionRef.current)
}
}
if (pendingActiveTerminalHandle) {
const pendingTerminalTab = nextTabs.find(
(tab): tab is Extract<MobileSessionTab, { type: 'terminal' }> =>
tab.type === 'terminal' && tab.terminal === pendingActiveTerminalHandle
)
const pendingTerminalExists = mergedTerminalsForActive.some(
(terminal) => terminal.handle === pendingActiveTerminalHandle
)
if (active?.type === 'terminal' && active.terminal === pendingActiveTerminalHandle) {
const snapshotActive = nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null
if (
snapshotActive?.type === 'terminal' &&
snapshotActive.terminal === pendingActiveTerminalHandle &&
!confirmsMirroredTabSelection(result.publicationEpoch)
) {
selectionSource = 'pending-handle-local-ack'
} else {
pendingSelectionRef.current = withoutPendingHandle(pendingSelectionRef.current)
}
} else if (pendingTerminalTab) {
// Why: desktop active flags lag a mobile tap; key by handle too, as fallback PTY tabs lack a stable tab id at startup.
active = pendingTerminalTab
selectionSource = 'pending-handle-tab'
} else if (pendingTerminalExists) {
const nextActiveTabId = getActiveTabIdForHandle(nextTabs, pendingActiveTerminalHandle)
activeSessionTabIdRef.current = nextActiveTabId
setActiveSessionTabId(nextActiveTabId)
activeSessionTabTypeRef.current = 'terminal'
// Why: every other active-handle branch assigns the ref alongside the
// state. Leaving it stale here makes `covered` resolve against the wrong
// handle, so a native-chat rearm silently no-ops on the webview gates.
activeHandleRef.current = pendingActiveTerminalHandle
setActiveHandle(pendingActiveTerminalHandle)
subscribeToTerminal(pendingActiveTerminalHandle)
return outcome
} else {
pendingSelectionRef.current = withoutPendingHandle(pendingSelectionRef.current)
}
}
diagnostics.tabsApplied(result, nextTabs, active, selectionSource)
if (!resolved.retainSelectedSessionTabId || active !== resolved.activeTab) {
selectedSessionTabIdRef.current = active?.id ?? null
}
activeSessionTabTypeRef.current = active?.type ?? null
activeSessionTabIdRef.current = active?.id ?? null
setActiveSessionTabId(active?.id ?? null)
if (active?.type === 'terminal') {
if (typeof active.terminal !== 'string') {
const previous = activeHandleRef.current
if (previous) {
unsubscribeTerminal(previous)
}
activeHandleRef.current = null
setActiveHandle(null)
return outcome
}
const previous = activeHandleRef.current
if (previous && previous !== active.terminal) {
unsubscribeTerminal(previous)
}
activeHandleRef.current = active.terminal
setActiveHandle(active.terminal)
subscribeToTerminal(active.terminal)
} else if (active) {
// Why: an empty snapshot can transiently omit a live terminal; explicit close clears it on RPC success.
const previous = activeHandleRef.current
if (previous) {
unsubscribeTerminal(previous)
}
activeHandleRef.current = null
setActiveHandle(null)
}
return outcome
},
[defaultTerminalHandlesToLiveInput, subscribeToTerminal, unsubscribeTerminal]
)
return {
applySessionTabs
}
}
export type MobileSessionTabApplicationModel = MobileSessionTerminalListModel &
ReturnType<typeof useMobileSessionTabApplication>