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...@@ -3,14 +3,44 @@ import type { EnvironmentId, PreviewRuntime, PreviewSessionSnapshot } from "@t3t
33
4import { isElectron } from "~/env";4import { isElectron } from "~/env";
5import { isPreviewSupportedInRuntime } from "~/previewStateStore";5import { isPreviewSupportedInRuntime } from "~/previewStateStore";
6import { appAtomRegistry } from "~/rpc/atomRegistry";
6import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";7import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
7import {8import {
8 readEnvironmentSupportsServerBrowser,9 readEnvironmentSupportsServerBrowser,
9 useEnvironmentSupportsServerBrowser,10 useEnvironmentSupportsServerBrowser,
10} from "~/state/entities";11} 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 */
12export function previewRuntimeFor(environmentId: EnvironmentId): PreviewRuntime | undefined {20export 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";
14}44}
1545
16/** Electron hosts its own browser tabs; other clients need the environment to host them. */46/** 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 {...@@ -64,6 +64,8 @@ interface Props {
64 nativePictureInPicture: boolean;64 nativePictureInPicture: boolean;
65 /** Profile display name, shown so the menu says which data is being cleared. */65 /** Profile display name, shown so the menu says which data is being cleared. */
66 profileName: string | undefined;66 profileName: string | undefined;
67 /** Reopens the tab in the other browser it can run in. */
68 move?: { readonly label: string; readonly onMove: () => void };
67}69}
6870
69const MenuTriggerButton = () => (71const MenuTriggerButton = () => (
...@@ -91,6 +93,7 @@ export function PreviewMoreMenu({...@@ -91,6 +93,7 @@ export function PreviewMoreMenu({
91 onToggleDeviceToolbar,93 onToggleDeviceToolbar,
92 nativePictureInPicture,94 nativePictureInPicture,
93 profileName,95 profileName,
96 move,
94}: Props) {97}: Props) {
95 const zoomLabel = `${Math.round(zoomFactor * 100)}%`;98 const zoomLabel = `${Math.round(zoomFactor * 100)}%`;
96 const disabled = !enabled;99 const disabled = !enabled;
...@@ -113,6 +116,7 @@ export function PreviewMoreMenu({...@@ -113,6 +116,7 @@ export function PreviewMoreMenu({
113 : "Open separate preview window"}116 : "Open separate preview window"}
114 </MenuItem>117 </MenuItem>
115 ) : null}118 ) : null}
119 {move ? <MenuItem onClick={move.onMove}>{move.label}</MenuItem> : null}
116 <MenuItem onClick={onToggleDeviceToolbar} disabled={disabled}>120 <MenuItem onClick={onToggleDeviceToolbar} disabled={disabled}>
117 {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"}121 {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"}
118 </MenuItem>122 </MenuItem>
apps/web/src/components/preview/PreviewUnreachable.tsx+11-1
...@@ -11,6 +11,8 @@ interface Props {...@@ -11,6 +11,8 @@ interface Props {
11 /** Stringified Chromium error, e.g. "ERR_NAME_NOT_RESOLVED". */11 /** Stringified Chromium error, e.g. "ERR_NAME_NOT_RESOLVED". */
12 description: string;12 description: string;
13 onReload: () => void;13 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 };
14}16}
1517
16/**18/**
...@@ -58,7 +60,7 @@ export function PreviewFileNotShown({...@@ -58,7 +60,7 @@ export function PreviewFileNotShown({
58}60}
5961
60/** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */62/** 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) {
62 const [showDetails, setShowDetails] = useState(false);64 const [showDetails, setShowDetails] = useState(false);
63 const host = safeHost(url) ?? url;65 const host = safeHost(url) ?? url;
64 const friendly = describePreviewError(description);66 const friendly = describePreviewError(description);
...@@ -81,6 +83,9 @@ export function PreviewUnreachable({ url, code, description, onReload }: Props)...@@ -81,6 +83,9 @@ export function PreviewUnreachable({ url, code, description, onReload }: Props)
81 <ul className="list-disc space-y-1 pl-5 text-muted-foreground">83 <ul className="list-disc space-y-1 pl-5 text-muted-foreground">
82 <li>Checking your connection</li>84 <li>Checking your connection</li>
83 <li>Confirming the dev server is running</li>85 <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}
84 <li>Checking the proxy and the firewall</li>89 <li>Checking the proxy and the firewall</li>
85 </ul>90 </ul>
86 </div>91 </div>
...@@ -100,6 +105,11 @@ export function PreviewUnreachable({ url, code, description, onReload }: Props)...@@ -100,6 +105,11 @@ export function PreviewUnreachable({ url, code, description, onReload }: Props)
100 {showDetails ? "Hide details" : "Details"}105 {showDetails ? "Hide details" : "Details"}
101 </Button>106 </Button>
102 <div className="flex-1" />107 <div className="flex-1" />
108 {move ? (
109 <Button type="button" variant="outline" size="sm" onClick={move.onMove}>
110 {move.label}
111 </Button>
112 ) : null}
103 <Button type="button" size="sm" onClick={onReload}>113 <Button type="button" size="sm" onClick={onReload}>
104 Reload114 Reload
105 </Button>115 </Button>
apps/web/src/components/preview/PreviewView.test.tsx+2-1
...@@ -151,10 +151,11 @@ vi.mock("~/previewStateStore", () => ({...@@ -151,10 +151,11 @@ vi.mock("~/previewStateStore", () => ({
151vi.mock("~/state/environments", () => ({151vi.mock("~/state/environments", () => ({
152 useEnvironment: () => ({ label: "WSL" }),152 useEnvironment: () => ({ label: "WSL" }),
153 useEnvironmentHttpBaseUrl: () => "http://172.25.85.75:3773",153 useEnvironmentHttpBaseUrl: () => "http://172.25.85.75:3773",
154 usePrimaryEnvironmentId: () => null,
154}));155}));
155156
156vi.mock("~/state/preview", () => ({157vi.mock("~/state/preview", () => ({
157 previewEnvironment: { open: {}, resize: {} },158 previewEnvironment: { open: {}, close: {}, resize: {} },
158}));159}));
159160
160vi.mock("~/state/use-atom-command", () => ({161vi.mock("~/state/use-atom-command", () => ({
apps/web/src/components/preview/PreviewView.tsx+60-2
...@@ -36,7 +36,12 @@ import {...@@ -36,7 +36,12 @@ import {
36 useThreadPreviewState,36 useThreadPreviewState,
37} from "~/previewStateStore";37} from "~/previewStateStore";
38import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";38import { 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";
40import { previewEnvironment } from "~/state/preview";45import { previewEnvironment } from "~/state/preview";
41import { usePreviewStreamAccess } from "~/state/previewStream";46import { usePreviewStreamAccess } from "~/state/previewStream";
42import { useAtomCommand } from "~/state/use-atom-command";47import { useAtomCommand } from "~/state/use-atom-command";
...@@ -50,6 +55,7 @@ import { useRightPanelStore } from "~/rightPanelStore";...@@ -50,6 +55,7 @@ import { useRightPanelStore } from "~/rightPanelStore";
5055
51import { previewBridge } from "./previewBridge";56import { previewBridge } from "./previewBridge";
52import { subscribePreviewAction } from "./previewActionBus";57import { subscribePreviewAction } from "./previewActionBus";
58import { closePreviewSession } from "./closePreviewSession";
53import { openPreviewSession } from "./openPreviewSession";59import { openPreviewSession } from "./openPreviewSession";
54import { showPreviewPopup } from "./showPreviewPopup";60import { showPreviewPopup } from "./showPreviewPopup";
55import { PreviewChromeRow } from "./PreviewChromeRow";61import { PreviewChromeRow } from "./PreviewChromeRow";
...@@ -69,7 +75,7 @@ import { revealInFileExplorerLabel } from "./fileExplorerLabel";...@@ -69,7 +75,7 @@ import { revealInFileExplorerLabel } from "./fileExplorerLabel";
69import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";75import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic";
70import { Badge } from "~/components/ui/badge";76import { Badge } from "~/components/ui/badge";
71import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";77import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
72import { useRendersServerTabNatively } from "~/browser/previewRuntime";78import { alternatePreviewRuntime, useRendersServerTabNatively } from "~/browser/previewRuntime";
73import { ServerBrowserSurface, type ServerBrowserHandle } from "~/browser/ServerBrowserSurface";79import { ServerBrowserSurface, type ServerBrowserHandle } from "~/browser/ServerBrowserSurface";
74import { cn } from "~/lib/utils";80import { cn } from "~/lib/utils";
75import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";81import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore";
...@@ -169,6 +175,10 @@ export function PreviewView({...@@ -169,6 +175,10 @@ export function PreviewView({
169 ? new URL(environmentHttpBaseUrl).hostname175 ? new URL(environmentHttpBaseUrl).hostname
170 : null;176 : null;
171 const open = useAtomCommand(previewEnvironment.open);177 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);
172 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");182 const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize");
173 const adjust = useAtomCommand(previewEnvironment.adjust, "preview appearance or zoom");183 const adjust = useAtomCommand(previewEnvironment.adjust, "preview appearance or zoom");
174184
...@@ -438,6 +448,48 @@ export function PreviewView({...@@ -438,6 +448,48 @@ export function PreviewView({
438 void localApi.shell.openExternal(url).catch(() => undefined);448 void localApi.shell.openExternal(url).catch(() => undefined);
439 }, [url]);449 }, [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
441 const handlePictureInPicture = useCallback(() => {493 const handlePictureInPicture = useCallback(() => {
442 if (!tabId) return;494 if (!tabId) return;
443 if (miniPlayerTabId === tabId) {495 if (miniPlayerTabId === tabId) {
...@@ -977,6 +1029,9 @@ export function PreviewView({...@@ -977,6 +1029,9 @@ export function PreviewView({
977 (serverOwnsRendering || (desktopOverlay?.hasWebContents ?? false))1029 (serverOwnsRendering || (desktopOverlay?.hasWebContents ?? false))
978 }1030 }
979 actions={moreMenuActions}1031 actions={moreMenuActions}
1032 {...(moveTarget
1033 ? { move: { label: moveLabel, onMove: () => void handleMoveTab() } }
1034 : {})}
980 profileName={activeProfileName}1035 profileName={activeProfileName}
981 zoomFactor={1036 zoomFactor={
982 serverOwnsRendering ? serverZoomFactor : (desktopOverlay?.zoomFactor ?? 1)1037 serverOwnsRendering ? serverZoomFactor : (desktopOverlay?.zoomFactor ?? 1)
...@@ -1088,6 +1143,9 @@ export function PreviewView({...@@ -1088,6 +1143,9 @@ export function PreviewView({
1088 code={navStatus.code}1143 code={navStatus.code}
1089 description={navStatus.description}1144 description={navStatus.description}
1090 onReload={handleRefresh}1145 onReload={handleRefresh}
1146 {...(moveTarget === "server"
1147 ? { move: { label: moveLabel, onMove: () => void handleMoveTab() } }
1148 : {})}
1091 />1149 />
1092 </div>1150 </div>
1093 ) : null}1151 ) : null}
apps/web/src/components/preview/openPreviewSession.ts+5-2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type {1import type {
2 EnvironmentId,2 EnvironmentId,
3 PreviewOpenInput,3 PreviewOpenInput,
4 PreviewRuntime,
4 PreviewSessionSnapshot,5 PreviewSessionSnapshot,
5 PreviewViewportSetting,6 PreviewViewportSetting,
6 ScopedThreadRef,7 ScopedThreadRef,
...@@ -29,6 +30,8 @@ interface OpenPreviewSessionInput<E> {...@@ -29,6 +30,8 @@ interface OpenPreviewSessionInput<E> {
29 viewport?: PreviewViewportSetting;30 viewport?: PreviewViewportSetting;
30 /** Overrides the configured default profile. */31 /** Overrides the configured default profile. */
31 profileId?: string;32 profileId?: string;
33 /** Overrides where the tab runs, such as when moving a tab between browsers. */
34 runtime?: PreviewRuntime;
32}35}
3336
34export async function openPreviewSession<E>(37export async function openPreviewSession<E>(
...@@ -42,7 +45,7 @@ export async function openPreviewSession<E>(...@@ -42,7 +45,7 @@ export async function openPreviewSession<E>(
42 if (defaults instanceof BrowserSettingsReadError) {45 if (defaults instanceof BrowserSettingsReadError) {
43 return AsyncResult.failure(Cause.fail(defaults));46 return AsyncResult.failure(Cause.fail(defaults));
44 }47 }
45 const runtime = previewRuntimeFor(input.threadRef.environmentId);48 const runtime = input.runtime ?? previewRuntimeFor(input.threadRef.environmentId);
46 const result = await input.openPreview({49 const result = await input.openPreview({
47 environmentId: input.threadRef.environmentId,50 environmentId: input.threadRef.environmentId,
48 input: {51 input: {
...@@ -50,7 +53,7 @@ export async function openPreviewSession<E>(...@@ -50,7 +53,7 @@ export async function openPreviewSession<E>(
50 ...(input.url === undefined ? {} : { url: input.url }),53 ...(input.url === undefined ? {} : { url: input.url }),
51 viewport: input.viewport ?? browserDefaultOpenViewport(defaults),54 viewport: input.viewport ?? browserDefaultOpenViewport(defaults),
52 profileId: input.profileId ?? browserDefaultOpenProfileId(defaults),55 profileId: input.profileId ?? browserDefaultOpenProfileId(defaults),
53 ...(runtime === undefined ? {} : { runtime }),56 ...(runtime === undefined || runtime === "desktop" ? {} : { runtime }),
54 },57 },
55 });58 });
56 if (result._tag === "Failure") {59 if (result._tag === "Failure") {