| author | |
| committer | |
| log | cd8b9fc8b3d739f51bdfb3ba60ba5dccdda8d444 |
| tree | fe9ad21545a77d30b53c062bdfb0b28fac32334f |
| parent | fa6073e2e13b6a41cfd6c3834c300408e9d1d6d8 |
| signature | Signed by PGP key B5690EEEBB952194 |
26 files changed, 720 insertions(+), 1546 deletions(-)
apps/mobile/src/Stack.tsx-19| ... | ... | @@ -17,8 +17,6 @@ import { AppText as Text } from "./components/AppText"; |
| 17 | 17 | import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; |
| 18 | 18 | import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; |
| 19 | 19 | import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; |
| 20 | import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; | |
| 21 | import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation"; | |
| 22 | 20 | import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; |
| 23 | 21 | import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; |
| 24 | 22 | import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider"; |
| ... | ... | @@ -222,7 +220,6 @@ const NewTaskSheetStack = createNativeStackNavigator({ |
| 222 | 220 | // influence the adaptive workspace layout: opening Settings over Home should |
| 223 | 221 | // not flip the sidebar in or change the active thread. |
| 224 | 222 | const WORKSPACE_OVERLAY_ROUTES = new Set([ |
| 225 | "ConnectOnboarding", | |
| 226 | 223 | "Connections", |
| 227 | 224 | "ConnectionsNew", |
| 228 | 225 | "GitBranches", |
| ... | ... | @@ -254,8 +251,6 @@ function RootStackLayout(props: { |
| 254 | 251 | }) { |
| 255 | 252 | useAgentNotificationNavigation(); |
| 256 | 253 | useThreadOutboxDrain(); |
| 257 | // Presents the T3 Connect onboarding sheet after an in-session sign-in. | |
| 258 | useConnectOnboardingNavigation(); | |
| 259 | 254 | // Full pathname (sheets included) for keyboard-command scoping; the |
| 260 | 255 | // workspace layout only reacts to the underlying non-overlay route. |
| 261 | 256 | const path = getPathFromState(props.state, navigationPathConfig); |
| ... | ... | @@ -417,20 +412,6 @@ export const RootStack = createNativeStackNavigator({ |
| 417 | 412 | sheetGrabberVisible: true, |
| 418 | 413 | }, |
| 419 | 414 | }), |
| 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 | }), | |
| 434 | 415 | Connections: createNativeStackScreen({ |
| 435 | 416 | screen: ConnectionsRouteScreen, |
| 436 | 417 | linking: "connections", |
apps/mobile/src/connection/platform.ts+2-2| ... | ... | @@ -92,7 +92,7 @@ const capabilitiesLayer = Layer.succeedContext( |
| 92 | 92 | if (session === null) { |
| 93 | 93 | return yield* new ConnectionBlockedError({ |
| 94 | 94 | reason: "authentication", |
| 95 | detail: "Sign in to T3 Connect to connect this environment.", | |
| 95 | detail: "Sign in to T3 Cloud to connect this environment.", | |
| 96 | 96 | }); |
| 97 | 97 | } |
| 98 | 98 | const token = yield* session.readClerkToken().pipe( |
| ... | ... | @@ -107,7 +107,7 @@ const capabilitiesLayer = Layer.succeedContext( |
| 107 | 107 | if (token === null) { |
| 108 | 108 | return yield* new ConnectionBlockedError({ |
| 109 | 109 | reason: "authentication", |
| 110 | detail: "The T3 Connect session is unavailable.", | |
| 110 | detail: "The T3 Cloud session is unavailable.", | |
| 111 | 111 | }); |
| 112 | 112 | } |
| 113 | 113 | return token; |
apps/mobile/src/features/cloud/CloudAuthProvider.tsx-9| ... | ... | @@ -18,7 +18,6 @@ import { |
| 18 | 18 | setAgentAwarenessRelayTokenProvider, |
| 19 | 19 | unregisterAgentAwarenessDeviceForCurrentUser, |
| 20 | 20 | } from "../agent-awareness/remoteRegistration"; |
| 21 | import { requestConnectOnboarding } from "./connectOnboarding"; | |
| 22 | 21 | import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; |
| 23 | 22 | |
| 24 | 23 | function resetManagedRelayTokenCache() { |
| ... | ... | @@ -68,14 +67,6 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 68 | 67 | const nextAccount = isSignedIn && userId ? userId : null; |
| 69 | 68 | observedAccountRef.current = nextAccount; |
| 70 | 69 | |
| 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 | ||
| 79 | 70 | const queueAccountCleanup = ( |
| 80 | 71 | previous: { |
| 81 | 72 | readonly userId: string; |
apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx+1-1| ... | ... | @@ -39,7 +39,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } |
| 39 | 39 | You are on the waitlist |
| 40 | 40 | </Text> |
| 41 | 41 | <Text className="text-center font-sans text-base text-foreground-secondary"> |
| 42 | We will email you when your T3 Connect access is ready. | |
| 42 | We will email you when your T3 Cloud access is ready. | |
| 43 | 43 | </Text> |
| 44 | 44 | <SignInAction onPress={props.onSignIn} /> |
| 45 | 45 | </View> |
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx deleted-111| ... | ... | @@ -1,111 +0,0 @@ |
| 1 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | |
| 2 | import { useAuth } from "@clerk/expo"; | |
| 3 | import { useNavigation } from "@react-navigation/native"; | |
| 4 | import { useCallback, useState } from "react"; | |
| 5 | import { Pressable, RefreshControl, ScrollView, View } from "react-native"; | |
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 7 | ||
| 8 | import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; | |
| 9 | import { AppText as Text } from "../../components/AppText"; | |
| 10 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | |
| 11 | import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; | |
| 12 | import { splitEnvironmentSections } from "../connection/environmentSections"; | |
| 13 | import { useConnectionController } from "../connection/useConnectionController"; | |
| 14 | import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut"; | |
| 15 | import { 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 | */ | |
| 24 | export function ConnectOnboardingRouteScreen() { | |
| 25 | return hasCloudPublicConfig() ? <ConfiguredConnectOnboardingRouteScreen /> : null; | |
| 26 | } | |
| 27 | ||
| 28 | function 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 deleted-24| ... | ... | @@ -1,24 +0,0 @@ |
| 1 | import { Atom } from "effect/unstable/reactivity"; | |
| 2 | ||
| 3 | import { 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. | |
| 8 | export 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 | */ | |
| 18 | export function requestConnectOnboarding(accountId: string): void { | |
| 19 | appAtomRegistry.set(connectOnboardingRequestAtom, accountId); | |
| 20 | } | |
| 21 | ||
| 22 | export function clearConnectOnboardingRequest(): void { | |
| 23 | appAtomRegistry.set(connectOnboardingRequestAtom, null); | |
| 24 | } |
apps/mobile/src/features/cloud/connectOnboardingNavigation.ts deleted-45| ... | ... | @@ -1,45 +0,0 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { useNavigation } from "@react-navigation/native"; | |
| 3 | import { useEffect } from "react"; | |
| 4 | ||
| 5 | import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding"; | |
| 6 | import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut"; | |
| 7 | ||
| 8 | // Sign-in happens inside the Settings sheet; give its detent/session-state | |
| 9 | // transitions a beat to settle before presenting another formSheet on top. | |
| 10 | const PRESENT_ONBOARDING_DELAY_MS = 600; | |
| 11 | ||
| 12 | /** | |
| 13 | * Consumes the onboarding request inside the navigation tree (RootStackLayout) | |
| 14 | * and presents the onboarding formSheet. Lives apart from connectOnboarding.ts | |
| 15 | * so non-navigation consumers (CloudAuthProvider) do not pull | |
| 16 | * @react-navigation into their module graph. | |
| 17 | */ | |
| 18 | export function useConnectOnboardingNavigation(): void { | |
| 19 | const navigation = useNavigation(); | |
| 20 | const requestedAccountId = useAtomValue(connectOnboardingRequestAtom); | |
| 21 | ||
| 22 | useEffect(() => { | |
| 23 | if (requestedAccountId === null) { | |
| 24 | return; | |
| 25 | } | |
| 26 | let cancelled = false; | |
| 27 | const timer = setTimeout(() => { | |
| 28 | void (async () => { | |
| 29 | // A failed preference read prefers showing the sheet. | |
| 30 | const optedOut = await isConnectOnboardingOptedOut(requestedAccountId).catch(() => false); | |
| 31 | if (cancelled) { | |
| 32 | return; | |
| 33 | } | |
| 34 | clearConnectOnboardingRequest(); | |
| 35 | if (!optedOut) { | |
| 36 | navigation.navigate("ConnectOnboarding"); | |
| 37 | } | |
| 38 | })(); | |
| 39 | }, PRESENT_ONBOARDING_DELAY_MS); | |
| 40 | return () => { | |
| 41 | cancelled = true; | |
| 42 | clearTimeout(timer); | |
| 43 | }; | |
| 44 | }, [navigation, requestedAccountId]); | |
| 45 | } |
apps/mobile/src/features/cloud/connectOnboardingOptOut.ts deleted-21| ... | ... | @@ -1,21 +0,0 @@ |
| 1 | import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; | |
| 2 | ||
| 3 | // Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports | |
| 4 | // the request signal) never pulls lib/storage — expo-secure-store — into its | |
| 5 | // module graph; that breaks CloudAuthProvider.test.ts suite loading. | |
| 6 | ||
| 7 | /** Whether the account chose "Don't show this again". */ | |
| 8 | export async function isConnectOnboardingOptedOut(accountId: string): Promise<boolean> { | |
| 9 | const preferences = await loadPreferences(); | |
| 10 | return preferences.connectOnboardingOptOutAccounts?.includes(accountId) ?? false; | |
| 11 | } | |
| 12 | ||
| 13 | /** Persists "Don't show this again" for the account. */ | |
| 14 | export async function optOutOfConnectOnboarding(accountId: string): Promise<void> { | |
| 15 | const preferences = await loadPreferences(); | |
| 16 | const optedOut = preferences.connectOnboardingOptOutAccounts ?? []; | |
| 17 | if (optedOut.includes(accountId)) { | |
| 18 | return; | |
| 19 | } | |
| 20 | await savePreferencesPatch({ connectOnboardingOptOutAccounts: [...optedOut, accountId] }); | |
| 21 | } |
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx deleted-366| ... | ... | @@ -1,366 +0,0 @@ |
| 1 | import { useAuth } from "@clerk/expo"; | |
| 2 | import { SymbolView } from "expo-symbols"; | |
| 3 | import { | |
| 4 | connectionStatusText, | |
| 5 | type EnvironmentConnectionPhase, | |
| 6 | } from "@t3tools/client-runtime/connection"; | |
| 7 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 8 | import { useCallback, useState } from "react"; | |
| 9 | import { | |
| 10 | ActivityIndicator, | |
| 11 | Pressable, | |
| 12 | Switch, | |
| 13 | type NativeSyntheticEvent, | |
| 14 | type TextLayoutEventData, | |
| 15 | View, | |
| 16 | } from "react-native"; | |
| 17 | ||
| 18 | import { AppText as Text } from "../../components/AppText"; | |
| 19 | import { cn } from "../../lib/cn"; | |
| 20 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | |
| 21 | import { useThemeColor } from "../../lib/useThemeColor"; | |
| 22 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | |
| 23 | import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; | |
| 24 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; | |
| 25 | import { 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 | */ | |
| 33 | export 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 | ||
| 148 | function 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 | ||
| 177 | function 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 | ||
| 211 | function 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 | ||
| 351 | function 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: { |
| 135 | 135 | > |
| 136 | 136 | {props.environment.isRelayManaged ? ( |
| 137 | 137 | <Text className="text-sm text-foreground-muted"> |
| 138 | Managed by T3 Connect. Tunnel details update automatically. | |
| 138 | Managed by T3 Cloud. Tunnel details update automatically. | |
| 139 | 139 | </Text> |
| 140 | 140 | ) : ( |
| 141 | 141 | <> |
apps/mobile/src/features/connection/environmentSections.test.ts+1-1| ... | ... | @@ -35,7 +35,7 @@ function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | describe("mobile environment settings sections", () => { |
| 38 | it("keeps saved relay-managed connections under T3 Connect", () => { | |
| 38 | it("keeps saved relay-managed connections under T3 Cloud", () => { | |
| 39 | 39 | const local = connectedEnvironment({ |
| 40 | 40 | environmentId: "environment-local", |
| 41 | 41 | isRelayManaged: false, |
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+343-3| ... | ... | @@ -1,18 +1,38 @@ |
| 1 | import { useAuth } from "@clerk/expo"; | |
| 1 | 2 | import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 2 | 3 | import { useNavigation } from "@react-navigation/native"; |
| 3 | 4 | import { SymbolView } from "expo-symbols"; |
| 5 | import { | |
| 6 | connectionStatusText, | |
| 7 | type EnvironmentConnectionPhase, | |
| 8 | } from "@t3tools/client-runtime/connection"; | |
| 4 | 9 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 5 | 10 | import { useCallback, useState } from "react"; |
| 6 | import { ScrollView, View } from "react-native"; | |
| 11 | import { | |
| 12 | ActivityIndicator, | |
| 13 | Pressable, | |
| 14 | ScrollView, | |
| 15 | Switch, | |
| 16 | type NativeSyntheticEvent, | |
| 17 | type TextLayoutEventData, | |
| 18 | View, | |
| 19 | } from "react-native"; | |
| 7 | 20 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | 21 | |
| 9 | 22 | import { AppText as Text } from "../../components/AppText"; |
| 23 | import { | |
| 24 | type RelayEnvironmentView, | |
| 25 | useConnectionController, | |
| 26 | } from "../connection/useConnectionController"; | |
| 10 | 27 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; |
| 11 | import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; | |
| 28 | import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; | |
| 12 | 29 | import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; |
| 30 | import { ConnectionStatusDot } from "../connection/ConnectionStatusDot"; | |
| 13 | 31 | import { splitEnvironmentSections } from "../connection/environmentSections"; |
| 14 | 32 | import { cn } from "../../lib/cn"; |
| 33 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | |
| 15 | 34 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 35 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | |
| 16 | 36 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 17 | 37 | |
| 18 | 38 | export function SettingsEnvironmentsRouteScreen() { |
| ... | ... | @@ -97,7 +117,7 @@ export function SettingsEnvironmentsRouteScreen() { |
| 97 | 117 | )} |
| 98 | 118 | |
| 99 | 119 | {hasCloudPublicConfig() ? ( |
| 100 | <CloudEnvironmentRows | |
| 120 | <ConfiguredCloudEnvironmentRows | |
| 101 | 121 | connectedCloudEnvironments={connectedCloudEnvironments} |
| 102 | 122 | onReconnectEnvironment={onReconnectEnvironment} |
| 103 | 123 | /> |
| ... | ... | @@ -106,3 +126,323 @@ export function SettingsEnvironmentsRouteScreen() { |
| 106 | 126 | </View> |
| 107 | 127 | ); |
| 108 | 128 | } |
| 129 | ||
| 130 | function 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 | ||
| 230 | function 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 | ||
| 259 | function 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 | ||
| 293 | function 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 | ||
| 433 | function CopyTraceIdButton(props: { readonly traceId: string }) { | |
| 434 | const iconColor = useThemeColor("--color-icon"); | |
| 435 | ||
| 436 | return ( | |
| 437 | <Pressable | |
| 438 | accessibilityRole="button" | |
| 439 | onPress={() => { | |
| 440 | copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); | |
| 441 | }} | |
| 442 | className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" | |
| 443 | > | |
| 444 | <SymbolView name="doc.on.doc" size={12} tintColor={iconColor} type="monochrome" /> | |
| 445 | <Text className="text-xs font-t3-bold text-foreground">Copy trace ID</Text> | |
| 446 | </Pressable> | |
| 447 | ); | |
| 448 | } |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+2-2| ... | ... | @@ -240,8 +240,8 @@ function ConfiguredSettingsRouteScreen() { |
| 240 | 240 | |
| 241 | 241 | const promptSignIn = useCallback(() => { |
| 242 | 242 | Alert.alert( |
| 243 | "Request T3 Connect access", | |
| 244 | "Live Activity updates require approved T3 Connect access so relay can deliver updates to this device.", | |
| 243 | "Request T3 Cloud access", | |
| 244 | "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.", | |
| 245 | 245 | [ |
| 246 | 246 | { text: "Cancel", style: "cancel" }, |
| 247 | 247 | { |
apps/mobile/src/lib/storage.ts-8| ... | ... | @@ -69,8 +69,6 @@ export interface Preferences { |
| 69 | 69 | /** Code/diff font size override; null/absent means derived from baseFontSize. */ |
| 70 | 70 | readonly codeFontSize?: number | null; |
| 71 | 71 | readonly codeWordBreak?: boolean; |
| 72 | /** Cloud account ids that opted out of the T3 Connect onboarding sheet. */ | |
| 73 | readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>; | |
| 74 | 72 | } |
| 75 | 73 | |
| 76 | 74 | async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> { |
| ... | ... | @@ -169,7 +167,6 @@ export async function loadPreferences(): Promise<Preferences> { |
| 169 | 167 | markdownFontSize?: number; |
| 170 | 168 | codeFontSize?: number | null; |
| 171 | 169 | codeWordBreak?: boolean; |
| 172 | connectOnboardingOptOutAccounts?: ReadonlyArray<string>; | |
| 173 | 170 | } = {}; |
| 174 | 171 | |
| 175 | 172 | if (typeof parsed.liveActivitiesEnabled === "boolean") { |
| ... | ... | @@ -190,11 +187,6 @@ export async function loadPreferences(): Promise<Preferences> { |
| 190 | 187 | if (typeof parsed.codeWordBreak === "boolean") { |
| 191 | 188 | preferences.codeWordBreak = parsed.codeWordBreak; |
| 192 | 189 | } |
| 193 | if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) { | |
| 194 | preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter( | |
| 195 | (account): account is string => typeof account === "string", | |
| 196 | ); | |
| 197 | } | |
| 198 | 190 | |
| 199 | 191 | return preferences; |
| 200 | 192 | } |
apps/web/src/cloud/connectOnboarding.ts deleted-18| ... | ... | @@ -1,18 +0,0 @@ |
| 1 | import * 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 | */ | |
| 8 | export const CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY = "t3code:connect-onboarding-opt-out:v1"; | |
| 9 | ||
| 10 | export const ConnectOnboardingOptOutSchema = Schema.Struct({ | |
| 11 | optOutAccounts: Schema.Array(Schema.String), | |
| 12 | }); | |
| 13 | ||
| 14 | export type ConnectOnboardingOptOutState = typeof ConnectOnboardingOptOutSchema.Type; | |
| 15 | ||
| 16 | export const EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE: ConnectOnboardingOptOutState = { | |
| 17 | optOutAccounts: [], | |
| 18 | }; |
apps/web/src/cloud/useCloudLinkController.ts deleted-161| ... | ... | @@ -1,161 +0,0 @@ |
| 1 | import { useAuth } from "@clerk/react"; | |
| 2 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; | |
| 3 | import { | |
| 4 | isAtomCommandInterrupted, | |
| 5 | settlePromise, | |
| 6 | squashAtomCommandFailure, | |
| 7 | } from "@t3tools/client-runtime/state/runtime"; | |
| 8 | import { useState } from "react"; | |
| 9 | ||
| 10 | import { toastManager } from "../components/ui/toast"; | |
| 11 | import { relayEnvironmentDiscovery } from "../state/relay"; | |
| 12 | import { useAtomCommand } from "../state/use-atom-command"; | |
| 13 | import { | |
| 14 | linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, | |
| 15 | unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, | |
| 16 | updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, | |
| 17 | } from "./linkEnvironmentAtoms"; | |
| 18 | import { usePrimaryCloudLinkState } from "./primaryCloudLinkState"; | |
| 19 | import { resolveRelayClerkTokenOptions } from "./publicConfig"; | |
| 20 | ||
| 21 | export 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 | */ | |
| 35 | export 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 deleted-56| ... | ... | @@ -1,56 +0,0 @@ |
| 1 | import { cn } from "~/lib/utils"; | |
| 2 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | |
| 3 | ||
| 4 | type ConnectionStatusDotProps = { | |
| 5 | tooltipText?: string | null; | |
| 6 | dotClassName: string; | |
| 7 | pingClassName?: string | null; | |
| 8 | }; | |
| 9 | ||
| 10 | export 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 deleted-204| ... | ... | @@ -1,204 +0,0 @@ |
| 1 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; | |
| 2 | import { | |
| 3 | RelayConnectionRegistration, | |
| 4 | RelayConnectionTarget, | |
| 5 | } from "@t3tools/client-runtime/connection"; | |
| 6 | import { | |
| 7 | isAtomCommandInterrupted, | |
| 8 | squashAtomCommandFailure, | |
| 9 | } from "@t3tools/client-runtime/state/runtime"; | |
| 10 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 11 | import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; | |
| 12 | import * as Option from "effect/Option"; | |
| 13 | import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; | |
| 14 | ||
| 15 | import { environmentCatalog } from "~/connection/catalog"; | |
| 16 | import { cn } from "~/lib/utils"; | |
| 17 | import { relayEnvironmentDiscovery } from "~/state/relay"; | |
| 18 | import { useRelayEnvironmentDiscovery } from "~/state/environments"; | |
| 19 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 20 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; | |
| 21 | import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows"; | |
| 22 | import { Button } from "../ui/button"; | |
| 23 | import { Skeleton } from "../ui/skeleton"; | |
| 24 | import { toastManager } from "../ui/toast"; | |
| 25 | ||
| 26 | export 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 | */ | |
| 47 | export 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 deleted-405| ... | ... | @@ -1,405 +0,0 @@ |
| 1 | import { useAuth } from "@clerk/react"; | |
| 2 | import { AuthAdministrativeScopes, AuthRelayWriteScope } from "@t3tools/contracts"; | |
| 3 | import { CheckIcon } from "lucide-react"; | |
| 4 | import { useEffect, useMemo, useRef, useState } from "react"; | |
| 5 | ||
| 6 | import { | |
| 7 | CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY, | |
| 8 | ConnectOnboardingOptOutSchema, | |
| 9 | EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, | |
| 10 | } from "~/cloud/connectOnboarding"; | |
| 11 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; | |
| 12 | import { useCloudLinkController } from "~/cloud/useCloudLinkController"; | |
| 13 | import { usePrimarySessionState } from "~/environments/primary"; | |
| 14 | import { useLocalStorage } from "~/hooks/useLocalStorage"; | |
| 15 | import { cn } from "~/lib/utils"; | |
| 16 | import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; | |
| 17 | import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; | |
| 18 | import { Button } from "../ui/button"; | |
| 19 | import { Checkbox } from "../ui/checkbox"; | |
| 20 | import { | |
| 21 | Dialog, | |
| 22 | DialogDescription, | |
| 23 | DialogFooter, | |
| 24 | DialogHeader, | |
| 25 | DialogPanel, | |
| 26 | DialogPopup, | |
| 27 | DialogTitle, | |
| 28 | } from "../ui/dialog"; | |
| 29 | import { Switch } from "../ui/switch"; | |
| 30 | import { 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 | */ | |
| 41 | export function ConnectOnboardingDialog() { | |
| 42 | if (!hasCloudPublicConfig()) return null; | |
| 43 | ||
| 44 | return <ConfiguredConnectOnboardingDialog />; | |
| 45 | } | |
| 46 | ||
| 47 | type OnboardingStep = "publish" | "devices"; | |
| 48 | ||
| 49 | function 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 | ||
| 256 | const STEP_LABELS: Record<OnboardingStep, string> = { | |
| 257 | publish: "Publish", | |
| 258 | devices: "Connect devices", | |
| 259 | }; | |
| 260 | ||
| 261 | function 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 | ||
| 312 | function 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 | ||
| 350 | function 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 | ||
| 379 | function 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+359-16| ... | ... | @@ -7,7 +7,8 @@ import { |
| 7 | 7 | TerminalIcon, |
| 8 | 8 | TriangleAlertIcon, |
| 9 | 9 | } from "lucide-react"; |
| 10 | import { type ReactNode, memo, useCallback, useMemo, useState } from "react"; | |
| 10 | import { useAuth } from "@clerk/react"; | |
| 11 | import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; | |
| 11 | 12 | import { |
| 12 | 13 | AuthAccessReadScope, |
| 13 | 14 | AuthAccessWriteScope, |
| ... | ... | @@ -29,11 +30,18 @@ import { |
| 29 | 30 | type DesktopWslState, |
| 30 | 31 | type EnvironmentId, |
| 31 | 32 | } from "@t3tools/contracts"; |
| 32 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; | |
| 33 | import { | |
| 34 | connectionStatusText, | |
| 35 | RelayConnectionRegistration, | |
| 36 | RelayConnectionTarget, | |
| 37 | } from "@t3tools/client-runtime/connection"; | |
| 38 | import { findErrorTraceId } from "@t3tools/client-runtime/errors"; | |
| 33 | 39 | import { |
| 34 | 40 | isAtomCommandInterrupted, |
| 41 | settlePromise, | |
| 35 | 42 | squashAtomCommandFailure, |
| 36 | 43 | } from "@t3tools/client-runtime/state/runtime"; |
| 44 | import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; | |
| 37 | 45 | import * as DateTime from "effect/DateTime"; |
| 38 | 46 | import * as Option from "effect/Option"; |
| 39 | 47 | |
| ... | ... | @@ -42,6 +50,7 @@ import { cn } from "../../lib/utils"; |
| 42 | 50 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; |
| 43 | 51 | import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; |
| 44 | 52 | import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; |
| 53 | import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig"; | |
| 45 | 54 | import { |
| 46 | 55 | SettingsPageContainer, |
| 47 | 56 | SettingsRow, |
| ... | ... | @@ -73,6 +82,7 @@ import { |
| 73 | 82 | } from "../ui/alert-dialog"; |
| 74 | 83 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 75 | 84 | import { QRCodeSvg } from "../ui/qr-code"; |
| 85 | import { Skeleton } from "../ui/skeleton"; | |
| 76 | 86 | import { Spinner } from "../ui/spinner"; |
| 77 | 87 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 78 | 88 | import { Switch } from "../ui/switch"; |
| ... | ... | @@ -107,8 +117,13 @@ import { |
| 107 | 117 | import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; |
| 108 | 118 | import { useUiStateStore } from "~/uiStateStore"; |
| 109 | 119 | import { resolveServerConfigVersionMismatch } from "~/versionSkew"; |
| 120 | import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; | |
| 110 | 121 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 111 | import { useCloudLinkController } from "~/cloud/useCloudLinkController"; | |
| 122 | import { | |
| 123 | linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, | |
| 124 | unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, | |
| 125 | updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, | |
| 126 | } from "~/cloud/linkEnvironmentAtoms"; | |
| 112 | 127 | import { authEnvironment } from "~/state/auth"; |
| 113 | 128 | import { environmentCatalog } from "~/connection/catalog"; |
| 114 | 129 | import { |
| ... | ... | @@ -126,11 +141,10 @@ import { |
| 126 | 141 | type EnvironmentPresentation, |
| 127 | 142 | useEnvironments, |
| 128 | 143 | usePrimaryEnvironment, |
| 144 | useRelayEnvironmentDiscovery, | |
| 129 | 145 | } from "~/state/environments"; |
| 146 | import { relayEnvironmentDiscovery } from "~/state/relay"; | |
| 130 | 147 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 131 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; | |
| 132 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; | |
| 133 | import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; | |
| 134 | 148 | |
| 135 | 149 | const DEFAULT_TAILSCALE_SERVE_PORT = 443; |
| 136 | 150 | const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray<AdvertisedEndpoint> = []; |
| ... | ... | @@ -246,6 +260,60 @@ function AccessScopeSummary({ |
| 246 | 260 | ); |
| 247 | 261 | } |
| 248 | 262 | |
| 263 | type ConnectionStatusDotProps = { | |
| 264 | tooltipText?: string | null; | |
| 265 | dotClassName: string; | |
| 266 | pingClassName?: string | null; | |
| 267 | }; | |
| 268 | ||
| 269 | function 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 | ||
| 249 | 317 | function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string { |
| 250 | 318 | const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; |
| 251 | 319 | return target.port ? `${authority}:${target.port}` : authority; |
| ... | ... | @@ -369,8 +437,13 @@ function formatDesktopSshConnectionError(error: unknown): string { |
| 369 | 437 | return withoutTaggedErrorPrefix.trim() || fallback; |
| 370 | 438 | } |
| 371 | 439 | |
| 440 | /** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ | |
| 441 | const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; | |
| 372 | 442 | const ENDPOINT_ROW_CLASSNAME = "border-t border-border/60 px-4 py-2.5 first:border-t-0 sm:px-5"; |
| 373 | 443 | |
| 444 | const ITEM_ROW_INNER_CLASSNAME = | |
| 445 | "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; | |
| 446 | ||
| 374 | 447 | type AccessSectionPresentation = "current" | "endpoint-rail"; |
| 375 | 448 | |
| 376 | 449 | function accessRowClassName(_presentation: AccessSectionPresentation) { |
| ... | ... | @@ -1556,17 +1629,51 @@ function CloudLinkSwitch({ |
| 1556 | 1629 | } |
| 1557 | 1630 | |
| 1558 | 1631 | function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { |
| 1559 | const { | |
| 1560 | isSignedIn, | |
| 1561 | linkState: primaryCloudLinkState, | |
| 1562 | managedTunnelActive, | |
| 1563 | publishAgentActivity, | |
| 1564 | operationError, | |
| 1565 | reconcileCloudState, | |
| 1566 | } = useCloudLinkController(); | |
| 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); | |
| 1567 | 1648 | const [isUpdating, setIsUpdating] = useState(false); |
| 1568 | 1649 | const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); |
| 1569 | 1650 | |
| 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; | |
| 1570 | 1677 | const disabledReason = !isSignedIn |
| 1571 | 1678 | ? "Sign in to T3 Connect to manage this environment." |
| 1572 | 1679 | : !canManageRelay |
| ... | ... | @@ -1574,6 +1681,86 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1574 | 1681 | : null; |
| 1575 | 1682 | const isBusy = isUpdating || isUpdatingPreference; |
| 1576 | 1683 | |
| 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 | ||
| 1577 | 1764 | const updateManagedTunnel = async (enabled: boolean) => { |
| 1578 | 1765 | setIsUpdating(true); |
| 1579 | 1766 | const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity }); |
| ... | ... | @@ -1670,6 +1857,163 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable |
| 1670 | 1857 | ); |
| 1671 | 1858 | } |
| 1672 | 1859 | |
| 1860 | function 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 | ||
| 1874 | function 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 | ||
| 1673 | 2017 | function CloudRemoteEnvironmentRows({ |
| 1674 | 2018 | primaryEnvironmentId, |
| 1675 | 2019 | savedEnvironmentIds, |
| ... | ... | @@ -1678,10 +2022,9 @@ function CloudRemoteEnvironmentRows({ |
| 1678 | 2022 | readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>; |
| 1679 | 2023 | }) { |
| 1680 | 2024 | return hasCloudPublicConfig() ? ( |
| 1681 | <CloudEnvironmentConnectRows | |
| 2025 | <ConfiguredCloudRemoteEnvironmentRows | |
| 1682 | 2026 | primaryEnvironmentId={primaryEnvironmentId} |
| 1683 | 2027 | savedEnvironmentIds={savedEnvironmentIds} |
| 1684 | empty={<EmptyRemoteEnvironments />} | |
| 1685 | 2028 | /> |
| 1686 | 2029 | ) : savedEnvironmentIds.length === 0 ? ( |
| 1687 | 2030 | <EmptyRemoteEnvironments cloudEnabled={false} /> |
apps/web/src/components/settings/itemRows.ts deleted-5| ... | ... | @@ -1,5 +0,0 @@ |
| 1 | /** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ | |
| 2 | export const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; | |
| 3 | ||
| 4 | export const ITEM_ROW_INNER_CLASSNAME = | |
| 5 | "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; |
apps/web/src/connection/platform.ts+2-2| ... | ... | @@ -182,7 +182,7 @@ const capabilitiesLayer = Layer.effectContext( |
| 182 | 182 | if (session === null) { |
| 183 | 183 | return yield* new ConnectionBlockedError({ |
| 184 | 184 | reason: "authentication", |
| 185 | detail: "Sign in to T3 Connect to connect this environment.", | |
| 185 | detail: "Sign in to T3 Cloud to connect this environment.", | |
| 186 | 186 | }); |
| 187 | 187 | } |
| 188 | 188 | const token = yield* session.readClerkToken().pipe( |
| ... | ... | @@ -197,7 +197,7 @@ const capabilitiesLayer = Layer.effectContext( |
| 197 | 197 | if (token === null) { |
| 198 | 198 | return yield* new ConnectionBlockedError({ |
| 199 | 199 | reason: "authentication", |
| 200 | detail: "The T3 Connect session is unavailable.", | |
| 200 | detail: "The T3 Cloud session is unavailable.", | |
| 201 | 201 | }); |
| 202 | 202 | } |
| 203 | 203 | return token; |
apps/web/src/routes/__root.tsx-2| ... | ... | @@ -14,7 +14,6 @@ import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding"; |
| 14 | 14 | import { resolveServerBackedAppDisplayName } from "../branding.logic"; |
| 15 | 15 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| 16 | 16 | import { CommandPalette } from "../components/CommandPalette"; |
| 17 | import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; | |
| 18 | 17 | import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; |
| 19 | 18 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; |
| 20 | 19 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| ... | ... | @@ -129,7 +128,6 @@ function RootRouteView() { |
| 129 | 128 | <DocumentTitleSync /> |
| 130 | 129 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} |
| 131 | 130 | <RelayClientInstallDialog /> |
| 132 | <ConnectOnboardingDialog /> | |
| 133 | 131 | <SshPasswordPromptDialog /> |
| 134 | 132 | <SlowRpcRequestToastCoordinator /> |
| 135 | 133 | <HostedStaticEnvironmentBootstrap /> |
packages/client-runtime/src/relay/discovery.test.ts-49| ... | ... | @@ -342,55 +342,6 @@ describe("RelayEnvironmentDiscovery", () => { |
| 342 | 342 | }), |
| 343 | 343 | ); |
| 344 | 344 | |
| 345 | it.effect("refreshes proactively when credentials change before any manual refresh", () => | |
| 346 | Effect.gen(function* () { | |
| 347 | const harness = yield* makeHarness(); | |
| 348 | yield* Effect.gen(function* () { | |
| 349 | const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; | |
| 350 | const requests = yield* Ref.get(harness.statusRequests); | |
| 351 | for (const environment of environments) { | |
| 352 | yield* Deferred.succeed( | |
| 353 | requests.get(environment.environmentId)!, | |
| 354 | status(environment, "online"), | |
| 355 | ); | |
| 356 | } | |
| 357 | ||
| 358 | // Let the scoped wakeup subscription start before emitting, mirroring | |
| 359 | // a real sign-in which always happens long after service start. | |
| 360 | yield* Effect.yieldNow; | |
| 361 | ||
| 362 | // Sign-in activates the session and emits credentials-changed; the | |
| 363 | // list must populate without any screen having asked for a refresh. | |
| 364 | yield* harness.wake("credentials-changed"); | |
| 365 | const populated = yield* SubscriptionRef.changes(discovery.state).pipe( | |
| 366 | Stream.filter( | |
| 367 | (state) => state.environments.size === environments.length && !state.refreshing, | |
| 368 | ), | |
| 369 | Stream.runHead, | |
| 370 | Effect.map(Option.getOrThrow), | |
| 371 | ); | |
| 372 | expect(Option.isNone(populated.error)).toBe(true); | |
| 373 | expect(yield* Ref.get(harness.listCalls)).toBe(1); | |
| 374 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | |
| 375 | }), | |
| 376 | ); | |
| 377 | ||
| 378 | it.effect("settles to a clean empty state when refreshed while signed out", () => | |
| 379 | Effect.gen(function* () { | |
| 380 | const harness = yield* makeHarness(); | |
| 381 | yield* Effect.gen(function* () { | |
| 382 | const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; | |
| 383 | yield* Ref.set(harness.clerkToken, null); | |
| 384 | yield* discovery.refresh; | |
| 385 | ||
| 386 | const state = yield* SubscriptionRef.get(discovery.state); | |
| 387 | expect(state.environments.size).toBe(0); | |
| 388 | expect(state.refreshing).toBe(false); | |
| 389 | expect(Option.isNone(state.error)).toBe(true); | |
| 390 | }).pipe(Effect.provide(harness.layer)); | |
| 391 | }), | |
| 392 | ); | |
| 393 | ||
| 394 | 345 | it.effect("does not republish stale rows after sign-out invalidates an in-flight refresh", () => |
| 395 | 346 | Effect.gen(function* () { |
| 396 | 347 | const harness = yield* makeHarness(); |
packages/client-runtime/src/relay/discovery.ts+5-11| ... | ... | @@ -279,15 +279,10 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { |
| 279 | 279 | if ((yield* Ref.get(accountGeneration)) !== generation) { |
| 280 | 280 | return; |
| 281 | 281 | } |
| 282 | // Signed out is the idle state, not a failure: the proactive | |
| 283 | // refresh on credentials-changed also runs on sign-out and must | |
| 284 | // settle back to a clean empty list. | |
| 285 | const signedOut = | |
| 286 | error._tag === "ConnectionBlockedError" && error.reason === "authentication"; | |
| 287 | 282 | yield* SubscriptionRef.update(state, (current) => ({ |
| 288 | 283 | ...current, |
| 289 | 284 | refreshing: false, |
| 290 | error: signedOut ? Option.none() : Option.some(error), | |
| 285 | error: Option.some(error), | |
| 291 | 286 | })); |
| 292 | 287 | }), |
| 293 | 288 | ), |
| ... | ... | @@ -316,12 +311,11 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { |
| 316 | 311 | yield* Ref.update(accountGeneration, (current) => current + 1); |
| 317 | 312 | yield* Ref.set(activeAccountId, Option.none()); |
| 318 | 313 | yield* Ref.set(offlineReportFingerprints, new Map()); |
| 314 | const shouldRefresh = yield* Ref.get(hasRefreshed); | |
| 319 | 315 | yield* SubscriptionRef.set(state, EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); |
| 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); | |
| 316 | if (shouldRefresh) { | |
| 317 | yield* refresh.pipe(Effect.forkScoped); | |
| 318 | } | |
| 325 | 319 | }) |
| 326 | 320 | : Effect.void, |
| 327 | 321 | ), |
packages/client-runtime/src/relay/managedRelayState.ts+4-4| ... | ... | @@ -123,7 +123,7 @@ export function createManagedRelaySession(input: ManagedRelaySessionInput): Mana |
| 123 | 123 | try: () => readCachedClerkToken(nowMillis), |
| 124 | 124 | catch: (cause) => |
| 125 | 125 | new ManagedRelaySessionError({ |
| 126 | message: "Could not obtain the T3 Connect session token.", | |
| 126 | message: "Could not obtain the T3 Cloud session token.", | |
| 127 | 127 | cause, |
| 128 | 128 | }), |
| 129 | 129 | }); |
| ... | ... | @@ -181,7 +181,7 @@ function readSessionClerkToken( |
| 181 | 181 | ? Effect.succeed(token) |
| 182 | 182 | : Effect.fail( |
| 183 | 183 | new ManagedRelaySessionError({ |
| 184 | message: "The T3 Connect session token is unavailable.", | |
| 184 | message: "The T3 Cloud session token is unavailable.", | |
| 185 | 185 | }), |
| 186 | 186 | ), |
| 187 | 187 | ), |
| ... | ... | @@ -226,7 +226,7 @@ function requireClerkToken( |
| 226 | 226 | if (!session || session.accountId !== accountId) { |
| 227 | 227 | return Effect.fail( |
| 228 | 228 | new ManagedRelaySessionError({ |
| 229 | message: "Sign in to T3 Connect before loading relay data.", | |
| 229 | message: "Sign in to T3 Cloud before loading relay data.", | |
| 230 | 230 | }), |
| 231 | 231 | ); |
| 232 | 232 | } |
| ... | ... | @@ -296,7 +296,7 @@ export function readManagedRelaySnapshotState<A>( |
| 296 | 296 | let errorTraceId: string | null = null; |
| 297 | 297 | if (result._tag === "Failure") { |
| 298 | 298 | const cause = Cause.squash(result.cause); |
| 299 | error = cause instanceof Error ? cause.message : "Could not load T3 Connect data."; | |
| 299 | error = cause instanceof Error ? cause.message : "Could not load T3 Cloud data."; | |
| 300 | 300 | errorTraceId = findErrorTraceId(cause); |
| 301 | 301 | } |
| 302 | 302 | return { |