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:
Jinwoo-H
2026-08-30 20:22:03 -04:00
134 changed files with 6919 additions and 3370 deletions
-1
View File
@@ -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
+18 -2
View File
@@ -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)
+121 -469
View File
@@ -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(),
+11 -15
View File
@@ -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,
+22
View File
@@ -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"
}
+10 -1
View File
@@ -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
},
{
+21
View File
@@ -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,
+10
View File
@@ -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
View File
@@ -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.
+6
View File
@@ -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'
+9
View File
@@ -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)
+2
View File
@@ -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
}
+1
View File
@@ -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
})
+2 -1
View File
@@ -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
})
+4 -4
View File
@@ -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)
}
}
}
+7 -1
View File
@@ -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'])
+28
View File
@@ -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
+15
View File
@@ -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({
+7
View File
@@ -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)
+6 -1
View File
@@ -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
})
+25 -27
View File
@@ -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}
/>
</>
)
}
@@ -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('.')
@@ -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
}
@@ -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,
@@ -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', () => {
@@ -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',
@@ -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'
@@ -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
}
}
@@ -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()
@@ -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
}
}
@@ -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
@@ -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.
@@ -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.'
}
@@ -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'
}
)
)
}
@@ -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
}
@@ -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 }
}
@@ -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
}
@@ -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)
}
})
}
@@ -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
])
}
@@ -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
}
}
@@ -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', () => {
@@ -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
@@ -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
}
]
)
}
@@ -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