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