authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-07 12:39:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-07 12:39:56-07:00
logfa6073e2e13b6a41cfd6c3834c300408e9d1d6d8
treecd57c84964c9f01691e536f752b062e1c011712e
parent4eb1b5793340f8a197f9f995aa396c9a306dbabf
signature Signed by PGP key B5690EEEBB952194

Add T3 Connect onboarding for mobile and web (#3765)


26 files changed, 1546 insertions(+), 720 deletions(-)

apps/mobile/src/Stack.tsx+19
......@@ -17,6 +17,8 @@ import { AppText as Text } from "./components/AppText";
1717import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
1818import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
1919import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";
20import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
21import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation";
2022import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
2123import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
2224import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
......@@ -220,6 +222,7 @@ const NewTaskSheetStack = createNativeStackNavigator({
220222// influence the adaptive workspace layout: opening Settings over Home should
221223// not flip the sidebar in or change the active thread.
222224const WORKSPACE_OVERLAY_ROUTES = new Set([
225 "ConnectOnboarding",
223226 "Connections",
224227 "ConnectionsNew",
225228 "GitBranches",
......@@ -251,6 +254,8 @@ function RootStackLayout(props: {
251254}) {
252255 useAgentNotificationNavigation();
253256 useThreadOutboxDrain();
257 // Presents the T3 Connect onboarding sheet after an in-session sign-in.
258 useConnectOnboardingNavigation();
254259 // Full pathname (sheets included) for keyboard-command scoping; the
255260 // workspace layout only reacts to the underlying non-overlay route.
256261 const path = getPathFromState(props.state, navigationPathConfig);
......@@ -412,6 +417,20 @@ export const RootStack = createNativeStackNavigator({
412417 sheetGrabberVisible: true,
413418 },
414419 }),
420 ConnectOnboarding: createNativeStackScreen({
421 screen: ConnectOnboardingRouteScreen,
422 linking: "connect-onboarding",
423 options: {
424 // Root screenOptions hide headers; formSheets that want the native
425 // title bar opt back in with the sheet header preset.
426 ...SHEET_SOLID_HEADER_OPTIONS,
427 title: "Set up T3 Connect",
428 gestureEnabled: true,
429 presentation: "formSheet",
430 sheetAllowedDetents: [0.6, 0.95],
431 sheetGrabberVisible: true,
432 },
433 }),
415434 Connections: createNativeStackScreen({
416435 screen: ConnectionsRouteScreen,
417436 linking: "connections",
apps/mobile/src/connection/platform.ts+2-2
......@@ -92,7 +92,7 @@ const capabilitiesLayer = Layer.succeedContext(
9292 if (session === null) {
9393 return yield* new ConnectionBlockedError({
9494 reason: "authentication",
95 detail: "Sign in to T3 Cloud to connect this environment.",
95 detail: "Sign in to T3 Connect to connect this environment.",
9696 });
9797 }
9898 const token = yield* session.readClerkToken().pipe(
......@@ -107,7 +107,7 @@ const capabilitiesLayer = Layer.succeedContext(
107107 if (token === null) {
108108 return yield* new ConnectionBlockedError({
109109 reason: "authentication",
110 detail: "The T3 Cloud session is unavailable.",
110 detail: "The T3 Connect session is unavailable.",
111111 });
112112 }
113113 return token;
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+9
......@@ -18,6 +18,7 @@ import {
1818 setAgentAwarenessRelayTokenProvider,
1919 unregisterAgentAwarenessDeviceForCurrentUser,
2020} from "../agent-awareness/remoteRegistration";
21import { requestConnectOnboarding } from "./connectOnboarding";
2122import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
2223
2324function resetManagedRelayTokenCache() {
......@@ -67,6 +68,14 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) {
6768 const nextAccount = isSignedIn && userId ? userId : null;
6869 observedAccountRef.current = nextAccount;
6970
71 // Every sign-in that completes during this session (a cold start observes
72 // undefined → account and must not re-prompt) requests the T3 Connect
73 // onboarding sheet — sign-out clears the connected environments, so each
74 // new session starts with no devices to reach.
75 if (previousObservedAccount === null && nextAccount !== null) {
76 requestConnectOnboarding(nextAccount);
77 }
78
7079 const queueAccountCleanup = (
7180 previous: {
7281 readonly userId: string;
apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx+1-1
......@@ -39,7 +39,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }
3939 You are on the waitlist
4040 </Text>
4141 <Text className="text-center font-sans text-base text-foreground-secondary">
42 We will email you when your T3 Cloud access is ready.
42 We will email you when your T3 Connect access is ready.
4343 </Text>
4444 <SignInAction onPress={props.onSignIn} />
4545 </View>
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx created+111
......@@ -0,0 +1,111 @@
1import { NativeHeaderToolbar } from "../../native/StackHeader";
2import { useAuth } from "@clerk/expo";
3import { useNavigation } from "@react-navigation/native";
4import { useCallback, 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 return hasCloudPublicConfig() ? <ConfiguredConnectOnboardingRouteScreen /> : null;
26}
27
28function ConfiguredConnectOnboardingRouteScreen() {
29 const navigation = useNavigation();
30 const insets = useSafeAreaInsets();
31 const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
32 const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections();
33 const { refreshRelayEnvironments } = useConnectionController();
34 const { connectedCloudEnvironments } = splitEnvironmentSections({
35 connectedEnvironments,
36 cloudEnvironments: null,
37 });
38
39 // Pull-to-refresh tracks its own spinner instead of discovery's refreshing
40 // flag, so background refreshes (e.g. the sign-in one) don't yank the
41 // content down.
42 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
43 const handlePullRefresh = useCallback(() => {
44 void (async () => {
45 setIsPullRefreshing(true);
46 await refreshRelayEnvironments();
47 setIsPullRefreshing(false);
48 })();
49 }, [refreshRelayEnvironments]);
50
51 const handleClose = useCallback(() => {
52 navigation.goBack();
53 }, [navigation]);
54
55 const handleDontShowAgain = useCallback(() => {
56 if (userId) {
57 void (async () => {
58 const result = await settlePromise(() => optOutOfConnectOnboarding(userId));
59 reportAtomCommandResult(result, { label: "connect onboarding opt-out" });
60 })();
61 }
62 navigation.goBack();
63 }, [navigation, userId]);
64
65 return (
66 <View collapsable={false} className="flex-1 bg-sheet">
67 <NativeHeaderToolbar placement="right">
68 <NativeHeaderToolbar.Button icon="xmark" onPress={handleClose} separateBackground />
69 </NativeHeaderToolbar>
70 <ScrollView
71 contentInsetAdjustmentBehavior="automatic"
72 showsVerticalScrollIndicator={false}
73 style={{ flex: 1 }}
74 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
75 contentContainerStyle={{
76 gap: 16,
77 paddingHorizontal: 20,
78 paddingTop: 16,
79 }}
80 refreshControl={
81 <RefreshControl refreshing={isPullRefreshing} onRefresh={handlePullRefresh} />
82 }
83 >
84 {isSignedIn ? (
85 <CloudEnvironmentRows
86 connectedCloudEnvironments={connectedCloudEnvironments}
87 onReconnectEnvironment={onReconnectEnvironment}
88 showHeader={false}
89 />
90 ) : (
91 <View collapsable={false} className="rounded-[24px] bg-card p-5">
92 <Text className="text-sm leading-normal text-foreground-muted">
93 Sign in to your T3 account to set up T3 Connect.
94 </Text>
95 </View>
96 )}
97
98 {userId ? (
99 <Pressable
100 accessibilityRole="button"
101 hitSlop={8}
102 onPress={handleDontShowAgain}
103 className="items-center py-1 active:opacity-70"
104 >
105 <Text className="text-xs text-foreground-muted">{"Don't show this again"}</Text>
106 </Pressable>
107 ) : null}
108 </ScrollView>
109 </View>
110 );
111}
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+45
......@@ -0,0 +1,45 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";
3import { useEffect } from "react";
4
5import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding";
6import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut";
7
8// Sign-in happens inside the Settings sheet; give its detent/session-state
9// transitions a beat to settle before presenting another formSheet on top.
10const PRESENT_ONBOARDING_DELAY_MS = 600;
11
12/**
13 * Consumes the onboarding request inside the navigation tree (RootStackLayout)
14 * and presents the onboarding formSheet. Lives apart from connectOnboarding.ts
15 * so non-navigation consumers (CloudAuthProvider) do not pull
16 * @react-navigation into their module graph.
17 */
18export function useConnectOnboardingNavigation(): void {
19 const navigation = useNavigation();
20 const requestedAccountId = useAtomValue(connectOnboardingRequestAtom);
21
22 useEffect(() => {
23 if (requestedAccountId === null) {
24 return;
25 }
26 let cancelled = false;
27 const timer = setTimeout(() => {
28 void (async () => {
29 // A failed preference read prefers showing the sheet.
30 const optedOut = await isConnectOnboardingOptedOut(requestedAccountId).catch(() => false);
31 if (cancelled) {
32 return;
33 }
34 clearConnectOnboardingRequest();
35 if (!optedOut) {
36 navigation.navigate("ConnectOnboarding");
37 }
38 })();
39 }, PRESENT_ONBOARDING_DELAY_MS);
40 return () => {
41 cancelled = true;
42 clearTimeout(timer);
43 };
44 }, [navigation, requestedAccountId]);
45}
apps/mobile/src/features/cloud/connectOnboardingOptOut.ts created+21
......@@ -0,0 +1,21 @@
1import { loadPreferences, savePreferencesPatch } from "../../lib/storage";
2
3// Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports
4// the request signal) never pulls lib/storage — expo-secure-store — into its
5// module graph; that breaks CloudAuthProvider.test.ts suite loading.
6
7/** Whether the account chose "Don't show this again". */
8export async function isConnectOnboardingOptedOut(accountId: string): Promise<boolean> {
9 const preferences = await loadPreferences();
10 return preferences.connectOnboardingOptOutAccounts?.includes(accountId) ?? false;
11}
12
13/** Persists "Don't show this again" for the account. */
14export async function optOutOfConnectOnboarding(accountId: string): Promise<void> {
15 const preferences = await loadPreferences();
16 const optedOut = preferences.connectOnboardingOptOutAccounts ?? [];
17 if (optedOut.includes(accountId)) {
18 return;
19 }
20 await savePreferencesPatch({ connectOnboardingOptOutAccounts: [...optedOut, accountId] });
21}
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx created+366
......@@ -0,0 +1,366 @@
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 ? (
128 <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5">
129 <Text className="text-base font-t3-bold text-foreground">
130 Could not load T3 Connect environments
131 </Text>
132 <Text className="text-sm text-foreground-muted">{controller.relayDiscovery.error}</Text>
133 {controller.relayDiscovery.errorTraceId ? (
134 <CopyTraceIdButton traceId={controller.relayDiscovery.errorTraceId} />
135 ) : null}
136 </View>
137 ) : (
138 <View collapsable={false} className="rounded-[24px] bg-card p-5">
139 <Text className="text-sm leading-normal text-foreground-muted">
140 No additional linked cloud environments.
141 </Text>
142 </View>
143 )}
144 </View>
145 );
146}
147
148function ConnectedCloudEnvironmentRow(props: {
149 readonly environment: ConnectedEnvironmentSummary;
150 readonly borderTop: boolean;
151 readonly errorExpanded: boolean;
152 readonly onConnect: () => void;
153 readonly onDisconnect: () => void;
154 readonly onToggleError: () => void;
155}) {
156 return (
157 <CloudEnvironmentRowShell
158 borderTop={props.borderTop}
159 connectionError={props.environment.connectionError}
160 connectionErrorTraceId={props.environment.connectionErrorTraceId}
161 connectionState={props.environment.connectionState}
162 errorExpanded={props.errorExpanded}
163 label={props.environment.environmentLabel}
164 onValueChange={(enabled) => {
165 if (enabled) {
166 props.onConnect();
167 return;
168 }
169 props.onDisconnect();
170 }}
171 onToggleError={props.onToggleError}
172 value={props.environment.connectionState !== "available"}
173 />
174 );
175}
176
177function CloudEnvironmentRow(props: {
178 readonly environment: RelayEnvironmentView;
179 readonly borderTop: boolean;
180 readonly errorExpanded: boolean;
181 readonly onConnect: () => void;
182 readonly onToggleError: () => void;
183}) {
184 const presentation = availableCloudEnvironmentPresentation({
185 isStatusPending: props.environment.availability === "checking",
186 status: props.environment.status,
187 statusError: props.environment.error,
188 statusErrorTraceId: props.environment.traceId,
189 });
190
191 return (
192 <CloudEnvironmentRowShell
193 borderTop={props.borderTop}
194 connectionError={presentation.connectionError}
195 connectionErrorTraceId={presentation.connectionErrorTraceId}
196 connectionState={presentation.connectionState}
197 errorExpanded={props.errorExpanded}
198 label={props.environment.environment.label}
199 onValueChange={(enabled) => {
200 if (enabled) {
201 props.onConnect();
202 }
203 }}
204 onToggleError={props.onToggleError}
205 statusText={presentation.statusText}
206 value={false}
207 />
208 );
209}
210
211function CloudEnvironmentRowShell(props: {
212 readonly borderTop: boolean;
213 readonly connectionError: string | null;
214 readonly connectionErrorTraceId: string | null;
215 readonly connectionState: EnvironmentConnectionPhase;
216 readonly disabled?: boolean;
217 readonly errorExpanded: boolean;
218 readonly label: string;
219 readonly onToggleError: () => void;
220 readonly onValueChange: (enabled: boolean) => void;
221 readonly statusText?: string;
222 readonly value: boolean;
223}) {
224 const activeTrack = String(useThemeColor("--color-switch-active"));
225 const track = String(useThemeColor("--color-secondary-border"));
226 const chevron = useThemeColor("--color-chevron");
227 const isRetrying =
228 props.connectionState === "connecting" || props.connectionState === "reconnecting";
229 const shouldPulse = isRetrying;
230 const statusText =
231 props.statusText ??
232 connectionStatusText({
233 phase: props.connectionState,
234 error: props.connectionError,
235 traceId: props.connectionErrorTraceId,
236 });
237 const statusClassName = props.connectionError
238 ? "text-rose-500 dark:text-rose-400"
239 : "text-foreground-muted";
240 const [errorMeasurement, setErrorMeasurement] = useState<{
241 readonly text: string;
242 readonly lineCount: number;
243 } | null>(null);
244 const errorTraceId = props.connectionErrorTraceId;
245 const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText;
246 const errorLineCount =
247 errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0;
248 const errorCanExpand = props.connectionError !== null && errorLineCount > 1;
249 const isErrorExpanded = errorCanExpand && props.errorExpanded;
250 const StatusContainer = errorCanExpand ? Pressable : View;
251 const onMeasuredErrorTextLayout = useCallback(
252 (event: NativeSyntheticEvent<TextLayoutEventData>) => {
253 if (!props.connectionError) {
254 return;
255 }
256 const nextLineCount = event.nativeEvent.lines.length;
257 setErrorMeasurement((currentMeasurement) =>
258 currentMeasurement?.text === measuredErrorText &&
259 currentMeasurement.lineCount === nextLineCount
260 ? currentMeasurement
261 : { text: measuredErrorText, lineCount: nextLineCount },
262 );
263 },
264 [measuredErrorText, props.connectionError],
265 );
266 return (
267 <View
268 collapsable={false}
269 className={cn(
270 "flex-row items-center gap-3 bg-card px-4 py-3.5",
271 props.borderTop && "border-t border-border",
272 )}
273 >
274 <View className="min-w-0 flex-1 gap-0.5">
275 <View className="min-w-0 flex-row items-center gap-2">
276 <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} />
277 <Text
278 className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground"
279 numberOfLines={1}
280 >
281 {props.label}
282 </Text>
283 </View>
284 {props.connectionError ? (
285 <Text
286 aria-hidden
287 className={cn("absolute left-0 right-0 text-xs", statusClassName)}
288 onTextLayout={onMeasuredErrorTextLayout}
289 style={{ opacity: 0, zIndex: -1 }}
290 >
291 {measuredErrorText}
292 </Text>
293 ) : null}
294 <StatusContainer
295 {...(errorCanExpand
296 ? { accessibilityRole: "button" as const, onPress: props.onToggleError }
297 : {})}
298 className="min-w-0 flex-row items-start gap-1"
299 >
300 <Text
301 className={cn("min-w-0 flex-1 text-xs", statusClassName)}
302 numberOfLines={isErrorExpanded ? undefined : 1}
303 >
304 {statusText}
305 {errorTraceId ? (
306 <>
307 {" Trace ID: "}
308 <Text
309 accessibilityHint="Copies the trace ID"
310 accessibilityRole="button"
311 className={cn("text-xs underline", statusClassName)}
312 onLongPress={(event) => {
313 event.stopPropagation();
314 copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" });
315 }}
316 onPress={(event) => {
317 event.stopPropagation();
318 }}
319 style={{ textDecorationStyle: "dotted" }}
320 >
321 {errorTraceId}
322 </Text>
323 </>
324 ) : null}
325 </Text>
326 {errorCanExpand ? (
327 <SymbolView
328 name="chevron.down"
329 size={10}
330 tintColor={chevron}
331 type="monochrome"
332 style={{
333 marginTop: 3,
334 transform: [{ rotate: isErrorExpanded ? "180deg" : "0deg" }],
335 }}
336 />
337 ) : null}
338 </StatusContainer>
339 </View>
340 <Switch
341 disabled={props.disabled}
342 ios_backgroundColor={track}
343 onValueChange={props.onValueChange}
344 trackColor={{ false: track, true: activeTrack }}
345 value={props.value}
346 />
347 </View>
348 );
349}
350
351function CopyTraceIdButton(props: { readonly traceId: string }) {
352 const iconColor = useThemeColor("--color-icon");
353
354 return (
355 <Pressable
356 accessibilityRole="button"
357 onPress={() => {
358 copyTextWithHaptic(props.traceId, { target: "connection-trace-id" });
359 }}
360 className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70"
361 >
362 <SymbolView name="doc.on.doc" size={12} tintColor={iconColor} type="monochrome" />
363 <Text className="text-xs font-t3-bold text-foreground">Copy trace ID</Text>
364 </Pressable>
365 );
366}
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+1-1
......@@ -135,7 +135,7 @@ export function ConnectionEnvironmentRow(props: {
135135 >
136136 {props.environment.isRelayManaged ? (
137137 <Text className="text-sm text-foreground-muted">
138 Managed by T3 Cloud. Tunnel details update automatically.
138 Managed by T3 Connect. Tunnel details update automatically.
139139 </Text>
140140 ) : (
141141 <>
apps/mobile/src/features/connection/environmentSections.test.ts+1-1
......@@ -35,7 +35,7 @@ function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord {
3535}
3636
3737describe("mobile environment settings sections", () => {
38 it("keeps saved relay-managed connections under T3 Cloud", () => {
38 it("keeps saved relay-managed connections under T3 Connect", () => {
3939 const local = connectedEnvironment({
4040 environmentId: "environment-local",
4141 isRelayManaged: false,
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+3-343
......@@ -1,38 +1,18 @@
1import { useAuth } from "@clerk/expo";
21import { NativeHeaderToolbar } from "../../native/StackHeader";
32import { useNavigation } from "@react-navigation/native";
43import { SymbolView } from "expo-symbols";
5import {
6 connectionStatusText,
7 type EnvironmentConnectionPhase,
8} from "@t3tools/client-runtime/connection";
94import type { EnvironmentId } from "@t3tools/contracts";
105import { useCallback, useState } from "react";
11import {
12 ActivityIndicator,
13 Pressable,
14 ScrollView,
15 Switch,
16 type NativeSyntheticEvent,
17 type TextLayoutEventData,
18 View,
19} from "react-native";
6import { ScrollView, View } from "react-native";
207import { useSafeAreaInsets } from "react-native-safe-area-context";
218
229import { AppText as Text } from "../../components/AppText";
23import {
24 type RelayEnvironmentView,
25 useConnectionController,
26} from "../connection/useConnectionController";
2710import { hasCloudPublicConfig } from "../cloud/publicConfig";
28import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
11import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
2912import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
30import { ConnectionStatusDot } from "../connection/ConnectionStatusDot";
3113import { splitEnvironmentSections } from "../connection/environmentSections";
3214import { cn } from "../../lib/cn";
33import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
3415import { useThemeColor } from "../../lib/useThemeColor";
35import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
3616import { useRemoteConnections } from "../../state/use-remote-environment-registry";
3717
3818export function SettingsEnvironmentsRouteScreen() {
......@@ -117,7 +97,7 @@ export function SettingsEnvironmentsRouteScreen() {
11797 )}
11898
11999 {hasCloudPublicConfig() ? (
120 <ConfiguredCloudEnvironmentRows
100 <CloudEnvironmentRows
121101 connectedCloudEnvironments={connectedCloudEnvironments}
122102 onReconnectEnvironment={onReconnectEnvironment}
123103 />
......@@ -126,323 +106,3 @@ export function SettingsEnvironmentsRouteScreen() {
126106 </View>
127107 );
128108}
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() {
240240
241241 const promptSignIn = useCallback(() => {
242242 Alert.alert(
243 "Request T3 Cloud access",
244 "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.",
243 "Request T3 Connect access",
244 "Live Activity updates require approved T3 Connect access so relay can deliver updates to this device.",
245245 [
246246 { text: "Cancel", style: "cancel" },
247247 {
apps/mobile/src/lib/storage.ts+8
......@@ -69,6 +69,8 @@ export interface Preferences {
6969 /** Code/diff font size override; null/absent means derived from baseFontSize. */
7070 readonly codeFontSize?: number | null;
7171 readonly codeWordBreak?: boolean;
72 /** Cloud account ids that opted out of the T3 Connect onboarding sheet. */
73 readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
7274}
7375
7476async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> {
......@@ -167,6 +169,7 @@ export async function loadPreferences(): Promise<Preferences> {
167169 markdownFontSize?: number;
168170 codeFontSize?: number | null;
169171 codeWordBreak?: boolean;
172 connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
170173 } = {};
171174
172175 if (typeof parsed.liveActivitiesEnabled === "boolean") {
......@@ -187,6 +190,11 @@ export async function loadPreferences(): Promise<Preferences> {
187190 if (typeof parsed.codeWordBreak === "boolean") {
188191 preferences.codeWordBreak = parsed.codeWordBreak;
189192 }
193 if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) {
194 preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter(
195 (account): account is string => typeof account === "string",
196 );
197 }
190198
191199 return preferences;
192200}
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+161
......@@ -0,0 +1,161 @@
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 if (tokenResult._tag === "Failure") {
89 reportUpdateFailure(squashAtomCommandFailure(tokenResult));
90 return false;
91 }
92 const clerkToken = tokenResult.value;
93 const wantsLink = desired.managedTunnel || desired.publish;
94
95 // A failure after this point may follow a partially applied mutation (e.g.
96 // the link succeeded but the preference update did not), so every exit —
97 // success or failure — refreshes the rendered state to whatever the server
98 // actually holds now.
99 if (!wantsLink) {
100 const unlinkResult = await unlinkPrimaryEnvironment({
101 target,
102 clerkToken: clerkToken ?? null,
103 });
104 if (unlinkResult._tag === "Failure") {
105 if (!isAtomCommandInterrupted(unlinkResult)) {
106 reportUpdateFailure(squashAtomCommandFailure(unlinkResult));
107 }
108 primaryCloudLinkState.refresh();
109 return false;
110 }
111 } else {
112 if (!clerkToken) {
113 reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this."));
114 return false;
115 }
116 if (!linked || managedTunnelActive !== desired.managedTunnel) {
117 const linkResult = await linkPrimaryEnvironment({
118 target,
119 clerkToken,
120 mode: desired.managedTunnel ? "managed" : "publish_only",
121 });
122 if (linkResult._tag === "Failure") {
123 if (!isAtomCommandInterrupted(linkResult)) {
124 reportUpdateFailure(squashAtomCommandFailure(linkResult));
125 }
126 primaryCloudLinkState.refresh();
127 return false;
128 }
129 }
130 const prefResult = await updatePrimaryEnvironmentPreferences({
131 target,
132 publishAgentActivity: desired.publish,
133 });
134 if (prefResult._tag === "Failure") {
135 if (!isAtomCommandInterrupted(prefResult)) {
136 reportUpdateFailure(squashAtomCommandFailure(prefResult));
137 }
138 primaryCloudLinkState.refresh();
139 return false;
140 }
141 }
142
143 primaryCloudLinkState.refresh();
144 const refreshResult = await refreshRelayEnvironments();
145 if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) {
146 reportUpdateFailure(squashAtomCommandFailure(refreshResult));
147 return false;
148 }
149 return true;
150 };
151
152 return {
153 isSignedIn,
154 linkState: primaryCloudLinkState,
155 linked,
156 managedTunnelActive,
157 publishAgentActivity,
158 operationError,
159 reconcileCloudState,
160 };
161}
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+204
......@@ -0,0 +1,204 @@
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 return empty;
140 }
141
142 return visibleEnvironments.map(({ environment, availability, error }) => {
143 const alreadyConnected = savedIds.has(environment.environmentId);
144 return (
145 <div key={environment.environmentId} className={ITEM_ROW_CLASSNAME}>
146 <div className={ITEM_ROW_INNER_CLASSNAME}>
147 <div className="min-w-0">
148 <div className="flex items-center gap-2">
149 <ConnectionStatusDot
150 dotClassName={
151 availability === "online"
152 ? "bg-success"
153 : availability === "error"
154 ? "bg-destructive"
155 : availability === "checking"
156 ? "bg-warning"
157 : "bg-muted-foreground/35"
158 }
159 pingClassName={availability === "checking" ? "bg-warning/60 duration-2000" : null}
160 tooltipText={
161 availability === "online"
162 ? "Relay online"
163 : availability === "offline"
164 ? "Relay offline"
165 : availability === "checking"
166 ? "Checking relay status"
167 : (Option.getOrNull(error)?.message ?? "Relay status unavailable")
168 }
169 />
170 <p className="truncate text-sm font-medium">{environment.label}</p>
171 </div>
172 <p
173 className={cn(
174 "mt-1 truncate text-xs",
175 availability === "error" ? "text-destructive" : "text-muted-foreground",
176 )}
177 >
178 {availability === "online"
179 ? "Available · Relay online"
180 : availability === "offline"
181 ? "Available · Relay offline"
182 : availability === "checking"
183 ? "Available · Checking relay status…"
184 : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")}
185 </p>
186 </div>
187 {alreadyConnected ? (
188 <Button size="sm" variant="outline" disabled>
189 Connected
190 </Button>
191 ) : (
192 <Button
193 size="sm"
194 disabled={connectingEnvironmentId !== null}
195 onClick={() => void connectEnvironment(environment)}
196 >
197 {connectingEnvironmentId === environment.environmentId ? "Connecting…" : "Connect"}
198 </Button>
199 )}
200 </div>
201 </div>
202 );
203 });
204}
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx created+405
......@@ -0,0 +1,405 @@
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 const { isLoaded, isSignedIn, userId } = useAuth();
51 const [optOutState, setOptOutState] = useLocalStorage(
52 CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY,
53 EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE,
54 ConnectOnboardingOptOutSchema,
55 );
56
57 const desktopBridge = window.desktopBridge;
58 const primarySessionState = usePrimarySessionState();
59 const currentSessionScopes = desktopBridge
60 ? AuthAdministrativeScopes
61 : primarySessionState.data?.authenticated
62 ? (primarySessionState.data.scopes ?? null)
63 : null;
64 const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false;
65 // The publish step is only offered when we know the answer; opening the
66 // wizard before the session state resolves would let the step set change
67 // mid-flight. A failed session read still opens the wizard — it just means
68 // no publish step.
69 const sessionScopesKnown =
70 Boolean(desktopBridge) ||
71 primarySessionState.data !== null ||
72 primarySessionState.error !== null;
73
74 const controller = useCloudLinkController();
75 const showPublishStep = canManageRelay && controller.linkState.target !== null;
76 const steps: ReadonlyArray<OnboardingStep> = showPublishStep
77 ? ["publish", "devices"]
78 : ["devices"];
79
80 const [requestedAccount, setRequestedAccount] = useState<string | null>(null);
81 const [openForAccount, setOpenForAccount] = useState<string | null>(null);
82 const [step, setStep] = useState<OnboardingStep>("devices");
83 const [exposeEnvironment, setExposeEnvironment] = useState(true);
84 const [publishAgentActivity, setPublishAgentActivity] = useState(true);
85 const [dontShowAgain, setDontShowAgain] = useState(false);
86 const [isApplying, setIsApplying] = useState(false);
87 const prefilledFromLinkStateRef = useRef(false);
88 const observedAccountRef = useRef<string | null | undefined>(undefined);
89
90 const optOutAccounts = optOutState.optOutAccounts;
91
92 // Every sign-in that completes during this session requests the wizard. A
93 // cold load observes undefined → account and must not re-prompt — only a
94 // null → account transition is a sign-in.
95 useEffect(() => {
96 if (!isLoaded) return;
97 const previousAccount = observedAccountRef.current;
98 const nextAccount = isSignedIn && userId ? userId : null;
99 observedAccountRef.current = nextAccount;
100 if (previousAccount === null && nextAccount !== null) {
101 setRequestedAccount(nextAccount);
102 }
103 }, [isLoaded, isSignedIn, userId]);
104
105 // Open once the session scopes resolve so the step set is stable. Accounts
106 // that chose "Don't show this again" are skipped.
107 useEffect(() => {
108 if (requestedAccount === null || openForAccount !== null) return;
109 if (optOutAccounts.includes(requestedAccount)) {
110 setRequestedAccount(null);
111 return;
112 }
113 if (!sessionScopesKnown) return;
114 setRequestedAccount(null);
115 prefilledFromLinkStateRef.current = false;
116 setExposeEnvironment(true);
117 setPublishAgentActivity(true);
118 setDontShowAgain(false);
119 setStep(canManageRelay && controller.linkState.target !== null ? "publish" : "devices");
120 setOpenForAccount(requestedAccount);
121 }, [
122 canManageRelay,
123 controller.linkState.target,
124 openForAccount,
125 optOutAccounts,
126 requestedAccount,
127 sessionScopesKnown,
128 ]);
129
130 // Signing out (or switching accounts) mid-wizard invalidates everything the
131 // wizard would do — close it and let the sign-in trigger re-evaluate.
132 useEffect(() => {
133 if (openForAccount !== null && (!isSignedIn || userId !== openForAccount)) {
134 setOpenForAccount(null);
135 }
136 if (requestedAccount !== null && (!isSignedIn || userId !== requestedAccount)) {
137 setRequestedAccount(null);
138 }
139 }, [isSignedIn, openForAccount, requestedAccount, userId]);
140
141 // Toggles default on, but an environment that is already linked should show
142 // its actual configuration instead of silently proposing to rewrite it.
143 const linkStateData = controller.linkState.data;
144 useEffect(() => {
145 if (openForAccount === null || prefilledFromLinkStateRef.current || linkStateData === null) {
146 return;
147 }
148 prefilledFromLinkStateRef.current = true;
149 if (linkStateData.linked) {
150 setExposeEnvironment(linkStateData.managedTunnelActive ?? linkStateData.linked);
151 setPublishAgentActivity(linkStateData.publishAgentActivity);
152 }
153 }, [linkStateData, openForAccount]);
154
155 const complete = () => {
156 const account = openForAccount;
157 setOpenForAccount(null);
158 if (account !== null && dontShowAgain) {
159 setOptOutState((state) =>
160 state.optOutAccounts.includes(account)
161 ? state
162 : { optOutAccounts: [...state.optOutAccounts, account] },
163 );
164 }
165 };
166
167 const applyPublishSelection = async () => {
168 // The wizard only ever enables — with both toggles off there is nothing to
169 // apply, and an existing link must not be torn down from onboarding.
170 if (!exposeEnvironment && !publishAgentActivity) {
171 setStep("devices");
172 return;
173 }
174 setIsApplying(true);
175 const ok = await controller.reconcileCloudState({
176 managedTunnel: exposeEnvironment,
177 publish: publishAgentActivity,
178 });
179 setIsApplying(false);
180 if (!ok) return;
181 toastManager.add({
182 type: "success",
183 title: "T3 Connect enabled",
184 description: exposeEnvironment
185 ? "This environment is available to your other devices through T3 Connect."
186 : "This environment publishes agent activity to your mobile clients.",
187 });
188 setStep("devices");
189 };
190
191 return (
192 <Dialog
193 open={openForAccount !== null}
194 onOpenChange={(open) => {
195 if (!open) complete();
196 }}
197 >
198 <DialogPopup className="max-w-xl">
199 <DialogHeader>
200 <DialogTitle>Set up T3 Connect</DialogTitle>
201 <DialogDescription>
202 Mesh your devices together — publish this environment and connect the rest, all in one
203 place.
204 </DialogDescription>
205 {steps.length > 1 ? (
206 <OnboardingStepper steps={steps} currentStep={step} onStepSelect={setStep} />
207 ) : null}
208 </DialogHeader>
209 <DialogPanel>
210 {step === "publish" ? (
211 <PublishStep
212 exposeEnvironment={exposeEnvironment}
213 publishAgentActivity={publishAgentActivity}
214 disabled={isApplying}
215 operationError={controller.operationError}
216 onExposeEnvironmentChange={setExposeEnvironment}
217 onPublishAgentActivityChange={setPublishAgentActivity}
218 />
219 ) : (
220 <DevicesStep />
221 )}
222 </DialogPanel>
223 <DialogFooter variant="bare" className="sm:justify-between">
224 <label className="flex cursor-pointer items-center gap-2 self-start text-xs text-muted-foreground sm:self-center">
225 <Checkbox
226 checked={dontShowAgain}
227 onCheckedChange={(checked) => setDontShowAgain(checked === true)}
228 />
229 Don&apos;t show this again
230 </label>
231 <div className="flex flex-col-reverse gap-2 sm:flex-row">
232 {step === "publish" ? (
233 <>
234 <Button variant="ghost" disabled={isApplying} onClick={() => setStep("devices")}>
235 Not now
236 </Button>
237 <Button
238 disabled={
239 isApplying || (controller.linkState.isPending && linkStateData === null)
240 }
241 onClick={() => void applyPublishSelection()}
242 >
243 {isApplying ? "Enabling…" : "Continue"}
244 </Button>
245 </>
246 ) : (
247 <Button onClick={complete}>Done</Button>
248 )}
249 </div>
250 </DialogFooter>
251 </DialogPopup>
252 </Dialog>
253 );
254}
255
256const STEP_LABELS: Record<OnboardingStep, string> = {
257 publish: "Publish",
258 devices: "Connect devices",
259};
260
261function OnboardingStepper({
262 steps,
263 currentStep,
264 onStepSelect,
265}: {
266 readonly steps: ReadonlyArray<OnboardingStep>;
267 readonly currentStep: OnboardingStep;
268 readonly onStepSelect: (step: OnboardingStep) => void;
269}) {
270 const currentIndex = steps.indexOf(currentStep);
271 return (
272 <div className="grid grid-cols-2 gap-2">
273 {steps.map((step, index) => (
274 <button
275 key={step}
276 type="button"
277 className={cn(
278 "grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left",
279 index === currentIndex
280 ? "border-primary bg-primary/10 ring-1 ring-primary/25"
281 : index < currentIndex
282 ? "border-border bg-background"
283 : "border-border bg-muted/40",
284 )}
285 onClick={() => onStepSelect(step)}
286 >
287 <span
288 className={cn(
289 "row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border",
290 index < currentIndex
291 ? "border-primary bg-primary text-primary-foreground"
292 : index === currentIndex
293 ? "border-primary bg-background"
294 : "border-muted-foreground/35 bg-background",
295 )}
296 aria-hidden
297 >
298 {index < currentIndex ? <CheckIcon className="size-3" /> : null}
299 </span>
300 <span className="text-[10px] font-medium uppercase text-muted-foreground">
301 Step {index + 1}
302 </span>
303 <span className="truncate text-xs font-semibold text-foreground">
304 {STEP_LABELS[step]}
305 </span>
306 </button>
307 ))}
308 </div>
309 );
310}
311
312function PublishStep({
313 exposeEnvironment,
314 publishAgentActivity,
315 disabled,
316 operationError,
317 onExposeEnvironmentChange,
318 onPublishAgentActivityChange,
319}: {
320 readonly exposeEnvironment: boolean;
321 readonly publishAgentActivity: boolean;
322 readonly disabled: boolean;
323 readonly operationError: string | null;
324 readonly onExposeEnvironmentChange: (enabled: boolean) => void;
325 readonly onPublishAgentActivityChange: (enabled: boolean) => void;
326}) {
327 return (
328 <div className="space-y-3">
329 <div className="rounded-lg border">
330 <OnboardingToggleRow
331 title="Publish this environment"
332 description="Make this environment available to your other devices through T3 Connect."
333 checked={exposeEnvironment}
334 disabled={disabled}
335 onCheckedChange={onExposeEnvironmentChange}
336 />
337 <OnboardingToggleRow
338 title="Publish agent activity"
339 description="Send activity from this environment to your mobile clients for push notifications and Live Activities."
340 checked={publishAgentActivity}
341 disabled={disabled}
342 onCheckedChange={onPublishAgentActivityChange}
343 />
344 </div>
345 {operationError ? <p className="text-xs text-destructive">{operationError}</p> : null}
346 </div>
347 );
348}
349
350function OnboardingToggleRow({
351 title,
352 description,
353 checked,
354 disabled,
355 onCheckedChange,
356}: {
357 readonly title: string;
358 readonly description: string;
359 readonly checked: boolean;
360 readonly disabled: boolean;
361 readonly onCheckedChange: (enabled: boolean) => void;
362}) {
363 return (
364 <div className="flex items-start justify-between gap-4 border-t border-border/60 px-4 py-3 first:border-t-0">
365 <div className="min-w-0">
366 <p className="text-sm font-medium">{title}</p>
367 <p className="mt-1 text-xs text-muted-foreground">{description}</p>
368 </div>
369 <Switch
370 aria-label={title}
371 checked={checked}
372 disabled={disabled}
373 onCheckedChange={onCheckedChange}
374 />
375 </div>
376 );
377}
378
379function DevicesStep() {
380 const { environments } = useEnvironments();
381 const primaryEnvironment = usePrimaryEnvironment();
382 const savedEnvironmentIds = useMemo(
383 () =>
384 environments
385 .filter((environment) => environment.entry.target._tag !== "PrimaryConnectionTarget")
386 .map((environment) => environment.environmentId),
387 [environments],
388 );
389
390 return (
391 <div className="overflow-hidden rounded-lg border">
392 <CloudEnvironmentConnectRows
393 primaryEnvironmentId={primaryEnvironment?.environmentId ?? null}
394 savedEnvironmentIds={savedEnvironmentIds}
395 showSavedAsConnected
396 empty={
397 <p className="px-4 py-6 text-center text-sm text-muted-foreground">
398 No other environments are published to your account yet. Publish one from another device
399 and it will show up here.
400 </p>
401 }
402 />
403 </div>
404 );
405}
apps/web/src/components/settings/ConnectionsSettings.tsx+16-359
......@@ -7,8 +7,7 @@ import {
77 TerminalIcon,
88 TriangleAlertIcon,
99} from "lucide-react";
10import { useAuth } from "@clerk/react";
11import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react";
10import { type ReactNode, memo, useCallback, useMemo, useState } from "react";
1211import {
1312 AuthAccessReadScope,
1413 AuthAccessWriteScope,
......@@ -30,18 +29,11 @@ import {
3029 type DesktopWslState,
3130 type EnvironmentId,
3231} from "@t3tools/contracts";
33import {
34 connectionStatusText,
35 RelayConnectionRegistration,
36 RelayConnectionTarget,
37} from "@t3tools/client-runtime/connection";
38import { findErrorTraceId } from "@t3tools/client-runtime/errors";
32import { connectionStatusText } from "@t3tools/client-runtime/connection";
3933import {
4034 isAtomCommandInterrupted,
41 settlePromise,
4235 squashAtomCommandFailure,
4336} from "@t3tools/client-runtime/state/runtime";
44import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
4537import * as DateTime from "effect/DateTime";
4638import * as Option from "effect/Option";
4739
......@@ -50,7 +42,6 @@ import { cn } from "../../lib/utils";
5042import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
5143import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
5244import { applyWslEnableSelection } from "./ConnectionsSettings.logic";
53import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig";
5445import {
5546 SettingsPageContainer,
5647 SettingsRow,
......@@ -82,7 +73,6 @@ import {
8273} from "../ui/alert-dialog";
8374import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
8475import { QRCodeSvg } from "../ui/qr-code";
85import { Skeleton } from "../ui/skeleton";
8676import { Spinner } from "../ui/spinner";
8777import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
8878import { Switch } from "../ui/switch";
......@@ -117,13 +107,8 @@ import {
117107import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
118108import { useUiStateStore } from "~/uiStateStore";
119109import { resolveServerConfigVersionMismatch } from "~/versionSkew";
120import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState";
121110import { hasCloudPublicConfig } from "~/cloud/publicConfig";
122import {
123 linkPrimaryEnvironment as linkPrimaryEnvironmentAtom,
124 unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom,
125 updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom,
126} from "~/cloud/linkEnvironmentAtoms";
111import { useCloudLinkController } from "~/cloud/useCloudLinkController";
127112import { authEnvironment } from "~/state/auth";
128113import { environmentCatalog } from "~/connection/catalog";
129114import {
......@@ -141,10 +126,11 @@ import {
141126 type EnvironmentPresentation,
142127 useEnvironments,
143128 usePrimaryEnvironment,
144 useRelayEnvironmentDiscovery,
145129} from "~/state/environments";
146import { relayEnvironmentDiscovery } from "~/state/relay";
147130import { 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
149135const DEFAULT_TAILSCALE_SERVE_PORT = 443;
150136const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = [];
......@@ -260,60 +246,6 @@ function AccessScopeSummary({
260246 );
261247}
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
317249function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string {
318250 const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname;
319251 return target.port ? `${authority}:${target.port}` : authority;
......@@ -437,13 +369,8 @@ function formatDesktopSshConnectionError(error: unknown): string {
437369 return withoutTaggedErrorPrefix.trim() || fallback;
438370}
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";
442372const 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
447374type AccessSectionPresentation = "current" | "endpoint-rail";
448375
449376function accessRowClassName(_presentation: AccessSectionPresentation) {
......@@ -1629,51 +1556,17 @@ function CloudLinkSwitch({
16291556}
16301557
16311558function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) {
1632 const { getToken, isSignedIn } = useAuth();
1633 const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, {
1634 reportFailure: false,
1635 });
1636 const linkPrimaryEnvironment = useAtomCommand(linkPrimaryEnvironmentAtom, {
1637 reportFailure: false,
1638 });
1639 const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, {
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);
1559 const {
1560 isSignedIn,
1561 linkState: primaryCloudLinkState,
1562 managedTunnelActive,
1563 publishAgentActivity,
1564 operationError,
1565 reconcileCloudState,
1566 } = useCloudLinkController();
16481567 const [isUpdating, setIsUpdating] = useState(false);
16491568 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;
16771570 const disabledReason = !isSignedIn
16781571 ? "Sign in to T3 Connect to manage this environment."
16791572 : !canManageRelay
......@@ -1681,86 +1574,6 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
16811574 : null;
16821575 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
17641577 const updateManagedTunnel = async (enabled: boolean) => {
17651578 setIsUpdating(true);
17661579 const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity });
......@@ -1857,163 +1670,6 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable
18571670 );
18581671}
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
20171673function CloudRemoteEnvironmentRows({
20181674 primaryEnvironmentId,
20191675 savedEnvironmentIds,
......@@ -2022,9 +1678,10 @@ function CloudRemoteEnvironmentRows({
20221678 readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>;
20231679}) {
20241680 return hasCloudPublicConfig() ? (
2025 <ConfiguredCloudRemoteEnvironmentRows
1681 <CloudEnvironmentConnectRows
20261682 primaryEnvironmentId={primaryEnvironmentId}
20271683 savedEnvironmentIds={savedEnvironmentIds}
1684 empty={<EmptyRemoteEnvironments />}
20281685 />
20291686 ) : savedEnvironmentIds.length === 0 ? (
20301687 <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(
182182 if (session === null) {
183183 return yield* new ConnectionBlockedError({
184184 reason: "authentication",
185 detail: "Sign in to T3 Cloud to connect this environment.",
185 detail: "Sign in to T3 Connect to connect this environment.",
186186 });
187187 }
188188 const token = yield* session.readClerkToken().pipe(
......@@ -197,7 +197,7 @@ const capabilitiesLayer = Layer.effectContext(
197197 if (token === null) {
198198 return yield* new ConnectionBlockedError({
199199 reason: "authentication",
200 detail: "The T3 Cloud session is unavailable.",
200 detail: "The T3 Connect session is unavailable.",
201201 });
202202 }
203203 return token;
apps/web/src/routes/__root.tsx+2
......@@ -14,6 +14,7 @@ import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding";
1414import { resolveServerBackedAppDisplayName } from "../branding.logic";
1515import { AppSidebarLayout } from "../components/AppSidebarLayout";
1616import { CommandPalette } from "../components/CommandPalette";
17import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
1718import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
1819import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
1920import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
......@@ -128,6 +129,7 @@ function RootRouteView() {
128129 <DocumentTitleSync />
129130 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
130131 <RelayClientInstallDialog />
132 <ConnectOnboardingDialog />
131133 <SshPasswordPromptDialog />
132134 <SlowRpcRequestToastCoordinator />
133135 <HostedStaticEnvironmentBootstrap />
packages/client-runtime/src/relay/discovery.test.ts+49
......@@ -342,6 +342,55 @@ describe("RelayEnvironmentDiscovery", () => {
342342 }),
343343 );
344344
345 it.effect("refreshes proactively when credentials change before any manual refresh", () =>
346 Effect.gen(function* () {
347 const harness = yield* makeHarness();
348 yield* Effect.gen(function* () {
349 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
350 const requests = yield* Ref.get(harness.statusRequests);
351 for (const environment of environments) {
352 yield* Deferred.succeed(
353 requests.get(environment.environmentId)!,
354 status(environment, "online"),
355 );
356 }
357
358 // Let the scoped wakeup subscription start before emitting, mirroring
359 // a real sign-in which always happens long after service start.
360 yield* Effect.yieldNow;
361
362 // Sign-in activates the session and emits credentials-changed; the
363 // list must populate without any screen having asked for a refresh.
364 yield* harness.wake("credentials-changed");
365 const populated = yield* SubscriptionRef.changes(discovery.state).pipe(
366 Stream.filter(
367 (state) => state.environments.size === environments.length && !state.refreshing,
368 ),
369 Stream.runHead,
370 Effect.map(Option.getOrThrow),
371 );
372 expect(Option.isNone(populated.error)).toBe(true);
373 expect(yield* Ref.get(harness.listCalls)).toBe(1);
374 }).pipe(Effect.provide(harness.layer), Effect.scoped);
375 }),
376 );
377
378 it.effect("settles to a clean empty state when refreshed while signed out", () =>
379 Effect.gen(function* () {
380 const harness = yield* makeHarness();
381 yield* Effect.gen(function* () {
382 const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery;
383 yield* Ref.set(harness.clerkToken, null);
384 yield* discovery.refresh;
385
386 const state = yield* SubscriptionRef.get(discovery.state);
387 expect(state.environments.size).toBe(0);
388 expect(state.refreshing).toBe(false);
389 expect(Option.isNone(state.error)).toBe(true);
390 }).pipe(Effect.provide(harness.layer));
391 }),
392 );
393
345394 it.effect("does not republish stale rows after sign-out invalidates an in-flight refresh", () =>
346395 Effect.gen(function* () {
347396 const harness = yield* makeHarness();
packages/client-runtime/src/relay/discovery.ts+11-5
......@@ -279,10 +279,15 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () {
279279 if ((yield* Ref.get(accountGeneration)) !== generation) {
280280 return;
281281 }
282 // Signed out is the idle state, not a failure: the proactive
283 // refresh on credentials-changed also runs on sign-out and must
284 // settle back to a clean empty list.
285 const signedOut =
286 error._tag === "ConnectionBlockedError" && error.reason === "authentication";
282287 yield* SubscriptionRef.update(state, (current) => ({
283288 ...current,
284289 refreshing: false,
285 error: Option.some(error),
290 error: signedOut ? Option.none() : Option.some(error),
286291 }));
287292 }),
288293 ),
......@@ -311,11 +316,12 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () {
311316 yield* Ref.update(accountGeneration, (current) => current + 1);
312317 yield* Ref.set(activeAccountId, Option.none());
313318 yield* Ref.set(offlineReportFingerprints, new Map());
314 const shouldRefresh = yield* Ref.get(hasRefreshed);
315319 yield* SubscriptionRef.set(state, EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE);
316 if (shouldRefresh) {
317 yield* refresh.pipe(Effect.forkScoped);
318 }
320 // Refresh proactively — this wakeup fires when a session activates
321 // (sign-in or cold start), and the list should be populated before
322 // any screen asks for it. A signed-out refresh settles back to the
323 // clean empty state.
324 yield* refresh.pipe(Effect.forkScoped);
319325 })
320326 : Effect.void,
321327 ),
packages/client-runtime/src/relay/managedRelayState.ts+4-4
......@@ -123,7 +123,7 @@ export function createManagedRelaySession(input: ManagedRelaySessionInput): Mana
123123 try: () => readCachedClerkToken(nowMillis),
124124 catch: (cause) =>
125125 new ManagedRelaySessionError({
126 message: "Could not obtain the T3 Cloud session token.",
126 message: "Could not obtain the T3 Connect session token.",
127127 cause,
128128 }),
129129 });
......@@ -181,7 +181,7 @@ function readSessionClerkToken(
181181 ? Effect.succeed(token)
182182 : Effect.fail(
183183 new ManagedRelaySessionError({
184 message: "The T3 Cloud session token is unavailable.",
184 message: "The T3 Connect session token is unavailable.",
185185 }),
186186 ),
187187 ),
......@@ -226,7 +226,7 @@ function requireClerkToken(
226226 if (!session || session.accountId !== accountId) {
227227 return Effect.fail(
228228 new ManagedRelaySessionError({
229 message: "Sign in to T3 Cloud before loading relay data.",
229 message: "Sign in to T3 Connect before loading relay data.",
230230 }),
231231 );
232232 }
......@@ -296,7 +296,7 @@ export function readManagedRelaySnapshotState<A>(
296296 let errorTraceId: string | null = null;
297297 if (result._tag === "Failure") {
298298 const cause = Cause.squash(result.cause);
299 error = cause instanceof Error ? cause.message : "Could not load T3 Cloud data.";
299 error = cause instanceof Error ? cause.message : "Could not load T3 Connect data.";
300300 errorTraceId = findErrorTraceId(cause);
301301 }
302302 return {