authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-13 13:34:14-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 13:34:14-03:00
logf26198d799bb81bf754985d457a60f8a851ea895
tree1d74bf8250b00debdbad6759fa4ae0c633d16564
parent42b6bcc6f59bd0369377746b0f2ef370c275b624
signature Signed by PGP key B5690EEEBB952194

feat(web): organize connections by environment (#11542)


3 files changed, 220 insertions(+), 92 deletions(-)

apps/web/src/components/settings/ConnectionsSettings.tsx+164-44
......@@ -6,6 +6,7 @@ import {
66 TerminalIcon,
77} from "lucide-react";
88import { useAtomValue } from "@effect/atom-react";
9import { useLocation } from "@tanstack/react-router";
910import { Atom } from "effect/unstable/reactivity";
1011import {
1112 type KeyboardEvent,
......@@ -41,7 +42,7 @@ import {
4142 type EnvironmentId,
4243 resolveEnvironmentMachineKind,
4344} from "@t3tools/contracts";
44import { connectionStatusText } from "@t3tools/client-runtime/connection";
45import { connectionStatusText, connectionStatusTitle } from "@t3tools/client-runtime/connection";
4546import {
4647 isAtomCommandInterrupted,
4748 squashAtomCommandFailure,
......@@ -67,7 +68,7 @@ import {
6768} from "./settingsLayout";
6869import { searchableSetting } from "./settingsSearch";
6970import { EnvironmentIconPicker } from "./EnvironmentIconPicker";
70import { LoadBalancingSettings } from "./LoadBalancingSettings";
71import { LoadBalancingPreference, LoadBalancingSettings } from "./LoadBalancingSettings";
7172import { GitHubRoutingSettings } from "./GitHubRoutingSettings";
7273import { Input } from "../ui/input";
7374import { CommandShortcut } from "../ui/command";
......@@ -157,7 +158,7 @@ import {
157158import { requestConfirmDialog } from "~/confirmDialog";
158159import { useAtomCommand } from "../../state/use-atom-command";
159160import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";
160import { ConnectionStatusDot } from "../ConnectionStatusDot";
161import { ConnectionStatusDot, connectionPhaseDotClassName } from "../ConnectionStatusDot";
161162import {
162163 ServerUpdateAction,
163164 ServerUpdateProgress,
......@@ -1498,37 +1499,40 @@ function SavedBackendListRow({
14981499 <div className={cn(ITEM_ROW_CLASSNAME, !enabled && "opacity-60")}>
14991500 <div className={ITEM_ROW_INNER_CLASSNAME}>
15001501 <div className="min-w-0 flex-1 space-y-1">
1501 <div className="flex min-h-5 items-center gap-1.5">
1502 <ConnectionStatusDot
1503 tooltipText={statusTooltip}
1504 dotClassName={stateDotClassName}
1505 pingClassName={
1506 enabled && (connectionState === "connecting" || connectionState === "reconnecting")
1507 ? "bg-warning/60 duration-2000"
1508 : null
1509 }
1510 />
1511 <EnvironmentMachineIcon
1512 aria-hidden
1513 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
1514 className="size-3.5 shrink-0 text-muted-foreground"
1515 />
1516 <h3 className="min-w-0 truncate text-sm font-medium text-foreground">
1517 {environment.label}
1518 </h3>
1502 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
1503 <div className="flex min-h-5 min-w-0 max-w-full items-center gap-1.5">
1504 <ConnectionStatusDot
1505 tooltipText={statusTooltip}
1506 dotClassName={stateDotClassName}
1507 pingClassName={
1508 enabled &&
1509 (connectionState === "connecting" || connectionState === "reconnecting")
1510 ? "bg-warning/60 duration-2000"
1511 : null
1512 }
1513 />
1514 <EnvironmentMachineIcon
1515 aria-hidden
1516 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
1517 className="size-3.5 shrink-0 text-muted-foreground"
1518 />
1519 <h3 className="min-w-0 truncate text-sm font-medium text-foreground">
1520 {environment.label}
1521 </h3>
1522 </div>
1523 {isConnected ? (
1524 <div className="w-full sm:w-auto">
1525 <EnvironmentIconPicker
1526 environmentId={environmentId}
1527 serverConfig={environment.serverConfig}
1528 size="xs"
1529 />
1530 </div>
1531 ) : null}
15191532 </div>
15201533 {metadataBits.length > 0 ? (
15211534 <p className="truncate text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>
15221535 ) : null}
1523 {isConnected ? (
1524 <div className="pt-1">
1525 <EnvironmentIconPicker
1526 environmentId={environmentId}
1527 serverConfig={environment.serverConfig}
1528 size="xs"
1529 />
1530 </div>
1531 ) : null}
15321536 {serverUpdateState.status !== "idle" ? (
15331537 <div className="max-w-md">
15341538 <ServerUpdateProgress state={serverUpdateState} />
......@@ -1586,12 +1590,12 @@ function SavedBackendListRow({
15861590 <TooltipTrigger
15871591 render={
15881592 <Button size="xs" variant="outline" disabled>
1589 Managed above
1593 Managed locally
15901594 </Button>
15911595 }
15921596 />
15931597 <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">
1594 The WSL backend is managed by the WSL setting above — turn it on or off there.
1598 Select the primary environment to turn the WSL backend on or off.
15951599 </TooltipPopup>
15961600 </Tooltip>
15971601 ) : (
......@@ -1813,6 +1817,19 @@ export function ConnectionsSettings() {
18131817 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
18141818 const { environments } = useEnvironments();
18151819 const primaryEnvironment = usePrimaryEnvironment();
1820 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);
1821 const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") });
1822 const [handledSearchTargetId, setHandledSearchTargetId] = useState<string | null>(null);
1823 if (primaryEnvironment && handledSearchTargetId !== searchTargetId) {
1824 setHandledSearchTargetId(searchTargetId);
1825 if (["connections-environment", "wsl-backend"].includes(searchTargetId)) {
1826 setSelectedEnvironmentId(primaryEnvironment.environmentId);
1827 }
1828 }
1829 const selectedEnvironment =
1830 environments.find((environment) => environment.environmentId === selectedEnvironmentId) ??
1831 primaryEnvironment ??
1832 environments[0];
18161833 const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false });
18171834 const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, {
18181835 reportFailure: false,
......@@ -3235,8 +3252,8 @@ export function ConnectionsSettings() {
32353252 />
32363253 );
32373254
3238 return (
3239 <SettingsPageContainer>
3255 const primarySettings = (
3256 <>
32403257 {canManageLocalBackend ? (
32413258 <>
32423259 <SettingsSection
......@@ -3324,6 +3341,7 @@ export function ConnectionsSettings() {
33243341 {isLocalBackendRemotelyReachable ? (
33253342 <SettingsSection
33263343 title="Authorized clients"
3344 className="[&>div:first-child]:flex-wrap"
33273345 headerAction={
33283346 <AuthorizedClientsHeaderAction
33293347 onPairingLinkCreated={handlePairingLinkCreated}
......@@ -3626,9 +3644,15 @@ export function ConnectionsSettings() {
36263644 <CloudLinkRow canManageRelay={canManageRelay} />
36273645 </SettingsSection>
36283646 )}
3647 </>
3648 );
36293649
3650 return (
3651 <SettingsPageContainer width="wide">
36303652 <SettingsSection
36313653 {...searchableSetting("remote-environments")}
3654 title="Environments"
3655 variant="plain"
36323656 headerAction={
36333657 <div className="flex items-center gap-1">
36343658 {savedServerUpdateTargets.length > 0 ? (
......@@ -3701,21 +3725,117 @@ export function ConnectionsSettings() {
37013725 </div>
37023726 }
37033727 >
3704 {savedEnvironments.map((environment) => (
3705 <SavedBackendListRow
3706 key={environment.environmentId}
3707 environment={environment}
3708 removingEnvironmentId={removingSavedEnvironmentId}
3709 onSetEnabled={handleSetSavedBackendEnabled}
3710 onRemove={handleRemoveSavedBackend}
3711 />
3712 ))}
3728 <div className="overflow-hidden rounded-xl border border-border/60 bg-card/40 shadow-xs/5 lg:grid lg:h-[min(34rem,calc(100dvh-18rem))] lg:min-h-[28rem] lg:grid-cols-[17rem_minmax(0,1fr)]">
3729 <div className="border-b border-border/60 bg-muted/10 lg:flex lg:min-h-0 lg:flex-col lg:border-r lg:border-b-0">
3730 <ScrollArea
3731 scrollFade
3732 chainVerticalScroll
3733 className="max-h-64 lg:max-h-none lg:min-h-0 lg:flex-1"
3734 >
3735 <div className="divide-y divide-border/50" aria-label="Environments">
3736 {(primaryEnvironment
3737 ? [primaryEnvironment, ...savedEnvironments]
3738 : savedEnvironments
3739 ).map((environment) => {
3740 const selected = environment.environmentId === selectedEnvironment?.environmentId;
3741 const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
3742 return (
3743 <button
3744 key={environment.environmentId}
3745 type="button"
3746 aria-label={"Select " + environment.label}
3747 aria-pressed={selected}
3748 onClick={() => setSelectedEnvironmentId(environment.environmentId)}
3749 className={cn(
3750 "flex min-h-18 w-full cursor-pointer items-start gap-3 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring sm:px-4",
3751 selected ? "bg-muted/45" : "hover:bg-muted/25",
3752 !environment.entry.enabled && !selected && "opacity-60",
3753 )}
3754 >
3755 <EnvironmentMachineIcon
3756 aria-hidden
3757 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
3758 className="mt-0.5 size-4 shrink-0 text-muted-foreground"
3759 />
3760 <span className="min-w-0 flex-1 space-y-1">
3761 <span className="flex items-center gap-2">
3762 <span className="truncate text-sm font-medium">{environment.label}</span>
3763 {isPrimary ? (
3764 <span className="shrink-0 text-[10px] text-muted-foreground">
3765 Primary
3766 </span>
3767 ) : null}
3768 </span>
3769 <Tooltip>
3770 <TooltipTrigger
3771 render={
3772 <span className="block truncate text-xs text-muted-foreground" />
3773 }
3774 >
3775 {environment.displayUrl ?? "T3 Connect"}
3776 </TooltipTrigger>
3777 <TooltipPopup>{environment.displayUrl ?? "T3 Connect"}</TooltipPopup>
3778 </Tooltip>
3779 <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
3780 <span
3781 aria-hidden
3782 className={cn(
3783 "size-1.5 shrink-0 rounded-full",
3784 !environment.entry.enabled
3785 ? "bg-muted-foreground/40"
3786 : connectionPhaseDotClassName(environment.connection.phase),
3787 )}
3788 />
3789 {environment.entry.enabled
3790 ? connectionStatusTitle(environment.connection)
3791 : "Off"}
3792 </span>
3793 </span>
3794 </button>
3795 );
3796 })}
3797 </div>
3798 </ScrollArea>
3799 </div>
3800 <ScrollArea
3801 key={selectedEnvironment?.environmentId}
3802 scrollFade
3803 chainVerticalScroll
3804 className="min-w-0 lg:min-h-0 lg:h-full"
3805 >
3806 {selectedEnvironment ? (
3807 <div className="space-y-6 p-4">
3808 {selectedEnvironment.entry.target._tag === "PrimaryConnectionTarget" ? (
3809 primarySettings
3810 ) : (
3811 <SettingsSection title="Connection">
3812 <SavedBackendListRow
3813 key={selectedEnvironment.environmentId}
3814 environment={selectedEnvironment}
3815 removingEnvironmentId={removingSavedEnvironmentId}
3816 onSetEnabled={handleSetSavedBackendEnabled}
3817 onRemove={handleRemoveSavedBackend}
3818 />
3819 </SettingsSection>
3820 )}
3821 {selectedEnvironment.entry.enabled && loadBalancingEnvironments.length > 1 ? (
3822 <SettingsSection title="Thread placement">
3823 <LoadBalancingPreference environment={selectedEnvironment} />
3824 </SettingsSection>
3825 ) : null}
3826 <GitHubRoutingSettings
3827 environments={environments}
3828 selectedEnvironmentId={selectedEnvironment.environmentId}
3829 />
3830 </div>
3831 ) : null}
3832 </ScrollArea>
3833 </div>
37133834 <CloudRemoteEnvironmentRows
37143835 primaryEnvironmentId={primaryEnvironmentId}
37153836 savedEnvironments={savedEnvironments}
37163837 />
37173838 </SettingsSection>
3718 <GitHubRoutingSettings environments={environments} />
37193839 <LoadBalancingSettings environments={loadBalancingEnvironments} />
37203840 </SettingsPageContainer>
37213841 );
apps/web/src/components/settings/GitHubRoutingSettings.tsx+9-2
......@@ -1,4 +1,5 @@
11import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";
23import {
34 gitHubRoutingConnectionKey,
45 gitHubRoutingPermissionFor,
......@@ -22,24 +23,30 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }>
2223
2324export function GitHubRoutingSettings({
2425 environments,
26 selectedEnvironmentId,
2527}: {
2628 readonly environments: ReadonlyArray<EnvironmentPresentation>;
29 readonly selectedEnvironmentId: EnvironmentId;
2730}) {
2831 const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom);
2932 const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
3033 const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission);
3134 const [saving, setSaving] = useState(false);
35 const selectedEnvironments = environments.filter(
36 (environment) => environment.environmentId === selectedEnvironmentId,
37 );
3238
3339 return (
3440 <SettingsSection {...searchableSetting("github-routing")}>
3541 <SettingsRow
3642 title="Share GitHub access"
43 className="sm:[&>div]:grid-cols-1 [&_p]:max-w-none"
3744 description="Choose environments you trust to share PR data and use each other's GitHub access. Enable both environments. Read and act may use broader permissions than the original environment. This applies only to this client."
3845 />
39 {environments.map((environment) => (
46 {selectedEnvironments.map((environment) => (
4047 <SettingsRow
4148 key={environment.environmentId}
42 title={environment.label}
49 title="GitHub access"
4350 description={environment.displayUrl ?? "T3 Connect"}
4451 control={
4552 <Select
apps/web/src/components/settings/LoadBalancingSettings.tsx+47-46
......@@ -1,5 +1,3 @@
1import { connectionStatusText } from "@t3tools/client-runtime/connection";
2
31import {
42 useClientSettings,
53 useClientSettingsHydrated,
......@@ -51,51 +49,54 @@ export function LoadBalancingSettings({
5149 />
5250 }
5351 />
54 {environments.map((environment) => {
55 const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50;
56 // Keep saved slider weights until the user chooses a different preference.
57 const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100;
52 </SettingsSection>
53 );
54}
5855
59 return (
60 <SettingsRow
61 key={environment.environmentId}
62 title={environment.label}
63 description={connectionStatusText(environment.connection)}
64 control={
65 <Select
66 items={preferences}
67 value={preference}
68 disabled={!settingsHydrated || !settings.loadBalancingEnabled}
69 onValueChange={(value) => {
70 if (value !== null) {
71 updateSettings({
72 loadBalancingWeights: {
73 ...settings.loadBalancingWeights,
74 [environment.environmentId]: value,
75 },
76 });
77 }
78 }}
79 >
80 <SelectTrigger
81 size="sm"
82 className="w-full sm:w-40"
83 aria-label={`${environment.label} load preference`}
84 >
85 <SelectValue />
86 </SelectTrigger>
87 <SelectPopup align="end" alignItemWithTrigger={false}>
88 {preferences.map(({ value, label }) => (
89 <SelectItem key={value} value={value}>
90 {label}
91 </SelectItem>
92 ))}
93 </SelectPopup>
94 </Select>
56export function LoadBalancingPreference({ environment }: { environment: EnvironmentPresentation }) {
57 const settings = useClientSettings();
58 const settingsHydrated = useClientSettingsHydrated();
59 const updateSettings = useUpdateClientSettings();
60 const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50;
61 // Keep saved slider weights until the user chooses a different preference.
62 const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100;
63
64 return (
65 <SettingsRow
66 title="Load preference"
67 description="How often this machine receives new threads when load balancing is on."
68 control={
69 <Select
70 items={preferences}
71 value={preference}
72 disabled={!settingsHydrated || !settings.loadBalancingEnabled}
73 onValueChange={(value) => {
74 if (value !== null) {
75 updateSettings({
76 loadBalancingWeights: {
77 ...settings.loadBalancingWeights,
78 [environment.environmentId]: value,
79 },
80 });
9581 }
96 />
97 );
98 })}
99 </SettingsSection>
82 }}
83 >
84 <SelectTrigger
85 size="sm"
86 className="w-full sm:w-40"
87 aria-label={`${environment.label} load preference`}
88 >
89 <SelectValue />
90 </SelectTrigger>
91 <SelectPopup align="end" alignItemWithTrigger={false}>
92 {preferences.map(({ value, label }) => (
93 <SelectItem key={value} value={value}>
94 {label}
95 </SelectItem>
96 ))}
97 </SelectPopup>
98 </Select>
99 }
100 />
100101 );
101102}