authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-07 14:17:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-07 14:17:18-07:00
logdad08897664b73d4154736ffe3be3b8d12f64a3d
treec140d0e5d1f2c17147911f9ab1373bc8dff3f6c7
parentb9cc8d6ef17ca9f45bec621bef71ad3f706b9276
signature Signed by PGP key B5690EEEBB952194

Revert "Revert "Add T3 Connect onboarding for mobile and web"" (#3777)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

26 files changed, 1682 insertions(+), 727 deletions(-)

apps/mobile/src/Stack.tsx+19
...@@ -17,6 +17,8 @@ import { AppText as Text } from "./components/AppText";...@@ -17,6 +17,8 @@ import { AppText as Text } from "./components/AppText";
17import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";17import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
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";
21import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation";
20import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";22import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
21import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";23import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
22import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";24import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
...@@ -220,6 +222,7 @@ const NewTaskSheetStack = createNativeStackNavigator({...@@ -220,6 +222,7 @@ const NewTaskSheetStack = createNativeStackNavigator({
220// influence the adaptive workspace layout: opening Settings over Home should222// influence the adaptive workspace layout: opening Settings over Home should
221// not flip the sidebar in or change the active thread.223// not flip the sidebar in or change the active thread.
222const WORKSPACE_OVERLAY_ROUTES = new Set([224const WORKSPACE_OVERLAY_ROUTES = new Set([
225 "ConnectOnboarding",
223 "Connections",226 "Connections",
224 "ConnectionsNew",227 "ConnectionsNew",
225 "GitBranches",228 "GitBranches",
...@@ -251,6 +254,8 @@ function RootStackLayout(props: {...@@ -251,6 +254,8 @@ function RootStackLayout(props: {
251}) {254}) {
252 useAgentNotificationNavigation();255 useAgentNotificationNavigation();
253 useThreadOutboxDrain();256 useThreadOutboxDrain();
257 // Presents the T3 Connect onboarding sheet after an in-session sign-in.
258 useConnectOnboardingNavigation();
254 // Full pathname (sheets included) for keyboard-command scoping; the259 // Full pathname (sheets included) for keyboard-command scoping; the
255 // workspace layout only reacts to the underlying non-overlay route.260 // workspace layout only reacts to the underlying non-overlay route.
256 const path = getPathFromState(props.state, navigationPathConfig);261 const path = getPathFromState(props.state, navigationPathConfig);
...@@ -412,6 +417,20 @@ export const RootStack = createNativeStackNavigator({...@@ -412,6 +417,20 @@ export const RootStack = createNativeStackNavigator({
412 sheetGrabberVisible: true,417 sheetGrabberVisible: true,
413 },418 },
414 }),419 }),
420 ConnectOnboarding: createNativeStackScreen({
421 screen: ConnectOnboardingRouteScreen,
422 linking: "connect-onboarding",
423 options: {
424 // Root screenOptions hide headers; formSheets that want the native
425 // title bar opt back in with the sheet header preset.
426 ...SHEET_SOLID_HEADER_OPTIONS,
427 title: "Set up T3 Connect",
428 gestureEnabled: true,
429 presentation: "formSheet",
430 sheetAllowedDetents: [0.6, 0.95],
431 sheetGrabberVisible: true,
432 },
433 }),
415 Connections: createNativeStackScreen({434 Connections: createNativeStackScreen({
416 screen: ConnectionsRouteScreen,435 screen: ConnectionsRouteScreen,
417 linking: "connections",436 linking: "connections",
apps/mobile/src/connection/platform.ts+2-2
...@@ -92,7 +92,7 @@ const capabilitiesLayer = Layer.succeedContext(...@@ -92,7 +92,7 @@ const capabilitiesLayer = Layer.succeedContext(
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(...@@ -107,7 +107,7 @@ const capabilitiesLayer = Layer.succeedContext(
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+17
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 setAgentAwarenessRelayTokenProvider,18 setAgentAwarenessRelayTokenProvider,
19 unregisterAgentAwarenessDeviceForCurrentUser,19 unregisterAgentAwarenessDeviceForCurrentUser,
20} from "../agent-awareness/remoteRegistration";20} from "../agent-awareness/remoteRegistration";
21import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding";
21import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";22import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
2223
23function resetManagedRelayTokenCache() {24function resetManagedRelayTokenCache() {
...@@ -67,6 +68,19 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {...@@ -67,6 +68,19 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
67 const nextAccount = isSignedIn && userId ? userId : null;68 const nextAccount = isSignedIn && userId ? userId : null;
68 observedAccountRef.current = nextAccount;69 observedAccountRef.current = nextAccount;
6970
71 // Every sign-in or account switch that completes during this session (a
72 // cold start observes undefined → account and must not re-prompt) requests
73 // the T3 Connect onboarding sheet — account transitions clear the
74 // connected environments, so each new session starts with no devices to
75 // reach. The request itself is issued after the cleanup transition inside
76 // activateSession, so the sheet never lists the previous account's
77 // environments; sign-out drops any not-yet-presented request instead.
78 const isAccountTransition =
79 previousObservedAccount !== undefined && previousObservedAccount !== nextAccount;
80 if (isAccountTransition && nextAccount === null) {
81 clearConnectOnboardingRequest();
82 }
83
70 const queueAccountCleanup = (84 const queueAccountCleanup = (
71 previous: {85 previous: {
72 readonly userId: string;86 readonly userId: string;
...@@ -114,6 +128,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {...@@ -114,6 +128,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
114 }128 }
115 previousTokenProviderRef.current = { userId, provider: tokenProvider };129 previousTokenProviderRef.current = { userId, provider: tokenProvider };
116 activateCloudRelayAccount(userId, tokenProvider);130 activateCloudRelayAccount(userId, tokenProvider);
131 if (isAccountTransition) {
132 requestConnectOnboarding(userId);
133 }
117 };134 };
118 const activateAfterTransition = (transition: Promise<void>) => {135 const activateAfterTransition = (transition: Promise<void>) => {
119 void (async () => {136 void (async () => {
apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx+1-1
...@@ -39,7 +39,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }...@@ -39,7 +39,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
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 created+129
...@@ -0,0 +1,129 @@
1import { NativeHeaderToolbar } from "../../native/StackHeader";
2import { useAuth } from "@clerk/expo";
3import { StackActions, useNavigation } from "@react-navigation/native";
4import { useCallback, useEffect, useState } from "react";
5import { Pressable, RefreshControl, ScrollView, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";
7
8import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime";
9import { AppText as Text } from "../../components/AppText";
10import { useRemoteConnections } from "../../state/use-remote-environment-registry";
11import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
12import { splitEnvironmentSections } from "../connection/environmentSections";
13import { useConnectionController } from "../connection/useConnectionController";
14import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut";
15import { hasCloudPublicConfig } from "./publicConfig";
16
17/**
18 * Post-sign-in onboarding sheet for T3 Connect. Mobile never publishes
19 * environments itself — it consumes ones published elsewhere — so this simply
20 * surfaces the account's T3 Connect environments right after sign-in so every
21 * device can be connected in one go. It shows on every sign-in: sign-out
22 * clears the connected environments, so each new session starts from zero.
23 */
24export function ConnectOnboardingRouteScreen() {
25 const navigation = useNavigation();
26
27 // The route is deep-linkable; without cloud config the sheet would present
28 // empty with no chrome to dismiss it, so bail back out instead.
29 useEffect(() => {
30 if (hasCloudPublicConfig()) {
31 return;
32 }
33 if (navigation.canGoBack()) {
34 navigation.goBack();
35 } else {
36 navigation.dispatch(StackActions.replace("Home"));
37 }
38 }, [navigation]);
39
40 return hasCloudPublicConfig() ? <ConfiguredConnectOnboardingRouteScreen /> : null;
41}
42
43function ConfiguredConnectOnboardingRouteScreen() {
44 const navigation = useNavigation();
45 const insets = useSafeAreaInsets();
46 const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
47 const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections();
48 const { refreshRelayEnvironments } = useConnectionController();
49 const { connectedCloudEnvironments } = splitEnvironmentSections({
50 connectedEnvironments,
51 cloudEnvironments: null,
52 });
53
54 // Pull-to-refresh tracks its own spinner instead of discovery's refreshing
55 // flag, so background refreshes (e.g. the sign-in one) don't yank the
56 // content down.
57 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
58 const handlePullRefresh = useCallback(() => {
59 void (async () => {
60 setIsPullRefreshing(true);
61 await refreshRelayEnvironments();
62 setIsPullRefreshing(false);
63 })();
64 }, [refreshRelayEnvironments]);
65
66 const handleClose = useCallback(() => {
67 navigation.goBack();
68 }, [navigation]);
69
70 // Persist before dismissing so a quick sign-out/sign-in cannot race ahead
71 // of the preference write; the write is a local secure-store update.
72 const handleDontShowAgain = useCallback(() => {
73 void (async () => {
74 if (userId) {
75 const result = await settlePromise(() => optOutOfConnectOnboarding(userId));
76 reportAtomCommandResult(result, { label: "connect onboarding opt-out" });
77 }
78 navigation.goBack();
79 })();
80 }, [navigation, userId]);
81
82 return (
83 <View collapsable={false} className="flex-1 bg-sheet">
84 <NativeHeaderToolbar placement="right">
85 <NativeHeaderToolbar.Button icon="xmark" onPress={handleClose} separateBackground />
86 </NativeHeaderToolbar>
87 <ScrollView
88 alwaysBounceVertical
89 contentInsetAdjustmentBehavior="automatic"
90 showsVerticalScrollIndicator={false}
91 style={{ flex: 1 }}
92 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
93 contentContainerStyle={{
94 gap: 16,
95 paddingHorizontal: 20,
96 paddingTop: 16,
97 }}
98 refreshControl={
99 <RefreshControl refreshing={isPullRefreshing} onRefresh={handlePullRefresh} />
100 }
101 >
102 {isSignedIn ? (
103 <CloudEnvironmentRows
104 connectedCloudEnvironments={connectedCloudEnvironments}
105 onReconnectEnvironment={onReconnectEnvironment}
106 showHeader={false}
107 />
108 ) : (
109 <View collapsable={false} className="rounded-[24px] bg-card p-5">
110 <Text className="text-sm leading-normal text-foreground-muted">
111 Sign in to your T3 account to set up T3 Connect.
112 </Text>
113 </View>
114 )}
115
116 {userId ? (
117 <Pressable
118 accessibilityRole="button"
119 hitSlop={8}
120 onPress={handleDontShowAgain}
121 className="items-center py-1 active:opacity-70"
122 >
123 <Text className="text-xs text-foreground-muted">{"Don't show this again"}</Text>
124 </Pressable>
125 ) : null}
126 </ScrollView>
127 </View>
128 );
129}
apps/mobile/src/features/cloud/connectOnboarding.ts created+24
...@@ -0,0 +1,24 @@
1import { Atom } from "effect/unstable/reactivity";
2
3import { appAtomRegistry } from "../../state/atom-registry";
4
5// Signals RootStackLayout (inside the navigation tree) that an in-session
6// sign-in just completed. Holds the account id so a sign-out between the
7// request and the navigation cannot present the sheet for the wrong account.
8export const connectOnboardingRequestAtom = Atom.make<string | null>(null).pipe(
9 Atom.keepAlive,
10 Atom.withLabel("mobile:connect-onboarding-request"),
11);
12
13/**
14 * Requests the onboarding sheet for the given account. Sign-out clears the
15 * connected environments, so onboarding runs on every in-session sign-in —
16 * each new session starts with no connected devices.
17 */
18export function requestConnectOnboarding(accountId: string): void {
19 appAtomRegistry.set(connectOnboardingRequestAtom, accountId);
20}
21
22export function clearConnectOnboardingRequest(): void {
23 appAtomRegistry.set(connectOnboardingRequestAtom, null);
24}
apps/mobile/src/features/cloud/connectOnboardingNavigation.ts created+49
...@@ -0,0 +1,49 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";
3import { useEffect } from "react";
4
5import { appAtomRegistry } from "../../state/atom-registry";
6import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding";
7import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut";
8
9// Sign-in happens inside the Settings sheet; give its detent/session-state
10// transitions a beat to settle before presenting another formSheet on top.
11const PRESENT_ONBOARDING_DELAY_MS = 600;
12
13/**
14 * Consumes the onboarding request inside the navigation tree (RootStackLayout)
15 * and presents the onboarding formSheet. Lives apart from connectOnboarding.ts
16 * so non-navigation consumers (CloudAuthProvider) do not pull
17 * @react-navigation into their module graph.
18 */
19export function useConnectOnboardingNavigation(): void {
20 const navigation = useNavigation();
21 const requestedAccountId = useAtomValue(connectOnboardingRequestAtom);
22
23 useEffect(() => {
24 if (requestedAccountId === null) {
25 return;
26 }
27 let cancelled = false;
28 const timer = setTimeout(() => {
29 void (async () => {
30 // A failed preference read prefers showing the sheet.
31 const optedOut = await isConnectOnboardingOptedOut(requestedAccountId).catch(() => false);
32 // The cancelled flag covers effect re-runs, but a sign-out can clear
33 // the request atom moments before this render commits — re-check the
34 // atom so a stale request never presents the sheet.
35 if (cancelled || appAtomRegistry.get(connectOnboardingRequestAtom) !== requestedAccountId) {
36 return;
37 }
38 clearConnectOnboardingRequest();
39 if (!optedOut) {
40 navigation.navigate("ConnectOnboarding");
41 }
42 })();
43 }, PRESENT_ONBOARDING_DELAY_MS);
44 return () => {
45 cancelled = true;
46 clearTimeout(timer);
47 };
48 }, [navigation, requestedAccountId]);
49}
apps/mobile/src/features/cloud/connectOnboardingOptOut.ts created+21
...@@ -0,0 +1,21 @@
1import { loadPreferences, updatePreferences } from "../../lib/storage";
2
3// Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports
4// the request signal) never pulls lib/storage — expo-secure-store — into its
5// module graph; that breaks CloudAuthProvider.test.ts suite loading.
6
7/** Whether the account chose "Don't show this again". */
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 await updatePreferences((current) => {
16 const optedOut = current.connectOnboardingOptOutAccounts ?? [];
17 return optedOut.includes(accountId)
18 ? {}
19 : { connectOnboardingOptOutAccounts: [...optedOut, accountId] };
20 });
21}
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx created+379
...@@ -0,0 +1,379 @@
1import { useAuth } from "@clerk/expo";
2import { SymbolView } from "expo-symbols";
3import {
4 connectionStatusText,
5 type EnvironmentConnectionPhase,
6} from "@t3tools/client-runtime/connection";
7import type { EnvironmentId } from "@t3tools/contracts";
8import { useCallback, useState } from "react";
9import {
10 ActivityIndicator,
11 Pressable,
12 Switch,
13 type NativeSyntheticEvent,
14 type TextLayoutEventData,
15 View,
16} from "react-native";
17
18import { AppText as Text } from "../../components/AppText";
19import { cn } from "../../lib/cn";
20import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
21import { useThemeColor } from "../../lib/useThemeColor";
22import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
23import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
24import { ConnectionStatusDot } from "./ConnectionStatusDot";
25import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController";
26
27/**
28 * "T3 Connect" section: every environment published to the signed-in account,
29 * with connect switches, availability status, refresh, and loading/error
30 * states. Shared between the Settings environments screen and the T3 Connect
31 * onboarding sheet.
32 */
33export function CloudEnvironmentRows(props: {
34 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
35 readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void;
36 /**
37 * Hide the "T3 Connect" section title + refresh button for hosts that
38 * provide their own chrome (the onboarding sheet's native header and
39 * pull-to-refresh).
40 */
41 readonly showHeader?: boolean;
42}) {
43 const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
44 const controller = useConnectionController();
45 const iconColor = useThemeColor("--color-icon");
46 const availableCloudEnvironments = controller.availableRelayEnvironments;
47 const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
48 const hasCloudRows =
49 props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0;
50
51 const handleConnectCloudEnvironment = useCallback(
52 (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment),
53 [controller],
54 );
55
56 const handleDisconnectCloudEnvironment = useCallback(
57 (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId),
58 [controller],
59 );
60
61 const handleToggleCloudError = useCallback((environmentId: string) => {
62 setExpandedErrorId((current) => (current === environmentId ? null : environmentId));
63 }, []);
64
65 const showHeader = props.showHeader ?? true;
66
67 if (!isSignedIn) return null;
68
69 return (
70 <View collapsable={false} className={cn("gap-3", showHeader && "mt-5")}>
71 {showHeader ? (
72 <View className="flex-row items-center justify-between px-1">
73 <Text className="text-sm font-t3-bold uppercase text-foreground-muted">T3 Connect</Text>
74 <Pressable
75 accessibilityRole="button"
76 disabled={controller.relayDiscovery.isRefreshing}
77 onPress={() => {
78 void controller.refreshRelayEnvironments();
79 }}
80 className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50"
81 >
82 {controller.relayDiscovery.isRefreshing ? (
83 <ActivityIndicator color={iconColor} size="small" />
84 ) : (
85 <SymbolView
86 name="arrow.clockwise"
87 size={14}
88 tintColor={iconColor}
89 type="monochrome"
90 />
91 )}
92 </Pressable>
93 </View>
94 ) : null}
95
96 {hasCloudRows ? (
97 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
98 {props.connectedCloudEnvironments.map((environment, index) => (
99 <ConnectedCloudEnvironmentRow
100 key={environment.environmentId}
101 environment={environment}
102 borderTop={index !== 0}
103 onConnect={() => props.onReconnectEnvironment(environment.environmentId)}
104 onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)}
105 errorExpanded={expandedErrorId === environment.environmentId}
106 onToggleError={() => handleToggleCloudError(environment.environmentId)}
107 />
108 ))}
109 {availableCloudEnvironments.map((environment, index) => (
110 <CloudEnvironmentRow
111 key={environment.environment.environmentId}
112 environment={environment}
113 borderTop={props.connectedCloudEnvironments.length > 0 || index !== 0}
114 onConnect={() => handleConnectCloudEnvironment(environment)}
115 errorExpanded={expandedErrorId === environment.environment.environmentId}
116 onToggleError={() => handleToggleCloudError(environment.environment.environmentId)}
117 />
118 ))}
119 </View>
120 ) : controller.relayDiscovery.isRefreshing ? (
121 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6">
122 <ActivityIndicator color={iconColor} />
123 <Text className="text-center text-sm leading-normal text-foreground-muted">
124 Loading linked cloud environments.
125 </Text>
126 </View>
127 ) : controller.relayDiscovery.error ? null : (
128 <View collapsable={false} className="rounded-[24px] bg-card p-5">
129 <Text className="text-sm leading-normal text-foreground-muted">
130 No additional linked cloud environments.
131 </Text>
132 </View>
133 )}
134
135 {/* Rendered alongside any connected rows — a failed discovery must not
136 hide behind an otherwise-healthy list. */}
137 {controller.relayDiscovery.error && !controller.relayDiscovery.isRefreshing ? (
138 <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5">
139 <Text className="text-base font-t3-bold text-foreground">
140 Could not load T3 Connect environments
141 </Text>
142 <Text className="text-sm text-foreground-muted">{controller.relayDiscovery.error}</Text>
143 {controller.relayDiscovery.errorTraceId ? (
144 <CopyTraceIdButton traceId={controller.relayDiscovery.errorTraceId} />
145 ) : null}
146 <Pressable
147 accessibilityRole="button"
148 onPress={() => {
149 void controller.refreshRelayEnvironments();
150 }}
151 className="self-start rounded-full bg-subtle px-3.5 py-2 active:opacity-70"
152 >
153 <Text className="text-xs font-t3-bold text-foreground">Try again</Text>
154 </Pressable>
155 </View>
156 ) : null}
157 </View>
158 );
159}
160
161function ConnectedCloudEnvironmentRow(props: {
162 readonly environment: ConnectedEnvironmentSummary;
163 readonly borderTop: boolean;
164 readonly errorExpanded: boolean;
165 readonly onConnect: () => void;
166 readonly onDisconnect: () => void;
167 readonly onToggleError: () => void;
168}) {
169 return (
170 <CloudEnvironmentRowShell
171 borderTop={props.borderTop}
172 connectionError={props.environment.connectionError}
173 connectionErrorTraceId={props.environment.connectionErrorTraceId}
174 connectionState={props.environment.connectionState}
175 errorExpanded={props.errorExpanded}
176 label={props.environment.environmentLabel}
177 onValueChange={(enabled) => {
178 if (enabled) {
179 props.onConnect();
180 return;
181 }
182 props.onDisconnect();
183 }}
184 onToggleError={props.onToggleError}
185 value={props.environment.connectionState !== "available"}
186 />
187 );
188}
189
190function CloudEnvironmentRow(props: {
191 readonly environment: RelayEnvironmentView;
192 readonly borderTop: boolean;
193 readonly errorExpanded: boolean;
194 readonly onConnect: () => void;
195 readonly onToggleError: () => void;
196}) {
197 const presentation = availableCloudEnvironmentPresentation({
198 isStatusPending: props.environment.availability === "checking",
199 status: props.environment.status,
200 statusError: props.environment.error,
201 statusErrorTraceId: props.environment.traceId,
202 });
203
204 return (
205 <CloudEnvironmentRowShell
206 borderTop={props.borderTop}
207 connectionError={presentation.connectionError}
208 connectionErrorTraceId={presentation.connectionErrorTraceId}
209 connectionState={presentation.connectionState}
210 errorExpanded={props.errorExpanded}
211 label={props.environment.environment.label}
212 onValueChange={(enabled) => {
213 if (enabled) {
214 props.onConnect();
215 }
216 }}
217 onToggleError={props.onToggleError}
218 statusText={presentation.statusText}
219 value={false}
220 />
221 );
222}
223
224function CloudEnvironmentRowShell(props: {
225 readonly borderTop: boolean;
226 readonly connectionError: string | null;
227 readonly connectionErrorTraceId: string | null;
228 readonly connectionState: EnvironmentConnectionPhase;
229 readonly disabled?: boolean;
230 readonly errorExpanded: boolean;
231 readonly label: string;
232 readonly onToggleError: () => void;
233 readonly onValueChange: (enabled: boolean) => void;
234 readonly statusText?: string;
235 readonly value: boolean;
236}) {
237 const activeTrack = String(useThemeColor("--color-switch-active"));
238 const track = String(useThemeColor("--color-secondary-border"));
239 const chevron = useThemeColor("--color-chevron");
240 const isRetrying =
241 props.connectionState === "connecting" || props.connectionState === "reconnecting";
242 const shouldPulse = isRetrying;
243 const statusText =
244 props.statusText ??
245 connectionStatusText({
246 phase: props.connectionState,
247 error: props.connectionError,
248 traceId: props.connectionErrorTraceId,
249 });
250 const statusClassName = props.connectionError
251 ? "text-rose-500 dark:text-rose-400"
252 : "text-foreground-muted";
253 const [errorMeasurement, setErrorMeasurement] = useState<{
254 readonly text: string;
255 readonly lineCount: number;
256 } | null>(null);
257 const errorTraceId = props.connectionErrorTraceId;
258 const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText;
259 const errorLineCount =
260 errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0;
261 const errorCanExpand = props.connectionError !== null && errorLineCount > 1;
262 const isErrorExpanded = errorCanExpand && props.errorExpanded;
263 const StatusContainer = errorCanExpand ? Pressable : View;
264 const onMeasuredErrorTextLayout = useCallback(
265 (event: NativeSyntheticEvent<TextLayoutEventData>) => {
266 if (!props.connectionError) {
267 return;
268 }
269 const nextLineCount = event.nativeEvent.lines.length;
270 setErrorMeasurement((currentMeasurement) =>
271 currentMeasurement?.text === measuredErrorText &&
272 currentMeasurement.lineCount === nextLineCount
273 ? currentMeasurement
274 : { text: measuredErrorText, lineCount: nextLineCount },
275 );
276 },
277 [measuredErrorText, props.connectionError],
278 );
279 return (
280 <View
281 collapsable={false}
282 className={cn(
283 "flex-row items-center gap-3 bg-card px-4 py-3.5",
284 props.borderTop && "border-t border-border",
285 )}
286 >
287 <View className="min-w-0 flex-1 gap-0.5">
288 <View className="min-w-0 flex-row items-center gap-2">
289 <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} />
290 <Text
291 className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground"
292 numberOfLines={1}
293 >
294 {props.label}
295 </Text>
296 </View>
297 {props.connectionError ? (
298 <Text
299 aria-hidden
300 className={cn("absolute left-0 right-0 text-xs", statusClassName)}
301 onTextLayout={onMeasuredErrorTextLayout}
302 style={{ opacity: 0, zIndex: -1 }}
303 >
304 {measuredErrorText}
305 </Text>
306 ) : null}
307 <StatusContainer
308 {...(errorCanExpand
309 ? { accessibilityRole: "button" as const, onPress: props.onToggleError }
310 : {})}
311 className="min-w-0 flex-row items-start gap-1"
312 >
313 <Text
314 className={cn("min-w-0 flex-1 text-xs", statusClassName)}
315 numberOfLines={isErrorExpanded ? undefined : 1}
316 >
317 {statusText}
318 {errorTraceId ? (
319 <>
320 {" Trace ID: "}
321 <Text
322 accessibilityHint="Copies the trace ID"
323 accessibilityRole="button"
324 className={cn("text-xs underline", statusClassName)}
325 onLongPress={(event) => {
326 event.stopPropagation();
327 copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" });
328 }}
329 onPress={(event) => {
330 event.stopPropagation();
331 }}
332 style={{ textDecorationStyle: "dotted" }}
333 >
334 {errorTraceId}
335 </Text>
336 </>
337 ) : null}
338 </Text>
339 {errorCanExpand ? (
340 <SymbolView
341 name="chevron.down"
342 size={10}
343 tintColor={chevron}
344 type="monochrome"
345 style={{
346 marginTop: 3,
347 transform: [{ rotate: isErrorExpanded ? "180deg" : "0deg" }],
348 }}
349 />
350 ) : null}
351 </StatusContainer>
352 </View>
353 <Switch
354 disabled={props.disabled}
355 ios_backgroundColor={track}
356 onValueChange={props.onValueChange}
357 trackColor={{ false: track, true: activeTrack }}
358 value={props.value}
359 />
360 </View>
361 );
362}
363
364function CopyTraceIdButton(props: { readonly traceId: string }) {
365 const iconColor = useThemeColor("--color-icon");
366
367 return (
368 <Pressable
369 accessibilityRole="button"
370 onPress={() => {
371 copyTextWithHaptic(props.traceId, { target: "connection-trace-id" });
372 }}
373 className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70"
374 >
375 <SymbolView name="doc.on.doc" size={12} tintColor={iconColor} type="monochrome" />
376 <Text className="text-xs font-t3-bold text-foreground">Copy trace ID</Text>
377 </Pressable>
378 );
379}
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+1-1
...@@ -135,7 +135,7 @@ export function ConnectionEnvironmentRow(props: {...@@ -135,7 +135,7 @@ export function ConnectionEnvironmentRow(props: {
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 {...@@ -35,7 +35,7 @@ function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord {
35}35}
3636
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/SettingsEnvironmentsRouteScreen.tsx+3-343
...@@ -1,38 +1,18 @@...@@ -1,38 +1,18 @@
1import { useAuth } from "@clerk/expo";
2import { NativeHeaderToolbar } from "../../native/StackHeader";1import { NativeHeaderToolbar } from "../../native/StackHeader";
3import { useNavigation } from "@react-navigation/native";2import { useNavigation } from "@react-navigation/native";
4import { SymbolView } from "expo-symbols";3import { SymbolView } from "expo-symbols";
5import {
6 connectionStatusText,
7 type EnvironmentConnectionPhase,
8} from "@t3tools/client-runtime/connection";
9import type { EnvironmentId } from "@t3tools/contracts";4import type { EnvironmentId } from "@t3tools/contracts";
10import { useCallback, useState } from "react";5import { useCallback, useState } from "react";
11import {6import { ScrollView, View } from "react-native";
12 ActivityIndicator,
13 Pressable,
14 ScrollView,
15 Switch,
16 type NativeSyntheticEvent,
17 type TextLayoutEventData,
18 View,
19} from "react-native";
20import { useSafeAreaInsets } from "react-native-safe-area-context";7import { useSafeAreaInsets } from "react-native-safe-area-context";
218
22import { AppText as Text } from "../../components/AppText";9import { AppText as Text } from "../../components/AppText";
23import {
24 type RelayEnvironmentView,
25 useConnectionController,
26} from "../connection/useConnectionController";
27import { hasCloudPublicConfig } from "../cloud/publicConfig";10import { hasCloudPublicConfig } from "../cloud/publicConfig";
28import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";11import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
29import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";12import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
30import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
31import { splitEnvironmentSections } from "../connection/environmentSections";13import { splitEnvironmentSections } from "../connection/environmentSections";
32import { cn } from "../../lib/cn";14import { cn } from "../../lib/cn";
33import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
34import { useThemeColor } from "../../lib/useThemeColor";15import { useThemeColor } from "../../lib/useThemeColor";
35import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
36import { useRemoteConnections } from "../../state/use-remote-environment-registry";16import { useRemoteConnections } from "../../state/use-remote-environment-registry";
3717
38export function SettingsEnvironmentsRouteScreen() {18export function SettingsEnvironmentsRouteScreen() {
...@@ -117,7 +97,7 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -117,7 +97,7 @@ export function SettingsEnvironmentsRouteScreen() {
117 )}97 )}
11898
119 {hasCloudPublicConfig() ? (99 {hasCloudPublicConfig() ? (
120 <ConfiguredCloudEnvironmentRows100 <CloudEnvironmentRows
121 connectedCloudEnvironments={connectedCloudEnvironments}101 connectedCloudEnvironments={connectedCloudEnvironments}
122 onReconnectEnvironment={onReconnectEnvironment}102 onReconnectEnvironment={onReconnectEnvironment}
123 />103 />
...@@ -126,323 +106,3 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -126,323 +106,3 @@ export function SettingsEnvironmentsRouteScreen() {
126 </View>106 </View>
127 );107 );
128}108}
129
130function ConfiguredCloudEnvironmentRows(props: {
131 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
132 readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void;
133}) {
134 const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
135 const controller = useConnectionController();
136 const iconColor = useThemeColor("--color-icon");
137 const availableCloudEnvironments = controller.availableRelayEnvironments;
138 const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
139 const hasCloudRows =
140 props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0;
141
142 const handleConnectCloudEnvironment = useCallback(
143 (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment),
144 [controller],
145 );
146
147 const handleDisconnectCloudEnvironment = useCallback(
148 (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId),
149 [controller],
150 );
151
152 const handleToggleCloudError = useCallback((environmentId: string) => {
153 setExpandedErrorId((current) => (current === environmentId ? null : environmentId));
154 }, []);
155
156 if (!isSignedIn) return null;
157
158 return (
159 <View collapsable={false} className="mt-5 gap-3">
160 <View className="flex-row items-center justify-between px-1">
161 <Text className="text-sm font-t3-bold uppercase text-foreground-muted">T3 Cloud</Text>
162 <Pressable
163 accessibilityRole="button"
164 disabled={controller.relayDiscovery.isRefreshing}
165 onPress={() => {
166 void controller.refreshRelayEnvironments();
167 }}
168 className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50"
169 >
170 {controller.relayDiscovery.isRefreshing ? (
171 <ActivityIndicator color={iconColor} size="small" />
172 ) : (
173 <SymbolView name="arrow.clockwise" size={14} tintColor={iconColor} type="monochrome" />
174 )}
175 </Pressable>
176 </View>
177
178 {hasCloudRows ? (
179 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
180 {props.connectedCloudEnvironments.map((environment, index) => (
181 <ConnectedCloudEnvironmentRow
182 key={environment.environmentId}
183 environment={environment}
184 borderTop={index !== 0}
185 onConnect={() => props.onReconnectEnvironment(environment.environmentId)}
186 onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)}
187 errorExpanded={expandedErrorId === environment.environmentId}
188 onToggleError={() => handleToggleCloudError(environment.environmentId)}
189 />
190 ))}
191 {availableCloudEnvironments.map((environment, index) => (
192 <CloudEnvironmentRow
193 key={environment.environment.environmentId}
194 environment={environment}
195 borderTop={props.connectedCloudEnvironments.length > 0 || index !== 0}
196 onConnect={() => handleConnectCloudEnvironment(environment)}
197 errorExpanded={expandedErrorId === environment.environment.environmentId}
198 onToggleError={() => handleToggleCloudError(environment.environment.environmentId)}
199 />
200 ))}
201 </View>
202 ) : controller.relayDiscovery.isRefreshing ? (
203 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6">
204 <ActivityIndicator color={iconColor} />
205 <Text className="text-center text-sm leading-normal text-foreground-muted">
206 Loading linked cloud environments.
207 </Text>
208 </View>
209 ) : controller.relayDiscovery.error ? (
210 <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5">
211 <Text className="text-base font-t3-bold text-foreground">
212 Could not load T3 Cloud environments
213 </Text>
214 <Text className="text-sm text-foreground-muted">{controller.relayDiscovery.error}</Text>
215 {controller.relayDiscovery.errorTraceId ? (
216 <CopyTraceIdButton traceId={controller.relayDiscovery.errorTraceId} />
217 ) : null}
218 </View>
219 ) : (
220 <View collapsable={false} className="rounded-[24px] bg-card p-5">
221 <Text className="text-sm leading-normal text-foreground-muted">
222 No additional linked cloud environments.
223 </Text>
224 </View>
225 )}
226 </View>
227 );
228}
229
230function ConnectedCloudEnvironmentRow(props: {
231 readonly environment: ConnectedEnvironmentSummary;
232 readonly borderTop: boolean;
233 readonly errorExpanded: boolean;
234 readonly onConnect: () => void;
235 readonly onDisconnect: () => void;
236 readonly onToggleError: () => void;
237}) {
238 return (
239 <CloudEnvironmentRowShell
240 borderTop={props.borderTop}
241 connectionError={props.environment.connectionError}
242 connectionErrorTraceId={props.environment.connectionErrorTraceId}
243 connectionState={props.environment.connectionState}
244 errorExpanded={props.errorExpanded}
245 label={props.environment.environmentLabel}
246 onValueChange={(enabled) => {
247 if (enabled) {
248 props.onConnect();
249 return;
250 }
251 props.onDisconnect();
252 }}
253 onToggleError={props.onToggleError}
254 value={props.environment.connectionState !== "available"}
255 />
256 );
257}
258
259function CloudEnvironmentRow(props: {
260 readonly environment: RelayEnvironmentView;
261 readonly borderTop: boolean;
262 readonly errorExpanded: boolean;
263 readonly onConnect: () => void;
264 readonly onToggleError: () => void;
265}) {
266 const presentation = availableCloudEnvironmentPresentation({
267 isStatusPending: props.environment.availability === "checking",
268 status: props.environment.status,
269 statusError: props.environment.error,
270 statusErrorTraceId: props.environment.traceId,
271 });
272
273 return (
274 <CloudEnvironmentRowShell
275 borderTop={props.borderTop}
276 connectionError={presentation.connectionError}
277 connectionErrorTraceId={presentation.connectionErrorTraceId}
278 connectionState={presentation.connectionState}
279 errorExpanded={props.errorExpanded}
280 label={props.environment.environment.label}
281 onValueChange={(enabled) => {
282 if (enabled) {
283 props.onConnect();
284 }
285 }}
286 onToggleError={props.onToggleError}
287 statusText={presentation.statusText}
288 value={false}
289 />
290 );
291}
292
293function CloudEnvironmentRowShell(props: {
294 readonly borderTop: boolean;
295 readonly connectionError: string | null;
296 readonly connectionErrorTraceId: string | null;
297 readonly connectionState: EnvironmentConnectionPhase;
298 readonly disabled?: boolean;
299 readonly errorExpanded: boolean;
300 readonly label: string;
301 readonly onToggleError: () => void;
302 readonly onValueChange: (enabled: boolean) => void;
303 readonly statusText?: string;
304 readonly value: boolean;
305}) {
306 const activeTrack = String(useThemeColor("--color-switch-active"));
307 const track = String(useThemeColor("--color-secondary-border"));
308 const chevron = useThemeColor("--color-chevron");
309 const isRetrying =
310 props.connectionState === "connecting" || props.connectionState === "reconnecting";
311 const shouldPulse = isRetrying;
312 const statusText =
313 props.statusText ??
314 connectionStatusText({
315 phase: props.connectionState,
316 error: props.connectionError,
317 traceId: props.connectionErrorTraceId,
318 });
319 const statusClassName = props.connectionError
320 ? "text-rose-500 dark:text-rose-400"
321 : "text-foreground-muted";
322 const [errorMeasurement, setErrorMeasurement] = useState<{
323 readonly text: string;
324 readonly lineCount: number;
325 } | null>(null);
326 const errorTraceId = props.connectionErrorTraceId;
327 const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText;
328 const errorLineCount =
329 errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0;
330 const errorCanExpand = props.connectionError !== null && errorLineCount > 1;
331 const isErrorExpanded = errorCanExpand && props.errorExpanded;
332 const StatusContainer = errorCanExpand ? Pressable : View;
333 const onMeasuredErrorTextLayout = useCallback(
334 (event: NativeSyntheticEvent<TextLayoutEventData>) => {
335 if (!props.connectionError) {
336 return;
337 }
338 const nextLineCount = event.nativeEvent.lines.length;
339 setErrorMeasurement((currentMeasurement) =>
340 currentMeasurement?.text === measuredErrorText &&
341 currentMeasurement.lineCount === nextLineCount
342 ? currentMeasurement
343 : { text: measuredErrorText, lineCount: nextLineCount },
344 );
345 },
346 [measuredErrorText, props.connectionError],
347 );
348 return (
349 <View
350 collapsable={false}
351 className={cn(
352 "flex-row items-center gap-3 bg-card px-4 py-3.5",
353 props.borderTop && "border-t border-border",
354 )}
355 >
356 <View className="min-w-0 flex-1 gap-0.5">
357 <View className="min-w-0 flex-row items-center gap-2">
358 <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} />
359 <Text
360 className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground"
361 numberOfLines={1}
362 >
363 {props.label}
364 </Text>
365 </View>
366 {props.connectionError ? (
367 <Text
368 aria-hidden
369 className={cn("absolute left-0 right-0 text-xs", statusClassName)}
370 onTextLayout={onMeasuredErrorTextLayout}
371 style={{ opacity: 0, zIndex: -1 }}
372 >
373 {measuredErrorText}
374 </Text>
375 ) : null}
376 <StatusContainer
377 {...(errorCanExpand
378 ? { accessibilityRole: "button" as const, onPress: props.onToggleError }
379 : {})}
380 className="min-w-0 flex-row items-start gap-1"
381 >
382 <Text
383 className={cn("min-w-0 flex-1 text-xs", statusClassName)}
384 numberOfLines={isErrorExpanded ? undefined : 1}
385 >
386 {statusText}
387 {errorTraceId ? (
388 <>
389 {" Trace ID: "}
390 <Text
391 accessibilityHint="Copies the trace ID"
392 accessibilityRole="button"
393 className={cn("text-xs underline", statusClassName)}
394 onLongPress={(event) => {
395 event.stopPropagation();
396 copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" });
397 }}
398 onPress={(event) => {
399 event.stopPropagation();
400 }}
401 style={{ textDecorationStyle: "dotted" }}
402 >
403 {errorTraceId}
404 </Text>
405 </>
406 ) : null}
407 </Text>
408 {errorCanExpand ? (
409 <SymbolView
410 name="chevron.down"
411 size={10}
412 tintColor={chevron}
413 type="monochrome"
414 style={{
415 marginTop: 3,
416 transform: [{ rotate: isErrorExpanded ? "180deg" : "0deg" }],
417 }}
418 />
419 ) : null}
420 </StatusContainer>
421 </View>
422 <Switch
423 disabled={props.disabled}
424 ios_backgroundColor={track}
425 onValueChange={props.onValueChange}
426 trackColor={{ false: track, true: activeTrack }}
427 value={props.value}
428 />
429 </View>
430 );
431}
432
433function CopyTraceIdButton(props: { readonly traceId: string }) {
434 const iconColor = useThemeColor("--color-icon");
435
436 return (
437 <Pressable
438 accessibilityRole="button"
439 onPress={() => {
440 copyTextWithHaptic(props.traceId, { target: "connection-trace-id" });
441 }}
442 className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70"
443 >
444 <SymbolView name="doc.on.doc" size={12} tintColor={iconColor} type="monochrome" />
445 <Text className="text-xs font-t3-bold text-foreground">Copy trace ID</Text>
446 </Pressable>
447 );
448}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+2-2
...@@ -240,8 +240,8 @@ function ConfiguredSettingsRouteScreen() {...@@ -240,8 +240,8 @@ function ConfiguredSettingsRouteScreen() {
240240
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+29-7
...@@ -69,6 +69,8 @@ export interface Preferences {...@@ -69,6 +69,8 @@ export interface Preferences {
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 opted out of the T3 Connect onboarding sheet. */
73 readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
72}74}
7375
74async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> {76async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> {
...@@ -167,6 +169,7 @@ export async function loadPreferences(): Promise<Preferences> {...@@ -167,6 +169,7 @@ export async function loadPreferences(): Promise<Preferences> {
167 markdownFontSize?: number;169 markdownFontSize?: number;
168 codeFontSize?: number | null;170 codeFontSize?: number | null;
169 codeWordBreak?: boolean;171 codeWordBreak?: boolean;
172 connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
170 } = {};173 } = {};
171174
172 if (typeof parsed.liveActivitiesEnabled === "boolean") {175 if (typeof parsed.liveActivitiesEnabled === "boolean") {
...@@ -187,18 +190,37 @@ export async function loadPreferences(): Promise<Preferences> {...@@ -187,18 +190,37 @@ export async function loadPreferences(): Promise<Preferences> {
187 if (typeof parsed.codeWordBreak === "boolean") {190 if (typeof parsed.codeWordBreak === "boolean") {
188 preferences.codeWordBreak = parsed.codeWordBreak;191 preferences.codeWordBreak = parsed.codeWordBreak;
189 }192 }
193 if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) {
194 preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter(
195 (account): account is string => typeof account === "string",
196 );
197 }
190198
191 return preferences;199 return preferences;
192}200}
193201
202// Preference writes are read-modify-write over one JSON blob; concurrent
203// writers would drop each other's fields, so all writes are serialized here.
204let preferencesWriteQueue: Promise<unknown> = Promise.resolve();
205
206export async function updatePreferences(
207 update: (current: Preferences) => Partial<Preferences>,
208): Promise<Preferences> {
209 const task = preferencesWriteQueue.then(async () => {
210 const current = await loadPreferences();
211 const next: Preferences = {
212 ...current,
213 ...update(current),
214 };
215 await writeJsonStorageItem(PREFERENCES_KEY, next);
216 return next;
217 });
218 preferencesWriteQueue = task.catch(() => undefined);
219 return task;
220}
221
194export async function savePreferencesPatch(patch: Partial<Preferences>): Promise<Preferences> {222export async function savePreferencesPatch(patch: Partial<Preferences>): Promise<Preferences> {
195 const current = await loadPreferences();223 return updatePreferences(() => patch);
196 const next: Preferences = {
197 ...current,
198 ...patch,
199 };
200 await writeJsonStorageItem(PREFERENCES_KEY, next);
201 return next;
202}224}
203225
204export async function loadOrCreateAgentAwarenessDeviceId(): Promise<string> {226export async function loadOrCreateAgentAwarenessDeviceId(): Promise<string> {
apps/web/src/cloud/connectOnboarding.ts created+18
...@@ -0,0 +1,18 @@
1import * as Schema from "effect/Schema";
2
3/**
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.
7 */
8export const CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY = "t3code:connect-onboarding-opt-out:v1";
9
10export const ConnectOnboardingOptOutSchema = Schema.Struct({
11 optOutAccounts: Schema.Array(Schema.String),
12});
13
14export type ConnectOnboardingOptOutState = typeof ConnectOnboardingOptOutSchema.Type;
15
16export const EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE: ConnectOnboardingOptOutState = {
17 optOutAccounts: [],
18};
apps/web/src/cloud/useCloudLinkController.ts created+163
...@@ -0,0 +1,163 @@
1import { useAuth } from "@clerk/react";
2import { findErrorTraceId } from "@t3tools/client-runtime/errors";
3import {
4 isAtomCommandInterrupted,
5 settlePromise,
6 squashAtomCommandFailure,
7} from "@t3tools/client-runtime/state/runtime";
8import { useState } from "react";
9
10import { toastManager } from "../components/ui/toast";
11import { relayEnvironmentDiscovery } from "../state/relay";
12import { useAtomCommand } from "../state/use-atom-command";
13import {
14 linkPrimaryEnvironment as linkPrimaryEnvironmentAtom,
15 unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom,
16 updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom,
17} from "./linkEnvironmentAtoms";
18import { usePrimaryCloudLinkState } from "./primaryCloudLinkState";
19import { resolveRelayClerkTokenOptions } from "./publicConfig";
20
21export interface CloudLinkDesiredState {
22 readonly managedTunnel: boolean;
23 readonly publish: boolean;
24}
25
26/**
27 * Drives the primary environment's T3 Connect link. T3 Connect (managed
28 * tunnel) and agent-activity publishing are independent capabilities backed by
29 * a single relay link, so consumers express the full desired state and
30 * `reconcileCloudState` applies it: unlink when neither is wanted, otherwise
31 * (re)link with the mode the managed-tunnel bit implies and set the publish
32 * preference. Re-linking only happens when the managed-tunnel mode actually
33 * changes, so flipping publish alone is cheap.
34 */
35export function useCloudLinkController() {
36 const { getToken, isSignedIn } = useAuth();
37 const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, {
38 reportFailure: false,
39 });
40 const linkPrimaryEnvironment = useAtomCommand(linkPrimaryEnvironmentAtom, {
41 reportFailure: false,
42 });
43 const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, {
44 reportFailure: false,
45 });
46 const updatePrimaryEnvironmentPreferences = useAtomCommand(
47 updatePrimaryEnvironmentPreferencesAtom,
48 { reportFailure: false },
49 );
50 const primaryCloudLinkState = usePrimaryCloudLinkState();
51 const [operationError, setOperationError] = useState<string | null>(null);
52
53 const reportUpdateFailure = (cause: unknown) => {
54 const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access.";
55 const traceId = findErrorTraceId(cause);
56 console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause });
57 setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message);
58 toastManager.add({
59 type: "error",
60 title: "Could not update T3 Connect",
61 description: message,
62 data: traceId
63 ? {
64 secondaryActionProps: {
65 children: "Copy trace ID",
66 onClick: () => void navigator.clipboard?.writeText(traceId),
67 },
68 }
69 : undefined,
70 });
71 };
72
73 // Older environment servers predate the managedTunnelActive field; for them a
74 // link always implies a managed tunnel, so fall back to `linked`.
75 const managedTunnelActive =
76 primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false;
77 const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false;
78 const linked = primaryCloudLinkState.data?.linked ?? false;
79
80 const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise<boolean> => {
81 setOperationError(null);
82 const target = primaryCloudLinkState.target;
83 if (!target) {
84 reportUpdateFailure(new Error("Local environment is not ready yet."));
85 return false;
86 }
87 const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));
88 const wantsLink = desired.managedTunnel || desired.publish;
89
90 // A failure after this point may follow a partially applied mutation (e.g.
91 // the link succeeded but the preference update did not), so every exit —
92 // success or failure — refreshes the rendered state to whatever the server
93 // actually holds now.
94 if (!wantsLink) {
95 // Unlink works without a relay token — a failed token read must not
96 // leave the user unable to turn T3 Connect off.
97 const unlinkResult = await unlinkPrimaryEnvironment({
98 target,
99 clerkToken: tokenResult._tag === "Success" ? (tokenResult.value ?? null) : null,
100 });
101 if (unlinkResult._tag === "Failure") {
102 if (!isAtomCommandInterrupted(unlinkResult)) {
103 reportUpdateFailure(squashAtomCommandFailure(unlinkResult));
104 }
105 primaryCloudLinkState.refresh();
106 return false;
107 }
108 } else {
109 if (tokenResult._tag === "Failure") {
110 reportUpdateFailure(squashAtomCommandFailure(tokenResult));
111 return false;
112 }
113 const clerkToken = tokenResult.value;
114 if (!clerkToken) {
115 reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this."));
116 return false;
117 }
118 if (!linked || managedTunnelActive !== desired.managedTunnel) {
119 const linkResult = await linkPrimaryEnvironment({
120 target,
121 clerkToken,
122 mode: desired.managedTunnel ? "managed" : "publish_only",
123 });
124 if (linkResult._tag === "Failure") {
125 if (!isAtomCommandInterrupted(linkResult)) {
126 reportUpdateFailure(squashAtomCommandFailure(linkResult));
127 }
128 primaryCloudLinkState.refresh();
129 return false;
130 }
131 }
132 const prefResult = await updatePrimaryEnvironmentPreferences({
133 target,
134 publishAgentActivity: desired.publish,
135 });
136 if (prefResult._tag === "Failure") {
137 if (!isAtomCommandInterrupted(prefResult)) {
138 reportUpdateFailure(squashAtomCommandFailure(prefResult));
139 }
140 primaryCloudLinkState.refresh();
141 return false;
142 }
143 }
144
145 primaryCloudLinkState.refresh();
146 const refreshResult = await refreshRelayEnvironments();
147 if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
148 reportUpdateFailure(squashAtomCommandFailure(refreshResult));
149 return false;
150 }
151 return true;
152 };
153
154 return {
155 isSignedIn,
156 linkState: primaryCloudLinkState,
157 linked,
158 managedTunnelActive,
159 publishAgentActivity,
160 operationError,
161 reconcileCloudState,
162 };
163}
apps/web/src/components/ConnectionStatusDot.tsx created+56
...@@ -0,0 +1,56 @@
1import { cn } from "~/lib/utils";
2import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
3
4type ConnectionStatusDotProps = {
5 tooltipText?: string | null;
6 dotClassName: string;
7 pingClassName?: string | null;
8};
9
10export function ConnectionStatusDot({
11 tooltipText,
12 dotClassName,
13 pingClassName,
14}: ConnectionStatusDotProps) {
15 const dotContent = (
16 <>
17 {pingClassName ? (
18 <span
19 className={cn(
20 "absolute inline-flex h-full w-full animate-ping rounded-full",
21 pingClassName,
22 )}
23 />
24 ) : null}
25 <span className={cn("relative inline-flex size-2 rounded-full", dotClassName)} />
26 </>
27 );
28
29 if (!tooltipText) {
30 return (
31 <span className="relative flex size-3 shrink-0 items-center justify-center">
32 {dotContent}
33 </span>
34 );
35 }
36
37 const dot = (
38 <button
39 type="button"
40 title={tooltipText}
41 aria-label={tooltipText}
42 className="relative flex size-3 shrink-0 cursor-help items-center justify-center rounded-full outline-hidden"
43 >
44 {dotContent}
45 </button>
46 );
47
48 return (
49 <Tooltip>
50 <TooltipTrigger render={dot} />
51 <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">
52 {tooltipText}
53 </TooltipPopup>
54 </Tooltip>
55 );
56}
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx created+227
...@@ -0,0 +1,227 @@
1import { findErrorTraceId } from "@t3tools/client-runtime/errors";
2import {
3 RelayConnectionRegistration,
4 RelayConnectionTarget,
5} from "@t3tools/client-runtime/connection";
6import {
7 isAtomCommandInterrupted,
8 squashAtomCommandFailure,
9} from "@t3tools/client-runtime/state/runtime";
10import type { EnvironmentId } from "@t3tools/contracts";
11import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
12import * as Option from "effect/Option";
13import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
14
15import { environmentCatalog } from "~/connection/catalog";
16import { cn } from "~/lib/utils";
17import { relayEnvironmentDiscovery } from "~/state/relay";
18import { useRelayEnvironmentDiscovery } from "~/state/environments";
19import { useAtomCommand } from "~/state/use-atom-command";
20import { ConnectionStatusDot } from "../ConnectionStatusDot";
21import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows";
22import { Button } from "../ui/button";
23import { Skeleton } from "../ui/skeleton";
24import { toastManager } from "../ui/toast";
25
26export function RemoteEnvironmentRowsSkeleton() {
27 return (
28 <div className={ITEM_ROW_CLASSNAME}>
29 <div className={ITEM_ROW_INNER_CLASSNAME}>
30 <div className="min-w-0 flex-1 space-y-2">
31 <Skeleton className="h-4 w-32 rounded-full" />
32 <Skeleton className="h-3 w-20 rounded-full" />
33 </div>
34 <Skeleton className="h-7 w-16 rounded-md" />
35 </div>
36 </div>
37 );
38}
39
40/**
41 * The user's T3 Connect environments from relay discovery, each with a
42 * Connect button. The primary environment is always excluded; already-saved
43 * environments are hidden unless `showSavedAsConnected` renders them as
44 * connected instead (used by onboarding, where the full device mesh should be
45 * visible).
46 */
47export function CloudEnvironmentConnectRows({
48 primaryEnvironmentId,
49 savedEnvironmentIds,
50 showSavedAsConnected = false,
51 empty = null,
52}: {
53 readonly primaryEnvironmentId: EnvironmentId | null;
54 readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>;
55 readonly showSavedAsConnected?: boolean;
56 readonly empty?: ReactNode;
57}) {
58 const environmentsState = useRelayEnvironmentDiscovery();
59 const registerEnvironment = useAtomCommand(environmentCatalog.register, {
60 reportFailure: false,
61 });
62 const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, {
63 reportFailure: false,
64 });
65 const connectRelayEnvironment = useCallback(
66 (environment: RelayClientEnvironmentRecord) =>
67 registerEnvironment(
68 new RelayConnectionRegistration({
69 target: new RelayConnectionTarget({
70 environmentId: environment.environmentId,
71 label: environment.label,
72 }),
73 }),
74 ),
75 [registerEnvironment],
76 );
77 const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<EnvironmentId | null>(
78 null,
79 );
80 const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]);
81
82 useEffect(() => {
83 void refreshRelayEnvironments();
84 }, [refreshRelayEnvironments]);
85
86 const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => {
87 setConnectingEnvironmentId(environment.environmentId);
88 const result = await connectRelayEnvironment(environment);
89 setConnectingEnvironmentId(null);
90 if (result._tag === "Success") {
91 toastManager.add({
92 type: "success",
93 title: "Environment connected",
94 description: `${environment.label} is available through T3 Connect.`,
95 });
96 return;
97 }
98 if (isAtomCommandInterrupted(result)) {
99 return;
100 }
101 const cause = squashAtomCommandFailure(result);
102 const message =
103 cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment.";
104 const traceId = findErrorTraceId(cause);
105 console.error("[t3-connect] Could not connect environment", { message, traceId, cause });
106 toastManager.add({
107 type: "error",
108 title: "Could not connect environment",
109 description: message,
110 data: traceId
111 ? {
112 secondaryActionProps: {
113 children: "Copy trace ID",
114 onClick: () => void navigator.clipboard?.writeText(traceId),
115 },
116 }
117 : undefined,
118 });
119 };
120
121 const visibleEnvironments = [...environmentsState.environments.values()].filter(
122 ({ environment }) =>
123 environment.environmentId !== primaryEnvironmentId &&
124 (showSavedAsConnected || !savedIds.has(environment.environmentId)),
125 );
126
127 const standalone = showSavedAsConnected || savedEnvironmentIds.length === 0;
128
129 if (
130 standalone &&
131 visibleEnvironments.length === 0 &&
132 environmentsState.refreshing &&
133 environmentsState.environments.size === 0
134 ) {
135 return <RemoteEnvironmentRowsSkeleton />;
136 }
137
138 if (standalone && visibleEnvironments.length === 0) {
139 // A failed or offline discovery is not "no environments" — misreporting it
140 // as empty would read as the user's devices having disappeared.
141 const discoveryProblem = environmentsState.offline
142 ? "You appear to be offline."
143 : (Option.getOrNull(environmentsState.error)?.message ?? null);
144 if (discoveryProblem !== null && !environmentsState.refreshing) {
145 return (
146 <div className={ITEM_ROW_CLASSNAME}>
147 <p className="text-sm font-medium text-destructive">
148 Could not load T3 Connect environments
149 </p>
150 <p className="mt-1 text-xs text-muted-foreground">{discoveryProblem}</p>
151 <Button
152 size="sm"
153 variant="outline"
154 className="mt-3"
155 onClick={() => void refreshRelayEnvironments()}
156 >
157 Try again
158 </Button>
159 </div>
160 );
161 }
162 return empty;
163 }
164
165 return visibleEnvironments.map(({ environment, availability, error }) => {
166 const alreadyConnected = savedIds.has(environment.environmentId);
167 return (
168 <div key={environment.environmentId} className={ITEM_ROW_CLASSNAME}>
169 <div className={ITEM_ROW_INNER_CLASSNAME}>
170 <div className="min-w-0">
171 <div className="flex items-center gap-2">
172 <ConnectionStatusDot
173 dotClassName={
174 availability === "online"
175 ? "bg-success"
176 : availability === "error"
177 ? "bg-destructive"
178 : availability === "checking"
179 ? "bg-warning"
180 : "bg-muted-foreground/35"
181 }
182 pingClassName={availability === "checking" ? "bg-warning/60 duration-2000" : null}
183 tooltipText={
184 availability === "online"
185 ? "Relay online"
186 : availability === "offline"
187 ? "Relay offline"
188 : availability === "checking"
189 ? "Checking relay status"
190 : (Option.getOrNull(error)?.message ?? "Relay status unavailable")
191 }
192 />
193 <p className="truncate text-sm font-medium">{environment.label}</p>
194 </div>
195 <p
196 className={cn(
197 "mt-1 truncate text-xs",
198 availability === "error" ? "text-destructive" : "text-muted-foreground",
199 )}
200 >
201 {availability === "online"
202 ? "Available · Relay online"
203 : availability === "offline"
204 ? "Available · Relay offline"
205 : availability === "checking"
206 ? "Available · Checking relay status…"
207 : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")}
208 </p>
209 </div>
210 {alreadyConnected ? (
211 <Button size="sm" variant="outline" disabled>
212 Connected
213 </Button>
214 ) : (
215 <Button
216 size="sm"
217 disabled={connectingEnvironmentId !== null}
218 onClick={() => void connectEnvironment(environment)}
219 >
220 {connectingEnvironmentId === environment.environmentId ? "Connecting…" : "Connect"}
221 </Button>
222 )}
223 </div>
224 </div>
225 );
226 });
227}
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx created+437
...@@ -0,0 +1,437 @@
1import { useAuth } from "@clerk/react";
2import { AuthAdministrativeScopes, AuthRelayWriteScope } from "@t3tools/contracts";
3import { CheckIcon } from "lucide-react";
4import { useEffect, useMemo, useRef, useState } from "react";
5
6import {
7 CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY,
8 ConnectOnboardingOptOutSchema,
9 EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE,
10} from "~/cloud/connectOnboarding";
11import { hasCloudPublicConfig } from "~/cloud/publicConfig";
12import { useCloudLinkController } from "~/cloud/useCloudLinkController";
13import { usePrimarySessionState } from "~/environments/primary";
14import { useLocalStorage } from "~/hooks/useLocalStorage";
15import { cn } from "~/lib/utils";
16import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
17import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";
18import { Button } from "../ui/button";
19import { Checkbox } from "../ui/checkbox";
20import {
21 Dialog,
22 DialogDescription,
23 DialogFooter,
24 DialogHeader,
25 DialogPanel,
26 DialogPopup,
27 DialogTitle,
28} from "../ui/dialog";
29import { Switch } from "../ui/switch";
30import { toastManager } from "../ui/toast";
31
32/**
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
36 * environment (managed tunnel + agent activity, both defaulting on) when the
37 * current session is authorized to manage the relay link, then lists the
38 * account's T3 Connect environments so every device can be connected right
39 * away. A cold load with a restored session does not count as a sign-in.
40 */
41export function ConnectOnboardingDialog() {
42 if (!hasCloudPublicConfig()) return null;
43
44 return <ConfiguredConnectOnboardingDialog />;
45}
46
47type OnboardingStep = "publish" | "devices";
48
49function ConfiguredConnectOnboardingDialog() {
50 // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read as
51 // signed-out, or its later activation would look like a fresh sign-in.
52 const { isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
53 const [optOutState, setOptOutState] = useLocalStorage(
54 CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY,
55 EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE,
56 ConnectOnboardingOptOutSchema,
57 );
58
59 const desktopBridge = window.desktopBridge;
60 const primarySessionState = usePrimarySessionState();
61 const currentSessionScopes = desktopBridge
62 ? AuthAdministrativeScopes
63 : primarySessionState.data?.authenticated
64 ? (primarySessionState.data.scopes ?? null)
65 : null;
66 const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false;
67 // The publish step is only offered when we know the answer; opening the
68 // wizard before the session state resolves would let the step set change
69 // mid-flight. A failed session read still opens the wizard — it just means
70 // no publish step.
71 const sessionScopesKnown =
72 Boolean(desktopBridge) ||
73 primarySessionState.data !== null ||
74 primarySessionState.error !== null;
75
76 const controller = useCloudLinkController();
77 const showPublishStep = canManageRelay && controller.linkState.target !== null;
78 const steps: ReadonlyArray<OnboardingStep> = showPublishStep
79 ? ["publish", "devices"]
80 : ["devices"];
81
82 const [requestedAccount, setRequestedAccount] = useState<string | null>(null);
83 const [openForAccount, setOpenForAccount] = useState<string | null>(null);
84 const [step, setStep] = useState<OnboardingStep>("devices");
85 const [exposeEnvironment, setExposeEnvironment] = useState(true);
86 const [publishAgentActivity, setPublishAgentActivity] = useState(true);
87 const [dontShowAgain, setDontShowAgain] = useState(false);
88 const [isApplying, setIsApplying] = useState(false);
89 const prefilledFromLinkStateRef = useRef(false);
90 const observedAccountRef = useRef<string | null | undefined>(undefined);
91
92 const optOutAccounts = optOutState.optOutAccounts;
93
94 // Every sign-in or account switch that completes during this session
95 // requests the wizard — account transitions clear the connected relay
96 // environments, so each new session starts with no devices to reach. A cold
97 // load observes undefined → account and must not re-prompt.
98 useEffect(() => {
99 if (!isLoaded) return;
100 // A loaded-but-incomplete snapshot (signed in, user id not yet populated)
101 // must not be recorded as signed-out — the next render would then look
102 // like a fresh sign-in on a cold load.
103 if (isSignedIn && !userId) return;
104 const previousAccount = observedAccountRef.current;
105 const nextAccount = isSignedIn && userId ? userId : null;
106 observedAccountRef.current = nextAccount;
107 if (previousAccount !== undefined && previousAccount !== nextAccount && nextAccount !== null) {
108 setRequestedAccount(nextAccount);
109 }
110 }, [isLoaded, isSignedIn, userId]);
111
112 // A manageable session implies a primary environment, so when the scopes
113 // allow publishing, wait for the connection target too — otherwise the
114 // wizard could open on the devices step moments before the publish step
115 // becomes available and freeze there.
116 const publishStepDecided = !canManageRelay || controller.linkState.target !== null;
117
118 // Open once the session scopes resolve so the step set is stable. Accounts
119 // that chose "Don't show this again" are skipped.
120 useEffect(() => {
121 if (requestedAccount === null || openForAccount !== null) return;
122 if (optOutAccounts.includes(requestedAccount)) {
123 setRequestedAccount(null);
124 return;
125 }
126 if (!sessionScopesKnown || !publishStepDecided) return;
127 setRequestedAccount(null);
128 prefilledFromLinkStateRef.current = false;
129 setExposeEnvironment(true);
130 setPublishAgentActivity(true);
131 setDontShowAgain(false);
132 setStep(canManageRelay && controller.linkState.target !== null ? "publish" : "devices");
133 setOpenForAccount(requestedAccount);
134 }, [
135 canManageRelay,
136 controller.linkState.target,
137 openForAccount,
138 optOutAccounts,
139 publishStepDecided,
140 requestedAccount,
141 sessionScopesKnown,
142 ]);
143
144 // Signing out (or switching accounts) mid-wizard invalidates everything the
145 // wizard would do — close it and let the sign-in trigger re-evaluate.
146 useEffect(() => {
147 if (openForAccount !== null && (!isSignedIn || userId !== openForAccount)) {
148 setOpenForAccount(null);
149 }
150 if (requestedAccount !== null && (!isSignedIn || userId !== requestedAccount)) {
151 setRequestedAccount(null);
152 }
153 }, [isSignedIn, openForAccount, requestedAccount, userId]);
154
155 // Toggles default on, but an environment that is already linked should show
156 // its actual configuration instead of silently proposing to rewrite it.
157 // Only when the link belongs to the account being onboarded, though — after
158 // an account switch the cached link state can still describe the previous
159 // account's setup.
160 const linkStateData = controller.linkState.data;
161 useEffect(() => {
162 if (openForAccount === null || prefilledFromLinkStateRef.current || linkStateData === null) {
163 return;
164 }
165 prefilledFromLinkStateRef.current = true;
166 if (linkStateData.linked && linkStateData.cloudUserId === openForAccount) {
167 setExposeEnvironment(linkStateData.managedTunnelActive ?? linkStateData.linked);
168 setPublishAgentActivity(linkStateData.publishAgentActivity);
169 }
170 }, [linkStateData, openForAccount]);
171
172 const complete = () => {
173 // Keep the wizard up while a link request is in flight so its outcome
174 // (and any failure) stays visible.
175 if (isApplying) return;
176 const account = openForAccount;
177 setOpenForAccount(null);
178 if (account !== null && dontShowAgain) {
179 setOptOutState((state) =>
180 state.optOutAccounts.includes(account)
181 ? state
182 : { optOutAccounts: [...state.optOutAccounts, account] },
183 );
184 }
185 };
186
187 const applyPublishSelection = async () => {
188 // The wizard only ever enables — with both toggles off there is nothing to
189 // apply, and an existing link must not be torn down from onboarding.
190 if (!exposeEnvironment && !publishAgentActivity) {
191 setStep("devices");
192 return;
193 }
194 setIsApplying(true);
195 const ok = await controller.reconcileCloudState({
196 managedTunnel: exposeEnvironment,
197 publish: publishAgentActivity,
198 });
199 setIsApplying(false);
200 if (!ok) return;
201 toastManager.add({
202 type: "success",
203 title: "T3 Connect enabled",
204 description: exposeEnvironment
205 ? "This environment is available to your other devices through T3 Connect."
206 : "This environment publishes agent activity to your mobile clients.",
207 });
208 setStep("devices");
209 };
210
211 return (
212 <Dialog
213 open={openForAccount !== null}
214 onOpenChange={(open) => {
215 // Keep the dialog up while a link request is in flight so its outcome
216 // (and any failure) stays visible.
217 if (!open && !isApplying) complete();
218 }}
219 >
220 <DialogPopup className="max-w-xl">
221 <DialogHeader>
222 <DialogTitle>Set up T3 Connect</DialogTitle>
223 <DialogDescription>
224 Mesh your devices together — publish this environment and connect the rest, all in one
225 place.
226 </DialogDescription>
227 {steps.length > 1 ? (
228 <OnboardingStepper
229 steps={steps}
230 currentStep={step}
231 disabled={isApplying}
232 onStepSelect={setStep}
233 />
234 ) : null}
235 </DialogHeader>
236 <DialogPanel>
237 {step === "publish" ? (
238 <PublishStep
239 exposeEnvironment={exposeEnvironment}
240 publishAgentActivity={publishAgentActivity}
241 disabled={isApplying}
242 operationError={controller.operationError}
243 onExposeEnvironmentChange={setExposeEnvironment}
244 onPublishAgentActivityChange={setPublishAgentActivity}
245 />
246 ) : (
247 <DevicesStep />
248 )}
249 </DialogPanel>
250 <DialogFooter variant="bare" className="sm:justify-between">
251 <label className="flex cursor-pointer items-center gap-2 self-start text-xs text-muted-foreground sm:self-center">
252 <Checkbox
253 checked={dontShowAgain}
254 onCheckedChange={(checked) => setDontShowAgain(checked === true)}
255 />
256 Don&apos;t show this again
257 </label>
258 <div className="flex flex-col-reverse gap-2 sm:flex-row">
259 {step === "publish" ? (
260 <>
261 <Button variant="ghost" disabled={isApplying} onClick={() => setStep("devices")}>
262 Not now
263 </Button>
264 <Button
265 disabled={
266 isApplying || (controller.linkState.isPending && linkStateData === null)
267 }
268 onClick={() => void applyPublishSelection()}
269 >
270 {isApplying ? "Enabling…" : "Continue"}
271 </Button>
272 </>
273 ) : (
274 <Button disabled={isApplying} onClick={complete}>
275 Done
276 </Button>
277 )}
278 </div>
279 </DialogFooter>
280 </DialogPopup>
281 </Dialog>
282 );
283}
284
285const STEP_LABELS: Record<OnboardingStep, string> = {
286 publish: "Publish",
287 devices: "Connect devices",
288};
289
290function OnboardingStepper({
291 steps,
292 currentStep,
293 disabled,
294 onStepSelect,
295}: {
296 readonly steps: ReadonlyArray<OnboardingStep>;
297 readonly currentStep: OnboardingStep;
298 readonly disabled: boolean;
299 readonly onStepSelect: (step: OnboardingStep) => void;
300}) {
301 const currentIndex = steps.indexOf(currentStep);
302 return (
303 <div className="grid grid-cols-2 gap-2">
304 {steps.map((step, index) => (
305 <button
306 key={step}
307 type="button"
308 disabled={disabled}
309 className={cn(
310 "grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left",
311 index === currentIndex
312 ? "border-primary bg-primary/10 ring-1 ring-primary/25"
313 : index < currentIndex
314 ? "border-border bg-background"
315 : "border-border bg-muted/40",
316 )}
317 onClick={() => onStepSelect(step)}
318 >
319 <span
320 className={cn(
321 "row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border",
322 index < currentIndex
323 ? "border-primary bg-primary text-primary-foreground"
324 : index === currentIndex
325 ? "border-primary bg-background"
326 : "border-muted-foreground/35 bg-background",
327 )}
328 aria-hidden
329 >
330 {index < currentIndex ? <CheckIcon className="size-3" /> : null}
331 </span>
332 <span className="text-[10px] font-medium uppercase text-muted-foreground">
333 Step {index + 1}
334 </span>
335 <span className="truncate text-xs font-semibold text-foreground">
336 {STEP_LABELS[step]}
337 </span>
338 </button>
339 ))}
340 </div>
341 );
342}
343
344function PublishStep({
345 exposeEnvironment,
346 publishAgentActivity,
347 disabled,
348 operationError,
349 onExposeEnvironmentChange,
350 onPublishAgentActivityChange,
351}: {
352 readonly exposeEnvironment: boolean;
353 readonly publishAgentActivity: boolean;
354 readonly disabled: boolean;
355 readonly operationError: string | null;
356 readonly onExposeEnvironmentChange: (enabled: boolean) => void;
357 readonly onPublishAgentActivityChange: (enabled: boolean) => void;
358}) {
359 return (
360 <div className="space-y-3">
361 <div className="rounded-lg border">
362 <OnboardingToggleRow
363 title="Publish this environment"
364 description="Make this environment available to your other devices through T3 Connect."
365 checked={exposeEnvironment}
366 disabled={disabled}
367 onCheckedChange={onExposeEnvironmentChange}
368 />
369 <OnboardingToggleRow
370 title="Publish agent activity"
371 description="Send activity from this environment to your mobile clients for push notifications and Live Activities."
372 checked={publishAgentActivity}
373 disabled={disabled}
374 onCheckedChange={onPublishAgentActivityChange}
375 />
376 </div>
377 {operationError ? <p className="text-xs text-destructive">{operationError}</p> : null}
378 </div>
379 );
380}
381
382function OnboardingToggleRow({
383 title,
384 description,
385 checked,
386 disabled,
387 onCheckedChange,
388}: {
389 readonly title: string;
390 readonly description: string;
391 readonly checked: boolean;
392 readonly disabled: boolean;
393 readonly onCheckedChange: (enabled: boolean) => void;
394}) {
395 return (
396 <div className="flex items-start justify-between gap-4 border-t border-border/60 px-4 py-3 first:border-t-0">
397 <div className="min-w-0">
398 <p className="text-sm font-medium">{title}</p>
399 <p className="mt-1 text-xs text-muted-foreground">{description}</p>
400 </div>
401 <Switch
402 aria-label={title}
403 checked={checked}
404 disabled={disabled}
405 onCheckedChange={onCheckedChange}
406 />
407 </div>
408 );
409}
410
411function DevicesStep() {
412 const { environments } = useEnvironments();
413 const primaryEnvironment = usePrimaryEnvironment();
414 const savedEnvironmentIds = useMemo(
415 () =>
416 environments
417 .filter((environment) => environment.entry.target._tag !== "PrimaryConnectionTarget")
418 .map((environment) => environment.environmentId),
419 [environments],
420 );
421
422 return (
423 <div className="overflow-hidden rounded-lg border">
424 <CloudEnvironmentConnectRows
425 primaryEnvironmentId={primaryEnvironment?.environmentId ?? null}
426 savedEnvironmentIds={savedEnvironmentIds}
427 showSavedAsConnected
428 empty={
429 <p className="px-4 py-6 text-center text-sm text-muted-foreground">
430 No other environments are published to your account yet. Publish one from another device
431 and it will show up here.
432 </p>
433 }
434 />
435 </div>
436 );
437}
apps/web/src/components/settings/ConnectionsSettings.tsx+16-359
...@@ -7,8 +7,7 @@ import {...@@ -7,8 +7,7 @@ import {
7 TerminalIcon,7 TerminalIcon,
8 TriangleAlertIcon,8 TriangleAlertIcon,
9} from "lucide-react";9} from "lucide-react";
10import { useAuth } from "@clerk/react";10import { type ReactNode, memo, useCallback, useMemo, useState } from "react";
11import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react";
12import {11import {
13 AuthAccessReadScope,12 AuthAccessReadScope,
14 AuthAccessWriteScope,13 AuthAccessWriteScope,
...@@ -30,18 +29,11 @@ import {...@@ -30,18 +29,11 @@ import {
30 type DesktopWslState,29 type DesktopWslState,
31 type EnvironmentId,30 type EnvironmentId,
32} from "@t3tools/contracts";31} from "@t3tools/contracts";
33import {32import { connectionStatusText } from "@t3tools/client-runtime/connection";
34 connectionStatusText,
35 RelayConnectionRegistration,
36 RelayConnectionTarget,
37} from "@t3tools/client-runtime/connection";
38import { findErrorTraceId } from "@t3tools/client-runtime/errors";
39import {33import {
40 isAtomCommandInterrupted,34 isAtomCommandInterrupted,
41 settlePromise,
42 squashAtomCommandFailure,35 squashAtomCommandFailure,
43} from "@t3tools/client-runtime/state/runtime";36} from "@t3tools/client-runtime/state/runtime";
44import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
45import * as DateTime from "effect/DateTime";37import * as DateTime from "effect/DateTime";
46import * as Option from "effect/Option";38import * as Option from "effect/Option";
4739
...@@ -50,7 +42,6 @@ import { cn } from "../../lib/utils";...@@ -50,7 +42,6 @@ import { cn } from "../../lib/utils";
50import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";42import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
51import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";43import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
52import { applyWslEnableSelection } from "./ConnectionsSettings.logic";44import { applyWslEnableSelection } from "./ConnectionsSettings.logic";
53import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig";
54import {45import {
55 SettingsPageContainer,46 SettingsPageContainer,
56 SettingsRow,47 SettingsRow,
...@@ -82,7 +73,6 @@ import {...@@ -82,7 +73,6 @@ import {
82} from "../ui/alert-dialog";73} from "../ui/alert-dialog";
83import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";74import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
84import { QRCodeSvg } from "../ui/qr-code";75import { QRCodeSvg } from "../ui/qr-code";
85import { Skeleton } from "../ui/skeleton";
86import { Spinner } from "../ui/spinner";76import { Spinner } from "../ui/spinner";
87import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";77import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
88import { Switch } from "../ui/switch";78import { Switch } from "../ui/switch";
...@@ -117,13 +107,8 @@ import {...@@ -117,13 +107,8 @@ import {
117import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";107import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
118import { useUiStateStore } from "~/uiStateStore";108import { useUiStateStore } from "~/uiStateStore";
119import { resolveServerConfigVersionMismatch } from "~/versionSkew";109import { resolveServerConfigVersionMismatch } from "~/versionSkew";
120import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState";
121import { hasCloudPublicConfig } from "~/cloud/publicConfig";110import { hasCloudPublicConfig } from "~/cloud/publicConfig";
122import {111import { useCloudLinkController } from "~/cloud/useCloudLinkController";
123 linkPrimaryEnvironment as linkPrimaryEnvironmentAtom,
124 unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom,
125 updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom,
126} from "~/cloud/linkEnvironmentAtoms";
127import { authEnvironment } from "~/state/auth";112import { authEnvironment } from "~/state/auth";
128import { environmentCatalog } from "~/connection/catalog";113import { environmentCatalog } from "~/connection/catalog";
129import {114import {
...@@ -141,10 +126,11 @@ import {...@@ -141,10 +126,11 @@ import {
141 type EnvironmentPresentation,126 type EnvironmentPresentation,
142 useEnvironments,127 useEnvironments,
143 usePrimaryEnvironment,128 usePrimaryEnvironment,
144 useRelayEnvironmentDiscovery,
145} from "~/state/environments";129} from "~/state/environments";
146import { relayEnvironmentDiscovery } from "~/state/relay";
147import { useAtomCommand } from "../../state/use-atom-command";130import { useAtomCommand } from "../../state/use-atom-command";
131import { ConnectionStatusDot } from "../ConnectionStatusDot";
132import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
133import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
148134
149const DEFAULT_TAILSCALE_SERVE_PORT = 443;135const DEFAULT_TAILSCALE_SERVE_PORT = 443;
150const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = [];136const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = [];
...@@ -260,60 +246,6 @@ function AccessScopeSummary({...@@ -260,60 +246,6 @@ function AccessScopeSummary({
260 );246 );
261}247}
262248
263type ConnectionStatusDotProps = {
264 tooltipText?: string | null;
265 dotClassName: string;
266 pingClassName?: string | null;
267};
268
269function ConnectionStatusDot({
270 tooltipText,
271 dotClassName,
272 pingClassName,
273}: ConnectionStatusDotProps) {
274 const dotContent = (
275 <>
276 {pingClassName ? (
277 <span
278 className={cn(
279 "absolute inline-flex h-full w-full animate-ping rounded-full",
280 pingClassName,
281 )}
282 />
283 ) : null}
284 <span className={cn("relative inline-flex size-2 rounded-full", dotClassName)} />
285 </>
286 );
287
288 if (!tooltipText) {
289 return (
290 <span className="relative flex size-3 shrink-0 items-center justify-center">
291 {dotContent}
292 </span>
293 );
294 }
295
296 const dot = (
297 <button
298 type="button"
299 title={tooltipText}
300 aria-label={tooltipText}
301 className="relative flex size-3 shrink-0 cursor-help items-center justify-center rounded-full outline-hidden"
302 >
303 {dotContent}
304 </button>
305 );
306
307 return (
308 <Tooltip>
309 <TooltipTrigger render={dot} />
310 <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">
311 {tooltipText}
312 </TooltipPopup>
313 </Tooltip>
314 );
315}
316
317function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string {249function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string {
318 const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname;250 const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname;
319 return target.port ? `${authority}:${target.port}` : authority;251 return target.port ? `${authority}:${target.port}` : authority;
...@@ -437,13 +369,8 @@ function formatDesktopSshConnectionError(error: unknown): string {...@@ -437,13 +369,8 @@ function formatDesktopSshConnectionError(error: unknown): string {
437 return withoutTaggedErrorPrefix.trim() || fallback;369 return withoutTaggedErrorPrefix.trim() || fallback;
438}370}
439371
440/** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */
441const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5";
442const ENDPOINT_ROW_CLASSNAME = "border-t border-border/60 px-4 py-2.5 first:border-t-0 sm:px-5";372const ENDPOINT_ROW_CLASSNAME = "border-t border-border/60 px-4 py-2.5 first:border-t-0 sm:px-5";
443373
444const ITEM_ROW_INNER_CLASSNAME =
445 "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between";
446
447type AccessSectionPresentation = "current" | "endpoint-rail";374type AccessSectionPresentation = "current" | "endpoint-rail";
448375
449function accessRowClassName(_presentation: AccessSectionPresentation) {376function accessRowClassName(_presentation: AccessSectionPresentation) {
...@@ -1629,51 +1556,17 @@ function CloudLinkSwitch({...@@ -1629,51 +1556,17 @@ function CloudLinkSwitch({
1629}1556}
16301557
1631function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) {1558function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) {
1632 const { getToken, isSignedIn } = useAuth();1559 const {
1633 const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, {1560 isSignedIn,
1634 reportFailure: false,1561 linkState: primaryCloudLinkState,
1635 });1562 managedTunnelActive,
1636 const linkPrimaryEnvironment = useAtomCommand(linkPrimaryEnvironmentAtom, {1563 publishAgentActivity,
1637 reportFailure: false,1564 operationError,
1638 });1565 reconcileCloudState,
1639 const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, {1566 } = useCloudLinkController();
1640 reportFailure: false,
1641 });
1642 const updatePrimaryEnvironmentPreferences = useAtomCommand(
1643 updatePrimaryEnvironmentPreferencesAtom,
1644 { reportFailure: false },
1645 );
1646 const primaryCloudLinkState = usePrimaryCloudLinkState();
1647 const [operationError, setOperationError] = useState<string | null>(null);
1648 const [isUpdating, setIsUpdating] = useState(false);1567 const [isUpdating, setIsUpdating] = useState(false);
1649 const [isUpdatingPreference, setIsUpdatingPreference] = useState(false);1568 const [isUpdatingPreference, setIsUpdatingPreference] = useState(false);
16501569
1651 const reportUpdateFailure = (cause: unknown) => {
1652 const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access.";
1653 const traceId = findErrorTraceId(cause);
1654 console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause });
1655 setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message);
1656 toastManager.add({
1657 type: "error",
1658 title: "Could not update T3 Connect",
1659 description: message,
1660 data: traceId
1661 ? {
1662 secondaryActionProps: {
1663 children: "Copy trace ID",
1664 onClick: () => void navigator.clipboard?.writeText(traceId),
1665 },
1666 }
1667 : undefined,
1668 });
1669 };
1670
1671 // Older environment servers predate the managedTunnelActive field; for them a
1672 // link always implies a managed tunnel, so fall back to `linked`.
1673 const managedTunnelActive =
1674 primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false;
1675 const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false;
1676 const linked = primaryCloudLinkState.data?.linked ?? false;
1677 const disabledReason = !isSignedIn1570 const disabledReason = !isSignedIn
1678 ? "Sign in to T3 Connect to manage this environment."1571 ? "Sign in to T3 Connect to manage this environment."
1679 : !canManageRelay1572 : !canManageRelay
...@@ -1681,86 +1574,6 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b...@@ -1681,86 +1574,6 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
1681 : null;1574 : null;
1682 const isBusy = isUpdating || isUpdatingPreference;1575 const isBusy = isUpdating || isUpdatingPreference;
16831576
1684 // T3 Connect (managed tunnel) and publishing are independent capabilities
1685 // backed by a single relay link. Reconcile the whole desired state: unlink
1686 // when neither is wanted, otherwise (re)link with the mode the managed-tunnel
1687 // bit implies and set the publish preference. Re-linking only happens when the
1688 // managed-tunnel mode actually changes, so flipping publish alone is cheap.
1689 const reconcileCloudState = async (desired: {
1690 readonly managedTunnel: boolean;
1691 readonly publish: boolean;
1692 }): Promise<boolean> => {
1693 setOperationError(null);
1694 const target = primaryCloudLinkState.target;
1695 if (!target) {
1696 reportUpdateFailure(new Error("Local environment is not ready yet."));
1697 return false;
1698 }
1699 const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));
1700 if (tokenResult._tag === "Failure") {
1701 reportUpdateFailure(squashAtomCommandFailure(tokenResult));
1702 return false;
1703 }
1704 const clerkToken = tokenResult.value;
1705 const wantsLink = desired.managedTunnel || desired.publish;
1706
1707 // A failure after this point may follow a partially applied mutation (e.g.
1708 // the link succeeded but the preference update did not), so every exit —
1709 // success or failure — refreshes the rendered state to whatever the server
1710 // actually holds now.
1711 if (!wantsLink) {
1712 const unlinkResult = await unlinkPrimaryEnvironment({
1713 target,
1714 clerkToken: clerkToken ?? null,
1715 });
1716 if (unlinkResult._tag === "Failure") {
1717 if (!isAtomCommandInterrupted(unlinkResult)) {
1718 reportUpdateFailure(squashAtomCommandFailure(unlinkResult));
1719 }
1720 primaryCloudLinkState.refresh();
1721 return false;
1722 }
1723 } else {
1724 if (!clerkToken) {
1725 reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this."));
1726 return false;
1727 }
1728 if (!linked || managedTunnelActive !== desired.managedTunnel) {
1729 const linkResult = await linkPrimaryEnvironment({
1730 target,
1731 clerkToken,
1732 mode: desired.managedTunnel ? "managed" : "publish_only",
1733 });
1734 if (linkResult._tag === "Failure") {
1735 if (!isAtomCommandInterrupted(linkResult)) {
1736 reportUpdateFailure(squashAtomCommandFailure(linkResult));
1737 }
1738 primaryCloudLinkState.refresh();
1739 return false;
1740 }
1741 }
1742 const prefResult = await updatePrimaryEnvironmentPreferences({
1743 target,
1744 publishAgentActivity: desired.publish,
1745 });
1746 if (prefResult._tag === "Failure") {
1747 if (!isAtomCommandInterrupted(prefResult)) {
1748 reportUpdateFailure(squashAtomCommandFailure(prefResult));
1749 }
1750 primaryCloudLinkState.refresh();
1751 return false;
1752 }
1753 }
1754
1755 primaryCloudLinkState.refresh();
1756 const refreshResult = await refreshRelayEnvironments();
1757 if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
1758 reportUpdateFailure(squashAtomCommandFailure(refreshResult));
1759 return false;
1760 }
1761 return true;
1762 };
1763
1764 const updateManagedTunnel = async (enabled: boolean) => {1577 const updateManagedTunnel = async (enabled: boolean) => {
1765 setIsUpdating(true);1578 setIsUpdating(true);
1766 const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity });1579 const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity });
...@@ -1857,163 +1670,6 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable...@@ -1857,163 +1670,6 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable
1857 );1670 );
1858}1671}
18591672
1860function RemoteEnvironmentRowsSkeleton() {
1861 return (
1862 <div className={ITEM_ROW_CLASSNAME}>
1863 <div className={ITEM_ROW_INNER_CLASSNAME}>
1864 <div className="min-w-0 flex-1 space-y-2">
1865 <Skeleton className="h-4 w-32 rounded-full" />
1866 <Skeleton className="h-3 w-20 rounded-full" />
1867 </div>
1868 <Skeleton className="h-7 w-16 rounded-md" />
1869 </div>
1870 </div>
1871 );
1872}
1873
1874function ConfiguredCloudRemoteEnvironmentRows({
1875 primaryEnvironmentId,
1876 savedEnvironmentIds,
1877}: {
1878 readonly primaryEnvironmentId: EnvironmentId | null;
1879 readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>;
1880}) {
1881 const environmentsState = useRelayEnvironmentDiscovery();
1882 const registerEnvironment = useAtomCommand(environmentCatalog.register, {
1883 reportFailure: false,
1884 });
1885 const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, {
1886 reportFailure: false,
1887 });
1888 const connectRelayEnvironment = useCallback(
1889 (environment: RelayClientEnvironmentRecord) =>
1890 registerEnvironment(
1891 new RelayConnectionRegistration({
1892 target: new RelayConnectionTarget({
1893 environmentId: environment.environmentId,
1894 label: environment.label,
1895 }),
1896 }),
1897 ),
1898 [registerEnvironment],
1899 );
1900 const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<EnvironmentId | null>(
1901 null,
1902 );
1903 const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]);
1904
1905 useEffect(() => {
1906 void refreshRelayEnvironments();
1907 }, [refreshRelayEnvironments]);
1908
1909 const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => {
1910 setConnectingEnvironmentId(environment.environmentId);
1911 const result = await connectRelayEnvironment(environment);
1912 setConnectingEnvironmentId(null);
1913 if (result._tag === "Success") {
1914 toastManager.add({
1915 type: "success",
1916 title: "Environment connected",
1917 description: `${environment.label} is available through T3 Connect.`,
1918 });
1919 return;
1920 }
1921 if (isAtomCommandInterrupted(result)) {
1922 return;
1923 }
1924 const cause = squashAtomCommandFailure(result);
1925 const message =
1926 cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment.";
1927 const traceId = findErrorTraceId(cause);
1928 console.error("[t3-connect] Could not connect environment", { message, traceId, cause });
1929 toastManager.add({
1930 type: "error",
1931 title: "Could not connect environment",
1932 description: message,
1933 data: traceId
1934 ? {
1935 secondaryActionProps: {
1936 children: "Copy trace ID",
1937 onClick: () => void navigator.clipboard?.writeText(traceId),
1938 },
1939 }
1940 : undefined,
1941 });
1942 };
1943
1944 const connectableEnvironments = [...environmentsState.environments.values()].filter(
1945 ({ environment }) =>
1946 environment.environmentId !== primaryEnvironmentId &&
1947 !savedIds.has(environment.environmentId),
1948 );
1949
1950 if (
1951 savedEnvironmentIds.length === 0 &&
1952 environmentsState.refreshing &&
1953 environmentsState.environments.size === 0
1954 ) {
1955 return <RemoteEnvironmentRowsSkeleton />;
1956 }
1957
1958 if (savedEnvironmentIds.length === 0 && connectableEnvironments.length === 0) {
1959 return <EmptyRemoteEnvironments />;
1960 }
1961
1962 return connectableEnvironments.map(({ environment, availability, error }) => (
1963 <div key={environment.environmentId} className={ITEM_ROW_CLASSNAME}>
1964 <div className={ITEM_ROW_INNER_CLASSNAME}>
1965 <div className="min-w-0">
1966 <div className="flex items-center gap-2">
1967 <ConnectionStatusDot
1968 dotClassName={
1969 availability === "online"
1970 ? "bg-success"
1971 : availability === "error"
1972 ? "bg-destructive"
1973 : availability === "checking"
1974 ? "bg-warning"
1975 : "bg-muted-foreground/35"
1976 }
1977 pingClassName={availability === "checking" ? "bg-warning/60 duration-2000" : null}
1978 tooltipText={
1979 availability === "online"
1980 ? "Relay online"
1981 : availability === "offline"
1982 ? "Relay offline"
1983 : availability === "checking"
1984 ? "Checking relay status"
1985 : (Option.getOrNull(error)?.message ?? "Relay status unavailable")
1986 }
1987 />
1988 <p className="truncate text-sm font-medium">{environment.label}</p>
1989 </div>
1990 <p
1991 className={cn(
1992 "mt-1 truncate text-xs",
1993 availability === "error" ? "text-destructive" : "text-muted-foreground",
1994 )}
1995 >
1996 {availability === "online"
1997 ? "Available · Relay online"
1998 : availability === "offline"
1999 ? "Available · Relay offline"
2000 : availability === "checking"
2001 ? "Available · Checking relay status…"
2002 : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")}
2003 </p>
2004 </div>
2005 <Button
2006 size="sm"
2007 disabled={connectingEnvironmentId !== null}
2008 onClick={() => void connectEnvironment(environment)}
2009 >
2010 {connectingEnvironmentId === environment.environmentId ? "Connecting…" : "Connect"}
2011 </Button>
2012 </div>
2013 </div>
2014 ));
2015}
2016
2017function CloudRemoteEnvironmentRows({1673function CloudRemoteEnvironmentRows({
2018 primaryEnvironmentId,1674 primaryEnvironmentId,
2019 savedEnvironmentIds,1675 savedEnvironmentIds,
...@@ -2022,9 +1678,10 @@ function CloudRemoteEnvironmentRows({...@@ -2022,9 +1678,10 @@ function CloudRemoteEnvironmentRows({
2022 readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>;1678 readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>;
2023}) {1679}) {
2024 return hasCloudPublicConfig() ? (1680 return hasCloudPublicConfig() ? (
2025 <ConfiguredCloudRemoteEnvironmentRows1681 <CloudEnvironmentConnectRows
2026 primaryEnvironmentId={primaryEnvironmentId}1682 primaryEnvironmentId={primaryEnvironmentId}
2027 savedEnvironmentIds={savedEnvironmentIds}1683 savedEnvironmentIds={savedEnvironmentIds}
1684 empty={<EmptyRemoteEnvironments />}
2028 />1685 />
2029 ) : savedEnvironmentIds.length === 0 ? (1686 ) : savedEnvironmentIds.length === 0 ? (
2030 <EmptyRemoteEnvironments cloudEnabled={false} />1687 <EmptyRemoteEnvironments cloudEnabled={false} />
apps/web/src/components/settings/itemRows.ts created+5
...@@ -0,0 +1,5 @@
1/** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */
2export const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5";
3
4export const ITEM_ROW_INNER_CLASSNAME =
5 "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between";
apps/web/src/connection/platform.ts+2-2
...@@ -182,7 +182,7 @@ const capabilitiesLayer = Layer.effectContext(...@@ -182,7 +182,7 @@ const capabilitiesLayer = Layer.effectContext(
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(...@@ -197,7 +197,7 @@ const capabilitiesLayer = Layer.effectContext(
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;
apps/web/src/routes/__root.tsx+2
...@@ -14,6 +14,7 @@ import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding";...@@ -14,6 +14,7 @@ import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding";
14import { resolveServerBackedAppDisplayName } from "../branding.logic";14import { resolveServerBackedAppDisplayName } from "../branding.logic";
15import { AppSidebarLayout } from "../components/AppSidebarLayout";15import { AppSidebarLayout } from "../components/AppSidebarLayout";
16import { CommandPalette } from "../components/CommandPalette";16import { CommandPalette } from "../components/CommandPalette";
17import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
17import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";18import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
18import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";19import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
19import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";20import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
...@@ -128,6 +129,7 @@ function RootRouteView() {...@@ -128,6 +129,7 @@ function RootRouteView() {
128 <DocumentTitleSync />129 <DocumentTitleSync />
129 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}130 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
130 <RelayClientInstallDialog />131 <RelayClientInstallDialog />
132 <ConnectOnboardingDialog />
131 <SshPasswordPromptDialog />133 <SshPasswordPromptDialog />
132 <SlowRpcRequestToastCoordinator />134 <SlowRpcRequestToastCoordinator />
133 <HostedStaticEnvironmentBootstrap />135 <HostedStaticEnvironmentBootstrap />
packages/client-runtime/src/relay/discovery.test.ts+49
...@@ -342,6 +342,55 @@ describe("RelayEnvironmentDiscovery", () => {...@@ -342,6 +342,55 @@ describe("RelayEnvironmentDiscovery", () => {
342 }),342 }),
343 );343 );
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
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+26-5
...@@ -222,7 +222,27 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () {...@@ -222,7 +222,27 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () {
222 error: Option.none(),222 error: Option.none(),
223 });223 });
224224
225 const clerkToken = yield* session.clerkToken;225 // Signed out is the idle state, not a failure: the proactive refresh on
226 // credentials-changed also runs on sign-out and must settle back to a
227 // clean empty list. Only the session-level "no credentials" error is
228 // benign — relay-side auth failures (expired/invalid tokens) happen
229 // after this point and must surface as errors.
230 const tokenResult = yield* Effect.result(session.clerkToken);
231 if (tokenResult._tag === "Failure") {
232 const failure = tokenResult.failure;
233 if (failure._tag === "ConnectionBlockedError" && failure.reason === "authentication") {
234 if ((yield* Ref.get(accountGeneration)) !== generation) {
235 return;
236 }
237 yield* SubscriptionRef.update(state, (current) => ({
238 ...current,
239 refreshing: false,
240 }));
241 return;
242 }
243 return yield* Effect.fail(failure);
244 }
245 const clerkToken = tokenResult.success;
226 if ((yield* Ref.get(accountGeneration)) !== generation) {246 if ((yield* Ref.get(accountGeneration)) !== generation) {
227 return;247 return;
228 }248 }
...@@ -311,11 +331,12 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () {...@@ -311,11 +331,12 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () {
311 yield* Ref.update(accountGeneration, (current) => current + 1);331 yield* Ref.update(accountGeneration, (current) => current + 1);
312 yield* Ref.set(activeAccountId, Option.none());332 yield* Ref.set(activeAccountId, Option.none());
313 yield* Ref.set(offlineReportFingerprints, new Map());333 yield* Ref.set(offlineReportFingerprints, new Map());
314 const shouldRefresh = yield* Ref.get(hasRefreshed);
315 yield* SubscriptionRef.set(state, EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE);334 yield* SubscriptionRef.set(state, EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE);
316 if (shouldRefresh) {335 // Refresh proactively — this wakeup fires when a session activates
317 yield* refresh.pipe(Effect.forkScoped);336 // (sign-in or cold start), and the list should be populated before
318 }337 // any screen asks for it. A signed-out refresh settles back to the
338 // clean empty state.
339 yield* refresh.pipe(Effect.forkScoped);
319 })340 })
320 : Effect.void,341 : Effect.void,
321 ),342 ),
packages/client-runtime/src/relay/managedRelayState.ts+4-4
...@@ -123,7 +123,7 @@ export function createManagedRelaySession(input: ManagedRelaySessionInput): Mana...@@ -123,7 +123,7 @@ export function createManagedRelaySession(input: ManagedRelaySessionInput): Mana
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(...@@ -181,7 +181,7 @@ function readSessionClerkToken(
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(...@@ -226,7 +226,7 @@ function requireClerkToken(
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>(...@@ -296,7 +296,7 @@ export function readManagedRelaySnapshotState<A>(
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 {