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
1818 import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
1919 import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";
2020 import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
21−import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboarding";
21+import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation";
2222 import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
2323 import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
2424 import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
@@ -423,7 +423,7 @@ export const RootStack = createNativeStackNavigator({ · 398 unmodified lines
423423 options: {
424424 gestureEnabled: true,
425425 presentation: "formSheet",
426− sheetAllowedDetents: [0.75, 0.95],
426+ sheetAllowedDetents: [0.6, 0.95],
427427 sheetGrabberVisible: true,
428428 },
429429 }),
apps/mobile/src/connection/platform.ts+2−2
@@ -92,7 +92,7 @@ const capabilitiesLayer = Layer.succeedContext( · 91 unmodified lines
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( · 8 unmodified lines
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+5−7
@@ -18,7 +18,7 @@ import { · 17 unmodified lines
1818 setAgentAwarenessRelayTokenProvider,
1919 unregisterAgentAwarenessDeviceForCurrentUser,
2020 } from "../agent-awareness/remoteRegistration";
21−import { requestConnectOnboardingIfNeeded } from "./connectOnboarding";
21+import { requestConnectOnboarding } from "./connectOnboarding";
2222 import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
2323
2424 function resetManagedRelayTokenCache() {
@@ -68,14 +68,12 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { · 43 unmodified lines
6868 const nextAccount = isSignedIn && userId ? userId : null;
6969 observedAccountRef.current = nextAccount;
7070
71− // A sign-in that completed during this session (a cold start observes
71+ // Every sign-in that completes during this session (a cold start observes
7272 // 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.
7475 if (previousObservedAccount === null && nextAccount !== null) {
75− void (async () => {
76− const result = await settlePromise(() => requestConnectOnboardingIfNeeded(nextAccount));
77− reportAtomCommandResult(result, { label: "connect onboarding request" });
78− })();
76+ requestConnectOnboarding(nextAccount);
7977 }
8078
8179 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
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>
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx+46−441
@@ -1,67 +1,23 @@
11 import { useAuth } from "@clerk/expo";
22 import { useNavigation } from "@react-navigation/native";
3−import * as Effect from "effect/Effect";
4−import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5−import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
3+import { useCallback } from "react";
4+import { Pressable, ScrollView, View } from "react-native";
65 import { useSafeAreaInsets } from "react-native-safe-area-context";
76
8−import {
9− isAtomCommandInterrupted,
10− reportAtomCommandResult,
11− settleAsyncResult,
12− settlePromise,
13− squashAtomCommandFailure,
14−} from "@t3tools/client-runtime/state/runtime";
7+import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime";
158 import { AppText as Text } from "../../components/AppText";
16−import type { SavedRemoteConnection } from "../../lib/connection";
17−import { cn } from "../../lib/cn";
18−import { runtime } from "../../lib/runtime";
19−import { useThemeColor } from "../../lib/useThemeColor";
20−import {
21− useRemoteConnections,
22− useSavedRemoteConnections,
23−} from "../../state/use-remote-environment-registry";
9+import { useRemoteConnections } from "../../state/use-remote-environment-registry";
2410 import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
2511 import { splitEnvironmentSections } from "../connection/environmentSections";
26−import { SettingsSection } from "../settings/components/SettingsSection";
27−import { SettingsSwitchRow } from "../settings/components/SettingsSwitchRow";
28−import { markConnectOnboardingCompleted } from "./connectOnboarding";
29−import {
30− getEnvironmentConnectPublishState,
31− linkEnvironmentToCloud,
32− setEnvironmentPublishAgentActivity,
33−} from "./linkEnvironment";
34−import { refreshManagedRelayEnvironments } from "./managedRelayState";
35−import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
36−
37−type PublishTargetPhase = "checking" | "ready" | "published" | "unauthorized" | "error";
38−
39−interface 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−
48−function 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−}
12+import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut";
13+import { hasCloudPublicConfig } from "./publicConfig";
5814
5915 /**
60− * Post-sign-in onboarding sheet for T3 Connect. Offers to publish this
61− * device's locally paired environments (managed tunnel + agent activity, both
62− * defaulting on) when the paired session is authorized, then lists the
63− * account's T3 Connect environments so every device can be connected right
64− * away. Dismissing the sheet counts as completion — it never nags twice.
16+ * Post-sign-in onboarding sheet for T3 Connect. Mobile never publishes
17+ * environments itself — it consumes ones published elsewhere — so this simply
18+ * surfaces the account's T3 Connect environments right after sign-in so every
19+ * device can be connected in one go. It shows on every sign-in: sign-out
20+ * clears the connected environments, so each new session starts from zero.
6521 */
6622 export function ConnectOnboardingRouteScreen() {
6723 return hasCloudPublicConfig() ? <ConfiguredConnectOnboardingRouteScreen /> : null;
@@ -70,195 +26,26 @@ export function ConnectOnboardingRouteScreen() { · 2 unmodified lines
7026 function ConfiguredConnectOnboardingRouteScreen() {
7127 const navigation = useNavigation();
7228 const insets = useSafeAreaInsets();
73− const { getToken, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
74− const { isLoadingSavedConnection, savedConnectionsById } = useSavedRemoteConnections();
29+ const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
7530 const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections();
7631 const { connectedCloudEnvironments } = splitEnvironmentSections({
7732 connectedEnvironments,
7833 cloudEnvironments: null,
7934 });
8035
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−
9936 const handleDone = useCallback(() => {
100− persistCompletion();
10137 navigation.goBack();
102− }, [navigation, persistCompletion]);
103−
104− const bearerConnections = useMemo(
105− () =>
106− Object.values(savedConnectionsById).filter((connection) => connection.bearerToken !== null),
107− [savedConnectionsById],
108− );
109−
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;
38+ }, [navigation]);
39+
40+ const handleDontShowAgain = useCallback(() => {
41+ if (userId) {
42+ void (async () => {
43+ const result = await settlePromise(() => optOutOfConnectOnboarding(userId));
44+ reportAtomCommandResult(result, { label: "connect onboarding opt-out" });
45+ })();
19946 }
200− setIsApplying(true);
201− const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));
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− ]);
47+ navigation.goBack();
48+ }, [navigation, userId]);
26249
26350 return (
26451 <View collapsable={false} className="flex-1 bg-sheet">
@@ -286,38 +73,15 @@ function ConfiguredConnectOnboardingRouteScreen() { · 21 unmodified lines
28673 </Pressable>
28774 </View>
28875 <Text className="text-sm leading-normal text-foreground-muted">
289− {
290− "Mesh your devices together — publish this device's environments and connect the rest, all in one place."
291− }
76+ Environments published from your other devices are ready to connect here.
29277 </Text>
29378 </View>
29479
29580 {isSignedIn ? (
296− <>
297− <PublishSection
298− targets={targets}
299− publishEnvironment={publishEnvironment}
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− </>
81+ <CloudEnvironmentRows
82+ connectedCloudEnvironments={connectedCloudEnvironments}
83+ onReconnectEnvironment={onReconnectEnvironment}
84+ />
32185 ) : (
32286 <View collapsable={false} className="rounded-[24px] bg-card p-5">
32387 <Text className="text-sm leading-normal text-foreground-muted">
@@ -326,185 +90,26 @@ function ConfiguredConnectOnboardingRouteScreen() { · 2 unmodified lines
32690 </View>
32791 )}
32892
329− <Pressable
330− accessibilityRole="button"
331− onPress={handleDone}
332− className="min-h-[48px] items-center justify-center rounded-[18px] bg-card active:opacity-70"
333− >
334− <Text className="text-base font-t3-bold text-foreground">Done</Text>
335− </Pressable>
336− </ScrollView>
337− </View>
338− );
339−}
340−
341−function PublishSection(props: {
342− readonly targets: ReadonlyArray<PublishTarget> | null;
343− readonly publishEnvironment: boolean;
344− readonly publishAgentActivity: boolean;
345− readonly isApplying: boolean;
346− readonly onPublishEnvironmentChange: (enabled: boolean) => void;
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>
93+ <View className="gap-3">
94+ <Pressable
95+ accessibilityRole="button"
96+ onPress={handleDone}
97+ className="min-h-[48px] items-center justify-center rounded-[18px] bg-card active:opacity-70"
98+ >
99+ <Text className="text-base font-t3-bold text-foreground">Done</Text>
100+ </Pressable>
101+ {userId ? (
102+ <Pressable
103+ accessibilityRole="button"
104+ hitSlop={8}
105+ onPress={handleDontShowAgain}
106+ className="items-center py-1 active:opacity-70"
107+ >
108+ <Text className="text-xs text-foreground-muted">{"Don't show this again"}</Text>
109+ </Pressable>
110+ ) : null}
391111 </View>
392− </View>
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−
477−function 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−
492−function 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>
112+ </ScrollView>
508113 </View>
509114 );
510115 }
apps/mobile/src/features/cloud/connectOnboarding.ts+7−51
@@ -1,68 +1,24 @@
1−import { useAtomValue } from "@effect/atom-react";
2−import { useNavigation } from "@react-navigation/native";
31 import { Atom } from "effect/unstable/reactivity";
4−import { useEffect } from "react";
52
6−import { loadPreferences, savePreferencesPatch } from "../../lib/storage";
73 import { appAtomRegistry } from "../../state/atom-registry";
84
95 // Signals RootStackLayout (inside the navigation tree) that an in-session
10−// sign-in just completed for an account that has not seen the T3 Connect
11−// onboarding sheet yet. Holds the account id so a sign-out between the request
12−// and the navigation cannot present the sheet for the wrong account.
13−const connectOnboardingRequestAtom = Atom.make<string | null>(null).pipe(
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(
149 Atom.keepAlive,
1510 Atom.withLabel("mobile:connect-onboarding-request"),
1611 );
1712
1813 /**
19− * Requests the onboarding sheet for the given account unless it already
20− * completed (or skipped) onboarding on this device.
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.
2117 */
22−export async function requestConnectOnboardingIfNeeded(accountId: string): Promise<void> {
23− const preferences = await loadPreferences();
24− if (preferences.connectOnboardingCompletedAccounts?.includes(accountId)) {
25− return;
26− }
18+export function requestConnectOnboarding(accountId: string): void {
2719 appAtomRegistry.set(connectOnboardingRequestAtom, accountId);
2820 }
2921
3022 export function clearConnectOnboardingRequest(): void {
3123 appAtomRegistry.set(connectOnboardingRequestAtom, null);
3224 }
33−
34−/** Persists that the account saw the sheet, so it never nags twice. */
35−export 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.
46−const PRESENT_ONBOARDING_DELAY_MS = 600;
47−
48−/**
49− * Consumes the onboarding request inside the navigation tree (RootStackLayout)
50− * and presents the onboarding formSheet.
51− */
52−export 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 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import { useNavigation } from "@react-navigation/native";
3+import { useEffect } from "react";
4+
5+import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding";
6+import { 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.
10+const 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+ */
18+export 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 @@
1+import { 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". */
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+ 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
33 import * as Schema from "effect/Schema";
44 import { HttpClient } from "effect/unstable/http";
55 import {
6− AuthRelayWriteScope,
76 EnvironmentCloudEndpointUnavailableError,
87 EnvironmentHttpBadRequestError,
98 EnvironmentHttpConflictError,
@@ -77,13 +76,6 @@ const isEnvironmentCloudApiError = Schema.is( · 67 unmodified lines
7776 const MANAGED_ENDPOINT_PROVIDER_KIND =
7877 "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
7978
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).
83−export type CloudLinkMode = "managed" | "publish_only";
84−
85−const PUBLISH_ONLY_PROVIDER_KIND = "manual" satisfies RelayManagedEndpointProviderKind;
86−
8779 function cloudEnvironmentLinkError(message: string) {
8880 return (cause: unknown) => {
8981 const environmentError = findEnvironmentCloudApiError(cause);
@@ -267,7 +259,6 @@ function ensureConnectEndpointMatchesEnvironment(input: { · 177 unmodified lines
267259 export function linkEnvironmentToCloud(input: {
268260 readonly connection: SavedRemoteConnection;
269261 readonly clerkToken: string;
270− readonly mode?: CloudLinkMode;
271262 }): Effect.Effect<
272263 void,
273264 CloudEnvironmentLinkError,
@@ -280,10 +271,6 @@ export function linkEnvironmentToCloud(input: { · 6 unmodified lines
280271 });
281272 }
282273 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;
287274 const relayUrl = yield* requireRelayUrl();
288275 const relayClient = yield* ManagedRelay.ManagedRelayClient;
289276 const deviceId = yield* Effect.tryPromise({
@@ -301,7 +288,7 @@ export function linkEnvironmentToCloud(input: { · 11 unmodified lines
301288 payload: {
302289 notificationsEnabled: true,
303290 liveActivitiesEnabled,
304− managedTunnelsEnabled,
291+ managedTunnelsEnabled: true,
305292 },
306293 })
307294 .pipe(
@@ -319,7 +306,7 @@ export function linkEnvironmentToCloud(input: { · 11 unmodified lines
319306 endpoint: {
320307 httpBaseUrl: input.connection.httpBaseUrl,
321308 wsBaseUrl: input.connection.wsBaseUrl,
322− providerKind,
309+ providerKind: MANAGED_ENDPOINT_PROVIDER_KIND,
323310 },
324311 origin: endpointOrigin(input.connection.httpBaseUrl),
325312 },
@@ -333,7 +320,7 @@ export function linkEnvironmentToCloud(input: { · 7 unmodified lines
333320 proof,
334321 notificationsEnabled: true,
335322 liveActivitiesEnabled,
336− managedTunnelsEnabled,
323+ managedTunnelsEnabled: true,
337324 },
338325 })
339326 .pipe(
@@ -341,7 +328,7 @@ export function linkEnvironmentToCloud(input: { · 1 unmodified line
341328 );
342329 yield* ensureLinkedEnvironmentMatches({
343330 expectedEnvironmentId: input.connection.environmentId,
344− expectedProviderKind: providerKind,
331+ expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND,
345332 link,
346333 });
347334
@@ -363,91 +350,6 @@ export function linkEnvironmentToCloud(input: { · 15 unmodified lines
363350 });
364351 }
365352
366−function 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−
378−export 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− */
394−export 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− */
431−export 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−
451353 export function listCloudEnvironments(input: {
452354 readonly clerkToken: string;
453355 }): Effect.Effect<
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+3−3
@@ -25,7 +25,7 @@ import { ConnectionStatusDot } from "./ConnectionStatusDot"; · 24 unmodified lines
2525 import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController";
2626
2727 /**
28− * "T3 Cloud" section: every environment published to the signed-in account,
28+ * "T3 Connect" section: every environment published to the signed-in account,
2929 * with connect switches, availability status, refresh, and loading/error
3030 * states. Shared between the Settings environments screen and the T3 Connect
3131 * onboarding sheet.
@@ -61,7 +61,7 @@ export function CloudEnvironmentRows(props: { · 29 unmodified lines
6161 return (
6262 <View collapsable={false} className="mt-5 gap-3">
6363 <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>
6565 <Pressable
6666 accessibilityRole="button"
6767 disabled={controller.relayDiscovery.isRefreshing}
@@ -112,7 +112,7 @@ export function CloudEnvironmentRows(props: { · 44 unmodified lines
112112 ) : controller.relayDiscovery.error ? (
113113 <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5">
114114 <Text className="text-base font-t3-bold text-foreground">
115− Could not load T3 Cloud environments
115+ Could not load T3 Connect environments
116116 </Text>
117117 <Text className="text-sm text-foreground-muted">{controller.relayDiscovery.error}</Text>
118118 {controller.relayDiscovery.errorTraceId ? (
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+1−1
@@ -135,7 +135,7 @@ export function ConnectionEnvironmentRow(props: { · 134 unmodified lines
135135 >
136136 {props.environment.isRelayManaged ? (
137137 <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.
139139 </Text>
140140 ) : (
141141 <>
apps/mobile/src/features/connection/environmentSections.test.ts+1−1
@@ -35,7 +35,7 @@ function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { · 34 unmodified lines
3535 }
3636
3737 describe("mobile environment settings sections", () => {
38− it("keeps saved relay-managed connections under T3 Cloud", () => {
38+ it("keeps saved relay-managed connections under T3 Connect", () => {
3939 const local = connectedEnvironment({
4040 environmentId: "environment-local",
4141 isRelayManaged: false,
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+2−2
@@ -240,8 +240,8 @@ function ConfiguredSettingsRouteScreen() { · 239 unmodified lines
240240
241241 const promptSignIn = useCallback(() => {
242242 Alert.alert(
243− "Request T3 Cloud access",
244− "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.",
243+ "Request T3 Connect access",
244+ "Live Activity updates require approved T3 Connect access so relay can deliver updates to this device.",
245245 [
246246 { text: "Cancel", style: "cancel" },
247247 {
apps/mobile/src/lib/storage.ts+7−8
@@ -69,8 +69,8 @@ export interface Preferences { · 68 unmodified lines
6969 /** Code/diff font size override; null/absent means derived from baseFontSize. */
7070 readonly codeFontSize?: number | null;
7171 readonly codeWordBreak?: boolean;
72− /** Cloud account ids that already saw the T3 Connect onboarding sheet. */
73− readonly connectOnboardingCompletedAccounts?: ReadonlyArray<string>;
72+ /** Cloud account ids that opted out of the T3 Connect onboarding sheet. */
73+ readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
7474 }
7575
7676 async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> {
@@ -169,7 +169,7 @@ export async function loadPreferences(): Promise<Preferences> { · 92 unmodified lines
169169 markdownFontSize?: number;
170170 codeFontSize?: number | null;
171171 codeWordBreak?: boolean;
172− connectOnboardingCompletedAccounts?: ReadonlyArray<string>;
172+ connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
173173 } = {};
174174
175175 if (typeof parsed.liveActivitiesEnabled === "boolean") {
@@ -190,11 +190,10 @@ export async function loadPreferences(): Promise<Preferences> { · 14 unmodified lines
190190 if (typeof parsed.codeWordBreak === "boolean") {
191191 preferences.codeWordBreak = parsed.codeWordBreak;
192192 }
193− if (Array.isArray(parsed.connectOnboardingCompletedAccounts)) {
194− preferences.connectOnboardingCompletedAccounts =
195− parsed.connectOnboardingCompletedAccounts.filter(
196− (account): account is string => typeof account === "string",
197− );
193+ if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) {
194+ preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter(
195+ (account): account is string => typeof account === "string",
196+ );
198197 }
199198
200199 return preferences;
apps/web/src/cloud/connectOnboarding.ts+9−9
@@ -1,18 +1,18 @@
11 import * as Schema from "effect/Schema";
22
33 /**
4− * Tracks which T3 Connect accounts have completed (or dismissed) the
5− * post-sign-in onboarding wizard, so it is shown at most once per account per
6− * browser.
4+ * Accounts that opted out of the post-sign-in T3 Connect onboarding wizard
5+ * ("Don't show this again"). The wizard otherwise shows on every sign-in,
6+ * since sign-out clears the connected environments.
77 */
8−export const CONNECT_ONBOARDING_STORAGE_KEY = "t3code:connect-onboarding:v1";
8+export const CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY = "t3code:connect-onboarding-opt-out:v1";
99
10−export const ConnectOnboardingStateSchema = Schema.Struct({
11− completedAccounts: Schema.Array(Schema.String),
10+export const ConnectOnboardingOptOutSchema = Schema.Struct({
11+ optOutAccounts: Schema.Array(Schema.String),
1212 });
1313
14−export type ConnectOnboardingState = typeof ConnectOnboardingStateSchema.Type;
14+export type ConnectOnboardingOptOutState = typeof ConnectOnboardingOptOutSchema.Type;
1515
16−export const EMPTY_CONNECT_ONBOARDING_STATE: ConnectOnboardingState = {
17− completedAccounts: [],
16+export const EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE: ConnectOnboardingOptOutState = {
17+ optOutAccounts: [],
1818 };
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx+79−42
@@ -4,9 +4,9 @@ import { CheckIcon } from "lucide-react"; · 3 unmodified lines
44 import { useEffect, useMemo, useRef, useState } from "react";
55
66 import {
7− CONNECT_ONBOARDING_STORAGE_KEY,
8− ConnectOnboardingStateSchema,
9− EMPTY_CONNECT_ONBOARDING_STATE,
7+ CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY,
8+ ConnectOnboardingOptOutSchema,
9+ EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE,
1010 } from "~/cloud/connectOnboarding";
1111 import { hasCloudPublicConfig } from "~/cloud/publicConfig";
1212 import { useCloudLinkController } from "~/cloud/useCloudLinkController";
@@ -16,6 +16,7 @@ import { cn } from "~/lib/utils"; · 3 unmodified lines
1616 import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
1717 import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";
1818 import { Button } from "../ui/button";
19+import { Checkbox } from "../ui/checkbox";
1920 import {
2021 Dialog,
2122 DialogDescription,
@@ -29,12 +30,13 @@ import { Switch } from "../ui/switch"; · 7 unmodified lines
2930 import { toastManager } from "../ui/toast";
3031
3132 /**
32− * Post-sign-in onboarding wizard for T3 Connect. Opens once per account (per
33− * browser) after the user is signed in: first prompts to publish this
33+ * Post-sign-in onboarding wizard for T3 Connect. Opens on every in-session
34+ * 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
3436 * environment (managed tunnel + agent activity, both defaulting on) when the
3537 * current session is authorized to manage the relay link, then lists the
3638 * 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.
3840 */
3941 export function ConnectOnboardingDialog() {
4042 if (!hasCloudPublicConfig()) return null;
@@ -46,10 +48,10 @@ type OnboardingStep = "publish" | "devices"; · 5 unmodified lines
4648
4749 function ConfiguredConnectOnboardingDialog() {
4850 const { isLoaded, isSignedIn, userId } = useAuth();
49− const [onboardingState, setOnboardingState] = useLocalStorage(
50− CONNECT_ONBOARDING_STORAGE_KEY,
51− EMPTY_CONNECT_ONBOARDING_STATE,
52− ConnectOnboardingStateSchema,
51+ const [optOutState, setOptOutState] = useLocalStorage(
52+ CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY,
53+ EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE,
54+ ConnectOnboardingOptOutSchema,
5355 );
5456
5557 const desktopBridge = window.desktopBridge;
@@ -75,34 +77,54 @@ function ConfiguredConnectOnboardingDialog() { · 19 unmodified lines
7577 ? ["publish", "devices"]
7678 : ["devices"];
7779
80+ const [requestedAccount, setRequestedAccount] = useState<string | null>(null);
7881 const [openForAccount, setOpenForAccount] = useState<string | null>(null);
7982 const [step, setStep] = useState<OnboardingStep>("devices");
8083 const [exposeEnvironment, setExposeEnvironment] = useState(true);
8184 const [publishAgentActivity, setPublishAgentActivity] = useState(true);
85+ const [dontShowAgain, setDontShowAgain] = useState(false);
8286 const [isApplying, setIsApplying] = useState(false);
8387 const prefilledFromLinkStateRef = useRef(false);
88+ const observedAccountRef = useRef<string | null | undefined>(undefined);
8489
85− const completedAccounts = onboardingState.completedAccounts;
90+ const optOutAccounts = optOutState.optOutAccounts;
8691
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.
8795 useEffect(() => {
88− if (!isLoaded || !isSignedIn || !userId) return;
89− if (openForAccount !== null) return;
90− if (completedAccounts.includes(userId)) return;
96+ if (!isLoaded) return;
97+ const previousAccount = observedAccountRef.current;
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+ }
91113 if (!sessionScopesKnown) return;
114+ setRequestedAccount(null);
92115 prefilledFromLinkStateRef.current = false;
93116 setExposeEnvironment(true);
94117 setPublishAgentActivity(true);
118+ setDontShowAgain(false);
95119 setStep(canManageRelay && controller.linkState.target !== null ? "publish" : "devices");
96− setOpenForAccount(userId);
120+ setOpenForAccount(requestedAccount);
97121 }, [
98122 canManageRelay,
99− completedAccounts,
100123 controller.linkState.target,
101− isLoaded,
102− isSignedIn,
103124 openForAccount,
125+ optOutAccounts,
126+ requestedAccount,
104127 sessionScopesKnown,
105− userId,
106128 ]);
107129
108130 // Signing out (or switching accounts) mid-wizard invalidates everything the
@@ -111,7 +133,10 @@ function ConfiguredConnectOnboardingDialog() { · 2 unmodified lines
111133 if (openForAccount !== null && (!isSignedIn || userId !== openForAccount)) {
112134 setOpenForAccount(null);
113135 }
114− }, [isSignedIn, openForAccount, userId]);
136+ if (requestedAccount !== null && (!isSignedIn || userId !== requestedAccount)) {
137+ setRequestedAccount(null);
138+ }
139+ }, [isSignedIn, openForAccount, requestedAccount, userId]);
115140
116141 // Toggles default on, but an environment that is already linked should show
117142 // its actual configuration instead of silently proposing to rewrite it.
@@ -130,12 +155,13 @@ function ConfiguredConnectOnboardingDialog() { · 12 unmodified lines
130155 const complete = () => {
131156 const account = openForAccount;
132157 setOpenForAccount(null);
133− if (!account) return;
134− setOnboardingState((state) =>
135− state.completedAccounts.includes(account)
136− ? state
137− : { completedAccounts: [...state.completedAccounts, account] },
138− );
158+ if (account !== null && dontShowAgain) {
159+ setOptOutState((state) =>
160+ state.optOutAccounts.includes(account)
161+ ? state
162+ : { optOutAccounts: [...state.optOutAccounts, account] },
163+ );
164+ }
139165 };
140166
141167 const applyPublishSelection = async () => {
@@ -194,22 +220,33 @@ function ConfiguredConnectOnboardingDialog() { · 52 unmodified lines
194220 <DevicesStep />
195221 )}
196222 </DialogPanel>
197− <DialogFooter variant="bare">
198− {step === "publish" ? (
199− <>
200− <Button variant="ghost" disabled={isApplying} onClick={() => setStep("devices")}>
201− Not now
202− </Button>
203− <Button
204− disabled={isApplying || (controller.linkState.isPending && linkStateData === null)}
205− onClick={() => void applyPublishSelection()}
206− >
207− {isApplying ? "Enabling…" : "Continue"}
208− </Button>
209− </>
210− ) : (
211− <Button onClick={complete}>Done</Button>
212− )}
223+ <DialogFooter variant="bare" className="sm:justify-between">
224+ <label className="flex cursor-pointer items-center gap-2 self-start text-xs text-muted-foreground sm:self-center">
225+ <Checkbox
226+ checked={dontShowAgain}
227+ onCheckedChange={(checked) => setDontShowAgain(checked === true)}
228+ />
229+ Don&apos;t show this again
230+ </label>
231+ <div className="flex flex-col-reverse gap-2 sm:flex-row">
232+ {step === "publish" ? (
233+ <>
234+ <Button variant="ghost" disabled={isApplying} onClick={() => setStep("devices")}>
235+ Not now
236+ </Button>
237+ <Button
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>
213250 </DialogFooter>
214251 </DialogPopup>
215252 </Dialog>
apps/web/src/connection/platform.ts+2−2
@@ -182,7 +182,7 @@ const capabilitiesLayer = Layer.effectContext( · 181 unmodified lines
182182 if (session === null) {
183183 return yield* new ConnectionBlockedError({
184184 reason: "authentication",
185− detail: "Sign in to T3 Cloud to connect this environment.",
185+ detail: "Sign in to T3 Connect to connect this environment.",
186186 });
187187 }
188188 const token = yield* session.readClerkToken().pipe(
@@ -197,7 +197,7 @@ const capabilitiesLayer = Layer.effectContext( · 8 unmodified lines
197197 if (token === null) {
198198 return yield* new ConnectionBlockedError({
199199 reason: "authentication",
200− detail: "The T3 Cloud session is unavailable.",
200+ detail: "The T3 Connect session is unavailable.",
201201 });
202202 }
203203 return token;
packages/client-runtime/src/relay/discovery.test.ts+49−0
@@ -342,6 +342,55 @@ describe("RelayEnvironmentDiscovery", () => { · 341 unmodified lines
342342 }),
343343 );
344344
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+
345394 it.effect("does not republish stale rows after sign-out invalidates an in-flight refresh", () =>
346395 Effect.gen(function* () {
347396 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
279279 if ((yield* Ref.get(accountGeneration)) !== generation) {
280280 return;
281281 }
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";
282287 yield* SubscriptionRef.update(state, (current) => ({
283288 ...current,
284289 refreshing: false,
285− error: Option.some(error),
290+ error: signedOut ? Option.none() : Option.some(error),
286291 }));
287292 }),
288293 ),
@@ -311,11 +316,12 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { · 22 unmodified lines
311316 yield* Ref.update(accountGeneration, (current) => current + 1);
312317 yield* Ref.set(activeAccountId, Option.none());
313318 yield* Ref.set(offlineReportFingerprints, new Map());
314− const shouldRefresh = yield* Ref.get(hasRefreshed);
315319 yield* SubscriptionRef.set(state, EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE);
316− if (shouldRefresh) {
317− yield* refresh.pipe(Effect.forkScoped);
318− }
320+ // Refresh proactively — this wakeup fires when a session activates
321+ // (sign-in or cold start), and the list should be populated before
322+ // any screen asks for it. A signed-out refresh settles back to the
323+ // clean empty state.
324+ yield* refresh.pipe(Effect.forkScoped);
319325 })
320326 : Effect.void,
321327 ),
packages/client-runtime/src/relay/managedRelayState.ts+4−4
@@ -123,7 +123,7 @@ export function createManagedRelaySession(input: ManagedRelaySessionInput): Mana · 122 unmodified lines
123123 try: () => readCachedClerkToken(nowMillis),
124124 catch: (cause) =>
125125 new ManagedRelaySessionError({
126− message: "Could not obtain the T3 Cloud session token.",
126+ message: "Could not obtain the T3 Connect session token.",
127127 cause,
128128 }),
129129 });
@@ -181,7 +181,7 @@ function readSessionClerkToken( · 51 unmodified lines
181181 ? Effect.succeed(token)
182182 : Effect.fail(
183183 new ManagedRelaySessionError({
184− message: "The T3 Cloud session token is unavailable.",
184+ message: "The T3 Connect session token is unavailable.",
185185 }),
186186 ),
187187 ),
@@ -226,7 +226,7 @@ function requireClerkToken( · 38 unmodified lines
226226 if (!session || session.accountId !== accountId) {
227227 return Effect.fail(
228228 new ManagedRelaySessionError({
229− message: "Sign in to T3 Cloud before loading relay data.",
229+ message: "Sign in to T3 Connect before loading relay data.",
230230 }),
231231 );
232232 }
@@ -296,7 +296,7 @@ export function readManagedRelaySnapshotState<A>( · 63 unmodified lines
296296 let errorTraceId: string | null = null;
297297 if (result._tag === "Failure") {
298298 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.";
300300 errorTraceId = findErrorTraceId(cause);
301301 }
302302 return {