Skip to content

Commit d0a5c29

Browse files
Copilotsawka
andauthored
Add preview-safe web widget preview (#3062)
The preview server already had a `sysinfo` example, but the `web` widget could not be previewed because it relies on Electron’s `<webview>` tag. This change adds a standalone `web` preview and replaces the Electron-only renderer with a preview-safe mock placeholder that surfaces the target URL. - **Preview server** - Added `frontend/preview/previews/web.preview.tsx` - Reuses the existing full-block preview pattern (`Block` + mock workspace/tab/block setup) - Seeds the preview mock object store so `WebViewModel` sees the same block metadata it expects in the app - **Web widget fallback in preview** - Wrapped the shared web view renderer with `MockBoundary` - In preview windows, renders a lightweight placeholder instead of `<webview>` - Placeholder shows the resolved URL that would be loaded by the real widget - **WebView model hardening** - Made block metadata reads tolerant of preview initialization timing - Normalized preview URL handling so empty/null values fall back to `about:blank` - Ensured header input state remains string-backed during preview rendering - **Focused coverage** - Added a small unit test for preview fallback URL rendering and blank/null URL normalization ```tsx <MockBoundary fallback={<WebViewPreviewFallback url={metaUrl} />}> <webview id="webview" className="webview" ref={model.webviewRef} src={metaUrlInitial} preload={getWebviewPreloadUrl()} allowpopups="true" partition={webPartition} useragent={userAgent} /> </MockBoundary> ``` - **<screenshot>** - Preview UI: https://github.com/user-attachments/assets/ac2be6f3-f56f-431e-a4b6-e25d2a270cf2 <!-- START COPILOT CODING AGENT SUFFIX --> <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> > checkout sysinfo.preview.tsx... i'd like to try creating a preview for another simple view. this time the web widget. > > note that it uses the electron webview tag which obviously won't work. so we'll just mock that up using hmm it is in the preview mock directory i think like a mock boundary... the fallback can just be a div showing the URL that is supposed to be rendered. </details> <!-- START COPILOT CODING AGENT TIPS --> --- 🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
1 parent ac6b2f3 commit d0a5c29

3 files changed

Lines changed: 198 additions & 18 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { renderToStaticMarkup } from "react-dom/server";
5+
import { describe, expect, it } from "vitest";
6+
import { getWebPreviewDisplayUrl, WebViewPreviewFallback } from "./webview";
7+
8+
describe("webview preview fallback", () => {
9+
it("shows the requested URL", () => {
10+
const markup = renderToStaticMarkup(<WebViewPreviewFallback url="https://waveterm.dev/docs" />);
11+
12+
expect(markup).toContain("electron webview unavailable");
13+
expect(markup).toContain("https://waveterm.dev/docs");
14+
});
15+
16+
it("falls back to about:blank when no URL is available", () => {
17+
expect(getWebPreviewDisplayUrl("")).toBe("about:blank");
18+
expect(getWebPreviewDisplayUrl(null)).toBe("about:blank");
19+
});
20+
});

frontend/app/view/webview/webview.tsx

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
SuggestionControlNoData,
1515
SuggestionControlNoResults,
1616
} from "@/app/suggestion/suggestion";
17+
import { MockBoundary } from "@/app/waveenv/mockboundary";
1718
import { WOS, globalStore } from "@/store/global";
1819
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
1920
import { fireAndForget, useAtomValueSafe } from "@/util/util";
@@ -83,7 +84,7 @@ export class WebViewModel implements ViewModel {
8384
const defaultUrlAtom = getSettingsKeyAtom("web:defaulturl");
8485
this.homepageUrl = atom((get) => {
8586
const defaultUrl = get(defaultUrlAtom);
86-
const pinnedUrl = get(this.blockAtom).meta.pinnedurl;
87+
const pinnedUrl = get(this.blockAtom)?.meta?.pinnedurl;
8788
return pinnedUrl ?? defaultUrl;
8889
});
8990
this.urlWrapperClassName = atom("");
@@ -112,7 +113,7 @@ export class WebViewModel implements ViewModel {
112113
const refreshIcon = get(this.refreshIcon);
113114
const mediaPlaying = get(this.mediaPlaying);
114115
const mediaMuted = get(this.mediaMuted);
115-
const url = currUrl ?? metaUrl ?? homepageUrl;
116+
const url = currUrl ?? metaUrl ?? homepageUrl ?? "";
116117
const rtn: HeaderElem[] = [];
117118
if (get(this.hideNav)) {
118119
return rtn;
@@ -802,13 +803,35 @@ interface WebViewProps {
802803
initialSrc?: string;
803804
}
804805

806+
function getWebPreviewDisplayUrl(url?: string | null): string {
807+
return url?.trim() || "about:blank";
808+
}
809+
810+
function WebViewPreviewFallback({ url }: { url?: string | null }) {
811+
const displayUrl = getWebPreviewDisplayUrl(url);
812+
813+
return (
814+
<div className="flex h-full w-full items-center justify-center bg-panel">
815+
<div className="mx-6 flex max-w-[720px] flex-col gap-3 rounded-lg border border-dashed border-border bg-background px-6 py-5 shadow-sm">
816+
<div className="text-xs font-mono text-muted">preview mock · electron webview unavailable</div>
817+
<div className="text-sm text-foreground">web widget placeholder</div>
818+
<div className="rounded-md border border-border bg-panel px-3 py-2 font-mono text-xs text-foreground break-all">
819+
{displayUrl}
820+
</div>
821+
</div>
822+
</div>
823+
);
824+
}
825+
805826
const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) => {
806827
const blockData = useAtomValue(model.blockAtom);
807828
const defaultUrl = useAtomValue(model.homepageUrl);
808829
const defaultSearchAtom = getSettingsKeyAtom("web:defaultsearch");
809830
const defaultSearch = useAtomValue(defaultSearchAtom);
810-
let metaUrl = blockData?.meta?.url || defaultUrl;
811-
metaUrl = model.ensureUrlScheme(metaUrl, defaultSearch);
831+
let metaUrl = blockData?.meta?.url || defaultUrl || "";
832+
if (metaUrl) {
833+
metaUrl = model.ensureUrlScheme(metaUrl, defaultSearch);
834+
}
812835
const metaUrlRef = useRef(metaUrl);
813836
const zoomFactor = useAtomValue(getBlockMetaKeyAtom(model.blockId, "web:zoom")) || 1;
814837
const partitionOverride = useAtomValueSafe(model.partitionOverride);
@@ -1055,19 +1078,21 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
10551078

10561079
return (
10571080
<Fragment>
1058-
<webview
1059-
id="webview"
1060-
className="webview"
1061-
ref={model.webviewRef}
1062-
src={metaUrlInitial}
1063-
data-blockid={model.blockId}
1064-
data-webcontentsid={webContentsId} // needed for emain
1065-
preload={getWebviewPreloadUrl()}
1066-
// @ts-expect-error This is a discrepancy between the React typing and the Chromium impl for webviewTag. Chrome webviewTag expects a string, while React expects a boolean.
1067-
allowpopups="true"
1068-
partition={webPartition}
1069-
useragent={userAgent}
1070-
/>
1081+
<MockBoundary fallback={<WebViewPreviewFallback url={metaUrl} />}>
1082+
<webview
1083+
id="webview"
1084+
className="webview"
1085+
ref={model.webviewRef}
1086+
src={metaUrlInitial}
1087+
data-blockid={model.blockId}
1088+
data-webcontentsid={webContentsId} // needed for emain
1089+
preload={getWebviewPreloadUrl()}
1090+
// @ts-expect-error This is a discrepancy between the React typing and the Chromium impl for webviewTag. Chrome webviewTag expects a string, while React expects a boolean.
1091+
allowpopups="true"
1092+
partition={webPartition}
1093+
useragent={userAgent}
1094+
/>
1095+
</MockBoundary>
10711096
{errorText && (
10721097
<div className="webview-error">
10731098
<div>{errorText}</div>
@@ -1079,4 +1104,4 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
10791104
);
10801105
});
10811106

1082-
export { WebView };
1107+
export { getWebPreviewDisplayUrl, WebView, WebViewPreviewFallback };
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { Block } from "@/app/block/block";
5+
import { globalStore } from "@/app/store/jotaiStore";
6+
import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model";
7+
import { mockObjectForPreview } from "@/app/store/wos";
8+
import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv";
9+
import type { NodeModel } from "@/layout/index";
10+
import { atom } from "jotai";
11+
import * as React from "react";
12+
import { applyMockEnvOverrides, MockWaveEnv } from "../mock/mockwaveenv";
13+
14+
const PreviewWorkspaceId = "preview-web-workspace";
15+
const PreviewTabId = "preview-web-tab";
16+
const PreviewNodeId = "preview-web-node";
17+
const PreviewBlockId = "preview-web-block";
18+
const PreviewUrl = "https://waveterm.dev";
19+
20+
function makeMockWorkspace(): Workspace {
21+
return {
22+
otype: "workspace",
23+
oid: PreviewWorkspaceId,
24+
version: 1,
25+
name: "Preview Workspace",
26+
tabids: [PreviewTabId],
27+
activetabid: PreviewTabId,
28+
meta: {},
29+
} as Workspace;
30+
}
31+
32+
function makeMockTab(): Tab {
33+
return {
34+
otype: "tab",
35+
oid: PreviewTabId,
36+
version: 1,
37+
name: "Web Preview",
38+
blockids: [PreviewBlockId],
39+
meta: {},
40+
} as Tab;
41+
}
42+
43+
function makeMockBlock(): Block {
44+
return {
45+
otype: "block",
46+
oid: PreviewBlockId,
47+
version: 1,
48+
meta: {
49+
view: "web",
50+
url: PreviewUrl,
51+
},
52+
} as Block;
53+
}
54+
55+
const previewWaveObjs: Record<string, WaveObj> = {
56+
[`workspace:${PreviewWorkspaceId}`]: makeMockWorkspace(),
57+
[`tab:${PreviewTabId}`]: makeMockTab(),
58+
[`block:${PreviewBlockId}`]: makeMockBlock(),
59+
};
60+
61+
for (const [oref, obj] of Object.entries(previewWaveObjs)) {
62+
mockObjectForPreview(oref, obj);
63+
}
64+
65+
function makePreviewNodeModel(): NodeModel {
66+
const isFocusedAtom = atom(true);
67+
const isMagnifiedAtom = atom(false);
68+
69+
return {
70+
additionalProps: atom({} as any),
71+
innerRect: atom({ width: "1040px", height: "620px" }),
72+
blockNum: atom(1),
73+
numLeafs: atom(1),
74+
nodeId: PreviewNodeId,
75+
blockId: PreviewBlockId,
76+
addEphemeralNodeToLayout: () => {},
77+
animationTimeS: atom(0),
78+
isResizing: atom(false),
79+
isFocused: isFocusedAtom,
80+
isMagnified: isMagnifiedAtom,
81+
anyMagnified: atom(false),
82+
isEphemeral: atom(false),
83+
ready: atom(true),
84+
disablePointerEvents: atom(false),
85+
toggleMagnify: () => {
86+
globalStore.set(isMagnifiedAtom, !globalStore.get(isMagnifiedAtom));
87+
},
88+
focusNode: () => {
89+
globalStore.set(isFocusedAtom, true);
90+
},
91+
onClose: () => {},
92+
dragHandleRef: { current: null },
93+
displayContainerRef: { current: null },
94+
};
95+
}
96+
97+
function WebPreviewInner() {
98+
const baseEnv = useWaveEnv();
99+
const nodeModel = React.useMemo(() => makePreviewNodeModel(), []);
100+
101+
const env = React.useMemo<MockWaveEnv>(() => {
102+
return applyMockEnvOverrides(baseEnv, {
103+
tabId: PreviewTabId,
104+
mockWaveObjs: previewWaveObjs,
105+
atoms: {
106+
workspaceId: atom(PreviewWorkspaceId),
107+
staticTabId: atom(PreviewTabId),
108+
},
109+
settings: {
110+
"web:defaultsearch": "https://www.google.com/search?q=%s",
111+
},
112+
});
113+
}, [baseEnv]);
114+
115+
const tabModel = React.useMemo(() => getTabModelByTabId(PreviewTabId, env), [env]);
116+
117+
return (
118+
<WaveEnvContext.Provider value={env}>
119+
<TabModelContext.Provider value={tabModel}>
120+
<div className="flex w-full max-w-[1100px] flex-col gap-2 px-6 py-6">
121+
<div className="text-xs text-muted font-mono">full web block using preview mock fallback</div>
122+
<div className="rounded-md border border-border bg-panel p-4">
123+
<div className="h-[680px]">
124+
<Block preview={false} nodeModel={nodeModel} />
125+
</div>
126+
</div>
127+
</div>
128+
</TabModelContext.Provider>
129+
</WaveEnvContext.Provider>
130+
);
131+
}
132+
133+
export function WebPreview() {
134+
return <WebPreviewInner />;
135+
}

0 commit comments

Comments
 (0)