authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 16:39:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 16:39:34-07:00
log66298aa926e6a4c95ebecbc576a68634315899c0
tree6c21f1b0c8607ed7b02cd31098676f90ad5d7adf
parentd9f7c0caed5cc0fe84cec97f2b6f002084542a4e
signature Signed by PGP key B5690EEEBB952194

fix(web): desktop opens remote environments' browser tabs locally (#17316)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

7 files changed, 173 insertions(+), 7 deletions(-)

apps/web/src/browser/previewRuntime.test.ts created+60
......@@ -0,0 +1,60 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4const state = vi.hoisted(() => ({
5 desktop: false,
6 primary: null as string | null,
7 serverBrowser: new Set<string>(),
8}));
9
10vi.mock("~/env", () => ({ isElectron: false }));
11vi.mock("~/previewStateStore", () => ({ isPreviewSupportedInRuntime: () => state.desktop }));
12vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: { get: () => state.primary } }));
13vi.mock("~/state/primaryEnvironment", () => ({ primaryEnvironmentIdAtom: {} }));
14vi.mock("~/state/entities", () => ({
15 readEnvironmentSupportsServerBrowser: (id: string) => state.serverBrowser.has(id),
16 useEnvironmentSupportsServerBrowser: () => false,
17}));
18
19import { alternatePreviewRuntime, previewRuntimeFor } from "./previewRuntime";
20
21const local = "local" as EnvironmentId;
22const remote = "remote" as EnvironmentId;
23
24afterEach(() => {
25 state.desktop = false;
26 state.primary = null;
27 state.serverBrowser = new Set();
28});
29
30describe("previewRuntimeFor", () => {
31 it("opens a remote environment's tabs on this computer in the desktop app", () => {
32 state.desktop = true;
33 state.primary = local;
34 state.serverBrowser = new Set([local, remote]);
35
36 expect(previewRuntimeFor(remote)).toBeUndefined();
37 expect(previewRuntimeFor(local)).toBe("server");
38 });
39
40 it("uses the environment's browser where the client has none of its own", () => {
41 state.serverBrowser = new Set([remote]);
42
43 expect(previewRuntimeFor(remote)).toBe("server");
44 });
45});
46
47describe("alternatePreviewRuntime", () => {
48 it("moves a remote environment's tab between this computer and the environment", () => {
49 state.desktop = true;
50
51 expect(alternatePreviewRuntime(remote, local, true, {})).toBe("server");
52 expect(alternatePreviewRuntime(remote, local, true, { runtime: "server" })).toBe("desktop");
53 expect(alternatePreviewRuntime(local, local, true, {})).toBeNull();
54 expect(alternatePreviewRuntime(remote, local, false, {})).toBeNull();
55 });
56
57 it("offers no move outside the desktop app", () => {
58 expect(alternatePreviewRuntime(remote, null, true, { runtime: "server" })).toBeNull();
59 });
60});
apps/web/src/browser/previewRuntime.ts+31-1
......@@ -3,14 +3,44 @@ import type { EnvironmentId, PreviewRuntime, PreviewSessionSnapshot } from "@t3t
33
44import { isElectron } from "~/env";
55import { isPreviewSupportedInRuntime } from "~/previewStateStore";
6import { appAtomRegistry } from "~/rpc/atomRegistry";
67import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
78import {
89 readEnvironmentSupportsServerBrowser,
910 useEnvironmentSupportsServerBrowser,
1011} from "~/state/entities";
1112
13/**
14 * Where a tab the user opens should run. The desktop app draws its own
15 * server's tabs natively, so those stay agent-drivable at no cost. A remote
16 * environment's tabs would stream, so the desktop opens them in its own
17 * browser instead: no latency, and it reaches what this computer reaches. The
18 * user can move a tab to the environment when only it can reach the page.
19 */
1220export function previewRuntimeFor(environmentId: EnvironmentId): PreviewRuntime | undefined {
13 return readEnvironmentSupportsServerBrowser(environmentId) ? "server" : undefined;
21 if (!readEnvironmentSupportsServerBrowser(environmentId)) return undefined;
22 if (
23 isPreviewSupportedInRuntime() &&
24 environmentId !== appAtomRegistry.get(primaryEnvironmentIdAtom)
25 ) {
26 return undefined;
27 }
28 return "server";
29}
30
31/**
32 * The other browser a tab can move to, or null when it has none: a desktop
33 * tab of a remote environment can move to that environment's browser, and back.
34 */
35export function alternatePreviewRuntime(
36 environmentId: EnvironmentId,
37 primaryEnvironmentId: EnvironmentId | null,
38 serverBrowser: boolean,
39 snapshot: Pick<PreviewSessionSnapshot, "runtime"> | null | undefined,
40): PreviewRuntime | null {
41 if (!snapshot || !serverBrowser || !isPreviewSupportedInRuntime()) return null;
42 if (environmentId === primaryEnvironmentId) return null;
43 return snapshot.runtime === "server" ? "desktop" : "server";
1444}
1545
1646/** Electron hosts its own browser tabs; other clients need the environment to host them. */
apps/web/src/components/preview/PreviewMoreMenu.tsx+4
......@@ -64,6 +64,8 @@ interface Props {
6464 nativePictureInPicture: boolean;
6565 /** Profile display name, shown so the menu says which data is being cleared. */
6666 profileName: string | undefined;
67 /** Reopens the tab in the other browser it can run in. */
68 move?: { readonly label: string; readonly onMove: () => void };
6769}
6870
6971const MenuTriggerButton = () => (
......@@ -91,6 +93,7 @@ export function PreviewMoreMenu({
9193 onToggleDeviceToolbar,
9294 nativePictureInPicture,
9395 profileName,
96 move,
9497}: Props) {
9598 const zoomLabel = `${Math.round(zoomFactor * 100)}%`;
9699 const disabled = !enabled;
......@@ -113,6 +116,7 @@ export function PreviewMoreMenu({
113116 : "Open separate preview window"}
114117 </MenuItem>
115118 ) : null}
119 {move ? <MenuItem onClick={move.onMove}>{move.label}</MenuItem> : null}
116120 <MenuItem onClick={onToggleDeviceToolbar} disabled={disabled}>
117121 {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"}
118122 </MenuItem>
apps/web/src/components/preview/PreviewUnreachable.tsx+11-1
......@@ -11,6 +11,8 @@ interface Props {
1111 /** Stringified Chromium error, e.g. "ERR_NAME_NOT_RESOLVED". */
1212 description: string;
1313 onReload: () => void;
14 /** Reopens the page in a browser that may reach it, such as the environment's. */
15 move?: { readonly label: string; readonly onMove: () => void };
1416}
1517
1618/**
......@@ -58,7 +60,7 @@ export function PreviewFileNotShown({
5860}
5961
6062/** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */
61export function PreviewUnreachable({ url, code, description, onReload }: Props) {
63export function PreviewUnreachable({ url, code, description, onReload, move }: Props) {
6264 const [showDetails, setShowDetails] = useState(false);
6365 const host = safeHost(url) ?? url;
6466 const friendly = describePreviewError(description);
......@@ -81,6 +83,9 @@ export function PreviewUnreachable({ url, code, description, onReload }: Props)
8183 <ul className="list-disc space-y-1 pl-5 text-muted-foreground">
8284 <li>Checking your connection</li>
8385 <li>Confirming the dev server is running</li>
86 {move ? (
87 <li>Opening it in the environment&rsquo;s browser, which reaches its network</li>
88 ) : null}
8489 <li>Checking the proxy and the firewall</li>
8590 </ul>
8691 </div>
......@@ -100,6 +105,11 @@ export function PreviewUnreachable({ url, code, description, onReload }: Props)
100105 {showDetails ? "Hide details" : "Details"}
101106 </Button>
102107 <div className="flex-1" />
108 {move ? (
109 <Button type="button" variant="outline" size="sm" onClick={move.onMove}>
110 {move.label}
111 </Button>
112 ) : null}
103113 <Button type="button" size="sm" onClick={onReload}>
104114 Reload
105115 </Button>
apps/web/src/components/preview/PreviewView.test.tsx+2-1
......@@ -151,10 +151,11 @@ vi.mock("~/previewStateStore", () => ({
151151vi.mock("~/state/environments", () => ({
152152 useEnvironment: () => ({ label: "WSL" }),
153153 useEnvironmentHttpBaseUrl: () => "http://172.25.85.75:3773",
154 usePrimaryEnvironmentId: () => null,
154155}));
155156
156157vi.mock("~/state/preview", () => ({
157 previewEnvironment: { open: {}, resize: {} },
158 previewEnvironment: { open: {}, close: {}, resize: {} },
158159}));
159160
160161vi.mock("~/state/use-atom-command", () => ({
apps/web/src/components/preview/PreviewView.tsx+60-2
......@@ -36,7 +36,12 @@ import {
3636 useThreadPreviewState,
3737} from "~/previewStateStore";
3838import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
39import { useEnvironmentHttpBaseUrl } from "~/state/environments";
39import { useEnvironmentSupportsServerBrowser } from "~/state/entities";
40import {
41 useEnvironment,
42 useEnvironmentHttpBaseUrl,
43 usePrimaryEnvironmentId,
44} from "~/state/environments";
4045import { previewEnvironment } from "~/state/preview";
4146import { usePreviewStreamAccess } from "~/state/previewStream";
4247import { useAtomCommand } from "~/state/use-atom-command";
......@@ -50,6 +55,7 @@ import { useRightPanelStore } from "~/rightPanelStore";
5055
5156import { previewBridge } from "./previewBridge";
5257import { subscribePreviewAction } from "./previewActionBus";
58import { closePreviewSession } from "./closePreviewSession";
5359import { openPreviewSession } from "./openPreviewSession";
5460import { showPreviewPopup } from "./showPreviewPopup";
5561import { PreviewChromeRow } from "./PreviewChromeRow";
......@@ -69,7 +75,7 @@ import { revealInFileExplorerLabel } from "./fileExplorerLabel";
6975import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
7076import { Badge } from "~/components/ui/badge";
7177import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
72import { useRendersServerTabNatively } from "~/browser/previewRuntime";
78import { alternatePreviewRuntime, useRendersServerTabNatively } from "~/browser/previewRuntime";
7379import { ServerBrowserSurface, type ServerBrowserHandle } from "~/browser/ServerBrowserSurface";
7480import { cn } from "~/lib/utils";
7581import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
......@@ -169,6 +175,10 @@ export function PreviewView({
169175 ? new URL(environmentHttpBaseUrl).hostname
170176 : null;
171177 const open = useAtomCommand(previewEnvironment.open);
178 const closePreview = useAtomCommand(previewEnvironment.close, "preview close");
179 const environmentLabel = useEnvironment(threadRef.environmentId)?.label ?? "the environment";
180 const primaryEnvironmentId = usePrimaryEnvironmentId();
181 const serverBrowser = useEnvironmentSupportsServerBrowser(threadRef.environmentId);
172182 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");
173183 const adjust = useAtomCommand(previewEnvironment.adjust, "preview appearance or zoom");
174184
......@@ -438,6 +448,48 @@ export function PreviewView({
438448 void localApi.shell.openExternal(url).catch(() => undefined);
439449 }, [url]);
440450
451 // A desktop tab of a remote environment can only reach what this computer
452 // reaches; the environment's browser reaches its own network, and its agents.
453 const moveTarget = alternatePreviewRuntime(
454 threadRef.environmentId,
455 primaryEnvironmentId,
456 serverBrowser,
457 snapshot,
458 );
459 const moveLabel =
460 moveTarget === "server" ? `Open in ${environmentLabel}'s browser` : "Open on this computer";
461 const handleMoveTab = useCallback(async () => {
462 if (!moveTarget || !tabId || !snapshot) return;
463 const result = await openPreviewSession({
464 openPreview: open,
465 threadRef,
466 // Loopback means the environment's machine, which this computer reaches by its address.
467 ...(url
468 ? {
469 url:
470 moveTarget === "desktop"
471 ? resolveDiscoveredServerUrl(threadRef.environmentId, url)
472 : url,
473 }
474 : {}),
475 viewport,
476 ...(snapshot.profileId === undefined ? {} : { profileId: snapshot.profileId }),
477 runtime: moveTarget,
478 });
479 if (result._tag === "Failure") {
480 if (isAtomCommandInterrupted(result)) return;
481 const error = squashAtomCommandFailure(result);
482 toastManager.add({
483 type: "error",
484 title: "Unable to move the browser tab",
485 description: error instanceof Error ? error.message : "An error occurred.",
486 });
487 return;
488 }
489 useRightPanelStore.getState().openBrowser(threadRef, result.value.tabId);
490 await closePreviewSession({ closePreview, snapshot, tabId, threadRef });
491 }, [closePreview, moveTarget, open, snapshot, tabId, threadRef, url, viewport]);
492
441493 const handlePictureInPicture = useCallback(() => {
442494 if (!tabId) return;
443495 if (miniPlayerTabId === tabId) {
......@@ -977,6 +1029,9 @@ export function PreviewView({
9771029 (serverOwnsRendering || (desktopOverlay?.hasWebContents ?? false))
9781030 }
9791031 actions={moreMenuActions}
1032 {...(moveTarget
1033 ? { move: { label: moveLabel, onMove: () => void handleMoveTab() } }
1034 : {})}
9801035 profileName={activeProfileName}
9811036 zoomFactor={
9821037 serverOwnsRendering ? serverZoomFactor : (desktopOverlay?.zoomFactor ?? 1)
......@@ -1088,6 +1143,9 @@ export function PreviewView({
10881143 code={navStatus.code}
10891144 description={navStatus.description}
10901145 onReload={handleRefresh}
1146 {...(moveTarget === "server"
1147 ? { move: { label: moveLabel, onMove: () => void handleMoveTab() } }
1148 : {})}
10911149 />
10921150 </div>
10931151 ) : null}
apps/web/src/components/preview/openPreviewSession.ts+5-2
......@@ -1,6 +1,7 @@
11import type {
22 EnvironmentId,
33 PreviewOpenInput,
4 PreviewRuntime,
45 PreviewSessionSnapshot,
56 PreviewViewportSetting,
67 ScopedThreadRef,
......@@ -29,6 +30,8 @@ interface OpenPreviewSessionInput<E> {
2930 viewport?: PreviewViewportSetting;
3031 /** Overrides the configured default profile. */
3132 profileId?: string;
33 /** Overrides where the tab runs, such as when moving a tab between browsers. */
34 runtime?: PreviewRuntime;
3235}
3336
3437export async function openPreviewSession<E>(
......@@ -42,7 +45,7 @@ export async function openPreviewSession<E>(
4245 if (defaults instanceof BrowserSettingsReadError) {
4346 return AsyncResult.failure(Cause.fail(defaults));
4447 }
45 const runtime = previewRuntimeFor(input.threadRef.environmentId);
48 const runtime = input.runtime ?? previewRuntimeFor(input.threadRef.environmentId);
4649 const result = await input.openPreview({
4750 environmentId: input.threadRef.environmentId,
4851 input: {
......@@ -50,7 +53,7 @@ export async function openPreviewSession<E>(
5053 ...(input.url === undefined ? {} : { url: input.url }),
5154 viewport: input.viewport ?? browserDefaultOpenViewport(defaults),
5255 profileId: input.profileId ?? browserDefaultOpenProfileId(defaults),
53 ...(runtime === undefined ? {} : { runtime }),
56 ...(runtime === undefined || runtime === "desktop" ? {} : { runtime }),
5457 },
5558 });
5659 if (result._tag === "Failure") {