From 53ab016efd144bb3f5b32991c0b5b7531f9f3565 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 7 Jul 2026 02:03:05 -0700 Subject: [PATCH 1/3] Add T3 Connect onboarding flow - Present an onboarding sheet after in-session sign-in - Let users publish local environments and connect cloud environments - Persist onboarding completion per account across mobile sessions --- apps/mobile/src/Stack.tsx | 15 + .../src/features/cloud/CloudAuthProvider.tsx | 11 + .../cloud/ConnectOnboardingRouteScreen.tsx | 510 ++++++++++++++++++ .../src/features/cloud/connectOnboarding.ts | 68 +++ .../src/features/cloud/linkEnvironment.ts | 106 +++- .../connection/CloudEnvironmentRows.tsx | 351 ++++++++++++ .../SettingsEnvironmentsRouteScreen.tsx | 346 +----------- apps/mobile/src/lib/storage.ts | 9 + apps/web/src/cloud/connectOnboarding.ts | 18 + apps/web/src/cloud/useCloudLinkController.ts | 161 ++++++ .../src/components/ConnectionStatusDot.tsx | 56 ++ .../cloud/CloudEnvironmentConnectList.tsx | 204 +++++++ .../cloud/ConnectOnboardingDialog.tsx | 368 +++++++++++++ .../settings/ConnectionsSettings.tsx | 375 +------------ apps/web/src/components/settings/itemRows.ts | 5 + apps/web/src/routes/__root.tsx | 2 + 16 files changed, 1899 insertions(+), 706 deletions(-) create mode 100644 apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx create mode 100644 apps/mobile/src/features/cloud/connectOnboarding.ts create mode 100644 apps/mobile/src/features/connection/CloudEnvironmentRows.tsx create mode 100644 apps/web/src/cloud/connectOnboarding.ts create mode 100644 apps/web/src/cloud/useCloudLinkController.ts create mode 100644 apps/web/src/components/ConnectionStatusDot.tsx create mode 100644 apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx create mode 100644 apps/web/src/components/cloud/ConnectOnboardingDialog.tsx create mode 100644 apps/web/src/components/settings/itemRows.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 29cf29da90a..4592400e921 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -17,6 +17,8 @@ import { AppText as Text } from "./components/AppText"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; +import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; +import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboarding"; import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider"; @@ -220,6 +222,7 @@ const NewTaskSheetStack = createNativeStackNavigator({ // influence the adaptive workspace layout: opening Settings over Home should // not flip the sidebar in or change the active thread. const WORKSPACE_OVERLAY_ROUTES = new Set([ + "ConnectOnboarding", "Connections", "ConnectionsNew", "GitBranches", @@ -251,6 +254,8 @@ function RootStackLayout(props: { }) { useAgentNotificationNavigation(); useThreadOutboxDrain(); + // Presents the T3 Connect onboarding sheet after an in-session sign-in. + useConnectOnboardingNavigation(); // Full pathname (sheets included) for keyboard-command scoping; the // workspace layout only reacts to the underlying non-overlay route. const path = getPathFromState(props.state, navigationPathConfig); @@ -412,6 +417,16 @@ export const RootStack = createNativeStackNavigator({ sheetGrabberVisible: true, }, }), + ConnectOnboarding: createNativeStackScreen({ + screen: ConnectOnboardingRouteScreen, + linking: "connect-onboarding", + options: { + gestureEnabled: true, + presentation: "formSheet", + sheetAllowedDetents: [0.75, 0.95], + sheetGrabberVisible: true, + }, + }), Connections: createNativeStackScreen({ screen: ConnectionsRouteScreen, linking: "connections", diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index 4180ae15c5e..9d8e9d25763 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -18,6 +18,7 @@ import { setAgentAwarenessRelayTokenProvider, unregisterAgentAwarenessDeviceForCurrentUser, } from "../agent-awareness/remoteRegistration"; +import { requestConnectOnboardingIfNeeded } from "./connectOnboarding"; import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; function resetManagedRelayTokenCache() { @@ -67,6 +68,16 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { const nextAccount = isSignedIn && userId ? userId : null; observedAccountRef.current = nextAccount; + // A sign-in that completed during this session (a cold start observes + // undefined → account and must not re-prompt) requests the T3 Connect + // onboarding sheet once per account. + if (previousObservedAccount === null && nextAccount !== null) { + void (async () => { + const result = await settlePromise(() => requestConnectOnboardingIfNeeded(nextAccount)); + reportAtomCommandResult(result, { label: "connect onboarding request" }); + })(); + } + const queueAccountCleanup = ( previous: { readonly userId: string; diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx new file mode 100644 index 00000000000..9b132b4def3 --- /dev/null +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -0,0 +1,510 @@ +import { useAuth } from "@clerk/expo"; +import { useNavigation } from "@react-navigation/native"; +import * as Effect from "effect/Effect"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { + isAtomCommandInterrupted, + reportAtomCommandResult, + settleAsyncResult, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { AppText as Text } from "../../components/AppText"; +import type { SavedRemoteConnection } from "../../lib/connection"; +import { cn } from "../../lib/cn"; +import { runtime } from "../../lib/runtime"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { + useRemoteConnections, + useSavedRemoteConnections, +} from "../../state/use-remote-environment-registry"; +import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; +import { splitEnvironmentSections } from "../connection/environmentSections"; +import { SettingsSection } from "../settings/components/SettingsSection"; +import { SettingsSwitchRow } from "../settings/components/SettingsSwitchRow"; +import { markConnectOnboardingCompleted } from "./connectOnboarding"; +import { + getEnvironmentConnectPublishState, + linkEnvironmentToCloud, + setEnvironmentPublishAgentActivity, +} from "./linkEnvironment"; +import { refreshManagedRelayEnvironments } from "./managedRelayState"; +import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; + +type PublishTargetPhase = "checking" | "ready" | "published" | "unauthorized" | "error"; + +interface PublishTarget { + readonly connection: SavedRemoteConnection; + readonly phase: PublishTargetPhase; + readonly linked: boolean; + readonly managedTunnelActive: boolean; + readonly publishAgentActivity: boolean; + readonly error: string | null; +} + +function pendingPublishTarget(connection: SavedRemoteConnection): PublishTarget { + return { + connection, + phase: "checking", + linked: false, + managedTunnelActive: false, + publishAgentActivity: false, + error: null, + }; +} + +/** + * Post-sign-in onboarding sheet for T3 Connect. Offers to publish this + * device's locally paired environments (managed tunnel + agent activity, both + * defaulting on) when the paired session is authorized, then lists the + * account's T3 Connect environments so every device can be connected right + * away. Dismissing the sheet counts as completion — it never nags twice. + */ +export function ConnectOnboardingRouteScreen() { + return hasCloudPublicConfig() ? : null; +} + +function ConfiguredConnectOnboardingRouteScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { getToken, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); + const { isLoadingSavedConnection, savedConnectionsById } = useSavedRemoteConnections(); + const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections(); + const { connectedCloudEnvironments } = splitEnvironmentSections({ + connectedEnvironments, + cloudEnvironments: null, + }); + + // Dismissing the sheet (swipe, Skip, or Done) counts as completion. + const completionPersistedRef = useRef(false); + const persistCompletion = useCallback(() => { + if (completionPersistedRef.current || !userId) { + return; + } + completionPersistedRef.current = true; + void (async () => { + const result = await settlePromise(() => markConnectOnboardingCompleted(userId)); + reportAtomCommandResult(result, { label: "connect onboarding completion" }); + })(); + }, [userId]); + + useEffect( + () => navigation.addListener("beforeRemove", persistCompletion), + [navigation, persistCompletion], + ); + + const handleDone = useCallback(() => { + persistCompletion(); + navigation.goBack(); + }, [navigation, persistCompletion]); + + const bearerConnections = useMemo( + () => + Object.values(savedConnectionsById).filter((connection) => connection.bearerToken !== null), + [savedConnectionsById], + ); + + const [targets, setTargets] = useState | null>(null); + const [publishEnvironment, setPublishEnvironment] = useState(true); + const [publishAgentActivity, setPublishAgentActivity] = useState(true); + const [isApplying, setIsApplying] = useState(false); + + const loadPublishTargets = useCallback( + async (connections: ReadonlyArray) => { + if (connections.length === 0) { + setTargets([]); + return; + } + setTargets(connections.map(pendingPublishTarget)); + const result = await settleAsyncResult(() => + runtime.runPromiseExit( + Effect.forEach( + connections, + (connection) => + getEnvironmentConnectPublishState({ connection }).pipe( + Effect.match({ + onFailure: (error): PublishTarget => ({ + ...pendingPublishTarget(connection), + phase: "error", + error: error.message, + }), + onSuccess: (state): PublishTarget => ({ + connection, + phase: + state.canPublish === false + ? "unauthorized" + : state.linked && state.managedTunnelActive && state.publishAgentActivity + ? "published" + : "ready", + linked: state.linked, + managedTunnelActive: state.managedTunnelActive, + publishAgentActivity: state.publishAgentActivity, + error: null, + }), + }), + ), + { concurrency: "unbounded" }, + ), + ), + ); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + const message = + error instanceof Error ? error.message : "Could not check the environment link state."; + setTargets( + connections.map((connection) => ({ + ...pendingPublishTarget(connection), + phase: "error" as const, + error: message, + })), + ); + return; + } + setTargets(result.value); + }, + [], + ); + + // Check authorization + link state once the locally saved connections are + // ready. Loaded once per mount: reconnect churn in the registry must not + // flip the section back to "checking" mid-interaction. + const loadedRef = useRef(false); + useEffect(() => { + if (isLoadingSavedConnection || loadedRef.current) { + return; + } + loadedRef.current = true; + void loadPublishTargets(bearerConnections); + }, [bearerConnections, isLoadingSavedConnection, loadPublishTargets]); + + const publishableTargets = useMemo( + () => (targets ?? []).filter((target) => target.phase === "ready"), + [targets], + ); + const targetConnections = useMemo( + () => (targets ?? []).map((target) => target.connection), + [targets], + ); + + const handlePublish = useCallback(async () => { + const applicable = publishableTargets; + if (applicable.length === 0 || (!publishEnvironment && !publishAgentActivity)) { + return; + } + setIsApplying(true); + const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); + const clerkToken = tokenResult._tag === "Success" ? tokenResult.value : null; + if (clerkToken === null) { + setIsApplying(false); + const error = tokenResult._tag === "Failure" ? squashAtomCommandFailure(tokenResult) : null; + Alert.alert( + "Publishing unavailable", + error instanceof Error ? error.message : "Sign in to publish environments over T3 Connect.", + ); + return; + } + + const failures: Array = []; + await Promise.all( + applicable.map(async (target) => { + // Onboarding only ever enables: it links when something is missing and + // never tears down an existing link or clears a preference. + const needsLink = publishEnvironment + ? !target.linked || !target.managedTunnelActive + : !target.linked && publishAgentActivity; + const applyEffect = Effect.gen(function* () { + if (needsLink) { + yield* linkEnvironmentToCloud({ + connection: target.connection, + clerkToken, + mode: publishEnvironment ? "managed" : "publish_only", + }); + } + if (publishAgentActivity && !target.publishAgentActivity) { + yield* setEnvironmentPublishAgentActivity({ + connection: target.connection, + publishAgentActivity: true, + }); + } + }); + const result = await settleAsyncResult(() => runtime.runPromiseExit(applyEffect)); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + failures.push( + `${target.connection.environmentLabel}: ${ + error instanceof Error ? error.message : "Publishing failed." + }`, + ); + } + }), + ); + + refreshManagedRelayEnvironments(); + await loadPublishTargets(targetConnections); + setIsApplying(false); + if (failures.length > 0) { + Alert.alert("Couldn't publish every environment", failures.join("\n\n")); + } + }, [ + getToken, + loadPublishTargets, + publishAgentActivity, + publishEnvironment, + publishableTargets, + targetConnections, + ]); + + return ( + + + + + Set up T3 Connect + + Skip + + + + { + "Mesh your devices together — publish this device's environments and connect the rest, all in one place." + } + + + + {isSignedIn ? ( + <> + void handlePublish()} + onRetry={() => void loadPublishTargets(targetConnections)} + /> + + + + Connect your environments + + + Environments published from your other devices are ready to connect here. + + + + + ) : ( + + + Sign in to your T3 account to set up T3 Connect. + + + )} + + + Done + + + + ); +} + +function PublishSection(props: { + readonly targets: ReadonlyArray | null; + readonly publishEnvironment: boolean; + readonly publishAgentActivity: boolean; + readonly isApplying: boolean; + readonly onPublishEnvironmentChange: (enabled: boolean) => void; + readonly onPublishAgentActivityChange: (enabled: boolean) => void; + readonly onPublish: () => void; + readonly onRetry: () => void; +}) { + const iconColor = useThemeColor("--color-icon"); + const primaryForeground = useThemeColor("--color-primary-foreground"); + const targets = props.targets; + const isChecking = targets === null || targets.some((target) => target.phase === "checking"); + const publishableTargets = (targets ?? []).filter((target) => target.phase === "ready"); + const publishedTargets = (targets ?? []).filter((target) => target.phase === "published"); + const hasRetryableErrors = (targets ?? []).some((target) => target.phase === "error"); + const canPublish = + publishableTargets.length > 0 && + (props.publishEnvironment || props.publishAgentActivity) && + !props.isApplying; + + if (targets !== null && targets.length === 0) { + return ( + + + Publish this device + + + + { + "No locally paired environments on this device yet. Pair an environment first and it can be published to your other devices from Settings." + } + + + + ); + } + + if (isChecking) { + return ( + + + Publish this device + + + + + Checking which environments can publish. + + + + ); + } + + return ( + + + {publishableTargets.length > 0 ? ( + <> + + + + ) : publishedTargets.length > 0 ? ( + + + {"This device's environments already publish over T3 Connect."} + + + ) : ( + + + { + "This device's paired sessions aren't authorized to publish. Pair again with an administrative link to enable publishing." + } + + + )} + + + {publishableTargets.length > 0 ? ( + + Make the environments paired with this device available to your other devices, and send + agent activity for notifications and Live Activities. + + ) : null} + + {targets.map((target) => ( + + ))} + + {publishableTargets.length > 0 ? ( + + {props.isApplying ? : null} + + {props.isApplying + ? "Publishing..." + : `Publish ${publishableTargets.length === 1 ? "environment" : `${publishableTargets.length} environments`}`} + + + ) : null} + + {hasRetryableErrors ? ( + + Check again + + ) : null} + + ); +} + +function publishTargetStatusText(target: PublishTarget): string { + switch (target.phase) { + case "checking": + return "Checking..."; + case "published": + return "Already publishing"; + case "ready": + return target.linked ? "Partially published — ready to update" : "Ready to publish"; + case "unauthorized": + return "This device's session is not authorized to publish"; + case "error": + return target.error ?? "Could not check the environment"; + } +} + +function PublishTargetStatusRow(props: { readonly target: PublishTarget }) { + const isError = props.target.phase === "error"; + return ( + + + {props.target.connection.environmentLabel} + + + {publishTargetStatusText(props.target)} + + + ); +} diff --git a/apps/mobile/src/features/cloud/connectOnboarding.ts b/apps/mobile/src/features/cloud/connectOnboarding.ts new file mode 100644 index 00000000000..3859a8fa3a6 --- /dev/null +++ b/apps/mobile/src/features/cloud/connectOnboarding.ts @@ -0,0 +1,68 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useNavigation } from "@react-navigation/native"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect } from "react"; + +import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; +import { appAtomRegistry } from "../../state/atom-registry"; + +// Signals RootStackLayout (inside the navigation tree) that an in-session +// sign-in just completed for an account that has not seen the T3 Connect +// onboarding sheet yet. Holds the account id so a sign-out between the request +// and the navigation cannot present the sheet for the wrong account. +const connectOnboardingRequestAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:connect-onboarding-request"), +); + +/** + * Requests the onboarding sheet for the given account unless it already + * completed (or skipped) onboarding on this device. + */ +export async function requestConnectOnboardingIfNeeded(accountId: string): Promise { + const preferences = await loadPreferences(); + if (preferences.connectOnboardingCompletedAccounts?.includes(accountId)) { + return; + } + appAtomRegistry.set(connectOnboardingRequestAtom, accountId); +} + +export function clearConnectOnboardingRequest(): void { + appAtomRegistry.set(connectOnboardingRequestAtom, null); +} + +/** Persists that the account saw the sheet, so it never nags twice. */ +export async function markConnectOnboardingCompleted(accountId: string): Promise { + const preferences = await loadPreferences(); + const completed = preferences.connectOnboardingCompletedAccounts ?? []; + if (completed.includes(accountId)) { + return; + } + await savePreferencesPatch({ connectOnboardingCompletedAccounts: [...completed, accountId] }); +} + +// Sign-in happens inside the Settings sheet; give its detent/session-state +// transitions a beat to settle before presenting another formSheet on top. +const PRESENT_ONBOARDING_DELAY_MS = 600; + +/** + * Consumes the onboarding request inside the navigation tree (RootStackLayout) + * and presents the onboarding formSheet. + */ +export function useConnectOnboardingNavigation(): void { + const navigation = useNavigation(); + const requestedAccountId = useAtomValue(connectOnboardingRequestAtom); + + useEffect(() => { + if (requestedAccountId === null) { + return; + } + const timer = setTimeout(() => { + clearConnectOnboardingRequest(); + navigation.navigate("ConnectOnboarding"); + }, PRESENT_ONBOARDING_DELAY_MS); + return () => { + clearTimeout(timer); + }; + }, [navigation, requestedAccountId]); +} diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index a77ca628978..cdfb59698ef 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { + AuthRelayWriteScope, EnvironmentCloudEndpointUnavailableError, EnvironmentHttpBadRequestError, EnvironmentHttpConflictError, @@ -76,6 +77,13 @@ const isEnvironmentCloudApiError = Schema.is( const MANAGED_ENDPOINT_PROVIDER_KIND = "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; +// "publish_only" links the environment to the relay for agent-activity +// publishing alone: no managed tunnel is provisioned (mirrors the web client's +// CloudLinkMode). +export type CloudLinkMode = "managed" | "publish_only"; + +const PUBLISH_ONLY_PROVIDER_KIND = "manual" satisfies RelayManagedEndpointProviderKind; + function cloudEnvironmentLinkError(message: string) { return (cause: unknown) => { const environmentError = findEnvironmentCloudApiError(cause); @@ -259,6 +267,7 @@ function ensureConnectEndpointMatchesEnvironment(input: { export function linkEnvironmentToCloud(input: { readonly connection: SavedRemoteConnection; readonly clerkToken: string; + readonly mode?: CloudLinkMode; }): Effect.Effect< void, CloudEnvironmentLinkError, @@ -271,6 +280,10 @@ export function linkEnvironmentToCloud(input: { }); } const localBearerToken = input.connection.bearerToken; + const managedTunnelsEnabled = (input.mode ?? "managed") === "managed"; + const providerKind = managedTunnelsEnabled + ? MANAGED_ENDPOINT_PROVIDER_KIND + : PUBLISH_ONLY_PROVIDER_KIND; const relayUrl = yield* requireRelayUrl(); const relayClient = yield* ManagedRelay.ManagedRelayClient; const deviceId = yield* Effect.tryPromise({ @@ -288,7 +301,7 @@ export function linkEnvironmentToCloud(input: { payload: { notificationsEnabled: true, liveActivitiesEnabled, - managedTunnelsEnabled: true, + managedTunnelsEnabled, }, }) .pipe( @@ -306,7 +319,7 @@ export function linkEnvironmentToCloud(input: { endpoint: { httpBaseUrl: input.connection.httpBaseUrl, wsBaseUrl: input.connection.wsBaseUrl, - providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, + providerKind, }, origin: endpointOrigin(input.connection.httpBaseUrl), }, @@ -320,7 +333,7 @@ export function linkEnvironmentToCloud(input: { proof, notificationsEnabled: true, liveActivitiesEnabled, - managedTunnelsEnabled: true, + managedTunnelsEnabled, }, }) .pipe( @@ -328,7 +341,7 @@ export function linkEnvironmentToCloud(input: { ); yield* ensureLinkedEnvironmentMatches({ expectedEnvironmentId: input.connection.environmentId, - expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, + expectedProviderKind: providerKind, link, }); @@ -350,6 +363,91 @@ export function linkEnvironmentToCloud(input: { }); } +function requireBearerToken( + connection: SavedRemoteConnection, +): Effect.Effect { + return connection.bearerToken + ? Effect.succeed(connection.bearerToken) + : Effect.fail( + new CloudEnvironmentLinkError({ + message: "Only a locally paired bearer connection can publish over T3 Connect.", + }), + ); +} + +export interface EnvironmentConnectPublishState { + /** + * Whether the connection's session holds relay:write. `null` when the + * environment did not report session scopes (older servers) — callers should + * attempt publishing and surface the server's denial. + */ + readonly canPublish: boolean | null; + readonly linked: boolean; + readonly managedTunnelActive: boolean; + readonly publishAgentActivity: boolean; +} + +/** + * Reads the environment-side publish authorization (session scopes) and relay + * link state for a locally paired bearer connection. + */ +export function getEnvironmentConnectPublishState(input: { + readonly connection: SavedRemoteConnection; +}): Effect.Effect< + EnvironmentConnectPublishState, + CloudEnvironmentLinkError, + HttpClient.HttpClient +> { + return Effect.gen(function* () { + const localBearerToken = yield* requireBearerToken(input.connection); + const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl); + const headers = { authorization: `Bearer ${localBearerToken}` }; + const session = yield* environmentClient.auth + .session({ headers }) + .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not read the environment session."))); + const linkState = yield* environmentClient.connect + .linkState({ headers }) + .pipe( + Effect.mapError(cloudEnvironmentLinkError("Could not read the environment link state.")), + ); + return { + canPublish: !session.authenticated + ? false + : session.scopes === undefined + ? null + : session.scopes.includes(AuthRelayWriteScope), + linked: linkState.linked, + managedTunnelActive: linkState.managedTunnelActive ?? linkState.linked, + publishAgentActivity: linkState.publishAgentActivity, + }; + }); +} + +/** + * Sets the environment-server-side "publish agent activity" preference. The + * link flow does not touch this secret, so clients opting into activity + * publishing must call it explicitly. + */ +export function setEnvironmentPublishAgentActivity(input: { + readonly connection: SavedRemoteConnection; + readonly publishAgentActivity: boolean; +}): Effect.Effect { + return Effect.gen(function* () { + const localBearerToken = yield* requireBearerToken(input.connection); + const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl); + yield* environmentClient.connect + .preferences({ + headers: { authorization: `Bearer ${localBearerToken}` }, + payload: { publishAgentActivity: input.publishAgentActivity }, + }) + .pipe( + Effect.mapError( + cloudEnvironmentLinkError("Could not update the environment publish preference."), + ), + ); + }); +} + export function listCloudEnvironments(input: { readonly clerkToken: string; }): Effect.Effect< diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx new file mode 100644 index 00000000000..27b99fabfdd --- /dev/null +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -0,0 +1,351 @@ +import { useAuth } from "@clerk/expo"; +import { SymbolView } from "expo-symbols"; +import { + connectionStatusText, + type EnvironmentConnectionPhase, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useState } from "react"; +import { + ActivityIndicator, + Pressable, + Switch, + type NativeSyntheticEvent, + type TextLayoutEventData, + View, +} from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { useThemeColor } from "../../lib/useThemeColor"; +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; +import { ConnectionStatusDot } from "./ConnectionStatusDot"; +import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; + +/** + * "T3 Cloud" section: every environment published to the signed-in account, + * with connect switches, availability status, refresh, and loading/error + * states. Shared between the Settings environments screen and the T3 Connect + * onboarding sheet. + */ +export function CloudEnvironmentRows(props: { + readonly connectedCloudEnvironments: ReadonlyArray; + readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; +}) { + const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const controller = useConnectionController(); + const iconColor = useThemeColor("--color-icon"); + const availableCloudEnvironments = controller.availableRelayEnvironments; + const [expandedErrorId, setExpandedErrorId] = useState(null); + const hasCloudRows = + props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; + + const handleConnectCloudEnvironment = useCallback( + (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment), + [controller], + ); + + const handleDisconnectCloudEnvironment = useCallback( + (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId), + [controller], + ); + + const handleToggleCloudError = useCallback((environmentId: string) => { + setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); + }, []); + + if (!isSignedIn) return null; + + return ( + + + T3 Cloud + { + void controller.refreshRelayEnvironments(); + }} + className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" + > + {controller.relayDiscovery.isRefreshing ? ( + + ) : ( + + )} + + + + {hasCloudRows ? ( + + {props.connectedCloudEnvironments.map((environment, index) => ( + props.onReconnectEnvironment(environment.environmentId)} + onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)} + errorExpanded={expandedErrorId === environment.environmentId} + onToggleError={() => handleToggleCloudError(environment.environmentId)} + /> + ))} + {availableCloudEnvironments.map((environment, index) => ( + 0 || index !== 0} + onConnect={() => handleConnectCloudEnvironment(environment)} + errorExpanded={expandedErrorId === environment.environment.environmentId} + onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} + /> + ))} + + ) : controller.relayDiscovery.isRefreshing ? ( + + + + Loading linked cloud environments. + + + ) : controller.relayDiscovery.error ? ( + + + Could not load T3 Cloud environments + + {controller.relayDiscovery.error} + {controller.relayDiscovery.errorTraceId ? ( + + ) : null} + + ) : ( + + + No additional linked cloud environments. + + + )} + + ); +} + +function ConnectedCloudEnvironmentRow(props: { + readonly environment: ConnectedEnvironmentSummary; + readonly borderTop: boolean; + readonly errorExpanded: boolean; + readonly onConnect: () => void; + readonly onDisconnect: () => void; + readonly onToggleError: () => void; +}) { + return ( + { + if (enabled) { + props.onConnect(); + return; + } + props.onDisconnect(); + }} + onToggleError={props.onToggleError} + value={props.environment.connectionState !== "available"} + /> + ); +} + +function CloudEnvironmentRow(props: { + readonly environment: RelayEnvironmentView; + readonly borderTop: boolean; + readonly errorExpanded: boolean; + readonly onConnect: () => void; + readonly onToggleError: () => void; +}) { + const presentation = availableCloudEnvironmentPresentation({ + isStatusPending: props.environment.availability === "checking", + status: props.environment.status, + statusError: props.environment.error, + statusErrorTraceId: props.environment.traceId, + }); + + return ( + { + if (enabled) { + props.onConnect(); + } + }} + onToggleError={props.onToggleError} + statusText={presentation.statusText} + value={false} + /> + ); +} + +function CloudEnvironmentRowShell(props: { + readonly borderTop: boolean; + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; + readonly connectionState: EnvironmentConnectionPhase; + readonly disabled?: boolean; + readonly errorExpanded: boolean; + readonly label: string; + readonly onToggleError: () => void; + readonly onValueChange: (enabled: boolean) => void; + readonly statusText?: string; + readonly value: boolean; +}) { + const activeTrack = String(useThemeColor("--color-switch-active")); + const track = String(useThemeColor("--color-secondary-border")); + const chevron = useThemeColor("--color-chevron"); + const isRetrying = + props.connectionState === "connecting" || props.connectionState === "reconnecting"; + const shouldPulse = isRetrying; + const statusText = + props.statusText ?? + connectionStatusText({ + phase: props.connectionState, + error: props.connectionError, + traceId: props.connectionErrorTraceId, + }); + const statusClassName = props.connectionError + ? "text-rose-500 dark:text-rose-400" + : "text-foreground-muted"; + const [errorMeasurement, setErrorMeasurement] = useState<{ + readonly text: string; + readonly lineCount: number; + } | null>(null); + const errorTraceId = props.connectionErrorTraceId; + const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText; + const errorLineCount = + errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0; + const errorCanExpand = props.connectionError !== null && errorLineCount > 1; + const isErrorExpanded = errorCanExpand && props.errorExpanded; + const StatusContainer = errorCanExpand ? Pressable : View; + const onMeasuredErrorTextLayout = useCallback( + (event: NativeSyntheticEvent) => { + if (!props.connectionError) { + return; + } + const nextLineCount = event.nativeEvent.lines.length; + setErrorMeasurement((currentMeasurement) => + currentMeasurement?.text === measuredErrorText && + currentMeasurement.lineCount === nextLineCount + ? currentMeasurement + : { text: measuredErrorText, lineCount: nextLineCount }, + ); + }, + [measuredErrorText, props.connectionError], + ); + return ( + + + + + + {props.label} + + + {props.connectionError ? ( + + {measuredErrorText} + + ) : null} + + + {statusText} + {errorTraceId ? ( + <> + {" Trace ID: "} + { + event.stopPropagation(); + copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); + }} + onPress={(event) => { + event.stopPropagation(); + }} + style={{ textDecorationStyle: "dotted" }} + > + {errorTraceId} + + + ) : null} + + {errorCanExpand ? ( + + ) : null} + + + + + ); +} + +function CopyTraceIdButton(props: { readonly traceId: string }) { + const iconColor = useThemeColor("--color-icon"); + + return ( + { + copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); + }} + className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" + > + + Copy trace ID + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index c8861ec75a1..d5eecb5016f 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -1,38 +1,18 @@ -import { useAuth } from "@clerk/expo"; import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useNavigation } from "@react-navigation/native"; import { SymbolView } from "expo-symbols"; -import { - connectionStatusText, - type EnvironmentConnectionPhase, -} from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useState } from "react"; -import { - ActivityIndicator, - Pressable, - ScrollView, - Switch, - type NativeSyntheticEvent, - type TextLayoutEventData, - View, -} from "react-native"; +import { ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; -import { - type RelayEnvironmentView, - useConnectionController, -} from "../connection/useConnectionController"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; -import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; +import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; -import { ConnectionStatusDot } from "../connection/ConnectionStatusDot"; import { splitEnvironmentSections } from "../connection/environmentSections"; import { cn } from "../../lib/cn"; -import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useThemeColor } from "../../lib/useThemeColor"; -import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; export function SettingsEnvironmentsRouteScreen() { @@ -117,7 +97,7 @@ export function SettingsEnvironmentsRouteScreen() { )} {hasCloudPublicConfig() ? ( - @@ -126,323 +106,3 @@ export function SettingsEnvironmentsRouteScreen() { ); } - -function ConfiguredCloudEnvironmentRows(props: { - readonly connectedCloudEnvironments: ReadonlyArray; - readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; -}) { - const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - const controller = useConnectionController(); - const iconColor = useThemeColor("--color-icon"); - const availableCloudEnvironments = controller.availableRelayEnvironments; - const [expandedErrorId, setExpandedErrorId] = useState(null); - const hasCloudRows = - props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; - - const handleConnectCloudEnvironment = useCallback( - (entry: RelayEnvironmentView) => controller.connectRelayEnvironment(entry.environment), - [controller], - ); - - const handleDisconnectCloudEnvironment = useCallback( - (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId), - [controller], - ); - - const handleToggleCloudError = useCallback((environmentId: string) => { - setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); - }, []); - - if (!isSignedIn) return null; - - return ( - - - T3 Cloud - { - void controller.refreshRelayEnvironments(); - }} - className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" - > - {controller.relayDiscovery.isRefreshing ? ( - - ) : ( - - )} - - - - {hasCloudRows ? ( - - {props.connectedCloudEnvironments.map((environment, index) => ( - props.onReconnectEnvironment(environment.environmentId)} - onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)} - errorExpanded={expandedErrorId === environment.environmentId} - onToggleError={() => handleToggleCloudError(environment.environmentId)} - /> - ))} - {availableCloudEnvironments.map((environment, index) => ( - 0 || index !== 0} - onConnect={() => handleConnectCloudEnvironment(environment)} - errorExpanded={expandedErrorId === environment.environment.environmentId} - onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} - /> - ))} - - ) : controller.relayDiscovery.isRefreshing ? ( - - - - Loading linked cloud environments. - - - ) : controller.relayDiscovery.error ? ( - - - Could not load T3 Cloud environments - - {controller.relayDiscovery.error} - {controller.relayDiscovery.errorTraceId ? ( - - ) : null} - - ) : ( - - - No additional linked cloud environments. - - - )} - - ); -} - -function ConnectedCloudEnvironmentRow(props: { - readonly environment: ConnectedEnvironmentSummary; - readonly borderTop: boolean; - readonly errorExpanded: boolean; - readonly onConnect: () => void; - readonly onDisconnect: () => void; - readonly onToggleError: () => void; -}) { - return ( - { - if (enabled) { - props.onConnect(); - return; - } - props.onDisconnect(); - }} - onToggleError={props.onToggleError} - value={props.environment.connectionState !== "available"} - /> - ); -} - -function CloudEnvironmentRow(props: { - readonly environment: RelayEnvironmentView; - readonly borderTop: boolean; - readonly errorExpanded: boolean; - readonly onConnect: () => void; - readonly onToggleError: () => void; -}) { - const presentation = availableCloudEnvironmentPresentation({ - isStatusPending: props.environment.availability === "checking", - status: props.environment.status, - statusError: props.environment.error, - statusErrorTraceId: props.environment.traceId, - }); - - return ( - { - if (enabled) { - props.onConnect(); - } - }} - onToggleError={props.onToggleError} - statusText={presentation.statusText} - value={false} - /> - ); -} - -function CloudEnvironmentRowShell(props: { - readonly borderTop: boolean; - readonly connectionError: string | null; - readonly connectionErrorTraceId: string | null; - readonly connectionState: EnvironmentConnectionPhase; - readonly disabled?: boolean; - readonly errorExpanded: boolean; - readonly label: string; - readonly onToggleError: () => void; - readonly onValueChange: (enabled: boolean) => void; - readonly statusText?: string; - readonly value: boolean; -}) { - const activeTrack = String(useThemeColor("--color-switch-active")); - const track = String(useThemeColor("--color-secondary-border")); - const chevron = useThemeColor("--color-chevron"); - const isRetrying = - props.connectionState === "connecting" || props.connectionState === "reconnecting"; - const shouldPulse = isRetrying; - const statusText = - props.statusText ?? - connectionStatusText({ - phase: props.connectionState, - error: props.connectionError, - traceId: props.connectionErrorTraceId, - }); - const statusClassName = props.connectionError - ? "text-rose-500 dark:text-rose-400" - : "text-foreground-muted"; - const [errorMeasurement, setErrorMeasurement] = useState<{ - readonly text: string; - readonly lineCount: number; - } | null>(null); - const errorTraceId = props.connectionErrorTraceId; - const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText; - const errorLineCount = - errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0; - const errorCanExpand = props.connectionError !== null && errorLineCount > 1; - const isErrorExpanded = errorCanExpand && props.errorExpanded; - const StatusContainer = errorCanExpand ? Pressable : View; - const onMeasuredErrorTextLayout = useCallback( - (event: NativeSyntheticEvent) => { - if (!props.connectionError) { - return; - } - const nextLineCount = event.nativeEvent.lines.length; - setErrorMeasurement((currentMeasurement) => - currentMeasurement?.text === measuredErrorText && - currentMeasurement.lineCount === nextLineCount - ? currentMeasurement - : { text: measuredErrorText, lineCount: nextLineCount }, - ); - }, - [measuredErrorText, props.connectionError], - ); - return ( - - - - - - {props.label} - - - {props.connectionError ? ( - - {measuredErrorText} - - ) : null} - - - {statusText} - {errorTraceId ? ( - <> - {" Trace ID: "} - { - event.stopPropagation(); - copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); - }} - onPress={(event) => { - event.stopPropagation(); - }} - style={{ textDecorationStyle: "dotted" }} - > - {errorTraceId} - - - ) : null} - - {errorCanExpand ? ( - - ) : null} - - - - - ); -} - -function CopyTraceIdButton(props: { readonly traceId: string }) { - const iconColor = useThemeColor("--color-icon"); - - return ( - { - copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); - }} - className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" - > - - Copy trace ID - - ); -} diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index 57f7359ec43..9d60b335e0e 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -69,6 +69,8 @@ export interface Preferences { /** Code/diff font size override; null/absent means derived from baseFontSize. */ readonly codeFontSize?: number | null; readonly codeWordBreak?: boolean; + /** Cloud account ids that already saw the T3 Connect onboarding sheet. */ + readonly connectOnboardingCompletedAccounts?: ReadonlyArray; } async function readStorageItem(key: MobileStorageKeyValue): Promise { @@ -167,6 +169,7 @@ export async function loadPreferences(): Promise { markdownFontSize?: number; codeFontSize?: number | null; codeWordBreak?: boolean; + connectOnboardingCompletedAccounts?: ReadonlyArray; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -187,6 +190,12 @@ export async function loadPreferences(): Promise { if (typeof parsed.codeWordBreak === "boolean") { preferences.codeWordBreak = parsed.codeWordBreak; } + if (Array.isArray(parsed.connectOnboardingCompletedAccounts)) { + preferences.connectOnboardingCompletedAccounts = + parsed.connectOnboardingCompletedAccounts.filter( + (account): account is string => typeof account === "string", + ); + } return preferences; } diff --git a/apps/web/src/cloud/connectOnboarding.ts b/apps/web/src/cloud/connectOnboarding.ts new file mode 100644 index 00000000000..9ad08f929cd --- /dev/null +++ b/apps/web/src/cloud/connectOnboarding.ts @@ -0,0 +1,18 @@ +import * as Schema from "effect/Schema"; + +/** + * Tracks which T3 Connect accounts have completed (or dismissed) the + * post-sign-in onboarding wizard, so it is shown at most once per account per + * browser. + */ +export const CONNECT_ONBOARDING_STORAGE_KEY = "t3code:connect-onboarding:v1"; + +export const ConnectOnboardingStateSchema = Schema.Struct({ + completedAccounts: Schema.Array(Schema.String), +}); + +export type ConnectOnboardingState = typeof ConnectOnboardingStateSchema.Type; + +export const EMPTY_CONNECT_ONBOARDING_STATE: ConnectOnboardingState = { + completedAccounts: [], +}; diff --git a/apps/web/src/cloud/useCloudLinkController.ts b/apps/web/src/cloud/useCloudLinkController.ts new file mode 100644 index 00000000000..f3cee3be76d --- /dev/null +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -0,0 +1,161 @@ +import { useAuth } from "@clerk/react"; +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useState } from "react"; + +import { toastManager } from "../components/ui/toast"; +import { relayEnvironmentDiscovery } from "../state/relay"; +import { useAtomCommand } from "../state/use-atom-command"; +import { + linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, + unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, + updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, +} from "./linkEnvironmentAtoms"; +import { usePrimaryCloudLinkState } from "./primaryCloudLinkState"; +import { resolveRelayClerkTokenOptions } from "./publicConfig"; + +export interface CloudLinkDesiredState { + readonly managedTunnel: boolean; + readonly publish: boolean; +} + +/** + * Drives the primary environment's T3 Connect link. T3 Connect (managed + * tunnel) and agent-activity publishing are independent capabilities backed by + * a single relay link, so consumers express the full desired state and + * `reconcileCloudState` applies it: unlink when neither is wanted, otherwise + * (re)link with the mode the managed-tunnel bit implies and set the publish + * preference. Re-linking only happens when the managed-tunnel mode actually + * changes, so flipping publish alone is cheap. + */ +export function useCloudLinkController() { + const { getToken, isSignedIn } = useAuth(); + const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { + reportFailure: false, + }); + const linkPrimaryEnvironment = useAtomCommand(linkPrimaryEnvironmentAtom, { + reportFailure: false, + }); + const unlinkPrimaryEnvironment = useAtomCommand(unlinkPrimaryEnvironmentAtom, { + reportFailure: false, + }); + const updatePrimaryEnvironmentPreferences = useAtomCommand( + updatePrimaryEnvironmentPreferencesAtom, + { reportFailure: false }, + ); + const primaryCloudLinkState = usePrimaryCloudLinkState(); + const [operationError, setOperationError] = useState(null); + + const reportUpdateFailure = (cause: unknown) => { + const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access."; + const traceId = findErrorTraceId(cause); + console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause }); + setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); + toastManager.add({ + type: "error", + title: "Could not update T3 Connect", + description: message, + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, + }); + }; + + // Older environment servers predate the managedTunnelActive field; for them a + // link always implies a managed tunnel, so fall back to `linked`. + const managedTunnelActive = + primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false; + const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; + const linked = primaryCloudLinkState.data?.linked ?? false; + + const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise => { + setOperationError(null); + const target = primaryCloudLinkState.target; + if (!target) { + reportUpdateFailure(new Error("Local environment is not ready yet.")); + return false; + } + const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); + if (tokenResult._tag === "Failure") { + reportUpdateFailure(squashAtomCommandFailure(tokenResult)); + return false; + } + const clerkToken = tokenResult.value; + const wantsLink = desired.managedTunnel || desired.publish; + + // A failure after this point may follow a partially applied mutation (e.g. + // the link succeeded but the preference update did not), so every exit — + // success or failure — refreshes the rendered state to whatever the server + // actually holds now. + if (!wantsLink) { + const unlinkResult = await unlinkPrimaryEnvironment({ + target, + clerkToken: clerkToken ?? null, + }); + if (unlinkResult._tag === "Failure") { + if (!isAtomCommandInterrupted(unlinkResult)) { + reportUpdateFailure(squashAtomCommandFailure(unlinkResult)); + } + primaryCloudLinkState.refresh(); + return false; + } + } else { + if (!clerkToken) { + reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this.")); + return false; + } + if (!linked || managedTunnelActive !== desired.managedTunnel) { + const linkResult = await linkPrimaryEnvironment({ + target, + clerkToken, + mode: desired.managedTunnel ? "managed" : "publish_only", + }); + if (linkResult._tag === "Failure") { + if (!isAtomCommandInterrupted(linkResult)) { + reportUpdateFailure(squashAtomCommandFailure(linkResult)); + } + primaryCloudLinkState.refresh(); + return false; + } + } + const prefResult = await updatePrimaryEnvironmentPreferences({ + target, + publishAgentActivity: desired.publish, + }); + if (prefResult._tag === "Failure") { + if (!isAtomCommandInterrupted(prefResult)) { + reportUpdateFailure(squashAtomCommandFailure(prefResult)); + } + primaryCloudLinkState.refresh(); + return false; + } + } + + primaryCloudLinkState.refresh(); + const refreshResult = await refreshRelayEnvironments(); + if (refreshResult._tag === "Failure" && !isAtomCommandInterrupted(refreshResult)) { + reportUpdateFailure(squashAtomCommandFailure(refreshResult)); + return false; + } + return true; + }; + + return { + isSignedIn, + linkState: primaryCloudLinkState, + linked, + managedTunnelActive, + publishAgentActivity, + operationError, + reconcileCloudState, + }; +} diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx new file mode 100644 index 00000000000..b12acf1ba40 --- /dev/null +++ b/apps/web/src/components/ConnectionStatusDot.tsx @@ -0,0 +1,56 @@ +import { cn } from "~/lib/utils"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; + +type ConnectionStatusDotProps = { + tooltipText?: string | null; + dotClassName: string; + pingClassName?: string | null; +}; + +export function ConnectionStatusDot({ + tooltipText, + dotClassName, + pingClassName, +}: ConnectionStatusDotProps) { + const dotContent = ( + <> + {pingClassName ? ( + + ) : null} + + + ); + + if (!tooltipText) { + return ( + + {dotContent} + + ); + } + + const dot = ( + + ); + + return ( + + + + {tooltipText} + + + ); +} diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx new file mode 100644 index 00000000000..0b39eecfcff --- /dev/null +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -0,0 +1,204 @@ +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { + RelayConnectionRegistration, + RelayConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import * as Option from "effect/Option"; +import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; + +import { environmentCatalog } from "~/connection/catalog"; +import { cn } from "~/lib/utils"; +import { relayEnvironmentDiscovery } from "~/state/relay"; +import { useRelayEnvironmentDiscovery } from "~/state/environments"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows"; +import { Button } from "../ui/button"; +import { Skeleton } from "../ui/skeleton"; +import { toastManager } from "../ui/toast"; + +export function RemoteEnvironmentRowsSkeleton() { + return ( +
+
+
+ + +
+ +
+
+ ); +} + +/** + * The user's T3 Connect environments from relay discovery, each with a + * Connect button. The primary environment is always excluded; already-saved + * environments are hidden unless `showSavedAsConnected` renders them as + * connected instead (used by onboarding, where the full device mesh should be + * visible). + */ +export function CloudEnvironmentConnectRows({ + primaryEnvironmentId, + savedEnvironmentIds, + showSavedAsConnected = false, + empty = null, +}: { + readonly primaryEnvironmentId: EnvironmentId | null; + readonly savedEnvironmentIds: ReadonlyArray; + readonly showSavedAsConnected?: boolean; + readonly empty?: ReactNode; +}) { + const environmentsState = useRelayEnvironmentDiscovery(); + const registerEnvironment = useAtomCommand(environmentCatalog.register, { + reportFailure: false, + }); + const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { + reportFailure: false, + }); + const connectRelayEnvironment = useCallback( + (environment: RelayClientEnvironmentRecord) => + registerEnvironment( + new RelayConnectionRegistration({ + target: new RelayConnectionTarget({ + environmentId: environment.environmentId, + label: environment.label, + }), + }), + ), + [registerEnvironment], + ); + const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( + null, + ); + const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); + + useEffect(() => { + void refreshRelayEnvironments(); + }, [refreshRelayEnvironments]); + + const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { + setConnectingEnvironmentId(environment.environmentId); + const result = await connectRelayEnvironment(environment); + setConnectingEnvironmentId(null); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Environment connected", + description: `${environment.label} is available through T3 Connect.`, + }); + return; + } + if (isAtomCommandInterrupted(result)) { + return; + } + const cause = squashAtomCommandFailure(result); + const message = + cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment."; + const traceId = findErrorTraceId(cause); + console.error("[t3-connect] Could not connect environment", { message, traceId, cause }); + toastManager.add({ + type: "error", + title: "Could not connect environment", + description: message, + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, + }); + }; + + const visibleEnvironments = [...environmentsState.environments.values()].filter( + ({ environment }) => + environment.environmentId !== primaryEnvironmentId && + (showSavedAsConnected || !savedIds.has(environment.environmentId)), + ); + + const standalone = showSavedAsConnected || savedEnvironmentIds.length === 0; + + if ( + standalone && + visibleEnvironments.length === 0 && + environmentsState.refreshing && + environmentsState.environments.size === 0 + ) { + return ; + } + + if (standalone && visibleEnvironments.length === 0) { + return empty; + } + + return visibleEnvironments.map(({ environment, availability, error }) => { + const alreadyConnected = savedIds.has(environment.environmentId); + return ( +
+
+
+
+ +

{environment.label}

+
+

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

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

{operationError}

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

{title}

+

{description}

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

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

{environment.label}

-
-

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

-
- -
-
- )); -} - function CloudRemoteEnvironmentRows({ primaryEnvironmentId, savedEnvironmentIds, @@ -2022,9 +1678,10 @@ function CloudRemoteEnvironmentRows({ readonly savedEnvironmentIds: ReadonlyArray; }) { return hasCloudPublicConfig() ? ( - } /> ) : savedEnvironmentIds.length === 0 ? ( diff --git a/apps/web/src/components/settings/itemRows.ts b/apps/web/src/components/settings/itemRows.ts new file mode 100644 index 00000000000..df0f1f7c235 --- /dev/null +++ b/apps/web/src/components/settings/itemRows.ts @@ -0,0 +1,5 @@ +/** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ +export const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; + +export const ITEM_ROW_INNER_CLASSNAME = + "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 36de3b95706..69739ce4570 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -14,6 +14,7 @@ import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; +import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; @@ -128,6 +129,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + From 4f648f1e113cc4e0df99705dae4cb363e144fe3d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 7 Jul 2026 11:07:31 -0700 Subject: [PATCH 2/3] Simplify Connect onboarding to sign-in only - Remove device publishing flow from onboarding - Add per-account "Don't show this again" opt-out - Update mobile and web copy to T3 Connect --- apps/mobile/src/Stack.tsx | 4 +- apps/mobile/src/connection/platform.ts | 4 +- .../src/features/cloud/CloudAuthProvider.tsx | 12 +- .../cloud/CloudWaitlistEnrollment.tsx | 2 +- .../cloud/ConnectOnboardingRouteScreen.tsx | 487 ++---------------- .../src/features/cloud/connectOnboarding.ts | 58 +-- .../cloud/connectOnboardingNavigation.ts | 45 ++ .../features/cloud/connectOnboardingOptOut.ts | 21 + .../src/features/cloud/linkEnvironment.ts | 106 +--- .../connection/CloudEnvironmentRows.tsx | 6 +- .../connection/ConnectionEnvironmentRow.tsx | 2 +- .../connection/environmentSections.test.ts | 2 +- .../features/settings/SettingsRouteScreen.tsx | 4 +- apps/mobile/src/lib/storage.ts | 15 +- apps/web/src/cloud/connectOnboarding.ts | 18 +- .../cloud/ConnectOnboardingDialog.tsx | 121 +++-- apps/web/src/connection/platform.ts | 4 +- .../src/relay/discovery.test.ts | 49 ++ .../client-runtime/src/relay/discovery.ts | 16 +- .../src/relay/managedRelayState.ts | 8 +- 20 files changed, 301 insertions(+), 683 deletions(-) create mode 100644 apps/mobile/src/features/cloud/connectOnboardingNavigation.ts create mode 100644 apps/mobile/src/features/cloud/connectOnboardingOptOut.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 4592400e921..7c12431911b 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -18,7 +18,7 @@ import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRo import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; -import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboarding"; +import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation"; import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider"; @@ -423,7 +423,7 @@ export const RootStack = createNativeStackNavigator({ options: { gestureEnabled: true, presentation: "formSheet", - sheetAllowedDetents: [0.75, 0.95], + sheetAllowedDetents: [0.6, 0.95], sheetGrabberVisible: true, }, }), diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 769632a8fcb..fd3c1530392 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -92,7 +92,7 @@ const capabilitiesLayer = Layer.succeedContext( if (session === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - detail: "Sign in to T3 Cloud to connect this environment.", + detail: "Sign in to T3 Connect to connect this environment.", }); } const token = yield* session.readClerkToken().pipe( @@ -107,7 +107,7 @@ const capabilitiesLayer = Layer.succeedContext( if (token === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - detail: "The T3 Cloud session is unavailable.", + detail: "The T3 Connect session is unavailable.", }); } return token; diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index 9d8e9d25763..85ab55491bb 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -18,7 +18,7 @@ import { setAgentAwarenessRelayTokenProvider, unregisterAgentAwarenessDeviceForCurrentUser, } from "../agent-awareness/remoteRegistration"; -import { requestConnectOnboardingIfNeeded } from "./connectOnboarding"; +import { requestConnectOnboarding } from "./connectOnboarding"; import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; function resetManagedRelayTokenCache() { @@ -68,14 +68,12 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { const nextAccount = isSignedIn && userId ? userId : null; observedAccountRef.current = nextAccount; - // A sign-in that completed during this session (a cold start observes + // Every sign-in that completes during this session (a cold start observes // undefined → account and must not re-prompt) requests the T3 Connect - // onboarding sheet once per account. + // onboarding sheet — sign-out clears the connected environments, so each + // new session starts with no devices to reach. if (previousObservedAccount === null && nextAccount !== null) { - void (async () => { - const result = await settlePromise(() => requestConnectOnboardingIfNeeded(nextAccount)); - reportAtomCommandResult(result, { label: "connect onboarding request" }); - })(); + requestConnectOnboarding(nextAccount); } const queueAccountCleanup = ( diff --git a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx index dbaf564e91f..3bc51e397d8 100644 --- a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx +++ b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx @@ -39,7 +39,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } You are on the waitlist - We will email you when your T3 Cloud access is ready. + We will email you when your T3 Connect access is ready. diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index 9b132b4def3..f7f4c26b357 100644 --- a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -1,67 +1,23 @@ import { useAuth } from "@clerk/expo"; import { useNavigation } from "@react-navigation/native"; -import * as Effect from "effect/Effect"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; +import { useCallback } from "react"; +import { Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - isAtomCommandInterrupted, - reportAtomCommandResult, - settleAsyncResult, - settlePromise, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; +import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; import { AppText as Text } from "../../components/AppText"; -import type { SavedRemoteConnection } from "../../lib/connection"; -import { cn } from "../../lib/cn"; -import { runtime } from "../../lib/runtime"; -import { useThemeColor } from "../../lib/useThemeColor"; -import { - useRemoteConnections, - useSavedRemoteConnections, -} from "../../state/use-remote-environment-registry"; +import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { splitEnvironmentSections } from "../connection/environmentSections"; -import { SettingsSection } from "../settings/components/SettingsSection"; -import { SettingsSwitchRow } from "../settings/components/SettingsSwitchRow"; -import { markConnectOnboardingCompleted } from "./connectOnboarding"; -import { - getEnvironmentConnectPublishState, - linkEnvironmentToCloud, - setEnvironmentPublishAgentActivity, -} from "./linkEnvironment"; -import { refreshManagedRelayEnvironments } from "./managedRelayState"; -import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; - -type PublishTargetPhase = "checking" | "ready" | "published" | "unauthorized" | "error"; - -interface PublishTarget { - readonly connection: SavedRemoteConnection; - readonly phase: PublishTargetPhase; - readonly linked: boolean; - readonly managedTunnelActive: boolean; - readonly publishAgentActivity: boolean; - readonly error: string | null; -} - -function pendingPublishTarget(connection: SavedRemoteConnection): PublishTarget { - return { - connection, - phase: "checking", - linked: false, - managedTunnelActive: false, - publishAgentActivity: false, - error: null, - }; -} +import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut"; +import { hasCloudPublicConfig } from "./publicConfig"; /** - * Post-sign-in onboarding sheet for T3 Connect. Offers to publish this - * device's locally paired environments (managed tunnel + agent activity, both - * defaulting on) when the paired session is authorized, then lists the - * account's T3 Connect environments so every device can be connected right - * away. Dismissing the sheet counts as completion — it never nags twice. + * Post-sign-in onboarding sheet for T3 Connect. Mobile never publishes + * environments itself — it consumes ones published elsewhere — so this simply + * surfaces the account's T3 Connect environments right after sign-in so every + * device can be connected in one go. It shows on every sign-in: sign-out + * clears the connected environments, so each new session starts from zero. */ export function ConnectOnboardingRouteScreen() { return hasCloudPublicConfig() ? : null; @@ -70,195 +26,26 @@ export function ConnectOnboardingRouteScreen() { function ConfiguredConnectOnboardingRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { getToken, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); - const { isLoadingSavedConnection, savedConnectionsById } = useSavedRemoteConnections(); + const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections(); const { connectedCloudEnvironments } = splitEnvironmentSections({ connectedEnvironments, cloudEnvironments: null, }); - // Dismissing the sheet (swipe, Skip, or Done) counts as completion. - const completionPersistedRef = useRef(false); - const persistCompletion = useCallback(() => { - if (completionPersistedRef.current || !userId) { - return; - } - completionPersistedRef.current = true; - void (async () => { - const result = await settlePromise(() => markConnectOnboardingCompleted(userId)); - reportAtomCommandResult(result, { label: "connect onboarding completion" }); - })(); - }, [userId]); - - useEffect( - () => navigation.addListener("beforeRemove", persistCompletion), - [navigation, persistCompletion], - ); - const handleDone = useCallback(() => { - persistCompletion(); navigation.goBack(); - }, [navigation, persistCompletion]); - - const bearerConnections = useMemo( - () => - Object.values(savedConnectionsById).filter((connection) => connection.bearerToken !== null), - [savedConnectionsById], - ); - - const [targets, setTargets] = useState | null>(null); - const [publishEnvironment, setPublishEnvironment] = useState(true); - const [publishAgentActivity, setPublishAgentActivity] = useState(true); - const [isApplying, setIsApplying] = useState(false); - - const loadPublishTargets = useCallback( - async (connections: ReadonlyArray) => { - if (connections.length === 0) { - setTargets([]); - return; - } - setTargets(connections.map(pendingPublishTarget)); - const result = await settleAsyncResult(() => - runtime.runPromiseExit( - Effect.forEach( - connections, - (connection) => - getEnvironmentConnectPublishState({ connection }).pipe( - Effect.match({ - onFailure: (error): PublishTarget => ({ - ...pendingPublishTarget(connection), - phase: "error", - error: error.message, - }), - onSuccess: (state): PublishTarget => ({ - connection, - phase: - state.canPublish === false - ? "unauthorized" - : state.linked && state.managedTunnelActive && state.publishAgentActivity - ? "published" - : "ready", - linked: state.linked, - managedTunnelActive: state.managedTunnelActive, - publishAgentActivity: state.publishAgentActivity, - error: null, - }), - }), - ), - { concurrency: "unbounded" }, - ), - ), - ); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) { - return; - } - const error = squashAtomCommandFailure(result); - const message = - error instanceof Error ? error.message : "Could not check the environment link state."; - setTargets( - connections.map((connection) => ({ - ...pendingPublishTarget(connection), - phase: "error" as const, - error: message, - })), - ); - return; - } - setTargets(result.value); - }, - [], - ); - - // Check authorization + link state once the locally saved connections are - // ready. Loaded once per mount: reconnect churn in the registry must not - // flip the section back to "checking" mid-interaction. - const loadedRef = useRef(false); - useEffect(() => { - if (isLoadingSavedConnection || loadedRef.current) { - return; - } - loadedRef.current = true; - void loadPublishTargets(bearerConnections); - }, [bearerConnections, isLoadingSavedConnection, loadPublishTargets]); - - const publishableTargets = useMemo( - () => (targets ?? []).filter((target) => target.phase === "ready"), - [targets], - ); - const targetConnections = useMemo( - () => (targets ?? []).map((target) => target.connection), - [targets], - ); - - const handlePublish = useCallback(async () => { - const applicable = publishableTargets; - if (applicable.length === 0 || (!publishEnvironment && !publishAgentActivity)) { - return; + }, [navigation]); + + const handleDontShowAgain = useCallback(() => { + if (userId) { + void (async () => { + const result = await settlePromise(() => optOutOfConnectOnboarding(userId)); + reportAtomCommandResult(result, { label: "connect onboarding opt-out" }); + })(); } - setIsApplying(true); - const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); - const clerkToken = tokenResult._tag === "Success" ? tokenResult.value : null; - if (clerkToken === null) { - setIsApplying(false); - const error = tokenResult._tag === "Failure" ? squashAtomCommandFailure(tokenResult) : null; - Alert.alert( - "Publishing unavailable", - error instanceof Error ? error.message : "Sign in to publish environments over T3 Connect.", - ); - return; - } - - const failures: Array = []; - await Promise.all( - applicable.map(async (target) => { - // Onboarding only ever enables: it links when something is missing and - // never tears down an existing link or clears a preference. - const needsLink = publishEnvironment - ? !target.linked || !target.managedTunnelActive - : !target.linked && publishAgentActivity; - const applyEffect = Effect.gen(function* () { - if (needsLink) { - yield* linkEnvironmentToCloud({ - connection: target.connection, - clerkToken, - mode: publishEnvironment ? "managed" : "publish_only", - }); - } - if (publishAgentActivity && !target.publishAgentActivity) { - yield* setEnvironmentPublishAgentActivity({ - connection: target.connection, - publishAgentActivity: true, - }); - } - }); - const result = await settleAsyncResult(() => runtime.runPromiseExit(applyEffect)); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - failures.push( - `${target.connection.environmentLabel}: ${ - error instanceof Error ? error.message : "Publishing failed." - }`, - ); - } - }), - ); - - refreshManagedRelayEnvironments(); - await loadPublishTargets(targetConnections); - setIsApplying(false); - if (failures.length > 0) { - Alert.alert("Couldn't publish every environment", failures.join("\n\n")); - } - }, [ - getToken, - loadPublishTargets, - publishAgentActivity, - publishEnvironment, - publishableTargets, - targetConnections, - ]); + navigation.goBack(); + }, [navigation, userId]); return ( @@ -286,38 +73,15 @@ function ConfiguredConnectOnboardingRouteScreen() { - { - "Mesh your devices together — publish this device's environments and connect the rest, all in one place." - } + Environments published from your other devices are ready to connect here. {isSignedIn ? ( - <> - void handlePublish()} - onRetry={() => void loadPublishTargets(targetConnections)} - /> - - - - Connect your environments - - - Environments published from your other devices are ready to connect here. - - - - + ) : ( @@ -326,185 +90,26 @@ function ConfiguredConnectOnboardingRouteScreen() { )} - - Done - - - - ); -} - -function PublishSection(props: { - readonly targets: ReadonlyArray | null; - readonly publishEnvironment: boolean; - readonly publishAgentActivity: boolean; - readonly isApplying: boolean; - readonly onPublishEnvironmentChange: (enabled: boolean) => void; - readonly onPublishAgentActivityChange: (enabled: boolean) => void; - readonly onPublish: () => void; - readonly onRetry: () => void; -}) { - const iconColor = useThemeColor("--color-icon"); - const primaryForeground = useThemeColor("--color-primary-foreground"); - const targets = props.targets; - const isChecking = targets === null || targets.some((target) => target.phase === "checking"); - const publishableTargets = (targets ?? []).filter((target) => target.phase === "ready"); - const publishedTargets = (targets ?? []).filter((target) => target.phase === "published"); - const hasRetryableErrors = (targets ?? []).some((target) => target.phase === "error"); - const canPublish = - publishableTargets.length > 0 && - (props.publishEnvironment || props.publishAgentActivity) && - !props.isApplying; - - if (targets !== null && targets.length === 0) { - return ( - - - Publish this device - - - - { - "No locally paired environments on this device yet. Pair an environment first and it can be published to your other devices from Settings." - } - - - - ); - } - - if (isChecking) { - return ( - - - Publish this device - - - - - Checking which environments can publish. - + + + Done + + {userId ? ( + + {"Don't show this again"} + + ) : null} - - ); - } - - return ( - - - {publishableTargets.length > 0 ? ( - <> - - - - ) : publishedTargets.length > 0 ? ( - - - {"This device's environments already publish over T3 Connect."} - - - ) : ( - - - { - "This device's paired sessions aren't authorized to publish. Pair again with an administrative link to enable publishing." - } - - - )} - - - {publishableTargets.length > 0 ? ( - - Make the environments paired with this device available to your other devices, and send - agent activity for notifications and Live Activities. - - ) : null} - - {targets.map((target) => ( - - ))} - - {publishableTargets.length > 0 ? ( - - {props.isApplying ? : null} - - {props.isApplying - ? "Publishing..." - : `Publish ${publishableTargets.length === 1 ? "environment" : `${publishableTargets.length} environments`}`} - - - ) : null} - - {hasRetryableErrors ? ( - - Check again - - ) : null} - - ); -} - -function publishTargetStatusText(target: PublishTarget): string { - switch (target.phase) { - case "checking": - return "Checking..."; - case "published": - return "Already publishing"; - case "ready": - return target.linked ? "Partially published — ready to update" : "Ready to publish"; - case "unauthorized": - return "This device's session is not authorized to publish"; - case "error": - return target.error ?? "Could not check the environment"; - } -} - -function PublishTargetStatusRow(props: { readonly target: PublishTarget }) { - const isError = props.target.phase === "error"; - return ( - - - {props.target.connection.environmentLabel} - - - {publishTargetStatusText(props.target)} - + ); } diff --git a/apps/mobile/src/features/cloud/connectOnboarding.ts b/apps/mobile/src/features/cloud/connectOnboarding.ts index 3859a8fa3a6..9d3944331a7 100644 --- a/apps/mobile/src/features/cloud/connectOnboarding.ts +++ b/apps/mobile/src/features/cloud/connectOnboarding.ts @@ -1,68 +1,24 @@ -import { useAtomValue } from "@effect/atom-react"; -import { useNavigation } from "@react-navigation/native"; import { Atom } from "effect/unstable/reactivity"; -import { useEffect } from "react"; -import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; import { appAtomRegistry } from "../../state/atom-registry"; // Signals RootStackLayout (inside the navigation tree) that an in-session -// sign-in just completed for an account that has not seen the T3 Connect -// onboarding sheet yet. Holds the account id so a sign-out between the request -// and the navigation cannot present the sheet for the wrong account. -const connectOnboardingRequestAtom = Atom.make(null).pipe( +// sign-in just completed. Holds the account id so a sign-out between the +// request and the navigation cannot present the sheet for the wrong account. +export const connectOnboardingRequestAtom = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("mobile:connect-onboarding-request"), ); /** - * Requests the onboarding sheet for the given account unless it already - * completed (or skipped) onboarding on this device. + * Requests the onboarding sheet for the given account. Sign-out clears the + * connected environments, so onboarding runs on every in-session sign-in — + * each new session starts with no connected devices. */ -export async function requestConnectOnboardingIfNeeded(accountId: string): Promise { - const preferences = await loadPreferences(); - if (preferences.connectOnboardingCompletedAccounts?.includes(accountId)) { - return; - } +export function requestConnectOnboarding(accountId: string): void { appAtomRegistry.set(connectOnboardingRequestAtom, accountId); } export function clearConnectOnboardingRequest(): void { appAtomRegistry.set(connectOnboardingRequestAtom, null); } - -/** Persists that the account saw the sheet, so it never nags twice. */ -export async function markConnectOnboardingCompleted(accountId: string): Promise { - const preferences = await loadPreferences(); - const completed = preferences.connectOnboardingCompletedAccounts ?? []; - if (completed.includes(accountId)) { - return; - } - await savePreferencesPatch({ connectOnboardingCompletedAccounts: [...completed, accountId] }); -} - -// Sign-in happens inside the Settings sheet; give its detent/session-state -// transitions a beat to settle before presenting another formSheet on top. -const PRESENT_ONBOARDING_DELAY_MS = 600; - -/** - * Consumes the onboarding request inside the navigation tree (RootStackLayout) - * and presents the onboarding formSheet. - */ -export function useConnectOnboardingNavigation(): void { - const navigation = useNavigation(); - const requestedAccountId = useAtomValue(connectOnboardingRequestAtom); - - useEffect(() => { - if (requestedAccountId === null) { - return; - } - const timer = setTimeout(() => { - clearConnectOnboardingRequest(); - navigation.navigate("ConnectOnboarding"); - }, PRESENT_ONBOARDING_DELAY_MS); - return () => { - clearTimeout(timer); - }; - }, [navigation, requestedAccountId]); -} diff --git a/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts b/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts new file mode 100644 index 00000000000..97591f943ea --- /dev/null +++ b/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts @@ -0,0 +1,45 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useNavigation } from "@react-navigation/native"; +import { useEffect } from "react"; + +import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding"; +import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut"; + +// Sign-in happens inside the Settings sheet; give its detent/session-state +// transitions a beat to settle before presenting another formSheet on top. +const PRESENT_ONBOARDING_DELAY_MS = 600; + +/** + * Consumes the onboarding request inside the navigation tree (RootStackLayout) + * and presents the onboarding formSheet. Lives apart from connectOnboarding.ts + * so non-navigation consumers (CloudAuthProvider) do not pull + * @react-navigation into their module graph. + */ +export function useConnectOnboardingNavigation(): void { + const navigation = useNavigation(); + const requestedAccountId = useAtomValue(connectOnboardingRequestAtom); + + useEffect(() => { + if (requestedAccountId === null) { + return; + } + let cancelled = false; + const timer = setTimeout(() => { + void (async () => { + // A failed preference read prefers showing the sheet. + const optedOut = await isConnectOnboardingOptedOut(requestedAccountId).catch(() => false); + if (cancelled) { + return; + } + clearConnectOnboardingRequest(); + if (!optedOut) { + navigation.navigate("ConnectOnboarding"); + } + })(); + }, PRESENT_ONBOARDING_DELAY_MS); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [navigation, requestedAccountId]); +} diff --git a/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts b/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts new file mode 100644 index 00000000000..0899509ea22 --- /dev/null +++ b/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts @@ -0,0 +1,21 @@ +import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; + +// Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports +// the request signal) never pulls lib/storage — expo-secure-store — into its +// module graph; that breaks CloudAuthProvider.test.ts suite loading. + +/** Whether the account chose "Don't show this again". */ +export async function isConnectOnboardingOptedOut(accountId: string): Promise { + const preferences = await loadPreferences(); + return preferences.connectOnboardingOptOutAccounts?.includes(accountId) ?? false; +} + +/** Persists "Don't show this again" for the account. */ +export async function optOutOfConnectOnboarding(accountId: string): Promise { + const preferences = await loadPreferences(); + const optedOut = preferences.connectOnboardingOptOutAccounts ?? []; + if (optedOut.includes(accountId)) { + return; + } + await savePreferencesPatch({ connectOnboardingOptOutAccounts: [...optedOut, accountId] }); +} diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index cdfb59698ef..a77ca628978 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { - AuthRelayWriteScope, EnvironmentCloudEndpointUnavailableError, EnvironmentHttpBadRequestError, EnvironmentHttpConflictError, @@ -77,13 +76,6 @@ const isEnvironmentCloudApiError = Schema.is( const MANAGED_ENDPOINT_PROVIDER_KIND = "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; -// "publish_only" links the environment to the relay for agent-activity -// publishing alone: no managed tunnel is provisioned (mirrors the web client's -// CloudLinkMode). -export type CloudLinkMode = "managed" | "publish_only"; - -const PUBLISH_ONLY_PROVIDER_KIND = "manual" satisfies RelayManagedEndpointProviderKind; - function cloudEnvironmentLinkError(message: string) { return (cause: unknown) => { const environmentError = findEnvironmentCloudApiError(cause); @@ -267,7 +259,6 @@ function ensureConnectEndpointMatchesEnvironment(input: { export function linkEnvironmentToCloud(input: { readonly connection: SavedRemoteConnection; readonly clerkToken: string; - readonly mode?: CloudLinkMode; }): Effect.Effect< void, CloudEnvironmentLinkError, @@ -280,10 +271,6 @@ export function linkEnvironmentToCloud(input: { }); } const localBearerToken = input.connection.bearerToken; - const managedTunnelsEnabled = (input.mode ?? "managed") === "managed"; - const providerKind = managedTunnelsEnabled - ? MANAGED_ENDPOINT_PROVIDER_KIND - : PUBLISH_ONLY_PROVIDER_KIND; const relayUrl = yield* requireRelayUrl(); const relayClient = yield* ManagedRelay.ManagedRelayClient; const deviceId = yield* Effect.tryPromise({ @@ -301,7 +288,7 @@ export function linkEnvironmentToCloud(input: { payload: { notificationsEnabled: true, liveActivitiesEnabled, - managedTunnelsEnabled, + managedTunnelsEnabled: true, }, }) .pipe( @@ -319,7 +306,7 @@ export function linkEnvironmentToCloud(input: { endpoint: { httpBaseUrl: input.connection.httpBaseUrl, wsBaseUrl: input.connection.wsBaseUrl, - providerKind, + providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, }, origin: endpointOrigin(input.connection.httpBaseUrl), }, @@ -333,7 +320,7 @@ export function linkEnvironmentToCloud(input: { proof, notificationsEnabled: true, liveActivitiesEnabled, - managedTunnelsEnabled, + managedTunnelsEnabled: true, }, }) .pipe( @@ -341,7 +328,7 @@ export function linkEnvironmentToCloud(input: { ); yield* ensureLinkedEnvironmentMatches({ expectedEnvironmentId: input.connection.environmentId, - expectedProviderKind: providerKind, + expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, link, }); @@ -363,91 +350,6 @@ export function linkEnvironmentToCloud(input: { }); } -function requireBearerToken( - connection: SavedRemoteConnection, -): Effect.Effect { - return connection.bearerToken - ? Effect.succeed(connection.bearerToken) - : Effect.fail( - new CloudEnvironmentLinkError({ - message: "Only a locally paired bearer connection can publish over T3 Connect.", - }), - ); -} - -export interface EnvironmentConnectPublishState { - /** - * Whether the connection's session holds relay:write. `null` when the - * environment did not report session scopes (older servers) — callers should - * attempt publishing and surface the server's denial. - */ - readonly canPublish: boolean | null; - readonly linked: boolean; - readonly managedTunnelActive: boolean; - readonly publishAgentActivity: boolean; -} - -/** - * Reads the environment-side publish authorization (session scopes) and relay - * link state for a locally paired bearer connection. - */ -export function getEnvironmentConnectPublishState(input: { - readonly connection: SavedRemoteConnection; -}): Effect.Effect< - EnvironmentConnectPublishState, - CloudEnvironmentLinkError, - HttpClient.HttpClient -> { - return Effect.gen(function* () { - const localBearerToken = yield* requireBearerToken(input.connection); - const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl); - const headers = { authorization: `Bearer ${localBearerToken}` }; - const session = yield* environmentClient.auth - .session({ headers }) - .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not read the environment session."))); - const linkState = yield* environmentClient.connect - .linkState({ headers }) - .pipe( - Effect.mapError(cloudEnvironmentLinkError("Could not read the environment link state.")), - ); - return { - canPublish: !session.authenticated - ? false - : session.scopes === undefined - ? null - : session.scopes.includes(AuthRelayWriteScope), - linked: linkState.linked, - managedTunnelActive: linkState.managedTunnelActive ?? linkState.linked, - publishAgentActivity: linkState.publishAgentActivity, - }; - }); -} - -/** - * Sets the environment-server-side "publish agent activity" preference. The - * link flow does not touch this secret, so clients opting into activity - * publishing must call it explicitly. - */ -export function setEnvironmentPublishAgentActivity(input: { - readonly connection: SavedRemoteConnection; - readonly publishAgentActivity: boolean; -}): Effect.Effect { - return Effect.gen(function* () { - const localBearerToken = yield* requireBearerToken(input.connection); - const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl); - yield* environmentClient.connect - .preferences({ - headers: { authorization: `Bearer ${localBearerToken}` }, - payload: { publishAgentActivity: input.publishAgentActivity }, - }) - .pipe( - Effect.mapError( - cloudEnvironmentLinkError("Could not update the environment publish preference."), - ), - ); - }); -} - export function listCloudEnvironments(input: { readonly clerkToken: string; }): Effect.Effect< diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 27b99fabfdd..2796d2e570e 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -25,7 +25,7 @@ import { ConnectionStatusDot } from "./ConnectionStatusDot"; import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; /** - * "T3 Cloud" section: every environment published to the signed-in account, + * "T3 Connect" section: every environment published to the signed-in account, * with connect switches, availability status, refresh, and loading/error * states. Shared between the Settings environments screen and the T3 Connect * onboarding sheet. @@ -61,7 +61,7 @@ export function CloudEnvironmentRows(props: { return ( - T3 Cloud + T3 Connect - Could not load T3 Cloud environments + Could not load T3 Connect environments {controller.relayDiscovery.error} {controller.relayDiscovery.errorTraceId ? ( diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 394bc180183..4b2da3e24ca 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -135,7 +135,7 @@ export function ConnectionEnvironmentRow(props: { > {props.environment.isRelayManaged ? ( - Managed by T3 Cloud. Tunnel details update automatically. + Managed by T3 Connect. Tunnel details update automatically. ) : ( <> diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts index 497af4bfac4..75f78738ade 100644 --- a/apps/mobile/src/features/connection/environmentSections.test.ts +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -35,7 +35,7 @@ function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { } describe("mobile environment settings sections", () => { - it("keeps saved relay-managed connections under T3 Cloud", () => { + it("keeps saved relay-managed connections under T3 Connect", () => { const local = connectedEnvironment({ environmentId: "environment-local", isRelayManaged: false, diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 2b07b96b3c0..a4a5499eeee 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -240,8 +240,8 @@ function ConfiguredSettingsRouteScreen() { const promptSignIn = useCallback(() => { Alert.alert( - "Request T3 Cloud access", - "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.", + "Request T3 Connect access", + "Live Activity updates require approved T3 Connect access so relay can deliver updates to this device.", [ { text: "Cancel", style: "cancel" }, { diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index 9d60b335e0e..ed3191cfa29 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -69,8 +69,8 @@ export interface Preferences { /** Code/diff font size override; null/absent means derived from baseFontSize. */ readonly codeFontSize?: number | null; readonly codeWordBreak?: boolean; - /** Cloud account ids that already saw the T3 Connect onboarding sheet. */ - readonly connectOnboardingCompletedAccounts?: ReadonlyArray; + /** Cloud account ids that opted out of the T3 Connect onboarding sheet. */ + readonly connectOnboardingOptOutAccounts?: ReadonlyArray; } async function readStorageItem(key: MobileStorageKeyValue): Promise { @@ -169,7 +169,7 @@ export async function loadPreferences(): Promise { markdownFontSize?: number; codeFontSize?: number | null; codeWordBreak?: boolean; - connectOnboardingCompletedAccounts?: ReadonlyArray; + connectOnboardingOptOutAccounts?: ReadonlyArray; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -190,11 +190,10 @@ export async function loadPreferences(): Promise { if (typeof parsed.codeWordBreak === "boolean") { preferences.codeWordBreak = parsed.codeWordBreak; } - if (Array.isArray(parsed.connectOnboardingCompletedAccounts)) { - preferences.connectOnboardingCompletedAccounts = - parsed.connectOnboardingCompletedAccounts.filter( - (account): account is string => typeof account === "string", - ); + if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) { + preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter( + (account): account is string => typeof account === "string", + ); } return preferences; diff --git a/apps/web/src/cloud/connectOnboarding.ts b/apps/web/src/cloud/connectOnboarding.ts index 9ad08f929cd..7d61e11c6c6 100644 --- a/apps/web/src/cloud/connectOnboarding.ts +++ b/apps/web/src/cloud/connectOnboarding.ts @@ -1,18 +1,18 @@ import * as Schema from "effect/Schema"; /** - * Tracks which T3 Connect accounts have completed (or dismissed) the - * post-sign-in onboarding wizard, so it is shown at most once per account per - * browser. + * Accounts that opted out of the post-sign-in T3 Connect onboarding wizard + * ("Don't show this again"). The wizard otherwise shows on every sign-in, + * since sign-out clears the connected environments. */ -export const CONNECT_ONBOARDING_STORAGE_KEY = "t3code:connect-onboarding:v1"; +export const CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY = "t3code:connect-onboarding-opt-out:v1"; -export const ConnectOnboardingStateSchema = Schema.Struct({ - completedAccounts: Schema.Array(Schema.String), +export const ConnectOnboardingOptOutSchema = Schema.Struct({ + optOutAccounts: Schema.Array(Schema.String), }); -export type ConnectOnboardingState = typeof ConnectOnboardingStateSchema.Type; +export type ConnectOnboardingOptOutState = typeof ConnectOnboardingOptOutSchema.Type; -export const EMPTY_CONNECT_ONBOARDING_STATE: ConnectOnboardingState = { - completedAccounts: [], +export const EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE: ConnectOnboardingOptOutState = { + optOutAccounts: [], }; diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index 1568040a7af..9cca1d3c0b5 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -4,9 +4,9 @@ import { CheckIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { - CONNECT_ONBOARDING_STORAGE_KEY, - ConnectOnboardingStateSchema, - EMPTY_CONNECT_ONBOARDING_STATE, + CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY, + ConnectOnboardingOptOutSchema, + EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, } from "~/cloud/connectOnboarding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; @@ -16,6 +16,7 @@ import { cn } from "~/lib/utils"; import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; import { Dialog, DialogDescription, @@ -29,12 +30,13 @@ import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; /** - * Post-sign-in onboarding wizard for T3 Connect. Opens once per account (per - * browser) after the user is signed in: first prompts to publish this + * Post-sign-in onboarding wizard for T3 Connect. Opens on every in-session + * sign-in — sign-out removes the connected relay environments, so each new + * session starts with no devices to reach. It first prompts to publish this * environment (managed tunnel + agent activity, both defaulting on) when the * current session is authorized to manage the relay link, then lists the * account's T3 Connect environments so every device can be connected right - * away. Dismissing the dialog counts as completion — it never nags twice. + * away. A cold load with a restored session does not count as a sign-in. */ export function ConnectOnboardingDialog() { if (!hasCloudPublicConfig()) return null; @@ -46,10 +48,10 @@ type OnboardingStep = "publish" | "devices"; function ConfiguredConnectOnboardingDialog() { const { isLoaded, isSignedIn, userId } = useAuth(); - const [onboardingState, setOnboardingState] = useLocalStorage( - CONNECT_ONBOARDING_STORAGE_KEY, - EMPTY_CONNECT_ONBOARDING_STATE, - ConnectOnboardingStateSchema, + const [optOutState, setOptOutState] = useLocalStorage( + CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY, + EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, + ConnectOnboardingOptOutSchema, ); const desktopBridge = window.desktopBridge; @@ -75,34 +77,54 @@ function ConfiguredConnectOnboardingDialog() { ? ["publish", "devices"] : ["devices"]; + const [requestedAccount, setRequestedAccount] = useState(null); const [openForAccount, setOpenForAccount] = useState(null); const [step, setStep] = useState("devices"); const [exposeEnvironment, setExposeEnvironment] = useState(true); const [publishAgentActivity, setPublishAgentActivity] = useState(true); + const [dontShowAgain, setDontShowAgain] = useState(false); const [isApplying, setIsApplying] = useState(false); const prefilledFromLinkStateRef = useRef(false); + const observedAccountRef = useRef(undefined); - const completedAccounts = onboardingState.completedAccounts; + const optOutAccounts = optOutState.optOutAccounts; + // Every sign-in that completes during this session requests the wizard. A + // cold load observes undefined → account and must not re-prompt — only a + // null → account transition is a sign-in. useEffect(() => { - if (!isLoaded || !isSignedIn || !userId) return; - if (openForAccount !== null) return; - if (completedAccounts.includes(userId)) return; + if (!isLoaded) return; + const previousAccount = observedAccountRef.current; + const nextAccount = isSignedIn && userId ? userId : null; + observedAccountRef.current = nextAccount; + if (previousAccount === null && nextAccount !== null) { + setRequestedAccount(nextAccount); + } + }, [isLoaded, isSignedIn, userId]); + + // Open once the session scopes resolve so the step set is stable. Accounts + // that chose "Don't show this again" are skipped. + useEffect(() => { + if (requestedAccount === null || openForAccount !== null) return; + if (optOutAccounts.includes(requestedAccount)) { + setRequestedAccount(null); + return; + } if (!sessionScopesKnown) return; + setRequestedAccount(null); prefilledFromLinkStateRef.current = false; setExposeEnvironment(true); setPublishAgentActivity(true); + setDontShowAgain(false); setStep(canManageRelay && controller.linkState.target !== null ? "publish" : "devices"); - setOpenForAccount(userId); + setOpenForAccount(requestedAccount); }, [ canManageRelay, - completedAccounts, controller.linkState.target, - isLoaded, - isSignedIn, openForAccount, + optOutAccounts, + requestedAccount, sessionScopesKnown, - userId, ]); // Signing out (or switching accounts) mid-wizard invalidates everything the @@ -111,7 +133,10 @@ function ConfiguredConnectOnboardingDialog() { if (openForAccount !== null && (!isSignedIn || userId !== openForAccount)) { setOpenForAccount(null); } - }, [isSignedIn, openForAccount, userId]); + if (requestedAccount !== null && (!isSignedIn || userId !== requestedAccount)) { + setRequestedAccount(null); + } + }, [isSignedIn, openForAccount, requestedAccount, userId]); // Toggles default on, but an environment that is already linked should show // its actual configuration instead of silently proposing to rewrite it. @@ -130,12 +155,13 @@ function ConfiguredConnectOnboardingDialog() { const complete = () => { const account = openForAccount; setOpenForAccount(null); - if (!account) return; - setOnboardingState((state) => - state.completedAccounts.includes(account) - ? state - : { completedAccounts: [...state.completedAccounts, account] }, - ); + if (account !== null && dontShowAgain) { + setOptOutState((state) => + state.optOutAccounts.includes(account) + ? state + : { optOutAccounts: [...state.optOutAccounts, account] }, + ); + } }; const applyPublishSelection = async () => { @@ -194,22 +220,33 @@ function ConfiguredConnectOnboardingDialog() { )} - - {step === "publish" ? ( - <> - - - - ) : ( - - )} + + +
+ {step === "publish" ? ( + <> + + + + ) : ( + + )} +
diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index f74465048f0..56d25fa142e 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -182,7 +182,7 @@ const capabilitiesLayer = Layer.effectContext( if (session === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - detail: "Sign in to T3 Cloud to connect this environment.", + detail: "Sign in to T3 Connect to connect this environment.", }); } const token = yield* session.readClerkToken().pipe( @@ -197,7 +197,7 @@ const capabilitiesLayer = Layer.effectContext( if (token === null) { return yield* new ConnectionBlockedError({ reason: "authentication", - detail: "The T3 Cloud session is unavailable.", + detail: "The T3 Connect session is unavailable.", }); } return token; diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index 66c27b8678a..8cb963000bd 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -342,6 +342,55 @@ describe("RelayEnvironmentDiscovery", () => { }), ); + it.effect("refreshes proactively when credentials change before any manual refresh", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; + const requests = yield* Ref.get(harness.statusRequests); + for (const environment of environments) { + yield* Deferred.succeed( + requests.get(environment.environmentId)!, + status(environment, "online"), + ); + } + + // Let the scoped wakeup subscription start before emitting, mirroring + // a real sign-in which always happens long after service start. + yield* Effect.yieldNow; + + // Sign-in activates the session and emits credentials-changed; the + // list must populate without any screen having asked for a refresh. + yield* harness.wake("credentials-changed"); + const populated = yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter( + (state) => state.environments.size === environments.length && !state.refreshing, + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(Option.isNone(populated.error)).toBe(true); + expect(yield* Ref.get(harness.listCalls)).toBe(1); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("settles to a clean empty state when refreshed while signed out", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery.RelayEnvironmentDiscovery; + yield* Ref.set(harness.clerkToken, null); + yield* discovery.refresh; + + const state = yield* SubscriptionRef.get(discovery.state); + expect(state.environments.size).toBe(0); + expect(state.refreshing).toBe(false); + expect(Option.isNone(state.error)).toBe(true); + }).pipe(Effect.provide(harness.layer)); + }), + ); + it.effect("does not republish stale rows after sign-out invalidates an in-flight refresh", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts index 8cbadea1ca5..9ef99e2fce2 100644 --- a/packages/client-runtime/src/relay/discovery.ts +++ b/packages/client-runtime/src/relay/discovery.ts @@ -279,10 +279,15 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { if ((yield* Ref.get(accountGeneration)) !== generation) { return; } + // Signed out is the idle state, not a failure: the proactive + // refresh on credentials-changed also runs on sign-out and must + // settle back to a clean empty list. + const signedOut = + error._tag === "ConnectionBlockedError" && error.reason === "authentication"; yield* SubscriptionRef.update(state, (current) => ({ ...current, refreshing: false, - error: Option.some(error), + error: signedOut ? Option.none() : Option.some(error), })); }), ), @@ -311,11 +316,12 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { yield* Ref.update(accountGeneration, (current) => current + 1); yield* Ref.set(activeAccountId, Option.none()); yield* Ref.set(offlineReportFingerprints, new Map()); - const shouldRefresh = yield* Ref.get(hasRefreshed); yield* SubscriptionRef.set(state, EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); - if (shouldRefresh) { - yield* refresh.pipe(Effect.forkScoped); - } + // Refresh proactively — this wakeup fires when a session activates + // (sign-in or cold start), and the list should be populated before + // any screen asks for it. A signed-out refresh settles back to the + // clean empty state. + yield* refresh.pipe(Effect.forkScoped); }) : Effect.void, ), diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index ec6a0710dd1..1d12c90aae5 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -123,7 +123,7 @@ export function createManagedRelaySession(input: ManagedRelaySessionInput): Mana try: () => readCachedClerkToken(nowMillis), catch: (cause) => new ManagedRelaySessionError({ - message: "Could not obtain the T3 Cloud session token.", + message: "Could not obtain the T3 Connect session token.", cause, }), }); @@ -181,7 +181,7 @@ function readSessionClerkToken( ? Effect.succeed(token) : Effect.fail( new ManagedRelaySessionError({ - message: "The T3 Cloud session token is unavailable.", + message: "The T3 Connect session token is unavailable.", }), ), ), @@ -226,7 +226,7 @@ function requireClerkToken( if (!session || session.accountId !== accountId) { return Effect.fail( new ManagedRelaySessionError({ - message: "Sign in to T3 Cloud before loading relay data.", + message: "Sign in to T3 Connect before loading relay data.", }), ); } @@ -296,7 +296,7 @@ export function readManagedRelaySnapshotState( let errorTraceId: string | null = null; if (result._tag === "Failure") { const cause = Cause.squash(result.cause); - error = cause instanceof Error ? cause.message : "Could not load T3 Cloud data."; + error = cause instanceof Error ? cause.message : "Could not load T3 Connect data."; errorTraceId = findErrorTraceId(cause); } return { From 593aa69833bd3e4c92190063e99b1f706bd7d3a5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 7 Jul 2026 12:33:02 -0700 Subject: [PATCH 3/3] Polish connect onboarding sheet - Restore native sheet header and close affordance - Add pull-to-refresh and simplify onboarding actions - Reuse cloud rows without the section header in onboarding --- apps/mobile/src/Stack.tsx | 4 ++ .../cloud/ConnectOnboardingRouteScreen.tsx | 72 +++++++++---------- .../connection/CloudEnvironmentRows.tsx | 51 ++++++++----- 3 files changed, 71 insertions(+), 56 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7c12431911b..d68d6f2b97b 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -421,6 +421,10 @@ export const RootStack = createNativeStackNavigator({ screen: ConnectOnboardingRouteScreen, linking: "connect-onboarding", options: { + // Root screenOptions hide headers; formSheets that want the native + // title bar opt back in with the sheet header preset. + ...SHEET_SOLID_HEADER_OPTIONS, + title: "Set up T3 Connect", gestureEnabled: true, presentation: "formSheet", sheetAllowedDetents: [0.6, 0.95], diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index f7f4c26b357..3e71b600ebe 100644 --- a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -1,7 +1,8 @@ +import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useAuth } from "@clerk/expo"; import { useNavigation } from "@react-navigation/native"; -import { useCallback } from "react"; -import { Pressable, ScrollView, View } from "react-native"; +import { useCallback, useState } from "react"; +import { Pressable, RefreshControl, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; @@ -9,6 +10,7 @@ import { AppText as Text } from "../../components/AppText"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { splitEnvironmentSections } from "../connection/environmentSections"; +import { useConnectionController } from "../connection/useConnectionController"; import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut"; import { hasCloudPublicConfig } from "./publicConfig"; @@ -28,12 +30,25 @@ function ConfiguredConnectOnboardingRouteScreen() { const insets = useSafeAreaInsets(); const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections(); + const { refreshRelayEnvironments } = useConnectionController(); const { connectedCloudEnvironments } = splitEnvironmentSections({ connectedEnvironments, cloudEnvironments: null, }); - const handleDone = useCallback(() => { + // Pull-to-refresh tracks its own spinner instead of discovery's refreshing + // flag, so background refreshes (e.g. the sign-in one) don't yank the + // content down. + const [isPullRefreshing, setIsPullRefreshing] = useState(false); + const handlePullRefresh = useCallback(() => { + void (async () => { + setIsPullRefreshing(true); + await refreshRelayEnvironments(); + setIsPullRefreshing(false); + })(); + }, [refreshRelayEnvironments]); + + const handleClose = useCallback(() => { navigation.goBack(); }, [navigation]); @@ -49,38 +64,28 @@ function ConfiguredConnectOnboardingRouteScreen() { return ( + + + + } > - - - Set up T3 Connect - - Skip - - - - Environments published from your other devices are ready to connect here. - - - {isSignedIn ? ( ) : ( @@ -90,25 +95,16 @@ function ConfiguredConnectOnboardingRouteScreen() { )} - + {userId ? ( - Done + {"Don't show this again"} - {userId ? ( - - {"Don't show this again"} - - ) : null} - + ) : null} ); diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 2796d2e570e..2f0c3c54c64 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -33,6 +33,12 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect export function CloudEnvironmentRows(props: { readonly connectedCloudEnvironments: ReadonlyArray; readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; + /** + * Hide the "T3 Connect" section title + refresh button for hosts that + * provide their own chrome (the onboarding sheet's native header and + * pull-to-refresh). + */ + readonly showHeader?: boolean; }) { const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const controller = useConnectionController(); @@ -56,27 +62,36 @@ export function CloudEnvironmentRows(props: { setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); }, []); + const showHeader = props.showHeader ?? true; + if (!isSignedIn) return null; return ( - - - T3 Connect - { - void controller.refreshRelayEnvironments(); - }} - className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" - > - {controller.relayDiscovery.isRefreshing ? ( - - ) : ( - - )} - - + + {showHeader ? ( + + T3 Connect + { + void controller.refreshRelayEnvironments(); + }} + className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" + > + {controller.relayDiscovery.isRefreshing ? ( + + ) : ( + + )} + + + ) : null} {hasCloudRows ? (