From ef0674e630173b6079c8000bbedfceb7d22b6181 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sat, 25 Jul 2026 13:09:15 -0700 Subject: [PATCH 1/3] feat(web): allow custom environment names --- .../settings/ConnectionsSettings.tsx | 107 ++++++++++++++++-- apps/web/src/hooks/useSettings.ts | 4 +- apps/web/src/state/environments.ts | 17 ++- packages/contracts/src/settings.test.ts | 25 ++++ packages/contracts/src/settings.ts | 6 +- 5 files changed, 143 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 52b89530127..ac436702ec5 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -38,6 +38,7 @@ import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; @@ -1340,6 +1341,7 @@ type SavedBackendListRowProps = { environment: EnvironmentPresentation; removingEnvironmentId: EnvironmentId | null; onConnect: (environmentId: EnvironmentId) => void; + onRename: (environment: EnvironmentPresentation) => void; onRemove: (environmentId: EnvironmentId) => void; }; @@ -1347,6 +1349,7 @@ function SavedBackendListRow({ environment, removingEnvironmentId, onConnect, + onRename, onRemove, }: SavedBackendListRowProps) { const environmentId = environment.environmentId; @@ -1389,14 +1392,12 @@ function SavedBackendListRow({ [copyTraceIdToClipboard], ); const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); - const sshTarget = - environment.entry.target._tag === "SshConnectionTarget" && - Option.isSome(environment.entry.profile) && - environment.entry.profile.value._tag === "SshConnectionProfile" - ? environment.entry.profile.value.target - : null; const metadataBits = [ - sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, + environment.displayUrl + ? environment.entry.target._tag === "SshConnectionTarget" + ? `SSH ${environment.displayUrl}` + : environment.displayUrl + : null, environment.relayManaged ? "T3 Connect" : null, ].filter((value): value is string => value !== null); @@ -1456,6 +1457,9 @@ function SavedBackendListRow({ ) : null}
+ {isWslEnvironment ? ( settings.environmentDisplayNames); + const updateClientSettings = useUpdateClientSettings(); const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, { reportFailure: false, @@ -1796,6 +1802,9 @@ export function ConnectionsSettings() { const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] = useState(null); + const [renameEnvironmentTarget, setRenameEnvironmentTarget] = + useState(null); + const [renameEnvironmentValue, setRenameEnvironmentValue] = useState(""); const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); const [isDesktopServerExposureDialogOpen, setIsDesktopServerExposureDialogOpen] = useState(false); const [isUpdatingTailscaleServe, setIsUpdatingTailscaleServe] = useState(false); @@ -2121,7 +2130,7 @@ export function ConnectionsSettings() { return; } - const result = await connectSshEnvironment({ target, label: "" }); + const result = await connectSshEnvironment({ target, label: target.alias }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { setSavedBackendError(formatDesktopSshConnectionError(squashAtomCommandFailure(result))); @@ -2228,6 +2237,41 @@ export function ConnectionsSettings() { [retryEnvironment], ); + const handleStartRenameEnvironment = useCallback( + (environment: EnvironmentPresentation) => { + setRenameEnvironmentTarget(environment); + setRenameEnvironmentValue(environmentDisplayNames[environment.environmentId] ?? ""); + }, + [environmentDisplayNames], + ); + + const handleCloseRenameEnvironment = useCallback(() => { + setRenameEnvironmentTarget(null); + setRenameEnvironmentValue(""); + }, []); + + const handleSaveRenameEnvironment = useCallback(() => { + if (!renameEnvironmentTarget) { + return; + } + const environmentId = renameEnvironmentTarget.environmentId; + const displayName = renameEnvironmentValue.trim(); + const nextDisplayNames = { ...environmentDisplayNames }; + if (displayName === "") { + delete nextDisplayNames[environmentId]; + } else { + nextDisplayNames[environmentId] = displayName; + } + updateClientSettings({ environmentDisplayNames: nextDisplayNames }); + handleCloseRenameEnvironment(); + }, [ + environmentDisplayNames, + handleCloseRenameEnvironment, + renameEnvironmentTarget, + renameEnvironmentValue, + updateClientSettings, + ]); + const handleRemoveSavedBackend = useCallback( async (environmentId: EnvironmentId) => { setRemovingSavedEnvironmentId(environmentId); @@ -3385,6 +3429,7 @@ export function ConnectionsSettings() { environment={environment} removingEnvironmentId={removingSavedEnvironmentId} onConnect={handleConnectSavedBackend} + onRename={handleStartRenameEnvironment} onRemove={handleRemoveSavedBackend} /> ))} @@ -3393,6 +3438,52 @@ export function ConnectionsSettings() { savedEnvironments={savedEnvironments} /> + { + if (!open) { + handleCloseRenameEnvironment(); + } + }} + > + + + Rename environment + + Set an optional name for this client. Leave it empty to use the environment’s default + name. + + + + + {renameEnvironmentTarget?.displayUrl ? ( +

+ {renameEnvironmentTarget.displayUrl} +

+ ) : null} +
+ + + + +
+
); } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index fc0563b4ac6..c23bcaf67ea 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -27,7 +27,7 @@ import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { ensureLocalApi } from "~/localApi"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; -import { usePrimaryEnvironment } from "~/state/environments"; +import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment"; import { useAtomCommand } from "~/state/use-atom-command"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -278,7 +278,7 @@ export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { } export function useUpdatePrimarySettings() { - return useUpdateSettingsTarget(usePrimaryEnvironment()?.environmentId ?? null); + return useUpdateSettingsTarget(useAtomValue(primaryEnvironmentIdAtom)); } export function useUpdateClientSettings() { diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 443e99b84cd..12dfc06009e 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -9,6 +9,7 @@ import * as Option from "effect/Option"; import { useMemo } from "react"; import { environmentCatalog } from "../connection/catalog"; +import { useClientSettings } from "../hooks/useSettings"; import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; import { useEnvironmentQuery } from "./query"; @@ -18,6 +19,7 @@ import { usePreparedConnection } from "./session"; export interface EnvironmentPresentation extends BaseEnvironmentPresentation { readonly environmentId: EnvironmentId; readonly label: string; + readonly defaultLabel: string; readonly displayUrl: string | null; readonly relayManaged: boolean; } @@ -25,11 +27,14 @@ export interface EnvironmentPresentation extends BaseEnvironmentPresentation { function projectEnvironmentPresentation( environmentId: EnvironmentId, presentation: BaseEnvironmentPresentation, + environmentDisplayNames: Readonly>, ): EnvironmentPresentation { + const defaultLabel = presentation.entry.target.label; return { ...presentation, environmentId, - label: presentation.entry.target.label, + label: environmentDisplayNames[environmentId] ?? defaultLabel, + defaultLabel, displayUrl: connectionCatalogDisplayUrl(presentation.entry), relayManaged: presentation.entry.target._tag === "RelayConnectionTarget", }; @@ -39,13 +44,14 @@ export function useEnvironments() { const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom); const presentationById = useAtomValue(environmentPresentations.presentationsAtom); + const environmentDisplayNames = useClientSettings((settings) => settings.environmentDisplayNames); const environments = useMemo( () => [...presentationById.entries()].map(([environmentId, presentation]) => - projectEnvironmentPresentation(environmentId, presentation), + projectEnvironmentPresentation(environmentId, presentation, environmentDisplayNames), ), - [presentationById], + [environmentDisplayNames, presentationById], ); return { @@ -64,12 +70,13 @@ export function useEnvironment( environmentId: EnvironmentId | null, ): EnvironmentPresentation | null { const { presentation } = useEnvironmentPresentation(environmentId); + const environmentDisplayNames = useClientSettings((settings) => settings.environmentDisplayNames); return useMemo( () => environmentId === null || presentation === null ? null - : projectEnvironmentPresentation(environmentId, presentation), - [environmentId, presentation], + : projectEnvironmentPresentation(environmentId, presentation, environmentDisplayNames), + [environmentDisplayNames, environmentId, presentation], ); } diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1e2f6b95c74..89d8e29055c 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; +import { EnvironmentId } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, @@ -33,6 +34,30 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings environment display names", () => { + const environmentId = EnvironmentId.make("environment-1"); + + it("defaults to no client-local overrides", () => { + expect(decodeClientSettings({}).environmentDisplayNames).toEqual({}); + }); + + it("trims saved overrides", () => { + expect( + decodeClientSettings({ + environmentDisplayNames: { [environmentId]: " Workstation " }, + }).environmentDisplayNames, + ).toEqual({ [environmentId]: "Workstation" }); + }); + + it("rejects empty overrides", () => { + expect(() => + decodeClientSettingsPatch({ + environmentDisplayNames: { [environmentId]: " " }, + }), + ).toThrow(); + }); +}); + describe("ClientSettings glass opacity", () => { it("defaults to a readable translucent surface", () => { expect(decodeClientSettings({}).glassOpacity).toBe(80); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4c6306514f6..ab5f2e3c618 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { EnvironmentId, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { DEFAULT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; @@ -67,6 +67,9 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + environmentDisplayNames: Schema.Record(EnvironmentId, TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), glassOpacity: GlassOpacity.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), ), @@ -605,6 +608,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), + environmentDisplayNames: Schema.optionalKey(Schema.Record(EnvironmentId, TrimmedNonEmptyString)), glassOpacity: Schema.optionalKey(GlassOpacity), favorites: Schema.optionalKey( Schema.Array( From 183538557ae0ec1a22711fcdb953912539073999 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 27 Jul 2026 18:12:08 -0700 Subject: [PATCH 2/3] fix(client-runtime): format SSH display addresses --- .../src/connection/presentation.test.ts | 33 ++++++++++++++++++- .../src/connection/presentation.ts | 9 +++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index e13638a2b41..01c394e1460 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -2,10 +2,15 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Option from "effect/Option"; -import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; +import { + BearerConnectionProfile, + type ConnectionCatalogEntry, + SshConnectionProfile, +} from "./catalog.ts"; import { BearerConnectionTarget, ConnectionTransientError, + SshConnectionTarget, type SupervisorConnectionState, } from "./model.ts"; import { @@ -55,6 +60,32 @@ describe("connection presentation", () => { expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); }); + it("formats SSH display information without a missing username and preserves the port", () => { + const target = new SshConnectionTarget({ + environmentId: EnvironmentId.make("environment-ssh"), + label: "SSH environment", + connectionId: "connection-ssh", + }); + const entry: ConnectionCatalogEntry = { + target, + profile: Option.some( + new SshConnectionProfile({ + connectionId: target.connectionId, + environmentId: target.environmentId, + label: target.label, + target: { + alias: "devbox", + hostname: "devbox.example.test", + username: null, + port: 2222, + }, + }), + ), + }; + + expect(connectionCatalogDisplayUrl(entry)).toBe("devbox.example.test:2222"); + }); + it("distinguishes initial connection, reconnect, and retry errors", () => { expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({ phase: "connecting", diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index 168443deceb..159873c7854 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -100,9 +100,12 @@ export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): stri ? entry.profile.value.httpBaseUrl : null; case "SshConnectionTarget": - return Option.isSome(entry.profile) && entry.profile.value._tag === "SshConnectionProfile" - ? `${entry.profile.value.target.username}@${entry.profile.value.target.hostname}` - : null; + if (Option.isNone(entry.profile) || entry.profile.value._tag !== "SshConnectionProfile") { + return null; + } + const { hostname, port, username } = entry.profile.value.target; + const authority = username ? `${username}@${hostname}` : hostname; + return port ? `${authority}:${port}` : authority; } } From 5e10a60709587d23937448ac9f0e69a8770197e1 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 27 Jul 2026 19:16:15 -0700 Subject: [PATCH 3/3] fix(web): disable environment rename while removing --- apps/web/src/components/settings/ConnectionsSettings.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index ac436702ec5..220a1260db4 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1457,7 +1457,12 @@ function SavedBackendListRow({ ) : null}
- {isWslEnvironment ? (