Skip to content

Commit dad0889

Browse files
Revert "Revert "Add T3 Connect onboarding for mobile and web"" (#3777)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b9cc8d6 commit dad0889

26 files changed

Lines changed: 1682 additions & 727 deletions

apps/mobile/src/Stack.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { AppText as Text } from "./components/AppText";
1717
import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
1818
import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
1919
import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";
20+
import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
21+
import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation";
2022
import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
2123
import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
2224
import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
@@ -220,6 +222,7 @@ const NewTaskSheetStack = createNativeStackNavigator({
220222
// influence the adaptive workspace layout: opening Settings over Home should
221223
// not flip the sidebar in or change the active thread.
222224
const WORKSPACE_OVERLAY_ROUTES = new Set([
225+
"ConnectOnboarding",
223226
"Connections",
224227
"ConnectionsNew",
225228
"GitBranches",
@@ -251,6 +254,8 @@ function RootStackLayout(props: {
251254
}) {
252255
useAgentNotificationNavigation();
253256
useThreadOutboxDrain();
257+
// Presents the T3 Connect onboarding sheet after an in-session sign-in.
258+
useConnectOnboardingNavigation();
254259
// Full pathname (sheets included) for keyboard-command scoping; the
255260
// workspace layout only reacts to the underlying non-overlay route.
256261
const path = getPathFromState(props.state, navigationPathConfig);
@@ -412,6 +417,20 @@ export const RootStack = createNativeStackNavigator({
412417
sheetGrabberVisible: true,
413418
},
414419
}),
420+
ConnectOnboarding: createNativeStackScreen({
421+
screen: ConnectOnboardingRouteScreen,
422+
linking: "connect-onboarding",
423+
options: {
424+
// Root screenOptions hide headers; formSheets that want the native
425+
// title bar opt back in with the sheet header preset.
426+
...SHEET_SOLID_HEADER_OPTIONS,
427+
title: "Set up T3 Connect",
428+
gestureEnabled: true,
429+
presentation: "formSheet",
430+
sheetAllowedDetents: [0.6, 0.95],
431+
sheetGrabberVisible: true,
432+
},
433+
}),
415434
Connections: createNativeStackScreen({
416435
screen: ConnectionsRouteScreen,
417436
linking: "connections",

apps/mobile/src/connection/platform.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ const capabilitiesLayer = Layer.succeedContext(
9292
if (session === null) {
9393
return yield* new ConnectionBlockedError({
9494
reason: "authentication",
95-
detail: "Sign in to T3 Cloud to connect this environment.",
95+
detail: "Sign in to T3 Connect to connect this environment.",
9696
});
9797
}
9898
const token = yield* session.readClerkToken().pipe(
@@ -107,7 +107,7 @@ const capabilitiesLayer = Layer.succeedContext(
107107
if (token === null) {
108108
return yield* new ConnectionBlockedError({
109109
reason: "authentication",
110-
detail: "The T3 Cloud session is unavailable.",
110+
detail: "The T3 Connect session is unavailable.",
111111
});
112112
}
113113
return token;

apps/mobile/src/features/cloud/CloudAuthProvider.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
setAgentAwarenessRelayTokenProvider,
1919
unregisterAgentAwarenessDeviceForCurrentUser,
2020
} from "../agent-awareness/remoteRegistration";
21+
import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding";
2122
import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
2223

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

71+
// Every sign-in or account switch that completes during this session (a
72+
// cold start observes undefined → account and must not re-prompt) requests
73+
// the T3 Connect onboarding sheet — account transitions clear the
74+
// connected environments, so each new session starts with no devices to
75+
// reach. The request itself is issued after the cleanup transition inside
76+
// activateSession, so the sheet never lists the previous account's
77+
// environments; sign-out drops any not-yet-presented request instead.
78+
const isAccountTransition =
79+
previousObservedAccount !== undefined && previousObservedAccount !== nextAccount;
80+
if (isAccountTransition && nextAccount === null) {
81+
clearConnectOnboardingRequest();
82+
}
83+
7084
const queueAccountCleanup = (
7185
previous: {
7286
readonly userId: string;
@@ -114,6 +128,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
114128
}
115129
previousTokenProviderRef.current = { userId, provider: tokenProvider };
116130
activateCloudRelayAccount(userId, tokenProvider);
131+
if (isAccountTransition) {
132+
requestConnectOnboarding(userId);
133+
}
117134
};
118135
const activateAfterTransition = (transition: Promise<void>) => {
119136
void (async () => {

apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
3939
You are on the waitlist
4040
</Text>
4141
<Text className="text-center font-sans text-base text-foreground-secondary">
42-
We will email you when your T3 Cloud access is ready.
42+
We will email you when your T3 Connect access is ready.
4343
</Text>
4444
<SignInAction onPress={props.onSignIn} />
4545
</View>
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { NativeHeaderToolbar } from "../../native/StackHeader";
2+
import { useAuth } from "@clerk/expo";
3+
import { StackActions, useNavigation } from "@react-navigation/native";
4+
import { useCallback, useEffect, useState } from "react";
5+
import { Pressable, RefreshControl, ScrollView, View } from "react-native";
6+
import { useSafeAreaInsets } from "react-native-safe-area-context";
7+
8+
import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime";
9+
import { AppText as Text } from "../../components/AppText";
10+
import { useRemoteConnections } from "../../state/use-remote-environment-registry";
11+
import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
12+
import { splitEnvironmentSections } from "../connection/environmentSections";
13+
import { useConnectionController } from "../connection/useConnectionController";
14+
import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut";
15+
import { hasCloudPublicConfig } from "./publicConfig";
16+
17+
/**
18+
* Post-sign-in onboarding sheet for T3 Connect. Mobile never publishes
19+
* environments itself — it consumes ones published elsewhere — so this simply
20+
* surfaces the account's T3 Connect environments right after sign-in so every
21+
* device can be connected in one go. It shows on every sign-in: sign-out
22+
* clears the connected environments, so each new session starts from zero.
23+
*/
24+
export function ConnectOnboardingRouteScreen() {
25+
const navigation = useNavigation();
26+
27+
// The route is deep-linkable; without cloud config the sheet would present
28+
// empty with no chrome to dismiss it, so bail back out instead.
29+
useEffect(() => {
30+
if (hasCloudPublicConfig()) {
31+
return;
32+
}
33+
if (navigation.canGoBack()) {
34+
navigation.goBack();
35+
} else {
36+
navigation.dispatch(StackActions.replace("Home"));
37+
}
38+
}, [navigation]);
39+
40+
return hasCloudPublicConfig() ? <ConfiguredConnectOnboardingRouteScreen /> : null;
41+
}
42+
43+
function ConfiguredConnectOnboardingRouteScreen() {
44+
const navigation = useNavigation();
45+
const insets = useSafeAreaInsets();
46+
const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
47+
const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections();
48+
const { refreshRelayEnvironments } = useConnectionController();
49+
const { connectedCloudEnvironments } = splitEnvironmentSections({
50+
connectedEnvironments,
51+
cloudEnvironments: null,
52+
});
53+
54+
// Pull-to-refresh tracks its own spinner instead of discovery's refreshing
55+
// flag, so background refreshes (e.g. the sign-in one) don't yank the
56+
// content down.
57+
const [isPullRefreshing, setIsPullRefreshing] = useState(false);
58+
const handlePullRefresh = useCallback(() => {
59+
void (async () => {
60+
setIsPullRefreshing(true);
61+
await refreshRelayEnvironments();
62+
setIsPullRefreshing(false);
63+
})();
64+
}, [refreshRelayEnvironments]);
65+
66+
const handleClose = useCallback(() => {
67+
navigation.goBack();
68+
}, [navigation]);
69+
70+
// Persist before dismissing so a quick sign-out/sign-in cannot race ahead
71+
// of the preference write; the write is a local secure-store update.
72+
const handleDontShowAgain = useCallback(() => {
73+
void (async () => {
74+
if (userId) {
75+
const result = await settlePromise(() => optOutOfConnectOnboarding(userId));
76+
reportAtomCommandResult(result, { label: "connect onboarding opt-out" });
77+
}
78+
navigation.goBack();
79+
})();
80+
}, [navigation, userId]);
81+
82+
return (
83+
<View collapsable={false} className="flex-1 bg-sheet">
84+
<NativeHeaderToolbar placement="right">
85+
<NativeHeaderToolbar.Button icon="xmark" onPress={handleClose} separateBackground />
86+
</NativeHeaderToolbar>
87+
<ScrollView
88+
alwaysBounceVertical
89+
contentInsetAdjustmentBehavior="automatic"
90+
showsVerticalScrollIndicator={false}
91+
style={{ flex: 1 }}
92+
contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
93+
contentContainerStyle={{
94+
gap: 16,
95+
paddingHorizontal: 20,
96+
paddingTop: 16,
97+
}}
98+
refreshControl={
99+
<RefreshControl refreshing={isPullRefreshing} onRefresh={handlePullRefresh} />
100+
}
101+
>
102+
{isSignedIn ? (
103+
<CloudEnvironmentRows
104+
connectedCloudEnvironments={connectedCloudEnvironments}
105+
onReconnectEnvironment={onReconnectEnvironment}
106+
showHeader={false}
107+
/>
108+
) : (
109+
<View collapsable={false} className="rounded-[24px] bg-card p-5">
110+
<Text className="text-sm leading-normal text-foreground-muted">
111+
Sign in to your T3 account to set up T3 Connect.
112+
</Text>
113+
</View>
114+
)}
115+
116+
{userId ? (
117+
<Pressable
118+
accessibilityRole="button"
119+
hitSlop={8}
120+
onPress={handleDontShowAgain}
121+
className="items-center py-1 active:opacity-70"
122+
>
123+
<Text className="text-xs text-foreground-muted">{"Don't show this again"}</Text>
124+
</Pressable>
125+
) : null}
126+
</ScrollView>
127+
</View>
128+
);
129+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Atom } from "effect/unstable/reactivity";
2+
3+
import { appAtomRegistry } from "../../state/atom-registry";
4+
5+
// Signals RootStackLayout (inside the navigation tree) that an in-session
6+
// sign-in just completed. Holds the account id so a sign-out between the
7+
// request and the navigation cannot present the sheet for the wrong account.
8+
export const connectOnboardingRequestAtom = Atom.make<string | null>(null).pipe(
9+
Atom.keepAlive,
10+
Atom.withLabel("mobile:connect-onboarding-request"),
11+
);
12+
13+
/**
14+
* Requests the onboarding sheet for the given account. Sign-out clears the
15+
* connected environments, so onboarding runs on every in-session sign-in —
16+
* each new session starts with no connected devices.
17+
*/
18+
export function requestConnectOnboarding(accountId: string): void {
19+
appAtomRegistry.set(connectOnboardingRequestAtom, accountId);
20+
}
21+
22+
export function clearConnectOnboardingRequest(): void {
23+
appAtomRegistry.set(connectOnboardingRequestAtom, null);
24+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { useAtomValue } from "@effect/atom-react";
2+
import { useNavigation } from "@react-navigation/native";
3+
import { useEffect } from "react";
4+
5+
import { appAtomRegistry } from "../../state/atom-registry";
6+
import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding";
7+
import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut";
8+
9+
// Sign-in happens inside the Settings sheet; give its detent/session-state
10+
// transitions a beat to settle before presenting another formSheet on top.
11+
const PRESENT_ONBOARDING_DELAY_MS = 600;
12+
13+
/**
14+
* Consumes the onboarding request inside the navigation tree (RootStackLayout)
15+
* and presents the onboarding formSheet. Lives apart from connectOnboarding.ts
16+
* so non-navigation consumers (CloudAuthProvider) do not pull
17+
* @react-navigation into their module graph.
18+
*/
19+
export function useConnectOnboardingNavigation(): void {
20+
const navigation = useNavigation();
21+
const requestedAccountId = useAtomValue(connectOnboardingRequestAtom);
22+
23+
useEffect(() => {
24+
if (requestedAccountId === null) {
25+
return;
26+
}
27+
let cancelled = false;
28+
const timer = setTimeout(() => {
29+
void (async () => {
30+
// A failed preference read prefers showing the sheet.
31+
const optedOut = await isConnectOnboardingOptedOut(requestedAccountId).catch(() => false);
32+
// The cancelled flag covers effect re-runs, but a sign-out can clear
33+
// the request atom moments before this render commits — re-check the
34+
// atom so a stale request never presents the sheet.
35+
if (cancelled || appAtomRegistry.get(connectOnboardingRequestAtom) !== requestedAccountId) {
36+
return;
37+
}
38+
clearConnectOnboardingRequest();
39+
if (!optedOut) {
40+
navigation.navigate("ConnectOnboarding");
41+
}
42+
})();
43+
}, PRESENT_ONBOARDING_DELAY_MS);
44+
return () => {
45+
cancelled = true;
46+
clearTimeout(timer);
47+
};
48+
}, [navigation, requestedAccountId]);
49+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { loadPreferences, updatePreferences } from "../../lib/storage";
2+
3+
// Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports
4+
// the request signal) never pulls lib/storage — expo-secure-store — into its
5+
// module graph; that breaks CloudAuthProvider.test.ts suite loading.
6+
7+
/** Whether the account chose "Don't show this again". */
8+
export async function isConnectOnboardingOptedOut(accountId: string): Promise<boolean> {
9+
const preferences = await loadPreferences();
10+
return preferences.connectOnboardingOptOutAccounts?.includes(accountId) ?? false;
11+
}
12+
13+
/** Persists "Don't show this again" for the account. */
14+
export async function optOutOfConnectOnboarding(accountId: string): Promise<void> {
15+
await updatePreferences((current) => {
16+
const optedOut = current.connectOnboardingOptOutAccounts ?? [];
17+
return optedOut.includes(accountId)
18+
? {}
19+
: { connectOnboardingOptOutAccounts: [...optedOut, accountId] };
20+
});
21+
}

0 commit comments

Comments
 (0)