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 {...@@ -6,6 +6,7 @@ import {
6 TerminalIcon,6 TerminalIcon,
7} from "lucide-react";7} from "lucide-react";
8import { useAtomValue } from "@effect/atom-react";8import { useAtomValue } from "@effect/atom-react";
9import { useLocation } from "@tanstack/react-router";
9import { Atom } from "effect/unstable/reactivity";10import { Atom } from "effect/unstable/reactivity";
10import {11import {
11 type KeyboardEvent,12 type KeyboardEvent,
...@@ -41,7 +42,7 @@ import {...@@ -41,7 +42,7 @@ import {
41 type EnvironmentId,42 type EnvironmentId,
42 resolveEnvironmentMachineKind,43 resolveEnvironmentMachineKind,
43} from "@t3tools/contracts";44} from "@t3tools/contracts";
44import { connectionStatusText } from "@t3tools/client-runtime/connection";45import { connectionStatusText, connectionStatusTitle } from "@t3tools/client-runtime/connection";
45import {46import {
46 isAtomCommandInterrupted,47 isAtomCommandInterrupted,
47 squashAtomCommandFailure,48 squashAtomCommandFailure,
...@@ -67,7 +68,7 @@ import {...@@ -67,7 +68,7 @@ import {
67} from "./settingsLayout";68} from "./settingsLayout";
68import { searchableSetting } from "./settingsSearch";69import { searchableSetting } from "./settingsSearch";
69import { EnvironmentIconPicker } from "./EnvironmentIconPicker";70import { EnvironmentIconPicker } from "./EnvironmentIconPicker";
70import { LoadBalancingSettings } from "./LoadBalancingSettings";71import { LoadBalancingPreference, LoadBalancingSettings } from "./LoadBalancingSettings";
71import { GitHubRoutingSettings } from "./GitHubRoutingSettings";72import { GitHubRoutingSettings } from "./GitHubRoutingSettings";
72import { Input } from "../ui/input";73import { Input } from "../ui/input";
73import { CommandShortcut } from "../ui/command";74import { CommandShortcut } from "../ui/command";
...@@ -157,7 +158,7 @@ import {...@@ -157,7 +158,7 @@ import {
157import { requestConfirmDialog } from "~/confirmDialog";158import { requestConfirmDialog } from "~/confirmDialog";
158import { useAtomCommand } from "../../state/use-atom-command";159import { useAtomCommand } from "../../state/use-atom-command";
159import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";160import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";
160import { ConnectionStatusDot } from "../ConnectionStatusDot";161import { ConnectionStatusDot, connectionPhaseDotClassName } from "../ConnectionStatusDot";
161import {162import {
162 ServerUpdateAction,163 ServerUpdateAction,
163 ServerUpdateProgress,164 ServerUpdateProgress,
...@@ -1498,37 +1499,40 @@ function SavedBackendListRow({...@@ -1498,37 +1499,40 @@ function SavedBackendListRow({
1498 <div className={cn(ITEM_ROW_CLASSNAME, !enabled && "opacity-60")}>1499 <div className={cn(ITEM_ROW_CLASSNAME, !enabled && "opacity-60")}>
1499 <div className={ITEM_ROW_INNER_CLASSNAME}>1500 <div className={ITEM_ROW_INNER_CLASSNAME}>
1500 <div className="min-w-0 flex-1 space-y-1">1501 <div className="min-w-0 flex-1 space-y-1">
1501 <div className="flex min-h-5 items-center gap-1.5">1502 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
1502 <ConnectionStatusDot1503 <div className="flex min-h-5 min-w-0 max-w-full items-center gap-1.5">
1503 tooltipText={statusTooltip}1504 <ConnectionStatusDot
1504 dotClassName={stateDotClassName}1505 tooltipText={statusTooltip}
1505 pingClassName={1506 dotClassName={stateDotClassName}
1506 enabled && (connectionState === "connecting" || connectionState === "reconnecting")1507 pingClassName={
1507 ? "bg-warning/60 duration-2000"1508 enabled &&
1508 : null1509 (connectionState === "connecting" || connectionState === "reconnecting")
1509 }1510 ? "bg-warning/60 duration-2000"
1510 />1511 : null
1511 <EnvironmentMachineIcon1512 }
1512 aria-hidden1513 />
1513 kind={resolveEnvironmentMachineKind(environment.serverConfig)}1514 <EnvironmentMachineIcon
1514 className="size-3.5 shrink-0 text-muted-foreground"1515 aria-hidden
1515 />1516 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
1516 <h3 className="min-w-0 truncate text-sm font-medium text-foreground">1517 className="size-3.5 shrink-0 text-muted-foreground"
1517 {environment.label}1518 />
1518 </h3>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}
1519 </div>1532 </div>
1520 {metadataBits.length > 0 ? (1533 {metadataBits.length > 0 ? (
1521 <p className="truncate text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>1534 <p className="truncate text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>
1522 ) : null}1535 ) : 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}
1532 {serverUpdateState.status !== "idle" ? (1536 {serverUpdateState.status !== "idle" ? (
1533 <div className="max-w-md">1537 <div className="max-w-md">
1534 <ServerUpdateProgress state={serverUpdateState} />1538 <ServerUpdateProgress state={serverUpdateState} />
...@@ -1586,12 +1590,12 @@ function SavedBackendListRow({...@@ -1586,12 +1590,12 @@ function SavedBackendListRow({
1586 <TooltipTrigger1590 <TooltipTrigger
1587 render={1591 render={
1588 <Button size="xs" variant="outline" disabled>1592 <Button size="xs" variant="outline" disabled>
1589 Managed above1593 Managed locally
1590 </Button>1594 </Button>
1591 }1595 }
1592 />1596 />
1593 <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">1597 <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.
1595 </TooltipPopup>1599 </TooltipPopup>
1596 </Tooltip>1600 </Tooltip>
1597 ) : (1601 ) : (
...@@ -1813,6 +1817,19 @@ export function ConnectionsSettings() {...@@ -1813,6 +1817,19 @@ export function ConnectionsSettings() {
1813 const keybindings = useAtomValue(primaryServerKeybindingsAtom);1817 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
1814 const { environments } = useEnvironments();1818 const { environments } = useEnvironments();
1815 const primaryEnvironment = usePrimaryEnvironment();1819 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];
1816 const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false });1833 const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false });
1817 const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, {1834 const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, {
1818 reportFailure: false,1835 reportFailure: false,
...@@ -3235,8 +3252,8 @@ export function ConnectionsSettings() {...@@ -3235,8 +3252,8 @@ export function ConnectionsSettings() {
3235 />3252 />
3236 );3253 );
32373254
3238 return (3255 const primarySettings = (
3239 <SettingsPageContainer>3256 <>
3240 {canManageLocalBackend ? (3257 {canManageLocalBackend ? (
3241 <>3258 <>
3242 <SettingsSection3259 <SettingsSection
...@@ -3324,6 +3341,7 @@ export function ConnectionsSettings() {...@@ -3324,6 +3341,7 @@ export function ConnectionsSettings() {
3324 {isLocalBackendRemotelyReachable ? (3341 {isLocalBackendRemotelyReachable ? (
3325 <SettingsSection3342 <SettingsSection
3326 title="Authorized clients"3343 title="Authorized clients"
3344 className="[&>div:first-child]:flex-wrap"
3327 headerAction={3345 headerAction={
3328 <AuthorizedClientsHeaderAction3346 <AuthorizedClientsHeaderAction
3329 onPairingLinkCreated={handlePairingLinkCreated}3347 onPairingLinkCreated={handlePairingLinkCreated}
...@@ -3626,9 +3644,15 @@ export function ConnectionsSettings() {...@@ -3626,9 +3644,15 @@ export function ConnectionsSettings() {
3626 <CloudLinkRow canManageRelay={canManageRelay} />3644 <CloudLinkRow canManageRelay={canManageRelay} />
3627 </SettingsSection>3645 </SettingsSection>
3628 )}3646 )}
3647 </>
3648 );
36293649
3650 return (
3651 <SettingsPageContainer width="wide">
3630 <SettingsSection3652 <SettingsSection
3631 {...searchableSetting("remote-environments")}3653 {...searchableSetting("remote-environments")}
3654 title="Environments"
3655 variant="plain"
3632 headerAction={3656 headerAction={
3633 <div className="flex items-center gap-1">3657 <div className="flex items-center gap-1">
3634 {savedServerUpdateTargets.length > 0 ? (3658 {savedServerUpdateTargets.length > 0 ? (
...@@ -3701,21 +3725,117 @@ export function ConnectionsSettings() {...@@ -3701,21 +3725,117 @@ export function ConnectionsSettings() {
3701 </div>3725 </div>
3702 }3726 }
3703 >3727 >
3704 {savedEnvironments.map((environment) => (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)]">
3705 <SavedBackendListRow3729 <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">
3706 key={environment.environmentId}3730 <ScrollArea
3707 environment={environment}3731 scrollFade
3708 removingEnvironmentId={removingSavedEnvironmentId}3732 chainVerticalScroll
3709 onSetEnabled={handleSetSavedBackendEnabled}3733 className="max-h-64 lg:max-h-none lg:min-h-0 lg:flex-1"
3710 onRemove={handleRemoveSavedBackend}3734 >
3711 />3735 <div className="divide-y divide-border/50" aria-label="Environments">
3712 ))}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>
3713 <CloudRemoteEnvironmentRows3834 <CloudRemoteEnvironmentRows
3714 primaryEnvironmentId={primaryEnvironmentId}3835 primaryEnvironmentId={primaryEnvironmentId}
3715 savedEnvironments={savedEnvironments}3836 savedEnvironments={savedEnvironments}
3716 />3837 />
3717 </SettingsSection>3838 </SettingsSection>
3718 <GitHubRoutingSettings environments={environments} />
3719 <LoadBalancingSettings environments={loadBalancingEnvironments} />3839 <LoadBalancingSettings environments={loadBalancingEnvironments} />
3720 </SettingsPageContainer>3840 </SettingsPageContainer>
3721 );3841 );
apps/web/src/components/settings/GitHubRoutingSettings.tsx+9-2
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";
2import {3import {
3 gitHubRoutingConnectionKey,4 gitHubRoutingConnectionKey,
4 gitHubRoutingPermissionFor,5 gitHubRoutingPermissionFor,
...@@ -22,24 +23,30 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }>...@@ -22,24 +23,30 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }>
2223
23export function GitHubRoutingSettings({24export function GitHubRoutingSettings({
24 environments,25 environments,
26 selectedEnvironmentId,
25}: {27}: {
26 readonly environments: ReadonlyArray<EnvironmentPresentation>;28 readonly environments: ReadonlyArray<EnvironmentPresentation>;
29 readonly selectedEnvironmentId: EnvironmentId;
27}) {30}) {
28 const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom);31 const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom);
29 const catalog = useAtomValue(environmentCatalog.catalogValueAtom);32 const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
30 const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission);33 const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission);
31 const [saving, setSaving] = useState(false);34 const [saving, setSaving] = useState(false);
35 const selectedEnvironments = environments.filter(
36 (environment) => environment.environmentId === selectedEnvironmentId,
37 );
3238
33 return (39 return (
34 <SettingsSection {...searchableSetting("github-routing")}>40 <SettingsSection {...searchableSetting("github-routing")}>
35 <SettingsRow41 <SettingsRow
36 title="Share GitHub access"42 title="Share GitHub access"
43 className="sm:[&>div]:grid-cols-1 [&_p]:max-w-none"
37 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."44 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."
38 />45 />
39 {environments.map((environment) => (46 {selectedEnvironments.map((environment) => (
40 <SettingsRow47 <SettingsRow
41 key={environment.environmentId}48 key={environment.environmentId}
42 title={environment.label}49 title="GitHub access"
43 description={environment.displayUrl ?? "T3 Connect"}50 description={environment.displayUrl ?? "T3 Connect"}
44 control={51 control={
45 <Select52 <Select
apps/web/src/components/settings/LoadBalancingSettings.tsx+47-46
...@@ -1,5 +1,3 @@...@@ -1,5 +1,3 @@
1import { connectionStatusText } from "@t3tools/client-runtime/connection";
2
3import {1import {
4 useClientSettings,2 useClientSettings,
5 useClientSettingsHydrated,3 useClientSettingsHydrated,
...@@ -51,51 +49,54 @@ export function LoadBalancingSettings({...@@ -51,51 +49,54 @@ export function LoadBalancingSettings({
51 />49 />
52 }50 }
53 />51 />
54 {environments.map((environment) => {52 </SettingsSection>
55 const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50;53 );
56 // Keep saved slider weights until the user chooses a different preference.54}
57 const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100;
5855
59 return (56export function LoadBalancingPreference({ environment }: { environment: EnvironmentPresentation }) {
60 <SettingsRow57 const settings = useClientSettings();
61 key={environment.environmentId}58 const settingsHydrated = useClientSettingsHydrated();
62 title={environment.label}59 const updateSettings = useUpdateClientSettings();
63 description={connectionStatusText(environment.connection)}60 const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50;
64 control={61 // Keep saved slider weights until the user chooses a different preference.
65 <Select62 const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100;
66 items={preferences}63
67 value={preference}64 return (
68 disabled={!settingsHydrated || !settings.loadBalancingEnabled}65 <SettingsRow
69 onValueChange={(value) => {66 title="Load preference"
70 if (value !== null) {67 description="How often this machine receives new threads when load balancing is on."
71 updateSettings({68 control={
72 loadBalancingWeights: {69 <Select
73 ...settings.loadBalancingWeights,70 items={preferences}
74 [environment.environmentId]: value,71 value={preference}
75 },72 disabled={!settingsHydrated || !settings.loadBalancingEnabled}
76 });73 onValueChange={(value) => {
77 }74 if (value !== null) {
78 }}75 updateSettings({
79 >76 loadBalancingWeights: {
80 <SelectTrigger77 ...settings.loadBalancingWeights,
81 size="sm"78 [environment.environmentId]: value,
82 className="w-full sm:w-40"79 },
83 aria-label={`${environment.label} load preference`}80 });
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>
95 }81 }
96 />82 }}
97 );83 >
98 })}84 <SelectTrigger
99 </SettingsSection>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 />
100 );101 );
101}102}