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,
};