mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +00:00
* fix(mobile): a Retry that can only re-dial is not offered where nothing dials Six failed-load screens share one Retry shape: re-dial a host that is not connected, otherwise re-read. On the page the re-dial is inert (`client-context.web.tsx:55`) and each screen's load already re-runs when the shell's client reconnects, so in the disconnected state that Retry did nothing at all. `connectionRetryAction` makes the decision once and answers null when a re-dial is needed and none exists; agent history, the file explorer root, the file preview, git history, the source-control status gate and the diff review render no Retry for null. The explorer's per-folder Retry keeps its control: it queues the folder, and the queue drains on the next `connected` whoever brought it back. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page offers no re-dial, so no header offers one `forceReconnect` on the page was `() => Promise.resolve()`: the shell owns the connection and nothing in the document can re-dial it. The host header's Reconnect and the session header's "tap to retry" were wired to it and did nothing there. The context member is now nullable and the page's provider hands out null, so the compiler found every caller: both headers render no reconnect affordance for null, and the session status keeps the verdict label without promising a tap. Native providers and the recording adapters still pass a function, so nothing a phone renders changes. The session route's host-JSX parity hash moves for the header's extra null check; the page test doubles that stubbed the old inert re-dial now stub null. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): the auth-failed banner cites the page's re-dial as null Three comments and the banner's override reason still said the page's `forceReconnect` was an inert `() => Promise.resolve()`, and cited `client-context.web.tsx` lines the previous commit moved. They now say null and point at the lines that hold it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the session closure gains the page's Retry decision `connection-retry-action.ts` is the one module the Retry fix adds to the session route's page closure, reached through the explorer, source control and git history it docks. Measured on this head with all five generators run first, and diffed against the pre-change closure: one local module added, none removed. Session route closure 4207 -> 4208 modules, local 1021 -> 1022. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): the capability probe belongs on the page, and says why The push fence excluded `runtime-capability-probe.ts` because the session route and the host screen run it. The session half holds, and the probe works there: `status.get` carries no client identity and makes no write, the shell forwards it like any non-`native.` request, and the desktop's mobile allowlist admits it. The host-screen half no longer does: `codex-reset-credit-capability.ts` is reached only from `accounts.tsx`, which the bundle carries and the page hands to the native screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the session retry test's cast under its disable line The formatter wrapped the cast onto the line after the disable comment, which left it uncovered. The cast now sits on its own line directly below the SAFETY note. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): the agent-history Retry test mocks the pathname the handoff reads Main's page route handoff now subscribes to `usePathname` (#22300), and the Retry suite this branch added mounts that handoff with an `expo-router` mock that lacked it. Same one-line addition main made to the back-handoff suite. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the reload each hidden page Retry relies on Hiding a Retry on the page rests on the screen's load re-running when the shell's client reconnects, because nothing on the page re-dials. Only the explorer's folder drain pinned that. Each other site now has a case that starts unreachable with no Retry and asserts the load goes out on the client and state the reconnect delivers: agent history (status.get), file preview (the preview read), diff review (the snapshot load), git history (git.history) and source-control status (git.status, in the loaders suite because the panel test mocks the state hook). Each goes red when the `client`/`connState` dependencies it guards are removed; for source control that is both `loadStatus` and the `loadBranchCompare` it depends on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): one import of the transport types in the source-control loaders test CI's native code-quality audit denies the duplicate-import warning the reload pin added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
ActivityIndicator,
|
|
FlatList,
|
|
Pressable,
|
|
Text,
|
|
View,
|
|
type ListRenderItem
|
|
} from 'react-native'
|
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
import { ChevronLeft, X } from 'lucide-react-native'
|
|
import { useRouteHandoff } from '../navigation/route-handoff'
|
|
import { useHostClient, useForceReconnect } from '../transport/client-context'
|
|
import { connectionRetryAction } from '../transport/connection-retry-action'
|
|
import { getWorktreeLabel } from '../session/worktree-label'
|
|
import {
|
|
flattenDirectoryCache,
|
|
getDirectoryCacheState,
|
|
type DirectoryCache,
|
|
type FileExplorerRow
|
|
} from './file-tree'
|
|
import type { RpcFailure } from '../transport/types'
|
|
import { colors } from '../theme/mobile-theme'
|
|
import {
|
|
beginDirectoryLoad,
|
|
createDirectoryLoadRevisions,
|
|
isCurrentDirectoryLoad,
|
|
resetDirectoryLoadRevisions,
|
|
type DirectoryLoadRevisions
|
|
} from './directory-load-revisions'
|
|
import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback'
|
|
import { fileDirectoryRead, legacyFileListRead } from './mobile-file-explorer-operations'
|
|
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
|
|
import { MobileFileExplorerRow } from './mobile-file-explorer-row'
|
|
import { navigateToMobileFilePreview } from './mobile-file-preview-navigation'
|
|
|
|
export function MobileFileExplorerPanel(props: {
|
|
hostId: string
|
|
worktreeId: string
|
|
name?: string
|
|
embedded?: boolean
|
|
onRequestClose?: () => void
|
|
}) {
|
|
const { hostId, worktreeId, name, embedded, onRequestClose } = props
|
|
const router = useRouteHandoff()
|
|
const { client, state: connState } = useHostClient(hostId)
|
|
const forceReconnect = useForceReconnect()
|
|
const scopeRef = useRef('')
|
|
const scope = `${hostId}:${worktreeId}`
|
|
scopeRef.current = scope
|
|
const directoryLoadRevisionsRef = useRef<DirectoryLoadRevisions>(createDirectoryLoadRevisions())
|
|
const pendingDirectoryRetriesRef = useRef<Set<string>>(new Set())
|
|
const directoryCacheRef = useRef<DirectoryCache>({})
|
|
const [directoryCache, setDirectoryCache] = useState<DirectoryCache>({})
|
|
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [legacyListTruncated, setLegacyListTruncated] = useState(false)
|
|
const worktreeLabel = getWorktreeLabel(name, worktreeId)
|
|
|
|
const loadDirectory = useCallback(
|
|
async (relativePath: string) => {
|
|
const scope = scopeRef.current
|
|
const loadToken = beginDirectoryLoad(directoryLoadRevisionsRef.current, scope, relativePath)
|
|
const rootLoad = relativePath === ''
|
|
|
|
if (!client || connState !== 'connected') {
|
|
const message =
|
|
connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...'
|
|
if (rootLoad) {
|
|
const hasLoadedRoot =
|
|
(getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0
|
|
setLoading(false)
|
|
// Why: transient reconnects should not blank an already browsable tree.
|
|
setError(hasLoadedRoot ? null : message)
|
|
} else {
|
|
setDirectoryCache((prev) => ({
|
|
...prev,
|
|
[relativePath]: {
|
|
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
|
|
error: message
|
|
}
|
|
}))
|
|
}
|
|
return
|
|
}
|
|
|
|
const hadLoadedRoot =
|
|
rootLoad && (getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0
|
|
if (rootLoad) {
|
|
// Why: a reconnect refresh must not blank an already browsable tree —
|
|
// the full-screen spinner unmounts the list and resets scroll.
|
|
if (!hadLoadedRoot) {
|
|
setLoading(true)
|
|
}
|
|
setError(null)
|
|
}
|
|
setDirectoryCache((prev) => ({
|
|
...prev,
|
|
[relativePath]: {
|
|
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
|
|
loading: true
|
|
}
|
|
}))
|
|
|
|
try {
|
|
const response = await fileDirectoryRead.request(client, {
|
|
worktree: `id:${worktreeId}`,
|
|
relativePath
|
|
})
|
|
const directory = fileDirectoryRead.interpret(response)
|
|
if (!directory.accepted) {
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this policy skips only a refusal, so an unaccepted reply is a failure envelope.
|
|
const refusal = (response as RpcFailure).error
|
|
// Why: desktops that predate the files.readDir mobile allowlist
|
|
// entry still serve the capped files.list; fall back so the Files
|
|
// tab keeps working until the desktop updates.
|
|
if (rootLoad && isMobileMethodUnavailableError(refusal?.code, refusal?.message)) {
|
|
const legacyReply = await legacyFileListRead.request(client, {
|
|
worktree: `id:${worktreeId}`
|
|
})
|
|
const legacy = legacyFileListRead.interpret(legacyReply)
|
|
if (legacy.accepted) {
|
|
if (
|
|
!isCurrentDirectoryLoad(
|
|
directoryLoadRevisionsRef.current,
|
|
scopeRef.current,
|
|
loadToken
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
const legacyResult = legacy.value
|
|
setDirectoryCache(directoryCacheFromFileList(legacyResult.files))
|
|
// Why: the capped list silently omits files past the cap — keep
|
|
// the legacy explorer's "Showing first 5000" note.
|
|
setLegacyListTruncated(legacyResult.truncated)
|
|
return
|
|
}
|
|
throw new Error(
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this policy skips only a refusal, so an unaccepted reply is a failure envelope.
|
|
(legacyReply as RpcFailure).error?.message ||
|
|
refusal?.message ||
|
|
'Unable to load files'
|
|
)
|
|
}
|
|
throw new Error(refusal?.message || 'Unable to load files')
|
|
}
|
|
if (
|
|
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
|
|
) {
|
|
return
|
|
}
|
|
const entries = directory.value
|
|
if (rootLoad) {
|
|
setLegacyListTruncated(false)
|
|
}
|
|
setDirectoryCache((prev) => ({
|
|
...prev,
|
|
[relativePath]: { entries }
|
|
}))
|
|
} catch (err) {
|
|
if (
|
|
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
|
|
) {
|
|
return
|
|
}
|
|
const message = err instanceof Error ? err.message : 'Unable to load files'
|
|
if (rootLoad) {
|
|
// Why: a failed background refresh keeps the cached tree browsable;
|
|
// only a cold load surfaces the full-screen error.
|
|
setError(hadLoadedRoot ? null : message)
|
|
} else {
|
|
setDirectoryCache((prev) => ({
|
|
...prev,
|
|
[relativePath]: {
|
|
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
|
|
error: message
|
|
}
|
|
}))
|
|
}
|
|
} finally {
|
|
if (
|
|
rootLoad &&
|
|
isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
|
|
) {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
},
|
|
[client, connState, worktreeId]
|
|
)
|
|
|
|
useEffect(() => {
|
|
scopeRef.current = scope
|
|
resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current)
|
|
pendingDirectoryRetriesRef.current.clear()
|
|
directoryCacheRef.current = {}
|
|
setDirectoryCache({})
|
|
setExpanded(new Set())
|
|
setLoading(true)
|
|
setError(null)
|
|
setLegacyListTruncated(false)
|
|
}, [scope])
|
|
|
|
useEffect(() => {
|
|
directoryCacheRef.current = directoryCache
|
|
}, [directoryCache])
|
|
|
|
useEffect(() => {
|
|
void loadDirectory('')
|
|
}, [hostId, loadDirectory])
|
|
|
|
useEffect(() => {
|
|
if (connState !== 'connected' || pendingDirectoryRetriesRef.current.size === 0) {
|
|
return
|
|
}
|
|
const pending = [...pendingDirectoryRetriesRef.current]
|
|
pendingDirectoryRetriesRef.current.clear()
|
|
for (const relativePath of pending) {
|
|
void loadDirectory(relativePath)
|
|
}
|
|
}, [connState, loadDirectory])
|
|
|
|
const rows = useMemo(
|
|
() => flattenDirectoryCache(directoryCache, expanded),
|
|
[directoryCache, expanded]
|
|
)
|
|
|
|
const toggleDirectory = useCallback(
|
|
(relativePath: string) => {
|
|
setExpanded((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(relativePath)) {
|
|
next.delete(relativePath)
|
|
} else {
|
|
next.add(relativePath)
|
|
}
|
|
return next
|
|
})
|
|
const state = getDirectoryCacheState(directoryCache, relativePath)
|
|
if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) {
|
|
void loadDirectory(relativePath)
|
|
}
|
|
},
|
|
[directoryCache, expanded, loadDirectory]
|
|
)
|
|
|
|
const retryDirectory = useCallback(
|
|
(relativePath: string) => {
|
|
if (connState !== 'connected' && hostId) {
|
|
// Still worth a tap on the page, where nothing re-dials: the queued read runs when the
|
|
// shell's client reconnects on its own.
|
|
pendingDirectoryRetriesRef.current.add(relativePath)
|
|
void forceReconnect?.(hostId)
|
|
return
|
|
}
|
|
void loadDirectory(relativePath)
|
|
},
|
|
[connState, forceReconnect, hostId, loadDirectory]
|
|
)
|
|
|
|
const previewFile = useCallback(
|
|
(relativePath: string, displayName: string) => {
|
|
navigateToMobileFilePreview(
|
|
router,
|
|
{
|
|
hostId,
|
|
worktreeId,
|
|
relativePath,
|
|
name: displayName,
|
|
worktreeName: name
|
|
},
|
|
{ embedded, onRequestClose }
|
|
)
|
|
},
|
|
[embedded, hostId, name, onRequestClose, router, worktreeId]
|
|
)
|
|
|
|
const renderItem: ListRenderItem<FileExplorerRow> = ({ item }) => {
|
|
return (
|
|
<MobileFileExplorerRow
|
|
item={item}
|
|
expanded={expanded}
|
|
onPreviewFile={previewFile}
|
|
onRetryDirectory={retryDirectory}
|
|
onToggleDirectory={toggleDirectory}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const headerBar = (
|
|
<View style={styles.topBar}>
|
|
{embedded ? (
|
|
<Pressable
|
|
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
|
|
onPress={() => onRequestClose?.()}
|
|
hitSlop={8}
|
|
accessibilityLabel="Close files"
|
|
>
|
|
<X size={20} color={colors.textSecondary} strokeWidth={2.2} />
|
|
</Pressable>
|
|
) : (
|
|
<Pressable
|
|
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
|
|
onPress={() => router.back()}
|
|
hitSlop={8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Back to session"
|
|
>
|
|
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
|
|
</Pressable>
|
|
)}
|
|
<View style={styles.titleBlock}>
|
|
<Text style={styles.title} numberOfLines={1}>
|
|
Files
|
|
</Text>
|
|
<Text style={styles.meta} numberOfLines={1}>
|
|
{worktreeLabel}
|
|
{legacyListTruncated ? ' - Showing first 5000' : ''}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)
|
|
|
|
// Why: while disconnected, re-sending the request is useless — revive the parked transport
|
|
// instead (issue #5049); loadDirectory re-runs via its effect once the new client connects.
|
|
const rootRetry = connectionRetryAction({
|
|
hostId,
|
|
needsReconnect: connState !== 'connected',
|
|
forceReconnect,
|
|
reload: () => void loadDirectory('')
|
|
})
|
|
const body = loading ? (
|
|
<View style={styles.state}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
</View>
|
|
) : error ? (
|
|
<View style={styles.state}>
|
|
<Text style={styles.errorText}>{error}</Text>
|
|
{rootRetry ? (
|
|
<Pressable style={styles.retryButton} onPress={rootRetry}>
|
|
<Text style={styles.retryText}>Retry</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
) : rows.length === 0 ? (
|
|
<View style={styles.state}>
|
|
<Text style={styles.emptyText}>No files found</Text>
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
data={rows}
|
|
renderItem={renderItem}
|
|
keyExtractor={(item) => item.id}
|
|
contentContainerStyle={styles.listContent}
|
|
style={styles.list}
|
|
/>
|
|
)
|
|
|
|
// Embedded: the dock column owns safe-area/layout, so render a plain View and
|
|
// a non-inset header. Full-screen: keep the SafeAreaView top inset + chrome.
|
|
return (
|
|
<View style={styles.container}>
|
|
{embedded ? (
|
|
<View style={styles.header}>{headerBar}</View>
|
|
) : (
|
|
<SafeAreaView style={styles.header} edges={['top']}>
|
|
{headerBar}
|
|
</SafeAreaView>
|
|
)}
|
|
{body}
|
|
</View>
|
|
)
|
|
}
|