diff --git a/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx b/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx index df0e8c165929e..c042f8df3ef8f 100644 --- a/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx +++ b/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx @@ -38,7 +38,7 @@ export function useConnectionDiagnosis( const relayOnly = connectionRoutes(presentation.entry).length === 1 && hasRelayRoute(presentation.entry); const diagnosis = presentConnectionDiagnosis({ - hostLabel: presentation.entry.target.label, + hostLabel: presentation.label, state, via: relayOnly ? "relay" : "direct", clientVersion: Constants.expoConfig?.version, diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index aa332b407b2e3..7dff941837c4b 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -49,15 +49,18 @@ export function ConnectionEnvironmentRow(props: { updates: { readonly label: string; readonly displayUrl: string }, ) => Promise>; }) { - const [label, setLabel] = useState(props.environment.environmentLabel); + const presentation = useAtomValue( + environmentPresentations.presentationAtom(props.environment.environmentId), + ); + // Edit the saved label; the displayed one may be the environment's own custom name. + const [label, setLabel] = useState( + presentation?.entry.target.label ?? props.environment.environmentLabel, + ); const [url, setUrl] = useState(props.environment.displayUrl); const serverConfig = useAtomValue( serverEnvironment.configValueAtom(props.environment.environmentId), ); const unsupported = props.environment.connectionState === "unsupported"; - const presentation = useAtomValue( - environmentPresentations.presentationAtom(props.environment.environmentId), - ); const canRetry = presentation !== null && hasSavedBearerRoute(presentation.entry); const { diagnosis, state: connectionState } = useConnectionDiagnosis( props.environment.environmentId, diff --git a/apps/mobile/src/features/connection/PullRequestRoutingSettings.tsx b/apps/mobile/src/features/connection/PullRequestRoutingSettings.tsx index 44383db74b2ad..afa7793fa4b2f 100644 --- a/apps/mobile/src/features/connection/PullRequestRoutingSettings.tsx +++ b/apps/mobile/src/features/connection/PullRequestRoutingSettings.tsx @@ -13,6 +13,7 @@ import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; import { environmentCatalog } from "../../connection/catalog"; +import { environmentPresentations } from "../../state/presentation"; import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "../settings/components/SettingsSection"; @@ -38,6 +39,7 @@ const options: ReadonlyArray<{ export function PullRequestRoutingSettings() { const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const permissions = useAtomValue(environmentCatalog.pullRequestRoutingPermissionsValueAtom); + const presentations = useAtomValue(environmentPresentations.presentationsAtom); const update = useAtomCommand(environmentCatalog.setPullRequestRoutingPermission); const [expanded, setExpanded] = useState(null); const [saving, setSaving] = useState(false); @@ -48,6 +50,7 @@ export function PullRequestRoutingSettings() { {[...catalog.entries.values()].map((entry) => { const environmentId = entry.target.environmentId; + const label = presentations.get(environmentId)?.label ?? entry.target.label; const selected = pullRequestRoutingPermissionFor(entry, permissions); const disabled = !catalog.isReady || saving || pullRequestRoutingConnectionKey(entry) === null; @@ -55,15 +58,13 @@ export function PullRequestRoutingSettings() { setExpanded(expanded === environmentId ? null : environmentId)} > - - {entry.target.label} - + {label} {connectionCatalogDisplayUrl(entry) ?? "T3 Connect"} diff --git a/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx b/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx index 79df6d05c700f..ac47d604ddc51 100644 --- a/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx +++ b/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx @@ -26,7 +26,7 @@ export function WorkspaceFilePreviewError(props: { return ( failedIds.has(environmentId)) - .map(([, presentation]) => presentation.entry.target.label); + .map(([, presentation]) => presentation.label); const refresh = (afterPending: boolean) => Promise.all( diff --git a/apps/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts index 776a615f46afb..45fcf91b4abd8 100644 --- a/apps/mobile/src/state/environments.ts +++ b/apps/mobile/src/state/environments.ts @@ -13,7 +13,6 @@ import { useEnvironmentQuery } from "./query"; export interface EnvironmentPresentation extends BaseEnvironmentPresentation { readonly environmentId: EnvironmentId; - readonly label: string; readonly displayUrl: string | null; readonly relayManaged: boolean; } @@ -25,7 +24,6 @@ export function projectEnvironmentPresentation( return { ...presentation, environmentId, - label: presentation.entry.target.label, displayUrl: connectionCatalogDisplayUrl(presentation.entry), relayManaged: hasRelayRoute(presentation.entry), }; diff --git a/apps/mobile/src/state/remote-environment-projections.test.ts b/apps/mobile/src/state/remote-environment-projections.test.ts index 92fbd512e15b1..33e67b70e3f9c 100644 --- a/apps/mobile/src/state/remote-environment-projections.test.ts +++ b/apps/mobile/src/state/remote-environment-projections.test.ts @@ -28,8 +28,10 @@ function presentation( endpoint: string = environmentId, serverConfig: ServerConfig | null = null, ): EnvironmentPresentation { + const entry = { target: target(environmentId, endpoint), profile: Option.none(), enabled: true }; return { - entry: { target: target(environmentId, endpoint), profile: Option.none(), enabled: true }, + entry, + label: entry.target.label, connection: { phase: "connected", error: null, traceId: null }, serverConfig, }; diff --git a/apps/mobile/src/state/remote-environment-projections.ts b/apps/mobile/src/state/remote-environment-projections.ts index c70acce6d932f..bf2df00b0d018 100644 --- a/apps/mobile/src/state/remote-environment-projections.ts +++ b/apps/mobile/src/state/remote-environment-projections.ts @@ -21,6 +21,7 @@ export function createRemoteEnvironmentProjectionAtoms(input: { }) { const savedConnectionAtom = Atom.family((environmentId: EnvironmentId) => { let previousEntry: EnvironmentPresentation["entry"] | null = null; + let previousLabel: string | null = null; let previousPrepared: PreparedConnection | null = null; let previous: SavedRemoteConnection | null = null; @@ -28,6 +29,7 @@ export function createRemoteEnvironmentProjectionAtoms(input: { const presentation = get(input.presentationAtom(environmentId)); if (presentation === null) { previousEntry = null; + previousLabel = null; previousPrepared = null; previous = null; return null; @@ -37,6 +39,7 @@ export function createRemoteEnvironmentProjectionAtoms(input: { if ( previous !== null && presentation.entry === previousEntry && + presentation.label === previousLabel && prepared === previousPrepared ) { return previous; @@ -58,10 +61,11 @@ export function createRemoteEnvironmentProjectionAtoms(input: { : presentation.entry.target._tag === "RelayConnectionTarget"; previousEntry = presentation.entry; + previousLabel = presentation.label; previousPrepared = prepared; previous = { environmentId, - environmentLabel: presentation.entry.target.label, + environmentLabel: presentation.label, pairingUrl: displayUrl, displayUrl, httpBaseUrl, diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index b0931e175c558..bce7cc727cee7 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -45,7 +45,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => if (!access.canReadDiagnostics) { statuses.push({ environmentId, - label: presentation.entry.target.label, + label: presentation.label, isConnected: presentation.connection.phase === "connected", ...access, summary: null, @@ -57,7 +57,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const summary = Option.getOrNull(AsyncResult.value(result)); statuses.push({ environmentId, - label: presentation.entry.target.label, + label: presentation.label, isPending: result.waiting, isConnected: presentation.connection.phase === "connected", canReadDiagnostics: true, diff --git a/apps/mobile/src/state/workspace-connection-atoms.test.ts b/apps/mobile/src/state/workspace-connection-atoms.test.ts index 64c7256ca89fb..0fb1e720a80f5 100644 --- a/apps/mobile/src/state/workspace-connection-atoms.test.ts +++ b/apps/mobile/src/state/workspace-connection-atoms.test.ts @@ -26,6 +26,7 @@ function presentation(): EnvironmentPresentation { enabled: true, profile: Option.none(), }, + label: "Mac", connection: { phase: "connected", error: null, traceId: null }, serverConfig: null, }; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 4678aff95b736..57c660e4ad5d5 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -256,6 +256,7 @@ export const make = Effect.gen(function* () { serverResolvedCommandContext: true, threadStop: true, environmentIcon: true, + environmentLabel: true, projectCloneTracking: true, ignoredFileListing: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), diff --git a/apps/server/src/mcp/toolkits/environment/handlers.test.ts b/apps/server/src/mcp/toolkits/environment/handlers.test.ts index b4d39bd41c17f..2590f3ce1b7c9 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.test.ts @@ -95,3 +95,48 @@ it.effect("refuses a preferences update when the caller's turn ends while it wai ); }), ); + +it.effect("reads the environment by its custom name", () => + Effect.gen(function* () { + const layerDependencies = Layer.mergeAll( + ThreadCommandExecutor.layer, + Layer.succeed(McpInvocationContext.McpInvocationContext, { + environmentId, + requestNamespace: "provider:preferences", + thread: undefined, + client: undefined, + capabilities: new Set(["orchestration" as const]), + issuedAt: 0, + }), + Layer.mock(ThreadManagement.ThreadManagementService)({}), + Layer.mock(Environment.ServerEnvironment)({ + getDescriptor: Effect.succeed({ + environmentId, + label: "devbox", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0", + capabilities: { repositoryIdentity: false }, + }), + }), + Layer.mock(Settings.ServerSettingsService)({ + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + environmentLabel: "MacBook Pro · user2", + }), + }), + ); + const result = yield* Effect.gen(function* () { + const toolkit = yield* EnvironmentToolkit; + return yield* toolkit + .handle("t3_environment_read", {}) + .pipe(Stream.unwrap, Stream.runCollect); + }).pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(EnvironmentHandlers.layer).pipe( + Layer.provideMerge(layerDependencies), + ), + ), + ); + expect(result.at(-1)?.result).toMatchObject({ label: "MacBook Pro · user2" }); + }), +); diff --git a/apps/server/src/mcp/toolkits/environment/handlers.ts b/apps/server/src/mcp/toolkits/environment/handlers.ts index fb1716885d6e8..5d5accd13ec6e 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.ts @@ -47,7 +47,7 @@ export const layer = McpToolAccess.toLayer(EnvironmentToolkit, { const current = yield* settings.getSettings.pipe(Effect.mapError(unavailable)); return { environmentId: descriptor.environmentId, - label: descriptor.label, + label: current.environmentLabel ?? descriptor.label, serverVersion: descriptor.serverVersion, platform: descriptor.platform, preferences: preferences(current), diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 597cafb35e92f..26c6bd5f5fff6 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -545,8 +545,7 @@ export function useComposerSend(input: ComposerSendInput) { })[0]; if (strandedThread) { const owner = - readEnvironmentPresentation(strandedThread.environmentId)?.entry.target.label ?? - "its machine"; + readEnvironmentPresentation(strandedThread.environmentId)?.label ?? "its machine"; toastManager.add( stackedThreadToast({ type: "warning", diff --git a/apps/web/src/components/settings/ChatGptWelcomeCoordinator.tsx b/apps/web/src/components/settings/ChatGptWelcomeCoordinator.tsx index 6b449407abb26..ea8ed10178f72 100644 --- a/apps/web/src/components/settings/ChatGptWelcomeCoordinator.tsx +++ b/apps/web/src/components/settings/ChatGptWelcomeCoordinator.tsx @@ -41,7 +41,7 @@ export function ChatGptWelcomeCoordinator() { provider.instanceId, provider.auth.profileId ?? provider.auth.email ?? "default", ]), - environmentLabel: presentation.entry.target.label, + environmentLabel: presentation.label, providerName: provider.displayName ?? "Codex", })), ); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 3aa4a150a4d83..db8eb9df299a3 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,5 +1,5 @@ import { SessionPermissions } from "./SessionPermissions"; -import { ChevronRightIcon, EllipsisIcon } from "lucide-react"; +import { ChevronRightIcon, EllipsisIcon, PencilIcon } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; import { useState } from "react"; import { @@ -35,6 +35,7 @@ import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsL import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; +import { EnvironmentRenameDialog } from "./EnvironmentRenameDialog"; import { FoldedSettingsSection } from "./FoldedSettingsSection"; import { LoadBalancingSettings } from "./LoadBalancingSettings"; import { PullRequestRoutingSettings } from "./PullRequestRoutingSettings"; @@ -61,7 +62,7 @@ import { } from "../ui/alert-dialog"; import { Spinner } from "../ui/spinner"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -235,6 +236,7 @@ export function ConnectionsSettings() { >(null); const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false); const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false); + const [renamePrimaryOpen, setRenamePrimaryOpen] = useState(false); const [routeTarget, setRouteTarget] = useState(null); const [savedBackendError, setSavedBackendError] = useState(null); const handleAddBackendDialogOpenChange = (open: boolean) => { @@ -1046,26 +1048,39 @@ export function ConnectionsSettings() { } headerAction={ primaryEnvironmentId !== null ? ( - - + + + } + > + + + + - } - > - - - - - - + setRenamePrimaryOpen(true)}> + + Rename… + + + + + ) : null } > diff --git a/apps/web/src/components/settings/EnvironmentRenameDialog.tsx b/apps/web/src/components/settings/EnvironmentRenameDialog.tsx new file mode 100644 index 0000000000000..5038d7ec62d70 --- /dev/null +++ b/apps/web/src/components/settings/EnvironmentRenameDialog.tsx @@ -0,0 +1,144 @@ +import { AuthSettingsWriteScope, type EnvironmentId, type ServerConfig } from "@t3tools/contracts"; +import { useState } from "react"; + +import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { useEnvironmentScope } from "../../state/session"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; + +type RenameServerConfig = Pick | null; + +function resolveEnvironmentRenameLock(input: { + readonly serverConfig: Pick | null; + readonly operateAccess: "granted" | "denied" | "pending"; +}): string | null { + if (input.serverConfig === null) { + return "Connect to this environment to rename it."; + } + if (input.serverConfig.environment.capabilities.environmentLabel !== true) { + return "This environment's server is too old to keep a name. Update it to rename it."; + } + if (input.operateAccess !== "granted") { + return "Your session on this environment cannot change its settings."; + } + return null; +} + +export function EnvironmentRenameDialog({ + environmentId, + serverConfig, + label, + open, + onOpenChange, +}: { + readonly environmentId: EnvironmentId; + readonly serverConfig: RenameServerConfig; + readonly label: string; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +}) { + return ( + + + onOpenChange(false)} + /> + + + ); +} + +function EnvironmentRenameForm({ + environmentId, + serverConfig, + label, + onClose, +}: { + readonly environmentId: EnvironmentId; + readonly serverConfig: RenameServerConfig; + readonly label: string; + readonly onClose: () => void; +}) { + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const operateAccess = useEnvironmentScope(environmentId, AuthSettingsWriteScope) + ? "granted" + : "denied"; + const lock = resolveEnvironmentRenameLock({ serverConfig, operateAccess }); + const customLabel = serverConfig?.settings?.environmentLabel ?? null; + const [name, setName] = useState(label); + + const save = (environmentLabel: string | null) => { + if (lock !== null) return; + if (environmentLabel !== customLabel) { + updateSettings({ environmentLabel }); + } + onClose(); + }; + const submit = () => { + const trimmed = name.trim(); + save(trimmed === "" || (customLabel === null && trimmed === label) ? null : trimmed); + }; + + return ( +
{ + event.preventDefault(); + submit(); + }} + > + + Rename environment + + Every client connected to this environment shows this name. Leave it empty to use the + default name. + + + +
+ Name + setName(event.target.value)} + /> +
+ {lock !== null ?

{lock}

: null} +
+ + {customLabel !== null ? ( + + ) : null} + + + +
+ ); +} diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index 5a331193bcae0..b0922557025fb 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"; @@ -27,6 +28,7 @@ import { cn } from "../../lib/utils"; import { SettingsRow } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; +import { EnvironmentRenameDialog } from "./EnvironmentRenameDialog"; import { EnvironmentRoutesList } from "./EnvironmentRoutesList"; import { ConnectionDiagnosisPanel, @@ -115,6 +117,7 @@ export function SavedBackendListRow({ onAddRoute, }: SavedBackendListRowProps) { const [routesOpen, setRoutesOpen] = useState(false); + const [renameOpen, setRenameOpen] = useState(false); const [permissionsOpen, setPermissionsOpen] = useState(false); const [diagnosisOpen, setDiagnosisOpen] = useState(false); const environmentId = environment.environmentId; @@ -414,6 +417,10 @@ export function SavedBackendListRow({ environmentId={environmentId} serverConfig={environment.serverConfig} /> + setRenameOpen(true)}> + + Rename… + setRoutesOpen((open) => !open)}> {routesOpen ? "Hide routes" : "Routes"} @@ -430,6 +437,13 @@ export function SavedBackendListRow({ + ); } diff --git a/apps/web/src/components/usage/UsagePriceOverrides.tsx b/apps/web/src/components/usage/UsagePriceOverrides.tsx index efa0f4535338f..2d6aa96a45c70 100644 --- a/apps/web/src/components/usage/UsagePriceOverrides.tsx +++ b/apps/web/src/components/usage/UsagePriceOverrides.tsx @@ -59,7 +59,7 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] => : "denied"; return { environmentId, - label: environment.entry.target.label, + label: environment.label, prices: settings?.usagePriceOverrides ?? null, aliases: environment.serverConfig?.environment.capabilities.usageModelAliases === true diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index e399c201c34d8..0b2c2b5a11998 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -21,7 +21,6 @@ import { usePreparedConnection } from "./session"; export interface EnvironmentPresentation extends BaseEnvironmentPresentation { readonly environmentId: EnvironmentId; - readonly label: string; readonly displayUrl: string | null; readonly relayManaged: boolean; } @@ -33,7 +32,6 @@ function projectEnvironmentPresentation( return { ...presentation, environmentId, - label: presentation.entry.target.label, displayUrl: connectionCatalogDisplayUrl(presentation.entry), relayManaged: hasRelayRoute(presentation.entry), }; diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index a5bb95c430c8b..38f0e3637ed69 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -48,7 +48,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => if (!access.canReadDiagnostics) { statuses.push({ environmentId, - label: presentation.entry.target.label, + label: presentation.label, isConnected, ...access, summary: null, @@ -60,7 +60,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const summary = Option.getOrNull(AsyncResult.value(result)); statuses.push({ environmentId, - label: presentation.entry.target.label, + label: presentation.label, isPending: result.waiting, canReadDiagnostics: true, isConnected, diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index fbda09d6af41d..a5467b1fc7fb8 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -104,6 +104,14 @@ shown there apply only to the route marked **In use**; other routes are not checked. Direct pairing and T3 Connect have separate sessions and may grant different permissions. +### Name a machine + +An environment is named after its machine, so two user accounts on one computer +show the same name. To tell them apart, open the environment's menu in +**Settings → Connections** and choose **Rename…**. The name is saved on that +environment, so every connected device shows it. Clear the name to go back to +the default. + ### Balance new threads across machines Auto balance is off by default. On web and desktop, enable it in diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index 6bfa9791dfdca..73770528d623b 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -29,6 +29,7 @@ export type EnvironmentServerConfig = Omit; export interface EnvironmentPresentation { readonly entry: ConnectionCatalogEntry; + readonly label: string; readonly connection: EnvironmentConnectionPresentation; readonly serverConfig: EnvironmentServerConfig | null; } diff --git a/packages/client-runtime/src/state/presentation.test.ts b/packages/client-runtime/src/state/presentation.test.ts index 3c3fa20a4a976..28adc869a94f8 100644 --- a/packages/client-runtime/src/state/presentation.test.ts +++ b/packages/client-runtime/src/state/presentation.test.ts @@ -88,6 +88,29 @@ describe("environment summary subscriptions", () => { h.registry.dispose(); } }); + + it("labels an environment by its custom name until the name is cleared", () => { + const h = harness(); + const named = (environmentLabel: string | null) => + ({ ...config(), settings: { environmentLabel } }) as ServerConfig; + const stop = h.registry.subscribe(h.identitiesAtom, () => {}); + try { + h.registry.set(h.configs(FIRST), named("MacBook Pro · user2")); + expect(h.registry.get(h.full.presentationAtom(FIRST))?.label).toBe("MacBook Pro · user2"); + expect(h.registry.get(h.identitiesAtom)).toEqual([ + { environmentId: FIRST, label: "MacBook Pro · user2" }, + { environmentId: SECOND, label: "second" }, + ]); + expect(h.registry.get(h.environmentsAtom)[0]?.environmentLabel).toBe("MacBook Pro · user2"); + + h.registry.set(h.configs(FIRST), named(null)); + expect(h.registry.get(h.full.presentationAtom(FIRST))?.label).toBe("first"); + expect(h.registry.get(h.identitiesAtom)[0]).toEqual({ environmentId: FIRST, label: "first" }); + } finally { + stop(); + h.registry.dispose(); + } + }); }); describe("presentations with providers", () => { diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index 48d27974a2c8c..c43b153c0d247 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -1,4 +1,5 @@ import { + resolveEnvironmentLabel, resolveEnvironmentMachineKind, type EnvironmentMachineKind, type EnvironmentId, @@ -86,19 +87,22 @@ export function createEnvironmentPresentationAtoms(input: { entry.unsupportedReason === undefined ? presentEnvironmentConnection(state) : { phase: "unsupported", error: entry.unsupportedReason, traceId: null }; + const serverConfig = withoutProviders( + get(input.serverConfigValueAtom(environmentId)), + previous?.serverConfig ?? null, + ); const next = { entry, + label: resolveEnvironmentLabel(serverConfig, entry.target.label), connection: previous !== null && sameConnection(previous.connection, connection) ? previous.connection : connection, - serverConfig: withoutProviders( - get(input.serverConfigValueAtom(environmentId)), - previous?.serverConfig ?? null, - ), + serverConfig, } satisfies EnvironmentPresentation; return previous !== null && previous.entry === next.entry && + previous.label === next.label && previous.connection === next.connection && previous.serverConfig === next.serverConfig ? previous @@ -204,7 +208,7 @@ export function projectEnvironmentConnectionSummary( ): EnvironmentConnectionSummary { return { environmentId, - environmentLabel: environment.entry.target.label, + environmentLabel: environment.label, displayUrl: connectionCatalogDisplayUrl(environment.entry) ?? "", isRelayManaged: hasRelayRoute(environment.entry), isEnabled: environment.entry.enabled, @@ -232,7 +236,7 @@ export function createEnvironmentSummaryAtoms(input: { const identitiesAtom = Atom.make((get) => { const next = [...get(input.catalogValueAtom).entries].map(([environmentId, entry]) => ({ environmentId, - label: entry.target.label, + label: get(input.presentationAtom(environmentId))?.label ?? entry.target.label, })); const previous = Option.getOrNull( get.self>(), diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7b47d88202c7d..bd47125cc7e0f 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -129,6 +129,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server answers projects.listIgnoredFiles. */ ignoredFileListing: Schema.optionalKey(Schema.Boolean), environmentIcon: Schema.optionalKey(Schema.Boolean), + environmentLabel: Schema.optionalKey(Schema.Boolean), desktopAppUpdate: Schema.optionalKey(Schema.Boolean), /** Subscribe to command output only when advertised; older servers never send it. */ commandOutputStreaming: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c14b4b4b24eaf..bbc721b0df5f7 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -640,6 +640,14 @@ export function resolveEnvironmentMachineKind( return config?.settings?.environmentIcon ?? config?.environment.platform.machine ?? "server"; } +/** A custom label wins over the label the client saved when it added the environment. */ +export function resolveEnvironmentLabel( + config: { readonly settings?: Pick } | null, + savedLabel: string, +): string { + return config?.settings?.environmentLabel ?? savedLabel; +} + const ServerUpsertKeybindingReplaceTarget = Schema.Struct({ key: KeybindingValue, command: KeybindingCommand, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 88460d06bcdf6..4903a6fa00b3e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -245,6 +245,8 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)) export type FontFamilyPreference = typeof FontFamilyPreference.Type; /** Clients apply each environment theme set once; later local choices persist until the next set. Empty clears it. */ +export const EnvironmentLabel = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); + export const DefaultThemePreference = Schema.String.check(Schema.isMaxLength(64)); // Excluded from RPC patches so t3 theme set can validate syntax and resolvability. export type DefaultThemePreference = typeof DefaultThemePreference.Type; @@ -1195,6 +1197,10 @@ export const ServerSettings = Schema.Struct({ environmentIcon: ForwardCompatibleNullable(EnvironmentMachineKind).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + /** Null keeps the detected machine name. */ + environmentLabel: Schema.NullOr(EnvironmentLabel).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), /** Null inherits t3.json, then local; omit it on the wire for older clients requiring a literal. */ defaultThreadEnvMode: OmittedWhenNull(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.Boolean.pipe( @@ -1525,6 +1531,7 @@ export const ServerSettingsPatch = Schema.Struct({ providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), + environmentLabel: Schema.optionalKey(Schema.NullOr(EnvironmentLabel)), defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)),