orca/mobile/app/h/[hostId]/session/[worktreeId].tsx

5384 lines
203 KiB
TypeScript

import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import {
Animated,
AppState,
Linking,
type AppStateStatus,
BackHandler,
FlatList,
Image,
View,
Text,
ScrollView,
TextInput,
Pressable,
Keyboard,
Platform,
ActivityIndicator,
type KeyboardEvent,
type LayoutChangeEvent,
type ListRenderItem
} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
AlertTriangle,
ArrowUp,
Bot,
ChevronDown,
ChevronLeft,
ChevronsRight,
Copy,
Folder,
File,
FileText,
GitBranch,
Globe,
Keyboard as KeyboardIcon,
MessageSquare,
Monitor,
MoreHorizontal,
Plus,
RefreshCw,
Send,
Smartphone,
SquareTerminal,
X
} from 'lucide-react-native'
import type { RpcClient } from '../../../../src/transport/rpc-client'
import { loadHosts } from '../../../../src/transport/host-store'
import { startRuntimeCapabilityProbe } from '../../../../src/transport/runtime-capability-probe'
import {
loadTerminalAutocompleteEnabled,
loadTerminalLinkOpenMode,
loadTerminalTextScale,
HOST_DOCK_MIN_WIDTH,
saveTerminalTextScale,
type MobileTerminalLinkOpenMode
} from '../../../../src/storage/preferences'
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import {
useLastConnectedAt,
useReconnectAttempt
} from '../../../../src/transport/client-context-connection-metrics'
import {
classifyConnection,
verdictDisplayLabel
} from '../../../../src/transport/connection-health'
import { useResponsiveLayout } from '../../../../src/layout/responsive-layout'
import {
type ActivePanel,
canDockSessionPanel,
resolvePanelAction,
shouldShowSessionHeaderChecksAction,
panelRouteDescriptor
} from '../../../../src/session/session-panel-host'
import {
createBulkCloseSheetActions,
createCloseWithBulkActions
} from '../../../../src/session/mobile-bulk-close-sheet-actions'
import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context'
import { isFloatingWorkspaceWorktreeId } from '../../../../src/session/floating-workspace'
import { SessionDockColumn } from '../../../../src/session/SessionDockColumn'
import { MobileSessionHeaderIconButton } from '../../../../src/session/MobileSessionHeaderIconButton'
import { MobileSessionHeaderMoreActionsSheet } from '../../../../src/session/MobileSessionHeaderMoreActionsSheet'
import { QuickCommandsSheet } from '../../../../src/session/QuickCommandsSheet'
import {
buildMobileQuickCommandLaunch,
supportsMobileQuickCommands,
type MobileQuickCommandLaunch
} from '../../../../src/terminal/quick-commands'
import { MOBILE_AI_VAULT_CAPABILITY } from '../../../../src/agent-history/agent-history-capability'
import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types'
import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result'
import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation'
import {
triggerMediumImpact,
triggerSelection,
triggerSuccess,
triggerError,
triggerEdgeBump
} from '../../../../src/platform/haptics'
import type {
TerminalKeyboardAvoidanceMetrics,
TerminalModes,
TerminalWebViewHandle
} from '../../../../src/terminal/terminal-webview-contract'
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift'
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
import {
getDefaultTerminalAccessoryBuiltInIds,
getVisibleTerminalAccessoryKeys,
loadTerminalAccessoryLayout
} from '../../../../src/terminal/terminal-accessory-layout'
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
import {
clearTerminalLiveInputFocusTimer,
isTerminalLiveInputWithinByteLimit,
scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input'
import { useTerminalLiveInputFocus } from '../../../../src/terminal/use-terminal-live-input-focus'
import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss'
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate'
import {
buildTerminalSendParams,
TERMINAL_INPUT_SEND_OPTIONS
} from '../../../../src/terminal/terminal-send-request'
import {
getTerminalCommandKeyboardType,
getTerminalLiveInputKeyboardType
} from '../../../../src/terminal/terminal-keyboard-type'
import { normalizeTerminalTextInput } from '../../../../src/terminal/terminal-text-input-normalization'
import {
appendBufferedDictation,
routeDictationTranscript
} from '../../../../src/terminal/terminal-live-dictation-routing'
import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input'
import {
recoverActiveTerminalAfterForeground,
shouldRecoverTerminalOnAppStateChange
} from '../../../../src/terminal/terminal-foreground-recovery'
import { MobileBrowserPane } from '../../../../src/browser/MobileBrowserPane'
import { normalizeBrowserUrl } from '../../../../src/browser/browser-url'
import { StatusDot } from '../../../../src/components/StatusDot'
import { ActionSheetModal } from '../../../../src/components/ActionSheetModal'
import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon'
import { TextInputModal } from '../../../../src/components/TextInputModal'
import { ConfirmModal } from '../../../../src/components/ConfirmModal'
import { MobileRichMarkdownEditor } from '../../../../src/components/MobileRichMarkdownEditor'
import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments'
import {
CustomKeyModal,
loadCustomKeys,
saveCustomKeys,
type CustomKey
} from '../../../../src/components/CustomKeyModal'
import {
addMobileDiffComment,
formatDiffComments,
normalizeMobileDiffComments,
removeDeliveredMobileDiffComments,
removeMobileDiffComments
} from '../../../../src/session/mobile-diff-comments'
import {
buildPlainMobileDiffSyntaxLines,
highlightMobileCode,
highlightMobileDiffLines,
resolveMobileSyntaxLanguage
} from '../../../../src/session/mobile-file-syntax'
import {
getTerminalRecordsFromSessionTabs,
mergeTerminalListWithKnownRecords,
mergeTerminalRecordsByCurrentOrder,
mobileSessionTabsEqual,
terminalRecordsEqual
} from '../../../../src/session/mobile-terminal-records'
import {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
} from '../../../../src/session/mobile-terminal-tab-agent'
import type { MobileNewTabAgentOption } from '../../../../src/session/mobile-new-tab-agent-options'
import { loadMobileNewTabAgentOptions } from '../../../../src/session/mobile-new-tab-agent-loader'
import { useMobileSessionImageAttachments } from '../../../../src/session/use-mobile-session-image-attachments'
import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate'
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference'
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc'
import { captureMobileFileMutationOwnership } from '../../../../src/files/mobile-file-mutation-ownership'
import { useMobileFileTapHandlers } from '../../../../src/session/use-mobile-file-tap-handlers'
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
import { useMissingWorktreeBounce } from '../../../../src/session/use-missing-worktree-bounce'
import { hostRouteWithNotice } from '../../../../src/host-route-notice'
import { LAST_VISITED_WORKTREE_STORAGE_KEY } from '../../../../src/worktree/last-visited-worktree-repo'
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from '../../../../src/session/session-tab-snapshot-gate'
import {
createInitialSessionAutoCreateState,
useInitialSessionTerminalAutoCreate,
useWorktreeSessionTabsLoaded
} from '../../../../src/session/use-initial-session-terminal-autocreate'
import {
buildMarkdownDiskFallbackDoc,
shouldReadMarkdownFromDiskAfterReadTabFailure
} from '../../../../src/session/mobile-markdown-disk-fallback'
import { MobileHtmlPreview } from '../../../../src/components/MobileHtmlPreview'
import { MobileDictationSetupSheet } from '../../../../src/components/MobileDictationSetupSheet'
import {
fetchDictationSetup,
isDictationSetupRequiredError
} from '../../../../src/dictation/mobile-dictation-setup'
import { TerminalPaneView } from '../../../../src/session/TerminalPaneView'
import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay'
import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet'
import { useMobileNativeChatController } from '../../../../src/session/use-mobile-native-chat-controller'
import { useMobileNativeChatReadability } from '../../../../src/session/use-mobile-native-chat-readability'
import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease'
import { useMobileNativeChatSendError } from '../../../../src/session/use-mobile-native-chat-send-error'
import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions'
import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream'
import { mobileNativeChatScopeKey } from '../../../../src/session/mobile-native-chat-scope-key'
import {
createTerminalPrunePredicate,
pruneTerminalKeyboardMetrics,
resolveRetainedTerminalHandles
} from '../../../../src/session/mobile-terminal-prune-decision'
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
import {
TerminalViewportResubscribeBudget,
readTerminalViewportDims,
runTerminalViewportFitPass
} from '../../../../src/session/mobile-terminal-viewport-resubscribe'
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
import { runAcceptedMobileSessionTabsEffects } from '../../../../src/session/mobile-session-tabs-accepted-effects'
import type {
SessionTabsApplyOutcome,
SessionTabsStreamSource
} from '../../../../src/session/mobile-session-tabs-stream-health'
import { useMobileSessionTabsFetchReporting } from '../../../../src/session/use-mobile-session-tabs-fetch-reporting'
import { useMobileSessionTabsReconciliation } from '../../../../src/session/use-mobile-session-tabs-reconciliation'
import {
getRepoIdFromMobileWorktreeId,
getActiveTabIdForHandle,
isFileExistsErrorMessage,
isGestureMouseTrackingMode,
isTerminalPhoneDisplayMode,
MOBILE_SESSION_STATUS_LABELS,
TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY,
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,
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'
import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab'
import {
createMobileSessionCreateWarningState,
dismissMobileSessionCreateWarningState,
reconcileMobileSessionCreateWarningState
} from '../../../../src/session/mobile-session-create-warning-state'
import { colors, spacing } from '../../../../src/theme/mobile-theme'
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
import { styles } from '../../../../src/session/mobile-session-styles'
import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types'
import type {
DiffCommentActions,
DiffNotesDelivery,
DiffSyntaxState,
DirtyMarkdownDraft,
FileDocState,
FileSyntaxState,
MarkdownDocState,
MobileDisplayMode,
MobileNewTabAgentLoadState,
MobileSessionTab,
MobileSessionTabType,
RenderableDiffLine,
RuntimeRepoSummary,
SessionTabsResult,
Terminal,
TerminalCreateResult,
TerminalGestureInputBucket,
TerminalGestureInputQueue
} from '../../../../src/session/mobile-session-route-types'
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
function MarkdownReader({
documentId,
doc,
onRefresh,
onChange,
onSave,
onCopy,
onDiscard,
keyboardLift
}: {
documentId: string
doc: MarkdownDocState | undefined
onRefresh: () => void
onChange: (content: string) => void
onSave: () => void
onCopy: () => void
onDiscard: () => void
keyboardLift: number
}) {
// Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset.
const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
if (!doc || doc.status === 'loading') {
return (
<View style={styles.markdownState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
}
if (doc.status === 'error') {
return (
<View style={styles.markdownState}>
<Text style={styles.markdownError}>{doc.message}</Text>
<Pressable style={styles.markdownRefreshButton} onPress={onRefresh}>
<RefreshCw size={14} color={colors.textPrimary} />
<Text style={styles.markdownRefreshText}>Retry</Text>
</Pressable>
</View>
)
}
const statusText = doc.saveError
? doc.saveError
: doc.readOnlyReason
? 'Read only'
: doc.stale
? 'Changed on desktop'
: null
const showRefresh = (doc.stale && !doc.isDirty) || !doc.editable
const showCopy = doc.saveError || !doc.editable
const showSave = doc.isDirty || doc.saving
const showFloatingActions = statusText || showRefresh || showCopy || showSave
return (
<View style={styles.markdownEditor}>
<MobileRichMarkdownEditor
key={documentId}
content={doc.localContent}
editable={doc.editable && !doc.saving}
onChange={onChange}
onKeyboardInsetChange={setWebviewKeyboardInset}
/>
{showFloatingActions ? (
<View
pointerEvents="box-none"
style={[
styles.markdownFloatingBar,
// Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it.
{
bottom: resolveMarkdownFloatingActionsBottom({
keyboardLift: effectiveKeyboardLift,
restingBottom: spacing.lg,
liftedClearance: spacing.md
})
}
]}
>
{statusText ? (
<Text
style={[styles.markdownFloatingStatus, doc.saveError ? styles.markdownError : null]}
numberOfLines={2}
>
{statusText}
</Text>
) : null}
<View style={styles.markdownFloatingActions}>
{showCopy ? (
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
</Pressable>
) : null}
{showRefresh ? (
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
<RefreshCw size={13} color={colors.textPrimary} />
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
</Pressable>
) : null}
{doc.isDirty ? (
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
</Pressable>
) : null}
{showSave ? (
<Pressable
style={[
styles.markdownFloatingButton,
styles.markdownSaveButton,
(!doc.editable || !doc.isDirty || doc.saving) && styles.markdownButtonDisabled
]}
disabled={!doc.editable || !doc.isDirty || doc.saving}
onPress={onSave}
>
{doc.saving ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Text style={styles.markdownFloatingButtonText}>Save</Text>
)}
</Pressable>
) : null}
</View>
</View>
) : null}
</View>
)
}
function DiffLineRow({
line,
title,
index,
comments,
activeCommentLine,
commentDraft,
commentsBusy,
onStartComment,
onCancelComment,
onDraftChange,
onSubmitComment,
onDeleteComment
}: {
line: RenderableDiffLine
title: string
index: number
comments: DiffComment[]
activeCommentLine: number | null
commentDraft: string
commentsBusy: boolean
onStartComment: (lineNumber: number) => void
onCancelComment: () => void
onDraftChange: (value: string) => void
onSubmitComment: (lineNumber: number) => void
onDeleteComment: (commentId: string) => void
}) {
const commentLine = line.newLineNumber
const isCommenting = commentLine !== undefined && activeCommentLine === commentLine
const canComment = commentLine !== undefined
// Why: review notes anchor to the modified side, so show that line number in the single mobile gutter.
const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? ''
return (
<View style={styles.diffLineBlock}>
<View
style={[
styles.diffLine,
line.kind === 'add' && styles.diffLineAdded,
line.kind === 'delete' && styles.diffLineDeleted
]}
>
<Text style={styles.diffGutter}>{gutterLineNumber}</Text>
<Text
selectable
style={styles.diffText}
accessibilityLabel={`${title} diff line ${index + 1}`}
>
<Text
style={[
styles.diffPrefix,
line.kind === 'add' && styles.diffPrefixAdded,
line.kind === 'delete' && styles.diffPrefixDeleted
]}
>
{line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '}
</Text>
<MobileSyntaxSegments segments={line.segments} />
</Text>
{canComment ? (
<Pressable
style={({ pressed }) => [
styles.diffCommentAddButton,
pressed && styles.diffCommentAddButtonPressed,
commentsBusy && styles.diffCommentButtonDisabled
]}
disabled={commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onStartComment(commentLine)
}
}}
accessibilityLabel={`Add note on line ${commentLine}`}
>
<Plus size={12} color={colors.textSecondary} strokeWidth={2.3} />
</Pressable>
) : null}
</View>
{comments.length > 0 ? (
<View style={styles.diffCommentList}>
{comments.map((comment) => (
<View key={comment.id} style={styles.diffCommentCard}>
<View style={styles.diffCommentHeader}>
<MessageSquare size={12} color={colors.textMuted} strokeWidth={2.2} />
<Text style={styles.diffCommentMeta}>Line {comment.lineNumber}</Text>
<Pressable
style={styles.diffCommentDeleteButton}
disabled={commentsBusy}
onPress={() => onDeleteComment(comment.id)}
accessibilityLabel={`Delete note on line ${comment.lineNumber}`}
>
<X size={12} color={colors.textMuted} strokeWidth={2.2} />
</Pressable>
</View>
<Text style={styles.diffCommentBody}>{comment.body}</Text>
</View>
))}
</View>
) : null}
{isCommenting ? (
<View style={styles.diffCommentComposer}>
<TextInput
style={[styles.textInput, styles.diffCommentInput]}
value={commentDraft}
onChangeText={onDraftChange}
placeholder="Add review note"
placeholderTextColor={colors.textMuted}
editable={!commentsBusy}
multiline
textAlignVertical="top"
autoFocus
/>
<View style={styles.diffCommentComposerActions}>
<Pressable
style={styles.diffCommentSecondaryAction}
disabled={commentsBusy}
onPress={onCancelComment}
>
<Text style={styles.diffCommentSecondaryText}>Cancel</Text>
</Pressable>
<Pressable
style={[
styles.diffCommentPrimaryAction,
(!commentDraft.trim() || commentsBusy) && styles.diffCommentButtonDisabled
]}
disabled={!commentDraft.trim() || commentsBusy}
onPress={() => {
if (commentLine !== undefined) {
onSubmitComment(commentLine)
}
}}
>
<Text style={styles.diffCommentPrimaryText}>Save note</Text>
</Pressable>
</View>
</View>
) : null}
</View>
)
}
function FileReader({
doc,
title,
relativePath,
language,
diffCommentActions
}: {
doc: FileDocState | undefined
title: string
relativePath: string
language?: string
diffCommentActions?: DiffCommentActions
}) {
const syntaxLanguage = useMemo(
() => resolveMobileSyntaxLanguage(relativePath || title, language),
[language, relativePath, title]
)
const [fileSyntax, setFileSyntax] = useState<FileSyntaxState | null>(null)
const [diffSyntax, setDiffSyntax] = useState<DiffSyntaxState | null>(null)
const [activeCommentLine, setActiveCommentLine] = useState<number | null>(null)
const [commentDraft, setCommentDraft] = useState('')
const plainDiffLines = useMemo(
() =>
doc?.status === 'ready' && doc.kind === 'diff'
? buildPlainMobileDiffSyntaxLines(doc.lines)
: [],
[doc]
)
const diffCommentsForFile = useMemo(
() =>
diffCommentActions?.comments.filter(
(comment) => comment.filePath === relativePath && comment.source !== 'markdown'
) ?? [],
[diffCommentActions?.comments, relativePath]
)
const diffCommentsByLine = useMemo(() => {
const map = new Map<number, DiffComment[]>()
for (const comment of diffCommentsForFile) {
const list = map.get(comment.lineNumber) ?? []
list.push(comment)
map.set(comment.lineNumber, list)
}
for (const list of map.values()) {
list.sort((a, b) => a.createdAt - b.createdAt)
}
return map
}, [diffCommentsForFile])
const startComment = useCallback((lineNumber: number) => {
setActiveCommentLine(lineNumber)
setCommentDraft('')
}, [])
const cancelComment = useCallback(() => {
setActiveCommentLine(null)
setCommentDraft('')
}, [])
const submitComment = useCallback(
(lineNumber: number) => {
if (!diffCommentActions) {
return
}
void diffCommentActions.onAdd(relativePath, lineNumber, commentDraft).then((added) => {
if (added) {
setActiveCommentLine(null)
setCommentDraft('')
}
})
},
[commentDraft, diffCommentActions, relativePath]
)
const renderDiffLine: ListRenderItem<RenderableDiffLine> = useCallback(
({ item, index }) => (
<DiffLineRow
line={item}
title={title}
index={index}
comments={
item.newLineNumber !== undefined ? (diffCommentsByLine.get(item.newLineNumber) ?? []) : []
}
activeCommentLine={activeCommentLine}
commentDraft={commentDraft}
commentsBusy={diffCommentActions?.busy === true}
onStartComment={startComment}
onCancelComment={cancelComment}
onDraftChange={setCommentDraft}
onSubmitComment={submitComment}
onDeleteComment={(commentId) => {
if (diffCommentActions) {
void diffCommentActions.onDelete(commentId)
}
}}
/>
),
[
activeCommentLine,
cancelComment,
commentDraft,
diffCommentActions,
diffCommentsByLine,
startComment,
submitComment,
title
]
)
useEffect(() => {
if (doc?.status !== 'ready') {
return undefined
}
// Why: defer highlighting one tick so large files show as plain text immediately before colors are applied.
const timer = setTimeout(() => {
// file + html share the syntax-segment source view (html's "Source" toggle).
if (doc.kind === 'file' || doc.kind === 'html') {
setFileSyntax({
doc,
language: syntaxLanguage,
segments: highlightMobileCode(doc.content, syntaxLanguage).segments
})
return
}
if (doc.kind === 'diff') {
setDiffSyntax({
doc,
language: syntaxLanguage,
lines: highlightMobileDiffLines(doc.lines, syntaxLanguage)
})
}
// image: no syntax highlighting.
}, 0)
return () => clearTimeout(timer)
}, [doc, syntaxLanguage])
if (!doc || doc.status === 'loading') {
return (
<View style={styles.markdownState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
}
if (doc.status === 'error') {
return (
<View style={styles.markdownState}>
<Text style={styles.markdownError}>{doc.message}</Text>
</View>
)
}
if (doc.kind === 'diff') {
const activeDiffSyntax =
diffSyntax?.doc === doc && diffSyntax.language === syntaxLanguage ? diffSyntax.lines : null
const commentCount = diffCommentActions?.comments.length ?? 0
const unsentCommentCount =
diffCommentActions?.comments.filter((comment) => !comment.sentAt).length ?? 0
const commentsBusy = diffCommentActions?.busy === true
const canCopyNotes = commentCount > 0 && !commentsBusy
const canSendNotes = unsentCommentCount > 0 && !commentsBusy
return (
<View style={styles.markdownEditor}>
{diffCommentActions ? (
<View style={styles.diffNotesToolbar}>
<View style={styles.diffNotesTitleRow}>
<MessageSquare size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesTitle}>
{commentCount === 0
? 'No review notes'
: `${commentCount} review ${commentCount === 1 ? 'note' : 'notes'}`}
</Text>
</View>
<View style={styles.diffNotesActions}>
<Pressable
style={[
styles.diffNotesActionButton,
!canCopyNotes && styles.diffCommentButtonDisabled
]}
disabled={!canCopyNotes}
onPress={() => void diffCommentActions.onCopyAll()}
accessibilityLabel="Copy review notes"
>
<Copy size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesActionText}>Copy</Text>
</Pressable>
<Pressable
style={[
styles.diffNotesActionButton,
!canSendNotes && styles.diffCommentButtonDisabled
]}
disabled={!canSendNotes}
onPress={diffCommentActions.onSendAll}
accessibilityLabel="Send review notes to AI"
>
<Send size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.diffNotesActionText}>Send</Text>
</Pressable>
</View>
</View>
) : null}
<FlatList
data={activeDiffSyntax ?? plainDiffLines}
style={styles.filePreviewScroll}
contentContainerStyle={styles.filePreviewContent}
keyExtractor={(line, index) =>
`${index}:${line.kind}:${line.oldLineNumber ?? ''}:${line.newLineNumber ?? ''}`
}
renderItem={renderDiffLine}
initialNumToRender={32}
maxToRenderPerBatch={48}
windowSize={7}
removeClippedSubviews={Platform.OS !== 'web'}
keyboardShouldPersistTaps="handled"
/>
</View>
)
}
if (doc.kind === 'image') {
return (
<View style={styles.imagePreviewContainer}>
<ScrollView
style={styles.imagePreviewScroll}
contentContainerStyle={styles.imagePreviewContent}
maximumZoomScale={4}
minimumZoomScale={1}
centerContent
>
<Image
source={{ uri: doc.dataUri }}
style={styles.imagePreview}
resizeMode="contain"
accessibilityLabel={`${title} image`}
/>
</ScrollView>
</View>
)
}
const renderSourceText = (content: string) => (
<View style={styles.markdownEditor}>
<ScrollView
style={styles.filePreviewScroll}
contentContainerStyle={styles.filePreviewContent}
>
<Text selectable style={styles.filePreviewText} accessibilityLabel={`${title} preview`}>
<MobileSyntaxSegments
segments={
fileSyntax?.doc === doc && fileSyntax.language === syntaxLanguage
? fileSyntax.segments
: [{ text: content, kind: 'plain' }]
}
/>
</Text>
</ScrollView>
</View>
)
if (doc.kind === 'html') {
return (
<View style={styles.markdownEditor}>
<MobileHtmlPreview html={doc.content} renderSource={() => renderSourceText(doc.content)} />
</View>
)
}
return renderSourceText(doc.content)
}
export default function SessionScreen() {
const {
hostId,
worktreeId,
name: routeWorktreeName,
created,
warning: createdWarning
} = useLocalSearchParams<{
hostId: string
worktreeId: string
name?: string
created?: string
warning?: string
}>()
const isFolderWorkspaceRoute = worktreeId.startsWith('folder:') // Synthetic ids have no repo scope.
// Why: the floating sentinel has no repo/worktree, so repo-backed surfaces hide.
const isFloatingWorkspaceRoute = isFloatingWorkspaceWorktreeId(worktreeId)
const router = useRouter()
const insets = useSafeAreaInsets()
// Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md).
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const forceReconnectHost = useForceReconnect()
const { name: worktreeName, resolution: worktreeResolution } = useLiveWorktreeName({
client,
connState,
routeName: routeWorktreeName,
worktreeId
})
// Why: a workspace deleted on the desktop leaves every RPC on this route failing forever.
useMissingWorktreeBounce({
hostId,
worktreeId,
resolution: worktreeResolution,
bounce: (id) => router.replace(hostRouteWithNotice(id, 'worktree-missing'))
})
// Master-detail state: wide layouts dock a tapped panel beside the session; narrow keeps it null and pushes full-screen routes.
const { isWideLayout } = useResponsiveLayout()
const [activePanel, setActivePanel] = useState<ActivePanel>(null)
const [sessionContentRowWidth, setSessionContentRowWidth] = useState(0)
const canDockPanel =
!isFloatingWorkspaceRoute &&
canDockSessionPanel({
isWideLayout,
availableWidth: sessionContentRowWidth,
dockWidth: HOST_DOCK_MIN_WIDTH
})
// Why: if rotation/split-screen makes the docked row too narrow, clear activePanel so it doesn't survive into overlay/push mode.
useEffect(() => {
if (!canDockPanel && activePanel !== null) {
setActivePanel(null)
}
}, [canDockPanel, activePanel])
// GitHub remote probe gates the PR dock icon so non-GitHub providers can't open the hosted-review surface; skip the unused identity RPCs.
const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } =
useMobilePrBranchContext({
// Why: a null client parks the hook in its not-ready state — the floating
// sentinel has no repo to probe.
client: isFloatingWorkspaceRoute ? null : client,
connState,
worktreeId,
includeBranchIdentity: false
})
useEffect(() => {
if (prRepoContextLoaded && !prIsGithubRepo && activePanel === 'pr') {
setActivePanel(null)
}
}, [activePanel, prRepoContextLoaded, prIsGithubRepo])
const initialCreateWarning = typeof createdWarning === 'string' ? createdWarning.trim() : ''
const [terminals, setTerminals] = useState<Terminal[]>([])
const terminalsRef = useRef<Terminal[]>([])
const [sessionTabs, setSessionTabs] = useState<MobileSessionTab[]>([])
const sessionTabsRef = useRef<MobileSessionTab[]>([])
// Why: track the last applied (epoch, version) so a late older snapshot can't overwrite a newer one and resurrect closed tabs (session-tab-snapshot-gate).
const appliedSnapshotMarkerRef = useRef<AppliedSnapshotMarker>({ epoch: null, version: -1 })
const appliedSessionTabsRevisionRef = useRef(0)
// Why: after an optimistic close, suppress the tab (with expiry) until the publisher confirms, so an in-flight snapshot can't flash it back.
const closedTabTombstonesRef = useRef<Map<string, number>>(new Map())
const [terminalsLoaded, setTerminalsLoaded] = useWorktreeSessionTabsLoaded(worktreeId)
const [input, setInput] = useState('')
// Why: baseline terminal zoom reloaded on focus so a Settings → Terminal change applies in place (panes stay mounted).
const [terminalTextScale, setTerminalTextScale] = useState(1)
// Why: terminal command-bar autocomplete opt-in, reloaded on focus so a Settings → Terminal toggle takes effect on return.
const [autocompleteEnabled, setAutocompleteEnabled] = useState(false)
const [terminalLinkOpenMode, setTerminalLinkOpenMode] =
useState<MobileTerminalLinkOpenMode>('orca-browser')
const [liveInputCapture, setLiveInputCapture] = useState('')
const {
clearTerminalLiveInputDefault,
defaultTerminalHandlesToLiveInput,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
pruneTerminalHandlesFromLiveInput,
toggleTerminalLiveInput
} = useTerminalLiveInputModePreference({ hostId, worktreeId })
const [activeHandle, setActiveHandle] = useState<string | null>(null)
// Reactive teardown signal for the native-chat covered stream; see unsubscribeTerminal.
const [coveredStreamRevision, setCoveredStreamRevision] = useState(0)
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
const activeSessionTabIdRef = useRef<string | null>(null)
// Auto-scroll the tab strip so the desktop-synced active tab is revealed without a manual scroll.
const tabStripRef = useRef<ScrollView>(null)
const tabStripOffsetRef = useRef(0)
const tabStripViewportWidthRef = useRef(0)
const tabStripContentWidthRef = useRef(0)
const tabLayoutsRef = useRef<Map<string, { x: number; width: number }>>(new Map())
const [markdownDocs, setMarkdownDocs] = useState<Map<string, MarkdownDocState>>(new Map())
const markdownDocsRef = useRef<Map<string, MarkdownDocState>>(new Map())
const [fileDocs, setFileDocs] = useState<Map<string, FileDocState>>(new Map())
const [diffComments, setDiffComments] = useState<DiffComment[]>([])
const diffCommentsRef = useRef<DiffComment[]>([])
const [diffCommentBusy, setDiffCommentBusy] = useState(false)
const [pendingDiffNotesDelivery, setPendingDiffNotesDelivery] =
useState<DiffNotesDelivery | null>(null)
const [creating, setCreating] = useState(false)
// Why: React state isn't a synchronous lock; this ref blocks a double-tap's second create in the same tick before `creating` re-renders.
const creatingTerminalRef = useRef(false)
const [creatingBrowser, setCreatingBrowser] = useState(false)
const [creatingMarkdown, setCreatingMarkdown] = useState(false)
const [createError, setCreateError] = useState('')
const [createWarningState, setCreateWarningState] = useState(() =>
createMobileSessionCreateWarningState(initialCreateWarning)
)
const [showCreateTabDrawer, setShowCreateTabDrawer] = useState(false)
const [showQuickCommands, setShowQuickCommands] = useState(false)
const [createTabAgentLoadState, setCreateTabAgentLoadState] =
useState<MobileNewTabAgentLoadState>('idle')
const [createTabAgentOptions, setCreateTabAgentOptions] = useState<MobileNewTabAgentOption[]>([])
const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false)
const [showHeaderMoreActions, setShowHeaderMoreActions] = useState(false)
const [actionTarget, setActionTarget] = useState<Terminal | null>(null)
const [markdownActionTarget, setMarkdownActionTarget] = useState<Extract<
MobileSessionTab,
{ type: 'markdown' }
> | null>(null)
const [fileActionTarget, setFileActionTarget] = useState<Extract<
MobileSessionTab,
{ type: 'file' }
> | null>(null)
const [browserActionTarget, setBrowserActionTarget] = useState<Extract<
MobileSessionTab,
{ type: 'browser' }
> | null>(null)
const [discardMarkdownTarget, setDiscardMarkdownTarget] = useState<Extract<
MobileSessionTab,
{ type: 'markdown' }
> | null>(null)
const [leaveDrafts, setLeaveDrafts] = useState<DirtyMarkdownDraft[] | null>(null)
const [renameTarget, setRenameTarget] = useState<Terminal | null>(null)
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [visibleBuiltInIds, setVisibleBuiltInIds] = useState<string[]>(
getDefaultTerminalAccessoryBuiltInIds
)
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
const [deleteKeyTarget, setDeleteKeyTarget] = useState<CustomKey | null>(null)
const visibleBuiltInAccessoryKeys = useMemo(
() => getVisibleTerminalAccessoryKeys(visibleBuiltInIds),
[visibleBuiltInIds]
)
// Why: Expo SDK 55 edge-to-edge doesn't resize the window on IME open, so track keyboard height ourselves and lift the input without resizing the desktop PTY.
const [keyboardHeight, setKeyboardHeight] = useState(0)
// Why: server-authoritative display mode per terminal, populated from subscribe responses.
const [terminalModes, setTerminalModes] = useState<Map<string, MobileDisplayMode>>(new Map())
const [terminalKeyboardMetrics, setTerminalKeyboardMetrics] = useState<
Map<string, TerminalKeyboardAvoidanceMetrics>
>(new Map())
const [selectModeActive, setSelectModeActive] = useState(false)
const [canPaste, setCanPaste] = useState(false)
const [showDictationSetup, setShowDictationSetup] = useState(false)
// 'hold' = press-and-hold mic, 'toggle' = tap-to-start/stop; mirrors Settings ▸ Voice ▸ Dictation Mode.
const [dictationMode, setDictationMode] = useState<'toggle' | 'hold'>('toggle')
const [toastMessage, setToastMessage] = useState<string | null>(null)
const toastOpacityRef = useRef(new Animated.Value(0))
const toastHideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const toastSeqRef = useRef(0)
// Why: WebView pushes terminal modes on every change so paste reads a synchronous snapshot — no round-trip.
const ptyModesRef = useRef<Map<string, TerminalModes>>(new Map())
const terminalGestureInputBucketsRef = useRef<Map<string, TerminalGestureInputBucket>>(new Map())
const terminalGestureInputQueuesRef = useRef<Map<string, TerminalGestureInputQueue>>(new Map())
const terminalGestureInputInFlightRef = useRef<Set<string>>(new Set())
const terminalCwdRef = useRef<Map<string, string>>(new Map())
const initialModesSeenRef = useRef<Set<string>>(new Set())
const deviceTokenRef = useRef<string | null>(null)
// Why: state (not a ref) so the connection verdict re-renders when the endpoint loads and the Tailscale hint can appear.
const [hostEndpoint, setHostEndpoint] = useState<string | null>(null)
const clientRef = useRef<RpcClient | null>(null)
const connStateRef = useRef<ConnectionState>(connState)
// Why: measured once on mount, then passed with every subscribe so the server can auto-fit the PTY to phone dims.
const viewportRef = useRef<{ cols: number; rows: number } | null>(null)
const viewportMeasuredRef = useRef(false)
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
const liveInputRef = useRef<TextInput>(null)
const commandInputRef = useRef<TextInput>(null)
const liveInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sendLiveTerminalInputRef = useRef<TerminalLiveInputSender>(async () => false)
const sessionTabActionSheetKeyboardHideSubRef = useRef<ReturnType<
typeof Keyboard.addListener
> | null>(null)
const sessionTabActionSheetRequestSeqRef = useRef(0)
const dictationRouteContextRef = useRef<{
readonly handle: string | null
readonly liveInputEnabled: boolean
} | null>(null)
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: bounds the scrollback→resubscribe fit loop per handle (STA-3337).
const viewportResubscribeBudgetRef = useRef(new TerminalViewportResubscribeBudget())
// Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered.
const webReadyHandlesRef = useRef<Set<string>>(new Set())
const activeHandleRef = useRef<string | null>(null)
const activeSessionTabTypeRef = useRef<MobileSessionTabType | null>(null)
const pendingActiveSessionTabIdRef = useRef<string | null>(null)
const pendingActiveTerminalHandleRef = useRef<string | null>(null)
// Why: remember the page id to activate its session tab once it syncs (bridge auto-activate flags only webContents, not the app-level active tab).
const pendingBrowserFocusPageIdRef = useRef<string | null>(null)
const switchSessionTabRef = useRef<((tab: MobileSessionTab) => void) | null>(null)
const pendingTerminalActivationAttemptRef = useRef<string | null>(null)
// Why: route the terminal URL tap through a ref so it runs the current handleCreateBrowser closure (the memoized one may hold a null-client render).
const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise<boolean>) | null>(null)
const initialSessionAutoCreateRef = useRef(createInitialSessionAutoCreateState())
const markdownSaveSeqRef = useRef<Map<string, number>>(new Map())
const markdownSaveInFlightRef = useRef<Set<string>>(new Set())
const subscribeSeqRef = useRef<Map<string, number>>(new Map())
// Why: post-RPC refresh timers capture this screen and must not survive route reuse or unmount.
const delayedActionTimersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
// Why: highest applyLayout seq seen per handle; drop older scrollback/resized as stale, but a >20 gap resets (fresh subscription/server restart).
const layoutSeqRef = useRef<Map<string, number>>(new Map())
const sendingRef = useRef(false)
// Why: exact terminal-frame height for measureFitDimensions; window.innerHeight can overstate the visible area.
const terminalFrameHeightRef = useRef<number>(0)
// Why: sidebar resizes change the terminal frame width without a window-dim change; track it so the refit hook re-fits (see terminal-viewport-refit.ts).
const [terminalFrameWidth, setTerminalFrameWidth] = useState(0)
const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null
const {
clearPendingLiveInputCommit,
flushPendingLiveInputBeforeExternalSend,
handleLiveInputAccessoryBytes,
handleLiveInputChange,
handleLiveInputKeyPress,
handleLiveInputSubmit
} = useTerminalLiveInputCommit({
activeHandle,
activeHandleRef,
activeSessionTabType: activeSessionTab?.type,
activeSessionTabTypeRef,
connected: connState === 'connected',
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
})
const { canCompose, canSend } = resolveMobileTerminalInputGate({
connState,
activeHandle,
activeSessionTabType: activeSessionTab?.type
})
const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false
const { focusLiveInput, handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({
activeHandleRef,
canSend,
inputRef: liveInputRef,
keyboardHeight,
lifecycleIdentity: client,
lifecycleKey: JSON.stringify([hostId, worktreeId, connState]),
liveInputEnabled,
timerRef: liveInputFocusTimerRef
})
useFocusEffect(
useCallback(() => {
// Expo retains this route while pushed screens are visible.
return resetLiveInputFocus
}, [resetLiveInputFocus])
)
const [browserScreencastSupported, setBrowserScreencastSupported] = useState<boolean | null>(null)
// Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel.
const [agentSessionHistorySupported, setAgentSessionHistorySupported] = useState<boolean | null>(
null
)
const [quickCommandsSupported, setQuickCommandsSupported] = useState<boolean | null>(null)
// Why: stable callbacks (handleFileTap) read the live value via this ref, since
// the capability probe resolves after the callbacks are created.
const browserScreencastSupportedRef = useRef(browserScreencastSupported)
browserScreencastSupportedRef.current = browserScreencastSupported
// Why: terminal gesture/input callbacks are stable/imperative, so keep their refs current before commit, not in a later effect.
clientRef.current = client
connStateRef.current = connState
activeSessionTabTypeRef.current = activeSessionTab?.type ?? null
sessionTabsRef.current = sessionTabs
activeSessionTabIdRef.current = activeSessionTabId
markdownDocsRef.current = markdownDocs
const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState(
createWarningState,
initialCreateWarning
)
// Why: Expo can reuse this screen for a new route; reconcile before paint so a dismissed old warning doesn't flash.
if (reconciledCreateWarningState !== createWarningState) {
setCreateWarningState(reconciledCreateWarningState)
}
const createWarning = reconciledCreateWarningState.visible
const clearDelayedActionTimers = useCallback(() => {
for (const timer of delayedActionTimersRef.current) {
clearTimeout(timer)
}
delayedActionTimersRef.current.clear()
}, [])
const scheduleDelayedAction = useCallback((fn: () => void, ms: number) => {
const timer = setTimeout(() => {
delayedActionTimersRef.current.delete(timer)
fn()
}, ms)
delayedActionTimersRef.current.add(timer)
}, [])
const clearToastHideTimer = useCallback(() => {
if (!toastHideTimerRef.current) {
return
}
clearTimeout(toastHideTimerRef.current)
toastHideTimerRef.current = null
}, [])
const showToast = useCallback(
(message: string, durationMs = 1200) => {
const seq = toastSeqRef.current + 1
toastSeqRef.current = seq
clearToastHideTimer()
setToastMessage(message)
Animated.timing(toastOpacityRef.current, {
toValue: 1,
duration: 150,
useNativeDriver: true
}).start(({ finished }) => {
if (!finished || toastSeqRef.current !== seq) {
return
}
toastHideTimerRef.current = setTimeout(() => {
toastHideTimerRef.current = null
Animated.timing(toastOpacityRef.current, {
toValue: 0,
duration: 200,
useNativeDriver: true
}).start((result) => {
if (result.finished && toastSeqRef.current === seq) {
setToastMessage(null)
}
})
}, durationMs)
})
},
[clearToastHideTimer]
)
const nativeChatScopeKey = mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId)
const nativeChatSendError = useMobileNativeChatSendError({
scopeKey: nativeChatScopeKey,
showToast
})
const nativeChatTranscriptIsLocalReadable = useMobileNativeChatReadability(client, worktreeId)
const {
ready: nativeChatInputLeaseReady,
readyRef: nativeChatInputLeaseReadyRef,
lockReason: nativeChatInputLockReason,
markReady: markNativeChatInputLeaseReady,
clear: clearNativeChatInputLease
} = useMobileNativeChatInputLease({
activeHandle,
connected: connState === 'connected'
})
const nativeChatController = useMobileNativeChatController({
client,
hostId,
worktreeId,
activeSessionTab,
activeSessionTabId,
activeHandleRef,
deviceTokenRef,
nativeChatTranscriptIsLocalReadable,
nativeChatInputLeaseReady,
connState,
onSendError: nativeChatSendError.show,
onSendResolved: nativeChatSendError.clear
})
const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
nativeChatSendError.bannerMountedRef.current = showNativeChat
const dictation = useMobileDictation({
client,
enabled: canSend,
onTranscript: (text) => {
// Why: dictation belongs to the visible composer — native chat consumes it locally, terminal mode keeps live-input routing.
if (showNativeChatRef.current) {
nativeChatController.setChatComposerText((current) =>
appendBufferedDictation(current, text)
)
showToast('Dictation inserted')
return
}
// Live mode inserts the transcript into its PTY as text (no Return); buffered mode appends to the command field.
const routeContext = dictationRouteContextRef.current
dictationRouteContextRef.current = null
const route = routeDictationTranscript(
text,
routeContext?.liveInputEnabled ?? liveInputEnabled
)
if (route.kind === 'live-insert') {
const insertHandle = routeContext?.handle ?? activeHandleRef.current
if (!insertHandle) {
return
}
void (async () => {
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(insertHandle)
if (!flushedPendingInput) {
return
}
const sent = await sendLiveTerminalInput(insertHandle, route.text)
if (sent) {
showToast('Dictation inserted')
}
})()
return
}
setInput((current) => appendBufferedDictation(current, route.text))
showToast('Dictation inserted')
},
onError: (err) => {
dictationRouteContextRef.current = null
// Dictation not set up on desktop → open the setup sheet instead of a dead-end toast.
if (isDictationSetupRequiredError(err.message)) {
setShowDictationSetup(true)
return
}
triggerError()
showToast(err.message)
}
})
const startDictation = useCallback(() => {
const routeContext = activeHandle
? { handle: activeHandle, liveInputEnabled: liveInputTerminalHandles.has(activeHandle) }
: null
dictationRouteContextRef.current = routeContext
void dictation.start().catch((err) => {
if (dictationRouteContextRef.current === routeContext) {
dictationRouteContextRef.current = null
}
triggerError()
showToast(err instanceof Error ? err.message : String(err))
})
}, [activeHandle, dictation, liveInputTerminalHandles, triggerError, showToast])
const cancelDictation = useCallback(() => {
dictationRouteContextRef.current = null
void dictation.cancel()
}, [dictation])
// Toggle mode: one tap starts, the next stops; long-press cancels mid-record.
const handleDictationToggle = useCallback(() => {
if (dictation.isProcessing) {
cancelDictation()
} else if (dictation.isStarting) {
return
} else if (dictation.isRecording) {
void dictation.stop()
} else {
startDictation()
}
}, [cancelDictation, dictation, startDictation])
// Hold mode: press starts, release stops — like a walkie-talkie.
const handleDictationPressIn = useCallback(() => {
if (!dictation.isStarting && !dictation.isRecording && !dictation.isProcessing) {
startDictation()
}
}, [dictation, startDictation])
const handleDictationPressOut = useCallback(() => {
if (dictation.isRecording) {
void dictation.stop()
} else if (dictation.isStarting) {
// Released before recording began: cancel so we don't leave a live mic.
cancelDictation()
}
}, [cancelDictation, dictation])
const refreshDictationMode = useCallback(async () => {
if (!client) {
return
}
try {
const setup = await fetchDictationSetup(client)
setDictationMode(setup.dictationMode)
} catch {
// Non-fatal: fall back to the default toggle behavior.
}
}, [client])
// Re-read on focus so a Settings ▸ Voice dictation-mode change is reflected on return.
useFocusEffect(
useCallback(() => {
void refreshDictationMode()
}, [refreshDictationMode])
)
useEffect(() => {
diffCommentsRef.current = diffComments
}, [diffComments])
const getTerminalRef = useCallback((handle: string | null) => {
return handle ? terminalRefs.current.get(handle) : undefined
}, [])
const unsubscribeTerminal = useCallback(
(handle: string) => {
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: reset the high-water mark so a fresh subscription's first scrollback isn't dropped as stale.
layoutSeqRef.current.delete(handle)
// Why compare against the RENDERED lease: `clear` reports the drop from its
// synchronous mirror, so a `subscribed`+`end` pair applied in one render batch
// reports "dropped" while React only ever sees false → the effect never re-runs
// and the composer stays locked (#10681). A dead PTY can also emit `end` with no
// preceding `subscribed`, where the clear is a no-op for the same reason. Either
// way the flip carries no signal, so bump. When the lease really was up on
// screen, `leaseReady` already re-runs the effect and bumping too would
// double-render this whole route on every chat open.
const leaseWasOnScreen = nativeChatInputLeaseReadyRef.current
const leaseDropped = clearNativeChatInputLease(handle)
if (
(!leaseDropped || !leaseWasOnScreen) &&
showNativeChatRef.current &&
handle === activeHandleRef.current
) {
setCoveredStreamRevision((revision) => revision + 1)
}
},
[clearNativeChatInputLease, nativeChatInputLeaseReadyRef, showNativeChatRef]
)
const unsubscribeTerminalRef = useRef(unsubscribeTerminal)
unsubscribeTerminalRef.current = unsubscribeTerminal
const clearTerminalCache = useCallback(() => {
terminalUnsubsRef.current.forEach((unsub) => unsub())
clearNativeChatInputLease()
terminalUnsubsRef.current.clear()
subscribingHandlesRef.current.clear()
initializedHandlesRef.current.clear()
terminalDiagnosticsRef.current.clearTerminalCache()
viewportResubscribeBudgetRef.current.clear()
webReadyHandlesRef.current.clear()
subscribeSeqRef.current.clear()
layoutSeqRef.current.clear()
terminalCwdRef.current.clear()
setTerminalKeyboardMetrics(new Map())
for (const term of terminalRefs.current.values()) {
term.clear()
}
}, [clearNativeChatInputLease])
// Why: measure the phone viewport once from the first TerminalWebView; dims ride every subscribe so the server auto-fits without a separate RPC.
const measureViewportOnce = useCallback(
async (handle: string) => {
if (viewportMeasuredRef.current) {
return
}
const dims = await getTerminalRef(handle)?.measureFitDimensions(
terminalFrameHeightRef.current || undefined
)
terminalDiagnosticsRef.current.viewportMeasured(handle, dims, terminalFrameHeightRef.current)
if (dims) {
viewportRef.current = dims
viewportMeasuredRef.current = true
}
},
[getTerminalRef]
)
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
}
const covered = nativeChatTerminalStream.isTerminalCoveredByNativeChat(
showNativeChatRef.current,
activeHandleRef.current,
handle
)
// Why: a native-chat-covered terminal has no mounted webview, so only gate on the webview when not covered.
if (!covered) {
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: viewport is embedded in the subscribe params so the server auto-fits before serializing scrollback (no focus→safeFit race).
const unsub = subscribeMobileTerminalSafely(
client,
{
terminal: handle,
client: { id: deviceTokenRef.current!, type: 'mobile' as const },
viewport: nativeChatTerminalStream.mobileNativeChatSubscribeViewport(
covered,
viewportRef.current
),
capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered)
},
(result) => {
if (subscribeSeqRef.current.get(handle) !== seq) {
return
}
const data = result as Record<string, unknown>
diagnostics.firstStreamEvent(handle, seq, data.type)
if (data.type === 'end' || data.type === 'error') {
unsubscribeTerminalRef.current(handle)
return
}
if (data.type === 'subscribed') {
markNativeChatInputLeaseReady(handle)
return
}
// Why: keep the subscription as the input-floor lease but don't mutate covered xterm state; return-to-terminal resubscribes.
if (
nativeChatTerminalStream.isTerminalCoveredByNativeChat(
showNativeChatRef.current,
activeHandleRef.current,
handle
)
) {
return
}
// Why: drop `resized` events older than the seen seq (superseded layout); scrollback always resets the mark, else reconnect blanks the terminal.
const eventSeq = typeof data.seq === 'number' ? data.seq : null
if (eventSeq != null && data.type === 'resized') {
const last = layoutSeqRef.current.get(handle)
if (last != null && eventSeq < last && last - eventSeq <= 20) {
console.log('[fit][session] DROP-stale-seq', {
handle: handle.slice(-8),
type: data.type,
eventSeq,
lastSeq: last,
cols: data.cols,
rows: data.rows,
displayMode: data.displayMode
})
return
}
layoutSeqRef.current.set(handle, eventSeq)
} else if (eventSeq != null && data.type === 'scrollback') {
layoutSeqRef.current.set(handle, eventSeq)
}
if (data.type === 'scrollback') {
diagnostics.streamScrollback(handle, seq, eventSeq, data)
if (initializedHandlesRef.current.has(handle)) {
return
}
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
const { hostCols, hostRows } = readTerminalViewportDims(data)
// Why: absent host dims must not be coerced into a comparable size — 80x24
// never equals a phone viewport and armed a zero-delay resubscribe loop (STA-3337).
const cols = hostCols ?? viewportRef.current?.cols ?? 80
const rows = hostRows ?? viewportRef.current?.rows ?? 24
const initialData =
typeof data.serialized === 'string' && data.serialized.length > 0
? data.serialized
: ''
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
const ref = getTerminalRef(handle)
// Why: only mark initialized once init() reaches the WebView, else later scrollback is dropped and the terminal stays blank.
if (!ref) {
console.log('[fit][session] scrollback DROPPED — no terminal ref', {
handle: handle.slice(-8),
cols,
rows
})
return
}
ref.init(cols, rows, initialData, false, oscLinks)
initializedHandlesRef.current.add(handle)
if (data.displayMode) {
const displayMode = data.displayMode as MobileDisplayMode
// Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route.
setTerminalModes((prev) =>
prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode)
)
}
// Why: cold-start refit — init()'s fit can run against a transient scrollWidth, so re-fire against a settled DOM.
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
// Why: first subscribe has no viewport (xterm not loaded yet), so measure after init
// and resubscribe so the server can phone-fit — bounded per handle so a
// non-converging host degrades visibly instead of hot-looping (STA-3337).
runTerminalViewportFitPass({
handle,
seq,
hostCols,
hostRows,
budget: viewportResubscribeBudgetRef.current,
diagnostics,
viewportRef,
viewportMeasuredRef,
subscribeSeqRef,
initializedHandlesRef,
terminalUnsubsRef,
terminalFrameHeightRef,
getTerminalRef,
unsubscribeTerminal,
subscribeToTerminal,
scheduleDelayedAction,
showToast
})
} else if (data.type === 'metadata') {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
} else if (data.type === 'data') {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
// Why: missing ref is the likely cause of "blank but input works" — writes dropped after mid-flight unmount or scrollback never landed.
const dataRef = getTerminalRef(handle)
if (!dataRef) {
console.log('[fit][session] data DROPPED — no terminal ref', {
handle: handle.slice(-8),
chunkLen: typeof data.chunk === 'string' ? data.chunk.length : 0,
initialized: initializedHandlesRef.current.has(handle)
})
return
}
if (!initializedHandlesRef.current.has(handle)) {
console.log('[fit][session] data RECEIVED before scrollback', {
handle: handle.slice(-8),
chunkLen: typeof data.chunk === 'string' ? data.chunk.length : 0
})
}
dataRef.write(data.chunk as string)
} else if (data.type === 'resized') {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
// Server resize: reinit xterm on a full-buffer snapshot (width reflow rewraps scrollback), else just resize geometry.
const viewport = viewportMeasuredRef.current ? viewportRef.current : null
const [cols, rows] = viewportResubscribeBudgetRef.current.observeResize(
handle,
data,
viewport
)
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)
} else {
getTerminalRef(handle)?.resize(cols, rows)
}
if (data.displayMode) {
const displayMode = data.displayMode as MobileDisplayMode
// Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route.
setTerminalModes((prev) =>
prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode)
)
}
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
}
},
() => unsubscribeTerminalRef.current(handle)
)
if (subscribeSeqRef.current.get(handle) === seq) {
terminalUnsubsRef.current.set(handle, unsub)
} else {
unsub()
}
subscribingHandlesRef.current.delete(handle)
},
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, showToast]
)
const nativeChatStream = useMobileNativeChatTerminalStream({
showNativeChat,
activeHandle,
activeTabType: activeSessionTab?.type ?? null,
leaseReady: nativeChatInputLeaseReady,
streamRevision: coveredStreamRevision,
subscriptionsRef: terminalUnsubsRef,
subscribingRef: subscribingHandlesRef,
webReadyRef: webReadyHandlesRef,
initializedRef: initializedHandlesRef,
subscribe: subscribeToTerminal,
unsubscribe: unsubscribeTerminal
})
// Why: server does the resize and emits 'resized' on the existing subscription — no client-side state tracking needed.
const toggleInFlightRef = useRef<Set<string>>(new Set())
const toggleDisplayMode = useCallback(
async (handle: string) => {
if (!client) {
return
}
if (toggleInFlightRef.current.has(handle)) {
return
}
const current = terminalModes.get(handle) ?? 'auto'
// Why: 'phone' is an observed state, not a setting; the toggle only requests 'auto' or 'desktop'.
const next: 'auto' | 'desktop' =
current === 'auto' || current === 'phone' ? 'desktop' : 'auto'
toggleInFlightRef.current.add(handle)
try {
await client.sendRequest('terminal.setDisplayMode', {
terminal: handle,
mode: next,
// Why: presence-lock take-floor — requesting 'auto' is the explicit "drive at phone dims" gesture.
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {}),
// Why: late-bind viewport for terminals subscribed before measurement, or auto toggles no-op on a null stored viewport.
...(viewportRef.current && next === 'auto' ? { viewport: viewportRef.current } : {})
})
} catch {
// Mode change failed — server state unchanged, UI stays in sync.
} finally {
toggleInFlightRef.current.delete(handle)
}
},
[client, terminalModes]
)
const lastKnownTerminalCountRef = useRef(0)
const fetchTerminalsInFlightRef = useRef(false)
const fetchTerminals = useCallback(
async (opts: { allowEmptyLoaded?: boolean } = {}) => {
if (!client) {
return
}
if (fetchTerminalsInFlightRef.current) {
return
}
fetchTerminalsInFlightRef.current = true
const allowEmptyLoaded = opts.allowEmptyLoaded ?? true
try {
const response = await client.sendRequest('terminal.list', {
worktree: `id:${worktreeId}`,
includeVisualLayouts: false
})
if (response.ok) {
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
if (result.terminals.length === 0 && !allowEmptyLoaded) {
return
}
// Why: require two consecutive empties before trusting 0, so transient empty responses don't flash the UI empty.
if (result.terminals.length === 0 && lastKnownTerminalCountRef.current > 0) {
lastKnownTerminalCountRef.current = 0
return
}
const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))
const pruneContext = {
liveHandles,
showNativeChat: showNativeChatRef.current,
activeHandle: activeHandleRef.current
}
// Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out.
// Sweep against the retained set, not the raw list: a chat-covered handle
// keeps its subscription across a graph reload, so erasing its live-input
// preference on the same refresh is the erasure this guard exists to stop.
pruneTerminalHandlesFromLiveInput(resolveRetainedTerminalHandles(pruneContext))
defaultTerminalHandlesToLiveInput([...liveHandles])
const shouldPrune = createTerminalPrunePredicate(pruneContext)
for (const handle of Array.from(terminalUnsubsRef.current.keys())) {
if (!shouldPrune(handle)) {
continue
}
unsubscribeTerminal(handle)
terminalRefs.current.delete(handle)
initializedHandlesRef.current.delete(handle)
viewportResubscribeBudgetRef.current.forget(handle)
clearTerminalLiveInputDefault(handle)
}
setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))
// Why: a chat-covered handle the host reports again refills its rearm budget,
// so an exhausted rearm can't lock the composer until leave-chat.
nativeChatStream.notifyListedHandles(liveHandles)
// Why: same absence-gated refill for the viewport-fit budget — a handle that
// left the list and returned may converge now, so it earns fresh attempts.
viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles)
lastKnownTerminalCountRef.current = result.terminals.length
// Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order.
const seen = new Set<string>()
const deduped = result.terminals.filter((t) => {
if (seen.has(t.handle)) {
return false
}
seen.add(t.handle)
return true
})
const mergedTerminals = mergeTerminalListWithKnownRecords(
deduped,
terminalsRef.current,
sessionTabsRef.current
)
setTerminals((prev) =>
terminalRecordsEqual(prev, mergedTerminals) ? prev : mergedTerminals
)
terminalsRef.current = mergedTerminals
// Session tabs are the UI authority; terminal.list only refreshes per-handle metadata for existing terminal surfaces.
}
} catch {
// Failed to list terminals
} finally {
fetchTerminalsInFlightRef.current = false
}
},
[
client,
worktreeId,
clearTerminalLiveInputDefault,
defaultTerminalHandlesToLiveInput,
nativeChatStream,
pruneTerminalHandlesFromLiveInput,
subscribeToTerminal,
unsubscribeTerminal
]
)
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]
}
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 snapshotActive = nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null
const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current
const pendingActiveTerminalHandle = pendingActiveTerminalHandleRef.current
let active = snapshotActive
let selectionSource = 'snapshot'
if (pendingActiveSessionTabId) {
if (snapshotActive?.id === pendingActiveSessionTabId) {
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 mid-activate-RPC; keep the local selection to avoid snapping back.
active = pendingTab
selectionSource = 'pending-tab'
} else {
pendingActiveSessionTabIdRef.current = null
}
}
}
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 (
snapshotActive?.type === 'terminal' &&
snapshotActive.terminal === pendingActiveTerminalHandle
) {
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
pendingActiveTerminalHandleRef.current = null
} else {
selectionSource = 'pending-handle-local-ack'
}
} 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 {
pendingActiveTerminalHandleRef.current = null
}
}
diagnostics.tabsApplied(result, nextTabs, active, selectionSource)
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)
initializedHandlesRef.current.delete(previous)
}
activeHandleRef.current = null
setActiveHandle(null)
return outcome
}
const previous = activeHandleRef.current
if (previous && previous !== active.terminal) {
unsubscribeTerminal(previous)
initializedHandlesRef.current.delete(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)
initializedHandlesRef.current.delete(previous)
}
activeHandleRef.current = null
setActiveHandle(null)
}
return outcome
},
[defaultTerminalHandlesToLiveInput, subscribeToTerminal, unsubscribeTerminal]
)
const readMarkdownTab = useCallback(
async (tab: Extract<MobileSessionTab, { type: 'markdown' }>) => {
if (!client) {
return
}
setMarkdownDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' }))
try {
const response = await client.sendRequest('markdown.readTab', {
worktree: `id:${worktreeId}`,
tabId: tab.id
})
if (response.ok) {
const result = (response as RpcSuccess).result as {
content: string
version: string
isDirty: boolean
editable?: boolean
readOnlyReason?: string
}
setMarkdownDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'ready',
content: result.content,
localContent: result.content,
baseVersion: result.version,
isDirty: false,
editable: result.editable === true,
stale: result.isDirty,
readOnlyReason: result.readOnlyReason
})
)
return
}
if (!shouldReadMarkdownFromDiskAfterReadTabFailure(response as RpcFailure)) {
throw new Error((response as RpcFailure).error.message)
}
// Why: a headless host fails markdown.readTab (renderer_unavailable); fall back to the on-disk file for read-only render.
const fallback = await client.sendRequest('files.read', {
worktree: `id:${worktreeId}`,
relativePath: tab.relativePath
})
if (!fallback.ok) {
throw new Error('Unable to read markdown')
}
const fileResult = (fallback as RpcSuccess).result as {
content: string
truncated: boolean
byteLength: number
}
setMarkdownDocs((prev) =>
new Map(prev).set(
tab.id,
buildMarkdownDiskFallbackDoc({
content: fileResult.content,
truncated: fileResult.truncated,
tabIsDirty: tab.isDirty
})
)
)
} catch {
setMarkdownDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'error',
message: "Couldn't load markdown"
})
)
}
},
[client, worktreeId]
)
const readFileTab = useCallback(
async (tab: Extract<MobileSessionTab, { type: 'file' }>) => {
if (!client) {
return
}
setFileDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' }))
try {
const doc = await resolveMobileFileTabDoc(client, {
worktreeId,
relativePath: tab.relativePath,
diffSource: tab.diffSource
})
setFileDocs((prev) => new Map(prev).set(tab.id, doc))
} catch (err) {
const message = err instanceof Error ? err.message : ''
const previewMessage =
message === 'binary_file'
? 'Binary preview unavailable'
: message === 'file_too_large'
? 'File too large for mobile preview'
: tab.diffSource === 'staged' || tab.diffSource === 'unstaged'
? "Couldn't load diff preview"
: "Couldn't load file preview"
setFileDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'error',
message: previewMessage
})
)
}
},
[client, worktreeId]
)
const loadDiffComments = useCallback(async (): Promise<void> => {
if (!client || connState !== 'connected' || !worktreeId || isFloatingWorkspaceRoute) {
setDiffComments([])
return
}
const response = await client.sendRequest('worktree.show', {
worktree: `id:${worktreeId}`
})
if (!response.ok) {
return
}
const result = (response as RpcSuccess).result as {
worktree?: { diffComments?: unknown }
}
setDiffComments(normalizeMobileDiffComments(result.worktree?.diffComments, worktreeId))
}, [client, connState, worktreeId, isFloatingWorkspaceRoute])
const persistDiffComments = useCallback(
async (comments: readonly DiffComment[]): Promise<void> => {
if (!client || connState !== 'connected') {
throw new Error('Waiting for desktop...')
}
const response = await client.sendRequest('worktree.set', {
worktree: `id:${worktreeId}`,
diffComments: comments
})
if (!response.ok) {
throw new Error((response as RpcFailure).error.message || 'Failed to save review notes')
}
},
[client, connState, worktreeId]
)
useEffect(() => {
void loadDiffComments()
}, [loadDiffComments])
const addDiffCommentForFile = useCallback(
async (filePath: string, lineNumber: number, body: string): Promise<boolean> => {
if (diffCommentBusy) {
return false
}
const nextId = `mobile-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
const result = addMobileDiffComment(diffCommentsRef.current, {
id: nextId,
worktreeId,
filePath,
lineNumber,
body,
createdAt: Date.now()
})
if (!result.comment) {
return false
}
const previous = diffCommentsRef.current
setDiffCommentBusy(true)
setDiffComments(result.comments)
try {
await persistDiffComments(result.comments)
triggerSuccess()
showToast('Note added')
return true
} catch (err) {
setDiffComments(previous)
triggerError()
showToast(err instanceof Error ? err.message : 'Failed to save note', 1600)
return false
} finally {
setDiffCommentBusy(false)
}
},
[diffCommentBusy, persistDiffComments, showToast, worktreeId]
)
const deleteDiffCommentForFile = useCallback(
async (commentId: string): Promise<void> => {
if (diffCommentBusy) {
return
}
const previous = diffCommentsRef.current
const next = removeMobileDiffComments(previous, new Set([commentId]))
if (next.length === previous.length) {
return
}
setDiffCommentBusy(true)
setDiffComments(next)
try {
await persistDiffComments(next)
triggerSelection()
} catch (err) {
setDiffComments(previous)
triggerError()
showToast(err instanceof Error ? err.message : 'Failed to delete note', 1600)
} finally {
setDiffCommentBusy(false)
}
},
[diffCommentBusy, persistDiffComments, showToast]
)
const copyDiffCommentsToClipboard = useCallback(async (): Promise<void> => {
const comments = diffCommentsRef.current
if (comments.length === 0) {
return
}
try {
await Clipboard.setStringAsync(formatDiffComments(comments))
triggerSuccess()
showToast('Notes copied')
} catch {
triggerError()
showToast("Couldn't copy notes", 1600)
}
}, [showToast])
const sendDiffCommentsToAgent = useCallback((): void => {
const comments = diffCommentsRef.current.filter((comment) => !comment.sentAt)
if (comments.length === 0) {
return
}
setPendingDiffNotesDelivery({
comments: [...comments],
prompt: formatDiffComments(comments)
})
}, [])
const clearDeliveredDiffComments = useCallback(
async (delivered: readonly DiffComment[]): Promise<void> => {
const previous = diffCommentsRef.current
const next = removeDeliveredMobileDiffComments(previous, delivered)
if (next.length === previous.length) {
return
}
setDiffCommentBusy(true)
setDiffComments(next)
try {
await persistDiffComments(next)
} catch {
setDiffComments(previous)
} finally {
setDiffCommentBusy(false)
}
},
[persistDiffComments]
)
const updateMarkdownLocalContent = useCallback((tabId: string, content: string) => {
setMarkdownDocs((prev) => {
const current = prev.get(tabId)
if (current?.status !== 'ready') {
return prev
}
const next = new Map(prev)
next.set(tabId, {
...current,
localContent: content,
isDirty: content !== current.content,
saveError: undefined
})
return next
})
}, [])
const copyMarkdownLocalContent = useCallback(
async (tabId: string) => {
const current = markdownDocs.get(tabId)
if (current?.status !== 'ready') {
return
}
await Clipboard.setStringAsync(current.localContent)
triggerSuccess()
showToast('Copied')
},
[markdownDocs, showToast]
)
const getDirtyMarkdownDrafts = useCallback(() => {
const drafts: DirtyMarkdownDraft[] = []
for (const [tabId, doc] of markdownDocs) {
if (doc.status === 'ready' && doc.isDirty) {
const tab = sessionTabs.find((candidate) => candidate.id === tabId)
drafts.push({ tabId, title: tab?.title || 'Markdown', content: doc.localContent })
}
}
return drafts
}, [markdownDocs, sessionTabs])
const leaveSession = useCallback(() => {
if (router.canGoBack()) {
router.back()
return
}
// Why: Android back can fire at the root route; replace avoids React Navigation's dev-only GO_BACK warning.
router.replace(`/h/${hostId}`)
}, [hostId, router])
const requestLeaveSession = useCallback(() => {
const dirtyDrafts = getDirtyMarkdownDrafts()
if (dirtyDrafts.length === 0) {
leaveSession()
return
}
Keyboard.dismiss()
setLeaveDrafts(dirtyDrafts)
}, [getDirtyMarkdownDrafts, leaveSession])
useEffect(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
requestLeaveSession()
return true
})
return () => subscription.remove()
}, [requestLeaveSession])
const discardMarkdownLocalContent = useCallback(
(tab: Extract<MobileSessionTab, { type: 'markdown' }>) => {
const current = markdownDocs.get(tab.id)
if (current?.status !== 'ready') {
return
}
if (!current.isDirty) {
void readMarkdownTab(tab)
return
}
Keyboard.dismiss()
setDiscardMarkdownTarget(tab)
},
[markdownDocs, readMarkdownTab]
)
const confirmDiscardMarkdown = useCallback(() => {
const target = discardMarkdownTarget
setDiscardMarkdownTarget(null)
if (target) {
void readMarkdownTab(target)
}
}, [discardMarkdownTarget, readMarkdownTab])
const saveMarkdownTab = useCallback(
async (tab: Extract<MobileSessionTab, { type: 'markdown' }>) => {
if (!client) {
return
}
const current = markdownDocs.get(tab.id)
if (current?.status !== 'ready' || current.saving || !current.editable) {
return
}
if (markdownSaveInFlightRef.current.has(tab.id)) {
return
}
markdownSaveInFlightRef.current.add(tab.id)
const saveSeq = (markdownSaveSeqRef.current.get(tab.id) ?? 0) + 1
markdownSaveSeqRef.current.set(tab.id, saveSeq)
setMarkdownDocs((prev) => {
const existing = prev.get(tab.id)
if (existing?.status !== 'ready') {
return prev
}
return new Map(prev).set(tab.id, { ...existing, saving: true, saveError: undefined })
})
try {
const response = await client.sendRequest('markdown.saveTab', {
worktree: `id:${worktreeId}`,
tabId: tab.id,
baseVersion: current.baseVersion,
content: current.localContent
})
if (!response.ok) {
throw new Error((response as RpcFailure).error.message)
}
const result = (response as RpcSuccess).result as {
content: string
version: string
isDirty: false
}
if (markdownSaveSeqRef.current.get(tab.id) !== saveSeq) {
return
}
setMarkdownDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'ready',
content: result.content,
localContent: result.content,
baseVersion: result.version,
isDirty: false,
editable: true
})
)
markdownSaveSeqRef.current.delete(tab.id)
triggerSuccess()
showToast('Saved')
} catch (error) {
triggerError()
const message = error instanceof Error ? error.message : 'Save failed'
if (markdownSaveSeqRef.current.get(tab.id) !== saveSeq) {
return
}
setMarkdownDocs((prev) => {
const existing = prev.get(tab.id)
if (existing?.status !== 'ready') {
return prev
}
return new Map(prev).set(tab.id, {
...existing,
saving: false,
saveError: message || 'Save failed'
})
})
} finally {
markdownSaveInFlightRef.current.delete(tab.id)
}
},
[client, markdownDocs, showToast, worktreeId]
)
const consumeAcceptedSessionTabs = useCallback(
(
_result: SessionTabsResult,
effectiveTabs: readonly MobileSessionTab[],
source: SessionTabsStreamSource
): void => {
runAcceptedMobileSessionTabsEffects<MobileSessionTab>({
effectiveTabs,
source,
getPendingBrowserPageId: () => pendingBrowserFocusPageIdRef.current,
clearPendingBrowserPageId: (pageId) => {
if (pendingBrowserFocusPageIdRef.current === pageId) {
pendingBrowserFocusPageIdRef.current = null
}
},
activateBrowserTab: (tab) => switchSessionTabRef.current?.(tab),
markActiveMarkdownStale: (tabId) => {
setMarkdownDocs((prev) => {
const current = prev.get(tabId)
if (current?.status !== 'ready' || current.isDirty) {
return prev
}
return new Map(prev).set(tabId, { ...current, stale: true })
})
}
})
},
[]
)
const hasSessionTabsRecoveryNeed = useCallback(
() =>
closedTabTombstonesRef.current.size > 0 ||
pendingBrowserFocusPageIdRef.current !== null ||
// Why: a chat-covered handle that ran out of rearms and left `terminal.list`
// was reminted by a desktop graph reload. Only a fresh tab snapshot carries
// the replacement handle, so force one instead of holding the composer locked.
nativeChatStream.hasTabsRecoveryNeed(),
[nativeChatStream]
)
const getSessionTabsApplicationRevision = useCallback(
() => appliedSessionTabsRevisionRef.current,
[]
)
const sessionTabsFetchReporting = useMobileSessionTabsFetchReporting<SessionTabsResult>({
worktreeId,
diagnosticsRef: terminalDiagnosticsRef
})
const { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs } =
useMobileSessionTabsReconciliation<SessionTabsResult, MobileSessionTab>({
client,
connState,
worktreeId,
applySessionTabs,
consumeAcceptedSessionTabs,
fetchTerminals,
hasRecoveryNeed: hasSessionTabsRecoveryNeed,
getApplicationRevision: getSessionTabsApplicationRevision,
...sessionTabsFetchReporting
})
useEffect(() => {
if (connState === 'connected') {
return
}
for (const queued of terminalGestureInputQueuesRef.current.values()) {
if (queued.timer) {
clearTimeout(queued.timer)
}
}
terminalGestureInputQueuesRef.current.clear()
terminalGestureInputInFlightRef.current.clear()
}, [connState])
const hostQueryReplyInputSupportedRef = useRef(false)
useEffect(() => {
if (!client || connState !== 'connected') {
setBrowserScreencastSupported(null)
setAgentSessionHistorySupported(null)
setQuickCommandsSupported(null)
setShowQuickCommands(false)
hostQueryReplyInputSupportedRef.current = false
return
}
// Why: a client swap can keep the route connected while moving to an older
// host; clear the prior capability before exposing host-specific actions.
setBrowserScreencastSupported(null)
setAgentSessionHistorySupported(null)
setQuickCommandsSupported(null)
setShowQuickCommands(false)
hostQueryReplyInputSupportedRef.current = false
// Why: the probe retries — a relay→direct cutover or request timeout rejects
// status.get without changing connState, which used to latch these hidden.
return startRuntimeCapabilityProbe(client, (capabilities) => {
setBrowserScreencastSupported(capabilities.includes('browser.screencast.v1'))
setAgentSessionHistorySupported(capabilities.includes(MOBILE_AI_VAULT_CAPABILITY))
setQuickCommandsSupported(supportsMobileQuickCommands(capabilities))
// Why: hosts without this capability strip inputKind from terminal.send,
// so a forwarded xterm reply would become floor-stealing shell input.
hostQueryReplyInputSupportedRef.current = capabilities.includes(
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY
)
})
}, [client, connState])
// Why: read deviceToken from host record so code can pass client.id on subscribe/send for driver-state-machine identity.
useEffect(() => {
if (!hostId) {
return
}
let stale = false
void loadHosts().then((hosts) => {
if (stale) {
return
}
const host = hosts.find((h) => h.id === hostId)
if (host) {
deviceTokenRef.current = host.deviceToken
setHostEndpoint(host.endpoint)
}
})
return () => {
stale = true
}
}, [hostId])
useEffect(() => {
void loadCustomKeys().then(setCustomKeys)
}, [])
useFocusEffect(
useCallback(() => {
let stale = false
void loadTerminalAccessoryLayout().then((layout) => {
if (!stale) {
setVisibleBuiltInIds(layout.visibleBuiltInIds)
}
})
return () => {
stale = true
}
}, [])
)
useEffect(() => {
let mounted = true
const refresh = () => {
void loadTerminalAccessoryLayout().then((layout) => {
if (mounted) {
setVisibleBuiltInIds(layout.visibleBuiltInIds)
}
})
}
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s === 'active') {
refresh()
}
})
return () => {
mounted = false
sub.remove()
}
}, [])
const pendingForegroundRecoveryRef = useRef(false)
useEffect(() => {
let previousAppState: AppStateStatus | null = AppState.currentState
const sub = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
const shouldRecover = shouldRecoverTerminalOnAppStateChange(
previousAppState,
nextAppState,
Platform.OS
)
previousAppState = nextAppState
if (!shouldRecover) {
return
}
for (const terminalRef of terminalRefs.current.values()) {
terminalRef.prepareForForegroundRecovery()
}
// Why: iOS can resume a WKWebView with a blank xterm store and no web-ready; invalidate the latch so init waits for the pong.
const outcome = recoverActiveTerminalAfterForeground({
activeHandleRef,
terminalRefs,
initializedHandlesRef,
connStateRef,
unsubscribeTerminal,
subscribeToTerminal,
schedule: scheduleDelayedAction
})
pendingForegroundRecoveryRef.current = outcome === 'deferred'
})
return () => {
sub.remove()
}
}, [scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal])
// Why: resume lands mid-reconnect (socket dies in bg); re-run recovery once connected or a blanked WKWebView stays stale.
useEffect(() => {
if (connState !== 'connected' || !pendingForegroundRecoveryRef.current) {
return
}
pendingForegroundRecoveryRef.current = false
if (AppState.currentState !== 'active') {
return
}
recoverActiveTerminalAfterForeground({
activeHandleRef,
terminalRefs,
initializedHandlesRef,
connStateRef,
unsubscribeTerminal,
subscribeToTerminal,
schedule: scheduleDelayedAction
})
}, [connState, scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal])
// Why: non-subscribe layout refits (tab strip, fold, rotation) live in a dedicated hook — see terminal-viewport-refit.ts.
const { notifyTerminalFrameHeight, notifyKeyboardVisibility } = useTerminalViewportRefit({
activeHandleRef,
terminalRefs,
terminalFrameHeightRef,
viewportRef,
viewportMeasuredRef,
nativeChatCoveredRef: showNativeChatRef,
clientRef,
deviceTokenRef,
initializedHandlesRef,
connState,
tabStripVisible: terminals.length > 1,
textScale: terminalTextScale,
terminalFrameWidth,
unsubscribeTerminal,
subscribeToTerminal
})
useEffect(() => {
const onShow = (e: KeyboardEvent) => {
notifyKeyboardVisibility(true)
setKeyboardHeight(e.endCoordinates?.height ?? 0)
}
const onHide = () => {
notifyKeyboardVisibility(false)
setKeyboardHeight(0)
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
const showSub = Keyboard.addListener(showEvent, onShow)
const hideSub = Keyboard.addListener(hideEvent, onHide)
return () => {
showSub.remove()
hideSub.remove()
}
}, [notifyKeyboardVisibility])
const scrollActiveTabIntoView = useCallback((tabId: string | null, animated: boolean) => {
if (!tabId) {
return
}
const layout = tabLayoutsRef.current.get(tabId)
if (!layout) {
return
}
const nextOffset = resolveTabStripScrollOffset({
tabX: layout.x,
tabWidth: layout.width,
viewportWidth: tabStripViewportWidthRef.current,
contentWidth: tabStripContentWidthRef.current,
currentOffset: tabStripOffsetRef.current
})
if (nextOffset !== tabStripOffsetRef.current) {
tabStripOffsetRef.current = nextOffset
tabStripRef.current?.scrollTo({ x: nextOffset, animated })
}
}, [])
// Reveal the active tab on change; defer one frame so freshly mounted tab layouts are recorded.
useEffect(() => {
const id = requestAnimationFrame(() => scrollActiveTabIntoView(activeSessionTabId, true))
return () => cancelAnimationFrame(id)
}, [activeSessionTabId, scrollActiveTabIntoView])
useEffect(() => {
if (hostId && worktreeId) {
void AsyncStorage.setItem(
LAST_VISITED_WORKTREE_STORAGE_KEY,
JSON.stringify({ hostId, worktreeId })
)
}
}, [hostId, worktreeId])
const handleDeleteCustomKey = useCallback(
async (key: CustomKey) => {
const updated = customKeys.filter((k) => k.id !== key.id)
setCustomKeys(updated)
await saveCustomKeys(updated)
},
[customKeys]
)
const handleManageShortcuts = useCallback(() => {
setShowCustomKeyModal(false)
router.push('/terminal-settings')
}, [router])
useEffect(() => {
// Why: Expo reuses this screen across worktrees; reset route state so it can't open stale UI or reject the next snapshot.
sessionTabActionSheetRequestSeqRef.current += 1
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
sessionTabActionSheetKeyboardHideSubRef.current = null
clearTerminalCache()
activeHandleRef.current = null
activeSessionTabTypeRef.current = null
pendingActiveSessionTabIdRef.current = null
pendingActiveTerminalHandleRef.current = null
pendingBrowserFocusPageIdRef.current = null
pendingTerminalActivationAttemptRef.current = null
initialSessionAutoCreateRef.current = createInitialSessionAutoCreateState()
terminalDiagnosticsRef.current.resetRoute()
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
closedTabTombstonesRef.current.clear()
for (const queued of terminalGestureInputQueuesRef.current.values()) {
if (queued.timer) {
clearTimeout(queued.timer)
}
}
terminalGestureInputQueuesRef.current.clear()
terminalGestureInputInFlightRef.current.clear()
setActiveHandle(null)
setTerminals([])
terminalsRef.current = []
setSessionTabs([])
setActiveSessionTabId(null)
clearPendingLiveInputCommit()
setMarkdownDocs(new Map())
setFileDocs(new Map())
clearDelayedActionTimers()
return () => {
sessionTabActionSheetRequestSeqRef.current += 1
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
clearPendingLiveInputCommit()
clearDelayedActionTimers()
}
}, [
clearDelayedActionTimers,
clearPendingLiveInputCommit,
clearTerminalCache,
hostId,
worktreeId
])
useEffect(() => {
if (connState !== 'connected') {
return
}
// Why: keep the current xterm visible while the reconnect snapshot hydrates, not a blank "Loading terminals" surface.
if (initializedHandlesRef.current.size === 0) {
setTerminalsLoaded(false)
}
// Why: clear the initialized flag so the reconnect scrollback replaces stale content instead of being dropped.
initializedHandlesRef.current.clear()
let disposed = false
const timers: ReturnType<typeof setTimeout>[] = []
function addTimer(fn: () => void, ms: number) {
if (disposed) {
return
}
timers.push(setTimeout(fn, ms))
}
void (async () => {
const reportActivationOutcome = (response: RpcSuccess | null): void => {
if (!disposed && response && headlessActivationNeedsHostRenderer(response.result)) {
showToast('Open Orca on the host to wake sleeping agents.', 3000)
}
}
if (client && created !== '1' && !isFloatingWorkspaceRoute) {
// Why: hydrate host-owned tabs without pulling other paired clients (esp. desktop) into this worktree.
void client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false,
navigation: 'caller'
})
.then((response) => reportActivationOutcome(response.ok ? response : null))
.catch(() => null)
}
if (disposed) {
return
}
await ensureSessionTabs().catch(() => null)
if (disposed) {
return
}
await fetchTerminals({ allowEmptyLoaded: false })
if (disposed) {
return
}
addTimer(() => void fetchTerminals({ allowEmptyLoaded: false }), 750)
addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 1500)
if (client && created === '1' && !isFloatingWorkspaceRoute) {
addTimer(() => {
if (activeHandleRef.current) {
return
}
void (async () => {
const activationResponse = await client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false,
navigation: 'caller'
})
.catch(() => null)
reportActivationOutcome(activationResponse?.ok ? activationResponse : null)
if (disposed) {
return
}
await fetchTerminals({ allowEmptyLoaded: true })
addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 750)
})()
}, 1800)
}
})()
return () => {
disposed = true
for (const t of timers) {
clearTimeout(t)
}
}
}, [
client,
connState,
created,
fetchTerminals,
ensureSessionTabs,
isFloatingWorkspaceRoute,
showToast,
worktreeId
])
// Why: pick up Settings → Terminal text size on return; panes stay mounted and update in place.
useFocusEffect(
useCallback(() => {
let active = true
void loadTerminalTextScale().then((scale) => {
if (active) {
setTerminalTextScale(scale)
}
})
return () => {
active = false
}
}, [])
)
// Why: pick up the Settings → Terminal autocomplete toggle when returning here.
useFocusEffect(
useCallback(() => {
let active = true
void loadTerminalAutocompleteEnabled().then((enabled) => {
if (active) {
setAutocompleteEnabled(enabled)
}
})
return () => {
active = false
}
}, [])
)
// Why: link routing is a phone-local choice; reload after Settings → Browser.
useFocusEffect(
useCallback(() => {
let active = true
void loadTerminalLinkOpenMode().then((mode) => {
if (active) {
setTerminalLinkOpenMode(mode)
}
})
return () => {
active = false
}
}, [])
)
// Why: unsubscribe restores old dims (clears phone-fit banner); resubscribe phone-fits the new one.
const switchTab = useCallback(
(handle: string) => {
triggerSelection()
const matchingTab = sessionTabs.find(
(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'
defaultTerminalHandlesToLiveInput([handle])
setActiveSessionTabId(matchingTab?.id ?? null)
const prev = activeHandleRef.current
activeHandleRef.current = handle
setActiveHandle(handle)
if (prev && prev !== handle) {
unsubscribeTerminal(prev)
initializedHandlesRef.current.delete(prev)
}
// Force a fresh subscribe even if eagerly subscribed without viewport
if (terminalUnsubsRef.current.has(handle)) {
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
}
subscribeToTerminal(handle)
if (client) {
if (matchingTab) {
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id,
notifyClients: false,
navigation: 'caller',
intent: 'user'
}).catch(() => {})
}
}
},
[
client,
defaultTerminalHandlesToLiveInput,
sessionTabs,
subscribeToTerminal,
unsubscribeTerminal,
worktreeId
]
)
const switchSessionTab = useCallback(
(tab: MobileSessionTab) => {
if (tab.type === 'terminal') {
if (typeof tab.terminal === 'string') {
switchTab(tab.terminal)
return
}
terminalDiagnosticsRef.current.tabSwitch('terminal', tab.id, true)
triggerSelection()
pendingActiveSessionTabIdRef.current = tab.id
pendingActiveTerminalHandleRef.current = null
activeSessionTabTypeRef.current = 'terminal'
setActiveSessionTabId(tab.id)
const prev = activeHandleRef.current
if (prev) {
unsubscribeTerminal(prev)
initializedHandlesRef.current.delete(prev)
}
activeHandleRef.current = null
setActiveHandle(null)
if (client) {
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false,
navigation: 'caller',
intent: 'user'
}).catch(() => {})
}
return
}
triggerSelection()
terminalDiagnosticsRef.current.tabSwitch(tab.type, tab.id, false)
pendingActiveSessionTabIdRef.current = tab.id
pendingActiveTerminalHandleRef.current = null
activeSessionTabTypeRef.current = tab.type
setActiveSessionTabId(tab.id)
const prev = activeHandleRef.current
if (prev) {
unsubscribeTerminal(prev)
initializedHandlesRef.current.delete(prev)
}
activeHandleRef.current = null
setActiveHandle(null)
if (client) {
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false,
navigation: 'caller',
intent: 'user'
}).catch(() => {})
}
if (tab.type === 'browser') {
return
}
if (tab.type === 'file') {
void readFileTab(tab)
return
}
const cached = markdownDocs.get(tab.id)
if (cached?.status === 'ready' && cached.isDirty) {
return
}
// Why: tab list lacks a reliable version for desktop clean saves; re-read on revisit unless the phone has a draft.
void readMarkdownTab(tab)
},
[client, markdownDocs, readFileTab, readMarkdownTab, switchTab, unsubscribeTerminal, worktreeId]
)
// Ref to latest switchSessionTab so fetchSessionTabs can activate a synced browser tab without a dependency cycle.
switchSessionTabRef.current = switchSessionTab
// Why: only store the ref; subscribe on web-ready to avoid the blank-terminal race (init queued before xterm.js loaded).
const setTerminalWebViewRef = useCallback((handle: string, ref: TerminalWebViewHandle | null) => {
terminalDiagnosticsRef.current.webViewRef(handle, ref != null)
if (ref) {
terminalRefs.current.set(handle, ref)
} else {
terminalRefs.current.delete(handle)
terminalGestureInputBucketsRef.current.delete(handle)
const queued = terminalGestureInputQueuesRef.current.get(handle)
if (queued?.timer) {
clearTimeout(queued.timer)
}
terminalGestureInputQueuesRef.current.delete(handle)
terminalGestureInputInFlightRef.current.delete(handle)
}
}, [])
const handleTerminalWebReady = useCallback(
(handle: string) => {
const wasAlreadyReady = webReadyHandlesRef.current.has(handle)
webReadyHandlesRef.current.add(handle)
nativeChatStream.notifyWebReady(handle, wasAlreadyReady)
terminalDiagnosticsRef.current.webViewReady(
handle,
wasAlreadyReady,
handle === activeHandleRef.current
)
if (wasAlreadyReady && initializedHandlesRef.current.has(handle)) {
// Why: WebView reloaded (hot reload / Android churn); old xterm buffer is gone, so resubscribe for a fresh scrollback.
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
if (handle === activeHandleRef.current) {
subscribeToTerminal(handle)
}
return
}
// Why: first subscribe may skip (no WebView ref); await measure so it carries the viewport, else it races measureViewportOnce and skips.
// Why: a just-created tab can lose activeHandleRef to a lagging snapshot; honor the pending marker so its web-ready subscribe still fires.
const isIntendedActive = () =>
handle === activeHandleRef.current || handle === pendingActiveTerminalHandleRef.current
if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) {
void (async () => {
await measureViewportOnce(handle)
if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) {
subscribeToTerminal(handle)
}
})()
}
},
[measureViewportOnce, nativeChatStream, subscribeToTerminal, unsubscribeTerminal]
)
useEffect(() => {
if (activeSessionTab?.type !== 'markdown') {
return
}
const doc = markdownDocs.get(activeSessionTab.id)
if (!doc) {
void readMarkdownTab(activeSessionTab)
}
}, [activeSessionTab, markdownDocs, readMarkdownTab])
useEffect(() => {
if (activeSessionTab?.type !== 'file') {
return
}
const doc = fileDocs.get(activeSessionTab.id)
if (!doc) {
void readFileTab(activeSessionTab)
}
}, [activeSessionTab, fileDocs, readFileTab])
async function handleSend() {
// Why: the return key still submits while offline; hold the composed text instead of firing a doomed RPC (#6713).
if (!client || !activeHandle || sendingRef.current || !canSend) {
return
}
sendingRef.current = true
const text = normalizeTerminalTextInput(input)
setInput('')
try {
// Why: fail now and restore the text — a send parked across a reconnect would execute long after the tap.
await client.sendRequest(
'terminal.send',
buildTerminalSendParams({
terminal: activeHandle,
text,
enter: true,
deviceToken: deviceTokenRef.current
}),
TERMINAL_INPUT_SEND_OPTIONS
)
} catch {
setInput(text)
} finally {
sendingRef.current = false
}
}
async function handleAccessoryKey(input: ReturnType<typeof createTerminalLiveAccessoryInput>) {
if (!client || !activeHandle || !canSend) {
return
}
const targetHandle = activeHandle
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
if (accessoryCommit.kind !== 'allow-raw') {
return
}
await sendTerminalLiveAccessoryRawBytes({
client: clientRef.current,
targetHandle,
activeHandle: activeHandleRef.current,
activeSessionTabType: activeSessionTabTypeRef.current,
connState: connStateRef.current,
bytes: input.bytes,
deviceToken: deviceTokenRef.current
})
}
const sendLiveTerminalInput = useCallback(
async (handle: string, bytes: string): Promise<boolean> => {
const text = normalizeTerminalTextInput(bytes)
if (text.length === 0) {
return false
}
if (!isTerminalLiveInputWithinByteLimit(text)) {
triggerError()
showToast('Input too large (max 256 KiB)', 1500)
return false
}
const rpc = clientRef.current
// Why: callers suppress follow-up controls/toasts when this live send is stale.
if (
!rpc ||
connStateRef.current !== 'connected' ||
handle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal'
) {
return false
}
// Why: live-mirror deltas queued behind a dying send drain into the connect
// wait and replay stale bytes after reconnect (#6713's `YZZYecho …` corruption).
return rpc
.sendRequest(
'terminal.send',
buildTerminalSendParams({
terminal: handle,
text,
enter: false,
deviceToken: deviceTokenRef.current
}),
TERMINAL_INPUT_SEND_OPTIONS
)
.then(isTerminalSendRpcAccepted, () => false)
},
[showToast]
)
sendLiveTerminalInputRef.current = sendLiveTerminalInput
const clearSessionTabActionSheetKeyboardListener = useCallback(() => {
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
sessionTabActionSheetKeyboardHideSubRef.current = null
}, [])
const openSessionTabActionSheet = useCallback((tab: MobileSessionTab) => {
if (tab.type === 'terminal') {
if (typeof tab.terminal !== 'string') {
return
}
setActionTarget({
handle: tab.terminal,
title: tab.title,
isActive: tab.terminal === activeHandleRef.current
})
} else if (tab.type === 'markdown') {
setMarkdownActionTarget(tab)
} else if (tab.type === 'file') {
setFileActionTarget(tab)
} else {
setBrowserActionTarget(tab)
}
}, [])
const openSessionTabActionSheetAfterKeyboardDismiss = useCallback(
(tab: MobileSessionTab) => {
// Why: live input may queue a refocus; open the action sheet after the keyboard is gone, not racing it under the drawer.
sessionTabActionSheetRequestSeqRef.current += 1
const requestSeq = sessionTabActionSheetRequestSeqRef.current
clearSessionTabActionSheetKeyboardListener()
let didOpen = false
const openAfterDismiss = () => {
if (didOpen || requestSeq !== sessionTabActionSheetRequestSeqRef.current) {
return
}
didOpen = true
clearSessionTabActionSheetKeyboardListener()
openSessionTabActionSheet(tab)
}
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
if (keyboardHeight <= 0) {
liveInputRef.current?.blur()
Keyboard.dismiss()
openAfterDismiss()
return
}
sessionTabActionSheetKeyboardHideSubRef.current = Keyboard.addListener(
'keyboardDidHide',
openAfterDismiss
)
liveInputRef.current?.blur()
Keyboard.dismiss()
scheduleDelayedAction(openAfterDismiss, TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS)
},
[
clearSessionTabActionSheetKeyboardListener,
keyboardHeight,
openSessionTabActionSheet,
scheduleDelayedAction
]
)
const dismissSoftwareKeyboard = useCallback(() => {
dismissTerminalKeyboard({
clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef),
commandInput: commandInputRef.current,
dismissKeyboard: () => Keyboard.dismiss(),
liveInput: liveInputRef.current
})
}, [])
// Tap a terminal or chat file path → resolve on host, open as file tab/preview.
const { handleFileTap, handleNativeChatFileTap } = useMobileFileTapHandlers<MobileSessionTab>({
client,
hostId,
worktreeId,
worktreeName: routeWorktreeName,
activeHandleRef,
terminalCwdRef,
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
fetchSessionTabs,
getSessionTabs: () => sessionTabsRef.current,
getActiveSessionTabId: () => activeSessionTabIdRef.current,
getActiveSessionTabType: () => activeSessionTabTypeRef.current,
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
scheduleDelayedAction,
reportChatTapFailure: nativeChatSendError.show
})
const handleOpenedFileDiffActivationSeqRef = useRef(0)
// Capture active tab at tap time; reading it after openDiff would misread a mid-RPC switch and let the retry steal focus.
const fileOpenStartActiveTabIdRef = useRef<string | null>(null)
const handleFileOpenStart = useCallback(() => {
fileOpenStartActiveTabIdRef.current = activeSessionTabIdRef.current
}, [])
const handleOpenedFileDiff = useCallback(
(relativePath: string) => {
const activationSeq = ++handleOpenedFileDiffActivationSeqRef.current
const activeTabIdAtTap = fileOpenStartActiveTabIdRef.current
let activated = false
const activateOpenedTab = async (): Promise<void> => {
// Route matching through the shared helper so the repro test exercises the same logic production runs.
const settled = await activateOpenedSourceControlDiffTab<MobileSessionTab>({
relativePath,
activeTabIdAtTap,
fetchSessionTabs,
getTabs: () => sessionTabsRef.current,
getActiveTabId: () => activeSessionTabIdRef.current,
getActivationState: () => ({
activated,
activationSeq,
latestActivationSeq: handleOpenedFileDiffActivationSeqRef.current
}),
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab)
})
if (settled) {
activated = true
}
}
scheduleDelayedAction(() => void activateOpenedTab(), 300)
scheduleDelayedAction(() => void activateOpenedTab(), 900)
scheduleDelayedAction(() => void activateOpenedTab(), 1800)
},
[fetchSessionTabs, scheduleDelayedAction]
)
const handleTerminalOpenUrl = useCallback(
(handle: string, url: string) => {
if (handle !== activeHandleRef.current) {
return
}
// Why: browser.tabCreate resolves a real worktree, which the floating
// sentinel doesn't have — open taps in the phone browser instead.
if (terminalLinkOpenMode === 'phone-browser' || isFloatingWorkspaceRoute) {
void Linking.openURL(url).catch(() => {})
return
}
void handleCreateBrowserRef.current?.(url)
},
[terminalLinkOpenMode, isFloatingWorkspaceRoute]
)
const toggleLiveInput = useCallback(() => {
if (!activeHandle) {
return
}
const nextEnabled = toggleTerminalLiveInput(activeHandle)
clearPendingLiveInputCommit()
if (nextEnabled) {
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
} else {
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
liveInputRef.current?.blur()
}
}, [activeHandle, clearPendingLiveInputCommit, toggleTerminalLiveInput])
const allowTerminalGestureInput = useCallback(
(handle: string, sequenceCount: number): boolean => {
const now = Date.now()
const current = terminalGestureInputBucketsRef.current.get(handle) ?? {
tokens: TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY,
lastRefillMs: now
}
const elapsedSeconds = Math.max(0, now - current.lastRefillMs) / 1000
const tokens = Math.min(
TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY,
current.tokens + elapsedSeconds * TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND
)
// Why: tokens count terminal control sequences, not WebView messages; one gesture may batch up to 32 wheel/key reports.
if (tokens < sequenceCount) {
terminalGestureInputBucketsRef.current.set(handle, { tokens, lastRefillMs: now })
return false
}
terminalGestureInputBucketsRef.current.set(handle, {
tokens: tokens - sequenceCount,
lastRefillMs: now
})
return true
},
[]
)
const flushTerminalGestureInput = useCallback(async (handle: string) => {
const queued = terminalGestureInputQueuesRef.current.get(handle)
if (!queued) {
return
}
if (queued.timer) {
clearTimeout(queued.timer)
queued.timer = null
}
if (terminalGestureInputInFlightRef.current.has(handle)) {
return
}
terminalGestureInputQueuesRef.current.delete(handle)
const isActive =
handle === activeHandleRef.current && activeSessionTabTypeRef.current === 'terminal'
const isFresh = Date.now() - queued.lastUpdatedMs <= TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS
const rpc = clientRef.current
if (!rpc || connStateRef.current !== 'connected' || !isActive || !isFresh) {
return
}
terminalGestureInputInFlightRef.current.add(handle)
try {
// Why: gesture arrows parked across a reconnect would move a TUI long after the swipe.
await rpc.sendRequest(
'terminal.send',
buildTerminalSendParams({
terminal: handle,
text: queued.bytes,
enter: false,
deviceToken: deviceTokenRef.current
}),
TERMINAL_INPUT_SEND_OPTIONS
)
} catch {
// Transient failure
} finally {
terminalGestureInputInFlightRef.current.delete(handle)
const next = terminalGestureInputQueuesRef.current.get(handle)
if (next) {
if (Date.now() - next.lastUpdatedMs > TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS) {
if (next.timer) {
clearTimeout(next.timer)
}
terminalGestureInputQueuesRef.current.delete(handle)
} else {
void flushTerminalGestureInput(handle)
}
}
}
}, [])
const enqueueTerminalGestureInput = useCallback(
(handle: string, bytes: string, sequenceCount: number) => {
const now = Date.now()
const current = terminalGestureInputQueuesRef.current.get(handle)
if (
current &&
current.sequenceCount + sequenceCount <= TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES
) {
current.bytes += bytes
current.sequenceCount += sequenceCount
current.lastUpdatedMs = now
return
}
if (current) {
if (current.timer) {
clearTimeout(current.timer)
}
if (!terminalGestureInputInFlightRef.current.has(handle)) {
void flushTerminalGestureInput(handle)
} else {
// Why: cap is a soft guideline — append instead of dropping queued bytes; the in-flight flush picks up the merged queue.
current.bytes += bytes
current.sequenceCount += sequenceCount
current.lastUpdatedMs = now
current.timer = setTimeout(() => {
current.timer = null
void flushTerminalGestureInput(handle)
}, TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS)
return
}
}
const queued: TerminalGestureInputQueue = {
bytes,
sequenceCount,
timer: null,
lastUpdatedMs: now
}
queued.timer = setTimeout(() => {
queued.timer = null
void flushTerminalGestureInput(handle)
}, TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS)
terminalGestureInputQueuesRef.current.set(handle, queued)
},
[flushTerminalGestureInput]
)
const handleTerminalInput = useCallback(
async (handle: string, bytes: string) => {
if (!client || connState !== 'connected' || bytes.length === 0) {
return
}
if (handle !== activeHandleRef.current || activeSessionTabTypeRef.current !== 'terminal') {
return
}
const modes = ptyModesRef.current.get(handle)
// Why: WebView gesture bytes can become PTY input, so gate mouse reports behind validation and SSH-safe rate limiting.
if (!modes?.altScreen && !isGestureMouseTrackingMode(modes?.mouseTrackingMode)) {
return
}
const sequenceCount = countTerminalGestureInputSequences(bytes)
if (sequenceCount == null) {
return
}
if (!allowTerminalGestureInput(handle, sequenceCount)) {
return
}
enqueueTerminalGestureInput(handle, bytes, sequenceCount)
},
[allowTerminalGestureInput, client, connState, enqueueTerminalGestureInput]
)
const handleTerminalQueryReply = useCallback((handle: string, bytes: string) => {
void sendMobileTerminalQueryReply({
bytes,
client: clientRef.current,
clientId: deviceTokenRef.current,
connected: connStateRef.current === 'connected',
handle,
hostSupportsQueryReplyInput: hostQueryReplyInputSupportedRef.current,
subscribedTerminals: terminalUnsubsRef.current
})
}, [])
async function handleClearTerminal(target: Terminal) {
if (!client) {
return
}
getTerminalRef(target.handle)?.clear()
try {
await client.sendRequest('terminal.clearBuffer', {
terminal: target.handle
})
showToast('Terminal cleared')
} catch {
showToast("Couldn't clear terminal", 1500)
}
}
// Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive).
const repeatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const repeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Why: ref keeps repeat firing the current callback; else a mid-hold tab switch/reconnect routes bytes to a stale terminal.
const handleAccessoryKeyRef = useRef(handleAccessoryKey)
handleAccessoryKeyRef.current = handleAccessoryKey
const stopAccessoryRepeat = useCallback(() => {
if (repeatTimeoutRef.current) {
clearTimeout(repeatTimeoutRef.current)
repeatTimeoutRef.current = null
}
if (repeatIntervalRef.current) {
clearInterval(repeatIntervalRef.current)
repeatIntervalRef.current = null
}
}, [])
const startAccessoryRepeat = useCallback(
(input: ReturnType<typeof createTerminalLiveAccessoryInput>) => {
stopAccessoryRepeat()
repeatTimeoutRef.current = setTimeout(() => {
repeatIntervalRef.current = setInterval(() => {
void handleAccessoryKeyRef.current(input)
}, 45)
}, 400)
},
[stopAccessoryRepeat]
)
const setMobileSessionRootRef = useCallback(
(node: View | null): void => {
if (node !== null) {
return
}
// Why: clear only on real route detach; client churn during mount would wipe xterm state mid-subscribe.
toastSeqRef.current += 1
clearTerminalCache()
clearToastHideTimer()
clearDelayedActionTimers()
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
clearPendingLiveInputCommit()
sessionTabActionSheetRequestSeqRef.current += 1
clearSessionTabActionSheetKeyboardListener()
stopAccessoryRepeat()
},
[
clearPendingLiveInputCommit,
clearDelayedActionTimers,
clearSessionTabActionSheetKeyboardListener,
clearTerminalCache,
clearToastHideTimer,
stopAccessoryRepeat
]
)
const handleSelectionMode = useCallback((handle: string, active: boolean) => {
if (handle !== activeHandleRef.current) {
return
}
setSelectModeActive(active)
if (active) {
Keyboard.dismiss()
}
}, [])
const handleSelectionCopy = useCallback(
async (handle: string, text: string) => {
if (handle !== activeHandleRef.current) {
return
}
if (!text || text.length === 0) {
terminalRefs.current.get(handle)?.cancelSelect()
return
}
try {
await Clipboard.setStringAsync(text)
triggerSuccess()
// Why: Android 13+ shows its own system copy toast; iOS shows none, so only iOS needs our in-app toast.
if (Platform.OS === 'ios') {
showToast('Copied')
}
terminalRefs.current.get(handle)?.cancelSelect()
} catch (e) {
triggerError()
const err = e as { name?: string; message?: string }
// eslint-disable-next-line no-console
console.warn('[mobile-clip] setString failed', {
name: err.name,
message: err.message
})
showToast("Couldn't copy", 1500)
}
},
[showToast]
)
const handleSelectionEvicted = useCallback(
(handle: string) => {
if (handle !== activeHandleRef.current) {
return
}
// eslint-disable-next-line no-console
console.warn('[mobile-clip] selection evicted')
showToast('Selection cleared (scrolled out of buffer)', 1500)
setSelectModeActive(false)
},
[showToast]
)
const handleModesChanged = useCallback((handle: string, modes: TerminalModes) => {
ptyModesRef.current.set(handle, modes)
initialModesSeenRef.current.add(handle)
}, [])
const handleKeyboardAvoidanceMetrics = useCallback(
(handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => {
setTerminalKeyboardMetrics((prev) => {
const current = prev.get(handle)
if (
current &&
current.cursorY === metrics.cursorY &&
current.contentBottomRow === metrics.contentBottomRow &&
current.rows === metrics.rows &&
current.altScreen === metrics.altScreen
) {
return prev
}
return new Map(prev).set(handle, metrics)
})
},
[]
)
const handleHaptic = useCallback((kind: 'selection' | 'success' | 'error' | 'edge-bump') => {
if (kind === 'selection') {
triggerSelection()
} else if (kind === 'success') {
triggerSuccess()
} else if (kind === 'error') {
triggerError()
} else if (kind === 'edge-bump') {
triggerEdgeBump()
}
}, [])
const getActiveWorktreeConnectionId = useCallback(async (): Promise<string | null> => {
// Why: the floating workspace always runs on the paired host itself, never an SSH repo target.
if (!client || isFloatingWorkspaceRoute) {
return null
}
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
const repoResponse = await client.sendRequest('repo.list')
if (!repoResponse.ok) {
throw new Error((repoResponse as RpcFailure).error.message)
}
const repos =
((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? []
return repos.find((repo) => repo.id === repoId)?.connectionId?.trim() || null
}, [client, isFloatingWorkspaceRoute, worktreeId])
const refreshCanPaste = useCallback(() => {
void Promise.all([
Clipboard.hasStringAsync().catch(() => false),
Clipboard.hasImageAsync().catch(() => false)
]).then(([hasString, hasImage]) => {
setCanPaste(hasString || hasImage)
})
}, [])
const handlePaste = useMobileTerminalPaste({
client,
activeHandle,
activeHandleRef,
activeSessionTabTypeRef,
canSend,
connState,
connStateRef,
clientRef,
deviceTokenRef,
flushPendingLiveInputBeforeExternalSend,
getActiveWorktreeConnectionId,
onError: triggerError,
onSuccess: triggerSelection,
ptyModesRef,
refreshCanPaste,
showToast
})
const flushPendingLiveInputBeforeAttachmentSend = useMobileAttachmentInputLeaseGate({
flushPendingLiveInputBeforeExternalSend,
connStateRef,
activeHandleRef,
activeSessionTabTypeRef,
nativeChatInputLeaseReadyRef,
showToast
})
// Terminal input pastes an attached image straight into the visible terminal;
// native chat instead holds it as a composer chip and rides it along on submit.
const { attachImage, isAttaching, nativeChatImages } = useMobileSessionImageAttachments({
client,
activeHandle,
activeHandleRef,
canSend,
connState,
deviceTokenRef,
nativeChatScopeKey,
nativeChatInputLeaseReady,
getActiveWorktreeConnectionId,
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome,
readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft,
showToast,
onNativeChatSendError: nativeChatSendError.show,
onSuccess: triggerSelection,
onError: triggerError
})
// Why: refresh canPaste on mount, AppState active, after paste.
useEffect(() => {
let mounted = true
const refresh = () => {
void Promise.all([
Clipboard.hasStringAsync().catch(() => false),
Clipboard.hasImageAsync().catch(() => false)
]).then(([hasString, hasImage]) => {
if (mounted) {
setCanPaste(hasString || hasImage)
}
})
}
refresh()
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s === 'active') {
refresh()
} else if (selectModeActive && activeHandleRef.current) {
terminalRefs.current.get(activeHandleRef.current)?.cancelSelect()
}
})
return () => {
mounted = false
sub.remove()
}
}, [selectModeActive])
useEffect(() => {
const shouldLoadAgentOptions = showCreateTabDrawer || pendingDiffNotesDelivery !== null
if (!shouldLoadAgentOptions) {
setCreateTabAgentLoadState('idle')
setCreateTabAgentOptions([])
return
}
if (!client || connState !== 'connected') {
setCreateTabAgentLoadState('idle')
setCreateTabAgentOptions([])
return
}
let stale = false
setCreateTabAgentLoadState('loading')
setCreateTabAgentOptions([])
void (async () => {
const options = await loadMobileNewTabAgentOptions({
client,
worktreeId
})
if (stale) {
return
}
setCreateTabAgentOptions(options)
setCreateTabAgentLoadState('loaded')
})().catch(() => {
if (!stale) {
setCreateTabAgentOptions([])
setCreateTabAgentLoadState('error')
}
})
return () => {
stale = true
}
}, [client, connState, pendingDiffNotesDelivery, showCreateTabDrawer, worktreeId])
async function handleCreateTerminal(
agent?: MobileNewTabAgentOption['agent'],
options?: MobileQuickCommandLaunch['options'] & {
onPromptSent?: () => void
errorToast?: string
}
) {
if (!client || creatingTerminalRef.current) {
return
}
creatingTerminalRef.current = true
setCreating(true)
setCreateError('')
// Why: idempotency key so a transport retry (reconnect replay) resolves to the same terminal, not a duplicate; kept compact (no worktree id) for the schema length cap.
const clientMutationId = `mobile-create:${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2, 10)}`
try {
const response = await client.sendRequest('session.tabs.createTerminal', {
worktree: `id:${worktreeId}`,
afterTabId: activeSessionTabId ?? undefined,
clientMutationId,
...(options?.startupCommand ? { command: options.startupCommand } : {}),
...(options?.startupCommandDelivery
? { startupCommandDelivery: options.startupCommandDelivery }
: {}),
...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}),
...(agent ? { agent } : {}),
activate: false,
select: true,
navigation: 'caller'
})
if (response.ok) {
const result = (response as RpcSuccess).result as TerminalCreateResult
const created = result.tab
// Why: unsubscribe the old terminal so the server restores its desktop dims; otherwise its restore timer is never set.
const prev = activeHandleRef.current
if (prev) {
unsubscribeTerminal(prev)
initializedHandlesRef.current.delete(prev)
}
pendingActiveSessionTabIdRef.current = created.id
activeSessionTabTypeRef.current = 'terminal'
setActiveSessionTabId(created.id)
setSessionTabs((prev) => {
if (prev.some((tab) => tab.id === created.id)) {
return prev
}
return [...prev, { ...created, isActive: true }]
})
if (typeof created.terminal === 'string') {
const createdHandle = created.terminal
defaultTerminalHandlesToLiveInput([createdHandle])
// Why: snapshots lag the create RPC; without this marker applySessionTabs reverts the active handle, blanking the new pane.
pendingActiveTerminalHandleRef.current = createdHandle
activeHandleRef.current = createdHandle
setActiveHandle(createdHandle)
setTerminals((prev) => {
const existing = prev.find((terminal) => terminal.handle === createdHandle)
const createdTerminal: Terminal = {
handle: createdHandle,
title: created.title || existing?.title || 'Terminal',
terminalTheme: created.terminalTheme ?? existing?.terminalTheme,
isActive: true
}
if (existing) {
const next = prev.map((terminal) =>
terminal.handle === createdHandle ? { ...terminal, ...createdTerminal } : terminal
)
terminalsRef.current = next
return terminalRecordsEqual(prev, next) ? prev : next
}
const next = [...prev, createdTerminal]
terminalsRef.current = next
return next
})
subscribeToTerminal(createdHandle)
if (options?.initialPrompt?.trim()) {
void client
.sendRequest(
'terminal.send',
buildTerminalSendParams({
terminal: createdHandle,
text: options.initialPrompt,
enter: options.enter !== false,
deviceToken: deviceTokenRef.current
})
)
.then((sendResponse) => {
if (!sendResponse.ok) {
throw new Error(
(sendResponse as RpcFailure).error.message || 'Failed to send notes'
)
}
const result = (sendResponse as RpcSuccess).result as {
send?: { accepted?: boolean }
}
if (result.send?.accepted === false) {
throw new Error('Terminal input is locked by another client.')
}
triggerSuccess()
showToast(options.successToast ?? 'Notes sent')
options.onPromptSent?.()
})
.catch((err) => {
triggerError()
showToast(
options.errorToast ??
(err instanceof Error ? err.message : "Couldn't send notes"),
1800
)
})
} else if (options?.successToast) {
triggerSuccess()
showToast(options.successToast)
}
} else {
// Why: a prior pending handle must not outlive a create that returned no terminal; web-ready subscribe gates on this ref.
pendingActiveTerminalHandleRef.current = null
activeHandleRef.current = null
setActiveHandle(null)
}
scheduleDelayedAction(() => void fetchSessionTabs(), 500)
} else {
const message = options?.errorToast ?? 'Failed to create terminal'
setCreateError(message)
if (options?.errorToast) {
triggerError()
showToast(message, 1800)
}
}
} catch {
const message = options?.errorToast ?? 'Failed to create terminal'
setCreateError(message)
if (options?.errorToast) {
triggerError()
showToast(message, 1800)
}
} finally {
creatingTerminalRef.current = false
setCreating(false)
}
}
// Quick commands spawn a fresh terminal tab, mirroring desktop's
// run-quick-command-in-new-tab: agent prompts and runnable terminal commands
// use the host's shell-ready startup path; insert-only commands stay drafts.
function launchQuickCommand(command: TerminalQuickCommand): boolean {
if (
!client ||
connState !== 'connected' ||
creatingTerminalRef.current ||
creatingBrowser ||
creatingMarkdown
) {
return false
}
const launch = buildMobileQuickCommandLaunch(command)
if (!launch) {
triggerError()
showToast('Edit this quick command before running it', 1800)
return false
}
const label = command.label.trim() || 'Quick command'
void handleCreateTerminal(launch.agent, {
...launch.options,
errorToast: `Couldn't run ${label}`
})
return true
}
async function handleCreateMarkdownNote() {
if (!client || creatingMarkdown) {
return
}
setCreatingMarkdown(true)
setCreateError('')
try {
const worktree = `id:${worktreeId}`
const mutationOwnership = await captureMobileFileMutationOwnership(client, worktree)
for (let attempt = 1; attempt <= 100; attempt += 1) {
const relativePath = attempt === 1 ? 'untitled.md' : `untitled-${attempt}.md`
const createResponse = await client.sendRequest(
'files.createFile',
{ worktree, relativePath, ...mutationOwnership },
{ timeoutMs: 15_000 }
)
if (!createResponse.ok) {
const message = (createResponse as RpcFailure).error.message
if (isFileExistsErrorMessage(message) && attempt < 100) {
continue
}
throw new Error(message || 'Failed to create markdown note')
}
const openResponse = await client.sendRequest(
'files.open',
{ worktree, relativePath },
{ timeoutMs: 15_000 }
)
if (!openResponse.ok) {
throw new Error((openResponse as RpcFailure).error.message)
}
scheduleDelayedAction(() => void fetchSessionTabs(), 300)
return
}
throw new Error('Unable to create untitled markdown note')
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create markdown note'
setCreateError(message)
showToast(message, 1800)
} finally {
setCreatingMarkdown(false)
}
}
async function handleCreateBrowser(rawUrl = 'about:blank'): Promise<boolean> {
if (!client || creatingBrowser) {
return false
}
// Why: read via ref so a tap before the capability probe resolves (or a stale callback) still sees the live value.
if (browserScreencastSupportedRef.current !== true) {
showToast('Desktop update required for mobile browser streaming', 1600)
return false
}
const url = normalizeBrowserUrl(rawUrl)
if (!url) {
const message = 'Enter a valid URL'
setCreateError(message)
showToast(message, 1400)
return false
}
setCreatingBrowser(true)
setCreateError('')
try {
const response = await client.sendRequest(
'browser.tabCreate',
{
worktree: `id:${worktreeId}`,
url,
// The user opened this tab (tapped HTML / address bar) → focus it.
activate: true
},
{ timeoutMs: 30_000 }
)
if (!response.ok) {
throw new Error((response as RpcFailure).error.message)
}
// Focus the new browser tab once it syncs; refresh a few times since the desktop registers the tab asynchronously.
const created = (response as RpcSuccess).result as { browserPageId?: string }
if (created.browserPageId) {
pendingBrowserFocusPageIdRef.current = created.browserPageId
}
void fetchSessionTabs()
scheduleDelayedAction(() => void fetchPendingBrowserSessionTabs(), 400)
scheduleDelayedAction(() => void fetchPendingBrowserSessionTabs(), 1200)
return true
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create browser'
setCreateError(message)
showToast(message, 1800)
return false
} finally {
setCreatingBrowser(false)
}
}
// Keep the ref at the latest handleCreateBrowser so a terminal URL tap always runs the current closure.
handleCreateBrowserRef.current = handleCreateBrowser
async function handleBrowserNavigationCommand(
tab: Extract<MobileSessionTab, { type: 'browser' }>,
method: 'browser.back' | 'browser.forward' | 'browser.reload'
) {
if (!client || !tab.browserPageId) {
showToast('Browser page is not available yet.', 1500)
return
}
try {
const response = await client.sendRequest(
method,
{
worktree: `id:${worktreeId}`,
page: tab.browserPageId
},
{ timeoutMs: 15_000 }
)
if (!response.ok) {
throw new Error((response as RpcFailure).error.message)
}
scheduleDelayedAction(() => void fetchSessionTabs(), 250)
} catch (err) {
const message = err instanceof Error ? err.message : 'Browser command failed'
showToast(message, 1600)
}
}
async function handleRenameTerminal(value: string) {
if (!client || !renameTarget) {
return
}
const target = renameTarget
setRenameTarget(null)
try {
const title = value.trim()
const response = await client.sendRequest('terminal.rename', {
terminal: target.handle,
title
})
if (response.ok) {
setTerminals((prev) => {
const next = prev.map((terminal) =>
terminal.handle === target.handle
? { ...terminal, title: title || 'Terminal' }
: terminal
)
terminalsRef.current = next
return next
})
scheduleDelayedAction(() => void fetchTerminals(), 300)
}
} catch {
// Rename failed — refresh will restore the server title.
}
}
async function handleCloseTerminal(target: Terminal) {
if (!client) {
return
}
try {
const response = await client.sendRequest('terminal.close', {
terminal: target.handle
})
if (response.ok) {
unsubscribeTerminal(target.handle)
terminalRefs.current.delete(target.handle)
initializedHandlesRef.current.delete(target.handle)
clearTerminalLiveInputDefault(target.handle)
const next = terminals.filter((terminal) => terminal.handle !== target.handle)
setTerminals(next)
terminalsRef.current = next
if (activeHandleRef.current === target.handle) {
const replacement = next[0] ?? null
activeHandleRef.current = replacement?.handle ?? null
pendingActiveTerminalHandleRef.current = replacement?.handle ?? null
setActiveHandle(replacement?.handle ?? null)
if (replacement) {
subscribeToTerminal(replacement.handle)
}
}
}
} catch {
// Close failed — keep the local tab list unchanged.
}
}
async function handleCloseSessionTab(tab: MobileSessionTab) {
if (!client) {
return
}
try {
const response = await client.sendRequest('session.tabs.close', {
worktree: `id:${worktreeId}`,
tabId: tab.id,
// Why: a tapped tab close is explicit user intent; older hosts strip
// the unknown field and keep their legacy behavior.
reason: 'user'
})
if (response.ok) {
const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id)
if (tab.type === 'browser' && tab.browserPageId === pendingBrowserFocusPageIdRef.current) {
pendingBrowserFocusPageIdRef.current = null
}
if (tab.type === 'terminal' && typeof tab.terminal === 'string') {
const terminalHandle = tab.terminal
unsubscribeTerminal(terminalHandle)
terminalRefs.current.delete(terminalHandle)
initializedHandlesRef.current.delete(terminalHandle)
clearTerminalLiveInputDefault(terminalHandle)
}
sessionTabsRef.current = remainingTabs
setSessionTabs(remainingTabs)
// Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab.
closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000)
// Why: bulk close re-activates the anchor before awaiting each close;
// the render-synced ref sees that switch while this closure would not,
// so comparing against the ref keeps the anchor from being nulled out.
if (activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0) {
activeSessionTabTypeRef.current = null
activeSessionTabIdRef.current = null
setActiveSessionTabId(null)
activeHandleRef.current = null
setActiveHandle(null)
}
}
} catch {
// Close failed — keep the authoritative session snapshot visible.
}
}
const bulkCloseActions = createBulkCloseSheetActions({
sessionTabsRef,
markdownDocs,
activeSessionTabIdRef,
switchSessionTab,
closeSessionTab: handleCloseSessionTab
})
const closeWithBulkActions = createCloseWithBulkActions(handleCloseSessionTab, bulkCloseActions)
const visibleTabs: MobileSessionTab[] = sessionTabs
const activeMarkdownTab = activeSessionTab?.type === 'markdown' ? activeSessionTab : null
const activeFileTab = activeSessionTab?.type === 'file' ? activeSessionTab : null
const activeBrowserTab = activeSessionTab?.type === 'browser' ? activeSessionTab : null
const activePendingTerminalTab =
activeSessionTab?.type === 'terminal' && typeof activeSessionTab.terminal !== 'string'
? activeSessionTab
: null
useEffect(() => {
if (!client || connState !== 'connected' || !activePendingTerminalTab) {
if (connState !== 'connected' || !activePendingTerminalTab) {
pendingTerminalActivationAttemptRef.current = null
}
return
}
const activationKey = `${worktreeId}:${activePendingTerminalTab.id}:${activePendingTerminalTab.leafId ?? ''}`
if (pendingTerminalActivationAttemptRef.current === activationKey) {
return
}
// Why: a server-owned tab can be active but still pending; activation is the RPC that materializes its PTY handle.
pendingTerminalActivationAttemptRef.current = activationKey
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: activePendingTerminalTab.id,
leafId: activePendingTerminalTab.leafId,
notifyClients: false,
navigation: 'caller',
// Why: this only ever runs for the tab the user is looking at, so it is the
// tail of their tap — the gesture that materializes a parked pane.
intent: 'user'
})
.then((response) => {
if (!response.ok) {
if (pendingTerminalActivationAttemptRef.current === activationKey) {
pendingTerminalActivationAttemptRef.current = null
}
return
}
applySessionTabs((response as RpcSuccess).result as SessionTabsResult)
scheduleDelayedAction(() => void fetchSessionTabs(), 300)
scheduleDelayedAction(() => void fetchSessionTabs(), 1200)
})
.catch(() => {
if (pendingTerminalActivationAttemptRef.current === activationKey) {
pendingTerminalActivationAttemptRef.current = null
}
})
}, [
activePendingTerminalTab,
applySessionTabs,
client,
connState,
fetchSessionTabs,
scheduleDelayedAction,
worktreeId
])
const showLoadingState = connState === 'connected' && !terminalsLoaded && visibleTabs.length === 0
const showEmptyState =
connState === 'connected' && terminalsLoaded && visibleTabs.length === 0 && !activeHandle
// Why: a newly created workspace can hydrate with zero tabs before its first terminal exists.
useInitialSessionTerminalAutoCreate({
client,
newlyCreatedWorkspace: created === '1',
connState,
terminalsLoaded,
visibleTabCount: visibleTabs.length,
activeHandle,
createInFlight: creating || creatingBrowser || creatingMarkdown,
stateRef: initialSessionAutoCreateRef,
worktreeId,
consumeCreationRoute: () => router.setParams({ created: undefined }),
createTerminal: () => void handleCreateTerminal()
})
// Why: reconnect trickles to 90s at its give-up cap; surface tap-to-retry so recovery needn't wait it out (issue #5049).
const connectionVerdict = classifyConnection({
state: connState,
reconnectAttempts,
lastConnectedAt,
endpoint: hostEndpoint
})
const showConnectionRetry =
connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable'
const terminalSummary =
connState === 'connected'
? showLoadingState
? 'Loading tabs'
: visibleTabs.length === 1
? '1 tab'
: `${visibleTabs.length} tabs`
: showConnectionRetry
? `${verdictDisplayLabel(connectionVerdict)} — tap to retry`
: MOBILE_SESSION_STATUS_LABELS[connState]
// Why: iOS keyboard height includes the home-indicator inset; Android IME height does not.
const keyboardLift =
keyboardHeight > 0
? Platform.OS === 'ios'
? Math.max(0, keyboardHeight - insets.bottom)
: keyboardHeight
: 0
const activeTerminalKeyboardLift = computeActiveTerminalKeyboardLift({
keyboardLift,
metrics: activeHandle ? terminalKeyboardMetrics.get(activeHandle) : undefined,
terminalFrameHeight: terminalFrameHeightRef.current
})
const toastAnimatedStyle = {
opacity: toastOpacityRef.current,
transform: [{ translateY: -keyboardLift }]
}
const createTabAgentActions =
createTabAgentLoadState === 'loading'
? [
{
label: 'Detecting Agents',
icon: Bot,
disabled: true,
loading: true,
onPress: () => {}
}
]
: createTabAgentOptions.length > 0
? createTabAgentOptions.map((option) => ({
label: option.label,
renderIcon: () => <MobileAgentIcon agentId={option.agent} size={16} />,
onPress: () => {
setShowCreateTabDrawer(false)
void handleCreateTerminal(option.agent)
}
}))
: createTabAgentLoadState === 'loaded'
? [
{
label: 'No Enabled Agents',
icon: Bot,
disabled: true,
onPress: () => {}
}
]
: createTabAgentLoadState === 'error'
? [
{
label: 'Agent Presets Unavailable',
hint: 'Check the host connection',
icon: Bot,
disabled: true,
onPress: () => {}
}
]
: []
const sendDiffNotesAgentActions =
pendingDiffNotesDelivery === null
? []
: createTabAgentLoadState === 'loading'
? [
{
label: 'Detecting Agents',
icon: Bot,
disabled: true,
loading: true,
onPress: () => {}
}
]
: createTabAgentOptions.length > 0
? createTabAgentOptions.map((option) => ({
label: option.label,
hint: 'New agent session',
icon: Bot,
onPress: () => {
const delivery = pendingDiffNotesDelivery
setPendingDiffNotesDelivery(null)
if (!delivery) {
return
}
void handleCreateTerminal(option.agent, {
initialPrompt: delivery.prompt,
onPromptSent: () => void clearDeliveredDiffComments(delivery.comments)
})
}
}))
: createTabAgentLoadState === 'loaded'
? [
{
label: 'No Enabled Agents',
icon: Bot,
disabled: true,
onPress: () => {}
}
]
: createTabAgentLoadState === 'error'
? [
{
label: 'Agent Presets Unavailable',
hint: 'Copy notes instead',
icon: Bot,
disabled: true,
onPress: () => {}
}
]
: []
// Panel-icon taps route through the dock-vs-push decision (U1): dock-capable rows dock, constrained rows push.
const handleSessionContentRowLayout = useCallback((event: LayoutChangeEvent) => {
const width = Math.round(event.nativeEvent.layout.width)
setSessionContentRowWidth((prev) => (prev === width ? prev : width))
}, [])
const handlePanelTap = (tapped: Exclude<ActivePanel, null>) => {
const action = resolvePanelAction({ canDock: canDockPanel, tapped, current: activePanel })
if (action.kind === 'dock') {
setActivePanel(action.next)
return
}
const descriptor = panelRouteDescriptor(action.panel)
router.push({
pathname: descriptor.pathname,
params: {
hostId,
worktreeId,
name: worktreeName || '',
// SC + PR both land on the source-control hub with origin:'session' for post-diff-open dismissal (U2); Files opts out.
...(action.panel === 'sourceControl' || action.panel === 'pr' ? { origin: 'session' } : {}),
// The PR panel routes into the hub's Pull Request segment via descriptor params.
...descriptor.params
}
})
}
const openAgentSessionHistory = () => {
const params = new URLSearchParams({ name: worktreeName || '' })
router.push(`/h/${hostId}/agent-history/${encodeURIComponent(worktreeId)}?${params.toString()}`)
}
const showAgentSessionHistoryAction =
!isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && agentSessionHistorySupported === true
const showChecksAction = shouldShowSessionHeaderChecksAction({
isFolderWorkspaceRoute: isFolderWorkspaceRoute || isFloatingWorkspaceRoute,
repoContextLoaded: prRepoContextLoaded,
hostedChecksSupported: prIsGithubRepo
})
const showHeaderMoreButton = showAgentSessionHistoryAction || showChecksAction
const createTabBusy = creating || creatingBrowser || creatingMarkdown
return (
<View ref={setMobileSessionRootRef} style={styles.container}>
<View style={styles.kavInner}>
<SafeAreaView style={styles.sessionChrome} edges={['top']}>
<View style={styles.sessionTopBar}>
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
onPress={requestLeaveSession}
hitSlop={8}
accessibilityLabel="Back to worktrees"
>
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
<View style={styles.sessionTitleBlock}>
<Text style={styles.sessionTitle} numberOfLines={1}>
{worktreeName || 'Terminal'}
</Text>
<Pressable
style={styles.sessionMetaRow}
disabled={!showConnectionRetry}
onPress={() => {
if (hostId) {
void forceReconnectHost(hostId)
}
}}
accessibilityRole={showConnectionRetry ? 'button' : undefined}
accessibilityLabel={showConnectionRetry ? 'Reconnect to desktop' : undefined}
>
<StatusDot state={connState} />
<Text style={styles.sessionMetaText} numberOfLines={1}>
{terminalSummary}
</Text>
</Pressable>
</View>
{!isFloatingWorkspaceRoute && (
<MobileSessionHeaderIconButton
active={activePanel === 'files'}
accessibilityLabel="Open file explorer"
icon={Folder}
onPress={() => handlePanelTap('files')}
/>
)}
{!isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && (
<MobileSessionHeaderIconButton
active={activePanel === 'sourceControl'}
accessibilityLabel="Open source control"
icon={GitBranch}
onPress={() => handlePanelTap('sourceControl')}
/>
)}
{showHeaderMoreButton ? (
<MobileSessionHeaderIconButton
active={activePanel === 'pr'}
accessibilityLabel="More session actions"
icon={MoreHorizontal}
onPress={() => setShowHeaderMoreActions(true)}
/>
) : null}
</View>
{visibleTabs.length > 0 && (
<View style={styles.tabBar}>
{/* Why: tab taps must register on first press with the keyboard open instead of being eaten by dismissal (#5106). */}
<ScrollView
ref={tabStripRef}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabScroll}
contentContainerStyle={styles.tabContent}
keyboardShouldPersistTaps="handled"
scrollEventThrottle={16}
onScroll={(e) => {
tabStripOffsetRef.current = e.nativeEvent.contentOffset.x
}}
onLayout={(e) => {
tabStripViewportWidthRef.current = e.nativeEvent.layout.width
scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
}}
onContentSizeChange={(width) => {
tabStripContentWidthRef.current = width
scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
}}
>
{visibleTabs.map((t) => (
<Pressable
key={t.id}
style={[styles.tab, t.id === activeSessionTabId && styles.tabActive]}
onLayout={(e) => {
const { x, width } = e.nativeEvent.layout
tabLayoutsRef.current.set(t.id, { x, width })
if (t.id === activeSessionTabIdRef.current) {
scrollActiveTabIntoView(t.id, false)
}
}}
onPress={() => switchSessionTab(t)}
onLongPress={() => {
triggerMediumImpact()
openSessionTabActionSheetAfterKeyboardDismiss(t)
}}
delayLongPress={400}
>
<View style={styles.tabLabelRow}>
{t.type === 'browser' && (
<Globe size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'markdown' && (
<FileText size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'file' && (
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{t.type === 'terminal' &&
(() => {
const agentId = resolveMobileTerminalTabAgentId(t)
return agentId ? <MobileAgentIcon agentId={agentId} size={13} /> : null
})()}
<Text
style={[
styles.tabText,
t.id === activeSessionTabId && styles.tabTextActive
]}
numberOfLines={1}
>
{getMobileSessionTabTitle(t)}
</Text>
</View>
</Pressable>
))}
</ScrollView>
{/* Why: pinned outside the scroll strip so the new-agent button stays reachable however far the tabs scroll. */}
<Pressable
style={({ pressed }) => [
styles.newTerminalButton,
pressed && styles.newTerminalButtonPressed,
(creating || creatingBrowser || creatingMarkdown || connState !== 'connected') &&
styles.newTerminalButtonDisabled
]}
disabled={
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
}
onPress={() => {
setCreateError('')
setShowCreateTabDrawer(true)
}}
accessibilityLabel="New tab"
>
<Plus size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
{/* Why: stable placement matters, while old hosts must stay gated because they strip agentPrompt. */}
<QuickCommandsTabButton
disabled={
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
}
onPress={() => {
if (quickCommandsSupported === true) {
setShowQuickCommands(true)
return
}
showToast(
quickCommandsSupported === false
? 'Desktop update required for quick commands'
: 'Checking desktop capabilities — try again in a moment',
1600
)
}}
/>
</View>
)}
</SafeAreaView>
{/* Content-row host (KTD2): on wide, content shares this row with the docked panel as the flex-1 left child. */}
<View style={styles.sessionContentRow} onLayout={handleSessionContentRowLayout}>
<View style={styles.sessionContentMain}>
{createWarning ? (
<View style={styles.createWarningBanner}>
<AlertTriangle size={16} color={colors.statusAmber} strokeWidth={2.2} />
<Text style={styles.createWarningText}>{createWarning}</Text>
<Pressable
style={styles.createWarningDismiss}
onPress={() => setCreateWarningState(dismissMobileSessionCreateWarningState)}
accessibilityLabel="Dismiss workspace creation warning"
hitSlop={8}
>
<X size={16} color={colors.textMuted} strokeWidth={2.2} />
</Pressable>
</View>
) : null}
{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
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.emptyState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.emptyText}>
{activePendingTerminalTab.title || 'Loading terminal'}
</Text>
</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={nativeChatInputLockReason}
sendErrorMessage={nativeChatSendError.message}
onClearSendError={nativeChatSendError.clear}
keyboardInset={keyboardLift}
/>
{toastMessage && (
<Animated.View pointerEvents="none" style={[styles.toast, toastAnimatedStyle]}>
<Text style={styles.toastText}>{toastMessage}</Text>
</Animated.View>
)}
</View>
)}
{/* Why: translate instead of resize so keyboard toggles don't trigger a server-side PTY viewport change. */}
{!activeMarkdownTab && !activeFileTab && !activeBrowserTab && !showNativeChat && (
<View
style={[
styles.commandDock,
{ paddingBottom: insets.bottom, transform: [{ translateY: -keyboardLift }] }
]}
>
{/* Accessory keys */}
<View style={styles.accessoryBar}>
{/* Why: fixed keyboard escape hatch; outside ScrollView + shortcut path so it can't scroll away or be hidden (#5106). */}
{keyboardLift > 0 && (
<Pressable
style={({ pressed }) => [
styles.keyboardDismissKey,
pressed && styles.accessoryKeyPressed
]}
onPress={dismissSoftwareKeyboard}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Dismiss keyboard"
accessibilityHint="Hides the software keyboard and keeps the current terminal session open."
>
<View style={styles.keyboardDismissGlyph}>
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
<ChevronDown
size={10}
color={colors.textSecondary}
strokeWidth={2.5}
style={styles.keyboardDismissChevron}
/>
</View>
</Pressable>
)}
{/* Why: default tap handling makes the first accessory-key tap dismiss the keyboard and get swallowed (#5106). */}
<ScrollView
style={styles.accessoryScroll}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.accessoryContent}
keyboardShouldPersistTaps="always"
>
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => {
if (activeHandle) {
void toggleDisplayMode(activeHandle)
}
}}
accessibilityLabel={
isTerminalPhoneDisplayMode(activeHandle, terminalModes)
? 'Switch to desktop mode'
: 'Switch to phone mode'
}
>
{isTerminalPhoneDisplayMode(activeHandle, terminalModes) ? (
<Monitor
size={14}
color={canSend ? colors.textSecondary : colors.textMuted}
/>
) : (
<Smartphone
size={14}
color={canSend ? colors.textSecondary : colors.textMuted}
/>
)}
</Pressable>
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
liveInputEnabled && styles.accessoryKeyActive,
pressed && styles.accessoryKeyPressed,
!canCompose && styles.accessoryKeyDisabled
]}
// Why: offline, live mode is dead but the buffered box still composes keep the escape hatch tappable (#6713).
disabled={!canCompose}
onPress={toggleLiveInput}
accessibilityLabel={
liveInputEnabled
? 'Switch to buffered command input'
: 'Switch to live terminal input'
}
>
<ChevronsRight
size={14}
color={
liveInputEnabled
? colors.bgBase
: canCompose
? colors.textSecondary
: colors.textMuted
}
/>
</Pressable>
{canPaste && (
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handlePaste()}
accessibilityLabel="Paste from clipboard"
>
<Text
style={[
styles.accessoryKeyText,
!canSend && styles.accessoryKeyTextDisabled
]}
>
Paste
</Text>
</Pressable>
)}
{visibleBuiltInAccessoryKeys.map((key) => (
<Pressable
key={key.id}
style={({ pressed }) => [
styles.accessoryKey,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPressIn={() => {
if (!key.repeatable) {
return
}
const input = createTerminalLiveAccessoryInput(key)
void handleAccessoryKey(input)
startAccessoryRepeat(input)
}}
onPressOut={() => {
if (key.repeatable) {
stopAccessoryRepeat()
}
}}
onPress={() => {
if (key.repeatable) {
return
}
void handleAccessoryKey(createTerminalLiveAccessoryInput(key))
}}
accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`}
>
<Text
style={[
styles.accessoryKeyText,
!canSend && styles.accessoryKeyTextDisabled
]}
>
{key.label}
</Text>
</Pressable>
))}
{customKeys.map((key) => (
<Pressable
key={key.id}
style={({ pressed }) => [
styles.accessoryKey,
styles.customAccessoryKey,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handleAccessoryKey({ bytes: key.bytes })}
onLongPress={() => {
triggerMediumImpact()
setDeleteKeyTarget(key)
}}
delayLongPress={400}
accessibilityLabel={`Send ${key.label}`}
>
<Text
style={[
styles.accessoryKeyText,
!canSend && styles.accessoryKeyTextDisabled
]}
>
{key.label}
</Text>
</Pressable>
))}
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
pressed && styles.accessoryKeyPressed
]}
onPress={() => setShowCustomKeyModal(true)}
accessibilityLabel="Add custom shortcut"
>
<Plus size={14} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
</ScrollView>
</View>
{/* Input bar */}
{liveInputEnabled ? (
<View style={[styles.inputBar, styles.liveInputBar]}>
<Pressable
style={({ pressed }) => [
styles.liveInputFocusTarget,
pressed && styles.liveInputFocusTargetPressed,
!canSend && styles.liveInputFocusTargetDisabled
]}
disabled={!canSend}
onPress={focusLiveInput}
accessibilityRole="button"
accessibilityLabel="Show keyboard for live terminal input"
accessibilityHint="Typed text is sent directly to the active terminal"
>
<KeyboardIcon size={16} color={colors.textSecondary} strokeWidth={2} />
<MobileTerminalLiveInputStatus
dictation={dictation}
isAttaching={isAttaching}
liveInputText={liveInputCapture}
/>
</Pressable>
<MobileTerminalInputActions
canSend={canSend}
isAttaching={isAttaching}
dictation={dictation}
dictationMode={dictationMode}
buttonStyle={styles.dictationButton}
activeButtonStyle={styles.dictationButtonActive}
disabledButtonStyle={styles.sendButtonDisabled}
onAttachImage={() => void attachImage('library')}
onAttachFile={() => void attachImage('files')}
onDictationToggle={handleDictationToggle}
onDictationPressIn={handleDictationPressIn}
onDictationPressOut={handleDictationPressOut}
onDictationCancel={cancelDictation}
/>
<TextInput
ref={liveInputRef}
style={styles.liveInputCapture}
value={liveInputCapture}
onChangeText={handleLiveInputChange}
onKeyPress={handleLiveInputKeyPress}
onSubmitEditing={handleLiveInputSubmit}
placeholder=""
showSoftInputOnFocus
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
smartInsertDelete={false}
// Why: iOS textContentType overrides autoComplete and can narrow the keyboard; keep IME switching available.
autoComplete="off"
keyboardType={getTerminalLiveInputKeyboardType(Platform.OS)}
returnKeyType="default"
blurOnSubmit={false}
editable={canSend}
importantForAutofill="no"
/>
</View>
) : (
<View style={styles.inputBar}>
<TextInput
ref={commandInputRef}
// Why: Android caches IME inputType at mount, so toggling autocomplete must remount there; iOS updates in place.
key={
Platform.OS === 'android'
? autocompleteEnabled
? 'cmd-input-ac-on'
: 'cmd-input-ac-off'
: 'cmd-input'
}
style={styles.textInput}
value={input}
// Why: iOS kills active dictation/IME if JS writes a value differing from native text; store raw, normalize at send.
onChangeText={setInput}
placeholder="Type a command…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={autocompleteEnabled}
spellCheck={autocompleteEnabled}
smartInsertDelete={false}
// Why: not autofill content, but keyboard must stay default so non-Latin IMEs remain selectable.
autoComplete="off"
keyboardType={getTerminalCommandKeyboardType(
Platform.OS,
autocompleteEnabled
)}
returnKeyType="send"
// Why: composing is local an outage must not lock the field or discard typed text (#6713).
editable={canCompose}
onSubmitEditing={() => void handleSend()}
/>
<MobileTerminalInputActions
canSend={canSend}
isAttaching={isAttaching}
dictation={dictation}
dictationMode={dictationMode}
buttonStyle={styles.dictationButton}
activeButtonStyle={styles.dictationButtonActive}
disabledButtonStyle={styles.sendButtonDisabled}
onAttachImage={() => void attachImage('library')}
onAttachFile={() => void attachImage('files')}
onDictationToggle={handleDictationToggle}
onDictationPressIn={handleDictationPressIn}
onDictationPressOut={handleDictationPressOut}
onDictationCancel={cancelDictation}
/>
<Pressable
style={[styles.sendButton, !canSend && styles.sendButtonDisabled]}
disabled={!canSend}
onPress={() => void handleSend()}
accessibilityLabel="Send command"
>
<ArrowUp size={18} color={colors.textSecondary} strokeWidth={2.5} />
</Pressable>
</View>
)}
</View>
)}
</View>
{canDockPanel && activePanel !== null && (
<SessionDockColumn
activePanel={activePanel}
hostId={hostId}
worktreeId={worktreeId}
name={worktreeName || ''}
availableWidth={sessionContentRowWidth}
onRequestClose={() => setActivePanel(null)}
onFileOpenStart={handleFileOpenStart}
onOpenedFileDiff={handleOpenedFileDiff}
/>
)}
</View>
</View>
<MobileSessionHeaderMoreActionsSheet
visible={showHeaderMoreActions}
showAgentSessionHistory={showAgentSessionHistoryAction}
showChecks={showChecksAction}
onOpenAgentSessionHistory={openAgentSessionHistory}
onOpenChecks={() => handlePanelTap('pr')}
onClose={() => setShowHeaderMoreActions(false)}
/>
<QuickCommandsSheet
visible={showQuickCommands && quickCommandsSupported === true}
onClose={() => setShowQuickCommands(false)}
client={client}
repoId={
isFolderWorkspaceRoute || isFloatingWorkspaceRoute
? null
: getRepoIdFromMobileWorktreeId(worktreeId) || null
}
repoName={worktreeName || null}
onLaunch={launchQuickCommand}
/>
<ActionSheetModal
visible={showCreateTabDrawer}
title="New Tab"
actions={[
...createTabAgentActions,
{
label: 'Terminal',
icon: SquareTerminal,
onPress: () => {
setShowCreateTabDrawer(false)
void handleCreateTerminal()
}
},
// Why: browser/markdown creation resolve a real worktree on the host
// (browser.tabCreate, files.createFile); the floating sentinel is
// terminal-only over RPC, so those options hide there.
...(isFloatingWorkspaceRoute
? []
: [
{
label: 'Browser',
icon: Globe,
closeBeforePress: true,
onPress: () => {
if (browserScreencastSupported !== true) {
showToast('Desktop update required for mobile browser streaming', 1600)
return
}
setShowCreateBrowserModal(true)
}
},
{
label: 'Markdown Note',
icon: FileText,
onPress: () => {
setShowCreateTabDrawer(false)
void handleCreateMarkdownNote()
}
}
])
]}
onClose={() => setShowCreateTabDrawer(false)}
/>
<ActionSheetModal
visible={pendingDiffNotesDelivery !== null}
title="Send Review Notes"
message="Choose an agent session for the current notes."
actions={[
...sendDiffNotesAgentActions,
{
label: 'Copy Notes',
icon: Copy,
onPress: () => {
const delivery = pendingDiffNotesDelivery
setPendingDiffNotesDelivery(null)
if (!delivery) {
return
}
void Clipboard.setStringAsync(delivery.prompt)
.then(() => {
triggerSuccess()
showToast('Notes copied')
})
.catch(() => {
triggerError()
showToast("Couldn't copy notes", 1500)
})
}
}
]}
onClose={() => setPendingDiffNotesDelivery(null)}
/>
<ActionSheetModal
visible={actionTarget != null}
title={actionTarget?.title || 'Terminal'}
actions={getMobileTerminalActionSheetActions({
target: actionTarget,
tabs: sessionTabs.filter((tab) => tab.type === 'terminal'),
isTabChatView: nativeChatController.isTabChatView,
nativeChatTranscriptIsLocalReadable,
onDismiss: () => setActionTarget(null),
onToggleChat: toggleTabChatView,
isPhoneMode: (handle) => isTerminalPhoneDisplayMode(handle, terminalModes),
onToggleDisplayMode: (handle) => void toggleDisplayMode(handle),
onRename: setRenameTarget,
onClear: (target) => void handleClearTerminal(target),
onClose: (target) => void handleCloseTerminal(target),
onCloseSessionTab: (tab) => void handleCloseSessionTab(tab),
bulkCloseActions
})}
onClose={() => setActionTarget(null)}
/>
<ActionSheetModal
visible={markdownActionTarget != null}
title={markdownActionTarget?.title || 'Markdown'}
actions={[
{
label: 'Refresh',
icon: RefreshCw,
// Why: dirty refresh opens ConfirmModal; wait for this sheet's native
// Modal to unmount first (same dual-Modal race as tab Rename, #10331).
closeBeforePress: true,
onPress: () => {
const target = markdownActionTarget
if (target) {
discardMarkdownLocalContent(target)
}
}
},
{
label: 'Copy Path',
icon: FileText,
onPress: () => {
const target = markdownActionTarget
setMarkdownActionTarget(null)
if (target) {
void Clipboard.setStringAsync(target.relativePath || target.filePath)
showToast('Path copied')
}
}
},
...closeWithBulkActions(markdownActionTarget, () => setMarkdownActionTarget(null))
]}
onClose={() => setMarkdownActionTarget(null)}
/>
<ActionSheetModal
visible={fileActionTarget != null}
title={fileActionTarget?.title || 'File'}
actions={[
{
label: 'Refresh',
icon: RefreshCw,
onPress: () => {
const target = fileActionTarget
setFileActionTarget(null)
if (target) {
void readFileTab(target)
}
}
},
...closeWithBulkActions(fileActionTarget, () => setFileActionTarget(null))
]}
onClose={() => setFileActionTarget(null)}
/>
<MobileBrowserTabActionSheet
target={browserActionTarget}
onClose={() => setBrowserActionTarget(null)}
onNavigate={handleBrowserNavigationCommand}
onCloseTab={handleCloseSessionTab}
bulkCloseActions={bulkCloseActions}
/>
<ActionSheetModal
visible={leaveDrafts != null}
title="Unsaved markdown changes"
message="Copy or discard phone drafts before leaving."
actions={[
{
label: 'Copy All & Leave',
icon: FileText,
onPress: () => {
const drafts = leaveDrafts ?? []
const combined = drafts
.map((draft) => `# ${draft.title}\n\n${draft.content}`)
.join('\n\n---\n\n')
void Clipboard.setStringAsync(combined)
.then(() => {
setLeaveDrafts(null)
leaveSession()
})
.catch(() => {
triggerError()
showToast("Couldn't copy drafts", 1500)
})
}
},
{
label: 'Discard & Leave',
destructive: true,
onPress: () => {
setLeaveDrafts(null)
leaveSession()
}
}
]}
onClose={() => setLeaveDrafts(null)}
/>
<ConfirmModal
visible={discardMarkdownTarget != null}
title="Discard Changes"
message="Replace the phone draft with the latest desktop file?"
confirmLabel="Discard"
destructive
onConfirm={confirmDiscardMarkdown}
onCancel={() => setDiscardMarkdownTarget(null)}
/>
<TextInputModal
visible={renameTarget != null}
title="Rename Terminal"
defaultValue={renameTarget?.title || 'Terminal'}
placeholder="Terminal name"
onSubmit={(value) => void handleRenameTerminal(value)}
onCancel={() => setRenameTarget(null)}
/>
<TextInputModal
visible={showCreateBrowserModal}
title="New Browser"
message="Enter a URL, or leave blank for a new tab."
defaultValue=""
placeholder="https://example.com"
submitLabel="Open"
allowEmpty
selectTextOnFocus
keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
onSubmit={(value) => {
void handleCreateBrowser(value).then((created) => {
if (created) {
setShowCreateBrowserModal(false)
}
})
}}
onCancel={() => setShowCreateBrowserModal(false)}
/>
<CustomKeyModal
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
onKeysChanged={setCustomKeys}
onManageShortcuts={handleManageShortcuts}
/>
<MobileDictationSetupSheet
visible={showDictationSetup}
client={client}
onClose={() => setShowDictationSetup(false)}
onReady={() => setShowDictationSetup(false)}
/>
<ActionSheetModal
visible={deleteKeyTarget != null}
title={deleteKeyTarget?.label ?? 'Shortcut'}
message="Remove this custom shortcut?"
actions={[
{
label: 'Remove',
destructive: true,
onPress: () => {
if (deleteKeyTarget) {
void handleDeleteCustomKey(deleteKeyTarget)
}
setDeleteKeyTarget(null)
}
}
]}
onClose={() => setDeleteKeyTarget(null)}
/>
</View>
)
}