authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-16 09:22:07+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-16 09:22:07+02:00
logb98233702c88fd7a4312b231e68b089d5f3b0e80
tree38e7877edafc7e189fff8cd53cd6ea563019a854
parentecb35f75839925dd1ac6f854efeef5c9e291d11b
signature Signed by PGP key B5690EEEBB952194

[codex] Fix onboarding connection status (#4001)

Co-authored-by: codex <codex@users.noreply.github.com>

5 files changed, 196 insertions(+), 58 deletions(-)

apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+73-40
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { findErrorTraceId } from "@t3tools/client-runtime/errors";1import { findErrorTraceId } from "@t3tools/client-runtime/errors";
2import {2import {
3 type EnvironmentConnectionPresentation,
3 RelayConnectionRegistration,4 RelayConnectionRegistration,
4 RelayConnectionTarget,5 RelayConnectionTarget,
5} from "@t3tools/client-runtime/connection";6} from "@t3tools/client-runtime/connection";
...@@ -10,7 +11,7 @@ import {...@@ -10,7 +11,7 @@ import {
10import type { EnvironmentId } from "@t3tools/contracts";11import type { EnvironmentId } from "@t3tools/contracts";
11import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";12import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
12import * as Option from "effect/Option";13import * as Option from "effect/Option";
13import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";14import { type ReactNode, useCallback, useEffect, useState } from "react";
1415
15import { environmentCatalog } from "~/connection/catalog";16import { environmentCatalog } from "~/connection/catalog";
16import { cn } from "~/lib/utils";17import { cn } from "~/lib/utils";
...@@ -22,6 +23,12 @@ import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRo...@@ -22,6 +23,12 @@ import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRo
22import { Button } from "../ui/button";23import { Button } from "../ui/button";
23import { Skeleton } from "../ui/skeleton";24import { Skeleton } from "../ui/skeleton";
24import { toastManager } from "../ui/toast";25import { toastManager } from "../ui/toast";
26import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation";
27
28export interface SavedCloudEnvironmentConnection {
29 readonly environmentId: EnvironmentId;
30 readonly connection: EnvironmentConnectionPresentation;
31}
2532
26export function RemoteEnvironmentRowsSkeleton() {33export function RemoteEnvironmentRowsSkeleton() {
27 return (34 return (
...@@ -40,19 +47,19 @@ export function RemoteEnvironmentRowsSkeleton() {...@@ -40,19 +47,19 @@ export function RemoteEnvironmentRowsSkeleton() {
40/**47/**
41 * The user's T3 Connect environments from relay discovery, each with a48 * The user's T3 Connect environments from relay discovery, each with a
42 * Connect button. The primary environment is always excluded; already-saved49 * Connect button. The primary environment is always excluded; already-saved
43 * environments are hidden unless `showSavedAsConnected` renders them as50 * environments are hidden unless `showSavedEnvironments` renders them with
44 * connected instead (used by onboarding, where the full device mesh should be51 * their live connection state (used by onboarding, where the full device mesh
45 * visible).52 * should be visible).
46 */53 */
47export function CloudEnvironmentConnectRows({54export function CloudEnvironmentConnectRows({
48 primaryEnvironmentId,55 primaryEnvironmentId,
49 savedEnvironmentIds,56 savedEnvironments,
50 showSavedAsConnected = false,57 showSavedEnvironments = false,
51 empty = null,58 empty = null,
52}: {59}: {
53 readonly primaryEnvironmentId: EnvironmentId | null;60 readonly primaryEnvironmentId: EnvironmentId | null;
54 readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>;61 readonly savedEnvironments: ReadonlyArray<SavedCloudEnvironmentConnection>;
55 readonly showSavedAsConnected?: boolean;62 readonly showSavedEnvironments?: boolean;
56 readonly empty?: ReactNode;63 readonly empty?: ReactNode;
57}) {64}) {
58 const environmentsState = useRelayEnvironmentDiscovery();65 const environmentsState = useRelayEnvironmentDiscovery();
...@@ -77,7 +84,9 @@ export function CloudEnvironmentConnectRows({...@@ -77,7 +84,9 @@ export function CloudEnvironmentConnectRows({
77 const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<EnvironmentId | null>(84 const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<EnvironmentId | null>(
78 null,85 null,
79 );86 );
80 const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]);87 const savedById = new Map(
88 savedEnvironments.map((environment) => [environment.environmentId, environment]),
89 );
8190
82 useEffect(() => {91 useEffect(() => {
83 void refreshRelayEnvironments();92 void refreshRelayEnvironments();
...@@ -90,8 +99,8 @@ export function CloudEnvironmentConnectRows({...@@ -90,8 +99,8 @@ export function CloudEnvironmentConnectRows({
90 if (result._tag === "Success") {99 if (result._tag === "Success") {
91 toastManager.add({100 toastManager.add({
92 type: "success",101 type: "success",
93 title: "Environment connected",102 title: "Environment added",
94 description: `${environment.label} is available through T3 Connect.`,103 description: `Connecting to ${environment.label} through T3 Connect.`,
95 });104 });
96 return;105 return;
97 }106 }
...@@ -121,10 +130,10 @@ export function CloudEnvironmentConnectRows({...@@ -121,10 +130,10 @@ export function CloudEnvironmentConnectRows({
121 const visibleEnvironments = [...environmentsState.environments.values()].filter(130 const visibleEnvironments = [...environmentsState.environments.values()].filter(
122 ({ environment }) =>131 ({ environment }) =>
123 environment.environmentId !== primaryEnvironmentId &&132 environment.environmentId !== primaryEnvironmentId &&
124 (showSavedAsConnected || !savedIds.has(environment.environmentId)),133 (showSavedEnvironments || !savedById.has(environment.environmentId)),
125 );134 );
126135
127 const standalone = showSavedAsConnected || savedEnvironmentIds.length === 0;136 const standalone = showSavedEnvironments || savedEnvironments.length === 0;
128137
129 if (138 if (
130 standalone &&139 standalone &&
...@@ -163,31 +172,57 @@ export function CloudEnvironmentConnectRows({...@@ -163,31 +172,57 @@ export function CloudEnvironmentConnectRows({
163 }172 }
164173
165 return visibleEnvironments.map(({ environment, availability, error }) => {174 return visibleEnvironments.map(({ environment, availability, error }) => {
166 const alreadyConnected = savedIds.has(environment.environmentId);175 const savedEnvironment = savedById.get(environment.environmentId);
176 const savedConnection = savedEnvironment
177 ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection)
178 : null;
179 const dotClassName = savedConnection
180 ? savedConnection.tone === "connected"
181 ? "bg-success"
182 : savedConnection.tone === "connecting"
183 ? "bg-warning"
184 : savedConnection.tone === "error"
185 ? "bg-destructive"
186 : "bg-muted-foreground/35"
187 : availability === "online"
188 ? "bg-success"
189 : availability === "error"
190 ? "bg-destructive"
191 : availability === "checking"
192 ? "bg-warning"
193 : "bg-muted-foreground/35";
194 const statusText = savedConnection
195 ? savedConnection.statusText
196 : availability === "online"
197 ? "Available · Relay online"
198 : availability === "offline"
199 ? "Available · Relay offline"
200 : availability === "checking"
201 ? "Available · Checking relay status…"
202 : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable");
167 return (203 return (
168 <div key={environment.environmentId} className={ITEM_ROW_CLASSNAME}>204 <div key={environment.environmentId} className={ITEM_ROW_CLASSNAME}>
169 <div className={ITEM_ROW_INNER_CLASSNAME}>205 <div className={ITEM_ROW_INNER_CLASSNAME}>
170 <div className="min-w-0">206 <div className="min-w-0">
171 <div className="flex items-center gap-2">207 <div className="flex items-center gap-2">
172 <ConnectionStatusDot208 <ConnectionStatusDot
173 dotClassName={209 dotClassName={dotClassName}
174 availability === "online"210 pingClassName={
175 ? "bg-success"211 savedConnection?.tone === "connecting" ||
176 : availability === "error"212 (savedConnection === null && availability === "checking")
177 ? "bg-destructive"213 ? "bg-warning/60 duration-2000"
178 : availability === "checking"214 : null
179 ? "bg-warning"
180 : "bg-muted-foreground/35"
181 }215 }
182 pingClassName={availability === "checking" ? "bg-warning/60 duration-2000" : null}
183 tooltipText={216 tooltipText={
184 availability === "online"217 savedConnection
185 ? "Relay online"218 ? savedConnection.statusText
186 : availability === "offline"219 : availability === "online"
187 ? "Relay offline"220 ? "Relay online"
188 : availability === "checking"221 : availability === "offline"
189 ? "Checking relay status"222 ? "Relay offline"
190 : (Option.getOrNull(error)?.message ?? "Relay status unavailable")223 : availability === "checking"
224 ? "Checking relay status"
225 : (Option.getOrNull(error)?.message ?? "Relay status unavailable")
191 }226 }
192 />227 />
193 <p className="truncate text-sm font-medium">{environment.label}</p>228 <p className="truncate text-sm font-medium">{environment.label}</p>
...@@ -195,21 +230,19 @@ export function CloudEnvironmentConnectRows({...@@ -195,21 +230,19 @@ export function CloudEnvironmentConnectRows({
195 <p230 <p
196 className={cn(231 className={cn(
197 "mt-1 truncate text-xs",232 "mt-1 truncate text-xs",
198 availability === "error" ? "text-destructive" : "text-muted-foreground",233 savedConnection?.tone === "error" ||
234 (savedConnection?.tone === "connecting" && savedEnvironment?.connection.error) ||
235 (savedConnection === null && availability === "error")
236 ? "text-destructive"
237 : "text-muted-foreground",
199 )}238 )}
200 >239 >
201 {availability === "online"240 {statusText}
202 ? "Available · Relay online"
203 : availability === "offline"
204 ? "Available · Relay offline"
205 : availability === "checking"
206 ? "Available · Checking relay status…"
207 : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")}
208 </p>241 </p>
209 </div>242 </div>
210 {alreadyConnected ? (243 {savedConnection ? (
211 <Button size="sm" variant="outline" disabled>244 <Button size="sm" variant="outline" disabled>
212 Connected245 {savedConnection.buttonLabel}
213 </Button>246 </Button>
214 ) : (247 ) : (
215 <Button248 <Button
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx+5-9
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { useAuth } from "@clerk/react";1import { useAuth } from "@clerk/react";
2import { AuthAdministrativeScopes, AuthRelayWriteScope } from "@t3tools/contracts";2import { AuthAdministrativeScopes, AuthRelayWriteScope } from "@t3tools/contracts";
3import { CheckIcon } from "lucide-react";3import { CheckIcon } from "lucide-react";
4import { useEffect, useMemo, useRef, useState } from "react";4import { useEffect, useRef, useState } from "react";
55
6import {6import {
7 CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY,7 CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY,
...@@ -411,20 +411,16 @@ function OnboardingToggleRow({...@@ -411,20 +411,16 @@ function OnboardingToggleRow({
411function DevicesStep() {411function DevicesStep() {
412 const { environments } = useEnvironments();412 const { environments } = useEnvironments();
413 const primaryEnvironment = usePrimaryEnvironment();413 const primaryEnvironment = usePrimaryEnvironment();
414 const savedEnvironmentIds = useMemo(414 const savedEnvironments = environments.filter(
415 () =>415 (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget",
416 environments
417 .filter((environment) => environment.entry.target._tag !== "PrimaryConnectionTarget")
418 .map((environment) => environment.environmentId),
419 [environments],
420 );416 );
421417
422 return (418 return (
423 <div className="overflow-hidden rounded-lg border">419 <div className="overflow-hidden rounded-lg border">
424 <CloudEnvironmentConnectRows420 <CloudEnvironmentConnectRows
425 primaryEnvironmentId={primaryEnvironment?.environmentId ?? null}421 primaryEnvironmentId={primaryEnvironment?.environmentId ?? null}
426 savedEnvironmentIds={savedEnvironmentIds}422 savedEnvironments={savedEnvironments}
427 showSavedAsConnected423 showSavedEnvironments
428 empty={424 empty={
429 <p className="px-4 py-6 text-center text-sm text-muted-foreground">425 <p className="px-4 py-6 text-center text-sm text-muted-foreground">
430 No other environments are published to your account yet. Publish one from another device426 No other environments are published to your account yet. Publish one from another device
apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.test.ts created+55
...@@ -0,0 +1,55 @@
1import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
2import { describe, expect, it } from "vite-plus/test";
3
4import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation";
5
6function connection(
7 phase: EnvironmentConnectionPresentation["phase"],
8 error: string | null = null,
9): EnvironmentConnectionPresentation {
10 return { phase, error, traceId: null };
11}
12
13describe("saved cloud environment connection presentation", () => {
14 it("only labels a live connection as connected", () => {
15 expect(presentSavedCloudEnvironmentConnection(connection("connected"))).toEqual({
16 buttonLabel: "Connected",
17 statusText: "Connected",
18 tone: "connected",
19 });
20
21 expect(presentSavedCloudEnvironmentConnection(connection("connecting"))).toEqual({
22 buttonLabel: "Connecting…",
23 statusText: "Connecting...",
24 tone: "connecting",
25 });
26 });
27
28 it("surfaces a failed attempt while the supervisor reconnects", () => {
29 expect(
30 presentSavedCloudEnvironmentConnection(
31 connection("reconnecting", "Relay environment endpoint is unavailable."),
32 ),
33 ).toEqual({
34 buttonLabel: "Reconnecting…",
35 statusText:
36 "Failed to connect. Reconnecting... Reason: Relay environment endpoint is unavailable.",
37 tone: "connecting",
38 });
39 });
40
41 it.each([
42 ["error", "Connection failed", "Connection failed. Reason: Access denied.", "error"],
43 ["offline", "Offline", "Offline", "idle"],
44 ["available", "Not connected", "Available", "idle"],
45 ] as const)(
46 "presents %s without claiming the environment is connected",
47 (phase, buttonLabel, statusText, tone) => {
48 expect(
49 presentSavedCloudEnvironmentConnection(
50 connection(phase, phase === "error" ? "Access denied." : null),
51 ),
52 ).toEqual({ buttonLabel, statusText, tone });
53 },
54 );
55});
apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts created+58
...@@ -0,0 +1,58 @@
1import {
2 connectionStatusText,
3 type EnvironmentConnectionPresentation,
4} from "@t3tools/client-runtime/connection";
5
6export interface SavedCloudEnvironmentConnectionPresentation {
7 readonly buttonLabel: string;
8 readonly statusText: string;
9 readonly tone: "connected" | "connecting" | "error" | "idle";
10}
11
12/**
13 * Present the live supervisor state for an environment that is already in the
14 * connection catalog. Catalog membership only means the environment is saved;
15 * it does not mean the connection attempt succeeded.
16 */
17export function presentSavedCloudEnvironmentConnection(
18 connection: EnvironmentConnectionPresentation,
19): SavedCloudEnvironmentConnectionPresentation {
20 switch (connection.phase) {
21 case "connected":
22 return {
23 buttonLabel: "Connected",
24 statusText: connectionStatusText(connection),
25 tone: "connected",
26 };
27 case "connecting":
28 return {
29 buttonLabel: "Connecting…",
30 statusText: connectionStatusText(connection),
31 tone: "connecting",
32 };
33 case "reconnecting":
34 return {
35 buttonLabel: "Reconnecting…",
36 statusText: connectionStatusText(connection),
37 tone: "connecting",
38 };
39 case "error":
40 return {
41 buttonLabel: "Connection failed",
42 statusText: connectionStatusText(connection),
43 tone: "error",
44 };
45 case "offline":
46 return {
47 buttonLabel: "Offline",
48 statusText: connectionStatusText(connection),
49 tone: "idle",
50 };
51 case "available":
52 return {
53 buttonLabel: "Not connected",
54 statusText: connectionStatusText(connection),
55 tone: "idle",
56 };
57 }
58}
apps/web/src/components/settings/ConnectionsSettings.tsx+5-9
...@@ -1672,18 +1672,18 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable...@@ -1672,18 +1672,18 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable
16721672
1673function CloudRemoteEnvironmentRows({1673function CloudRemoteEnvironmentRows({
1674 primaryEnvironmentId,1674 primaryEnvironmentId,
1675 savedEnvironmentIds,1675 savedEnvironments,
1676}: {1676}: {
1677 readonly primaryEnvironmentId: EnvironmentId | null;1677 readonly primaryEnvironmentId: EnvironmentId | null;
1678 readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>;1678 readonly savedEnvironments: ReadonlyArray<EnvironmentPresentation>;
1679}) {1679}) {
1680 return hasCloudPublicConfig() ? (1680 return hasCloudPublicConfig() ? (
1681 <CloudEnvironmentConnectRows1681 <CloudEnvironmentConnectRows
1682 primaryEnvironmentId={primaryEnvironmentId}1682 primaryEnvironmentId={primaryEnvironmentId}
1683 savedEnvironmentIds={savedEnvironmentIds}1683 savedEnvironments={savedEnvironments}
1684 empty={<EmptyRemoteEnvironments />}1684 empty={<EmptyRemoteEnvironments />}
1685 />1685 />
1686 ) : savedEnvironmentIds.length === 0 ? (1686 ) : savedEnvironments.length === 0 ? (
1687 <EmptyRemoteEnvironments cloudEnabled={false} />1687 <EmptyRemoteEnvironments cloudEnabled={false} />
1688 ) : null;1688 ) : null;
1689}1689}
...@@ -1713,10 +1713,6 @@ export function ConnectionsSettings() {...@@ -1713,10 +1713,6 @@ export function ConnectionsSettings() {
1713 .toSorted((left, right) => left.label.localeCompare(right.label)),1713 .toSorted((left, right) => left.label.localeCompare(right.label)),
1714 [environments],1714 [environments],
1715 );1715 );
1716 const savedEnvironmentIds = useMemo(
1717 () => savedEnvironments.map((environment) => environment.environmentId),
1718 [savedEnvironments],
1719 );
1720 const savedDesktopSshEnvironmentsByAlias = useMemo(1716 const savedDesktopSshEnvironmentsByAlias = useMemo(
1721 () =>1717 () =>
1722 savedEnvironments.reduce<Record<string, EnvironmentPresentation>>(1718 savedEnvironments.reduce<Record<string, EnvironmentPresentation>>(
...@@ -3363,7 +3359,7 @@ export function ConnectionsSettings() {...@@ -3363,7 +3359,7 @@ export function ConnectionsSettings() {
3363 ))}3359 ))}
3364 <CloudRemoteEnvironmentRows3360 <CloudRemoteEnvironmentRows
3365 primaryEnvironmentId={primaryEnvironmentId}3361 primaryEnvironmentId={primaryEnvironmentId}
3366 savedEnvironmentIds={savedEnvironmentIds}3362 savedEnvironments={savedEnvironments}
3367 />3363 />
3368 </SettingsSection>3364 </SettingsSection>
3369 </SettingsPageContainer>3365 </SettingsPageContainer>