diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 06bd4bc5773..df52b88ea60 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,7 +1,9 @@ import { BlurTargetView } from "expo-blur"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; -import { useEffect } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useEffect, useState } from "react"; import { StatusBar, useColorScheme } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; @@ -22,6 +24,8 @@ import { appAtomRegistry } from "./state/atom-registry"; import { OverlayPortalHost } from "./components/OverlayPortal"; import { appBlurTargetRef } from "./lib/appBlurTarget"; import { useThemeColor } from "./lib/useThemeColor"; +import { environmentCatalog } from "./connection/catalog"; +import { environmentShellSummaryAtom } from "./state/shell"; import "../global.css"; @@ -29,7 +33,7 @@ if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { prepareNativeShowcaseCapture(); } -void SplashScreen.preventAutoHideAsync().catch(() => { +const splashScreenPrevention = SplashScreen.preventAutoHideAsync().catch(() => { // The native module can be unavailable in non-native test environments. }); @@ -46,13 +50,31 @@ const appLinking = { }; const Navigation = createStaticNavigation(RootStack); +const BOOTSTRAP_HYDRATION_BUDGET_MS = 750; function SplashScreenCoordinator() { const { isReady } = useAppearancePreferences(); + const catalogResult = useAtomValue(environmentCatalog.catalogAtom); + const shellSummary = useAtomValue(environmentShellSummaryAtom); + const [hydrationBudgetElapsed, setHydrationBudgetElapsed] = useState(false); + const connectionBootstrapReady = + AsyncResult.isFailure(catalogResult) || shellSummary.areShellCachesHydrated; + const bootstrapReady = isReady && (connectionBootstrapReady || hydrationBudgetElapsed); useEffect(() => { - if (isReady) void SplashScreen.hide(); - }, [isReady]); + if (connectionBootstrapReady) return; + const timeout = setTimeout( + () => setHydrationBudgetElapsed(true), + BOOTSTRAP_HYDRATION_BUDGET_MS, + ); + return () => clearTimeout(timeout); + }, [connectionBootstrapReady]); + + useEffect(() => { + if (bootstrapReady) { + void splashScreenPrevention.then(() => SplashScreen.hide()); + } + }, [bootstrapReady]); return null; } diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 507a39d94a1..0afb3989972 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -66,8 +66,7 @@ import { type HomeProjectSortOrder, } from "./homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; -import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; -import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; +import { WorkspaceConnectionStatusOverlay } from "./WorkspaceConnectionStatus"; /* ─── Types ──────────────────────────────────────────────────────────── */ @@ -862,20 +861,28 @@ export function HomeScreen(props: HomeScreenProps) { ? null : (props.savedConnectionsById[props.selectedEnvironmentId]?.environmentLabel ?? "this environment"); - const shouldShowConnectionStatus = shouldShowWorkspaceConnectionStatus(props.catalogState); const emptyState = deriveEmptyState({ catalogState: props.catalogState, projectCount: props.projects.length, }); - const connectionStatus = - shouldShowConnectionStatus && Platform.OS !== "ios" ? ( - - - - ) : null; + const emptyAction = !props.catalogState.hasConnections + ? { label: "Add environment", onPress: props.onAddConnection } + : !props.catalogState.hasReadyEnvironment && + !props.catalogState.hasConnectingEnvironment && + !props.catalogState.isLoadingConnections + ? { label: "Environment settings", onPress: props.onOpenEnvironments } + : null; + const connectionStatus = ( + + ); if (!hasAnyThreads) { return ( @@ -890,45 +897,22 @@ export function HomeScreen(props: HomeScreenProps) { - {emptyState.loading && !shouldShowConnectionStatus ? ( + {emptyState.loading ? ( ) : null} - {shouldShowConnectionStatus && Platform.OS === "ios" ? ( - - - - ) : null} {connectionStatus} ); } - const listHeader = ( - <> - {Platform.OS === "ios" ? null : } - - {shouldShowConnectionStatus && Platform.OS === "ios" ? ( - - - - ) : null} - - ); + const listHeader = <>{Platform.OS === "ios" ? null : }; // Project scoping lives in the header filter menu (no inline chip row on // mobile — the menu is the one filter surface). diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts b/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts index 8c3c873cc9e..87adbae7430 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts +++ b/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts @@ -4,6 +4,7 @@ import type { WorkspaceState } from "../../state/workspaceModel"; import { shouldShowWorkspaceConnectionStatus, workspaceConnectionStatusLabel, + workspaceConnectionStatusPresentation, } from "./workspace-connection-status"; function workspaceState(overrides: Partial = {}): WorkspaceState { @@ -27,6 +28,7 @@ function workspaceState(overrides: Partial = {}): WorkspaceState describe("workspace connection status", () => { it("stays hidden while a ready environment is connected", () => { expect(shouldShowWorkspaceConnectionStatus(workspaceState())).toBe(false); + expect(workspaceConnectionStatusPresentation(workspaceState())).toBe("hidden"); }); it("surfaces offline snapshots", () => { @@ -34,6 +36,7 @@ describe("workspace connection status", () => { expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); expect(workspaceConnectionStatusLabel(state)).toBe("You are offline"); + expect(workspaceConnectionStatusPresentation(state)).toBe("immediate"); }); it("names the environment while reconnecting", () => { @@ -55,6 +58,27 @@ describe("workspace connection status", () => { expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); expect(workspaceConnectionStatusLabel(state)).toBe("Reconnecting to Julius’s Mac mini"); + expect(workspaceConnectionStatusPresentation(state)).toBe("deferred"); + }); + + it("distinguishes an initial connection from a reconnect", () => { + const state = workspaceState({ + hasConnectingEnvironment: true, + hasReadyEnvironment: false, + connectingEnvironments: [ + { + environmentId: "environment-1" as never, + environmentLabel: "Julius’s Mac mini", + displayUrl: "", + isRelayManaged: false, + connectionState: "connecting", + connectionError: null, + connectionErrorTraceId: null, + }, + ], + }); + + expect(workspaceConnectionStatusLabel(state)).toBe("Connecting to Julius’s Mac mini"); }); it("surfaces connection errors before the generic disconnected fallback", () => { @@ -66,6 +90,7 @@ describe("workspace connection status", () => { expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini"); + expect(workspaceConnectionStatusPresentation(state)).toBe("immediate"); }); it("shows shell catch-up while cached threads remain visible", () => { diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx index 1e986ad1a50..f468152498e 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx @@ -1,10 +1,17 @@ import { SymbolView } from "../../components/AppSymbol"; -import { ActivityIndicator, Pressable } from "react-native"; +import { useEffect, useState } from "react"; +import { ActivityIndicator, Pressable, View } from "react-native"; +import Animated, { FadeInUp, FadeOutDown } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; import { useThemeColor } from "../../lib/useThemeColor"; import type { WorkspaceState } from "../../state/workspaceModel"; -import { workspaceConnectionStatusLabel } from "./workspace-connection-status"; +import { + workspaceConnectionStatusLabel, + workspaceConnectionStatusPresentation, +} from "./workspace-connection-status"; + +const TRANSIENT_STATUS_DELAY_MS = 350; export function WorkspaceConnectionStatus(props: { readonly state: WorkspaceState; @@ -22,6 +29,7 @@ export function WorkspaceConnectionStatus(props: { ); } + +/** + * Connection chrome that never participates in list or empty-state layout. + * Transient recovery is delayed to suppress healthy sub-350ms reconnect + * flashes; persistent and actionable states appear immediately. + */ +export function WorkspaceConnectionStatusOverlay(props: { + readonly state: WorkspaceState; + readonly onPress: () => void; + readonly bottomOffset: number; +}) { + const presentation = workspaceConnectionStatusPresentation(props.state); + const [presented, setPresented] = useState(presentation === "immediate"); + const [displayedState, setDisplayedState] = useState(props.state); + + useEffect(() => { + if (presentation !== "hidden") { + setDisplayedState(props.state); + } + }, [presentation, props.state]); + + useEffect(() => { + if (presentation === "hidden") { + setPresented(false); + return; + } + if (presentation === "immediate" || presented) { + setPresented(true); + return; + } + const timeout = setTimeout(() => setPresented(true), TRANSIENT_STATUS_DELAY_MS); + return () => clearTimeout(timeout); + }, [presentation, presented]); + + if (!presented) return null; + + return ( + + + + + + ); +} diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index d8eed4383b1..75d1b98905d 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -10,13 +10,42 @@ export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): bool ); } +export type WorkspaceConnectionStatusPresentation = "hidden" | "deferred" | "immediate"; + +/** + * Brief reconnects are delayed so a healthy foreground probe never flashes + * transient chrome. Offline/error states remain immediate because they are + * actionable and may persist. + */ +export function workspaceConnectionStatusPresentation( + state: WorkspaceState, +): WorkspaceConnectionStatusPresentation { + if (!shouldShowWorkspaceConnectionStatus(state)) return "hidden"; + if ( + state.networkStatus === "offline" || + state.connectionError !== null || + (!state.hasConnectingEnvironment && !state.hasPendingShellSnapshot) + ) { + return "immediate"; + } + return "deferred"; +} + export function workspaceConnectionStatusLabel(state: WorkspaceState): string { if (state.networkStatus === "offline") return "You are offline"; if (state.connectingEnvironments.length === 1) { - return `Reconnecting to ${state.connectingEnvironments[0]!.environmentLabel}`; + const environment = state.connectingEnvironments[0]!; + return environment.connectionState === "connecting" + ? `Connecting to ${environment.environmentLabel}` + : `Reconnecting to ${environment.environmentLabel}`; } if (state.connectingEnvironments.length > 1) { - return `Reconnecting ${state.connectingEnvironments.length} environments`; + const isInitialConnection = state.connectingEnvironments.every( + (environment) => environment.connectionState === "connecting", + ); + return `${isInitialConnection ? "Connecting to" : "Reconnecting"} ${ + state.connectingEnvironments.length + } environments`; } if (state.connectionError !== null) return state.connectionError; if (state.hasPendingShellSnapshot) { diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 36a86ceb1e3..fd9145b8ff9 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -55,8 +55,7 @@ import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThrea import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; -import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus"; -import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status"; +import { WorkspaceConnectionStatusOverlay } from "../home/WorkspaceConnectionStatus"; import { SidebarHeaderActions } from "./sidebar-header-actions"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; @@ -550,7 +549,6 @@ function ThreadNavigationSidebarPane( threadListV2Enabled, threadListV2Layout, ]); - const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); const listMenuActions = useMemo( () => [ { @@ -1114,21 +1112,15 @@ function ThreadNavigationSidebarPane( scrollEventThrottle={16} showsVerticalScrollIndicator={false} style={styles.threadList} - ListHeaderComponent={ - showsConnectionStatus ? ( - - - - ) : null - } ListEmptyComponent={listEmpty} /> + ); @@ -1246,17 +1238,12 @@ function ThreadNavigationSidebarPane( value={props.searchQuery} /> - - {showsConnectionStatus ? ( - - - - ) : null} + ); } diff --git a/apps/mobile/src/state/workspaceModel.test.ts b/apps/mobile/src/state/workspaceModel.test.ts index e51273d57de..cfae0e64cdc 100644 --- a/apps/mobile/src/state/workspaceModel.test.ts +++ b/apps/mobile/src/state/workspaceModel.test.ts @@ -51,6 +51,7 @@ const EMPTY_SHELL_SUMMARY: EnvironmentShellSummary = { hasSynchronizingShell: false, hasCachedShell: false, hasLiveShell: false, + areShellCachesHydrated: true, firstError: null, latestSnapshotUpdatedAt: null, }; diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index e08fd9e552f..13d71019e68 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -146,6 +146,7 @@ function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState snapshot: Option.some(snapshot), status: "live", error: Option.none(), + cacheHydrated: true, }; } diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index e006fc3cd76..b0769cd45cf 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -150,7 +150,7 @@ describe("environment shell synchronization", () => { }), ); - it.effect("replaces a warm shell cache with an authoritative HTTP snapshot", () => + it.effect("resumes a warm shell cache without a redundant HTTP snapshot", () => Effect.gen(function* () { const cachedSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 5, @@ -225,12 +225,12 @@ describe("environment shell synchronization", () => { Stream.runHead, ); - expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(9); + expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(5); expect(yield* Ref.get(capturedCompletionMarker)).toBe(true); - expect(yield* SubscriptionRef.get(loaderCalls)).toBe(1); + expect(yield* SubscriptionRef.get(loaderCalls)).toBe(0); const synchronizing = yield* SubscriptionRef.get(shellState); expect(synchronizing.status).toBe("synchronizing"); - expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(httpSnapshot); + expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(cachedSnapshot); yield* Queue.offer(events, { kind: "synchronized" }); yield* SubscriptionRef.changes(shellState).pipe( @@ -240,16 +240,21 @@ describe("environment shell synchronization", () => { }), ); - it.effect("refreshes the authoritative shell snapshot when the app becomes active", () => + it.effect("resumes the in-memory shell cursor when the app becomes active", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); const wakeups = yield* Queue.unbounded(); const loaderCalls = yield* Ref.make(0); const subscriptionCount = yield* Ref.make(0); + const subscriptionSequences = yield* Ref.make>([]); const client = { - [ORCHESTRATION_WS_METHODS.subscribeShell]: () => + [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) => Stream.unwrap( - Ref.update(subscriptionCount, (count) => count + 1).pipe( + Ref.update(subscriptionSequences, (sequences) => [ + ...sequences, + input.afterSequence, + ]).pipe( + Effect.andThen(Ref.update(subscriptionCount, (count) => count + 1)), Effect.as(Stream.fromQueue(events)), ), ), @@ -296,15 +301,10 @@ describe("environment shell synchronization", () => { ), ); - yield* SubscriptionRef.changes(shellState).pipe( - Stream.filter( - (value) => - value.status === "synchronizing" && - Option.isSome(value.snapshot) && - value.snapshot.value.snapshotSequence === 10, - ), - Stream.runHead, - ); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(subscriptionCount)) >= 1) break; + yield* Effect.yieldNow; + } yield* Queue.offer(events, { kind: "synchronized" }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter((value) => value.status === "live"), @@ -312,38 +312,36 @@ describe("environment shell synchronization", () => { ); yield* Queue.offer(wakeups, "application-active"); - yield* SubscriptionRef.changes(shellState).pipe( - Stream.filter( - (value) => - value.status === "synchronizing" && - Option.isSome(value.snapshot) && - value.snapshot.value.snapshotSequence === 20, - ), - Stream.runHead, - ); - for (let attempt = 0; attempt < 100; attempt += 1) { if ((yield* Ref.get(subscriptionCount)) >= 2) break; yield* Effect.yieldNow; } + yield* Queue.offer(events, { kind: "synchronized" }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((value) => value.status === "live"), + Stream.runHead, + ); - expect(yield* Ref.get(loaderCalls)).toBe(2); + expect(yield* Ref.get(loaderCalls)).toBe(0); expect(yield* Ref.get(subscriptionCount)).toBe(2); + expect(yield* Ref.get(subscriptionSequences)).toEqual([1, 1]); yield* Queue.offer(wakeups, "application-active-probe"); for (let attempt = 0; attempt < 100; attempt += 1) { if ((yield* Ref.get(subscriptionCount)) >= 3) break; yield* Effect.yieldNow; } - expect(yield* Ref.get(loaderCalls)).toBe(3); + expect(yield* Ref.get(loaderCalls)).toBe(0); expect(yield* Ref.get(subscriptionCount)).toBe(3); + expect(yield* Ref.get(subscriptionSequences)).toEqual([1, 1, 1]); yield* Queue.offer(wakeups, "application-active-reconnect"); for (let attempt = 0; attempt < 10; attempt += 1) { yield* Effect.yieldNow; } - expect(yield* Ref.get(loaderCalls)).toBe(3); + expect(yield* Ref.get(loaderCalls)).toBe(0); expect(yield* Ref.get(subscriptionCount)).toBe(3); + expect(yield* Ref.get(subscriptionSequences)).toEqual([1, 1, 1]); }), ); }); diff --git a/packages/client-runtime/src/state/shell.test.ts b/packages/client-runtime/src/state/shell.test.ts index f1326e0a5cb..3522acd141d 100644 --- a/packages/client-runtime/src/state/shell.test.ts +++ b/packages/client-runtime/src/state/shell.test.ts @@ -28,6 +28,7 @@ function shellState(input: { readonly updatedAt?: string; readonly error?: string; readonly snapshotSequence?: number; + readonly cacheHydrated?: boolean; }): EnvironmentShellState { return { snapshot: @@ -41,6 +42,7 @@ function shellState(input: { }), status: input.status, error: input.error === undefined ? Option.none() : Option.some(input.error), + cacheHydrated: input.cacheHydrated ?? true, }; } @@ -97,6 +99,7 @@ describe("environment shell projections", () => { hasSynchronizingShell: true, hasCachedShell: true, hasLiveShell: false, + areShellCachesHydrated: true, firstError: "Retrying.", latestSnapshotUpdatedAt: "2026-06-02T00:00:00.000Z", }); @@ -113,6 +116,22 @@ describe("environment shell projections", () => { expect(harness.registry.get(harness.summaryAtom)).toBe(summary); }); + it("reports cache hydration only after every catalog environment has loaded", () => { + const harness = makeHarness(); + + harness.registry.set( + harness.shellStateAtom(OTHER_ENVIRONMENT_ID), + shellState({ status: "empty", cacheHydrated: false }), + ); + expect(harness.registry.get(harness.summaryAtom).areShellCachesHydrated).toBe(false); + + harness.registry.set( + harness.shellStateAtom(OTHER_ENVIRONMENT_ID), + shellState({ status: "empty", cacheHydrated: true }), + ); + expect(harness.registry.get(harness.summaryAtom).areShellCachesHydrated).toBe(true); + }); + it("preserves server-config map identity until a config reference changes", () => { const harness = makeHarness(); const empty = harness.registry.get(harness.serverConfigsAtom); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index a266af5f5f4..f719fd8eca2 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -32,12 +32,15 @@ export interface EnvironmentShellState { readonly snapshot: Option.Option; readonly status: EnvironmentShellStatus; readonly error: Option.Option; + /** True once the persisted shell cache has been read for this environment. */ + readonly cacheHydrated: boolean; } const EMPTY_SHELL_STATE: EnvironmentShellState = { snapshot: Option.none(), status: "empty", error: Option.none(), + cacheHydrated: false, }; function shellStatusForSnapshot( @@ -69,6 +72,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") snapshot: cachedSnapshot, status: shellStatusForSnapshot(cachedSnapshot), error: Option.none(), + cacheHydrated: true, }); const awaitingCompletion = yield* Ref.make(false); const persistence = yield* Queue.sliding(1); @@ -165,6 +169,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") snapshot: Option.some(nextSnapshot), status: waiting ? "synchronizing" : "live", error: Option.none(), + cacheHydrated: true, }); yield* Queue.offer(persistence, nextSnapshot); }); @@ -187,6 +192,21 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; + const current = yield* SubscriptionRef.get(state); + // Modern servers make a cached cursor authoritative: they replay a + // small gap, replace an invalid/old cursor with a fresh snapshot, and + // emit a completion marker after buffered live events. Reusing that + // cursor avoids a redundant full HTTP shell download on every app + // foreground while preserving deletion/catch-up correctness. + if (supportsCompletionMarker && Option.isSome(current.snapshot)) { + return { + afterSequence: current.snapshot.value.snapshotSequence, + requestCompletionMarker: true as const, + }; + } + + // Cold caches and older servers still use the authoritative HTTP + // snapshot path before subscribing. const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( Effect.flatMap( Option.match({ @@ -248,6 +268,8 @@ export interface EnvironmentShellSummary { readonly hasSynchronizingShell: boolean; readonly hasCachedShell: boolean; readonly hasLiveShell: boolean; + /** True once every catalog environment has completed its local cache read. */ + readonly areShellCachesHydrated: boolean; readonly firstError: string | null; readonly latestSnapshotUpdatedAt: string | null; } @@ -257,6 +279,7 @@ const EMPTY_ENVIRONMENT_SHELL_SUMMARY: EnvironmentShellSummary = Object.freeze({ hasSynchronizingShell: false, hasCachedShell: false, hasLiveShell: false, + areShellCachesHydrated: false, firstError: null, latestSnapshotUpdatedAt: null, }); @@ -272,6 +295,7 @@ function shellSummariesEqual( left.hasSynchronizingShell === right.hasSynchronizingShell && left.hasCachedShell === right.hasCachedShell && left.hasLiveShell === right.hasLiveShell && + left.areShellCachesHydrated === right.areShellCachesHydrated && left.firstError === right.firstError && left.latestSnapshotUpdatedAt === right.latestSnapshotUpdatedAt ); @@ -295,18 +319,21 @@ export function createEnvironmentShellSummaryAtom(input: { }) { let previousSummary = EMPTY_ENVIRONMENT_SHELL_SUMMARY; return Atom.make((get) => { + const catalog = get(input.catalogValueAtom); let hasSnapshot = false; let hasSynchronizingShell = false; let hasCachedShell = false; let hasLiveShell = false; + let areShellCachesHydrated = catalog.isReady; let firstError: string | null = null; let latestSnapshotUpdatedAt: string | null = null; - for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + for (const environmentId of catalog.entries.keys()) { const state = get(input.shellStateValueAtom(environmentId)); hasSynchronizingShell ||= state.status === "synchronizing"; hasCachedShell ||= state.status === "cached"; hasLiveShell ||= state.status === "live"; + areShellCachesHydrated &&= state.cacheHydrated; if (firstError === null) { firstError = Option.getOrNull(state.error); } @@ -325,6 +352,7 @@ export function createEnvironmentShellSummaryAtom(input: { hasSynchronizingShell, hasCachedShell, hasLiveShell, + areShellCachesHydrated, firstError, latestSnapshotUpdatedAt, };