authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-13 19:40:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 19:40:19-07:00
log5e961d3d7fc07ff1ba998bfd20bb366c2ab8c9a9
treed4d73b0361d657d22bf4aa54ca9e1bd2e55a6666
parent2d737465027b18cafd0c4704e9025b124bc94b2c
signature Signed by PGP key B5690EEEBB952194

feat(web): flatten the connections page into one environments list (#11672)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

13 files changed, 720 insertions(+), 586 deletions(-)

apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx+3-3
...@@ -175,7 +175,7 @@ describe("cloud onboarding discovery", () => {...@@ -175,7 +175,7 @@ describe("cloud onboarding discovery", () => {
175 finishDiscovery(linkedMachines);175 finishDiscovery(linkedMachines);
176 });176 });
177 expect(onDiscoveryReady).toHaveBeenCalledTimes(1);177 expect(onDiscoveryReady).toHaveBeenCalledTimes(1);
178 expect(renderer!.root.findByType("button").children).toEqual(["Connect"]);178 expect(renderer!.root.findByType("button").children).toEqual(["Add"]);
179 });179 });
180180
181 it("connects and selects discovered computers by default without overwriting deselection", async () => {181 it("connects and selects discovered computers by default without overwriting deselection", async () => {
...@@ -232,7 +232,7 @@ describe("cloud onboarding discovery", () => {...@@ -232,7 +232,7 @@ describe("cloud onboarding discovery", () => {
232 expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([232 expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([
233 "Work laptop",233 "Work laptop",
234 ]);234 ]);
235 expect(renderer!.root.findByType("button").children).toEqual(["Connect"]);235 expect(renderer!.root.findByType("button").children).toEqual(["Add"]);
236 await advance(30_000);236 await advance(30_000);
237 expect(discovery.listEnvironments).toHaveBeenCalledTimes(2);237 expect(discovery.listEnvironments).toHaveBeenCalledTimes(2);
238 });238 });
...@@ -240,7 +240,7 @@ describe("cloud onboarding discovery", () => {...@@ -240,7 +240,7 @@ describe("cloud onboarding discovery", () => {
240 it("keeps a discovered computer visible when it is added to the browser", async () => {240 it("keeps a discovered computer visible when it is added to the browser", async () => {
241 discovery.listEnvironments.mockResolvedValue(linkedMachines);241 discovery.listEnvironments.mockResolvedValue(linkedMachines);
242 await mount();242 await mount();
243 expect(renderer!.root.findByType("button").children).toEqual(["Connect"]);243 expect(renderer!.root.findByType("button").children).toEqual(["Add"]);
244 await act(async () => {244 await act(async () => {
245 renderer!.update(245 renderer!.update(
246 <CloudEnvironmentConnectRows246 <CloudEnvironmentConnectRows
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+6-5
...@@ -288,12 +288,13 @@ export function CloudEnvironmentConnectRows({...@@ -288,12 +288,13 @@ export function CloudEnvironmentConnectRows({
288 const statusText = savedConnection288 const statusText = savedConnection
289 ? savedConnection.statusText289 ? savedConnection.statusText
290 : availability === "online"290 : availability === "online"
291 ? "Available · Relay online"291 ? "T3 Connect · Not added · Relay online"
292 : availability === "offline"292 : availability === "offline"
293 ? "Available · Relay offline"293 ? "T3 Connect · Not added · Relay offline"
294 : availability === "checking"294 : availability === "checking"
295 ? "Available · Checking relay status…"295 ? "T3 Connect · Not added · Checking relay status…"
296 : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable");296 : (Option.getOrNull(error)?.message ??
297 "T3 Connect · Not added · Relay status unavailable");
297 if (selection) {298 if (selection) {
298 return (299 return (
299 <label300 <label
...@@ -386,7 +387,7 @@ export function CloudEnvironmentConnectRows({...@@ -386,7 +387,7 @@ export function CloudEnvironmentConnectRows({
386 disabled={connectingEnvironmentIds.size > 0}387 disabled={connectingEnvironmentIds.size > 0}
387 onClick={() => void connectEnvironment(environment)}388 onClick={() => void connectEnvironment(environment)}
388 >389 >
389 {connectingEnvironmentIds.has(environment.environmentId) ? "Connecting…" : "Connect"}390 {connectingEnvironmentIds.has(environment.environmentId) ? "Adding…" : "Add"}
390 </Button>391 </Button>
391 )}392 )}
392 </div>393 </div>
apps/web/src/components/settings/ConnectionsSettings.tsx+268-371
...@@ -6,7 +6,6 @@ import {...@@ -6,7 +6,6 @@ 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";
10import { Atom } from "effect/unstable/reactivity";9import { Atom } from "effect/unstable/reactivity";
11import {10import {
12 type KeyboardEvent,11 type KeyboardEvent,
...@@ -42,7 +41,7 @@ import {...@@ -42,7 +41,7 @@ import {
42 type EnvironmentId,41 type EnvironmentId,
43 resolveEnvironmentMachineKind,42 resolveEnvironmentMachineKind,
44} from "@t3tools/contracts";43} from "@t3tools/contracts";
45import { connectionStatusText, connectionStatusTitle } from "@t3tools/client-runtime/connection";44import { connectionStatusText } from "@t3tools/client-runtime/connection";
46import {45import {
47 isAtomCommandInterrupted,46 isAtomCommandInterrupted,
48 squashAtomCommandFailure,47 squashAtomCommandFailure,
...@@ -67,8 +66,14 @@ import {...@@ -67,8 +66,14 @@ import {
67 useRelativeTimeTick,66 useRelativeTimeTick,
68} from "./settingsLayout";67} from "./settingsLayout";
69import { searchableSetting } from "./settingsSearch";68import { searchableSetting } from "./settingsSearch";
70import { EnvironmentIconPicker } from "./EnvironmentIconPicker";69import { EnvironmentIconMenu } from "./EnvironmentIconPicker";
71import { LoadBalancingPreference, LoadBalancingSettings } from "./LoadBalancingSettings";70import {
71 EnvironmentRow,
72 environmentTransportLabel,
73 formatDesktopSshTarget,
74} from "./EnvironmentRow";
75import { FoldedSettingsSection } from "./FoldedSettingsSection";
76import { LoadBalancingSettings } from "./LoadBalancingSettings";
72import { GitHubRoutingSettings } from "./GitHubRoutingSettings";77import { GitHubRoutingSettings } from "./GitHubRoutingSettings";
73import { Input } from "../ui/input";78import { Input } from "../ui/input";
74import { CommandShortcut } from "../ui/command";79import { CommandShortcut } from "../ui/command";
...@@ -106,7 +111,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";...@@ -106,7 +111,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
106import { QRCodeSvg } from "../ui/qr-code";111import { QRCodeSvg } from "../ui/qr-code";
107import { Spinner } from "../ui/spinner";112import { Spinner } from "../ui/spinner";
108import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";113import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
109import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";114import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu";
110import { Switch } from "../ui/switch";115import { Switch } from "../ui/switch";
111import { stackedThreadToast, toastManager } from "../ui/toast";116import { stackedThreadToast, toastManager } from "../ui/toast";
112import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";117import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
...@@ -158,7 +163,7 @@ import {...@@ -158,7 +163,7 @@ import {
158import { requestConfirmDialog } from "~/confirmDialog";163import { requestConfirmDialog } from "~/confirmDialog";
159import { useAtomCommand } from "../../state/use-atom-command";164import { useAtomCommand } from "../../state/use-atom-command";
160import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";165import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";
161import { ConnectionStatusDot, connectionPhaseDotClassName } from "../ConnectionStatusDot";166import { ConnectionStatusDot } from "../ConnectionStatusDot";
162import {167import {
163 ServerUpdateAction,168 ServerUpdateAction,
164 ServerUpdateProgress,169 ServerUpdateProgress,
...@@ -288,11 +293,6 @@ function AccessScopeSummary({...@@ -288,11 +293,6 @@ function AccessScopeSummary({
288 );293 );
289}294}
290295
291function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string {
292 const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname;
293 return target.port ? `${authority}:${target.port}` : authority;
294}
295
296function parseManualDesktopSshTarget(input: {296function parseManualDesktopSshTarget(input: {
297 readonly host: string;297 readonly host: string;
298 readonly username: string;298 readonly username: string;
...@@ -436,6 +436,20 @@ function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>)...@@ -436,6 +436,20 @@ function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>)
436 );436 );
437}437}
438438
439/** Closed-header summary for the Authorized clients fold. */
440function summarizeAuthorizedClients(
441 sessions: ReadonlyArray<ServerClientSessionRecord>,
442 links: ReadonlyArray<ServerPairingLinkRecord>,
443): string {
444 const parts = [
445 `${sessions.length} ${sessions.length === 1 ? "client" : "clients"}`,
446 links.length > 0
447 ? `${links.length} ${links.length === 1 ? "pairing link" : "pairing links"}`
448 : null,
449 ];
450 return parts.filter((part): part is string => part !== null).join(" · ");
451}
452
439function sortDesktopClientSessions(sessions: ReadonlyArray<ServerClientSessionRecord>) {453function sortDesktopClientSessions(sessions: ReadonlyArray<ServerClientSessionRecord>) {
440 return [...sessions].toSorted((left, right) => {454 return [...sessions].toSorted((left, right) => {
441 if (left.current !== right.current) {455 if (left.current !== right.current) {
...@@ -1419,6 +1433,44 @@ type SavedBackendListRowProps = {...@@ -1419,6 +1433,44 @@ type SavedBackendListRowProps = {
1419 onRemove: (environment: EnvironmentPresentation) => void;1433 onRemove: (environment: EnvironmentPresentation) => void;
1420};1434};
14211435
1436/**
1437 * Status word for a row subtitle: "Reconnecting: <reason>" instead of the
1438 * long-form sentence, since the row has one line and the full text is one
1439 * hover away.
1440 */
1441function savedBackendStatus(environment: EnvironmentPresentation): {
1442 readonly text: string;
1443 readonly tone: "muted" | "error";
1444} {
1445 if (!environment.entry.enabled) return { text: "Off", tone: "muted" };
1446 const { connection } = environment;
1447 switch (connection.phase) {
1448 case "connected":
1449 return { text: "Connected", tone: "muted" };
1450 case "connecting":
1451 return { text: "Connecting", tone: "muted" };
1452 case "reconnecting":
1453 return {
1454 text: connection.error ? `Reconnecting: ${connection.error}` : "Reconnecting",
1455 tone: "error",
1456 };
1457 case "error":
1458 return {
1459 text: connection.error ? `Connection failed: ${connection.error}` : "Connection failed",
1460 tone: "error",
1461 };
1462 case "offline":
1463 return { text: "Offline", tone: "muted" };
1464 case "available":
1465 return { text: "Not connected", tone: "muted" };
1466 }
1467}
1468
1469/**
1470 * One added machine in the Environments list. The switch is the main action;
1471 * the update icon appears only when that machine can take an update; the
1472 * row menu holds the icon override, trace ID, and removal.
1473 */
1422function SavedBackendListRow({1474function SavedBackendListRow({
1423 environment,1475 environment,
1424 removingEnvironmentId,1476 removingEnvironmentId,
...@@ -1427,19 +1479,8 @@ function SavedBackendListRow({...@@ -1427,19 +1479,8 @@ function SavedBackendListRow({
1427}: SavedBackendListRowProps) {1479}: SavedBackendListRowProps) {
1428 const environmentId = environment.environmentId;1480 const environmentId = environment.environmentId;
1429 const enabled = environment.entry.enabled;1481 const enabled = environment.entry.enabled;
1430 const connectionState = environment.connection.phase;1482 const isConnected = environment.connection.phase === "connected";
1431 const isConnected = connectionState === "connected";
1432 const isRemoving = removingEnvironmentId === environmentId;1483 const isRemoving = removingEnvironmentId === environmentId;
1433 const stateDotClassName = !enabled
1434 ? "bg-muted-foreground/40"
1435 : connectionState === "connected"
1436 ? "bg-success"
1437 : connectionState === "connecting" || connectionState === "reconnecting"
1438 ? "bg-warning"
1439 : connectionState === "error"
1440 ? "bg-destructive"
1441 : "bg-muted-foreground/40";
1442 const statusTooltip = enabled ? connectionStatusText(environment.connection) : "Off";
1443 const errorTraceId = environment.connection.traceId;1484 const errorTraceId = environment.connection.traceId;
1444 const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({1485 const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({
1445 target: "trace ID",1486 target: "trace ID",
...@@ -1470,25 +1511,18 @@ function SavedBackendListRow({...@@ -1470,25 +1511,18 @@ function SavedBackendListRow({
1470 const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));1511 const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));
1471 const resumingServerUpdate =1512 const resumingServerUpdate =
1472 serverUpdateState.status === "running" && serverUpdateState.stage === "resuming";1513 serverUpdateState.status === "running" && serverUpdateState.stage === "resuming";
1473 const sshTarget =1514 const status = savedBackendStatus(environment);
1474 environment.entry.target._tag === "SshConnectionTarget" &&1515 const serverVersion = environment.serverConfig?.environment.serverVersion ?? null;
1475 Option.isSome(environment.entry.profile) &&1516 const subtitleText = [
1476 environment.entry.profile.value._tag === "SshConnectionProfile"1517 environmentTransportLabel(environment),
1477 ? environment.entry.profile.value.target1518 resumingServerUpdate ? "Restarting" : status.text,
1478 : null;1519 enabled && versionMismatch ? serverVersion : null,
1479 const metadataBits = [1520 ]
1480 sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null,1521 .filter((value): value is string => value !== null)
1481 environment.relayManaged ? "T3 Connect" : null,1522 .join(" · ");
1482 enabled ? null : "Off",
1483 ].filter((value): value is string => value !== null);
14841523
1485 // The WSL backend is a desktop-managed local backend (it surfaces as a bearer
1486 // environment whose connection id is prefixed "local:"), not a remote
1487 // environment you connect to or remove here — its lifecycle is driven by the
1488 // WSL on/off + distro picker on this page.
1489 const isWslEnvironment = isDesktopLocalConnectionTarget(environment.entry.target);
1490 // Only a connected, enabled machine can take a remote update; a switched-off1524 // Only a connected, enabled machine can take a remote update; a switched-off
1491 // one keeps the "update available" note so the icon is not a surprise later.1525 // one keeps the version note so the icon is not a surprise later.
1492 const showUpdateAction =1526 const showUpdateAction =
1493 enabled &&1527 enabled &&
1494 isConnected &&1528 isConnected &&
...@@ -1496,153 +1530,96 @@ function SavedBackendListRow({...@@ -1496,153 +1530,96 @@ function SavedBackendListRow({
1496 (serverUpdateState.status === "idle" || serverUpdateState.status === "failed");1530 (serverUpdateState.status === "idle" || serverUpdateState.status === "failed");
14971531
1498 return (1532 return (
1499 <div className={cn(ITEM_ROW_CLASSNAME, !enabled && "opacity-60")}>1533 <EnvironmentRow
1500 <div className={ITEM_ROW_INNER_CLASSNAME}>1534 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
1501 <div className="min-w-0 flex-1 space-y-1">1535 label={environment.label}
1502 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">1536 dimmed={!enabled}
1503 <div className="flex min-h-5 min-w-0 max-w-full items-center gap-1.5">1537 subtitle={
1504 <ConnectionStatusDot1538 <Tooltip>
1505 tooltipText={statusTooltip}1539 <TooltipTrigger
1506 dotClassName={stateDotClassName}1540 render={
1507 pingClassName={1541 <span
1508 enabled &&1542 className={cn(
1509 (connectionState === "connecting" || connectionState === "reconnecting")1543 "block truncate",
1510 ? "bg-warning/60 duration-2000"1544 enabled && status.tone === "error" && !resumingServerUpdate && "text-destructive",
1511 : null1545 )}
1512 }
1513 />
1514 <EnvironmentMachineIcon
1515 aria-hidden
1516 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
1517 className="size-3.5 shrink-0 text-muted-foreground"
1518 />1546 />
1519 <h3 className="min-w-0 truncate text-sm font-medium text-foreground">1547 }
1520 {environment.label}1548 >
1521 </h3>1549 {subtitleText}
1522 </div>1550 </TooltipTrigger>
1523 {isConnected ? (1551 <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">
1524 <div className="w-full sm:w-auto">1552 {enabled ? connectionStatusText(environment.connection) : "Switched off"}
1525 <EnvironmentIconPicker1553 {versionMismatch
1526 environmentId={environmentId}1554 ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}`
1527 serverConfig={environment.serverConfig}1555 : ""}
1528 size="xs"1556 </TooltipPopup>
1529 />1557 </Tooltip>
1530 </div>1558 }
1531 ) : null}1559 below={
1560 serverUpdateState.status !== "idle" ? (
1561 <div className="mt-1 max-w-md">
1562 <ServerUpdateProgress state={serverUpdateState} />
1532 </div>1563 </div>
1533 {metadataBits.length > 0 ? (1564 ) : null
1534 <p className="truncate text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>1565 }
1535 ) : null}1566 >
1536 {serverUpdateState.status !== "idle" ? (1567 {showUpdateAction ? (
1537 <div className="max-w-md">1568 <ServerUpdateAction
1538 <ServerUpdateProgress state={serverUpdateState} />1569 environmentId={environmentId}
1539 </div>1570 serverLabel={`${environment.label} server`}
1540 ) : versionMismatch ? (1571 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
1541 <Tooltip>1572 desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}
1542 <TooltipTrigger1573 threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)}
1543 render={1574 targetVersion={versionMismatch.clientVersion}
1544 <button1575 label={serverUpdateState.status === "failed" ? "Retry update" : "Update"}
1545 type="button"1576 appearance="icon"
1546 className="w-fit cursor-help rounded-sm text-left text-muted-foreground text-xs"1577 />
1547 >1578 ) : null}
1548 Server update available1579 <Tooltip>
1549 </button>1580 <TooltipTrigger
1550 }1581 render={
1551 />1582 <Switch
1552 <TooltipPopup side="top">1583 size="sm"
1553 {versionMismatch.serverVersion} <span aria-hidden="true">→</span>{" "}1584 checked={enabled}
1554 {versionMismatch.clientVersion}1585 disabled={isRemoving}
1555 </TooltipPopup>1586 aria-label={`${enabled ? "Switch off" : "Switch on"} ${environment.label}`}
1556 </Tooltip>1587 onCheckedChange={(checked) => onSetEnabled(environmentId, checked)}
1557 ) : null}1588 />
1558 {enabled && environment.connection.error && !resumingServerUpdate ? (1589 }
1559 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">1590 />
1560 <span className="min-w-0 break-words">1591 <TooltipPopup side="top">{enabled ? "Switch off" : "Switch on"}</TooltipPopup>
1561 {connectionStatusText(environment.connection)}1592 </Tooltip>
1562 </span>1593 <Menu>
1563 {errorTraceId ? (1594 <MenuTrigger
1564 <button1595 render={
1565 type="button"1596 <Button
1566 className="shrink-0 underline underline-offset-2"1597 type="button"
1567 onClick={() => copyTraceId(errorTraceId)}1598 variant="ghost"
1568 >1599 size="icon-xs"
1569 Copy trace ID1600 className="text-muted-foreground hover:text-foreground"
1570 </button>1601 disabled={isRemoving}
1571 ) : null}1602 aria-label={`More actions for ${environment.label}`}
1572 </p>
1573 ) : null}
1574 </div>
1575 <div className="flex w-full shrink-0 items-center gap-1 sm:w-auto sm:justify-end">
1576 {showUpdateAction ? (
1577 <ServerUpdateAction
1578 environmentId={environmentId}
1579 serverLabel={`${environment.label} server`}
1580 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
1581 desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}
1582 threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)}
1583 targetVersion={versionMismatch.clientVersion}
1584 label={serverUpdateState.status === "failed" ? "Retry update" : "Update"}
1585 appearance="icon"
1586 />1603 />
1604 }
1605 >
1606 <EllipsisIcon className="size-3.5" />
1607 </MenuTrigger>
1608 <MenuPopup align="end" className="min-w-52">
1609 <EnvironmentIconMenu
1610 environmentId={environmentId}
1611 serverConfig={environment.serverConfig}
1612 />
1613 {errorTraceId ? (
1614 <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem>
1587 ) : null}1615 ) : null}
1588 {isWslEnvironment ? (1616 <MenuSeparator />
1589 <Tooltip>1617 <MenuItem variant="destructive" onClick={() => onRemove(environment)}>
1590 <TooltipTrigger1618 {isRemoving ? "Removing…" : "Remove from this device…"}
1591 render={1619 </MenuItem>
1592 <Button size="xs" variant="outline" disabled>1620 </MenuPopup>
1593 Managed locally1621 </Menu>
1594 </Button>1622 </EnvironmentRow>
1595 }
1596 />
1597 <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">
1598 Select the primary environment to turn the WSL backend on or off.
1599 </TooltipPopup>
1600 </Tooltip>
1601 ) : (
1602 <>
1603 <Tooltip>
1604 <TooltipTrigger
1605 render={
1606 <Switch
1607 size="sm"
1608 checked={enabled}
1609 disabled={isRemoving}
1610 aria-label={`${enabled ? "Switch off" : "Switch on"} ${environment.label}`}
1611 onCheckedChange={(checked) => onSetEnabled(environmentId, checked)}
1612 />
1613 }
1614 />
1615 <TooltipPopup side="top">{enabled ? "Switch off" : "Switch on"}</TooltipPopup>
1616 </Tooltip>
1617 <Menu>
1618 <MenuTrigger
1619 render={
1620 <Button
1621 type="button"
1622 variant="ghost"
1623 size="icon-xs"
1624 className="text-muted-foreground hover:text-foreground"
1625 disabled={isRemoving}
1626 aria-label={`More actions for ${environment.label}`}
1627 />
1628 }
1629 >
1630 <EllipsisIcon className="size-3.5" />
1631 </MenuTrigger>
1632 <MenuPopup align="end" className="min-w-52">
1633 {errorTraceId ? (
1634 <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem>
1635 ) : null}
1636 <MenuItem variant="destructive" onClick={() => onRemove(environment)}>
1637 {isRemoving ? "Removing…" : "Remove from this device…"}
1638 </MenuItem>
1639 </MenuPopup>
1640 </Menu>
1641 </>
1642 )}
1643 </div>
1644 </div>
1645 </div>
1646 );1623 );
1647}1624}
16481625
...@@ -1817,19 +1794,6 @@ export function ConnectionsSettings() {...@@ -1817,19 +1794,6 @@ export function ConnectionsSettings() {
1817 const keybindings = useAtomValue(primaryServerKeybindingsAtom);1794 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
1818 const { environments } = useEnvironments();1795 const { environments } = useEnvironments();
1819 const primaryEnvironment = usePrimaryEnvironment();1796 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];
1833 const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false });1797 const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false });
1834 const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, {1798 const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, {
1835 reportFailure: false,1799 reportFailure: false,
...@@ -1846,13 +1810,24 @@ export function ConnectionsSettings() {...@@ -1846,13 +1810,24 @@ export function ConnectionsSettings() {
1846 ? (primarySessionState.data.scopes ?? null)1810 ? (primarySessionState.data.scopes ?? null)
1847 : null;1811 : null;
1848 const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null);1812 const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null);
1813 // Catalog order is the order the machines were added; rows never jump when
1814 // one is switched off.
1849 const savedEnvironments = useMemo(1815 const savedEnvironments = useMemo(
1850 () =>1816 () =>
1851 environments1817 environments.filter(
1852 .filter((environment) => environment.entry.target._tag !== "PrimaryConnectionTarget")1818 (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget",
1853 .toSorted((left, right) => left.label.localeCompare(right.label)),1819 ),
1854 [environments],1820 [environments],
1855 );1821 );
1822 // The WSL backend is managed from the WSL row under this machine, so it has
1823 // no row of its own in the list.
1824 const listedEnvironments = useMemo(
1825 () =>
1826 savedEnvironments.filter(
1827 (environment) => !isDesktopLocalConnectionTarget(environment.entry.target),
1828 ),
1829 [savedEnvironments],
1830 );
1856 // Machines "Update all" can reach: switched on, connected, behind the client1831 // Machines "Update all" can reach: switched on, connected, behind the client
1857 // version, remotely updatable, and not already mid-update. The button only1832 // version, remotely updatable, and not already mid-update. The button only
1858 // renders when this list is non-empty.1833 // renders when this list is non-empty.
...@@ -1901,10 +1876,15 @@ export function ConnectionsSettings() {...@@ -1901,10 +1876,15 @@ export function ConnectionsSettings() {
1901 [savedServerUpdateStates],1876 [savedServerUpdateStates],
1902 );1877 );
1903 // Switched-off machines never receive threads, so they stay out of the1878 // Switched-off machines never receive threads, so they stay out of the
1904 // load balancing list.1879 // load balancing and GitHub sharing lists. The WSL backend has no row in
1880 // the Environments list but does take threads, so it stays in here. This
1881 // machine leads the list.
1905 const loadBalancingEnvironments = useMemo(1882 const loadBalancingEnvironments = useMemo(
1906 () => environments.filter((environment) => environment.entry.enabled),1883 () => [
1907 [environments],1884 ...(primaryEnvironment ? [primaryEnvironment] : []),
1885 ...savedEnvironments.filter((environment) => environment.entry.enabled),
1886 ],
1887 [primaryEnvironment, savedEnvironments],
1908 );1888 );
1909 const savedDesktopSshEnvironmentKeys = useMemo(() => {1889 const savedDesktopSshEnvironmentKeys = useMemo(() => {
1910 const keys = new Set<string>();1890 const keys = new Set<string>();
...@@ -3259,69 +3239,78 @@ export function ConnectionsSettings() {...@@ -3259,69 +3239,78 @@ export function ConnectionsSettings() {
3259 <SettingsSection3239 <SettingsSection
3260 {...searchableSetting("connections-environment")}3240 {...searchableSetting("connections-environment")}
3261 title={primaryEnvironment?.label ?? "Primary environment"}3241 title={primaryEnvironment?.label ?? "Primary environment"}
3262 >3242 icon={
3263 {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? (3243 <EnvironmentMachineIcon
3264 <SettingsRow3244 aria-hidden
3265 title={3245 kind={resolveEnvironmentMachineKind(primaryServerConfig)}
3266 primaryServerUpdateState.status === "failed"3246 className="size-4"
3267 ? "Update failed"3247 />
3268 : primaryServerUpdateState.status === "running"3248 }
3269 ? "Updating server"3249 headerAction={
3270 : "Server update available"3250 primaryEnvironmentId !== null ? (
3271 }3251 <Menu>
3272 description={3252 <MenuTrigger
3273 primaryServerUpdateState.status !== "idle" ? (3253 render={
3274 <ServerUpdateProgress state={primaryServerUpdateState} />3254 <Button
3275 ) : primaryVersionMismatch ? (3255 type="button"
3276 <Tooltip>3256 variant="ghost"
3277 <TooltipTrigger3257 size="icon-xs"
3278 render={3258 className="text-muted-foreground hover:text-foreground"
3279 <button type="button" className="w-fit cursor-help rounded-sm text-left">3259 aria-label="More actions for this machine"
3280 Update to match this client.
3281 </button>
3282 }
3283 />3260 />
3284 <TooltipPopup side="top">3261 }
3285 {primaryVersionMismatch.serverVersion} <span aria-hidden="true">→</span>{" "}3262 >
3286 {primaryVersionMismatch.clientVersion}3263 <EllipsisIcon className="size-3.5" />
3287 </TooltipPopup>3264 </MenuTrigger>
3288 </Tooltip>3265 <MenuPopup align="end" className="min-w-52">
3289 ) : null3266 <EnvironmentIconMenu
3290 }
3291 control={
3292 primaryVersionMismatch &&
3293 primaryEnvironmentId !== null &&
3294 primaryServerUpdateState.status !== "running" ? (
3295 <ServerUpdateAction
3296 size="sm"
3297 environmentId={primaryEnvironmentId}3267 environmentId={primaryEnvironmentId}
3298 serverLabel={3268 serverConfig={primaryServerConfig}
3299 primaryEnvironment ? `${primaryEnvironment.label} server` : "server"
3300 }
3301 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
3302 desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)}
3303 threadContinuation={supportsServerUpdateThreadContinuation(
3304 primaryServerConfig,
3305 )}
3306 targetVersion={primaryVersionMismatch.clientVersion}
3307 label={primaryServerUpdateState.status === "failed" ? "Retry" : "Update"}
3308 />3269 />
3309 ) : undefined3270 </MenuPopup>
3310 }3271 </Menu>
3311 />3272 ) : null
3312 ) : null}3273 }
3313 {primaryEnvironmentId !== null ? (3274 >
3314 <SettingsRow3275 <SettingsRow
3315 {...searchableSetting("environment-icon")}3276 title="Version"
3316 description="The machine other devices see this environment as. Automatic uses what the server detected."3277 description={
3317 control={3278 primaryServerUpdateState.status !== "idle" ? (
3318 <EnvironmentIconPicker3279 <ServerUpdateProgress state={primaryServerUpdateState} />
3280 ) : (
3281 [
3282 primaryServerConfig?.environment.serverVersion ?? null,
3283 primaryEnvironment?.displayUrl ?? null,
3284 ]
3285 .filter((value): value is string => value !== null)
3286 .join(" · ") || "Loading…"
3287 )
3288 }
3289 control={
3290 primaryVersionMismatch &&
3291 primaryEnvironmentId !== null &&
3292 primaryServerUpdateState.status !== "running" ? (
3293 <ServerUpdateAction
3294 size="sm"
3319 environmentId={primaryEnvironmentId}3295 environmentId={primaryEnvironmentId}
3320 serverConfig={primaryServerConfig}3296 serverLabel={
3297 primaryEnvironment ? `${primaryEnvironment.label} server` : "server"
3298 }
3299 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
3300 desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)}
3301 threadContinuation={supportsServerUpdateThreadContinuation(primaryServerConfig)}
3302 targetVersion={primaryVersionMismatch.clientVersion}
3303 label={
3304 primaryServerUpdateState.status === "failed"
3305 ? "Retry update"
3306 : `Update to ${primaryVersionMismatch.clientVersion}`
3307 }
3321 />3308 />
3322 }3309 ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? (
3323 />3310 <span className="text-xs text-muted-foreground">Up to date</span>
3324 ) : null}3311 ) : undefined
3312 }
3313 />
3325 {desktopBridge ? (3314 {desktopBridge ? (
3326 <>3315 <>
3327 {renderNetworkAccessRow()}3316 {renderNetworkAccessRow()}
...@@ -3339,10 +3328,14 @@ export function ConnectionsSettings() {...@@ -3339,10 +3328,14 @@ export function ConnectionsSettings() {
3339 </SettingsSection>3328 </SettingsSection>
33403329
3341 {isLocalBackendRemotelyReachable ? (3330 {isLocalBackendRemotelyReachable ? (
3342 <SettingsSection3331 <FoldedSettingsSection
3332 id="authorized-clients"
3343 title="Authorized clients"3333 title="Authorized clients"
3344 className="[&>div:first-child]:flex-wrap"3334 summary={summarizeAuthorizedClients(
3345 headerAction={3335 desktopClientSessions,
3336 visibleDesktopPairingLinks,
3337 )}
3338 control={
3346 <AuthorizedClientsHeaderAction3339 <AuthorizedClientsHeaderAction
3347 onPairingLinkCreated={handlePairingLinkCreated}3340 onPairingLinkCreated={handlePairingLinkCreated}
3348 clientSessions={desktopClientSessions}3341 clientSessions={desktopClientSessions}
...@@ -3359,7 +3352,7 @@ export function ConnectionsSettings() {...@@ -3359,7 +3352,7 @@ export function ConnectionsSettings() {
3359 >3352 >
3360 {renderAuthorizedClients("current")}3353 {renderAuthorizedClients("current")}
3361 </ScrollArea>3354 </ScrollArea>
3362 </SettingsSection>3355 </FoldedSettingsSection>
3363 ) : null}3356 ) : null}
3364 <AlertDialog3357 <AlertDialog
3365 open={isDesktopServerExposureDialogOpen}3358 open={isDesktopServerExposureDialogOpen}
...@@ -3649,10 +3642,10 @@ export function ConnectionsSettings() {...@@ -3649,10 +3642,10 @@ export function ConnectionsSettings() {
36493642
3650 return (3643 return (
3651 <SettingsPageContainer width="wide">3644 <SettingsPageContainer width="wide">
3645 {primarySettings}
3652 <SettingsSection3646 <SettingsSection
3653 {...searchableSetting("remote-environments")}3647 {...searchableSetting("remote-environments")}
3654 title="Environments"3648 title="Environments"
3655 variant="plain"
3656 headerAction={3649 headerAction={
3657 <div className="flex items-center gap-1">3650 <div className="flex items-center gap-1">
3658 {savedServerUpdateTargets.length > 0 ? (3651 {savedServerUpdateTargets.length > 0 ? (
...@@ -3725,118 +3718,22 @@ export function ConnectionsSettings() {...@@ -3725,118 +3718,22 @@ export function ConnectionsSettings() {
3725 </div>3718 </div>
3726 }3719 }
3727 >3720 >
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)]">3721 {listedEnvironments.map((environment) => (
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">3722 <SavedBackendListRow
3730 <ScrollArea3723 key={environment.environmentId}
3731 scrollFade3724 environment={environment}
3732 chainVerticalScroll3725 removingEnvironmentId={removingSavedEnvironmentId}
3733 className="max-h-64 lg:max-h-none lg:min-h-0 lg:flex-1"3726 onSetEnabled={handleSetSavedBackendEnabled}
3734 >3727 onRemove={handleRemoveSavedBackend}
3735 <div className="divide-y divide-border/50" aria-label="Environments">3728 />
3736 {(primaryEnvironment3729 ))}
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>
3834 <CloudRemoteEnvironmentRows3730 <CloudRemoteEnvironmentRows
3835 primaryEnvironmentId={primaryEnvironmentId}3731 primaryEnvironmentId={primaryEnvironmentId}
3836 savedEnvironments={savedEnvironments}3732 savedEnvironments={savedEnvironments}
3837 />3733 />
3838 </SettingsSection>3734 </SettingsSection>
3839 <LoadBalancingSettings environments={loadBalancingEnvironments} />3735 <LoadBalancingSettings environments={loadBalancingEnvironments} />
3736 <GitHubRoutingSettings environments={loadBalancingEnvironments} />
3840 </SettingsPageContainer>3737 </SettingsPageContainer>
3841 );3738 );
3842}3739}
apps/web/src/components/settings/EnvironmentIconPicker.tsx+57-81
...@@ -5,7 +5,6 @@ import {...@@ -5,7 +5,6 @@ import {
5 type EnvironmentId,5 type EnvironmentId,
6 type ServerConfig,6 type ServerConfig,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { useCallback } from "react";
98
10import { isElectron } from "../../env";9import { isElectron } from "../../env";
11import { usePrimarySessionState } from "../../environments/primary";10import { usePrimarySessionState } from "../../environments/primary";
...@@ -13,15 +12,20 @@ import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";...@@ -13,15 +12,20 @@ import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
13import { usePrimaryEnvironmentId } from "../../state/environments";12import { usePrimaryEnvironmentId } from "../../state/environments";
14import { useEnvironmentSessionState } from "../../state/session";13import { useEnvironmentSessionState } from "../../state/session";
15import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon";14import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
16import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";15import {
17import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";16 MenuItem,
17 MenuRadioGroup,
18 MenuRadioItem,
19 MenuSeparator,
20 MenuSub,
21 MenuSubPopup,
22 MenuSubTrigger,
23} from "../ui/menu";
18import {24import {
19 resolvePrimaryOperateAccess,25 resolvePrimaryOperateAccess,
20 resolveRemoteOperateAccess,26 resolveRemoteOperateAccess,
21} from "./ProviderSettingsPanel.logic";27} from "./ProviderSettingsPanel.logic";
2228
23const AUTOMATIC_VALUE = "automatic";
24
25/**29/**
26 * Why the picker is inert, in the order the user can do something about it.30 * Why the picker is inert, in the order the user can do something about it.
27 * Null means it can be changed.31 * Null means it can be changed.
...@@ -68,94 +72,66 @@ function useEnvironmentOperateAccess(environmentId: EnvironmentId) {...@@ -68,94 +72,66 @@ function useEnvironmentOperateAccess(environmentId: EnvironmentId) {
68}72}
6973
70/**74/**
71 * Picks the machine glyph an environment wears everywhere it is listed.75 * "Icon" submenu for an environment's row menu. Lists the machine kinds with
72 * "Automatic" clears the override so the server's own detection shows76 * the server's own detection marked, so the user can tell whether detection
73 * through; the label says what that currently resolves to so the user can77 * got it right before overriding. Picking the detected kind clears the
74 * tell whether detection got it right before overriding. The control stays78 * override. Locked environments show the reason as a disabled item instead of
75 * visible while locked so the current icon still reads, the same way79 * hiding the submenu, so the current icon still reads.
76 * server-scoped rows go inert instead of disappearing.
77 */80 */
78export function EnvironmentIconPicker({81export function EnvironmentIconMenu({
79 environmentId,82 environmentId,
80 serverConfig,83 serverConfig,
81 size = "sm",
82}: {84}: {
83 readonly environmentId: EnvironmentId;85 readonly environmentId: EnvironmentId;
84 readonly serverConfig: ServerConfig | null;86 readonly serverConfig: ServerConfig | null;
85 readonly size?: "xs" | "sm";
86}) {87}) {
87 const updateSettings = useUpdateEnvironmentSettings(environmentId);88 const updateSettings = useUpdateEnvironmentSettings(environmentId);
88 const operateAccess = useEnvironmentOperateAccess(environmentId);89 const operateAccess = useEnvironmentOperateAccess(environmentId);
89 const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess });90 const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess });
90 const override = serverConfig?.settings.environmentIcon ?? null;91 // With no detection the server falls back to "server", so picking that
91 const detected = serverConfig?.environment.platform.machine ?? null;92 // kind clears the override the same way picking the detected kind does.
93 const detected = serverConfig?.environment.platform.machine ?? "server";
92 const resolved = resolveEnvironmentMachineKind(serverConfig);94 const resolved = resolveEnvironmentMachineKind(serverConfig);
93 const value = override ?? AUTOMATIC_VALUE;
94 const automaticLabel =
95 detected === null ? "Automatic" : `Automatic (${ENVIRONMENT_MACHINE_KIND_LABELS[detected]})`;
96
97 const handleValueChange = useCallback(
98 (next: string | null) => {
99 if (next === null) return;
100 if (next === AUTOMATIC_VALUE) {
101 updateSettings({ environmentIcon: null });
102 } else if (isEnvironmentMachineKind(next)) {
103 updateSettings({ environmentIcon: next });
104 }
105 },
106 [updateSettings],
107 );
10895
109 const select = (
110 <Select value={value} onValueChange={handleValueChange} disabled={lock !== null}>
111 <SelectTrigger size={size} className="w-full sm:w-52" aria-label="Environment icon">
112 <SelectValue>
113 <span className="flex min-w-0 items-center gap-2">
114 <EnvironmentMachineIcon kind={resolved} className="size-3.5 shrink-0" />
115 <span className="truncate">
116 {override === null ? automaticLabel : ENVIRONMENT_MACHINE_KIND_LABELS[override]}
117 </span>
118 </span>
119 </SelectValue>
120 </SelectTrigger>
121 <SelectPopup align="end" alignItemWithTrigger={false}>
122 <SelectItem hideIndicator value={AUTOMATIC_VALUE}>
123 <span className="flex min-w-0 items-center gap-2">
124 <EnvironmentMachineIcon kind={detected ?? "server"} className="size-3.5 shrink-0" />
125 <span className="truncate">{automaticLabel}</span>
126 </span>
127 </SelectItem>
128 {ENVIRONMENT_MACHINE_KINDS.map((kind) => (
129 <SelectItem hideIndicator key={kind} value={kind}>
130 <span className="flex min-w-0 items-center gap-2">
131 <EnvironmentMachineIcon kind={kind} className="size-3.5 shrink-0" />
132 <span className="truncate">{ENVIRONMENT_MACHINE_KIND_LABELS[kind]}</span>
133 </span>
134 </SelectItem>
135 ))}
136 </SelectPopup>
137 </Select>
138 );
139
140 if (lock === null) {
141 return select;
142 }
143 return (96 return (
144 <Tooltip>97 <MenuSub>
145 <TooltipTrigger98 <MenuSubTrigger>
146 render={99 <EnvironmentMachineIcon kind={resolved} />
147 // Focusable so keyboard users can still reach the explanation.100 Icon
148 <span101 </MenuSubTrigger>
149 tabIndex={0}102 <MenuSubPopup className="min-w-44">
150 className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-auto"103 {lock !== null ? (
151 />104 <>
152 }105 <MenuItem disabled className="whitespace-normal text-xs">
153 >106 {lock}
154 <span className="flex w-full items-center sm:w-auto">{select}</span>107 </MenuItem>
155 </TooltipTrigger>108 <MenuSeparator />
156 <TooltipPopup side="top" className="max-w-72">109 </>
157 {lock}110 ) : null}
158 </TooltipPopup>111 <MenuRadioGroup
159 </Tooltip>112 value={resolved}
113 onValueChange={(next) => {
114 if (lock !== null || !isEnvironmentMachineKind(next)) return;
115 updateSettings({ environmentIcon: next === detected ? null : next });
116 }}
117 >
118 {ENVIRONMENT_MACHINE_KINDS.map((kind) => (
119 <MenuRadioItem key={kind} value={kind} disabled={lock !== null}>
120 <span className="flex min-w-0 items-center gap-2">
121 <EnvironmentMachineIcon kind={kind} className="size-3.5 shrink-0" />
122 <span className="min-w-0 flex-1 truncate">
123 {ENVIRONMENT_MACHINE_KIND_LABELS[kind]}
124 </span>
125 {kind === detected ? (
126 <span className="shrink-0 text-xs text-muted-foreground">
127 {serverConfig?.environment.platform.machine ? "detected" : "default"}
128 </span>
129 ) : null}
130 </span>
131 </MenuRadioItem>
132 ))}
133 </MenuRadioGroup>
134 </MenuSubPopup>
135 </MenuSub>
160 );136 );
161}137}
apps/web/src/components/settings/EnvironmentRow.tsx created+74
...@@ -0,0 +1,74 @@
1import type { DesktopSshEnvironmentTarget, EnvironmentMachineKind } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import type { ReactNode } from "react";
4
5import { cn } from "~/lib/utils";
6import type { EnvironmentPresentation } from "~/state/environments";
7import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
8import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
9
10export function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): string {
11 const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname;
12 return target.port ? `${authority}:${target.port}` : authority;
13}
14
15/**
16 * How this client reaches a machine, printed first in every environment row so
17 * T3 Connect, SSH, WSL, and plain remote links are told apart without a legend.
18 */
19export function environmentTransportLabel(environment: EnvironmentPresentation): string {
20 const { entry } = environment;
21 if (entry.target._tag === "PrimaryConnectionTarget") return "This machine";
22 if (environment.relayManaged) return "T3 Connect";
23 if (isDesktopLocalConnectionTarget(entry.target)) return "WSL";
24 if (
25 entry.target._tag === "SshConnectionTarget" &&
26 Option.isSome(entry.profile) &&
27 entry.profile.value._tag === "SshConnectionProfile"
28 ) {
29 return `SSH ${formatDesktopSshTarget(entry.profile.value.target)}`;
30 }
31 return environment.displayUrl ?? "Remote link";
32}
33
34/**
35 * One machine in a grouped settings list: icon, name, a single subtitle line,
36 * and controls on the right. Every environment list on the Connections page
37 * uses this so the lists share one rhythm.
38 */
39export function EnvironmentRow({
40 kind,
41 label,
42 subtitle,
43 below,
44 dimmed = false,
45 className,
46 children,
47}: {
48 readonly kind: EnvironmentMachineKind;
49 readonly label: string;
50 readonly subtitle: ReactNode;
51 /** Extra content under the subtitle, such as update progress. */
52 readonly below?: ReactNode;
53 readonly dimmed?: boolean;
54 readonly className?: string;
55 readonly children?: ReactNode;
56}) {
57 return (
58 <div
59 className={cn(
60 "grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 px-3 py-2.5 sm:px-4",
61 dimmed && "opacity-60",
62 className,
63 )}
64 >
65 <EnvironmentMachineIcon aria-hidden kind={kind} className="size-4 text-muted-foreground" />
66 <div className="min-w-0">
67 <p className="truncate text-sm font-medium text-foreground">{label}</p>
68 <div className="truncate text-xs text-muted-foreground">{subtitle}</div>
69 {below}
70 </div>
71 <div className="flex shrink-0 items-center gap-1">{children}</div>
72 </div>
73 );
74}
apps/web/src/components/settings/FoldedSettingsSection.tsx created+67
...@@ -0,0 +1,67 @@
1import { ChevronRightIcon } from "lucide-react";
2import { type ReactNode, useState } from "react";
3
4import { cn } from "~/lib/utils";
5import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
6import { useSettingsSearchTarget, useSettingsSearchTargetId } from "./settingsLayout";
7
8/**
9 * A grouped settings section that starts closed. The header carries the title,
10 * a one line summary of what is set inside, and an optional control such as
11 * the section's own switch. A settings search that targets the section opens it.
12 */
13export function FoldedSettingsSection({
14 id,
15 title,
16 summary,
17 control,
18 children,
19}: {
20 readonly id: string;
21 readonly title: string;
22 readonly summary?: string | null;
23 readonly control?: ReactNode;
24 readonly children: ReactNode;
25}) {
26 const [open, setOpen] = useState(false);
27 const searchTargetId = useSettingsSearchTargetId();
28 const targetRef = useSettingsSearchTarget<HTMLElement>(id);
29 // A search jump lands inside the fold, so open it before the scroll runs.
30 const [openedForTarget, setOpenedForTarget] = useState<string | null>(null);
31 if (searchTargetId === id && openedForTarget !== id) {
32 setOpenedForTarget(id);
33 if (!open) setOpen(true);
34 }
35
36 return (
37 <section id={id} ref={targetRef} tabIndex={-1} className="outline-none">
38 <Collapsible
39 open={open}
40 onOpenChange={setOpen}
41 className="rounded-xl border border-border/60 bg-card/40 text-foreground shadow-xs/5"
42 >
43 <div className="flex items-center gap-4 px-3 sm:px-4">
44 <CollapsibleTrigger className="flex min-h-11 min-w-0 flex-1 items-center gap-2 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-md">
45 <ChevronRightIcon
46 aria-hidden
47 className={cn(
48 "size-4 shrink-0 text-muted-foreground transition-transform duration-150 motion-reduce:transition-none",
49 open && "rotate-90",
50 )}
51 />
52 <span className="shrink-0 text-sm font-medium">{title}</span>
53 {summary ? (
54 <span className="min-w-0 truncate text-xs text-muted-foreground">{summary}</span>
55 ) : null}
56 </CollapsibleTrigger>
57 {control ? <div className="flex shrink-0 items-center">{control}</div> : null}
58 </div>
59 <CollapsiblePanel>
60 <div className="border-t border-border/50 [&>*+*]:border-t [&>*+*]:border-border/50">
61 {children}
62 </div>
63 </CollapsiblePanel>
64 </Collapsible>
65 </section>
66 );
67}
apps/web/src/components/settings/GitHubRoutingSettings.test.ts created+20
...@@ -0,0 +1,20 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { summarizeGitHubRouting } from "./GitHubRoutingSettings";
4
5describe("summarizeGitHubRouting", () => {
6 it("is empty when no machine shares", () => {
7 expect(summarizeGitHubRouting([{ label: "alvin", permission: "off" }])).toBeNull();
8 });
9
10 it("groups sharing machines by permission, read and act first", () => {
11 expect(
12 summarizeGitHubRouting([
13 { label: "alvin", permission: "read" },
14 { label: "bb-1", permission: "read-write" },
15 { label: "cup2", permission: "off" },
16 { label: "Theo's MacBook Pro", permission: "read-write" },
17 ]),
18 ).toBe("bb-1, Theo's MacBook Pro read and act · alvin read PRs");
19 });
20});
apps/web/src/components/settings/GitHubRoutingSettings.tsx+90-56
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";2import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
3import {3import {
4 gitHubRoutingConnectionKey,4 gitHubRoutingConnectionKey,
5 gitHubRoutingPermissionFor,5 gitHubRoutingPermissionFor,
...@@ -12,7 +12,8 @@ import type { EnvironmentPresentation } from "~/state/environments";...@@ -12,7 +12,8 @@ import type { EnvironmentPresentation } from "~/state/environments";
12import { useAtomCommand } from "~/state/use-atom-command";12import { useAtomCommand } from "~/state/use-atom-command";
13import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";13import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
14import { toastManager } from "../ui/toast";14import { toastManager } from "../ui/toast";
15import { SettingsRow, SettingsSection } from "./settingsLayout";15import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow";
16import { FoldedSettingsSection } from "./FoldedSettingsSection";
16import { searchableSetting } from "./settingsSearch";17import { searchableSetting } from "./settingsSearch";
1718
18const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> = [19const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> = [
...@@ -21,73 +22,106 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }>...@@ -21,73 +22,106 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }>
21 { value: "read-write", label: "Read and act" },22 { value: "read-write", label: "Read and act" },
22];23];
2324
25const summaryLabels = { "read-write": "read and act", read: "read PRs" } as const;
26
27/**
28 * Closed-header summary: the machines that share, grouped by permission.
29 * Null when nothing is shared.
30 */
31export function summarizeGitHubRouting(
32 entries: ReadonlyArray<{ readonly label: string; readonly permission: GitHubRoutingPermission }>,
33): string | null {
34 const groups = (["read-write", "read"] as const).flatMap((permission) => {
35 const labels = entries.filter((entry) => entry.permission === permission);
36 return labels.length === 0
37 ? []
38 : [`${labels.map((entry) => entry.label).join(", ")} ${summaryLabels[permission]}`];
39 });
40 return groups.length === 0 ? null : groups.join(" · ");
41}
42
43/**
44 * Folded section under the environments list. One row per switched-on machine
45 * with how much of its GitHub access the other machines may use. The trust
46 * warning is the first line of the body so it sits next to the control.
47 * Rendered only when two or more machines are on.
48 */
24export function GitHubRoutingSettings({49export function GitHubRoutingSettings({
25 environments,50 environments,
26 selectedEnvironmentId,
27}: {51}: {
28 readonly environments: ReadonlyArray<EnvironmentPresentation>;52 readonly environments: ReadonlyArray<EnvironmentPresentation>;
29 readonly selectedEnvironmentId: EnvironmentId;
30}) {53}) {
31 const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom);54 const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom);
32 const catalog = useAtomValue(environmentCatalog.catalogValueAtom);55 const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
33 const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission);56 const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission);
34 const [saving, setSaving] = useState(false);57 const [saving, setSaving] = useState(false);
35 const selectedEnvironments = environments.filter(
36 (environment) => environment.environmentId === selectedEnvironmentId,
37 );
3858
59 if (environments.length < 2) return null;
60
61 const { id, title } = searchableSetting("github-routing");
39 return (62 return (
40 <SettingsSection {...searchableSetting("github-routing")}>63 <FoldedSettingsSection
41 <SettingsRow64 id={id}
42 title="Share GitHub access"65 title={title}
43 className="sm:[&>div]:grid-cols-1 [&_p]:max-w-none"66 summary={
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."67 summarizeGitHubRouting(
45 />68 environments.map((environment) => ({
46 {selectedEnvironments.map((environment) => (69 label: environment.label,
47 <SettingsRow70 permission: gitHubRoutingPermissionFor(environment.entry, permissions),
71 })),
72 ) ?? "Off"
73 }
74 >
75 <p className="px-3 py-2.5 text-xs text-muted-foreground sm:px-4">
76 Machines you trust here can read PR data through each other's GitHub access. Enable both
77 machines. Read and act may use broader permissions than the machine that owns them. This
78 applies only to this device.
79 </p>
80 {environments.map((environment) => (
81 <EnvironmentRow
48 key={environment.environmentId}82 key={environment.environmentId}
49 title="GitHub access"83 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
50 description={environment.displayUrl ?? "T3 Connect"}84 label={environment.label}
51 control={85 subtitle={environmentTransportLabel(environment)}
52 <Select86 >
53 items={options}87 <Select
54 value={gitHubRoutingPermissionFor(environment.entry, permissions)}88 items={options}
55 disabled={89 value={gitHubRoutingPermissionFor(environment.entry, permissions)}
56 !catalog.isReady || saving || gitHubRoutingConnectionKey(environment.entry) === null90 disabled={
57 }91 !catalog.isReady || saving || gitHubRoutingConnectionKey(environment.entry) === null
58 onValueChange={(permission) => {92 }
59 if (permission === null) return;93 onValueChange={(permission) => {
60 setSaving(true);94 if (permission === null) return;
61 void update({ environmentId: environment.environmentId, permission }).then(95 setSaving(true);
62 (result) => {96 void update({ environmentId: environment.environmentId, permission }).then(
63 setSaving(false);97 (result) => {
64 if (result._tag === "Failure")98 setSaving(false);
65 toastManager.add({99 if (result._tag === "Failure")
66 type: "error",100 toastManager.add({
67 title: "Could not save GitHub routing permission",101 type: "error",
68 });102 title: "Could not save GitHub routing permission",
69 },103 });
70 );104 },
71 }}105 );
106 }}
107 >
108 <SelectTrigger
109 size="xs"
110 className="w-32"
111 aria-label={`${environment.label} GitHub routing`}
72 >112 >
73 <SelectTrigger113 <SelectValue />
74 size="sm"114 </SelectTrigger>
75 className="w-full sm:w-40"115 <SelectPopup align="end" alignItemWithTrigger={false}>
76 aria-label={`${environment.label} GitHub routing`}116 {options.map(({ value, label }) => (
77 >117 <SelectItem key={value} value={value}>
78 <SelectValue />118 {label}
79 </SelectTrigger>119 </SelectItem>
80 <SelectPopup align="end" alignItemWithTrigger={false}>120 ))}
81 {options.map(({ value, label }) => (121 </SelectPopup>
82 <SelectItem key={value} value={value}>122 </Select>
83 {label}123 </EnvironmentRow>
84 </SelectItem>
85 ))}
86 </SelectPopup>
87 </Select>
88 }
89 />
90 ))}124 ))}
91 </SettingsSection>125 </FoldedSettingsSection>
92 );126 );
93}127}
apps/web/src/components/settings/LoadBalancingSettings.test.ts created+32
...@@ -0,0 +1,32 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { loadPreferenceForWeight, summarizeLoadPreferences } from "./LoadBalancingSettings";
5
6const machines = [
7 { environmentId: EnvironmentId.make("a"), label: "alvin" },
8 { environmentId: EnvironmentId.make("b"), label: "bb-1" },
9 { environmentId: EnvironmentId.make("c"), label: "ProMini" },
10];
11
12describe("loadPreferenceForWeight", () => {
13 it("snaps legacy slider weights onto the four preferences", () => {
14 expect(loadPreferenceForWeight(undefined)).toBe(50);
15 expect(loadPreferenceForWeight(0)).toBe(0);
16 expect(loadPreferenceForWeight(10)).toBe(25);
17 expect(loadPreferenceForWeight(50)).toBe(50);
18 expect(loadPreferenceForWeight(80)).toBe(100);
19 });
20});
21
22describe("summarizeLoadPreferences", () => {
23 it("is empty when every machine is at Normal", () => {
24 expect(summarizeLoadPreferences(machines, { a: 50 })).toBeNull();
25 });
26
27 it("lists only the machines that differ from Normal, in list order", () => {
28 expect(summarizeLoadPreferences(machines, { b: 100, c: 0 })).toBe(
29 "bb-1 prefer · ProMini manual only",
30 );
31 });
32});
apps/web/src/components/settings/LoadBalancingSettings.tsx+97-65
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
2
1import {3import {
2 useClientSettings,4 useClientSettings,
3 useClientSettingsHydrated,5 useClientSettingsHydrated,
...@@ -6,7 +8,8 @@ import {...@@ -6,7 +8,8 @@ import {
6import type { EnvironmentPresentation } from "~/state/environments";8import type { EnvironmentPresentation } from "~/state/environments";
7import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";9import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
8import { Switch } from "../ui/switch";10import { Switch } from "../ui/switch";
9import { SettingsRow, SettingsSection } from "./settingsLayout";11import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow";
12import { FoldedSettingsSection } from "./FoldedSettingsSection";
10import { searchableSetting } from "./settingsSearch";13import { searchableSetting } from "./settingsSearch";
1114
12const preferences = [15const preferences = [
...@@ -14,8 +17,44 @@ const preferences = [...@@ -14,8 +17,44 @@ const preferences = [
14 { value: 50, label: "Normal" },17 { value: 50, label: "Normal" },
15 { value: 25, label: "Less often" },18 { value: 25, label: "Less often" },
16 { value: 0, label: "Manual only" },19 { value: 0, label: "Manual only" },
17];20] as const;
21
22type LoadPreference = (typeof preferences)[number]["value"];
23
24/** Snaps a saved weight (older builds stored a slider value) onto the four preferences. */
25export function loadPreferenceForWeight(weight: number | undefined): LoadPreference {
26 if (weight === undefined || weight === 50) return 50;
27 if (weight === 0) return 0;
28 return weight < 50 ? 25 : 100;
29}
30
31function preferenceLabel(preference: LoadPreference): string {
32 return preferences.find((entry) => entry.value === preference)!.label;
33}
1834
35/**
36 * Closed-header summary: the machines not at Normal, so the folded section
37 * still tells you what is set. Null when every machine is at the default.
38 */
39export function summarizeLoadPreferences(
40 environments: ReadonlyArray<Pick<EnvironmentPresentation, "environmentId" | "label">>,
41 weights: Readonly<Record<string, number>>,
42): string | null {
43 const parts = environments.flatMap((environment) => {
44 const preference = loadPreferenceForWeight(weights[environment.environmentId]);
45 return preference === 50
46 ? []
47 : [`${environment.label} ${preferenceLabel(preference).toLowerCase()}`];
48 });
49 return parts.length === 0 ? null : parts.join(" · ");
50}
51
52/**
53 * Folded section under the environments list. Its switch turns balancing on
54 * for this client, and the body holds one row per switched-on machine with
55 * how often that machine should receive new threads. Rendered only when two
56 * or more machines are on, since one machine has nothing to balance against.
57 */
19export function LoadBalancingSettings({58export function LoadBalancingSettings({
20 environments,59 environments,
21}: {60}: {
...@@ -25,78 +64,71 @@ export function LoadBalancingSettings({...@@ -25,78 +64,71 @@ export function LoadBalancingSettings({
25 const settingsHydrated = useClientSettingsHydrated();64 const settingsHydrated = useClientSettingsHydrated();
26 const updateSettings = useUpdateClientSettings();65 const updateSettings = useUpdateClientSettings();
2766
28 if (environments.length < 2) {67 if (environments.length < 2) return null;
29 return (
30 <SettingsSection {...searchableSetting("load-balancing")} variant="plain">
31 <p className="px-3 text-sm text-muted-foreground sm:px-4">
32 Connect another machine to automatically balance load across environments.
33 </p>
34 </SettingsSection>
35 );
36 }
3768
69 const { id, title } = searchableSetting("load-balancing");
38 return (70 return (
39 <SettingsSection {...searchableSetting("load-balancing")}>71 <FoldedSettingsSection
40 <SettingsRow72 id={id}
41 title="Automatically balance load"73 title={title}
42 description="Choose a machine automatically for new threads in shared projects."74 summary={
43 control={75 settings.loadBalancingEnabled
44 <Switch76 ? summarizeLoadPreferences(environments, settings.loadBalancingWeights)
45 aria-label="Automatically balance load"77 : "Off"
46 checked={settings.loadBalancingEnabled}78 }
47 disabled={!settingsHydrated}
48 onCheckedChange={(loadBalancingEnabled) => updateSettings({ loadBalancingEnabled })}
49 />
50 }
51 />
52 </SettingsSection>
53 );
54}
55
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={79 control={
69 <Select80 <Switch
70 items={preferences}81 aria-label="Automatically balance load"
71 value={preference}82 checked={settings.loadBalancingEnabled}
72 disabled={!settingsHydrated || !settings.loadBalancingEnabled}83 disabled={!settingsHydrated}
73 onValueChange={(value) => {84 onCheckedChange={(loadBalancingEnabled) => updateSettings({ loadBalancingEnabled })}
74 if (value !== null) {85 />
86 }
87 >
88 <p className="px-3 py-2.5 text-xs text-muted-foreground sm:px-4">
89 New threads in shared projects start on the machine with the most free CPU and memory,
90 weighted by each machine's preference.
91 </p>
92 {environments.map((environment) => (
93 <EnvironmentRow
94 key={environment.environmentId}
95 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
96 label={environment.label}
97 subtitle={environmentTransportLabel(environment)}
98 >
99 <Select
100 items={preferences}
101 value={loadPreferenceForWeight(
102 settings.loadBalancingWeights[environment.environmentId],
103 )}
104 disabled={!settingsHydrated || !settings.loadBalancingEnabled}
105 onValueChange={(value) => {
106 if (value === null) return;
75 updateSettings({107 updateSettings({
76 loadBalancingWeights: {108 loadBalancingWeights: {
77 ...settings.loadBalancingWeights,109 ...settings.loadBalancingWeights,
78 [environment.environmentId]: value,110 [environment.environmentId]: value,
79 },111 },
80 });112 });
81 }113 }}
82 }}
83 >
84 <SelectTrigger
85 size="sm"
86 className="w-full sm:w-40"
87 aria-label={`${environment.label} load preference`}
88 >114 >
89 <SelectValue />115 <SelectTrigger
90 </SelectTrigger>116 size="xs"
91 <SelectPopup align="end" alignItemWithTrigger={false}>117 className="w-32"
92 {preferences.map(({ value, label }) => (118 aria-label={`${environment.label} load preference`}
93 <SelectItem key={value} value={value}>119 >
94 {label}120 <SelectValue />
95 </SelectItem>121 </SelectTrigger>
96 ))}122 <SelectPopup align="end" alignItemWithTrigger={false}>
97 </SelectPopup>123 {preferences.map(({ value, label }) => (
98 </Select>124 <SelectItem key={value} value={value}>
99 }125 {label}
100 />126 </SelectItem>
127 ))}
128 </SelectPopup>
129 </Select>
130 </EnvironmentRow>
131 ))}
132 </FoldedSettingsSection>
101 );133 );
102}134}
apps/web/src/components/settings/settingsSearch.ts+3-3
...@@ -682,7 +682,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -682,7 +682,7 @@ export const SETTINGS_SEARCH_ITEMS = [
682 },682 },
683 {683 {
684 id: "connections-environment",684 id: "connections-environment",
685 title: "This environment",685 title: "This machine",
686 to: "/settings/connections",686 to: "/settings/connections",
687 searchTerms: [687 searchTerms: [
688 "connections server backend local remote access administrative permissions scope pairing links qr code authorized clients sessions revoke endpoint",688 "connections server backend local remote access administrative permissions scope pairing links qr code authorized clients sessions revoke endpoint",
...@@ -690,7 +690,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -690,7 +690,7 @@ export const SETTINGS_SEARCH_ITEMS = [
690 },690 },
691 {691 {
692 id: "remote-environments",692 id: "remote-environments",
693 title: "Remote environments",693 title: "Environments",
694 to: "/settings/connections",694 to: "/settings/connections",
695 searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"],695 searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"],
696 },696 },
...@@ -704,7 +704,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -704,7 +704,7 @@ export const SETTINGS_SEARCH_ITEMS = [
704 },704 },
705 {705 {
706 id: "github-routing",706 id: "github-routing",
707 title: "GitHub routing",707 title: "GitHub sharing",
708 to: "/settings/connections",708 to: "/settings/connections",
709 searchTerms: ["pull request trusted environments shared credentials permissions read actions"],709 searchTerms: ["pull request trusted environments shared credentials permissions read actions"],
710 },710 },
docs/user/remote-access.md+2-1
...@@ -65,7 +65,8 @@ another link to share....@@ -65,7 +65,8 @@ another link to share.
6565
66Auto balance is off by default. On web and desktop, enable it in66Auto balance is off by default. On web and desktop, enable it in
67**Settings → Connections → Load balancing** to automatically choose a machine for67**Settings → Connections → Load balancing** to automatically choose a machine for
68new threads in projects grouped across connected environments.68new threads in projects grouped across connected environments. The section
69appears once two or more machines are switched on.
69Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and70Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and
70memory available, **Less often** to reduce its share, or **Manual only** to exclude71memory available, **Less often** to reduce its share, or **Manual only** to exclude
71it from automatic selection. These are preferences, not fixed traffic percentages.72it from automatic selection. These are preferences, not fixed traffic percentages.
docs/user/source-control.md+1-1
...@@ -99,7 +99,7 @@ GitLab calls these merge requests....@@ -99,7 +99,7 @@ GitLab calls these merge requests.
99GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also99GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also
100supports approving waiting fork workflows and opening a revert pull request for a merged change.100supports approving waiting fork workflows and opening a revert pull request for a merged change.
101101
102GitHub routing is off by default. In Settings → Connections (Environments on mobile), choose102GitHub sharing is off by default. In Settings → Connections → GitHub sharing (Environments on mobile), choose
103**Read PRs** or **Read and act** for each environment you trust to share GitHub access.103**Read PRs** or **Read and act** for each environment you trust to share GitHub access.
104Enable both the original environment and the environment answering its requests on this client.104Enable both the original environment and the environment answering its requests on this client.
105**Read and act** can use broader GitHub permissions than the original environment's credential;105**Read and act** can use broader GitHub permissions than the original environment's credential;