Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -22,14 +24,16 @@ 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";

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.
});

Expand All @@ -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;
}
Expand Down
62 changes: 23 additions & 39 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────── */

Expand Down Expand Up @@ -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" ? (
<View
className="absolute left-0 right-0 items-center"
style={{ bottom: Math.max(insets.bottom, 18) + 76 }}
>
<WorkspaceConnectionStatus state={props.catalogState} onPress={props.onOpenEnvironments} />
</View>
) : 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 = (
<WorkspaceConnectionStatusOverlay
state={props.catalogState}
onPress={props.onOpenEnvironments}
bottomOffset={
Platform.OS === "android"
? Math.max(insets.bottom, 18) + 76
: Math.max(insets.bottom, 18) + 72
}
/>
);

if (!hasAnyThreads) {
return (
Expand All @@ -890,45 +897,22 @@ export function HomeScreen(props: HomeScreenProps) {
<EmptyState
title={emptyState.title}
detail={emptyState.detail}
actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined}
onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined}
actionLabel={emptyAction?.label}
onAction={emptyAction?.onPress}
variant="plain"
/>
{emptyState.loading && !shouldShowConnectionStatus ? (
{emptyState.loading ? (
<View className="mt-4 items-center">
<ActivityIndicator color={accentColor} />
</View>
) : null}
{shouldShowConnectionStatus && Platform.OS === "ios" ? (
<View className="mt-4">
<WorkspaceConnectionStatus
state={props.catalogState}
onPress={props.onOpenEnvironments}
variant="sidebar"
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty state shows duplicate spinners

Low Severity

The empty-state ActivityIndicator no longer checks whether connection status is shown. During a deferred connecting load, the empty copy still spins and, after 350ms, WorkspaceConnectionStatusOverlay adds a second spinner for the same recovery state.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 06c7cdc. Configure here.

</View>
) : null}
</View>
{connectionStatus}
</View>
);
}

const listHeader = (
<>
{Platform.OS === "ios" ? null : <HomeTopContentSpacer />}

{shouldShowConnectionStatus && Platform.OS === "ios" ? (
<View className="pb-4">
<WorkspaceConnectionStatus
state={props.catalogState}
onPress={props.onOpenEnvironments}
variant="sidebar"
/>
</View>
) : null}
</>
);
const listHeader = <>{Platform.OS === "ios" ? null : <HomeTopContentSpacer />}</>;

// Project scoping lives in the header filter menu (no inline chip row on
// mobile — the menu is the one filter surface).
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { WorkspaceState } from "../../state/workspaceModel";
import {
shouldShowWorkspaceConnectionStatus,
workspaceConnectionStatusLabel,
workspaceConnectionStatusPresentation,
} from "./workspace-connection-status";

function workspaceState(overrides: Partial<WorkspaceState> = {}): WorkspaceState {
Expand All @@ -27,13 +28,15 @@ function workspaceState(overrides: Partial<WorkspaceState> = {}): 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", () => {
const state = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false });

expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true);
expect(workspaceConnectionStatusLabel(state)).toBe("You are offline");
expect(workspaceConnectionStatusPresentation(state)).toBe("immediate");
});

it("names the environment while reconnecting", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
64 changes: 62 additions & 2 deletions apps/mobile/src/features/home/WorkspaceConnectionStatus.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,6 +29,7 @@ export function WorkspaceConnectionStatus(props: {
<Pressable
accessibilityHint="Opens environment settings"
accessibilityLabel={workspaceConnectionStatusLabel(props.state)}
accessibilityLiveRegion="polite"
accessibilityRole="button"
onPress={props.onPress}
className={
Expand Down Expand Up @@ -54,3 +62,55 @@ export function WorkspaceConnectionStatus(props: {
</Pressable>
);
}

/**
* 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overlay shows after status hidden

Medium Severity

The WorkspaceConnectionStatusOverlay's presented state, which controls its visibility, updates asynchronously in useEffect. This can cause the overlay to briefly appear when it should be hidden, or delay its appearance/disappearance, due to a mismatch with the immediate workspaceConnectionStatusPresentation state.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 06c7cdc. Configure here.


return (
<View
className="absolute inset-x-4 z-20 items-center"
pointerEvents="box-none"
style={{ bottom: props.bottomOffset }}
>
<Animated.View
className="w-full max-w-[430px]"
entering={FadeInUp.duration(180)}
exiting={FadeOutDown.duration(140)}
>
<WorkspaceConnectionStatus state={displayedState} onPress={props.onPress} />
</Animated.View>
</View>
);
}
33 changes: 31 additions & 2 deletions apps/mobile/src/features/home/workspace-connection-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading