tsunami / builder updates (jsfuncs, devtools, gpt-5.4, etc) (#3226)
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Docsite CI/CD / Build Docsite (push) Waiting to run
Docsite CI/CD / Deploy to GitHub Pages (push) Blocked by required conditions
TestDriver.ai Build / Build for TestDriver.ai (push) Waiting to run

lots of updates for tsunami and builder window:
* jsfuncs
* devtools windows
* devtools proper cleanup (fixes crashes)
* scrollbar fixes
* lock AI models -- gpt-5.4, builder prompts, etc
This commit is contained in:
Mike Sawka 2026-04-17 12:30:39 -07:00 committed by GitHub
parent 3d2c0d1ca8
commit 4969ee19b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 253 additions and 62 deletions

View file

@ -4,7 +4,7 @@
import { ClientService } from "@/app/store/services";
import { RpcApi } from "@/app/store/wshclientapi";
import { randomUUID } from "crypto";
import { BrowserWindow } from "electron";
import { BrowserWindow, webContents } from "electron";
import { globalEvents } from "emain/emain-events";
import path from "path";
import { getElectronAppBasePath, isDevVite, unamePlatform } from "./emain-platform";
@ -87,6 +87,20 @@ export async function createBuilderWindow(appId: string): Promise<BuilderWindowT
typedBuilderWindow.builderAppId = appId;
typedBuilderWindow.savedInitOpts = initOpts;
typedBuilderWindow.on("close", () => {
const wc = typedBuilderWindow.webContents;
if (wc.isDevToolsOpened()) {
wc.closeDevTools();
}
for (const guest of webContents.getAllWebContents()) {
if (guest.getType() === "webview" && guest.hostWebContents?.id === wc.id) {
if (guest.isDevToolsOpened()) {
guest.closeDevTools();
}
}
}
});
typedBuilderWindow.on("focus", () => {
focusedBuilderWindow = typedBuilderWindow;
console.log("builder window focused", builderId);

View file

@ -490,6 +490,17 @@ export function initIpcHandlers() {
console.error("Error deleting builder rtinfo:", e);
}
}
const wc = bw.webContents;
if (wc.isDevToolsOpened()) {
wc.closeDevTools();
}
for (const guest of electron.webContents.getAllWebContents()) {
if (guest.getType() === "webview" && guest.hostWebContents?.id === wc.id) {
if (guest.isDevToolsOpened()) {
guest.closeDevTools();
}
}
}
bw.destroy();
});

View file

@ -5,7 +5,7 @@ import { ClientService, ObjectService, WindowService, WorkspaceService } from "@
import { waveEventSubscribeSingle } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi";
import { fireAndForget } from "@/util/util";
import { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen } from "electron";
import { BaseWindow, BaseWindowConstructorOptions, dialog, globalShortcut, ipcMain, screen, webContents } from "electron";
import { globalEvents } from "emain/emain-events";
import path from "path";
import { debounce } from "throttle-debounce";
@ -299,6 +299,7 @@ export class WaveBrowserWindow extends BaseWindow {
if (this.isDestroyed()) {
return;
}
this.closeAllDevTools();
console.log("win 'close' handler fired", this.waveWindowId);
if (getGlobalIsQuitting() || updater?.status == "installing" || getGlobalIsRelaunching()) {
return;
@ -358,6 +359,24 @@ export class WaveBrowserWindow extends BaseWindow {
setTimeout(() => globalEvents.emit("windows-updated"), 50);
}
private closeAllDevTools() {
for (const tabView of this.allLoadedTabViews.values()) {
if (tabView.webContents?.isDevToolsOpened()) {
tabView.webContents.closeDevTools();
}
}
const tabViewIds = new Set(
[...this.allLoadedTabViews.values()].map((tv) => tv.webContents?.id).filter((id) => id != null)
);
for (const wc of webContents.getAllWebContents()) {
if (wc.getType() === "webview" && tabViewIds.has(wc.hostWebContents?.id)) {
if (wc.isDevToolsOpened()) {
wc.closeDevTools();
}
}
}
}
private removeAllChildViews() {
for (const tabView of this.allLoadedTabViews.values()) {
if (!this.isDestroyed()) {

View file

@ -41,6 +41,27 @@ export interface DroppedFile {
previewUrl?: string;
}
const BuilderAIModeConfigs: Record<string, AIModeConfigType> = {
"waveaibuilder@default": {
"display:name": "Builder Default",
"display:order": -2,
"display:icon": "sparkles",
"display:description": "Good mix of speed and accuracy\n(gpt-5.4 with minimal thinking)",
"ai:provider": "wave",
"ai:switchcompat": ["wavecloud"],
"waveai:premium": true,
},
"waveaibuilder@deep": {
"display:name": "Builder Deep",
"display:order": -1,
"display:icon": "lightbulb",
"display:description": "Slower but most capable\n(gpt-5.4 with full reasoning)",
"ai:provider": "wave",
"ai:switchcompat": ["wavecloud"],
"waveai:premium": true,
},
};
export class WaveAIModel {
private static instance: WaveAIModel | null = null;
inputRef: React.RefObject<AIPanelInputRef> | null = null;
@ -80,7 +101,11 @@ export class WaveAIModel {
this.orefContext = orefContext;
this.inBuilder = inBuilder;
this.chatId = jotai.atom(null) as jotai.PrimitiveAtom<string>;
this.aiModeConfigs = atoms.waveaiModeConfigAtom;
if (inBuilder) {
this.aiModeConfigs = jotai.atom(BuilderAIModeConfigs) as jotai.Atom<Record<string, AIModeConfigType>>;
} else {
this.aiModeConfigs = atoms.waveaiModeConfigAtom;
}
this.hasPremiumAtom = jotai.atom((get) => {
const rateLimitInfo = get(atoms.waveAIRateLimitInfoAtom);
@ -118,7 +143,7 @@ export class WaveAIModel {
this.defaultModeAtom = jotai.atom((get) => {
const telemetryEnabled = get(getSettingsKeyAtom("telemetry:enabled")) ?? false;
if (this.inBuilder) {
return telemetryEnabled ? "waveai@balanced" : "invalid";
return telemetryEnabled ? "waveaibuilder@default" : "invalid";
}
const aiModeConfigs = get(this.aiModeConfigs);
if (!telemetryEnabled) {

View file

@ -19,9 +19,9 @@ import { openLink } from "@/store/global";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import { fireAndForget, useAtomValueSafe } from "@/util/util";
import clsx from "clsx";
import { WebviewTag } from "electron";
import type { WebviewTag } from "electron";
import { Atom, PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai";
import { Fragment, createRef, memo, useCallback, useEffect, useRef, useState } from "react";
import { Fragment, createRef, memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import "./webview.scss";
import type { WebViewEnv } from "./webviewenv";
@ -951,6 +951,15 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps)
}, 100);
}
useLayoutEffect(() => {
return () => {
const webview = model.webviewRef.current;
if (webview?.isDevToolsOpened()) {
webview.closeDevTools();
}
};
}, []);
useEffect(() => {
return () => {
globalStore.set(model.domReady, false);

View file

@ -257,6 +257,10 @@ const BuilderAppPanel = memo(() => {
model.switchBuilderApp();
}, [model]);
const handleOpenDevToolsClick = useCallback(() => {
model.openPreviewDevTools();
}, [model]);
const handleKebabClick = useCallback(
(e: React.MouseEvent) => {
const menu: ContextMenuItem[] = [
@ -267,6 +271,13 @@ const BuilderAppPanel = memo(() => {
{
type: "separator",
},
{
label: "Open DevTools",
click: handleOpenDevToolsClick,
},
{
type: "separator",
},
{
label: "Switch App",
click: handleSwitchAppClick,
@ -274,7 +285,7 @@ const BuilderAppPanel = memo(() => {
];
ContextMenuModel.getInstance().showContextMenu(menu, e);
},
[handleSwitchAppClick, handlePublishClick]
[handleSwitchAppClick, handlePublishClick, handleOpenDevToolsClick]
);
return (

View file

@ -7,6 +7,7 @@ import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { atoms, getApi, WOS } from "@/store/global";
import { base64ToString, stringToBase64 } from "@/util/util";
import type { WebviewTag } from "electron";
import { atom, type Atom, type PrimitiveAtom } from "jotai";
import type * as MonacoTypes from "monaco-editor";
import { debounce } from "throttle-debounce";
@ -35,6 +36,7 @@ export class BuilderAppPanelModel {
saveNeededAtom!: Atom<boolean>;
focusElemRef: { current: HTMLInputElement | null } = { current: null };
monacoEditorRef: { current: MonacoTypes.editor.IStandaloneCodeEditor | null } = { current: null };
webviewRef: { current: WebviewTag | null } = { current: null };
statusUnsubFn: (() => void) | null = null;
appGoUpdateUnsubFn: (() => void) | null = null;
debouncedRestart: (() => void) & { cancel: () => void };
@ -314,6 +316,15 @@ export class BuilderAppPanelModel {
this.monacoEditorRef.current = ref;
}
openPreviewDevTools() {
if (!this.webviewRef.current) return;
if (this.webviewRef.current.isDevToolsOpened()) {
this.webviewRef.current.closeDevTools();
} else {
this.webviewRef.current.openDevTools();
}
}
dispose() {
if (this.statusUnsubFn) {
this.statusUnsubFn();

View file

@ -70,8 +70,8 @@ const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => {
<div className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold text-error">Secrets Required</h2>
<p className="text-base text-secondary leading-relaxed">
This app requires secrets that must be configured. Please use the Secrets tab to set and bind
the required secrets for your app to run.
This app requires secrets that must be configured. Please use the Secrets tab to set and
bind the required secrets for your app to run.
</p>
<div className="text-left bg-panel border border-error/30 rounded-lg p-4 max-h-96 overflow-auto mt-2">
<pre className="text-sm text-secondary whitespace-pre-wrap font-mono">{displayMsg}</pre>
@ -178,47 +178,48 @@ const BuilderPreviewTab = memo(() => {
const originalContent = useAtomValue(model.originalContentAtom);
const builderStatus = useAtomValue(model.builderStatusAtom);
const builderId = useAtomValue(atoms.builderId);
const fileExists = originalContent.length > 0;
if (isLoading) {
return null;
}
if (builderStatus?.status === "error") {
return <ErrorStateView errorMsg={builderStatus?.errormsg || ""} />;
}
if (!fileExists) {
return <EmptyStateView />;
}
const [lastKnownUrl, setLastKnownUrl] = useState<string>(null);
const status = builderStatus?.status || "init";
const isWebViewActive = status === "running" && builderStatus?.port && builderStatus.port !== 0;
if (status === "init") {
return null;
}
if (status === "building") {
return <BuildingStateView />;
}
if (status === "stopped") {
return <StoppedStateView onStart={() => model.startBuilder()} />;
}
const shouldShowWebView = status === "running" && builderStatus?.port && builderStatus.port !== 0;
if (shouldShowWebView) {
if (isWebViewActive) {
const previewUrl = `http://localhost:${builderStatus.port}/?clientid=wave:${builderId}`;
return (
<div className="w-full h-full">
<webview src={previewUrl} className="w-full h-full" />
</div>
);
if (previewUrl !== lastKnownUrl) {
setLastKnownUrl(previewUrl);
}
}
return null;
let overlay = null;
if (!isLoading && !isWebViewActive) {
if (builderStatus?.status === "error") {
overlay = <ErrorStateView errorMsg={builderStatus?.errormsg || ""} />;
} else if (!fileExists || status === "init") {
overlay = <EmptyStateView />;
} else if (status === "building") {
overlay = <BuildingStateView />;
} else if (status === "stopped") {
overlay = <StoppedStateView onStart={() => model.startBuilder()} />;
}
}
return (
<div className="w-full h-full relative">
{lastKnownUrl && (
<webview
ref={model.webviewRef}
src={lastKnownUrl}
className="w-full h-full"
style={{
visibility: isWebViewActive ? "visible" : "hidden",
pointerEvents: isWebViewActive ? "auto" : "none",
}}
/>
)}
{overlay && <div className="absolute inset-0">{overlay}</div>}
</div>
);
});
BuilderPreviewTab.displayName = "BuilderPreviewTab";

View file

@ -163,9 +163,11 @@ const (
)
const (
AIModeQuick = "waveai@quick"
AIModeBalanced = "waveai@balanced"
AIModeDeep = "waveai@deep"
AIModeQuick = "waveai@quick"
AIModeBalanced = "waveai@balanced"
AIModeDeep = "waveai@deep"
AIModeBuilderDefault = "waveaibuilder@default"
AIModeBuilderDeep = "waveaibuilder@deep"
)
const (

View file

@ -247,7 +247,44 @@ func isValidAzureResourceName(name string) bool {
return AzureResourceNameRegex.MatchString(name)
}
var builderModeConfigs = map[string]wconfig.AIModeConfigType{
uctypes.AIModeBuilderDefault: {
DisplayName: "Builder Default",
DisplayOrder: -2,
DisplayIcon: "sparkles",
DisplayDescription: "Good mix of speed and accuracy\n(gpt-5.4 with minimal thinking)",
Provider: uctypes.AIProvider_Wave,
APIType: uctypes.APIType_OpenAIResponses,
Model: "gpt-5.4",
ThinkingLevel: uctypes.ThinkingLevelLow,
Verbosity: uctypes.VerbosityLevelLow,
Capabilities: []string{uctypes.AICapabilityTools, uctypes.AICapabilityImages, uctypes.AICapabilityPdfs},
WaveAIPremium: true,
SwitchCompat: []string{"wavecloud"},
},
uctypes.AIModeBuilderDeep: {
DisplayName: "Builder Deep",
DisplayOrder: -1,
DisplayIcon: "lightbulb",
DisplayDescription: "Slower but most capable\n(gpt-5.4 with full reasoning)",
Provider: uctypes.AIProvider_Wave,
APIType: uctypes.APIType_OpenAIResponses,
Model: "gpt-5.4",
ThinkingLevel: uctypes.ThinkingLevelMedium,
Verbosity: uctypes.VerbosityLevelLow,
Capabilities: []string{uctypes.AICapabilityTools, uctypes.AICapabilityImages, uctypes.AICapabilityPdfs},
WaveAIPremium: true,
SwitchCompat: []string{"wavecloud"},
},
}
func getAIModeConfig(aiMode string) (*wconfig.AIModeConfigType, error) {
if config, ok := builderModeConfigs[aiMode]; ok {
resolved := config
applyProviderDefaults(&resolved)
return &resolved, nil
}
fullConfig := wconfig.GetWatcher().GetFullConfig()
config, ok := fullConfig.WaveAIModes[aiMode]
if !ok {
@ -271,13 +308,13 @@ func handleConfigUpdate(fullConfig wconfig.FullConfigType) {
func ComputeResolvedAIModeConfigs(fullConfig wconfig.FullConfigType) map[string]wconfig.AIModeConfigType {
resolvedConfigs := make(map[string]wconfig.AIModeConfigType)
for modeName, modeConfig := range fullConfig.WaveAIModes {
resolved := modeConfig
applyProviderDefaults(&resolved)
resolvedConfigs[modeName] = resolved
}
return resolvedConfigs
}
@ -285,7 +322,7 @@ func broadcastAIModeConfigs(configs map[string]wconfig.AIModeConfigType) {
update := wconfig.AIModeConfigUpdate{
Configs: configs,
}
wps.Broker.Publish(wps.WaveEvent{
Event: wps.Event_AIModeConfig,
Data: update,

View file

@ -670,8 +670,8 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) {
}
// Get WaveAI settings
premium := shouldUsePremium()
builderMode := req.BuilderId != ""
premium := shouldUsePremium() || builderMode
if req.AIMode == "" {
http.Error(w, "aimode is required in request body", http.StatusBadRequest)
return

View file

@ -13,7 +13,6 @@
"resolveJsonModule": true,
"isolatedModules": true,
"experimentalDecorators": true,
"downlevelIteration": true,
"baseUrl": "./",
"paths": {
"@/app/*": ["frontend/app/*"],

View file

@ -9,7 +9,7 @@ const globalModel = new TsunamiModel();
function App() {
return (
<div className="min-h-screen bg-background text-foreground">
<div className="h-full bg-background text-foreground overflow-auto">
<VDomView model={globalModel} />
</div>
);

View file

@ -62,7 +62,10 @@
}
/* Disable overscroll behavior */
html, body {
html, body, #root {
height: 100%;
color-scheme: dark;
background: var(--color-background);
overscroll-behavior: none;
overscroll-behavior-x: none;
overscroll-behavior-y: none;

View file

@ -12,4 +12,5 @@ type KeyPressDecl = {
};
key: string;
keyType: string;
nomatch?: boolean;
};

View file

@ -82,8 +82,10 @@ type VDomFunc = {
type: "func";
stoppropagation?: boolean;
preventdefault?: boolean;
preventbackend?: boolean;
globalevent?: string;
keys?: string[];
jscode?: string;
};
// vdom.VDomMessage

View file

@ -72,7 +72,9 @@ function parseKey(key: string): { key: string; type: string } {
function parseKeyDescription(keyDescription: string): KeyPressDecl {
let rtn = { key: "", mods: {} } as KeyPressDecl;
let keys = keyDescription.replace(/[()]/g, "").split(":");
for (let key of keys) {
for (let i = 0; i < keys.length; i++) {
let key = keys[i];
let isLastToken = i === keys.length - 1;
if (key == "Cmd") {
if (PLATFORM == PlatformMacOS) {
rtn.mods.Meta = true;
@ -106,6 +108,10 @@ function parseKeyDescription(keyDescription: string): KeyPressDecl {
}
rtn.mods.Meta = true;
} else {
if (!isLastToken) {
rtn.nomatch = true;
return rtn;
}
let { key: parsedKey, type: keyType } = parseKey(key);
rtn.key = parsedKey;
rtn.keyType = keyType;
@ -194,6 +200,9 @@ function isInputEvent(event: VDomKeyboardEvent): boolean {
function checkKeyPressed(event: VDomKeyboardEvent, keyDescription: string): boolean {
let keyPress = parseKeyDescription(keyDescription);
if (keyPress.nomatch) {
return false;
}
if (notMod(keyPress.mods.Option, event.option)) {
return false;
}
@ -236,6 +245,9 @@ function checkKeyPressed(event: VDomKeyboardEvent, keyDescription: string): bool
}
function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEvent): VDomKeyboardEvent {
if (event == null || typeof event.key !== "string") {
return { type: "unknown" } as VDomKeyboardEvent;
}
let rtn: VDomKeyboardEvent = {} as VDomKeyboardEvent;
rtn.control = event.ctrlKey;
rtn.shift = event.shiftKey;

View file

@ -170,15 +170,21 @@ const SvgUrlIdAttributes = {
"text-decoration": true,
};
function convertVDomFunc(model: TsunamiModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void {
return (e: any) => {
function convertVDomFunc(
model: TsunamiModel,
fnDecl: VDomFunc,
compId: string,
propName: string
): (...args: any[]) => any {
return (...args: any[]) => {
const e = args[0];
if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["keys"]) {
dlog("key event", fnDecl, e);
let waveEvent = adaptFromReactOrNativeKeyEvent(e);
for (let keyDesc of fnDecl["keys"] || []) {
if (checkKeyPressed(waveEvent, keyDesc)) {
e.preventDefault();
e.stopPropagation();
e?.preventDefault?.();
e?.stopPropagation?.();
model.callVDomFunc(fnDecl, e, compId, propName);
return;
}
@ -186,12 +192,24 @@ function convertVDomFunc(model: TsunamiModel, fnDecl: VDomFunc, compId: string,
return;
}
if (fnDecl.preventdefault) {
e.preventDefault();
e?.preventDefault?.();
}
if (fnDecl.stoppropagation) {
e.stopPropagation();
e?.stopPropagation?.();
}
model.callVDomFunc(fnDecl, e, compId, propName);
let retVal: any;
if (fnDecl.jscode) {
try {
const fn = eval(fnDecl.jscode);
if (typeof fn === "function") retVal = fn(...args);
} catch (err) {
console.error("vdom jscode error:", err);
}
}
if (!fnDecl.preventbackend) {
model.callVDomFunc(fnDecl, e, compId, propName);
}
return retVal;
};
}
@ -254,7 +272,7 @@ function convertChildren(elem: VDomElem, model: TsunamiModel): React.ReactNode[]
if (elem.children == null || elem.children.length == 0) {
return null;
}
let childrenComps: React.ReactNode[] = [];
const childrenComps: React.ReactNode[] = [];
for (let child of elem.children) {
if (child == null) {
continue;

View file

@ -99,6 +99,20 @@ func H(tag string, props map[string]any, children ...any) *VDomElem {
return rtn
}
// JSFunc creates a VDomFunc that executes client-side JS only, with no backend call.
// jsCode must be a JS function expression whose signature matches the natural arguments of the event handler
// (e.g. (e) => { ... } for DOM events, or whatever args the underlying handler receives).
func JSFunc(jsCode string) *VDomFunc {
return &VDomFunc{Type: ObjectType_Func, JsCode: jsCode, PreventBackend: true}
}
// CombinedFunc creates a VDomFunc that executes client-side JS first, then fires to the backend.
// jsCode must be a JS function expression whose signature matches the natural arguments of the event handler
// (e.g. (e) => { ... } for DOM events, or whatever args the underlying handler receives).
func CombinedFunc(jsCode string, fn any) *VDomFunc {
return &VDomFunc{Type: ObjectType_Func, JsCode: jsCode, Fn: fn}
}
// If returns the provided part if the condition is true, otherwise returns nil.
// This is useful for conditional rendering in VDOM children lists, props, and style attributes.
func If(cond bool, part any) any {

View file

@ -32,8 +32,10 @@ type VDomFunc struct {
Type string `json:"type" tstype:"\"func\""`
StopPropagation bool `json:"stoppropagation,omitempty"` // set to call e.stopPropagation() on the client side
PreventDefault bool `json:"preventdefault,omitempty"` // set to call e.preventDefault() on the client side
PreventBackend bool `json:"preventbackend,omitempty"` // set to skip firing the event to the backend
GlobalEvent string `json:"globalevent,omitempty"`
Keys []string `json:"keys,omitempty"` // special for keyDown events a list of keys to "capture"
JsCode string `json:"jscode,omitempty"` // client-side JS function expression: (e, elem) => { ... }
}
// used in props