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