authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-15 22:42:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 22:42:53-07:00
logc1b2ed6504bbd57440d03c6a5af2736f33be7c65
tree02842faf01a5feb74e76aa6d21da159633b7ee03
parent0f5a1513e09fb41e0d0f86157aff64cc3657975d
signature Signed by PGP key B5690EEEBB952194

fix(clients): show unsupported environments as neutral rows with their machine icon (#12026)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

11 files changed, 153 insertions(+), 52 deletions(-)

apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+34-13
......@@ -7,6 +7,7 @@ import {
77import {
88 type EnvironmentId,
99 type EnvironmentMachineKind,
10 type ExecutionEnvironmentDescriptor,
1011 resolveEnvironmentMachineKind,
1112} from "@t3tools/contracts";
1213import { useAtomValue } from "@effect/atom-react";
......@@ -99,6 +100,16 @@ function CloudEnvironmentRowsContent(
99100 [controller],
100101 );
101102
103 // The relay's health probe carries each server's descriptor, so a machine
104 // can wear its detected glyph before this device ever connects to it.
105 const discoveredDescriptors = new Map(
106 controller.relayEnvironments.flatMap((entry) =>
107 entry.status?.descriptor === undefined
108 ? []
109 : [[entry.environment.environmentId, entry.status.descriptor] as const],
110 ),
111 );
112
102113 const handleToggleCloudError = useCallback((environmentId: string) => {
103114 setExpandedErrorId((current) => (current === environmentId ? null : environmentId));
104115 }, []);
......@@ -140,6 +151,7 @@ function CloudEnvironmentRowsContent(
140151 <ConnectedCloudEnvironmentRow
141152 key={environment.environmentId}
142153 environment={environment}
154 descriptor={discoveredDescriptors.get(environment.environmentId)}
143155 borderTop={index !== 0}
144156 onSetEnabled={(enabled) =>
145157 props.onSetEnvironmentEnabled(environment.environmentId, enabled)
......@@ -210,6 +222,8 @@ function CloudEnvironmentRowsContent(
210222 */
211223function ConnectedCloudEnvironmentRow(props: {
212224 readonly environment: ConnectedEnvironmentSummary;
225 /** Discovery's view of the server, for the glyph before the first connection. */
226 readonly descriptor: ExecutionEnvironmentDescriptor | undefined;
213227 readonly borderTop: boolean;
214228 readonly errorExpanded: boolean;
215229 readonly onSetEnabled: (enabled: boolean) => void;
......@@ -233,7 +247,10 @@ function ConnectedCloudEnvironmentRow(props: {
233247 connectionState={enabled || unsupported ? props.environment.connectionState : "available"}
234248 errorExpanded={props.errorExpanded}
235249 label={props.environment.environmentLabel}
236 machine={resolveEnvironmentMachineKind(serverConfig)}
250 machine={resolveEnvironmentMachineKind(
251 serverConfig ??
252 (props.descriptor === undefined ? null : { environment: props.descriptor }),
253 )}
237254 onValueChange={props.onSetEnabled}
238255 onToggleError={props.onToggleError}
239256 disabled={unsupported}
......@@ -266,6 +283,11 @@ function CloudEnvironmentRow(props: {
266283 connectionState={presentation.connectionState}
267284 errorExpanded={props.errorExpanded}
268285 label={props.environment.environment.label}
286 machine={resolveEnvironmentMachineKind(
287 props.environment.status?.descriptor === undefined
288 ? null
289 : { environment: props.environment.status.descriptor },
290 )}
269291 onValueChange={(enabled) => {
270292 if (enabled) {
271293 props.onConnect();
......@@ -287,8 +309,7 @@ function CloudEnvironmentRowShell(props: {
287309 readonly disabled?: boolean;
288310 readonly errorExpanded: boolean;
289311 readonly label: string;
290 /** Absent for environments the relay lists but this device has not connected to. */
291 readonly machine?: EnvironmentMachineKind;
312 readonly machine: EnvironmentMachineKind;
292313 readonly onToggleError: () => void;
293314 readonly onValueChange: (enabled: boolean) => void;
294315 readonly statusText?: string;
......@@ -304,9 +325,11 @@ function CloudEnvironmentRowShell(props: {
304325 error: props.connectionError,
305326 traceId: props.connectionErrorTraceId,
306327 });
307 const statusClassName = props.connectionError
308 ? "text-danger-foreground"
309 : "text-foreground-muted";
328 // Unsupported is a compatibility note, not a failure, so it stays muted.
329 const statusClassName =
330 props.connectionError && props.connectionState !== "unsupported"
331 ? "text-danger-foreground"
332 : "text-foreground-muted";
310333 const [errorMeasurement, setErrorMeasurement] = useState<{
311334 readonly text: string;
312335 readonly lineCount: number;
......@@ -344,13 +367,11 @@ function CloudEnvironmentRowShell(props: {
344367 <View className="min-w-0 flex-1 gap-0.5">
345368 <View className="min-w-0 flex-row items-center gap-2">
346369 <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} />
347 {props.machine ? (
348 <EnvironmentMachineSymbol
349 kind={props.machine}
350 size={14}
351 tintColorClassName="accent-foreground-muted"
352 />
353 ) : null}
370 <EnvironmentMachineSymbol
371 kind={props.machine}
372 size={14}
373 tintColorClassName="accent-foreground-muted"
374 />
354375 <Text
355376 className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground"
356377 numberOfLines={1}
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+2-2
......@@ -50,8 +50,8 @@ export function ConnectionEnvironmentRow(props: {
5050 const enabled = props.environment.isEnabled && !unsupported;
5151 const statusLabel = connectionStatusLabel(props.environment);
5252 const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null;
53 const hasConnectionFailure =
54 (enabled || unsupported) && props.environment.connectionError !== null;
53 // Unsupported is a compatibility note, not a failure, so it stays muted.
54 const hasConnectionFailure = enabled && props.environment.connectionError !== null;
5555 const isRetrying =
5656 enabled &&
5757 (props.environment.connectionState === "connecting" ||
apps/mobile/src/features/connection/ConnectionStatusDot.tsx+3-1
......@@ -18,7 +18,10 @@ function statusDotTone(state: ConnectionStatusDotState): {
1818 readonly haloColor: string;
1919} {
2020 switch (state) {
21 // Unsupported is not a failure: the machine is fine, this build just
22 // cannot talk to it, so it wears the same neutral dot as "available".
2123 case "available":
24 case "unsupported":
2225 return {
2326 dotColor: "#9ca3af",
2427 haloColor: "rgba(156,163,175,0.42)",
......@@ -35,7 +38,6 @@ function statusDotTone(state: ConnectionStatusDotState): {
3538 haloColor: "rgba(245,158,11,0.5)",
3639 };
3740 case "offline":
38 case "unsupported":
3941 case "error":
4042 return {
4143 dotColor: "#ef4444",
apps/mobile/src/features/connection/connectionTone.ts+2-2
......@@ -24,8 +24,8 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone {
2424 case "unsupported":
2525 return {
2626 label: "Client not supported",
27 pillClassName: "bg-danger",
28 textClassName: "text-danger-foreground",
27 pillClassName: "bg-subtle",
28 textClassName: "text-foreground-secondary",
2929 };
3030 case "error":
3131 return {
apps/web/src/components/ConnectionStatusDot.tsx-1
......@@ -11,7 +11,6 @@ export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase):
1111 case "connecting":
1212 case "reconnecting":
1313 return "bg-warning";
14 case "unsupported":
1514 case "error":
1615 return "bg-destructive";
1716 default:
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+68-23
......@@ -9,7 +9,11 @@ import {
99 isAtomCommandInterrupted,
1010 squashAtomCommandFailure,
1111} from "@t3tools/client-runtime/state/runtime";
12import type { EnvironmentId } from "@t3tools/contracts";
12import {
13 type EnvironmentId,
14 resolveEnvironmentMachineKind,
15 type ServerConfig,
16} from "@t3tools/contracts";
1317import type {
1418 RelayClientEnvironmentRecord,
1519 RelayEnvironmentStatusResponse,
......@@ -23,6 +27,7 @@ import { relayEnvironmentDiscovery } from "~/state/relay";
2327import { useRelayEnvironmentDiscovery } from "~/state/environments";
2428import { useAtomCommand } from "~/state/use-atom-command";
2529import { ConnectionStatusDot } from "../ConnectionStatusDot";
30import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
2631import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows";
2732import { Checkbox } from "../ui/checkbox";
2833import { Button } from "../ui/button";
......@@ -43,6 +48,8 @@ function discoveredCompatibilityError(
4348export interface SavedCloudEnvironmentConnection {
4449 readonly environmentId: EnvironmentId;
4550 readonly connection: EnvironmentConnectionPresentation;
51 /** Present once connected; carries the user's icon override. */
52 readonly serverConfig?: ServerConfig | null;
4653}
4754
4855function RemoteEnvironmentRowsSkeleton() {
......@@ -296,15 +303,25 @@ export function CloudEnvironmentConnectRows({
296303 const compatibilityError = discoveredCompatibilityError(status);
297304 const unsupported =
298305 compatibilityError !== null || savedEnvironment?.connection.phase === "unsupported";
306 const unsupportedDetail =
307 compatibilityError?.message ?? savedEnvironment?.connection.error ?? null;
299308 const savedConnection = unsupported
300309 ? presentSavedCloudEnvironmentConnection({
301310 phase: "unsupported",
302 error: compatibilityError?.message ?? savedEnvironment?.connection.error ?? null,
311 error: unsupportedDetail,
303312 traceId: null,
304313 })
305314 : savedEnvironment
306315 ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection)
307316 : null;
317 // A connected machine's own config (with the user's icon pick) wins. Before
318 // that, the relay's health probe already carries the server's descriptor, so
319 // a machine can wear its detected glyph before this device ever connects.
320 const descriptor = status === undefined ? undefined : Option.getOrNull(status)?.descriptor;
321 const machineKind = resolveEnvironmentMachineKind(
322 savedEnvironment?.serverConfig ??
323 (descriptor === undefined ? null : { environment: descriptor }),
324 );
308325 const dotClassName = savedConnection
309326 ? savedConnection.tone === "connected"
310327 ? "bg-success"
......@@ -320,16 +337,19 @@ export function CloudEnvironmentConnectRows({
320337 : availability === "checking"
321338 ? "bg-warning"
322339 : "bg-muted-foreground/35";
323 const statusText = savedConnection
324 ? savedConnection.statusText
325 : availability === "online"
326 ? "T3 Connect · Not added · Relay online"
327 : availability === "offline"
328 ? "T3 Connect · Not added · Relay offline"
329 : availability === "checking"
330 ? "T3 Connect · Not added · Checking relay status…"
331 : (Option.getOrNull(error)?.message ??
332 "T3 Connect · Not added · Relay status unavailable");
340 const statusText =
341 unsupported && !savedEnvironment
342 ? "T3 Connect · Not added · Client not supported"
343 : savedConnection
344 ? savedConnection.statusText
345 : availability === "online"
346 ? "T3 Connect · Not added · Relay online"
347 : availability === "offline"
348 ? "T3 Connect · Not added · Relay offline"
349 : availability === "checking"
350 ? "T3 Connect · Not added · Checking relay status…"
351 : (Option.getOrNull(error)?.message ??
352 "T3 Connect · Not added · Relay status unavailable");
333353 if (selection) {
334354 return (
335355 <label
......@@ -348,6 +368,11 @@ export function CloudEnvironmentConnectRows({
348368 }
349369 }}
350370 />
371 <EnvironmentMachineIcon
372 aria-hidden
373 kind={machineKind}
374 className="size-4 shrink-0 text-muted-foreground"
375 />
351376 <span className="min-w-0 flex-1 truncate text-sm font-medium">{environment.label}</span>
352377 <Tooltip>
353378 <TooltipTrigger
......@@ -368,7 +393,9 @@ export function CloudEnvironmentConnectRows({
368393 ? "Unavailable"
369394 : "Checking…"))}
370395 </TooltipTrigger>
371 <TooltipPopup className="max-w-80 break-words">{statusText}</TooltipPopup>
396 <TooltipPopup className="max-w-80 break-words">
397 {unsupportedDetail ?? statusText}
398 </TooltipPopup>
372399 </Tooltip>
373400 </label>
374401 );
......@@ -387,17 +414,24 @@ export function CloudEnvironmentConnectRows({
387414 : null
388415 }
389416 tooltipText={
390 savedConnection
391 ? savedConnection.statusText
392 : availability === "online"
393 ? "Relay online"
394 : availability === "offline"
395 ? "Relay offline"
396 : availability === "checking"
397 ? "Checking relay status"
398 : (Option.getOrNull(error)?.message ?? "Relay status unavailable")
417 unsupportedDetail !== null
418 ? unsupportedDetail
419 : savedConnection
420 ? savedConnection.statusText
421 : availability === "online"
422 ? "Relay online"
423 : availability === "offline"
424 ? "Relay offline"
425 : availability === "checking"
426 ? "Checking relay status"
427 : (Option.getOrNull(error)?.message ?? "Relay status unavailable")
399428 }
400429 />
430 <EnvironmentMachineIcon
431 aria-hidden
432 kind={machineKind}
433 className="size-4 shrink-0 text-muted-foreground"
434 />
401435 <p className="truncate text-sm font-medium">{environment.label}</p>
402436 </div>
403437 <p
......@@ -413,7 +447,18 @@ export function CloudEnvironmentConnectRows({
413447 {statusText}
414448 </p>
415449 </div>
416 {savedConnection ? (
450 {unsupported && !savedEnvironment ? (
451 <Tooltip>
452 <TooltipTrigger render={<span className="inline-flex" tabIndex={0} />}>
453 <Button size="sm" disabled>
454 Add
455 </Button>
456 </TooltipTrigger>
457 <TooltipPopup className="max-w-80 break-words">
458 {unsupportedDetail ?? "Client not supported"}
459 </TooltipPopup>
460 </Tooltip>
461 ) : savedConnection ? (
417462 <Button size="sm" variant="outline" disabled>
418463 {savedConnection.buttonLabel}
419464 </Button>
apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.test.ts+1
......@@ -40,6 +40,7 @@ describe("saved cloud environment connection presentation", () => {
4040
4141 it.each([
4242 ["error", "Connection failed", "Connection failed. Reason: Access denied.", "error"],
43 ["unsupported", "Client not supported", "Client not supported", "idle"],
4344 ["offline", "Offline", "Offline", "idle"],
4445 ["available", "Not connected", "Available", "idle"],
4546 ] as const)(
apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts+7-2
......@@ -36,11 +36,16 @@ export function presentSavedCloudEnvironmentConnection(
3636 statusText: connectionStatusText(connection),
3737 tone: "connecting",
3838 };
39 // Not a failure: the machine is fine, this build just cannot talk to it.
3940 case "unsupported":
41 return {
42 buttonLabel: "Client not supported",
43 statusText: connectionStatusText(connection),
44 tone: "idle",
45 };
4046 case "error":
4147 return {
42 buttonLabel:
43 connection.phase === "unsupported" ? "Client not supported" : "Connection failed",
48 buttonLabel: "Connection failed",
4449 statusText: connectionStatusText(connection),
4550 tone: "error",
4651 };
apps/web/src/components/settings/ConnectionsSettings.tsx+20-3
......@@ -161,6 +161,7 @@ import {
161161 type EnvironmentPresentation,
162162 useEnvironments,
163163 usePrimaryEnvironment,
164 useRelayEnvironmentDiscovery,
164165} from "~/state/environments";
165166import { requestConfirmDialog } from "~/confirmDialog";
166167import { useAtomCommand } from "../../state/use-atom-command";
......@@ -1457,8 +1458,9 @@ function savedBackendStatus(environment: EnvironmentPresentation): {
14571458 text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting",
14581459 tone: "error",
14591460 };
1461 // Not a failure: the machine is fine, this build just cannot talk to it.
14601462 case "unsupported":
1461 return { text: "Client not supported", tone: "error" };
1463 return { text: "Client not supported", tone: "muted" };
14621464 case "error":
14631465 return {
14641466 text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed",
......@@ -1519,6 +1521,17 @@ function SavedBackendListRow({
15191521 serverUpdateState.status === "running" && serverUpdateState.stage === "resuming";
15201522 const status = savedBackendStatus(environment);
15211523 const serverVersion = environment.serverConfig?.environment.serverVersion ?? null;
1524 // A saved T3 Connect machine this device has never reached (unsupported,
1525 // or not yet connected) still has a descriptor from relay discovery, so
1526 // it can wear its detected glyph instead of the generic server.
1527 const relayDiscovery = useRelayEnvironmentDiscovery();
1528 const discoveredDescriptor = Option.getOrNull(
1529 relayDiscovery.environments.get(environmentId)?.status ?? Option.none(),
1530 )?.descriptor;
1531 const machineKind = resolveEnvironmentMachineKind(
1532 environment.serverConfig ??
1533 (discoveredDescriptor === undefined ? null : { environment: discoveredDescriptor }),
1534 );
15221535 const subtitleText = [
15231536 environmentTransportLabel(environment),
15241537 resumingServerUpdate ? "Restarting" : status.text,
......@@ -1537,7 +1550,7 @@ function SavedBackendListRow({
15371550
15381551 return (
15391552 <EnvironmentRow
1540 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
1553 kind={machineKind}
15411554 label={environment.label}
15421555 dimmed={!enabled}
15431556 subtitle={
......@@ -1558,7 +1571,11 @@ function SavedBackendListRow({
15581571 {subtitleText}
15591572 </TooltipTrigger>
15601573 <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">
1561 {enabled || unsupported ? connectionStatusText(environment.connection) : "Switched off"}
1574 {unsupported && environment.connection.error
1575 ? environment.connection.error
1576 : enabled
1577 ? connectionStatusText(environment.connection)
1578 : "Switched off"}
15621579 {versionMismatch
15631580 ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}`
15641581 : ""}
packages/contracts/src/server.test.ts+6
......@@ -207,6 +207,12 @@ describe("resolveEnvironmentMachineKind", () => {
207207 ).toBe("mac-mini");
208208 });
209209
210 it("uses detection from a bare descriptor before connecting", () => {
211 expect(resolveEnvironmentMachineKind({ environment: descriptor({ machine: "laptop" }) })).toBe(
212 "laptop",
213 );
214 });
215
210216 it("falls back to a server for older servers and before connect", () => {
211217 expect(
212218 resolveEnvironmentMachineKind({
packages/contracts/src/server.ts+10-5
......@@ -598,14 +598,19 @@ export type ServerConfig = typeof ServerConfig.Type;
598598
599599/**
600600 * The machine an environment should be drawn as: the user's pick, else what
601 * the server detected, else a generic server. A null config (not connected
602 * yet, or an older server) resolves to the same generic so rows never
603 * flicker between glyphs.
601 * the server detected, else a generic server. Settings only exist once
602 * connected; a descriptor alone (relay discovery, before any connection)
603 * still yields the detected kind. A null config (nothing known yet, or an
604 * older server) resolves to the same generic so rows never flicker between
605 * glyphs.
604606 */
605607export function resolveEnvironmentMachineKind(
606 config: Pick<ServerConfig, "environment" | "settings"> | null,
608 config: {
609 readonly environment: Pick<ExecutionEnvironmentDescriptor, "platform">;
610 readonly settings?: Pick<ServerSettings, "environmentIcon">;
611 } | null,
607612): EnvironmentMachineKind {
608 return config?.settings.environmentIcon ?? config?.environment.platform.machine ?? "server";
613 return config?.settings?.environmentIcon ?? config?.environment.platform.machine ?? "server";
609614}
610615
611616const ServerUpsertKeybindingReplaceTarget = Schema.Struct({