authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-04 16:53:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-04 16:53:54-07:00
logfff6a5b028f85122ffef8d3636f390f95ade5172
tree828c5deff0a9df30f754ab46ea673829e8f69c52
parentda6e1a96782594cab3a6925f441731a65be57c11
signature Signed by PGP key B5690EEEBB952194

feat(web): make pairing QR codes actually scannable, with endpoint choice (#5360)

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

3 files changed, 315 insertions(+), 189 deletions(-)

apps/web/src/components/settings/ConnectionsSettings.logic.test.ts+92-2
......@@ -1,6 +1,10 @@
1import type { DesktopWslState } from "@t3tools/contracts";
1import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts";
22import { describe, expect, it, vi } from "vite-plus/test";
3import { applyWslEnableSelection } from "./ConnectionsSettings.logic";
3import {
4 applyWslEnableSelection,
5 isQrShareableEndpoint,
6 selectQrEndpointOption,
7} from "./ConnectionsSettings.logic";
48
59const baseWslState: DesktopWslState = {
610 enabled: false,
......@@ -73,3 +77,89 @@ describe("applyWslEnableSelection", () => {
7377 expect(state).toMatchObject({ enabled: true, wslOnly: true });
7478 });
7579});
80
81function makeEndpoint(overrides: Partial<AdvertisedEndpoint>): AdvertisedEndpoint {
82 return {
83 id: "desktop-lan:http://192.168.1.42:4780",
84 label: "Local network",
85 provider: { id: "desktop-core", label: "Desktop", kind: "core", isAddon: false },
86 httpBaseUrl: "http://192.168.1.42:4780",
87 wsBaseUrl: "ws://192.168.1.42:4780",
88 reachability: "lan",
89 compatibility: { hostedHttpsApp: "unknown", desktopApp: "compatible" },
90 source: "desktop-core",
91 status: "available",
92 ...overrides,
93 };
94}
95
96describe("isQrShareableEndpoint", () => {
97 it("excludes loopback endpoints so a scanned phone never dials itself", () => {
98 expect(
99 isQrShareableEndpoint(
100 makeEndpoint({
101 id: "desktop-loopback:4780",
102 reachability: "loopback",
103 httpBaseUrl: "http://127.0.0.1:4780",
104 }),
105 ),
106 ).toBe(false);
107 });
108
109 it("excludes unavailable endpoints and keeps reachable ones", () => {
110 expect(isQrShareableEndpoint(makeEndpoint({ status: "unavailable" }))).toBe(false);
111 expect(isQrShareableEndpoint(makeEndpoint({}))).toBe(true);
112 expect(
113 isQrShareableEndpoint(makeEndpoint({ reachability: "private-network", status: "unknown" })),
114 ).toBe(true);
115 });
116});
117
118describe("selectQrEndpointOption", () => {
119 const options = [
120 {
121 id: "desktop-loopback:4780",
122 preferenceKey: "desktop-core:loopback:http",
123 qrShareable: false,
124 },
125 {
126 id: "tailscale-ip:http://100.84.12.7:4780",
127 preferenceKey: "tailscale:ip:http",
128 qrShareable: true,
129 },
130 {
131 id: "tailscale-ip:http://100.84.12.8:4780",
132 preferenceKey: "tailscale:ip:http",
133 qrShareable: true,
134 },
135 {
136 id: "desktop-lan:http://192.168.1.42:4780",
137 preferenceKey: "desktop-core:lan:http",
138 qrShareable: true,
139 },
140 ];
141
142 it("resolves an explicit selection by unique endpoint id, not the shared preference key", () => {
143 expect(selectQrEndpointOption(options, "tailscale-ip:http://100.84.12.8:4780", null)?.id).toBe(
144 "tailscale-ip:http://100.84.12.8:4780",
145 );
146 });
147
148 it("falls back to the saved default preference key when nothing is selected", () => {
149 expect(selectQrEndpointOption(options, null, "desktop-core:lan:http")?.id).toBe(
150 "desktop-lan:http://192.168.1.42:4780",
151 );
152 });
153
154 it("skips non-QR-shareable options in the fallback so the panel never opens on loopback", () => {
155 expect(selectQrEndpointOption(options, "tailscale-ip:gone", "nope")?.id).toBe(
156 "tailscale-ip:http://100.84.12.7:4780",
157 );
158 });
159
160 it("returns the first option when nothing is QR-shareable, and null when empty", () => {
161 const loopbackOnly = options.slice(0, 1);
162 expect(selectQrEndpointOption(loopbackOnly, null, null)?.id).toBe("desktop-loopback:4780");
163 expect(selectQrEndpointOption([], "anything", "anything")).toBeNull();
164 });
165});
apps/web/src/components/settings/ConnectionsSettings.logic.ts+44-1
......@@ -1,7 +1,50 @@
1import type { DesktopBridge, DesktopWslState } from "@t3tools/contracts";
1import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tools/contracts";
22
33type WslEnableBridge = Pick<DesktopBridge, "setWslBackendEnabled" | "setWslDistro" | "setWslOnly">;
44
5/**
6 * A QR code encoding a loopback URL makes the scanning device dial itself, so
7 * loopback endpoints stay copyable from the endpoint menu but are never
8 * offered as QR targets.
9 */
10export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean {
11 return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback";
12}
13
14export type QrEndpointOption = {
15 /** Unique per endpoint instance (AdvertisedEndpoint.id); safe as a React key. */
16 readonly id: string;
17 /**
18 * Stable per endpoint *type* (endpointDefaultPreferenceKey). Multiple
19 * endpoints can share one, so it is only used to match the saved default.
20 */
21 readonly preferenceKey: string;
22 /** False for endpoints that stay copyable but must never render as a QR. */
23 readonly qrShareable: boolean;
24};
25
26/**
27 * Resolves which endpoint the share panel shows: the user's explicit pick,
28 * else the saved default endpoint, else the first QR-shareable option (so the
29 * panel never opens on a loopback QR), else the first option. A stale
30 * selectedId (endpoint disappeared) falls back rather than blanking the panel.
31 */
32export function selectQrEndpointOption<T extends QrEndpointOption>(
33 options: ReadonlyArray<T>,
34 selectedId: string | null,
35 defaultPreferenceKey: string | null,
36): T | null {
37 return (
38 (selectedId !== null ? options.find((option) => option.id === selectedId) : undefined) ??
39 (defaultPreferenceKey !== null
40 ? options.find((option) => option.preferenceKey === defaultPreferenceKey)
41 : undefined) ??
42 options.find((option) => option.qrShareable) ??
43 options[0] ??
44 null
45 );
46}
47
548export async function applyWslEnableSelection(input: {
649 readonly bridge: WslEnableBridge;
750 readonly mode: "both" | "wsl-only";
apps/web/src/components/settings/ConnectionsSettings.tsx+179-186
......@@ -1,5 +1,4 @@
11import {
2 ChevronDownIcon,
32 ChevronsLeftRightEllipsisIcon,
43 PlusIcon,
54 QrCodeIcon,
......@@ -8,7 +7,7 @@ import {
87 TriangleAlertIcon,
98} from "lucide-react";
109import { useAtomValue } from "@effect/atom-react";
11import { type ReactNode, memo, useCallback, useMemo, useState } from "react";
10import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react";
1211import {
1312 AuthAccessReadScope,
1413 AuthAccessWriteScope,
......@@ -42,7 +41,11 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
4241import { cn } from "../../lib/utils";
4342import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
4443import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
45import { applyWslEnableSelection } from "./ConnectionsSettings.logic";
44import {
45 applyWslEnableSelection,
46 isQrShareableEndpoint,
47 selectQrEndpointOption,
48} from "./ConnectionsSettings.logic";
4649import {
4750 SettingsPageContainer,
4851 SettingsRow,
......@@ -82,17 +85,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast";
8285import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
8386import { Button } from "../ui/button";
8487import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
85import { Group, GroupSeparator } from "../ui/group";
8688import { AnimatedHeight } from "../AnimatedHeight";
87import {
88 Menu,
89 MenuGroup,
90 MenuGroupLabel,
91 MenuItem,
92 MenuPopup,
93 MenuSeparator,
94 MenuTrigger,
95} from "../ui/menu";
9689import { Textarea } from "../ui/textarea";
9790import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl";
9891import { readHostedPairingRequest } from "../../hostedPairing";
......@@ -506,6 +499,22 @@ function isHostedAppPairingUrl(value: string): boolean {
506499 }
507500}
508501
502function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string {
503 if (isHostedAppPairingUrl(url)) {
504 return "Opens the hosted app, no install needed";
505 }
506 switch (endpoint.reachability) {
507 case "lan":
508 return "Devices on the same network";
509 case "private-network":
510 return "Devices on your private network";
511 case "public":
512 return "Reachable from anywhere";
513 case "loopback":
514 return "Clients on this machine";
515 }
516}
517
509518type PairingLinkListRowProps = {
510519 pairingLink: ServerPairingLinkRecord;
511520 endpointUrl: string | null | undefined;
......@@ -531,6 +540,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
531540 [pairingLink.expiresAt],
532541 );
533542 const [isRevealDialogOpen, setIsRevealDialogOpen] = useState(false);
543 const [isQrPanelOpen, setIsQrPanelOpen] = useState(false);
544 // Ephemeral per-row choice of which endpoint the QR encodes (AdvertisedEndpoint.id);
545 // null falls back to the saved default endpoint.
546 const [qrEndpointId, setQrEndpointId] = useState<string | null>(null);
547 const qrPanelId = useId();
534548
535549 const currentOriginPairingUrl = useMemo(
536550 () => resolveCurrentOriginPairingUrl(pairingLink.credential),
......@@ -549,10 +563,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
549563 }, [defaultEndpointKey, endpoints, pairingLink.credential]);
550564 const endpointCopyOptions = useMemo(() => {
551565 const options: Array<{
552 readonly key: string;
566 readonly id: string;
567 readonly preferenceKey: string;
553568 readonly label: string;
554569 readonly url: string;
555570 readonly detail: string;
571 readonly qrShareable: boolean;
556572 }> = [];
557573 for (const endpoint of endpoints) {
558574 if (endpoint.status === "unavailable") {
......@@ -560,10 +576,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
560576 }
561577 const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential);
562578 options.push({
563 key: endpointDefaultPreferenceKey(endpoint),
579 id: endpoint.id,
580 preferenceKey: endpointDefaultPreferenceKey(endpoint),
564581 label: endpoint.label,
565582 url,
566 detail: isHostedAppPairingUrl(url) ? "Hosted app link" : "Backend pairing URL",
583 detail: endpointShareHint(endpoint, url),
584 qrShareable: isQrShareableEndpoint(endpoint),
567585 });
568586 }
569587 return options;
......@@ -575,16 +593,25 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
575593 : isLoopbackHostname(window.location.hostname)
576594 ? null
577595 : currentOriginPairingUrl);
578 const revealValue = shareablePairingUrl ?? pairingLink.credential;
579 const isShareableHostedAppPairingUrl =
580 shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl);
596 // Value of the copy attempt that last failed. The clipboard-failure reveal
597 // dialog must show exactly what failed to copy, not the row's default URL.
598 const [failedCopyValue, setFailedCopyValue] = useState<string | null>(null);
599 const revealValue = failedCopyValue ?? shareablePairingUrl ?? pairingLink.credential;
600 const isRevealValueUrl = revealValue !== pairingLink.credential;
601 const isRevealValueHostedAppPairingUrl = isRevealValueUrl && isHostedAppPairingUrl(revealValue);
602 // Never render a QR for a loopback URL, even in the manual-copy fallback.
603 const isRevealValueQrShareable =
604 endpointCopyOptions.find((option) => option.url === revealValue)?.qrShareable ?? true;
581605 const canCopyToClipboard =
582606 typeof window !== "undefined" &&
583607 window.isSecureContext &&
584608 navigator.clipboard?.writeText != null;
585609
586 const { copyToClipboard } = useCopyToClipboard<"code" | "hosted-link" | "link">({
587 onCopy: (kind) => {
610 const { copyToClipboard } = useCopyToClipboard<{
611 value: string;
612 kind: "code" | "hosted-link" | "link";
613 }>({
614 onCopy: ({ kind }) => {
588615 toastManager.add({
589616 type: "success",
590617 title:
......@@ -601,7 +628,10 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
601628 : "Paste it into another client to finish pairing.",
602629 });
603630 },
604 onError: (error, kind) => {
631 onError: (error, { value, kind }) => {
632 // Captured per attempt so concurrent copies cannot make the dialog
633 // reveal a different value than the one that failed.
634 setFailedCopyValue(value);
605635 setIsRevealDialogOpen(true);
606636 toastManager.add(
607637 stackedThreadToast({
......@@ -621,7 +651,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
621651
622652 const copyPairingValue = useCallback(
623653 (value: string, kind: "code" | "hosted-link" | "link") => {
624 copyToClipboard(value, kind);
654 copyToClipboard(value, { value, kind });
625655 },
626656 [copyToClipboard],
627657 );
......@@ -635,97 +665,19 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
635665 copyPairingValue(pairingLink.credential, "code");
636666 }, [copyPairingValue, pairingLink.credential]);
637667
638 const handleCopyDefaultLink = useCallback(() => {
639 if (!shareablePairingUrl) return;
640 copyPairingValue(shareablePairingUrl, copyKindForUrl(shareablePairingUrl));
641 }, [copyKindForUrl, copyPairingValue, shareablePairingUrl]);
642
643668 const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt);
644669
645670 const primaryLabel = pairingLink.label ?? "Pairing link";
646 const defaultEndpointCopyOption =
647 endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ??
648 endpointCopyOptions[0] ??
649 null;
650 const defaultEndpointCopyLabel = defaultEndpointCopyOption?.label ?? "URL";
651 const backendEndpointCopyOptions = endpointCopyOptions.filter(
652 (option) => !isHostedAppPairingUrl(option.url),
653 );
654 const hostedEndpointCopyOptions = endpointCopyOptions.filter((option) =>
655 isHostedAppPairingUrl(option.url),
656 );
657 const renderEndpointMenuItems = (
658 options: typeof endpointCopyOptions = endpointCopyOptions,
659 renderDetail = true,
660 ) =>
661 options.map((option) => (
662 <MenuItem
663 key={option.key}
664 onClick={() => copyPairingValue(option.url, copyKindForUrl(option.url))}
665 >
666 <span className="min-w-0 flex-1">
667 <span className="block truncate">{option.label}</span>
668 {renderDetail ? (
669 <span className="block truncate text-[11px] text-muted-foreground">
670 {option.detail}
671 </span>
672 ) : null}
673 </span>
674 </MenuItem>
675 ));
676 const renderPairingCodeMenuItem = (renderDetail = true) => (
677 <MenuItem onClick={handleCopyCode}>
678 <span className="min-w-0 flex-1">
679 <span className="block truncate">Copy code</span>
680 {renderDetail ? (
681 <span className="block truncate text-[11px] text-muted-foreground">Token only</span>
682 ) : null}
683 </span>
684 </MenuItem>
685 );
686 const renderCompactEndpointGroup = (
687 label: string,
688 options: typeof endpointCopyOptions,
689 includeSeparator: boolean,
690 ) =>
691 options.length > 0 ? (
692 <>
693 {includeSeparator ? <MenuSeparator /> : null}
694 <MenuGroup>
695 <MenuGroupLabel>{label}</MenuGroupLabel>
696 {renderEndpointMenuItems(options, false)}
697 </MenuGroup>
698 </>
699 ) : null;
700 const renderGroupedCopyMenuItems = (options?: { codeFirst?: boolean }) => (
701 <>
702 {options?.codeFirst ? (
703 <>
704 <MenuGroup>
705 <MenuGroupLabel>Pairing code</MenuGroupLabel>
706 {renderPairingCodeMenuItem(false)}
707 </MenuGroup>
708 {endpointCopyOptions.length > 0 ? <MenuSeparator /> : null}
709 </>
710 ) : null}
711 {renderCompactEndpointGroup("Pairing URLs", backendEndpointCopyOptions, false)}
712 {renderCompactEndpointGroup(
713 "Hosted app link",
714 hostedEndpointCopyOptions,
715 backendEndpointCopyOptions.length > 0,
716 )}
717 {!options?.codeFirst ? (
718 <>
719 {endpointCopyOptions.length > 0 ? <MenuSeparator /> : null}
720 <MenuGroup>
721 <MenuGroupLabel>Pairing code</MenuGroupLabel>
722 {renderPairingCodeMenuItem(false)}
723 </MenuGroup>
724 </>
725 ) : null}
726 </>
727 );
728
671 const selectedQrOption = selectQrEndpointOption(
672 endpointCopyOptions,
673 qrEndpointId,
674 defaultEndpointKey,
675 );
676 const qrPairingUrl = selectedQrOption?.url ?? shareablePairingUrl;
677 // With no endpoint list the fallback is never loopback: selectPairingEndpoint
678 // skips loopback and the current-origin fallback is guarded by
679 // isLoopbackHostname, so only an explicit loopback selection hides the QR.
680 const canRenderQrForSelection = selectedQrOption?.qrShareable ?? true;
729681 if (expiresAtMs <= nowMs) {
730682 return null;
731683 }
......@@ -740,35 +692,6 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
740692 dotClassName="bg-amber-400"
741693 />
742694 <h3 className="text-sm font-medium text-foreground">{primaryLabel}</h3>
743 <Popover>
744 {shareablePairingUrl ? (
745 <>
746 <PopoverTrigger
747 openOnHover
748 delay={250}
749 closeDelay={100}
750 render={
751 <button
752 type="button"
753 className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground/50 outline-none hover:text-foreground"
754 aria-label="Show QR code"
755 />
756 }
757 >
758 <QrCodeIcon aria-hidden className="size-3" />
759 </PopoverTrigger>
760 <PopoverPopup side="top" align="start" tooltipStyle className="w-max">
761 <QRCodeSvg
762 value={shareablePairingUrl}
763 size={88}
764 level="M"
765 marginSize={2}
766 title="Pairing link — scan to open on another device"
767 />
768 </PopoverPopup>
769 </>
770 ) : null}
771 </Popover>
772695 </div>
773696 <p className="text-xs text-muted-foreground" title={expiresAbsolute}>
774697 {formatExpiresInLabel(pairingLink.expiresAt, nowMs)}
......@@ -782,46 +705,31 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
782705 ) : null}
783706 </div>
784707 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
785 <Dialog open={isRevealDialogOpen} onOpenChange={setIsRevealDialogOpen}>
708 {shareablePairingUrl && canCopyToClipboard ? (
709 <Button
710 size="xs"
711 variant="outline"
712 aria-expanded={isQrPanelOpen}
713 aria-controls={qrPanelId}
714 onClick={() => setIsQrPanelOpen((open) => !open)}
715 >
716 <QrCodeIcon aria-hidden />
717 Share
718 </Button>
719 ) : null}
720 <Dialog
721 open={isRevealDialogOpen}
722 onOpenChange={(open) => {
723 setIsRevealDialogOpen(open);
724 if (!open) setFailedCopyValue(null);
725 }}
726 >
786727 {canCopyToClipboard ? (
787 <>
788 {shareablePairingUrl ? (
789 <Group aria-label="Copy selected endpoint">
790 <Button
791 size="xs"
792 variant="outline"
793 className="max-w-56"
794 title={`Copy pairing URL for: ${defaultEndpointCopyLabel}`}
795 onClick={handleCopyDefaultLink}
796 >
797 <span className="truncate">
798 Copy pairing URL for: {defaultEndpointCopyLabel}
799 </span>
800 </Button>
801 <GroupSeparator />
802 <Menu>
803 <MenuTrigger
804 render={
805 <Button
806 size="icon-xs"
807 variant="outline"
808 aria-label="Choose endpoint to copy"
809 />
810 }
811 >
812 <ChevronDownIcon className="size-3.5" />
813 </MenuTrigger>
814 <MenuPopup align="end" className="min-w-60">
815 {renderGroupedCopyMenuItems()}
816 </MenuPopup>
817 </Menu>
818 </Group>
819 ) : (
820 <Button size="xs" variant="outline" onClick={handleCopyCode}>
821 Copy code
822 </Button>
823 )}
824 </>
728 shareablePairingUrl ? null : (
729 <Button size="xs" variant="outline" onClick={handleCopyCode}>
730 Copy code
731 </Button>
732 )
825733 ) : (
826734 <DialogTrigger render={<Button size="xs" variant="outline" />}>
827735 {shareablePairingUrl ? "Show link" : "Show code"}
......@@ -830,15 +738,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
830738 <DialogPopup className="max-w-md">
831739 <DialogHeader>
832740 <DialogTitle>
833 {shareablePairingUrl
834 ? isShareableHostedAppPairingUrl
741 {isRevealValueUrl
742 ? isRevealValueHostedAppPairingUrl
835743 ? "Hosted app pairing link"
836744 : "Pairing link"
837745 : "Pairing code"}
838746 </DialogTitle>
839747 <DialogDescription>
840 {shareablePairingUrl
841 ? isShareableHostedAppPairingUrl
748 {isRevealValueUrl
749 ? isRevealValueHostedAppPairingUrl
842750 ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect."
843751 : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect."
844752 : "Clipboard copy is unavailable here. Manually copy this code into another client."}
......@@ -848,15 +756,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
848756 <Textarea
849757 readOnly
850758 value={revealValue}
851 rows={shareablePairingUrl ? 4 : 3}
759 rows={isRevealValueUrl ? 4 : 3}
852760 className="text-xs leading-relaxed"
853761 onFocus={(event) => event.currentTarget.select()}
854762 onClick={(event) => event.currentTarget.select()}
855763 />
856 {shareablePairingUrl ? (
764 {isRevealValueUrl && isRevealValueQrShareable ? (
857765 <div className="flex justify-center rounded-xl border border-border/60 bg-muted/30 p-4">
858766 <QRCodeSvg
859 value={shareablePairingUrl}
767 value={revealValue}
860768 size={132}
861769 level="M"
862770 marginSize={2}
......@@ -887,6 +795,91 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
887795 </Button>
888796 </div>
889797 </div>
798 {isQrPanelOpen && qrPairingUrl !== null ? (
799 <div
800 id={qrPanelId}
801 className="mt-3 flex flex-col gap-4 border-t border-border/50 pt-3 sm:flex-row sm:items-start sm:justify-between"
802 >
803 <div className="min-w-0 flex-1 space-y-3">
804 {endpointCopyOptions.length > 1 ? (
805 <div
806 className="space-y-1.5"
807 role="radiogroup"
808 aria-label="Endpoint the pairing QR code and URL use"
809 >
810 <p className="text-[11px] text-muted-foreground/70">Reach this machine via</p>
811 {endpointCopyOptions.map((option) => {
812 const isSelected = option.id === selectedQrOption?.id;
813 return (
814 <button
815 key={option.id}
816 type="button"
817 role="radio"
818 aria-checked={isSelected}
819 className={cn(
820 "flex w-full items-baseline gap-2 rounded-lg border px-2.5 py-1.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring",
821 isSelected
822 ? "border-foreground/60 bg-muted/30"
823 : "border-border/50 hover:bg-muted/20",
824 )}
825 onClick={() => setQrEndpointId(option.id)}
826 >
827 <span
828 className={cn(
829 "text-xs font-medium",
830 isSelected ? "text-foreground" : "text-muted-foreground",
831 )}
832 >
833 {option.label}
834 </span>
835 <span className="min-w-0 truncate text-[11px] text-muted-foreground/70">
836 {option.detail}
837 </span>
838 </button>
839 );
840 })}
841 </div>
842 ) : null}
843 <div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-2.5 py-1.5">
844 <code
845 title={qrPairingUrl}
846 className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground"
847 >
848 {qrPairingUrl}
849 </code>
850 <Button
851 size="xs"
852 variant="ghost"
853 className="shrink-0"
854 onClick={() => copyPairingValue(qrPairingUrl, copyKindForUrl(qrPairingUrl))}
855 >
856 Copy link
857 </Button>
858 </div>
859 <Button size="xs" variant="ghost" onClick={handleCopyCode}>
860 Copy code only
861 </Button>
862 </div>
863 {canRenderQrForSelection ? (
864 <div className="w-fit shrink-0 self-center rounded-xl bg-white p-3 sm:self-start">
865 <QRCodeSvg
866 value={qrPairingUrl}
867 size={168}
868 level="M"
869 marginSize={1}
870 title="Pairing link — scan to open on another device"
871 />
872 </div>
873 ) : (
874 <div className="flex size-[192px] shrink-0 items-center justify-center self-center rounded-xl border border-border/50 p-4 sm:self-start">
875 <p className="text-center text-[11px] text-muted-foreground/70">
876 No QR for this endpoint. Another device scanning a loopback link would dial itself;
877 copy the URL for use on this machine instead.
878 </p>
879 </div>
880 )}
881 </div>
882 ) : null}
890883 </div>
891884 );
892885});