Skip to content
Merged
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
19 changes: 19 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { AppText as Text } from "./components/AppText";
import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";
import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation";
import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
Expand Down Expand Up @@ -220,6 +222,7 @@ const NewTaskSheetStack = createNativeStackNavigator({
// influence the adaptive workspace layout: opening Settings over Home should
// not flip the sidebar in or change the active thread.
const WORKSPACE_OVERLAY_ROUTES = new Set([
"ConnectOnboarding",
"Connections",
"ConnectionsNew",
"GitBranches",
Expand Down Expand Up @@ -251,6 +254,8 @@ function RootStackLayout(props: {
}) {
useAgentNotificationNavigation();
useThreadOutboxDrain();
// Presents the T3 Connect onboarding sheet after an in-session sign-in.
useConnectOnboardingNavigation();
// Full pathname (sheets included) for keyboard-command scoping; the
// workspace layout only reacts to the underlying non-overlay route.
const path = getPathFromState(props.state, navigationPathConfig);
Expand Down Expand Up @@ -412,6 +417,20 @@ export const RootStack = createNativeStackNavigator({
sheetGrabberVisible: true,
},
}),
ConnectOnboarding: createNativeStackScreen({

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.

🟡 Medium src/Stack.tsx:420

The ConnectOnboarding route is registered unconditionally in RootStack, but ConnectOnboardingRouteScreen renders null when hasCloudPublicConfig() is false. Builds where CloudAuthProvider is active (e.g. a Clerk publishable key and relay URL are set) but hasCloudPublicConfig() returns false can still trigger onboarding navigation after sign-in, presenting users with a blank titled form sheet with no content and no way forward. Consider gating the route registration on hasCloudPublicConfig() so navigation to ConnectOnboarding is never possible when the screen would render nothing.

Also found in 1 other location(s)

apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx:25

ConnectOnboardingRouteScreen returns null whenever hasCloudPublicConfig() is false, but useConnectOnboardingNavigation() still navigates to "ConnectOnboarding" after sign-in based only on the account request. With a partial cloud config (for example publishableKey and relay.url set but jwtTemplate missing), users can still be routed here and get an empty form sheet with no explanation.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/Stack.tsx around line 420:

The `ConnectOnboarding` route is registered unconditionally in `RootStack`, but `ConnectOnboardingRouteScreen` renders `null` when `hasCloudPublicConfig()` is false. Builds where `CloudAuthProvider` is active (e.g. a Clerk publishable key and relay URL are set) but `hasCloudPublicConfig()` returns false can still trigger onboarding navigation after sign-in, presenting users with a blank titled form sheet with no content and no way forward. Consider gating the route registration on `hasCloudPublicConfig()` so navigation to `ConnectOnboarding` is never possible when the screen would render nothing.

Also found in 1 other location(s):
- apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx:25 -- `ConnectOnboardingRouteScreen` returns `null` whenever `hasCloudPublicConfig()` is false, but `useConnectOnboardingNavigation()` still navigates to `"ConnectOnboarding"` after sign-in based only on the account request. With a partial cloud config (for example `publishableKey` and `relay.url` set but `jwtTemplate` missing), users can still be routed here and get an empty form sheet with no explanation.

screen: ConnectOnboardingRouteScreen,
linking: "connect-onboarding",
options: {
// Root screenOptions hide headers; formSheets that want the native
// title bar opt back in with the sheet header preset.
...SHEET_SOLID_HEADER_OPTIONS,
title: "Set up T3 Connect",
gestureEnabled: true,
presentation: "formSheet",
sheetAllowedDetents: [0.6, 0.95],
sheetGrabberVisible: true,
},
}),
Connections: createNativeStackScreen({
screen: ConnectionsRouteScreen,
linking: "connections",
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const capabilitiesLayer = Layer.succeedContext(
if (session === null) {
return yield* new ConnectionBlockedError({
reason: "authentication",
detail: "Sign in to T3 Cloud to connect this environment.",
detail: "Sign in to T3 Connect to connect this environment.",
});
}
const token = yield* session.readClerkToken().pipe(
Expand All @@ -107,7 +107,7 @@ const capabilitiesLayer = Layer.succeedContext(
if (token === null) {
return yield* new ConnectionBlockedError({
reason: "authentication",
detail: "The T3 Cloud session is unavailable.",
detail: "The T3 Connect session is unavailable.",
});
}
return token;
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/features/cloud/CloudAuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
setAgentAwarenessRelayTokenProvider,
unregisterAgentAwarenessDeviceForCurrentUser,
} from "../agent-awareness/remoteRegistration";
import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding";
import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";

function resetManagedRelayTokenCache() {
Expand Down Expand Up @@ -67,6 +68,19 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
const nextAccount = isSignedIn && userId ? userId : null;
observedAccountRef.current = nextAccount;

// Every sign-in or account switch that completes during this session (a
// cold start observes undefined → account and must not re-prompt) requests
// the T3 Connect onboarding sheet — account transitions clear the
// connected environments, so each new session starts with no devices to
// reach. The request itself is issued after the cleanup transition inside
// activateSession, so the sheet never lists the previous account's
// environments; sign-out drops any not-yet-presented request instead.
const isAccountTransition =
previousObservedAccount !== undefined && previousObservedAccount !== nextAccount;
if (isAccountTransition && nextAccount === null) {
clearConnectOnboardingRequest();
}
Comment thread
cursor[bot] marked this conversation as resolved.

const queueAccountCleanup = (
previous: {
readonly userId: string;
Expand Down Expand Up @@ -114,6 +128,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
}
previousTokenProviderRef.current = { userId, provider: tokenProvider };

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.

🟡 Medium cloud/CloudAuthProvider.tsx:129

On an account switch, requestConnectOnboarding(userId) can silently fail to fire because isAccountTransition is computed synchronously at the top of the effect, but activateSession() runs only after await transition completes. The effect depends on getToken, which useAuth() recreates on every render, so a re-render during the queued cleanup re-runs the effect and overwrites observedAccountRef.current with the new userId, making isAccountTransition false. The first run is then cancelled, and the second run activates the new session without calling requestConnectOnboarding, so the onboarding sheet is skipped on account switches. Consider capturing isAccountTransition in the closure that survives cancellation, or derive the transition state from previousTokenProviderRef instead of the mutable observedAccountRef.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/cloud/CloudAuthProvider.tsx around line 129:

On an account switch, `requestConnectOnboarding(userId)` can silently fail to fire because `isAccountTransition` is computed synchronously at the top of the effect, but `activateSession()` runs only after `await transition` completes. The effect depends on `getToken`, which `useAuth()` recreates on every render, so a re-render during the queued cleanup re-runs the effect and overwrites `observedAccountRef.current` with the new `userId`, making `isAccountTransition` false. The first run is then cancelled, and the second run activates the new session without calling `requestConnectOnboarding`, so the onboarding sheet is skipped on account switches. Consider capturing `isAccountTransition` in the closure that survives cancellation, or derive the transition state from `previousTokenProviderRef` instead of the mutable `observedAccountRef`.

activateCloudRelayAccount(userId, tokenProvider);
if (isAccountTransition) {
requestConnectOnboarding(userId);
}

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.

Onboarding request dropped on effect rerun

High Severity

Deferring requestConnectOnboarding to async activateSession gates it on isAccountTransition from that effect run. After observedAccountRef is updated, a second effect run (e.g. when getToken changes) sees no transition, cancels the first activation, and never requests onboarding—so the post-sign-in sheet can be skipped.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cac134c. Configure here.

};
const activateAfterTransition = (transition: Promise<void>) => {
void (async () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
You are on the waitlist
</Text>
<Text className="text-center font-sans text-base text-foreground-secondary">
We will email you when your T3 Cloud access is ready.
We will email you when your T3 Connect access is ready.
</Text>
<SignInAction onPress={props.onSignIn} />
</View>
Expand Down
129 changes: 129 additions & 0 deletions apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { NativeHeaderToolbar } from "../../native/StackHeader";
import { useAuth } from "@clerk/expo";
import { StackActions, useNavigation } from "@react-navigation/native";
import { useCallback, useEffect, useState } from "react";
import { Pressable, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime";
import { AppText as Text } from "../../components/AppText";
import { useRemoteConnections } from "../../state/use-remote-environment-registry";
import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
import { splitEnvironmentSections } from "../connection/environmentSections";
import { useConnectionController } from "../connection/useConnectionController";
import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut";
import { hasCloudPublicConfig } from "./publicConfig";

/**
* Post-sign-in onboarding sheet for T3 Connect. Mobile never publishes
* environments itself — it consumes ones published elsewhere — so this simply
* surfaces the account's T3 Connect environments right after sign-in so every
* device can be connected in one go. It shows on every sign-in: sign-out
* clears the connected environments, so each new session starts from zero.
*/
export function ConnectOnboardingRouteScreen() {
const navigation = useNavigation();

// The route is deep-linkable; without cloud config the sheet would present
// empty with no chrome to dismiss it, so bail back out instead.
useEffect(() => {
if (hasCloudPublicConfig()) {
return;
}
if (navigation.canGoBack()) {
navigation.goBack();
} else {
navigation.dispatch(StackActions.replace("Home"));
}
}, [navigation]);

Comment thread
cursor[bot] marked this conversation as resolved.
return hasCloudPublicConfig() ? <ConfiguredConnectOnboardingRouteScreen /> : null;
}

function ConfiguredConnectOnboardingRouteScreen() {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections();
const { refreshRelayEnvironments } = useConnectionController();
const { connectedCloudEnvironments } = splitEnvironmentSections({
connectedEnvironments,
cloudEnvironments: null,
});

// Pull-to-refresh tracks its own spinner instead of discovery's refreshing
// flag, so background refreshes (e.g. the sign-in one) don't yank the
// content down.
const [isPullRefreshing, setIsPullRefreshing] = useState(false);
const handlePullRefresh = useCallback(() => {
void (async () => {
setIsPullRefreshing(true);
await refreshRelayEnvironments();
setIsPullRefreshing(false);
})();
}, [refreshRelayEnvironments]);

const handleClose = useCallback(() => {
navigation.goBack();
}, [navigation]);

// Persist before dismissing so a quick sign-out/sign-in cannot race ahead
// of the preference write; the write is a local secure-store update.
const handleDontShowAgain = useCallback(() => {
void (async () => {
if (userId) {
const result = await settlePromise(() => optOutOfConnectOnboarding(userId));
reportAtomCommandResult(result, { label: "connect onboarding opt-out" });
}
navigation.goBack();
})();
}, [navigation, userId]);

return (
<View collapsable={false} className="flex-1 bg-sheet">
<NativeHeaderToolbar placement="right">
<NativeHeaderToolbar.Button icon="xmark" onPress={handleClose} separateBackground />
</NativeHeaderToolbar>
<ScrollView
alwaysBounceVertical
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
style={{ flex: 1 }}
contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
contentContainerStyle={{
gap: 16,
paddingHorizontal: 20,
paddingTop: 16,
}}
refreshControl={
<RefreshControl refreshing={isPullRefreshing} onRefresh={handlePullRefresh} />
}
>
{isSignedIn ? (
<CloudEnvironmentRows
connectedCloudEnvironments={connectedCloudEnvironments}
onReconnectEnvironment={onReconnectEnvironment}
showHeader={false}
/>
) : (
<View collapsable={false} className="rounded-[24px] bg-card p-5">
<Text className="text-sm leading-normal text-foreground-muted">
Sign in to your T3 account to set up T3 Connect.
</Text>
</View>
)}

{userId ? (
<Pressable
accessibilityRole="button"
hitSlop={8}
onPress={handleDontShowAgain}
className="items-center py-1 active:opacity-70"
>
<Text className="text-xs text-foreground-muted">{"Don't show this again"}</Text>
</Pressable>
) : null}
</ScrollView>
</View>
);
}
24 changes: 24 additions & 0 deletions apps/mobile/src/features/cloud/connectOnboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Atom } from "effect/unstable/reactivity";

import { appAtomRegistry } from "../../state/atom-registry";

// Signals RootStackLayout (inside the navigation tree) that an in-session
// sign-in just completed. Holds the account id so a sign-out between the
// request and the navigation cannot present the sheet for the wrong account.
export const connectOnboardingRequestAtom = Atom.make<string | null>(null).pipe(
Atom.keepAlive,
Atom.withLabel("mobile:connect-onboarding-request"),
);

/**
* Requests the onboarding sheet for the given account. Sign-out clears the
* connected environments, so onboarding runs on every in-session sign-in —
* each new session starts with no connected devices.
*/
export function requestConnectOnboarding(accountId: string): void {
appAtomRegistry.set(connectOnboardingRequestAtom, accountId);
}

export function clearConnectOnboardingRequest(): void {
appAtomRegistry.set(connectOnboardingRequestAtom, null);
}
49 changes: 49 additions & 0 deletions apps/mobile/src/features/cloud/connectOnboardingNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useAtomValue } from "@effect/atom-react";
import { useNavigation } from "@react-navigation/native";
import { useEffect } from "react";

import { appAtomRegistry } from "../../state/atom-registry";
import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding";
import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut";

// Sign-in happens inside the Settings sheet; give its detent/session-state
// transitions a beat to settle before presenting another formSheet on top.
const PRESENT_ONBOARDING_DELAY_MS = 600;

/**
* Consumes the onboarding request inside the navigation tree (RootStackLayout)
* and presents the onboarding formSheet. Lives apart from connectOnboarding.ts
* so non-navigation consumers (CloudAuthProvider) do not pull
* @react-navigation into their module graph.
*/
export function useConnectOnboardingNavigation(): void {
const navigation = useNavigation();
const requestedAccountId = useAtomValue(connectOnboardingRequestAtom);

useEffect(() => {
if (requestedAccountId === null) {
return;
}
let cancelled = false;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const timer = setTimeout(() => {
void (async () => {
// A failed preference read prefers showing the sheet.
const optedOut = await isConnectOnboardingOptedOut(requestedAccountId).catch(() => false);
// The cancelled flag covers effect re-runs, but a sign-out can clear
// the request atom moments before this render commits — re-check the
// atom so a stale request never presents the sheet.
if (cancelled || appAtomRegistry.get(connectOnboardingRequestAtom) !== requestedAccountId) {
return;
}
clearConnectOnboardingRequest();
if (!optedOut) {
navigation.navigate("ConnectOnboarding");
}
})();
}, PRESENT_ONBOARDING_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [navigation, requestedAccountId]);
Comment thread
cursor[bot] marked this conversation as resolved.
}
21 changes: 21 additions & 0 deletions apps/mobile/src/features/cloud/connectOnboardingOptOut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { loadPreferences, updatePreferences } from "../../lib/storage";

// Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports
// the request signal) never pulls lib/storage — expo-secure-store — into its
// module graph; that breaks CloudAuthProvider.test.ts suite loading.

/** Whether the account chose "Don't show this again". */
export async function isConnectOnboardingOptedOut(accountId: string): Promise<boolean> {
const preferences = await loadPreferences();
return preferences.connectOnboardingOptOutAccounts?.includes(accountId) ?? false;
}

/** Persists "Don't show this again" for the account. */
export async function optOutOfConnectOnboarding(accountId: string): Promise<void> {
await updatePreferences((current) => {
const optedOut = current.connectOnboardingOptOutAccounts ?? [];
return optedOut.includes(accountId)
? {}
: { connectOnboardingOptOutAccounts: [...optedOut, accountId] };
});
}
Loading
Loading