diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 29cf29da90a..d68d6f2b97b 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -17,6 +17,8 @@ import { AppText as Text } from "./components/AppText"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; +import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; +import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation"; import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider"; @@ -220,6 +222,7 @@ const NewTaskSheetStack = createNativeStackNavigator({ // influence the adaptive workspace layout: opening Settings over Home should // not flip the sidebar in or change the active thread. const WORKSPACE_OVERLAY_ROUTES = new Set([ + "ConnectOnboarding", "Connections", "ConnectionsNew", "GitBranches", @@ -251,6 +254,8 @@ function RootStackLayout(props: { }) { useAgentNotificationNavigation(); useThreadOutboxDrain(); + // Presents the T3 Connect onboarding sheet after an in-session sign-in. + useConnectOnboardingNavigation(); // Full pathname (sheets included) for keyboard-command scoping; the // workspace layout only reacts to the underlying non-overlay route. const path = getPathFromState(props.state, navigationPathConfig); @@ -412,6 +417,20 @@ export const RootStack = createNativeStackNavigator({ sheetGrabberVisible: true, }, }), + ConnectOnboarding: createNativeStackScreen({ + screen: ConnectOnboardingRouteScreen, + linking: "connect-onboarding", + options: { + // Root screenOptions hide headers; formSheets that want the native + // title bar opt back in with the sheet header preset. + ...SHEET_SOLID_HEADER_OPTIONS, + title: "Set up T3 Connect", + gestureEnabled: true, + presentation: "formSheet", + sheetAllowedDetents: [0.6, 0.95], + sheetGrabberVisible: true, + }, + }), Connections: createNativeStackScreen({ screen: ConnectionsRouteScreen, linking: "connections", diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 769632a8fcb..fd3c1530392 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -92,7 +92,7 @@ const capabilitiesLayer = Layer.succeedContext( if (session === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - detail: "Sign in to T3 Cloud to connect this environment.", + detail: "Sign in to T3 Connect to connect this environment.", }); } const token = yield* session.readClerkToken().pipe( @@ -107,7 +107,7 @@ const capabilitiesLayer = Layer.succeedContext( if (token === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - detail: "The T3 Cloud session is unavailable.", + detail: "The T3 Connect session is unavailable.", }); } return token; diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index 4180ae15c5e..85ab55491bb 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -18,6 +18,7 @@ import { setAgentAwarenessRelayTokenProvider, unregisterAgentAwarenessDeviceForCurrentUser, } from "../agent-awareness/remoteRegistration"; +import { requestConnectOnboarding } from "./connectOnboarding"; import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; function resetManagedRelayTokenCache() { @@ -67,6 +68,14 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { const nextAccount = isSignedIn && userId ? userId : null; observedAccountRef.current = nextAccount; + // Every sign-in that completes during this session (a cold start observes + // undefined → account and must not re-prompt) requests the T3 Connect + // onboarding sheet — sign-out clears the connected environments, so each + // new session starts with no devices to reach. + if (previousObservedAccount === null && nextAccount !== null) { + requestConnectOnboarding(nextAccount); + } + const queueAccountCleanup = ( previous: { readonly userId: string; diff --git a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx index dbaf564e91f..3bc51e397d8 100644 --- a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx +++ b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx @@ -39,7 +39,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } You are on the waitlist - We will email you when your T3 Cloud access is ready. + We will email you when your T3 Connect access is ready. diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx new file mode 100644 index 00000000000..3e71b600ebe --- /dev/null +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -0,0 +1,111 @@ +import { NativeHeaderToolbar } from "../../native/StackHeader"; +import { useAuth } from "@clerk/expo"; +import { useNavigation } from "@react-navigation/native"; +import { useCallback, useState } from "react"; +import { Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; +import { AppText as Text } from "../../components/AppText"; +import { useRemoteConnections } from "../../state/use-remote-environment-registry"; +import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; +import { splitEnvironmentSections } from "../connection/environmentSections"; +import { useConnectionController } from "../connection/useConnectionController"; +import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut"; +import { hasCloudPublicConfig } from "./publicConfig"; + +/** + * Post-sign-in onboarding sheet for T3 Connect. Mobile never publishes + * environments itself — it consumes ones published elsewhere — so this simply + * surfaces the account's T3 Connect environments right after sign-in so every + * device can be connected in one go. It shows on every sign-in: sign-out + * clears the connected environments, so each new session starts from zero. + */ +export function ConnectOnboardingRouteScreen() { + return hasCloudPublicConfig() ? : null; +} + +function ConfiguredConnectOnboardingRouteScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); + const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections(); + const { refreshRelayEnvironments } = useConnectionController(); + const { connectedCloudEnvironments } = splitEnvironmentSections({ + connectedEnvironments, + cloudEnvironments: null, + }); + + // Pull-to-refresh tracks its own spinner instead of discovery's refreshing + // flag, so background refreshes (e.g. the sign-in one) don't yank the + // content down. + const [isPullRefreshing, setIsPullRefreshing] = useState(false); + const handlePullRefresh = useCallback(() => { + void (async () => { + setIsPullRefreshing(true); + await refreshRelayEnvironments(); + setIsPullRefreshing(false); + })(); + }, [refreshRelayEnvironments]); + + const handleClose = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleDontShowAgain = useCallback(() => { + if (userId) { + void (async () => { + const result = await settlePromise(() => optOutOfConnectOnboarding(userId)); + reportAtomCommandResult(result, { label: "connect onboarding opt-out" }); + })(); + } + navigation.goBack(); + }, [navigation, userId]); + + return ( + + + + + + } + > + {isSignedIn ? ( + + ) : ( + + + Sign in to your T3 account to set up T3 Connect. + + + )} + + {userId ? ( + + {"Don't show this again"} + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/cloud/connectOnboarding.ts b/apps/mobile/src/features/cloud/connectOnboarding.ts new file mode 100644 index 00000000000..9d3944331a7 --- /dev/null +++ b/apps/mobile/src/features/cloud/connectOnboarding.ts @@ -0,0 +1,24 @@ +import { Atom } from "effect/unstable/reactivity"; + +import { appAtomRegistry } from "../../state/atom-registry"; + +// Signals RootStackLayout (inside the navigation tree) that an in-session +// sign-in just completed. Holds the account id so a sign-out between the +// request and the navigation cannot present the sheet for the wrong account. +export const connectOnboardingRequestAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:connect-onboarding-request"), +); + +/** + * Requests the onboarding sheet for the given account. Sign-out clears the + * connected environments, so onboarding runs on every in-session sign-in — + * each new session starts with no connected devices. + */ +export function requestConnectOnboarding(accountId: string): void { + appAtomRegistry.set(connectOnboardingRequestAtom, accountId); +} + +export function clearConnectOnboardingRequest(): void { + appAtomRegistry.set(connectOnboardingRequestAtom, null); +} diff --git a/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts b/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts new file mode 100644 index 00000000000..97591f943ea --- /dev/null +++ b/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts @@ -0,0 +1,45 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useNavigation } from "@react-navigation/native"; +import { useEffect } from "react"; + +import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding"; +import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut"; + +// Sign-in happens inside the Settings sheet; give its detent/session-state +// transitions a beat to settle before presenting another formSheet on top. +const PRESENT_ONBOARDING_DELAY_MS = 600; + +/** + * Consumes the onboarding request inside the navigation tree (RootStackLayout) + * and presents the onboarding formSheet. Lives apart from connectOnboarding.ts + * so non-navigation consumers (CloudAuthProvider) do not pull + * @react-navigation into their module graph. + */ +export function useConnectOnboardingNavigation(): void { + const navigation = useNavigation(); + const requestedAccountId = useAtomValue(connectOnboardingRequestAtom); + + useEffect(() => { + if (requestedAccountId === null) { + return; + } + let cancelled = false; + const timer = setTimeout(() => { + void (async () => { + // A failed preference read prefers showing the sheet. + const optedOut = await isConnectOnboardingOptedOut(requestedAccountId).catch(() => false); + if (cancelled) { + return; + } + clearConnectOnboardingRequest(); + if (!optedOut) { + navigation.navigate("ConnectOnboarding"); + } + })(); + }, PRESENT_ONBOARDING_DELAY_MS); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [navigation, requestedAccountId]); +} diff --git a/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts b/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts new file mode 100644 index 00000000000..0899509ea22 --- /dev/null +++ b/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts @@ -0,0 +1,21 @@ +import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; + +// Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports +// the request signal) never pulls lib/storage — expo-secure-store — into its +// module graph; that breaks CloudAuthProvider.test.ts suite loading. + +/** Whether the account chose "Don't show this again". */ +export async function isConnectOnboardingOptedOut(accountId: string): Promise { + const preferences = await loadPreferences(); + return preferences.connectOnboardingOptOutAccounts?.includes(accountId) ?? false; +} + +/** Persists "Don't show this again" for the account. */ +export async function optOutOfConnectOnboarding(accountId: string): Promise { + const preferences = await loadPreferences(); + const optedOut = preferences.connectOnboardingOptOutAccounts ?? []; + if (optedOut.includes(accountId)) { + return; + } + await savePreferencesPatch({ connectOnboardingOptOutAccounts: [...optedOut, accountId] }); +} diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx new file mode 100644 index 00000000000..2f0c3c54c64 --- /dev/null +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -0,0 +1,366 @@ +import { useAuth } from "@clerk/expo"; +import { SymbolView } from "expo-symbols"; +import { + connectionStatusText, + type EnvironmentConnectionPhase, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useState } from "react"; +import { + ActivityIndicator, + Pressable, + Switch, + type NativeSyntheticEvent, + type TextLayoutEventData, + View, +} from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { useThemeColor } from "../../lib/useThemeColor"; +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; +import { ConnectionStatusDot } from "./ConnectionStatusDot"; +import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; + +/** + * "T3 Connect" section: every environment published to the signed-in account, + * with connect switches, availability status, refresh, and loading/error + * states. Shared between the Settings environments screen and the T3 Connect + * onboarding sheet. + */ +export function CloudEnvironmentRows(props: { + readonly connectedCloudEnvironments: ReadonlyArray; + readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; + /** + * Hide the "T3 Connect" section title + refresh button for hosts that + * provide their own chrome (the onboarding sheet's native header and + * pull-to-refresh). + */ + readonly showHeader?: boolean; +}) { + const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const controller = useConnectionController(); + const iconColor = useThemeColor("--color-icon"); + const availableCloudEnvironments = controller.availableRelayEnvironments; + const [expandedErrorId, setExpandedErrorId] = useState(null); + const hasCloudRows = + props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; + + const handleConnectCloudEnvironment = useCallback( + (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment), + [controller], + ); + + const handleDisconnectCloudEnvironment = useCallback( + (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId), + [controller], + ); + + const handleToggleCloudError = useCallback((environmentId: string) => { + setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); + }, []); + + const showHeader = props.showHeader ?? true; + + if (!isSignedIn) return null; + + return ( + + {showHeader ? ( + + T3 Connect + { + void controller.refreshRelayEnvironments(); + }} + className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" + > + {controller.relayDiscovery.isRefreshing ? ( + + ) : ( + + )} + + + ) : null} + + {hasCloudRows ? ( + + {props.connectedCloudEnvironments.map((environment, index) => ( + props.onReconnectEnvironment(environment.environmentId)} + onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)} + errorExpanded={expandedErrorId === environment.environmentId} + onToggleError={() => handleToggleCloudError(environment.environmentId)} + /> + ))} + {availableCloudEnvironments.map((environment, index) => ( + 0 || index !== 0} + onConnect={() => handleConnectCloudEnvironment(environment)} + errorExpanded={expandedErrorId === environment.environment.environmentId} + onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} + /> + ))} + + ) : controller.relayDiscovery.isRefreshing ? ( + + + + Loading linked cloud environments. + + + ) : controller.relayDiscovery.error ? ( + + + Could not load T3 Connect environments + + {controller.relayDiscovery.error} + {controller.relayDiscovery.errorTraceId ? ( + + ) : null} + + ) : ( + + + No additional linked cloud environments. + + + )} + + ); +} + +function ConnectedCloudEnvironmentRow(props: { + readonly environment: ConnectedEnvironmentSummary; + readonly borderTop: boolean; + readonly errorExpanded: boolean; + readonly onConnect: () => void; + readonly onDisconnect: () => void; + readonly onToggleError: () => void; +}) { + return ( + { + if (enabled) { + props.onConnect(); + return; + } + props.onDisconnect(); + }} + onToggleError={props.onToggleError} + value={props.environment.connectionState !== "available"} + /> + ); +} + +function CloudEnvironmentRow(props: { + readonly environment: RelayEnvironmentView; + readonly borderTop: boolean; + readonly errorExpanded: boolean; + readonly onConnect: () => void; + readonly onToggleError: () => void; +}) { + const presentation = availableCloudEnvironmentPresentation({ + isStatusPending: props.environment.availability === "checking", + status: props.environment.status, + statusError: props.environment.error, + statusErrorTraceId: props.environment.traceId, + }); + + return ( + { + if (enabled) { + props.onConnect(); + } + }} + onToggleError={props.onToggleError} + statusText={presentation.statusText} + value={false} + /> + ); +} + +function CloudEnvironmentRowShell(props: { + readonly borderTop: boolean; + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; + readonly connectionState: EnvironmentConnectionPhase; + readonly disabled?: boolean; + readonly errorExpanded: boolean; + readonly label: string; + readonly onToggleError: () => void; + readonly onValueChange: (enabled: boolean) => void; + readonly statusText?: string; + readonly value: boolean; +}) { + const activeTrack = String(useThemeColor("--color-switch-active")); + const track = String(useThemeColor("--color-secondary-border")); + const chevron = useThemeColor("--color-chevron"); + const isRetrying = + props.connectionState === "connecting" || props.connectionState === "reconnecting"; + const shouldPulse = isRetrying; + const statusText = + props.statusText ?? + connectionStatusText({ + phase: props.connectionState, + error: props.connectionError, + traceId: props.connectionErrorTraceId, + }); + const statusClassName = props.connectionError + ? "text-rose-500 dark:text-rose-400" + : "text-foreground-muted"; + const [errorMeasurement, setErrorMeasurement] = useState<{ + readonly text: string; + readonly lineCount: number; + } | null>(null); + const errorTraceId = props.connectionErrorTraceId; + const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText; + const errorLineCount = + errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0; + const errorCanExpand = props.connectionError !== null && errorLineCount > 1; + const isErrorExpanded = errorCanExpand && props.errorExpanded; + const StatusContainer = errorCanExpand ? Pressable : View; + const onMeasuredErrorTextLayout = useCallback( + (event: NativeSyntheticEvent) => { + if (!props.connectionError) { + return; + } + const nextLineCount = event.nativeEvent.lines.length; + setErrorMeasurement((currentMeasurement) => + currentMeasurement?.text === measuredErrorText && + currentMeasurement.lineCount === nextLineCount + ? currentMeasurement + : { text: measuredErrorText, lineCount: nextLineCount }, + ); + }, + [measuredErrorText, props.connectionError], + ); + return ( + + + + + + {props.label} + + + {props.connectionError ? ( + + {measuredErrorText} + + ) : null} + + + {statusText} + {errorTraceId ? ( + <> + {" Trace ID: "} + { + event.stopPropagation(); + copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); + }} + onPress={(event) => { + event.stopPropagation(); + }} + style={{ textDecorationStyle: "dotted" }} + > + {errorTraceId} + + + ) : null} + + {errorCanExpand ? ( + + ) : null} + + + + + ); +} + +function CopyTraceIdButton(props: { readonly traceId: string }) { + const iconColor = useThemeColor("--color-icon"); + + return ( + { + copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); + }} + className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" + > + + Copy trace ID + + ); +} diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 394bc180183..4b2da3e24ca 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -135,7 +135,7 @@ export function ConnectionEnvironmentRow(props: { > {props.environment.isRelayManaged ? ( - Managed by T3 Cloud. Tunnel details update automatically. + Managed by T3 Connect. Tunnel details update automatically. ) : ( <> diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts index 497af4bfac4..75f78738ade 100644 --- a/apps/mobile/src/features/connection/environmentSections.test.ts +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -35,7 +35,7 @@ function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { } describe("mobile environment settings sections", () => { - it("keeps saved relay-managed connections under T3 Cloud", () => { + it("keeps saved relay-managed connections under T3 Connect", () => { const local = connectedEnvironment({ environmentId: "environment-local", isRelayManaged: false, diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index c8861ec75a1..d5eecb5016f 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -1,38 +1,18 @@ -import { useAuth } from "@clerk/expo"; import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useNavigation } from "@react-navigation/native"; import { SymbolView } from "expo-symbols"; -import { - connectionStatusText, - type EnvironmentConnectionPhase, -} from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useState } from "react"; -import { - ActivityIndicator, - Pressable, - ScrollView, - Switch, - type NativeSyntheticEvent, - type TextLayoutEventData, - View, -} from "react-native"; +import { ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; -import { - type RelayEnvironmentView, - useConnectionController, -} from "../connection/useConnectionController"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; -import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; +import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; -import { ConnectionStatusDot } from "../connection/ConnectionStatusDot"; import { splitEnvironmentSections } from "../connection/environmentSections"; import { cn } from "../../lib/cn"; -import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useThemeColor } from "../../lib/useThemeColor"; -import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; export function SettingsEnvironmentsRouteScreen() { @@ -117,7 +97,7 @@ export function SettingsEnvironmentsRouteScreen() { )} {hasCloudPublicConfig() ? ( - @@ -126,323 +106,3 @@ export function SettingsEnvironmentsRouteScreen() { ); } - -function ConfiguredCloudEnvironmentRows(props: { - readonly connectedCloudEnvironments: ReadonlyArray; - readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; -}) { - const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - const controller = useConnectionController(); - const iconColor = useThemeColor("--color-icon"); - const availableCloudEnvironments = controller.availableRelayEnvironments; - const [expandedErrorId, setExpandedErrorId] = useState(null); - const hasCloudRows = - props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; - - const handleConnectCloudEnvironment = useCallback( - (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment), - [controller], - ); - - const handleDisconnectCloudEnvironment = useCallback( - (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId), - [controller], - ); - - const handleToggleCloudError = useCallback((environmentId: string) => { - setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); - }, []); - - if (!isSignedIn) return null; - - return ( - - - T3 Cloud - { - void controller.refreshRelayEnvironments(); - }} - className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" - > - {controller.relayDiscovery.isRefreshing ? ( - - ) : ( - - )} - - - - {hasCloudRows ? ( - - {props.connectedCloudEnvironments.map((environment, index) => ( - props.onReconnectEnvironment(environment.environmentId)} - onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)} - errorExpanded={expandedErrorId === environment.environmentId} - onToggleError={() => handleToggleCloudError(environment.environmentId)} - /> - ))} - {availableCloudEnvironments.map((environment, index) => ( - 0 || index !== 0} - onConnect={() => handleConnectCloudEnvironment(environment)} - errorExpanded={expandedErrorId === environment.environment.environmentId} - onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} - /> - ))} - - ) : controller.relayDiscovery.isRefreshing ? ( - - - - Loading linked cloud environments. - - - ) : controller.relayDiscovery.error ? ( - - - Could not load T3 Cloud environments - - {controller.relayDiscovery.error} - {controller.relayDiscovery.errorTraceId ? ( - - ) : null} - - ) : ( - - - No additional linked cloud environments. - - - )} - - ); -} - -function ConnectedCloudEnvironmentRow(props: { - readonly environment: ConnectedEnvironmentSummary; - readonly borderTop: boolean; - readonly errorExpanded: boolean; - readonly onConnect: () => void; - readonly onDisconnect: () => void; - readonly onToggleError: () => void; -}) { - return ( - { - if (enabled) { - props.onConnect(); - return; - } - props.onDisconnect(); - }} - onToggleError={props.onToggleError} - value={props.environment.connectionState !== "available"} - /> - ); -} - -function CloudEnvironmentRow(props: { - readonly environment: RelayEnvironmentView; - readonly borderTop: boolean; - readonly errorExpanded: boolean; - readonly onConnect: () => void; - readonly onToggleError: () => void; -}) { - const presentation = availableCloudEnvironmentPresentation({ - isStatusPending: props.environment.availability === "checking", - status: props.environment.status, - statusError: props.environment.error, - statusErrorTraceId: props.environment.traceId, - }); - - return ( - { - if (enabled) { - props.onConnect(); - } - }} - onToggleError={props.onToggleError} - statusText={presentation.statusText} - value={false} - /> - ); -} - -function CloudEnvironmentRowShell(props: { - readonly borderTop: boolean; - readonly connectionError: string | null; - readonly connectionErrorTraceId: string | null; - readonly connectionState: EnvironmentConnectionPhase; - readonly disabled?: boolean; - readonly errorExpanded: boolean; - readonly label: string; - readonly onToggleError: () => void; - readonly onValueChange: (enabled: boolean) => void; - readonly statusText?: string; - readonly value: boolean; -}) { - const activeTrack = String(useThemeColor("--color-switch-active")); - const track = String(useThemeColor("--color-secondary-border")); - const chevron = useThemeColor("--color-chevron"); - const isRetrying = - props.connectionState === "connecting" || props.connectionState === "reconnecting"; - const shouldPulse = isRetrying; - const statusText = - props.statusText ?? - connectionStatusText({ - phase: props.connectionState, - error: props.connectionError, - traceId: props.connectionErrorTraceId, - }); - const statusClassName = props.connectionError - ? "text-rose-500 dark:text-rose-400" - : "text-foreground-muted"; - const [errorMeasurement, setErrorMeasurement] = useState<{ - readonly text: string; - readonly lineCount: number; - } | null>(null); - const errorTraceId = props.connectionErrorTraceId; - const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText; - const errorLineCount = - errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0; - const errorCanExpand = props.connectionError !== null && errorLineCount > 1; - const isErrorExpanded = errorCanExpand && props.errorExpanded; - const StatusContainer = errorCanExpand ? Pressable : View; - const onMeasuredErrorTextLayout = useCallback( - (event: NativeSyntheticEvent) => { - if (!props.connectionError) { - return; - } - const nextLineCount = event.nativeEvent.lines.length; - setErrorMeasurement((currentMeasurement) => - currentMeasurement?.text === measuredErrorText && - currentMeasurement.lineCount === nextLineCount - ? currentMeasurement - : { text: measuredErrorText, lineCount: nextLineCount }, - ); - }, - [measuredErrorText, props.connectionError], - ); - return ( - - - - - - {props.label} - - - {props.connectionError ? ( - - {measuredErrorText} - - ) : null} - - - {statusText} - {errorTraceId ? ( - <> - {" Trace ID: "} - { - event.stopPropagation(); - copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); - }} - onPress={(event) => { - event.stopPropagation(); - }} - style={{ textDecorationStyle: "dotted" }} - > - {errorTraceId} - - - ) : null} - - {errorCanExpand ? ( - - ) : null} - - - - - ); -} - -function CopyTraceIdButton(props: { readonly traceId: string }) { - const iconColor = useThemeColor("--color-icon"); - - return ( - { - copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); - }} - className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" - > - - Copy trace ID - - ); -} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 2b07b96b3c0..a4a5499eeee 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -240,8 +240,8 @@ function ConfiguredSettingsRouteScreen() { const promptSignIn = useCallback(() => { Alert.alert( - "Request T3 Cloud access", - "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.", + "Request T3 Connect access", + "Live Activity updates require approved T3 Connect access so relay can deliver updates to this device.", [ { text: "Cancel", style: "cancel" }, { diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index 57f7359ec43..ed3191cfa29 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -69,6 +69,8 @@ export interface Preferences { /** Code/diff font size override; null/absent means derived from baseFontSize. */ readonly codeFontSize?: number | null; readonly codeWordBreak?: boolean; + /** Cloud account ids that opted out of the T3 Connect onboarding sheet. */ + readonly connectOnboardingOptOutAccounts?: ReadonlyArray; } async function readStorageItem(key: MobileStorageKeyValue): Promise { @@ -167,6 +169,7 @@ export async function loadPreferences(): Promise { markdownFontSize?: number; codeFontSize?: number | null; codeWordBreak?: boolean; + connectOnboardingOptOutAccounts?: ReadonlyArray; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -187,6 +190,11 @@ export async function loadPreferences(): Promise { if (typeof parsed.codeWordBreak === "boolean") { preferences.codeWordBreak = parsed.codeWordBreak; } + if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) { + preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter( + (account): account is string => typeof account === "string", + ); + } return preferences; } diff --git a/apps/web/src/cloud/connectOnboarding.ts b/apps/web/src/cloud/connectOnboarding.ts new file mode 100644 index 00000000000..7d61e11c6c6 --- /dev/null +++ b/apps/web/src/cloud/connectOnboarding.ts @@ -0,0 +1,18 @@ +import * as Schema from "effect/Schema"; + +/** + * Accounts that opted out of the post-sign-in T3 Connect onboarding wizard + * ("Don't show this again"). The wizard otherwise shows on every sign-in, + * since sign-out clears the connected environments. + */ +export const CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY = "t3code:connect-onboarding-opt-out:v1"; + +export const ConnectOnboardingOptOutSchema = Schema.Struct({ + optOutAccounts: Schema.Array(Schema.String), +}); + +export type ConnectOnboardingOptOutState = typeof ConnectOnboardingOptOutSchema.Type; + +export const EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE: ConnectOnboardingOptOutState = { + optOutAccounts: [], +}; diff --git a/apps/web/src/cloud/useCloudLinkController.ts b/apps/web/src/cloud/useCloudLinkController.ts new file mode 100644 index 00000000000..f3cee3be76d --- /dev/null +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -0,0 +1,161 @@ +import { useAuth } from "@clerk/react"; +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useState } from "react"; + +import { toastManager } from "../components/ui/toast"; +import { relayEnvironmentDiscovery } from "../state/relay"; +import { useAtomCommand } from "../state/use-atom-command"; +import { + linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, + unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, + updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, +} from "./linkEnvironmentAtoms"; +import { usePrimaryCloudLinkState } from "./primaryCloudLinkState"; +import { resolveRelayClerkTokenOptions } from "./publicConfig"; + +export interface CloudLinkDesiredState { + readonly managedTunnel: boolean; + readonly publish: boolean; +} + +/** + * Drives the primary environment's T3 Connect link. T3 Connect (managed + * tunnel) and agent-activity publishing are independent capabilities backed by + * a single relay link, so consumers express the full desired state and + * `reconcileCloudState` applies it: unlink when neither is wanted, otherwise + * (re)link with the mode the managed-tunnel bit implies and set the publish + * preference. Re-linking only happens when the managed-tunnel mode actually + * changes, so flipping publish alone is cheap. + */ +export function useCloudLinkController() { + const { getToken, isSignedIn } = useAuth(); + const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { + reportFailure: false, + }); + const linkPrimaryEnvironment = useAtomCommand(linkPrimaryEnvironmentAtom, { + reportFailure: false, + }); + const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, { + reportFailure: false, + }); + const updatePrimaryEnvironmentPreferences = useAtomCommand( + updatePrimaryEnvironmentPreferencesAtom, + { reportFailure: false }, + ); + const primaryCloudLinkState = usePrimaryCloudLinkState(); + const [operationError, setOperationError] = useState(null); + + const reportUpdateFailure = (cause: unknown) => { + const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access."; + const traceId = findErrorTraceId(cause); + console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause }); + setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); + toastManager.add({ + type: "error", + title: "Could not update T3 Connect", + description: message, + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, + }); + }; + + // Older environment servers predate the managedTunnelActive field; for them a + // link always implies a managed tunnel, so fall back to `linked`. + const managedTunnelActive = + primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false; + const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; + const linked = primaryCloudLinkState.data?.linked ?? false; + + const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise => { + setOperationError(null); + const target = primaryCloudLinkState.target; + if (!target) { + reportUpdateFailure(new Error("Local environment is not ready yet.")); + return false; + } + const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); + if (tokenResult._tag === "Failure") { + reportUpdateFailure(squashAtomCommandFailure(tokenResult)); + return false; + } + const clerkToken = tokenResult.value; + const wantsLink = desired.managedTunnel || desired.publish; + + // A failure after this point may follow a partially applied mutation (e.g. + // the link succeeded but the preference update did not), so every exit — + // success or failure — refreshes the rendered state to whatever the server + // actually holds now. + if (!wantsLink) { + const unlinkResult = await unlinkPrimaryEnvironment({ + target, + clerkToken: clerkToken ?? null, + }); + if (unlinkResult._tag === "Failure") { + if (!isAtomCommandInterrupted(unlinkResult)) { + reportUpdateFailure(squashAtomCommandFailure(unlinkResult)); + } + primaryCloudLinkState.refresh(); + return false; + } + } else { + if (!clerkToken) { + reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this.")); + return false; + } + if (!linked || managedTunnelActive !== desired.managedTunnel) { + const linkResult = await linkPrimaryEnvironment({ + target, + clerkToken, + mode: desired.managedTunnel ? "managed" : "publish_only", + }); + if (linkResult._tag === "Failure") { + if (!isAtomCommandInterrupted(linkResult)) { + reportUpdateFailure(squashAtomCommandFailure(linkResult)); + } + primaryCloudLinkState.refresh(); + return false; + } + } + const prefResult = await updatePrimaryEnvironmentPreferences({ + target, + publishAgentActivity: desired.publish, + }); + if (prefResult._tag === "Failure") { + if (!isAtomCommandInterrupted(prefResult)) { + reportUpdateFailure(squashAtomCommandFailure(prefResult)); + } + primaryCloudLinkState.refresh(); + return false; + } + } + + primaryCloudLinkState.refresh(); + const refreshResult = await refreshRelayEnvironments(); + if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) { + reportUpdateFailure(squashAtomCommandFailure(refreshResult)); + return false; + } + return true; + }; + + return { + isSignedIn, + linkState: primaryCloudLinkState, + linked, + managedTunnelActive, + publishAgentActivity, + operationError, + reconcileCloudState, + }; +} diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx new file mode 100644 index 00000000000..b12acf1ba40 --- /dev/null +++ b/apps/web/src/components/ConnectionStatusDot.tsx @@ -0,0 +1,56 @@ +import { cn } from "~/lib/utils"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; + +type ConnectionStatusDotProps = { + tooltipText?: string | null; + dotClassName: string; + pingClassName?: string | null; +}; + +export function ConnectionStatusDot({ + tooltipText, + dotClassName, + pingClassName, +}: ConnectionStatusDotProps) { + const dotContent = ( + <> + {pingClassName ? ( + + ) : null} + + + ); + + if (!tooltipText) { + return ( + + {dotContent} + + ); + } + + const dot = ( + + ); + + return ( + + + + {tooltipText} + + + ); +} diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx new file mode 100644 index 00000000000..0b39eecfcff --- /dev/null +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -0,0 +1,204 @@ +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { + RelayConnectionRegistration, + RelayConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import * as Option from "effect/Option"; +import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; + +import { environmentCatalog } from "~/connection/catalog"; +import { cn } from "~/lib/utils"; +import { relayEnvironmentDiscovery } from "~/state/relay"; +import { useRelayEnvironmentDiscovery } from "~/state/environments"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows"; +import { Button } from "../ui/button"; +import { Skeleton } from "../ui/skeleton"; +import { toastManager } from "../ui/toast"; + +export function RemoteEnvironmentRowsSkeleton() { + return ( +
+
+
+ + +
+ +
+
+ ); +} + +/** + * The user's T3 Connect environments from relay discovery, each with a + * Connect button. The primary environment is always excluded; already-saved + * environments are hidden unless `showSavedAsConnected` renders them as + * connected instead (used by onboarding, where the full device mesh should be + * visible). + */ +export function CloudEnvironmentConnectRows({ + primaryEnvironmentId, + savedEnvironmentIds, + showSavedAsConnected = false, + empty = null, +}: { + readonly primaryEnvironmentId: EnvironmentId | null; + readonly savedEnvironmentIds: ReadonlyArray; + readonly showSavedAsConnected?: boolean; + readonly empty?: ReactNode; +}) { + const environmentsState = useRelayEnvironmentDiscovery(); + const registerEnvironment = useAtomCommand(environmentCatalog.register, { + reportFailure: false, + }); + const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { + reportFailure: false, + }); + const connectRelayEnvironment = useCallback( + (environment: RelayClientEnvironmentRecord) => + registerEnvironment( + new RelayConnectionRegistration({ + target: new RelayConnectionTarget({ + environmentId: environment.environmentId, + label: environment.label, + }), + }), + ), + [registerEnvironment], + ); + const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( + null, + ); + const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); + + useEffect(() => { + void refreshRelayEnvironments(); + }, [refreshRelayEnvironments]); + + const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { + setConnectingEnvironmentId(environment.environmentId); + const result = await connectRelayEnvironment(environment); + setConnectingEnvironmentId(null); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Environment connected", + description: `${environment.label} is available through T3 Connect.`, + }); + return; + } + if (isAtomCommandInterrupted(result)) { + return; + } + const cause = squashAtomCommandFailure(result); + const message = + cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment."; + const traceId = findErrorTraceId(cause); + console.error("[t3-connect] Could not connect environment", { message, traceId, cause }); + toastManager.add({ + type: "error", + title: "Could not connect environment", + description: message, + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, + }); + }; + + const visibleEnvironments = [...environmentsState.environments.values()].filter( + ({ environment }) => + environment.environmentId !== primaryEnvironmentId && + (showSavedAsConnected || !savedIds.has(environment.environmentId)), + ); + + const standalone = showSavedAsConnected || savedEnvironmentIds.length === 0; + + if ( + standalone && + visibleEnvironments.length === 0 && + environmentsState.refreshing && + environmentsState.environments.size === 0 + ) { + return ; + } + + if (standalone && visibleEnvironments.length === 0) { + return empty; + } + + return visibleEnvironments.map(({ environment, availability, error }) => { + const alreadyConnected = savedIds.has(environment.environmentId); + return ( +
+
+
+
+ +

{environment.label}

+
+

+ {availability === "online" + ? "Available · Relay online" + : availability === "offline" + ? "Available · Relay offline" + : availability === "checking" + ? "Available · Checking relay status…" + : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")} +

+
+ {alreadyConnected ? ( + + ) : ( + + )} +
+
+ ); + }); +} diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx new file mode 100644 index 00000000000..9cca1d3c0b5 --- /dev/null +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -0,0 +1,405 @@ +import { useAuth } from "@clerk/react"; +import { AuthAdministrativeScopes, AuthRelayWriteScope } from "@t3tools/contracts"; +import { CheckIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { + CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY, + ConnectOnboardingOptOutSchema, + EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, +} from "~/cloud/connectOnboarding"; +import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { useCloudLinkController } from "~/cloud/useCloudLinkController"; +import { usePrimarySessionState } from "~/environments/primary"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { cn } from "~/lib/utils"; +import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; +import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Switch } from "../ui/switch"; +import { toastManager } from "../ui/toast"; + +/** + * Post-sign-in onboarding wizard for T3 Connect. Opens on every in-session + * sign-in — sign-out removes the connected relay environments, so each new + * session starts with no devices to reach. It first prompts to publish this + * environment (managed tunnel + agent activity, both defaulting on) when the + * current session is authorized to manage the relay link, then lists the + * account's T3 Connect environments so every device can be connected right + * away. A cold load with a restored session does not count as a sign-in. + */ +export function ConnectOnboardingDialog() { + if (!hasCloudPublicConfig()) return null; + + return ; +} + +type OnboardingStep = "publish" | "devices"; + +function ConfiguredConnectOnboardingDialog() { + const { isLoaded, isSignedIn, userId } = useAuth(); + const [optOutState, setOptOutState] = useLocalStorage( + CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY, + EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, + ConnectOnboardingOptOutSchema, + ); + + const desktopBridge = window.desktopBridge; + const primarySessionState = usePrimarySessionState(); + const currentSessionScopes = desktopBridge + ? AuthAdministrativeScopes + : primarySessionState.data?.authenticated + ? (primarySessionState.data.scopes ?? null) + : null; + const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; + // The publish step is only offered when we know the answer; opening the + // wizard before the session state resolves would let the step set change + // mid-flight. A failed session read still opens the wizard — it just means + // no publish step. + const sessionScopesKnown = + Boolean(desktopBridge) || + primarySessionState.data !== null || + primarySessionState.error !== null; + + const controller = useCloudLinkController(); + const showPublishStep = canManageRelay && controller.linkState.target !== null; + const steps: ReadonlyArray = showPublishStep + ? ["publish", "devices"] + : ["devices"]; + + const [requestedAccount, setRequestedAccount] = useState(null); + const [openForAccount, setOpenForAccount] = useState(null); + const [step, setStep] = useState("devices"); + const [exposeEnvironment, setExposeEnvironment] = useState(true); + const [publishAgentActivity, setPublishAgentActivity] = useState(true); + const [dontShowAgain, setDontShowAgain] = useState(false); + const [isApplying, setIsApplying] = useState(false); + const prefilledFromLinkStateRef = useRef(false); + const observedAccountRef = useRef(undefined); + + const optOutAccounts = optOutState.optOutAccounts; + + // Every sign-in that completes during this session requests the wizard. A + // cold load observes undefined → account and must not re-prompt — only a + // null → account transition is a sign-in. + useEffect(() => { + if (!isLoaded) return; + const previousAccount = observedAccountRef.current; + const nextAccount = isSignedIn && userId ? userId : null; + observedAccountRef.current = nextAccount; + if (previousAccount === null && nextAccount !== null) { + setRequestedAccount(nextAccount); + } + }, [isLoaded, isSignedIn, userId]); + + // Open once the session scopes resolve so the step set is stable. Accounts + // that chose "Don't show this again" are skipped. + useEffect(() => { + if (requestedAccount === null || openForAccount !== null) return; + if (optOutAccounts.includes(requestedAccount)) { + setRequestedAccount(null); + return; + } + if (!sessionScopesKnown) return; + setRequestedAccount(null); + prefilledFromLinkStateRef.current = false; + setExposeEnvironment(true); + setPublishAgentActivity(true); + setDontShowAgain(false); + setStep(canManageRelay && controller.linkState.target !== null ? "publish" : "devices"); + setOpenForAccount(requestedAccount); + }, [ + canManageRelay, + controller.linkState.target, + openForAccount, + optOutAccounts, + requestedAccount, + sessionScopesKnown, + ]); + + // Signing out (or switching accounts) mid-wizard invalidates everything the + // wizard would do — close it and let the sign-in trigger re-evaluate. + useEffect(() => { + if (openForAccount !== null && (!isSignedIn || userId !== openForAccount)) { + setOpenForAccount(null); + } + if (requestedAccount !== null && (!isSignedIn || userId !== requestedAccount)) { + setRequestedAccount(null); + } + }, [isSignedIn, openForAccount, requestedAccount, userId]); + + // Toggles default on, but an environment that is already linked should show + // its actual configuration instead of silently proposing to rewrite it. + const linkStateData = controller.linkState.data; + useEffect(() => { + if (openForAccount === null || prefilledFromLinkStateRef.current || linkStateData === null) { + return; + } + prefilledFromLinkStateRef.current = true; + if (linkStateData.linked) { + setExposeEnvironment(linkStateData.managedTunnelActive ?? linkStateData.linked); + setPublishAgentActivity(linkStateData.publishAgentActivity); + } + }, [linkStateData, openForAccount]); + + const complete = () => { + const account = openForAccount; + setOpenForAccount(null); + if (account !== null && dontShowAgain) { + setOptOutState((state) => + state.optOutAccounts.includes(account) + ? state + : { optOutAccounts: [...state.optOutAccounts, account] }, + ); + } + }; + + const applyPublishSelection = async () => { + // The wizard only ever enables — with both toggles off there is nothing to + // apply, and an existing link must not be torn down from onboarding. + if (!exposeEnvironment && !publishAgentActivity) { + setStep("devices"); + return; + } + setIsApplying(true); + const ok = await controller.reconcileCloudState({ + managedTunnel: exposeEnvironment, + publish: publishAgentActivity, + }); + setIsApplying(false); + if (!ok) return; + toastManager.add({ + type: "success", + title: "T3 Connect enabled", + description: exposeEnvironment + ? "This environment is available to your other devices through T3 Connect." + : "This environment publishes agent activity to your mobile clients.", + }); + setStep("devices"); + }; + + return ( + { + if (!open) complete(); + }} + > + + + Set up T3 Connect + + Mesh your devices together — publish this environment and connect the rest, all in one + place. + + {steps.length > 1 ? ( + + ) : null} + + + {step === "publish" ? ( + + ) : ( + + )} + + + +
+ {step === "publish" ? ( + <> + + + + ) : ( + + )} +
+
+
+
+ ); +} + +const STEP_LABELS: Record = { + publish: "Publish", + devices: "Connect devices", +}; + +function OnboardingStepper({ + steps, + currentStep, + onStepSelect, +}: { + readonly steps: ReadonlyArray; + readonly currentStep: OnboardingStep; + readonly onStepSelect: (step: OnboardingStep) => void; +}) { + const currentIndex = steps.indexOf(currentStep); + return ( +
+ {steps.map((step, index) => ( + + ))} +
+ ); +} + +function PublishStep({ + exposeEnvironment, + publishAgentActivity, + disabled, + operationError, + onExposeEnvironmentChange, + onPublishAgentActivityChange, +}: { + readonly exposeEnvironment: boolean; + readonly publishAgentActivity: boolean; + readonly disabled: boolean; + readonly operationError: string | null; + readonly onExposeEnvironmentChange: (enabled: boolean) => void; + readonly onPublishAgentActivityChange: (enabled: boolean) => void; +}) { + return ( +
+
+ + +
+ {operationError ?

{operationError}

: null} +
+ ); +} + +function OnboardingToggleRow({ + title, + description, + checked, + disabled, + onCheckedChange, +}: { + readonly title: string; + readonly description: string; + readonly checked: boolean; + readonly disabled: boolean; + readonly onCheckedChange: (enabled: boolean) => void; +}) { + return ( +
+
+

{title}

+

{description}

+
+ +
+ ); +} + +function DevicesStep() { + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + const savedEnvironmentIds = useMemo( + () => + environments + .filter((environment) => environment.entry.target._tag !== "PrimaryConnectionTarget") + .map((environment) => environment.environmentId), + [environments], + ); + + return ( +
+ + No other environments are published to your account yet. Publish one from another device + and it will show up here. +

+ } + /> +
+ ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index f058a1cc61c..ebde77f3ebd 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -7,8 +7,7 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; -import { useAuth } from "@clerk/react"; -import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; +import { type ReactNode, memo, useCallback, useMemo, useState } from "react"; import { AuthAccessReadScope, AuthAccessWriteScope, @@ -30,18 +29,11 @@ import { type DesktopWslState, type EnvironmentId, } from "@t3tools/contracts"; -import { - connectionStatusText, - RelayConnectionRegistration, - RelayConnectionTarget, -} from "@t3tools/client-runtime/connection"; -import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { isAtomCommandInterrupted, - settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; @@ -50,7 +42,6 @@ import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; -import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig"; import { SettingsPageContainer, SettingsRow, @@ -82,7 +73,6 @@ import { } from "../ui/alert-dialog"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { QRCodeSvg } from "../ui/qr-code"; -import { Skeleton } from "../ui/skeleton"; import { Spinner } from "../ui/spinner"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; @@ -117,13 +107,8 @@ import { import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; import { useUiStateStore } from "~/uiStateStore"; import { resolveServerConfigVersionMismatch } from "~/versionSkew"; -import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; -import { - linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, - unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, - updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, -} from "~/cloud/linkEnvironmentAtoms"; +import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { authEnvironment } from "~/state/auth"; import { environmentCatalog } from "~/connection/catalog"; import { @@ -141,10 +126,11 @@ import { type EnvironmentPresentation, useEnvironments, usePrimaryEnvironment, - useRelayEnvironmentDiscovery, } from "~/state/environments"; -import { relayEnvironmentDiscovery } from "~/state/relay"; import { useAtomCommand } from "../../state/use-atom-command"; +import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; +import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray = []; @@ -260,60 +246,6 @@ function AccessScopeSummary({ ); } -type ConnectionStatusDotProps = { - tooltipText?: string | null; - dotClassName: string; - pingClassName?: string | null; -}; - -function ConnectionStatusDot({ - tooltipText, - dotClassName, - pingClassName, -}: ConnectionStatusDotProps) { - const dotContent = ( - <> - {pingClassName ? ( - - ) : null} - - - ); - - if (!tooltipText) { - return ( - - {dotContent} - - ); - } - - const dot = ( - - ); - - return ( - - - - {tooltipText} - - - ); -} - function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string { const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; return target.port ? `${authority}:${target.port}` : authority; @@ -437,13 +369,8 @@ function formatDesktopSshConnectionError(error: unknown): string { return withoutTaggedErrorPrefix.trim() || fallback; } -/** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ -const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; const ENDPOINT_ROW_CLASSNAME = "border-t border-border/60 px-4 py-2.5 first:border-t-0 sm:px-5"; -const ITEM_ROW_INNER_CLASSNAME = - "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; - type AccessSectionPresentation = "current" | "endpoint-rail"; function accessRowClassName(_presentation: AccessSectionPresentation) { @@ -1629,51 +1556,17 @@ function CloudLinkSwitch({ } function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { - const { getToken, isSignedIn } = useAuth(); - const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { - reportFailure: false, - }); - const linkPrimaryEnvironment = useAtomCommand(linkPrimaryEnvironmentAtom, { - reportFailure: false, - }); - const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, { - reportFailure: false, - }); - const updatePrimaryEnvironmentPreferences = useAtomCommand( - updatePrimaryEnvironmentPreferencesAtom, - { reportFailure: false }, - ); - const primaryCloudLinkState = usePrimaryCloudLinkState(); - const [operationError, setOperationError] = useState(null); + const { + isSignedIn, + linkState: primaryCloudLinkState, + managedTunnelActive, + publishAgentActivity, + operationError, + reconcileCloudState, + } = useCloudLinkController(); const [isUpdating, setIsUpdating] = useState(false); const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); - const reportUpdateFailure = (cause: unknown) => { - const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access."; - const traceId = findErrorTraceId(cause); - console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause }); - setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); - toastManager.add({ - type: "error", - title: "Could not update T3 Connect", - description: message, - data: traceId - ? { - secondaryActionProps: { - children: "Copy trace ID", - onClick: () => void navigator.clipboard?.writeText(traceId), - }, - } - : undefined, - }); - }; - - // Older environment servers predate the managedTunnelActive field; for them a - // link always implies a managed tunnel, so fall back to `linked`. - const managedTunnelActive = - primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false; - const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; - const linked = primaryCloudLinkState.data?.linked ?? false; const disabledReason = !isSignedIn ? "Sign in to T3 Connect to manage this environment." : !canManageRelay @@ -1681,86 +1574,6 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b : null; const isBusy = isUpdating || isUpdatingPreference; - // T3 Connect (managed tunnel) and publishing are independent capabilities - // backed by a single relay link. Reconcile the whole desired state: unlink - // when neither is wanted, otherwise (re)link with the mode the managed-tunnel - // bit implies and set the publish preference. Re-linking only happens when the - // managed-tunnel mode actually changes, so flipping publish alone is cheap. - const reconcileCloudState = async (desired: { - readonly managedTunnel: boolean; - readonly publish: boolean; - }): Promise => { - setOperationError(null); - const target = primaryCloudLinkState.target; - if (!target) { - reportUpdateFailure(new Error("Local environment is not ready yet.")); - return false; - } - const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); - if (tokenResult._tag === "Failure") { - reportUpdateFailure(squashAtomCommandFailure(tokenResult)); - return false; - } - const clerkToken = tokenResult.value; - const wantsLink = desired.managedTunnel || desired.publish; - - // A failure after this point may follow a partially applied mutation (e.g. - // the link succeeded but the preference update did not), so every exit — - // success or failure — refreshes the rendered state to whatever the server - // actually holds now. - if (!wantsLink) { - const unlinkResult = await unlinkPrimaryEnvironment({ - target, - clerkToken: clerkToken ?? null, - }); - if (unlinkResult._tag === "Failure") { - if (!isAtomCommandInterrupted(unlinkResult)) { - reportUpdateFailure(squashAtomCommandFailure(unlinkResult)); - } - primaryCloudLinkState.refresh(); - return false; - } - } else { - if (!clerkToken) { - reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this.")); - return false; - } - if (!linked || managedTunnelActive !== desired.managedTunnel) { - const linkResult = await linkPrimaryEnvironment({ - target, - clerkToken, - mode: desired.managedTunnel ? "managed" : "publish_only", - }); - if (linkResult._tag === "Failure") { - if (!isAtomCommandInterrupted(linkResult)) { - reportUpdateFailure(squashAtomCommandFailure(linkResult)); - } - primaryCloudLinkState.refresh(); - return false; - } - } - const prefResult = await updatePrimaryEnvironmentPreferences({ - target, - publishAgentActivity: desired.publish, - }); - if (prefResult._tag === "Failure") { - if (!isAtomCommandInterrupted(prefResult)) { - reportUpdateFailure(squashAtomCommandFailure(prefResult)); - } - primaryCloudLinkState.refresh(); - return false; - } - } - - primaryCloudLinkState.refresh(); - const refreshResult = await refreshRelayEnvironments(); - if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) { - reportUpdateFailure(squashAtomCommandFailure(refreshResult)); - return false; - } - return true; - }; - const updateManagedTunnel = async (enabled: boolean) => { setIsUpdating(true); const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity }); @@ -1857,163 +1670,6 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable ); } -function RemoteEnvironmentRowsSkeleton() { - return ( -
-
-
- - -
- -
-
- ); -} - -function ConfiguredCloudRemoteEnvironmentRows({ - primaryEnvironmentId, - savedEnvironmentIds, -}: { - readonly primaryEnvironmentId: EnvironmentId | null; - readonly savedEnvironmentIds: ReadonlyArray; -}) { - const environmentsState = useRelayEnvironmentDiscovery(); - const registerEnvironment = useAtomCommand(environmentCatalog.register, { - reportFailure: false, - }); - const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { - reportFailure: false, - }); - const connectRelayEnvironment = useCallback( - (environment: RelayClientEnvironmentRecord) => - registerEnvironment( - new RelayConnectionRegistration({ - target: new RelayConnectionTarget({ - environmentId: environment.environmentId, - label: environment.label, - }), - }), - ), - [registerEnvironment], - ); - const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( - null, - ); - const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); - - useEffect(() => { - void refreshRelayEnvironments(); - }, [refreshRelayEnvironments]); - - const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { - setConnectingEnvironmentId(environment.environmentId); - const result = await connectRelayEnvironment(environment); - setConnectingEnvironmentId(null); - if (result._tag === "Success") { - toastManager.add({ - type: "success", - title: "Environment connected", - description: `${environment.label} is available through T3 Connect.`, - }); - return; - } - if (isAtomCommandInterrupted(result)) { - return; - } - const cause = squashAtomCommandFailure(result); - const message = - cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment."; - const traceId = findErrorTraceId(cause); - console.error("[t3-connect] Could not connect environment", { message, traceId, cause }); - toastManager.add({ - type: "error", - title: "Could not connect environment", - description: message, - data: traceId - ? { - secondaryActionProps: { - children: "Copy trace ID", - onClick: () => void navigator.clipboard?.writeText(traceId), - }, - } - : undefined, - }); - }; - - const connectableEnvironments = [...environmentsState.environments.values()].filter( - ({ environment }) => - environment.environmentId !== primaryEnvironmentId && - !savedIds.has(environment.environmentId), - ); - - if ( - savedEnvironmentIds.length === 0 && - environmentsState.refreshing && - environmentsState.environments.size === 0 - ) { - return ; - } - - if (savedEnvironmentIds.length === 0 && connectableEnvironments.length === 0) { - return ; - } - - return connectableEnvironments.map(({ environment, availability, error }) => ( -
-
-
-
- -

{environment.label}

-
-

- {availability === "online" - ? "Available · Relay online" - : availability === "offline" - ? "Available · Relay offline" - : availability === "checking" - ? "Available · Checking relay status…" - : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")} -

-
- -
-
- )); -} - function CloudRemoteEnvironmentRows({ primaryEnvironmentId, savedEnvironmentIds, @@ -2022,9 +1678,10 @@ function CloudRemoteEnvironmentRows({ readonly savedEnvironmentIds: ReadonlyArray; }) { return hasCloudPublicConfig() ? ( - } /> ) : savedEnvironmentIds.length === 0 ? ( diff --git a/apps/web/src/components/settings/itemRows.ts b/apps/web/src/components/settings/itemRows.ts new file mode 100644 index 00000000000..df0f1f7c235 --- /dev/null +++ b/apps/web/src/components/settings/itemRows.ts @@ -0,0 +1,5 @@ +/** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ +export const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; + +export const ITEM_ROW_INNER_CLASSNAME = + "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index f74465048f0..56d25fa142e 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -182,7 +182,7 @@ const capabilitiesLayer = Layer.effectContext( if (session === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - detail: "Sign in to T3 Cloud to connect this environment.", + detail: "Sign in to T3 Connect to connect this environment.", }); } const token = yield* session.readClerkToken().pipe( @@ -197,7 +197,7 @@ const capabilitiesLayer = Layer.effectContext( if (token === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - detail: "The T3 Cloud session is unavailable.", + detail: "The T3 Connect session is unavailable.", }); } return token; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 36de3b95706..69739ce4570 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -14,6 +14,7 @@ import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; +import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; @@ -128,6 +129,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index 66c27b8678a..8cb963000bd 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -342,6 +342,55 @@ describe("RelayEnvironmentDiscovery", () => { }), ); + it.effect("refreshes proactively when credentials change before any manual refresh", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; + const requests = yield* Ref.get(harness.statusRequests); + for (const environment of environments) { + yield* Deferred.succeed( + requests.get(environment.environmentId)!, + status(environment, "online"), + ); + } + + // Let the scoped wakeup subscription start before emitting, mirroring + // a real sign-in which always happens long after service start. + yield* Effect.yieldNow; + + // Sign-in activates the session and emits credentials-changed; the + // list must populate without any screen having asked for a refresh. + yield* harness.wake("credentials-changed"); + const populated = yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter( + (state) => state.environments.size === environments.length && !state.refreshing, + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(Option.isNone(populated.error)).toBe(true); + expect(yield* Ref.get(harness.listCalls)).toBe(1); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("settles to a clean empty state when refreshed while signed out", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; + yield* Ref.set(harness.clerkToken, null); + yield* discovery.refresh; + + const state = yield* SubscriptionRef.get(discovery.state); + expect(state.environments.size).toBe(0); + expect(state.refreshing).toBe(false); + expect(Option.isNone(state.error)).toBe(true); + }).pipe(Effect.provide(harness.layer)); + }), + ); + it.effect("does not republish stale rows after sign-out invalidates an in-flight refresh", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts index 8cbadea1ca5..9ef99e2fce2 100644 --- a/packages/client-runtime/src/relay/discovery.ts +++ b/packages/client-runtime/src/relay/discovery.ts @@ -279,10 +279,15 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { if ((yield* Ref.get(accountGeneration)) !== generation) { return; } + // Signed out is the idle state, not a failure: the proactive + // refresh on credentials-changed also runs on sign-out and must + // settle back to a clean empty list. + const signedOut = + error._tag === "ConnectionBlockedError" && error.reason === "authentication"; yield* SubscriptionRef.update(state, (current) => ({ ...current, refreshing: false, - error: Option.some(error), + error: signedOut ? Option.none() : Option.some(error), })); }), ), @@ -311,11 +316,12 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { yield* Ref.update(accountGeneration, (current) => current + 1); yield* Ref.set(activeAccountId, Option.none()); yield* Ref.set(offlineReportFingerprints, new Map()); - const shouldRefresh = yield* Ref.get(hasRefreshed); yield* SubscriptionRef.set(state, EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); - if (shouldRefresh) { - yield* refresh.pipe(Effect.forkScoped); - } + // Refresh proactively — this wakeup fires when a session activates + // (sign-in or cold start), and the list should be populated before + // any screen asks for it. A signed-out refresh settles back to the + // clean empty state. + yield* refresh.pipe(Effect.forkScoped); }) : Effect.void, ), diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index ec6a0710dd1..1d12c90aae5 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -123,7 +123,7 @@ export function createManagedRelaySession(input: ManagedRelaySessionInput): Mana try: () => readCachedClerkToken(nowMillis), catch: (cause) => new ManagedRelaySessionError({ - message: "Could not obtain the T3 Cloud session token.", + message: "Could not obtain the T3 Connect session token.", cause, }), }); @@ -181,7 +181,7 @@ function readSessionClerkToken( ? Effect.succeed(token) : Effect.fail( new ManagedRelaySessionError({ - message: "The T3 Cloud session token is unavailable.", + message: "The T3 Connect session token is unavailable.", }), ), ), @@ -226,7 +226,7 @@ function requireClerkToken( if (!session || session.accountId !== accountId) { return Effect.fail( new ManagedRelaySessionError({ - message: "Sign in to T3 Cloud before loading relay data.", + message: "Sign in to T3 Connect before loading relay data.", }), ); } @@ -296,7 +296,7 @@ export function readManagedRelaySnapshotState( let errorTraceId: string | null = null; if (result._tag === "Failure") { const cause = Cause.squash(result.cause); - error = cause instanceof Error ? cause.message : "Could not load T3 Cloud data."; + error = cause instanceof Error ? cause.message : "Could not load T3 Connect data."; errorTraceId = findErrorTraceId(cause); } return {