History

Simplify Connect onboarding to sign-in only

- Remove device publishing flow from onboarding - Add per-account "Don't show this again" opt-out - Update mobile and web copy to T3 Connect

Julius Marminge committed 4 months ago

Commit 4f648f11 parent 53ab01620 files · +301 −683Browse files

Unified · Split

20 files
  1. apps/mobile/src/Stack.tsx +2 −2
  2. apps/mobile/src/connection/platform.ts +2 −2
  3. apps/mobile/src/features/cloud/CloudAuthProvider.tsx +5 −7
  4. apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx +1 −1
  5. apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +46 −441
  6. apps/mobile/src/features/cloud/connectOnboarding.ts +7 −51
  7. apps/mobile/src/features/cloud/connectOnboardingNavigation.ts +45 −0
  8. apps/mobile/src/features/cloud/connectOnboardingOptOut.ts +21 −0
  9. apps/mobile/src/features/cloud/linkEnvironment.ts +4 −102
  10. apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +3 −3
  11. apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +1 −1
  12. apps/mobile/src/features/connection/environmentSections.test.ts +1 −1
  13. apps/mobile/src/features/settings/SettingsRouteScreen.tsx +2 −2
  14. apps/mobile/src/lib/storage.ts +7 −8
  15. apps/web/src/cloud/connectOnboarding.ts +9 −9
  16. apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +79 −42
  17. apps/web/src/connection/platform.ts +2 −2
  18. packages/client-runtime/src/relay/discovery.test.ts +49 −0
  19. packages/client-runtime/src/relay/discovery.ts +11 −5
  20. packages/client-runtime/src/relay/managedRelayState.ts +4 −4
apps/mobile/src/Stack.tsx+2−2
@@ -18,7 +18,7 @@ import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRo · 17 unmodified lines
18import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";18import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
19import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";19import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";
20import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";20import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
21import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboarding";21import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation";
22import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";22import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
23import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";23import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
24import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";24import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
@@ -423,7 +423,7 @@ export const RootStack = createNativeStackNavigator({ · 398 unmodified lines
423 options: {423 options: {
424 gestureEnabled: true,424 gestureEnabled: true,
425 presentation: "formSheet",425 presentation: "formSheet",
426 sheetAllowedDetents: [0.75, 0.95],426 sheetAllowedDetents: [0.6, 0.95],
427 sheetGrabberVisible: true,427 sheetGrabberVisible: true,
428 },428 },
429 }),429 }),
apps/mobile/src/connection/platform.ts+2−2
@@ -92,7 +92,7 @@ const capabilitiesLayer = Layer.succeedContext( · 91 unmodified lines
92 if (session === null) {92 if (session === null) {
93 return yield* new ConnectionBlockedError({93 return yield* new ConnectionBlockedError({
94 reason: "authentication",94 reason: "authentication",
95 detail: "Sign in to T3 Cloud to connect this environment.",95 detail: "Sign in to T3 Connect to connect this environment.",
96 });96 });
97 }97 }
98 const token = yield* session.readClerkToken().pipe(98 const token = yield* session.readClerkToken().pipe(
@@ -107,7 +107,7 @@ const capabilitiesLayer = Layer.succeedContext( · 8 unmodified lines
107 if (token === null) {107 if (token === null) {
108 return yield* new ConnectionBlockedError({108 return yield* new ConnectionBlockedError({
109 reason: "authentication",109 reason: "authentication",
110 detail: "The T3 Cloud session is unavailable.",110 detail: "The T3 Connect session is unavailable.",
111 });111 });
112 }112 }
113 return token;113 return token;
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+5−7
@@ -18,7 +18,7 @@ import { · 17 unmodified lines
18 setAgentAwarenessRelayTokenProvider,18 setAgentAwarenessRelayTokenProvider,
19 unregisterAgentAwarenessDeviceForCurrentUser,19 unregisterAgentAwarenessDeviceForCurrentUser,
20} from "../agent-awareness/remoteRegistration";20} from "../agent-awareness/remoteRegistration";
21import { requestConnectOnboardingIfNeeded } from "./connectOnboarding";21import { requestConnectOnboarding } from "./connectOnboarding";
22import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";22import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
23 23
24function resetManagedRelayTokenCache() {24function resetManagedRelayTokenCache() {
@@ -68,14 +68,12 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { · 43 unmodified lines
68 const nextAccount = isSignedIn && userId ? userId : null;68 const nextAccount = isSignedIn && userId ? userId : null;
69 observedAccountRef.current = nextAccount;69 observedAccountRef.current = nextAccount;
70 70
71 // A sign-in that completed during this session (a cold start observes71 // Every sign-in that completes during this session (a cold start observes
72 // undefined → account and must not re-prompt) requests the T3 Connect72 // undefined → account and must not re-prompt) requests the T3 Connect
73 // onboarding sheet once per account.73 // onboarding sheet — sign-out clears the connected environments, so each
74 // new session starts with no devices to reach.
74 if (previousObservedAccount === null && nextAccount !== null) {75 if (previousObservedAccount === null && nextAccount !== null) {
75 void (async () => {76 requestConnectOnboarding(nextAccount);
76 const result = await settlePromise(() => requestConnectOnboardingIfNeeded(nextAccount));
77 reportAtomCommandResult(result, { label: "connect onboarding request" });
78 })();
79 }77 }
80 78
81 const queueAccountCleanup = (79 const queueAccountCleanup = (
apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx+1−1
@@ -39,7 +39,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } · 38 unmodified lines
39 You are on the waitlist39 You are on the waitlist
40 </Text>40 </Text>
41 <Text className="text-center font-sans text-base text-foreground-secondary">41 <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.
43 </Text>43 </Text>
44 <SignInAction onPress={props.onSignIn} />44 <SignInAction onPress={props.onSignIn} />
45 </View>45 </View>
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx+46−441
@@ -1,67 +1,23 @@
1import { useAuth } from "@clerk/expo";1import { useAuth } from "@clerk/expo";
2import { useNavigation } from "@react-navigation/native";2import { useNavigation } from "@react-navigation/native";
3import * as Effect from "effect/Effect";3import { useCallback } from "react";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";4import { Pressable, ScrollView, View } from "react-native";
5import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
7 6
8import {7import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime";
9 isAtomCommandInterrupted,
10 reportAtomCommandResult,
11 settleAsyncResult,
12 settlePromise,
13 squashAtomCommandFailure,
14} from "@t3tools/client-runtime/state/runtime";
15import { AppText as Text } from "../../components/AppText";8import { AppText as Text } from "../../components/AppText";
16import type { SavedRemoteConnection } from "../../lib/connection";9import { useRemoteConnections } from "../../state/use-remote-environment-registry";
17import { cn } from "../../lib/cn";
18import { runtime } from "../../lib/runtime";
19import { useThemeColor } from "../../lib/useThemeColor";
20import {
21 useRemoteConnections,
22 useSavedRemoteConnections,
23} from "../../state/use-remote-environment-registry";
24import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";10import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
25import { splitEnvironmentSections } from "../connection/environmentSections";11import { splitEnvironmentSections } from "../connection/environmentSections";
26import { SettingsSection } from "../settings/components/SettingsSection";12import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut";
27import { SettingsSwitchRow } from "../settings/components/SettingsSwitchRow";13import { hasCloudPublicConfig } from "./publicConfig";
28import { markConnectOnboardingCompleted } from "./connectOnboarding";
29import {
30 getEnvironmentConnectPublishState,
31 linkEnvironmentToCloud,
32 setEnvironmentPublishAgentActivity,
33} from "./linkEnvironment";
34import { refreshManagedRelayEnvironments } from "./managedRelayState";
35import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
36
37type PublishTargetPhase = "checking" | "ready" | "published" | "unauthorized" | "error";
38
39interface PublishTarget {
40 readonly connection: SavedRemoteConnection;
41 readonly phase: PublishTargetPhase;
42 readonly linked: boolean;
43 readonly managedTunnelActive: boolean;
44 readonly publishAgentActivity: boolean;
45 readonly error: string | null;
46}
47
48function pendingPublishTarget(connection: SavedRemoteConnection): PublishTarget {
49 return {
50 connection,
51 phase: "checking",
52 linked: false,
53 managedTunnelActive: false,
54 publishAgentActivity: false,
55 error: null,
56 };
57}
58 14
59/**15/**
60 * Post-sign-in onboarding sheet for T3 Connect. Offers to publish this16 * Post-sign-in onboarding sheet for T3 Connect. Mobile never publishes
61 * device's locally paired environments (managed tunnel + agent activity, both17 * environments itself — it consumes ones published elsewhere — so this simply
62 * defaulting on) when the paired session is authorized, then lists the18 * surfaces the account's T3 Connect environments right after sign-in so every
63 * account's T3 Connect environments so every device can be connected right19 * device can be connected in one go. It shows on every sign-in: sign-out
64 * away. Dismissing the sheet counts as completion — it never nags twice.20 * clears the connected environments, so each new session starts from zero.
65 */21 */
66export function ConnectOnboardingRouteScreen() {22export function ConnectOnboardingRouteScreen() {
67 return hasCloudPublicConfig() ? <ConfiguredConnectOnboardingRouteScreen /> : null;23 return hasCloudPublicConfig() ? <ConfiguredConnectOnboardingRouteScreen /> : null;
@@ -70,195 +26,26 @@ export function ConnectOnboardingRouteScreen() { · 2 unmodified lines
70function ConfiguredConnectOnboardingRouteScreen() {26function ConfiguredConnectOnboardingRouteScreen() {
71 const navigation = useNavigation();27 const navigation = useNavigation();
72 const insets = useSafeAreaInsets();28 const insets = useSafeAreaInsets();
73 const { getToken, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });29 const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
74 const { isLoadingSavedConnection, savedConnectionsById } = useSavedRemoteConnections();
75 const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections();30 const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections();
76 const { connectedCloudEnvironments } = splitEnvironmentSections({31 const { connectedCloudEnvironments } = splitEnvironmentSections({
77 connectedEnvironments,32 connectedEnvironments,
78 cloudEnvironments: null,33 cloudEnvironments: null,
79 });34 });
80 35
81 // Dismissing the sheet (swipe, Skip, or Done) counts as completion.
82 const completionPersistedRef = useRef(false);
83 const persistCompletion = useCallback(() => {
84 if (completionPersistedRef.current || !userId) {
85 return;
86 }
87 completionPersistedRef.current = true;
88 void (async () => {
89 const result = await settlePromise(() => markConnectOnboardingCompleted(userId));
90 reportAtomCommandResult(result, { label: "connect onboarding completion" });
91 })();
92 }, [userId]);
93
94 useEffect(
95 () => navigation.addListener("beforeRemove", persistCompletion),
96 [navigation, persistCompletion],
97 );
98
99 const handleDone = useCallback(() => {36 const handleDone = useCallback(() => {
100 persistCompletion();
101 navigation.goBack();37 navigation.goBack();
102 }, [navigation, persistCompletion]);38 }, [navigation]);
103 39
104 const bearerConnections = useMemo(40 const handleDontShowAgain = useCallback(() => {
105 () =>41 if (userId) {
106 Object.values(savedConnectionsById).filter((connection) => connection.bearerToken !== null),42 void (async () => {
107 [savedConnectionsById],43 const result = await settlePromise(() => optOutOfConnectOnboarding(userId));
108 );44 reportAtomCommandResult(result, { label: "connect onboarding opt-out" });
109 45 })();
110 const [targets, setTargets] = useState<ReadonlyArray<PublishTarget> | null>(null);
111 const [publishEnvironment, setPublishEnvironment] = useState(true);
112 const [publishAgentActivity, setPublishAgentActivity] = useState(true);
113 const [isApplying, setIsApplying] = useState(false);
114
115 const loadPublishTargets = useCallback(
116 async (connections: ReadonlyArray<SavedRemoteConnection>) => {
117 if (connections.length === 0) {
118 setTargets([]);
119 return;
120 }
121 setTargets(connections.map(pendingPublishTarget));
122 const result = await settleAsyncResult(() =>
123 runtime.runPromiseExit(
124 Effect.forEach(
125 connections,
126 (connection) =>
127 getEnvironmentConnectPublishState({ connection }).pipe(
128 Effect.match({
129 onFailure: (error): PublishTarget => ({
130 ...pendingPublishTarget(connection),
131 phase: "error",
132 error: error.message,
133 }),
134 onSuccess: (state): PublishTarget => ({
135 connection,
136 phase:
137 state.canPublish === false
138 ? "unauthorized"
139 : state.linked && state.managedTunnelActive && state.publishAgentActivity
140 ? "published"
141 : "ready",
142 linked: state.linked,
143 managedTunnelActive: state.managedTunnelActive,
144 publishAgentActivity: state.publishAgentActivity,
145 error: null,
146 }),
147 }),
148 ),
149 { concurrency: "unbounded" },
150 ),
151 ),
152 );
153 if (result._tag === "Failure") {
154 if (isAtomCommandInterrupted(result)) {
155 return;
156 }
157 const error = squashAtomCommandFailure(result);
158 const message =
159 error instanceof Error ? error.message : "Could not check the environment link state.";
160 setTargets(
161 connections.map((connection) => ({
162 ...pendingPublishTarget(connection),
163 phase: "error" as const,
164 error: message,
165 })),
166 );
167 return;
168 }
169 setTargets(result.value);
170 },
171 [],
172 );
173
174 // Check authorization + link state once the locally saved connections are
175 // ready. Loaded once per mount: reconnect churn in the registry must not
176 // flip the section back to "checking" mid-interaction.
177 const loadedRef = useRef(false);
178 useEffect(() => {
179 if (isLoadingSavedConnection || loadedRef.current) {
180 return;
181 }
182 loadedRef.current = true;
183 void loadPublishTargets(bearerConnections);
184 }, [bearerConnections, isLoadingSavedConnection, loadPublishTargets]);
185
186 const publishableTargets = useMemo(
187 () => (targets ?? []).filter((target) => target.phase === "ready"),
188 [targets],
189 );
190 const targetConnections = useMemo(
191 () => (targets ?? []).map((target) => target.connection),
192 [targets],
193 );
194
195 const handlePublish = useCallback(async () => {
196 const applicable = publishableTargets;
197 if (applicable.length === 0 || (!publishEnvironment && !publishAgentActivity)) {
198 return;
199 }46 }
200 setIsApplying(true);47 navigation.goBack();
201 const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));48 }, [navigation, userId]);
202 const clerkToken = tokenResult._tag === "Success" ? tokenResult.value : null;
203 if (clerkToken === null) {
204 setIsApplying(false);
205 const error = tokenResult._tag === "Failure" ? squashAtomCommandFailure(tokenResult) : null;
206 Alert.alert(
207 "Publishing unavailable",
208 error instanceof Error ? error.message : "Sign in to publish environments over T3 Connect.",
209 );
210 return;
211 }
212
213 const failures: Array<string> = [];
214 await Promise.all(
215 applicable.map(async (target) => {
216 // Onboarding only ever enables: it links when something is missing and
217 // never tears down an existing link or clears a preference.
218 const needsLink = publishEnvironment
219 ? !target.linked || !target.managedTunnelActive
220 : !target.linked && publishAgentActivity;
221 const applyEffect = Effect.gen(function* () {
222 if (needsLink) {
223 yield* linkEnvironmentToCloud({
224 connection: target.connection,
225 clerkToken,
226 mode: publishEnvironment ? "managed" : "publish_only",
227 });
228 }
229 if (publishAgentActivity && !target.publishAgentActivity) {
230 yield* setEnvironmentPublishAgentActivity({
231 connection: target.connection,
232 publishAgentActivity: true,
233 });
234 }
235 });
236 const result = await settleAsyncResult(() => runtime.runPromiseExit(applyEffect));
237 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
238 const error = squashAtomCommandFailure(result);
239 failures.push(
240 `${target.connection.environmentLabel}: ${
241 error instanceof Error ? error.message : "Publishing failed."
242 }`,
243 );
244 }
245 }),
246 );
247
248 refreshManagedRelayEnvironments();
249 await loadPublishTargets(targetConnections);
250 setIsApplying(false);
251 if (failures.length > 0) {
252 Alert.alert("Couldn't publish every environment", failures.join("\n\n"));
253 }
254 }, [
255 getToken,
256 loadPublishTargets,
257 publishAgentActivity,
258 publishEnvironment,
259 publishableTargets,
260 targetConnections,
261 ]);
262 49
263 return (50 return (
264 <View collapsable={false} className="flex-1 bg-sheet">51 <View collapsable={false} className="flex-1 bg-sheet">
@@ -286,38 +73,15 @@ function ConfiguredConnectOnboardingRouteScreen() { · 21 unmodified lines
286 </Pressable>73 </Pressable>
287 </View>74 </View>
288 <Text className="text-sm leading-normal text-foreground-muted">75 <Text className="text-sm leading-normal text-foreground-muted">
289 {76 Environments published from your other devices are ready to connect here.
290 "Mesh your devices together — publish this device's environments and connect the rest, all in one place."
291 }
292 </Text>77 </Text>
293 </View>78 </View>
294 79
295 {isSignedIn ? (80 {isSignedIn ? (
296 <>81 <CloudEnvironmentRows
297 <PublishSection82 connectedCloudEnvironments={connectedCloudEnvironments}
298 targets={targets}83 onReconnectEnvironment={onReconnectEnvironment}
299 publishEnvironment={publishEnvironment}84 />
300 publishAgentActivity={publishAgentActivity}
301 isApplying={isApplying}
302 onPublishEnvironmentChange={setPublishEnvironment}
303 onPublishAgentActivityChange={setPublishAgentActivity}
304 onPublish={() => void handlePublish()}
305 onRetry={() => void loadPublishTargets(targetConnections)}
306 />
307
308 <View className="gap-2">
309 <Text className="px-2 text-sm font-t3-medium text-foreground-muted">
310 Connect your environments
311 </Text>
312 <Text className="px-2 text-sm leading-normal text-foreground-muted">
313 Environments published from your other devices are ready to connect here.
314 </Text>
315 <CloudEnvironmentRows
316 connectedCloudEnvironments={connectedCloudEnvironments}
317 onReconnectEnvironment={onReconnectEnvironment}
318 />
319 </View>
320 </>
321 ) : (85 ) : (
322 <View collapsable={false} className="rounded-[24px] bg-card p-5">86 <View collapsable={false} className="rounded-[24px] bg-card p-5">
323 <Text className="text-sm leading-normal text-foreground-muted">87 <Text className="text-sm leading-normal text-foreground-muted">
@@ -326,185 +90,26 @@ function ConfiguredConnectOnboardingRouteScreen() { · 2 unmodified lines
326 </View>90 </View>
327 )}91 )}
328 92
329 <Pressable93 <View className="gap-3">
330 accessibilityRole="button"94 <Pressable
331 onPress={handleDone}95 accessibilityRole="button"
332 className="min-h-[48px] items-center justify-center rounded-[18px] bg-card active:opacity-70"96 onPress={handleDone}
333 >97 className="min-h-[48px] items-center justify-center rounded-[18px] bg-card active:opacity-70"
334 <Text className="text-base font-t3-bold text-foreground">Done</Text>98 >
335 </Pressable>99 <Text className="text-base font-t3-bold text-foreground">Done</Text>
336 </ScrollView>100 </Pressable>
337 </View>101 {userId ? (
338 );102 <Pressable
339}103 accessibilityRole="button"
340 104 hitSlop={8}
341function PublishSection(props: {105 onPress={handleDontShowAgain}
342 readonly targets: ReadonlyArray<PublishTarget> | null;106 className="items-center py-1 active:opacity-70"
343 readonly publishEnvironment: boolean;107 >
344 readonly publishAgentActivity: boolean;108 <Text className="text-xs text-foreground-muted">{"Don't show this again"}</Text>
345 readonly isApplying: boolean;109 </Pressable>
346 readonly onPublishEnvironmentChange: (enabled: boolean) => void;110 ) : null}
347 readonly onPublishAgentActivityChange: (enabled: boolean) => void;
348 readonly onPublish: () => void;
349 readonly onRetry: () => void;
350}) {
351 const iconColor = useThemeColor("--color-icon");
352 const primaryForeground = useThemeColor("--color-primary-foreground");
353 const targets = props.targets;
354 const isChecking = targets === null || targets.some((target) => target.phase === "checking");
355 const publishableTargets = (targets ?? []).filter((target) => target.phase === "ready");
356 const publishedTargets = (targets ?? []).filter((target) => target.phase === "published");
357 const hasRetryableErrors = (targets ?? []).some((target) => target.phase === "error");
358 const canPublish =
359 publishableTargets.length > 0 &&
360 (props.publishEnvironment || props.publishAgentActivity) &&
361 !props.isApplying;
362
363 if (targets !== null && targets.length === 0) {
364 return (
365 <View className="gap-2">
366 <Text className="px-2 text-sm font-t3-medium text-foreground-muted">
367 Publish this device
368 </Text>
369 <View collapsable={false} className="rounded-[24px] bg-card p-5">
370 <Text className="text-sm leading-normal text-foreground-muted">
371 {
372 "No locally paired environments on this device yet. Pair an environment first and it can be published to your other devices from Settings."
373 }
374 </Text>
375 </View>
376 </View>
377 );
378 }
379
380 if (isChecking) {
381 return (
382 <View className="gap-2">
383 <Text className="px-2 text-sm font-t3-medium text-foreground-muted">
384 Publish this device
385 </Text>
386 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6">
387 <ActivityIndicator color={iconColor} />
388 <Text className="text-center text-sm leading-normal text-foreground-muted">
389 Checking which environments can publish.
390 </Text>
391 </View>111 </View>
392 </View>112 </ScrollView>
393 );
394 }
395
396 return (
397 <View className="gap-3">
398 <SettingsSection title="Publish this device">
399 {publishableTargets.length > 0 ? (
400 <>
401 <SettingsSwitchRow
402 icon="antenna.radiowaves.left.and.right"
403 label="Publish environment"
404 disabled={props.isApplying}
405 value={props.publishEnvironment}
406 onValueChange={props.onPublishEnvironmentChange}
407 />
408 <SettingsSwitchRow
409 icon="bolt.circle"
410 label="Publish agent activity"
411 disabled={props.isApplying}
412 value={props.publishAgentActivity}
413 onValueChange={props.onPublishAgentActivityChange}
414 />
415 </>
416 ) : publishedTargets.length > 0 ? (
417 <View className="p-4">
418 <Text className="text-sm leading-normal text-foreground-muted">
419 {"This device's environments already publish over T3 Connect."}
420 </Text>
421 </View>
422 ) : (
423 <View className="p-4">
424 <Text className="text-sm leading-normal text-foreground-muted">
425 {
426 "This device's paired sessions aren't authorized to publish. Pair again with an administrative link to enable publishing."
427 }
428 </Text>
429 </View>
430 )}
431 </SettingsSection>
432
433 {publishableTargets.length > 0 ? (
434 <Text className="px-2 text-sm leading-normal text-foreground-muted">
435 Make the environments paired with this device available to your other devices, and send
436 agent activity for notifications and Live Activities.
437 </Text>
438 ) : null}
439
440 {targets.map((target) => (
441 <PublishTargetStatusRow key={target.connection.environmentId} target={target} />
442 ))}
443
444 {publishableTargets.length > 0 ? (
445 <Pressable
446 accessibilityRole="button"
447 disabled={!canPublish}
448 onPress={props.onPublish}
449 className={cn(
450 "min-h-[48px] flex-row items-center justify-center gap-2 rounded-[18px] bg-primary px-5 active:opacity-70",
451 !canPublish && "opacity-50",
452 )}
453 >
454 {props.isApplying ? <ActivityIndicator color={primaryForeground} size="small" /> : null}
455 <Text className="text-base font-t3-bold text-primary-foreground">
456 {props.isApplying
457 ? "Publishing..."
458 : `Publish ${publishableTargets.length === 1 ? "environment" : `${publishableTargets.length} environments`}`}
459 </Text>
460 </Pressable>
461 ) : null}
462
463 {hasRetryableErrors ? (
464 <Pressable
465 accessibilityRole="button"
466 disabled={props.isApplying}
467 onPress={props.onRetry}
468 className="self-start rounded-full bg-subtle px-3.5 py-2 active:opacity-70 disabled:opacity-50"
469 >
470 <Text className="text-xs font-t3-bold text-foreground">Check again</Text>
471 </Pressable>
472 ) : null}
473 </View>
474 );
475}
476
477function publishTargetStatusText(target: PublishTarget): string {
478 switch (target.phase) {
479 case "checking":
480 return "Checking...";
481 case "published":
482 return "Already publishing";
483 case "ready":
484 return target.linked ? "Partially published — ready to update" : "Ready to publish";
485 case "unauthorized":
486 return "This device's session is not authorized to publish";
487 case "error":
488 return target.error ?? "Could not check the environment";
489 }
490}
491
492function PublishTargetStatusRow(props: { readonly target: PublishTarget }) {
493 const isError = props.target.phase === "error";
494 return (
495 <View className="flex-row items-baseline gap-2 px-2">
496 <Text className="min-w-0 flex-shrink text-xs font-t3-bold text-foreground" numberOfLines={1}>
497 {props.target.connection.environmentLabel}
498 </Text>
499 <Text
500 className={cn(
501 "min-w-0 flex-1 text-xs",
502 isError ? "text-rose-500 dark:text-rose-400" : "text-foreground-muted",
503 )}
504 numberOfLines={3}
505 >
506 {publishTargetStatusText(props.target)}
507 </Text>
508 </View>113 </View>
509 );114 );
510}115}
apps/mobile/src/features/cloud/connectOnboarding.ts+7−51
@@ -1,68 +1,24 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";
3import { Atom } from "effect/unstable/reactivity";1import { Atom } from "effect/unstable/reactivity";
4import { useEffect } from "react";
5 2
6import { loadPreferences, savePreferencesPatch } from "../../lib/storage";
7import { appAtomRegistry } from "../../state/atom-registry";3import { appAtomRegistry } from "../../state/atom-registry";
8 4
9// Signals RootStackLayout (inside the navigation tree) that an in-session5// Signals RootStackLayout (inside the navigation tree) that an in-session
10// sign-in just completed for an account that has not seen the T3 Connect6// sign-in just completed. Holds the account id so a sign-out between the
11// onboarding sheet yet. Holds the account id so a sign-out between the request7// request and the navigation cannot present the sheet for the wrong account.
12// and the navigation cannot present the sheet for the wrong account.8export const connectOnboardingRequestAtom = Atom.make<string | null>(null).pipe(
13const connectOnboardingRequestAtom = Atom.make<string | null>(null).pipe(
14 Atom.keepAlive,9 Atom.keepAlive,
15 Atom.withLabel("mobile:connect-onboarding-request"),10 Atom.withLabel("mobile:connect-onboarding-request"),
16);11);
17 12
18/**13/**
19 * Requests the onboarding sheet for the given account unless it already14 * Requests the onboarding sheet for the given account. Sign-out clears the
20 * completed (or skipped) onboarding on this device.15 * connected environments, so onboarding runs on every in-session sign-in —
16 * each new session starts with no connected devices.
21 */17 */
22export async function requestConnectOnboardingIfNeeded(accountId: string): Promise<void> {18export function requestConnectOnboarding(accountId: string): void {
23 const preferences = await loadPreferences();
24 if (preferences.connectOnboardingCompletedAccounts?.includes(accountId)) {
25 return;
26 }
27 appAtomRegistry.set(connectOnboardingRequestAtom, accountId);19 appAtomRegistry.set(connectOnboardingRequestAtom, accountId);
28}20}
29 21
30export function clearConnectOnboardingRequest(): void {22export function clearConnectOnboardingRequest(): void {
31 appAtomRegistry.set(connectOnboardingRequestAtom, null);23 appAtomRegistry.set(connectOnboardingRequestAtom, null);
32}24}
33
34/** Persists that the account saw the sheet, so it never nags twice. */
35export async function markConnectOnboardingCompleted(accountId: string): Promise<void> {
36 const preferences = await loadPreferences();
37 const completed = preferences.connectOnboardingCompletedAccounts ?? [];
38 if (completed.includes(accountId)) {
39 return;
40 }
41 await savePreferencesPatch({ connectOnboardingCompletedAccounts: [...completed, accountId] });
42}
43
44// Sign-in happens inside the Settings sheet; give its detent/session-state
45// transitions a beat to settle before presenting another formSheet on top.
46const PRESENT_ONBOARDING_DELAY_MS = 600;
47
48/**
49 * Consumes the onboarding request inside the navigation tree (RootStackLayout)
50 * and presents the onboarding formSheet.
51 */
52export function useConnectOnboardingNavigation(): void {
53 const navigation = useNavigation();
54 const requestedAccountId = useAtomValue(connectOnboardingRequestAtom);
55
56 useEffect(() => {
57 if (requestedAccountId === null) {
58 return;
59 }
60 const timer = setTimeout(() => {
61 clearConnectOnboardingRequest();
62 navigation.navigate("ConnectOnboarding");
63 }, PRESENT_ONBOARDING_DELAY_MS);
64 return () => {
65 clearTimeout(timer);
66 };
67 }, [navigation, requestedAccountId]);
68}
apps/mobile/src/features/cloud/connectOnboardingNavigation.ts+45−0
@@ -0,0 +1,45 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";
3import { useEffect } from "react";
4
5import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding";
6import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut";
7
8// Sign-in happens inside the Settings sheet; give its detent/session-state
9// transitions a beat to settle before presenting another formSheet on top.
10const PRESENT_ONBOARDING_DELAY_MS = 600;
11
12/**
13 * Consumes the onboarding request inside the navigation tree (RootStackLayout)
14 * and presents the onboarding formSheet. Lives apart from connectOnboarding.ts
15 * so non-navigation consumers (CloudAuthProvider) do not pull
16 * @react-navigation into their module graph.
17 */
18export function useConnectOnboardingNavigation(): void {
19 const navigation = useNavigation();
20 const requestedAccountId = useAtomValue(connectOnboardingRequestAtom);
21
22 useEffect(() => {
23 if (requestedAccountId === null) {
24 return;
25 }
26 let cancelled = false;
27 const timer = setTimeout(() => {
28 void (async () => {
29 // A failed preference read prefers showing the sheet.
30 const optedOut = await isConnectOnboardingOptedOut(requestedAccountId).catch(() => false);
31 if (cancelled) {
32 return;
33 }
34 clearConnectOnboardingRequest();
35 if (!optedOut) {
36 navigation.navigate("ConnectOnboarding");
37 }
38 })();
39 }, PRESENT_ONBOARDING_DELAY_MS);
40 return () => {
41 cancelled = true;
42 clearTimeout(timer);
43 };
44 }, [navigation, requestedAccountId]);
45}
apps/mobile/src/features/cloud/connectOnboardingOptOut.ts+21−0
@@ -0,0 +1,21 @@
1import { loadPreferences, savePreferencesPatch } 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". */
8export 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. */
14export async function optOutOfConnectOnboarding(accountId: string): Promise<void> {
15 const preferences = await loadPreferences();
16 const optedOut = preferences.connectOnboardingOptOutAccounts ?? [];
17 if (optedOut.includes(accountId)) {
18 return;
19 }
20 await savePreferencesPatch({ connectOnboardingOptOutAccounts: [...optedOut, accountId] });
21}
apps/mobile/src/features/cloud/linkEnvironment.ts+4−102
@@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import { HttpClient } from "effect/unstable/http";4import { HttpClient } from "effect/unstable/http";
5import {5import {
6 AuthRelayWriteScope,
7 EnvironmentCloudEndpointUnavailableError,6 EnvironmentCloudEndpointUnavailableError,
8 EnvironmentHttpBadRequestError,7 EnvironmentHttpBadRequestError,
9 EnvironmentHttpConflictError,8 EnvironmentHttpConflictError,
@@ -77,13 +76,6 @@ const isEnvironmentCloudApiError = Schema.is( · 67 unmodified lines
77const MANAGED_ENDPOINT_PROVIDER_KIND =76const MANAGED_ENDPOINT_PROVIDER_KIND =
78 "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;77 "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
79 78
80// "publish_only" links the environment to the relay for agent-activity
81// publishing alone: no managed tunnel is provisioned (mirrors the web client's
82// CloudLinkMode).
83export type CloudLinkMode = "managed" | "publish_only";
84
85const PUBLISH_ONLY_PROVIDER_KIND = "manual" satisfies RelayManagedEndpointProviderKind;
86
87function cloudEnvironmentLinkError(message: string) {79function cloudEnvironmentLinkError(message: string) {
88 return (cause: unknown) => {80 return (cause: unknown) => {
89 const environmentError = findEnvironmentCloudApiError(cause);81 const environmentError = findEnvironmentCloudApiError(cause);
@@ -267,7 +259,6 @@ function ensureConnectEndpointMatchesEnvironment(input: { · 177 unmodified lines
267export function linkEnvironmentToCloud(input: {259export function linkEnvironmentToCloud(input: {
268 readonly connection: SavedRemoteConnection;260 readonly connection: SavedRemoteConnection;
269 readonly clerkToken: string;261 readonly clerkToken: string;
270 readonly mode?: CloudLinkMode;
271}): Effect.Effect<262}): Effect.Effect<
272 void,263 void,
273 CloudEnvironmentLinkError,264 CloudEnvironmentLinkError,
@@ -280,10 +271,6 @@ export function linkEnvironmentToCloud(input: { · 6 unmodified lines
280 });271 });
281 }272 }
282 const localBearerToken = input.connection.bearerToken;273 const localBearerToken = input.connection.bearerToken;
283 const managedTunnelsEnabled = (input.mode ?? "managed") === "managed";
284 const providerKind = managedTunnelsEnabled
285 ? MANAGED_ENDPOINT_PROVIDER_KIND
286 : PUBLISH_ONLY_PROVIDER_KIND;
287 const relayUrl = yield* requireRelayUrl();274 const relayUrl = yield* requireRelayUrl();
288 const relayClient = yield* ManagedRelay.ManagedRelayClient;275 const relayClient = yield* ManagedRelay.ManagedRelayClient;
289 const deviceId = yield* Effect.tryPromise({276 const deviceId = yield* Effect.tryPromise({
@@ -301,7 +288,7 @@ export function linkEnvironmentToCloud(input: { · 11 unmodified lines
301 payload: {288 payload: {
302 notificationsEnabled: true,289 notificationsEnabled: true,
303 liveActivitiesEnabled,290 liveActivitiesEnabled,
304 managedTunnelsEnabled,291 managedTunnelsEnabled: true,
305 },292 },
306 })293 })
307 .pipe(294 .pipe(
@@ -319,7 +306,7 @@ export function linkEnvironmentToCloud(input: { · 11 unmodified lines
319 endpoint: {306 endpoint: {
320 httpBaseUrl: input.connection.httpBaseUrl,307 httpBaseUrl: input.connection.httpBaseUrl,
321 wsBaseUrl: input.connection.wsBaseUrl,308 wsBaseUrl: input.connection.wsBaseUrl,
322 providerKind,309 providerKind: MANAGED_ENDPOINT_PROVIDER_KIND,
323 },310 },
324 origin: endpointOrigin(input.connection.httpBaseUrl),311 origin: endpointOrigin(input.connection.httpBaseUrl),
325 },312 },
@@ -333,7 +320,7 @@ export function linkEnvironmentToCloud(input: { · 7 unmodified lines
333 proof,320 proof,
334 notificationsEnabled: true,321 notificationsEnabled: true,
335 liveActivitiesEnabled,322 liveActivitiesEnabled,
336 managedTunnelsEnabled,323 managedTunnelsEnabled: true,
337 },324 },
338 })325 })
339 .pipe(326 .pipe(
@@ -341,7 +328,7 @@ export function linkEnvironmentToCloud(input: { · 1 unmodified line
341 );328 );
342 yield* ensureLinkedEnvironmentMatches({329 yield* ensureLinkedEnvironmentMatches({
343 expectedEnvironmentId: input.connection.environmentId,330 expectedEnvironmentId: input.connection.environmentId,
344 expectedProviderKind: providerKind,331 expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND,
345 link,332 link,
346 });333 });
347 334
@@ -363,91 +350,6 @@ export function linkEnvironmentToCloud(input: { · 15 unmodified lines
363 });350 });
364}351}
365 352
366function requireBearerToken(
367 connection: SavedRemoteConnection,
368): Effect.Effect<string, CloudEnvironmentLinkError> {
369 return connection.bearerToken
370 ? Effect.succeed(connection.bearerToken)
371 : Effect.fail(
372 new CloudEnvironmentLinkError({
373 message: "Only a locally paired bearer connection can publish over T3 Connect.",
374 }),
375 );
376}
377
378export interface EnvironmentConnectPublishState {
379 /**
380 * Whether the connection's session holds relay:write. `null` when the
381 * environment did not report session scopes (older servers) — callers should
382 * attempt publishing and surface the server's denial.
383 */
384 readonly canPublish: boolean | null;
385 readonly linked: boolean;
386 readonly managedTunnelActive: boolean;
387 readonly publishAgentActivity: boolean;
388}
389
390/**
391 * Reads the environment-side publish authorization (session scopes) and relay
392 * link state for a locally paired bearer connection.
393 */
394export function getEnvironmentConnectPublishState(input: {
395 readonly connection: SavedRemoteConnection;
396}): Effect.Effect<
397 EnvironmentConnectPublishState,
398 CloudEnvironmentLinkError,
399 HttpClient.HttpClient
400> {
401 return Effect.gen(function* () {
402 const localBearerToken = yield* requireBearerToken(input.connection);
403 const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl);
404 const headers = { authorization: `Bearer ${localBearerToken}` };
405 const session = yield* environmentClient.auth
406 .session({ headers })
407 .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not read the environment session.")));
408 const linkState = yield* environmentClient.connect
409 .linkState({ headers })
410 .pipe(
411 Effect.mapError(cloudEnvironmentLinkError("Could not read the environment link state.")),
412 );
413 return {
414 canPublish: !session.authenticated
415 ? false
416 : session.scopes === undefined
417 ? null
418 : session.scopes.includes(AuthRelayWriteScope),
419 linked: linkState.linked,
420 managedTunnelActive: linkState.managedTunnelActive ?? linkState.linked,
421 publishAgentActivity: linkState.publishAgentActivity,
422 };
423 });
424}
425
426/**
427 * Sets the environment-server-side "publish agent activity" preference. The
428 * link flow does not touch this secret, so clients opting into activity
429 * publishing must call it explicitly.
430 */
431export function setEnvironmentPublishAgentActivity(input: {
432 readonly connection: SavedRemoteConnection;
433 readonly publishAgentActivity: boolean;
434}): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient> {
435 return Effect.gen(function* () {
436 const localBearerToken = yield* requireBearerToken(input.connection);
437 const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl);
438 yield* environmentClient.connect
439 .preferences({
440 headers: { authorization: `Bearer ${localBearerToken}` },
441 payload: { publishAgentActivity: input.publishAgentActivity },
442 })
443 .pipe(
444 Effect.mapError(
445 cloudEnvironmentLinkError("Could not update the environment publish preference."),
446 ),
447 );
448 });
449}
450
451export function listCloudEnvironments(input: {353export function listCloudEnvironments(input: {
452 readonly clerkToken: string;354 readonly clerkToken: string;
453}): Effect.Effect<355}): Effect.Effect<
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+3−3
@@ -25,7 +25,7 @@ import { ConnectionStatusDot } from "./ConnectionStatusDot"; · 24 unmodified lines
25import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController";25import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController";
26 26
27/**27/**
28 * "T3 Cloud" section: every environment published to the signed-in account,28 * "T3 Connect" section: every environment published to the signed-in account,
29 * with connect switches, availability status, refresh, and loading/error29 * with connect switches, availability status, refresh, and loading/error
30 * states. Shared between the Settings environments screen and the T3 Connect30 * states. Shared between the Settings environments screen and the T3 Connect
31 * onboarding sheet.31 * onboarding sheet.
@@ -61,7 +61,7 @@ export function CloudEnvironmentRows(props: { · 29 unmodified lines
61 return (61 return (
62 <View collapsable={false} className="mt-5 gap-3">62 <View collapsable={false} className="mt-5 gap-3">
63 <View className="flex-row items-center justify-between px-1">63 <View className="flex-row items-center justify-between px-1">
64 <Text className="text-sm font-t3-bold uppercase text-foreground-muted">T3 Cloud</Text>64 <Text className="text-sm font-t3-bold uppercase text-foreground-muted">T3 Connect</Text>
65 <Pressable65 <Pressable
66 accessibilityRole="button"66 accessibilityRole="button"
67 disabled={controller.relayDiscovery.isRefreshing}67 disabled={controller.relayDiscovery.isRefreshing}
@@ -112,7 +112,7 @@ export function CloudEnvironmentRows(props: { · 44 unmodified lines
112 ) : controller.relayDiscovery.error ? (112 ) : controller.relayDiscovery.error ? (
113 <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5">113 <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5">
114 <Text className="text-base font-t3-bold text-foreground">114 <Text className="text-base font-t3-bold text-foreground">
115 Could not load T3 Cloud environments115 Could not load T3 Connect environments
116 </Text>116 </Text>
117 <Text className="text-sm text-foreground-muted">{controller.relayDiscovery.error}</Text>117 <Text className="text-sm text-foreground-muted">{controller.relayDiscovery.error}</Text>
118 {controller.relayDiscovery.errorTraceId ? (118 {controller.relayDiscovery.errorTraceId ? (
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+1−1
@@ -135,7 +135,7 @@ export function ConnectionEnvironmentRow(props: { · 134 unmodified lines
135 >135 >
136 {props.environment.isRelayManaged ? (136 {props.environment.isRelayManaged ? (
137 <Text className="text-sm text-foreground-muted">137 <Text className="text-sm text-foreground-muted">
138 Managed by T3 Cloud. Tunnel details update automatically.138 Managed by T3 Connect. Tunnel details update automatically.
139 </Text>139 </Text>
140 ) : (140 ) : (
141 <>141 <>
apps/mobile/src/features/connection/environmentSections.test.ts+1−1
@@ -35,7 +35,7 @@ function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { · 34 unmodified lines
35}35}
36 36
37describe("mobile environment settings sections", () => {37describe("mobile environment settings sections", () => {
38 it("keeps saved relay-managed connections under T3 Cloud", () => {38 it("keeps saved relay-managed connections under T3 Connect", () => {
39 const local = connectedEnvironment({39 const local = connectedEnvironment({
40 environmentId: "environment-local",40 environmentId: "environment-local",
41 isRelayManaged: false,41 isRelayManaged: false,
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+2−2
@@ -240,8 +240,8 @@ function ConfiguredSettingsRouteScreen() { · 239 unmodified lines
240 240
241 const promptSignIn = useCallback(() => {241 const promptSignIn = useCallback(() => {
242 Alert.alert(242 Alert.alert(
243 "Request T3 Cloud access",243 "Request T3 Connect access",
244 "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.",244 "Live Activity updates require approved T3 Connect access so relay can deliver updates to this device.",
245 [245 [
246 { text: "Cancel", style: "cancel" },246 { text: "Cancel", style: "cancel" },
247 {247 {
apps/mobile/src/lib/storage.ts+7−8
@@ -69,8 +69,8 @@ export interface Preferences { · 68 unmodified lines
69 /** Code/diff font size override; null/absent means derived from baseFontSize. */69 /** Code/diff font size override; null/absent means derived from baseFontSize. */
70 readonly codeFontSize?: number | null;70 readonly codeFontSize?: number | null;
71 readonly codeWordBreak?: boolean;71 readonly codeWordBreak?: boolean;
72 /** Cloud account ids that already saw the T3 Connect onboarding sheet. */72 /** Cloud account ids that opted out of the T3 Connect onboarding sheet. */
73 readonly connectOnboardingCompletedAccounts?: ReadonlyArray<string>;73 readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
74}74}
75 75
76async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> {76async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> {
@@ -169,7 +169,7 @@ export async function loadPreferences(): Promise<Preferences> { · 92 unmodified lines
169 markdownFontSize?: number;169 markdownFontSize?: number;
170 codeFontSize?: number | null;170 codeFontSize?: number | null;
171 codeWordBreak?: boolean;171 codeWordBreak?: boolean;
172 connectOnboardingCompletedAccounts?: ReadonlyArray<string>;172 connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
173 } = {};173 } = {};
174 174
175 if (typeof parsed.liveActivitiesEnabled === "boolean") {175 if (typeof parsed.liveActivitiesEnabled === "boolean") {
@@ -190,11 +190,10 @@ export async function loadPreferences(): Promise<Preferences> { · 14 unmodified lines
190 if (typeof parsed.codeWordBreak === "boolean") {190 if (typeof parsed.codeWordBreak === "boolean") {
191 preferences.codeWordBreak = parsed.codeWordBreak;191 preferences.codeWordBreak = parsed.codeWordBreak;
192 }192 }
193 if (Array.isArray(parsed.connectOnboardingCompletedAccounts)) {193 if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) {
194 preferences.connectOnboardingCompletedAccounts =194 preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter(
195 parsed.connectOnboardingCompletedAccounts.filter(195 (account): account is string => typeof account === "string",
196 (account): account is string => typeof account === "string",196 );
197 );
198 }197 }
199 198
200 return preferences;199 return preferences;
apps/web/src/cloud/connectOnboarding.ts+9−9
@@ -1,18 +1,18 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2 2
3/**3/**
4 * Tracks which T3 Connect accounts have completed (or dismissed) the4 * Accounts that opted out of the post-sign-in T3 Connect onboarding wizard
5 * post-sign-in onboarding wizard, so it is shown at most once per account per5 * ("Don't show this again"). The wizard otherwise shows on every sign-in,
6 * browser.6 * since sign-out clears the connected environments.
7 */7 */
8export const CONNECT_ONBOARDING_STORAGE_KEY = "t3code:connect-onboarding:v1";8export const CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY = "t3code:connect-onboarding-opt-out:v1";
9 9
10export const ConnectOnboardingStateSchema = Schema.Struct({10export const ConnectOnboardingOptOutSchema = Schema.Struct({
11 completedAccounts: Schema.Array(Schema.String),11 optOutAccounts: Schema.Array(Schema.String),
12});12});
13 13
14export type ConnectOnboardingState = typeof ConnectOnboardingStateSchema.Type;14export type ConnectOnboardingOptOutState = typeof ConnectOnboardingOptOutSchema.Type;
15 15
16export const EMPTY_CONNECT_ONBOARDING_STATE: ConnectOnboardingState = {16export const EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE: ConnectOnboardingOptOutState = {
17 completedAccounts: [],17 optOutAccounts: [],
18};18};
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx+79−42
@@ -4,9 +4,9 @@ import { CheckIcon } from "lucide-react"; · 3 unmodified lines
4import { useEffect, useMemo, useRef, useState } from "react";4import { useEffect, useMemo, useRef, useState } from "react";
5 5
6import {6import {
7 CONNECT_ONBOARDING_STORAGE_KEY,7 CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY,
8 ConnectOnboardingStateSchema,8 ConnectOnboardingOptOutSchema,
9 EMPTY_CONNECT_ONBOARDING_STATE,9 EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE,
10} from "~/cloud/connectOnboarding";10} from "~/cloud/connectOnboarding";
11import { hasCloudPublicConfig } from "~/cloud/publicConfig";11import { hasCloudPublicConfig } from "~/cloud/publicConfig";
12import { useCloudLinkController } from "~/cloud/useCloudLinkController";12import { useCloudLinkController } from "~/cloud/useCloudLinkController";
@@ -16,6 +16,7 @@ import { cn } from "~/lib/utils"; · 3 unmodified lines
16import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";16import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
17import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";17import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";
18import { Button } from "../ui/button";18import { Button } from "../ui/button";
19import { Checkbox } from "../ui/checkbox";
19import {20import {
20 Dialog,21 Dialog,
21 DialogDescription,22 DialogDescription,
@@ -29,12 +30,13 @@ import { Switch } from "../ui/switch"; · 7 unmodified lines
29import { toastManager } from "../ui/toast";30import { toastManager } from "../ui/toast";
30 31
31/**32/**
32 * Post-sign-in onboarding wizard for T3 Connect. Opens once per account (per33 * Post-sign-in onboarding wizard for T3 Connect. Opens on every in-session
33 * browser) after the user is signed in: first prompts to publish this34 * sign-in — sign-out removes the connected relay environments, so each new
35 * session starts with no devices to reach. It first prompts to publish this
34 * environment (managed tunnel + agent activity, both defaulting on) when the36 * environment (managed tunnel + agent activity, both defaulting on) when the
35 * current session is authorized to manage the relay link, then lists the37 * current session is authorized to manage the relay link, then lists the
36 * account's T3 Connect environments so every device can be connected right38 * account's T3 Connect environments so every device can be connected right
37 * away. Dismissing the dialog counts as completion — it never nags twice.39 * away. A cold load with a restored session does not count as a sign-in.
38 */40 */
39export function ConnectOnboardingDialog() {41export function ConnectOnboardingDialog() {
40 if (!hasCloudPublicConfig()) return null;42 if (!hasCloudPublicConfig()) return null;
@@ -46,10 +48,10 @@ type OnboardingStep = "publish" | "devices"; · 5 unmodified lines
46 48
47function ConfiguredConnectOnboardingDialog() {49function ConfiguredConnectOnboardingDialog() {
48 const { isLoaded, isSignedIn, userId } = useAuth();50 const { isLoaded, isSignedIn, userId } = useAuth();
49 const [onboardingState, setOnboardingState] = useLocalStorage(51 const [optOutState, setOptOutState] = useLocalStorage(
50 CONNECT_ONBOARDING_STORAGE_KEY,52 CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY,
51 EMPTY_CONNECT_ONBOARDING_STATE,53 EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE,
52 ConnectOnboardingStateSchema,54 ConnectOnboardingOptOutSchema,
53 );55 );
54 56
55 const desktopBridge = window.desktopBridge;57 const desktopBridge = window.desktopBridge;
@@ -75,34 +77,54 @@ function ConfiguredConnectOnboardingDialog() { · 19 unmodified lines
75 ? ["publish", "devices"]77 ? ["publish", "devices"]
76 : ["devices"];78 : ["devices"];
77 79
80 const [requestedAccount, setRequestedAccount] = useState<string | null>(null);
78 const [openForAccount, setOpenForAccount] = useState<string | null>(null);81 const [openForAccount, setOpenForAccount] = useState<string | null>(null);
79 const [step, setStep] = useState<OnboardingStep>("devices");82 const [step, setStep] = useState<OnboardingStep>("devices");
80 const [exposeEnvironment, setExposeEnvironment] = useState(true);83 const [exposeEnvironment, setExposeEnvironment] = useState(true);
81 const [publishAgentActivity, setPublishAgentActivity] = useState(true);84 const [publishAgentActivity, setPublishAgentActivity] = useState(true);
85 const [dontShowAgain, setDontShowAgain] = useState(false);
82 const [isApplying, setIsApplying] = useState(false);86 const [isApplying, setIsApplying] = useState(false);
83 const prefilledFromLinkStateRef = useRef(false);87 const prefilledFromLinkStateRef = useRef(false);
88 const observedAccountRef = useRef<string | null | undefined>(undefined);
84 89
85 const completedAccounts = onboardingState.completedAccounts;90 const optOutAccounts = optOutState.optOutAccounts;
86 91
92 // Every sign-in that completes during this session requests the wizard. A
93 // cold load observes undefined → account and must not re-prompt — only a
94 // null → account transition is a sign-in.
87 useEffect(() => {95 useEffect(() => {
88 if (!isLoaded || !isSignedIn || !userId) return;96 if (!isLoaded) return;
89 if (openForAccount !== null) return;97 const previousAccount = observedAccountRef.current;
90 if (completedAccounts.includes(userId)) return;98 const nextAccount = isSignedIn && userId ? userId : null;
99 observedAccountRef.current = nextAccount;
100 if (previousAccount === null && nextAccount !== null) {
101 setRequestedAccount(nextAccount);
102 }
103 }, [isLoaded, isSignedIn, userId]);
104
105 // Open once the session scopes resolve so the step set is stable. Accounts
106 // that chose "Don't show this again" are skipped.
107 useEffect(() => {
108 if (requestedAccount === null || openForAccount !== null) return;
109 if (optOutAccounts.includes(requestedAccount)) {
110 setRequestedAccount(null);
111 return;
112 }
91 if (!sessionScopesKnown) return;113 if (!sessionScopesKnown) return;
114 setRequestedAccount(null);
92 prefilledFromLinkStateRef.current = false;115 prefilledFromLinkStateRef.current = false;
93 setExposeEnvironment(true);116 setExposeEnvironment(true);
94 setPublishAgentActivity(true);117 setPublishAgentActivity(true);
118 setDontShowAgain(false);
95 setStep(canManageRelay && controller.linkState.target !== null ? "publish" : "devices");119 setStep(canManageRelay && controller.linkState.target !== null ? "publish" : "devices");
96 setOpenForAccount(userId);120 setOpenForAccount(requestedAccount);
97 }, [121 }, [
98 canManageRelay,122 canManageRelay,
99 completedAccounts,
100 controller.linkState.target,123 controller.linkState.target,
101 isLoaded,
102 isSignedIn,
103 openForAccount,124 openForAccount,
125 optOutAccounts,
126 requestedAccount,
104 sessionScopesKnown,127 sessionScopesKnown,
105 userId,
106 ]);128 ]);
107 129
108 // Signing out (or switching accounts) mid-wizard invalidates everything the130 // Signing out (or switching accounts) mid-wizard invalidates everything the
@@ -111,7 +133,10 @@ function ConfiguredConnectOnboardingDialog() { · 2 unmodified lines
111 if (openForAccount !== null && (!isSignedIn || userId !== openForAccount)) {133 if (openForAccount !== null && (!isSignedIn || userId !== openForAccount)) {
112 setOpenForAccount(null);134 setOpenForAccount(null);
113 }135 }
114 }, [isSignedIn, openForAccount, userId]);136 if (requestedAccount !== null && (!isSignedIn || userId !== requestedAccount)) {
137 setRequestedAccount(null);
138 }
139 }, [isSignedIn, openForAccount, requestedAccount, userId]);
115 140
116 // Toggles default on, but an environment that is already linked should show141 // Toggles default on, but an environment that is already linked should show
117 // its actual configuration instead of silently proposing to rewrite it.142 // its actual configuration instead of silently proposing to rewrite it.
@@ -130,12 +155,13 @@ function ConfiguredConnectOnboardingDialog() { · 12 unmodified lines
130 const complete = () => {155 const complete = () => {
131 const account = openForAccount;156 const account = openForAccount;
132 setOpenForAccount(null);157 setOpenForAccount(null);
133 if (!account) return;158 if (account !== null && dontShowAgain) {
134 setOnboardingState((state) =>159 setOptOutState((state) =>
135 state.completedAccounts.includes(account)160 state.optOutAccounts.includes(account)
136 ? state161 ? state
137 : { completedAccounts: [...state.completedAccounts, account] },162 : { optOutAccounts: [...state.optOutAccounts, account] },
138 );163 );
164 }
139 };165 };
140 166
141 const applyPublishSelection = async () => {167 const applyPublishSelection = async () => {
@@ -194,22 +220,33 @@ function ConfiguredConnectOnboardingDialog() { · 52 unmodified lines
194 <DevicesStep />220 <DevicesStep />
195 )}221 )}
196 </DialogPanel>222 </DialogPanel>
197 <DialogFooter variant="bare">223 <DialogFooter variant="bare" className="sm:justify-between">
198 {step === "publish" ? (224 <label className="flex cursor-pointer items-center gap-2 self-start text-xs text-muted-foreground sm:self-center">
199 <>225 <Checkbox
200 <Button variant="ghost" disabled={isApplying} onClick={() => setStep("devices")}>226 checked={dontShowAgain}
201 Not now227 onCheckedChange={(checked) => setDontShowAgain(checked === true)}
202 </Button>228 />
203 <Button229 Don&apos;t show this again
204 disabled={isApplying || (controller.linkState.isPending && linkStateData === null)}230 </label>
205 onClick={() => void applyPublishSelection()}231 <div className="flex flex-col-reverse gap-2 sm:flex-row">
206 >232 {step === "publish" ? (
207 {isApplying ? "Enabling…" : "Continue"}233 <>
208 </Button>234 <Button variant="ghost" disabled={isApplying} onClick={() => setStep("devices")}>
209 </>235 Not now
210 ) : (236 </Button>
211 <Button onClick={complete}>Done</Button>237 <Button
212 )}238 disabled={
239 isApplying || (controller.linkState.isPending && linkStateData === null)
240 }
241 onClick={() => void applyPublishSelection()}
242 >
243 {isApplying ? "Enabling…" : "Continue"}
244 </Button>
245 </>
246 ) : (
247 <Button onClick={complete}>Done</Button>
248 )}
249 </div>
213 </DialogFooter>250 </DialogFooter>
214 </DialogPopup>251 </DialogPopup>
215 </Dialog>252 </Dialog>
apps/web/src/connection/platform.ts+2−2
@@ -182,7 +182,7 @@ const capabilitiesLayer = Layer.effectContext( · 181 unmodified lines
182 if (session === null) {182 if (session === null) {
183 return yield* new ConnectionBlockedError({183 return yield* new ConnectionBlockedError({
184 reason: "authentication",184 reason: "authentication",
185 detail: "Sign in to T3 Cloud to connect this environment.",185 detail: "Sign in to T3 Connect to connect this environment.",
186 });186 });
187 }187 }
188 const token = yield* session.readClerkToken().pipe(188 const token = yield* session.readClerkToken().pipe(
@@ -197,7 +197,7 @@ const capabilitiesLayer = Layer.effectContext( · 8 unmodified lines
197 if (token === null) {197 if (token === null) {
198 return yield* new ConnectionBlockedError({198 return yield* new ConnectionBlockedError({
199 reason: "authentication",199 reason: "authentication",
200 detail: "The T3 Cloud session is unavailable.",200 detail: "The T3 Connect session is unavailable.",
201 });201 });
202 }202 }
203 return token;203 return token;
packages/client-runtime/src/relay/discovery.test.ts+49−0
@@ -342,6 +342,55 @@ describe("RelayEnvironmentDiscovery", () => { · 341 unmodified lines
342 }),342 }),
343 );343 );
344 344
345 it.effect("refreshes proactively when credentials change before any manual refresh", () =>
346 Effect.gen(function* () {
347 const harness = yield* makeHarness();
348 yield* Effect.gen(function* () {
349 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
350 const requests = yield* Ref.get(harness.statusRequests);
351 for (const environment of environments) {
352 yield* Deferred.succeed(
353 requests.get(environment.environmentId)!,
354 status(environment, "online"),
355 );
356 }
357
358 // Let the scoped wakeup subscription start before emitting, mirroring
359 // a real sign-in which always happens long after service start.
360 yield* Effect.yieldNow;
361
362 // Sign-in activates the session and emits credentials-changed; the
363 // list must populate without any screen having asked for a refresh.
364 yield* harness.wake("credentials-changed");
365 const populated = yield* SubscriptionRef.changes(discovery.state).pipe(
366 Stream.filter(
367 (state) => state.environments.size === environments.length && !state.refreshing,
368 ),
369 Stream.runHead,
370 Effect.map(Option.getOrThrow),
371 );
372 expect(Option.isNone(populated.error)).toBe(true);
373 expect(yield* Ref.get(harness.listCalls)).toBe(1);
374 }).pipe(Effect.provide(harness.layer), Effect.scoped);
375 }),
376 );
377
378 it.effect("settles to a clean empty state when refreshed while signed out", () =>
379 Effect.gen(function* () {
380 const harness = yield* makeHarness();
381 yield* Effect.gen(function* () {
382 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
383 yield* Ref.set(harness.clerkToken, null);
384 yield* discovery.refresh;
385
386 const state = yield* SubscriptionRef.get(discovery.state);
387 expect(state.environments.size).toBe(0);
388 expect(state.refreshing).toBe(false);
389 expect(Option.isNone(state.error)).toBe(true);
390 }).pipe(Effect.provide(harness.layer));
391 }),
392 );
393
345 it.effect("does not republish stale rows after sign-out invalidates an in-flight refresh", () =>394 it.effect("does not republish stale rows after sign-out invalidates an in-flight refresh", () =>
346 Effect.gen(function* () {395 Effect.gen(function* () {
347 const harness = yield* makeHarness();396 const harness = yield* makeHarness();
packages/client-runtime/src/relay/discovery.ts+11−5
@@ -279,10 +279,15 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { · 278 unmodified lines
279 if ((yield* Ref.get(accountGeneration)) !== generation) {279 if ((yield* Ref.get(accountGeneration)) !== generation) {
280 return;280 return;
281 }281 }
282 // Signed out is the idle state, not a failure: the proactive
283 // refresh on credentials-changed also runs on sign-out and must
284 // settle back to a clean empty list.
285 const signedOut =
286 error._tag === "ConnectionBlockedError" && error.reason === "authentication";
282 yield* SubscriptionRef.update(state, (current) => ({287 yield* SubscriptionRef.update(state, (current) => ({
283 ...current,288 ...current,
284 refreshing: false,289 refreshing: false,
285 error: Option.some(error),290 error: signedOut ? Option.none() : Option.some(error),
286 }));291 }));
287 }),292 }),
288 ),293 ),
@@ -311,11 +316,12 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { · 22 unmodified lines
311 yield* Ref.update(accountGeneration, (current) => current + 1);316 yield* Ref.update(accountGeneration, (current) => current + 1);
312 yield* Ref.set(activeAccountId, Option.none());317 yield* Ref.set(activeAccountId, Option.none());
313 yield* Ref.set(offlineReportFingerprints, new Map());318 yield* Ref.set(offlineReportFingerprints, new Map());
314 const shouldRefresh = yield* Ref.get(hasRefreshed);
315 yield* SubscriptionRef.set(state, EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE);319 yield* SubscriptionRef.set(state, EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE);
316 if (shouldRefresh) {320 // Refresh proactively — this wakeup fires when a session activates
317 yield* refresh.pipe(Effect.forkScoped);321 // (sign-in or cold start), and the list should be populated before
318 }322 // any screen asks for it. A signed-out refresh settles back to the
323 // clean empty state.
324 yield* refresh.pipe(Effect.forkScoped);
319 })325 })
320 : Effect.void,326 : Effect.void,
321 ),327 ),
packages/client-runtime/src/relay/managedRelayState.ts+4−4
@@ -123,7 +123,7 @@ export function createManagedRelaySession(input: ManagedRelaySessionInput): Mana · 122 unmodified lines
123 try: () => readCachedClerkToken(nowMillis),123 try: () => readCachedClerkToken(nowMillis),
124 catch: (cause) =>124 catch: (cause) =>
125 new ManagedRelaySessionError({125 new ManagedRelaySessionError({
126 message: "Could not obtain the T3 Cloud session token.",126 message: "Could not obtain the T3 Connect session token.",
127 cause,127 cause,
128 }),128 }),
129 });129 });
@@ -181,7 +181,7 @@ function readSessionClerkToken( · 51 unmodified lines
181 ? Effect.succeed(token)181 ? Effect.succeed(token)
182 : Effect.fail(182 : Effect.fail(
183 new ManagedRelaySessionError({183 new ManagedRelaySessionError({
184 message: "The T3 Cloud session token is unavailable.",184 message: "The T3 Connect session token is unavailable.",
185 }),185 }),
186 ),186 ),
187 ),187 ),
@@ -226,7 +226,7 @@ function requireClerkToken( · 38 unmodified lines
226 if (!session || session.accountId !== accountId) {226 if (!session || session.accountId !== accountId) {
227 return Effect.fail(227 return Effect.fail(
228 new ManagedRelaySessionError({228 new ManagedRelaySessionError({
229 message: "Sign in to T3 Cloud before loading relay data.",229 message: "Sign in to T3 Connect before loading relay data.",
230 }),230 }),
231 );231 );
232 }232 }
@@ -296,7 +296,7 @@ export function readManagedRelaySnapshotState<A>( · 63 unmodified lines
296 let errorTraceId: string | null = null;296 let errorTraceId: string | null = null;
297 if (result._tag === "Failure") {297 if (result._tag === "Failure") {
298 const cause = Cause.squash(result.cause);298 const cause = Cause.squash(result.cause);
299 error = cause instanceof Error ? cause.message : "Could not load T3 Cloud data.";299 error = cause instanceof Error ? cause.message : "Could not load T3 Connect data.";
300 errorTraceId = findErrorTraceId(cause);300 errorTraceId = findErrorTraceId(cause);
301 }301 }
302 return {302 return {