diff --git a/packages/components/src/components/sessions/components/file-tree-view.tsx b/packages/components/src/components/sessions/components/file-tree-view.tsx index e44dde229..82d938d0b 100644 --- a/packages/components/src/components/sessions/components/file-tree-view.tsx +++ b/packages/components/src/components/sessions/components/file-tree-view.tsx @@ -65,6 +65,11 @@ interface FileTreeViewProps { // unmount (the side panel swaps the Files tab out for a file/diff viewer). // Callers that stay mounted can omit it and keep component-local state. viewStateKey?: string; + // Re-arms the file provider behind this tree. Rendered as "Try again" on the + // provider-unavailable panel, which otherwise offers no way out: the + // provider's acquisition is effect-driven and its inputs do not move when the + // machine comes back, so the failure outlives the outage that caused it. + onProviderRetry?: () => void; } type ControlledFileTreeViewProps = Omit; @@ -445,6 +450,7 @@ function ControlledFileTreeView({ fileProviderMessage, changedFilePaths, viewStateKey, + onProviderRetry, }: ControlledFileTreeViewProps) { const { t } = useTranslation(); const scrollViewportRef = useRef(null); @@ -516,6 +522,16 @@ function ControlledFileTreeView({ description={ message ?? t('sessions.codeSession.files.unavailable', 'Files are unavailable.') } + {...(onProviderRetry === undefined + ? {} + : { + action: ( + + ), + })} /> ); } diff --git a/packages/components/src/components/sessions/session-detail.tsx b/packages/components/src/components/sessions/session-detail.tsx index cf08bfa3a..b6580e52c 100644 --- a/packages/components/src/components/sessions/session-detail.tsx +++ b/packages/components/src/components/sessions/session-detail.tsx @@ -5338,6 +5338,9 @@ const SessionDetail = ({ // Opening a file selects its viewer tab, which unmounts this tree. Key // its expanded folders per session so returning to Files restores them. viewStateKey={`session-files:${activeSession.id}`} + // "Files unavailable" is otherwise terminal: the provider re-arms on an + // offline -> online edge, and this is the way out of every other cause. + onProviderRetry={activeSessionCodeCollabFiles.reload} /> ) : activeSidebarTab === 'pr' && latestPr && repoFullName && latestPrNumber ? ( void; }; export const CODE_COLLAB_NO_OWNING_MACHINE_MESSAGE = @@ -187,18 +195,33 @@ function useCodeCollabFileIndexLoadState(args: { readonly ownerSessionId: SessionId; readonly prepareTarget?: () => Promise; readonly loadLocalSnapshot?: () => Promise; + /** + * Bumped to re-run the acquisition below with every other input unchanged. + * Without it a failed acquire is terminal: nothing else in `requestKey` moves + * when the machine comes back, so the effect never fires again and the file + * surfaces stay on "Files unavailable" until the component unmounts. + */ + readonly reloadNonce?: number; }): HookFileIndexLoadState { const [acquired, setAcquired] = useState(null); const [fallbackState, setFallbackState] = useState(null); const [acquireError, setAcquireError] = useState(null); - const { enabled, cache, workspaceId, ownerSessionId, prepareTarget, loadLocalSnapshot } = args; + const { + enabled, + cache, + workspaceId, + ownerSessionId, + prepareTarget, + loadLocalSnapshot, + reloadNonce = 0, + } = args; const flockDocId = enabled && workspaceId ? getCodeCollabFileIndexFlockDocId(workspaceId as WorkspaceId, ownerSessionId) : null; const requestKey = useMemo( - () => ({ cache, flockDocId, loadLocalSnapshot, prepareTarget }), - [cache, flockDocId, loadLocalSnapshot, prepareTarget] + () => ({ cache, flockDocId, loadLocalSnapshot, prepareTarget, reloadNonce }), + [cache, flockDocId, loadLocalSnapshot, prepareTarget, reloadNonce] ); useEffect(() => { @@ -372,6 +395,24 @@ export function useCodeCollabSessionFileProvider( : undefined, [machineId, ownerSessionId, runtime, sessionId] ); + // Re-arm on a TRANSITION, never on a status. "Retry while the status is + // error" loops forever against a machine that is online and answering + // errors; an offline -> online edge fires at most once per outage, and every + // other cause is covered by the explicit `reload` below. + const [reloadNonce, setReloadNonce] = useState(0); + const reload = useCallback(() => setReloadNonce((nonce) => nonce + 1), []); + const machineOnlineStatus = useMachineOnlineStatus(machineId); + const sawMachineOfflineRef = useRef(false); + useEffect(() => { + if (machineOnlineStatus === 'offline') { + sawMachineOfflineRef.current = true; + return; + } + if (machineOnlineStatus === 'online' && sawMachineOfflineRef.current) { + sawMachineOfflineRef.current = false; + setReloadNonce((nonce) => nonce + 1); + } + }, [machineOnlineStatus]); const fileIndexLoadState = useCodeCollabFileIndexLoadState({ enabled: options.enabled !== false && !!machineId, cache: runtime?.codeCollabFileIndexCache ?? null, @@ -379,6 +420,7 @@ export function useCodeCollabSessionFileProvider( ownerSessionId, prepareTarget: prepareFileIndexTarget, loadLocalSnapshot: loadLocalFileIndexSnapshot, + reloadNonce, }); const fileIndexSnapshot = fileIndexLoadState.status === 'ready' ? fileIndexLoadState.snapshot : null; @@ -494,7 +536,7 @@ export function useCodeCollabSessionFileProvider( }); }, [materializedSharedState, providerTextState, role, rpcRuntime, sourceState]); - return useMemo(() => { + const result = useMemo(() => { if (options.enabled === false) { return disabledResult; } @@ -549,4 +591,11 @@ export function useCodeCollabSessionFileProvider( role, runtime, ]); + + // `reload` rides on every branch, including `disabledResult` (a module + // constant, so it cannot carry a per-hook callback of its own). + return useMemo( + () => ({ ...result, reload }), + [reload, result] + ); }