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", () => {
175175 finishDiscovery(linkedMachines);
176176 });
177177 expect(onDiscoveryReady).toHaveBeenCalledTimes(1);
178 expect(renderer!.root.findByType("button").children).toEqual(["Connect"]);
178 expect(renderer!.root.findByType("button").children).toEqual(["Add"]);
179179 });
180180
181181 it("connects and selects discovered computers by default without overwriting deselection", async () => {
......@@ -232,7 +232,7 @@ describe("cloud onboarding discovery", () => {
232232 expect(renderer!.root.findAllByType("p").map((node) => node.children)).toContainEqual([
233233 "Work laptop",
234234 ]);
235 expect(renderer!.root.findByType("button").children).toEqual(["Connect"]);
235 expect(renderer!.root.findByType("button").children).toEqual(["Add"]);
236236 await advance(30_000);
237237 expect(discovery.listEnvironments).toHaveBeenCalledTimes(2);
238238 });
......@@ -240,7 +240,7 @@ describe("cloud onboarding discovery", () => {
240240 it("keeps a discovered computer visible when it is added to the browser", async () => {
241241 discovery.listEnvironments.mockResolvedValue(linkedMachines);
242242 await mount();
243 expect(renderer!.root.findByType("button").children).toEqual(["Connect"]);
243 expect(renderer!.root.findByType("button").children).toEqual(["Add"]);
244244 await act(async () => {
245245 renderer!.update(
246246 <CloudEnvironmentConnectRows
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+6-5
......@@ -288,12 +288,13 @@ export function CloudEnvironmentConnectRows({
288288 const statusText = savedConnection
289289 ? savedConnection.statusText
290290 : availability === "online"
291 ? "Available · Relay online"
291 ? "T3 Connect · Not added · Relay online"
292292 : availability === "offline"
293 ? "Available · Relay offline"
293 ? "T3 Connect · Not added · Relay offline"
294294 : availability === "checking"
295 ? "Available · Checking relay status…"
296 : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable");
295 ? "T3 Connect · Not added · Checking relay status…"
296 : (Option.getOrNull(error)?.message ??
297 "T3 Connect · Not added · Relay status unavailable");
297298 if (selection) {
298299 return (
299300 <label
......@@ -386,7 +387,7 @@ export function CloudEnvironmentConnectRows({
386387 disabled={connectingEnvironmentIds.size > 0}
387388 onClick={() => void connectEnvironment(environment)}
388389 >
389 {connectingEnvironmentIds.has(environment.environmentId) ? "Connecting…" : "Connect"}
390 {connectingEnvironmentIds.has(environment.environmentId) ? "Adding…" : "Add"}
390391 </Button>
391392 )}
392393 </div>
apps/web/src/components/settings/ConnectionsSettings.tsx+268-371
......@@ -6,7 +6,6 @@ import {
66 TerminalIcon,
77} from "lucide-react";
88import { useAtomValue } from "@effect/atom-react";
9import { useLocation } from "@tanstack/react-router";
109import { Atom } from "effect/unstable/reactivity";
1110import {
1211 type KeyboardEvent,
......@@ -42,7 +41,7 @@ import {
4241 type EnvironmentId,
4342 resolveEnvironmentMachineKind,
4443} from "@t3tools/contracts";
45import { connectionStatusText, connectionStatusTitle } from "@t3tools/client-runtime/connection";
44import { connectionStatusText } from "@t3tools/client-runtime/connection";
4645import {
4746 isAtomCommandInterrupted,
4847 squashAtomCommandFailure,
......@@ -67,8 +66,14 @@ import {
6766 useRelativeTimeTick,
6867} from "./settingsLayout";
6968import { searchableSetting } from "./settingsSearch";
70import { EnvironmentIconPicker } from "./EnvironmentIconPicker";
71import { LoadBalancingPreference, LoadBalancingSettings } from "./LoadBalancingSettings";
69import { EnvironmentIconMenu } from "./EnvironmentIconPicker";
70import {
71 EnvironmentRow,
72 environmentTransportLabel,
73 formatDesktopSshTarget,
74} from "./EnvironmentRow";
75import { FoldedSettingsSection } from "./FoldedSettingsSection";
76import { LoadBalancingSettings } from "./LoadBalancingSettings";
7277import { GitHubRoutingSettings } from "./GitHubRoutingSettings";
7378import { Input } from "../ui/input";
7479import { CommandShortcut } from "../ui/command";
......@@ -106,7 +111,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
106111import { QRCodeSvg } from "../ui/qr-code";
107112import { Spinner } from "../ui/spinner";
108113import { 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";
110115import { Switch } from "../ui/switch";
111116import { stackedThreadToast, toastManager } from "../ui/toast";
112117import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -158,7 +163,7 @@ import {
158163import { requestConfirmDialog } from "~/confirmDialog";
159164import { useAtomCommand } from "../../state/use-atom-command";
160165import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server";
161import { ConnectionStatusDot, connectionPhaseDotClassName } from "../ConnectionStatusDot";
166import { ConnectionStatusDot } from "../ConnectionStatusDot";
162167import {
163168 ServerUpdateAction,
164169 ServerUpdateProgress,
......@@ -288,11 +293,6 @@ function AccessScopeSummary({
288293 );
289294}
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
296296function parseManualDesktopSshTarget(input: {
297297 readonly host: string;
298298 readonly username: string;
......@@ -436,6 +436,20 @@ function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>)
436436 );
437437}
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
439453function sortDesktopClientSessions(sessions: ReadonlyArray<ServerClientSessionRecord>) {
440454 return [...sessions].toSorted((left, right) => {
441455 if (left.current !== right.current) {
......@@ -1419,6 +1433,44 @@ type SavedBackendListRowProps = {
14191433 onRemove: (environment: EnvironmentPresentation) => void;
14201434};
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 */
14221474function SavedBackendListRow({
14231475 environment,
14241476 removingEnvironmentId,
......@@ -1427,19 +1479,8 @@ function SavedBackendListRow({
14271479}: SavedBackendListRowProps) {
14281480 const environmentId = environment.environmentId;
14291481 const enabled = environment.entry.enabled;
1430 const connectionState = environment.connection.phase;
1431 const isConnected = connectionState === "connected";
1482 const isConnected = environment.connection.phase === "connected";
14321483 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";
14431484 const errorTraceId = environment.connection.traceId;
14441485 const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({
14451486 target: "trace ID",
......@@ -1470,25 +1511,18 @@ function SavedBackendListRow({
14701511 const serverUpdateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId));
14711512 const resumingServerUpdate =
14721513 serverUpdateState.status === "running" && serverUpdateState.stage === "resuming";
1473 const sshTarget =
1474 environment.entry.target._tag === "SshConnectionTarget" &&
1475 Option.isSome(environment.entry.profile) &&
1476 environment.entry.profile.value._tag === "SshConnectionProfile"
1477 ? environment.entry.profile.value.target
1478 : null;
1479 const metadataBits = [
1480 sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null,
1481 environment.relayManaged ? "T3 Connect" : null,
1482 enabled ? null : "Off",
1483 ].filter((value): value is string => value !== null);
1514 const status = savedBackendStatus(environment);
1515 const serverVersion = environment.serverConfig?.environment.serverVersion ?? null;
1516 const subtitleText = [
1517 environmentTransportLabel(environment),
1518 resumingServerUpdate ? "Restarting" : status.text,
1519 enabled && versionMismatch ? serverVersion : null,
1520 ]
1521 .filter((value): value is string => value !== null)
1522 .join(" · ");
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);
14901524 // 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.
14921526 const showUpdateAction =
14931527 enabled &&
14941528 isConnected &&
......@@ -1496,153 +1530,96 @@ function SavedBackendListRow({
14961530 (serverUpdateState.status === "idle" || serverUpdateState.status === "failed");
14971531
14981532 return (
1499 <div className={cn(ITEM_ROW_CLASSNAME, !enabled && "opacity-60")}>
1500 <div className={ITEM_ROW_INNER_CLASSNAME}>
1501 <div className="min-w-0 flex-1 space-y-1">
1502 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
1503 <div className="flex min-h-5 min-w-0 max-w-full items-center gap-1.5">
1504 <ConnectionStatusDot
1505 tooltipText={statusTooltip}
1506 dotClassName={stateDotClassName}
1507 pingClassName={
1508 enabled &&
1509 (connectionState === "connecting" || connectionState === "reconnecting")
1510 ? "bg-warning/60 duration-2000"
1511 : null
1512 }
1513 />
1514 <EnvironmentMachineIcon
1515 aria-hidden
1516 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
1517 className="size-3.5 shrink-0 text-muted-foreground"
1533 <EnvironmentRow
1534 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
1535 label={environment.label}
1536 dimmed={!enabled}
1537 subtitle={
1538 <Tooltip>
1539 <TooltipTrigger
1540 render={
1541 <span
1542 className={cn(
1543 "block truncate",
1544 enabled && status.tone === "error" && !resumingServerUpdate && "text-destructive",
1545 )}
15181546 />
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}
1547 }
1548 >
1549 {subtitleText}
1550 </TooltipTrigger>
1551 <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight">
1552 {enabled ? connectionStatusText(environment.connection) : "Switched off"}
1553 {versionMismatch
1554 ? `\nUpdate available: ${versionMismatch.serverVersion} → ${versionMismatch.clientVersion}`
1555 : ""}
1556 </TooltipPopup>
1557 </Tooltip>
1558 }
1559 below={
1560 serverUpdateState.status !== "idle" ? (
1561 <div className="mt-1 max-w-md">
1562 <ServerUpdateProgress state={serverUpdateState} />
15321563 </div>
1533 {metadataBits.length > 0 ? (
1534 <p className="truncate text-xs text-muted-foreground">{metadataBits.join(" · ")}</p>
1535 ) : null}
1536 {serverUpdateState.status !== "idle" ? (
1537 <div className="max-w-md">
1538 <ServerUpdateProgress state={serverUpdateState} />
1539 </div>
1540 ) : versionMismatch ? (
1541 <Tooltip>
1542 <TooltipTrigger
1543 render={
1544 <button
1545 type="button"
1546 className="w-fit cursor-help rounded-sm text-left text-muted-foreground text-xs"
1547 >
1548 Server update available
1549 </button>
1550 }
1551 />
1552 <TooltipPopup side="top">
1553 {versionMismatch.serverVersion} <span aria-hidden="true">→</span>{" "}
1554 {versionMismatch.clientVersion}
1555 </TooltipPopup>
1556 </Tooltip>
1557 ) : null}
1558 {enabled && environment.connection.error && !resumingServerUpdate ? (
1559 <p className="flex min-w-0 items-center gap-2 text-destructive text-xs">
1560 <span className="min-w-0 break-words">
1561 {connectionStatusText(environment.connection)}
1562 </span>
1563 {errorTraceId ? (
1564 <button
1565 type="button"
1566 className="shrink-0 underline underline-offset-2"
1567 onClick={() => copyTraceId(errorTraceId)}
1568 >
1569 Copy trace ID
1570 </button>
1571 ) : null}
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"
1564 ) : null
1565 }
1566 >
1567 {showUpdateAction ? (
1568 <ServerUpdateAction
1569 environmentId={environmentId}
1570 serverLabel={`${environment.label} server`}
1571 selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)}
1572 desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)}
1573 threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)}
1574 targetVersion={versionMismatch.clientVersion}
1575 label={serverUpdateState.status === "failed" ? "Retry update" : "Update"}
1576 appearance="icon"
1577 />
1578 ) : null}
1579 <Tooltip>
1580 <TooltipTrigger
1581 render={
1582 <Switch
1583 size="sm"
1584 checked={enabled}
1585 disabled={isRemoving}
1586 aria-label={`${enabled ? "Switch off" : "Switch on"} ${environment.label}`}
1587 onCheckedChange={(checked) => onSetEnabled(environmentId, checked)}
1588 />
1589 }
1590 />
1591 <TooltipPopup side="top">{enabled ? "Switch off" : "Switch on"}</TooltipPopup>
1592 </Tooltip>
1593 <Menu>
1594 <MenuTrigger
1595 render={
1596 <Button
1597 type="button"
1598 variant="ghost"
1599 size="icon-xs"
1600 className="text-muted-foreground hover:text-foreground"
1601 disabled={isRemoving}
1602 aria-label={`More actions for ${environment.label}`}
15861603 />
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>
15871615 ) : null}
1588 {isWslEnvironment ? (
1589 <Tooltip>
1590 <TooltipTrigger
1591 render={
1592 <Button size="xs" variant="outline" disabled>
1593 Managed locally
1594 </Button>
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>
1616 <MenuSeparator />
1617 <MenuItem variant="destructive" onClick={() => onRemove(environment)}>
1618 {isRemoving ? "Removing…" : "Remove from this device…"}
1619 </MenuItem>
1620 </MenuPopup>
1621 </Menu>
1622 </EnvironmentRow>
16461623 );
16471624}
16481625
......@@ -1817,19 +1794,6 @@ export function ConnectionsSettings() {
18171794 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
18181795 const { environments } = useEnvironments();
18191796 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];
18331797 const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false });
18341798 const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, {
18351799 reportFailure: false,
......@@ -1846,13 +1810,24 @@ export function ConnectionsSettings() {
18461810 ? (primarySessionState.data.scopes ?? null)
18471811 : null;
18481812 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.
18491815 const savedEnvironments = useMemo(
18501816 () =>
1851 environments
1852 .filter((environment) => environment.entry.target._tag !== "PrimaryConnectionTarget")
1853 .toSorted((left, right) => left.label.localeCompare(right.label)),
1817 environments.filter(
1818 (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget",
1819 ),
18541820 [environments],
18551821 );
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 );
18561831 // Machines "Update all" can reach: switched on, connected, behind the client
18571832 // version, remotely updatable, and not already mid-update. The button only
18581833 // renders when this list is non-empty.
......@@ -1901,10 +1876,15 @@ export function ConnectionsSettings() {
19011876 [savedServerUpdateStates],
19021877 );
19031878 // 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.
19051882 const loadBalancingEnvironments = useMemo(
1906 () => environments.filter((environment) => environment.entry.enabled),
1907 [environments],
1883 () => [
1884 ...(primaryEnvironment ? [primaryEnvironment] : []),
1885 ...savedEnvironments.filter((environment) => environment.entry.enabled),
1886 ],
1887 [primaryEnvironment, savedEnvironments],
19081888 );
19091889 const savedDesktopSshEnvironmentKeys = useMemo(() => {
19101890 const keys = new Set<string>();
......@@ -3259,69 +3239,78 @@ export function ConnectionsSettings() {
32593239 <SettingsSection
32603240 {...searchableSetting("connections-environment")}
32613241 title={primaryEnvironment?.label ?? "Primary environment"}
3262 >
3263 {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? (
3264 <SettingsRow
3265 title={
3266 primaryServerUpdateState.status === "failed"
3267 ? "Update failed"
3268 : primaryServerUpdateState.status === "running"
3269 ? "Updating server"
3270 : "Server update available"
3271 }
3272 description={
3273 primaryServerUpdateState.status !== "idle" ? (
3274 <ServerUpdateProgress state={primaryServerUpdateState} />
3275 ) : primaryVersionMismatch ? (
3276 <Tooltip>
3277 <TooltipTrigger
3278 render={
3279 <button type="button" className="w-fit cursor-help rounded-sm text-left">
3280 Update to match this client.
3281 </button>
3282 }
3242 icon={
3243 <EnvironmentMachineIcon
3244 aria-hidden
3245 kind={resolveEnvironmentMachineKind(primaryServerConfig)}
3246 className="size-4"
3247 />
3248 }
3249 headerAction={
3250 primaryEnvironmentId !== null ? (
3251 <Menu>
3252 <MenuTrigger
3253 render={
3254 <Button
3255 type="button"
3256 variant="ghost"
3257 size="icon-xs"
3258 className="text-muted-foreground hover:text-foreground"
3259 aria-label="More actions for this machine"
32833260 />
3284 <TooltipPopup side="top">
3285 {primaryVersionMismatch.serverVersion} <span aria-hidden="true">→</span>{" "}
3286 {primaryVersionMismatch.clientVersion}
3287 </TooltipPopup>
3288 </Tooltip>
3289 ) : null
3290 }
3291 control={
3292 primaryVersionMismatch &&
3293 primaryEnvironmentId !== null &&
3294 primaryServerUpdateState.status !== "running" ? (
3295 <ServerUpdateAction
3296 size="sm"
3261 }
3262 >
3263 <EllipsisIcon className="size-3.5" />
3264 </MenuTrigger>
3265 <MenuPopup align="end" className="min-w-52">
3266 <EnvironmentIconMenu
32973267 environmentId={primaryEnvironmentId}
3298 serverLabel={
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"}
3268 serverConfig={primaryServerConfig}
33083269 />
3309 ) : undefined
3310 }
3311 />
3312 ) : null}
3313 {primaryEnvironmentId !== null ? (
3314 <SettingsRow
3315 {...searchableSetting("environment-icon")}
3316 description="The machine other devices see this environment as. Automatic uses what the server detected."
3317 control={
3318 <EnvironmentIconPicker
3270 </MenuPopup>
3271 </Menu>
3272 ) : null
3273 }
3274 >
3275 <SettingsRow
3276 title="Version"
3277 description={
3278 primaryServerUpdateState.status !== "idle" ? (
3279 <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"
33193295 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 }
33213308 />
3322 }
3323 />
3324 ) : null}
3309 ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? (
3310 <span className="text-xs text-muted-foreground">Up to date</span>
3311 ) : undefined
3312 }
3313 />
33253314 {desktopBridge ? (
33263315 <>
33273316 {renderNetworkAccessRow()}
......@@ -3339,10 +3328,14 @@ export function ConnectionsSettings() {
33393328 </SettingsSection>
33403329
33413330 {isLocalBackendRemotelyReachable ? (
3342 <SettingsSection
3331 <FoldedSettingsSection
3332 id="authorized-clients"
33433333 title="Authorized clients"
3344 className="[&>div:first-child]:flex-wrap"
3345 headerAction={
3334 summary={summarizeAuthorizedClients(
3335 desktopClientSessions,
3336 visibleDesktopPairingLinks,
3337 )}
3338 control={
33463339 <AuthorizedClientsHeaderAction
33473340 onPairingLinkCreated={handlePairingLinkCreated}
33483341 clientSessions={desktopClientSessions}
......@@ -3359,7 +3352,7 @@ export function ConnectionsSettings() {
33593352 >
33603353 {renderAuthorizedClients("current")}
33613354 </ScrollArea>
3362 </SettingsSection>
3355 </FoldedSettingsSection>
33633356 ) : null}
33643357 <AlertDialog
33653358 open={isDesktopServerExposureDialogOpen}
......@@ -3649,10 +3642,10 @@ export function ConnectionsSettings() {
36493642
36503643 return (
36513644 <SettingsPageContainer width="wide">
3645 {primarySettings}
36523646 <SettingsSection
36533647 {...searchableSetting("remote-environments")}
36543648 title="Environments"
3655 variant="plain"
36563649 headerAction={
36573650 <div className="flex items-center gap-1">
36583651 {savedServerUpdateTargets.length > 0 ? (
......@@ -3725,118 +3718,22 @@ export function ConnectionsSettings() {
37253718 </div>
37263719 }
37273720 >
3728 <div className="overflow-hidden rounded-xl border border-border/60 bg-card/40 shadow-xs/5 lg:grid lg:h-[min(34rem,calc(100dvh-18rem))] lg:min-h-[28rem] lg:grid-cols-[17rem_minmax(0,1fr)]">
3729 <div className="border-b border-border/60 bg-muted/10 lg:flex lg:min-h-0 lg:flex-col lg:border-r lg:border-b-0">
3730 <ScrollArea
3731 scrollFade
3732 chainVerticalScroll
3733 className="max-h-64 lg:max-h-none lg:min-h-0 lg:flex-1"
3734 >
3735 <div className="divide-y divide-border/50" aria-label="Environments">
3736 {(primaryEnvironment
3737 ? [primaryEnvironment, ...savedEnvironments]
3738 : savedEnvironments
3739 ).map((environment) => {
3740 const selected = environment.environmentId === selectedEnvironment?.environmentId;
3741 const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
3742 return (
3743 <button
3744 key={environment.environmentId}
3745 type="button"
3746 aria-label={"Select " + environment.label}
3747 aria-pressed={selected}
3748 onClick={() => setSelectedEnvironmentId(environment.environmentId)}
3749 className={cn(
3750 "flex min-h-18 w-full cursor-pointer items-start gap-3 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring sm:px-4",
3751 selected ? "bg-muted/45" : "hover:bg-muted/25",
3752 !environment.entry.enabled && !selected && "opacity-60",
3753 )}
3754 >
3755 <EnvironmentMachineIcon
3756 aria-hidden
3757 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
3758 className="mt-0.5 size-4 shrink-0 text-muted-foreground"
3759 />
3760 <span className="min-w-0 flex-1 space-y-1">
3761 <span className="flex items-center gap-2">
3762 <span className="truncate text-sm font-medium">{environment.label}</span>
3763 {isPrimary ? (
3764 <span className="shrink-0 text-[10px] text-muted-foreground">
3765 Primary
3766 </span>
3767 ) : null}
3768 </span>
3769 <Tooltip>
3770 <TooltipTrigger
3771 render={
3772 <span className="block truncate text-xs text-muted-foreground" />
3773 }
3774 >
3775 {environment.displayUrl ?? "T3 Connect"}
3776 </TooltipTrigger>
3777 <TooltipPopup>{environment.displayUrl ?? "T3 Connect"}</TooltipPopup>
3778 </Tooltip>
3779 <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
3780 <span
3781 aria-hidden
3782 className={cn(
3783 "size-1.5 shrink-0 rounded-full",
3784 !environment.entry.enabled
3785 ? "bg-muted-foreground/40"
3786 : connectionPhaseDotClassName(environment.connection.phase),
3787 )}
3788 />
3789 {environment.entry.enabled
3790 ? connectionStatusTitle(environment.connection)
3791 : "Off"}
3792 </span>
3793 </span>
3794 </button>
3795 );
3796 })}
3797 </div>
3798 </ScrollArea>
3799 </div>
3800 <ScrollArea
3801 key={selectedEnvironment?.environmentId}
3802 scrollFade
3803 chainVerticalScroll
3804 className="min-w-0 lg:min-h-0 lg:h-full"
3805 >
3806 {selectedEnvironment ? (
3807 <div className="space-y-6 p-4">
3808 {selectedEnvironment.entry.target._tag === "PrimaryConnectionTarget" ? (
3809 primarySettings
3810 ) : (
3811 <SettingsSection title="Connection">
3812 <SavedBackendListRow
3813 key={selectedEnvironment.environmentId}
3814 environment={selectedEnvironment}
3815 removingEnvironmentId={removingSavedEnvironmentId}
3816 onSetEnabled={handleSetSavedBackendEnabled}
3817 onRemove={handleRemoveSavedBackend}
3818 />
3819 </SettingsSection>
3820 )}
3821 {selectedEnvironment.entry.enabled && loadBalancingEnvironments.length > 1 ? (
3822 <SettingsSection title="Thread placement">
3823 <LoadBalancingPreference environment={selectedEnvironment} />
3824 </SettingsSection>
3825 ) : null}
3826 <GitHubRoutingSettings
3827 environments={environments}
3828 selectedEnvironmentId={selectedEnvironment.environmentId}
3829 />
3830 </div>
3831 ) : null}
3832 </ScrollArea>
3833 </div>
3721 {listedEnvironments.map((environment) => (
3722 <SavedBackendListRow
3723 key={environment.environmentId}
3724 environment={environment}
3725 removingEnvironmentId={removingSavedEnvironmentId}
3726 onSetEnabled={handleSetSavedBackendEnabled}
3727 onRemove={handleRemoveSavedBackend}
3728 />
3729 ))}
38343730 <CloudRemoteEnvironmentRows
38353731 primaryEnvironmentId={primaryEnvironmentId}
38363732 savedEnvironments={savedEnvironments}
38373733 />
38383734 </SettingsSection>
38393735 <LoadBalancingSettings environments={loadBalancingEnvironments} />
3736 <GitHubRoutingSettings environments={loadBalancingEnvironments} />
38403737 </SettingsPageContainer>
38413738 );
38423739}
apps/web/src/components/settings/EnvironmentIconPicker.tsx+57-81
......@@ -5,7 +5,6 @@ import {
55 type EnvironmentId,
66 type ServerConfig,
77} from "@t3tools/contracts";
8import { useCallback } from "react";
98
109import { isElectron } from "../../env";
1110import { usePrimarySessionState } from "../../environments/primary";
......@@ -13,15 +12,20 @@ import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
1312import { usePrimaryEnvironmentId } from "../../state/environments";
1413import { useEnvironmentSessionState } from "../../state/session";
1514import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
16import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
17import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
15import {
16 MenuItem,
17 MenuRadioGroup,
18 MenuRadioItem,
19 MenuSeparator,
20 MenuSub,
21 MenuSubPopup,
22 MenuSubTrigger,
23} from "../ui/menu";
1824import {
1925 resolvePrimaryOperateAccess,
2026 resolveRemoteOperateAccess,
2127} from "./ProviderSettingsPanel.logic";
2228
23const AUTOMATIC_VALUE = "automatic";
24
2529/**
2630 * Why the picker is inert, in the order the user can do something about it.
2731 * Null means it can be changed.
......@@ -68,94 +72,66 @@ function useEnvironmentOperateAccess(environmentId: EnvironmentId) {
6872}
6973
7074/**
71 * Picks the machine glyph an environment wears everywhere it is listed.
72 * "Automatic" clears the override so the server's own detection shows
73 * through; the label says what that currently resolves to so the user can
74 * tell whether detection got it right before overriding. The control stays
75 * visible while locked so the current icon still reads, the same way
76 * server-scoped rows go inert instead of disappearing.
75 * "Icon" submenu for an environment's row menu. Lists the machine kinds with
76 * the server's own detection marked, so the user can tell whether detection
77 * got it right before overriding. Picking the detected kind clears the
78 * override. Locked environments show the reason as a disabled item instead of
79 * hiding the submenu, so the current icon still reads.
7780 */
78export function EnvironmentIconPicker({
81export function EnvironmentIconMenu({
7982 environmentId,
8083 serverConfig,
81 size = "sm",
8284}: {
8385 readonly environmentId: EnvironmentId;
8486 readonly serverConfig: ServerConfig | null;
85 readonly size?: "xs" | "sm";
8687}) {
8788 const updateSettings = useUpdateEnvironmentSettings(environmentId);
8889 const operateAccess = useEnvironmentOperateAccess(environmentId);
8990 const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess });
90 const override = serverConfig?.settings.environmentIcon ?? null;
91 const detected = serverConfig?.environment.platform.machine ?? null;
91 // With no detection the server falls back to "server", so picking that
92 // kind clears the override the same way picking the detected kind does.
93 const detected = serverConfig?.environment.platform.machine ?? "server";
9294 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 }
14396 return (
144 <Tooltip>
145 <TooltipTrigger
146 render={
147 // Focusable so keyboard users can still reach the explanation.
148 <span
149 tabIndex={0}
150 className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-auto"
151 />
152 }
153 >
154 <span className="flex w-full items-center sm:w-auto">{select}</span>
155 </TooltipTrigger>
156 <TooltipPopup side="top" className="max-w-72">
157 {lock}
158 </TooltipPopup>
159 </Tooltip>
97 <MenuSub>
98 <MenuSubTrigger>
99 <EnvironmentMachineIcon kind={resolved} />
100 Icon
101 </MenuSubTrigger>
102 <MenuSubPopup className="min-w-44">
103 {lock !== null ? (
104 <>
105 <MenuItem disabled className="whitespace-normal text-xs">
106 {lock}
107 </MenuItem>
108 <MenuSeparator />
109 </>
110 ) : null}
111 <MenuRadioGroup
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>
160136 );
161137}
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 @@
11import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";
2import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
33import {
44 gitHubRoutingConnectionKey,
55 gitHubRoutingPermissionFor,
......@@ -12,7 +12,8 @@ import type { EnvironmentPresentation } from "~/state/environments";
1212import { useAtomCommand } from "~/state/use-atom-command";
1313import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
1414import { toastManager } from "../ui/toast";
15import { SettingsRow, SettingsSection } from "./settingsLayout";
15import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow";
16import { FoldedSettingsSection } from "./FoldedSettingsSection";
1617import { searchableSetting } from "./settingsSearch";
1718
1819const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> = [
......@@ -21,73 +22,106 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }>
2122 { value: "read-write", label: "Read and act" },
2223];
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 */
2449export function GitHubRoutingSettings({
2550 environments,
26 selectedEnvironmentId,
2751}: {
2852 readonly environments: ReadonlyArray<EnvironmentPresentation>;
29 readonly selectedEnvironmentId: EnvironmentId;
3053}) {
3154 const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom);
3255 const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
3356 const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission);
3457 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");
3962 return (
40 <SettingsSection {...searchableSetting("github-routing")}>
41 <SettingsRow
42 title="Share GitHub access"
43 className="sm:[&>div]:grid-cols-1 [&_p]:max-w-none"
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."
45 />
46 {selectedEnvironments.map((environment) => (
47 <SettingsRow
63 <FoldedSettingsSection
64 id={id}
65 title={title}
66 summary={
67 summarizeGitHubRouting(
68 environments.map((environment) => ({
69 label: environment.label,
70 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
4882 key={environment.environmentId}
49 title="GitHub access"
50 description={environment.displayUrl ?? "T3 Connect"}
51 control={
52 <Select
53 items={options}
54 value={gitHubRoutingPermissionFor(environment.entry, permissions)}
55 disabled={
56 !catalog.isReady || saving || gitHubRoutingConnectionKey(environment.entry) === null
57 }
58 onValueChange={(permission) => {
59 if (permission === null) return;
60 setSaving(true);
61 void update({ environmentId: environment.environmentId, permission }).then(
62 (result) => {
63 setSaving(false);
64 if (result._tag === "Failure")
65 toastManager.add({
66 type: "error",
67 title: "Could not save GitHub routing permission",
68 });
69 },
70 );
71 }}
83 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
84 label={environment.label}
85 subtitle={environmentTransportLabel(environment)}
86 >
87 <Select
88 items={options}
89 value={gitHubRoutingPermissionFor(environment.entry, permissions)}
90 disabled={
91 !catalog.isReady || saving || gitHubRoutingConnectionKey(environment.entry) === null
92 }
93 onValueChange={(permission) => {
94 if (permission === null) return;
95 setSaving(true);
96 void update({ environmentId: environment.environmentId, permission }).then(
97 (result) => {
98 setSaving(false);
99 if (result._tag === "Failure")
100 toastManager.add({
101 type: "error",
102 title: "Could not save GitHub routing permission",
103 });
104 },
105 );
106 }}
107 >
108 <SelectTrigger
109 size="xs"
110 className="w-32"
111 aria-label={`${environment.label} GitHub routing`}
72112 >
73 <SelectTrigger
74 size="sm"
75 className="w-full sm:w-40"
76 aria-label={`${environment.label} GitHub routing`}
77 >
78 <SelectValue />
79 </SelectTrigger>
80 <SelectPopup align="end" alignItemWithTrigger={false}>
81 {options.map(({ value, label }) => (
82 <SelectItem key={value} value={value}>
83 {label}
84 </SelectItem>
85 ))}
86 </SelectPopup>
87 </Select>
88 }
89 />
113 <SelectValue />
114 </SelectTrigger>
115 <SelectPopup align="end" alignItemWithTrigger={false}>
116 {options.map(({ value, label }) => (
117 <SelectItem key={value} value={value}>
118 {label}
119 </SelectItem>
120 ))}
121 </SelectPopup>
122 </Select>
123 </EnvironmentRow>
90124 ))}
91 </SettingsSection>
125 </FoldedSettingsSection>
92126 );
93127}
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 @@
1import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
2
13import {
24 useClientSettings,
35 useClientSettingsHydrated,
......@@ -6,7 +8,8 @@ import {
68import type { EnvironmentPresentation } from "~/state/environments";
79import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
810import { Switch } from "../ui/switch";
9import { SettingsRow, SettingsSection } from "./settingsLayout";
11import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow";
12import { FoldedSettingsSection } from "./FoldedSettingsSection";
1013import { searchableSetting } from "./settingsSearch";
1114
1215const preferences = [
......@@ -14,8 +17,44 @@ const preferences = [
1417 { value: 50, label: "Normal" },
1518 { value: 25, label: "Less often" },
1619 { 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 */
1958export function LoadBalancingSettings({
2059 environments,
2160}: {
......@@ -25,78 +64,71 @@ export function LoadBalancingSettings({
2564 const settingsHydrated = useClientSettingsHydrated();
2665 const updateSettings = useUpdateClientSettings();
2766
28 if (environments.length < 2) {
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 }
67 if (environments.length < 2) return null;
3768
69 const { id, title } = searchableSetting("load-balancing");
3870 return (
39 <SettingsSection {...searchableSetting("load-balancing")}>
40 <SettingsRow
41 title="Automatically balance load"
42 description="Choose a machine automatically for new threads in shared projects."
43 control={
44 <Switch
45 aria-label="Automatically balance load"
46 checked={settings.loadBalancingEnabled}
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."
71 <FoldedSettingsSection
72 id={id}
73 title={title}
74 summary={
75 settings.loadBalancingEnabled
76 ? summarizeLoadPreferences(environments, settings.loadBalancingWeights)
77 : "Off"
78 }
6879 control={
69 <Select
70 items={preferences}
71 value={preference}
72 disabled={!settingsHydrated || !settings.loadBalancingEnabled}
73 onValueChange={(value) => {
74 if (value !== null) {
80 <Switch
81 aria-label="Automatically balance load"
82 checked={settings.loadBalancingEnabled}
83 disabled={!settingsHydrated}
84 onCheckedChange={(loadBalancingEnabled) => updateSettings({ loadBalancingEnabled })}
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;
75107 updateSettings({
76108 loadBalancingWeights: {
77109 ...settings.loadBalancingWeights,
78110 [environment.environmentId]: value,
79111 },
80112 });
81 }
82 }}
83 >
84 <SelectTrigger
85 size="sm"
86 className="w-full sm:w-40"
87 aria-label={`${environment.label} load preference`}
113 }}
88114 >
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 />
115 <SelectTrigger
116 size="xs"
117 className="w-32"
118 aria-label={`${environment.label} load preference`}
119 >
120 <SelectValue />
121 </SelectTrigger>
122 <SelectPopup align="end" alignItemWithTrigger={false}>
123 {preferences.map(({ value, label }) => (
124 <SelectItem key={value} value={value}>
125 {label}
126 </SelectItem>
127 ))}
128 </SelectPopup>
129 </Select>
130 </EnvironmentRow>
131 ))}
132 </FoldedSettingsSection>
101133 );
102134}
apps/web/src/components/settings/settingsSearch.ts+3-3
......@@ -682,7 +682,7 @@ export const SETTINGS_SEARCH_ITEMS = [
682682 },
683683 {
684684 id: "connections-environment",
685 title: "This environment",
685 title: "This machine",
686686 to: "/settings/connections",
687687 searchTerms: [
688688 "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 = [
690690 },
691691 {
692692 id: "remote-environments",
693 title: "Remote environments",
693 title: "Environments",
694694 to: "/settings/connections",
695695 searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"],
696696 },
......@@ -704,7 +704,7 @@ export const SETTINGS_SEARCH_ITEMS = [
704704 },
705705 {
706706 id: "github-routing",
707 title: "GitHub routing",
707 title: "GitHub sharing",
708708 to: "/settings/connections",
709709 searchTerms: ["pull request trusted environments shared credentials permissions read actions"],
710710 },
docs/user/remote-access.md+2-1
......@@ -65,7 +65,8 @@ another link to share.
6565
6666Auto balance is off by default. On web and desktop, enable it in
6767**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.
6970Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and
7071memory available, **Less often** to reduce its share, or **Manual only** to exclude
7172it 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.
9999GitHub, GitLab, and Azure DevOps support auto-merge while checks are outstanding. GitHub also
100100supports 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), choose
102GitHub sharing is off by default. In Settings → Connections → GitHub sharing (Environments on mobile), choose
103103**Read PRs** or **Read and act** for each environment you trust to share GitHub access.
104104Enable both the original environment and the environment answering its requests on this client.
105105**Read and act** can use broader GitHub permissions than the original environment's credential;