From a2a85a171034126a0d2386c2bd69b3d3e80d142a Mon Sep 17 00:00:00 2001 From: Advait Johari Date: Sat, 8 Aug 2026 21:53:31 -0500 Subject: [PATCH 1/4] feat(web): remote environments show which OS they run on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an OS glyph beside each remote environment's name in Settings → Connections, using svgl logos. The platform is already carried on the environment descriptor, so no server or relay change is needed. Saved rows read the cached server config (so a disconnected environment keeps its glyph); T3 Connect rows fall back to the relay discovery descriptor. An environment that has never connected and is currently unreachable has no descriptor in either source and renders no glyph. --- apps/web/src/components/EnvironmentOsIcon.tsx | 42 +++ apps/web/src/components/OsIcons.tsx | 276 ++++++++++++++++++ .../cloud/CloudEnvironmentConnectList.tsx | 9 +- .../ConnectionsSettings.logic.test.ts | 40 ++- .../settings/ConnectionsSettings.logic.ts | 31 +- .../settings/ConnectionsSettings.tsx | 5 + 6 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/EnvironmentOsIcon.tsx create mode 100644 apps/web/src/components/OsIcons.tsx 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..9371d00a039 --- /dev/null +++ b/apps/web/src/components/OsIcons.tsx @@ -0,0 +1,276 @@ +import { useId } from "react"; + +import type { Icon } from "./Icons"; + +/** + * OS logos from svgl (https://svgl.app), used to mark which platform a remote + * environment runs on. Apple ships fill-less (it would render black on a dark + * background), so it is pinned to currentColor; Windows and Tux keep their + * brand colours because Tux is only legible in colour. + */ + +export const AppleIcon: Icon = (props) => ( + + + +); + +export const WindowsIcon: Icon = (props) => ( + + + +); + +/** Tux carries 21 gradient/filter ids; useId keeps them unique per instance. */ +export const LinuxIcon: Icon = (props) => { + const id = useId().replaceAll(":", ""); + const ids = Array.from({ length: 21 }, (_, index) => `${id}-tux-${index}`); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 460a253812a..033d392a468 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -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"; @@ -171,7 +173,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 +228,11 @@ 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(" · ")}

From 52d301bd3430a21a9505c278784ea2bcc297abe1 Mon Sep 17 00:00:00 2001 From: Advait Johari Date: Sat, 8 Aug 2026 22:19:51 -0500 Subject: [PATCH 2/4] fix(web): keep os glyphs for offline environments Pass each saved environment's cached server config into OS resolution before falling back to relay discovery. Add a component regression test for an offline saved environment with a cached Windows config. --- .../CloudEnvironmentConnectList.test.tsx | 85 +++++++++++++++++++ .../cloud/CloudEnvironmentConnectList.tsx | 4 +- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx 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 033d392a468..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"; @@ -30,6 +30,7 @@ import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnec export interface SavedCloudEnvironmentConnection { readonly environmentId: EnvironmentId; readonly connection: EnvironmentConnectionPresentation; + readonly serverConfig: ServerConfig | null; } export function RemoteEnvironmentRowsSkeleton() { @@ -230,6 +231,7 @@ export function CloudEnvironmentConnectRows({

{environment.label}

From ad470843388a3ff422331e676ea5baea86990a45 Mon Sep 17 00:00:00 2001 From: Advait Johari Date: Sun, 9 Aug 2026 10:35:52 -0500 Subject: [PATCH 3/4] feat(web): draw the os glyphs monochrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The colour marks pulled focus in a list that is mostly muted text, and svgl's Tux shipped 21 gradient and filter definitions for a glyph that renders at 14px. Every mark now draws in currentColor so it inherits the row's text colour. Apple keeps a faint rim to separate its silhouette from the surface; Windows takes none, because a stroke closes up the gutters between its four panes at this size. Tux swaps to Simple Icons' single path, with the face drawn in a contrasting tone per theme — svgl's artwork defines the eyes and beak through colour alone and flattens into an unreadable blob. --- apps/web/src/components/OsIcons.tsx | 288 ++++------------------------ 1 file changed, 34 insertions(+), 254 deletions(-) diff --git a/apps/web/src/components/OsIcons.tsx b/apps/web/src/components/OsIcons.tsx index 9371d00a039..e4ff82f05d0 100644 --- a/apps/web/src/components/OsIcons.tsx +++ b/apps/web/src/components/OsIcons.tsx @@ -1,18 +1,33 @@ -import { useId } from "react"; - import type { Icon } from "./Icons"; /** - * OS logos from svgl (https://svgl.app), used to mark which platform a remote - * environment runs on. Apple ships fill-less (it would render black on a dark - * background), so it is pinned to currentColor; Windows and Tux keep their - * brand colours because Tux is only legible in colour. + * 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 detail path is drawn over it in a contrasting + * tone: on dark the surface colour punches the face back out, while on light a + * darker grey keeps it from reading as a white-outlined sticker. The two tones + * must differ — matching them to the body erases the face entirely. */ export const AppleIcon: Icon = (props) => ( @@ -21,256 +36,21 @@ export const AppleIcon: Icon = (props) => ( export const WindowsIcon: Icon = (props) => ( ); -/** Tux carries 21 gradient/filter ids; useId keeps them unique per instance. */ -export const LinuxIcon: Icon = (props) => { - const id = useId().replaceAll(":", ""); - const ids = Array.from({ length: 21 }, (_, index) => `${id}-tux-${index}`); - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; +export const LinuxIcon: Icon = (props) => ( + + + + +); From b6061db5d4b37df89b8c4538554a9f93f51519f4 Mon Sep 17 00:00:00 2001 From: Advait Johari Date: Sun, 9 Aug 2026 11:50:08 -0500 Subject: [PATCH 4/4] fix(web): keep the tux face readable on raised surfaces The face was painted with `background`, which only matches when the row sits on the page canvas. These rows also render on the popover of the T3 Connect onboarding dialog, where that paints a mismatched dark patch over the body rather than reading as a cutout. Draw the face in a fixed dark tone instead. It only has to stay darker than the muted body, which holds on every surface and in both themes. --- apps/web/src/components/OsIcons.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/OsIcons.tsx b/apps/web/src/components/OsIcons.tsx index e4ff82f05d0..a35834869a9 100644 --- a/apps/web/src/components/OsIcons.tsx +++ b/apps/web/src/components/OsIcons.tsx @@ -15,10 +15,11 @@ import type { Icon } from "./Icons"; * 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 detail path is drawn over it in a contrasting - * tone: on dark the surface colour punches the face back out, while on light a - * darker grey keeps it from reading as a white-outlined sticker. The two tones - * must differ — matching them to the body erases the face entirely. + * 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) => ( @@ -49,7 +50,7 @@ export const LinuxIcon: Icon = (props) => ( d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.132 1.884 1.071.771-.06 1.592-.536 2.257-1.306.631-.765 1.683-1.084 2.378-1.503.348-.199.629-.469.649-.853.023-.4-.2-.811-.714-1.376v-.097l-.003-.003c-.17-.2-.25-.535-.338-.926-.085-.401-.182-.786-.492-1.046h-.003c-.059-.054-.123-.067-.188-.135a.357.357 0 00-.19-.064c.431-1.278.264-2.55-.173-3.694-.533-1.41-1.465-2.638-2.175-3.483-.796-1.005-1.576-1.957-1.56-3.368.026-2.152.236-6.133-3.544-6.139z" />