Files
orca/mobile/src/session/MobileSessionActiveContent.tsx
T
Kelvin Amoaba cf7423c8f4 feat(mobile): redesign the chat composer with file attachments and inline skills (#30359)
* refactor(native-chat): move the slash picker builder and upload chunking to shared

Mobile builds the same skill and command list as desktop.

* feat(mobile): redesign the chat composer with file attachments and inline skills

* fix(mobile): the composer field owns its input style

The page's font-size census cannot follow a style passed in as a prop.
2026-10-18 20:03:26 +00:00

267 lines
9.4 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 { MobileNativeChatOverlay } from './MobileNativeChatOverlay'
import { resolveMobileNativeChatSessionId } from './mobile-native-chat-eligibility'
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,
handleTerminalTap,
browserScreencastSupported,
showToast,
nativeChatSendError,
nativeChatOverlayInputLockReason,
nativeChatController,
dictation,
handleDictationToggle,
handleDictationPressIn,
handleDictationPressOut,
readMarkdownTab,
addDiffCommentForFile,
deleteDiffCommentForFile,
copyDiffCommentsToClipboard,
sendDiffCommentsToAgent,
sendingDiffCommentIds,
updateMarkdownLocalContent,
copyMarkdownLocalContent,
discardMarkdownLocalContent,
saveMarkdownTab,
setTerminalWebViewRef,
handleTerminalWebReady,
notifyTerminalFrame,
notifyTerminalCellBoxChange,
handleFileTap,
handleNativeChatFileTap,
handleTerminalOpenUrl,
handleTerminalInput,
handleTerminalQueryReply,
handleSelectionMode,
handleSelectionCopy,
handleSelectionEvicted,
handleModesChanged,
handleKeyboardAvoidanceMetrics,
handleHaptic,
nativeChatImages,
nativeChatStructuredComposer,
activeMarkdownTab,
activeFileTab,
activeBrowserTab,
activePendingTerminalTab,
isPendingTerminalRecoveryParked,
retryPendingTerminalRecovery,
showLoadingState,
showEmptyState,
keyboardLift,
activeTerminalKeyboardLift,
toastAnimatedStyle,
createTabBusy
} = controller
const content = showLoadingState ? (
<View style={styles.emptyState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</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
client={client}
doc={fileDocs.get(activeFileTab.id)}
title={activeFileTab.title || 'File'}
relativePath={activeFileTab.relativePath}
language={activeFileTab.language}
diffCommentActions={
activeFileTab.diffSource === 'staged' || activeFileTab.diffSource === 'unstaged'
? {
comments: diffComments,
sendingCommentIds: sendingDiffCommentIds,
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.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>
) : (
<View style={styles.terminalFrame}>
{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}
onCellBoxChange={notifyTerminalCellBoxChange}
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}
structuredComposer={nativeChatStructuredComposer}
onMicPress={handleDictationToggle}
micActive={dictation.isRecording}
dictationMode={dictationMode}
onMicPressIn={handleDictationPressIn}
onMicPressOut={handleDictationPressOut}
inputLockReason={nativeChatOverlayInputLockReason}
sendErrorMessage={nativeChatSendError.message}
onClearSendError={nativeChatSendError.clear}
sendSurfaceId={controller.nativeChatScopeKey ?? ''}
sessionId={resolveMobileNativeChatSessionId(controller.activeSessionTab)}
getSendCompletionGeneration={controller.getSendCompletionGeneration}
keyboardInset={keyboardLift}
/>
{toastMessage && (
<Animated.View pointerEvents="none" style={[styles.toast, toastAnimatedStyle]}>
<Text style={styles.toastText}>{toastMessage}</Text>
</Animated.View>
)}
</View>
)
return (
<View
// Why: one frame under every branch; react-native-web observes onLayout only on a View that mounts with it.
style={styles.contentFrame}
onLayout={(e) => {
const { width, height } = e.nativeEvent.layout
notifyTerminalFrame({ width, height })
}}
>
{content}
</View>
)
}