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 new root-level ConnectOnboarding route renders ConnectOnboardingRouteScreen, which returns null when hasCloudPublicConfig() is false. Navigating to connect-onboarding (via deep link or direct navigation) on builds without cloud config opens an empty form sheet with no content and no way to dismiss or redirect — the user is stuck. Unlike SettingsAuthRouteScreen and other config-gated screens, this route has no fallback redirect. Consider redirecting to Home (or another valid route) when the cloud config is unavailable, or guarding the route so it is not reachable in those builds.

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

The new root-level `ConnectOnboarding` route renders `ConnectOnboardingRouteScreen`, which returns `null` when `hasCloudPublicConfig()` is `false`. Navigating to `connect-onboarding` (via deep link or direct navigation) on builds without cloud config opens an empty form sheet with no content and no way to dismiss or redirect — the user is stuck. Unlike `SettingsAuthRouteScreen` and other config-gated screens, this route has no fallback redirect. Consider redirecting to `Home` (or another valid route) when the cloud config is unavailable, or guarding the route so it is not reachable in those builds.

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
9 changes: 9 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 { requestConnectOnboarding } from "./connectOnboarding";
import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";

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

// Every sign-in that completes during this session (a cold start observes
// undefined → account and must not re-prompt) requests the T3 Connect
// onboarding sheet — sign-out clears the connected environments, so each
// new session starts with no devices to reach.
if (previousObservedAccount === null && nextAccount !== null) {
requestConnectOnboarding(nextAccount);
}
Comment thread
cursor[bot] marked this conversation as resolved.

const queueAccountCleanup = (
previous: {
readonly userId: string;
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
111 changes: 111 additions & 0 deletions apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { NativeHeaderToolbar } from "../../native/StackHeader";
import { useAuth } from "@clerk/expo";
import { useNavigation } from "@react-navigation/native";
import { useCallback, 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() {
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]);

const handleDontShowAgain = useCallback(() => {
if (userId) {
void (async () => {
const result = await settlePromise(() => optOutOfConnectOnboarding(userId));
reportAtomCommandResult(result, { label: "connect onboarding opt-out" });
})();
}
navigation.goBack();
}, [navigation, 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.

Opt-out save races navigation

Low Severity

handleDontShowAgain starts optOutOfConnectOnboarding asynchronously but calls navigation.goBack() immediately. If preference persistence fails or finishes after the user signs in again quickly, the opt-out may not be stored and onboarding can appear on the next sign-in despite choosing “Don’t show this again”.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4f648f1. Configure here.


return (
<View collapsable={false} className="flex-1 bg-sheet">
<NativeHeaderToolbar placement="right">
<NativeHeaderToolbar.Button icon="xmark" onPress={handleClose} separateBackground />
</NativeHeaderToolbar>
<ScrollView
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}

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 blocks refresh retry

Medium Severity

Onboarding hides the CloudEnvironmentRows header refresh control and relies on pull-to-refresh, but the ScrollView content often stays shorter than the sheet. On iOS that can prevent overscroll, so users who see “Could not load T3 Connect environments” may have no way to retry without leaving onboarding.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 593aa69. Configure here.

/>
) : (
<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);
}
45 changes: 45 additions & 0 deletions apps/mobile/src/features/cloud/connectOnboardingNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useAtomValue } from "@effect/atom-react";
import { useNavigation } from "@react-navigation/native";
import { useEffect } from "react";

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;
const timer = setTimeout(() => {
void (async () => {
// A failed preference read prefers showing the sheet.
const optedOut = await isConnectOnboardingOptedOut(requestedAccountId).catch(() => false);
if (cancelled) {
return;
}
clearConnectOnboardingRequest();
if (!optedOut) {
navigation.navigate("ConnectOnboarding");
}
})();

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.

Mobile onboarding opens after sign-out

Medium Severity

The useConnectOnboardingNavigation hook can incorrectly present the onboarding sheet for a stale account. After its 600ms delay, it navigates using a requestedAccountId that isn't validated against the current user. This occurs because connectOnboardingRequestAtom isn't cleared on sign-out or account switch.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4f648f1. Configure here.

}, PRESENT_ONBOARDING_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [navigation, requestedAccountId]);
}
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, savePreferencesPatch } 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> {

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/connectOnboardingOptOut.ts:14

optOutOfConnectOnboarding performs a read-modify-write on the Preferences blob via loadPreferences() then savePreferencesPatch(). If another caller (e.g. the appearance settings provider) calls savePreferencesPatch() between these two steps, whichever write finishes last overwrites the other's fields, so the new connectOnboardingOptOutAccounts (or the concurrent preference change) is silently lost. Consider making the read-modify-write atomic — e.g. by doing both inside a single critical section that serializes preference writes.

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

`optOutOfConnectOnboarding` performs a read-modify-write on the `Preferences` blob via `loadPreferences()` then `savePreferencesPatch()`. If another caller (e.g. the appearance settings provider) calls `savePreferencesPatch()` between these two steps, whichever write finishes last overwrites the other's fields, so the new `connectOnboardingOptOutAccounts` (or the concurrent preference change) is silently lost. Consider making the read-modify-write atomic — e.g. by doing both inside a single critical section that serializes preference writes.

const preferences = await loadPreferences();
const optedOut = preferences.connectOnboardingOptOutAccounts ?? [];
if (optedOut.includes(accountId)) {
return;
}
await savePreferencesPatch({ connectOnboardingOptOutAccounts: [...optedOut, accountId] });
}
Loading
Loading