mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Startup RPCs now fan out in parallel and the xterm engine pre-warms inside the real terminal frame while they are in flight, so the first pane inherits a warm WebView and an already-measured viewport instead of paying a round trip for it. The pre-warm opens its engine before measuring: web-ready only reports that the bundle loaded, and the WebView answers a measure with null until a terminal exists. It also pre-warms at the user's saved text size, because cell size is what the frame height gets divided by. Host writes such as worktree.activate wait for an evaluated status.get reply. Navigation still fails open when a host cannot answer one, but that fallback no longer reads as a passing compatibility verdict.
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import { Animated, View, Text, Pressable, ActivityIndicator } from 'react-native'
|
|
import { saveTerminalTextScale } from '../storage/preferences'
|
|
import { MobileBrowserPane } from '../browser/MobileBrowserPane'
|
|
import { TerminalPaneView } from './TerminalPaneView'
|
|
import { TerminalEnginePrewarm } from './TerminalEnginePrewarm'
|
|
import { MOBILE_SESSION_TAB_BAR_HEIGHT } from './mobile-session-frame-styles'
|
|
import { MobileNativeChatOverlay } from './MobileNativeChatOverlay'
|
|
import { colors } from '../theme/mobile-theme'
|
|
import { styles } from './mobile-session-styles'
|
|
import type { MobileSessionController } from './use-mobile-session-controller'
|
|
import { FileReader } from './MobileSessionFileReader'
|
|
import { MarkdownReader } from './MobileSessionMarkdownReader'
|
|
|
|
export function MobileSessionActiveContent({
|
|
controller
|
|
}: {
|
|
controller: MobileSessionController
|
|
}) {
|
|
const {
|
|
worktreeId,
|
|
insets,
|
|
connState,
|
|
client,
|
|
terminals,
|
|
terminalTextScale,
|
|
setTerminalTextScale,
|
|
activeHandle,
|
|
markdownDocs,
|
|
fileDocs,
|
|
diffComments,
|
|
diffCommentBusy,
|
|
createError,
|
|
setCreateError,
|
|
setShowCreateTabDrawer,
|
|
dictationMode,
|
|
toastMessage,
|
|
terminalFrameHeightRef,
|
|
setTerminalFrameWidth,
|
|
handleTerminalTap,
|
|
browserScreencastSupported,
|
|
showToast,
|
|
nativeChatSendError,
|
|
nativeChatOverlayInputLockReason,
|
|
nativeChatController,
|
|
dictation,
|
|
handleDictationToggle,
|
|
handleDictationPressIn,
|
|
handleDictationPressOut,
|
|
readMarkdownTab,
|
|
addDiffCommentForFile,
|
|
deleteDiffCommentForFile,
|
|
copyDiffCommentsToClipboard,
|
|
sendDiffCommentsToAgent,
|
|
updateMarkdownLocalContent,
|
|
copyMarkdownLocalContent,
|
|
discardMarkdownLocalContent,
|
|
saveMarkdownTab,
|
|
notifyTerminalFrameHeight,
|
|
setTerminalWebViewRef,
|
|
handleTerminalWebReady,
|
|
handleFileTap,
|
|
handleNativeChatFileTap,
|
|
handleTerminalOpenUrl,
|
|
handleTerminalInput,
|
|
handleTerminalQueryReply,
|
|
handleSelectionMode,
|
|
handleSelectionCopy,
|
|
handleSelectionEvicted,
|
|
handleModesChanged,
|
|
handleKeyboardAvoidanceMetrics,
|
|
handleHaptic,
|
|
nativeChatImages,
|
|
activeMarkdownTab,
|
|
activeFileTab,
|
|
activeBrowserTab,
|
|
activePendingTerminalTab,
|
|
isPendingTerminalRecoveryParked,
|
|
retryPendingTerminalRecovery,
|
|
showLoadingState,
|
|
measurePrewarmViewport,
|
|
visibleTabs,
|
|
showEmptyState,
|
|
keyboardLift,
|
|
activeTerminalKeyboardLift,
|
|
toastAnimatedStyle,
|
|
createTabBusy
|
|
} = controller
|
|
// Why the same list the header gates on: an unmounted tab bar gives the content row its band
|
|
// back, so the pre-warm would measure a taller box than the pane ever gets. Reading the header's
|
|
// own condition keeps the two from drifting when what counts as a visible tab changes.
|
|
const prewarmReservedTabBarHeight = visibleTabs.length > 0 ? 0 : MOBILE_SESSION_TAB_BAR_HEIGHT
|
|
return showLoadingState ? (
|
|
// Why: the engine boots inside the real terminal frame while the startup RPCs are still in
|
|
// flight, so the first pane inherits a warm WebView and a measured viewport (see prewarm).
|
|
<View style={styles.terminalFrame}>
|
|
<View style={styles.emptyState}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
</View>
|
|
<TerminalEnginePrewarm
|
|
reservedTabBarHeight={prewarmReservedTabBarHeight}
|
|
textScale={terminalTextScale}
|
|
onEngineMeasured={measurePrewarmViewport}
|
|
/>
|
|
</View>
|
|
) : showEmptyState ? (
|
|
<View style={styles.emptyState}>
|
|
<Text style={styles.emptyText}>No tabs in this session</Text>
|
|
{createError ? <Text style={styles.createError}>{createError}</Text> : null}
|
|
<View style={styles.emptyActions}>
|
|
<Pressable
|
|
style={[
|
|
styles.createButton,
|
|
(createTabBusy || connState !== 'connected') && styles.createButtonDisabled
|
|
]}
|
|
disabled={createTabBusy || connState !== 'connected'}
|
|
onPress={() => {
|
|
setCreateError('')
|
|
setShowCreateTabDrawer(true)
|
|
}}
|
|
>
|
|
<Text style={styles.createButtonText}>
|
|
{createTabBusy ? 'Creating...' : 'Create Tab'}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
) : activeMarkdownTab ? (
|
|
<View style={styles.markdownFrame}>
|
|
<MarkdownReader
|
|
documentId={activeMarkdownTab.id}
|
|
doc={markdownDocs.get(activeMarkdownTab.id)}
|
|
onRefresh={() => void readMarkdownTab(activeMarkdownTab)}
|
|
onChange={(content) => updateMarkdownLocalContent(activeMarkdownTab.id, content)}
|
|
onSave={() => void saveMarkdownTab(activeMarkdownTab)}
|
|
onCopy={() => void copyMarkdownLocalContent(activeMarkdownTab.id)}
|
|
onDiscard={() => discardMarkdownLocalContent(activeMarkdownTab)}
|
|
keyboardLift={keyboardLift}
|
|
/>
|
|
{toastMessage && (
|
|
<Animated.View pointerEvents="none" style={[styles.toast, toastAnimatedStyle]}>
|
|
<Text style={styles.toastText}>{toastMessage}</Text>
|
|
</Animated.View>
|
|
)}
|
|
</View>
|
|
) : activeFileTab ? (
|
|
<View style={styles.markdownFrame}>
|
|
<FileReader
|
|
doc={fileDocs.get(activeFileTab.id)}
|
|
title={activeFileTab.title || 'File'}
|
|
relativePath={activeFileTab.relativePath}
|
|
language={activeFileTab.language}
|
|
diffCommentActions={
|
|
activeFileTab.diffSource === 'staged' || activeFileTab.diffSource === 'unstaged'
|
|
? {
|
|
comments: diffComments,
|
|
busy: diffCommentBusy,
|
|
onAdd: addDiffCommentForFile,
|
|
onDelete: deleteDiffCommentForFile,
|
|
onCopyAll: copyDiffCommentsToClipboard,
|
|
onSendAll: sendDiffCommentsToAgent
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
{toastMessage && (
|
|
<Animated.View pointerEvents="none" style={[styles.toast, toastAnimatedStyle]}>
|
|
<Text style={styles.toastText}>{toastMessage}</Text>
|
|
</Animated.View>
|
|
)}
|
|
</View>
|
|
) : activeBrowserTab ? (
|
|
<View style={styles.browserFrame}>
|
|
{/* Why: pane owns imperative frame refs; don't render a stale frame while the old stream effect cleans up. */}
|
|
<MobileBrowserPane
|
|
key={activeBrowserTab.browserPageId ?? activeBrowserTab.id}
|
|
client={client}
|
|
worktreeId={worktreeId}
|
|
tab={activeBrowserTab}
|
|
screencastSupported={browserScreencastSupported}
|
|
keyboardLift={keyboardLift}
|
|
bottomInset={insets.bottom}
|
|
onToast={showToast}
|
|
/>
|
|
{toastMessage && (
|
|
<Animated.View pointerEvents="none" style={[styles.toast, toastAnimatedStyle]}>
|
|
<Text style={styles.toastText}>{toastMessage}</Text>
|
|
</Animated.View>
|
|
)}
|
|
</View>
|
|
) : activePendingTerminalTab ? (
|
|
<View style={styles.terminalFrame}>
|
|
<View style={styles.emptyState}>
|
|
{!isPendingTerminalRecoveryParked && (
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
)}
|
|
<Text style={styles.emptyText}>
|
|
{isPendingTerminalRecoveryParked
|
|
? 'Terminal is taking longer than expected'
|
|
: activePendingTerminalTab.title || 'Loading terminal'}
|
|
</Text>
|
|
{isPendingTerminalRecoveryParked && (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Retry loading terminal"
|
|
style={({ pressed }) => [
|
|
styles.createButton,
|
|
pressed && styles.newTerminalButtonPressed
|
|
]}
|
|
onPress={() => void retryPendingTerminalRecovery()}
|
|
>
|
|
<Text style={styles.createButtonText}>Retry</Text>
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
<TerminalEnginePrewarm
|
|
reservedTabBarHeight={prewarmReservedTabBarHeight}
|
|
textScale={terminalTextScale}
|
|
onEngineMeasured={measurePrewarmViewport}
|
|
/>
|
|
</View>
|
|
) : (
|
|
<View
|
|
style={styles.terminalFrame}
|
|
onLayout={(e) => {
|
|
terminalFrameHeightRef.current = e.nativeEvent.layout.height
|
|
// Why: notify height imperatively so dock settling re-fits the PTY without rerendering SessionScreen.
|
|
const nextWidth = Math.round(e.nativeEvent.layout.width)
|
|
const nextHeight = Math.round(e.nativeEvent.layout.height)
|
|
setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth))
|
|
notifyTerminalFrameHeight(nextHeight)
|
|
}}
|
|
>
|
|
{terminals.map((terminal) => (
|
|
<TerminalPaneView
|
|
key={terminal.handle}
|
|
handle={terminal.handle}
|
|
active={terminal.handle === activeHandle}
|
|
keyboardLift={terminal.handle === activeHandle ? activeTerminalKeyboardLift : 0}
|
|
terminalTheme={terminal.terminalTheme}
|
|
textScale={terminalTextScale}
|
|
onTextScaleChange={(scale) => {
|
|
// Why: pinch-to-zoom reports a new preset; persist it so the size sticks across panes and launches.
|
|
setTerminalTextScale(scale)
|
|
void saveTerminalTextScale(scale)
|
|
}}
|
|
onRef={setTerminalWebViewRef}
|
|
onWebReady={handleTerminalWebReady}
|
|
onSelectionMode={handleSelectionMode}
|
|
onSelectionCopy={handleSelectionCopy}
|
|
onSelectionEvicted={handleSelectionEvicted}
|
|
onModesChanged={handleModesChanged}
|
|
onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics}
|
|
onHaptic={handleHaptic}
|
|
onTerminalInput={handleTerminalInput}
|
|
onTerminalQueryReply={handleTerminalQueryReply}
|
|
onTerminalTap={handleTerminalTap}
|
|
onFileTap={handleFileTap}
|
|
onOpenUrl={handleTerminalOpenUrl}
|
|
/>
|
|
))}
|
|
<MobileNativeChatOverlay
|
|
controller={nativeChatController}
|
|
onOpenFile={handleNativeChatFileTap}
|
|
images={nativeChatImages}
|
|
onMicPress={handleDictationToggle}
|
|
micActive={dictation.isRecording}
|
|
dictationMode={dictationMode}
|
|
onMicPressIn={handleDictationPressIn}
|
|
onMicPressOut={handleDictationPressOut}
|
|
inputLockReason={nativeChatOverlayInputLockReason}
|
|
sendErrorMessage={nativeChatSendError.message}
|
|
onClearSendError={nativeChatSendError.clear}
|
|
sendSurfaceId={controller.nativeChatScopeKey ?? ''}
|
|
getSendCompletionGeneration={controller.getSendCompletionGeneration}
|
|
keyboardInset={keyboardLift}
|
|
/>
|
|
{toastMessage && (
|
|
<Animated.View pointerEvents="none" style={[styles.toast, toastAnimatedStyle]}>
|
|
<Text style={styles.toastText}>{toastMessage}</Text>
|
|
</Animated.View>
|
|
)}
|
|
</View>
|
|
)
|
|
}
|