5384 lines
203 KiB
TypeScript
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>
|
|
)
|
|
}
|