diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
index 52b895301279..220a1260db49 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,14 @@ function SavedBackendListRow({
) : null}
+
{isWslEnvironment ? (
settings.environmentDisplayNames);
+ const updateClientSettings = useUpdateClientSettings();
const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false });
const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, {
reportFailure: false,
@@ -1796,6 +1807,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 +2135,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 +2242,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 +3434,7 @@ export function ConnectionsSettings() {
environment={environment}
removingEnvironmentId={removingSavedEnvironmentId}
onConnect={handleConnectSavedBackend}
+ onRename={handleStartRenameEnvironment}
onRemove={handleRemoveSavedBackend}
/>
))}
@@ -3393,6 +3443,52 @@ export function ConnectionsSettings() {
savedEnvironments={savedEnvironments}
/>
+
);
}
diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts
index fc0563b4ac69..c23bcaf67eae 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 443e99b84cdc..12dfc06009e9 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/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts
index e13638a2b41f..01c394e14600 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 168443deceb4..159873c78549 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;
}
}
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 1e2f6b95c743..89d8e29055c7 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 4c6306514f6b..ab5f2e3c618b 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(