mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
* 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
247 lines
10 KiB
TypeScript
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>
|