diff --git a/apps/web/src/components/EnvironmentOsIcon.tsx b/apps/web/src/components/EnvironmentOsIcon.tsx new file mode 100644 index 00000000000..9a35a4baebd --- /dev/null +++ b/apps/web/src/components/EnvironmentOsIcon.tsx @@ -0,0 +1,42 @@ +import type { ExecutionEnvironmentPlatformOs } from "@t3tools/contracts"; + +import { cn } from "~/lib/utils"; +import type { Icon } from "./Icons"; +import { AppleIcon, LinuxIcon, WindowsIcon } from "./OsIcons"; + +/** Every OS the server can report, except "unknown", which has no glyph. */ +const OS_PRESENTATION = { + darwin: { label: "macOS", Icon: AppleIcon }, + linux: { label: "Linux", Icon: LinuxIcon }, + windows: { label: "Windows", Icon: WindowsIcon }, +} as const satisfies Record< + Exclude, + { readonly label: string; readonly Icon: Icon } +>; + +/** + * The platform glyph beside a remote environment's name. `os` is null when no + * descriptor is available (an environment that has never connected and is + * currently unreachable), in which case nothing renders โ€” an absent glyph + * reads better than a placeholder for an unknown platform. + */ +export function EnvironmentOsIcon({ + os, + className, +}: { + readonly os: ExecutionEnvironmentPlatformOs | null; + readonly className?: string; +}) { + if (os === null || os === "unknown") { + return null; + } + const { label, Icon } = OS_PRESENTATION[os]; + + return ( + + ); +} diff --git a/apps/web/src/components/OsIcons.tsx b/apps/web/src/components/OsIcons.tsx new file mode 100644 index 00000000000..a35834869a9 --- /dev/null +++ b/apps/web/src/components/OsIcons.tsx @@ -0,0 +1,57 @@ +import type { Icon } from "./Icons"; + +/** + * Monochrome OS logos for the remote-environment list. Every mark is drawn in + * `currentColor` so it inherits the row's text colour and works in both themes. + * + * Apple (svgl) ships fill-less, which would render black on a dark background, + * so the fill is pinned to currentColor and a `stroke-foreground/70` rim separates + * the silhouette from the surface behind it โ€” that token stays higher-contrast + * than the muted fill in both themes, so the rim reads light on dark and dark + * on light rather than disappearing. Windows (svgl) deliberately has no + * rim: its four panes are split by thin gutters that a stroke closes up at the + * 14px size these render at, turning the glyph into a solid block. + * + * Tux is Simple Icons' single-path mark rather than svgl's, whose full-colour + * artwork defines the eyes and beak purely through colour contrast and so + * collapses into an unreadable blob when flattened to one fill. The body is + * filled with currentColor and the face is drawn over it in a fixed dark tone. + * That tone is deliberately not the surface colour: these rows also render on + * the raised popover of the T3 Connect onboarding dialog, and painting the face + * with `background` there would leave a mismatched patch instead of a cutout. + * It only has to stay darker than the muted body, which holds in both themes. + */ + +export const AppleIcon: Icon = (props) => ( + + + +); + +export const WindowsIcon: Icon = (props) => ( + + + +); + +export const LinuxIcon: Icon = (props) => ( + + + + +); diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx new file mode 100644 index 00000000000..eff803233d5 --- /dev/null +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx @@ -0,0 +1,85 @@ +import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; +import { type Discovery } from "@t3tools/client-runtime/relay"; +import { + EnvironmentId, + type ExecutionEnvironmentDescriptor, + type ServerConfig, +} from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import * as Option from "effect/Option"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + discovery: null as Discovery.RelayEnvironmentDiscoveryState | null, +})); + +vi.mock("~/connection/catalog", () => ({ + environmentCatalog: { register: Symbol("register") }, +})); + +vi.mock("~/state/relay", () => ({ + relayEnvironmentDiscovery: { refresh: Symbol("refresh") }, +})); + +vi.mock("~/state/environments", () => ({ + useRelayEnvironmentDiscovery: () => testState.discovery, +})); + +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: () => vi.fn(), +})); + +import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; + +const environmentId = EnvironmentId.make("saved-windows-environment"); +const descriptor = { + platform: { os: "windows", arch: "x64" }, +} as ExecutionEnvironmentDescriptor; +const serverConfig = { environment: descriptor } as ServerConfig; +const connection: EnvironmentConnectionPresentation = { + phase: "offline", + error: null, + traceId: null, +}; +const relayEnvironment: RelayClientEnvironmentRecord = { + environmentId, + label: "Saved Windows environment", + endpoint: { + httpBaseUrl: "https://saved-windows.example.test", + wsBaseUrl: "wss://saved-windows.example.test", + providerKind: "t3_relay", + }, + linkedAt: "2026-08-08T12:00:00.000Z", +}; + +describe("CloudEnvironmentConnectRows", () => { + it("keeps the cached OS glyph when a saved environment's relay is offline", () => { + testState.discovery = { + environments: new Map([ + [ + environmentId, + { + environment: relayEnvironment, + availability: "offline", + status: Option.none(), + error: Option.none(), + }, + ], + ]), + refreshing: false, + offline: false, + error: Option.none(), + }; + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-label="Windows"'); + }); +}); diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 460a253812a..82d60f0ab7c 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -8,7 +8,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import * as Option from "effect/Option"; import { type ReactNode, useCallback, useEffect, useState } from "react"; @@ -19,6 +19,8 @@ import { relayEnvironmentDiscovery } from "~/state/relay"; import { useRelayEnvironmentDiscovery } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { EnvironmentOsIcon } from "../EnvironmentOsIcon"; +import { resolveEnvironmentOs } from "../settings/ConnectionsSettings.logic"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRows"; import { Button } from "../ui/button"; import { Skeleton } from "../ui/skeleton"; @@ -28,6 +30,7 @@ import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnec export interface SavedCloudEnvironmentConnection { readonly environmentId: EnvironmentId; readonly connection: EnvironmentConnectionPresentation; + readonly serverConfig: ServerConfig | null; } export function RemoteEnvironmentRowsSkeleton() { @@ -171,7 +174,7 @@ export function CloudEnvironmentConnectRows({ return empty; } - return visibleEnvironments.map(({ environment, availability, error }) => { + return visibleEnvironments.map(({ environment, availability, status, error }) => { const savedEnvironment = savedById.get(environment.environmentId); const savedConnection = savedEnvironment ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection) @@ -226,6 +229,12 @@ export function CloudEnvironmentConnectRows({ } />

{environment.label}

+

{ expect(selectQrEndpointOption([], "anything", "anything")).toBeNull(); }); }); + +describe("resolveEnvironmentOs", () => { + const descriptorWith = (os: ExecutionEnvironmentPlatformOs) => + ({ platform: { os, arch: "arm64" } }) as ExecutionEnvironmentDescriptor; + const serverConfigWith = (os: ExecutionEnvironmentPlatformOs) => + ({ environment: descriptorWith(os) }) as ServerConfig; + + it("prefers the cached server config so a disconnected environment keeps its glyph", () => { + expect( + resolveEnvironmentOs({ + serverConfig: serverConfigWith("darwin"), + discoveredDescriptor: descriptorWith("linux"), + }), + ).toBe("darwin"); + }); + + it("falls back to relay discovery for an environment this client never connected to", () => { + expect( + resolveEnvironmentOs({ serverConfig: null, discoveredDescriptor: descriptorWith("windows") }), + ).toBe("windows"); + }); + + it("returns null when neither source knows the platform", () => { + expect(resolveEnvironmentOs({})).toBeNull(); + expect(resolveEnvironmentOs({ serverConfig: null, discoveredDescriptor: null })).toBeNull(); + }); + + it("treats a reported 'unknown' as no glyph rather than a placeholder", () => { + expect(resolveEnvironmentOs({ serverConfig: serverConfigWith("unknown") })).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index faa0cb6c754..1dd57a03b69 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -1,7 +1,36 @@ -import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tools/contracts"; +import type { + AdvertisedEndpoint, + DesktopBridge, + DesktopWslState, + ExecutionEnvironmentDescriptor, + ExecutionEnvironmentPlatformOs, + ServerConfig, +} from "@t3tools/contracts"; type WslEnableBridge = Pick; +/** + * Which OS a remote environment runs on, for the platform glyph in the + * environment list. The server config is authoritative and survives + * disconnects (it is cached per environment), so a previously-connected + * environment keeps its glyph while offline. Relay discovery only reports a + * descriptor while the environment is reachable, so it is the fallback for + * environments this client has never connected to. + * + * Returns null when neither source knows the platform, or when the server + * reported "unknown" โ€” callers render no glyph rather than a placeholder. + */ +export function resolveEnvironmentOs(input: { + readonly serverConfig?: ServerConfig | null; + readonly discoveredDescriptor?: ExecutionEnvironmentDescriptor | null; +}): ExecutionEnvironmentPlatformOs | null { + const os = + input.serverConfig?.environment.platform.os ?? + input.discoveredDescriptor?.platform.os ?? + "unknown"; + return os === "unknown" ? null : os; +} + /** * A QR code encoding a loopback URL makes the scanning device dial itself, so * loopback endpoints stay copyable from the endpoint menu but are never diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 300c71a338f..e3500b44921 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -43,6 +43,7 @@ import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls import { applyWslEnableSelection, isQrShareableEndpoint, + resolveEnvironmentOs, selectQrEndpointOption, } from "./ConnectionsSettings.logic"; import { @@ -127,6 +128,7 @@ import { import { useAtomCommand } from "../../state/use-atom-command"; import { serverEnvironment } from "~/state/server"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { EnvironmentOsIcon } from "../EnvironmentOsIcon"; import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; @@ -1419,6 +1421,9 @@ function SavedBackendListRow({ } />

{environment.label}

+ {metadataBits.length > 0 ? (

{metadataBits.join(" ยท ")}