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 {...@@ -7,6 +7,7 @@ import {
7import {7import {
8 type EnvironmentId,8 type EnvironmentId,
9 type EnvironmentMachineKind,9 type EnvironmentMachineKind,
10 type ExecutionEnvironmentDescriptor,
10 resolveEnvironmentMachineKind,11 resolveEnvironmentMachineKind,
11} from "@t3tools/contracts";12} from "@t3tools/contracts";
12import { useAtomValue } from "@effect/atom-react";13import { useAtomValue } from "@effect/atom-react";
...@@ -99,6 +100,16 @@ function CloudEnvironmentRowsContent(...@@ -99,6 +100,16 @@ function CloudEnvironmentRowsContent(
99 [controller],100 [controller],
100 );101 );
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
102 const handleToggleCloudError = useCallback((environmentId: string) => {113 const handleToggleCloudError = useCallback((environmentId: string) => {
103 setExpandedErrorId((current) => (current === environmentId ? null : environmentId));114 setExpandedErrorId((current) => (current === environmentId ? null : environmentId));
104 }, []);115 }, []);
...@@ -140,6 +151,7 @@ function CloudEnvironmentRowsContent(...@@ -140,6 +151,7 @@ function CloudEnvironmentRowsContent(
140 <ConnectedCloudEnvironmentRow151 <ConnectedCloudEnvironmentRow
141 key={environment.environmentId}152 key={environment.environmentId}
142 environment={environment}153 environment={environment}
154 descriptor={discoveredDescriptors.get(environment.environmentId)}
143 borderTop={index !== 0}155 borderTop={index !== 0}
144 onSetEnabled={(enabled) =>156 onSetEnabled={(enabled) =>
145 props.onSetEnvironmentEnabled(environment.environmentId, enabled)157 props.onSetEnvironmentEnabled(environment.environmentId, enabled)
...@@ -210,6 +222,8 @@ function CloudEnvironmentRowsContent(...@@ -210,6 +222,8 @@ function CloudEnvironmentRowsContent(
210 */222 */
211function ConnectedCloudEnvironmentRow(props: {223function ConnectedCloudEnvironmentRow(props: {
212 readonly environment: ConnectedEnvironmentSummary;224 readonly environment: ConnectedEnvironmentSummary;
225 /** Discovery's view of the server, for the glyph before the first connection. */
226 readonly descriptor: ExecutionEnvironmentDescriptor | undefined;
213 readonly borderTop: boolean;227 readonly borderTop: boolean;
214 readonly errorExpanded: boolean;228 readonly errorExpanded: boolean;
215 readonly onSetEnabled: (enabled: boolean) => void;229 readonly onSetEnabled: (enabled: boolean) => void;
...@@ -233,7 +247,10 @@ function ConnectedCloudEnvironmentRow(props: {...@@ -233,7 +247,10 @@ function ConnectedCloudEnvironmentRow(props: {
233 connectionState={enabled || unsupported ? props.environment.connectionState : "available"}247 connectionState={enabled || unsupported ? props.environment.connectionState : "available"}
234 errorExpanded={props.errorExpanded}248 errorExpanded={props.errorExpanded}
235 label={props.environment.environmentLabel}249 label={props.environment.environmentLabel}
236 machine={resolveEnvironmentMachineKind(serverConfig)}250 machine={resolveEnvironmentMachineKind(
251 serverConfig ??
252 (props.descriptor === undefined ? null : { environment: props.descriptor }),
253 )}
237 onValueChange={props.onSetEnabled}254 onValueChange={props.onSetEnabled}
238 onToggleError={props.onToggleError}255 onToggleError={props.onToggleError}
239 disabled={unsupported}256 disabled={unsupported}
...@@ -266,6 +283,11 @@ function CloudEnvironmentRow(props: {...@@ -266,6 +283,11 @@ function CloudEnvironmentRow(props: {
266 connectionState={presentation.connectionState}283 connectionState={presentation.connectionState}
267 errorExpanded={props.errorExpanded}284 errorExpanded={props.errorExpanded}
268 label={props.environment.environment.label}285 label={props.environment.environment.label}
286 machine={resolveEnvironmentMachineKind(
287 props.environment.status?.descriptor === undefined
288 ? null
289 : { environment: props.environment.status.descriptor },
290 )}
269 onValueChange={(enabled) => {291 onValueChange={(enabled) => {
270 if (enabled) {292 if (enabled) {
271 props.onConnect();293 props.onConnect();
...@@ -287,8 +309,7 @@ function CloudEnvironmentRowShell(props: {...@@ -287,8 +309,7 @@ function CloudEnvironmentRowShell(props: {
287 readonly disabled?: boolean;309 readonly disabled?: boolean;
288 readonly errorExpanded: boolean;310 readonly errorExpanded: boolean;
289 readonly label: string;311 readonly label: string;
290 /** Absent for environments the relay lists but this device has not connected to. */312 readonly machine: EnvironmentMachineKind;
291 readonly machine?: EnvironmentMachineKind;
292 readonly onToggleError: () => void;313 readonly onToggleError: () => void;
293 readonly onValueChange: (enabled: boolean) => void;314 readonly onValueChange: (enabled: boolean) => void;
294 readonly statusText?: string;315 readonly statusText?: string;
...@@ -304,9 +325,11 @@ function CloudEnvironmentRowShell(props: {...@@ -304,9 +325,11 @@ function CloudEnvironmentRowShell(props: {
304 error: props.connectionError,325 error: props.connectionError,
305 traceId: props.connectionErrorTraceId,326 traceId: props.connectionErrorTraceId,
306 });327 });
307 const statusClassName = props.connectionError328 // Unsupported is a compatibility note, not a failure, so it stays muted.
308 ? "text-danger-foreground"329 const statusClassName =
309 : "text-foreground-muted";330 props.connectionError && props.connectionState !== "unsupported"
331 ? "text-danger-foreground"
332 : "text-foreground-muted";
310 const [errorMeasurement, setErrorMeasurement] = useState<{333 const [errorMeasurement, setErrorMeasurement] = useState<{
311 readonly text: string;334 readonly text: string;
312 readonly lineCount: number;335 readonly lineCount: number;
...@@ -344,13 +367,11 @@ function CloudEnvironmentRowShell(props: {...@@ -344,13 +367,11 @@ function CloudEnvironmentRowShell(props: {
344 <View className="min-w-0 flex-1 gap-0.5">367 <View className="min-w-0 flex-1 gap-0.5">
345 <View className="min-w-0 flex-row items-center gap-2">368 <View className="min-w-0 flex-row items-center gap-2">
346 <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} />369 <ConnectionStatusDot state={props.connectionState} pulse={shouldPulse} size={7} />
347 {props.machine ? (370 <EnvironmentMachineSymbol
348 <EnvironmentMachineSymbol371 kind={props.machine}
349 kind={props.machine}372 size={14}
350 size={14}373 tintColorClassName="accent-foreground-muted"
351 tintColorClassName="accent-foreground-muted"374 />
352 />
353 ) : null}
354 <Text375 <Text
355 className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground"376 className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground"
356 numberOfLines={1}377 numberOfLines={1}
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+2-2
...@@ -50,8 +50,8 @@ export function ConnectionEnvironmentRow(props: {...@@ -50,8 +50,8 @@ export function ConnectionEnvironmentRow(props: {
50 const enabled = props.environment.isEnabled && !unsupported;50 const enabled = props.environment.isEnabled && !unsupported;
51 const statusLabel = connectionStatusLabel(props.environment);51 const statusLabel = connectionStatusLabel(props.environment);
52 const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null;52 const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null;
53 const hasConnectionFailure =53 // Unsupported is a compatibility note, not a failure, so it stays muted.
54 (enabled || unsupported) && props.environment.connectionError !== null;54 const hasConnectionFailure = enabled && props.environment.connectionError !== null;
55 const isRetrying =55 const isRetrying =
56 enabled &&56 enabled &&
57 (props.environment.connectionState === "connecting" ||57 (props.environment.connectionState === "connecting" ||
apps/mobile/src/features/connection/ConnectionStatusDot.tsx+3-1
...@@ -18,7 +18,10 @@ function statusDotTone(state: ConnectionStatusDotState): {...@@ -18,7 +18,10 @@ function statusDotTone(state: ConnectionStatusDotState): {
18 readonly haloColor: string;18 readonly haloColor: string;
19} {19} {
20 switch (state) {20 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".
21 case "available":23 case "available":
24 case "unsupported":
22 return {25 return {
23 dotColor: "#9ca3af",26 dotColor: "#9ca3af",
24 haloColor: "rgba(156,163,175,0.42)",27 haloColor: "rgba(156,163,175,0.42)",
...@@ -35,7 +38,6 @@ function statusDotTone(state: ConnectionStatusDotState): {...@@ -35,7 +38,6 @@ function statusDotTone(state: ConnectionStatusDotState): {
35 haloColor: "rgba(245,158,11,0.5)",38 haloColor: "rgba(245,158,11,0.5)",
36 };39 };
37 case "offline":40 case "offline":
38 case "unsupported":
39 case "error":41 case "error":
40 return {42 return {
41 dotColor: "#ef4444",43 dotColor: "#ef4444",
apps/mobile/src/features/connection/connectionTone.ts+2-2
...@@ -24,8 +24,8 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone {...@@ -24,8 +24,8 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone {
24 case "unsupported":24 case "unsupported":
25 return {25 return {
26 label: "Client not supported",26 label: "Client not supported",
27 pillClassName: "bg-danger",27 pillClassName: "bg-subtle",
28 textClassName: "text-danger-foreground",28 textClassName: "text-foreground-secondary",
29 };29 };
30 case "error":30 case "error":
31 return {31 return {
apps/web/src/components/ConnectionStatusDot.tsx-1
...@@ -11,7 +11,6 @@ export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase):...@@ -11,7 +11,6 @@ export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase):
11 case "connecting":11 case "connecting":
12 case "reconnecting":12 case "reconnecting":
13 return "bg-warning";13 return "bg-warning";
14 case "unsupported":
15 case "error":14 case "error":
16 return "bg-destructive";15 return "bg-destructive";
17 default:16 default:
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+68-23
...@@ -9,7 +9,11 @@ import {...@@ -9,7 +9,11 @@ import {
9 isAtomCommandInterrupted,9 isAtomCommandInterrupted,
10 squashAtomCommandFailure,10 squashAtomCommandFailure,
11} from "@t3tools/client-runtime/state/runtime";11} 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";
13import type {17import type {
14 RelayClientEnvironmentRecord,18 RelayClientEnvironmentRecord,
15 RelayEnvironmentStatusResponse,19 RelayEnvironmentStatusResponse,
...@@ -23,6 +27,7 @@ import { relayEnvironmentDiscovery } from "~/state/relay";...@@ -23,6 +27,7 @@ import { relayEnvironmentDiscovery } from "~/state/relay";
23import { useRelayEnvironmentDiscovery } from "~/state/environments";27import { useRelayEnvironmentDiscovery } from "~/state/environments";
24import { useAtomCommand } from "~/state/use-atom-command";28import { useAtomCommand } from "~/state/use-atom-command";
25import { ConnectionStatusDot } from "../ConnectionStatusDot";29import { ConnectionStatusDot } from "../ConnectionStatusDot";
30import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
26import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows";31import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows";
27import { Checkbox } from "../ui/checkbox";32import { Checkbox } from "../ui/checkbox";
28import { Button } from "../ui/button";33import { Button } from "../ui/button";
...@@ -43,6 +48,8 @@ function discoveredCompatibilityError(...@@ -43,6 +48,8 @@ function discoveredCompatibilityError(
43export interface SavedCloudEnvironmentConnection {48export interface SavedCloudEnvironmentConnection {
44 readonly environmentId: EnvironmentId;49 readonly environmentId: EnvironmentId;
45 readonly connection: EnvironmentConnectionPresentation;50 readonly connection: EnvironmentConnectionPresentation;
51 /** Present once connected; carries the user's icon override. */
52 readonly serverConfig?: ServerConfig | null;
46}53}
4754
48function RemoteEnvironmentRowsSkeleton() {55function RemoteEnvironmentRowsSkeleton() {
...@@ -296,15 +303,25 @@ export function CloudEnvironmentConnectRows({...@@ -296,15 +303,25 @@ export function CloudEnvironmentConnectRows({
296 const compatibilityError = discoveredCompatibilityError(status);303 const compatibilityError = discoveredCompatibilityError(status);
297 const unsupported =304 const unsupported =
298 compatibilityError !== null || savedEnvironment?.connection.phase === "unsupported";305 compatibilityError !== null || savedEnvironment?.connection.phase === "unsupported";
306 const unsupportedDetail =
307 compatibilityError?.message ?? savedEnvironment?.connection.error ?? null;
299 const savedConnection = unsupported308 const savedConnection = unsupported
300 ? presentSavedCloudEnvironmentConnection({309 ? presentSavedCloudEnvironmentConnection({
301 phase: "unsupported",310 phase: "unsupported",
302 error: compatibilityError?.message ?? savedEnvironment?.connection.error ?? null,311 error: unsupportedDetail,
303 traceId: null,312 traceId: null,
304 })313 })
305 : savedEnvironment314 : savedEnvironment
306 ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection)315 ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection)
307 : null;316 : 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 );
308 const dotClassName = savedConnection325 const dotClassName = savedConnection
309 ? savedConnection.tone === "connected"326 ? savedConnection.tone === "connected"
310 ? "bg-success"327 ? "bg-success"
...@@ -320,16 +337,19 @@ export function CloudEnvironmentConnectRows({...@@ -320,16 +337,19 @@ export function CloudEnvironmentConnectRows({
320 : availability === "checking"337 : availability === "checking"
321 ? "bg-warning"338 ? "bg-warning"
322 : "bg-muted-foreground/35";339 : "bg-muted-foreground/35";
323 const statusText = savedConnection340 const statusText =
324 ? savedConnection.statusText341 unsupported && !savedEnvironment
325 : availability === "online"342 ? "T3 Connect · Not added · Client not supported"
326 ? "T3 Connect · Not added · Relay online"343 : savedConnection
327 : availability === "offline"344 ? savedConnection.statusText
328 ? "T3 Connect · Not added · Relay offline"345 : availability === "online"
329 : availability === "checking"346 ? "T3 Connect · Not added · Relay online"
330 ? "T3 Connect · Not added · Checking relay status…"347 : availability === "offline"
331 : (Option.getOrNull(error)?.message ??348 ? "T3 Connect · Not added · Relay offline"
332 "T3 Connect · Not added · Relay status unavailable");349 : availability === "checking"
350 ? "T3 Connect · Not added · Checking relay status…"
351 : (Option.getOrNull(error)?.message ??
352 "T3 Connect · Not added · Relay status unavailable");
333 if (selection) {353 if (selection) {
334 return (354 return (
335 <label355 <label
...@@ -348,6 +368,11 @@ export function CloudEnvironmentConnectRows({...@@ -348,6 +368,11 @@ export function CloudEnvironmentConnectRows({
348 }368 }
349 }}369 }}
350 />370 />
371 <EnvironmentMachineIcon
372 aria-hidden
373 kind={machineKind}
374 className="size-4 shrink-0 text-muted-foreground"
375 />
351 <span className="min-w-0 flex-1 truncate text-sm font-medium">{environment.label}</span>376 <span className="min-w-0 flex-1 truncate text-sm font-medium">{environment.label}</span>
352 <Tooltip>377 <Tooltip>
353 <TooltipTrigger378 <TooltipTrigger
...@@ -368,7 +393,9 @@ export function CloudEnvironmentConnectRows({...@@ -368,7 +393,9 @@ export function CloudEnvironmentConnectRows({
368 ? "Unavailable"393 ? "Unavailable"
369 : "Checking…"))}394 : "Checking…"))}
370 </TooltipTrigger>395 </TooltipTrigger>
371 <TooltipPopup className="max-w-80 break-words">{statusText}</TooltipPopup>396 <TooltipPopup className="max-w-80 break-words">
397 {unsupportedDetail ?? statusText}
398 </TooltipPopup>
372 </Tooltip>399 </Tooltip>
373 </label>400 </label>
374 );401 );
...@@ -387,17 +414,24 @@ export function CloudEnvironmentConnectRows({...@@ -387,17 +414,24 @@ export function CloudEnvironmentConnectRows({
387 : null414 : null
388 }415 }
389 tooltipText={416 tooltipText={
390 savedConnection417 unsupportedDetail !== null
391 ? savedConnection.statusText418 ? unsupportedDetail
392 : availability === "online"419 : savedConnection
393 ? "Relay online"420 ? savedConnection.statusText
394 : availability === "offline"421 : availability === "online"
395 ? "Relay offline"422 ? "Relay online"
396 : availability === "checking"423 : availability === "offline"
397 ? "Checking relay status"424 ? "Relay offline"
398 : (Option.getOrNull(error)?.message ?? "Relay status unavailable")425 : availability === "checking"
426 ? "Checking relay status"
427 : (Option.getOrNull(error)?.message ?? "Relay status unavailable")
399 }428 }
400 />429 />
430 <EnvironmentMachineIcon
431 aria-hidden
432 kind={machineKind}
433 className="size-4 shrink-0 text-muted-foreground"
434 />
401 <p className="truncate text-sm font-medium">{environment.label}</p>435 <p className="truncate text-sm font-medium">{environment.label}</p>
402 </div>436 </div>
403 <p437 <p
...@@ -413,7 +447,18 @@ export function CloudEnvironmentConnectRows({...@@ -413,7 +447,18 @@ export function CloudEnvironmentConnectRows({
413 {statusText}447 {statusText}
414 </p>448 </p>
415 </div>449 </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 ? (
417 <Button size="sm" variant="outline" disabled>462 <Button size="sm" variant="outline" disabled>
418 {savedConnection.buttonLabel}463 {savedConnection.buttonLabel}
419 </Button>464 </Button>
apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.test.ts+1
...@@ -40,6 +40,7 @@ describe("saved cloud environment connection presentation", () => {...@@ -40,6 +40,7 @@ describe("saved cloud environment connection presentation", () => {
4040
41 it.each([41 it.each([
42 ["error", "Connection failed", "Connection failed. Reason: Access denied.", "error"],42 ["error", "Connection failed", "Connection failed. Reason: Access denied.", "error"],
43 ["unsupported", "Client not supported", "Client not supported", "idle"],
43 ["offline", "Offline", "Offline", "idle"],44 ["offline", "Offline", "Offline", "idle"],
44 ["available", "Not connected", "Available", "idle"],45 ["available", "Not connected", "Available", "idle"],
45 ] as const)(46 ] as const)(
apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts+7-2
...@@ -36,11 +36,16 @@ export function presentSavedCloudEnvironmentConnection(...@@ -36,11 +36,16 @@ export function presentSavedCloudEnvironmentConnection(
36 statusText: connectionStatusText(connection),36 statusText: connectionStatusText(connection),
37 tone: "connecting",37 tone: "connecting",
38 };38 };
39 // Not a failure: the machine is fine, this build just cannot talk to it.
39 case "unsupported":40 case "unsupported":
41 return {
42 buttonLabel: "Client not supported",
43 statusText: connectionStatusText(connection),
44 tone: "idle",
45 };
40 case "error":46 case "error":
41 return {47 return {
42 buttonLabel:48 buttonLabel: "Connection failed",
43 connection.phase === "unsupported" ? "Client not supported" : "Connection failed",
44 statusText: connectionStatusText(connection),49 statusText: connectionStatusText(connection),
45 tone: "error",50 tone: "error",
46 };51 };
apps/web/src/components/settings/ConnectionsSettings.tsx+20-3
...@@ -161,6 +161,7 @@ import {...@@ -161,6 +161,7 @@ import {
161 type EnvironmentPresentation,161 type EnvironmentPresentation,
162 useEnvironments,162 useEnvironments,
163 usePrimaryEnvironment,163 usePrimaryEnvironment,
164 useRelayEnvironmentDiscovery,
164} from "~/state/environments";165} from "~/state/environments";
165import { requestConfirmDialog } from "~/confirmDialog";166import { requestConfirmDialog } from "~/confirmDialog";
166import { useAtomCommand } from "../../state/use-atom-command";167import { useAtomCommand } from "../../state/use-atom-command";
...@@ -1457,8 +1458,9 @@ function savedBackendStatus(environment: EnvironmentPresentation): {...@@ -1457,8 +1458,9 @@ function savedBackendStatus(environment: EnvironmentPresentation): {
1457 text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting",1458 text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting",
1458 tone: "error",1459 tone: "error",
1459 };1460 };
1461 // Not a failure: the machine is fine, this build just cannot talk to it.
1460 case "unsupported":1462 case "unsupported":
1461 return { text: "Client not supported", tone: "error" };1463 return { text: "Client not supported", tone: "muted" };
1462 case "error":1464 case "error":
1463 return {1465 return {
1464 text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed",1466 text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed",
...@@ -1519,6 +1521,17 @@ function SavedBackendListRow({...@@ -1519,6 +1521,17 @@ function SavedBackendListRow({
1519 serverUpdateState.status === "running" && serverUpdateState.stage === "resuming";1521 serverUpdateState.status === "running" && serverUpdateState.stage === "resuming";
1520 const status = savedBackendStatus(environment);1522 const status = savedBackendStatus(environment);
1521 const serverVersion = environment.serverConfig?.environment.serverVersion ?? null;1523 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 );
1522 const subtitleText = [1535 const subtitleText = [
1523 environmentTransportLabel(environment),1536 environmentTransportLabel(environment),
1524 resumingServerUpdate ? "Restarting" : status.text,1537 resumingServerUpdate ? "Restarting" : status.text,
...@@ -1537,7 +1550,7 @@ function SavedBackendListRow({...@@ -1537,7 +1550,7 @@ function SavedBackendListRow({
15371550
1538 return (1551 return (
1539 <EnvironmentRow1552 <EnvironmentRow
1540 kind={resolveEnvironmentMachineKind(environment.serverConfig)}1553 kind={machineKind}
1541 label={environment.label}1554 label={environment.label}
1542 dimmed={!enabled}1555 dimmed={!enabled}
1543 subtitle={1556 subtitle={
...@@ -1558,7 +1571,11 @@ function SavedBackendListRow({...@@ -1558,7 +1571,11 @@ function SavedBackendListRow({
1558 {subtitleText}1571 {subtitleText}
1559 </TooltipTrigger>1572 </TooltipTrigger>
1560 <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">1573 <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"}
1562 {versionMismatch1579 {versionMismatch
1563 ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}`1580 ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}`
1564 : ""}1581 : ""}
packages/contracts/src/server.test.ts+6
...@@ -207,6 +207,12 @@ describe("resolveEnvironmentMachineKind", () => {...@@ -207,6 +207,12 @@ describe("resolveEnvironmentMachineKind", () => {
207 ).toBe("mac-mini");207 ).toBe("mac-mini");
208 });208 });
209209
210 it("uses detection from a bare descriptor before connecting", () => {
211 expect(resolveEnvironmentMachineKind({ environment: descriptor({ machine: "laptop" }) })).toBe(
212 "laptop",
213 );
214 });
215
210 it("falls back to a server for older servers and before connect", () => {216 it("falls back to a server for older servers and before connect", () => {
211 expect(217 expect(
212 resolveEnvironmentMachineKind({218 resolveEnvironmentMachineKind({
packages/contracts/src/server.ts+10-5
...@@ -598,14 +598,19 @@ export type ServerConfig = typeof ServerConfig.Type;...@@ -598,14 +598,19 @@ export type ServerConfig = typeof ServerConfig.Type;
598598
599/**599/**
600 * The machine an environment should be drawn as: the user's pick, else what600 * 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 connected601 * the server detected, else a generic server. Settings only exist once
602 * yet, or an older server) resolves to the same generic so rows never602 * connected; a descriptor alone (relay discovery, before any connection)
603 * flicker between glyphs.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.
604 */606 */
605export function resolveEnvironmentMachineKind(607export 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,
607): EnvironmentMachineKind {612): EnvironmentMachineKind {
608 return config?.settings.environmentIcon ?? config?.environment.platform.machine ?? "server";613 return config?.settings?.environmentIcon ?? config?.environment.platform.machine ?? "server";
609}614}
610615
611const ServerUpsertKeybindingReplaceTarget = Schema.Struct({616const ServerUpsertKeybindingReplaceTarget = Schema.Struct({