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