mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(mobile): dismiss the keyboard after sending to an agent (#17059)
* fix(mobile): dismiss the keyboard after sending to an agent Sending a message left the software keyboard up, covering the reply the user was waiting on. Drop it once the send is accepted, on all three send paths: the terminal live input, the buffered command input, and the chat composer. Gated on the tab being an agent session. A plain shell keeps the keyboard so back-to-back commands stay typeable, a rejected send keeps it so the handed-back draft stays editable, and the accessory shortcut row is untouched because dismissing would pull away the row being tapped. * fix(mobile): gate keyboard dismissal on accepted sends * fix(mobile): fence keyboard dismissal completions * fix(mobile): fence stale send completions * test(mobile): update terminal guard expectations * fix(mobile): restore rejected buffered drafts by origin * fix(mobile): preserve intentional buffered draft clears * fix(mobile): harden send dismissal authority * test(mobile): preserve Strict Mode send dismissal * fix(mobile): preserve drafts across terminal remints * fix(mobile): preserve draft ownership through terminal races * fix(mobile): harden draft recovery and send freshness * fix(mobile): fence route reuse and native draft clears * fix(mobile): preserve native draft edits before clear * test(mobile): pin the terminal-list sweep that bounds buffered drafts `bufferedTerminalDraftState.pruneDrafts(retainedHandles)` is the only bound on two structures that live as long as the session screen — the buffered-draft record and the pending-restoration map — and nothing failed when it was deleted or when it was pointed at the raw `terminal.list` handles instead of the retained set. Both mutations reddened 0 of 3,949 mobile tests. Adds the wiring pin (both mutations now redden it) plus two behavioural tests showing why the argument matters: `terminal.list` omits a chat-covered handle while the desktop graph reloads, so the raw list drops a draft the user is still holding while the retained set keeps it. --------- Co-authored-by: Merge Sim <merge@sim.local> Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
Merge Sim
Merge Sim
parent
b8d5b0486e
commit
3d341c132f
@@ -5,8 +5,6 @@ import {
|
||||
Linking,
|
||||
type AppStateStatus,
|
||||
BackHandler,
|
||||
FlatList,
|
||||
Image,
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
@@ -16,8 +14,7 @@ import {
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
type KeyboardEvent,
|
||||
type LayoutChangeEvent,
|
||||
type ListRenderItem
|
||||
type LayoutChangeEvent
|
||||
} from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
@@ -37,12 +34,10 @@ import {
|
||||
GitBranch,
|
||||
Globe,
|
||||
Keyboard as KeyboardIcon,
|
||||
MessageSquare,
|
||||
Monitor,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Smartphone,
|
||||
SquareTerminal,
|
||||
X
|
||||
@@ -82,6 +77,8 @@ import {
|
||||
} 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 { useAgentSendKeyboardDismissal } from '../../../../src/session/use-agent-send-keyboard-dismissal'
|
||||
import { useMobileSendCompletionGeneration } from '../../../../src/session/use-mobile-send-completion-generation'
|
||||
import { SessionDockColumn } from '../../../../src/session/SessionDockColumn'
|
||||
import { MobileSessionHeaderIconButton } from '../../../../src/session/MobileSessionHeaderIconButton'
|
||||
import { MobileSessionHeaderMoreActionsSheet } from '../../../../src/session/MobileSessionHeaderMoreActionsSheet'
|
||||
@@ -130,6 +127,7 @@ import { useTerminalLiveInputFocus } from '../../../../src/terminal/use-terminal
|
||||
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 { useBufferedTerminalDrafts } from '../../../../src/terminal/use-buffered-terminal-drafts'
|
||||
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'
|
||||
@@ -160,7 +158,6 @@ import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon'
|
||||
import { TextInputModal } from '../../../../src/components/TextInputModal'
|
||||
import { ConfirmModal } from '../../../../src/components/ConfirmModal'
|
||||
import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader'
|
||||
import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments'
|
||||
import {
|
||||
CustomKeyModal,
|
||||
loadCustomKeys,
|
||||
@@ -174,12 +171,6 @@ import {
|
||||
removeDeliveredMobileDiffComments,
|
||||
removeMobileDiffComments
|
||||
} from '../../../../src/session/mobile-diff-comments'
|
||||
import {
|
||||
buildPlainMobileDiffSyntaxLines,
|
||||
highlightMobileCode,
|
||||
highlightMobileDiffLines,
|
||||
resolveMobileSyntaxLanguage
|
||||
} from '../../../../src/session/mobile-file-syntax'
|
||||
import {
|
||||
getTerminalRecordsFromSessionTabs,
|
||||
hasConnectedTerminalAbsentFromSessionTabs,
|
||||
@@ -223,7 +214,6 @@ import {
|
||||
buildMarkdownDiskFallbackDoc,
|
||||
shouldReadMarkdownFromDiskAfterReadTabFailure
|
||||
} from '../../../../src/session/mobile-markdown-disk-fallback'
|
||||
import { MobileHtmlPreview } from '../../../../src/components/MobileHtmlPreview'
|
||||
import { MobileDictationSetupSheet } from '../../../../src/components/MobileDictationSetupSheet'
|
||||
import {
|
||||
fetchDictationSetup,
|
||||
@@ -290,21 +280,18 @@ import {
|
||||
import { colors } from '../../../../src/theme/mobile-theme'
|
||||
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
|
||||
import { styles } from '../../../../src/session/mobile-session-styles'
|
||||
import { MobileSessionFileReader } from '../../../../src/session/MobileSessionFileReader'
|
||||
import type { DiffComment } from '../../../../../src/shared/diff-comment-types'
|
||||
import type { TerminalQuickCommand } from '../../../../../src/shared/terminal-quick-command-types'
|
||||
import type {
|
||||
DiffCommentActions,
|
||||
DiffNotesDelivery,
|
||||
DiffSyntaxState,
|
||||
DirtyMarkdownDraft,
|
||||
FileDocState,
|
||||
FileSyntaxState,
|
||||
MarkdownDocState,
|
||||
MobileDisplayMode,
|
||||
MobileNewTabAgentLoadState,
|
||||
MobileSessionTab,
|
||||
MobileSessionTabType,
|
||||
RenderableDiffLine,
|
||||
RuntimeRepoSummary,
|
||||
SessionTabsResult,
|
||||
Terminal,
|
||||
@@ -324,416 +311,6 @@ function getClosedTabTombstoneExpiry(): number {
|
||||
return Date.now() + CLOSED_TAB_TOMBSTONE_TTL_MS
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -815,7 +392,6 @@ export default function SessionScreen() {
|
||||
// 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.
|
||||
@@ -951,6 +527,8 @@ export default function SessionScreen() {
|
||||
// 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 bufferedTerminalDraftState = useBufferedTerminalDrafts({ activeHandle, activeHandleRef })
|
||||
const reconcileBufferedDraftsRef = useRef(bufferedTerminalDraftState.reconcileTerminalTabs)
|
||||
const activeSessionTabTypeRef = useRef<MobileSessionTabType | null>(null)
|
||||
const pendingActiveSessionTabIdRef = useRef<string | null>(null)
|
||||
// Why: survive transient snapshot gaps so the device's own tab pick can re-bind.
|
||||
@@ -983,6 +561,7 @@ export default function SessionScreen() {
|
||||
const {
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputBeforeExternalSend,
|
||||
getLiveInputInteractionGeneration: getLiveInteractionGeneration,
|
||||
handleLiveInputAccessoryBytes,
|
||||
handleLiveInputChange,
|
||||
handleLiveInputKeyPress,
|
||||
@@ -1015,12 +594,6 @@ export default function SessionScreen() {
|
||||
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>(
|
||||
@@ -1133,6 +706,11 @@ export default function SessionScreen() {
|
||||
})
|
||||
const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
|
||||
nativeChatSendError.bannerMountedRef.current = showNativeChat
|
||||
const routeKey = nativeChatScopeKey ?? `${hostId}\0${worktreeId}`
|
||||
const getSendCompletionGeneration = useMobileSendCompletionGeneration({
|
||||
onBlur: resetLiveInputFocus,
|
||||
surfaceKey: JSON.stringify([routeKey, activeHandle, showNativeChat, liveInputEnabled])
|
||||
})
|
||||
|
||||
const dictation = useMobileDictation({
|
||||
client,
|
||||
@@ -1170,7 +748,7 @@ export default function SessionScreen() {
|
||||
})()
|
||||
return
|
||||
}
|
||||
setInput((current) => appendBufferedDictation(current, route.text))
|
||||
bufferedTerminalDraftState.setInput((current) => appendBufferedDictation(current, route.text))
|
||||
showToast('Dictation inserted')
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -1664,7 +1242,9 @@ export default function SessionScreen() {
|
||||
// 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))
|
||||
const retainedHandles = resolveRetainedTerminalHandles(pruneContext)
|
||||
pruneTerminalHandlesFromLiveInput(retainedHandles)
|
||||
bufferedTerminalDraftState.pruneDrafts(retainedHandles)
|
||||
defaultTerminalHandlesToLiveInput([...liveHandles])
|
||||
const shouldPrune = createTerminalPrunePredicate(pruneContext)
|
||||
for (const handle of Array.from(terminalUnsubsRef.current.keys())) {
|
||||
@@ -1721,6 +1301,7 @@ export default function SessionScreen() {
|
||||
clearTerminalLiveInputDefault,
|
||||
defaultTerminalHandlesToLiveInput,
|
||||
nativeChatStream,
|
||||
bufferedTerminalDraftState.pruneDrafts,
|
||||
pruneTerminalHandlesFromLiveInput,
|
||||
subscribeToTerminal,
|
||||
terminalInventoryRequest,
|
||||
@@ -1761,6 +1342,9 @@ export default function SessionScreen() {
|
||||
if (orphanedDraftTabs.length > 0) {
|
||||
nextTabs = [...orphanedDraftTabs, ...nextTabs]
|
||||
}
|
||||
reconcileBufferedDraftsRef.current(currentSessionTabs, nextTabs, {
|
||||
retainMissingSurfaces: result.tabs.length === 0
|
||||
})
|
||||
sessionTabsRef.current = nextTabs
|
||||
initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0
|
||||
// Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop.
|
||||
@@ -2663,6 +2247,7 @@ export default function SessionScreen() {
|
||||
terminalDiagnosticsRef.current.resetRoute()
|
||||
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
|
||||
closedTabTombstonesRef.current.clear()
|
||||
bufferedTerminalDraftState.resetDrafts()
|
||||
for (const queued of terminalGestureInputQueuesRef.current.values()) {
|
||||
if (queued.timer) {
|
||||
clearTimeout(queued.timer)
|
||||
@@ -2682,14 +2267,17 @@ export default function SessionScreen() {
|
||||
return () => {
|
||||
sessionTabActionSheetRequestSeqRef.current += 1
|
||||
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
|
||||
bufferedTerminalDraftState.clearPendingRestorations()
|
||||
clearPendingLiveInputCommit()
|
||||
clearDelayedActionTimers()
|
||||
}
|
||||
}, [
|
||||
clearDelayedActionTimers,
|
||||
bufferedTerminalDraftState.clearPendingRestorations,
|
||||
clearPendingLiveInputCommit,
|
||||
clearTerminalCache,
|
||||
hostId,
|
||||
bufferedTerminalDraftState.resetDrafts,
|
||||
worktreeId
|
||||
])
|
||||
|
||||
@@ -3020,6 +2608,20 @@ export default function SessionScreen() {
|
||||
}
|
||||
}, [activeSessionTab, fileDocs, readFileTab])
|
||||
|
||||
const dismissSoftwareKeyboard = useCallback(() => {
|
||||
dismissTerminalKeyboard({
|
||||
clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef),
|
||||
commandInput: commandInputRef.current,
|
||||
dismissKeyboard: () => Keyboard.dismiss(),
|
||||
liveInput: liveInputRef.current
|
||||
})
|
||||
}, [])
|
||||
|
||||
const dismissKeyboardAfterAgentSend = useAgentSendKeyboardDismissal(
|
||||
dismissSoftwareKeyboard,
|
||||
getSendCompletionGeneration
|
||||
)
|
||||
|
||||
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) {
|
||||
@@ -3027,12 +2629,23 @@ export default function SessionScreen() {
|
||||
}
|
||||
sendingRef.current = true
|
||||
|
||||
const text = normalizeTerminalTextInput(input)
|
||||
setInput('')
|
||||
const draft = bufferedTerminalDraftState.input
|
||||
const text = normalizeTerminalTextInput(draft)
|
||||
const bufferedDraftSend = bufferedTerminalDraftState.beginBufferedTerminalDraftSend(
|
||||
activeHandle,
|
||||
draft
|
||||
)
|
||||
const sendOrigin = {
|
||||
handle: activeHandle,
|
||||
tab: activeSessionTab,
|
||||
generation: getSendCompletionGeneration()
|
||||
}
|
||||
const restoreRejectedDraft = () =>
|
||||
bufferedTerminalDraftState.restoreRejectedDraft(bufferedDraftSend)
|
||||
|
||||
try {
|
||||
// Why: fail now and restore the text — a send parked across a reconnect would execute long after the tap.
|
||||
await client.sendRequest(
|
||||
const response = await client.sendRequest(
|
||||
'terminal.send',
|
||||
buildTerminalSendParams({
|
||||
terminal: activeHandle,
|
||||
@@ -3042,9 +2655,17 @@ export default function SessionScreen() {
|
||||
}),
|
||||
TERMINAL_INPUT_SEND_OPTIONS
|
||||
)
|
||||
const accepted = isTerminalSendRpcAccepted(response)
|
||||
if (!accepted) {
|
||||
restoreRejectedDraft()
|
||||
}
|
||||
const draftUnchanged =
|
||||
accepted && bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend)
|
||||
dismissKeyboardAfterAgentSend(sendOrigin, accepted && draftUnchanged)
|
||||
} catch {
|
||||
setInput(text)
|
||||
restoreRejectedDraft()
|
||||
} finally {
|
||||
bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend)
|
||||
sendingRef.current = false
|
||||
}
|
||||
}
|
||||
@@ -3180,15 +2801,6 @@ export default function SessionScreen() {
|
||||
]
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -4123,6 +3735,7 @@ export default function SessionScreen() {
|
||||
})
|
||||
if (response.ok) {
|
||||
const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id)
|
||||
reconcileBufferedDraftsRef.current(sessionTabsRef.current, remainingTabs)
|
||||
if (tab.type === 'browser' && tab.browserPageId === pendingBrowserFocusPageIdRef.current) {
|
||||
pendingBrowserFocusPageIdRef.current = null
|
||||
}
|
||||
@@ -4649,7 +4262,7 @@ export default function SessionScreen() {
|
||||
</View>
|
||||
) : activeFileTab ? (
|
||||
<View style={styles.markdownFrame}>
|
||||
<FileReader
|
||||
<MobileSessionFileReader
|
||||
doc={fileDocs.get(activeFileTab.id)}
|
||||
title={activeFileTab.title || 'File'}
|
||||
relativePath={activeFileTab.relativePath}
|
||||
@@ -4768,6 +4381,8 @@ export default function SessionScreen() {
|
||||
inputLockReason={nativeChatInputLockReason}
|
||||
sendErrorMessage={nativeChatSendError.message}
|
||||
onClearSendError={nativeChatSendError.clear}
|
||||
sendSurfaceId={nativeChatScopeKey ?? ''}
|
||||
getSendCompletionGeneration={getSendCompletionGeneration}
|
||||
keyboardInset={keyboardLift}
|
||||
/>
|
||||
{toastMessage && (
|
||||
@@ -5023,7 +4638,20 @@ export default function SessionScreen() {
|
||||
// marked-text report that says whether this text is still preedit.
|
||||
onChange={handleLiveInputChange}
|
||||
onKeyPress={handleLiveInputKeyPress}
|
||||
onSubmitEditing={handleLiveInputSubmit}
|
||||
onSubmitEditing={() => {
|
||||
const submit = handleLiveInputSubmit()
|
||||
const sendOrigin = {
|
||||
tab: activeSessionTab,
|
||||
generation: getSendCompletionGeneration(),
|
||||
interaction: getLiveInteractionGeneration()
|
||||
}
|
||||
void submit.then((accepted) =>
|
||||
dismissKeyboardAfterAgentSend(
|
||||
sendOrigin,
|
||||
accepted && sendOrigin.interaction === getLiveInteractionGeneration()
|
||||
)
|
||||
)
|
||||
}}
|
||||
placeholder=""
|
||||
showSoftInputOnFocus
|
||||
autoCapitalize="none"
|
||||
@@ -5052,9 +4680,9 @@ export default function SessionScreen() {
|
||||
: 'cmd-input'
|
||||
}
|
||||
style={styles.textInput}
|
||||
value={input}
|
||||
value={bufferedTerminalDraftState.input}
|
||||
// Why: iOS kills active dictation/IME if JS writes a value differing from native text; store raw, normalize at send.
|
||||
onChangeText={setInput}
|
||||
onChangeText={bufferedTerminalDraftState.setInput}
|
||||
placeholder="Type a command…"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
@@ -5068,6 +4696,7 @@ export default function SessionScreen() {
|
||||
autocompleteEnabled
|
||||
)}
|
||||
returnKeyType="send"
|
||||
blurOnSubmit={false}
|
||||
// Why: composing is local — an outage must not lock the field or discard typed text (#6713).
|
||||
editable={canCompose}
|
||||
onSubmitEditing={() => void handleSend()}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Pressable, Text, TextInput, View } from 'react-native'
|
||||
import { MessageSquare, Plus, X } from 'lucide-react-native'
|
||||
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-session-styles'
|
||||
import type { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
import type { RenderableDiffLine } from './mobile-session-route-types'
|
||||
|
||||
export function MobileDiffCommentLineRow({
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
import { createElement } from 'react'
|
||||
import { createElement, StrictMode, type ComponentProps } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { Keyboard } from 'react-native'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { radii, spacing } from '../theme/mobile-theme'
|
||||
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
|
||||
import { MobileNativeChatComposer as NativeChatComposer } from './MobileNativeChatComposer'
|
||||
|
||||
const getNoComposerEditGeneration = () => 0
|
||||
|
||||
function MobileNativeChatComposer({
|
||||
getComposerEditGeneration = getNoComposerEditGeneration,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof NativeChatComposer>, 'getComposerEditGeneration'> & {
|
||||
getComposerEditGeneration?: () => number
|
||||
}): React.JSX.Element {
|
||||
return createElement(NativeChatComposer, { ...props, getComposerEditGeneration })
|
||||
}
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
@@ -45,6 +57,7 @@ vi.mock('../components/BottomDrawer', async () => {
|
||||
|
||||
describe('MobileNativeChatComposer', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const getCurrentSendCompletionGeneration = () => 0
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
@@ -53,15 +66,24 @@ describe('MobileNativeChatComposer', () => {
|
||||
|
||||
async function render(
|
||||
onSend: (text: string) => Promise<boolean>,
|
||||
onChangeText: () => void,
|
||||
isAttaching = false
|
||||
onChangeText: (text: string) => void,
|
||||
isAttaching = false,
|
||||
sendSurfaceId = 'tab-a',
|
||||
getSendCompletionGeneration = () => 0
|
||||
) {
|
||||
let composerEditGeneration = 0
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: ' hello ',
|
||||
onChangeText,
|
||||
onChangeText: (text) => {
|
||||
composerEditGeneration += 1
|
||||
onChangeText(text)
|
||||
},
|
||||
onSend,
|
||||
sendSurfaceId,
|
||||
getSendCompletionGeneration,
|
||||
getComposerEditGeneration: () => composerEditGeneration,
|
||||
isAttaching
|
||||
})
|
||||
)
|
||||
@@ -117,7 +139,9 @@ describe('MobileNativeChatComposer', () => {
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: ' /clear is prose ',
|
||||
onChangeText: vi.fn(),
|
||||
onSend
|
||||
onSend,
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: getCurrentSendCompletionGeneration
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -164,6 +188,8 @@ describe('MobileNativeChatComposer', () => {
|
||||
value: 'run the tests',
|
||||
onChangeText: vi.fn(),
|
||||
onSend,
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||||
sessionOptions: { isWorking: false, controller }
|
||||
})
|
||||
)
|
||||
@@ -196,6 +222,8 @@ describe('MobileNativeChatComposer', () => {
|
||||
value: 'hello',
|
||||
onChangeText: vi.fn(),
|
||||
onSend,
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||||
sessionOptions: {
|
||||
isWorking: false,
|
||||
controller: {
|
||||
@@ -241,6 +269,8 @@ describe('MobileNativeChatComposer', () => {
|
||||
value: 'half-typed',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||||
disabled: true
|
||||
})
|
||||
)
|
||||
@@ -262,6 +292,8 @@ describe('MobileNativeChatComposer', () => {
|
||||
value: '',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||||
attachments: [
|
||||
{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' },
|
||||
{ id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' }
|
||||
@@ -290,6 +322,8 @@ describe('MobileNativeChatComposer', () => {
|
||||
value: '',
|
||||
onChangeText: vi.fn(),
|
||||
onSend,
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||||
attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }]
|
||||
})
|
||||
)
|
||||
@@ -307,6 +341,8 @@ describe('MobileNativeChatComposer', () => {
|
||||
value: '/c',
|
||||
onChangeText,
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||||
agent: 'claude'
|
||||
})
|
||||
)
|
||||
@@ -345,6 +381,8 @@ describe('MobileNativeChatComposer', () => {
|
||||
value: '/',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||||
agent: 'codex'
|
||||
})
|
||||
)
|
||||
@@ -378,6 +416,8 @@ describe('MobileNativeChatComposer', () => {
|
||||
value: '',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||||
onMicPress,
|
||||
dictationMode: 'hold',
|
||||
onMicPressIn,
|
||||
@@ -396,6 +436,8 @@ describe('MobileNativeChatComposer', () => {
|
||||
value: '',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||||
onMicPress,
|
||||
dictationMode: 'toggle',
|
||||
onMicPressIn,
|
||||
@@ -408,4 +450,225 @@ describe('MobileNativeChatComposer', () => {
|
||||
expect(mic().props.onPressIn).toBeUndefined()
|
||||
expect(mic().props.onPressOut).toBeUndefined()
|
||||
})
|
||||
|
||||
it('dismisses the keyboard once a send is accepted', async () => {
|
||||
// Why: the reply the user is now waiting on sits behind the keyboard.
|
||||
vi.mocked(Keyboard.dismiss).mockClear()
|
||||
await render(vi.fn().mockResolvedValue(true), vi.fn())
|
||||
|
||||
await act(async () => sendButton().props.onPress())
|
||||
|
||||
expect(Keyboard.dismiss).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dismisses an accepted send after Strict Mode replays mount effects', async () => {
|
||||
vi.mocked(Keyboard.dismiss).mockClear()
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(
|
||||
StrictMode,
|
||||
null,
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: 'hello',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: getCurrentSendCompletionGeneration
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
await act(async () => sendButton().props.onPress())
|
||||
|
||||
expect(Keyboard.dismiss).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the keyboard up when the send is rejected', async () => {
|
||||
// A rejected send hands the draft back for editing, so yanking the keyboard
|
||||
// would make the user re-open it to fix and retry.
|
||||
vi.mocked(Keyboard.dismiss).mockClear()
|
||||
await render(vi.fn().mockResolvedValue(false), vi.fn())
|
||||
|
||||
await act(async () => sendButton().props.onPress())
|
||||
|
||||
expect(Keyboard.dismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dismiss a newly focused composer when an old accepted send settles', async () => {
|
||||
vi.mocked(Keyboard.dismiss).mockClear()
|
||||
let resolveSend: ((accepted: boolean) => void) | null = null
|
||||
const onSend = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveSend = resolve
|
||||
})
|
||||
)
|
||||
await render(onSend, vi.fn())
|
||||
|
||||
let pendingSend!: Promise<void>
|
||||
await act(async () => {
|
||||
pendingSend = sendButton().props.onPress()
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: 'new surface draft',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
sendSurfaceId: 'tab-b',
|
||||
getSendCompletionGeneration: getCurrentSendCompletionGeneration
|
||||
})
|
||||
)
|
||||
})
|
||||
await act(async () => {
|
||||
resolveSend?.(true)
|
||||
await pendingSend
|
||||
})
|
||||
|
||||
expect(Keyboard.dismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dismiss after a newer edit on the same surface', async () => {
|
||||
vi.mocked(Keyboard.dismiss).mockClear()
|
||||
let resolveSend: ((accepted: boolean) => void) | null = null
|
||||
const onSend = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveSend = resolve
|
||||
})
|
||||
)
|
||||
await render(onSend, vi.fn())
|
||||
|
||||
let pendingSend!: Promise<void>
|
||||
await act(async () => {
|
||||
pendingSend = sendButton().props.onPress()
|
||||
await Promise.resolve()
|
||||
})
|
||||
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
|
||||
props: { onChangeText: (text: string) => void }
|
||||
}
|
||||
await act(async () => input.props.onChangeText('newer draft'))
|
||||
await act(async () => {
|
||||
resolveSend?.(true)
|
||||
await pendingSend
|
||||
})
|
||||
|
||||
expect(Keyboard.dismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dismiss after autocomplete mutates the same surface', async () => {
|
||||
vi.mocked(Keyboard.dismiss).mockClear()
|
||||
let editGeneration = 0
|
||||
let resolveSend: ((accepted: boolean) => void) | null = null
|
||||
const onSend = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveSend = resolve
|
||||
})
|
||||
)
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '/c',
|
||||
onChangeText: () => {
|
||||
editGeneration += 1
|
||||
},
|
||||
onSend,
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||||
getComposerEditGeneration: () => editGeneration,
|
||||
agent: 'claude'
|
||||
})
|
||||
)
|
||||
})
|
||||
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
|
||||
props: { onSelectionChange: (event: { nativeEvent: { selection: { end: number } } }) => void }
|
||||
}
|
||||
await act(async () => input.props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } }))
|
||||
|
||||
let pendingSend!: Promise<void>
|
||||
await act(async () => {
|
||||
pendingSend = sendButton().props.onPress()
|
||||
await Promise.resolve()
|
||||
})
|
||||
const suggestion = renderer!.root.findAll(
|
||||
(node) => node.type === 'Pressable' && !node.props.accessibilityLabel
|
||||
)[0] as { props: { onPress: () => void } }
|
||||
await act(async () => suggestion.props.onPress())
|
||||
await act(async () => {
|
||||
resolveSend?.(true)
|
||||
await pendingSend
|
||||
})
|
||||
|
||||
expect(Keyboard.dismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dismiss after dictation mutates the controlled draft', async () => {
|
||||
vi.mocked(Keyboard.dismiss).mockClear()
|
||||
let editGeneration = 0
|
||||
let resolveSend: ((accepted: boolean) => void) | null = null
|
||||
const onSend = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveSend = resolve
|
||||
})
|
||||
)
|
||||
const props = {
|
||||
value: 'hello',
|
||||
onChangeText: vi.fn(),
|
||||
onSend,
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: getCurrentSendCompletionGeneration,
|
||||
getComposerEditGeneration: () => editGeneration
|
||||
}
|
||||
await act(async () => {
|
||||
renderer = create(createElement(MobileNativeChatComposer, props))
|
||||
})
|
||||
|
||||
let pendingSend!: Promise<void>
|
||||
await act(async () => {
|
||||
pendingSend = sendButton().props.onPress()
|
||||
await Promise.resolve()
|
||||
})
|
||||
editGeneration += 1
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
createElement(MobileNativeChatComposer, { ...props, value: 'hello dictated text' })
|
||||
)
|
||||
})
|
||||
await act(async () => {
|
||||
resolveSend?.(true)
|
||||
await pendingSend
|
||||
})
|
||||
|
||||
expect(Keyboard.dismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dismiss after its retained route loses focus', async () => {
|
||||
vi.mocked(Keyboard.dismiss).mockClear()
|
||||
let generation = 0
|
||||
let resolveSend: ((accepted: boolean) => void) | null = null
|
||||
const onSend = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveSend = resolve
|
||||
})
|
||||
)
|
||||
await render(onSend, vi.fn(), false, 'tab-a', () => generation)
|
||||
|
||||
let pendingSend!: Promise<void>
|
||||
await act(async () => {
|
||||
pendingSend = sendButton().props.onPress()
|
||||
await Promise.resolve()
|
||||
})
|
||||
generation += 1
|
||||
await act(async () => {
|
||||
resolveSend?.(true)
|
||||
await pendingSend
|
||||
})
|
||||
|
||||
expect(Keyboard.dismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
Keyboard,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
@@ -36,6 +37,12 @@ type Props = {
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
onSend: (text: string) => Promise<boolean>
|
||||
/** Changes whenever the route focuses a different chat composer surface. */
|
||||
sendSurfaceId: string
|
||||
/** Reads the retained route's focus generation without forcing a screen render. */
|
||||
getSendCompletionGeneration: () => number
|
||||
/** Reads user draft mutations owned above this renderable composer. */
|
||||
getComposerEditGeneration: () => number
|
||||
/** Active tab's agent — the slash autocomplete serves its command catalog. */
|
||||
agent?: string | null
|
||||
/** Model/session-option pickers shown in the composer action row; null when
|
||||
@@ -63,6 +70,9 @@ export function MobileNativeChatComposer({
|
||||
value,
|
||||
onChangeText,
|
||||
onSend,
|
||||
sendSurfaceId,
|
||||
getSendCompletionGeneration,
|
||||
getComposerEditGeneration,
|
||||
agent,
|
||||
sessionOptions,
|
||||
onAttachImage,
|
||||
@@ -87,6 +97,15 @@ export function MobileNativeChatComposer({
|
||||
null
|
||||
)
|
||||
const sendingRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
const sendSurfaceIdRef = useRef(sendSurfaceId)
|
||||
const sendSurfaceGenerationRef = useRef(0)
|
||||
useLayoutEffect(() => {
|
||||
if (sendSurfaceIdRef.current !== sendSurfaceId) {
|
||||
sendSurfaceIdRef.current = sendSurfaceId
|
||||
sendSurfaceGenerationRef.current += 1
|
||||
}
|
||||
}, [sendSurfaceId])
|
||||
const [sending, setSending] = useState(false)
|
||||
const trimmed = value.trim()
|
||||
const sessionOptionDispatching = sessionOptions?.controller.pendingId != null
|
||||
@@ -126,6 +145,14 @@ export function MobileNativeChatComposer({
|
||||
}
|
||||
}, [onNeedFiles, trigger?.kind, trigger?.query])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
sendSurfaceGenerationRef.current += 1
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleChange = (next: string): void => {
|
||||
onChangeText(next)
|
||||
}
|
||||
@@ -150,12 +177,24 @@ export function MobileNativeChatComposer({
|
||||
}
|
||||
sendingRef.current = true
|
||||
setSending(true)
|
||||
const sendSurfaceGeneration = sendSurfaceGenerationRef.current
|
||||
const sendCompletionGeneration = getSendCompletionGeneration()
|
||||
const composerEditGeneration = getComposerEditGeneration()
|
||||
try {
|
||||
// Raw, not trimmed: the send seam owns the wire trim, and a rejection has
|
||||
// to hand the user back exactly what they typed (#14819).
|
||||
const accepted = await onSend(value)
|
||||
if (accepted) {
|
||||
if (
|
||||
accepted &&
|
||||
mountedRef.current &&
|
||||
sendSurfaceGeneration === sendSurfaceGenerationRef.current &&
|
||||
sendCompletionGeneration === getSendCompletionGeneration() &&
|
||||
composerEditGeneration === getComposerEditGeneration()
|
||||
) {
|
||||
setCursor(0)
|
||||
// Why: the turn is now the agent's — the keyboard would cover the reply.
|
||||
// A rejected send keeps it up so the handed-back draft stays editable.
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
} finally {
|
||||
sendingRef.current = false
|
||||
|
||||
@@ -51,6 +51,8 @@ function overlayElement(tick: Tick): ReturnType<typeof createElement> {
|
||||
inputLockReason: null,
|
||||
sendErrorMessage: null,
|
||||
onClearSendError: vi.fn(),
|
||||
sendSurfaceId: tick.identity ?? 'tab-a',
|
||||
getSendCompletionGeneration: () => 0,
|
||||
keyboardInset: 0
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ type Props = {
|
||||
sendErrorMessage: string | null
|
||||
/** Drops that failure once a later send succeeds. */
|
||||
onClearSendError: () => void
|
||||
/** Stable host/worktree/tab identity for accepted-send completion fencing. */
|
||||
sendSurfaceId: string
|
||||
/** Reads the retained route's focus generation for accepted-send fencing. */
|
||||
getSendCompletionGeneration: () => number
|
||||
keyboardInset: number
|
||||
}
|
||||
|
||||
@@ -43,6 +47,8 @@ export function MobileNativeChatOverlay({
|
||||
inputLockReason,
|
||||
sendErrorMessage,
|
||||
onClearSendError,
|
||||
sendSurfaceId,
|
||||
getSendCompletionGeneration,
|
||||
keyboardInset
|
||||
}: Props): React.JSX.Element | null {
|
||||
const session = controller.nativeChatSession
|
||||
@@ -81,6 +87,9 @@ export function MobileNativeChatOverlay({
|
||||
loadingEarlier={session.loadingEarlier}
|
||||
onLoadEarlier={session.loadEarlier}
|
||||
onSend={images.sendNativeChat}
|
||||
sendSurfaceId={sendSurfaceId}
|
||||
getSendCompletionGeneration={getSendCompletionGeneration}
|
||||
getComposerEditGeneration={controller.getChatComposerEditGeneration}
|
||||
pending={controller.chatPending}
|
||||
imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId}
|
||||
composerText={controller.chatComposerText}
|
||||
|
||||
@@ -85,6 +85,8 @@ function chatViewElement(overrides: Overrides): ReturnType<typeof createElement>
|
||||
status: 'ready',
|
||||
streaming: null,
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
sendSurfaceId: 'tab-a',
|
||||
getSendCompletionGeneration: () => 0,
|
||||
pending: [],
|
||||
composerText: '',
|
||||
onComposerTextChange: vi.fn(),
|
||||
|
||||
@@ -58,6 +58,12 @@ type Props = {
|
||||
loadingEarlier?: boolean
|
||||
onLoadEarlier?: () => void
|
||||
onSend: (text: string) => Promise<boolean>
|
||||
/** Route identity used to fence accepted sends that settle after a tab/view switch. */
|
||||
sendSurfaceId: string
|
||||
/** Reads the retained route's focus generation for accepted-send fencing. */
|
||||
getSendCompletionGeneration: () => number
|
||||
/** Reads user draft mutations from the route-owned controller. */
|
||||
getComposerEditGeneration: () => number
|
||||
/** Accepted user echoes awaiting transcript replacement, including image previews. */
|
||||
pending: MobileNativeChatPendingItem[]
|
||||
/** Local photo URIs retained when the authoritative transcript replaces an
|
||||
@@ -126,6 +132,9 @@ export function MobileNativeChatView({
|
||||
loadingEarlier,
|
||||
onLoadEarlier,
|
||||
onSend,
|
||||
sendSurfaceId,
|
||||
getSendCompletionGeneration,
|
||||
getComposerEditGeneration,
|
||||
pending,
|
||||
imagePreviewsByMessageId,
|
||||
composerText,
|
||||
@@ -431,6 +440,8 @@ export function MobileNativeChatView({
|
||||
value={composerText}
|
||||
onChangeText={onComposerTextChange}
|
||||
onSend={handleSend}
|
||||
sendSurfaceId={sendSurfaceId}
|
||||
{...{ getSendCompletionGeneration, getComposerEditGeneration }}
|
||||
agent={agent}
|
||||
sessionOptions={sessionOptions}
|
||||
onAttachImage={onAttachImage}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Image,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
type ListRenderItem
|
||||
} from 'react-native'
|
||||
import { Copy, MessageSquare, Send } from 'lucide-react-native'
|
||||
import { MobileHtmlPreview } from '../components/MobileHtmlPreview'
|
||||
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
buildPlainMobileDiffSyntaxLines,
|
||||
highlightMobileCode,
|
||||
highlightMobileDiffLines,
|
||||
resolveMobileSyntaxLanguage
|
||||
} from './mobile-file-syntax'
|
||||
import { styles } from './mobile-session-styles'
|
||||
import { MobileDiffCommentLineRow } from './MobileDiffCommentLineRow'
|
||||
import type { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
import type {
|
||||
DiffCommentActions,
|
||||
DiffSyntaxState,
|
||||
FileDocState,
|
||||
FileSyntaxState,
|
||||
RenderableDiffLine
|
||||
} from './mobile-session-route-types'
|
||||
|
||||
export function MobileSessionFileReader({
|
||||
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 }) => (
|
||||
<MobileDiffCommentLineRow
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sessionRouteSource = readFileSync(
|
||||
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const bufferedDraftHookSource = readFileSync(
|
||||
new URL('../terminal/use-buffered-terminal-drafts.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const keyboardDismissalHookSource = readFileSync(
|
||||
new URL('./use-agent-send-keyboard-dismissal.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function routeSlice(anchorStart: string, anchorEnd: string): string {
|
||||
const start = sessionRouteSource.indexOf(anchorStart)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
// Why: a duplicated start anchor would silently slice the wrong region.
|
||||
expect(sessionRouteSource.indexOf(anchorStart, start + 1)).toBe(-1)
|
||||
const end = sessionRouteSource.indexOf(anchorEnd, start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return sessionRouteSource.slice(start, end + anchorEnd.length)
|
||||
}
|
||||
|
||||
describe('terminal send keyboard dismissal wiring', () => {
|
||||
it('gates the dismissal on the agent-session predicate', () => {
|
||||
const slice = routeSlice(
|
||||
'const dismissKeyboardAfterAgentSend = useAgentSendKeyboardDismissal(',
|
||||
'getSendCompletionGeneration\n )'
|
||||
)
|
||||
expect(slice).toContain('dismissSoftwareKeyboard')
|
||||
expect(keyboardDismissalHookSource).toContain(
|
||||
'shouldDismissKeyboardAfterTerminalSend(origin.tab, accepted)'
|
||||
)
|
||||
expect(keyboardDismissalHookSource).toContain(
|
||||
'origin.generation === getSendCompletionGeneration()'
|
||||
)
|
||||
expect(keyboardDismissalHookSource).toContain('dismissSoftwareKeyboard()')
|
||||
expect(keyboardDismissalHookSource).toContain('return useCallback(')
|
||||
expect(sessionRouteSource).toContain(
|
||||
"import { useAgentSendKeyboardDismissal } from '../../../../src/session/use-agent-send-keyboard-dismissal'"
|
||||
)
|
||||
})
|
||||
|
||||
it('invalidates pending terminal sends when the focused input surface changes', () => {
|
||||
const slice = routeSlice(
|
||||
'const getSendCompletionGeneration = useMobileSendCompletionGeneration({',
|
||||
'})'
|
||||
)
|
||||
expect(sessionRouteSource).toContain(
|
||||
'const routeKey = nativeChatScopeKey ?? `${hostId}\\0${worktreeId}`'
|
||||
)
|
||||
expect(slice).toContain(
|
||||
'surfaceKey: JSON.stringify([routeKey, activeHandle, showNativeChat, liveInputEnabled])'
|
||||
)
|
||||
})
|
||||
|
||||
it('dismisses after the live input submits, which is the only Enter path', () => {
|
||||
// terminal-live-input.ts deliberately keeps Enter off the key map, so
|
||||
// onSubmitEditing is the single send seam for the live field.
|
||||
const slice = routeSlice('ref={liveInputRef}', 'importantForAutofill="no"')
|
||||
expect(slice).toContain('generation: getSendCompletionGeneration()')
|
||||
expect(slice).toContain('const submit = handleLiveInputSubmit()')
|
||||
expect(slice).toContain('interaction: getLiveInteractionGeneration()')
|
||||
expect(slice).toContain('sendOrigin.interaction === getLiveInteractionGeneration()')
|
||||
expect(slice).toContain('dismissKeyboardAfterAgentSend(')
|
||||
// Explicit dismissal replaces RN's blur, which stays off so a shell send
|
||||
// does not drop focus.
|
||||
expect(slice).toContain('blurOnSubmit={false}')
|
||||
})
|
||||
|
||||
it('dismisses the buffered command send only once the write is accepted', () => {
|
||||
const slice = routeSlice('async function handleSend() {', 'async function handleAccessoryKey(')
|
||||
const acceptedAt = slice.indexOf('const accepted = isTerminalSendRpcAccepted(response)')
|
||||
const restoreAt = slice.indexOf('restoreRejectedDraft()', acceptedAt)
|
||||
const dismissAt = slice.indexOf('dismissKeyboardAfterAgentSend(')
|
||||
const responseAt = slice.indexOf('const response = await client.sendRequest(')
|
||||
const catchAt = slice.indexOf('} catch {')
|
||||
expect(dismissAt).toBeGreaterThan(0)
|
||||
expect(responseAt).toBeGreaterThan(0)
|
||||
expect(acceptedAt).toBeGreaterThan(responseAt)
|
||||
expect(restoreAt).toBeGreaterThan(acceptedAt)
|
||||
expect(dismissAt).toBeGreaterThan(responseAt)
|
||||
expect(slice).toContain(
|
||||
'const draftUnchanged =\n accepted && bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend)'
|
||||
)
|
||||
expect(slice).toContain('dismissKeyboardAfterAgentSend(sendOrigin, accepted && draftUnchanged)')
|
||||
expect(catchAt).toBeGreaterThan(0)
|
||||
// Both resolved rejections and transport failures restore the raw draft.
|
||||
expect(dismissAt).toBeLessThan(catchAt)
|
||||
expect(slice.slice(catchAt)).not.toContain('dismissKeyboardAfterAgentSend(')
|
||||
expect(slice.slice(catchAt)).toContain('restoreRejectedDraft()')
|
||||
})
|
||||
|
||||
it('keeps buffered Return focused until accepted-agent dismissal runs', () => {
|
||||
const slice = routeSlice('ref={commandInputRef}', 'onSubmitEditing={() => void handleSend()}')
|
||||
expect(slice).toContain('blurOnSubmit={false}')
|
||||
})
|
||||
|
||||
it('restores a rejected buffered draft by origin without generation fencing', () => {
|
||||
const sendSlice = routeSlice(
|
||||
'async function handleSend() {',
|
||||
'async function handleAccessoryKey('
|
||||
)
|
||||
const originAt = sendSlice.indexOf('handle: activeHandle')
|
||||
const requestAt = sendSlice.indexOf('await client.sendRequest(')
|
||||
const restoreSlice = routeSlice(
|
||||
'const bufferedDraftSend = bufferedTerminalDraftState.beginBufferedTerminalDraftSend(',
|
||||
'bufferedTerminalDraftState.restoreRejectedDraft(bufferedDraftSend)'
|
||||
)
|
||||
expect(originAt).toBeGreaterThan(0)
|
||||
expect(originAt).toBeLessThan(requestAt)
|
||||
expect(restoreSlice).toContain('activeHandle,\n draft')
|
||||
expect(bufferedDraftHookSource).not.toContain('getSendCompletionGeneration()')
|
||||
expect(bufferedDraftHookSource).toContain(
|
||||
'restoreRejectedBufferedTerminalDraft(current, send.token.handle, send.draft)'
|
||||
)
|
||||
expect(sendSlice.match(/restoreRejectedDraft\(\)/g)).toHaveLength(2)
|
||||
expect(keyboardDismissalHookSource).toContain(
|
||||
'origin.generation === getSendCompletionGeneration()'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps buffered draft callbacks scoped to terminal surfaces and prunes ended tabs', () => {
|
||||
expect(bufferedDraftHookSource).toContain('const handle = activeHandleRef.current')
|
||||
expect(bufferedDraftHookSource).toContain('invalidateBufferedTerminalDraftRestoration(')
|
||||
expect(bufferedDraftHookSource).toContain(
|
||||
'setDrafts((current) => updateBufferedTerminalDraft(current, handle, value))'
|
||||
)
|
||||
expect(bufferedDraftHookSource).toContain('pruneBufferedTerminalDraftRestorations(')
|
||||
expect(sessionRouteSource).toContain('useRef(bufferedTerminalDraftState.reconcileTerminalTabs)')
|
||||
expect(sessionRouteSource).toContain(
|
||||
'reconcileBufferedDraftsRef.current(currentSessionTabs, nextTabs, {'
|
||||
)
|
||||
const routeResetSlice = routeSlice(
|
||||
'// Why: Expo reuses this screen across worktrees;',
|
||||
'clearDelayedActionTimers()\n }'
|
||||
)
|
||||
expect(routeResetSlice).toContain('bufferedTerminalDraftState.resetDrafts()')
|
||||
expect(routeResetSlice).toContain('bufferedTerminalDraftState.clearPendingRestorations()')
|
||||
})
|
||||
|
||||
it('bounds buffered drafts on the terminal.list sweep, against the retained set', () => {
|
||||
// The drafts record and the pending-restoration map both live as long as the
|
||||
// session screen does; this one call is the only thing that bounds either.
|
||||
const slice = routeSlice(
|
||||
'const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))',
|
||||
'setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))'
|
||||
)
|
||||
expect(slice).toContain('const retainedHandles = resolveRetainedTerminalHandles(pruneContext)')
|
||||
expect(slice).toContain('bufferedTerminalDraftState.pruneDrafts(retainedHandles)')
|
||||
// Not the raw list: terminal.list omits a chat-covered handle while the desktop
|
||||
// graph reloads, so sweeping drafts against it erases text the user still holds.
|
||||
expect(slice).not.toContain('pruneDrafts(liveHandles)')
|
||||
expect(slice.match(/pruneDrafts\(/g)).toHaveLength(1)
|
||||
expect(bufferedDraftHookSource).toContain(
|
||||
'setDrafts((current) => pruneBufferedTerminalDrafts(current, retainedMappedHandles))'
|
||||
)
|
||||
expect(bufferedDraftHookSource).toContain(
|
||||
'pruneBufferedTerminalDraftRestorations(pendingRestorationsRef.current, retainedMappedHandles)'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the accessory shortcut keys alone, Enter included', () => {
|
||||
// Why: the accessory bar sits on top of the keyboard — dismissing would
|
||||
// pull away the very row the user is tapping.
|
||||
const slice = routeSlice(
|
||||
'async function handleAccessoryKey(',
|
||||
'const sendLiveTerminalInput = useCallback('
|
||||
)
|
||||
expect(slice).not.toContain('dismissKeyboardAfterAgentSend')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
shouldDismissKeyboardAfterTerminalSend,
|
||||
type AgentSendKeyboardDismissalTab
|
||||
} from './agent-send-keyboard-dismissal'
|
||||
|
||||
function terminalTab(overrides: Partial<AgentSendKeyboardDismissalTab> = {}) {
|
||||
return { type: 'terminal', title: 'Terminal', ...overrides }
|
||||
}
|
||||
|
||||
describe('shouldDismissKeyboardAfterTerminalSend', () => {
|
||||
it('dismisses for a live agent session', () => {
|
||||
expect(
|
||||
shouldDismissKeyboardAfterTerminalSend(
|
||||
terminalTab({ agentStatus: { agentType: 'claude' } }),
|
||||
true
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('dismisses off launchAgent before the first agent-status update lands', () => {
|
||||
expect(
|
||||
shouldDismissKeyboardAfterTerminalSend(terminalTab({ launchAgent: 'codex' }), true)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the keyboard when an agent send is rejected', () => {
|
||||
expect(
|
||||
shouldDismissKeyboardAfterTerminalSend(
|
||||
terminalTab({ agentStatus: { agentType: 'claude' } }),
|
||||
false
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the keyboard for a plain shell so back-to-back commands stay typeable', () => {
|
||||
expect(shouldDismissKeyboardAfterTerminalSend(terminalTab(), true)).toBe(false)
|
||||
expect(shouldDismissKeyboardAfterTerminalSend(terminalTab({ agentStatus: null }), true)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a blank agent label as no agent', () => {
|
||||
// A truthy-empty agentType would otherwise dismiss on every shell Enter.
|
||||
expect(
|
||||
shouldDismissKeyboardAfterTerminalSend(terminalTab({ agentStatus: { agentType: '' } }), true)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldDismissKeyboardAfterTerminalSend(
|
||||
terminalTab({ agentStatus: { agentType: ' ' } }),
|
||||
true
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldDismissKeyboardAfterTerminalSend(
|
||||
terminalTab({
|
||||
agentStatus: { agentType: null },
|
||||
launchAgent: null
|
||||
}),
|
||||
true
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('falls through to launchAgent only when live status carries no agent', () => {
|
||||
expect(
|
||||
shouldDismissKeyboardAfterTerminalSend(
|
||||
terminalTab({
|
||||
agentStatus: { agentType: null },
|
||||
launchAgent: 'claude'
|
||||
}),
|
||||
true
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('never dismisses for non-terminal tabs or a missing tab', () => {
|
||||
expect(
|
||||
shouldDismissKeyboardAfterTerminalSend(
|
||||
{
|
||||
type: 'markdown',
|
||||
title: 'README.md',
|
||||
agentStatus: { agentType: 'claude' }
|
||||
},
|
||||
true
|
||||
)
|
||||
).toBe(false)
|
||||
expect(shouldDismissKeyboardAfterTerminalSend(null, true)).toBe(false)
|
||||
expect(shouldDismissKeyboardAfterTerminalSend(undefined, true)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not authorize dismissal from unknown status or a display-only title', () => {
|
||||
expect(
|
||||
shouldDismissKeyboardAfterTerminalSend(
|
||||
terminalTab({ agentStatus: { agentType: 'unknown' } }),
|
||||
true
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldDismissKeyboardAfterTerminalSend(terminalTab({ title: '✦ Gemini CLI' }), true)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['zsh', 'bash', 'pwsh'])(
|
||||
'keeps the keyboard when identity-only done status is stale under %s',
|
||||
(title) => {
|
||||
expect(
|
||||
shouldDismissKeyboardAfterTerminalSend(
|
||||
terminalTab({
|
||||
title,
|
||||
agentStatus: { agentType: 'claude', state: 'done' },
|
||||
launchAgent: 'claude'
|
||||
}),
|
||||
true
|
||||
)
|
||||
).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('still dismisses for a completed agent under a non-shell title', () => {
|
||||
expect(
|
||||
shouldDismissKeyboardAfterTerminalSend(
|
||||
terminalTab({
|
||||
title: 'Terminal',
|
||||
agentStatus: { agentType: 'claude', state: 'done' }
|
||||
}),
|
||||
true
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import { isClaudeManagementTitle } from '../../../src/shared/agent-title-core'
|
||||
import { isShellProcess } from '../../../src/shared/shell-process-detection'
|
||||
import { resolveMobileTerminalTabOwnedAgentId } from './mobile-terminal-tab-agent'
|
||||
|
||||
/** Minimal session-tab shape needed to tell an agent session from a plain shell. */
|
||||
export type AgentSendKeyboardDismissalTab = {
|
||||
readonly type: string
|
||||
readonly title: string
|
||||
readonly agentStatus?: {
|
||||
readonly agentType?: AgentStatusEntry['agentType'] | null
|
||||
readonly state?: AgentStatusEntry['state']
|
||||
} | null
|
||||
readonly launchAgent?: TuiAgent | null
|
||||
}
|
||||
|
||||
/** Whether a send from this tab should drop the software keyboard.
|
||||
*
|
||||
* Why: sending to an agent hands the turn over, and the keyboard hides the
|
||||
* reply the user is now waiting on. A plain shell keeps it — commands come in
|
||||
* bursts, and re-opening the keyboard between each one costs more than the
|
||||
* covered rows. `launchAgent` counts before the first agent-status update
|
||||
* lands, so the very first accepted prompt of a session already dismisses. */
|
||||
export function shouldDismissKeyboardAfterTerminalSend(
|
||||
tab: AgentSendKeyboardDismissalTab | null | undefined,
|
||||
accepted: boolean
|
||||
): boolean {
|
||||
if (!accepted || !tab || tab.type !== 'terminal') {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
tab.agentStatus?.state === 'done' &&
|
||||
(isShellProcess(tab.title) || isClaudeManagementTitle(tab.title))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return resolveMobileTerminalTabOwnedAgentId(tab) !== null
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export type MobileNativeChatController = {
|
||||
nativeChatAgent: string | null
|
||||
chatComposerText: string
|
||||
setChatComposerText: Dispatch<SetStateAction<string>>
|
||||
getChatComposerEditGeneration: () => number
|
||||
chatPending: MobileNativeChatPendingMessage[]
|
||||
chatImagePreviewsByMessageId: Record<string, string[]>
|
||||
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export class MobileNativeChatDraftEditGenerations {
|
||||
private composerGeneration = 0
|
||||
private readonly byDraft = new Map<string, number>()
|
||||
|
||||
advance(draftKey: string): void {
|
||||
this.composerGeneration += 1
|
||||
this.byDraft.set(draftKey, this.readDraft(draftKey) + 1)
|
||||
}
|
||||
|
||||
readonly readComposer = (): number => this.composerGeneration
|
||||
|
||||
readDraft(draftKey: string): number {
|
||||
return this.byDraft.get(draftKey) ?? 0
|
||||
}
|
||||
|
||||
isCurrent(draftKey: string, generation: number): boolean {
|
||||
return this.readDraft(draftKey) === generation
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ function sendOrigin(
|
||||
const normalizedText = normalizeReconcileText(text)
|
||||
return {
|
||||
draftKey: 'host\0worktree\0tab',
|
||||
draftEditGeneration: 0,
|
||||
pendingKey: 'host\0worktree\0tab\0session',
|
||||
normalizedText,
|
||||
// Use production's counter so the test cannot mirror its normalization drift.
|
||||
|
||||
@@ -16,6 +16,7 @@ export type MobileNativeChatPendingMessage = {
|
||||
|
||||
export type MobileNativeChatSendOrigin = {
|
||||
draftKey: string
|
||||
draftEditGeneration: number
|
||||
pendingKey: string | null
|
||||
normalizedText: string
|
||||
baselineOccurrences: number
|
||||
|
||||
@@ -13,6 +13,7 @@ describe('mobile session last-tab close', () => {
|
||||
const block = sessionRouteSource.slice(start, end)
|
||||
|
||||
expect(block).toContain('} else if (active) {')
|
||||
expect(block).toContain('retainMissingSurfaces: result.tabs.length === 0')
|
||||
})
|
||||
|
||||
it('clears stale active identity when closing leaves no tabs', () => {
|
||||
@@ -25,5 +26,8 @@ describe('mobile session last-tab close', () => {
|
||||
)
|
||||
expect(block).toContain('activeSessionTabIdRef.current = null')
|
||||
expect(block).toContain('activeHandleRef.current = null')
|
||||
expect(block).toContain(
|
||||
'reconcileBufferedDraftsRef.current(sessionTabsRef.current, remainingTabs)'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
import {
|
||||
getMobileSessionTabTitle,
|
||||
resolveMobileTerminalTabAgentId
|
||||
resolveMobileTerminalTabAgentId,
|
||||
resolveMobileTerminalTabOwnedAgentId
|
||||
} from './mobile-terminal-tab-agent'
|
||||
|
||||
function agentStatus(agentType: string | undefined): AgentStatusEntry {
|
||||
@@ -75,6 +76,15 @@ describe('resolveMobileTerminalTabAgentId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveMobileTerminalTabOwnedAgentId', () => {
|
||||
it('excludes display-only terminal titles from behavioral authority', () => {
|
||||
expect(resolveMobileTerminalTabOwnedAgentId(terminalTab('✦ Gemini CLI'))).toBeNull()
|
||||
expect(
|
||||
resolveMobileTerminalTabOwnedAgentId(terminalTab('Terminal', { launchAgent: 'gemini' }))
|
||||
).toBe('gemini')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMobileSessionTabTitle', () => {
|
||||
it('strips leading agent decorations when an icon is shown', () => {
|
||||
expect(getMobileSessionTabTitle(terminalTab('✦ Gemini CLI'))).toBe('Gemini CLI')
|
||||
|
||||
@@ -15,11 +15,16 @@ import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
* Returns null when no agent is identified (plain shell / unknown), so the tab
|
||||
* keeps its text-only label.
|
||||
*/
|
||||
export function resolveMobileTerminalTabAgentId(tab: {
|
||||
type MobileTerminalTabAgentIdentity = {
|
||||
title: string
|
||||
agentStatus?: AgentStatusEntry | null
|
||||
launchAgent?: TuiAgent
|
||||
}): string | null {
|
||||
agentStatus?: { agentType?: AgentStatusEntry['agentType'] | null } | null
|
||||
launchAgent?: TuiAgent | null
|
||||
}
|
||||
|
||||
/** Agent identity Orca owns, excluding the display-only title fallback. */
|
||||
export function resolveMobileTerminalTabOwnedAgentId(
|
||||
tab: MobileTerminalTabAgentIdentity
|
||||
): string | null {
|
||||
const hookAgentType = tab.agentStatus?.agentType?.trim()
|
||||
if (hookAgentType && hookAgentType !== 'unknown') {
|
||||
return hookAgentType
|
||||
@@ -27,6 +32,16 @@ export function resolveMobileTerminalTabAgentId(tab: {
|
||||
if (tab.launchAgent) {
|
||||
return tab.launchAgent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveMobileTerminalTabAgentId(
|
||||
tab: MobileTerminalTabAgentIdentity
|
||||
): string | null {
|
||||
const ownedAgent = resolveMobileTerminalTabOwnedAgentId(tab)
|
||||
if (ownedAgent) {
|
||||
return ownedAgent
|
||||
}
|
||||
return resolveExplicitTerminalTitleAgentType(tab.title)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
shouldDismissKeyboardAfterTerminalSend,
|
||||
type AgentSendKeyboardDismissalTab
|
||||
} from './agent-send-keyboard-dismissal'
|
||||
|
||||
type AgentSendOrigin = {
|
||||
readonly tab: AgentSendKeyboardDismissalTab | null
|
||||
readonly generation: number
|
||||
}
|
||||
|
||||
export function useAgentSendKeyboardDismissal(
|
||||
dismissSoftwareKeyboard: () => void,
|
||||
getSendCompletionGeneration: () => number
|
||||
) {
|
||||
return useCallback(
|
||||
(origin: AgentSendOrigin, accepted: boolean): void => {
|
||||
if (
|
||||
origin.generation === getSendCompletionGeneration() &&
|
||||
shouldDismissKeyboardAfterTerminalSend(origin.tab, accepted)
|
||||
) {
|
||||
dismissSoftwareKeyboard()
|
||||
}
|
||||
},
|
||||
[dismissSoftwareKeyboard, getSendCompletionGeneration]
|
||||
)
|
||||
}
|
||||
@@ -93,6 +93,7 @@ const sendWithOutcome = vi.mocked(sendMobileNativeChatMessageWithOutcome)
|
||||
|
||||
const ORIGIN = {
|
||||
draftKey: 'h\0w\0tab-1',
|
||||
draftEditGeneration: 0,
|
||||
pendingKey: 'h\0w\0tab-1\0session-1',
|
||||
normalizedText: 'look',
|
||||
baselineOccurrences: 0,
|
||||
|
||||
@@ -90,6 +90,7 @@ export function useMobileNativeChatController(args: {
|
||||
const {
|
||||
composerText: chatComposerText,
|
||||
setComposerText: setChatComposerText,
|
||||
getComposerEditGeneration: getChatComposerEditGeneration,
|
||||
pending: chatPending,
|
||||
imagePreviewsByMessageId: chatImagePreviewsByMessageId,
|
||||
captureSendOrigin,
|
||||
@@ -262,6 +263,7 @@ export function useMobileNativeChatController(args: {
|
||||
nativeChatAgent: activeChatResolution?.agent ?? null,
|
||||
chatComposerText,
|
||||
setChatComposerText,
|
||||
getChatComposerEditGeneration,
|
||||
chatPending,
|
||||
chatImagePreviewsByMessageId,
|
||||
nativeChatSession,
|
||||
|
||||
@@ -115,6 +115,20 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
expect(state?.composerText).toBe('')
|
||||
})
|
||||
|
||||
it('tracks every composer mutation with a stable route-owned generation', async () => {
|
||||
await mount('a')
|
||||
const getter = state!.getComposerEditGeneration
|
||||
const initialGeneration = getter()
|
||||
|
||||
act(() => state?.setComposerText('typed'))
|
||||
expect(getter()).toBe(initialGeneration + 1)
|
||||
|
||||
await switchTo('b')
|
||||
expect(state?.getComposerEditGeneration).toBe(getter)
|
||||
act(() => state?.setComposerText((current) => `${current} dictated`))
|
||||
expect(getter()).toBe(initialGeneration + 2)
|
||||
})
|
||||
|
||||
it('restores the text on a definite rejection', async () => {
|
||||
await mount('a')
|
||||
act(() => state?.setComposerText('ping'))
|
||||
@@ -146,6 +160,26 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
expect(state?.composerText).toBe('newer edit')
|
||||
})
|
||||
|
||||
it('preserves an intentional clear after a newer edit while a rejection is pending', async () => {
|
||||
await mount('a')
|
||||
act(() => state?.setComposerText('ping'))
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.clearDraftForSend(origin, 'ping')
|
||||
}
|
||||
})
|
||||
act(() => state?.setComposerText('newer edit'))
|
||||
act(() => state?.setComposerText(''))
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.restoreRejectedDraft(origin, 'ping')
|
||||
}
|
||||
})
|
||||
|
||||
expect(state?.composerText).toBe('')
|
||||
})
|
||||
|
||||
it('restores a rejected send onto its originating tab only', async () => {
|
||||
await mount('a')
|
||||
act(() => state?.setComposerText('from a'))
|
||||
@@ -157,12 +191,13 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
})
|
||||
|
||||
await switchTo('b')
|
||||
act(() => state?.setComposerText('from b'))
|
||||
act(() => {
|
||||
if (originA) {
|
||||
state?.restoreRejectedDraft(originA, 'from a')
|
||||
}
|
||||
})
|
||||
expect(state?.composerText).toBe('')
|
||||
expect(state?.composerText).toBe('from b')
|
||||
|
||||
await switchTo('a')
|
||||
expect(state?.composerText).toBe('from a')
|
||||
@@ -443,6 +478,20 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
expect(state?.composerText).toBe('new edit')
|
||||
})
|
||||
|
||||
it('does not erase a whitespace-only newer edit when an older send clears', async () => {
|
||||
await mount('a')
|
||||
act(() => state?.setComposerText('/clear'))
|
||||
const origin = state?.captureSendOrigin('/clear')
|
||||
act(() => state?.setComposerText(' /clear'))
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.clearDraftForSend(origin, '/clear')
|
||||
}
|
||||
})
|
||||
|
||||
expect(state?.composerText).toBe(' /clear')
|
||||
})
|
||||
|
||||
it('stays quiet when an unconfirmed send lands in the transcript', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
|
||||
import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
|
||||
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
|
||||
import { MobileNativeChatDraftEditGenerations } from './mobile-native-chat-draft-edit-generations'
|
||||
|
||||
export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin }
|
||||
|
||||
@@ -54,6 +55,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
}): {
|
||||
composerText: string
|
||||
setComposerText: Dispatch<SetStateAction<string>>
|
||||
getComposerEditGeneration: () => number
|
||||
pending: MobileNativeChatPendingMessage[]
|
||||
/** Phone-local previews rebound to the transcript message that replaced the
|
||||
* optimistic echo, keyed by authoritative message id. */
|
||||
@@ -100,6 +102,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
Record<string, Record<string, string[]>>
|
||||
>({})
|
||||
const pendingCounterRef = useRef(0)
|
||||
const draftEditGenerationsRef = useRef(new MobileNativeChatDraftEditGenerations())
|
||||
const messagesRef = useRef(messages)
|
||||
messagesRef.current = messages
|
||||
const activeDraftKeyRef = useRef(draftKey)
|
||||
@@ -123,6 +126,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
if (!draftKey) {
|
||||
return
|
||||
}
|
||||
draftEditGenerationsRef.current.advance(draftKey)
|
||||
setDrafts((previous) => {
|
||||
const current = previous[draftKey] ?? ''
|
||||
const next = typeof value === 'function' ? value(current) : value
|
||||
@@ -131,7 +135,6 @@ export function useMobileNativeChatDrafts(args: {
|
||||
},
|
||||
[draftKey]
|
||||
)
|
||||
|
||||
const captureSendOrigin = useCallback(
|
||||
(text: string) => {
|
||||
if (!draftKey) {
|
||||
@@ -140,6 +143,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
const normalizedText = normalizeReconcileText(text)
|
||||
return {
|
||||
draftKey,
|
||||
draftEditGeneration: draftEditGenerationsRef.current.readDraft(draftKey),
|
||||
pendingKey,
|
||||
normalizedText,
|
||||
baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText),
|
||||
@@ -158,7 +162,8 @@ export function useMobileNativeChatDrafts(args: {
|
||||
// send". Clear at send time; a definite rejection restores the text below.
|
||||
const clearDraftForSend = useCallback((origin: MobileNativeChatSendOrigin, text: string) => {
|
||||
setDrafts((previous) =>
|
||||
(previous[origin.draftKey] ?? '').trim() === text.trim()
|
||||
draftEditGenerationsRef.current.isCurrent(origin.draftKey, origin.draftEditGeneration) &&
|
||||
(previous[origin.draftKey] ?? '') === text
|
||||
? { ...previous, [origin.draftKey]: '' }
|
||||
: previous
|
||||
)
|
||||
@@ -167,7 +172,10 @@ export function useMobileNativeChatDrafts(args: {
|
||||
const restoreRejectedDraft = useCallback((origin: MobileNativeChatSendOrigin, text: string) => {
|
||||
// Why: never clobber text the user typed while the rejection was in flight.
|
||||
setDrafts((previous) =>
|
||||
(previous[origin.draftKey] ?? '') === '' ? { ...previous, [origin.draftKey]: text } : previous
|
||||
draftEditGenerationsRef.current.isCurrent(origin.draftKey, origin.draftEditGeneration) &&
|
||||
(previous[origin.draftKey] ?? '') === ''
|
||||
? { ...previous, [origin.draftKey]: text }
|
||||
: previous
|
||||
)
|
||||
}, [])
|
||||
|
||||
@@ -269,9 +277,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
return
|
||||
}
|
||||
const movedIds = new Set(waitingForSession.map((item) => item.id))
|
||||
setPendingBySession((previous) =>
|
||||
mergeWaitingSessionPending(previous, pendingKey, waitingForSession)
|
||||
)
|
||||
setPendingBySession((state) => mergeWaitingSessionPending(state, pendingKey, waitingForSession))
|
||||
setPendingWaitingForSession((previous) =>
|
||||
removeWaitingSessionPending(previous, draftKey, movedIds)
|
||||
)
|
||||
@@ -330,6 +336,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
return {
|
||||
composerText: draftKey ? (drafts[draftKey] ?? '') : '',
|
||||
setComposerText,
|
||||
getComposerEditGeneration: draftEditGenerationsRef.current.readComposer,
|
||||
pending,
|
||||
imagePreviewsByMessageId: pendingKey
|
||||
? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createElement, Suspense } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useMobileSendCompletionGeneration } from './use-mobile-send-completion-generation'
|
||||
|
||||
let focusCleanup: (() => void) | undefined
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
useFocusEffect: (effect: () => void | (() => void)) => {
|
||||
focusCleanup = effect() ?? undefined
|
||||
}
|
||||
}))
|
||||
|
||||
describe('mobile send completion generation', () => {
|
||||
it('invalidates completions on a surface change and retained-route blur', () => {
|
||||
const onBlur = vi.fn()
|
||||
let getGeneration: (() => number) | null = null
|
||||
let renderer: ReactTestRenderer
|
||||
|
||||
function Harness({ surfaceKey }: { surfaceKey: string }): null {
|
||||
getGeneration = useMobileSendCompletionGeneration({ onBlur, surfaceKey })
|
||||
return null
|
||||
}
|
||||
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { surfaceKey: 'tab-a' }))
|
||||
})
|
||||
const initialGeneration = getGeneration!()
|
||||
act(() => {
|
||||
renderer.update(createElement(Harness, { surfaceKey: 'tab-b' }))
|
||||
})
|
||||
expect(getGeneration!()).toBeGreaterThan(initialGeneration)
|
||||
|
||||
const surfaceGeneration = getGeneration!()
|
||||
act(() => focusCleanup?.())
|
||||
expect(getGeneration!()).toBeGreaterThan(surfaceGeneration)
|
||||
expect(onBlur).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('does not invalidate the committed surface during a suspended render', () => {
|
||||
const never = new Promise<never>(() => undefined)
|
||||
let getGeneration: (() => number) | null = null
|
||||
let renderer: ReactTestRenderer
|
||||
|
||||
function Harness({ surfaceKey, suspend }: { surfaceKey: string; suspend: boolean }): null {
|
||||
getGeneration = useMobileSendCompletionGeneration({ onBlur: vi.fn(), surfaceKey })
|
||||
if (suspend) {
|
||||
throw never
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(
|
||||
Suspense,
|
||||
{ fallback: null },
|
||||
createElement(Harness, { surfaceKey: 'tab-a', suspend: false })
|
||||
)
|
||||
)
|
||||
})
|
||||
const committedGeneration = getGeneration!()
|
||||
act(() => {
|
||||
renderer.update(
|
||||
createElement(
|
||||
Suspense,
|
||||
{ fallback: null },
|
||||
createElement(Harness, { surfaceKey: 'tab-b', suspend: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(getGeneration!()).toBe(committedGeneration)
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react'
|
||||
import { useFocusEffect } from 'expo-router'
|
||||
|
||||
type Options = {
|
||||
readonly onBlur: () => void
|
||||
readonly surfaceKey: string
|
||||
}
|
||||
|
||||
/** Fences async send completions after a surface change or retained-route blur. */
|
||||
export function useMobileSendCompletionGeneration({ onBlur, surfaceKey }: Options): () => number {
|
||||
const generationRef = useRef(0)
|
||||
const surfaceRef = useRef(surfaceKey)
|
||||
useLayoutEffect(() => {
|
||||
if (surfaceRef.current !== surfaceKey) {
|
||||
surfaceRef.current = surfaceKey
|
||||
generationRef.current += 1
|
||||
}
|
||||
}, [surfaceKey])
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
return () => {
|
||||
generationRef.current += 1
|
||||
onBlur()
|
||||
}
|
||||
}, [onBlur])
|
||||
)
|
||||
return useCallback(() => generationRef.current, [])
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
beginBufferedTerminalDraftRestoration,
|
||||
invalidateBufferedTerminalDraftRestoration,
|
||||
pruneBufferedTerminalDrafts,
|
||||
pruneBufferedTerminalDraftRestorations,
|
||||
restoreRejectedBufferedTerminalDraft,
|
||||
settleBufferedTerminalDraftRestoration,
|
||||
updateBufferedTerminalDraft
|
||||
} from './buffered-terminal-draft-restoration'
|
||||
|
||||
describe('buffered terminal draft restoration', () => {
|
||||
it('restores the exact rejected draft when the composer is still empty', () => {
|
||||
const pendingRestorations = new Map()
|
||||
const token = beginBufferedTerminalDraftRestoration(pendingRestorations, 'terminal')
|
||||
expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'terminal', token)).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
restoreRejectedBufferedTerminalDraft({ terminal: '' }, 'terminal', ' echo a–b ')
|
||||
).toEqual({ terminal: ' echo a–b ' })
|
||||
})
|
||||
|
||||
it('preserves newer text composed while the rejected send was in flight', () => {
|
||||
const drafts = { terminal: 'next command' }
|
||||
const pendingRestorations = new Map()
|
||||
const token = beginBufferedTerminalDraftRestoration(pendingRestorations, 'terminal')
|
||||
invalidateBufferedTerminalDraftRestoration(pendingRestorations, 'terminal')
|
||||
expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'terminal', token)).toBe(
|
||||
false
|
||||
)
|
||||
expect(restoreRejectedBufferedTerminalDraft(drafts, 'terminal', 'rejected command')).toBe(
|
||||
drafts
|
||||
)
|
||||
})
|
||||
|
||||
it('clears restoration metadata when an accepted send settles', () => {
|
||||
const pendingRestorations = new Map()
|
||||
const token = beginBufferedTerminalDraftRestoration(pendingRestorations, 'terminal')
|
||||
|
||||
expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'terminal', token)).toBe(
|
||||
true
|
||||
)
|
||||
expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'terminal', token)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a later intentional clear while the rejected send was in flight', () => {
|
||||
const terminal = 'terminal'
|
||||
const rejectedDraft = 'rejected command'
|
||||
const pendingRestorations = new Map()
|
||||
const token = beginBufferedTerminalDraftRestoration(pendingRestorations, terminal)
|
||||
let drafts = { [terminal]: rejectedDraft }
|
||||
drafts = updateBufferedTerminalDraft(drafts, terminal, '')
|
||||
invalidateBufferedTerminalDraftRestoration(pendingRestorations, terminal)
|
||||
drafts = updateBufferedTerminalDraft(drafts, terminal, 'new command')
|
||||
drafts = updateBufferedTerminalDraft(drafts, terminal, '')
|
||||
|
||||
if (settleBufferedTerminalDraftRestoration(pendingRestorations, terminal, token)) {
|
||||
drafts = restoreRejectedBufferedTerminalDraft(drafts, terminal, rejectedDraft)
|
||||
}
|
||||
expect(drafts).toEqual({ [terminal]: '' })
|
||||
})
|
||||
|
||||
it('restores a rejection to terminal A after switching to terminal B', () => {
|
||||
const terminalA = 'terminal-a'
|
||||
const terminalB = 'terminal-b'
|
||||
const rejectedDraft = ' echo exact–text '
|
||||
let activeHandle = terminalA
|
||||
const sendOrigin = activeHandle
|
||||
const pendingRestorations = new Map()
|
||||
const token = beginBufferedTerminalDraftRestoration(pendingRestorations, sendOrigin)
|
||||
let drafts = { [terminalA]: rejectedDraft, [terminalB]: 'new command for B' }
|
||||
drafts = updateBufferedTerminalDraft(drafts, sendOrigin, '')
|
||||
activeHandle = terminalB
|
||||
|
||||
if (settleBufferedTerminalDraftRestoration(pendingRestorations, sendOrigin, token)) {
|
||||
drafts = restoreRejectedBufferedTerminalDraft(drafts, sendOrigin, rejectedDraft)
|
||||
}
|
||||
|
||||
expect(activeHandle).toBe(terminalB)
|
||||
expect(drafts).toEqual({
|
||||
[terminalA]: rejectedDraft,
|
||||
[terminalB]: 'new command for B'
|
||||
})
|
||||
})
|
||||
|
||||
it('prunes drafts when their terminal lifetime ends', () => {
|
||||
const liveDrafts = { live: 'keep' }
|
||||
expect(pruneBufferedTerminalDrafts(liveDrafts, new Set(['live']))).toBe(liveDrafts)
|
||||
expect(
|
||||
pruneBufferedTerminalDrafts({ live: 'keep', closed: 'drop' }, new Set(['live']))
|
||||
).toEqual({ live: 'keep' })
|
||||
|
||||
const pendingRestorations = new Map()
|
||||
const liveToken = beginBufferedTerminalDraftRestoration(pendingRestorations, 'live')
|
||||
const closedToken = beginBufferedTerminalDraftRestoration(pendingRestorations, 'closed')
|
||||
pruneBufferedTerminalDraftRestorations(pendingRestorations, new Set(['live']))
|
||||
expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'live', liveToken)).toBe(
|
||||
true
|
||||
)
|
||||
expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'closed', closedToken)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
export type BufferedTerminalDraftValue = string | ((current: string) => string)
|
||||
export type BufferedTerminalDraftRestorationToken = { handle: string }
|
||||
|
||||
export function updateBufferedTerminalDraft(
|
||||
currentDrafts: Record<string, string>,
|
||||
handle: string | null,
|
||||
value: BufferedTerminalDraftValue
|
||||
): Record<string, string> {
|
||||
if (!handle) {
|
||||
return currentDrafts
|
||||
}
|
||||
const current = currentDrafts[handle] ?? ''
|
||||
const next = typeof value === 'function' ? value(current) : value
|
||||
return next === current ? currentDrafts : { ...currentDrafts, [handle]: next }
|
||||
}
|
||||
|
||||
export function beginBufferedTerminalDraftRestoration(
|
||||
pendingRestorations: Map<string, BufferedTerminalDraftRestorationToken>,
|
||||
handle: string
|
||||
): BufferedTerminalDraftRestorationToken {
|
||||
const token = { handle }
|
||||
pendingRestorations.set(handle, token)
|
||||
return token
|
||||
}
|
||||
|
||||
export function invalidateBufferedTerminalDraftRestoration(
|
||||
pendingRestorations: Map<string, BufferedTerminalDraftRestorationToken>,
|
||||
handle: string
|
||||
): void {
|
||||
pendingRestorations.delete(handle)
|
||||
}
|
||||
|
||||
export function settleBufferedTerminalDraftRestoration(
|
||||
pendingRestorations: Map<string, BufferedTerminalDraftRestorationToken>,
|
||||
handle: string,
|
||||
token: BufferedTerminalDraftRestorationToken
|
||||
): boolean {
|
||||
const currentHandle = pendingRestorations.get(handle) === token ? handle : token.handle
|
||||
if (pendingRestorations.get(currentHandle) !== token) {
|
||||
return false
|
||||
}
|
||||
pendingRestorations.delete(currentHandle)
|
||||
return true
|
||||
}
|
||||
|
||||
export function remapBufferedTerminalDraftRestoration(
|
||||
pendingRestorations: Map<string, BufferedTerminalDraftRestorationToken>,
|
||||
previousHandle: string,
|
||||
nextHandle: string
|
||||
): void {
|
||||
const token = pendingRestorations.get(previousHandle)
|
||||
if (!token || pendingRestorations.has(nextHandle)) {
|
||||
pendingRestorations.delete(previousHandle)
|
||||
return
|
||||
}
|
||||
pendingRestorations.delete(previousHandle)
|
||||
token.handle = nextHandle
|
||||
pendingRestorations.set(nextHandle, token)
|
||||
}
|
||||
|
||||
export function remapBufferedTerminalDraft(
|
||||
currentDrafts: Record<string, string>,
|
||||
previousHandle: string,
|
||||
nextHandle: string
|
||||
): Record<string, string> {
|
||||
if (previousHandle === nextHandle || !Object.hasOwn(currentDrafts, previousHandle)) {
|
||||
return currentDrafts
|
||||
}
|
||||
const next = { ...currentDrafts }
|
||||
if (!Object.hasOwn(currentDrafts, nextHandle)) {
|
||||
next[nextHandle] = currentDrafts[previousHandle] ?? ''
|
||||
}
|
||||
delete next[previousHandle]
|
||||
return next
|
||||
}
|
||||
|
||||
/** Restore a rejected send without overwriting text composed while its RPC was in flight. */
|
||||
export function restoreRejectedBufferedTerminalDraft(
|
||||
currentDrafts: Record<string, string>,
|
||||
originHandle: string,
|
||||
rejectedDraft: string
|
||||
): Record<string, string> {
|
||||
if ((currentDrafts[originHandle] ?? '').length > 0) {
|
||||
return currentDrafts
|
||||
}
|
||||
return updateBufferedTerminalDraft(currentDrafts, originHandle, rejectedDraft)
|
||||
}
|
||||
|
||||
export function pruneBufferedTerminalDrafts(
|
||||
currentDrafts: Record<string, string>,
|
||||
retainedHandles: ReadonlySet<string>
|
||||
): Record<string, string> {
|
||||
let next = currentDrafts
|
||||
for (const handle of Object.keys(currentDrafts)) {
|
||||
if (retainedHandles.has(handle)) {
|
||||
continue
|
||||
}
|
||||
if (next === currentDrafts) {
|
||||
next = { ...currentDrafts }
|
||||
}
|
||||
delete next[handle]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function pruneBufferedTerminalDraftRestorations(
|
||||
pendingRestorations: Map<string, BufferedTerminalDraftRestorationToken>,
|
||||
retainedHandles: ReadonlySet<string>
|
||||
): void {
|
||||
for (const handle of pendingRestorations.keys()) {
|
||||
if (!retainedHandles.has(handle)) {
|
||||
pendingRestorations.delete(handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,13 @@ const sessionRouteSource = readFileSync(
|
||||
// apply dash normalization only on the send/mirror path. See stablyai/orca#7925.
|
||||
describe('terminal iOS dictation write-back', () => {
|
||||
it('does not write normalized text back into the buffered command input value', () => {
|
||||
expect(sessionRouteSource).toContain('onChangeText={setInput}')
|
||||
expect(sessionRouteSource).toContain('onChangeText={bufferedTerminalDraftState.setInput}')
|
||||
expect(sessionRouteSource).not.toContain(
|
||||
'setInput((previousText) => normalizeTerminalTextInput'
|
||||
)
|
||||
})
|
||||
|
||||
it('still normalizes the buffered command text at send time', () => {
|
||||
expect(sessionRouteSource).toContain('normalizeTerminalTextInput(input)')
|
||||
expect(sessionRouteSource).toContain('normalizeTerminalTextInput(draft)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,10 @@ const liveInputFocusSource = readFileSync(
|
||||
new URL('./use-terminal-live-input-focus.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const sendCompletionGenerationSource = readFileSync(
|
||||
new URL('../session/use-mobile-send-completion-generation.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function liveInputBarBlock(): string {
|
||||
const start = sessionRouteSource.indexOf('{liveInputEnabled ? (')
|
||||
@@ -41,7 +45,10 @@ describe('terminal live input affordance', () => {
|
||||
expect(block).toContain('showSoftInputOnFocus')
|
||||
expect(block).toContain('liveInputText={liveInputCapture}')
|
||||
expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({')
|
||||
expect(sessionRouteSource).toContain('return resetLiveInputFocus')
|
||||
expect(sessionRouteSource).toContain('useMobileSendCompletionGeneration({')
|
||||
expect(sessionRouteSource).toContain('onBlur: resetLiveInputFocus')
|
||||
expect(sendCompletionGenerationSource).toContain('return () => {')
|
||||
expect(sendCompletionGenerationSource).toContain('onBlur()')
|
||||
expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current')
|
||||
expect(liveInputFocusSource).toContain('lifecycleIdentity,')
|
||||
expect(liveInputFocusSource).toContain('resetLiveInputFocus')
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { createElement, useRef } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveRetainedTerminalHandles } from '../session/mobile-terminal-prune-decision'
|
||||
import { useBufferedTerminalDrafts } from './use-buffered-terminal-drafts'
|
||||
|
||||
type BufferedDraftHook = ReturnType<typeof useBufferedTerminalDrafts>
|
||||
|
||||
let currentHook: BufferedDraftHook | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let probeRenderCount = 0
|
||||
|
||||
function Probe({ activeHandle }: { readonly activeHandle: string | null }) {
|
||||
probeRenderCount += 1
|
||||
const activeHandleRef = useRef(activeHandle)
|
||||
activeHandleRef.current = activeHandle
|
||||
currentHook = useBufferedTerminalDrafts({ activeHandle, activeHandleRef })
|
||||
return null
|
||||
}
|
||||
|
||||
function hook(): BufferedDraftHook {
|
||||
if (!currentHook) {
|
||||
throw new Error('Hook probe is not mounted')
|
||||
}
|
||||
return currentHook
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
currentHook = null
|
||||
probeRenderCount = 0
|
||||
renderer = null
|
||||
})
|
||||
|
||||
describe('useBufferedTerminalDrafts', () => {
|
||||
it('does not re-render for an unchanged terminal reconciliation', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe, { activeHandle: 'terminal' }))
|
||||
})
|
||||
const initialRenderCount = probeRenderCount
|
||||
act(() => {
|
||||
hook().reconcileTerminalTabs(
|
||||
[{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }],
|
||||
[{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }]
|
||||
)
|
||||
})
|
||||
|
||||
expect(probeRenderCount).toBe(initialRenderCount)
|
||||
})
|
||||
|
||||
it('does not re-render when terminal-list pruning retains every mapped draft', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe, { activeHandle: 'terminal' }))
|
||||
})
|
||||
act(() => hook().setInput('draft'))
|
||||
act(() => {
|
||||
hook().reconcileTerminalTabs(
|
||||
[{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }],
|
||||
[{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }]
|
||||
)
|
||||
})
|
||||
const renderCountBeforePrune = probeRenderCount
|
||||
|
||||
act(() => hook().pruneDrafts(new Set()))
|
||||
|
||||
expect(probeRenderCount).toBe(renderCountBeforePrune)
|
||||
})
|
||||
|
||||
it('carries an unsent draft through a pending-handle remint', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe, { activeHandle: 'terminal-old' }))
|
||||
})
|
||||
act(() => hook().setInput('keep across reload'))
|
||||
act(() => {
|
||||
hook().reconcileTerminalTabs(
|
||||
[{ id: 'tab-old', leafId: 'leaf-1', terminal: 'terminal-old' }],
|
||||
[{ id: 'tab-old', leafId: 'leaf-1', terminal: null }]
|
||||
)
|
||||
renderer?.update(createElement(Probe, { activeHandle: null }))
|
||||
})
|
||||
act(() => {
|
||||
hook().reconcileTerminalTabs(
|
||||
[{ id: 'tab-old', leafId: 'leaf-1', terminal: null }],
|
||||
[{ id: 'tab-reminted', leafId: 'leaf-1', terminal: 'terminal-new' }]
|
||||
)
|
||||
renderer?.update(createElement(Probe, { activeHandle: 'terminal-new' }))
|
||||
})
|
||||
|
||||
expect(hook().input).toBe('keep across reload')
|
||||
})
|
||||
|
||||
it('carries an unsent draft through a transient empty snapshot and handle remint', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe, { activeHandle: 'terminal-old' }))
|
||||
})
|
||||
act(() => hook().setInput('keep through empty snapshot'))
|
||||
act(() => {
|
||||
hook().reconcileTerminalTabs(
|
||||
[{ id: 'tab-old', leafId: 'leaf-1', terminal: 'terminal-old' }],
|
||||
[],
|
||||
{ retainMissingSurfaces: true }
|
||||
)
|
||||
renderer?.update(createElement(Probe, { activeHandle: null }))
|
||||
})
|
||||
act(() => {
|
||||
hook().reconcileTerminalTabs(
|
||||
[],
|
||||
[{ id: 'tab-reminted', leafId: 'leaf-1', terminal: 'terminal-new' }]
|
||||
)
|
||||
renderer?.update(createElement(Probe, { activeHandle: 'terminal-new' }))
|
||||
})
|
||||
|
||||
expect(hook().input).toBe('keep through empty snapshot')
|
||||
})
|
||||
|
||||
it('restores a rejected send to its reminted terminal surface', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe, { activeHandle: 'terminal-old' }))
|
||||
})
|
||||
act(() => hook().setInput('rejected command'))
|
||||
let send: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
|
||||
act(() => {
|
||||
send = hook().beginBufferedTerminalDraftSend('terminal-old', hook().input)
|
||||
hook().reconcileTerminalTabs(
|
||||
[{ id: 'tab-old', leafId: 'leaf-1', terminal: 'terminal-old' }],
|
||||
[{ id: 'tab-reminted', leafId: 'leaf-1', terminal: 'terminal-new' }]
|
||||
)
|
||||
renderer?.update(createElement(Probe, { activeHandle: 'terminal-new' }))
|
||||
})
|
||||
act(() => hook().restoreRejectedDraft(send))
|
||||
|
||||
expect(hook().input).toBe('rejected command')
|
||||
})
|
||||
|
||||
it('restores a rejected send after a transient empty snapshot remints its surface', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe, { activeHandle: 'terminal-old' }))
|
||||
})
|
||||
act(() => hook().setInput('rejected command'))
|
||||
let send: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
|
||||
act(() => {
|
||||
send = hook().beginBufferedTerminalDraftSend('terminal-old', hook().input)
|
||||
hook().reconcileTerminalTabs(
|
||||
[{ id: 'tab-old', leafId: 'leaf-1', terminal: 'terminal-old' }],
|
||||
[],
|
||||
{ retainMissingSurfaces: true }
|
||||
)
|
||||
hook().reconcileTerminalTabs(
|
||||
[],
|
||||
[{ id: 'tab-reminted', leafId: 'leaf-1', terminal: 'terminal-new' }]
|
||||
)
|
||||
renderer?.update(createElement(Probe, { activeHandle: 'terminal-new' }))
|
||||
})
|
||||
act(() => hook().restoreRejectedDraft(send))
|
||||
|
||||
expect(hook().input).toBe('rejected command')
|
||||
})
|
||||
|
||||
it('keeps mapped drafts during terminal-list gaps but prunes them after confirmed close', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe, { activeHandle: 'terminal' }))
|
||||
})
|
||||
act(() => hook().setInput('bounded draft'))
|
||||
act(() => {
|
||||
hook().reconcileTerminalTabs(
|
||||
[{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }],
|
||||
[{ id: 'tab-1', leafId: 'leaf-1', terminal: null }]
|
||||
)
|
||||
hook().pruneDrafts(new Set())
|
||||
})
|
||||
expect(hook().input).toBe('bounded draft')
|
||||
|
||||
act(() => {
|
||||
hook().reconcileTerminalTabs([{ id: 'tab-1', leafId: 'leaf-1', terminal: null }], [])
|
||||
hook().pruneDrafts(new Set())
|
||||
})
|
||||
expect(hook().input).toBe('')
|
||||
})
|
||||
|
||||
it('preserves an intentional clear after the optimistic send clear', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe, { activeHandle: 'terminal' }))
|
||||
})
|
||||
act(() => hook().setInput('rejected command'))
|
||||
let send: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
|
||||
act(() => {
|
||||
send = hook().beginBufferedTerminalDraftSend('terminal', hook().input)
|
||||
})
|
||||
act(() => hook().setInput('new command'))
|
||||
act(() => hook().setInput(''))
|
||||
expect(hook().settleBufferedTerminalDraftSend(send)).toBe(false)
|
||||
act(() => hook().restoreRejectedDraft(send))
|
||||
|
||||
expect(hook().input).toBe('')
|
||||
})
|
||||
|
||||
it('restores by origin after a tab switch and preserves stable callback identities', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe, { activeHandle: 'terminal-a' }))
|
||||
})
|
||||
const callbacks = {
|
||||
begin: hook().beginBufferedTerminalDraftSend,
|
||||
prune: hook().pruneDrafts,
|
||||
reconcile: hook().reconcileTerminalTabs,
|
||||
reset: hook().resetDrafts,
|
||||
restore: hook().restoreRejectedDraft,
|
||||
setInput: hook().setInput,
|
||||
settle: hook().settleBufferedTerminalDraftSend
|
||||
}
|
||||
act(() => hook().setInput(' echo exact–text '))
|
||||
let send: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
|
||||
act(() => {
|
||||
send = hook().beginBufferedTerminalDraftSend('terminal-a', hook().input)
|
||||
renderer?.update(createElement(Probe, { activeHandle: 'terminal-b' }))
|
||||
})
|
||||
act(() => hook().setInput('new command for B'))
|
||||
act(() => hook().restoreRejectedDraft(send))
|
||||
act(() => renderer?.update(createElement(Probe, { activeHandle: 'terminal-a' })))
|
||||
|
||||
expect(hook().input).toBe(' echo exact–text ')
|
||||
expect(hook().beginBufferedTerminalDraftSend).toBe(callbacks.begin)
|
||||
expect(hook().pruneDrafts).toBe(callbacks.prune)
|
||||
expect(hook().reconcileTerminalTabs).toBe(callbacks.reconcile)
|
||||
expect(hook().resetDrafts).toBe(callbacks.reset)
|
||||
expect(hook().restoreRejectedDraft).toBe(callbacks.restore)
|
||||
expect(hook().setInput).toBe(callbacks.setInput)
|
||||
expect(hook().settleBufferedTerminalDraftSend).toBe(callbacks.settle)
|
||||
})
|
||||
|
||||
// Why the handle set the session sweep passes matters: terminal.list omits a
|
||||
// chat-covered handle while the desktop graph reloads, so the raw list and the
|
||||
// retained set disagree exactly there, and only the retained set keeps the draft.
|
||||
it('keeps a chat-covered draft against the retained set and drops it against the raw list', () => {
|
||||
const listedHandles = new Set(['other-terminal'])
|
||||
const retainedHandles = resolveRetainedTerminalHandles({
|
||||
liveHandles: listedHandles,
|
||||
showNativeChat: true,
|
||||
activeHandle: 'covered-terminal'
|
||||
})
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe, { activeHandle: 'covered-terminal' }))
|
||||
})
|
||||
act(() => hook().setInput('half-typed command'))
|
||||
|
||||
act(() => hook().pruneDrafts(retainedHandles))
|
||||
expect(hook().input).toBe('half-typed command')
|
||||
|
||||
act(() => hook().pruneDrafts(listedHandles))
|
||||
expect(hook().input).toBe('')
|
||||
})
|
||||
|
||||
it('keeps a chat-covered pending restoration against the retained set only', () => {
|
||||
const listedHandles = new Set(['other-terminal'])
|
||||
const retainedHandles = resolveRetainedTerminalHandles({
|
||||
liveHandles: listedHandles,
|
||||
showNativeChat: true,
|
||||
activeHandle: 'covered-terminal'
|
||||
})
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe, { activeHandle: 'covered-terminal' }))
|
||||
})
|
||||
act(() => hook().setInput('rejected command'))
|
||||
let retainedSend: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
|
||||
act(() => {
|
||||
retainedSend = hook().beginBufferedTerminalDraftSend('covered-terminal', hook().input)
|
||||
})
|
||||
act(() => hook().pruneDrafts(retainedHandles))
|
||||
act(() => hook().restoreRejectedDraft(retainedSend))
|
||||
expect(hook().input).toBe('rejected command')
|
||||
|
||||
let droppedSend: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
|
||||
act(() => {
|
||||
droppedSend = hook().beginBufferedTerminalDraftSend('covered-terminal', hook().input)
|
||||
})
|
||||
act(() => hook().pruneDrafts(listedHandles))
|
||||
act(() => hook().restoreRejectedDraft(droppedSend))
|
||||
expect(hook().input).toBe('')
|
||||
})
|
||||
|
||||
it('drops ended-handle and route-reset restoration metadata', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe, { activeHandle: 'terminal' }))
|
||||
})
|
||||
act(() => hook().setInput('rejected command'))
|
||||
let prunedSend: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
|
||||
act(() => {
|
||||
prunedSend = hook().beginBufferedTerminalDraftSend('terminal', hook().input)
|
||||
hook().reconcileTerminalTabs([{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }], [])
|
||||
hook().restoreRejectedDraft(prunedSend)
|
||||
})
|
||||
expect(hook().input).toBe('')
|
||||
|
||||
act(() => hook().setInput('route draft'))
|
||||
let resetSend: ReturnType<BufferedDraftHook['beginBufferedTerminalDraftSend']>
|
||||
act(() => {
|
||||
resetSend = hook().beginBufferedTerminalDraftSend('terminal', hook().input)
|
||||
hook().resetDrafts()
|
||||
hook().restoreRejectedDraft(resetSend)
|
||||
})
|
||||
expect(hook().input).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import {
|
||||
type BufferedTerminalDraftRestorationToken,
|
||||
type BufferedTerminalDraftValue,
|
||||
beginBufferedTerminalDraftRestoration,
|
||||
invalidateBufferedTerminalDraftRestoration,
|
||||
pruneBufferedTerminalDrafts,
|
||||
pruneBufferedTerminalDraftRestorations,
|
||||
remapBufferedTerminalDraft,
|
||||
remapBufferedTerminalDraftRestoration,
|
||||
restoreRejectedBufferedTerminalDraft,
|
||||
settleBufferedTerminalDraftRestoration,
|
||||
updateBufferedTerminalDraft
|
||||
} from './buffered-terminal-draft-restoration'
|
||||
|
||||
interface BufferedTerminalDraftSend {
|
||||
readonly draft: string
|
||||
readonly handle: string
|
||||
readonly token: BufferedTerminalDraftRestorationToken
|
||||
}
|
||||
|
||||
interface UseBufferedTerminalDraftsOptions {
|
||||
readonly activeHandle: string | null
|
||||
readonly activeHandleRef: RefObject<string | null>
|
||||
}
|
||||
|
||||
type BufferedTerminalDraftTab = {
|
||||
readonly id: string
|
||||
readonly type?: string
|
||||
readonly leafId?: string
|
||||
readonly terminal?: string | null
|
||||
}
|
||||
|
||||
type ReconcileBufferedTerminalDraftTabsOptions = {
|
||||
readonly retainMissingSurfaces?: boolean
|
||||
}
|
||||
|
||||
function getBufferedTerminalDraftSurfaceKey(tab: BufferedTerminalDraftTab): string {
|
||||
return tab.leafId ? `leaf:${tab.leafId}` : `tab:${tab.id}`
|
||||
}
|
||||
|
||||
export function useBufferedTerminalDrafts({
|
||||
activeHandle,
|
||||
activeHandleRef
|
||||
}: UseBufferedTerminalDraftsOptions) {
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({})
|
||||
const pendingRestorationsRef = useRef<Map<string, BufferedTerminalDraftRestorationToken>>(
|
||||
new Map()
|
||||
)
|
||||
const handlesBySurfaceRef = useRef<Map<string, string>>(new Map())
|
||||
const input = activeHandle ? (drafts[activeHandle] ?? '') : ''
|
||||
|
||||
const setInput = useCallback(
|
||||
(value: BufferedTerminalDraftValue) => {
|
||||
const handle = activeHandleRef.current
|
||||
if (!handle) {
|
||||
return
|
||||
}
|
||||
invalidateBufferedTerminalDraftRestoration(pendingRestorationsRef.current, handle)
|
||||
setDrafts((current) => updateBufferedTerminalDraft(current, handle, value))
|
||||
},
|
||||
[activeHandleRef]
|
||||
)
|
||||
|
||||
const beginBufferedTerminalDraftSend = useCallback(
|
||||
(handle: string, draft: string): BufferedTerminalDraftSend => {
|
||||
const token = beginBufferedTerminalDraftRestoration(pendingRestorationsRef.current, handle)
|
||||
setDrafts((current) => updateBufferedTerminalDraft(current, handle, ''))
|
||||
return { draft, handle, token }
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const restoreRejectedDraft = useCallback((send: BufferedTerminalDraftSend): void => {
|
||||
if (
|
||||
!settleBufferedTerminalDraftRestoration(
|
||||
pendingRestorationsRef.current,
|
||||
send.handle,
|
||||
send.token
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setDrafts((current) =>
|
||||
restoreRejectedBufferedTerminalDraft(current, send.token.handle, send.draft)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const settleBufferedTerminalDraftSend = useCallback(
|
||||
(send: BufferedTerminalDraftSend): boolean =>
|
||||
settleBufferedTerminalDraftRestoration(
|
||||
pendingRestorationsRef.current,
|
||||
send.handle,
|
||||
send.token
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
const pruneDrafts = useCallback((retainedHandles: ReadonlySet<string>): void => {
|
||||
const retainedMappedHandles = new Set(retainedHandles)
|
||||
for (const handle of handlesBySurfaceRef.current.values()) {
|
||||
retainedMappedHandles.add(handle)
|
||||
}
|
||||
setDrafts((current) => pruneBufferedTerminalDrafts(current, retainedMappedHandles))
|
||||
pruneBufferedTerminalDraftRestorations(pendingRestorationsRef.current, retainedMappedHandles)
|
||||
}, [])
|
||||
|
||||
const reconcileTerminalTabs = useCallback(
|
||||
(
|
||||
previousTabs: readonly BufferedTerminalDraftTab[],
|
||||
nextTabs: readonly BufferedTerminalDraftTab[],
|
||||
{ retainMissingSurfaces = false }: ReconcileBufferedTerminalDraftTabsOptions = {}
|
||||
): void => {
|
||||
const handlesBySurface = handlesBySurfaceRef.current
|
||||
for (const tab of previousTabs) {
|
||||
if (tab.type && tab.type !== 'terminal') {
|
||||
continue
|
||||
}
|
||||
if (typeof tab.terminal === 'string') {
|
||||
const surfaceKey = getBufferedTerminalDraftSurfaceKey(tab)
|
||||
if (!handlesBySurface.has(surfaceKey)) {
|
||||
handlesBySurface.set(surfaceKey, tab.terminal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const retainedHandles = new Set<string>(
|
||||
retainMissingSurfaces ? handlesBySurface.values() : []
|
||||
)
|
||||
const retainedSurfaces = new Set<string>(retainMissingSurfaces ? handlesBySurface.keys() : [])
|
||||
const remaps: Array<{ previousHandle: string; nextHandle: string }> = []
|
||||
for (const tab of nextTabs) {
|
||||
if (tab.type && tab.type !== 'terminal') {
|
||||
continue
|
||||
}
|
||||
const surfaceKey = getBufferedTerminalDraftSurfaceKey(tab)
|
||||
retainedSurfaces.add(surfaceKey)
|
||||
const previousHandle = handlesBySurface.get(surfaceKey)
|
||||
if (typeof tab.terminal === 'string') {
|
||||
retainedHandles.add(tab.terminal)
|
||||
if (previousHandle && previousHandle !== tab.terminal) {
|
||||
remaps.push({ previousHandle, nextHandle: tab.terminal })
|
||||
}
|
||||
handlesBySurface.set(surfaceKey, tab.terminal)
|
||||
} else if (previousHandle) {
|
||||
retainedHandles.add(previousHandle)
|
||||
}
|
||||
}
|
||||
for (const surfaceKey of handlesBySurface.keys()) {
|
||||
if (!retainedSurfaces.has(surfaceKey)) {
|
||||
handlesBySurface.delete(surfaceKey)
|
||||
}
|
||||
}
|
||||
|
||||
for (const { previousHandle, nextHandle } of remaps) {
|
||||
remapBufferedTerminalDraftRestoration(
|
||||
pendingRestorationsRef.current,
|
||||
previousHandle,
|
||||
nextHandle
|
||||
)
|
||||
}
|
||||
pruneBufferedTerminalDraftRestorations(pendingRestorationsRef.current, retainedHandles)
|
||||
setDrafts((current) => {
|
||||
let next = current
|
||||
for (const { previousHandle, nextHandle } of remaps) {
|
||||
next = remapBufferedTerminalDraft(next, previousHandle, nextHandle)
|
||||
}
|
||||
return pruneBufferedTerminalDrafts(next, retainedHandles)
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const resetDrafts = useCallback((): void => {
|
||||
pendingRestorationsRef.current.clear()
|
||||
handlesBySurfaceRef.current.clear()
|
||||
setDrafts((current) => (Object.keys(current).length === 0 ? current : {}))
|
||||
}, [])
|
||||
|
||||
const clearPendingRestorations = useCallback((): void => {
|
||||
pendingRestorationsRef.current.clear()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
beginBufferedTerminalDraftSend,
|
||||
clearPendingRestorations,
|
||||
input,
|
||||
pruneDrafts,
|
||||
reconcileTerminalTabs,
|
||||
resetDrafts,
|
||||
restoreRejectedDraft,
|
||||
setInput,
|
||||
settleBufferedTerminalDraftSend
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ function createAccessoryInputCommitHarness({
|
||||
liveInputComposingRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
onInteraction: vi.fn(),
|
||||
pendingLiveInputHandleRef,
|
||||
sentLiveInputTextRef,
|
||||
sendLiveTerminalInputRef,
|
||||
|
||||
@@ -32,6 +32,7 @@ type TerminalLiveAccessoryInputCommitOptions = {
|
||||
readonly liveInputComposingRef: RefObject<boolean | undefined>
|
||||
readonly liveInputRef: RefObject<TextInput | null>
|
||||
readonly liveInputTerminalHandles: ReadonlySet<string>
|
||||
readonly onInteraction: () => void
|
||||
readonly pendingLiveInputHandleRef: RefObject<string | null>
|
||||
readonly sentLiveInputTextRef: RefObject<string>
|
||||
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
|
||||
@@ -48,6 +49,7 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
liveInputComposingRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
onInteraction,
|
||||
pendingLiveInputHandleRef,
|
||||
sentLiveInputTextRef,
|
||||
sendLiveTerminalInputRef,
|
||||
@@ -64,6 +66,7 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
if (!liveInputTerminalHandles.has(activeHandle)) {
|
||||
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
|
||||
}
|
||||
onInteraction()
|
||||
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
|
||||
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
|
||||
clearPendingLiveInputCommit()
|
||||
@@ -116,6 +119,7 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
liveInputComposingRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
onInteraction,
|
||||
pendingLiveInputHandleRef,
|
||||
sentLiveInputTextRef,
|
||||
sendLiveTerminalInputRef,
|
||||
|
||||
@@ -19,6 +19,7 @@ function changeLiveInput(
|
||||
|
||||
type TerminalLiveInputCommitHarness = {
|
||||
readonly captures: readonly string[]
|
||||
readonly getHandlers: () => TerminalLiveInputCommitHandlers
|
||||
readonly handlers: TerminalLiveInputCommitHandlers
|
||||
readonly sent: readonly string[]
|
||||
readonly setActiveSessionTabType: (next: string | undefined) => void
|
||||
@@ -85,6 +86,12 @@ function createTerminalLiveInputCommitHarness({
|
||||
|
||||
return {
|
||||
captures,
|
||||
getHandlers: () => {
|
||||
if (!handlers) {
|
||||
throw new Error('terminal live input hook is not mounted')
|
||||
}
|
||||
return handlers
|
||||
},
|
||||
handlers,
|
||||
sent,
|
||||
setActiveSessionTabType: (next: string | undefined): void => {
|
||||
@@ -239,9 +246,10 @@ describe('terminal live input commit hook', () => {
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputSubmit()
|
||||
const accepted = await handlers.handleLiveInputSubmit()
|
||||
|
||||
// Then
|
||||
expect(accepted).toBe(true)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
|
||||
})
|
||||
|
||||
@@ -250,26 +258,54 @@ describe('terminal live input commit hook', () => {
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputSubmit()
|
||||
const accepted = await handlers.handleLiveInputSubmit()
|
||||
|
||||
// Then
|
||||
expect(accepted).toBe(true)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['\r']))
|
||||
})
|
||||
|
||||
it('increments a stable interaction generation for typing, submit, and accessory Enter', async () => {
|
||||
const { getHandlers, handlers, setActiveSessionTabType } =
|
||||
createTerminalLiveInputCommitHarness()
|
||||
const getter = handlers.getLiveInputInteractionGeneration
|
||||
const initialGeneration = getter()
|
||||
|
||||
changeLiveInput(handlers, 'newer text')
|
||||
const typedGeneration = getter()
|
||||
await handlers.handleLiveInputSubmit()
|
||||
const submitGeneration = getter()
|
||||
await handlers.handleLiveInputAccessoryBytes({ bytes: '\r' })
|
||||
setActiveSessionTabType(undefined)
|
||||
|
||||
expect(getHandlers().getLiveInputInteractionGeneration).toBe(getter)
|
||||
expect(typedGeneration).toBe(initialGeneration + 1)
|
||||
expect(submitGeneration).toBeGreaterThan(typedGeneration)
|
||||
expect(getter()).toBeGreaterThan(submitGeneration)
|
||||
})
|
||||
|
||||
it('Given a rejected held-text send When submit is requested Then suppresses the carriage return', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputSubmit()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const accepted = await handlers.handleLiveInputSubmit()
|
||||
|
||||
// Then: the held commit went out but was not accepted, so no \r follows
|
||||
expect(accepted).toBe(false)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한']))
|
||||
})
|
||||
|
||||
it('Given a rejected carriage return When submit is requested Then reports rejection', async () => {
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
|
||||
const accepted = await handlers.handleLiveInputSubmit()
|
||||
|
||||
expect(accepted).toBe(false)
|
||||
expect(sent).toEqual(['\r'])
|
||||
})
|
||||
|
||||
it('Given ASCII typing When changes arrive Then mirrors immediately', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, type RefObject } from 'react'
|
||||
import { useCallback, useEffect, useRef, type RefObject } from 'react'
|
||||
import type { TextInput } from 'react-native'
|
||||
import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
@@ -43,12 +43,13 @@ type TerminalLiveInputCommitOptions<TTabType extends string> = {
|
||||
type TerminalLiveInputCommitHandlers = {
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
|
||||
readonly getLiveInputInteractionGeneration: () => number
|
||||
readonly handleLiveInputAccessoryBytes: (
|
||||
input: TerminalLiveAccessoryInput
|
||||
) => Promise<TerminalLiveAccessoryInputCommitResult>
|
||||
readonly handleLiveInputChange: (event: TerminalLiveInputChangeEvent) => void
|
||||
readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void
|
||||
readonly handleLiveInputSubmit: () => void
|
||||
readonly handleLiveInputSubmit: () => Promise<boolean>
|
||||
}
|
||||
|
||||
export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
@@ -63,6 +64,10 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
|
||||
const liveInputInteractionGenerationRef = useRef(0)
|
||||
const advanceLiveInputInteractionGeneration = useCallback(() => {
|
||||
liveInputInteractionGenerationRef.current += 1
|
||||
}, [])
|
||||
const {
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
@@ -108,6 +113,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
|
||||
const flushPendingLiveInputBeforeExternalSend = useCallback(
|
||||
async (handle: string): Promise<boolean> => {
|
||||
advanceLiveInputInteractionGeneration()
|
||||
const pendingHandle = pendingLiveInputHandleRef.current
|
||||
if (pendingHandle && pendingHandle !== handle) {
|
||||
clearPendingLiveInputCommit()
|
||||
@@ -120,7 +126,12 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
}
|
||||
return waitForPendingLiveInputFlush()
|
||||
},
|
||||
[clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush]
|
||||
[
|
||||
advanceLiveInputInteractionGeneration,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
waitForPendingLiveInputFlush
|
||||
]
|
||||
)
|
||||
|
||||
const handleLiveInputChange = useCallback(
|
||||
@@ -132,6 +143,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
// Why: iOS kills an active dictation/IME session when JS writes a value
|
||||
// that differs from the native field text, so the controlled capture must
|
||||
// echo the field verbatim; only the PTY mirror sees normalized text.
|
||||
advanceLiveInputInteractionGeneration()
|
||||
setLiveInputCapture(nativeEvent.text)
|
||||
void applyLiveInputMirror(
|
||||
activeHandle,
|
||||
@@ -141,6 +153,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
advanceLiveInputInteractionGeneration,
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
liveInputTerminalHandles,
|
||||
@@ -148,11 +161,17 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
]
|
||||
)
|
||||
|
||||
const getLiveInputInteractionGeneration = useCallback(
|
||||
() => liveInputInteractionGenerationRef.current,
|
||||
[]
|
||||
)
|
||||
|
||||
const handleLiveInputKeyPress = useCallback(
|
||||
(event: TerminalLiveInputKeyPressEvent) => {
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
return
|
||||
}
|
||||
advanceLiveInputInteractionGeneration()
|
||||
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
|
||||
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
|
||||
clearPendingLiveInputCommit()
|
||||
@@ -183,6 +202,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
advanceLiveInputInteractionGeneration,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
liveInputTerminalHandles,
|
||||
@@ -200,6 +220,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
liveInputComposingRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
onInteraction: advanceLiveInputInteractionGeneration,
|
||||
pendingLiveInputHandleRef,
|
||||
sentLiveInputTextRef,
|
||||
sendLiveTerminalInputRef,
|
||||
@@ -207,19 +228,27 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
waitForPendingLiveInputFlush
|
||||
})
|
||||
|
||||
const handleLiveInputSubmit = useCallback(() => {
|
||||
const handleLiveInputSubmit = useCallback((): Promise<boolean> => {
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
return
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
void sendTerminalLiveControlAfterPendingFlush(
|
||||
advanceLiveInputInteractionGeneration()
|
||||
return sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, '\r')
|
||||
)
|
||||
}, [activeHandle, flushPendingLiveInputText, liveInputTerminalHandles, sendLiveTerminalInputRef])
|
||||
}, [
|
||||
activeHandle,
|
||||
advanceLiveInputInteractionGeneration,
|
||||
flushPendingLiveInputText,
|
||||
liveInputTerminalHandles,
|
||||
sendLiveTerminalInputRef
|
||||
])
|
||||
|
||||
return {
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputBeforeExternalSend,
|
||||
getLiveInputInteractionGeneration,
|
||||
handleLiveInputAccessoryBytes,
|
||||
handleLiveInputChange,
|
||||
handleLiveInputKeyPress,
|
||||
|
||||
Reference in New Issue
Block a user