From edaec2ea2910c536b4c0aa182e10019f2037deec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Jos=C3=A9zinho?= Date: Thu, 5 Nov 2026 01:38:45 +0000 Subject: [PATCH 1/5] feat(web): rename saved environments from settings Web and desktop could not change a saved environment's label after adding it, although the iOS app can. Add a Rename item to the environment menu, backed by a new EnvironmentRegistry.setLabel. It relabels every route rather than reusing updateBearer, which edits only the first bearer route: the app displays the first route's label, so a rename could fail or never show on an environment with learned routes. Credentials, addresses and pull request sharing permissions are unchanged. As on iOS, the item is hidden for any environment with a T3 Connect route. --- .../settings/RenameEnvironmentDialog.tsx | 94 +++++++++++++++++++ .../settings/SavedEnvironmentRows.tsx | 15 +++ .../src/connection/registry.test.ts | 48 ++++++++++ .../client-runtime/src/connection/registry.ts | 39 ++++++++ .../client-runtime/src/connection/routes.ts | 33 ++++++- .../client-runtime/src/state/connections.ts | 10 ++ .../src/state/threads-atoms.test.ts | 1 + 7 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/settings/RenameEnvironmentDialog.tsx diff --git a/apps/web/src/components/settings/RenameEnvironmentDialog.tsx b/apps/web/src/components/settings/RenameEnvironmentDialog.tsx new file mode 100644 index 0000000000000..a1082301cac95 --- /dev/null +++ b/apps/web/src/components/settings/RenameEnvironmentDialog.tsx @@ -0,0 +1,94 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useState } from "react"; + +import { environmentCatalog } from "~/connection/catalog"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogClose, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; + +// The popup unmounts when closed, so each open starts from the current label. +function RenameEnvironmentForm({ + environmentId, + label: currentLabel, + onDone, +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly onDone: () => void; +}) { + const [label, setLabel] = useState(currentLabel); + const setEnvironmentLabel = useAtomCommand(environmentCatalog.setLabel, "Rename environment"); + const canSave = label.trim() !== ""; + + const save = async () => { + if (!canSave) return; + const result = await setEnvironmentLabel({ environmentId, label }); + if (result._tag === "Success") onDone(); + }; + + return ( + <> + + Rename environment + + +
{ + event.preventDefault(); + void save(); + }} + > + + setLabel(event.target.value)} + autoFocus + /> +
+
+ + }>Cancel + + + + ); +} + +export function RenameEnvironmentDialog({ + open, + onOpenChange, + environmentId, + label, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly environmentId: EnvironmentId; + readonly label: string; +}) { + return ( + + + onOpenChange(false)} + /> + + + ); +} diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index 53b745412d983..8029d8e988486 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -3,6 +3,7 @@ import { ChevronRightIcon, ChevronsLeftRightEllipsisIcon, EllipsisIcon, + PencilIcon, RouteIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; @@ -29,6 +30,7 @@ import { SettingsRow } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; import { EnvironmentRoutesList } from "./EnvironmentRoutesList"; +import { RenameEnvironmentDialog } from "./RenameEnvironmentDialog"; import { ConnectionDiagnosisPanel, ConnectionDiagnosisSummary, @@ -117,6 +119,7 @@ export function SavedBackendListRow({ const [routesOpen, setRoutesOpen] = useState(false); const [permissionsOpen, setPermissionsOpen] = useState(false); const [diagnosisOpen, setDiagnosisOpen] = useState(false); + const [renameOpen, setRenameOpen] = useState(false); const environmentId = environment.environmentId; const unsupported = environment.connection.phase === "unsupported"; const blocked = unsupported && !hasSavedBearerRoute(environment.entry); @@ -413,6 +416,12 @@ export function SavedBackendListRow({ environmentId={environmentId} serverConfig={environment.serverConfig} /> + {environment.relayManaged ? null : ( + setRenameOpen(true)}> + + Rename… + + )} setRoutesOpen((open) => !open)}> {routesOpen ? "Hide routes" : "Routes"} @@ -429,6 +438,12 @@ export function SavedBackendListRow({ + ); } diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index f6c305ab6bb90..28609ba4aa746 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -2196,6 +2196,51 @@ describe("EnvironmentRegistry routes", () => { }), ); + it.effect("renames every route and keeps credentials and learned state", () => + Effect.gen(function* () { + const environmentId = LAN_TARGET.environmentId; + const learnedId = `learned:${environmentId}:http://100.64.0.9:3773@${LAN_TARGET.connectionId}`; + const harness = yield* makeHarness( + [ + new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId }), + LAN_TARGET, + RELAY_TARGET, + ], + [ + new BearerConnectionProfile({ ...LAN_PROFILE, connectionId: learnedId, learned: true }), + LAN_PROFILE, + ], + [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]], + ); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.setLabel(environmentId, " Studio "); + + expect( + routesOf(yield* Ref.get(harness.storedTargets), environmentId).map((target) => [ + target._tag, + target.label, + ]), + ).toEqual([ + ["BearerConnectionTarget", "Studio"], + ["BearerConnectionTarget", "Studio"], + ["RelayConnectionTarget", "Studio"], + ]); + const profiles = yield* Ref.get(harness.storedProfiles); + expect(profiles.get(learnedId)).toMatchObject({ label: "Studio", learned: true }); + expect(profiles.get(LAN_TARGET.connectionId)?.label).toBe("Studio"); + expect((yield* Ref.get(harness.storedCredentials)).get(LAN_TARGET.connectionId)).toEqual( + BEARER_CREDENTIAL, + ); + const entry = (yield* SubscriptionRef.get(registry.entries)).get(environmentId); + expect(entry?.target.label).toBe("Studio"); + + const error = yield* registry.setLabel(environmentId, " ").pipe(Effect.flip); + expect(error._tag).toBe("ConnectionBlockedError"); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("removing the last route forgets the environment", () => Effect.gen(function* () { const harness = yield* makeHarness( @@ -2246,6 +2291,9 @@ describe("EnvironmentRegistry routes", () => { LAN_TARGET.connectionId, ]); expect(yield* permissions.get(yield* current())).toBe("read"); + + yield* registry.setLabel(LAN_TARGET.environmentId, "Studio"); + expect(yield* permissions.get(yield* current())).toBe("read"); }).pipe( Effect.provide(harness.layer), Effect.provideService(PullRequestRoutingPermissions, permissions), diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 12f8aa5ec505f..326ecec5a3eb7 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -51,6 +51,7 @@ import { findRouteToSameAddress, hasSavedBearerRoute, mergeLearnedRoutes, + routeWithLabel, routesAfterRemoving, upsertRoute, type ReportedEndpoint, @@ -151,6 +152,16 @@ export class EnvironmentRegistry extends Context.Service< | PlatformEnvironmentRemovalError | ConnectionBlockedError >; + readonly setLabel: ( + environmentId: EnvironmentId, + label: string, + ) => Effect.Effect< + void, + | Persistence.ConnectionPersistenceError + | ConnectionAttemptError + | EnvironmentNotRegisteredError + | PlatformEnvironmentRemovalError + >; readonly removeRelayEnvironments: () => Effect.Effect< void, | Persistence.ConnectionPersistenceError @@ -1025,6 +1036,33 @@ export const make = Effect.gen(function* () { ); }); + const setLabel = Effect.fn("EnvironmentRegistry.setLabel")(function* ( + environmentId: EnvironmentId, + label: string, + ) { + const trimmed = label.trim(); + if (trimmed === "") { + return yield* new ConnectionBlockedError({ + reason: "configuration", + detail: "Environment label cannot be empty.", + }); + } + yield* withLeaseLock( + environmentId, + Effect.gen(function* () { + const entry = yield* userEntry(environmentId); + const routes = connectionRoutes(entry); + if (routes.every((route) => route.target.label === trimmed)) return; + // Every route carries the label: the first one is displayed, and reordering can change it. + const renamed = routes.map((route) => routeWithLabel(route, trimmed)); + for (const route of renamed) { + if (Option.isSome(route.profile)) yield* profiles.put(route.profile.value); + } + yield* replaceRoutesLocked(entry, renamed); + }), + ); + }); + const removeRelayEnvironments = Effect.fn("EnvironmentRegistry.removeRelayEnvironments")( function* () { const relayEnvironmentIds = [...(yield* SubscriptionRef.get(entries)).values()] @@ -1193,6 +1231,7 @@ export const make = Effect.gen(function* () { remove, removeRoute, reorderRoutes, + setLabel, removeRelayEnvironments, retryNow, setEnabled, diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts index 9d83550a8c4a2..00bd6a4e48989 100644 --- a/packages/client-runtime/src/connection/routes.ts +++ b/packages/client-runtime/src/connection/routes.ts @@ -11,8 +11,15 @@ import { BearerConnectionProfile, type ConnectionCatalogEntry, type ConnectionRoute, + SshConnectionProfile, } from "./catalog.ts"; -import { BearerConnectionTarget, type ConnectionTarget } from "./model.ts"; +import { + BearerConnectionTarget, + type ConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +} from "./model.ts"; export type ConnectionRouteKind = | "relay" @@ -65,6 +72,30 @@ export function routeEntry( return entryWithRoutes(entry, [route]); } +function targetWithLabel(target: ConnectionTarget, label: string): ConnectionTarget { + switch (target._tag) { + case "PrimaryConnectionTarget": + return new PrimaryConnectionTarget({ ...target, label }); + case "BearerConnectionTarget": + return new BearerConnectionTarget({ ...target, label }); + case "RelayConnectionTarget": + return new RelayConnectionTarget({ ...target, label }); + case "SshConnectionTarget": + return new SshConnectionTarget({ ...target, label }); + } +} + +export function routeWithLabel(route: ConnectionRoute, label: string): ConnectionRoute { + return { + target: targetWithLabel(route.target, label), + profile: Option.map(route.profile, (profile) => + profile._tag === "SshConnectionProfile" + ? new SshConnectionProfile({ ...profile, label }) + : new BearerConnectionProfile({ ...profile, label }), + ), + }; +} + export function routeHttpBaseUrl(route: ConnectionRoute): string | null { if (route.target._tag === "PrimaryConnectionTarget") return route.target.httpBaseUrl; const profile = Option.getOrNull(route.profile); diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts index 206f75a600416..40123896b62c8 100644 --- a/packages/client-runtime/src/state/connections.ts +++ b/packages/client-runtime/src/state/connections.ts @@ -182,6 +182,15 @@ export function createEnvironmentCatalogAtoms( Effect.flatMap((registry) => registry.reorderRoutes(input.environmentId, input.routeIds)), ), }); + const setLabel = createRuntimeCommand(runtime, { + label: "environment-catalog:set-label", + scheduler: commandScheduler, + concurrency: serial, + execute: (input: { readonly environmentId: EnvironmentIdType; readonly label: string }) => + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.setLabel(input.environmentId, input.label)), + ), + }); const retryNow = createRuntimeCommand(runtime, { label: "environment-catalog:retry-now", scheduler: commandScheduler, @@ -208,5 +217,6 @@ export function createEnvironmentCatalogAtoms( removeRelayEnvironments, retryNow, setEnabled, + setLabel, }; } diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index af2ab6d0575a6..4bc3bbbda986c 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -133,6 +133,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? remove: () => Effect.die("Unexpected environment removal"), removeRoute: () => Effect.die("Unexpected route removal"), reorderRoutes: () => Effect.die("Unexpected route reorder"), + setLabel: () => Effect.die("Unexpected label update"), removeRelayEnvironments: () => Effect.die("Unexpected environment removal"), retryNow: () => Effect.void, setEnabled: () => Effect.die("Unexpected environment toggle"), From 1d065e9a28af6cb0a895a2dd658e3e43ba021676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Jos=C3=A9zinho?= Date: Thu, 5 Nov 2026 02:53:08 +0000 Subject: [PATCH 2/5] fix(client-runtime): renaming an environment keeps its connection Renaming went through replaceRoutesLocked, which closes the supervisor and creates a new one, so a label change dropped a healthy connection. Update the saved routes, lease and entry in place, as learnRoutes and setEnabled do. A label changes no address, so the session can stay. The running supervisor keeps the old label for its own messages until it is next recreated. --- .../client-runtime/src/connection/registry.test.ts | 8 ++++++++ packages/client-runtime/src/connection/registry.ts | 14 +++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 28609ba4aa746..c77476f58878d 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -2214,8 +2214,16 @@ describe("EnvironmentRegistry routes", () => { ); yield* Effect.gen(function* () { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* awaitConnectionState( + registry, + environmentId, + (state) => state.phase === "connected", + ); yield* registry.setLabel(environmentId, " Studio "); + expect((yield* registry.state(environmentId)).phase).toBe("connected"); + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + expect(yield* Ref.get(harness.releasedSessions)).toBe(0); expect( routesOf(yield* Ref.get(harness.storedTargets), environmentId).map((target) => [ target._tag, diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 326ecec5a3eb7..c8cdd8569070a 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -1058,7 +1058,19 @@ export const make = Effect.gen(function* () { for (const route of renamed) { if (Option.isSome(route.profile)) yield* profiles.put(route.profile.value); } - yield* replaceRoutesLocked(entry, renamed); + const next = entryWithRoutes(entry, renamed); + yield* registrations.setRoutes(environmentId, persistedRoutes(next)); + // A label changes no address, so the supervisor keeps its session. It reads the old label + // for its own messages until it is next recreated. + const lease = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); + if (lease !== undefined) { + yield* SubscriptionRef.update(serviceScopes, (current) => + new Map(current).set(environmentId, { ...lease, entry: next }), + ); + } + yield* SubscriptionRef.update(entries, (current) => + new Map(current).set(environmentId, next), + ); }), ); }); From ed77b47bc7c7fd4c5e1b4763bf8c113ad90ee779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Jos=C3=A9zinho?= Date: Thu, 5 Nov 2026 03:13:28 +0000 Subject: [PATCH 3/5] fix(client-runtime): reconnects after a rename keep the new label The registry reused the supervisor on rename but left its retained entry on the old labels. A later reconnect passed that entry to the resolver, and the SSH broker saves the profile it is handed, so the old label was written back. Relabel the retained entry when the environment is renamed, keeping its routes. Only the registry needs this, so it comes from make rather than the service that sessions and test stubs implement. --- .../src/connection/registry.test.ts | 26 +++++++++++++ .../client-runtime/src/connection/registry.ts | 7 ++-- .../src/connection/supervisor.ts | 37 ++++++++++++++----- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index c77476f58878d..298633ec20d8a 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -2200,6 +2200,7 @@ describe("EnvironmentRegistry routes", () => { Effect.gen(function* () { const environmentId = LAN_TARGET.environmentId; const learnedId = `learned:${environmentId}:http://100.64.0.9:3773@${LAN_TARGET.connectionId}`; + const connectedProfileLabels: Array = []; const harness = yield* makeHarness( [ new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId }), @@ -2211,6 +2212,13 @@ describe("EnvironmentRegistry routes", () => { LAN_PROFILE, ], [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]], + { + checkRoute: (route) => { + if (Option.isSome(route.profile)) + connectedProfileLabels.push(route.profile.value.label); + return "unchecked"; + }, + }, ); yield* Effect.gen(function* () { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; @@ -2243,6 +2251,24 @@ describe("EnvironmentRegistry routes", () => { const entry = (yield* SubscriptionRef.get(registry.entries)).get(environmentId); expect(entry?.target.label).toBe("Studio"); + // A retained supervisor must reconnect with the new label: the SSH broker saves the + // profile it is handed, so a stale one would write the old label back. + connectedProfileLabels.length = 0; + yield* registry.setEnabled(environmentId, false); + yield* awaitConnectionState( + registry, + environmentId, + (state) => state.phase !== "connected", + ); + yield* registry.setEnabled(environmentId, true); + yield* awaitConnectionState( + registry, + environmentId, + (state) => state.phase === "connected", + ); + expect(connectedProfileLabels).not.toHaveLength(0); + expect(new Set(connectedProfileLabels)).toEqual(new Set(["Studio"])); + const error = yield* registry.setLabel(environmentId, " ").pipe(Effect.flip); expect(error._tag).toBe("ConnectionBlockedError"); }).pipe(Effect.provide(harness.layer), Effect.scoped); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index c8cdd8569070a..71a580b0891a8 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -213,7 +213,7 @@ export class EnvironmentRegistry extends Context.Service< interface EnvironmentServiceScope { readonly entry: ConnectionCatalogEntry; - readonly supervisor: EnvironmentSupervisor.EnvironmentSupervisor["Service"]; + readonly supervisor: EnvironmentSupervisor.ManagedEnvironmentSupervisor; readonly scope: Scope.Closeable; } @@ -1060,10 +1060,11 @@ export const make = Effect.gen(function* () { } const next = entryWithRoutes(entry, renamed); yield* registrations.setRoutes(environmentId, persistedRoutes(next)); - // A label changes no address, so the supervisor keeps its session. It reads the old label - // for its own messages until it is next recreated. + // A label changes no address, so the supervisor keeps its session. Its next connect uses + // the new label; its own messages keep the old one until it is next recreated. const lease = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); if (lease !== undefined) { + yield* lease.supervisor.setLabel(trimmed); yield* SubscriptionRef.update(serviceScopes, (current) => new Map(current).set(environmentId, { ...lease, entry: next }), ); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 5f279edf568c4..cd538fcfe03c0 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -38,6 +38,7 @@ import { entryWithRoutes, insertRoute, type ReportedEndpoint, + routeWithLabel, } from "./routes.ts"; const RETRY_BASE_DELAY_MS = 1_000; @@ -265,11 +266,16 @@ export class EnvironmentSupervisor extends Context.Service< } >()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") {} +/** What the registry holds. Sessions only ever see the service. */ +export type ManagedEnvironmentSupervisor = EnvironmentSupervisor["Service"] & { + readonly setLabel: (label: string) => Effect.Effect; +}; + export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( entry: ConnectionCatalogEntry, options?: EnvironmentSupervisorOptions, ): Effect.fn.Return< - EnvironmentSupervisor["Service"], + ManagedEnvironmentSupervisor, never, | Connectivity.Connectivity | ConnectionDriver.ConnectionDriver @@ -1240,13 +1246,24 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( yield* Effect.addFinalizer(() => Queue.shutdown(signals).pipe(Effect.andThen(clearLease))); - return EnvironmentSupervisor.of({ - target, - state, - session, - prepared, - connect, - disconnect, - retryNow, - }); + const setLabel = (label: string) => + Ref.update(currentEntry, (current) => + entryWithRoutes( + current, + connectionRoutes(current).map((route) => routeWithLabel(route, label)), + ), + ); + + return { + ...EnvironmentSupervisor.of({ + target, + state, + session, + prepared, + connect, + disconnect, + retryNow, + }), + setLabel, + }; }); From 85390beb126316a72c4c6d2df89aee05767f946b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Jos=C3=A9zinho?= Date: Thu, 5 Nov 2026 03:46:54 +0000 Subject: [PATCH 4/5] fix(client-runtime): a rename survives route learning, and failures show Route learning wrote its entry to the supervisor after the registry released its lock, so a rename landing in that gap went back to the old label on the next reconnect. Apply the supervisor's current label to the learned entry. A failed rename only logged to the console and left the dialog looking as if Save did nothing. Show the failure in a toast and keep the dialog open. --- .../settings/RenameEnvironmentDialog.tsx | 19 +++++++++- .../src/connection/supervisor.test.ts | 36 +++++++++++++++++++ .../src/connection/supervisor.ts | 18 ++++++---- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/settings/RenameEnvironmentDialog.tsx b/apps/web/src/components/settings/RenameEnvironmentDialog.tsx index a1082301cac95..78e33725f15d6 100644 --- a/apps/web/src/components/settings/RenameEnvironmentDialog.tsx +++ b/apps/web/src/components/settings/RenameEnvironmentDialog.tsx @@ -1,3 +1,7 @@ +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; import { useState } from "react"; @@ -15,6 +19,7 @@ import { } from "../ui/dialog"; import { Input } from "../ui/input"; import { Label } from "../ui/label"; +import { stackedThreadToast, toastManager } from "../ui/toast"; // The popup unmounts when closed, so each open starts from the current label. function RenameEnvironmentForm({ @@ -33,7 +38,19 @@ function RenameEnvironmentForm({ const save = async () => { if (!canSave) return; const result = await setEnvironmentLabel({ environmentId, label }); - if (result._tag === "Success") onDone(); + if (result._tag === "Success") { + onDone(); + return; + } + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not rename environment", + description: error instanceof Error ? error.message : "The label was not saved.", + }), + ); }; return ( diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index b4fc6955366a0..b2a696a95f7c4 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -2002,4 +2002,40 @@ describe("EnvironmentSupervisor routes", () => { ]); }), ); + + it.effect("keeps a rename that lands while route learning finishes", () => + Effect.gen(function* () { + const learned = yield* Deferred.make(); + const preparedLabels = yield* Ref.make>([]); + const harness = yield* makeHarness({ + configEvents: Stream.make({ + version: 1 as const, + type: "snapshot" as const, + config: { directEndpoints: [] } as unknown as ServerConfig, + }).pipe(Stream.ensuring(Deferred.succeed(learned, undefined))), + prepare: (_attempt, target) => + Ref.update(preparedLabels, (labels) => [...labels, target.label]).pipe( + Effect.as(preparedFor(target)), + ), + }); + const entry: ConnectionCatalogEntry = { + target: RELAY_TARGET, + profile: Option.none(), + enabled: true, + }; + const supervisor: EnvironmentSupervisor.ManagedEnvironmentSupervisor = + yield* EnvironmentSupervisor.make(entry, { + initiallyDesired: true, + learnRoutes: () => supervisor.setLabel("Studio").pipe(Effect.as(Option.some(entry))), + }).pipe(Effect.provide(harness.dependencies)); + + yield* Deferred.await(learned); + yield* supervisor.disconnect; + yield* awaitState(supervisor.state, (state) => state.phase !== "connected"); + yield* supervisor.connect; + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect((yield* Ref.get(preparedLabels)).at(-1)).toBe("Studio"); + }), + ); }); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index cd538fcfe03c0..2c83821eaec44 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -266,6 +266,12 @@ export class EnvironmentSupervisor extends Context.Service< } >()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") {} +const entryWithLabel = (entry: ConnectionCatalogEntry, label: string) => + entryWithRoutes( + entry, + connectionRoutes(entry).map((route) => routeWithLabel(route, label)), + ); + /** What the registry holds. Sessions only ever see the service. */ export type ManagedEnvironmentSupervisor = EnvironmentSupervisor["Service"] & { readonly setLabel: (label: string) => Effect.Effect; @@ -361,7 +367,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( Effect.gen(function* () { const updated = yield* learn({ activeRoute, reported }); if (Option.isSome(updated)) { - yield* Ref.set(currentEntry, updated.value); + // A rename can land after the registry built this entry. + yield* Ref.update(currentEntry, (current) => + entryWithLabel(updated.value, current.target.label), + ); yield* requestBetterRouteCheck(lease); } }), @@ -1247,12 +1256,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( yield* Effect.addFinalizer(() => Queue.shutdown(signals).pipe(Effect.andThen(clearLease))); const setLabel = (label: string) => - Ref.update(currentEntry, (current) => - entryWithRoutes( - current, - connectionRoutes(current).map((route) => routeWithLabel(route, label)), - ), - ); + Ref.update(currentEntry, (current) => entryWithLabel(current, label)); return { ...EnvironmentSupervisor.of({ From 98b039beeab65c090465f72b18d2b9bc4c76e6af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Jos=C3=A9zinho?= Date: Thu, 5 Nov 2026 08:16:35 +0000 Subject: [PATCH 5/5] fix(client-runtime): renaming keeps the saved SSH target The SSH broker saves a freshly resolved target only to the profile store, so the in-memory profile can be older. Renaming wrote that older profile back, which restored the previous host and made the next connect revoke pull request sharing permission. Relabel the stored profile instead, falling back to the in-memory one. --- .../src/connection/registry.test.ts | 22 +++++++++++++++++++ .../client-runtime/src/connection/registry.ts | 13 +++++++++-- .../client-runtime/src/connection/routes.ts | 13 ++++++----- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 298633ec20d8a..55c1663b2d312 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -2275,6 +2275,28 @@ describe("EnvironmentRegistry routes", () => { }), ); + it.effect("a rename keeps the SSH target the broker last saved", () => + Effect.gen(function* () { + const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const moved = { ...SSH_TARGET, hostname: "moved.example.test" }; + yield* Ref.update(harness.storedProfiles, (current) => + new Map(current).set( + SSH_CONNECTION.connectionId, + new SshConnectionProfile({ ...SSH_PROFILE, target: moved }), + ), + ); + + yield* registry.setLabel(SSH_CONNECTION.environmentId, "Studio"); + + expect((yield* Ref.get(harness.storedProfiles)).get(SSH_CONNECTION.connectionId)).toEqual( + new SshConnectionProfile({ ...SSH_PROFILE, label: "Studio", target: moved }), + ); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("removing the last route forgets the environment", () => Effect.gen(function* () { const harness = yield* makeHarness( diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 71a580b0891a8..35d44beed339f 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -51,6 +51,7 @@ import { findRouteToSameAddress, hasSavedBearerRoute, mergeLearnedRoutes, + profileWithLabel, routeWithLabel, routesAfterRemoving, upsertRoute, @@ -1055,8 +1056,16 @@ export const make = Effect.gen(function* () { if (routes.every((route) => route.target.label === trimmed)) return; // Every route carries the label: the first one is displayed, and reordering can change it. const renamed = routes.map((route) => routeWithLabel(route, trimmed)); - for (const route of renamed) { - if (Option.isSome(route.profile)) yield* profiles.put(route.profile.value); + for (const { profile } of routes) { + if (Option.isNone(profile)) continue; + // The SSH broker saves a freshly resolved target only to the store, so relabel that copy. + const saved = yield* profiles.get(profile.value.connectionId); + yield* profiles.put( + profileWithLabel( + Option.getOrElse(saved, () => profile.value), + trimmed, + ), + ); } const next = entryWithRoutes(entry, renamed); yield* registrations.setRoutes(environmentId, persistedRoutes(next)); diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts index 00bd6a4e48989..db0d0fbacd98d 100644 --- a/packages/client-runtime/src/connection/routes.ts +++ b/packages/client-runtime/src/connection/routes.ts @@ -10,6 +10,7 @@ import * as Option from "effect/Option"; import { BearerConnectionProfile, type ConnectionCatalogEntry, + type ConnectionProfile, type ConnectionRoute, SshConnectionProfile, } from "./catalog.ts"; @@ -88,14 +89,16 @@ function targetWithLabel(target: ConnectionTarget, label: string): ConnectionTar export function routeWithLabel(route: ConnectionRoute, label: string): ConnectionRoute { return { target: targetWithLabel(route.target, label), - profile: Option.map(route.profile, (profile) => - profile._tag === "SshConnectionProfile" - ? new SshConnectionProfile({ ...profile, label }) - : new BearerConnectionProfile({ ...profile, label }), - ), + profile: Option.map(route.profile, (profile) => profileWithLabel(profile, label)), }; } +export function profileWithLabel(profile: ConnectionProfile, label: string): ConnectionProfile { + return profile._tag === "SshConnectionProfile" + ? new SshConnectionProfile({ ...profile, label }) + : new BearerConnectionProfile({ ...profile, label }); +} + export function routeHttpBaseUrl(route: ConnectionRoute): string | null { if (route.target._tag === "PrimaryConnectionTarget") return route.target.httpBaseUrl; const profile = Option.getOrNull(route.profile);