authorgravatar for 67719167+Chrrxs@users.noreply.github.comChris Michael Guzman <67719167+Chrrxs@users.noreply.github.com> 2026-07-20 05:49:22-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 11:49:22+02:00
log2fae0d0ac300353be774c9f9fe79d3af025d4f91
treeed8d496489923026b2a9d282e8d558753dd607b4
parent08993a5ec8fbe5c55a2a4fa8f5fb37f870e87cf6
signature Signed by PGP key B5690EEEBB952194

fix(preview): preserve direct localhost navigation (#3939)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 227 insertions(+), 15 deletions(-)

apps/web/src/components/preview/PreviewView.test.tsx created+194
......@@ -0,0 +1,194 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5const mocks = vi.hoisted(() => ({
6 navigate: vi.fn(async (_tabId: string, _url: string): Promise<void> => undefined),
7 rememberPreviewUrl: vi.fn(),
8 readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })),
9 submittedUrl: null as ((url: string) => void) | null,
10 emptyStateUrl: null as ((url: string) => void) | null,
11 showEmptyState: false,
12}));
13
14vi.mock("~/state/session", () => ({
15 readPreparedConnection: mocks.readPreparedConnection,
16}));
17
18vi.mock("~/composerDraftStore", () => ({
19 useComposerDraftStore: (
20 select: (store: { addPreviewAnnotation: () => void; addImage: () => void }) => unknown,
21 ) => select({ addPreviewAnnotation: vi.fn(), addImage: vi.fn() }),
22}));
23
24vi.mock("~/lib/previewAnnotation", () => ({
25 previewAnnotationScreenshotFile: vi.fn(),
26}));
27
28vi.mock("~/localApi", () => ({
29 ensureLocalApi: vi.fn(),
30}));
31
32vi.mock("~/previewStateStore", () => ({
33 rememberPreviewUrl: mocks.rememberPreviewUrl,
34 updatePreviewServerSnapshot: vi.fn(),
35 useThreadPreviewState: () => ({
36 activeTabId: "tab-1",
37 desktopByTabId: {
38 "tab-1": {
39 canGoBack: false,
40 canGoForward: false,
41 loading: false,
42 zoomFactor: 1,
43 controller: "none",
44 },
45 },
46 recentlySeenUrls: [],
47 sessions: mocks.showEmptyState
48 ? {}
49 : {
50 "tab-1": {
51 threadId: "thread-1",
52 tabId: "tab-1",
53 navStatus: {
54 _tag: "Success",
55 url: "http://example.com/",
56 title: "Example",
57 },
58 canGoBack: false,
59 canGoForward: false,
60 updatedAt: "2026-07-13T00:00:00.000Z",
61 },
62 },
63 }),
64}));
65
66vi.mock("~/state/environments", () => ({
67 useEnvironment: () => ({ label: "WSL" }),
68 useEnvironmentHttpBaseUrl: () => "http://172.25.85.75:3773",
69}));
70
71vi.mock("~/state/preview", () => ({
72 previewEnvironment: { open: {}, resize: {} },
73}));
74
75vi.mock("~/state/use-atom-command", () => ({
76 useAtomCommand: () => vi.fn(),
77}));
78
79vi.mock("~/browser/browserRecording", () => ({
80 startBrowserRecording: vi.fn(),
81 stopBrowserRecording: vi.fn(),
82 useActiveBrowserRecordingTabId: () => null,
83}));
84
85vi.mock("~/browser/browserSurfaceStore", () => ({
86 useBrowserSurfaceStore: (
87 select: (state: { byTabId: Record<string, { rect?: unknown }> }) => unknown,
88 ) => select({ byTabId: {} }),
89}));
90
91vi.mock("~/components/ui/toast", () => ({
92 stackedThreadToast: vi.fn(),
93 toastManager: { add: vi.fn() },
94}));
95
96vi.mock("./previewBridge", () => ({
97 previewBridge: { navigate: mocks.navigate },
98}));
99
100vi.mock("./PreviewChromeRow", () => ({
101 PreviewChromeRow: (props: { onSubmit: (url: string) => void }) => {
102 mocks.submittedUrl = props.onSubmit;
103 return null;
104 },
105}));
106
107vi.mock("./PreviewEmptyState", () => ({
108 PreviewEmptyState: (props: { onOpenUrl: (url: string) => void }) => {
109 mocks.emptyStateUrl = props.onOpenUrl;
110 return null;
111 },
112}));
113vi.mock("./PreviewMoreMenu", () => ({ PreviewMoreMenu: () => null }));
114vi.mock("./PreviewUnreachable", () => ({ PreviewUnreachable: () => null }));
115vi.mock("./ZoomIndicator", () => ({ ZoomIndicator: () => null }));
116vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null }));
117vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null }));
118vi.mock("./useLoadingProgress", () => ({ useLoadingProgress: () => 0 }));
119vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() }));
120
121import { PreviewView } from "./PreviewView";
122
123describe("PreviewView navigation", () => {
124 beforeEach(() => {
125 mocks.navigate.mockClear();
126 mocks.rememberPreviewUrl.mockClear();
127 mocks.readPreparedConnection.mockClear();
128 mocks.submittedUrl = null;
129 mocks.emptyStateUrl = null;
130 mocks.showEmptyState = false;
131 });
132
133 it.each([
134 [
135 "https://localhost:8000/dashboard?mode=test#top",
136 "https://localhost:8000/dashboard?mode=test#top",
137 ],
138 ["localhost:5173/app", "http://localhost:5173/app"],
139 ])("preserves a direct localhost URL in a WSL environment", async (submitted, expected) => {
140 renderToStaticMarkup(
141 <PreviewView
142 threadRef={{
143 environmentId: EnvironmentId.make("environment-1"),
144 threadId: ThreadId.make("thread-1"),
145 }}
146 tabId="tab-1"
147 visible
148 />,
149 );
150
151 expect(mocks.submittedUrl).not.toBeNull();
152 mocks.submittedUrl?.(submitted);
153
154 await vi.waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith("tab-1", expected));
155 expect(mocks.rememberPreviewUrl).toHaveBeenCalledWith(
156 {
157 environmentId: "environment-1",
158 threadId: "thread-1",
159 },
160 expected,
161 );
162 });
163
164 it("maps an empty-state localhost server onto the WSL host", async () => {
165 mocks.showEmptyState = true;
166 renderToStaticMarkup(
167 <PreviewView
168 threadRef={{
169 environmentId: EnvironmentId.make("environment-1"),
170 threadId: ThreadId.make("thread-1"),
171 }}
172 tabId="tab-1"
173 visible
174 />,
175 );
176
177 expect(mocks.emptyStateUrl).not.toBeNull();
178 mocks.emptyStateUrl?.("http://localhost:5173/app?mode=test#top");
179
180 await vi.waitFor(() =>
181 expect(mocks.navigate).toHaveBeenCalledWith(
182 "tab-1",
183 "http://172.25.85.75:5173/app?mode=test#top",
184 ),
185 );
186 expect(mocks.rememberPreviewUrl).toHaveBeenCalledWith(
187 {
188 environmentId: "environment-1",
189 threadId: "thread-1",
190 },
191 "http://172.25.85.75:5173/app?mode=test#top",
192 );
193 });
194});
apps/web/src/components/preview/PreviewView.tsx+33-15
......@@ -7,6 +7,7 @@ import {
77 type PreviewViewportSetting,
88 type ScopedThreadRef,
99} from "@t3tools/contracts";
10import { normalizePreviewUrl } from "@t3tools/shared/preview";
1011import { useCallback, useEffect, useRef, useState } from "react";
1112
1213import { useComposerDraftStore } from "~/composerDraftStore";
......@@ -112,27 +113,44 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
112113 tabId ? (state.byTabId[tabId]?.rect ?? null) : null,
113114 );
114115
116 const navigateToResolvedUrl = useCallback(
117 async (resolvedUrl: string) => {
118 if (tabId && previewBridge) {
119 // Drive the webview imperatively; `usePreviewBridge` mirrors the
120 // resolved URL back to the server so other clients stay in sync.
121 await previewBridge.navigate(tabId, resolvedUrl);
122 rememberPreviewUrl(threadRef, resolvedUrl);
123 } else {
124 await openPreviewSession({
125 openPreview: open,
126 threadRef,
127 url: resolvedUrl,
128 });
129 }
130 },
131 [open, tabId, threadRef],
132 );
133
115134 const handleSubmitUrl = useCallback(
116135 async (next: string) => {
117136 try {
118 const resolvedUrl = resolveDiscoveredServerUrl(threadRef.environmentId, next);
119 if (tabId && previewBridge) {
120 // Drive the webview imperatively; `usePreviewBridge` mirrors the
121 // resolved URL back to the server so other clients stay in sync.
122 await previewBridge.navigate(tabId, resolvedUrl);
123 rememberPreviewUrl(threadRef, resolvedUrl);
124 } else {
125 await openPreviewSession({
126 openPreview: open,
127 threadRef,
128 url: resolvedUrl,
129 });
130 }
137 await navigateToResolvedUrl(normalizePreviewUrl(next));
131138 } catch {
132139 // Server-side `failed` event renders the unreachable view.
133140 }
134141 },
135 [open, tabId, threadRef],
142 [navigateToResolvedUrl],
143 );
144
145 const handleOpenServerUrl = useCallback(
146 async (next: string) => {
147 try {
148 await navigateToResolvedUrl(resolveDiscoveredServerUrl(threadRef.environmentId, next));
149 } catch {
150 // Server-side `failed` event renders the unreachable view.
151 }
152 },
153 [navigateToResolvedUrl, threadRef.environmentId],
136154 );
137155
138156 const handleRefresh = useCallback(() => {
......@@ -613,7 +631,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
613631 environmentId={threadRef.environmentId}
614632 configuredUrls={configuredUrls}
615633 recentlySeenUrls={previewState.recentlySeenUrls}
616 onOpenUrl={(next) => void handleSubmitUrl(next)}
634 onOpenUrl={(next) => void handleOpenServerUrl(next)}
617635 />
618636 ) : null}
619637 {snapshot && desktopOverlay ? (