mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
Merge remote-tracking branch 'origin/main' into mobile-rearch
# Conflicts: # mobile/app/h/[hostId]/session/[worktreeId].tsx # mobile/src/session/MobileNativeChatOverlay.tsx # mobile/src/session/mobile-terminal-tab-agent.ts # mobile/src/session/use-mobile-native-chat-drafts.ts
This commit is contained in:
@@ -27,7 +27,6 @@ inline src/renderer/src/components/TaskPage.tsx
|
||||
inline src/renderer/src/components/Terminal.tsx
|
||||
inline src/renderer/src/components/WorktreeJumpPalette.tsx
|
||||
inline src/renderer/src/components/automations/AutomationsPage.tsx
|
||||
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
|
||||
inline src/renderer/src/components/editor/MarkdownPreview.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
|
||||
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
|
||||
|
||||
@@ -23,6 +23,15 @@ export const OXLINT_SCANS = [
|
||||
}
|
||||
]
|
||||
|
||||
const SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS = new Map([
|
||||
[
|
||||
'react-doctor(no-derived-state-effect)',
|
||||
new Set([
|
||||
'src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts'
|
||||
])
|
||||
]
|
||||
])
|
||||
|
||||
export function parseAddedLineRanges(diff) {
|
||||
const ranges = []
|
||||
const hunkPattern = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
|
||||
@@ -263,6 +272,11 @@ function printDiagnostic(diagnostic, root) {
|
||||
console.error(`${file}:${line} ${code}: ${diagnostic.message}`)
|
||||
}
|
||||
|
||||
function isSuppressedDiagnostic(diagnostic, root) {
|
||||
const files = SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS.get(diagnostic.code)
|
||||
return files?.has(normalizedDiagnosticPath(root, diagnostic.filename)) ?? false
|
||||
}
|
||||
|
||||
function runOxlintScan(root, scan, files) {
|
||||
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const result = spawnSync(pnpm, ['exec', 'oxlint', ...scan.args, '--format', 'json', ...files], {
|
||||
@@ -295,8 +309,10 @@ export function main(
|
||||
|
||||
let failures = 0
|
||||
for (const scan of OXLINT_SCANS) {
|
||||
const diagnostics = runOxlintScan(root, scan, files).filter((diagnostic) =>
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks)
|
||||
const diagnostics = runOxlintScan(root, scan, files).filter(
|
||||
(diagnostic) =>
|
||||
!isSuppressedDiagnostic(diagnostic, root) &&
|
||||
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks)
|
||||
)
|
||||
for (const diagnostic of diagnostics) {
|
||||
printDiagnostic(diagnostic, root)
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
AppState,
|
||||
type AppStateStatus,
|
||||
BackHandler,
|
||||
FlatList,
|
||||
Image,
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
@@ -14,8 +12,7 @@ import {
|
||||
Keyboard,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
type LayoutChangeEvent,
|
||||
type ListRenderItem
|
||||
type LayoutChangeEvent
|
||||
} from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useRouter } from 'expo-router'
|
||||
@@ -33,12 +30,10 @@ import {
|
||||
GitBranch,
|
||||
Globe,
|
||||
Keyboard as KeyboardIcon,
|
||||
MessageSquare,
|
||||
Monitor,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Smartphone,
|
||||
SquareTerminal,
|
||||
X
|
||||
@@ -77,6 +72,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'
|
||||
@@ -111,6 +108,8 @@ import {
|
||||
import { useTerminalLiveInputFocus } from '../../../../src/terminal/use-terminal-live-input-focus'
|
||||
import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss'
|
||||
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
|
||||
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
|
||||
import { useBufferedTerminalDrafts } from '../../../../src/terminal/use-buffered-terminal-drafts'
|
||||
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
|
||||
import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate'
|
||||
import {
|
||||
@@ -139,9 +138,7 @@ 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 } from '../../../../src/components/CustomKeyModal'
|
||||
import type { CustomKey } from '../../../../src/storage/terminal-custom-key-storage'
|
||||
import { CustomKeyModal, type CustomKey } from '../../../../src/components/CustomKeyModal'
|
||||
import {
|
||||
addMobileDiffComment,
|
||||
formatDiffComments,
|
||||
@@ -149,12 +146,6 @@ import {
|
||||
removeDeliveredMobileDiffComments,
|
||||
removeMobileDiffComments
|
||||
} from '../../../../src/session/mobile-diff-comments'
|
||||
import {
|
||||
buildPlainMobileDiffSyntaxLines,
|
||||
highlightMobileCode,
|
||||
highlightMobileDiffLines,
|
||||
resolveMobileSyntaxLanguage
|
||||
} from '../../../../src/session/mobile-file-syntax'
|
||||
import {
|
||||
getTerminalRecordsFromSessionTabs,
|
||||
hasConnectedTerminalAbsentFromSessionTabs,
|
||||
@@ -186,7 +177,11 @@ import {
|
||||
type AppliedSnapshotMarker
|
||||
} from '../../../../src/session/session-tab-snapshot-gate'
|
||||
import { resolveActiveSessionTab } from '../../../../src/session/active-session-tab'
|
||||
import { MobileHtmlPreview } from '../../../../src/components/MobileHtmlPreview'
|
||||
import {
|
||||
createInitialSessionAutoCreateState,
|
||||
useInitialSessionTerminalAutoCreate,
|
||||
useWorktreeSessionTabsLoaded
|
||||
} from '../../../../src/session/use-initial-session-terminal-autocreate'
|
||||
import { MobileDictationSetupSheet } from '../../../../src/components/MobileDictationSetupSheet'
|
||||
import {
|
||||
fetchDictationSetup,
|
||||
@@ -210,11 +205,6 @@ import {
|
||||
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
|
||||
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
|
||||
import { runAcceptedMobileSessionTabsEffects } from '../../../../src/session/mobile-session-tabs-accepted-effects'
|
||||
import {
|
||||
createInitialSessionAutoCreateState,
|
||||
useInitialSessionTerminalAutoCreate,
|
||||
useWorktreeSessionTabsLoaded
|
||||
} from '../../../../src/session/use-initial-session-terminal-autocreate'
|
||||
import { TerminalViewportResubscribeBudget } from '../../../../src/session/mobile-terminal-viewport-resubscribe'
|
||||
import type {
|
||||
SessionTabsApplyOutcome,
|
||||
@@ -281,20 +271,17 @@ 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 {
|
||||
DiffCommentActions,
|
||||
DiffNotesDelivery,
|
||||
DiffSyntaxState,
|
||||
DirtyMarkdownDraft,
|
||||
FileDocState,
|
||||
FileSyntaxState,
|
||||
MarkdownDocState,
|
||||
MobileDisplayMode,
|
||||
MobileNewTabAgentLoadState,
|
||||
MobileSessionTab,
|
||||
MobileSessionTabType,
|
||||
RenderableDiffLine,
|
||||
RuntimeRepoSummary,
|
||||
SessionTabsResult,
|
||||
Terminal,
|
||||
@@ -314,422 +301,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,
|
||||
onOpenExternalUrl
|
||||
}: {
|
||||
doc: FileDocState | undefined
|
||||
title: string
|
||||
relativePath: string
|
||||
language?: string
|
||||
diffCommentActions?: DiffCommentActions
|
||||
onOpenExternalUrl?: (url: string) => void
|
||||
}) {
|
||||
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}
|
||||
onOpenLink={onOpenExternalUrl}
|
||||
renderSource={() => renderSourceText(doc.content)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return renderSourceText(doc.content)
|
||||
}
|
||||
|
||||
type SessionScreenProps = {
|
||||
sessionTabOperations?: HostSessionTabOperations
|
||||
sessionQuickCommandOperations?: HostSessionQuickCommandOperations
|
||||
@@ -933,7 +504,6 @@ export 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.
|
||||
@@ -1091,6 +661,8 @@ export 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.
|
||||
@@ -1123,6 +695,7 @@ export function SessionScreen({
|
||||
const {
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputBeforeExternalSend,
|
||||
getLiveInputInteractionGeneration: getLiveInteractionGeneration,
|
||||
handleLiveInputAccessoryBytes,
|
||||
handleLiveInputChange,
|
||||
handleLiveInputKeyPress,
|
||||
@@ -1295,6 +868,11 @@ export 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,
|
||||
@@ -1333,7 +911,7 @@ export function SessionScreen({
|
||||
})()
|
||||
return
|
||||
}
|
||||
setInput((current) => appendBufferedDictation(current, route.text))
|
||||
bufferedTerminalDraftState.setInput((current) => appendBufferedDictation(current, route.text))
|
||||
showToast('Dictation inserted')
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -1733,7 +1311,9 @@ export 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())) {
|
||||
@@ -1790,6 +1370,7 @@ export function SessionScreen({
|
||||
clearTerminalLiveInputDefault,
|
||||
defaultTerminalHandlesToLiveInput,
|
||||
nativeChatStream,
|
||||
bufferedTerminalDraftState.pruneDrafts,
|
||||
pruneTerminalHandlesFromLiveInput,
|
||||
subscribeToTerminal,
|
||||
terminalInventoryRequest,
|
||||
@@ -1833,6 +1414,9 @@ export function SessionScreen({
|
||||
}
|
||||
markdownDocLifecycleRef.current.reconcile(nextTabs, setMarkdownDocs)
|
||||
fileDocLifecycleRef.current.reconcile(nextTabs, setFileDocs)
|
||||
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.
|
||||
@@ -2641,6 +2225,7 @@ export function SessionScreen({
|
||||
closedTabTombstonesRef.current.clear()
|
||||
markdownDocLifecycleRef.current.reset()
|
||||
fileDocLifecycleRef.current.reset()
|
||||
bufferedTerminalDraftState.resetDrafts()
|
||||
for (const queued of terminalGestureInputQueuesRef.current.values()) {
|
||||
if (queued.timer) {
|
||||
clearTimeout(queued.timer)
|
||||
@@ -2663,14 +2248,17 @@ export function SessionScreen({
|
||||
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
|
||||
markdownDocLifecycleRef.current.reset()
|
||||
fileDocLifecycleRef.current.reset()
|
||||
bufferedTerminalDraftState.clearPendingRestorations()
|
||||
clearPendingLiveInputCommit()
|
||||
clearDelayedActionTimers()
|
||||
}
|
||||
}, [
|
||||
clearDelayedActionTimers,
|
||||
bufferedTerminalDraftState.clearPendingRestorations,
|
||||
clearPendingLiveInputCommit,
|
||||
clearTerminalCache,
|
||||
hostId,
|
||||
bufferedTerminalDraftState.resetDrafts,
|
||||
worktreeId
|
||||
])
|
||||
|
||||
@@ -2960,28 +2548,82 @@ export 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() {
|
||||
if (!sessionTerminalOperations || !activeHandle || !canSend || sendingRef.current) {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
const sent = await sessionTerminalOperations.sendInput(
|
||||
activeHandle,
|
||||
text,
|
||||
true,
|
||||
deviceTokenRef.current
|
||||
)
|
||||
if (!sent) {
|
||||
setInput(text)
|
||||
let response: Awaited<ReturnType<RpcClient['sendRequest']>> | null = null
|
||||
if (client && !sessionTerminalOperationsProp) {
|
||||
const sendThroughClient = async () => {
|
||||
const response = await client.sendRequest(
|
||||
'terminal.send',
|
||||
buildTerminalSendParams({
|
||||
terminal: activeHandle,
|
||||
text,
|
||||
enter: true,
|
||||
deviceToken: deviceTokenRef.current
|
||||
}),
|
||||
TERMINAL_INPUT_SEND_OPTIONS
|
||||
)
|
||||
return response
|
||||
}
|
||||
response = await sendThroughClient()
|
||||
}
|
||||
let accepted = false
|
||||
if (response) {
|
||||
accepted = (() => {
|
||||
const accepted = isTerminalSendRpcAccepted(response)
|
||||
return accepted
|
||||
})()
|
||||
} else {
|
||||
accepted = await sessionTerminalOperations.sendInput(
|
||||
activeHandle,
|
||||
text,
|
||||
true,
|
||||
deviceTokenRef.current
|
||||
)
|
||||
}
|
||||
if (!accepted) {
|
||||
restoreRejectedDraft()
|
||||
}
|
||||
const draftUnchanged =
|
||||
accepted && bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend)
|
||||
dismissKeyboardAfterAgentSend(sendOrigin, accepted && draftUnchanged)
|
||||
} catch {
|
||||
setInput(text)
|
||||
restoreRejectedDraft()
|
||||
} finally {
|
||||
bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend)
|
||||
sendingRef.current = false
|
||||
}
|
||||
}
|
||||
@@ -3111,15 +2753,7 @@ export 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>({
|
||||
operations: sessionTerminalFileOperations,
|
||||
hostId,
|
||||
@@ -4026,6 +3660,8 @@ export function SessionScreen({
|
||||
try {
|
||||
const response = await sessionTabOperations.close(worktreeId, tab.id)
|
||||
if (response.outcome === 'closed') {
|
||||
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
|
||||
}
|
||||
@@ -4047,7 +3683,6 @@ export function SessionScreen({
|
||||
markdownSaveSeqRef.current.delete(tab.id)
|
||||
markdownSaveInFlightRef.current.delete(tab.id)
|
||||
}
|
||||
const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id)
|
||||
sessionTabsRef.current = remainingTabs
|
||||
setSessionTabs(remainingTabs)
|
||||
// Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab.
|
||||
@@ -4554,7 +4189,8 @@ export function SessionScreen({
|
||||
</View>
|
||||
) : activeFileTab ? (
|
||||
<View style={styles.markdownFrame}>
|
||||
<FileReader
|
||||
{/* MobileSessionFileReader forwards onOpenLink={onOpenExternalUrl}. */}
|
||||
<MobileSessionFileReader
|
||||
doc={fileDocs.get(activeFileTab.id)}
|
||||
title={activeFileTab.title || 'File'}
|
||||
relativePath={activeFileTab.relativePath}
|
||||
@@ -4680,6 +4316,8 @@ export function SessionScreen({
|
||||
void sessionDeviceOperations?.openExternalUrl(url).catch(() => {})
|
||||
}}
|
||||
onCopyText={copyTextToDevice}
|
||||
sendSurfaceId={nativeChatScopeKey ?? ''}
|
||||
getSendCompletionGeneration={getSendCompletionGeneration}
|
||||
keyboardInset={keyboardLift}
|
||||
/>
|
||||
{toastMessage && (
|
||||
@@ -4935,7 +4573,20 @@ export 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"
|
||||
@@ -4964,9 +4615,9 @@ export 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"
|
||||
@@ -4980,6 +4631,7 @@ export 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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ type Props = {
|
||||
onClearSendError: () => void
|
||||
onOpenLink: (url: string) => void
|
||||
onCopyText: (text: string) => Promise<unknown>
|
||||
/** 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
|
||||
}
|
||||
|
||||
@@ -47,6 +51,8 @@ export function MobileNativeChatOverlay({
|
||||
onClearSendError,
|
||||
onOpenLink,
|
||||
onCopyText,
|
||||
sendSurfaceId,
|
||||
getSendCompletionGeneration,
|
||||
keyboardInset
|
||||
}: Props): React.JSX.Element | null {
|
||||
const session = controller.nativeChatSession
|
||||
@@ -87,6 +93,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
|
||||
@@ -128,6 +134,9 @@ export function MobileNativeChatView({
|
||||
loadingEarlier,
|
||||
onLoadEarlier,
|
||||
onSend,
|
||||
sendSurfaceId,
|
||||
getSendCompletionGeneration,
|
||||
getComposerEditGeneration,
|
||||
pending,
|
||||
imagePreviewsByMessageId,
|
||||
composerText,
|
||||
@@ -179,9 +188,6 @@ export function MobileNativeChatView({
|
||||
[]
|
||||
)
|
||||
|
||||
// `data` is the list source: folded transcript + synthetic streaming bubble +
|
||||
// route-owned accepted echoes. Memoize on the same deps so the
|
||||
// downstream autoscroll effects/`renderItem` keep referential stability.
|
||||
const { data } = useMemo(
|
||||
() =>
|
||||
buildMobileNativeChatTransientData({
|
||||
@@ -194,10 +200,6 @@ export function MobileNativeChatView({
|
||||
[messages, folded, streaming, pending, imagePreviewsByMessageId]
|
||||
)
|
||||
|
||||
// Follow the tail as the conversation grows and keep the newest message above
|
||||
// the keyboard when it opens — but only when already pinned to the bottom, so
|
||||
// we don't yank the user away while they read history. (Also fires on keyboard
|
||||
// close, which is harmless while atBottom.)
|
||||
useEffect(() => {
|
||||
if (data.length === 0 || !atBottom) {
|
||||
return
|
||||
@@ -344,8 +346,6 @@ export function MobileNativeChatView({
|
||||
}
|
||||
/>
|
||||
</GestureDetector>
|
||||
{/* Jump-to-latest control. The scroll-to-top affordance now lives
|
||||
per-message (the up-arrow in each agent message's controls). */}
|
||||
{!atBottom ? (
|
||||
<Pressable
|
||||
accessibilityLabel="Scroll to latest"
|
||||
@@ -357,10 +357,6 @@ export function MobileNativeChatView({
|
||||
) : null}
|
||||
</GestureHandlerRootView>
|
||||
)}
|
||||
{/* Pending agent prompt: a structured AskUserQuestion wins, then a
|
||||
heuristic permission, then a heuristic question. The controller owns
|
||||
dismissal (it must survive this subtree unmounting on a view toggle);
|
||||
`ask` arrives already nulled while dismissed. */}
|
||||
{ask ? (
|
||||
<MobileNativeChatAsk
|
||||
key={askKey ?? 'ask'}
|
||||
@@ -393,8 +389,6 @@ export function MobileNativeChatView({
|
||||
onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false}
|
||||
/>
|
||||
) : null}
|
||||
{/* Chrome row above the composer: the working indicator and the global
|
||||
tool-calls expand/collapse toggle on the left, Stop in the far corner. */}
|
||||
<View style={styles.chromeRow}>
|
||||
<View style={styles.chromeLeft}>
|
||||
{agentWorking ? <MobileAgentWorkingIndicator /> : null}
|
||||
@@ -437,6 +431,8 @@ export function MobileNativeChatView({
|
||||
value={composerText}
|
||||
onChangeText={onComposerTextChange}
|
||||
onSend={handleSend}
|
||||
sendSurfaceId={sendSurfaceId}
|
||||
{...{ getSendCompletionGeneration, getComposerEditGeneration }}
|
||||
agent={agent}
|
||||
sessionOptions={sessionOptions}
|
||||
onAttachImage={onAttachImage}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
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,
|
||||
onOpenExternalUrl
|
||||
}: {
|
||||
doc: FileDocState | undefined
|
||||
title: string
|
||||
relativePath: string
|
||||
language?: string
|
||||
diffCommentActions?: DiffCommentActions
|
||||
onOpenExternalUrl?: (url: string) => void
|
||||
}) {
|
||||
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}
|
||||
onOpenLink={onOpenExternalUrl}
|
||||
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 | string | 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
|
||||
}
|
||||
@@ -20,6 +20,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')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { stripLeadingAgentTitleDecorationOrEmpty } from '../../../src/shared/agent-title-decoration'
|
||||
import { resolveExplicitTerminalTitleAgentType } from '../../../src/shared/terminal-title-agent-type'
|
||||
import type { MobileNativeChatAgentStatusWithProvider } from './mobile-native-chat-eligibility'
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
import type { TuiAgent } from '../../../src/shared/tui-agent'
|
||||
import { isBlankBrowserUrl } from '../browser/browser-url'
|
||||
import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
|
||||
@@ -14,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?: MobileNativeChatAgentStatusWithProvider | null
|
||||
launchAgent?: string
|
||||
}): string | null {
|
||||
agentStatus?: { agentType?: AgentStatusEntry['agentType'] | null } | null
|
||||
launchAgent?: TuiAgent | string | 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
|
||||
@@ -26,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]
|
||||
)
|
||||
}
|
||||
@@ -92,6 +92,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,
|
||||
|
||||
@@ -121,6 +121,7 @@ export function useMobileNativeChatController(args: {
|
||||
const {
|
||||
composerText: chatComposerText,
|
||||
setComposerText: setChatComposerText,
|
||||
getComposerEditGeneration: getChatComposerEditGeneration,
|
||||
pending: chatPending,
|
||||
imagePreviewsByMessageId: chatImagePreviewsByMessageId,
|
||||
captureSendOrigin,
|
||||
@@ -297,6 +298,7 @@ export function useMobileNativeChatController(args: {
|
||||
nativeChatAgent: activeChatResolution?.agent ?? null,
|
||||
chatComposerText,
|
||||
setChatComposerText,
|
||||
getChatComposerEditGeneration,
|
||||
chatPending,
|
||||
chatImagePreviewsByMessageId,
|
||||
nativeChatSession,
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import type { HostSessionChatDraftOperations } from './host-session-chat-draft-operations'
|
||||
import type { HostSessionChatPendingDeliveryOperations } from './host-session-chat-pending-delivery-operations'
|
||||
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
|
||||
|
||||
type DraftState = ReturnType<typeof useMobileNativeChatDrafts>
|
||||
|
||||
function userTextMessage(id: string, text: string): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
function assistantTextMessage(id: string, text: string): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMobileNativeChatDrafts', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let state: DraftState | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
state = null
|
||||
})
|
||||
|
||||
function Harness({
|
||||
tabId,
|
||||
sessionId = `session-${tabId}`,
|
||||
messages = [],
|
||||
launchDraft = null,
|
||||
chatActive = true,
|
||||
transcriptLoading = false,
|
||||
persistence,
|
||||
pendingPersistence,
|
||||
transcriptSettled = !transcriptLoading
|
||||
}: {
|
||||
tabId: string
|
||||
sessionId?: string | null
|
||||
messages?: NativeChatMessage[]
|
||||
launchDraft?: string | null
|
||||
chatActive?: boolean
|
||||
transcriptLoading?: boolean
|
||||
persistence?: HostSessionChatDraftOperations
|
||||
pendingPersistence?: HostSessionChatPendingDeliveryOperations
|
||||
transcriptSettled?: boolean
|
||||
}): null {
|
||||
state = useMobileNativeChatDrafts({
|
||||
hostId: 'host',
|
||||
worktreeId: 'worktree',
|
||||
tabId,
|
||||
sessionId,
|
||||
messages,
|
||||
launchDraft,
|
||||
chatActive,
|
||||
transcriptLoading,
|
||||
persistence,
|
||||
pendingPersistence,
|
||||
transcriptSettled
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
async function mount(tabId: string): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { tabId }))
|
||||
})
|
||||
}
|
||||
|
||||
it('registers no deadline when the transcript echo beat the ambiguous RPC rejection', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mount('a')
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
const onUnconfirmed = vi.fn()
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] })
|
||||
)
|
||||
)
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
||||
}
|
||||
})
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
act(() => vi.advanceTimersByTime(30_000))
|
||||
expect(onUnconfirmed).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('surfaces uncertainty when no echo lands before the deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mount('a')
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
const onUnconfirmed = vi.fn()
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
||||
}
|
||||
})
|
||||
|
||||
act(() => vi.advanceTimersByTime(19_999))
|
||||
expect(onUnconfirmed).not.toHaveBeenCalled()
|
||||
act(() => vi.advanceTimersByTime(1))
|
||||
expect(onUnconfirmed).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not confirm an unconfirmed send against an older identical turn', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mount('a')
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, { tabId: 'a', messages: [userTextMessage('old', 'ping')] })
|
||||
)
|
||||
)
|
||||
act(() => state?.setComposerText('ping'))
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
const onUnconfirmed = vi.fn()
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
messages: [userTextMessage('old', 'ping'), assistantTextMessage('other', 'working')]
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('ping')
|
||||
|
||||
act(() => vi.advanceTimersByTime(30_000))
|
||||
expect(onUnconfirmed).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not confirm an unconfirmed send when pagination prepends an older identical turn', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mount('a')
|
||||
const anchor = assistantTextMessage('anchor', 'working')
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'a', messages: [anchor] }))
|
||||
)
|
||||
act(() => state?.setComposerText('ping'))
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
const onUnconfirmed = vi.fn()
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
messages: [userTextMessage('older', 'ping'), anchor]
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('ping')
|
||||
|
||||
act(() => vi.advanceTimersByTime(30_000))
|
||||
expect(onUnconfirmed).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('requires one new transcript echo per repeated unconfirmed send', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mount('a')
|
||||
act(() => state?.setComposerText('ping'))
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
const firstUnconfirmed = vi.fn()
|
||||
const secondUnconfirmed = vi.fn()
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.holdUnconfirmedSend(origin, 'ping', firstUnconfirmed)
|
||||
state?.holdUnconfirmedSend(origin, 'ping', secondUnconfirmed)
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, { tabId: 'a', messages: [userTextMessage('echo-1', 'ping')] })
|
||||
)
|
||||
)
|
||||
act(() => vi.advanceTimersByTime(30_000))
|
||||
|
||||
expect(firstUnconfirmed).not.toHaveBeenCalled()
|
||||
expect(secondUnconfirmed).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not retain a deadline when an ambiguous send settles after unmount', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mount('a')
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
const holdUnconfirmedSend = state?.holdUnconfirmedSend
|
||||
const onUnconfirmed = vi.fn()
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
act(() => {
|
||||
if (origin) {
|
||||
holdUnconfirmedSend?.(origin, 'ping', onUnconfirmed)
|
||||
}
|
||||
})
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
act(() => vi.advanceTimersByTime(30_000))
|
||||
expect(onUnconfirmed).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not erase newer edits when an unconfirmed send lands', async () => {
|
||||
await mount('a')
|
||||
act(() => state?.setComposerText('submitted'))
|
||||
const origin = state?.captureSendOrigin('submitted')
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.holdUnconfirmedSend(origin, 'submitted', vi.fn())
|
||||
}
|
||||
})
|
||||
act(() => state?.setComposerText('new edit'))
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'submitted')] })
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('new edit')
|
||||
})
|
||||
|
||||
it('does not confirm an old session send from an identical turn in its replacement', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mount('a')
|
||||
act(() => state?.setComposerText('ping'))
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
const onUnconfirmed = vi.fn()
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
sessionId: 'replacement',
|
||||
messages: [userTextMessage('replacement-message', 'ping')]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(state?.composerText).toBe('ping')
|
||||
act(() => vi.advanceTimersByTime(30_000))
|
||||
expect(onUnconfirmed).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -123,6 +123,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'))
|
||||
@@ -154,6 +168,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'))
|
||||
@@ -165,12 +199,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')
|
||||
@@ -552,6 +587,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 {
|
||||
@@ -654,223 +703,4 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
)
|
||||
expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///b.jpg']])
|
||||
})
|
||||
|
||||
it('registers no deadline when the transcript echo beat the ambiguous RPC rejection', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mount('a')
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
const onUnconfirmed = vi.fn()
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] })
|
||||
)
|
||||
)
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
||||
}
|
||||
})
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
act(() => vi.advanceTimersByTime(30_000))
|
||||
expect(onUnconfirmed).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('surfaces uncertainty when no echo lands before the deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mount('a')
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
const onUnconfirmed = vi.fn()
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
||||
}
|
||||
})
|
||||
|
||||
act(() => vi.advanceTimersByTime(19_999))
|
||||
expect(onUnconfirmed).not.toHaveBeenCalled()
|
||||
act(() => vi.advanceTimersByTime(1))
|
||||
expect(onUnconfirmed).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not confirm an unconfirmed send against an older identical turn', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mount('a')
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, { tabId: 'a', messages: [userTextMessage('old', 'ping')] })
|
||||
)
|
||||
)
|
||||
act(() => state?.setComposerText('ping'))
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
const onUnconfirmed = vi.fn()
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
messages: [userTextMessage('old', 'ping'), assistantTextMessage('other', 'working')]
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('ping')
|
||||
|
||||
act(() => vi.advanceTimersByTime(30_000))
|
||||
expect(onUnconfirmed).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not confirm an unconfirmed send when pagination prepends an older identical turn', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mount('a')
|
||||
const anchor = assistantTextMessage('anchor', 'working')
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'a', messages: [anchor] }))
|
||||
)
|
||||
act(() => state?.setComposerText('ping'))
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
const onUnconfirmed = vi.fn()
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
messages: [userTextMessage('older', 'ping'), anchor]
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('ping')
|
||||
|
||||
act(() => vi.advanceTimersByTime(30_000))
|
||||
expect(onUnconfirmed).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('requires one new transcript echo per repeated unconfirmed send', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mount('a')
|
||||
act(() => state?.setComposerText('ping'))
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
const firstUnconfirmed = vi.fn()
|
||||
const secondUnconfirmed = vi.fn()
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.holdUnconfirmedSend(origin, 'ping', firstUnconfirmed)
|
||||
state?.holdUnconfirmedSend(origin, 'ping', secondUnconfirmed)
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, { tabId: 'a', messages: [userTextMessage('echo-1', 'ping')] })
|
||||
)
|
||||
)
|
||||
act(() => vi.advanceTimersByTime(30_000))
|
||||
|
||||
expect(firstUnconfirmed).not.toHaveBeenCalled()
|
||||
expect(secondUnconfirmed).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not retain a deadline when an ambiguous send settles after unmount', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mount('a')
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
const holdUnconfirmedSend = state?.holdUnconfirmedSend
|
||||
const onUnconfirmed = vi.fn()
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
act(() => {
|
||||
if (origin) {
|
||||
holdUnconfirmedSend?.(origin, 'ping', onUnconfirmed)
|
||||
}
|
||||
})
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
act(() => vi.advanceTimersByTime(30_000))
|
||||
expect(onUnconfirmed).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not erase newer edits when an unconfirmed send lands', async () => {
|
||||
await mount('a')
|
||||
act(() => state?.setComposerText('submitted'))
|
||||
const origin = state?.captureSendOrigin('submitted')
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.holdUnconfirmedSend(origin, 'submitted', vi.fn())
|
||||
}
|
||||
})
|
||||
act(() => state?.setComposerText('new edit'))
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'submitted')] })
|
||||
)
|
||||
)
|
||||
expect(state?.composerText).toBe('new edit')
|
||||
})
|
||||
|
||||
it('does not confirm an old session send from an identical turn in its replacement', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await mount('a')
|
||||
act(() => state?.setComposerText('ping'))
|
||||
const origin = state?.captureSendOrigin('ping')
|
||||
const onUnconfirmed = vi.fn()
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
sessionId: 'replacement',
|
||||
messages: [userTextMessage('replacement-message', 'ping')]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(state?.composerText).toBe('ping')
|
||||
act(() => vi.advanceTimersByTime(30_000))
|
||||
expect(onUnconfirmed).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
type MobileNativeChatPendingDeliveryOrigin,
|
||||
type MobileNativeChatPendingMessage
|
||||
} from './use-mobile-native-chat-pending-deliveries'
|
||||
import { MobileNativeChatDraftEditGenerations } from './mobile-native-chat-draft-edit-generations'
|
||||
|
||||
export type MobileNativeChatSendOrigin = MobileNativeChatPendingDeliveryOrigin & {
|
||||
draftKey: string
|
||||
draftEditGeneration: number
|
||||
}
|
||||
|
||||
// Ack-lost sends wait for a transcript echo before surfacing as unconfirmed.
|
||||
@@ -48,6 +50,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. */
|
||||
@@ -80,6 +83,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
} = args
|
||||
const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId)
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({})
|
||||
const draftEditGenerationsRef = useRef(new MobileNativeChatDraftEditGenerations())
|
||||
const messagesRef = useRef(messages)
|
||||
messagesRef.current = messages
|
||||
const activeDraftKeyRef = useRef(draftKey)
|
||||
@@ -129,6 +133,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
return
|
||||
}
|
||||
markDraftEdited()
|
||||
draftEditGenerationsRef.current.advance(draftKey)
|
||||
setDrafts((previous) => {
|
||||
const current = previous[draftKey] ?? ''
|
||||
const next = typeof value === 'function' ? value(current) : value
|
||||
@@ -143,6 +148,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
draftKey
|
||||
? {
|
||||
draftKey,
|
||||
draftEditGeneration: draftEditGenerationsRef.current.readDraft(draftKey),
|
||||
...capturePendingOrigin(text.trim())
|
||||
}
|
||||
: null,
|
||||
@@ -151,7 +157,8 @@ export function useMobileNativeChatDrafts(args: {
|
||||
|
||||
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
|
||||
)
|
||||
@@ -159,7 +166,10 @@ export function useMobileNativeChatDrafts(args: {
|
||||
|
||||
const restoreRejectedDraft = useCallback((origin: MobileNativeChatSendOrigin, text: string) => {
|
||||
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
|
||||
)
|
||||
}, [])
|
||||
|
||||
@@ -236,6 +246,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
return {
|
||||
composerText,
|
||||
setComposerText,
|
||||
getComposerEditGeneration: draftEditGenerationsRef.current.readComposer,
|
||||
pending,
|
||||
imagePreviewsByMessageId,
|
||||
captureSendOrigin,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -33,6 +33,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>
|
||||
@@ -49,6 +50,7 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
liveInputComposingRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
onInteraction,
|
||||
pendingLiveInputHandleRef,
|
||||
sentLiveInputTextRef,
|
||||
sendLiveTerminalInputRef,
|
||||
@@ -65,6 +67,7 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
if (!liveInputTerminalHandles.has(activeHandle)) {
|
||||
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
|
||||
}
|
||||
onInteraction()
|
||||
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
|
||||
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
|
||||
clearPendingLiveInputCommit()
|
||||
@@ -117,6 +120,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,
|
||||
|
||||
@@ -310,6 +310,28 @@
|
||||
},
|
||||
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
|
||||
"reactDoctor": {
|
||||
"ignore": {
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts"
|
||||
],
|
||||
"rules": [
|
||||
"react-doctor/no-derived-state-effect"
|
||||
]
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts",
|
||||
"src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts",
|
||||
"src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts"
|
||||
],
|
||||
"rules": [
|
||||
"react-doctor/no-ref-current-in-render"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"rules": {
|
||||
"react-doctor/js-combine-iterations": "off"
|
||||
}
|
||||
|
||||
@@ -53,7 +53,16 @@ export const BROWSER_HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
},
|
||||
{
|
||||
name: 'browser-tab',
|
||||
keys: ['tab list', 'tab show', 'tab current', 'tab switch', 'tab create', 'tab close', 'exec'],
|
||||
keys: [
|
||||
'open-url',
|
||||
'tab list',
|
||||
'tab show',
|
||||
'tab current',
|
||||
'tab switch',
|
||||
'tab create',
|
||||
'tab close',
|
||||
'exec'
|
||||
],
|
||||
load: async () => (await import('./handlers/browser-tab.js')).BROWSER_TAB_HANDLERS
|
||||
},
|
||||
{
|
||||
|
||||
@@ -183,6 +183,27 @@ describe('orca cli browser page targeting', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('opens browser-launch URLs on the client hosting the current worktree', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
|
||||
okFixture('req_open_url', { browserPageId: 'page-local' })
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['open-url', '--url', 'https://example.com/login', '--json'], '/tmp/repo/feature')
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'browser.openUrl',
|
||||
{
|
||||
url: 'https://example.com/login',
|
||||
worktree: 'id:repo::/tmp/repo/feature'
|
||||
},
|
||||
{ timeoutMs: 60_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('passes tab profile updates through by page id', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
|
||||
@@ -15,6 +15,16 @@ import { RuntimeClientError } from '../runtime-client'
|
||||
import { getBrowserCommandTarget, getBrowserWorktreeSelector } from '../selectors'
|
||||
|
||||
export const BROWSER_TAB_HANDLERS: Record<string, CommandHandler> = {
|
||||
'open-url': async ({ flags, client, cwd, json }) => {
|
||||
const url = getRequiredStringFlag(flags, 'url')
|
||||
const worktree = await getBrowserWorktreeSelector(flags, cwd, client)
|
||||
const result = await client.call<{ browserPageId: string }>(
|
||||
'browser.openUrl',
|
||||
{ url, worktree },
|
||||
{ timeoutMs: 60_000 }
|
||||
)
|
||||
printResult(result, json, (v) => `Opened URL in tab ${v.browserPageId}`)
|
||||
},
|
||||
'tab list': async ({ flags, client, cwd, json }) => {
|
||||
const worktree = await getBrowserWorktreeSelector(flags, cwd, client)
|
||||
const result = await client.call<BrowserTabListResult>('browser.tabList', { worktree })
|
||||
|
||||
+10
-3
@@ -4,7 +4,11 @@ import type {
|
||||
RuntimeWorktreeListResult,
|
||||
RuntimeWorktreeRecord
|
||||
} from '../shared/runtime-types'
|
||||
import { isPathInsideOrEqual, isWslUncPathForCallerLinuxPath } from '../shared/cross-platform-path'
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
isWslUncPathForCallerLinuxPath,
|
||||
isWslUncPathForLinuxMountedPath
|
||||
} from '../shared/cross-platform-path'
|
||||
import { parseWslUncPath } from '../shared/wsl-paths'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime/types'
|
||||
@@ -49,8 +53,9 @@ export async function resolveCallerDistroPathSelector(
|
||||
// the WSL launcher always sets and which arrives here as the invocation cwd, proves it.
|
||||
const callerDistro = parseWslUncPath(cwd)?.distro
|
||||
const linuxPath = selector.startsWith('path:') ? selector.slice(5) : ''
|
||||
const isLinuxMountedPath = /^\/mnt\/[A-Za-z](?:\/|$)/.test(linuxPath)
|
||||
if (
|
||||
!callerDistro ||
|
||||
(!callerDistro && !isLinuxMountedPath) ||
|
||||
client.isRemote ||
|
||||
!linuxPath.startsWith('/') ||
|
||||
linuxPath.startsWith('//') ||
|
||||
@@ -63,7 +68,9 @@ export async function resolveCallerDistroPathSelector(
|
||||
limit: 10_000
|
||||
})
|
||||
const match = worktrees.result.worktrees.find((worktree) =>
|
||||
isWslUncPathForCallerLinuxPath(worktree.path, linuxPath, callerDistro)
|
||||
isLinuxMountedPath
|
||||
? isWslUncPathForLinuxMountedPath(worktree.path, linuxPath)
|
||||
: isWslUncPathForCallerLinuxPath(worktree.path, linuxPath, callerDistro!)
|
||||
)
|
||||
// Why the stored spelling rather than a synthesized UNC path: an unmatched selector must
|
||||
// reach the runtime verbatim and fail as the caller typed it, never as a guessed distro.
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['open-url'],
|
||||
summary: 'Open a URL on the paired client that hosts this terminal',
|
||||
usage: 'orca open-url --url <url> [--worktree <selector>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree']
|
||||
},
|
||||
{
|
||||
path: ['snapshot'],
|
||||
summary: 'Capture an accessibility snapshot of the active browser tab',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { normalizeWorktreeSelectorForCaller } from './selectors'
|
||||
const UBUNTU = 'Ubuntu-24.04'
|
||||
const DEBIAN = 'Debian'
|
||||
const LINUX_PATH = '/home/neil/qa-repo'
|
||||
const MOUNTED_PATH = '/mnt/c/Users/neil/qa-repo'
|
||||
|
||||
function uncPath(distro: string, linuxPath: string): string {
|
||||
return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
|
||||
@@ -37,6 +38,15 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('normalizeWorktreeSelectorForCaller in a WSL shell (#16628)', () => {
|
||||
it('resolves a shared /mnt drive without requiring the caller distro', async () => {
|
||||
const storedPath = uncPath(UBUNTU, '/mnt/c/Users/neil/qa-repo')
|
||||
const { client } = makeClient([storedPath])
|
||||
|
||||
await expect(
|
||||
normalizeWorktreeSelectorForCaller(`path:${MOUNTED_PATH}`, '/mnt/c/Users/neil', client)
|
||||
).resolves.toBe(`path:${storedPath}`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a backslash UNC registration', uncPath(UBUNTU, LINUX_PATH)],
|
||||
['a forward-slash UNC registration', `//wsl.localhost/${UBUNTU}${LINUX_PATH}`],
|
||||
|
||||
@@ -58,6 +58,75 @@ describe('registerPtyHandlers', () => {
|
||||
const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite()
|
||||
|
||||
describe('spawn environment', () => {
|
||||
it('routes headless browser launches through the owning Orca workspace', () => {
|
||||
const inheritedBrowser = process.env.BROWSER
|
||||
delete process.env.BROWSER
|
||||
try {
|
||||
const env = buildPtyHostEnv(
|
||||
'pty-headless',
|
||||
{},
|
||||
{
|
||||
isPackaged: true,
|
||||
userDataPath: '/tmp/orca-user-data',
|
||||
selectedCodexHomePath: null,
|
||||
agentStatusHooksEnabled: false,
|
||||
routeBrowserOpensToClient: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(env.BROWSER).toBe('orca open-url --url %s')
|
||||
} finally {
|
||||
if (inheritedBrowser === undefined) {
|
||||
delete process.env.BROWSER
|
||||
} else {
|
||||
process.env.BROWSER = inheritedBrowser
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves an explicit browser command on headless runtimes', () => {
|
||||
const env = buildPtyHostEnv(
|
||||
'pty-custom-browser',
|
||||
{ BROWSER: 'custom-browser %s' },
|
||||
{
|
||||
isPackaged: true,
|
||||
userDataPath: '/tmp/orca-user-data',
|
||||
selectedCodexHomePath: null,
|
||||
agentStatusHooksEnabled: false,
|
||||
routeBrowserOpensToClient: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(env.BROWSER).toBe('custom-browser %s')
|
||||
})
|
||||
|
||||
it('uses the registered WSL CLI name for headless browser launches', () => {
|
||||
const inheritedBrowser = process.env.BROWSER
|
||||
delete process.env.BROWSER
|
||||
try {
|
||||
const env = buildPtyHostEnv(
|
||||
'pty-headless-wsl',
|
||||
{},
|
||||
{
|
||||
isPackaged: true,
|
||||
userDataPath: '/tmp/orca-user-data',
|
||||
selectedCodexHomePath: null,
|
||||
isWsl: true,
|
||||
agentStatusHooksEnabled: false,
|
||||
routeBrowserOpensToClient: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(env.BROWSER).toBe('orca-ide open-url --url %s')
|
||||
} finally {
|
||||
if (inheritedBrowser === undefined) {
|
||||
delete process.env.BROWSER
|
||||
} else {
|
||||
process.env.BROWSER = inheritedBrowser
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('passes the PTY-resolved Codex home to the WSL relay lane', () => {
|
||||
const runtimeHome =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
|
||||
@@ -264,6 +264,15 @@ export function buildPtyHostEnv(
|
||||
: bundledCliBin
|
||||
}
|
||||
|
||||
if (
|
||||
opts.routeBrowserOpensToClient === true &&
|
||||
baseEnv.BROWSER === undefined &&
|
||||
process.env.BROWSER === undefined
|
||||
) {
|
||||
const cliCommand = opts.isWsl ? (opts.isPackaged ? 'orca-ide' : 'orca-dev') : 'orca'
|
||||
baseEnv.BROWSER = `${cliCommand} open-url --url %s`
|
||||
}
|
||||
|
||||
// Why: must run after the prepends above — they re-read PATH from the unscrubbed
|
||||
// process.env when baseEnv carries none, which is the daemon path's normal shape.
|
||||
stripLegacyTerminalShimEnv(baseEnv, process.platform)
|
||||
|
||||
@@ -29,6 +29,8 @@ export type BuildPtyHostEnvOptions = {
|
||||
agentStatusHooksEnabled: boolean
|
||||
codexStatusHooksEnabled?: boolean
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
/** Headless paired runtimes hand browser launches to the client-hosted Orca browser. */
|
||||
routeBrowserOpensToClient?: boolean
|
||||
/** Keep indexed Git config off the sparse daemon wire; the daemon appends guard entries after merging its inherited env. */
|
||||
deferGitConfigGuardToDaemon?: boolean
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis
|
||||
agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings),
|
||||
codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings),
|
||||
networkProxySettings: ptySettings,
|
||||
routeBrowserOpensToClient: ctx.deps.runtime?.shouldRelayTerminalBrowserOpens?.(),
|
||||
deferGitConfigGuardToDaemon:
|
||||
ctx.provider.supportsGitCredentialGuardHost?.(ctx.effectiveSessionId) === true
|
||||
})
|
||||
|
||||
@@ -72,7 +72,8 @@ export function configureLocalPtyProvider(args: {
|
||||
wslDistro: ctx?.wslDistro ?? null,
|
||||
agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings),
|
||||
codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings),
|
||||
networkProxySettings: ptySettings
|
||||
networkProxySettings: ptySettings,
|
||||
routeBrowserOpensToClient: runtime?.shouldRelayTerminalBrowserOpens?.()
|
||||
})
|
||||
// Why: agents need their terminal handle at process start to self-identify in orchestration messages without an extra RPC.
|
||||
const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE
|
||||
|
||||
@@ -258,6 +258,7 @@ export async function prepareRuntimePtySpawn(
|
||||
agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings),
|
||||
codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings),
|
||||
networkProxySettings: ptySettings,
|
||||
routeBrowserOpensToClient: ctx.deps.runtime?.shouldRelayTerminalBrowserOpens?.(),
|
||||
deferGitConfigGuardToDaemon:
|
||||
ctx.provider.supportsGitCredentialGuardHost?.(ctx.sessionId) === true
|
||||
})
|
||||
|
||||
@@ -90,7 +90,7 @@ import {
|
||||
shouldSetDisplayName,
|
||||
mergeWorktree
|
||||
} from './worktree-logic'
|
||||
import { resolveCreatedWorktree } from './created-worktree-reconciliation'
|
||||
import { findCreatedWorktree, resolveCreatedWorktree } from './created-worktree-reconciliation'
|
||||
import type { BranchPrefixSettings } from '../../shared/branch-prefix'
|
||||
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
|
||||
import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
|
||||
@@ -1840,9 +1840,9 @@ export async function createRemoteWorktree(
|
||||
const gitWorktrees = await timing.time('list_created_worktree', async () =>
|
||||
provider.listWorktrees(repo.path)
|
||||
)
|
||||
const created = gitWorktrees.find(
|
||||
(gw) => gw.branch?.endsWith(branchName) || gw.path.endsWith(effectiveSanitizedName)
|
||||
)
|
||||
// Match the exact requested path first, then the exact branch ref. Suffix matching can
|
||||
// select an older `prefix/<branchName>` worktree when the newly created row is present.
|
||||
const created = findCreatedWorktree(gitWorktrees, remotePath, branchName)
|
||||
if (!created) {
|
||||
throw new Error('Worktree created but not found in listing')
|
||||
}
|
||||
|
||||
@@ -126,6 +126,13 @@ describe('registerWorktreeHandlers', () => {
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/remote/old-improve-dashboard',
|
||||
head: 'old123',
|
||||
branch: 'refs/heads/archive/improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
{
|
||||
path: '/remote/repo-improve-dashboard',
|
||||
head: 'abc123',
|
||||
|
||||
@@ -52,6 +52,10 @@ async function runtimeCommandFor(
|
||||
}
|
||||
|
||||
describe('electronSidecarRuntimeMethodName', () => {
|
||||
it('maps client URL opens to the browser.openUrl RPC', () => {
|
||||
expect(electronSidecarRuntimeMethodName('browserOpenUrlOnClient')).toBe('browser.openUrl')
|
||||
})
|
||||
|
||||
it('round-trips every registered browser RPC method', async () => {
|
||||
const mismatches: string[] = []
|
||||
const unmapped: string[] = []
|
||||
|
||||
@@ -10,6 +10,9 @@ export const TARGETLESS_BROWSER_METHODS: Record<string, true> = {
|
||||
}
|
||||
|
||||
export function electronSidecarRuntimeMethodName(browserMethod: string): string {
|
||||
if (browserMethod === 'browserOpenUrlOnClient') {
|
||||
return 'browser.openUrl'
|
||||
}
|
||||
const suffix = browserMethod.slice('browser'.length)
|
||||
if (suffix === 'ProceedCertificate') {
|
||||
return 'browser.certificate.proceed'
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness'
|
||||
|
||||
function postedHookNames(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||
return fetchMock.mock.calls.map(
|
||||
(call) => JSON.parse(String(call[1]?.body)).payload.hook_event_name as string
|
||||
)
|
||||
}
|
||||
|
||||
const OMP_RUNTIME_CASES = [
|
||||
['configured OMP', { kind: 'omp' as const }],
|
||||
['title-routed OMP', { kind: 'pi' as const, title: 'omp' }],
|
||||
['argv-routed OMP', { kind: 'pi' as const, argv: ['node', '/usr/local/bin/omp'] }]
|
||||
] as const
|
||||
|
||||
describe('OMP agent_end contract', () => {
|
||||
it.each(OMP_RUNTIME_CASES)(
|
||||
'keeps %s working when agent_end will continue',
|
||||
async (_name, args) => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const harness = createAgentStatusExtensionHarness(args)
|
||||
const context = { isIdle: vi.fn(() => true) }
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('agent_end', { willContinue: true }, context)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start'])
|
||||
expect(context.isIdle).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(OMP_RUNTIME_CASES)(
|
||||
'settles a completed %s turn without waiting for ctx.isIdle',
|
||||
async (_name, args) => {
|
||||
// Why: absent payload and absent flag are both terminal for a version that cannot send one.
|
||||
for (const event of [{ willContinue: false }, {}, undefined]) {
|
||||
const harness = createAgentStatusExtensionHarness(args)
|
||||
const context = { isIdle: vi.fn(() => false) }
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('agent_end', event, context)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start', 'agent_end'])
|
||||
)
|
||||
expect(context.isIdle).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('settles a later terminal OMP agent_end after a continuation', async () => {
|
||||
const harness = createAgentStatusExtensionHarness({ kind: 'omp' })
|
||||
const context = { isIdle: vi.fn(() => false) }
|
||||
|
||||
await harness.callHook('agent_start')
|
||||
await harness.callHook('agent_end', { willContinue: true }, context)
|
||||
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start'])
|
||||
|
||||
await harness.callHook('agent_end', { willContinue: false }, context)
|
||||
await vi.waitFor(() =>
|
||||
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start', 'agent_end'])
|
||||
)
|
||||
})
|
||||
|
||||
it('does not apply the OMP contract to Pi or Prime', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
for (const kind of ['pi', 'prime-agent'] as const) {
|
||||
const harness = createAgentStatusExtensionHarness({ kind })
|
||||
const context = { isIdle: vi.fn(() => false) }
|
||||
|
||||
await harness.callHook('agent_end', { willContinue: false }, context)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(postedHookNames(harness.fetchMock)).toEqual([])
|
||||
expect(context.isIdle).toHaveBeenCalled()
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves non-terminal agent_end handling for Pi and Prime', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
for (const kind of ['pi', 'prime-agent'] as const) {
|
||||
const harness = createAgentStatusExtensionHarness({ kind })
|
||||
const context = { isIdle: vi.fn(() => true) }
|
||||
|
||||
await harness.callHook('agent_end', { willContinue: true }, context)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(postedHookNames(harness.fetchMock)).toEqual([])
|
||||
expect(context.isIdle).not.toHaveBeenCalled()
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,193 +1,9 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
// TypeScript 7 is a native CLI; transpile tests still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getPiAgentStatusExtensionSource } from './agent-status-extension-source'
|
||||
|
||||
type HookContext = {
|
||||
isIdle?: () => boolean
|
||||
sessionManager?: {
|
||||
getSessionId?: () => unknown
|
||||
getSessionFile?: () => unknown
|
||||
}
|
||||
}
|
||||
|
||||
type HookHandler = (event?: unknown, context?: HookContext) => Promise<void> | void
|
||||
|
||||
type FakeCurlChild = {
|
||||
on: ReturnType<typeof vi.fn>
|
||||
stdin: {
|
||||
on: ReturnType<typeof vi.fn>
|
||||
end: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
type Harness = {
|
||||
fetchMock: ReturnType<typeof vi.fn>
|
||||
spawnMock: ReturnType<typeof vi.fn>
|
||||
spawnedChildren: FakeCurlChild[]
|
||||
fsMock: {
|
||||
existsSync: ReturnType<typeof vi.fn>
|
||||
readFileSync: ReturnType<typeof vi.fn>
|
||||
statSync: ReturnType<typeof vi.fn>
|
||||
}
|
||||
handlers: Record<string, HookHandler>
|
||||
processEnv: Record<string, string | undefined>
|
||||
callHook: (name: string, event?: unknown, context?: HookContext) => Promise<void>
|
||||
// Re-invoke the extension factory in the same process (as Pi does on an
|
||||
// in-process extension reload), swapping in the freshly registered handlers.
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
const BASE_ENV = {
|
||||
ORCA_PANE_KEY: 'pane-1',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'launch-1',
|
||||
ORCA_TAB_ID: 'tab-1',
|
||||
ORCA_WORKTREE_ID: 'tree-1',
|
||||
ORCA_AGENT_HOOK_PORT: '4321',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token-1',
|
||||
ORCA_AGENT_HOOK_ENV: 'env-1',
|
||||
ORCA_AGENT_HOOK_VERSION: '1.2.3'
|
||||
} satisfies Record<string, string>
|
||||
|
||||
// Why: ownership keys on process.pid, so reload and child-process tests need
|
||||
// stable, distinct identities.
|
||||
const SELF_PID = 4242
|
||||
|
||||
function createHarness(args: {
|
||||
kind: 'pi' | 'omp' | 'prime-agent'
|
||||
env?: Record<string, string | undefined>
|
||||
pid?: number
|
||||
title?: string
|
||||
argv?: string[]
|
||||
existsSync?: (path: string) => boolean
|
||||
readFileSync?: (path: string, encoding: string) => string
|
||||
statSync?: (path: string) => { mtimeMs: number; size: number; ino: number }
|
||||
fetchImpl?: (...params: Parameters<typeof fetch>) => Promise<unknown>
|
||||
}): Harness {
|
||||
const fetchMock = vi.fn(
|
||||
args.fetchImpl ??
|
||||
(async () => ({
|
||||
ok: true
|
||||
}))
|
||||
)
|
||||
|
||||
const spawnedChildren: FakeCurlChild[] = []
|
||||
const spawnMock = vi.fn(() => {
|
||||
const child: FakeCurlChild = {
|
||||
on: vi.fn(),
|
||||
stdin: {
|
||||
on: vi.fn(),
|
||||
end: vi.fn()
|
||||
}
|
||||
}
|
||||
spawnedChildren.push(child)
|
||||
return child
|
||||
})
|
||||
|
||||
const fsMock = {
|
||||
existsSync: vi.fn(args.existsSync ?? (() => false)),
|
||||
statSync: vi.fn(
|
||||
args.statSync ??
|
||||
((path: string) => {
|
||||
throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' })
|
||||
})
|
||||
),
|
||||
readFileSync: vi.fn(
|
||||
args.readFileSync ??
|
||||
((path: string) => {
|
||||
throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const module = {
|
||||
exports: {} as { default?: (pi: { on: (name: string, handler: HookHandler) => void }) => void }
|
||||
}
|
||||
const requireMock = vi.fn((specifier: string) => {
|
||||
if (specifier === 'fs') {
|
||||
return fsMock
|
||||
}
|
||||
if (specifier === 'child_process') {
|
||||
return { spawn: spawnMock }
|
||||
}
|
||||
throw new Error(`unexpected require(${specifier})`)
|
||||
})
|
||||
|
||||
const processMock = {
|
||||
env: {
|
||||
...BASE_ENV,
|
||||
...(args.kind === 'prime-agent' ? { PRIME_AGENT_INTERNAL_DAEMON_WORKER: '1' } : {}),
|
||||
...args.env
|
||||
},
|
||||
pid: args.pid ?? SELF_PID,
|
||||
title: args.title ?? 'node',
|
||||
argv: args.argv ?? ['node', '/usr/bin/orca']
|
||||
}
|
||||
|
||||
const context = {
|
||||
module,
|
||||
exports: module.exports,
|
||||
require: requireMock,
|
||||
process: processMock,
|
||||
fetch: fetchMock,
|
||||
console: {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
log: vi.fn()
|
||||
},
|
||||
Promise,
|
||||
Buffer,
|
||||
URL,
|
||||
AbortController,
|
||||
setTimeout,
|
||||
clearTimeout
|
||||
} as Record<string, unknown>
|
||||
context.globalThis = context
|
||||
|
||||
const source = getPiAgentStatusExtensionSource(args.kind)
|
||||
const output = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020
|
||||
}
|
||||
}).outputText
|
||||
runInNewContext(output, context)
|
||||
|
||||
const register = module.exports.default
|
||||
if (!register) {
|
||||
throw new Error('expected default export from generated source')
|
||||
}
|
||||
|
||||
const handlers: Record<string, HookHandler> = {}
|
||||
const registerInto = (target: Record<string, HookHandler>): void => {
|
||||
register({
|
||||
on(name: string, handler: HookHandler) {
|
||||
target[name] = handler
|
||||
}
|
||||
})
|
||||
}
|
||||
registerInto(handlers)
|
||||
|
||||
return {
|
||||
fetchMock,
|
||||
spawnMock,
|
||||
spawnedChildren,
|
||||
fsMock,
|
||||
handlers,
|
||||
processEnv: processMock.env,
|
||||
callHook: async (name, event, hookContext) => {
|
||||
await handlers[name]?.(event, hookContext)
|
||||
},
|
||||
reload: () => {
|
||||
for (const key of Object.keys(handlers)) {
|
||||
delete handlers[key]
|
||||
}
|
||||
registerInto(handlers)
|
||||
}
|
||||
}
|
||||
}
|
||||
import {
|
||||
AGENT_STATUS_EXTENSION_SELF_PID as SELF_PID,
|
||||
createAgentStatusExtensionHarness as createHarness
|
||||
} from './agent-status-extension-test-harness'
|
||||
|
||||
describe('getPiAgentStatusExtensionSource', () => {
|
||||
it('registers Prime hooks only in the event-emitting daemon worker', () => {
|
||||
@@ -826,10 +642,10 @@ describe('getPiAgentStatusExtensionSource', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps reporting Pi-compatible agents once their agent_end handlers settle', async () => {
|
||||
it('keeps polling Pi and Prime until their agent_end handlers settle', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
for (const kind of ['pi', 'omp', 'prime-agent'] as const) {
|
||||
for (const kind of ['pi', 'prime-agent'] as const) {
|
||||
const harness = createHarness({ kind })
|
||||
let idle = false
|
||||
const context = { isIdle: vi.fn(() => idle) }
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
// TypeScript 7 is a native CLI; transpile tests still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
import { getPiAgentStatusExtensionSource } from './agent-status-extension-source'
|
||||
|
||||
export type HookContext = {
|
||||
isIdle?: () => boolean
|
||||
sessionManager?: {
|
||||
getSessionId?: () => unknown
|
||||
getSessionFile?: () => unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type HookHandler = (event?: unknown, context?: HookContext) => Promise<void> | void
|
||||
|
||||
type FakeCurlChild = {
|
||||
on: ReturnType<typeof vi.fn>
|
||||
stdin: {
|
||||
on: ReturnType<typeof vi.fn>
|
||||
end: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentStatusExtensionHarness = {
|
||||
fetchMock: ReturnType<typeof vi.fn>
|
||||
spawnMock: ReturnType<typeof vi.fn>
|
||||
spawnedChildren: FakeCurlChild[]
|
||||
fsMock: {
|
||||
existsSync: ReturnType<typeof vi.fn>
|
||||
readFileSync: ReturnType<typeof vi.fn>
|
||||
statSync: ReturnType<typeof vi.fn>
|
||||
}
|
||||
handlers: Record<string, HookHandler>
|
||||
processEnv: Record<string, string | undefined>
|
||||
callHook: (name: string, event?: unknown, context?: HookContext) => Promise<void>
|
||||
// Re-invoke the extension factory in the same process (as Pi does on an
|
||||
// in-process extension reload), swapping in the freshly registered handlers.
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
const BASE_ENV = {
|
||||
ORCA_PANE_KEY: 'pane-1',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'launch-1',
|
||||
ORCA_TAB_ID: 'tab-1',
|
||||
ORCA_WORKTREE_ID: 'tree-1',
|
||||
ORCA_AGENT_HOOK_PORT: '4321',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token-1',
|
||||
ORCA_AGENT_HOOK_ENV: 'env-1',
|
||||
ORCA_AGENT_HOOK_VERSION: '1.2.3'
|
||||
} satisfies Record<string, string>
|
||||
|
||||
// Why: ownership keys on process.pid, so reload and child-process tests need
|
||||
// stable, distinct identities.
|
||||
export const AGENT_STATUS_EXTENSION_SELF_PID = 4242
|
||||
|
||||
export function createAgentStatusExtensionHarness(args: {
|
||||
kind: 'pi' | 'omp' | 'prime-agent'
|
||||
env?: Record<string, string | undefined>
|
||||
pid?: number
|
||||
title?: string
|
||||
argv?: readonly string[]
|
||||
existsSync?: (path: string) => boolean
|
||||
readFileSync?: (path: string, encoding: string) => string
|
||||
statSync?: (path: string) => { mtimeMs: number; size: number; ino: number }
|
||||
fetchImpl?: (...params: Parameters<typeof fetch>) => Promise<unknown>
|
||||
}): AgentStatusExtensionHarness {
|
||||
const fetchMock = vi.fn(
|
||||
args.fetchImpl ??
|
||||
(async () => ({
|
||||
ok: true
|
||||
}))
|
||||
)
|
||||
|
||||
const spawnedChildren: FakeCurlChild[] = []
|
||||
const spawnMock = vi.fn(() => {
|
||||
const child: FakeCurlChild = {
|
||||
on: vi.fn(),
|
||||
stdin: {
|
||||
on: vi.fn(),
|
||||
end: vi.fn()
|
||||
}
|
||||
}
|
||||
spawnedChildren.push(child)
|
||||
return child
|
||||
})
|
||||
|
||||
const fsMock = {
|
||||
existsSync: vi.fn(args.existsSync ?? (() => false)),
|
||||
statSync: vi.fn(
|
||||
args.statSync ??
|
||||
((path: string) => {
|
||||
throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' })
|
||||
})
|
||||
),
|
||||
readFileSync: vi.fn(
|
||||
args.readFileSync ??
|
||||
((path: string) => {
|
||||
throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const module = {
|
||||
exports: {} as { default?: (pi: { on: (name: string, handler: HookHandler) => void }) => void }
|
||||
}
|
||||
const requireMock = vi.fn((specifier: string) => {
|
||||
if (specifier === 'fs') {
|
||||
return fsMock
|
||||
}
|
||||
if (specifier === 'child_process') {
|
||||
return { spawn: spawnMock }
|
||||
}
|
||||
throw new Error(`unexpected require(${specifier})`)
|
||||
})
|
||||
|
||||
const processMock = {
|
||||
env: {
|
||||
...BASE_ENV,
|
||||
...(args.kind === 'prime-agent' ? { PRIME_AGENT_INTERNAL_DAEMON_WORKER: '1' } : {}),
|
||||
...args.env
|
||||
},
|
||||
pid: args.pid ?? AGENT_STATUS_EXTENSION_SELF_PID,
|
||||
title: args.title ?? 'node',
|
||||
argv: args.argv ?? ['node', '/usr/bin/orca']
|
||||
}
|
||||
|
||||
const context = {
|
||||
module,
|
||||
exports: module.exports,
|
||||
require: requireMock,
|
||||
process: processMock,
|
||||
fetch: fetchMock,
|
||||
console: {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
log: vi.fn()
|
||||
},
|
||||
Promise,
|
||||
Buffer,
|
||||
URL,
|
||||
AbortController,
|
||||
setTimeout,
|
||||
clearTimeout
|
||||
} as Record<string, unknown>
|
||||
context.globalThis = context
|
||||
|
||||
const source = getPiAgentStatusExtensionSource(args.kind)
|
||||
const output = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020
|
||||
}
|
||||
}).outputText
|
||||
runInNewContext(output, context)
|
||||
|
||||
const register = module.exports.default
|
||||
if (!register) {
|
||||
throw new Error('expected default export from generated source')
|
||||
}
|
||||
|
||||
const handlers: Record<string, HookHandler> = {}
|
||||
const registerInto = (target: Record<string, HookHandler>): void => {
|
||||
register({
|
||||
on(name: string, handler: HookHandler) {
|
||||
target[name] = handler
|
||||
}
|
||||
})
|
||||
}
|
||||
registerInto(handlers)
|
||||
|
||||
return {
|
||||
fetchMock,
|
||||
spawnMock,
|
||||
spawnedChildren,
|
||||
fsMock,
|
||||
handlers,
|
||||
processEnv: processMock.env,
|
||||
callHook: async (name, event, hookContext) => {
|
||||
await handlers[name]?.(event, hookContext)
|
||||
},
|
||||
reload: () => {
|
||||
for (const key of Object.keys(handlers)) {
|
||||
delete handlers[key]
|
||||
}
|
||||
registerInto(handlers)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,9 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
' })',
|
||||
'',
|
||||
' // Why: modern Pi stays non-idle across retry/compaction/follow-up work,',
|
||||
' // while legacy Pi/OMP becomes idle after its final agent_end handlers.',
|
||||
' // while legacy Pi becomes idle after its final agent_end handlers.',
|
||||
' // OMP instead marks non-terminal agent_end events with willContinue, so it',
|
||||
' // returns before the recheck timer is ever armed.',
|
||||
' const AGENT_END_IDLE_RECHECK_MS = 25',
|
||||
' const AGENT_END_IDLE_RECHECK_MAX_MS = 250',
|
||||
' let agentSettledSupported = false',
|
||||
@@ -173,6 +175,10 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
|
||||
' clearPendingAgentEndCheck()',
|
||||
' return',
|
||||
' }',
|
||||
' if (isOmpRuntime()) {',
|
||||
' postAgentEndOnce()',
|
||||
' return',
|
||||
' }',
|
||||
' if (agentSettledSupported) return',
|
||||
" if (!ctx || typeof ctx.isIdle !== 'function') {",
|
||||
' postAgentEndOnce()',
|
||||
|
||||
@@ -19,6 +19,16 @@ describe('browser host capability selection', () => {
|
||||
expect(() => leases.select(undefined, ['mirror.v1'])).toThrow('browser_host_unavailable')
|
||||
})
|
||||
|
||||
it('skips a webview-only host when client automation is required', () => {
|
||||
const leases = registry()
|
||||
attach(leases, 'webview-only', ['webview'])
|
||||
attach(leases, 'automation', ['webview', 'automation-v1'])
|
||||
|
||||
expect(leases.select(undefined, ['webview', 'automation-v1'])).toMatchObject({
|
||||
browserHostClientId: 'host-automation'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps multiple qualified hosts ambiguous without arbitrary routing', () => {
|
||||
const leases = registry()
|
||||
attach(leases, 'a', ['webview', 'commands.v1'])
|
||||
|
||||
@@ -54,6 +54,7 @@ import type {
|
||||
BrowserSessionUserAgentMode
|
||||
} from '../../shared/browser-workspace-types'
|
||||
import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol'
|
||||
import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../shared/browser-client-automation-protocol'
|
||||
import type { BrowserPageCreationPlacement } from '../../shared/browser-client-host-placement'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { browserNetworkExecutionHostKey } from '../browser/browser-network-execution-route'
|
||||
@@ -96,6 +97,7 @@ import {
|
||||
} from './browser-tab-create-publication'
|
||||
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
|
||||
import type { BrowserHostLeaseRegistry } from './browser-host-lease-registry'
|
||||
import { BROWSER_HOST_WEBVIEW_CAPABILITY } from './browser-host-capability-selection'
|
||||
import {
|
||||
closeRuntimeBrowserClientPage,
|
||||
createRuntimeBrowserClientPage,
|
||||
@@ -1731,6 +1733,32 @@ export class RuntimeBrowserCommands {
|
||||
return { browserPageId }
|
||||
}
|
||||
|
||||
async browserOpenUrlOnClient(params: {
|
||||
url: string
|
||||
worktree: string
|
||||
}): Promise<{ browserPageId: string }> {
|
||||
const protocol = new URL(params.url).protocol
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
throw new BrowserError('invalid_argument', 'Only http(s) URLs can be opened on the client.')
|
||||
}
|
||||
const lease = this.host
|
||||
.getBrowserHostLeaseRegistry()
|
||||
.select(undefined, [
|
||||
BROWSER_HOST_WEBVIEW_CAPABILITY,
|
||||
BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY
|
||||
])
|
||||
return this.browserTabCreate(
|
||||
{
|
||||
url: params.url,
|
||||
worktree: params.worktree,
|
||||
activate: true,
|
||||
navigation: 'caller',
|
||||
placement: { kind: 'client', browserHostClientId: lease.browserHostClientId }
|
||||
},
|
||||
{ pairedDeviceId: lease.pairedDeviceId, clientKind: 'runtime' }
|
||||
)
|
||||
}
|
||||
|
||||
async browserTabSetProfile(
|
||||
params: {
|
||||
profileId: string
|
||||
|
||||
@@ -3135,6 +3135,21 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('relays terminal browser launches only while headless owns the graph', () => {
|
||||
const runtime = createRuntime()
|
||||
electronMocks.BrowserWindow.fromId.mockImplementation((windowId: number) =>
|
||||
windowId === TEST_WINDOW_ID ? ({ isDestroyed: () => false } as never) : null
|
||||
)
|
||||
|
||||
expect(runtime.shouldRelayTerminalBrowserOpens()).toBe(false)
|
||||
|
||||
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
|
||||
expect(runtime.shouldRelayTerminalBrowserOpens()).toBe(true)
|
||||
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
expect(runtime.shouldRelayTerminalBrowserOpens()).toBe(false)
|
||||
})
|
||||
|
||||
it('marks live headless PTYs for renderer reattach before desktop promotion', () => {
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
|
||||
@@ -6637,6 +6637,10 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
shouldRelayTerminalBrowserOpens(): boolean {
|
||||
return this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID
|
||||
}
|
||||
|
||||
// Why: scans the transcript-owning host's disk (correct by construction over
|
||||
// RPC — a remote/SSH host scans its own disk). Delegates to the one shared
|
||||
// cache so the desktop panel and the mobile screen never double-scan.
|
||||
@@ -41329,6 +41333,9 @@ export class OrcaRuntimeService {
|
||||
browserTabCreate: RuntimeBrowserCommands['browserTabCreate'] =
|
||||
this.browserCommands.browserTabCreate.bind(this.browserCommands)
|
||||
|
||||
browserOpenUrlOnClient: RuntimeBrowserCommands['browserOpenUrlOnClient'] =
|
||||
this.browserCommands.browserOpenUrlOnClient.bind(this.browserCommands)
|
||||
|
||||
browserTabSetProfile: RuntimeBrowserCommands['browserTabSetProfile'] =
|
||||
this.browserCommands.browserTabSetProfile.bind(this.browserCommands)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
Upload,
|
||||
Wait
|
||||
} from './browser-schemas'
|
||||
import { BrowserTabCreateParams } from './browser-tab-create-schema'
|
||||
import { BrowserOpenUrlParams, BrowserTabCreateParams } from './browser-tab-create-schema'
|
||||
import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods'
|
||||
|
||||
const CertificateProceed = BrowserTarget.extend({
|
||||
@@ -118,6 +118,11 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [
|
||||
? runtime.browserTabCreate(params, { pairedDeviceId, clientKind })
|
||||
: runtime.browserTabCreate(params, { clientKind })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.openUrl',
|
||||
params: BrowserOpenUrlParams,
|
||||
handler: async (params, { runtime }) => runtime.browserOpenUrlOnClient(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.tabSetProfile',
|
||||
params: TabSetProfile,
|
||||
|
||||
@@ -16,3 +16,8 @@ export const BrowserTabCreateParams = z.object({
|
||||
targetGroupId: OptionalString,
|
||||
placement: BrowserPageCreationPlacement.optional()
|
||||
})
|
||||
|
||||
export const BrowserOpenUrlParams = z.object({
|
||||
url: z.url(),
|
||||
worktree: z.string().min(1)
|
||||
})
|
||||
|
||||
@@ -48,6 +48,26 @@ describe('browser RPC methods', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('routes host browser-open requests through the dedicated client opener', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
browserOpenUrlOnClient: vi.fn().mockResolvedValue({ browserPageId: 'page-local' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS })
|
||||
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('browser.openUrl', {
|
||||
url: 'https://example.com/login',
|
||||
worktree: 'id:wt-1'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.browserOpenUrlOnClient).toHaveBeenCalledWith({
|
||||
url: 'https://example.com/login',
|
||||
worktree: 'id:wt-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('validates profile user-agent modes', () => {
|
||||
expect(
|
||||
ProfileCreate.safeParse({ label: 'Google', scope: 'isolated', userAgentMode: 'native' })
|
||||
|
||||
@@ -49,13 +49,13 @@ describe('GitHandler', () => {
|
||||
.spyOn(handler as unknown as GitSpyTarget, 'git')
|
||||
.mockRejectedValue(new Error('aborted'))
|
||||
|
||||
const result = await dispatcher.callRequest(
|
||||
'git.listWorktrees',
|
||||
{ repoPath: tmpDir },
|
||||
{ isStale: () => false, signal: controller.signal }
|
||||
)
|
||||
|
||||
expect(result).toEqual([])
|
||||
await expect(
|
||||
dispatcher.callRequest(
|
||||
'git.listWorktrees',
|
||||
{ repoPath: tmpDir },
|
||||
{ isStale: () => false, signal: controller.signal }
|
||||
)
|
||||
).rejects.toThrow('aborted')
|
||||
expect(gitSpy).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], tmpDir, {
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
@@ -119,37 +119,35 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext {
|
||||
|
||||
async listWorktrees(params: Record<string, unknown>, context?: RequestContext) {
|
||||
const repoPath = params.repoPath as string
|
||||
return this.gitCapabilities
|
||||
.runWithFallback(
|
||||
'worktree-list-z',
|
||||
async () => {
|
||||
const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, {
|
||||
return this.gitCapabilities.runWithFallback(
|
||||
'worktree-list-z',
|
||||
async () => {
|
||||
const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, {
|
||||
signal: context?.signal
|
||||
})
|
||||
return this.normalizeMainWorktreePath(
|
||||
repoPath,
|
||||
parseWorktreeList(stdout, { nulDelimited: true })
|
||||
)
|
||||
},
|
||||
async () => {
|
||||
// Why: Git <2.36 lacks worktree-list `-z`, so fall back to the newline-block parser (loses newline-in-path safety).
|
||||
try {
|
||||
const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, {
|
||||
signal: context?.signal
|
||||
})
|
||||
return this.normalizeMainWorktreePath(
|
||||
const normalized = await this.normalizeMainWorktreePath(
|
||||
repoPath,
|
||||
parseWorktreeList(stdout, { nulDelimited: true })
|
||||
parseWorktreeList(stdout)
|
||||
)
|
||||
},
|
||||
async () => {
|
||||
// Why: Git <2.36 lacks worktree-list `-z`, so fall back to the newline-block parser (loses newline-in-path safety).
|
||||
try {
|
||||
const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, {
|
||||
signal: context?.signal
|
||||
})
|
||||
const normalized = await this.normalizeMainWorktreePath(
|
||||
repoPath,
|
||||
parseWorktreeList(stdout)
|
||||
)
|
||||
// Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389).
|
||||
return annotatePrunableWorktreesByExistence(normalized)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
isUnsupportedWorktreeListZError
|
||||
)
|
||||
.catch(() => [])
|
||||
// Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389).
|
||||
return annotatePrunableWorktreesByExistence(normalized)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
isUnsupportedWorktreeListZError
|
||||
)
|
||||
}
|
||||
|
||||
async addWorktree(params: Record<string, unknown>) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,383 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks'
|
||||
import { useWorkspaceFileBrowserActionPredicate } from '@/lib/file-preview'
|
||||
import { selectWorktreeDiffCommentsOrEmpty } from '@/store/worktree-diff-comments-selector'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import '@/lib/monaco-setup'
|
||||
import type { DiffSection } from '../diff-section-types'
|
||||
import {
|
||||
EMPTY_GIT_BRANCH_ENTRIES,
|
||||
EMPTY_GIT_STATUS_ENTRIES,
|
||||
useCombinedDiffEntrySet
|
||||
} from './resolve-changes/use-combined-diff-entry-set'
|
||||
import { useCombinedDiffSectionLoadRegistry } from './load-sections/combined-diff-section-load-registry'
|
||||
import { useCombinedDiffSectionLoader } from './load-sections/use-combined-diff-section-loader'
|
||||
import { useCombinedDiffSectionRetry } from './load-sections/use-combined-diff-section-retry'
|
||||
import { useCombinedDiffSectionRevalidation } from './load-sections/use-combined-diff-section-revalidation'
|
||||
import { useCombinedDiffViewPersist } from './remember-view/use-combined-diff-view-persist'
|
||||
import { useCombinedDiffViewRestore } from './remember-view/use-combined-diff-view-restore'
|
||||
import { useCombinedDiffDirectScrollInput } from './scroll-viewport/use-combined-diff-direct-scroll-input'
|
||||
import { useCombinedDiffScrollAnchors } from './scroll-viewport/use-combined-diff-scroll-anchors'
|
||||
import { useCombinedDiffScrollPersistence } from './scroll-viewport/use-combined-diff-scroll-persistence'
|
||||
import { useCombinedDiffScrollbar } from './scroll-viewport/use-combined-diff-scrollbar'
|
||||
import { useCombinedDiffVirtualizer } from './scroll-viewport/use-combined-diff-virtualizer'
|
||||
import { CombinedDiffSectionList } from './scroll-viewport/combined-diff-section-list'
|
||||
import { CombinedDiffFileTree } from './browse-files/combined-diff-file-tree'
|
||||
import { useCombinedDiffTreeNavigation } from './browse-files/use-combined-diff-tree-navigation'
|
||||
import { CombinedDiffCommitHeader } from './review-controls/combined-diff-commit-header'
|
||||
import { CombinedDiffToolbar } from './review-controls/combined-diff-toolbar'
|
||||
import { ClearDiffNotesDialog } from './review-controls/combined-diff-notes-popover'
|
||||
import {
|
||||
CombinedDiffNoChangesEmptyState,
|
||||
CombinedDiffSkippedConflictNotice,
|
||||
CombinedDiffSkippedConflictsEmptyState
|
||||
} from './review-controls/combined-diff-skipped-conflicts'
|
||||
import { useCombinedDiffNotesActions } from './review-controls/use-combined-diff-notes-actions'
|
||||
import { useCombinedDiffSectionActions } from './review-controls/use-combined-diff-section-actions'
|
||||
import { useCombinedDiffViewPreferences } from './review-controls/use-combined-diff-view-preferences'
|
||||
|
||||
export default function CombinedDiffViewer({
|
||||
file,
|
||||
viewStateKey
|
||||
}: {
|
||||
file: OpenFile
|
||||
viewStateKey: string
|
||||
}): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const gitStatusEntries = useAppStore(
|
||||
(s) => s.gitStatusByWorktree[file.worktreeId] ?? EMPTY_GIT_STATUS_ENTRIES
|
||||
)
|
||||
const liveBranchEntries = useAppStore(
|
||||
(s) => s.gitBranchChangesByWorktree[file.worktreeId] ?? EMPTY_GIT_BRANCH_ENTRIES
|
||||
)
|
||||
const branchSummary = useAppStore((s) => s.gitBranchCompareSummaryByWorktree[file.worktreeId])
|
||||
const openAllDiffs = useAppStore((s) => s.openAllDiffs)
|
||||
const openConflictReview = useAppStore((s) => s.openConflictReview)
|
||||
const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const clearDiffComments = useAppStore((s) => s.clearDiffComments)
|
||||
const diffCommentsForWorktree = useAppStore((s) =>
|
||||
selectWorktreeDiffCommentsOrEmpty(s, file.worktreeId)
|
||||
)
|
||||
const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[file.worktreeId])
|
||||
const canOpenWorkspaceFileBrowserForPath = useWorkspaceFileBrowserActionPredicate(file.worktreeId)
|
||||
const isDark =
|
||||
settings?.theme === 'dark' ||
|
||||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
const [sections, setSections] = useState<DiffSection[]>([])
|
||||
const [sectionHeights, setSectionHeights] = useState<Record<number, number>>({})
|
||||
const [generation, setGeneration] = useState(0)
|
||||
// Why: a browser scroll clamp must re-pin the restore without being recorded as user intent.
|
||||
const [clampRestoreCount, setClampRestoreCount] = useState(0)
|
||||
const [programmaticScrollMarks] = useState(createProgrammaticScrollMarks)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const registry = useCombinedDiffSectionLoadRegistry(sections)
|
||||
const entrySet = useCombinedDiffEntrySet({
|
||||
file,
|
||||
gitStatusEntries,
|
||||
liveBranchEntries,
|
||||
sectionsRef: registry.sectionsRef
|
||||
})
|
||||
const notes = useCombinedDiffNotesActions({
|
||||
clearDiffComments,
|
||||
diffCommentsForWorktree,
|
||||
worktreeId: file.worktreeId
|
||||
})
|
||||
const preferences = useCombinedDiffViewPreferences({
|
||||
combinedDiffFileTreeVisibleByDefault: settings?.combinedDiffFileTreeVisibleByDefault,
|
||||
diffDefaultView: settings?.diffDefaultView,
|
||||
diffWordWrap: settings?.diffWordWrap,
|
||||
registry,
|
||||
setSections,
|
||||
updateSettings
|
||||
})
|
||||
const restore = useCombinedDiffViewRestore({
|
||||
entrySet,
|
||||
gitStatusEntries,
|
||||
registry,
|
||||
setGeneration,
|
||||
setSectionHeights,
|
||||
setSections,
|
||||
setSideBySide: preferences.setSideBySide,
|
||||
viewStateKey
|
||||
})
|
||||
const loadSection = useCombinedDiffSectionLoader({
|
||||
entrySet,
|
||||
file,
|
||||
registry,
|
||||
sectionCount: sections.length,
|
||||
setSectionHeights,
|
||||
setSections
|
||||
})
|
||||
const { ensureSectionLoaded, requestSectionReload, retrySection } = useCombinedDiffSectionRetry({
|
||||
invalidateViewStateCache: restore.invalidateViewStateCache,
|
||||
registry,
|
||||
setSectionHeights,
|
||||
setSections
|
||||
})
|
||||
|
||||
const { hasDirectScrollInput, markDirectScrollInput } = useCombinedDiffDirectScrollInput()
|
||||
const { cleanupActiveScrollbarDrag, handleScrollbarPointerDown, scrollThumb, updateScrollbar } =
|
||||
useCombinedDiffScrollbar({ markDirectScrollInput, scrollContainerRef })
|
||||
const virtualizer = useCombinedDiffVirtualizer({
|
||||
generation,
|
||||
programmaticScrollMarks,
|
||||
renderedIndicesRef: registry.renderedIndicesRef,
|
||||
scrollContainerRef,
|
||||
scrollOffsetRef: restore.scrollOffsetRef,
|
||||
sectionHeights,
|
||||
sections,
|
||||
sideBySide: preferences.sideBySide
|
||||
})
|
||||
const anchors = useCombinedDiffScrollAnchors({
|
||||
clampRestoreCount,
|
||||
generation,
|
||||
hasDirectScrollInput,
|
||||
latestDomScrollAnchorRef: restore.latestDomScrollAnchorRef,
|
||||
programmaticScrollMarks,
|
||||
scrollAnchorRef: restore.scrollAnchorRef,
|
||||
scrollContainerRef,
|
||||
scrollOffsetRef: restore.scrollOffsetRef,
|
||||
sections,
|
||||
sectionsRef: registry.sectionsRef,
|
||||
sideBySide: preferences.sideBySide,
|
||||
totalSize: virtualizer.getTotalSize(),
|
||||
viewStateKey,
|
||||
virtualizer
|
||||
})
|
||||
|
||||
const toggleSection = useCallback(
|
||||
(index: number) => {
|
||||
const shouldLoadAfterExpand = registry.sectionsRef.current[index]?.collapsed ?? false
|
||||
setSections((prev) =>
|
||||
prev.map((s, i) => (i === index ? { ...s, collapsed: !s.collapsed } : s))
|
||||
)
|
||||
if (shouldLoadAfterExpand) {
|
||||
registry.loadSchedulerRef.current.request(index)
|
||||
}
|
||||
},
|
||||
[registry.loadSchedulerRef, registry.sectionsRef]
|
||||
)
|
||||
|
||||
const treeNavigation = useCombinedDiffTreeNavigation({
|
||||
ensureSectionLoaded,
|
||||
entrySignature: entrySet.entrySignature,
|
||||
markDirectScrollInput,
|
||||
scrollToIndex: anchors.scrollToSectionIndex,
|
||||
sections,
|
||||
sectionsRef: registry.sectionsRef,
|
||||
toggleSection,
|
||||
treeMode: entrySet.treeMode
|
||||
})
|
||||
const combinedGitStatusSignature = useCombinedDiffSectionRevalidation({
|
||||
file,
|
||||
gitStatusEntries,
|
||||
registry,
|
||||
requestSectionReload,
|
||||
sectionIndexByKeyRef: treeNavigation.sectionIndexByKeyRef,
|
||||
sections,
|
||||
shouldAutoReloadFromGitStatus: entrySet.shouldAutoReloadFromGitStatus,
|
||||
treeMode: entrySet.treeMode
|
||||
})
|
||||
const { handleSectionSaveRef, modifiedEditorsRef, openSection, openSectionPreview } =
|
||||
useCombinedDiffSectionActions({
|
||||
activeGroupId,
|
||||
branchCompare: entrySet.branchCompare,
|
||||
canOpenWorkspaceFileBrowserForPath,
|
||||
commitCompare: entrySet.commitCompare,
|
||||
file,
|
||||
isAllMode: entrySet.isAllMode,
|
||||
isBranchMode: entrySet.isBranchMode,
|
||||
isCommitMode: entrySet.isCommitMode,
|
||||
sections,
|
||||
sectionsRef: registry.sectionsRef,
|
||||
setSectionHeights,
|
||||
setSections
|
||||
})
|
||||
|
||||
useCombinedDiffViewPersist({
|
||||
combinedGitStatusSignature,
|
||||
entryCount: entrySet.entries.length,
|
||||
entrySignature: entrySet.entrySignature,
|
||||
loadedIndicesRef: registry.loadedIndicesRef,
|
||||
scrollContainerRef,
|
||||
sectionHeights,
|
||||
sections,
|
||||
sideBySide: preferences.sideBySide,
|
||||
viewStateKey
|
||||
})
|
||||
useCombinedDiffScrollPersistence({
|
||||
anchors,
|
||||
entrySignature: entrySet.entrySignature,
|
||||
hasDirectScrollInput,
|
||||
latestDomScrollAnchorRef: restore.latestDomScrollAnchorRef,
|
||||
programmaticScrollMarks,
|
||||
scrollAnchorRef: restore.scrollAnchorRef,
|
||||
scrollContainerRef,
|
||||
scrollOffsetRef: restore.scrollOffsetRef,
|
||||
sectionCount: sections.length,
|
||||
sectionHeights,
|
||||
sections,
|
||||
setClampRestoreCount,
|
||||
updateScrollbar,
|
||||
viewStateKey
|
||||
})
|
||||
|
||||
const openAlternateDiff = useCallback(() => {
|
||||
if (!file.combinedAlternate) {
|
||||
return
|
||||
}
|
||||
|
||||
if (file.combinedAlternate.source === 'combined-all') {
|
||||
openAllDiffs(file.worktreeId, file.filePath)
|
||||
return
|
||||
}
|
||||
|
||||
if (branchSummary && branchSummary.status === 'ready') {
|
||||
openBranchAllDiffs(file.worktreeId, file.filePath, branchSummary, {
|
||||
source: 'combined-all'
|
||||
})
|
||||
}
|
||||
}, [branchSummary, file, openAllDiffs, openBranchAllDiffs])
|
||||
|
||||
const { setScrollSurfaceMounted } = notes
|
||||
const setScrollContainerRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
scrollContainerRef.current = node
|
||||
setScrollSurfaceMounted(node !== null)
|
||||
if (node === null) {
|
||||
cleanupActiveScrollbarDrag()
|
||||
return
|
||||
}
|
||||
window.requestAnimationFrame(updateScrollbar)
|
||||
},
|
||||
[cleanupActiveScrollbarDrag, setScrollSurfaceMounted, updateScrollbar]
|
||||
)
|
||||
|
||||
const skippedConflicts = file.skippedConflicts
|
||||
const reviewSkippedConflicts = useCallback(() => {
|
||||
openConflictReview(
|
||||
file.worktreeId,
|
||||
file.filePath,
|
||||
(skippedConflicts ?? []).map((entry) => ({
|
||||
path: entry.path,
|
||||
conflictKind: entry.conflictKind
|
||||
})),
|
||||
'combined-diff-exclusion'
|
||||
)
|
||||
}, [file.filePath, file.worktreeId, openConflictReview, skippedConflicts])
|
||||
|
||||
const commitHeader =
|
||||
entrySet.isCommitMode && entrySet.commitCompare ? (
|
||||
<CombinedDiffCommitHeader commitCompare={entrySet.commitCompare} />
|
||||
) : null
|
||||
|
||||
if (sections.length === 0 && (skippedConflicts?.length ?? 0) > 0) {
|
||||
return (
|
||||
<CombinedDiffSkippedConflictsEmptyState
|
||||
commitHeader={commitHeader}
|
||||
onReviewConflicts={reviewSkippedConflicts}
|
||||
skippedConflicts={skippedConflicts!}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return <CombinedDiffNoChangesEmptyState commitHeader={commitHeader} />
|
||||
}
|
||||
|
||||
const skippedConflictNotice =
|
||||
(skippedConflicts?.length ?? 0) > 0 ? (
|
||||
<CombinedDiffSkippedConflictNotice
|
||||
onReviewConflicts={reviewSkippedConflicts}
|
||||
skippedConflicts={skippedConflicts!}
|
||||
/>
|
||||
) : null
|
||||
const allSectionsCollapsed = sections.every((section) => section.collapsed)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<CombinedDiffToolbar
|
||||
activeGroupId={activeGroupId}
|
||||
allSectionsCollapsed={allSectionsCollapsed}
|
||||
branchCompare={entrySet.branchCompare}
|
||||
commitCompare={entrySet.commitCompare}
|
||||
diffCommentCount={notes.diffCommentCount}
|
||||
diffCommentsForWorktree={diffCommentsForWorktree}
|
||||
diffWordWrap={settings?.diffWordWrap}
|
||||
file={file}
|
||||
fileTreeCollapsed={preferences.fileTreeCollapsed}
|
||||
isAllMode={entrySet.isAllMode}
|
||||
isBranchMode={entrySet.isBranchMode}
|
||||
isCommitMode={entrySet.isCommitMode}
|
||||
notesCopied={notes.notesCopied}
|
||||
onCopyNotes={() => void notes.handleCopyNotes()}
|
||||
onOpenAlternateDiff={openAlternateDiff}
|
||||
onOpenClearNotes={() => notes.setClearNotesDialogOpen(true)}
|
||||
onShowFileTree={() => preferences.setFileTreeCollapsed(false)}
|
||||
previewDiffComments={notes.previewDiffComments}
|
||||
sectionCount={sections.length}
|
||||
setAllSectionsCollapsed={preferences.setAllSectionsCollapsed}
|
||||
sideBySide={preferences.sideBySide}
|
||||
toggleDiffWordWrap={preferences.toggleDiffWordWrap}
|
||||
toggleSideBySide={preferences.toggleSideBySide}
|
||||
/>
|
||||
|
||||
{commitHeader}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<CombinedDiffFileTree
|
||||
mode={entrySet.treeMode}
|
||||
worktreePath={file.filePath}
|
||||
entries={entrySet.entries}
|
||||
sectionIndexByKey={treeNavigation.sectionIndexByKey}
|
||||
activeSectionKey={treeNavigation.activeTreeSectionKey}
|
||||
viewedSectionKeys={treeNavigation.viewedSectionKeys}
|
||||
collapsed={preferences.fileTreeCollapsed}
|
||||
onCollapsedChange={preferences.setFileTreeCollapsed}
|
||||
onNavigate={treeNavigation.handleTreeNavigate}
|
||||
/>
|
||||
<CombinedDiffSectionList
|
||||
activeGroupId={activeGroupId}
|
||||
canOpenWorkspaceFileBrowserForPath={canOpenWorkspaceFileBrowserForPath}
|
||||
diffCommentsForWorktree={diffCommentsForWorktree}
|
||||
file={file}
|
||||
handleSectionSaveRef={handleSectionSaveRef}
|
||||
isAllMode={entrySet.isAllMode}
|
||||
isBranchMode={entrySet.isBranchMode}
|
||||
isCommitMode={entrySet.isCommitMode}
|
||||
isDark={isDark}
|
||||
loadSection={loadSection}
|
||||
markDirectScrollInput={markDirectScrollInput}
|
||||
modifiedEditorsRef={modifiedEditorsRef}
|
||||
onScrollbarPointerDown={handleScrollbarPointerDown}
|
||||
openSection={openSection}
|
||||
openSectionPreview={openSectionPreview}
|
||||
retrySection={retrySection}
|
||||
scrollThumb={scrollThumb}
|
||||
sectionHeights={sectionHeights}
|
||||
sections={sections}
|
||||
setScrollContainerRef={setScrollContainerRef}
|
||||
setSectionHeights={setSectionHeights}
|
||||
setSections={setSections}
|
||||
settings={settings}
|
||||
sideBySide={preferences.sideBySide}
|
||||
skippedConflictNotice={skippedConflictNotice}
|
||||
toggleSection={toggleSection}
|
||||
virtualizer={virtualizer}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ClearDiffNotesDialog
|
||||
diffCommentCount={notes.diffCommentCount}
|
||||
isClearingNotes={notes.isClearingNotes}
|
||||
onConfirm={() => void notes.handleConfirmClearNotes()}
|
||||
open={notes.clearNotesDialogVisible}
|
||||
setOpen={notes.setClearNotesDialogOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+9
-69
@@ -1,16 +1,18 @@
|
||||
import { basename } from '@/lib/path'
|
||||
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
|
||||
import type { GitStatusEntry } from '../../../../shared/git-status-types'
|
||||
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
|
||||
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
|
||||
import { isClipboardTextByteLengthOverLimit } from '../../../../../../shared/clipboard-text'
|
||||
import {
|
||||
buildSourceControlTree,
|
||||
compactSourceControlTree,
|
||||
flattenSourceControlTree
|
||||
} from '@/components/right-sidebar/source-control-tree'
|
||||
|
||||
export type CombinedDiffFileTreeMode = 'all' | 'uncommitted' | 'branch' | 'commit'
|
||||
export type CombinedDiffFileTreeEntry = GitStatusEntry | GitBranchChangeEntry
|
||||
export type CombinedDiffBranchTreeArea = 'combined-branch' | 'combined-commit'
|
||||
import {
|
||||
getCombinedDiffFileTreeSectionKey,
|
||||
isGitStatusEntry,
|
||||
type CombinedDiffBranchTreeArea,
|
||||
type CombinedDiffFileTreeEntry,
|
||||
type CombinedDiffFileTreeMode
|
||||
} from '../resolve-changes/combined-diff-section-identity'
|
||||
|
||||
export const NO_EXTENSION_KEY = '(no extension)'
|
||||
export const COMBINED_DIFF_FILE_TREE_QUERY_MAX_BYTES = 2 * 1024
|
||||
@@ -22,68 +24,6 @@ export function isCombinedDiffFileTreeQueryTooLarge(
|
||||
return isClipboardTextByteLengthOverLimit(query, maxBytes)
|
||||
}
|
||||
|
||||
export function getCombinedDiffFileTreeSectionKey(
|
||||
mode: CombinedDiffFileTreeMode,
|
||||
entry: CombinedDiffFileTreeEntry
|
||||
): string {
|
||||
if ((mode === 'all' || mode === 'uncommitted') && 'area' in entry) {
|
||||
return `${entry.area}:${entry.path}`
|
||||
}
|
||||
return `${mode === 'commit' ? 'combined-commit' : 'combined-branch'}:${entry.path}`
|
||||
}
|
||||
|
||||
export function createCombinedDiffSectionIndexMap(
|
||||
sections: readonly { key: string }[]
|
||||
): Map<string, number> {
|
||||
return new Map(sections.map((section, index) => [section.key, index]))
|
||||
}
|
||||
|
||||
export function getCombinedDiffFileTreeNavigationIndex({
|
||||
mode,
|
||||
entry,
|
||||
sectionIndexByKey
|
||||
}: {
|
||||
mode: CombinedDiffFileTreeMode
|
||||
entry: CombinedDiffFileTreeEntry
|
||||
sectionIndexByKey: ReadonlyMap<string, number>
|
||||
}): number | null {
|
||||
return sectionIndexByKey.get(getCombinedDiffFileTreeSectionKey(mode, entry)) ?? null
|
||||
}
|
||||
|
||||
export function handleCombinedDiffFileTreeNavigation({
|
||||
mode,
|
||||
entry,
|
||||
sections,
|
||||
sectionIndexByKey,
|
||||
toggleSection,
|
||||
loadSection,
|
||||
scrollToIndex
|
||||
}: {
|
||||
mode: CombinedDiffFileTreeMode
|
||||
entry: CombinedDiffFileTreeEntry
|
||||
sections: readonly { collapsed: boolean }[]
|
||||
sectionIndexByKey: ReadonlyMap<string, number>
|
||||
toggleSection: (index: number) => void
|
||||
loadSection?: (index: number) => void
|
||||
scrollToIndex: (index: number) => void
|
||||
}): number | null {
|
||||
const index = getCombinedDiffFileTreeNavigationIndex({ mode, entry, sectionIndexByKey })
|
||||
if (index === null || !sections[index]) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (sections[index].collapsed) {
|
||||
toggleSection(index)
|
||||
}
|
||||
loadSection?.(index)
|
||||
scrollToIndex(index)
|
||||
return index
|
||||
}
|
||||
|
||||
export function isGitStatusEntry(entry: CombinedDiffFileTreeEntry): entry is GitStatusEntry {
|
||||
return 'area' in entry
|
||||
}
|
||||
|
||||
export function getEntryExtension(entry: CombinedDiffFileTreeEntry): string {
|
||||
const name = basename(entry.path)
|
||||
const index = name.lastIndexOf('.')
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
getCombinedDiffFileTreeSectionKey,
|
||||
type CombinedDiffFileTreeEntry,
|
||||
type CombinedDiffFileTreeMode
|
||||
} from '../resolve-changes/combined-diff-section-identity'
|
||||
|
||||
export function getCombinedDiffFileTreeNavigationIndex({
|
||||
mode,
|
||||
entry,
|
||||
sectionIndexByKey
|
||||
}: {
|
||||
mode: CombinedDiffFileTreeMode
|
||||
entry: CombinedDiffFileTreeEntry
|
||||
sectionIndexByKey: ReadonlyMap<string, number>
|
||||
}): number | null {
|
||||
return sectionIndexByKey.get(getCombinedDiffFileTreeSectionKey(mode, entry)) ?? null
|
||||
}
|
||||
|
||||
export function handleCombinedDiffFileTreeNavigation({
|
||||
mode,
|
||||
entry,
|
||||
sections,
|
||||
sectionIndexByKey,
|
||||
toggleSection,
|
||||
loadSection,
|
||||
scrollToIndex
|
||||
}: {
|
||||
mode: CombinedDiffFileTreeMode
|
||||
entry: CombinedDiffFileTreeEntry
|
||||
sections: readonly { collapsed: boolean }[]
|
||||
sectionIndexByKey: ReadonlyMap<string, number>
|
||||
toggleSection: (index: number) => void
|
||||
loadSection?: (index: number) => void
|
||||
scrollToIndex: (index: number) => void
|
||||
}): number | null {
|
||||
const index = getCombinedDiffFileTreeNavigationIndex({ mode, entry, sectionIndexByKey })
|
||||
if (index === null || !sections[index]) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (sections[index].collapsed) {
|
||||
toggleSection(index)
|
||||
}
|
||||
loadSection?.(index)
|
||||
scrollToIndex(index)
|
||||
return index
|
||||
}
|
||||
+3
-3
@@ -6,18 +6,18 @@ import { getFileTypeIcon } from '@/lib/file-type-icons'
|
||||
import { basename, dirname, joinPath } from '@/lib/path'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
|
||||
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
|
||||
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
|
||||
import type {
|
||||
GitFileStatus,
|
||||
GitStagingArea,
|
||||
GitStatusEntry
|
||||
} from '../../../../shared/git-status-types'
|
||||
} from '../../../../../../shared/git-status-types'
|
||||
import {
|
||||
getCombinedDiffFileTreeSectionKey,
|
||||
type CombinedDiffBranchTreeArea,
|
||||
type CombinedDiffFileTreeEntry,
|
||||
type CombinedDiffFileTreeMode
|
||||
} from './combined-diff-file-tree-model'
|
||||
} from '../resolve-changes/combined-diff-section-identity'
|
||||
|
||||
export type CombinedDiffTreeNode = SourceControlTreeNode<
|
||||
GitStatusEntry | GitBranchChangeEntry,
|
||||
+7
-5
@@ -1,18 +1,20 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createCombinedDiffSectionIndexMap,
|
||||
getCombinedDiffFileTreeSectionKey
|
||||
} from '../resolve-changes/combined-diff-section-identity'
|
||||
import {
|
||||
getCombinedDiffFileTreeNavigationIndex,
|
||||
getCombinedDiffFileTreeSectionKey,
|
||||
handleCombinedDiffFileTreeNavigation
|
||||
} from './CombinedDiffFileTree'
|
||||
} from './combined-diff-file-tree-navigation'
|
||||
import {
|
||||
COMBINED_DIFF_FILE_TREE_QUERY_MAX_BYTES,
|
||||
getCombinedDiffBranchEntriesInTreeOrder,
|
||||
getFilteredCombinedDiffFileTreeEntries,
|
||||
isCombinedDiffFileTreeQueryTooLarge
|
||||
} from './combined-diff-file-tree-model'
|
||||
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
|
||||
import type { GitStatusEntry } from '../../../../shared/git-status-types'
|
||||
} from './combined-diff-file-tree-filter'
|
||||
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
|
||||
describe('CombinedDiffFileTree navigation mapping', () => {
|
||||
it('disambiguates uncommitted entries with the same path by area', () => {
|
||||
+6
-11
@@ -10,27 +10,22 @@ import {
|
||||
compactSourceControlTree,
|
||||
flattenSourceControlTree
|
||||
} from '@/components/right-sidebar/source-control-tree'
|
||||
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
|
||||
import type { GitStagingArea, GitStatusEntry } from '../../../../shared/git-status-types'
|
||||
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
|
||||
import type { GitStagingArea, GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
import {
|
||||
getEntryExtension,
|
||||
getFilteredCombinedDiffFileTreeEntries,
|
||||
getFilteredCombinedDiffFileTreeEntries
|
||||
} from './combined-diff-file-tree-filter'
|
||||
import {
|
||||
isGitStatusEntry,
|
||||
type CombinedDiffBranchTreeArea,
|
||||
type CombinedDiffFileTreeEntry,
|
||||
type CombinedDiffFileTreeMode
|
||||
} from './combined-diff-file-tree-model'
|
||||
} from '../resolve-changes/combined-diff-section-identity'
|
||||
import { CombinedDiffFileTreeRow, type CombinedDiffTreeNode } from './combined-diff-file-tree-row'
|
||||
import { useCombinedDiffFileTreeResize } from './use-combined-diff-file-tree-resize'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export {
|
||||
createCombinedDiffSectionIndexMap,
|
||||
getCombinedDiffFileTreeNavigationIndex,
|
||||
getCombinedDiffFileTreeSectionKey,
|
||||
handleCombinedDiffFileTreeNavigation
|
||||
} from './combined-diff-file-tree-model'
|
||||
|
||||
const UNCOMMITTED_AREA_ORDER: readonly GitStagingArea[] = ['unstaged', 'staged', 'untracked']
|
||||
const UNCOMMITTED_AREA_LABELS: Record<GitStagingArea, string> = {
|
||||
unstaged: 'Changes',
|
||||
+1
-1
@@ -3,7 +3,7 @@ import {
|
||||
clampCombinedDiffFileTreeWidth,
|
||||
COMBINED_DIFF_FILE_TREE_RESIZE_STEP,
|
||||
computeCombinedDiffFileTreeWidthBounds
|
||||
} from '../../../../shared/combined-diff-file-tree-width'
|
||||
} from '../../../../../../shared/combined-diff-file-tree-width'
|
||||
import { useSidebarResize } from '@/hooks/useSidebarResize'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
import type { DiffSection } from '../../diff-section-types'
|
||||
import {
|
||||
createCombinedDiffSectionIndexMap,
|
||||
type CombinedDiffFileTreeMode
|
||||
} from '../resolve-changes/combined-diff-section-identity'
|
||||
import { handleCombinedDiffFileTreeNavigation } from './combined-diff-file-tree-navigation'
|
||||
|
||||
export type CombinedDiffTreeNavigation = {
|
||||
activeTreeSectionKey: string | null
|
||||
handleTreeNavigate: (entry: GitStatusEntry | GitBranchChangeEntry) => void
|
||||
sectionIndexByKey: Map<string, number>
|
||||
sectionIndexByKeyRef: React.RefObject<ReadonlyMap<string, number>>
|
||||
viewedSectionKeys: Set<string>
|
||||
}
|
||||
|
||||
// Why: navigation targets are passed in rather than imported so this stays a leaf of the tree folder.
|
||||
export function useCombinedDiffTreeNavigation({
|
||||
ensureSectionLoaded,
|
||||
entrySignature,
|
||||
markDirectScrollInput,
|
||||
scrollToIndex,
|
||||
sections,
|
||||
sectionsRef,
|
||||
toggleSection,
|
||||
treeMode
|
||||
}: {
|
||||
ensureSectionLoaded: (index: number) => void
|
||||
entrySignature: string
|
||||
markDirectScrollInput: () => void
|
||||
scrollToIndex: (index: number) => void
|
||||
sections: DiffSection[]
|
||||
sectionsRef: React.RefObject<DiffSection[]>
|
||||
toggleSection: (index: number) => void
|
||||
treeMode: CombinedDiffFileTreeMode
|
||||
}): CombinedDiffTreeNavigation {
|
||||
const sectionIndexByKey = React.useMemo(
|
||||
() => createCombinedDiffSectionIndexMap(sections),
|
||||
[sections]
|
||||
)
|
||||
const sectionIndexByKeyRef = useRef<ReadonlyMap<string, number>>(sectionIndexByKey)
|
||||
sectionIndexByKeyRef.current = sectionIndexByKey
|
||||
|
||||
const [activeTreeSectionState, setActiveTreeSectionState] = useState<{
|
||||
entrySignature: string
|
||||
key: string | null
|
||||
}>(() => ({ entrySignature, key: null }))
|
||||
const activeTreeSectionKey =
|
||||
activeTreeSectionState.entrySignature === entrySignature ? activeTreeSectionState.key : null
|
||||
if (activeTreeSectionState.entrySignature !== entrySignature) {
|
||||
// Why: the tree highlight belongs to one entry set; reset now so it can't flash on another before an Effect would.
|
||||
setActiveTreeSectionState({ entrySignature, key: null })
|
||||
}
|
||||
const viewedSectionKeys = React.useMemo(
|
||||
() => new Set(sections.filter((section) => !section.loading).map((section) => section.key)),
|
||||
[sections]
|
||||
)
|
||||
const handleTreeNavigate = useCallback(
|
||||
(entry: GitStatusEntry | GitBranchChangeEntry) => {
|
||||
markDirectScrollInput()
|
||||
const navigatedIndex = handleCombinedDiffFileTreeNavigation({
|
||||
mode: treeMode,
|
||||
entry,
|
||||
sections: sectionsRef.current,
|
||||
sectionIndexByKey,
|
||||
toggleSection,
|
||||
loadSection: ensureSectionLoaded,
|
||||
scrollToIndex
|
||||
})
|
||||
if (navigatedIndex !== null) {
|
||||
setActiveTreeSectionState({
|
||||
entrySignature,
|
||||
key: sectionsRef.current[navigatedIndex]?.key ?? null
|
||||
})
|
||||
}
|
||||
},
|
||||
[
|
||||
ensureSectionLoaded,
|
||||
entrySignature,
|
||||
markDirectScrollInput,
|
||||
scrollToIndex,
|
||||
sectionIndexByKey,
|
||||
sectionsRef,
|
||||
toggleSection,
|
||||
treeMode
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
activeTreeSectionKey,
|
||||
handleTreeNavigate,
|
||||
sectionIndexByKey,
|
||||
sectionIndexByKeyRef,
|
||||
viewedSectionKeys
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
|
||||
import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection'
|
||||
|
||||
const initialState = useAppStore.getInitialState()
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { DiffSection } from '../../diff-section-types'
|
||||
import {
|
||||
createCombinedDiffLoadScheduler,
|
||||
type CombinedDiffLoadScheduler
|
||||
} from './combined-diff-load-scheduler'
|
||||
|
||||
// Why: git rewrites a path several times during a rebase; refetch once the writes stop.
|
||||
export const COMBINED_DIFF_SECTION_RELOAD_COALESCE_MS = 300
|
||||
|
||||
export function clearPendingSectionReloadTimers(timers: Map<number, number>): void {
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
timers.clear()
|
||||
}
|
||||
|
||||
// Why: one owner for the load bookkeeping the loader, retry and revalidation hooks all mutate,
|
||||
// and for the callback refs that break the loader <-> retry cycle.
|
||||
export type CombinedDiffSectionLoadRegistry = {
|
||||
generationRef: React.RefObject<number>
|
||||
loadSchedulerRef: React.RefObject<CombinedDiffLoadScheduler>
|
||||
loadSectionRef: React.RefObject<(index: number) => Promise<void>>
|
||||
loadedIndicesRef: React.RefObject<Set<number>>
|
||||
loadingIndicesRef: React.RefObject<Set<number>>
|
||||
reloadTimersRef: React.RefObject<Map<number, number>>
|
||||
renderedIndicesRef: React.RefObject<Set<number>>
|
||||
requestSectionReloadRef: React.RefObject<(index: number) => void>
|
||||
retrySectionRef: React.RefObject<(index: number) => void>
|
||||
sectionLoadTokensRef: React.RefObject<Map<number, number>>
|
||||
sectionsRef: React.RefObject<DiffSection[]>
|
||||
}
|
||||
|
||||
export function useCombinedDiffSectionLoadRegistry(
|
||||
sections: DiffSection[]
|
||||
): CombinedDiffSectionLoadRegistry {
|
||||
const loadedIndicesRef = useRef<Set<number>>(new Set())
|
||||
const loadingIndicesRef = useRef<Set<number>>(new Set())
|
||||
const sectionsRef = useRef<DiffSection[]>([])
|
||||
const generationRef = useRef(0)
|
||||
// Why: per-section reload token, so a sibling's reload can't discard this section's in-flight load.
|
||||
const sectionLoadTokensRef = useRef<Map<number, number>>(new Map())
|
||||
const renderedIndicesRef = useRef<Set<number>>(new Set())
|
||||
const reloadTimersRef = useRef<Map<number, number>>(new Map())
|
||||
const loadSectionRef = useRef<(index: number) => Promise<void>>(async () => {})
|
||||
const retrySectionRef = useRef<(index: number) => void>(() => {})
|
||||
const requestSectionReloadRef = useRef<(index: number) => void>(() => {})
|
||||
const loadSchedulerRef = useRef(
|
||||
createCombinedDiffLoadScheduler({
|
||||
loadSection: (index) => loadSectionRef.current(index)
|
||||
})
|
||||
)
|
||||
sectionsRef.current = sections
|
||||
|
||||
useEffect(() => {
|
||||
// Why: React StrictMode replays effect cleanup in dev; reset revives the scheduler for the replayed mount.
|
||||
const scheduler = loadSchedulerRef.current
|
||||
const reloadTimers = reloadTimersRef.current
|
||||
scheduler.reset()
|
||||
return () => {
|
||||
clearPendingSectionReloadTimers(reloadTimers)
|
||||
scheduler.dispose()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
generationRef,
|
||||
loadSchedulerRef,
|
||||
loadSectionRef,
|
||||
loadedIndicesRef,
|
||||
loadingIndicesRef,
|
||||
reloadTimersRef,
|
||||
renderedIndicesRef,
|
||||
requestSectionReloadRef,
|
||||
retrySectionRef,
|
||||
sectionLoadTokensRef,
|
||||
sectionsRef
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GitDiffResult } from '../../../../shared/git-diff-compare-types'
|
||||
import type { LargeDiffRenderLimit } from './large-diff-render-limit'
|
||||
import type { GitDiffResult } from '../../../../../../shared/git-diff-compare-types'
|
||||
import type { LargeDiffRenderLimit } from '../../large-diff-render-limit'
|
||||
import {
|
||||
isUnchangedDiffSectionReload,
|
||||
shouldRequestCombinedDiffSectionLoad
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import type { DiffLineCounts, LargeDiffRenderLimit } from './large-diff-render-limit'
|
||||
import type { DiffSection } from './diff-section-types'
|
||||
import type { DiffLineCounts, LargeDiffRenderLimit } from '../../large-diff-render-limit'
|
||||
import type { DiffSection } from '../../diff-section-types'
|
||||
|
||||
// Why: `diffResult === null` subsumes a dirty check — `dirty` is only ever set from a mounted
|
||||
// editor's content compare, which implies content was already loaded.
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
// Why: local Electron IPC has no RPC timeout; a hung git diff must become a retryable row error, not permanent "Loading...".
|
||||
const COMBINED_DIFF_SECTION_LOAD_TIMEOUT_MS = 30_000
|
||||
|
||||
class CombinedDiffSectionLoadTimeoutError extends Error {
|
||||
constructor() {
|
||||
super('Diff did not finish loading.')
|
||||
this.name = 'CombinedDiffSectionLoadTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
export function withDiffSectionLoadTimeout<T>(promise: Promise<T>): Promise<T> {
|
||||
let timeoutId: number | null = null
|
||||
|
||||
const timeoutPromise = new Promise<T>((_, reject) => {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
reject(new CombinedDiffSectionLoadTimeoutError())
|
||||
}, COMBINED_DIFF_SECTION_LOAD_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeoutId !== null) {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getDiffSectionLoadErrorMessage(error: unknown): string {
|
||||
if (error instanceof CombinedDiffSectionLoadTimeoutError) {
|
||||
return 'Diff did not finish loading.'
|
||||
}
|
||||
return error instanceof Error && error.message.trim().length > 0
|
||||
? error.message
|
||||
: 'Unable to load diff.'
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
getRuntimeGitBranchDiff,
|
||||
getRuntimeGitCommitDiff,
|
||||
getRuntimeGitDiff
|
||||
} from '@/runtime/runtime-git-client'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type {
|
||||
GitBranchChangeEntry,
|
||||
GitDiffResult
|
||||
} from '../../../../../../shared/git-diff-compare-types'
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection'
|
||||
import { withDiffSectionLoadTimeout } from './combined-diff-section-load-timeout'
|
||||
|
||||
export function fetchCombinedDiffSection({
|
||||
branchCompare,
|
||||
commitCompare,
|
||||
entry,
|
||||
file,
|
||||
isAllMode,
|
||||
isBranchMode,
|
||||
isCommitMode
|
||||
}: {
|
||||
branchCompare: NonNullable<OpenFile['branchCompare']> | null
|
||||
commitCompare: NonNullable<OpenFile['commitCompare']> | null
|
||||
entry: GitStatusEntry | GitBranchChangeEntry
|
||||
file: OpenFile
|
||||
isAllMode: boolean
|
||||
isBranchMode: boolean
|
||||
isCommitMode: boolean
|
||||
}): Promise<GitDiffResult> {
|
||||
const connectionId = getCombinedDiffSectionConnectionId(
|
||||
file.worktreeId,
|
||||
file.filePath,
|
||||
entry.path
|
||||
)
|
||||
const state = useAppStore.getState()
|
||||
const fileSettings = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId)
|
||||
if ((isBranchMode || (isAllMode && !('area' in entry))) && branchCompare) {
|
||||
return withDiffSectionLoadTimeout(
|
||||
getRuntimeGitBranchDiff(
|
||||
{
|
||||
settings: fileSettings,
|
||||
worktreeId: file.worktreeId,
|
||||
worktreePath: file.filePath,
|
||||
connectionId
|
||||
},
|
||||
{
|
||||
compare: {
|
||||
baseRef: branchCompare.baseRef,
|
||||
baseOid: branchCompare.baseOid!,
|
||||
headOid: branchCompare.headOid!,
|
||||
mergeBase: branchCompare.mergeBase!
|
||||
},
|
||||
filePath: entry.path,
|
||||
oldPath: entry.oldPath
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
if (isCommitMode && commitCompare) {
|
||||
return withDiffSectionLoadTimeout(
|
||||
getRuntimeGitCommitDiff(
|
||||
{
|
||||
settings: fileSettings,
|
||||
worktreeId: file.worktreeId,
|
||||
worktreePath: file.filePath,
|
||||
connectionId
|
||||
},
|
||||
{
|
||||
commitOid: commitCompare.commitOid,
|
||||
parentOid: commitCompare.parentOid,
|
||||
filePath: entry.path,
|
||||
oldPath: entry.oldPath
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
return withDiffSectionLoadTimeout(
|
||||
getRuntimeGitDiff(
|
||||
{
|
||||
settings: fileSettings,
|
||||
worktreeId: file.worktreeId,
|
||||
worktreePath: file.filePath,
|
||||
connectionId
|
||||
},
|
||||
{
|
||||
filePath: entry.path,
|
||||
staged: 'area' in entry && entry.area === 'staged'
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import type React from 'react'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type {
|
||||
GitBranchChangeEntry,
|
||||
GitDiffResult
|
||||
} from '../../../../../../shared/git-diff-compare-types'
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
import type { DiffSection } from '../../diff-section-types'
|
||||
import { getLargeDiffRenderLimit } from '../../large-diff-render-limit'
|
||||
import { getStoredTextDiffContent, getStoredTextDiffResult } from '../../large-diff-section-content'
|
||||
import { removeDiffSectionMeasuredHeight } from '../../diff-section-height-cache'
|
||||
import type { CombinedDiffEntrySet } from '../resolve-changes/use-combined-diff-entry-set'
|
||||
import { fetchCombinedDiffSection } from './fetch-combined-diff-section'
|
||||
import { getDiffSectionLoadErrorMessage } from './combined-diff-section-load-timeout'
|
||||
import { getInitialCombinedDiffSectionLoadIndices } from './combined-diff-initial-section-load'
|
||||
import { isUnchangedDiffSectionReload } from './combined-diff-section-load-state'
|
||||
import type { CombinedDiffSectionLoadRegistry } from './combined-diff-section-load-registry'
|
||||
|
||||
export function useCombinedDiffSectionLoader({
|
||||
entrySet,
|
||||
file,
|
||||
registry,
|
||||
sectionCount,
|
||||
setSectionHeights,
|
||||
setSections
|
||||
}: {
|
||||
entrySet: CombinedDiffEntrySet
|
||||
file: OpenFile
|
||||
registry: CombinedDiffSectionLoadRegistry
|
||||
sectionCount: number
|
||||
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
|
||||
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
|
||||
}): (index: number) => void {
|
||||
const {
|
||||
allEntries,
|
||||
branchCompare,
|
||||
commitCompare,
|
||||
commitEntries,
|
||||
entrySignature,
|
||||
isAllMode,
|
||||
isBranchMode,
|
||||
isCommitMode,
|
||||
renderableBranchEntries,
|
||||
uncommittedEntries
|
||||
} = entrySet
|
||||
const {
|
||||
generationRef,
|
||||
loadSchedulerRef,
|
||||
loadSectionRef,
|
||||
loadedIndicesRef,
|
||||
loadingIndicesRef,
|
||||
requestSectionReloadRef,
|
||||
sectionLoadTokensRef,
|
||||
sectionsRef
|
||||
} = registry
|
||||
|
||||
const loadSectionNow = useCallback(
|
||||
async (index: number) => {
|
||||
if (loadedIndicesRef.current.has(index) || loadingIndicesRef.current.has(index)) {
|
||||
return
|
||||
}
|
||||
loadingIndicesRef.current.add(index)
|
||||
|
||||
const gen = generationRef.current
|
||||
const loadToken = sectionLoadTokensRef.current.get(index) ?? 0
|
||||
const entries: (GitStatusEntry | GitBranchChangeEntry)[] = isAllMode
|
||||
? allEntries
|
||||
: isBranchMode
|
||||
? renderableBranchEntries
|
||||
: isCommitMode
|
||||
? commitEntries
|
||||
: uncommittedEntries
|
||||
const entry = entries[index]
|
||||
if (!entry) {
|
||||
loadingIndicesRef.current.delete(index)
|
||||
return
|
||||
}
|
||||
|
||||
let result: GitDiffResult
|
||||
let error: string | undefined
|
||||
try {
|
||||
result = await fetchCombinedDiffSection({
|
||||
branchCompare,
|
||||
commitCompare,
|
||||
entry,
|
||||
file,
|
||||
isAllMode,
|
||||
isBranchMode,
|
||||
isCommitMode
|
||||
})
|
||||
} catch (err) {
|
||||
error = getDiffSectionLoadErrorMessage(err)
|
||||
result = {
|
||||
kind: 'text',
|
||||
originalContent: '',
|
||||
modifiedContent: '',
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: false
|
||||
} as GitDiffResult
|
||||
}
|
||||
|
||||
const largeDiffRenderLimit =
|
||||
!error && result.kind === 'text'
|
||||
? (result.largeDiffRenderLimit ??
|
||||
getLargeDiffRenderLimit({
|
||||
originalContent: result.originalContent,
|
||||
modifiedContent: result.modifiedContent
|
||||
}))
|
||||
: null
|
||||
|
||||
if (generationRef.current !== gen) {
|
||||
// Why: the generation reset already cleared the in-flight set, and a newer load for this
|
||||
// index may own the entry now — deleting it here would hide that load from the guard above.
|
||||
return
|
||||
}
|
||||
loadingIndicesRef.current.delete(index)
|
||||
if ((sectionLoadTokensRef.current.get(index) ?? 0) !== loadToken) {
|
||||
// Why: an invalidation landed mid-flight and deferred its reload to this settle point, so
|
||||
// the refetch happens once here instead of racing a second fetch against this one.
|
||||
requestSectionReloadRef.current(index)
|
||||
return
|
||||
}
|
||||
const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit)
|
||||
const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit)
|
||||
loadedIndicesRef.current.add(index)
|
||||
const current = sectionsRef.current[index]
|
||||
// A revalidation lands on a section that is already showing content. If the refetch matches
|
||||
// what's on screen, committing it would swap Monaco models and re-measure for nothing.
|
||||
const wasShowingContent = current !== undefined && !current.loading
|
||||
if (
|
||||
wasShowingContent &&
|
||||
isUnchangedDiffSectionReload(current, {
|
||||
diffResult: storedResult,
|
||||
error,
|
||||
largeDiffRenderLimit,
|
||||
originalContent: storedContent.originalContent,
|
||||
modifiedContent: storedContent.modifiedContent
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (wasShowingContent) {
|
||||
// Why: content really changed, so the old Monaco height no longer describes this row.
|
||||
setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index))
|
||||
}
|
||||
setSections((prev) => {
|
||||
return prev.map((s, i) =>
|
||||
i === index
|
||||
? {
|
||||
...s,
|
||||
diffResult: storedResult,
|
||||
originalContent: storedContent.originalContent,
|
||||
modifiedContent: storedContent.modifiedContent,
|
||||
loading: false,
|
||||
error,
|
||||
largeDiffRenderLimit,
|
||||
// Why: models are keyed by path, so a changed refetch must not reuse the old model.
|
||||
contentGeneration: wasShowingContent
|
||||
? (s.contentGeneration ?? 0) + 1
|
||||
: s.contentGeneration
|
||||
}
|
||||
: s
|
||||
)
|
||||
})
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
branchCompare?.baseOid,
|
||||
branchCompare?.headOid,
|
||||
branchCompare?.mergeBase,
|
||||
allEntries,
|
||||
commitCompare?.commitOid,
|
||||
commitCompare?.parentOid,
|
||||
commitEntries,
|
||||
file.filePath,
|
||||
file.runtimeEnvironmentId,
|
||||
isAllMode,
|
||||
isBranchMode,
|
||||
isCommitMode,
|
||||
renderableBranchEntries,
|
||||
uncommittedEntries
|
||||
]
|
||||
)
|
||||
loadSectionRef.current = loadSectionNow
|
||||
|
||||
// Progressive loading: queue diff content when a section becomes visible.
|
||||
const loadSection = useCallback(
|
||||
(index: number) => {
|
||||
if (sectionsRef.current[index]?.collapsed) {
|
||||
return
|
||||
}
|
||||
loadSchedulerRef.current.request(index)
|
||||
},
|
||||
[loadSchedulerRef, sectionsRef]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Why: queue the first rows deterministically so the visible viewport doesn't depend on IntersectionObserver delivery.
|
||||
const currentSections = sectionsRef.current
|
||||
for (let index = 0; index < currentSections.length; index += 1) {
|
||||
if (currentSections[index]?.loading && loadedIndicesRef.current.has(index)) {
|
||||
loadedIndicesRef.current.delete(index)
|
||||
}
|
||||
}
|
||||
|
||||
const initialIndices = getInitialCombinedDiffSectionLoadIndices({
|
||||
sectionCount: currentSections.length,
|
||||
loadedIndices: loadedIndicesRef.current
|
||||
})
|
||||
|
||||
for (const index of initialIndices) {
|
||||
if (!currentSections[index]?.collapsed) {
|
||||
loadSection(index)
|
||||
}
|
||||
}
|
||||
}, [entrySignature, loadSection, loadedIndicesRef, sectionCount, sectionsRef])
|
||||
|
||||
return loadSection
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { useCallback } from 'react'
|
||||
import type React from 'react'
|
||||
import type { DiffSection } from '../../diff-section-types'
|
||||
import { removeDiffSectionMeasuredHeight } from '../../diff-section-height-cache'
|
||||
import { shouldRequestCombinedDiffSectionLoad } from './combined-diff-section-load-state'
|
||||
import {
|
||||
COMBINED_DIFF_SECTION_RELOAD_COALESCE_MS,
|
||||
type CombinedDiffSectionLoadRegistry
|
||||
} from './combined-diff-section-load-registry'
|
||||
|
||||
export type CombinedDiffSectionRetryActions = {
|
||||
ensureSectionLoaded: (index: number) => void
|
||||
requestSectionReload: (index: number) => void
|
||||
retrySection: (index: number) => void
|
||||
}
|
||||
|
||||
export function useCombinedDiffSectionRetry({
|
||||
invalidateViewStateCache,
|
||||
registry,
|
||||
setSectionHeights,
|
||||
setSections
|
||||
}: {
|
||||
invalidateViewStateCache: () => void
|
||||
registry: CombinedDiffSectionLoadRegistry
|
||||
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
|
||||
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
|
||||
}): CombinedDiffSectionRetryActions {
|
||||
const {
|
||||
loadSchedulerRef,
|
||||
loadedIndicesRef,
|
||||
loadingIndicesRef,
|
||||
reloadTimersRef,
|
||||
renderedIndicesRef,
|
||||
requestSectionReloadRef,
|
||||
retrySectionRef,
|
||||
sectionLoadTokensRef,
|
||||
sectionsRef
|
||||
} = registry
|
||||
|
||||
const retrySection = useCallback(
|
||||
(index: number) => {
|
||||
const collapsed = sectionsRef.current[index]?.collapsed ?? false
|
||||
loadedIndicesRef.current.delete(index)
|
||||
loadingIndicesRef.current.delete(index)
|
||||
invalidateViewStateCache()
|
||||
// Why: reloading one section must not bump the global generation — that is part of
|
||||
// the virtualizer item key, so it would remount every rendered Monaco editor (STA-3420).
|
||||
sectionLoadTokensRef.current.set(index, (sectionLoadTokensRef.current.get(index) ?? 0) + 1)
|
||||
const coalesced = reloadTimersRef.current.get(index)
|
||||
if (coalesced !== undefined) {
|
||||
window.clearTimeout(coalesced)
|
||||
reloadTimersRef.current.delete(index)
|
||||
}
|
||||
setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index))
|
||||
setSections((prev) =>
|
||||
prev.map((section, sectionIndex) =>
|
||||
sectionIndex === index
|
||||
? {
|
||||
...section,
|
||||
loading: !collapsed,
|
||||
error: undefined,
|
||||
diffResult: null,
|
||||
originalContent: '',
|
||||
modifiedContent: '',
|
||||
largeDiffRenderLimit: null,
|
||||
contentGeneration: (section.contentGeneration ?? 0) + 1
|
||||
}
|
||||
: section
|
||||
)
|
||||
)
|
||||
if (collapsed) {
|
||||
return
|
||||
}
|
||||
loadSchedulerRef.current.rerequest(index)
|
||||
},
|
||||
[
|
||||
invalidateViewStateCache,
|
||||
loadSchedulerRef,
|
||||
loadedIndicesRef,
|
||||
loadingIndicesRef,
|
||||
reloadTimersRef,
|
||||
sectionLoadTokensRef,
|
||||
sectionsRef,
|
||||
setSectionHeights,
|
||||
setSections
|
||||
]
|
||||
)
|
||||
retrySectionRef.current = retrySection
|
||||
|
||||
// Why: invalidation (rebase/commit/external write) revalidates in place — it must not tear the
|
||||
// section down first. Clearing content up front forces a Monaco remodel even when the refetched
|
||||
// diff is identical, which is what wedged the renderer during a rebase (STA-3420).
|
||||
const requestSectionReload = useCallback(
|
||||
(index: number): void => {
|
||||
const section = sectionsRef.current[index]
|
||||
if (!section || section.dirty) {
|
||||
return
|
||||
}
|
||||
loadedIndicesRef.current.delete(index)
|
||||
invalidateViewStateCache()
|
||||
sectionLoadTokensRef.current.set(index, (sectionLoadTokensRef.current.get(index) ?? 0) + 1)
|
||||
if (loadingIndicesRef.current.has(index)) {
|
||||
// Why: the in-flight load now carries a stale token, so it re-drives this reload when it
|
||||
// settles. Scheduling one here would fetch the same large diff a second time.
|
||||
return
|
||||
}
|
||||
if (section.collapsed || !renderedIndicesRef.current.has(index)) {
|
||||
// Why: a rebase invalidates every touched path at once. Refetching off-screen sections is
|
||||
// unbounded work nobody can see; the row reloads on mount once it scrolls into view.
|
||||
return
|
||||
}
|
||||
// Why: a rebase touches the same path many times over a few seconds. Without coalescing
|
||||
// each touch refetches a whole diff, and the payload churn alone stalls the renderer.
|
||||
const pending = reloadTimersRef.current.get(index)
|
||||
if (pending !== undefined) {
|
||||
window.clearTimeout(pending)
|
||||
}
|
||||
reloadTimersRef.current.set(
|
||||
index,
|
||||
window.setTimeout(() => {
|
||||
reloadTimersRef.current.delete(index)
|
||||
loadSchedulerRef.current.rerequest(index)
|
||||
}, COMBINED_DIFF_SECTION_RELOAD_COALESCE_MS)
|
||||
)
|
||||
},
|
||||
[
|
||||
invalidateViewStateCache,
|
||||
loadSchedulerRef,
|
||||
loadedIndicesRef,
|
||||
loadingIndicesRef,
|
||||
reloadTimersRef,
|
||||
renderedIndicesRef,
|
||||
sectionLoadTokensRef,
|
||||
sectionsRef
|
||||
]
|
||||
)
|
||||
requestSectionReloadRef.current = requestSectionReload
|
||||
|
||||
const ensureSectionLoaded = useCallback(
|
||||
(index: number): void => {
|
||||
const section = sectionsRef.current[index]
|
||||
if (!shouldRequestCombinedDiffSectionLoad(section, loadingIndicesRef.current.has(index))) {
|
||||
return
|
||||
}
|
||||
loadedIndicesRef.current.delete(index)
|
||||
loadSchedulerRef.current.request(index)
|
||||
},
|
||||
[loadSchedulerRef, loadedIndicesRef, loadingIndicesRef, sectionsRef]
|
||||
)
|
||||
|
||||
return { ensureSectionLoaded, requestSectionReload, retrySection }
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
import type { DiffSection } from '../../diff-section-types'
|
||||
import {
|
||||
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
|
||||
type EditorPathMutationTarget
|
||||
} from '../../editor-autosave'
|
||||
import { buildCombinedGitStatusSignature } from '../resolve-changes/combined-diff-git-status-signature'
|
||||
import {
|
||||
getCombinedDiffFileTreeSectionKey,
|
||||
type CombinedDiffFileTreeMode
|
||||
} from '../resolve-changes/combined-diff-section-identity'
|
||||
import type { CombinedDiffSectionLoadRegistry } from './combined-diff-section-load-registry'
|
||||
|
||||
// Why: git status and on-disk writes both revalidate loaded rows; one hook owns both watches so
|
||||
// they cannot disagree about which sections are eligible.
|
||||
export function useCombinedDiffSectionRevalidation({
|
||||
file,
|
||||
gitStatusEntries,
|
||||
registry,
|
||||
requestSectionReload,
|
||||
sectionIndexByKeyRef,
|
||||
sections,
|
||||
shouldAutoReloadFromGitStatus,
|
||||
treeMode
|
||||
}: {
|
||||
file: OpenFile
|
||||
gitStatusEntries: GitStatusEntry[]
|
||||
registry: CombinedDiffSectionLoadRegistry
|
||||
requestSectionReload: (index: number) => void
|
||||
sectionIndexByKeyRef: React.RefObject<ReadonlyMap<string, number>>
|
||||
sections: DiffSection[]
|
||||
shouldAutoReloadFromGitStatus: boolean
|
||||
treeMode: CombinedDiffFileTreeMode
|
||||
}): string {
|
||||
const { loadedIndicesRef } = registry
|
||||
const combinedGitStatusSignature = React.useMemo(() => {
|
||||
if (!shouldAutoReloadFromGitStatus) {
|
||||
return ''
|
||||
}
|
||||
return buildCombinedGitStatusSignature(sections, gitStatusEntries)
|
||||
}, [gitStatusEntries, sections, shouldAutoReloadFromGitStatus])
|
||||
const prevCombinedGitStatusSignatureRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoReloadFromGitStatus) {
|
||||
prevCombinedGitStatusSignatureRef.current = null
|
||||
return
|
||||
}
|
||||
if (prevCombinedGitStatusSignatureRef.current === null) {
|
||||
prevCombinedGitStatusSignatureRef.current = combinedGitStatusSignature
|
||||
return
|
||||
}
|
||||
if (prevCombinedGitStatusSignatureRef.current === combinedGitStatusSignature) {
|
||||
return
|
||||
}
|
||||
prevCombinedGitStatusSignatureRef.current = combinedGitStatusSignature
|
||||
for (const index of loadedIndicesRef.current) {
|
||||
requestSectionReload(index)
|
||||
}
|
||||
}, [
|
||||
combinedGitStatusSignature,
|
||||
loadedIndicesRef,
|
||||
requestSectionReload,
|
||||
shouldAutoReloadFromGitStatus
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (treeMode !== 'all' && treeMode !== 'uncommitted') {
|
||||
return
|
||||
}
|
||||
const handler = (event: Event): void => {
|
||||
const detail = (event as CustomEvent<EditorPathMutationTarget>).detail
|
||||
if (!detail || detail.worktreeId !== file.worktreeId) {
|
||||
return
|
||||
}
|
||||
const hasRuntimeOwnerFilter = Object.hasOwn(detail, 'runtimeEnvironmentId')
|
||||
const targetRuntimeOwner = detail.runtimeEnvironmentId?.trim() || null
|
||||
const fileRuntimeOwner = file.runtimeEnvironmentId?.trim() || null
|
||||
if (hasRuntimeOwnerFilter && targetRuntimeOwner !== fileRuntimeOwner) {
|
||||
return
|
||||
}
|
||||
for (const area of ['unstaged', 'staged', 'untracked'] as const) {
|
||||
const key = getCombinedDiffFileTreeSectionKey('uncommitted', {
|
||||
path: detail.relativePath,
|
||||
status: 'modified',
|
||||
area
|
||||
})
|
||||
const index = sectionIndexByKeyRef.current.get(key)
|
||||
if (index !== undefined) {
|
||||
requestSectionReload(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener)
|
||||
return () =>
|
||||
window.removeEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener)
|
||||
}, [
|
||||
file.runtimeEnvironmentId,
|
||||
file.worktreeId,
|
||||
requestSectionReload,
|
||||
sectionIndexByKeyRef,
|
||||
treeMode
|
||||
])
|
||||
|
||||
return combinedGitStatusSignature
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor'
|
||||
import type { DiffSection } from '../../diff-section-types'
|
||||
import {
|
||||
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
|
||||
type EditorPathMutationTarget
|
||||
} from '../../editor-autosave'
|
||||
|
||||
export type CachedCombinedDiffViewState = {
|
||||
entrySignature: string
|
||||
gitStatusSignature: string
|
||||
sections: DiffSection[]
|
||||
sectionHeights: Record<number, number>
|
||||
loadedIndices: number[]
|
||||
scrollTop: number
|
||||
sideBySide: boolean
|
||||
}
|
||||
|
||||
export const combinedDiffViewStateCache = new Map<string, CachedCombinedDiffViewState>()
|
||||
export const combinedDiffScrollTopCache = new Map<string, number>()
|
||||
export const combinedDiffScrollAnchorCache = new Map<string, VirtualizedScrollAnchor>()
|
||||
|
||||
// Why: session-scoped toolbar choices outlive the unmount, so they are module state rather than component state.
|
||||
export const combinedDiffViewPreferences: {
|
||||
collapsed: boolean | null
|
||||
sideBySide: boolean | null
|
||||
fileTreeCollapsed: boolean | null
|
||||
} = { collapsed: null, sideBySide: null, fileTreeCollapsed: null }
|
||||
|
||||
function invalidateCombinedDiffCachesForRelativePath(relativePath: string): void {
|
||||
for (const [key, cached] of combinedDiffViewStateCache.entries()) {
|
||||
if (cached.sections.some((section) => section.path === relativePath)) {
|
||||
combinedDiffViewStateCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, (event) => {
|
||||
const detail = (event as CustomEvent<EditorPathMutationTarget>).detail
|
||||
if (detail?.relativePath) {
|
||||
// Why: inactive combined-diff tabs are unmounted, so only a module-level cache bust stops a remount replaying stale bodies.
|
||||
invalidateCombinedDiffCachesForRelativePath(detail.relativePath)
|
||||
}
|
||||
})
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { useEffect } from 'react'
|
||||
import type React from 'react'
|
||||
import { setWithLRU } from '@/lib/scroll-cache'
|
||||
import type { DiffSection } from '../../diff-section-types'
|
||||
import { combinedDiffScrollTopCache, combinedDiffViewStateCache } from './combined-diff-view-memory'
|
||||
|
||||
export function useCombinedDiffViewPersist({
|
||||
combinedGitStatusSignature,
|
||||
entryCount,
|
||||
entrySignature,
|
||||
scrollContainerRef,
|
||||
sectionHeights,
|
||||
sections,
|
||||
loadedIndicesRef,
|
||||
sideBySide,
|
||||
viewStateKey
|
||||
}: {
|
||||
combinedGitStatusSignature: string
|
||||
entryCount: number
|
||||
entrySignature: string
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>
|
||||
sectionHeights: Record<number, number>
|
||||
sections: DiffSection[]
|
||||
loadedIndicesRef: React.RefObject<Set<number>>
|
||||
sideBySide: boolean
|
||||
viewStateKey: string
|
||||
}): void {
|
||||
useEffect(() => {
|
||||
if (sections.length === 0 && entryCount > 0) {
|
||||
return
|
||||
}
|
||||
const preservedScrollTop =
|
||||
combinedDiffScrollTopCache.get(viewStateKey) ?? scrollContainerRef.current?.scrollTop ?? 0
|
||||
setWithLRU(combinedDiffViewStateCache, viewStateKey, {
|
||||
entrySignature,
|
||||
gitStatusSignature: combinedGitStatusSignature,
|
||||
sections,
|
||||
sectionHeights,
|
||||
loadedIndices: Array.from(loadedIndicesRef.current).filter(
|
||||
(index) => !sections[index]?.loading
|
||||
),
|
||||
scrollTop: preservedScrollTop,
|
||||
sideBySide
|
||||
})
|
||||
}, [
|
||||
combinedGitStatusSignature,
|
||||
entryCount,
|
||||
entrySignature,
|
||||
loadedIndicesRef,
|
||||
scrollContainerRef,
|
||||
sectionHeights,
|
||||
sections,
|
||||
sideBySide,
|
||||
viewStateKey
|
||||
])
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react'
|
||||
import type React from 'react'
|
||||
import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor'
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
import type { DiffSection } from '../../diff-section-types'
|
||||
import { buildCombinedGitStatusSignature } from '../resolve-changes/combined-diff-git-status-signature'
|
||||
import { combinedDiffSectionsMatchEntryMetadata } from '../resolve-changes/combined-diff-section-cache-match'
|
||||
import { getCombinedDiffFileTreeSectionKey } from '../resolve-changes/combined-diff-section-identity'
|
||||
import type { CombinedDiffEntrySet } from '../resolve-changes/use-combined-diff-entry-set'
|
||||
import type { CombinedDiffSectionLoadRegistry } from '../load-sections/combined-diff-section-load-registry'
|
||||
import { clearPendingSectionReloadTimers } from '../load-sections/combined-diff-section-load-registry'
|
||||
import {
|
||||
combinedDiffScrollAnchorCache,
|
||||
combinedDiffScrollTopCache,
|
||||
combinedDiffViewPreferences,
|
||||
combinedDiffViewStateCache
|
||||
} from './combined-diff-view-memory'
|
||||
|
||||
export type CombinedDiffViewRestore = {
|
||||
invalidateViewStateCache: () => void
|
||||
latestDomScrollAnchorRef: React.RefObject<VirtualizedScrollAnchor>
|
||||
scrollAnchorRef: React.RefObject<VirtualizedScrollAnchor>
|
||||
scrollOffsetRef: React.RefObject<number>
|
||||
}
|
||||
|
||||
export function useCombinedDiffViewRestore({
|
||||
entrySet,
|
||||
gitStatusEntries,
|
||||
registry,
|
||||
setGeneration,
|
||||
setSectionHeights,
|
||||
setSections,
|
||||
setSideBySide,
|
||||
viewStateKey
|
||||
}: {
|
||||
entrySet: CombinedDiffEntrySet
|
||||
gitStatusEntries: GitStatusEntry[]
|
||||
registry: CombinedDiffSectionLoadRegistry
|
||||
setGeneration: React.Dispatch<React.SetStateAction<number>>
|
||||
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
|
||||
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
|
||||
setSideBySide: React.Dispatch<React.SetStateAction<boolean>>
|
||||
viewStateKey: string
|
||||
}): CombinedDiffViewRestore {
|
||||
const {
|
||||
entries,
|
||||
entrySignature,
|
||||
hasUncommittedEntriesSnapshot,
|
||||
shouldAutoReloadFromGitStatus,
|
||||
treeMode
|
||||
} = entrySet
|
||||
const {
|
||||
generationRef,
|
||||
loadSchedulerRef,
|
||||
loadedIndicesRef,
|
||||
loadingIndicesRef,
|
||||
reloadTimersRef,
|
||||
sectionLoadTokensRef
|
||||
} = registry
|
||||
|
||||
const scrollOffsetRef = useRef(combinedDiffScrollTopCache.get(viewStateKey) ?? 0)
|
||||
const scrollAnchorRef = useRef<VirtualizedScrollAnchor>(
|
||||
combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
|
||||
)
|
||||
const latestDomScrollAnchorRef = useRef<VirtualizedScrollAnchor>(
|
||||
combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
|
||||
)
|
||||
|
||||
// Why: tab/worktree switches unmount this viewer; cache by pane key so remount restores sections+scroll before repaint.
|
||||
useLayoutEffect(() => {
|
||||
const cached = combinedDiffViewStateCache.get(viewStateKey)
|
||||
const canRestoreSnapshotSectionsByKey =
|
||||
hasUncommittedEntriesSnapshot &&
|
||||
cached !== undefined &&
|
||||
combinedDiffSectionsMatchEntryMetadata({
|
||||
entries,
|
||||
sections: cached.sections,
|
||||
treeMode
|
||||
})
|
||||
const canRestoreCachedSections =
|
||||
cached &&
|
||||
(cached.entrySignature === entrySignature || canRestoreSnapshotSectionsByKey) &&
|
||||
(!shouldAutoReloadFromGitStatus ||
|
||||
(cached.gitStatusSignature ?? '') ===
|
||||
buildCombinedGitStatusSignature(cached.sections, gitStatusEntries)) &&
|
||||
(cached.sections.length > 0 || entries.length === 0)
|
||||
if (canRestoreCachedSections && cached) {
|
||||
const collapsedPreference = combinedDiffViewPreferences.collapsed
|
||||
const restoredSections =
|
||||
collapsedPreference === null
|
||||
? cached.sections
|
||||
: cached.sections.map((section) => ({
|
||||
...section,
|
||||
collapsed: collapsedPreference
|
||||
}))
|
||||
setSections(restoredSections)
|
||||
setSectionHeights(cached.sectionHeights)
|
||||
setSideBySide(combinedDiffViewPreferences.sideBySide ?? cached.sideBySide)
|
||||
loadedIndicesRef.current = new Set(
|
||||
cached.loadedIndices.filter((index) => !restoredSections[index]?.loading)
|
||||
)
|
||||
loadingIndicesRef.current.clear()
|
||||
scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
|
||||
scrollAnchorRef.current = combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
|
||||
latestDomScrollAnchorRef.current = scrollAnchorRef.current
|
||||
return
|
||||
}
|
||||
|
||||
scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? 0
|
||||
scrollAnchorRef.current = combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
|
||||
latestDomScrollAnchorRef.current = scrollAnchorRef.current
|
||||
setSections(
|
||||
entries.map((entry) => ({
|
||||
key: getCombinedDiffFileTreeSectionKey(treeMode, entry),
|
||||
path: entry.path,
|
||||
status: entry.status,
|
||||
area: 'area' in entry ? entry.area : undefined,
|
||||
oldPath: entry.oldPath,
|
||||
added: 'added' in entry ? entry.added : undefined,
|
||||
removed: 'removed' in entry ? entry.removed : undefined,
|
||||
originalContent: '',
|
||||
modifiedContent: '',
|
||||
collapsed: combinedDiffViewPreferences.collapsed ?? false,
|
||||
loading: true,
|
||||
error: undefined,
|
||||
dirty: false,
|
||||
diffResult: null,
|
||||
largeDiffRenderLimit: null
|
||||
}))
|
||||
)
|
||||
setSectionHeights({})
|
||||
loadedIndicesRef.current.clear()
|
||||
loadingIndicesRef.current.clear()
|
||||
sectionLoadTokensRef.current.clear()
|
||||
clearPendingSectionReloadTimers(reloadTimersRef.current)
|
||||
loadSchedulerRef.current.reset()
|
||||
generationRef.current += 1
|
||||
setGeneration((prev) => prev + 1)
|
||||
}, [
|
||||
entries,
|
||||
entrySignature,
|
||||
generationRef,
|
||||
gitStatusEntries,
|
||||
hasUncommittedEntriesSnapshot,
|
||||
loadSchedulerRef,
|
||||
loadedIndicesRef,
|
||||
loadingIndicesRef,
|
||||
reloadTimersRef,
|
||||
sectionLoadTokensRef,
|
||||
setGeneration,
|
||||
setSectionHeights,
|
||||
setSections,
|
||||
setSideBySide,
|
||||
shouldAutoReloadFromGitStatus,
|
||||
treeMode,
|
||||
viewStateKey
|
||||
])
|
||||
|
||||
const invalidateViewStateCache = useCallback((): void => {
|
||||
combinedDiffViewStateCache.delete(viewStateKey)
|
||||
}, [viewStateKey])
|
||||
|
||||
return {
|
||||
invalidateViewStateCache,
|
||||
latestDomScrollAnchorRef,
|
||||
scrollAnchorRef,
|
||||
scrollOffsetRef
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -5,8 +5,8 @@ import {
|
||||
resolveCombinedUncommittedSnapshotEntries,
|
||||
shouldAutoReloadCombinedDiffFromGitStatus
|
||||
} from './combined-diff-entries'
|
||||
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
|
||||
import type { GitStatusEntry } from '../../../../shared/git-status-types'
|
||||
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
|
||||
describe('getCombinedUncommittedEntries', () => {
|
||||
it('filters unresolved conflicts from live entries', () => {
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
|
||||
import type { GitStatusEntry } from '../../../../shared/git-status-types'
|
||||
import type { CombinedDiffFileTreeMode } from './combined-diff-file-tree-model'
|
||||
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
import type { CombinedDiffFileTreeMode } from './combined-diff-section-identity'
|
||||
|
||||
/**
|
||||
* Fallback filtering for combined-diff tabs that were opened before the
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
import type { DiffSection } from '../../diff-section-types'
|
||||
|
||||
export function buildCombinedGitStatusSignature(
|
||||
sections: readonly { path: string }[],
|
||||
gitStatusEntries: readonly GitStatusEntry[]
|
||||
): string {
|
||||
const sectionPaths = new Set(sections.map((section) => section.path))
|
||||
const matching = gitStatusEntries.filter((entry) => sectionPaths.has(entry.path))
|
||||
return JSON.stringify(
|
||||
matching.map((entry) => ({
|
||||
path: entry.path,
|
||||
oldPath: entry.oldPath ?? null,
|
||||
area: entry.area,
|
||||
status: entry.status,
|
||||
added: entry.added ?? null,
|
||||
removed: entry.removed ?? null
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
export function getRetainedResolvedSnapshotEntries(
|
||||
sections: readonly DiffSection[]
|
||||
): GitStatusEntry[] {
|
||||
return sections.flatMap((section) =>
|
||||
section.area === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
path: section.path,
|
||||
status: section.status as GitStatusEntry['status'],
|
||||
area: section.area,
|
||||
oldPath: section.oldPath,
|
||||
added: section.added,
|
||||
removed: section.removed
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types'
|
||||
import type { GitStatusEntry } from '../../../../shared/git-status-types'
|
||||
import type { DiffSection } from './diff-section-types'
|
||||
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
|
||||
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
|
||||
import type { DiffSection } from '../../diff-section-types'
|
||||
import { combinedDiffSectionsMatchEntryMetadata } from './combined-diff-section-cache-match'
|
||||
|
||||
function section(overrides: Partial<DiffSection>): DiffSection {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user